Compare commits
95 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28fce4145a | |||
| d5b4abd2ac | |||
| e6df25388a | |||
| 611dd873a3 | |||
| d7c0ddc374 | |||
| be2dd8d568 | |||
| f4def12a1c | |||
| 459ebcdbdb | |||
| f565d37175 | |||
| 0d8c257a5d | |||
| d01cc483ca | |||
| cf2130090e | |||
| 232288c60b | |||
| 2c9cae2e0f | |||
| a166255d8f | |||
| ffd8eb9c08 | |||
| ed6b40a591 | |||
| c4f6c600d6 | |||
| 787a9b1cb7 | |||
| 6398d32c0c | |||
| c6194b848d | |||
| b71b7cf4ac | |||
| cc289f0f93 | |||
| 2107f038dc | |||
| 9441cbc5ae | |||
| dd87489d6b | |||
| d4c416c2fc | |||
| b6f351d729 | |||
| e2cad2f09e | |||
| 4afad820b4 | |||
| 7af3a9dab6 | |||
| 031005e227 | |||
| 3a0ce85c54 | |||
| 695724eb4a | |||
| 086044f5f7 | |||
| 3298ed9f39 | |||
| 1eb913e509 | |||
| e453d007d5 | |||
| 9033efb60f | |||
| ea2ee6b341 | |||
| 84a9cd556f | |||
| 9ec35bd101 | |||
| edda2cbef8 | |||
| 2cf871210b | |||
| fbee146c44 | |||
| 6a239e9875 | |||
| fde8c52a29 | |||
| 84a9b50dd7 | |||
| 63b126e7b0 | |||
| 167f7d67cb | |||
| 829bfa9570 | |||
| 9bab609ddf | |||
| 05a35c6d48 | |||
| 686a84959d | |||
| 11a0d447a6 | |||
| 9dee049973 | |||
| 16a666b095 | |||
| 073698c8e7 | |||
| b14c31d137 | |||
| 31be224223 | |||
| a9df55d18e | |||
| 42f38ff66e | |||
| 57b6773f4c | |||
| 9a637ccd73 | |||
| 93b063b9d2 | |||
| a97adaccd8 | |||
| d1dac82ca7 | |||
| 63fac0b455 | |||
| a487120c32 | |||
| 44a5b25957 | |||
| b4590ca108 | |||
| 9c30eda09e | |||
| 1a8ef35ad8 | |||
| 906b66b736 | |||
| 98d4d4ed63 | |||
| 5858d893b5 | |||
| 7221081183 | |||
| 8c76a1b3fe | |||
| 98af56c393 | |||
| 960e689d42 | |||
| 18f546a4fe | |||
| 1cd8d31da4 | |||
| 4ac67d0167 | |||
| 4d1353757d | |||
| 3826905a4b | |||
| e0a9e08a3a | |||
| 0c40be4a4d | |||
| a0008b1cde | |||
| fce72fa5d6 | |||
| 0a953ab918 | |||
| 03e1a1a15e | |||
| a1f0c5c151 | |||
| b55a566da0 | |||
| 957f222da7 | |||
| a39fec79cd |
@@ -18,6 +18,14 @@ env:
|
||||
# Force Node 24 for all JS-based actions to avoid the libuv
|
||||
# process_title assertion crash on Windows (known Node 20 bug).
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
# e2e only needs a working binary, so skip fat LTO (same settings as the `ci`
|
||||
# profile releases ship). Overriding release keeps artifacts in target/release.
|
||||
CARGO_PROFILE_RELEASE_LTO: thin
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 16
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
jobs:
|
||||
lua-tests:
|
||||
@@ -25,6 +33,13 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
# e2e tests could be flaky on CI so we do not block release creation if they failed
|
||||
continue-on-error: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
|
||||
# alpine-musl installs rust via apk and has no sccache, so keep this scoped.
|
||||
env:
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
RUSTC_WRAPPER: sccache
|
||||
# fff-search alone exceeds the 600s default on windows, and the server
|
||||
# sees no new requests while it compiles, so it would idle out mid-unit.
|
||||
SCCACHE_IDLE_TIMEOUT: "0"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -32,7 +47,6 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
- os: macos-latest
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
@@ -49,20 +63,23 @@ jobs:
|
||||
cache-on-failure: false
|
||||
cache-key: "v2-lua-e2e"
|
||||
rustflags: ""
|
||||
target: ${{ matrix.target || '' }}
|
||||
|
||||
- name: Build Rust binary (Windows)
|
||||
if: matrix.target
|
||||
run: cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@v0.0.11
|
||||
with:
|
||||
version: v0.17.0
|
||||
|
||||
- name: Copy binary to target/release (Windows)
|
||||
if: matrix.target
|
||||
- name: Build Rust binary
|
||||
shell: bash
|
||||
run: |
|
||||
cp target/${{ matrix.target }}/release/fff_nvim.dll target/release/fff_nvim.dll
|
||||
run: make build-e2e
|
||||
|
||||
- name: sccache stats
|
||||
if: always()
|
||||
shell: bash
|
||||
run: sccache --show-stats
|
||||
|
||||
- name: Verify Windows DLL has no unexpected dependencies
|
||||
if: matrix.target
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Find dumpbin via vswhere (always available on GitHub Actions Windows runners)
|
||||
@@ -78,10 +95,6 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Build Rust binary
|
||||
if: ${{ !matrix.target }}
|
||||
run: cargo build --release -p fff-nvim --features zlob
|
||||
|
||||
- name: Install Neovim
|
||||
uses: rhysd/action-setup-vim@v1
|
||||
with:
|
||||
@@ -100,10 +113,15 @@ jobs:
|
||||
if: failure()
|
||||
shell: bash
|
||||
run: |
|
||||
echo "=== fff-test.log ==="
|
||||
if [ -f fff-test.log ]; then
|
||||
cat fff-test.log
|
||||
else
|
||||
# init_tracing writes session files named fff-test+<ts>+<pid>.log
|
||||
found=0
|
||||
for f in fff-test*.log; do
|
||||
[ -f "$f" ] || continue
|
||||
found=1
|
||||
echo "=== $f ==="
|
||||
cat "$f"
|
||||
done
|
||||
if [ "$found" = 0 ]; then
|
||||
echo "(no log file produced)"
|
||||
fi
|
||||
|
||||
@@ -130,7 +148,7 @@ jobs:
|
||||
|
||||
- name: Install node dependencies
|
||||
shell: bash
|
||||
run: cd packages/fff-node && npm install
|
||||
run: cd packages && npm ci
|
||||
|
||||
- name: Run node tests
|
||||
shell: bash
|
||||
@@ -180,12 +198,14 @@ jobs:
|
||||
run: cargo build --release -p fff-c
|
||||
|
||||
- name: Install workspace npm deps
|
||||
run: npm install --no-package-lock
|
||||
working-directory: packages
|
||||
run: npm ci
|
||||
|
||||
# Upstream @yuuang/ffi-rs-linux-x64-musl ships with libc:"glibc" in
|
||||
# its package.json (a publishing bug in ffi-rs), so npm filters it
|
||||
# out. Force-install it so the FFI runtime is present on Alpine.
|
||||
- name: Install ffi-rs musl runtime
|
||||
working-directory: packages
|
||||
run: |
|
||||
FFI_RS_VERSION=$(node -p "require('ffi-rs/package.json').version")
|
||||
npm install --no-package-lock --no-save --force \
|
||||
@@ -196,13 +216,17 @@ jobs:
|
||||
# this is what exercises detectLinuxLibc().
|
||||
- name: Stage musl bin package
|
||||
run: |
|
||||
PKG_DIR=node_modules/@ff-labs/fff-bin-linux-x64-musl
|
||||
PKG_DIR=packages/node_modules/@ff-labs/fff-bin-linux-x64-musl
|
||||
mkdir -p "$PKG_DIR"
|
||||
cp target/release/libfff_c.so "$PKG_DIR/libfff_c.so"
|
||||
cat >"$PKG_DIR/package.json" <<'JSON'
|
||||
{ "name": "@ff-labs/fff-bin-linux-x64-musl", "version": "0.0.0" }
|
||||
JSON
|
||||
|
||||
# fff-node builds with bun; the npm `bun` package ships musl binaries
|
||||
- name: Install bun
|
||||
run: npm install -g bun
|
||||
|
||||
- name: Build fff-node
|
||||
working-directory: packages/fff-node
|
||||
run: npm run build
|
||||
|
||||
@@ -12,6 +12,10 @@ on:
|
||||
- '**.md'
|
||||
- 'doc/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
jobs:
|
||||
lua-ls:
|
||||
name: lua-language-server type check
|
||||
|
||||
@@ -12,6 +12,10 @@ on:
|
||||
- '**.md'
|
||||
- 'doc/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Oxc
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "packages/**"
|
||||
- .github/workflows/oxc.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- "packages/**"
|
||||
- .github/workflows/oxc.yml
|
||||
|
||||
env:
|
||||
CLICOLOR: 1
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
jobs:
|
||||
oxc:
|
||||
name: Check packages with oxfmt and oxlint
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: oxfmt
|
||||
run: bun run format:check
|
||||
|
||||
- name: oxlint
|
||||
run: bun run lint -- -f github --deny-warnings
|
||||
@@ -15,6 +15,17 @@ on:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
MACOSX_DEPLOYMENT_TARGET: "13.0"
|
||||
CARGO_PROFILE_RELEASE_LTO: thin
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 16
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
RUSTC_WRAPPER: sccache
|
||||
# fff-search alone exceeds the 600s default on windows, and the server sees
|
||||
# no new requests while it compiles, so it would idle out mid-unit.
|
||||
SCCACHE_IDLE_TIMEOUT: "0"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -31,7 +42,16 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1.15.4
|
||||
with:
|
||||
cache: true
|
||||
cache-on-failure: true
|
||||
cache-key: "v1-rust-python"
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@v0.0.11
|
||||
with:
|
||||
version: v0.17.0
|
||||
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
@@ -43,6 +63,8 @@ jobs:
|
||||
working-directory: packages/fff-python
|
||||
shell: bash
|
||||
run: |
|
||||
uv sync --all-extras
|
||||
# --no-install-project: uv would otherwise build the wheel via
|
||||
# maturin's PEP 517 backend, then `maturin develop` builds it again.
|
||||
uv sync --all-extras --no-install-project
|
||||
uv run maturin develop --release
|
||||
uv run pytest -v
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Build & Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, fix/use-trusted-publishing]
|
||||
branches: [main, fix/npm-pulish]
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
@@ -17,10 +17,18 @@ on:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
jobs:
|
||||
build-nvim:
|
||||
name: Build Neovim ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_IDLE_TIMEOUT: "0"
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
@@ -84,6 +92,18 @@ jobs:
|
||||
- name: Install Rust
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
# Cache the per-target build dir and cargo-zigbuild binary. Keyed by
|
||||
# target so matrix legs don't collide. See issue on slow release CI.
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
|
||||
with:
|
||||
key: nvim-${{ matrix.target }}
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@v0.0.11
|
||||
with:
|
||||
version: v0.17.0
|
||||
|
||||
- name: Install Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
@@ -96,7 +116,7 @@ jobs:
|
||||
- name: Build for Linux
|
||||
if: contains(matrix.os, 'ubuntu') && !contains(matrix.target, 'android')
|
||||
run: |
|
||||
cargo zigbuild --profile ci --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-nvim --features zlob
|
||||
cargo zigbuild --profile ci --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-nvim --no-default-features --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for Android (Termux)
|
||||
@@ -110,13 +130,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 --profile ci --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
cargo build --profile ci --target ${{ matrix.target }} -p fff-nvim --no-default-features --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 --profile ci --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --profile ci --target ${{ matrix.target }} -p fff-nvim --no-default-features --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Ad-hoc sign macOS binary
|
||||
@@ -127,7 +147,7 @@ jobs:
|
||||
if: contains(matrix.os, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --profile ci --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
cargo build --profile ci --target ${{ matrix.target }} -p fff-nvim --no-default-features --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Upload artifacts
|
||||
@@ -139,6 +159,10 @@ jobs:
|
||||
build-c:
|
||||
name: Build C FFI ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_IDLE_TIMEOUT: "0"
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
@@ -176,6 +200,7 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-linux-android
|
||||
artifact_name: target/aarch64-linux-android/ci/libfff_c.so
|
||||
npm_package: fff-bin-android-arm64
|
||||
lib_filename: libfff_c.so
|
||||
ext: so
|
||||
|
||||
@@ -215,6 +240,16 @@ jobs:
|
||||
- name: Install Rust
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
|
||||
with:
|
||||
key: c-${{ matrix.target }}
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@v0.0.11
|
||||
with:
|
||||
version: v0.17.0
|
||||
|
||||
- name: Install Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
@@ -227,7 +262,7 @@ jobs:
|
||||
- name: Build for Linux
|
||||
if: contains(matrix.os, 'ubuntu') && !contains(matrix.target, 'android')
|
||||
run: |
|
||||
cargo zigbuild --profile ci --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-c --features zlob
|
||||
cargo zigbuild --profile ci --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-c --no-default-features --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for Android (Termux)
|
||||
@@ -240,13 +275,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 --profile ci --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
cargo build --profile ci --target ${{ matrix.target }} -p fff-c --no-default-features --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 --profile ci --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --profile ci --target ${{ matrix.target }} -p fff-c --no-default-features --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Ad-hoc sign macOS binary
|
||||
@@ -257,11 +292,10 @@ jobs:
|
||||
if: contains(matrix.os, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --profile ci --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
cargo build --profile ci --target ${{ matrix.target }} -p fff-c --no-default-features --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Prepare npm package
|
||||
if: "!contains(matrix.target, 'android')"
|
||||
shell: bash
|
||||
run: |
|
||||
# Copy the built binary into the platform npm package directory
|
||||
@@ -274,7 +308,6 @@ jobs:
|
||||
path: c-lib-${{ matrix.target }}.*
|
||||
|
||||
- name: Upload npm package artifact
|
||||
if: "!contains(matrix.target, 'android')"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: npm-${{ matrix.npm_package }}
|
||||
@@ -283,6 +316,10 @@ jobs:
|
||||
build-mcp:
|
||||
name: Build MCP ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_IDLE_TIMEOUT: "0"
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
@@ -328,6 +365,16 @@ jobs:
|
||||
- name: Install Rust
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
|
||||
with:
|
||||
key: mcp-${{ matrix.target }}
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@v0.0.11
|
||||
with:
|
||||
version: v0.17.0
|
||||
|
||||
- name: Install Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
@@ -340,13 +387,13 @@ jobs:
|
||||
- name: Build for Linux
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
run: |
|
||||
cargo zigbuild --profile ci --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-mcp --features zlob
|
||||
cargo zigbuild --profile ci --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-mcp --no-default-features --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 --profile ci --target ${{ matrix.target }} -p fff-mcp --features zlob
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --profile ci --target ${{ matrix.target }} -p fff-mcp --no-default-features --features zlob
|
||||
cp "${{ matrix.artifact_name }}" "fff-mcp-${{ matrix.target }}"
|
||||
|
||||
- name: Ad-hoc sign macOS binary
|
||||
@@ -357,7 +404,7 @@ jobs:
|
||||
if: contains(matrix.os, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --profile ci --target ${{ matrix.target }} -p fff-mcp --features zlob
|
||||
cargo build --profile ci --target ${{ matrix.target }} -p fff-mcp --no-default-features --features zlob
|
||||
cp "${{ matrix.artifact_name }}" "fff-mcp-${{ matrix.target }}.exe"
|
||||
|
||||
- name: Upload artifact
|
||||
@@ -423,7 +470,7 @@ jobs:
|
||||
AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
args: --release --out dist --features zlob
|
||||
args: --release --out dist --no-default-features --features zlob
|
||||
sccache: "true"
|
||||
working-directory: packages/fff-python
|
||||
container: ${{ matrix.container || '' }}
|
||||
@@ -462,11 +509,14 @@ jobs:
|
||||
name: Release
|
||||
needs: [build-nvim, build-c, build-mcp, build-python, build-python-sdist]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/fix/use-trusted-publishing' || startsWith(github.ref, 'refs/tags/v'))
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
# we have to make sure that pushing a commit on this workflow triggers the CI for nightly neovim
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
token: ${{ secrets.GUSTAV_PAT || github.token }}
|
||||
|
||||
- name: Install Lua
|
||||
uses: leafo/gh-actions-lua@v12
|
||||
@@ -593,7 +643,7 @@ jobs:
|
||||
|
||||
Update mcp via:
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/dmtrKovalenko/fff.nvim/main/install-mcp.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/dmtrKovalenko/fff/main/install-mcp.sh | bash
|
||||
```
|
||||
|
||||
- name: Bump Homebrew formula (uses local checksums)
|
||||
@@ -605,14 +655,12 @@ jobs:
|
||||
run: make bump-install-mcp-sh VERSION="${{ steps.version.outputs.version }}" BINARIES_DIR=./binaries
|
||||
|
||||
- name: Commit formula + installer bump to main
|
||||
# Uses the default GITHUB_TOKEN configured by actions/checkout above.
|
||||
# Requires github-actions[bot] in the main branch-protection bypass list.
|
||||
if: steps.version.outputs.is_release == 'true' && github.repository_owner == 'dmtrKovalenko'
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
# Workflow runs on detached HEAD at the v* tag — explicit target needed.
|
||||
branch: main
|
||||
commit_message: "chore: bump fff-mcp release artifacts to v${{ steps.version.outputs.version }} [skip ci]"
|
||||
commit_message: "chore: bump fff-mcp release artifacts to v${{ steps.version.outputs.version }}"
|
||||
file_pattern: "Formula/fff-mcp.rb install-mcp.sh"
|
||||
commit_user_name: github-actions[bot]
|
||||
commit_user_email: 41898282+github-actions[bot]@users.noreply.github.com
|
||||
@@ -621,7 +669,7 @@ jobs:
|
||||
name: Publish Python wheels to PyPI
|
||||
needs: [build-python, build-python-sdist]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/fix/use-trusted-publishing' || startsWith(github.ref, 'refs/tags/v'))
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/p/fff-search
|
||||
@@ -646,7 +694,7 @@ jobs:
|
||||
name: Publish Rust crates
|
||||
needs: [build-nvim, build-c, build-mcp]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/fix/use-trusted-publishing' || startsWith(github.ref, 'refs/tags/v'))
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
@@ -677,7 +725,7 @@ jobs:
|
||||
name: Publish npm packages
|
||||
needs: [build-c]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/fix/use-trusted-publishing' || startsWith(github.ref, 'refs/tags/v'))
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/fix/npm-pulish' || startsWith(github.ref, 'refs/tags/v'))
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
@@ -693,6 +741,8 @@ jobs:
|
||||
node-version: "25"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
run: lua scripts/determine-version.lua
|
||||
@@ -721,6 +771,16 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
# Every install must happen before the first set-npm-version: the bump
|
||||
# injects platform optionalDependencies that only resolve once published,
|
||||
# and npm ci validates the whole workspace against the lockfile.
|
||||
- name: Install and build JS packages
|
||||
working-directory: packages
|
||||
run: |
|
||||
npm ci
|
||||
(cd fff-bun && npm run build)
|
||||
(cd fff-node && npm run build)
|
||||
|
||||
- name: Publish bun package
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
@@ -741,8 +801,6 @@ jobs:
|
||||
make set-npm-version PKG=packages/fff-node VERSION="$VERSION"
|
||||
|
||||
cd packages/fff-node
|
||||
npm install
|
||||
npm run build
|
||||
npm publish --tag "$TAG" --access public --provenance
|
||||
|
||||
- name: Publish pi-fff package
|
||||
|
||||
+77
-14
@@ -17,6 +17,15 @@ env:
|
||||
# Ensure consistent macOS deployment target across all compiled objects
|
||||
# (Rust, cc-compiled C code, and Zig-compiled zlob) to avoid linker warnings
|
||||
MACOSX_DEPLOYMENT_TARGET: "13"
|
||||
# RUSTC_WRAPPER is set per job, since cargo fmt runs without sccache.
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
# fff-search alone exceeds the 600s default on windows, and the server sees
|
||||
# no new requests while it compiles, so it would idle out mid-unit.
|
||||
SCCACHE_IDLE_TIMEOUT: "0"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -46,11 +55,19 @@ jobs:
|
||||
cache-key: "v1-rust"
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@v0.0.11
|
||||
with:
|
||||
version: v0.17.0
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --features zlob --workspace --exclude fff-nvim
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
# fff-python requires full python o3 machinery which is very slow
|
||||
run: cargo test --no-default-features --features zlob --workspace --exclude fff-nvim --exclude fff-python
|
||||
|
||||
stress-test:
|
||||
name: Stress Test (Watcher + Git)
|
||||
name: Fuzz Tests
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
# Keep going after one OS fails so we can see whether a bug
|
||||
@@ -61,6 +78,11 @@ jobs:
|
||||
# Long-running; don't let a stuck watcher thread burn a full CI
|
||||
# timeout. Two scenarios should finish well under this limit.
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
FFF_STRESS_CASES: "5"
|
||||
FFF_STRESS_MIN_OPS: "30"
|
||||
FFF_STRESS_MAX_OPS: "60"
|
||||
RUSTC_WRAPPER: sccache
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
@@ -77,21 +99,22 @@ jobs:
|
||||
cache-key: "v1-rust-stress-${{ matrix.os }}"
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Stress test (seeded / deterministic)
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@v0.0.11
|
||||
with:
|
||||
version: v0.17.0
|
||||
|
||||
- name: Stress test seeded
|
||||
shell: bash
|
||||
run: make test-stress-seeded
|
||||
env:
|
||||
FFF_STRESS_CASES: "3"
|
||||
FFF_STRESS_MIN_OPS: "30"
|
||||
FFF_STRESS_MAX_OPS: "50"
|
||||
|
||||
- name: Stress test (random / fuzzy)
|
||||
- name: Stress test random
|
||||
shell: bash
|
||||
run: make test-stress-random
|
||||
env:
|
||||
FFF_STRESS_CASES: "5"
|
||||
FFF_STRESS_MIN_OPS: "30"
|
||||
FFF_STRESS_MAX_OPS: "60"
|
||||
|
||||
- name: Stress test regressions
|
||||
shell: bash
|
||||
run: make test-stress-regressions
|
||||
|
||||
- name: Upload proptest regressions on failure
|
||||
if: failure()
|
||||
@@ -101,6 +124,39 @@ jobs:
|
||||
path: crates/fff-core/tests/fuzz_git_watcher_stress.proptest-regressions
|
||||
if-no-files-found: ignore
|
||||
|
||||
build-i686:
|
||||
name: Build i686-unknown-linux-gnu
|
||||
runs-on: ubuntu-latest
|
||||
# Verifies that fff-search compiles on 32-bit x86, where std::arch::x86_64
|
||||
# is unavailable. SIMD paths are disabled on this target; only the scalar
|
||||
# fallback should build. See issue #656.
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install cross toolchain
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-multilib g++-multilib
|
||||
|
||||
- name: Install Rust (i686 target)
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1.15.4
|
||||
with:
|
||||
target: i686-unknown-linux-gnu
|
||||
cache: true
|
||||
cache-on-failure: true
|
||||
cache-key: "v1-rust-i686"
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@v0.0.11
|
||||
with:
|
||||
version: v0.17.0
|
||||
|
||||
- name: Build fff-search for i686
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
run: cargo build -p fff-search --target i686-unknown-linux-gnu
|
||||
|
||||
fmt:
|
||||
name: cargo fmt
|
||||
runs-on: ubuntu-latest
|
||||
@@ -131,6 +187,13 @@ jobs:
|
||||
with:
|
||||
toolchain: stable
|
||||
components: clippy
|
||||
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@v0.0.11
|
||||
with:
|
||||
version: v0.17.0
|
||||
|
||||
- name: Run clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
run: cargo clippy --no-default-features --features zlob -- -D warnings
|
||||
|
||||
@@ -12,6 +12,10 @@ on:
|
||||
env:
|
||||
CLICOLOR: 1
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
jobs:
|
||||
spelling:
|
||||
name: Spell Check with Typos
|
||||
|
||||
@@ -20,6 +20,10 @@ on:
|
||||
env:
|
||||
CLICOLOR: 1
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
jobs:
|
||||
stylua:
|
||||
name: Check lua files using Stylua
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
-- luacheck configuration for fff.nvim
|
||||
-- luacheck configuration for fff
|
||||
-- https://luacheck.readthedocs.io/en/stable/config.html
|
||||
|
||||
-- Neovim globals
|
||||
|
||||
@@ -31,6 +31,13 @@ When doing code make sure to REDUCE SIZE OF COMMENTS. This is very important. Ev
|
||||
- Do not make public structs if something can be private
|
||||
|
||||
|
||||
## Style guide
|
||||
|
||||
- NO MODULES COMMENTS
|
||||
- NO TOP FILE COMMENTS
|
||||
- NO COMMENT LONGER THAN 2 LINES UNLESS ASKED EXPLICITLY
|
||||
- UTILITY FUNCTIONS GO INTO THE END OF FILE
|
||||
|
||||
## Architecture
|
||||
|
||||
Everything that is performance critical happens in rust world, everything that is neovim specific happens in the lua code.
|
||||
|
||||
Generated
+24
-44
@@ -91,9 +91,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
version = "1.0.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
@@ -440,9 +440,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
version = "0.9.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
@@ -633,7 +633,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "fff-c"
|
||||
version = "0.9.6"
|
||||
version = "0.10.5"
|
||||
dependencies = [
|
||||
"fff-query-parser",
|
||||
"fff-search",
|
||||
@@ -643,7 +643,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fff-grep"
|
||||
version = "0.9.6"
|
||||
version = "0.10.5"
|
||||
dependencies = [
|
||||
"bstr",
|
||||
"memchr",
|
||||
@@ -651,7 +651,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fff-mcp"
|
||||
version = "0.9.6"
|
||||
version = "0.10.5"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"fff-query-parser",
|
||||
@@ -662,8 +662,10 @@ dependencies = [
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -682,7 +684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fff-nvim"
|
||||
version = "0.9.6"
|
||||
version = "0.10.5"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"chrono",
|
||||
@@ -698,11 +700,12 @@ dependencies = [
|
||||
"rand 0.8.5",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"zlob",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fff-python"
|
||||
version = "0.9.6"
|
||||
version = "0.10.5"
|
||||
dependencies = [
|
||||
"fff-query-parser",
|
||||
"fff-search",
|
||||
@@ -712,7 +715,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fff-query-parser"
|
||||
version = "0.9.6"
|
||||
version = "0.10.5"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"zlob",
|
||||
@@ -720,7 +723,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fff-search"
|
||||
version = "0.9.6"
|
||||
version = "0.10.5"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"aho-corasick",
|
||||
@@ -924,15 +927,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "git2"
|
||||
version = "0.20.4"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b"
|
||||
checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"libc",
|
||||
"libgit2-sys",
|
||||
"log",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1266,15 +1268,6 @@ dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
@@ -1341,9 +1334,9 @@ checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
||||
|
||||
[[package]]
|
||||
name = "libgit2-sys"
|
||||
version = "0.18.3+1.9.2"
|
||||
version = "0.18.7+1.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487"
|
||||
checksum = "23c7391e4b9f4ffab1a624223cc1d7385ff9a678f490768add717de7ea2f4d89"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -1448,9 +1441,9 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "memmap2"
|
||||
version = "0.9.10"
|
||||
version = "0.9.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
|
||||
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
@@ -1533,13 +1526,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "neo_frizbee"
|
||||
version = "0.10.3"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0dd76fab81213d184cc28a7757791775bdcfd7f2a15e3558d7a4f7e4ee7de864"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"raw-cpuid",
|
||||
]
|
||||
checksum = "7a2f6120a8da26bea3587731072111062c5d8c51ca3a3a75a716bd8b735d5882"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
@@ -2031,15 +2020,6 @@ dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
version = "11.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.11.0"
|
||||
@@ -3257,9 +3237,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zlob"
|
||||
version = "1.4.1"
|
||||
version = "1.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "466e82062db3527af78a7627a0e066f2420f8d2e573d530956fb9192956dc7b6"
|
||||
checksum = "f0f010d9b10495cc0037cb47cedc143c39c7fa2d59f752dca19036fd0234a210"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"bitflags 2.11.0",
|
||||
|
||||
+8
-5
@@ -11,9 +11,12 @@ members = [
|
||||
|
||||
resolver = "2"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
module_inception = "allow"
|
||||
|
||||
[workspace.dependencies]
|
||||
fff-grep = { version = "0.9.6", path = "crates/fff-grep" }
|
||||
fff-query-parser = { version = "0.9.6", path = "crates/fff-query-parser", default-features = false }
|
||||
fff-grep = { version = "0.10.5", path = "crates/fff-grep" }
|
||||
fff-query-parser = { version = "0.10.5", path = "crates/fff-query-parser", default-features = false }
|
||||
|
||||
# Shared dependencies
|
||||
ahash = "0.8"
|
||||
@@ -23,7 +26,7 @@ ctrlc = "3.4.2"
|
||||
dirs = "5.0"
|
||||
dunce = "1.0"
|
||||
# git2 - base config without TLS (each crate adds platform-specific TLS)
|
||||
git2 = { version = "0.20.2", default-features = false, features = [
|
||||
git2 = { version = "0.21.0", default-features = false, features = [
|
||||
"vendored-libgit2",
|
||||
] }
|
||||
glidesort = "0.1"
|
||||
@@ -33,10 +36,10 @@ ignore = "0.4.22"
|
||||
memmap2 = "0.9"
|
||||
mimalloc = "0.1.47"
|
||||
signal-hook-registry = "1.4"
|
||||
zlob = "1.4.1"
|
||||
zlob = { version = "=1.6.3" }
|
||||
|
||||
mlua = { version = "0.11.1", features = ["module", "luajit"] }
|
||||
neo_frizbee = { version = "0.10.3", features = ["match_end_col"] }
|
||||
neo_frizbee = { version = "0.11.0", features = ["match_end_col"] }
|
||||
notify = { version = "9.0.0-rc.3" }
|
||||
notify-debouncer-full = { package = "fff-notify-debouncer-full", version = "0.9.4" }
|
||||
once_cell = "1.20.2"
|
||||
|
||||
+8
-8
@@ -2,34 +2,34 @@
|
||||
# Maintained in-repo; auto-bumped by .github/workflows/release.yaml on stable releases.
|
||||
class FffMcp < Formula
|
||||
desc "Fast file search toolkit for AI agents (MCP server)"
|
||||
homepage "https://github.com/dmtrKovalenko/fff.nvim"
|
||||
homepage "https://github.com/dmtrKovalenko/fff"
|
||||
license "MIT"
|
||||
version "0.9.5"
|
||||
version "0.10.5"
|
||||
|
||||
LIVECHECK_REPO = "dmtrKovalenko/fff.nvim".freeze
|
||||
RELEASE_BASE = "https://github.com/dmtrKovalenko/fff.nvim/releases/download".freeze
|
||||
LIVECHECK_REPO = "dmtrKovalenko/fff".freeze
|
||||
RELEASE_BASE = "https://github.com/dmtrKovalenko/fff/releases/download".freeze
|
||||
|
||||
on_macos do
|
||||
on_arm do
|
||||
url "#{RELEASE_BASE}/v#{version}/fff-mcp-aarch64-apple-darwin"
|
||||
sha256 "5e46ffd613a71cb460fb674a0756070658e806b10f6151f8ffd006ddddd8dccf"
|
||||
sha256 "2f1a75c2401e5df7f5a10827f9ce9fe8401f290ff99c3f3f1423c7d7b156d849"
|
||||
end
|
||||
|
||||
on_intel do
|
||||
url "#{RELEASE_BASE}/v#{version}/fff-mcp-x86_64-apple-darwin"
|
||||
sha256 "66b717a9dd0a058972042f6a20bbf51274df1b8cdb4b6c5586c0d8a7b0fc0d3a"
|
||||
sha256 "c81e22a7fb657731a7441be877c622a110eb7e7dbc94e3b57d79c9cea91f38db"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
on_arm do
|
||||
url "#{RELEASE_BASE}/v#{version}/fff-mcp-aarch64-unknown-linux-gnu"
|
||||
sha256 "3f7b5306c79f099f8c18720a5f88e1c503ebaa11553758a230270727adf5bd13"
|
||||
sha256 "48f5af0d9e6ef4506c5cccd3f4571da4f0acc9b9c0967832167e49fe651f8ee5"
|
||||
end
|
||||
|
||||
on_intel do
|
||||
url "#{RELEASE_BASE}/v#{version}/fff-mcp-x86_64-unknown-linux-gnu"
|
||||
sha256 "0bceb87752cb38540dbad585cd08fab3d26a315576bbf5af8b0d3c9c9ddf068b"
|
||||
sha256 "0408cbac06303b3bf2041181089d8b4974b527883242bdcf8ba599b41c003973"
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -12,19 +12,15 @@ FFF_STRESS_DEFAULT_SEED ?= 0xDEADBEEFCAFEBABE
|
||||
SHELL := bash
|
||||
# Order matters: `-c` must be last so bash treats the recipe as the script
|
||||
# string rather than the literal `-o` / `pipefail` tokens.
|
||||
.SHELLFLAGS := -o pipefail -ec
|
||||
.SHELLFLAGS := -o pipefail -euc
|
||||
|
||||
.PHONY: build build-c-lib install uninstall test test-rust test-c-smoke test-c-api test-lua test-lua-snap test-version test-bun test-node prepare-bun prepare-bun-packaged prepare-node set-npm-version header test-stress test-stress-seeded test-stress-random test-stress-repos test-node-stress sync-js-api sync-js-api-check bump-homebrew-formula bump-install-mcp-sh test-bun-compile
|
||||
.PHONY: build build-c-lib install uninstall test test-rust test-rescan test-rescan-known-defects rescan-probe test-c-smoke test-c-api test-lua test-lua-snap test-version test-bun test-node prepare-bun prepare-bun-packaged prepare-node set-npm-version header test-stress test-stress-seeded test-stress-random test-stress-regressions test-stress-repos test-node-stress sync-js-api sync-js-api-check bump-homebrew-formula bump-install-mcp-sh test-bun-compile
|
||||
|
||||
all: format test lint
|
||||
|
||||
# Single source of truth for the shared FileFinder TS interface lives in
|
||||
# packages/shared/fff-api.ts. tsc cannot import across a package's
|
||||
# rootDir and the bun package publishes its raw src/, so the file is copied
|
||||
# into each package instead of symlinked.
|
||||
SYNC_API_SRC := packages/shared/fff-api.ts
|
||||
SYNC_API_TARGETS := packages/fff-node/src/fff-api.ts packages/fff-bun/src/fff-api.ts
|
||||
SYNC_API_BANNER := // ----------------------------------------------------------------------------\n// GENERATED FILE - DO NOT EDIT.\n// Source of truth: packages/shared/fff-api.ts\n// Run make sync-js-api from the repo root to regenerate.\n// ----------------------------------------------------------------------------\n\n
|
||||
SYNC_API_BANNER := // ----------------------------------------------------------------------------\n// GENERATED FILE - DO NOT EDIT.\n// Copied from: ${SYNC_API_SRC}\n// Run make sync-js-api from the repo root to regenerate.\n// ----------------------------------------------------------------------------\n\n
|
||||
|
||||
sync-js-api:
|
||||
@for target in $(SYNC_API_TARGETS); do \
|
||||
@@ -47,10 +43,15 @@ sync-js-api-check:
|
||||
exit $$status
|
||||
|
||||
build:
|
||||
cargo build --release --features zlob
|
||||
cargo build --release --no-default-features --features zlob
|
||||
|
||||
# Only the crates the e2e suites load (nvim lua tests + C/bun/node FFI tests),
|
||||
# skipping fff-python (pyo3) and fff-mcp (tokio/rmcp) which e2e never touches.
|
||||
build-e2e:
|
||||
cargo build --release -p fff-nvim -p fff-c --no-default-features --features zlob
|
||||
|
||||
build-c-lib:
|
||||
cargo build --release -p fff-c --features zlob
|
||||
cargo build --release -p fff-c --no-default-features --features zlob
|
||||
|
||||
header:
|
||||
cbindgen --config crates/fff-c/cbindgen.toml --crate fff-c --output crates/fff-c/include/fff.h
|
||||
@@ -94,7 +95,26 @@ test-setup:
|
||||
fi
|
||||
|
||||
test-rust:
|
||||
cargo test --workspace --features zlob --exclude fff-nvim
|
||||
cargo test --workspace --no-default-features --features zlob --exclude fff-nvim --exclude fff-python
|
||||
|
||||
# Watcher rescan harness: asserts that editing, build output, git activity and
|
||||
# preview reads all stay on the incremental path instead of re-walking the tree.
|
||||
test-rescan:
|
||||
cargo test -p fff-search --no-default-features --features zlob \
|
||||
--lib --test rescan_regression -- rescan
|
||||
|
||||
# Live probe for watcher rescan requests and their causes.
|
||||
# Usage: make rescan-probe DIR=~/some/repo [SECONDS=120]
|
||||
rescan-probe:
|
||||
cargo run --release -p fff-nvim --bin rescan_probe \
|
||||
--no-default-features --features zlob,rescan-stats -- \
|
||||
$(or $(DIR),.) $(if $(SECONDS),--seconds $(SECONDS),)
|
||||
|
||||
# The same harness, restricted to cases that currently fail on purpose. Each
|
||||
# `#[ignore]` reason names the code that causes the unnecessary rescan.
|
||||
test-rescan-known-defects:
|
||||
cargo test --no-fail-fast -p fff-search --no-default-features --features zlob \
|
||||
--lib --test rescan_regression -- --ignored --nocapture
|
||||
|
||||
CC ?= cc
|
||||
CFLAGS ?= -O0 -g -Wall -Wextra -std=c99
|
||||
@@ -103,7 +123,7 @@ SMOKE_BIN := $(TARGET_DIR)/fff_c_smoke
|
||||
SMOKE_SRC := crates/fff-c/tests/smoke.c
|
||||
SMOKE_INCLUDE := crates/fff-c/include
|
||||
|
||||
test-c-smoke: build-c-lib
|
||||
test-c-smoke: build-e2e
|
||||
$(CC) $(CFLAGS) -I $(SMOKE_INCLUDE) -L $(TARGET_DIR) \
|
||||
-Wl,-rpath,@loader_path/../target/release \
|
||||
-Wl,-rpath,$$(pwd)/$(TARGET_DIR) \
|
||||
@@ -116,7 +136,7 @@ test-c-api: test-c-smoke
|
||||
# neovim instance swallows internal crashes and doesn't rise the the error exiting silently
|
||||
# so check the stdout in case the sigsegv coming out of fff was printed (actual regression).
|
||||
# Output is streamed live via `tee`; pipefail (set above) propagates nvim's exit.
|
||||
test-lua: test-setup build
|
||||
test-lua: test-setup build-e2e
|
||||
@logfile=$$(mktemp); \
|
||||
trap 'rm -f "$$logfile"' EXIT; \
|
||||
nvim --headless -u tests/minimal_init.lua \
|
||||
@@ -128,14 +148,7 @@ test-lua: test-setup build
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# mini.test reference_screenshot snapshots. Separate runner because mini.test
|
||||
# spawns child processes and uses its own collector (incompatible with
|
||||
# PlenaryBustedDirectory). Streams output live via `tee` so failure diffs
|
||||
# appear as they happen instead of after a long capture-buffered silence.
|
||||
# `pcall` catches collect-time errors (e.g. parse error in the test file)
|
||||
# that would otherwise leave headless nvim hanging in its event loop because
|
||||
# the reporter's `cquit` never fires.
|
||||
test-lua-snap: test-setup build
|
||||
test-lua-snap: test-setup build-e2e
|
||||
@logfile=$$(mktemp); \
|
||||
trap 'rm -f "$$logfile"' EXIT; \
|
||||
nvim --headless -u tests/minimal_init.lua \
|
||||
@@ -151,13 +164,13 @@ test-version: test-setup
|
||||
nvim --headless -u tests/minimal_init.lua \
|
||||
-c "PlenaryBustedFile tests/version_spec.lua" 2>&1
|
||||
|
||||
prepare-bun: build sync-js-api
|
||||
prepare-bun: build-e2e sync-js-api
|
||||
mkdir -p packages/fff-bun/bin
|
||||
cp target/release/libfff_c.dylib packages/fff-bun/bin/ 2>/dev/null || true; \
|
||||
cp target/release/libfff_c.so packages/fff-bun/bin/ 2>/dev/null || true; \
|
||||
cp target/release/fff_c.dll packages/fff-bun/bin/ 2>/dev/null || true
|
||||
|
||||
prepare-node: build sync-js-api
|
||||
prepare-node: build-e2e sync-js-api
|
||||
mkdir -p packages/fff-node/bin
|
||||
cp target/release/libfff_c.dylib packages/fff-node/bin/ 2>/dev/null || true; \
|
||||
cp target/release/libfff_c.so packages/fff-node/bin/ 2>/dev/null || true; \
|
||||
@@ -208,7 +221,7 @@ test-bun-compile: prepare-bun-packaged
|
||||
rm -f packages/fff-bun/glob-bench-bin packages/fff-bun/glob-bench-bin.exe
|
||||
|
||||
test-node: prepare-node
|
||||
cd packages/fff-node && npm run build && node test/e2e.mjs
|
||||
cd packages/fff-node && npm run build && node test/e2e.mjs && node test/watch.mjs
|
||||
|
||||
test-js: test-bun test-node
|
||||
|
||||
@@ -227,7 +240,7 @@ test-stress-seeded:
|
||||
cargo test --release \
|
||||
-p fff-search \
|
||||
--test fuzz_git_watcher_stress \
|
||||
--features zlob \
|
||||
--no-default-features --features zlob \
|
||||
-- --nocapture stress_seeded
|
||||
|
||||
test-stress-random:
|
||||
@@ -235,35 +248,33 @@ test-stress-random:
|
||||
cargo test --release \
|
||||
-p fff-search \
|
||||
--test fuzz_git_watcher_stress \
|
||||
--features zlob \
|
||||
--no-default-features --features zlob \
|
||||
-- --nocapture stress_random
|
||||
|
||||
test-stress-regressions:
|
||||
RUSTFLAGS="$(STRESS_RUSTFLAGS)" \
|
||||
cargo test --release \
|
||||
-p fff-search \
|
||||
--test fuzz_git_watcher_stress \
|
||||
--no-default-features --features zlob \
|
||||
-- --nocapture stress_regression stress_merge_conflict_convergence
|
||||
|
||||
test-stress-repos:
|
||||
RUSTFLAGS="$(STRESS_RUSTFLAGS)" \
|
||||
cargo test --release \
|
||||
-p fff-search \
|
||||
--test fuzz_real_repos \
|
||||
--features zlob \
|
||||
--no-default-features --features zlob \
|
||||
-- --nocapture
|
||||
|
||||
test-stress: test-stress-seeded test-stress-random test-stress-repos
|
||||
test-stress: test-stress-seeded test-stress-random test-stress-regressions test-stress-repos
|
||||
|
||||
# Update version in a package.json, including optionalDependencies.
|
||||
# Usage: make set-npm-version PKG=packages/fff-bun VERSION=1.0.0-nightly.abc1234
|
||||
set-npm-version:
|
||||
@test -n "$(PKG)" || (echo "PKG is required" && exit 1)
|
||||
@test -n "$(VERSION)" || (echo "VERSION is required" && exit 1)
|
||||
node -e " \
|
||||
const fs = require('fs'); \
|
||||
const pkg = JSON.parse(fs.readFileSync('$(PKG)/package.json', 'utf8')); \
|
||||
pkg.version = '$(VERSION)'; \
|
||||
if (pkg.optionalDependencies) { \
|
||||
for (const dep of Object.keys(pkg.optionalDependencies)) { \
|
||||
pkg.optionalDependencies[dep] = '$(VERSION)'; \
|
||||
} \
|
||||
} \
|
||||
fs.writeFileSync('$(PKG)/package.json', JSON.stringify(pkg, null, 2) + '\n'); \
|
||||
"
|
||||
node scripts/set-npm-version.mjs "$(PKG)" "$(VERSION)"
|
||||
@echo "Set $(PKG) to $(VERSION)"
|
||||
|
||||
format-rust:
|
||||
@@ -271,22 +282,22 @@ format-rust:
|
||||
format-lua:
|
||||
stylua .
|
||||
format-ts:
|
||||
bun format
|
||||
cd packages && bun format
|
||||
|
||||
format: format-rust format-lua format-ts
|
||||
|
||||
lint-rust:
|
||||
cargo clippy --workspace --features zlob -- -D warnings
|
||||
cargo clippy --workspace --no-default-features --features zlob -- -D warnings
|
||||
lint-lua:
|
||||
~/.luarocks/bin/luacheck .
|
||||
lint-ts:
|
||||
bun lint
|
||||
cd packages && bun lint
|
||||
|
||||
lint: lint-rust lint-lua lint-ts
|
||||
|
||||
check: format lint
|
||||
|
||||
FFF_RELEASE_REPO ?= dmtrKovalenko/fff.nvim
|
||||
FFF_RELEASE_REPO ?= dmtrKovalenko/fff
|
||||
FFF_FORMULA_PATH ?= Formula/fff-mcp.rb
|
||||
FFF_INSTALL_SCRIPT_PATH ?= install-mcp.sh
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<img alt="FFF" src="./assets/logo-orange.png" width="300">
|
||||
<a href="./assets/logo-orange.png"><img alt="FFF" src="./assets/logo-orange.png" width="300"></a>
|
||||
|
||||
<p>
|
||||
<i>A file search toolkit for humans and AI agents. Really fast.</i>
|
||||
</p>
|
||||
|
||||
Typo-resistant path and content search, frecency-ranked file access, a background watcher, and a lightweight in-memory content index. Way faster than CLIs like ripgrep and fzf in any long-running process that searches more than once.
|
||||
Typo-resistant path and content search, frequency-ranked file access, a background watcher, and a lightweight in-memory content index. Way faster than CLIs like ripgrep and fzf in any long-running process that searches more than once.
|
||||
|
||||
Powers file search in [opencode](http://github.com/anomalyco/opencode/), [nushell](https://github.com/nushell/nushell), and many more amazing projects!
|
||||
|
||||
Originally started as [Neovim plugin](#neovim-plugin) people loved, but it turned out that plenty of AI harnesses and code editors need the same thing: accurate, fast file search as a library. That is what fff is.
|
||||
<p>
|
||||
<a href="https://trendshift.io/repositories/26711?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-26711" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/26711" alt="dmtrKovalenko%2Ffff | Trendshift" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
@@ -34,7 +37,7 @@ curl -L https://dmtrkovalenko.dev/install-fff-mcp.sh | bash
|
||||
Windows (PowerShell):
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/dmtrKovalenko/fff.nvim/main/install-mcp.ps1 | iex
|
||||
irm https://raw.githubusercontent.com/dmtrKovalenko/fff/main/install-mcp.ps1 | iex
|
||||
```
|
||||
|
||||
The scripts live at [`install-mcp.sh`](./install-mcp.sh) and [`install-mcp.ps1`](./install-mcp.ps1) if you want to read them first. They print the exact wiring instructions for your client.
|
||||
@@ -46,7 +49,32 @@ brew install dmtrKovalenko/fff/fff-mcp
|
||||
brew upgrade fff-mcp # after new stable releases
|
||||
```
|
||||
|
||||
Formula lives in [`Formula/fff-mcp.rb`](./Formula/fff-mcp.rb) in this repo and is **auto-bumped on every stable release** (see `bump-homebrew-formula` in [`.github/workflows/release.yaml`](./.github/workflows/release.yaml)). Installs the prebuilt `fff-mcp` binary from [GitHub releases](https://github.com/dmtrKovalenko/fff.nvim/releases).
|
||||
Formula lives in [`Formula/fff-mcp.rb`](./Formula/fff-mcp.rb) in this repo and is **auto-bumped on every stable release** (see `bump-homebrew-formula` in [`.github/workflows/release.yaml`](./.github/workflows/release.yaml)). Installs the prebuilt `fff-mcp` binary from [GitHub releases](https://github.com/dmtrKovalenko/fff/releases).
|
||||
|
||||
### Codex setup
|
||||
|
||||
Register the installed binary using its absolute path, since Codex desktop sessions may not inherit your interactive shell's `PATH`.
|
||||
|
||||
Homebrew:
|
||||
|
||||
```bash
|
||||
codex mcp add fff -- "$(brew --prefix)/bin/fff-mcp"
|
||||
```
|
||||
|
||||
One-line installer:
|
||||
|
||||
```bash
|
||||
codex mcp add fff -- "$HOME/.local/bin/fff-mcp"
|
||||
```
|
||||
|
||||
This creates an entry in `~/.codex/config.toml` similar to:
|
||||
|
||||
```toml
|
||||
[mcp_servers.fff]
|
||||
command = "/opt/homebrew/bin/fff-mcp"
|
||||
```
|
||||
|
||||
Use the actual installed path for your system, then restart Codex or start a new task so it loads the server.
|
||||
|
||||
Once the server is connected, ask the agent to "use fff" and it picks up the `ffgrep`, `fffind`, and `fff-multi-grep` tools.
|
||||
|
||||
@@ -92,7 +120,7 @@ Three operating modes, switchable at runtime with `/fff-mode`:
|
||||
| `tools-only` | Only tool injection. Keeps pi's native editor autocomplete. |
|
||||
| `override` | Replaces pi's built-in `grep`, `find`, and `multi_grep` with FFF implementations. |
|
||||
|
||||
Env vars: `PI_FFF_MODE`, `FFF_FRECENCY_DB`, `FFF_HISTORY_DB`. Flags: `--fff-mode`, `--fff-frecency-db`, `--fff-history-db`.
|
||||
Env vars: `PI_FFF_MODE`, `FFF_FRECENCY_DB`, `FFF_HISTORY_DB`. Flags: `--fff-mode`, `--fff-frecency-db`, `--fff-history-db`. The databases default to your existing fff.nvim ones when present, otherwise `~/.pi/agent/fff/`.
|
||||
|
||||
### Agent-facing tools
|
||||
|
||||
@@ -125,8 +153,9 @@ https://github.com/user-attachments/assets/5d0e1ce9-642c-4c44-aa88-01b05bb86abb
|
||||
#### lazy.nvim
|
||||
|
||||
```lua
|
||||
-- Package name changed from `fff.nvim` to `fff`. If you installed fff.nvim before, clean with `:Lazy clean`
|
||||
{
|
||||
'dmtrKovalenko/fff.nvim',
|
||||
'dmtrKovalenko/fff',
|
||||
build = function()
|
||||
-- downloads a prebuilt binary or falls back to cargo build
|
||||
require("fff.download").download_or_build_binary()
|
||||
@@ -147,9 +176,10 @@ https://github.com/user-attachments/assets/5d0e1ce9-642c-4c44-aa88-01b05bb86abb
|
||||
function() require('fff').live_grep({ grep = { modes = { 'fuzzy', 'plain' } } }) end,
|
||||
desc = 'Live fffuzy grep',
|
||||
},
|
||||
{ "fc",
|
||||
function() require('fff').live_grep({ query = vim.fn.expand("<cword>") }) end,
|
||||
desc = 'Search current word',
|
||||
{ "fw",
|
||||
function() require('fff').live_grep_under_cursor() end,
|
||||
mode = { 'n', 'x' },
|
||||
desc = 'Search current word / selection',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -158,13 +188,14 @@ https://github.com/user-attachments/assets/5d0e1ce9-642c-4c44-aa88-01b05bb86abb
|
||||
#### vim.pack
|
||||
|
||||
```lua
|
||||
vim.pack.add({ 'https://github.com/dmtrKovalenko/fff.nvim' })
|
||||
-- Package name changed from `fff.nvim` to `fff`. If you installed fff.nvim before, clean with `:packdel fff.nvim`
|
||||
vim.pack.add({ 'https://github.com/dmtrKovalenko/fff' })
|
||||
|
||||
vim.api.nvim_create_autocmd('PackChanged', {
|
||||
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
|
||||
if name == 'fff' and (kind == 'install' or kind == 'update') then
|
||||
if not ev.data.active then vim.cmd.packadd('fff') end
|
||||
require('fff.download').download_or_build_binary()
|
||||
end
|
||||
end,
|
||||
@@ -183,6 +214,7 @@ vim.keymap.set('n', 'ff', function() require('fff').find_files() end, { desc = '
|
||||
```lua
|
||||
require('fff').find_files() -- find files in current repo
|
||||
require('fff').live_grep() -- live content grep
|
||||
require('fff').live_grep_under_cursor() -- grep <cword> in normal, selection in visual
|
||||
require('fff').scan_files() -- force rescan
|
||||
require('fff').refresh_git_status() -- refresh git status
|
||||
require('fff').find_files_in_dir(path) -- find in a specific dir
|
||||
@@ -279,6 +311,14 @@ require('fff').setup({
|
||||
prompt_position = 'bottom', -- or 'top'
|
||||
preview_position = 'right', -- 'left' | 'right' | 'top' | 'bottom'
|
||||
preview_size = 0.5,
|
||||
-- Border style for the picker windows. Leave unset (nil) to follow the
|
||||
-- global `vim.o.winborder`; set it to override fff's borders independently.
|
||||
border = nil, -- 'single' | 'double' | 'rounded' | 'solid' | 'shadow' | 'none'
|
||||
-- border = {
|
||||
-- { ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ' },
|
||||
-- { ' ', ' ', ' ', ' ', ' ' },
|
||||
-- },
|
||||
|
||||
flex = { size = 130, wrap = 'top' },
|
||||
min_list_height = 10, -- do not display anything except the list below this threshold
|
||||
show_scrollbar = true,
|
||||
@@ -312,6 +352,7 @@ require('fff').setup({
|
||||
preview_scroll_down = '<C-d>',
|
||||
toggle_debug = '<F2>',
|
||||
cycle_grep_modes = '<S-Tab>',
|
||||
insert_newline_escape = '<C-CR>',
|
||||
-- grep mode only: jump cursor to first match of next/prev file group
|
||||
grep_jump_to_next_file = { '<C-A-n>', '<A-Down>' },
|
||||
grep_jump_to_prev_file = { '<C-A-p>', '<A-Up>' },
|
||||
@@ -334,6 +375,9 @@ require('fff').setup({
|
||||
git = {
|
||||
status_text_color = false, -- true to color filenames by git status
|
||||
},
|
||||
file_picker = {
|
||||
fuzzy_query_highlighting = false, -- true to highlight fuzzy query matches in file picker results
|
||||
},
|
||||
select = {
|
||||
-- Return winid to open the chosen file in, or nil to open in the original window
|
||||
select_window = function(current_buf, action) --[[ default impl ]] end,
|
||||
@@ -391,7 +435,7 @@ Both find and grep accept these tokens to refine a query:
|
||||
|
||||
- `git:modified`. One of `modified`, `staged`, `deleted`, `renamed`, `untracked`, `ignored`.
|
||||
- `test/`. Any deeply nested children of `test/`.
|
||||
- `!something`, `!test/`, `!git:modified`. Exclusion.
|
||||
- `!something`, `!test/`, `!git:modified`. Exclusion. Text exclusions need at least 3 alphanumeric-containing characters, so operators like `!=` or `!==` work.
|
||||
- `./**/*.{rs,lua}`. Any valid glob, powered by [zlob](https://github.com/dmtrKovalenko/zlob).
|
||||
|
||||
Grep-only:
|
||||
@@ -565,9 +609,14 @@ make build-c-lib
|
||||
cargo build --release -p fff-c --features zlob
|
||||
```
|
||||
|
||||
> The `zlob` feature (requires the [Zig](https://ziglang.org) toolchain) switches both
|
||||
> glob matching **and** filesystem traversal to [zlob](https://github.com/dmtrKovalenko/zlob)'s
|
||||
> native parallel walker. Without it, the default build uses the pure-Rust
|
||||
> [`ignore`](https://crates.io/crates/ignore) (ripgrep) walker and `globset`.
|
||||
|
||||
The output is a `cdylib` (`libfff_c.so` / `libfff_c.dylib` / `fff_c.dll`). The header lives at [`crates/fff-c/include/fff.h`](./crates/fff-c/include/fff.h).
|
||||
|
||||
Prebuilt binaries for every version, including every commit on main, are on the [releases page](https://github.com/dmtrKovalenko/fff.nvim/releases). The same binaries also ship inside the `@ff-labs/fff-bin-*` npm packages.
|
||||
Prebuilt binaries for every version, including every commit on main, are on the [releases page](https://github.com/dmtrKovalenko/fff/releases). The same binaries also ship inside the `@ff-labs/fff-bin-*` npm packages.
|
||||
|
||||
### Install
|
||||
|
||||
@@ -744,7 +793,7 @@ FFF is a file search library, not a CLI. Ripgrep and fzf are great tools, but th
|
||||
|
||||
FFF keeps the index and the file cache resident in one long-lived process and exposes the same Rust core through four thin layers: a native crate (`fff-search`), a C library (`libfff_c`), a Node/Bun SDK (`@ff-labs/fff-node`), and an MCP server. You call `FileFinder.create()` once, then every subsequent search hits warm memory. On a 500k-file Chromium checkout, that is the difference between 3-9 **SECONDS** per ripgrep spawn and sub-10 ms per FFF query.
|
||||
|
||||
Algorithm for fuzzy matching is much more comprehensive than fzf's algorithm it is **typo-resistant** and we provide a query language with additional constraint parsing for prefiltering e.g. "\*.rs !test/ shcema" is a perfectly valid query for fff, but fzf wouldn't find anything even for a single typo in "shcema".
|
||||
Algorithm for fuzzy matching is much more comprehensive than fzf's algorithm. It is **typo-resistant** and we provide a query language with additional constraint parsing for prefiltering e.g. "\*.rs !test/ shcema" is a perfectly valid query for fff, but fzf wouldn't find anything even for a single typo in "shcema".
|
||||
|
||||
### Why a programmatic API matters
|
||||
|
||||
@@ -802,7 +851,7 @@ If you are running one grep from a terminal, `rg` is still the right tool. If yo
|
||||
- `crates/fff-nvim` - Lua/mlua bindings for the Neovim plugin.
|
||||
- `crates/fff-mcp` - MCP server binary.
|
||||
- `packages/fff-node` - Node.js SDK (`@ff-labs/fff-node`).
|
||||
- `packages/fff-bun` - Bun SDK (`@ff-labs/fff-node`).
|
||||
- `packages/fff-bun` - Bun SDK (`@ff-labs/fff-bun`).
|
||||
- `packages/pi-fff` - pi extension (`@ff-labs/pi-fff`).
|
||||
- `lua/` - Neovim-side plugin code.
|
||||
|
||||
@@ -813,3 +862,15 @@ Bug reports and pull requests welcome. Agentic coding tools are welcome to be us
|
||||
## License
|
||||
|
||||
[MIT](./LICENSE) & open source forever.
|
||||
|
||||
## FAQ
|
||||
|
||||
### What does FFF stand for?
|
||||
|
||||
There is intentionally no single canonical definition. Pick your favourite:
|
||||
|
||||
- **F**ast **F**ile **F**inder
|
||||
- **F**uzzy **F**ile **F**inder
|
||||
- will search **F**iles **F**or **F**ood
|
||||
|
||||
The brand hex is `#F87216`, not `#FFF`. Logo variants: [orange](./assets/logo-orange.png) · [dark](./assets/logo-dark.png) · [light](./assets/logo-light.png).
|
||||
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
|
||||
"files": {
|
||||
"includes": ["packages/**/*.ts", "!packages/*/dist"],
|
||||
"ignoreUnknown": true
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 90
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"trailingCommas": "all",
|
||||
"semicolons": "always"
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.4",
|
||||
},
|
||||
},
|
||||
"packages/fff-bun": {
|
||||
"name": "@ff-labs/fff-bun",
|
||||
"version": "0.1.37",
|
||||
"bin": {
|
||||
"fff-demo": "./examples/search.ts",
|
||||
"fff-grep": "./examples/grep.ts",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.8",
|
||||
"typescript": "^5.0.0",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@ff-labs/fff-bin-darwin-arm64": "0.0.0",
|
||||
"@ff-labs/fff-bin-darwin-x64": "0.0.0",
|
||||
"@ff-labs/fff-bin-linux-arm64-gnu": "0.0.0",
|
||||
"@ff-labs/fff-bin-linux-arm64-musl": "0.0.0",
|
||||
"@ff-labs/fff-bin-linux-x64-gnu": "0.0.0",
|
||||
"@ff-labs/fff-bin-linux-x64-musl": "0.0.0",
|
||||
"@ff-labs/fff-bin-win32-arm64": "0.0.0",
|
||||
"@ff-labs/fff-bin-win32-x64": "0.0.0",
|
||||
},
|
||||
},
|
||||
"packages/fff-node": {
|
||||
"name": "@ff-labs/fff-node",
|
||||
"version": "0.1.37",
|
||||
"dependencies": {
|
||||
"ffi-rs": "^1.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@ff-labs/fff-bin-darwin-arm64": "0.0.0",
|
||||
"@ff-labs/fff-bin-darwin-x64": "0.0.0",
|
||||
"@ff-labs/fff-bin-linux-arm64-gnu": "0.0.0",
|
||||
"@ff-labs/fff-bin-linux-arm64-musl": "0.0.0",
|
||||
"@ff-labs/fff-bin-linux-x64-gnu": "0.0.0",
|
||||
"@ff-labs/fff-bin-linux-x64-musl": "0.0.0",
|
||||
"@ff-labs/fff-bin-win32-arm64": "0.0.0",
|
||||
"@ff-labs/fff-bin-win32-x64": "0.0.0",
|
||||
},
|
||||
},
|
||||
"packages/pi-fff": {
|
||||
"name": "@ff-labs/pi-fff",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@ff-labs/fff-node": "*",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "*",
|
||||
"@earendil-works/pi-tui": "*",
|
||||
"@sinclair/typebox": "*",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="],
|
||||
|
||||
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
|
||||
|
||||
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
|
||||
|
||||
"@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
|
||||
|
||||
"@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="],
|
||||
|
||||
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
|
||||
|
||||
"@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/eventstream-handler-node": "^3.972.16", "@aws-sdk/middleware-eventstream": "^3.972.12", "@aws-sdk/middleware-websocket": "^3.972.19", "@aws-sdk/token-providers": "3.1048.0", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ=="],
|
||||
|
||||
"@aws-sdk/core": ["@aws-sdk/core@3.974.18", "", { "dependencies": { "@aws-sdk/types": "^3.973.11", "@aws-sdk/xml-builder": "^3.972.28", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-JDYCPI0j7zGrzXTDFsLB346cxss7J/AxH7+O0MzWlqppJBEyB9Qe6TQXRL6iwLUo/xZkNv9KFmBL2hqElmwW0g=="],
|
||||
|
||||
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-3hKJVrZ7bqXzDAXCQp+OaQ1ASN+vWstaNuEH418wQVl//cRZhqhfR9Bjk1qIWmgUGe8/D3gdO73PgidRj378EQ=="],
|
||||
|
||||
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-VhwC9pGAZHhiQ2xSViyOPDFqvr9aRxGCAXZtADsUhU3R65nad7y//CwynE6mQnWNR+suRlqE79W36IVayL+m1g=="],
|
||||
|
||||
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.50", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/credential-provider-env": "^3.972.44", "@aws-sdk/credential-provider-http": "^3.972.46", "@aws-sdk/credential-provider-login": "^3.972.49", "@aws-sdk/credential-provider-process": "^3.972.44", "@aws-sdk/credential-provider-sso": "^3.972.49", "@aws-sdk/credential-provider-web-identity": "^3.972.49", "@aws-sdk/nested-clients": "^3.997.17", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-09Xi6ovxiK42+De/qBGF71sT5F2bWgYM+1fFyDwSOpy1xpsQ5R/naIu7MVDpH6Dic36QNc8dAv4KADtMGK2JYg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.49", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/nested-clients": "^3.997.17", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-EfJF/1Fh9mI4pZyoheU2RY9xUhTcugIZNkD63+orXMkYj/QXacJNbKVDUK90Yv5hE+aX+rt9J/EZ9Qr3vKOa7g=="],
|
||||
|
||||
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.52", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.44", "@aws-sdk/credential-provider-http": "^3.972.46", "@aws-sdk/credential-provider-ini": "^3.972.50", "@aws-sdk/credential-provider-process": "^3.972.44", "@aws-sdk/credential-provider-sso": "^3.972.49", "@aws-sdk/credential-provider-web-identity": "^3.972.49", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-7QX+PbyiWBEOVipJq8Nke/TqXT6lAPLE7fvTaopa39/IVWuLfS+Fzdy71sZJONf/mLGgmtj6aU17+REw3+aRrw=="],
|
||||
|
||||
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-V+UUhZpRP7QDRhi+qgBDisM9tUBnYmMje8Bk77A6MZsfeGeGdMsQXmaHP1CDYFcept0o/Rz5g2Y0TMeVlG9dzg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.49", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/nested-clients": "^3.997.17", "@aws-sdk/token-providers": "3.1063.0", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-9QqOYGuh5tZ76OzaT68kwI78AH+5lS/uZGGvkfxb3fc8FzRrIz2jOufNTliEBEeSAwmgK2rWLNsK+IB3zbtNPA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.49", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/nested-clients": "^3.997.17", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-IYx1lN38MnnPXv+NBLpuATu0cZakbZ321TAfjW+aVkw7HIJF38YnEwdeEO55MSl3pl7hIX1IvvnD6EmnAzmAJw=="],
|
||||
|
||||
"@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.20", "", { "dependencies": { "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-qr/S1iFCDIXlZwlZPaCqjKcHbJFr9scIFUhbh2+SrwPXZvRhyOUWjVDJpp8xoU4qrrMR0PqK1Yw5C2sSj7xAyw=="],
|
||||
|
||||
"@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.16", "", { "dependencies": { "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-KR2Gdui/QLbkdG9FxW3vk/vIa8KiDP5vQBNERo7MmlPHjn23GXJ53Cq5P/ok7/ALbTUiYZ78DiBHoDcvzPWvgQ=="],
|
||||
|
||||
"@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.26", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-foM3KvxGBHY9lRIm6C9JJJ5haodtXfJPPgJQcv5/c4A2pN4I7tlnOjh1o2d8Il1Y/j6GWOw3YeIYc2/VYjtGVQ=="],
|
||||
|
||||
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.17", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.18", "@aws-sdk/signature-v4-multi-region": "^3.996.32", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-lDRgraoTfKRawUyc176Ow93mrNrOho/x+EoK4C+lKU+vKkHWhNhzvSMVAx0WEJUJoeQxxDN5ZdKMfiGEyNejig=="],
|
||||
|
||||
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.32", "", { "dependencies": { "@aws-sdk/types": "^3.973.11", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-llvApLcsWtmRFhG2wT3WIp1CmDeRaIYutqty1ZZXoMzK7TiJ6MOLOimk9eXUS8PwgG4ew4pa4QAbt0lfhn++1w=="],
|
||||
|
||||
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1048.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/nested-clients": "^3.997.9", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA=="],
|
||||
|
||||
"@aws-sdk/types": ["@aws-sdk/types@3.973.11", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-YjS0qFuECClRh4qhEyW8XagW0fwEPBeZ1cfsW/gU73Kh/ExFILxbzxOfPCmzF/2DwEvhvsHYt0b0qnvStwKYrg=="],
|
||||
|
||||
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.6", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ZfHjfwSzeXj+Lg9AK5ZNmeDkXev6V+w2tn1t4kgDdRtUaRCthepTQiFwbD06EF9oNGH4LaLg+Mb6U16Ypv5bSw=="],
|
||||
|
||||
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.28", "", { "dependencies": { "@smithy/types": "^4.14.3", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-lI/l3c/vPvsxmspzV63NfS3x9q4CkMmdhJy4QiM+NThAufVkDvi/PZZQ6xETnICL0UD7jI808pY83gllf86RFg=="],
|
||||
|
||||
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
|
||||
|
||||
"@biomejs/biome": ["@biomejs/biome@2.4.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.4", "@biomejs/cli-darwin-x64": "2.4.4", "@biomejs/cli-linux-arm64": "2.4.4", "@biomejs/cli-linux-arm64-musl": "2.4.4", "@biomejs/cli-linux-x64": "2.4.4", "@biomejs/cli-linux-x64-musl": "2.4.4", "@biomejs/cli-win32-arm64": "2.4.4", "@biomejs/cli-win32-x64": "2.4.4" }, "bin": { "biome": "bin/biome" } }, "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q=="],
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A=="],
|
||||
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ=="],
|
||||
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg=="],
|
||||
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A=="],
|
||||
|
||||
"@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.78.1", "", { "dependencies": { "@earendil-works/pi-ai": "^0.78.1", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" } }, "sha512-oPwVRkkAvyKPWyM7E4k+EaTNmynbYn7ZLG/LBh9BUnMNb2gvpMp+VQ420R6JCJ20uogSqrHnWTyosSa/rU8lVw=="],
|
||||
|
||||
"@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.78.1", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.1", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-CM2pkTs1iupG/maw381lC9Q/Y/aQaMGK7GILc28ttImD0ci3LDwKroDsGkWbly5JIy3iqxdRxB9JlG7vvzCzTg=="],
|
||||
|
||||
"@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.78.1", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.78.1", "@earendil-works/pi-ai": "^0.78.1", "@earendil-works/pi-tui": "^0.78.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "typebox": "1.1.38", "undici": "8.3.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/cli.js" } }, "sha512-Syjf6Ib8UoY5t9ZdKjp0BRrQZuFkFBc8j2KEU9zG/ZnmYPcAxYeioofdv2Q3MEXnHEX2U8sKQptkSnJIdMsd0g=="],
|
||||
|
||||
"@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.78.1", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "15.0.12" } }, "sha512-07GVQo/38a0yvIPlWDr3RJn1B8gk3ZuIX9h2oIQ+Biyu3JN0KppWmgWHfaWRydQgse5JtC++KDw5MWaIRnV0mw=="],
|
||||
|
||||
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@workspace:packages/fff-bun"],
|
||||
|
||||
"@ff-labs/fff-node": ["@ff-labs/fff-node@workspace:packages/fff-node"],
|
||||
|
||||
"@ff-labs/pi-fff": ["@ff-labs/pi-fff@workspace:packages/pi-fff"],
|
||||
|
||||
"@google/genai": ["@google/genai@1.52.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q=="],
|
||||
|
||||
"@mariozechner/clipboard": ["@mariozechner/clipboard@0.3.9", "", { "optionalDependencies": { "@mariozechner/clipboard-darwin-arm64": "0.3.9", "@mariozechner/clipboard-darwin-universal": "0.3.9", "@mariozechner/clipboard-darwin-x64": "0.3.9", "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-musl": "0.3.9", "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" } }, "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA=="],
|
||||
|
||||
"@mariozechner/clipboard-darwin-arm64": ["@mariozechner/clipboard-darwin-arm64@0.3.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ=="],
|
||||
|
||||
"@mariozechner/clipboard-darwin-universal": ["@mariozechner/clipboard-darwin-universal@0.3.9", "", { "os": "darwin" }, "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ=="],
|
||||
|
||||
"@mariozechner/clipboard-darwin-x64": ["@mariozechner/clipboard-darwin-x64@0.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg=="],
|
||||
|
||||
"@mariozechner/clipboard-linux-arm64-gnu": ["@mariozechner/clipboard-linux-arm64-gnu@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw=="],
|
||||
|
||||
"@mariozechner/clipboard-linux-arm64-musl": ["@mariozechner/clipboard-linux-arm64-musl@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ=="],
|
||||
|
||||
"@mariozechner/clipboard-linux-riscv64-gnu": ["@mariozechner/clipboard-linux-riscv64-gnu@0.3.9", "", { "os": "linux", "cpu": "none" }, "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw=="],
|
||||
|
||||
"@mariozechner/clipboard-linux-x64-gnu": ["@mariozechner/clipboard-linux-x64-gnu@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw=="],
|
||||
|
||||
"@mariozechner/clipboard-linux-x64-musl": ["@mariozechner/clipboard-linux-x64-musl@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ=="],
|
||||
|
||||
"@mariozechner/clipboard-win32-arm64-msvc": ["@mariozechner/clipboard-win32-arm64-msvc@0.3.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ=="],
|
||||
|
||||
"@mariozechner/clipboard-win32-x64-msvc": ["@mariozechner/clipboard-win32-x64-msvc@0.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA=="],
|
||||
|
||||
"@mistralai/mistralai": ["@mistralai/mistralai@2.2.1", "", { "dependencies": { "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" } }, "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ=="],
|
||||
|
||||
"@nodable/entities": ["@nodable/entities@2.1.1", "", {}, "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg=="],
|
||||
|
||||
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||
|
||||
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
||||
|
||||
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
|
||||
|
||||
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="],
|
||||
|
||||
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="],
|
||||
|
||||
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
|
||||
|
||||
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.2", "", {}, "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw=="],
|
||||
|
||||
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
|
||||
|
||||
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
|
||||
|
||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="],
|
||||
|
||||
"@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="],
|
||||
|
||||
"@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="],
|
||||
|
||||
"@smithy/core": ["@smithy/core@3.24.6", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug=="],
|
||||
|
||||
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.3.8", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-5cAM+KZC02sTqDt6NaLXyu50M/GNMd1eTzDVR8Lb0BBsVtu7RWHo47VPPEEv1vt3Yub6uzr+M5FHC+GtoT0USg=="],
|
||||
|
||||
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g=="],
|
||||
|
||||
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
||||
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="],
|
||||
|
||||
"@smithy/signature-v4": ["@smithy/signature-v4@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ=="],
|
||||
|
||||
"@smithy/types": ["@smithy/types@4.14.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ=="],
|
||||
|
||||
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
||||
"@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||
|
||||
"@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
|
||||
|
||||
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
|
||||
|
||||
"@yuuang/ffi-rs-android-arm64": ["@yuuang/ffi-rs-android-arm64@1.3.1", "", { "os": "android", "cpu": "arm64" }, "sha512-V4nmlXdOYZEa7GOxSExVG95SLp8FE0iTq2yKeN54UlfNMr3Sik+1Ff57LcCv7qYcn4TBqnBAt5rT3FAM6T6caQ=="],
|
||||
|
||||
"@yuuang/ffi-rs-darwin-arm64": ["@yuuang/ffi-rs-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YlnTMIyzfW3mAULC5ZA774nzQfFlYXM0rrfq/8ZzWt+IMbYk55a++jrI+6JeKV+1EqlDS3TFBEFtjdBNG94KzQ=="],
|
||||
|
||||
"@yuuang/ffi-rs-darwin-x64": ["@yuuang/ffi-rs-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-sI3LpQQ34SX4nyOHc5yxA7FSqs9qPEUMqW/y/wWo9cuyPpaHMFsi/BeOVYsnC0syp3FrY7gzn6RnD6PlXCktXg=="],
|
||||
|
||||
"@yuuang/ffi-rs-linux-arm-gnueabihf": ["@yuuang/ffi-rs-linux-arm-gnueabihf@1.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-1WkcGkJTlwh4ZA59htKI+RXhiL3oKiYwLv7PO8LUf6FuADK73s5GcXp67iakKu243uYu+qGYr4RHco4ySddYhQ=="],
|
||||
|
||||
"@yuuang/ffi-rs-linux-arm64-gnu": ["@yuuang/ffi-rs-linux-arm64-gnu@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-J2PwqviycZxaEVA0Bwv38LqGDGSB9A1DPN4iYginYJZSvTvKW8kh7Tis0HbZrX1YDKnY8hi3lt0N0tCTNPDH5Q=="],
|
||||
|
||||
"@yuuang/ffi-rs-linux-arm64-musl": ["@yuuang/ffi-rs-linux-arm64-musl@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Hn1W1hBPssTaqikU1Bqp1XUdDdOgbnYVIOtR++LVx66hhrtjf/xrIUQOhTm+NmOFDG16JUKXe1skfM4gpaqYwg=="],
|
||||
|
||||
"@yuuang/ffi-rs-linux-x64-gnu": ["@yuuang/ffi-rs-linux-x64-gnu@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-kW6e+oCYZPvpH2ppPsffA18e1aLowtmWTRjVlyHtY04g/nQDepQvDUkkcvInh9fW5jLna7PjHvktW1tVgYIj2A=="],
|
||||
|
||||
"@yuuang/ffi-rs-linux-x64-musl": ["@yuuang/ffi-rs-linux-x64-musl@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HTwblAzruUS16nQPrez3ozvEHm1Xxh8J8w7rZYrpmAcNl1hzyOT8z/hY70M9Rt9fOqQ4Ovgor9qVy/U3ZJo0ZA=="],
|
||||
|
||||
"@yuuang/ffi-rs-win32-arm64-msvc": ["@yuuang/ffi-rs-win32-arm64-msvc@1.3.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-WeZkGl2BP1U4tRhEQH+FXLQS52N8obp74smK5AAGOfzPAT1pHkq6+dVkC1QCSIt7dHJs7SPtlnQw+5DkdZYlWA=="],
|
||||
|
||||
"@yuuang/ffi-rs-win32-ia32-msvc": ["@yuuang/ffi-rs-win32-ia32-msvc@1.3.1", "", { "os": "win32", "cpu": [ "x64", "ia32", ] }, "sha512-rNGgMeCH5mdeHiMiJgt7wWXovZ+FHEfXhU9p4zZBH4n8M1/QnEsRUwlapISPLpILSGpoYS6iBuq9/fUlZY8Mhg=="],
|
||||
|
||||
"@yuuang/ffi-rs-win32-x64-msvc": ["@yuuang/ffi-rs-win32-x64-msvc@1.3.1", "", { "os": "win32", "cpu": "x64" }, "sha512-dr2LcLD2CXo2a7BktlOpV68QhayqiI112KxIJC9tBgQO/Dkdg4CPsdqmvzzLhFo64iC5RLl2BT7M5lJImrfUWw=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
||||
|
||||
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
|
||||
|
||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
|
||||
|
||||
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="],
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="],
|
||||
|
||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||
|
||||
"ffi-rs": ["ffi-rs@1.3.1", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.1", "@yuuang/ffi-rs-darwin-arm64": "1.3.1", "@yuuang/ffi-rs-darwin-x64": "1.3.1", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.1", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.1", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.1", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.1", "@yuuang/ffi-rs-linux-x64-musl": "1.3.1", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.1", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.1", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.1" } }, "sha512-ZyNXL9fnclnZV+waQmWB9JrfbIEyxQa1OWtMrHOrAgcC04PgP5hBMG5TdhVN8N4uT/eul8zCFMVnJUukAFFlXA=="],
|
||||
|
||||
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
|
||||
|
||||
"gaxios": ["gaxios@7.1.5", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg=="],
|
||||
|
||||
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
|
||||
|
||||
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
|
||||
|
||||
"glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
|
||||
|
||||
"google-auth-library": ["google-auth-library@10.7.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ=="],
|
||||
|
||||
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="],
|
||||
|
||||
"hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||
|
||||
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
|
||||
|
||||
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||
|
||||
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
|
||||
|
||||
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
||||
"lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
|
||||
|
||||
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||
|
||||
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||
|
||||
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
|
||||
"openai": ["openai@6.26.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA=="],
|
||||
|
||||
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
|
||||
|
||||
"partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="],
|
||||
|
||||
"path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
|
||||
|
||||
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
|
||||
|
||||
"protobufjs": ["protobufjs@7.6.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="],
|
||||
|
||||
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="],
|
||||
|
||||
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typebox": ["typebox@1.1.38", "", {}, "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
|
||||
"xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="],
|
||||
|
||||
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
|
||||
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-http/@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.7", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A=="],
|
||||
|
||||
"@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1063.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/nested-clients": "^3.997.17", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-nYDaWWdzjKiDP5xj8k4oUgcYd4WPgzfAOgdU5vJsaqH/07Dfvm7ffisHCFJ+NEl7kUC9JEIUxh0kznvenbo3NQ=="],
|
||||
|
||||
"@aws-sdk/nested-clients/@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.7", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A=="],
|
||||
|
||||
"bun-types/@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
|
||||
|
||||
"p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
|
||||
|
||||
"protobufjs/@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
|
||||
|
||||
"bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"protobufjs/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,24 @@
|
||||
[package]
|
||||
name = "fff-c"
|
||||
version = "0.9.6"
|
||||
version = "0.10.5"
|
||||
edition = "2024"
|
||||
description = "Raw C api of FFF file finder"
|
||||
license = "MIT"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
zlob = ["fff/zlob"]
|
||||
default = ["ripgrep"] # use ripgrep base crates to avoid requiring zig for rust crate
|
||||
ripgrep = ["fff/ripgrep", "fff-query-parser/ripgrep"]
|
||||
zlob = ["fff/zlob", "fff-query-parser/zlob"]
|
||||
|
||||
[dependencies]
|
||||
git2.workspace = true
|
||||
|
||||
fff = { package = "fff-search", path = "../fff-core" , version = "0.9.6" }
|
||||
fff-query-parser = { path = "../fff-query-parser" , version = "0.9.6" }
|
||||
fff = { package = "fff-search", path = "../fff-core", version = "0.10.5", default-features = false }
|
||||
fff-query-parser = { path = "../fff-query-parser", version = "0.10.5", default-features = false }
|
||||
serde_json = "1.0"
|
||||
|
||||
+309
-301
File diff suppressed because it is too large
Load Diff
+141
-127
@@ -1,42 +1,70 @@
|
||||
//! Stable accessor functions for `fff-c` FFI struct fields.
|
||||
//!
|
||||
//! # Why this exists
|
||||
//!
|
||||
//! `fff-c` exposes its result types as plain `#[repr(C)]` structs. External
|
||||
//! consumers (Emacs Lisp via `emacs-ffi`, Python `ctypes`, etc.) that access
|
||||
//! fields by hardcoding byte offsets break silently whenever the struct layout
|
||||
//! changes — a new field shifts every subsequent offset with no compile-time
|
||||
//! warning.
|
||||
//!
|
||||
//! These functions turn field access into a **stable named API**: callers bind
|
||||
//! to a symbol name once and are fully insulated from layout changes.
|
||||
//!
|
||||
//! # Usage from Emacs Lisp (example)
|
||||
//!
|
||||
//! ```elisp
|
||||
//! (define-ffi-function fff--grep-match-line-content
|
||||
//! "fff_grep_match_get_line_content" :pointer [:pointer] fff--library)
|
||||
//!
|
||||
//! (ffi-get-c-string (fff--grep-match-line-content match-ptr))
|
||||
//! ```
|
||||
//!
|
||||
//! # Array iteration
|
||||
//!
|
||||
//! To walk result arrays use `fff_search_result_get_item`,
|
||||
//! `fff_grep_result_get_match`, and `fff_search_result_get_score` — these are
|
||||
//! defined in the main `lib.rs` FFI surface alongside the search functions.
|
||||
//! Stable accessor functions for `fff-c` FFI struct fields: a named API so
|
||||
//! FFI callers (Emacs Lisp, Python `ctypes`, etc.) don't hardcode struct byte
|
||||
//! offsets that break silently on layout changes. For array iteration use
|
||||
//! `fff_search_result_get_item` / `fff_grep_result_get_match` in `lib.rs`.
|
||||
|
||||
use std::ffi::c_char;
|
||||
use std::ptr;
|
||||
|
||||
use crate::ffi_types::{FffFileItem, FffGrepMatch, FffGrepResult, FffMatchRange, FffSearchResult};
|
||||
use crate::ffi_types::{
|
||||
FffFileItem, FffGrepMatch, FffGrepResult, FffMatchRange, FffResult, FffSearchResult,
|
||||
};
|
||||
|
||||
// ── FffResult ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns whether the operation completed successfully. Returns `false` if `result` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `result` must be a valid `FffResult` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_result_get_success(result: *const FffResult) -> bool {
|
||||
if result.is_null() {
|
||||
return false;
|
||||
}
|
||||
unsafe { (*result).success }
|
||||
}
|
||||
|
||||
/// Returns the operation error message, or null when there is no error or `result` is null.
|
||||
///
|
||||
/// Do not free the returned pointer. It remains valid until `fff_free_result` is called.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `result` must be a valid `FffResult` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_result_get_error(result: *const FffResult) -> *const c_char {
|
||||
if result.is_null() {
|
||||
return ptr::null();
|
||||
}
|
||||
unsafe { (*result).error }
|
||||
}
|
||||
|
||||
/// Returns the result payload handle, or null if `result` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `result` must be a valid `FffResult` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_result_get_handle(result: *const FffResult) -> *mut std::ffi::c_void {
|
||||
if result.is_null() {
|
||||
return ptr::null_mut();
|
||||
}
|
||||
unsafe { (*result).handle }
|
||||
}
|
||||
|
||||
/// Returns the result integer payload. Returns `0` if `result` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `result` must be a valid `FffResult` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_result_get_int_value(result: *const FffResult) -> i64 {
|
||||
if result.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*result).int_value }
|
||||
}
|
||||
|
||||
// ── FffFileItem ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns the relative path of a file item (e.g. `"src/main.rs"`).
|
||||
///
|
||||
/// Returns null if `item` is null. The returned pointer is valid for the
|
||||
/// lifetime of the owning `FffSearchResult`; do not free it directly.
|
||||
/// Relative path of a file item (e.g. `"src/main.rs"`); null if `item` is null. Do not free.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `item` must be a valid `FffFileItem` pointer or null.
|
||||
@@ -50,9 +78,7 @@ pub unsafe extern "C" fn fff_file_item_get_relative_path(
|
||||
unsafe { (*item).relative_path }
|
||||
}
|
||||
|
||||
/// Returns the file-name component of a file item (e.g. `"main.rs"`).
|
||||
///
|
||||
/// Returns null if `item` is null. Do not free the returned pointer.
|
||||
/// File-name component of a file item (e.g. `"main.rs"`); null if `item` is null. Do not free.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `item` must be a valid `FffFileItem` pointer or null.
|
||||
@@ -64,10 +90,8 @@ pub unsafe extern "C" fn fff_file_item_get_file_name(item: *const FffFileItem) -
|
||||
unsafe { (*item).file_name }
|
||||
}
|
||||
|
||||
/// Returns the git status string for a file item (e.g. `"M "`, `"??"`)
|
||||
/// or null if git is unavailable, the file is untracked, or `item` is null.
|
||||
///
|
||||
/// Do not free the returned pointer.
|
||||
/// Git status string of a file item (e.g. `"M "`, `"??"`); null if git is unavailable,
|
||||
/// the file is untracked, or `item` is null. Do not free.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `item` must be a valid `FffFileItem` pointer or null.
|
||||
@@ -79,7 +103,7 @@ pub unsafe extern "C" fn fff_file_item_get_git_status(item: *const FffFileItem)
|
||||
unsafe { (*item).git_status }
|
||||
}
|
||||
|
||||
/// Returns the file size in bytes. Returns `0` if `item` is null.
|
||||
/// File size in bytes; `0` if `item` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `item` must be a valid `FffFileItem` pointer or null.
|
||||
@@ -91,8 +115,7 @@ pub unsafe extern "C" fn fff_file_item_get_size(item: *const FffFileItem) -> u64
|
||||
unsafe { (*item).size }
|
||||
}
|
||||
|
||||
/// Returns the last-modified time as seconds since the UNIX epoch.
|
||||
/// Returns `0` if `item` is null.
|
||||
/// Last-modified time as seconds since the UNIX epoch; `0` if `item` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `item` must be a valid `FffFileItem` pointer or null.
|
||||
@@ -104,7 +127,7 @@ pub unsafe extern "C" fn fff_file_item_get_modified(item: *const FffFileItem) ->
|
||||
unsafe { (*item).modified }
|
||||
}
|
||||
|
||||
/// Returns the combined frecency score. Returns `0` if `item` is null.
|
||||
/// Combined frecency score; `0` if `item` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `item` must be a valid `FffFileItem` pointer or null.
|
||||
@@ -116,7 +139,7 @@ pub unsafe extern "C" fn fff_file_item_get_total_frecency_score(item: *const Fff
|
||||
unsafe { (*item).total_frecency_score }
|
||||
}
|
||||
|
||||
/// Returns the access-based frecency score. Returns `0` if `item` is null.
|
||||
/// Access-based frecency score; `0` if `item` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `item` must be a valid `FffFileItem` pointer or null.
|
||||
@@ -128,7 +151,7 @@ pub unsafe extern "C" fn fff_file_item_get_access_frecency_score(item: *const Ff
|
||||
unsafe { (*item).access_frecency_score }
|
||||
}
|
||||
|
||||
/// Returns the modification-based frecency score. Returns `0` if `item` is null.
|
||||
/// Modification-based frecency score; `0` if `item` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `item` must be a valid `FffFileItem` pointer or null.
|
||||
@@ -142,7 +165,7 @@ pub unsafe extern "C" fn fff_file_item_get_modification_frecency_score(
|
||||
unsafe { (*item).modification_frecency_score }
|
||||
}
|
||||
|
||||
/// Returns `true` if the file was detected as binary. Returns `false` if `item` is null.
|
||||
/// `true` if the file was detected as binary; `false` if `item` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `item` must be a valid `FffFileItem` pointer or null.
|
||||
@@ -156,9 +179,7 @@ pub unsafe extern "C" fn fff_file_item_get_is_binary(item: *const FffFileItem) -
|
||||
|
||||
// ── FffGrepMatch ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns the relative path of the file containing this grep match.
|
||||
///
|
||||
/// Returns null if `m` is null. Do not free the returned pointer.
|
||||
/// Relative path of the file containing this grep match; null if `m` is null. Do not free.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
@@ -170,9 +191,7 @@ pub unsafe extern "C" fn fff_grep_match_get_relative_path(m: *const FffGrepMatch
|
||||
unsafe { (*m).relative_path }
|
||||
}
|
||||
|
||||
/// Returns the file-name component of the file containing this grep match.
|
||||
///
|
||||
/// Returns null if `m` is null. Do not free the returned pointer.
|
||||
/// File-name component of the file containing this grep match; null if `m` is null. Do not free.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
@@ -184,10 +203,8 @@ pub unsafe extern "C" fn fff_grep_match_get_file_name(m: *const FffGrepMatch) ->
|
||||
unsafe { (*m).file_name }
|
||||
}
|
||||
|
||||
/// Returns the git status string for the matched file (e.g. `"M "`, `"??"`)
|
||||
/// or null if git is unavailable, the file is untracked, or `m` is null.
|
||||
///
|
||||
/// Do not free the returned pointer.
|
||||
/// Git status string of the matched file (e.g. `"M "`, `"??"`); null if git is unavailable,
|
||||
/// the file is untracked, or `m` is null. Do not free.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
@@ -199,9 +216,7 @@ pub unsafe extern "C" fn fff_grep_match_get_git_status(m: *const FffGrepMatch) -
|
||||
unsafe { (*m).git_status }
|
||||
}
|
||||
|
||||
/// Returns the full text content of the matched line.
|
||||
///
|
||||
/// Returns null if `m` is null. Do not free the returned pointer.
|
||||
/// Full text content of the matched line; null if `m` is null. Do not free.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
@@ -213,8 +228,7 @@ pub unsafe extern "C" fn fff_grep_match_get_line_content(m: *const FffGrepMatch)
|
||||
unsafe { (*m).line_content }
|
||||
}
|
||||
|
||||
/// Returns the 1-based line number of the match within its file.
|
||||
/// Returns `0` if `m` is null.
|
||||
/// 1-based line number of the match within its file; `0` if `m` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
@@ -226,8 +240,7 @@ pub unsafe extern "C" fn fff_grep_match_get_line_number(m: *const FffGrepMatch)
|
||||
unsafe { (*m).line_number }
|
||||
}
|
||||
|
||||
/// Returns the 0-based column of the match start within its line.
|
||||
/// Returns `0` if `m` is null.
|
||||
/// 0-based column of the match start within its line; `0` if `m` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
@@ -239,8 +252,7 @@ pub unsafe extern "C" fn fff_grep_match_get_col(m: *const FffGrepMatch) -> u32 {
|
||||
unsafe { (*m).col }
|
||||
}
|
||||
|
||||
/// Returns the byte offset of the match start from the beginning of the file.
|
||||
/// Returns `0` if `m` is null.
|
||||
/// Byte offset of the match start from the beginning of the file; `0` if `m` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
@@ -252,7 +264,7 @@ pub unsafe extern "C" fn fff_grep_match_get_byte_offset(m: *const FffGrepMatch)
|
||||
unsafe { (*m).byte_offset }
|
||||
}
|
||||
|
||||
/// Returns the file size in bytes for the matched file. Returns `0` if `m` is null.
|
||||
/// File size in bytes of the matched file; `0` if `m` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
@@ -264,8 +276,7 @@ pub unsafe extern "C" fn fff_grep_match_get_size(m: *const FffGrepMatch) -> u64
|
||||
unsafe { (*m).size }
|
||||
}
|
||||
|
||||
/// Returns the combined frecency score for the matched file.
|
||||
/// Returns `0` if `m` is null.
|
||||
/// Combined frecency score of the matched file; `0` if `m` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
@@ -277,8 +288,7 @@ pub unsafe extern "C" fn fff_grep_match_get_total_frecency_score(m: *const FffGr
|
||||
unsafe { (*m).total_frecency_score }
|
||||
}
|
||||
|
||||
/// Returns the access-based frecency score for the matched file.
|
||||
/// Returns `0` if `m` is null.
|
||||
/// Access-based frecency score of the matched file; `0` if `m` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
@@ -290,8 +300,7 @@ pub unsafe extern "C" fn fff_grep_match_get_access_frecency_score(m: *const FffG
|
||||
unsafe { (*m).access_frecency_score }
|
||||
}
|
||||
|
||||
/// Returns the modification-based frecency score for the matched file.
|
||||
/// Returns `0` if `m` is null.
|
||||
/// Modification-based frecency score of the matched file; `0` if `m` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
@@ -305,8 +314,7 @@ pub unsafe extern "C" fn fff_grep_match_get_modification_frecency_score(
|
||||
unsafe { (*m).modification_frecency_score }
|
||||
}
|
||||
|
||||
/// Returns the last-modified time as seconds since the UNIX epoch for the matched file.
|
||||
/// Returns `0` if `m` is null.
|
||||
/// Last-modified time of the matched file as seconds since the UNIX epoch; `0` if `m` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
@@ -318,8 +326,7 @@ pub unsafe extern "C" fn fff_grep_match_get_modified(m: *const FffGrepMatch) ->
|
||||
unsafe { (*m).modified }
|
||||
}
|
||||
|
||||
/// Returns the number of highlight ranges in this match. Returns `0` if `m` is null.
|
||||
///
|
||||
/// Number of highlight ranges in this match; `0` if `m` is null.
|
||||
/// Use with [`fff_grep_match_get_match_range`] to iterate the highlight spans.
|
||||
///
|
||||
/// ## Safety
|
||||
@@ -332,11 +339,8 @@ pub unsafe extern "C" fn fff_grep_match_get_match_ranges_count(m: *const FffGrep
|
||||
unsafe { (*m).match_ranges_count }
|
||||
}
|
||||
|
||||
/// Returns a pointer to the `index`-th [`FffMatchRange`] highlight span.
|
||||
///
|
||||
/// Returns null if `m` is null, `index >= match_ranges_count`, or the
|
||||
/// ranges array is null. The returned pointer is valid until the owning
|
||||
/// `FffGrepResult` is freed; do not free it directly.
|
||||
/// Pointer to the `index`-th [`FffMatchRange`] highlight span; null if `m` is null,
|
||||
/// `index >= match_ranges_count`, or the ranges array is null. Valid until the owning `FffGrepResult` is freed; do not free.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
@@ -355,9 +359,7 @@ pub unsafe extern "C" fn fff_grep_match_get_match_range(
|
||||
unsafe { m.match_ranges.add(index as usize) }
|
||||
}
|
||||
|
||||
/// Returns the number of context lines captured before the match.
|
||||
/// Returns `0` if `m` is null.
|
||||
///
|
||||
/// Number of context lines captured before the match; `0` if `m` is null.
|
||||
/// Use with [`fff_grep_match_get_context_before`] to read each line.
|
||||
///
|
||||
/// ## Safety
|
||||
@@ -370,10 +372,8 @@ pub unsafe extern "C" fn fff_grep_match_get_context_before_count(m: *const FffGr
|
||||
unsafe { (*m).context_before_count }
|
||||
}
|
||||
|
||||
/// Returns the `index`-th context line before the match.
|
||||
///
|
||||
/// Returns null if `m` is null, `index >= context_before_count`, or the
|
||||
/// context array is null. Do not free the returned pointer.
|
||||
/// The `index`-th context line before the match; null if `m` is null,
|
||||
/// `index >= context_before_count`, or the context array is null. Do not free.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
@@ -392,9 +392,7 @@ pub unsafe extern "C" fn fff_grep_match_get_context_before(
|
||||
unsafe { *m.context_before.add(index as usize) }
|
||||
}
|
||||
|
||||
/// Returns the number of context lines captured after the match.
|
||||
/// Returns `0` if `m` is null.
|
||||
///
|
||||
/// Number of context lines captured after the match; `0` if `m` is null.
|
||||
/// Use with [`fff_grep_match_get_context_after`] to read each line.
|
||||
///
|
||||
/// ## Safety
|
||||
@@ -407,10 +405,8 @@ pub unsafe extern "C" fn fff_grep_match_get_context_after_count(m: *const FffGre
|
||||
unsafe { (*m).context_after_count }
|
||||
}
|
||||
|
||||
/// Returns the `index`-th context line after the match.
|
||||
///
|
||||
/// Returns null if `m` is null, `index >= context_after_count`, or the
|
||||
/// context array is null. Do not free the returned pointer.
|
||||
/// The `index`-th context line after the match; null if `m` is null,
|
||||
/// `index >= context_after_count`, or the context array is null. Do not free.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
@@ -429,11 +425,8 @@ pub unsafe extern "C" fn fff_grep_match_get_context_after(
|
||||
unsafe { *m.context_after.add(index as usize) }
|
||||
}
|
||||
|
||||
/// Returns the fuzzy match score. Returns `0` if `m` is null or no fuzzy
|
||||
/// score is present.
|
||||
///
|
||||
/// Always check [`fff_grep_match_get_has_fuzzy_score`] first; `0` is
|
||||
/// ambiguous without that flag.
|
||||
/// Fuzzy match score; `0` if `m` is null or no fuzzy score is present.
|
||||
/// Always check [`fff_grep_match_get_has_fuzzy_score`] first; `0` is ambiguous without that flag.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
@@ -445,8 +438,7 @@ pub unsafe extern "C" fn fff_grep_match_get_fuzzy_score(m: *const FffGrepMatch)
|
||||
unsafe { (*m).fuzzy_score }
|
||||
}
|
||||
|
||||
/// Returns `true` if this match carries a valid fuzzy score.
|
||||
/// Returns `false` if `m` is null.
|
||||
/// `true` if this match carries a valid fuzzy score; `false` if `m` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
@@ -458,8 +450,7 @@ pub unsafe extern "C" fn fff_grep_match_get_has_fuzzy_score(m: *const FffGrepMat
|
||||
unsafe { (*m).has_fuzzy_score }
|
||||
}
|
||||
|
||||
/// Returns `true` if the match was identified as a symbol definition.
|
||||
/// Returns `false` if `m` is null.
|
||||
/// `true` if the match was identified as a symbol definition; `false` if `m` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
@@ -471,8 +462,7 @@ pub unsafe extern "C" fn fff_grep_match_get_is_definition(m: *const FffGrepMatch
|
||||
unsafe { (*m).is_definition }
|
||||
}
|
||||
|
||||
/// Returns `true` if the matched file was detected as binary.
|
||||
/// Returns `false` if `m` is null.
|
||||
/// `true` if the matched file was detected as binary; `false` if `m` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
@@ -486,7 +476,7 @@ pub unsafe extern "C" fn fff_grep_match_get_is_binary(m: *const FffGrepMatch) ->
|
||||
|
||||
// ── FffSearchResult ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns the number of items in the result. Returns `0` if `r` is null.
|
||||
/// Number of items in the result; `0` if `r` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `r` must be a valid `FffSearchResult` pointer or null.
|
||||
@@ -498,8 +488,7 @@ pub unsafe extern "C" fn fff_search_result_get_count(r: *const FffSearchResult)
|
||||
unsafe { (*r).count }
|
||||
}
|
||||
|
||||
/// Returns the total number of files that matched before the result was
|
||||
/// truncated to the page size. Returns `0` if `r` is null.
|
||||
/// Total number of files that matched before truncation to the page size; `0` if `r` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `r` must be a valid `FffSearchResult` pointer or null.
|
||||
@@ -511,8 +500,7 @@ pub unsafe extern "C" fn fff_search_result_get_total_matched(r: *const FffSearch
|
||||
unsafe { (*r).total_matched }
|
||||
}
|
||||
|
||||
/// Returns the total number of indexed files considered during search.
|
||||
/// Returns `0` if `r` is null.
|
||||
/// Total number of indexed files considered during search; `0` if `r` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `r` must be a valid `FffSearchResult` pointer or null.
|
||||
@@ -526,7 +514,7 @@ pub unsafe extern "C" fn fff_search_result_get_total_files(r: *const FffSearchRe
|
||||
|
||||
// ── FffGrepResult ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns the number of matches in the result. Returns `0` if `r` is null.
|
||||
/// Number of matches in the result; `0` if `r` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `r` must be a valid `FffGrepResult` pointer or null.
|
||||
@@ -538,8 +526,7 @@ pub unsafe extern "C" fn fff_grep_result_get_count(r: *const FffGrepResult) -> u
|
||||
unsafe { (*r).count }
|
||||
}
|
||||
|
||||
/// Returns the total number of matches found across all pages.
|
||||
/// Returns `0` if `r` is null.
|
||||
/// Total number of matches found across all pages; `0` if `r` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `r` must be a valid `FffGrepResult` pointer or null.
|
||||
@@ -551,8 +538,7 @@ pub unsafe extern "C" fn fff_grep_result_get_total_matched(r: *const FffGrepResu
|
||||
unsafe { (*r).total_matched }
|
||||
}
|
||||
|
||||
/// Returns the number of files actually opened and searched in this call.
|
||||
/// Returns `0` if `r` is null.
|
||||
/// Number of files actually opened and searched in this call; `0` if `r` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `r` must be a valid `FffGrepResult` pointer or null.
|
||||
@@ -564,8 +550,7 @@ pub unsafe extern "C" fn fff_grep_result_get_total_files_searched(r: *const FffG
|
||||
unsafe { (*r).total_files_searched }
|
||||
}
|
||||
|
||||
/// Returns the total number of indexed files before any filtering.
|
||||
/// Returns `0` if `r` is null.
|
||||
/// Total number of indexed files before any filtering; `0` if `r` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `r` must be a valid `FffGrepResult` pointer or null.
|
||||
@@ -577,8 +562,7 @@ pub unsafe extern "C" fn fff_grep_result_get_total_files(r: *const FffGrepResult
|
||||
unsafe { (*r).total_files }
|
||||
}
|
||||
|
||||
/// Returns the number of files eligible for search after path/type filtering.
|
||||
/// Returns `0` if `r` is null.
|
||||
/// Number of files eligible for search after path/type filtering; `0` if `r` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `r` must be a valid `FffGrepResult` pointer or null.
|
||||
@@ -590,9 +574,8 @@ pub unsafe extern "C" fn fff_grep_result_get_filtered_file_count(r: *const FffGr
|
||||
unsafe { (*r).filtered_file_count }
|
||||
}
|
||||
|
||||
/// Returns the file offset for the next page, or `0` if all files have been
|
||||
/// searched or `r` is null. Pass this value as `file_offset` to a subsequent
|
||||
/// `fff_live_grep` or `fff_multi_grep` call to continue pagination.
|
||||
/// File offset for the next page; `0` if all files have been searched or `r` is null.
|
||||
/// Pass as `file_offset` to a subsequent `fff_live_grep`/`fff_multi_grep` call to continue pagination.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `r` must be a valid `FffGrepResult` pointer or null.
|
||||
@@ -604,10 +587,8 @@ pub unsafe extern "C" fn fff_grep_result_get_next_file_offset(r: *const FffGrepR
|
||||
unsafe { (*r).next_file_offset }
|
||||
}
|
||||
|
||||
/// Returns the regex compilation error string if the engine fell back to
|
||||
/// literal matching, or null if there was no error or `r` is null.
|
||||
///
|
||||
/// Do not free the returned pointer.
|
||||
/// Regex compilation error string if the engine fell back to literal matching;
|
||||
/// null if there was no error or `r` is null. Do not free.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `r` must be a valid `FffGrepResult` pointer or null.
|
||||
@@ -801,8 +782,41 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_result_returns_zero_or_null() {
|
||||
let null: *const FffResult = ptr::null();
|
||||
unsafe {
|
||||
assert!(!fff_result_get_success(null));
|
||||
assert!(fff_result_get_error(null).is_null());
|
||||
assert!(fff_result_get_handle(null).is_null());
|
||||
assert_eq!(fff_result_get_int_value(null), 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── data correctness tests ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn result_getters_return_correct_values() {
|
||||
let error = CString::new("failed").unwrap();
|
||||
let handle = 0x1234usize as *mut std::ffi::c_void;
|
||||
let result = FffResult {
|
||||
success: false,
|
||||
error: error.as_ptr() as *mut std::ffi::c_char,
|
||||
handle,
|
||||
int_value: -7,
|
||||
};
|
||||
let p = &result as *const FffResult;
|
||||
unsafe {
|
||||
assert!(!fff_result_get_success(p));
|
||||
assert_eq!(
|
||||
std::ffi::CStr::from_ptr(fff_result_get_error(p)),
|
||||
error.as_c_str()
|
||||
);
|
||||
assert_eq!(fff_result_get_handle(p), handle);
|
||||
assert_eq!(fff_result_get_int_value(p), -7);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_item_getters_return_correct_values() {
|
||||
let mut item = make_file_item("src/main.rs", "main.rs");
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
//! FFI-compatible type definitions
|
||||
//!
|
||||
//! All result types use `#[repr(C)]` structs for direct memory access from any
|
||||
//! language with C FFI support. No JSON serialization is used for search or grep
|
||||
//! results — callers read struct fields directly.
|
||||
//! FFI-compatible type definitions: all result types are `#[repr(C)]` structs
|
||||
//! read directly from any language with C FFI — no JSON serialization.
|
||||
|
||||
use std::ffi::{CString, c_char, c_void};
|
||||
use std::ptr;
|
||||
@@ -15,16 +12,16 @@ use fff::{
|
||||
};
|
||||
|
||||
/// Current used version of [`FffCreateOptions`].
|
||||
pub const FFF_CREATE_OPTIONS_VERSION: u32 = 1;
|
||||
pub const FFF_CREATE_OPTIONS_VERSION: u32 = 2;
|
||||
|
||||
/// Options for `fff_create_instance_with`.
|
||||
///
|
||||
/// Versioned struct: you populate the struct at your call level, we guarantee that
|
||||
/// the version is stable across the version changes, new fields only appended!
|
||||
/// Versioned struct: the layout is stable across releases, new fields are
|
||||
/// only appended.
|
||||
#[repr(C)]
|
||||
pub struct FffCreateOptions {
|
||||
/// Set to [`FFF_CREATE_OPTIONS_VERSION`] when allocating. Used by the
|
||||
/// library to determine which trailing fields are populated.
|
||||
/// Set to [`FFF_CREATE_OPTIONS_VERSION`] when allocating; tells the
|
||||
/// library which trailing fields are populated.
|
||||
pub version: u32,
|
||||
/// Directory to index (required, non-NULL).
|
||||
pub base_path: *const c_char,
|
||||
@@ -51,13 +48,16 @@ pub struct FffCreateOptions {
|
||||
pub cache_budget_max_bytes: u64,
|
||||
/// Per-file byte cap inside the content cache. 0 = auto.
|
||||
pub cache_budget_max_file_size: u64,
|
||||
/// Allow indexing the filesystem root (`/`). Off by default — root is
|
||||
/// rarely the intended target and floods the watcher with churn.
|
||||
/// Allow indexing the filesystem root (`/`). Off by default: root is rarely
|
||||
/// intended and floods the watcher with churn.
|
||||
pub enable_fs_root_scanning: bool,
|
||||
/// Allow indexing the user's home directory. Same trade-off as
|
||||
/// `enable_fs_root_scanning`.
|
||||
/// Allow indexing the user's home directory. Same trade-off as `enable_fs_root_scanning`.
|
||||
pub enable_home_dir_scanning: bool,
|
||||
// ----- new version 2+ fields go here, ALWAYS appended -----
|
||||
// ----- v2 fields -----
|
||||
/// Follow symlinks during scan and watcher walks. Off by default: without
|
||||
/// external loop protection cyclic symlinks can wedge the watcher.
|
||||
pub follow_symlinks: bool,
|
||||
// ----- new version 3+ fields go here, ALWAYS appended -----
|
||||
}
|
||||
|
||||
impl FffCreateOptions {
|
||||
@@ -79,6 +79,7 @@ impl FffCreateOptions {
|
||||
cache_budget_max_file_size: 0,
|
||||
enable_fs_root_scanning: false,
|
||||
enable_home_dir_scanning: false,
|
||||
follow_symlinks: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,10 +128,8 @@ unsafe fn free_cstring_array(arr: *mut *mut c_char, count: u32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A file item returned by `fff_search`.
|
||||
///
|
||||
/// All string fields are heap-allocated and owned by the parent `FffSearchResult`.
|
||||
/// Free the entire result with `fff_free_search_result`.
|
||||
/// A file item returned by `fff_search`. Strings are owned by the parent
|
||||
/// `FffSearchResult`; free everything with `fff_free_search_result`.
|
||||
#[repr(C)]
|
||||
pub struct FffFileItem {
|
||||
pub relative_path: *mut c_char,
|
||||
@@ -224,13 +223,9 @@ impl FffScore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Location parsed from a query string (e.g. `"file.ts:42:10"`).
|
||||
///
|
||||
/// `tag` encodes the variant:
|
||||
/// 0 = no location,
|
||||
/// 1 = line only (`line` is set),
|
||||
/// 2 = position (`line` + `col`),
|
||||
/// 3 = range (`line`/`col` = start, `end_line`/`end_col` = end).
|
||||
/// Location parsed from a query string (e.g. `"file.ts:42:10"`). `tag`:
|
||||
/// 0 = none, 1 = line, 2 = position (`line` + `col`),
|
||||
/// 3 = range (`line`/`col` = start, `end_line`/`end_col` = end).
|
||||
#[repr(C)]
|
||||
pub struct FffLocation {
|
||||
pub tag: u8,
|
||||
@@ -275,14 +270,12 @@ impl From<Option<&Location>> for FffLocation {
|
||||
}
|
||||
}
|
||||
|
||||
/// Search result returned by `fff_search`.
|
||||
///
|
||||
/// The caller must free this with `fff_free_search_result`.
|
||||
/// Search result returned by `fff_search`; free with `fff_free_search_result`.
|
||||
#[repr(C)]
|
||||
pub struct FffSearchResult {
|
||||
/// Pointer to a heap-allocated array of `FffFileItem` (length = `count`).
|
||||
/// Heap array of `FffFileItem` (length = `count`).
|
||||
pub items: *mut FffFileItem,
|
||||
/// Pointer to a heap-allocated array of `FffScore` (length = `count`).
|
||||
/// Heap array of `FffScore` (length = `count`).
|
||||
pub scores: *mut FffScore,
|
||||
/// Number of items/scores in the arrays.
|
||||
pub count: u32,
|
||||
@@ -330,10 +323,8 @@ pub struct FffMatchRange {
|
||||
pub end: u32,
|
||||
}
|
||||
|
||||
/// A single grep match with file and line information.
|
||||
///
|
||||
/// All string fields and arrays are heap-allocated. Free the parent
|
||||
/// `FffGrepResult` with `fff_free_grep_result` to release everything.
|
||||
/// A single grep match with file and line information. Strings and arrays are
|
||||
/// owned by the parent `FffGrepResult`; free everything with `fff_free_grep_result`.
|
||||
#[repr(C)]
|
||||
pub struct FffGrepMatch {
|
||||
// -- pointers (8 bytes each) --
|
||||
@@ -435,12 +426,11 @@ impl FffGrepMatch {
|
||||
}
|
||||
}
|
||||
|
||||
/// Grep result returned by `fff_live_grep` and `fff_multi_grep`.
|
||||
///
|
||||
/// The caller must free this with `fff_free_grep_result`.
|
||||
/// Grep result returned by `fff_live_grep` and `fff_multi_grep`;
|
||||
/// free with `fff_free_grep_result`.
|
||||
#[repr(C)]
|
||||
pub struct FffGrepResult {
|
||||
/// Pointer to a heap-allocated array of `FffGrepMatch` (length = `count`).
|
||||
/// Heap array of `FffGrepMatch` (length = `count`).
|
||||
pub items: *mut FffGrepMatch,
|
||||
/// Number of matches in the `items` array.
|
||||
pub count: u32,
|
||||
@@ -577,10 +567,8 @@ impl FffResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// A directory item returned by `fff_search_directories`.
|
||||
///
|
||||
/// All string fields are heap-allocated and owned by the parent `FffDirSearchResult`.
|
||||
/// Free the entire result with `fff_free_dir_search_result`.
|
||||
/// A directory item returned by `fff_search_directories`. Strings are owned by
|
||||
/// the parent `FffDirSearchResult`; free everything with `fff_free_dir_search_result`.
|
||||
#[repr(C)]
|
||||
pub struct FffDirItem {
|
||||
pub relative_path: *mut c_char,
|
||||
@@ -611,14 +599,13 @@ impl FffDirItem {
|
||||
}
|
||||
}
|
||||
|
||||
/// Directory search result returned by `fff_search_directories`.
|
||||
///
|
||||
/// The caller must free this with `fff_free_dir_search_result`.
|
||||
/// Directory search result returned by `fff_search_directories`;
|
||||
/// free with `fff_free_dir_search_result`.
|
||||
#[repr(C)]
|
||||
pub struct FffDirSearchResult {
|
||||
/// Pointer to a heap-allocated array of `FffDirItem` (length = `count`).
|
||||
/// Heap array of `FffDirItem` (length = `count`).
|
||||
pub items: *mut FffDirItem,
|
||||
/// Pointer to a heap-allocated array of `FffScore` (length = `count`).
|
||||
/// Heap array of `FffScore` (length = `count`).
|
||||
pub scores: *mut FffScore,
|
||||
/// Number of items/scores in the arrays.
|
||||
pub count: u32,
|
||||
@@ -653,9 +640,8 @@ impl FffDirSearchResult {
|
||||
}
|
||||
|
||||
/// A single item in a mixed (files + directories) search result.
|
||||
///
|
||||
/// `item_type`: 0 = file, 1 = directory.
|
||||
/// All string fields are heap-allocated and owned by the parent `FffMixedSearchResult`.
|
||||
/// `item_type`: 0 = file, 1 = directory. Strings are owned by the parent
|
||||
/// `FffMixedSearchResult`.
|
||||
#[repr(C)]
|
||||
pub struct FffMixedItem {
|
||||
/// 0 = file, 1 = directory.
|
||||
@@ -666,8 +652,7 @@ pub struct FffMixedItem {
|
||||
pub git_status: *mut c_char,
|
||||
pub size: u64,
|
||||
pub modified: u64,
|
||||
/// The access frecency score for files, or max access frecency among all the immediate
|
||||
/// children for directories.
|
||||
/// Access frecency for files; max among immediate children for directories.
|
||||
pub access_frecency_score: i64,
|
||||
/// Always 0 for directories
|
||||
pub modification_frecency_score: i64,
|
||||
@@ -724,14 +709,13 @@ impl FffMixedItem {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mixed search result returned by `fff_search_mixed`.
|
||||
///
|
||||
/// The caller must free this with `fff_free_mixed_search_result`.
|
||||
/// Mixed search result returned by `fff_search_mixed`
|
||||
/// free with `fff_free_mixed_search_result`.
|
||||
#[repr(C)]
|
||||
pub struct FffMixedSearchResult {
|
||||
/// Pointer to a heap-allocated array of `FffMixedItem` (length = `count`).
|
||||
/// Heap array of `FffMixedItem` (length = `count`).
|
||||
pub items: *mut FffMixedItem,
|
||||
/// Pointer to a heap-allocated array of `FffScore` (length = `count`).
|
||||
/// Heap array of `FffScore` (length = `count`).
|
||||
pub scores: *mut FffScore,
|
||||
/// Number of items/scores in the arrays.
|
||||
pub count: u32,
|
||||
@@ -820,5 +804,6 @@ mod options_layout_tests {
|
||||
assert_eq!(offset_of!(FffCreateOptions, cache_budget_max_file_size), 72);
|
||||
assert_eq!(offset_of!(FffCreateOptions, enable_fs_root_scanning), 80);
|
||||
assert_eq!(offset_of!(FffCreateOptions, enable_home_dir_scanning), 81);
|
||||
assert_eq!(offset_of!(FffCreateOptions, follow_symlinks), 82);
|
||||
}
|
||||
}
|
||||
|
||||
+84
-200
@@ -1,26 +1,14 @@
|
||||
//! C FFI bindings for fff-core
|
||||
//! C FFI bindings for fff-core, usable from any language with C FFI
|
||||
//! (Bun, Node.js, Python, Ruby, etc.).
|
||||
//!
|
||||
//! This crate provides C-compatible FFI exports that can be used from any language
|
||||
//! with C FFI support (Bun, Node.js, Python, Ruby, etc.).
|
||||
//! All state is owned by an opaque instance handle: create with
|
||||
//! `fff_create_instance*`, pass to every call, free with `fff_destroy`.
|
||||
//! Multiple instances can coexist in one process.
|
||||
//!
|
||||
//! # Instance-based API
|
||||
//!
|
||||
//! All state is owned by an opaque `FffInstance` fff_handle. Callers create an instance
|
||||
//! with `fff_create_instance`, pass the fff_handle to every subsequent call, and free it with
|
||||
//! `fff_destroy`. Multiple independent instances can coexist in the same process.
|
||||
//!
|
||||
//! # Memory management
|
||||
//!
|
||||
//! * Every `fff_*` function that returns `*mut FffResult` requires the caller to
|
||||
//! free the result with `fff_free_result`.
|
||||
//! * The instance itself must be freed with `fff_destroy`.
|
||||
//!
|
||||
//! # Parameter conventions
|
||||
//!
|
||||
//! * Optional `*const c_char` parameters: pass NULL or an empty string to omit.
|
||||
//! * Numeric parameters: 0 means "use default" unless documented otherwise.
|
||||
//! * Grep mode (`u8`): 0 = plain text, 1 = regex, 2 = fuzzy.
|
||||
//! * Multi-grep patterns are passed as a single newline-separated (`\n`) string.
|
||||
//! Conventions: every returned `*mut FffResult` is freed with
|
||||
//! `fff_free_result`; optional string params take NULL/empty; numeric 0 means
|
||||
//! "use default" unless documented otherwise; grep mode `u8` is 0 = plain
|
||||
//! text, 1 = regex, 2 = fuzzy; multi-grep patterns are `\n`-separated.
|
||||
|
||||
use std::ffi::{CStr, CString, c_char, c_void};
|
||||
use std::path::PathBuf;
|
||||
@@ -30,6 +18,7 @@ use fff::shared::SharedQueryTracker;
|
||||
|
||||
mod accessors;
|
||||
mod ffi_types;
|
||||
mod watch;
|
||||
|
||||
use fff::file_picker::FilePicker;
|
||||
use fff::frecency::FrecencyTracker;
|
||||
@@ -42,20 +31,17 @@ use ffi_types::{
|
||||
FffScore, FffSearchResult,
|
||||
};
|
||||
|
||||
/// Opaque fff_handle holding all per-instance state.
|
||||
///
|
||||
/// The caller receives this as `*mut c_void` and must pass it to every FFI call.
|
||||
/// The fff_handle is freed by `fff_destroy`.
|
||||
/// Opaque handle holding all per-instance state; freed by `fff_destroy`.
|
||||
struct FffInstance {
|
||||
picker: SharedFilePicker,
|
||||
frecency: SharedFrecency,
|
||||
query_tracker: SharedQueryTracker,
|
||||
// we keep a single callback type
|
||||
watch_callback: std::sync::Arc<watch::WatchCallbackSlot>,
|
||||
}
|
||||
|
||||
/// Helper to convert C string to Rust &str.
|
||||
///
|
||||
/// Returns `None` if the pointer is null or the string is not valid UTF-8.
|
||||
unsafe fn cstr_to_str<'a>(s: *const c_char) -> Option<&'a str> {
|
||||
/// Convert a C string to `&str`; `None` if null or invalid UTF-8.
|
||||
pub(crate) unsafe fn cstr_to_str<'a>(s: *const c_char) -> Option<&'a str> {
|
||||
if s.is_null() {
|
||||
None
|
||||
} else {
|
||||
@@ -63,17 +49,15 @@ unsafe fn cstr_to_str<'a>(s: *const c_char) -> Option<&'a str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to convert an optional C string parameter.
|
||||
///
|
||||
/// Returns `None` if the pointer is null, empty, or not valid UTF-8.
|
||||
/// Optional C string param: `None` if null, empty, or invalid UTF-8.
|
||||
unsafe fn optional_cstr<'a>(s: *const c_char) -> Option<&'a str> {
|
||||
unsafe { cstr_to_str(s) }.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Recover a `&FffInstance` from the opaque pointer.
|
||||
///
|
||||
/// Returns an error `FffResult` if the pointer is null.
|
||||
unsafe fn instance_ref<'a>(fff_handle: *mut c_void) -> Result<&'a FffInstance, *mut FffResult> {
|
||||
/// Recover a `&FffInstance` from the opaque pointer; error `FffResult` if null.
|
||||
pub(crate) unsafe fn instance_ref<'a>(
|
||||
fff_handle: *mut c_void,
|
||||
) -> Result<&'a FffInstance, *mut FffResult> {
|
||||
if fff_handle.is_null() {
|
||||
Err(FffResult::err(
|
||||
"Instance handle is null. Create one with fff_create_instance first.",
|
||||
@@ -107,11 +91,8 @@ fn default_i32(val: i32, default: i32) -> i32 {
|
||||
|
||||
/// Create a new file finder instance (legacy 8-arg positional signature).
|
||||
///
|
||||
/// @deprecated Use [`fff_create_instance_with`] (or
|
||||
/// [`fff_create_instance_with_value`] for FFI bindings) — both take the
|
||||
/// versioned [`FffCreateOptions`] struct that evolves without ABI breaks.
|
||||
/// This function delegates to `fff_create_instance_with` internally; the
|
||||
/// `use_unsafe_no_lock` parameter is deprecated and ignored.
|
||||
/// @deprecated Use [`fff_create_instance_with`] (or [`fff_create_instance_with_value`]
|
||||
/// for FFI bindings). The `use_unsafe_no_lock` parameter is ignored.
|
||||
///
|
||||
/// ## Safety
|
||||
/// See `fff_create_instance_with`.
|
||||
@@ -143,10 +124,8 @@ pub unsafe extern "C" fn fff_create_instance(
|
||||
|
||||
/// Create a new file finder instance (legacy 13-arg positional signature).
|
||||
///
|
||||
/// @deprecated Use [`fff_create_instance_with`] (or
|
||||
/// [`fff_create_instance_with_value`] for FFI bindings) — both take the
|
||||
/// versioned [`FffCreateOptions`] struct that evolves without ABI breaks.
|
||||
/// The `use_unsafe_no_lock` parameter is deprecated and ignored.
|
||||
/// @deprecated Use [`fff_create_instance_with`] (or [`fff_create_instance_with_value`]
|
||||
/// for FFI bindings). The `use_unsafe_no_lock` parameter is ignored.
|
||||
///
|
||||
/// ## Safety
|
||||
/// See `fff_create_instance_with`.
|
||||
@@ -186,22 +165,14 @@ pub unsafe extern "C" fn fff_create_instance2(
|
||||
unsafe { fff_create_instance_with(&opts as *const FffCreateOptions) }
|
||||
}
|
||||
|
||||
/// Create a new file finder instance from an [`FffCreateOptions`] struct.
|
||||
/// Create a new file finder instance from a versioned [`FffCreateOptions`] struct.
|
||||
///
|
||||
/// **Direct C consumers** populate the struct (designated initializers
|
||||
/// recommended), set `version` to [`FFF_CREATE_OPTIONS_VERSION`], and pass
|
||||
/// it by pointer. New fields are appended in future versions; old callers
|
||||
/// passing `version = 1` keep working forever.
|
||||
/// Populate the struct, set `version` to [`FFF_CREATE_OPTIONS_VERSION`], pass by
|
||||
/// pointer. New fields are only appended; older `version` values keep working.
|
||||
/// FFI bindings needing struct-by-value should use [`fff_create_instance_with_value`].
|
||||
///
|
||||
/// **FFI consumers** that prefer struct-by-value semantics (e.g. ffi-rs's
|
||||
/// `paramsType: [structDef]`) should use [`fff_create_instance_with_value`]
|
||||
/// instead — it's a thin calling-convention adapter that delegates here.
|
||||
///
|
||||
/// Required: `opts.base_path` must be non-NULL and non-empty.
|
||||
///
|
||||
/// When all three `cache_budget_*` values are 0 the budget is auto-computed
|
||||
/// from repo size after the initial scan. Otherwise an explicit budget is
|
||||
/// used: any field left at 0 falls back to its `unlimited()` default.
|
||||
/// `opts.base_path` is required (non-NULL, non-empty). Zero `cache_budget_*`
|
||||
/// values are auto-computed from repo size after the initial scan.
|
||||
///
|
||||
/// ## Safety
|
||||
/// * `opts` must be a valid pointer to an `FffCreateOptions` whose `version`
|
||||
@@ -292,7 +263,7 @@ pub unsafe extern "C" fn fff_create_instance_with(opts: *const FffCreateOptions)
|
||||
watch: opts.watch,
|
||||
mode,
|
||||
cache_budget,
|
||||
follow_symlinks: false,
|
||||
follow_symlinks: opts.version >= 2 && opts.follow_symlinks,
|
||||
enable_fs_root_scanning: opts.enable_fs_root_scanning,
|
||||
enable_home_dir_scanning: opts.enable_home_dir_scanning,
|
||||
},
|
||||
@@ -304,22 +275,15 @@ pub unsafe extern "C" fn fff_create_instance_with(opts: *const FffCreateOptions)
|
||||
picker: shared_picker,
|
||||
frecency: shared_frecency,
|
||||
query_tracker,
|
||||
watch_callback: std::sync::Arc::new(watch::WatchCallbackSlot::default()),
|
||||
});
|
||||
|
||||
let fff_handle = Box::into_raw(instance) as *mut c_void;
|
||||
FffResult::ok_handle(fff_handle)
|
||||
}
|
||||
|
||||
/// Calling-convention adapter for [`fff_create_instance_with`].
|
||||
///
|
||||
/// Same logic, but takes the [`FffCreateOptions`] struct **by value**. This
|
||||
/// makes the function callable from FFI libraries whose native struct
|
||||
/// support passes structs by value on the wire (e.g. Node's `ffi-rs` with
|
||||
/// `paramsType: [structDef]`).
|
||||
///
|
||||
/// This is **not** a versioned wrapper — when new fields are appended to
|
||||
/// `FffCreateOptions`, both this function and `fff_create_instance_with`
|
||||
/// pick them up automatically with no signature change.
|
||||
/// [`fff_create_instance_with`] adapter taking [`FffCreateOptions`] **by value**,
|
||||
/// for FFI libraries that pass native structs by value (e.g. Node's `ffi-rs`).
|
||||
///
|
||||
/// ## Safety
|
||||
/// All `*const c_char` fields inside `opts` must be valid null-terminated
|
||||
@@ -341,6 +305,10 @@ pub unsafe extern "C" fn fff_destroy(fff_handle: *mut c_void) {
|
||||
|
||||
let instance = unsafe { Box::from_raw(fff_handle as *mut FffInstance) };
|
||||
|
||||
// The C callback and user_data may be freed as soon as this returns.
|
||||
instance.picker.shutdown_watches_and_wait();
|
||||
instance.watch_callback.clear();
|
||||
|
||||
if let Ok(mut guard) = instance.picker.write()
|
||||
&& let Some(picker) = guard.take()
|
||||
{
|
||||
@@ -357,16 +325,9 @@ pub unsafe extern "C" fn fff_destroy(fff_handle: *mut c_void) {
|
||||
|
||||
/// Perform fuzzy search on indexed files.
|
||||
///
|
||||
/// # Parameters
|
||||
///
|
||||
/// * `fff_handle` – instance from `fff_create_instance`
|
||||
/// * `query` – search query string
|
||||
/// * `current_file` – path of the currently open file for deprioritization (NULL/empty to skip)
|
||||
/// * `max_threads` – maximum worker threads (0 = auto-detect)
|
||||
/// * `page_index` – pagination offset (0 = first page)
|
||||
/// * `page_size` – results per page (0 = default 100)
|
||||
/// * `combo_boost_multiplier` – score multiplier for combo matches (0 = default 100)
|
||||
/// * `min_combo_count` – minimum combo count before boost applies (0 = default 3)
|
||||
/// `current_file` deprioritizes the currently open file (NULL/empty to skip).
|
||||
/// Zero picks the default: `max_threads` auto, `page_size` 100,
|
||||
/// `combo_boost_multiplier` 100, `min_combo_count` 3.
|
||||
///
|
||||
/// ## Safety
|
||||
/// * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
|
||||
@@ -439,22 +400,11 @@ pub unsafe extern "C" fn fff_search(
|
||||
FffResult::ok_handle(search_result as *mut c_void)
|
||||
}
|
||||
|
||||
/// Glob-only search: filter indexed files by a single glob pattern, rank by
|
||||
/// frecency, and paginate. Bypasses the regular query parser entirely.
|
||||
/// Glob-only search: filter indexed files by a single glob pattern (passed
|
||||
/// through verbatim, no query parsing), rank by frecency, and paginate.
|
||||
///
|
||||
/// Use this when you already have a literal glob pattern (e.g. `*.rs`, a
|
||||
/// recursive `**` match, or `src/components` prefix) and want neither fuzzy
|
||||
/// matching nor multi-token constraint parsing. Ranking falls back to
|
||||
/// frecency because there is no fuzzy score to combine with.
|
||||
///
|
||||
/// # Parameters
|
||||
///
|
||||
/// * `fff_handle` - instance from `fff_create_instance`
|
||||
/// * `pattern` - glob pattern (required, no parsing - passed through verbatim)
|
||||
/// * `current_file` - path of the currently open file for deprioritization (NULL/empty to skip)
|
||||
/// * `max_threads` - maximum worker threads (0 = auto-detect)
|
||||
/// * `page_index` - pagination offset (0 = first page)
|
||||
/// * `page_size` - results per page (0 = default 100)
|
||||
/// `current_file` deprioritizes the currently open file (NULL/empty to skip).
|
||||
/// Zero picks the default: `max_threads` auto, `page_size` 100.
|
||||
///
|
||||
/// ## Safety
|
||||
/// * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
|
||||
@@ -514,14 +464,8 @@ pub unsafe extern "C" fn fff_glob(
|
||||
|
||||
/// Perform fuzzy search on indexed directories.
|
||||
///
|
||||
/// # Parameters
|
||||
///
|
||||
/// * `fff_handle` – instance from `fff_create_instance`
|
||||
/// * `query` – search query string
|
||||
/// * `current_file` – path of the currently open file for distance scoring (NULL/empty to skip)
|
||||
/// * `max_threads` – maximum worker threads (0 = auto-detect)
|
||||
/// * `page_index` – pagination offset (0 = first page)
|
||||
/// * `page_size` – results per page (0 = default 100)
|
||||
/// `current_file` is used for distance scoring (NULL/empty to skip).
|
||||
/// Zero picks the default: `max_threads` auto, `page_size` 100.
|
||||
///
|
||||
/// ## Safety
|
||||
/// * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
|
||||
@@ -584,20 +528,8 @@ pub unsafe extern "C" fn fff_search_directories(
|
||||
|
||||
/// Perform a mixed fuzzy search across both files and directories.
|
||||
///
|
||||
/// Returns a single flat list where files and directories are interleaved
|
||||
/// by total score in descending order. Each item has an `item_type` field
|
||||
/// (0 = file, 1 = directory).
|
||||
///
|
||||
/// # Parameters
|
||||
///
|
||||
/// * `fff_handle` – instance from `fff_create_instance`
|
||||
/// * `query` – search query string
|
||||
/// * `current_file` – path of the currently open file (NULL/empty to skip)
|
||||
/// * `max_threads` – maximum worker threads (0 = auto-detect)
|
||||
/// * `page_index` – pagination offset (0 = first page)
|
||||
/// * `page_size` – results per page (0 = default 100)
|
||||
/// * `combo_boost_multiplier` – score multiplier for combo matches (0 = default 100)
|
||||
/// * `min_combo_count` – minimum combo count before boost applies (0 = default 3)
|
||||
/// Returns one flat list interleaved by descending total score; each item's
|
||||
/// `item_type` is 0 = file, 1 = directory. Parameters as in [`fff_search`].
|
||||
///
|
||||
/// ## Safety
|
||||
/// * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
|
||||
@@ -671,20 +603,11 @@ pub unsafe extern "C" fn fff_search_mixed(
|
||||
|
||||
/// Perform content search (grep) across indexed files.
|
||||
///
|
||||
/// # Parameters
|
||||
///
|
||||
/// * `fff_handle` – instance from `fff_create_instance`
|
||||
/// * `query` – search query (supports constraint syntax like `*.rs pattern`)
|
||||
/// * `mode` – 0 = plain text (SIMD), 1 = regex, 2 = fuzzy
|
||||
/// * `max_file_size` – skip files larger than this in bytes (0 = default 10 MB)
|
||||
/// * `max_matches_per_file` – max matches per file (0 = unlimited)
|
||||
/// * `smart_case` – case-insensitive when query is all lowercase
|
||||
/// * `file_offset` – file-based pagination offset (0 = start)
|
||||
/// * `page_limit` – max matches to return (0 = default 50)
|
||||
/// * `time_budget_ms` – wall-clock budget in ms (0 = unlimited)
|
||||
/// * `before_context` – context lines before each match
|
||||
/// * `after_context` – context lines after each match
|
||||
/// * `classify_definitions` – tag matches that are code definitions
|
||||
/// `query` supports constraint syntax like `*.rs pattern`; `mode` is
|
||||
/// 0 = plain text (SIMD), 1 = regex, 2 = fuzzy. Zero picks the default:
|
||||
/// `max_file_size` 10 MB, `page_limit` 50, `max_matches_per_file` and
|
||||
/// `time_budget_ms` unlimited. `smart_case` is case-insensitive for
|
||||
/// all-lowercase queries; `classify_definitions` tags code definitions.
|
||||
///
|
||||
/// ## Safety
|
||||
/// * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
|
||||
@@ -753,25 +676,11 @@ pub unsafe extern "C" fn fff_live_grep(
|
||||
FffResult::ok_handle(grep_result as *mut c_void)
|
||||
}
|
||||
|
||||
/// Perform multi-pattern OR search (Aho-Corasick) across indexed files.
|
||||
/// Multi-pattern OR search (SIMD Aho-Corasick): lines matching ANY pattern.
|
||||
///
|
||||
/// Searches for lines matching ANY of the provided patterns using
|
||||
/// SIMD-accelerated multi-needle matching.
|
||||
///
|
||||
/// # Parameters
|
||||
///
|
||||
/// * `fff_handle` – instance from `fff_create_instance`
|
||||
/// * `patterns_joined` – patterns separated by `\n` (e.g. `"foo\nbar\nbaz"`)
|
||||
/// * `constraints` – file filter like `"*.rs"` or `"/src/"` (NULL/empty to skip)
|
||||
/// * `max_file_size` – skip files larger than this in bytes (0 = default 10 MB)
|
||||
/// * `max_matches_per_file` – max matches per file (0 = unlimited)
|
||||
/// * `smart_case` – case-insensitive when all patterns are lowercase
|
||||
/// * `file_offset` – file-based pagination offset (0 = start)
|
||||
/// * `page_limit` – max matches to return (0 = default 50)
|
||||
/// * `time_budget_ms` – wall-clock budget in ms (0 = unlimited)
|
||||
/// * `before_context` – context lines before each match
|
||||
/// * `after_context` – context lines after each match
|
||||
/// * `classify_definitions` – tag matches that are code definitions
|
||||
/// `patterns_joined` is `\n`-separated (e.g. `"foo\nbar"`); `constraints` is an
|
||||
/// optional file filter like `"*.rs"` or `"/src/"` (NULL/empty to skip).
|
||||
/// Remaining parameters as in [`fff_live_grep`].
|
||||
///
|
||||
/// ## Safety
|
||||
/// * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
|
||||
@@ -820,19 +729,12 @@ pub unsafe extern "C" fn fff_multi_grep(
|
||||
}
|
||||
};
|
||||
|
||||
let is_ai = picker.mode().is_ai();
|
||||
|
||||
// Parse constraints from the optional string (e.g. "*.rs /src/")
|
||||
let parsed_constraints = constraints_str.map(|c| {
|
||||
if is_ai {
|
||||
fff::QueryParser::new(fff_query_parser::AiGrepConfig).parse(c)
|
||||
} else {
|
||||
fff::grep::parse_grep_query(c)
|
||||
}
|
||||
});
|
||||
let parsed_constraints = constraints_str
|
||||
.map(|c| fff::QueryParser::new(fff_query_parser::AiGrepConfig).parse_constraints(c));
|
||||
|
||||
let constraint_refs: &[fff::Constraint<'_>] = match &parsed_constraints {
|
||||
Some(q) => &q.constraints,
|
||||
Some(constraints) => constraints,
|
||||
None => &[],
|
||||
};
|
||||
|
||||
@@ -893,10 +795,8 @@ pub unsafe extern "C" fn fff_is_scanning(fff_handle: *mut c_void) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Get the base path of the file picker.
|
||||
///
|
||||
/// Returns an `FffResult` with a heap-allocated C string in the `handle`
|
||||
/// field. Free the string with `fff_free_string` after reading it.
|
||||
/// Get the picker's base path as a heap C string in `handle`;
|
||||
/// free it with `fff_free_string`.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `fff_handle` must be a valid instance pointer from `fff_create_instance`.
|
||||
@@ -1018,7 +918,7 @@ pub unsafe extern "C" fn fff_restart_index(
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let (warmup_caches, content_indexing, watch, mode, fs_root, home_dir) =
|
||||
let (warmup_caches, content_indexing, watch, mode, fs_root, home_dir, follow_symlinks) =
|
||||
if let Some(ref picker) = *guard {
|
||||
(
|
||||
picker.has_mmap_cache(),
|
||||
@@ -1027,9 +927,10 @@ pub unsafe extern "C" fn fff_restart_index(
|
||||
picker.mode(),
|
||||
picker.fs_root_scanning_enabled(),
|
||||
picker.home_dir_scanning_enabled(),
|
||||
picker.follows_symlinks(),
|
||||
)
|
||||
} else {
|
||||
(false, true, true, FFFMode::default(), false, false)
|
||||
(false, true, true, FFFMode::default(), false, false, false)
|
||||
};
|
||||
|
||||
drop(guard);
|
||||
@@ -1044,7 +945,7 @@ pub unsafe extern "C" fn fff_restart_index(
|
||||
watch,
|
||||
mode,
|
||||
cache_budget: None,
|
||||
follow_symlinks: false,
|
||||
follow_symlinks,
|
||||
enable_fs_root_scanning: fs_root,
|
||||
enable_home_dir_scanning: home_dir,
|
||||
},
|
||||
@@ -1357,10 +1258,8 @@ pub unsafe extern "C" fn fff_health_check(
|
||||
}
|
||||
}
|
||||
|
||||
/// Free a search result returned by `fff_search`.
|
||||
///
|
||||
/// This frees the `FffSearchResult` struct, its `items` and `scores` arrays,
|
||||
/// and all heap-allocated strings within each item and score.
|
||||
/// Free a search result returned by `fff_search`: the struct, its `items`
|
||||
/// and `scores` arrays, and every string within.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `result` must be a valid pointer previously returned via `FffResult.handle`
|
||||
@@ -1390,10 +1289,8 @@ pub unsafe extern "C" fn fff_free_search_result(result: *mut FffSearchResult) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a pointer to the `index`-th `FffFileItem` in a search result.
|
||||
///
|
||||
/// Returns null if `result` is null or `index >= result->count`.
|
||||
/// The returned pointer is valid until the search result is freed.
|
||||
/// Pointer to the `index`-th `FffFileItem`; null if `result` is null or
|
||||
/// `index >= count`. Valid until the search result is freed.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `result` must be a valid `FffSearchResult` pointer from `fff_search`.
|
||||
@@ -1412,10 +1309,8 @@ pub unsafe extern "C" fn fff_search_result_get_item(
|
||||
unsafe { result.items.add(index as usize) }
|
||||
}
|
||||
|
||||
/// Get a pointer to the `index`-th `FffScore` in a search result.
|
||||
///
|
||||
/// Returns null if `result` is null or `index >= result->count`.
|
||||
/// The returned pointer is valid until the search result is freed.
|
||||
/// Pointer to the `index`-th `FffScore`; null if `result` is null or
|
||||
/// `index >= count`. Valid until the search result is freed.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `result` must be a valid `FffSearchResult` pointer from `fff_search`.
|
||||
@@ -1434,10 +1329,8 @@ pub unsafe extern "C" fn fff_search_result_get_score(
|
||||
unsafe { result.scores.add(index as usize) }
|
||||
}
|
||||
|
||||
/// Free a grep result returned by `fff_live_grep` or `fff_multi_grep`.
|
||||
///
|
||||
/// This frees the `FffGrepResult` struct, its `items` array, and all
|
||||
/// heap-allocated strings, match ranges, and context arrays within each match.
|
||||
/// Free a grep result returned by `fff_live_grep` or `fff_multi_grep`:
|
||||
/// the struct, its `items` array, and all strings/ranges/context within.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `result` must be a valid pointer previously returned via `FffResult.handle`
|
||||
@@ -1464,10 +1357,8 @@ pub unsafe extern "C" fn fff_free_grep_result(result: *mut FffGrepResult) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a pointer to the `index`-th `FffGrepMatch` in a grep result.
|
||||
///
|
||||
/// Returns null if `result` is null or `index >= result->count`.
|
||||
/// The returned pointer is valid until the grep result is freed.
|
||||
/// Pointer to the `index`-th `FffGrepMatch`; null if `result` is null or
|
||||
/// `index >= count`. Valid until the grep result is freed.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `result` must be a valid `FffGrepResult` pointer from `fff_live_grep` or `fff_multi_grep`.
|
||||
@@ -1498,10 +1389,8 @@ pub unsafe extern "C" fn fff_free_scan_progress(result: *mut FffScanProgress) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Offset a pointer by `byte_offset` bytes.
|
||||
///
|
||||
/// General-purpose utility for FFI consumers that need pointer arithmetic
|
||||
/// (e.g. iterating over arrays). Returns null if `base` is null.
|
||||
/// Offset a pointer by `byte_offset` bytes (FFI array iteration helper).
|
||||
/// Returns null if `base` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// The resulting pointer must be within the bounds of the original allocation.
|
||||
@@ -1513,13 +1402,9 @@ pub unsafe extern "C" fn fff_ptr_offset(base: *const c_void, byte_offset: usize)
|
||||
unsafe { (base as *const u8).add(byte_offset) as *const c_void }
|
||||
}
|
||||
|
||||
/// Free a result returned by any `fff_*` function.
|
||||
/// **IMPORTANT:** this doesn't clean the the internal handle, so it is safe to call right after
|
||||
/// you handle the error case.
|
||||
///
|
||||
/// Note: Many non-libffi implementations are not supporting struct-by-value returns, so it's more
|
||||
/// convenient to have pointer returned at most of the time, though allocating result for every call
|
||||
/// is annoying, so we just rely on the fact that our allocator is good enough.
|
||||
/// Free a result envelope returned by any `fff_*` function.
|
||||
/// **IMPORTANT:** the `handle` payload is NOT freed release it separately
|
||||
/// using handle specific cleaning methods (`fff_destroy`, `fff_free_search_result`, etc.).
|
||||
///
|
||||
/// ## Safety
|
||||
/// `result_ptr` must be a valid pointer returned by a `fff_*` function.
|
||||
@@ -1534,9 +1419,8 @@ pub unsafe extern "C" fn fff_free_result(result_ptr: *mut FffResult) {
|
||||
if !result.error.is_null() {
|
||||
drop(CString::from_raw(result.error));
|
||||
}
|
||||
// Note: `handle` is NOT freed here — the caller must free it
|
||||
// with the appropriate function (fff_destroy, fff_free_search_result,
|
||||
// fff_free_grep_result, fff_free_string, fff_free_scan_progress, etc.).
|
||||
|
||||
// note: handle is not freed by design
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
use std::ffi::{CString, c_char, c_void};
|
||||
use std::ptr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use fff::{WatchEvent, WatchId, WatchOptions};
|
||||
|
||||
use crate::ffi_types::FffResult;
|
||||
use crate::instance_ref;
|
||||
|
||||
/// Current version of [`FffWatchOptions`].
|
||||
pub const FFF_WATCH_OPTIONS_VERSION: u32 = 1;
|
||||
|
||||
/// Options for `fff_watch`. Versioned: new fields are only appended.
|
||||
#[repr(C)]
|
||||
pub struct FffWatchOptions {
|
||||
/// Set to [`FFF_WATCH_OPTIONS_VERSION`] when allocating.
|
||||
pub version: u32,
|
||||
/// Per-subscription excludes (parcel-watcher style): entries with wildcards
|
||||
/// are base-relative globs, entries without are path prefixes. NULL when
|
||||
/// `ignore_count` is 0.
|
||||
pub ignore: *const *const c_char,
|
||||
pub ignore_count: u32,
|
||||
// ----- new version 2+ fields go here, ALWAYS appended -----
|
||||
}
|
||||
|
||||
/// A single watch event. `kind`: 0 = created, 1 = modified, 2 = removed,
|
||||
/// 3 = rescan (events were lost; re-stat what you care about).
|
||||
#[repr(C)]
|
||||
pub struct FffWatchEvent {
|
||||
/// Absolute path (heap C string owned by the parent batch).
|
||||
pub path: *mut c_char,
|
||||
pub kind: u8,
|
||||
}
|
||||
|
||||
/// A batch of watch events. Free with `fff_free_watch_events`.
|
||||
#[repr(C)]
|
||||
pub struct FffWatchEventBatch {
|
||||
pub events: *mut FffWatchEvent,
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
/// Instance-wide callback invoked with `(watch_id, batch)` for every `fff_watch`
|
||||
/// subscription. The callee owns and frees `batch` via `fff_free_watch_events`.
|
||||
pub type FffWatchCallback =
|
||||
unsafe extern "C" fn(watch_id: u64, batch: *mut FffWatchEventBatch, user_data: *mut c_void);
|
||||
|
||||
fn batch_into_raw(events: &[WatchEvent]) -> *mut FffWatchEventBatch {
|
||||
let items: Vec<FffWatchEvent> = events
|
||||
.iter()
|
||||
.map(|ev| FffWatchEvent {
|
||||
path: CString::new(ev.path.to_string_lossy().as_bytes())
|
||||
.unwrap_or_default()
|
||||
.into_raw(),
|
||||
kind: ev.kind as u8,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let count = items.len() as u32;
|
||||
let events_ptr = if items.is_empty() {
|
||||
ptr::null_mut()
|
||||
} else {
|
||||
let mut boxed = items.into_boxed_slice();
|
||||
let p = boxed.as_mut_ptr();
|
||||
std::mem::forget(boxed);
|
||||
p
|
||||
};
|
||||
|
||||
Box::into_raw(Box::new(FffWatchEventBatch {
|
||||
events: events_ptr,
|
||||
count,
|
||||
}))
|
||||
}
|
||||
|
||||
unsafe fn watch_options_from_ffi(
|
||||
opts: *const FffWatchOptions,
|
||||
) -> Result<WatchOptions, *mut FffResult> {
|
||||
if opts.is_null() {
|
||||
return Ok(WatchOptions::default());
|
||||
}
|
||||
let opts = unsafe { &*opts };
|
||||
if opts.version == 0 || opts.version > FFF_WATCH_OPTIONS_VERSION {
|
||||
return Err(FffResult::err(&format!(
|
||||
"Unsupported FffWatchOptions version {} (library understands up to {})",
|
||||
opts.version, FFF_WATCH_OPTIONS_VERSION
|
||||
)));
|
||||
}
|
||||
|
||||
let mut ignore = Vec::with_capacity(opts.ignore_count as usize);
|
||||
if opts.ignore_count > 0 {
|
||||
if opts.ignore.is_null() {
|
||||
return Err(FffResult::err("ignore_count > 0 but ignore is NULL"));
|
||||
}
|
||||
for i in 0..opts.ignore_count as usize {
|
||||
let entry = unsafe { *opts.ignore.add(i) };
|
||||
match unsafe { crate::cstr_to_str(entry) } {
|
||||
Some(s) if !s.is_empty() => ignore.push(s.to_string()),
|
||||
Some(_) => {}
|
||||
None => return Err(FffResult::err("ignore entry is NULL or invalid UTF-8")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(WatchOptions { ignore })
|
||||
}
|
||||
|
||||
// The caller guarantees user_data is safe on the callback thread.
|
||||
struct UserData(*mut c_void);
|
||||
unsafe impl Send for UserData {}
|
||||
unsafe impl Sync for UserData {}
|
||||
|
||||
// Shared so a closure surviving an unwatch race never dangles.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct WatchCallbackSlot(Mutex<Option<(FffWatchCallback, UserData)>>);
|
||||
|
||||
impl WatchCallbackSlot {
|
||||
fn get(&self) -> Option<(FffWatchCallback, *mut c_void)> {
|
||||
self.0
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|guard| guard.as_ref().map(|(cb, ud)| (*cb, ud.0)))
|
||||
}
|
||||
|
||||
fn set(&self, callback: FffWatchCallback, user_data: *mut c_void) {
|
||||
if let Ok(mut guard) = self.0.lock() {
|
||||
*guard = Some((callback, UserData(user_data)));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&self) {
|
||||
if let Ok(mut guard) = self.0.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the instance-wide watch callback used by all `fff_watch`
|
||||
/// subscriptions; call before the first `fff_watch`, calling again replaces it.
|
||||
///
|
||||
/// ## Safety
|
||||
/// * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
|
||||
/// * `callback` must remain callable until fff_unwatch called
|
||||
/// `fff_destroy(fff_handle)` returns.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_set_watch_callback(
|
||||
fff_handle: *mut c_void,
|
||||
callback: FffWatchCallback,
|
||||
user_data: *mut c_void,
|
||||
) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
inst.watch_callback.set(callback, user_data);
|
||||
FffResult::ok_empty()
|
||||
}
|
||||
|
||||
/// Subscribe to filesystem changes, delivered through the instance callback
|
||||
/// registered by `fff_set_watch_callback`.
|
||||
///
|
||||
/// Returns the watch id, pass it to `fff_unwatch` to stop.
|
||||
///
|
||||
/// `pattern` if non `NULL` can be wildcard pattern, absolute, or relative path
|
||||
/// that will be used to filter the events triggering exact subscription.
|
||||
///
|
||||
/// ## Safety
|
||||
/// * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
|
||||
/// * `pattern` must be NULL or valid null-terminated UTF-8.
|
||||
/// * `opts` must be NULL or a valid `FffWatchOptions` pointer.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_watch(
|
||||
fff_handle: *mut c_void,
|
||||
pattern: *const c_char,
|
||||
opts: *const FffWatchOptions,
|
||||
) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
// NULL pattern = watch the entire indexed tree ("" in core).
|
||||
let pattern_str = if pattern.is_null() {
|
||||
""
|
||||
} else {
|
||||
match unsafe { crate::cstr_to_str(pattern) } {
|
||||
Some(s) => s,
|
||||
None => return FffResult::err("Pattern is not valid UTF-8"),
|
||||
}
|
||||
};
|
||||
let options = match unsafe { watch_options_from_ffi(opts) } {
|
||||
Ok(o) => o,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if inst.watch_callback.get().is_none() {
|
||||
return FffResult::err("No watch callback registered. Call fff_set_watch_callback first.");
|
||||
}
|
||||
|
||||
let slot = Arc::clone(&inst.watch_callback);
|
||||
let result = inst.picker.watch(pattern_str, options, move |id, events| {
|
||||
if let Some((cb, user_data)) = slot.get() {
|
||||
let batch = batch_into_raw(events);
|
||||
unsafe { cb(id.0, batch, user_data) };
|
||||
}
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(id) => FffResult::ok_int(id.0 as i64),
|
||||
Err(e) => FffResult::err(&format!("Failed to subscribe: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`fff_watch`] adapter with flattened options, for FFI libraries that cannot
|
||||
/// marshal pointer arrays inside structs (e.g. Node's `ffi-rs`).
|
||||
///
|
||||
/// ## Safety
|
||||
/// * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
|
||||
/// * `pattern` must be NULL (watch everything) or valid null-terminated UTF-8.
|
||||
/// * `ignore` must be NULL or point to `ignore_count` valid C strings.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_watch_args(
|
||||
fff_handle: *mut c_void,
|
||||
pattern: *const c_char,
|
||||
ignore: *const *const c_char,
|
||||
ignore_count: u32,
|
||||
) -> *mut FffResult {
|
||||
let opts = FffWatchOptions {
|
||||
version: FFF_WATCH_OPTIONS_VERSION,
|
||||
ignore,
|
||||
ignore_count,
|
||||
};
|
||||
unsafe { fff_watch(fff_handle, pattern, &opts) }
|
||||
}
|
||||
|
||||
/// Remove a watch subscription. `int_value` = 1 if the id existed, 0 otherwise.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `fff_handle` must be a valid instance pointer from `fff_create_instance`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_unwatch(fff_handle: *mut c_void, watch_id: u64) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
FffResult::ok_int(inst.picker.unwatch(WatchId(watch_id)) as i64)
|
||||
}
|
||||
|
||||
/// Number of events in a batch, 0 if `batch` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `batch` must be a valid `FffWatchEventBatch` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_watch_events_count(batch: *const FffWatchEventBatch) -> u32 {
|
||||
if batch.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*batch).count }
|
||||
}
|
||||
|
||||
/// Absolute path of event `index`, will be null when out of bounds
|
||||
///
|
||||
/// ## Safety
|
||||
/// `batch` must be a valid `FffWatchEventBatch` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_watch_events_get_path(
|
||||
batch: *const FffWatchEventBatch,
|
||||
index: u32,
|
||||
) -> *const c_char {
|
||||
match unsafe { watch_event_at(batch, index) } {
|
||||
Some(ev) => ev.path,
|
||||
None => ptr::null(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Kind of event `index` (0 = created, 1 = modified, 2 = removed, 3 = rescan)
|
||||
/// 3 (rescan aka "re-stat something" kind) returned when OS based buffer
|
||||
/// has been overflown and some events might be loss. Paths will contain a list of
|
||||
/// directories that needs to be rescanned to ensure consistency.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `batch` must be a valid `FffWatchEventBatch` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_watch_events_get_kind(
|
||||
batch: *const FffWatchEventBatch,
|
||||
index: u32,
|
||||
) -> u8 {
|
||||
match unsafe { watch_event_at(batch, index) } {
|
||||
Some(ev) => ev.kind,
|
||||
None => 3,
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn watch_event_at<'a>(
|
||||
batch: *const FffWatchEventBatch,
|
||||
index: u32,
|
||||
) -> Option<&'a FffWatchEvent> {
|
||||
if batch.is_null() {
|
||||
return None;
|
||||
}
|
||||
let batch = unsafe { &*batch };
|
||||
if batch.events.is_null() || index >= batch.count {
|
||||
return None;
|
||||
}
|
||||
Some(unsafe { &*batch.events.add(index as usize) })
|
||||
}
|
||||
|
||||
/// Free a watch event batch delivered to the instance callback.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `batch` must be a pointer produced by this library, or null (no-op).
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_free_watch_events(batch: *mut FffWatchEventBatch) {
|
||||
if batch.is_null() {
|
||||
return;
|
||||
}
|
||||
unsafe {
|
||||
let batch = Box::from_raw(batch);
|
||||
if !batch.events.is_null() {
|
||||
let events =
|
||||
Vec::from_raw_parts(batch.events, batch.count as usize, batch.count as usize);
|
||||
for ev in events {
|
||||
if !ev.path.is_null() {
|
||||
drop(CString::from_raw(ev.path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// THESE TESTS MUST NEVER BE UPDATED, ONLY EXTENDED WITH NEW FIELDS —
|
||||
// bindings hardcode these offsets (ABI stability).
|
||||
#[cfg(test)]
|
||||
mod layout_tests {
|
||||
use super::*;
|
||||
use std::mem::{offset_of, size_of};
|
||||
|
||||
#[test]
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
fn watch_ffi_layouts_are_stable_64bit() {
|
||||
assert_eq!(size_of::<FffWatchOptions>(), 24);
|
||||
assert_eq!(offset_of!(FffWatchOptions, version), 0);
|
||||
assert_eq!(offset_of!(FffWatchOptions, ignore), 8);
|
||||
assert_eq!(offset_of!(FffWatchOptions, ignore_count), 16);
|
||||
|
||||
assert_eq!(size_of::<FffWatchEvent>(), 16);
|
||||
assert_eq!(offset_of!(FffWatchEvent, path), 0);
|
||||
assert_eq!(offset_of!(FffWatchEvent, kind), 8);
|
||||
|
||||
assert_eq!(size_of::<FffWatchEventBatch>(), 16);
|
||||
assert_eq!(offset_of!(FffWatchEventBatch, events), 0);
|
||||
assert_eq!(offset_of!(FffWatchEventBatch, count), 8);
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,190 @@
|
||||
* compilers.
|
||||
*/
|
||||
|
||||
/* expose mkdtemp/usleep under -std=c99 on glibc; harmless on musl/darwin */
|
||||
#define _DEFAULT_SOURCE
|
||||
#define _BSD_SOURCE
|
||||
|
||||
#include <fff.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
// simple mock function to make sure that both globbing patterns and dir based pattern work
|
||||
static int watch_glob_hits = 0;
|
||||
static int watch_dir_hits = 0;
|
||||
static int watch_all_hits = 0;
|
||||
static int watch_ignored_leaks = 0;
|
||||
static uint64_t watch_glob_id = 0;
|
||||
static uint64_t watch_dir_id = 0;
|
||||
static uint64_t watch_all_id = 0;
|
||||
|
||||
static void on_watch_batch(uint64_t watch_id, struct FffWatchEventBatch *batch, void *user_data) {
|
||||
(void)user_data;
|
||||
/* route by id like real SDKs do; unknown ids are benign no-ops */
|
||||
for (uint32_t i = 0; i < batch->count; i++) {
|
||||
const char *path = batch->events[i].path;
|
||||
if (!path) continue;
|
||||
if (watch_id == watch_glob_id && strstr(path, "hello.txt")) {
|
||||
watch_glob_hits++;
|
||||
}
|
||||
if (watch_id == watch_dir_id) {
|
||||
if (strstr(path, "hello.txt")) watch_dir_hits++;
|
||||
if (strstr(path, "noise.log")) watch_ignored_leaks++;
|
||||
}
|
||||
if (watch_id == watch_all_id && strstr(path, "hello.txt")) {
|
||||
watch_all_hits++;
|
||||
}
|
||||
}
|
||||
|
||||
fff_free_watch_events(batch); // need to clean dynamic array of events
|
||||
}
|
||||
|
||||
static int watch_smoke(void) {
|
||||
char tmpl[] = "/tmp/fff-c-watch-XXXXXX";
|
||||
char *dir = mkdtemp(tmpl);
|
||||
if (!dir) {
|
||||
fprintf(stderr, "watch_smoke: mkdtemp failed\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
struct FffResult *create_result = fff_create_instance_with(&(struct FffCreateOptions){
|
||||
.version = FFF_CREATE_OPTIONS_VERSION,
|
||||
.base_path = dir,
|
||||
.enable_mmap_cache = false,
|
||||
.enable_content_indexing = false,
|
||||
.watch = true,
|
||||
});
|
||||
if (!create_result->success) {
|
||||
fprintf(stderr, "watch_smoke: create failed: %s\n",
|
||||
create_result->error ? create_result->error : "?");
|
||||
fff_free_result(create_result);
|
||||
return 1;
|
||||
}
|
||||
void *picker = create_result->handle;
|
||||
fff_free_result(create_result);
|
||||
|
||||
struct FffResult *r = fff_wait_for_scan(picker, 10000);
|
||||
fff_free_result(r);
|
||||
r = fff_wait_for_watcher(picker, 10000);
|
||||
fff_free_result(r);
|
||||
usleep(300 * 1000); /* let the FSEvents stream settle */
|
||||
|
||||
/* instance-wide callback, then two subscriptions routed by id */
|
||||
r = fff_set_watch_callback(picker, on_watch_batch, NULL);
|
||||
if (!r->success) {
|
||||
fprintf(stderr, "watch_smoke: fff_set_watch_callback failed: %s\n",
|
||||
r->error ? r->error : "?");
|
||||
fff_free_result(r);
|
||||
fff_destroy(picker);
|
||||
return 1;
|
||||
}
|
||||
fff_free_result(r);
|
||||
|
||||
r = fff_watch(picker, "**/*.txt", NULL);
|
||||
if (!r->success) {
|
||||
fprintf(stderr, "watch_smoke: fff_watch failed: %s\n", r->error ? r->error : "?");
|
||||
fff_free_result(r);
|
||||
fff_destroy(picker);
|
||||
return 1;
|
||||
}
|
||||
watch_glob_id = (uint64_t)r->int_value;
|
||||
fff_free_result(r);
|
||||
|
||||
/* whole-tree dir subscription with an ignore glob */
|
||||
const char *ignores[] = {"*.log"};
|
||||
r = fff_watch(picker, dir,
|
||||
&(struct FffWatchOptions){.version = FFF_WATCH_OPTIONS_VERSION,
|
||||
.ignore = ignores,
|
||||
.ignore_count = 1});
|
||||
if (!r->success) {
|
||||
fprintf(stderr, "watch_smoke: dir fff_watch failed: %s\n", r->error ? r->error : "?");
|
||||
fff_free_result(r);
|
||||
fff_destroy(picker);
|
||||
return 1;
|
||||
}
|
||||
watch_dir_id = (uint64_t)r->int_value;
|
||||
fff_free_result(r);
|
||||
|
||||
/* NULL pattern subscribes to the entire indexed tree */
|
||||
r = fff_watch(picker, NULL, NULL);
|
||||
if (!r->success) {
|
||||
fprintf(stderr, "watch_smoke: NULL-pattern fff_watch failed: %s\n",
|
||||
r->error ? r->error : "?");
|
||||
fff_free_result(r);
|
||||
fff_destroy(picker);
|
||||
return 1;
|
||||
}
|
||||
watch_all_id = (uint64_t)r->int_value;
|
||||
fff_free_result(r);
|
||||
|
||||
char file_path[512];
|
||||
snprintf(file_path, sizeof(file_path), "%s/hello.txt", dir);
|
||||
FILE *f = fopen(file_path, "w");
|
||||
if (!f) {
|
||||
fprintf(stderr, "watch_smoke: fopen failed\n");
|
||||
fff_destroy(picker);
|
||||
return 1;
|
||||
}
|
||||
fputs("hello watch\n", f);
|
||||
fclose(f);
|
||||
|
||||
/* must be filtered out by the dir subscription's ignore glob */
|
||||
char log_path[512];
|
||||
snprintf(log_path, sizeof(log_path), "%s/noise.log", dir);
|
||||
FILE *lf = fopen(log_path, "w");
|
||||
if (lf) {
|
||||
fputs("noise\n", lf);
|
||||
fclose(lf);
|
||||
}
|
||||
|
||||
for (int attempt = 0;
|
||||
attempt < 100 && (watch_glob_hits == 0 || watch_dir_hits == 0 || watch_all_hits == 0);
|
||||
attempt++) {
|
||||
usleep(100 * 1000);
|
||||
}
|
||||
|
||||
r = fff_unwatch(picker, watch_glob_id);
|
||||
fff_free_result(r);
|
||||
r = fff_unwatch(picker, watch_dir_id);
|
||||
fff_free_result(r);
|
||||
r = fff_unwatch(picker, watch_all_id);
|
||||
fff_free_result(r);
|
||||
/* unwatch of an unknown id reports 0, not an error */
|
||||
r = fff_unwatch(picker, watch_dir_id);
|
||||
int unwatch_idempotent = r->success && r->int_value == 0;
|
||||
fff_free_result(r);
|
||||
|
||||
/* fff_destroy is the quiescence barrier: after it returns the callback
|
||||
* will never run again and could be freed (ours is static). */
|
||||
fff_destroy(picker);
|
||||
|
||||
if (watch_glob_hits == 0) {
|
||||
fprintf(stderr, "watch_smoke FAIL: glob subscriber saw no events\n");
|
||||
return 1;
|
||||
}
|
||||
if (watch_dir_hits == 0) {
|
||||
fprintf(stderr, "watch_smoke FAIL: dir subscriber saw no events\n");
|
||||
return 1;
|
||||
}
|
||||
if (watch_all_hits == 0) {
|
||||
fprintf(stderr, "watch_smoke FAIL: NULL-pattern subscriber saw no events\n");
|
||||
return 1;
|
||||
}
|
||||
if (watch_ignored_leaks > 0) {
|
||||
fprintf(stderr, "watch_smoke FAIL: ignore glob leaked %d events\n", watch_ignored_leaks);
|
||||
return 1;
|
||||
}
|
||||
if (!unwatch_idempotent) {
|
||||
fprintf(stderr, "watch_smoke FAIL: repeated unwatch was not a no-op\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
fprintf(stderr, "watch_smoke PASS (glob=%d dir=%d all=%d)\n", watch_glob_hits, watch_dir_hits,
|
||||
watch_all_hits);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
const char *base_path = argc > 1 ? argv[1] : ".";
|
||||
@@ -84,6 +265,11 @@ int main(int argc, char **argv) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (watch_smoke() != 0) {
|
||||
fprintf(stderr, "FAIL: watch test failed\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
fprintf(stderr, "PASS\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
[package]
|
||||
name = "fff-search"
|
||||
version = "0.9.6"
|
||||
version = "0.10.5"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>"]
|
||||
description = "Faboulous & Fast File Finder - a fast and extremely correct file finder SDK with typo resistance, SIMD, prefiltering, and more"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
crate-type = ["rlib", "staticlib", "cdylib"]
|
||||
crate-type = ["rlib"]
|
||||
|
||||
[[bench]]
|
||||
name = "parse_bench"
|
||||
@@ -27,10 +30,24 @@ name = "glob_bench"
|
||||
harness = false
|
||||
required-features = ["zlob"]
|
||||
|
||||
[[bench]]
|
||||
name = "grep_bench"
|
||||
harness = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# `ripgrep` is the pure-Rust walker/glob backend and is on by default so
|
||||
# consumers build without a Zig toolchain. CI/release opt into zlob via
|
||||
# `--no-default-features --features zlob`.
|
||||
default = ["ripgrep"]
|
||||
# Enable C FFI exports
|
||||
ffi = []
|
||||
# Count full rescans and their causes. Always on in debug builds; enable this
|
||||
# to keep the accounting in a release build (used by the rescan_probe binary).
|
||||
rescan-stats = []
|
||||
# Enables POC definition classification for grep result matched lines
|
||||
definitions = []
|
||||
# Pure-Rust filesystem walker + glob matcher (ignore + globset crates).
|
||||
ripgrep = ["dep:ignore", "dep:globset", "fff-query-parser/ripgrep"]
|
||||
# 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"]
|
||||
@@ -50,12 +67,12 @@ dirs = { workspace = true }
|
||||
libc = "0.2"
|
||||
git2 = { workspace = true }
|
||||
glidesort = { workspace = true }
|
||||
globset = { workspace = true }
|
||||
globset = { workspace = true, optional = true }
|
||||
fff-grep = { workspace = true , version = "0.9.0" }
|
||||
aho-corasick = "1"
|
||||
memchr = "2"
|
||||
heed = { workspace = true }
|
||||
ignore = { workspace = true }
|
||||
ignore = { workspace = true, optional = true }
|
||||
memmap2 = { workspace = true }
|
||||
neo_frizbee = { workspace = true }
|
||||
notify = { workspace = true }
|
||||
@@ -84,3 +101,5 @@ ctor = "0.2"
|
||||
proptest = { version = "1", default-features = false, features = ["std", "fork"] }
|
||||
rand = { version = "0.8", features = ["small_rng"] }
|
||||
tempfile = "3.8"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
fff is a file search toolkit. It is faster than ripgrep and fzf and designed for a long running applications like file editors, ai agents, or file exploerers.
|
||||
|
||||
> [!Important performance information]
|
||||
> For the most optimized fff build use `zlob` feature. It requires zig v0.16.0 to be installed on the machine.
|
||||
|
||||
## Features
|
||||
|
||||
- Fuzzy file name search
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
//! Compare three glob-matching strategies for `match_glob_pattern` in constraints.rs:
|
||||
//!
|
||||
//! 1. Current: `zlob_match_paths` -> collect `as_ptr()` into AHashSet, filter paths
|
||||
//! by pointer to recover indices.
|
||||
//! 2. Free fn: `zlob_match_paths_indices` (added in zlob 1.4) — indices direct from C.
|
||||
//! 3. Compiled: `ZlobPattern::compile` + `match_indices` — same indices path, but with
|
||||
//! a precompiled pattern (reusable). For one-shot it should match (2); the win
|
||||
//! appears if the pattern is reused (chunked / repeated calls).
|
||||
//! Compare three glob-matching strategies for `match_glob_pattern` in constraints.rs
|
||||
//! need to make sure that my zlob changes are not affecting fff's internal globbing
|
||||
use ahash::AHashSet;
|
||||
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
|
||||
use zlob::{ZlobFlags, ZlobPattern, zlob_match_paths, zlob_match_paths_indices};
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use fff_search::file_picker::{FilePicker, FilePickerOptions};
|
||||
use fff_search::{GrepMode, GrepSearchOptions, parse_grep_query};
|
||||
use std::io::Write;
|
||||
|
||||
/// Synthetic repo: half the files contain the needle on every line (stresses
|
||||
/// the per-match find/highlight path), half are pure noise (stresses the
|
||||
/// whole-file prefilter path).
|
||||
fn setup_repo(dir: &std::path::Path) {
|
||||
for i in 0..400 {
|
||||
let mut f = std::fs::File::create(dir.join(format!("match_{i}.rs"))).unwrap();
|
||||
for j in 0..100 {
|
||||
writeln!(
|
||||
f,
|
||||
"fn handle_{j}() {{ let controller = Controller::new({j}); controller.run(); }}"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
for i in 0..400 {
|
||||
let mut f = std::fs::File::create(dir.join(format!("noise_{i}.rs"))).unwrap();
|
||||
for j in 0..100 {
|
||||
writeln!(
|
||||
f,
|
||||
"fn compute_{j}() {{ let value = {j} * 42; process(value); }}"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn options(mode: GrepMode) -> GrepSearchOptions {
|
||||
GrepSearchOptions {
|
||||
// Force a full scan of every file so we measure matcher/sink work,
|
||||
// not pagination early-exit.
|
||||
page_limit: usize::MAX,
|
||||
max_matches_per_file: 0,
|
||||
mode,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn bench_grep(c: &mut Criterion) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
setup_repo(dir.path());
|
||||
|
||||
let mut picker = FilePicker::new(FilePickerOptions {
|
||||
base_path: dir.path().to_str().unwrap().into(),
|
||||
watch: false,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
picker.collect_files().unwrap();
|
||||
assert_eq!(picker.get_files().len(), 800);
|
||||
|
||||
let mut group = c.benchmark_group("grep_e2e");
|
||||
group.sample_size(30);
|
||||
|
||||
// Case-sensitive, 40k matched lines: hottest find_at/highlight path
|
||||
let query = parse_grep_query("Controller");
|
||||
let opts = options(GrepMode::PlainText);
|
||||
group.bench_function("plain_case_sensitive_many_matches", |b| {
|
||||
b.iter(|| {
|
||||
let r = picker.grep(&query, &opts);
|
||||
assert_eq!(r.files_with_matches, 400);
|
||||
std::hint::black_box(r.matches.len())
|
||||
});
|
||||
});
|
||||
|
||||
// Case-insensitive (SIMD folding path), 120k matched spans
|
||||
let query = parse_grep_query("controller");
|
||||
group.bench_function("plain_case_insensitive_many_matches", |b| {
|
||||
b.iter(|| {
|
||||
let r = picker.grep(&query, &opts);
|
||||
assert_eq!(r.files_with_matches, 400);
|
||||
std::hint::black_box(r.matches.len())
|
||||
});
|
||||
});
|
||||
|
||||
// No matches anywhere: whole-file prefilter dominates
|
||||
let query = parse_grep_query("Qqzyx");
|
||||
group.bench_function("plain_no_matches", |b| {
|
||||
b.iter(|| {
|
||||
let r = picker.grep(&query, &opts);
|
||||
assert_eq!(r.files_with_matches, 0);
|
||||
std::hint::black_box(r.total_files_searched)
|
||||
});
|
||||
});
|
||||
|
||||
// Regex mode: must be unaffected by NeedleFinder changes
|
||||
let query = parse_grep_query("Contr[a-z]+ller");
|
||||
let regex_opts = options(GrepMode::Regex);
|
||||
group.bench_function("regex_many_matches", |b| {
|
||||
b.iter(|| {
|
||||
let r = picker.grep(&query, ®ex_opts);
|
||||
assert_eq!(r.files_with_matches, 400);
|
||||
std::hint::black_box(r.matches.len())
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_grep);
|
||||
criterion_main!(benches);
|
||||
@@ -1,5 +1,5 @@
|
||||
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
|
||||
use fff_search::case_insensitive_memmem;
|
||||
use fff_search::simd_string_utils::memmem;
|
||||
use std::path::Path;
|
||||
|
||||
/// Load real source files from the repository as benchmark haystacks.
|
||||
@@ -41,7 +41,7 @@ fn load_real_files() -> Vec<(&'static str, Vec<u8>)> {
|
||||
}
|
||||
|
||||
fn bench_memmem(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("case_insensitive_memmem");
|
||||
let mut group = c.benchmark_group("simd_string_utils_memmem");
|
||||
|
||||
let files = load_real_files();
|
||||
assert!(!files.is_empty(), "No source files found for benchmarking");
|
||||
@@ -69,18 +69,10 @@ fn bench_memmem(c: &mut Criterion) {
|
||||
let id = format!("{file_label}/{needle_label}");
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("packed_pair", &id),
|
||||
BenchmarkId::new("find", &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)));
|
||||
b.iter(|| black_box(memmem::find(h, n)));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,15 @@ fn main() {
|
||||
// used by tests/fuzz_git_watcher_stress.rs
|
||||
println!("cargo::rustc-check-cfg=cfg(stress)");
|
||||
|
||||
// Full-rescan accounting. Debug builds get it for free; a release build has
|
||||
// to opt in with `--features rescan-stats` (what the rescan_probe needs).
|
||||
println!("cargo::rustc-check-cfg=cfg(rescan_stats)");
|
||||
if std::env::var("DEBUG").is_ok_and(|debug| debug != "false")
|
||||
|| std::env::var("CARGO_FEATURE_RESCAN_STATS").is_ok()
|
||||
{
|
||||
println!("cargo::rustc-cfg=rescan_stats");
|
||||
}
|
||||
|
||||
// When the `zlob` feature is enabled (Zig-compiled C library):
|
||||
// On Windows MSVC, explicitly link the C runtime libraries.
|
||||
// Zig-compiled static libraries don't emit /DEFAULTLIB directives for the
|
||||
@@ -41,6 +50,5 @@ fn zig_available() -> bool {
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
@@ -1,662 +0,0 @@
|
||||
//! 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,16 +12,25 @@ pub const MAX_INDEXABLE_FILE_SIZE: usize = 2 * 1024 * 1024;
|
||||
pub const MAX_CACHED_CONTENT_BYTES: u64 = 512 * 1024 * 1024;
|
||||
|
||||
/// Files below one page waste the remainder when mmapped, so the cache skips
|
||||
/// them and falls back to chunked reads. Unused on Windows (no content cache).
|
||||
/// them and falls back to chunked reads. Unused on Windows (no content cache)
|
||||
#[cfg(all(not(target_os = "windows"), target_arch = "aarch64"))]
|
||||
pub const MMAP_THRESHOLD: u64 = 16 * 1024;
|
||||
#[cfg(all(not(target_os = "windows"), not(target_arch = "aarch64")))]
|
||||
pub const MMAP_THRESHOLD: u64 = 4 * 1024;
|
||||
|
||||
/// Capacity reserved for files the watcher discovers after the initial scan;
|
||||
/// exceeding it forces a full rescan.
|
||||
/// Watcher overflow capacity reserved after the initial scan
|
||||
pub const MAX_OVERFLOW_FILES: usize = 1024;
|
||||
|
||||
/// Minimum delay between watcher-initiated rescans.
|
||||
pub const RESCAN_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
/// Rescan delay for large indexes.
|
||||
pub const RESCAN_MIN_INTERVAL_LARGE_INDEX: std::time::Duration =
|
||||
std::time::Duration::from_secs(5 * 60);
|
||||
|
||||
/// Live-file count at which [`RESCAN_MIN_INTERVAL_LARGE_INDEX`] takes over.
|
||||
pub const LARGE_INDEX_FILE_COUNT: usize = 1_000_000;
|
||||
|
||||
/// Fresh-mmap threshold: files at or above this size get mmapped directly on
|
||||
/// cache miss instead of chunked reads into Vec. Empirically tuned per-platform.
|
||||
/// Only referenced on Unix; Windows uses the `std::fs::read` fallback so this
|
||||
|
||||
@@ -14,7 +14,7 @@ pub struct DbHealth {
|
||||
}
|
||||
|
||||
pub trait DbHealthChecker {
|
||||
fn get_env(&self) -> &heed::Env;
|
||||
fn get_env(&self) -> &heed::Env<heed::WithoutTls>;
|
||||
fn is_healthy(&self) -> bool;
|
||||
/// Entries per database, each group has a static string label
|
||||
fn count_entries(&self) -> Result<Vec<(&'static str, u64)>>;
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
use heed::{Env, EnvOpenOptions, WithoutTls};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::ops::Deref;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, LazyLock, Mutex, MutexGuard, PoisonError, Weak};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::lmdb::DbHealth;
|
||||
|
||||
pub(crate) struct EnvSpec {
|
||||
pub label: &'static str,
|
||||
pub map_size: usize,
|
||||
pub max_dbs: u32,
|
||||
pub size_cap_bytes: u64,
|
||||
}
|
||||
|
||||
pub(crate) struct PooledEnv {
|
||||
env: Env<WithoutTls>,
|
||||
key: PathBuf,
|
||||
/// lmdb's env spec label
|
||||
label: &'static str,
|
||||
map_size: usize,
|
||||
max_dbs: u32,
|
||||
health: DbHealth,
|
||||
gc_started: AtomicBool,
|
||||
dbi_lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl Drop for PooledEnv {
|
||||
fn drop(&mut self) {
|
||||
let mut pool = POOL.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
// Only remove a dead entry: begin_exclusive_destroy may have removed ours.
|
||||
if pool.get(&self.key).is_some_and(|w| w.strong_count() == 0) {
|
||||
pool.remove(&self.key);
|
||||
}
|
||||
// heed closes the env right after this body; a concurrent reopen of the
|
||||
// same path rides out that gap via env_closing_event in get_or_open.
|
||||
}
|
||||
}
|
||||
|
||||
// Cloneable handle to a process-shared LMDB env, derefs to `heed::Env`.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SharedEnv(Arc<PooledEnv>);
|
||||
|
||||
impl Deref for SharedEnv {
|
||||
type Target = Env<WithoutTls>;
|
||||
fn deref(&self) -> &Env<WithoutTls> {
|
||||
&self.0.env
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SharedEnv {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_tuple("SharedEnv").field(&self.0.env).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SharedEnv {
|
||||
pub(crate) fn get_or_open(db_path: &Path, spec: &EnvSpec) -> Result<Self> {
|
||||
fs::create_dir_all(db_path).map_err(Error::CreateDir)?;
|
||||
let path = fs::canonicalize(db_path).map_err(|e| Error::EnvOpen {
|
||||
db: spec.label,
|
||||
source: heed::Error::Io(e),
|
||||
})?;
|
||||
|
||||
let mut close_waits = 0u32;
|
||||
let mut transient_retries = 0u32;
|
||||
|
||||
loop {
|
||||
let mut open_failed = false;
|
||||
|
||||
{
|
||||
let mut pool = POOL.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(existing) = pool.get(&path).and_then(Weak::upgrade) {
|
||||
drop(pool);
|
||||
if existing.label != spec.label
|
||||
|| existing.map_size != spec.map_size
|
||||
|| existing.max_dbs != spec.max_dbs
|
||||
{
|
||||
return Err(Error::EnvSpecMismatch {
|
||||
path,
|
||||
open_as: existing.label,
|
||||
requested_as: spec.label,
|
||||
});
|
||||
}
|
||||
return Ok(Self(existing));
|
||||
}
|
||||
|
||||
erase_if_oversized(&path, spec);
|
||||
let result = unsafe {
|
||||
// MDB_NOTLS: reader slots are tied to txn objects (freed on
|
||||
// commit/abort) instead of pinned per thread for its lifetime (#783).
|
||||
let mut opts = EnvOpenOptions::new().read_txn_without_tls();
|
||||
opts.map_size(spec.map_size);
|
||||
opts.max_readers(max_readers());
|
||||
if spec.max_dbs > 0 {
|
||||
opts.max_dbs(spec.max_dbs);
|
||||
}
|
||||
opts.open(&path)
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(env) => {
|
||||
let entry = Arc::new(PooledEnv {
|
||||
env,
|
||||
key: path.clone(),
|
||||
label: spec.label,
|
||||
map_size: spec.map_size,
|
||||
max_dbs: spec.max_dbs,
|
||||
health: DbHealth::new(),
|
||||
gc_started: AtomicBool::new(false),
|
||||
dbi_lock: Mutex::new(()),
|
||||
});
|
||||
pool.insert(path.clone(), Arc::downgrade(&entry));
|
||||
drop(pool);
|
||||
let shared = Self(entry);
|
||||
|
||||
match shared.clear_stale_readers() {
|
||||
Ok(cleared_count) if cleared_count > 0 => {
|
||||
tracing::info!(
|
||||
cleared_count,
|
||||
db = spec.label,
|
||||
"reclaimed stale LMDB reader slots at open"
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
tracing::debug!("clear_stale_readers at open failed: {e}")
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(shared);
|
||||
}
|
||||
Err(heed::Error::EnvAlreadyOpened) => open_failed = true,
|
||||
// special handling cause we know this happens randomly
|
||||
Err(e)
|
||||
if is_transient_env_open_error(&e)
|
||||
&& transient_retries < MAX_TRANSIENT_RETRIES =>
|
||||
{
|
||||
transient_retries += 1;
|
||||
tracing::debug!(
|
||||
path = %path.display(),
|
||||
transient_retries,
|
||||
error = ?e,
|
||||
"transient LMDB env open error, retrying"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(Error::EnvOpen {
|
||||
db: spec.label,
|
||||
source: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if open_failed {
|
||||
close_waits += 1;
|
||||
if close_waits > MAX_CLOSE_WAITS {
|
||||
return Err(Error::EnvOpen {
|
||||
db: spec.label,
|
||||
source: heed::Error::EnvAlreadyOpened,
|
||||
});
|
||||
}
|
||||
|
||||
match heed::env_closing_event(&path) {
|
||||
Some(event) => {
|
||||
event.wait_timeout(CLOSE_WAIT);
|
||||
}
|
||||
None => thread::sleep(Duration::from_millis(2)),
|
||||
}
|
||||
} else {
|
||||
thread::sleep(TRANSIENT_RETRY_SLEEP);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn health(&self) -> &DbHealth {
|
||||
&self.0.health
|
||||
}
|
||||
|
||||
// First caller wins: GC runs once per opened env, not once per tracker.
|
||||
pub(crate) fn try_start_gc(&self) -> bool {
|
||||
!self.0.gc_started.swap(true, Ordering::AcqRel)
|
||||
}
|
||||
|
||||
// LMDB forbids mdb_dbi_open from concurrent txns in the same process.
|
||||
pub(crate) fn lock_dbi_open(&self) -> MutexGuard<'_, ()> {
|
||||
self.0
|
||||
.dbi_lock
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
pub(crate) fn destroy(&self) -> Result<Option<heed::EnvClosingEvent>> {
|
||||
let mut pool = POOL.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let holders = Arc::strong_count(&self.0);
|
||||
|
||||
if holders > 1 {
|
||||
return Err(Error::DbInUse {
|
||||
db: self.0.label,
|
||||
path: self.0.key.clone(),
|
||||
holders: holders - 1,
|
||||
});
|
||||
}
|
||||
|
||||
pool.remove(&self.0.key);
|
||||
Ok(heed::env_closing_event(&self.0.key))
|
||||
}
|
||||
}
|
||||
|
||||
static POOL: LazyLock<Mutex<HashMap<PathBuf, Weak<PooledEnv>>>> = LazyLock::new(Mutex::default);
|
||||
|
||||
const CLOSE_WAIT: Duration = Duration::from_millis(100);
|
||||
const MAX_CLOSE_WAITS: u32 = 100;
|
||||
const TRANSIENT_RETRY_SLEEP: Duration = Duration::from_millis(50);
|
||||
const MAX_TRANSIENT_RETRIES: u32 = 8;
|
||||
|
||||
// Concurrent mdb_env_open calls on the same path can race on macOS
|
||||
// this is for some reason fixable by simple retry of the open
|
||||
// heed's default reader table is 126 slots. In TLS mode each thread pins a slot
|
||||
// for its lifetime, so long-lived embedders (Neovim, node agents) that share one
|
||||
// lock file across many processes/threads exhaust it (#783). Reader slots are
|
||||
// tiny (~64B), so raise the ceiling; `FFF_LMDB_MAX_READERS` lets hosts tune it.
|
||||
const DEFAULT_MAX_READERS: u32 = 1024;
|
||||
|
||||
fn max_readers() -> u32 {
|
||||
parse_max_readers(std::env::var("FFF_LMDB_MAX_READERS").ok())
|
||||
}
|
||||
|
||||
// Never drop below heed's default 126; ignore missing/garbage/too-small values.
|
||||
fn parse_max_readers(raw: Option<String>) -> u32 {
|
||||
raw.and_then(|v| v.trim().parse::<u32>().ok())
|
||||
.filter(|&n| n >= 126)
|
||||
.unwrap_or(DEFAULT_MAX_READERS)
|
||||
}
|
||||
|
||||
fn is_transient_env_open_error(err: &heed::Error) -> bool {
|
||||
match err {
|
||||
heed::Error::Io(io) => matches!(
|
||||
io.kind(),
|
||||
std::io::ErrorKind::InvalidInput | std::io::ErrorKind::NotFound
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn erase_if_oversized(db_path: &Path, spec: &EnvSpec) {
|
||||
let data = db_path.join("data.mdb");
|
||||
let Ok(meta) = fs::metadata(&data) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if meta.len() <= spec.size_cap_bytes {
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::error!(
|
||||
path = %db_path.display(),
|
||||
size = meta.len(),
|
||||
cap = spec.size_cap_bytes,
|
||||
"LMDB db exceeds size cap, erasing"
|
||||
);
|
||||
let _ = fs::remove_file(&data);
|
||||
let _ = fs::remove_file(db_path.join("lock.mdb"));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DEFAULT_MAX_READERS, parse_max_readers};
|
||||
|
||||
#[test]
|
||||
fn max_readers_parsing() {
|
||||
assert_eq!(parse_max_readers(None), DEFAULT_MAX_READERS);
|
||||
assert_eq!(parse_max_readers(Some("nan".into())), DEFAULT_MAX_READERS);
|
||||
assert_eq!(parse_max_readers(Some("64".into())), DEFAULT_MAX_READERS); // below 126 floor
|
||||
assert_eq!(parse_max_readers(Some(" 512 ".into())), 512);
|
||||
assert_eq!(parse_max_readers(Some("126".into())), 126);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
use super::db_healthcheck::DbHealthChecker;
|
||||
use super::lmdb::{DbHealth, LmdbStore, is_map_full};
|
||||
use super::env_pool::SharedEnv;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::file_picker::FFFMode;
|
||||
use crate::git::is_modified_status;
|
||||
use crate::lmdb::{DbHealth, LmdbStore, is_map_full};
|
||||
use heed::Database;
|
||||
use heed::types::{Bytes, SerdeBincode};
|
||||
use heed::{Database, Env};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::{collections::VecDeque, path::Path};
|
||||
|
||||
@@ -19,7 +20,7 @@ const AI_MAX_HISTORY_DAYS: f64 = 7.0; // Only consider accesses within 7 days
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FrecencyTracker {
|
||||
env: Env,
|
||||
env: SharedEnv,
|
||||
db: Database<Bytes, SerdeBincode<VecDeque<u64>>>,
|
||||
health: DbHealth,
|
||||
}
|
||||
@@ -42,7 +43,7 @@ const AI_MODIFICATION_THRESHOLDS: [(i64, u64); 5] = [
|
||||
];
|
||||
|
||||
impl DbHealthChecker for FrecencyTracker {
|
||||
fn get_env(&self) -> &heed::Env {
|
||||
fn get_env(&self) -> &heed::Env<heed::WithoutTls> {
|
||||
&self.env
|
||||
}
|
||||
|
||||
@@ -77,7 +78,7 @@ impl LmdbStore for FrecencyTracker {
|
||||
// MAP_SIZE so we don't hit MDB_MAP_FULL before the open-time erase fires.
|
||||
const SIZE_CAP_BYTES: u64 = 12 * 1024 * 1024;
|
||||
|
||||
fn env(&self) -> &Env {
|
||||
fn shared_env(&self) -> &SharedEnv {
|
||||
&self.env
|
||||
}
|
||||
|
||||
@@ -85,7 +86,7 @@ impl LmdbStore for FrecencyTracker {
|
||||
&self.health
|
||||
}
|
||||
|
||||
fn purge_stale_data(env: &Env) -> Result<()> {
|
||||
fn purge_stale_data(env: &SharedEnv) -> Result<()> {
|
||||
let (deleted, pruned) = Self::purge_stale_entries(env)?;
|
||||
if deleted > 0 || pruned > 0 {
|
||||
tracing::info!(deleted, pruned, "Frecency GC purged entries");
|
||||
@@ -121,7 +122,7 @@ impl FrecencyTracker {
|
||||
/// Removes entries where all timestamps are older than MAX_HISTORY_DAYS,
|
||||
/// and prunes stale timestamps from entries that still have recent ones.
|
||||
/// Returns (deleted_count, pruned_count).
|
||||
fn purge_stale_entries(env: &Env) -> Result<(usize, usize)> {
|
||||
fn purge_stale_entries(env: &SharedEnv) -> Result<(usize, usize)> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
@@ -224,6 +225,16 @@ impl FrecencyTracker {
|
||||
}
|
||||
|
||||
fn path_to_hash_bytes(path: &Path) -> Result<[u8; 32]> {
|
||||
// On Windows, resolve to the canonical form (short-name/case/symlink)
|
||||
// so the same file always hashes to one key regardless of how the
|
||||
// caller spelled it. Falls back to the raw path when the file no
|
||||
// longer exists (e.g. watcher delete events), so the op is never
|
||||
// dropped. No-op on other platforms.
|
||||
#[cfg(windows)]
|
||||
let canonical: Option<std::path::PathBuf> = crate::path_utils::canonicalize(path).ok();
|
||||
#[cfg(windows)]
|
||||
let path: &Path = canonical.as_deref().unwrap_or(path);
|
||||
|
||||
let Some(key) = path.to_str() else {
|
||||
return Err(Error::InvalidPath(path.to_path_buf()));
|
||||
};
|
||||
@@ -400,6 +411,15 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::file_picker::FFFMode;
|
||||
|
||||
// A path that doesn't exist on disk must still hash (canonicalize fails on
|
||||
// Windows → falls back to the raw string), so watcher delete events and
|
||||
// raced files never drop their frecency op.
|
||||
#[test]
|
||||
fn hashes_nonexistent_path_without_error() {
|
||||
let missing = Path::new("/this/path/definitely/does/not/exist/frecency_test_xyz");
|
||||
assert!(FrecencyTracker::path_to_hash_bytes(missing).is_ok());
|
||||
}
|
||||
|
||||
fn calculate_test_frecency_score(access_timestamps: &[u64], current_time: u64) -> i64 {
|
||||
let mut total_frecency = 0.0;
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
use heed::{Database, Env, EnvOpenOptions};
|
||||
use std::fs;
|
||||
use heed::{Database, Env, WithoutTls};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::env_pool::{EnvSpec, SharedEnv};
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
pub(crate) fn is_map_full(err: &heed::Error) -> bool {
|
||||
@@ -85,9 +84,13 @@ pub(crate) fn spawn_lmdb_gc<T: LmdbStore>(shared: Arc<RwLock<Option<T>>>) {
|
||||
let Some(ref tracker) = *guard else {
|
||||
return; // destroyed before we started
|
||||
};
|
||||
let env = tracker.env();
|
||||
// Trackers attaching to an already-pooled env must not repeat the
|
||||
// GC; the first opener's run flips the shared health flag.
|
||||
if !tracker.shared_env().try_start_gc() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = T::purge_stale_data(env) {
|
||||
if let Err(e) = T::purge_stale_data(tracker.shared_env()) {
|
||||
tracing::debug!("purge_stale_data failed: {e}");
|
||||
}
|
||||
|
||||
@@ -105,18 +108,6 @@ pub(crate) fn spawn_lmdb_gc<T: LmdbStore>(shared: Arc<RwLock<Option<T>>>) {
|
||||
}
|
||||
}
|
||||
|
||||
// Concurrent `mdb_env_open` calls on the same path can race on macOS
|
||||
// this is for some reason fixabtly by simple retry of the open
|
||||
fn is_transient_env_open_error(err: &heed::Error) -> bool {
|
||||
match err {
|
||||
heed::Error::Io(io) => matches!(
|
||||
io.kind(),
|
||||
std::io::ErrorKind::InvalidInput | std::io::ErrorKind::NotFound
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) trait LmdbStore: Sized + Send + Sync + 'static {
|
||||
/// Short label used to defferintiate different instances of this trait
|
||||
const LABEL: &'static str;
|
||||
@@ -127,85 +118,51 @@ pub(crate) trait LmdbStore: Sized + Send + Sync + 'static {
|
||||
/// Hard cap on `data.mdb` size.
|
||||
const SIZE_CAP_BYTES: u64;
|
||||
|
||||
/// Borrow the env in the read lock
|
||||
fn env(&self) -> &Env;
|
||||
/// Borrow the pooled env handle shared by every tracker of this path.
|
||||
fn shared_env(&self) -> &SharedEnv;
|
||||
/// Borrow the health flag from the tracker.
|
||||
fn health(&self) -> &DbHealth;
|
||||
|
||||
/// Borrow the raw heed env.
|
||||
fn env(&self) -> &Env<WithoutTls> {
|
||||
self.shared_env()
|
||||
}
|
||||
|
||||
/// Override to purge stale rows, compact, etc. Default no-op. Runs on
|
||||
/// the GC thread while a read lock is held against the shared handle,
|
||||
/// so destroy / re-init naturally wait for it.
|
||||
fn purge_stale_data(_env: &Env) -> Result<()> {
|
||||
fn purge_stale_data(_env: &SharedEnv) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open the LMDB env. Returns env + a `DbHealth` starting in Pending;
|
||||
/// the GC thread spawned by `spawn_gc` flips it to Healthy. Write
|
||||
/// paths flip it to Degraded on MDB_MAP_FULL.
|
||||
/// Open (or join) the process-shared LMDB env for `db_path`. The health
|
||||
/// flag is per-env: the GC of the first opener flips it for everyone.
|
||||
#[tracing::instrument]
|
||||
fn open_env(db_path: &Path) -> Result<(Env, DbHealth)> {
|
||||
Self::erase_if_oversized(db_path);
|
||||
fs::create_dir_all(db_path).map_err(Error::CreateDir)?;
|
||||
let db = Self::LABEL;
|
||||
|
||||
const MAX_ATTEMPTS: u32 = 8;
|
||||
let mut attempt = 0u32;
|
||||
let env = loop {
|
||||
let result = unsafe {
|
||||
let mut opts = EnvOpenOptions::new();
|
||||
opts.map_size(Self::MAP_SIZE);
|
||||
if Self::MAX_DBS > 0 {
|
||||
opts.max_dbs(Self::MAX_DBS);
|
||||
}
|
||||
opts.open(db_path)
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(env) => break env,
|
||||
Err(e) if is_transient_env_open_error(&e) && attempt + 1 < MAX_ATTEMPTS => {
|
||||
attempt += 1;
|
||||
tracing::debug!(
|
||||
path = %db_path.display(),
|
||||
attempt,
|
||||
error = ?e,
|
||||
"transient LMDB env open error, retrying"
|
||||
);
|
||||
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
Err(e) => return Err(Error::EnvOpen { db, source: e }),
|
||||
}
|
||||
};
|
||||
|
||||
// Reclaim reader slots left behind by prior processes that died
|
||||
// without cleanup. Must run before we start any read txns (which
|
||||
// open_database_safe does) — otherwise we may hit MDB_READERS_FULL
|
||||
// on a fresh env just because lock.mdb still has stale entries
|
||||
// from a previous crash.
|
||||
//
|
||||
// This is the one LMDB maintenance call we run on the caller's
|
||||
// thread. If the lock file is genuinely wedged this will block
|
||||
// forever, but the alternative — never getting past init — is
|
||||
// worse and the bg-thread trick doesn't solve it anyway.
|
||||
match env.clear_stale_readers() {
|
||||
Ok(cleared) if cleared > 0 => {
|
||||
tracing::warn!(cleared, "reclaimed stale LMDB reader slots at open");
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => tracing::debug!("clear_stale_readers at open failed: {e}"),
|
||||
}
|
||||
|
||||
Ok((env, DbHealth::new()))
|
||||
fn open_env(db_path: &Path) -> Result<(SharedEnv, DbHealth)> {
|
||||
let shared = SharedEnv::get_or_open(
|
||||
db_path,
|
||||
&EnvSpec {
|
||||
label: Self::LABEL,
|
||||
map_size: Self::MAP_SIZE,
|
||||
max_dbs: Self::MAX_DBS,
|
||||
size_cap_bytes: Self::SIZE_CAP_BYTES,
|
||||
},
|
||||
)?;
|
||||
let health = shared.health().clone();
|
||||
Ok((shared, health))
|
||||
}
|
||||
|
||||
/// Open or create a database without blocking on the LMDB writer mutex
|
||||
/// when the database already exists.
|
||||
fn open_database_safe<KC, DC>(env: &Env, name: Option<&str>) -> Result<Database<KC, DC>>
|
||||
fn open_database_safe<KC, DC>(env: &SharedEnv, name: Option<&str>) -> Result<Database<KC, DC>>
|
||||
where
|
||||
KC: 'static,
|
||||
DC: 'static,
|
||||
{
|
||||
let db = Self::LABEL;
|
||||
// mdb_dbi_open must not run from concurrent txns in this process.
|
||||
let _dbi_guard = env.lock_dbi_open();
|
||||
|
||||
let rtxn = env
|
||||
.read_txn()
|
||||
.map_err(|source| Error::DbStartReadTxn { db, source })?;
|
||||
@@ -237,23 +194,4 @@ pub(crate) trait LmdbStore: Sized + Send + Sync + 'static {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn erase_if_oversized(db_path: &Path) {
|
||||
let data = db_path.join("data.mdb");
|
||||
let Ok(meta) = fs::metadata(&data) else {
|
||||
return;
|
||||
};
|
||||
if meta.len() <= Self::SIZE_CAP_BYTES {
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::error!(
|
||||
path = %db_path.display(),
|
||||
size = meta.len(),
|
||||
cap = Self::SIZE_CAP_BYTES,
|
||||
"LMDB db exceeds size cap, erasing"
|
||||
);
|
||||
let _ = fs::remove_file(&data);
|
||||
let _ = fs::remove_file(db_path.join("lock.mdb"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
pub mod db_healthcheck;
|
||||
pub mod frecency;
|
||||
pub(crate) mod env_pool;
|
||||
pub(crate) mod lmdb;
|
||||
|
||||
pub mod db_healthcheck;
|
||||
pub use db_healthcheck::{DbHealth, DbHealthChecker};
|
||||
|
||||
pub mod frecency;
|
||||
pub use frecency::*;
|
||||
|
||||
pub mod query_tracker;
|
||||
pub use query_tracker::*;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::db_healthcheck::DbHealthChecker;
|
||||
use super::lmdb::{DbHealth, LmdbStore, is_map_full};
|
||||
use super::env_pool::SharedEnv;
|
||||
use crate::error::Error;
|
||||
use crate::lmdb::{DbHealth, LmdbStore, is_map_full};
|
||||
use heed::types::{Bytes, SerdeBincode};
|
||||
use heed::{Database, Env};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -27,7 +28,7 @@ struct HistoryEntry {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct QueryTracker {
|
||||
env: Env,
|
||||
env: SharedEnv,
|
||||
// Database for (project_path, query) -> QueryMatchEntry mappings
|
||||
query_file_db: Database<Bytes, SerdeBincode<QueryMatchEntry>>,
|
||||
// Database for project_path -> VecDeque<HistoryEntry> mappings (file picker)
|
||||
@@ -38,7 +39,7 @@ pub struct QueryTracker {
|
||||
}
|
||||
|
||||
impl DbHealthChecker for QueryTracker {
|
||||
fn get_env(&self) -> &Env {
|
||||
fn get_env(&self) -> &Env<heed::WithoutTls> {
|
||||
&self.env
|
||||
}
|
||||
|
||||
@@ -92,7 +93,7 @@ impl LmdbStore for QueryTracker {
|
||||
const MAX_DBS: u32 = 16;
|
||||
const SIZE_CAP_BYTES: u64 = 8 * 1024 * 1024;
|
||||
|
||||
fn env(&self) -> &Env {
|
||||
fn shared_env(&self) -> &SharedEnv {
|
||||
&self.env
|
||||
}
|
||||
|
||||
@@ -197,7 +198,7 @@ impl QueryTracker {
|
||||
/// offset=0 returns most recent, offset=1 returns 2nd most recent, etc.
|
||||
fn read_history_at_offset(
|
||||
db: &Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
|
||||
env: &Env,
|
||||
env: &Env<heed::WithoutTls>,
|
||||
project_key: &[u8; 32],
|
||||
offset: usize,
|
||||
) -> Result<Option<String>, Error> {
|
||||
|
||||
@@ -34,6 +34,22 @@ pub enum Error {
|
||||
#[source]
|
||||
source: heed::Error,
|
||||
},
|
||||
#[error(
|
||||
"LMDB env at {path} is already open as the '{open_as}' database with different options; requested by '{requested_as}'. Use a distinct path per database."
|
||||
)]
|
||||
EnvSpecMismatch {
|
||||
path: std::path::PathBuf,
|
||||
open_as: &'static str,
|
||||
requested_as: &'static str,
|
||||
},
|
||||
#[error(
|
||||
"The {db} database at {path} is still used by {holders} other tracker(s) in this process"
|
||||
)]
|
||||
DbInUse {
|
||||
db: &'static str,
|
||||
path: std::path::PathBuf,
|
||||
holders: usize,
|
||||
},
|
||||
#[error("Failed to create {db} database: {source}")]
|
||||
DbCreate {
|
||||
db: &'static str,
|
||||
@@ -91,6 +107,24 @@ pub enum Error {
|
||||
|
||||
#[error("libgit2 error occurred: {0}")]
|
||||
Git(#[from] git2::Error),
|
||||
|
||||
#[error("Filesystem walk failed: {0}")]
|
||||
WalkFailed(String),
|
||||
|
||||
#[error("Invalid glob pattern '{pattern}': {reason}")]
|
||||
InvalidGlobPattern { pattern: String, reason: String },
|
||||
|
||||
#[error("File system watching is disabled for this picker")]
|
||||
WatcherDisabled,
|
||||
|
||||
#[error("File system watcher is not ready")]
|
||||
WatcherNotReady,
|
||||
|
||||
#[error("Indexed base path changed while creating the watch subscription")]
|
||||
WatchBaseChanged,
|
||||
|
||||
#[error("Failed to start watch callback dispatcher: {0}")]
|
||||
WatchDispatcherStart(#[source] std::io::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
+551
-191
File diff suppressed because it is too large
Load Diff
@@ -62,7 +62,7 @@ impl GitStatusCache {
|
||||
|
||||
let mut entries = AHashMap::with_capacity(statuses.len());
|
||||
for entry in &statuses {
|
||||
if let Some(entry_path) = entry.path() {
|
||||
if let Ok(entry_path) = entry.path() {
|
||||
// libgit2 returns entry paths with forward slashes on every platform
|
||||
// fff stores native paths - meaning we have forward slash issue on windows
|
||||
let full_path = crate::path_utils::normalize(repo_path.join(entry_path));
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
use crate::shared::{SharedFrecency, WeakFilePicker};
|
||||
use ahash::AHashSet;
|
||||
use parking_lot::{Condvar, Mutex};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
// we don't really need a queue here
|
||||
#[derive(Default)]
|
||||
struct Pending {
|
||||
paths: AHashSet<PathBuf>,
|
||||
full_rescan: bool,
|
||||
shutdown: bool,
|
||||
}
|
||||
|
||||
impl Pending {
|
||||
fn has_work(&self) -> bool {
|
||||
self.full_rescan || !self.paths.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Condvar based queue that is used for batch processing events
|
||||
pub(crate) struct GitStatusWorker {
|
||||
state: Mutex<Pending>,
|
||||
cv: Condvar,
|
||||
consumer_spawned: AtomicBool,
|
||||
}
|
||||
|
||||
impl GitStatusWorker {
|
||||
pub(crate) fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
state: Mutex::new(Pending::default()),
|
||||
cv: Condvar::new(),
|
||||
consumer_spawned: AtomicBool::new(false),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_once(
|
||||
self: &Arc<Self>,
|
||||
weak_picker: WeakFilePicker,
|
||||
frecency: SharedFrecency,
|
||||
) {
|
||||
if self
|
||||
.consumer_spawned
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
{
|
||||
Self::spawn_consumer(Arc::clone(self), weak_picker, frecency);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn enqueue_paths<I>(&self, paths: I)
|
||||
where
|
||||
I: IntoIterator<Item = PathBuf>,
|
||||
{
|
||||
let mut guard = self.state.lock();
|
||||
guard.paths.extend(paths);
|
||||
drop(guard);
|
||||
self.cv.notify_one();
|
||||
}
|
||||
|
||||
pub(crate) fn request_full_rescan(&self) {
|
||||
let mut guard = self.state.lock();
|
||||
guard.full_rescan = true;
|
||||
drop(guard);
|
||||
self.cv.notify_one();
|
||||
}
|
||||
|
||||
pub(crate) fn signal_shutdown(&self) {
|
||||
let mut guard = self.state.lock();
|
||||
guard.shutdown = true;
|
||||
drop(guard);
|
||||
self.cv.notify_one();
|
||||
}
|
||||
|
||||
fn wait_and_take(&self) -> Option<Pending> {
|
||||
let mut guard = self.state.lock();
|
||||
while !guard.shutdown && !guard.has_work() {
|
||||
self.cv.wait(&mut guard);
|
||||
}
|
||||
if guard.shutdown {
|
||||
return None;
|
||||
}
|
||||
Some(std::mem::take(&mut *guard))
|
||||
}
|
||||
|
||||
// the problem: git status update can take a lot of time especially on big repositories
|
||||
// and there is unpredictable wait time on the lock file if huge commit is going so we have to
|
||||
// spawn a separate thread to guartee that notify handler is unlocked even if git update takes a
|
||||
// lot of time on every event burst (pretty cheap as this thread is going to sleep 99.9% of time)
|
||||
fn spawn_consumer(
|
||||
mailbox: Arc<GitStatusWorker>,
|
||||
weak_picker: WeakFilePicker,
|
||||
frecency: SharedFrecency,
|
||||
) {
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("fff-git-status".into())
|
||||
.spawn(move || {
|
||||
while let Some(work) = mailbox.wait_and_take() {
|
||||
let Some(picker) = weak_picker.upgrade() else {
|
||||
break;
|
||||
};
|
||||
|
||||
if work.full_rescan {
|
||||
if let Err(e) = picker.refresh_git_status(&frecency) {
|
||||
tracing::error!("git-status worker: full rescan failed: {e:?}");
|
||||
}
|
||||
} else if !work.paths.is_empty() {
|
||||
let paths: Vec<PathBuf> = work.paths.into_iter().collect();
|
||||
if let Err(e) = picker.update_git_status_for_paths(&paths, &frecency) {
|
||||
tracing::error!("git-status worker: path update failed: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("git-status worker stopped");
|
||||
})
|
||||
.inspect_err(|err| tracing::error!(?err, "Failed to spawn git status worker"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
||||
//! Definition and import line classification (vibe coded POC)
|
||||
//!
|
||||
//! Byte-level heuristics that tag a matched line as a code definition
|
||||
//! (`struct`, `fn`, `class`, …) or an import/use statement. Used to
|
||||
//! rank/annotate grep results for AI/MCP consumers. Gated behind the
|
||||
//! `definitions` feature since only such consumers need it.
|
||||
|
||||
/// Detect if a line looks like a code definition (struct, fn, class, etc.)
|
||||
pub fn is_definition_line(line: &str) -> bool {
|
||||
let s = line.trim_start().as_bytes();
|
||||
let s = skip_modifiers(s);
|
||||
is_definition_keyword(s)
|
||||
}
|
||||
|
||||
/// Modifier keywords that can precede a definition keyword.
|
||||
/// Each must be followed by whitespace to be consumed.
|
||||
const MODIFIERS: &[&[u8]] = &[
|
||||
b"pub",
|
||||
b"export",
|
||||
b"default",
|
||||
b"async",
|
||||
b"abstract",
|
||||
b"unsafe",
|
||||
b"static",
|
||||
b"protected",
|
||||
b"private",
|
||||
b"public",
|
||||
];
|
||||
|
||||
/// Definition keywords to detect.
|
||||
const DEF_KEYWORDS: &[&[u8]] = &[
|
||||
b"struct",
|
||||
b"fn",
|
||||
b"enum",
|
||||
b"trait",
|
||||
b"impl",
|
||||
b"class",
|
||||
b"interface",
|
||||
b"function",
|
||||
b"def",
|
||||
b"func",
|
||||
b"type",
|
||||
b"module",
|
||||
b"object",
|
||||
];
|
||||
|
||||
/// Skip zero or more modifier keywords (including `pub(crate)` style visibility).
|
||||
fn skip_modifiers(mut s: &[u8]) -> &[u8] {
|
||||
loop {
|
||||
// Handle `pub(...)` — e.g. `pub(crate)`, `pub(super)`
|
||||
if s.starts_with(b"pub(")
|
||||
&& let Some(end) = s.iter().position(|&b| b == b')')
|
||||
{
|
||||
s = skip_ws(&s[end + 1..]);
|
||||
continue;
|
||||
}
|
||||
let mut matched = false;
|
||||
for &kw in MODIFIERS {
|
||||
if s.starts_with(kw) {
|
||||
let rest = &s[kw.len()..];
|
||||
if rest.first().is_some_and(|b| b.is_ascii_whitespace()) {
|
||||
s = skip_ws(rest);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if `s` starts with a definition keyword followed by a word boundary.
|
||||
fn is_definition_keyword(s: &[u8]) -> bool {
|
||||
for &kw in DEF_KEYWORDS {
|
||||
if s.starts_with(kw) {
|
||||
let after = s.get(kw.len());
|
||||
// Word boundary: end of input, or next byte is not alphanumeric/underscore
|
||||
if after.is_none_or(|b| !b.is_ascii_alphanumeric() && *b != b'_') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Skip ASCII whitespace.
|
||||
#[inline]
|
||||
fn skip_ws(s: &[u8]) -> &[u8] {
|
||||
let n = s
|
||||
.iter()
|
||||
.position(|b| !b.is_ascii_whitespace())
|
||||
.unwrap_or(s.len());
|
||||
&s[n..]
|
||||
}
|
||||
|
||||
/// Detect import/use lines — lower value than definitions or usages.
|
||||
///
|
||||
/// Checks if the line (after leading whitespace) starts with a common
|
||||
/// import statement prefix. Pure byte-level checks, no regex.
|
||||
pub fn is_import_line(line: &str) -> bool {
|
||||
let s = line.trim_start().as_bytes();
|
||||
s.starts_with(b"import ")
|
||||
|| s.starts_with(b"import\t")
|
||||
|| (s.starts_with(b"from ") && s.get(5).is_some_and(|&b| b == b'\'' || b == b'"'))
|
||||
|| s.starts_with(b"use ")
|
||||
|| s.starts_with(b"use\t")
|
||||
|| starts_with_require(s)
|
||||
|| starts_with_include(s)
|
||||
}
|
||||
|
||||
/// Match `require(` or `require (`.
|
||||
#[inline]
|
||||
fn starts_with_require(s: &[u8]) -> bool {
|
||||
if !s.starts_with(b"require") {
|
||||
return false;
|
||||
}
|
||||
let rest = &s[b"require".len()..];
|
||||
rest.first() == Some(&b'(') || (rest.first() == Some(&b' ') && rest.get(1) == Some(&b'('))
|
||||
}
|
||||
|
||||
/// Match `# include ` (with optional spaces after `#`).
|
||||
#[inline]
|
||||
fn starts_with_include(s: &[u8]) -> bool {
|
||||
if s.first() != Some(&b'#') {
|
||||
return false;
|
||||
}
|
||||
let rest = skip_ws(&s[1..]);
|
||||
rest.starts_with(b"include ") || rest.starts_with(b"include\t")
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
use crate::simd_path::ArenaPtr;
|
||||
use crate::types::{ContentCacheBudget, FileItem, MmapSlot};
|
||||
use fff_grep::lines::LineStep;
|
||||
use rayon::prelude::*;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use super::sink::{
|
||||
char_indices_to_byte_offsets, classify_definition, strip_line_terminators,
|
||||
truncate_display_bytes,
|
||||
};
|
||||
use super::types::{GrepMatch, GrepResult, GrepSearchOptions};
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn fuzzy_grep_search<'a>(
|
||||
grep_text: &str,
|
||||
files_to_search: &[&'a FileItem],
|
||||
options: &GrepSearchOptions,
|
||||
total_files: usize,
|
||||
filtered_file_count: usize,
|
||||
case_insensitive: bool,
|
||||
budget: &ContentCacheBudget,
|
||||
abort_signal: &AtomicBool,
|
||||
base_path: &Path,
|
||||
arena: ArenaPtr,
|
||||
overflow_arena: ArenaPtr,
|
||||
) -> GrepResult<'a> {
|
||||
// max_typos controls how many *needle* characters can be unmatched.
|
||||
// A transposition (e.g. "shcema" -> "schema") costs ~1 typo with
|
||||
// default gap penalties. We scale max_typos by needle length:
|
||||
// 1-2 chars -> 0 typos (exact subsequence only)
|
||||
// 3-5 chars -> 1 typo
|
||||
// 6+ chars -> 2 typos
|
||||
// Cap at 2: higher values (3+) let the SIMD prefilter pass lines
|
||||
// missing key characters entirely (e.g. query "flvencodeX" matching
|
||||
// lines without 'l' or 'v'). Quality comes from the post-match filters.
|
||||
let max_typos = (grep_text.len() / 3).min(2);
|
||||
let scoring = neo_frizbee::Scoring {
|
||||
// Use default gap penalties. Higher values (e.g. 20) cause
|
||||
// smith-waterman to prefer *dropping needle chars* over paying
|
||||
// gap costs, which inflates the typo count and breaks
|
||||
// transposition matching ("shcema" -> "schema" becomes 3 typos instead of 1)
|
||||
exact_match_bonus: 100,
|
||||
// gap_open_penalty: 4,
|
||||
// gap_extend_penalty: 2,
|
||||
prefix_bonus: 0,
|
||||
capitalization_bonus: if case_insensitive { 0 } else { 4 },
|
||||
..neo_frizbee::Scoring::default()
|
||||
};
|
||||
|
||||
let matcher = neo_frizbee::Matcher::new(
|
||||
grep_text,
|
||||
&neo_frizbee::Config {
|
||||
// Use the real max_typos so frizbee's SIMD prefilter actually rejects non-matching lines (~2 SIMD instructions per line vs full SW scoring).
|
||||
max_typos: Some(max_typos as u16),
|
||||
sort: false,
|
||||
scoring,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
// Minimum score threshold: 50% of a perfect contiguous match.
|
||||
// With default scoring (match_score=12, matching_case_bonus=4 = 16/char),
|
||||
// a transposition costs ~5 from a gap, keeping the score well above 50%
|
||||
let perfect_score = (grep_text.len() as u16) * 16;
|
||||
let min_score = (perfect_score * 50) / 100;
|
||||
|
||||
// Target identifiers are often longer than the query due to delimiters
|
||||
// (e.g. query "flvencodepicture" -> "ff_flv_encode_picture_header" from ffmpeg)
|
||||
// Allow 3x needle length to accommodate underscore/dot-separated names
|
||||
let max_match_span = grep_text.len() * 3;
|
||||
let needle_len = grep_text.len();
|
||||
|
||||
// Each delimiter (_, .) in the target creates a gap. A typical C/Rust
|
||||
// identifier like "ff_flv_encode_picture_header" has 4-5 underscores.
|
||||
// Scale generously so delimiter gaps don't reject valid matches.
|
||||
let max_gaps = (needle_len / 3).max(2);
|
||||
|
||||
// If a file doesn't contain enough distinct needle characters just skip it
|
||||
let needle_bytes = grep_text.as_bytes();
|
||||
let mut unique_needle_chars: Vec<u8> = Vec::new();
|
||||
for &b in needle_bytes {
|
||||
let lo = b.to_ascii_lowercase();
|
||||
let hi = b.to_ascii_uppercase();
|
||||
if !unique_needle_chars.contains(&lo) {
|
||||
unique_needle_chars.push(lo);
|
||||
}
|
||||
if lo != hi && !unique_needle_chars.contains(&hi) {
|
||||
unique_needle_chars.push(hi);
|
||||
}
|
||||
}
|
||||
|
||||
// How many distinct needle chars must appear in the file.
|
||||
// With max_typos allowed, we need at least (unique_count - max_typos)
|
||||
let unique_count = {
|
||||
let mut seen = [false; 256];
|
||||
for &b in needle_bytes {
|
||||
seen[b.to_ascii_lowercase() as usize] = true;
|
||||
}
|
||||
seen.iter().filter(|&&v| v).count()
|
||||
};
|
||||
let min_chars_required = unique_count.saturating_sub(max_typos);
|
||||
|
||||
let time_budget = if options.time_budget_ms > 0 {
|
||||
Some(std::time::Duration::from_millis(options.time_budget_ms))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let search_start = std::time::Instant::now();
|
||||
let budget_exceeded = AtomicBool::new(false);
|
||||
let max_matches_per_file = options.max_matches_per_file;
|
||||
|
||||
// for fuzzy match we need a bit smarter chunking as the amount of work we have to perform is
|
||||
// exponentially larger than the original grep (and the nature of work is heavier), so in short we have to
|
||||
// understand if the approximate index prefilter got us a lot of candidates or not
|
||||
//
|
||||
// if we have a few candidates -> likely we have a lot of matches, so verify the check faster
|
||||
// if we have a lot of candidates -> rely on a larger chunk pipelining more parallel lines at once
|
||||
let page_limit = options.page_limit;
|
||||
let base_chunk = rayon::current_num_threads() * 4;
|
||||
let prefilter_strong = total_files > 0 && files_to_search.len() * 2 < total_files;
|
||||
let max_chunk = if prefilter_strong {
|
||||
base_chunk
|
||||
} else {
|
||||
(base_chunk * 256).max(8 * 1024)
|
||||
};
|
||||
|
||||
let growth = if prefilter_strong { 1 } else { 2 };
|
||||
let mut chunk_size = base_chunk;
|
||||
let mut chunk_start = 0;
|
||||
let mut running_matches = 0usize;
|
||||
let mut per_file_results: Vec<(usize, &'a FileItem, Vec<GrepMatch>)> = Vec::new();
|
||||
|
||||
while chunk_start < files_to_search.len() {
|
||||
let chunk_end = (chunk_start + chunk_size).min(files_to_search.len());
|
||||
let chunk = &files_to_search[chunk_start..chunk_end];
|
||||
let chunk_offset = chunk_start;
|
||||
chunk_start = chunk_end;
|
||||
chunk_size = (chunk_size * growth).min(max_chunk);
|
||||
|
||||
// Parallel phase with `map_init`: each rayon worker thread clones the
|
||||
// matcher once and gets a reusable read buffer + mmap slot. Buffer holds
|
||||
// small files, slot holds fresh mmap for cache-miss files ≥ FRESH_MMAP_THRESHOLD.
|
||||
let chunk_results: Vec<(usize, &'a FileItem, Vec<GrepMatch>)> = chunk
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.map_init(
|
||||
|| {
|
||||
(
|
||||
matcher.clone(),
|
||||
Vec::with_capacity(64 * 1024),
|
||||
MmapSlot::default(),
|
||||
)
|
||||
},
|
||||
|(matcher, buf, mmap_slot), (local_idx, file)| {
|
||||
if abort_signal.load(Ordering::Relaxed) {
|
||||
budget_exceeded.store(true, Ordering::Relaxed);
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(budget) = time_budget
|
||||
&& search_start.elapsed() > budget
|
||||
{
|
||||
budget_exceeded.store(true, Ordering::Relaxed);
|
||||
return None;
|
||||
}
|
||||
|
||||
let file_arena = if file.is_overflow() {
|
||||
overflow_arena
|
||||
} else {
|
||||
arena
|
||||
};
|
||||
|
||||
let file_bytes =
|
||||
file.get_content_for_search(buf, mmap_slot, file_arena, base_path, budget)?;
|
||||
|
||||
if min_chars_required > 0 {
|
||||
let mut chars_found = 0usize;
|
||||
for &ch in &unique_needle_chars {
|
||||
if memchr::memchr(ch, file_bytes).is_some() {
|
||||
chars_found += 1;
|
||||
if chars_found >= min_chars_required {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if chars_found < min_chars_required {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the whole file as UTF-8 once upfront. Source code
|
||||
// files are virtually always valid UTF-8; this single check
|
||||
// replaces per-line from_utf8 calls (~8% of fuzzy grep time)
|
||||
let file_is_utf8 = std::str::from_utf8(file_bytes).is_ok();
|
||||
|
||||
let mut stepper = LineStep::new(b'\n', 0, file_bytes.len());
|
||||
let estimated_lines = (file_bytes.len() / 40).max(64);
|
||||
let mut file_lines: Vec<&str> = Vec::with_capacity(estimated_lines);
|
||||
let mut line_meta: Vec<(u64, u64)> = Vec::with_capacity(estimated_lines);
|
||||
|
||||
let mut line_number: u64 = 1;
|
||||
while let Some(line_match) = stepper.next_match(file_bytes) {
|
||||
let byte_offset = line_match.start() as u64;
|
||||
let trimmed = strip_line_terminators(&file_bytes[line_match]);
|
||||
|
||||
if !trimmed.is_empty() {
|
||||
// we know for sure that the file is UTF-8 at this point
|
||||
let line_str = if file_is_utf8 {
|
||||
unsafe { std::str::from_utf8_unchecked(trimmed) }
|
||||
} else if let Ok(s) = std::str::from_utf8(trimmed) {
|
||||
s
|
||||
} else {
|
||||
line_number += 1;
|
||||
continue;
|
||||
};
|
||||
file_lines.push(line_str);
|
||||
line_meta.push((line_number, byte_offset));
|
||||
}
|
||||
|
||||
line_number += 1;
|
||||
}
|
||||
|
||||
if file_lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Single-pass: score + indices in one Smith-Waterman run per line (not parallel)
|
||||
let matches_with_indices = matcher.match_list_indices(&file_lines);
|
||||
let mut file_matches: Vec<GrepMatch> = Vec::new();
|
||||
|
||||
for mut match_indices in matches_with_indices {
|
||||
if match_indices.score < min_score {
|
||||
continue;
|
||||
}
|
||||
|
||||
let idx = match_indices.index as usize;
|
||||
let raw_line = file_lines[idx];
|
||||
|
||||
let truncated = truncate_display_bytes(raw_line.as_bytes());
|
||||
let display_line = if truncated.len() < raw_line.len() {
|
||||
// SAFETY: truncate_display_bytes preserves UTF-8 char boundaries
|
||||
&raw_line[..truncated.len()]
|
||||
} else {
|
||||
raw_line
|
||||
};
|
||||
|
||||
// If the line was truncated, re-compute indices on the shorter string.
|
||||
if display_line.len() < raw_line.len() {
|
||||
let Some(re_indices) = matcher
|
||||
.match_list_indices(&[display_line])
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
match_indices = re_indices;
|
||||
}
|
||||
|
||||
match_indices.indices.sort_unstable();
|
||||
|
||||
// Minimum matched chars: at least (needle_len - max_typos)
|
||||
// characters must appear. This is consistent with the typo
|
||||
// budget: each typo can drop one needle char from the alignment.
|
||||
let min_matched = needle_len.saturating_sub(max_typos).max(1);
|
||||
if match_indices.indices.len() < min_matched {
|
||||
continue;
|
||||
}
|
||||
|
||||
let indices = &match_indices.indices;
|
||||
|
||||
if let (Some(&first), Some(&last)) = (indices.first(), indices.last()) {
|
||||
// reject widely scattered matches
|
||||
let span = last - first + 1;
|
||||
if span > max_match_span {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Density check: matched chars / span must be dense enough.
|
||||
// Relaxed for perfect subsequence matches (all needle chars
|
||||
// present), slightly relaxed for typo matches to handle
|
||||
// delimiter-heavy targets
|
||||
// (e.g. "ff_flv_encode_picture_header" has span inflated by underscores w/ density ~68%)
|
||||
let density = (indices.len() * 100) / span;
|
||||
let min_density = if indices.len() >= needle_len {
|
||||
45 // Perfect subsequence relaxed (delimiters inflate span)
|
||||
} else {
|
||||
65 // Has typos filter out a long string
|
||||
};
|
||||
if density < min_density {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gap count check: count discontinuities in the indices
|
||||
let gap_count = indices.windows(2).filter(|w| w[1] != w[0] + 1).count();
|
||||
if gap_count > max_gaps {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let (ln, bo) = line_meta[idx];
|
||||
let match_byte_offsets =
|
||||
char_indices_to_byte_offsets(display_line, &match_indices.indices);
|
||||
let col = match_byte_offsets
|
||||
.first()
|
||||
.map(|r| r.0 as usize)
|
||||
.unwrap_or(0);
|
||||
|
||||
file_matches.push(GrepMatch {
|
||||
file_index: 0,
|
||||
line_number: ln,
|
||||
col,
|
||||
byte_offset: bo,
|
||||
is_definition: classify_definition(
|
||||
options.classify_definitions,
|
||||
display_line,
|
||||
),
|
||||
line_content: display_line.to_string(),
|
||||
match_byte_offsets,
|
||||
fuzzy_score: Some(match_indices.score),
|
||||
context_before: Vec::new(),
|
||||
context_after: Vec::new(),
|
||||
});
|
||||
|
||||
if max_matches_per_file != 0 && file_matches.len() >= max_matches_per_file {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if file_matches.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((chunk_offset + local_idx, *file, file_matches))
|
||||
},
|
||||
)
|
||||
.flatten()
|
||||
.collect();
|
||||
|
||||
for result in chunk_results {
|
||||
running_matches += result.2.len();
|
||||
per_file_results.push(result);
|
||||
}
|
||||
|
||||
if running_matches >= page_limit || budget_exceeded.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
GrepResult::collect(
|
||||
per_file_results,
|
||||
files_to_search.len(),
|
||||
options,
|
||||
total_files,
|
||||
filtered_file_count,
|
||||
budget_exceeded.load(Ordering::Relaxed),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
use super::prefilter::prefilter_with_filepath_retry;
|
||||
use super::regex::{RegexMatcher, RegexSink, build_regex};
|
||||
use super::sink::{SinkState, debug_assert_newline_terminator};
|
||||
use super::types::{GrepMatch, GrepMode, GrepResult, GrepSearchOptions};
|
||||
use crate::index::{
|
||||
BigramFilter, BigramOverlay, bigram_boundary, fuzzy_candidates, literal_candidates,
|
||||
regex_candidates,
|
||||
};
|
||||
use crate::simd_string_utils::memmem;
|
||||
use crate::types::{ContentCacheBudget, FileItem, FileSliceExt, MmapSlot};
|
||||
use fff_grep::{
|
||||
Searcher, SearcherBuilder, Sink, SinkMatch,
|
||||
matcher::{Match, Matcher, NoError},
|
||||
};
|
||||
use fff_query_parser::{FFFQuery, GrepConfig, QueryParser};
|
||||
use rayon::prelude::*;
|
||||
use smallvec::SmallVec;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tracing::Level;
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub(super) enum NeedleFinder<'a> {
|
||||
CaseSensitive(memchr::memmem::Finder<'a>),
|
||||
/// Pre-lowered needle bytes for the SIMD case-insensitive search.
|
||||
CaseInsensitive(&'a [u8]),
|
||||
}
|
||||
|
||||
impl<'a> NeedleFinder<'a> {
|
||||
fn new(needle: &'a [u8], case_insensitive: bool) -> Self {
|
||||
if case_insensitive {
|
||||
Self::CaseInsensitive(needle)
|
||||
} else {
|
||||
Self::CaseSensitive(memchr::memmem::Finder::new(needle))
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn find(&self, haystack: &[u8]) -> Option<usize> {
|
||||
match self {
|
||||
Self::CaseSensitive(finder) => finder.find(haystack),
|
||||
Self::CaseInsensitive(needle_lower) => memmem::find(haystack, needle_lower),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn needle(&self) -> &[u8] {
|
||||
match self {
|
||||
Self::CaseSensitive(finder) => finder.needle(),
|
||||
Self::CaseInsensitive(needle_lower) => needle_lower,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare `haystack` against a slice of the needle with the same case
|
||||
/// semantics as `find`.
|
||||
#[inline]
|
||||
fn eq_fold(&self, haystack: &[u8], needle_seg: &[u8]) -> bool {
|
||||
match self {
|
||||
Self::CaseSensitive(_) => haystack == needle_seg,
|
||||
Self::CaseInsensitive(_) => {
|
||||
haystack.len() == needle_seg.len() && memmem::find(haystack, needle_seg) == Some(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect highlight spans for every needle occurrence within a line.
|
||||
/// The case branch is resolved once per line, not once per occurrence.
|
||||
#[inline]
|
||||
fn for_each_occurrence(&self, haystack: &[u8], mut on_match: impl FnMut(usize)) {
|
||||
match self {
|
||||
Self::CaseSensitive(finder) => {
|
||||
let mut start_pos = 0usize;
|
||||
while let Some(pos) = finder.find(&haystack[start_pos..]) {
|
||||
on_match(start_pos + pos);
|
||||
start_pos += pos + 1;
|
||||
}
|
||||
}
|
||||
Self::CaseInsensitive(needle_lower) => {
|
||||
let mut start_pos = 0usize;
|
||||
while let Some(pos) = memmem::find(&haystack[start_pos..], needle_lower) {
|
||||
on_match(start_pos + pos);
|
||||
start_pos += pos + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PlainTextMatcher<'a> {
|
||||
finder: &'a NeedleFinder<'a>,
|
||||
}
|
||||
|
||||
impl Matcher for PlainTextMatcher<'_> {
|
||||
type Error = NoError;
|
||||
|
||||
#[inline]
|
||||
fn find_at(&self, haystack: &[u8], at: usize) -> Result<Option<Match>, NoError> {
|
||||
let hay = &haystack[at..];
|
||||
let needle_len = self.finder.needle().len();
|
||||
|
||||
Ok(self
|
||||
.finder
|
||||
.find(hay)
|
||||
.map(|pos| Match::new(at + pos, at + pos + needle_len)))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn line_terminator(&self) -> Option<fff_grep::LineTerminator> {
|
||||
Some(fff_grep::LineTerminator::byte(b'\n'))
|
||||
}
|
||||
}
|
||||
|
||||
struct PlainTextSink<'r> {
|
||||
state: SinkState,
|
||||
finder: &'r NeedleFinder<'r>,
|
||||
pattern_len: u32,
|
||||
multiline_segment_len: Option<usize>,
|
||||
}
|
||||
|
||||
impl Sink for PlainTextSink<'_> {
|
||||
type Error = std::io::Error;
|
||||
|
||||
fn matched(
|
||||
&mut self,
|
||||
searcher: &Searcher,
|
||||
sink_match: &SinkMatch<'_>,
|
||||
) -> Result<bool, Self::Error> {
|
||||
debug_assert_newline_terminator(searcher);
|
||||
if self.state.max_matches != 0 && self.state.matches.len() >= self.state.max_matches {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let line_bytes = sink_match.bytes();
|
||||
let (display_bytes, display_len, line_number, byte_offset) =
|
||||
SinkState::prepare_line(line_bytes, sink_match);
|
||||
|
||||
let line_content = String::from_utf8_lossy(display_bytes).into_owned();
|
||||
let mut match_byte_offsets: SmallVec<[(u32, u32); 4]> = SmallVec::new();
|
||||
let mut col = 0usize;
|
||||
let mut first = true;
|
||||
|
||||
if let Some(seg_len) = self.multiline_segment_len {
|
||||
// Multiline needle: the match starts on this line, so the needle's
|
||||
// first segment must be a suffix of the line. Highlight that suffix.
|
||||
let seg = &self.finder.needle()[..seg_len];
|
||||
if !seg.is_empty()
|
||||
&& display_bytes.len() >= seg.len()
|
||||
&& self
|
||||
.finder
|
||||
.eq_fold(&display_bytes[display_bytes.len() - seg.len()..], seg)
|
||||
{
|
||||
col = display_bytes.len() - seg.len();
|
||||
match_byte_offsets.push((col as u32, display_len));
|
||||
}
|
||||
} else {
|
||||
let pattern_len = self.pattern_len;
|
||||
self.finder.for_each_occurrence(display_bytes, |pos| {
|
||||
let abs_start = pos as u32;
|
||||
let abs_end = (abs_start + pattern_len).min(display_len);
|
||||
if first {
|
||||
col = pos;
|
||||
first = false;
|
||||
}
|
||||
match_byte_offsets.push((abs_start, abs_end));
|
||||
});
|
||||
}
|
||||
|
||||
let (context_before, context_after) = self.state.extract_context(sink_match);
|
||||
self.state.push_match(
|
||||
line_number,
|
||||
col,
|
||||
byte_offset,
|
||||
line_content,
|
||||
match_byte_offsets,
|
||||
context_before,
|
||||
context_after,
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn finish(&mut self, _: &Searcher, _: &fff_grep::SinkFinish) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform a grep search across all indexed files.
|
||||
///
|
||||
/// When `query` is empty, returns git-modified/untracked files sorted by
|
||||
/// frecency for the "welcome state" UI.
|
||||
#[tracing::instrument(skip_all, fields(file_count = files.len()))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn grep_search<'a>(
|
||||
files: &'a [FileItem],
|
||||
query: &FFFQuery<'_>,
|
||||
options: &GrepSearchOptions,
|
||||
budget: &ContentCacheBudget,
|
||||
bigram_index: Option<&BigramFilter>,
|
||||
bigram_overlay: Option<&BigramOverlay>,
|
||||
abort_signal: &AtomicBool,
|
||||
base_path: &Path,
|
||||
arena: crate::simd_path::ArenaPtr,
|
||||
overflow_arena: crate::simd_path::ArenaPtr,
|
||||
) -> GrepResult<'a> {
|
||||
let result = grep_search_parsed(
|
||||
files,
|
||||
query,
|
||||
options,
|
||||
budget,
|
||||
bigram_index,
|
||||
bigram_overlay,
|
||||
abort_signal,
|
||||
base_path,
|
||||
arena,
|
||||
overflow_arena,
|
||||
);
|
||||
|
||||
// Constraint parsing can swallow tokens the user meant literally (e.g. `!=`
|
||||
// becoming an exclusion). If the constrained search scanned everything and
|
||||
// found nothing, retry the whole raw query as literal text. This also holds
|
||||
// for later pages: an empty full scan at offset 0 stays empty at any offset,
|
||||
// so paging offsets consistently index the literal search's file list.
|
||||
let full_scan_empty = result.matches.is_empty() && result.next_file_offset == 0;
|
||||
if !full_scan_empty || query.constraints.is_empty() || abort_signal.load(Ordering::Relaxed) {
|
||||
return result;
|
||||
}
|
||||
|
||||
let raw = query.raw_query.trim();
|
||||
if raw.is_empty() {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Keep any explicit FilePath scope (AI mode `path/to/file.ext` prefix) so the
|
||||
// fallback can't leak matches outside the file the user pinned. Only the
|
||||
// swallowed operator/glob tokens are dropped. See issue #756.
|
||||
let scoped_constraints: fff_query_parser::ConstraintVec<'_> = query
|
||||
.constraints
|
||||
.iter()
|
||||
.filter(|c| matches!(c, fff_query_parser::Constraint::FilePath(_)))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let literal_query = FFFQuery {
|
||||
raw_query: query.raw_query,
|
||||
constraints: scoped_constraints,
|
||||
fuzzy_query: fff_query_parser::FuzzyQuery::Text(raw),
|
||||
location: None,
|
||||
};
|
||||
|
||||
let mut fallback = grep_search_parsed(
|
||||
files,
|
||||
&literal_query,
|
||||
options,
|
||||
budget,
|
||||
bigram_index,
|
||||
bigram_overlay,
|
||||
abort_signal,
|
||||
base_path,
|
||||
arena,
|
||||
overflow_arena,
|
||||
);
|
||||
|
||||
if fallback.matches.is_empty() {
|
||||
result
|
||||
} else {
|
||||
fallback.literal_fallback = true;
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn grep_search_parsed<'a>(
|
||||
files: &'a [FileItem],
|
||||
query: &FFFQuery<'_>,
|
||||
options: &GrepSearchOptions,
|
||||
budget: &ContentCacheBudget,
|
||||
bigram_index: Option<&BigramFilter>,
|
||||
bigram_overlay: Option<&BigramOverlay>,
|
||||
abort_signal: &AtomicBool,
|
||||
base_path: &Path,
|
||||
arena: crate::simd_path::ArenaPtr,
|
||||
overflow_arena: crate::simd_path::ArenaPtr,
|
||||
) -> GrepResult<'a> {
|
||||
let total_files = files.live_count();
|
||||
let constraints_from_query = &query.constraints[..];
|
||||
|
||||
let grep_text = extract_grep_text(query);
|
||||
if grep_text.is_empty() {
|
||||
return GrepResult::empty(total_files, total_files);
|
||||
}
|
||||
|
||||
let case_insensitive = if options.smart_case {
|
||||
!grep_text.chars().any(|c| c.is_uppercase())
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let base_count = bigram_boundary(bigram_overlay, files.len());
|
||||
|
||||
let mut regex_fallback_error: Option<String> = None;
|
||||
let regex = match options.mode {
|
||||
GrepMode::PlainText => None,
|
||||
GrepMode::Fuzzy => {
|
||||
let bigram_candidates = fuzzy_candidates(bigram_index, bigram_overlay, &grep_text);
|
||||
|
||||
let (files_to_search, filtered_file_count) = prefilter_with_filepath_retry(
|
||||
files,
|
||||
constraints_from_query,
|
||||
bigram_candidates.as_deref(),
|
||||
base_count,
|
||||
options,
|
||||
arena,
|
||||
overflow_arena,
|
||||
);
|
||||
|
||||
if files_to_search.is_empty() {
|
||||
return GrepResult::empty(total_files, filtered_file_count);
|
||||
}
|
||||
|
||||
return super::fuzzy_grep::fuzzy_grep_search(
|
||||
&grep_text,
|
||||
&files_to_search,
|
||||
options,
|
||||
total_files,
|
||||
filtered_file_count,
|
||||
case_insensitive,
|
||||
budget,
|
||||
abort_signal,
|
||||
base_path,
|
||||
arena,
|
||||
overflow_arena,
|
||||
);
|
||||
}
|
||||
GrepMode::Regex => build_regex(&grep_text, options.smart_case)
|
||||
.inspect_err(|err| {
|
||||
tracing::warn!("Regex compilation failed for {}. Error {}", grep_text, err);
|
||||
|
||||
regex_fallback_error = Some(err.to_string());
|
||||
})
|
||||
.ok(),
|
||||
};
|
||||
|
||||
let (multiline_segment_len, effective_pattern) = match replace_newline_escapes(&grep_text) {
|
||||
Some((replaced, first_newline_pos)) => (Some(first_newline_pos), replaced),
|
||||
None => (None, grep_text),
|
||||
};
|
||||
|
||||
let is_multiline = multiline_segment_len.is_some();
|
||||
|
||||
// when there is multiple line requested automatically expand the context to include all the lines
|
||||
let after_context = if is_multiline && regex.is_none() && options.after_context == 0 {
|
||||
effective_pattern.bytes().filter(|&b| b == b'\n').count()
|
||||
} else {
|
||||
options.after_context
|
||||
};
|
||||
|
||||
let finder_pattern: Vec<u8> = if case_insensitive {
|
||||
effective_pattern.as_bytes().to_ascii_lowercase()
|
||||
} else {
|
||||
effective_pattern.as_bytes().to_vec()
|
||||
};
|
||||
let finder = NeedleFinder::new(&finder_pattern, case_insensitive);
|
||||
let pattern_len = finder_pattern.len() as u32;
|
||||
|
||||
// PlainText (or regex-fallback-to-plain): literal bigram query.
|
||||
// Regex: decompose the pattern HIR into an AND/OR bigram query tree.
|
||||
let bigram_candidates = if regex.is_none() {
|
||||
literal_candidates(bigram_index, bigram_overlay, &[&effective_pattern])
|
||||
} else {
|
||||
regex_candidates(bigram_index, bigram_overlay, &effective_pattern)
|
||||
};
|
||||
|
||||
let (files_to_search, filtered_file_count) = prefilter_with_filepath_retry(
|
||||
files,
|
||||
constraints_from_query,
|
||||
bigram_candidates.as_deref(),
|
||||
base_count,
|
||||
options,
|
||||
arena,
|
||||
overflow_arena,
|
||||
);
|
||||
|
||||
if files_to_search.is_empty() {
|
||||
return GrepResult::empty(total_files, filtered_file_count);
|
||||
}
|
||||
|
||||
// `PlainTextMatcher` is used by the grep-searcher engine for line detection.
|
||||
// `PlainTextSink` / `RegexSink` handle highlight extraction independently via ripgrep create
|
||||
let plain_matcher = PlainTextMatcher { finder: &finder };
|
||||
|
||||
let searcher = {
|
||||
let mut b = SearcherBuilder::new();
|
||||
b.line_number(true).multi_line(is_multiline);
|
||||
b
|
||||
}
|
||||
.build();
|
||||
|
||||
let should_prefilter = regex.is_none();
|
||||
let mut result = perform_grep(
|
||||
&files_to_search,
|
||||
options,
|
||||
&GrepContext {
|
||||
total_files,
|
||||
filtered_file_count,
|
||||
budget,
|
||||
base_path,
|
||||
arena,
|
||||
overflow_arena,
|
||||
prefilter: should_prefilter.then_some(&finder),
|
||||
abort_signal,
|
||||
},
|
||||
// The single sink-selection point: every mode's matcher/sink pairing
|
||||
// is decided here based on the compiled pattern.
|
||||
|file_bytes: &[u8], max_matches: usize| {
|
||||
let state = SinkState {
|
||||
file_index: 0,
|
||||
matches: Vec::with_capacity(4),
|
||||
max_matches,
|
||||
before_context: options.before_context,
|
||||
after_context,
|
||||
classify_definitions: options.classify_definitions,
|
||||
};
|
||||
|
||||
match regex {
|
||||
Some(ref re) => {
|
||||
let regex_matcher = RegexMatcher {
|
||||
regex: re,
|
||||
is_multiline,
|
||||
};
|
||||
let mut sink = RegexSink { state, re };
|
||||
if let Err(e) = searcher.search_slice(®ex_matcher, file_bytes, &mut sink) {
|
||||
tracing::error!(error = %e, "Grep (regex) search failed");
|
||||
}
|
||||
sink.state.matches
|
||||
}
|
||||
None => {
|
||||
let mut sink = PlainTextSink {
|
||||
state,
|
||||
finder: &finder,
|
||||
pattern_len,
|
||||
multiline_segment_len,
|
||||
};
|
||||
if let Err(e) = searcher.search_slice(&plain_matcher, file_bytes, &mut sink) {
|
||||
tracing::error!(error = %e, "Grep (plain text) search failed");
|
||||
}
|
||||
sink.state.matches
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
result.regex_fallback_error = regex_fallback_error;
|
||||
result
|
||||
}
|
||||
|
||||
/// Replace unescaped `\n` escapes with real newlines in a single pass.
|
||||
///
|
||||
/// Returns `Some((replaced, first_newline_pos))` when the pattern contained at
|
||||
/// least one real `\n` escape (the user wants multiline search), where
|
||||
/// `first_newline_pos` is the byte offset of the first inserted newline in the
|
||||
/// replaced string. Returns `None` when nothing had to be replaced: `\\n` is
|
||||
/// preserved as-is (escaped backslash + literal `n`, e.g. `\\nvim-data`).
|
||||
pub(super) fn replace_newline_escapes(text: &str) -> Option<(String, usize)> {
|
||||
let bytes = text.as_bytes();
|
||||
let mut result = Vec::with_capacity(bytes.len());
|
||||
let mut first_newline_pos: Option<usize> = None;
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'\\' && i + 1 < bytes.len() {
|
||||
if bytes[i + 1] == b'n' {
|
||||
// Odd number of consecutive backslashes before 'n' -> real \n escape
|
||||
let mut backslash_count = 1;
|
||||
while backslash_count <= i && bytes[i - backslash_count] == b'\\' {
|
||||
backslash_count += 1;
|
||||
}
|
||||
if backslash_count % 2 == 1 {
|
||||
first_newline_pos.get_or_insert(result.len());
|
||||
result.push(b'\n');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result.push(bytes[i]);
|
||||
i += 1;
|
||||
} else {
|
||||
result.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let first_newline_pos = first_newline_pos?;
|
||||
let replaced = String::from_utf8(result).unwrap_or_else(|_| text.to_string());
|
||||
Some((replaced, first_newline_pos))
|
||||
}
|
||||
|
||||
pub fn parse_grep_query(query: &str) -> FFFQuery<'_> {
|
||||
let parser = QueryParser::new(GrepConfig);
|
||||
parser.parse(query)
|
||||
}
|
||||
|
||||
/// Extract the grep pattern text from the parsed query: all non-constraint
|
||||
/// tokens joined with spaces, e.g. `"name = *.rs someth"` -> `"name = someth"`
|
||||
/// with constraint `Extension("rs")`.
|
||||
fn extract_grep_text(query: &FFFQuery<'_>) -> String {
|
||||
if !matches!(query.fuzzy_query, fff_query_parser::FuzzyQuery::Empty) {
|
||||
return query.grep_text();
|
||||
}
|
||||
|
||||
// if constraint-only or empty query we use raw_query for backslash-escape handling
|
||||
let t = query.raw_query.trim();
|
||||
if t.starts_with('\\') && t.len() > 1 {
|
||||
let suffix = &t[1..];
|
||||
let parser = QueryParser::new(GrepConfig);
|
||||
if !parser.parse(suffix).constraints.is_empty() {
|
||||
return suffix.to_string();
|
||||
}
|
||||
}
|
||||
t.to_string()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct GrepContext<'a, 'b> {
|
||||
pub(super) total_files: usize,
|
||||
pub(super) filtered_file_count: usize,
|
||||
pub(super) budget: &'a ContentCacheBudget,
|
||||
pub(super) base_path: &'a Path,
|
||||
pub(super) arena: crate::simd_path::ArenaPtr,
|
||||
pub(super) overflow_arena: crate::simd_path::ArenaPtr,
|
||||
pub(super) prefilter: Option<&'a NeedleFinder<'b>>,
|
||||
pub(super) abort_signal: &'a AtomicBool,
|
||||
}
|
||||
|
||||
impl GrepContext<'_, '_> {
|
||||
#[inline]
|
||||
fn arena_for_file(&self, file: &FileItem) -> crate::simd_path::ArenaPtr {
|
||||
if file.is_overflow() {
|
||||
self.overflow_arena
|
||||
} else {
|
||||
self.arena
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
skip_all,
|
||||
level = Level::DEBUG,
|
||||
fields(prefiltered_count = files_to_search.len())
|
||||
)]
|
||||
pub(super) fn perform_grep<'a, F>(
|
||||
files_to_search: &[&'a FileItem],
|
||||
options: &GrepSearchOptions,
|
||||
ctx: &GrepContext<'_, '_>,
|
||||
search_file: F,
|
||||
) -> GrepResult<'a>
|
||||
where
|
||||
F: Fn(&[u8], usize) -> Vec<GrepMatch> + Sync,
|
||||
{
|
||||
let time_budget = if options.time_budget_ms > 0 {
|
||||
Some(std::time::Duration::from_millis(options.time_budget_ms))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let search_start = std::time::Instant::now();
|
||||
let page_limit = options.page_limit;
|
||||
let budget_exceeded = AtomicBool::new(false);
|
||||
|
||||
let mut result_files: Vec<&'a FileItem> = Vec::new();
|
||||
let mut all_matches: Vec<GrepMatch> = Vec::new();
|
||||
let mut files_consumed: usize = 0;
|
||||
let mut page_filled = false;
|
||||
|
||||
// Each chunk is a rayon barrier. A flat small chunk over 500k files = ~7800
|
||||
// barriers; x2 growth makes it logarithmic. But a too-aggressive growth
|
||||
// over-scans: when a page fills mid-chunk, the whole submitted chunk still
|
||||
// runs.
|
||||
//
|
||||
// So only grow when the prefilter is weak (large candidate set);
|
||||
// when bigram cut the set in half, keep fixed small chunks for cheap page-fill termination.
|
||||
let base_chunk = rayon::current_num_threads() * 4;
|
||||
let prefilter_strong = ctx.total_files > 0 && files_to_search.len() * 2 < ctx.total_files;
|
||||
let max_chunk = if prefilter_strong {
|
||||
base_chunk
|
||||
} else {
|
||||
(base_chunk * 256).max(8 * 1024)
|
||||
};
|
||||
let growth = if prefilter_strong { 1 } else { 2 };
|
||||
let mut chunk_size = base_chunk;
|
||||
let mut chunk_start = 0;
|
||||
|
||||
while chunk_start < files_to_search.len() {
|
||||
let chunk_end = (chunk_start + chunk_size).min(files_to_search.len());
|
||||
let chunk = &files_to_search[chunk_start..chunk_end];
|
||||
chunk_start = chunk_end;
|
||||
chunk_size = (chunk_size * growth).min(max_chunk);
|
||||
let chunk_offset = files_consumed;
|
||||
|
||||
let chunk_results: Vec<(usize, &'a FileItem, Vec<GrepMatch>)> = chunk
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.map_init(
|
||||
// tested it out a few times, this is just fine for rayon worker in this specific
|
||||
// case it doesn't reallocate this many times and it is actually faster than using
|
||||
// scoped threads with a predefined local scratch buffers because of spawn cost
|
||||
|| (Vec::with_capacity(64 * 1024), MmapSlot::default()),
|
||||
|(buf, mmap_slot), (local_idx, file)| {
|
||||
// perform all the atomic machinery on every 8th
|
||||
if local_idx % 8 == 0 {
|
||||
let mut need_abort = ctx.abort_signal.load(Ordering::Relaxed);
|
||||
if !need_abort
|
||||
&& let Some(budget) = time_budget
|
||||
&& all_matches.len() > 1
|
||||
&& search_start.elapsed() > budget
|
||||
{
|
||||
need_abort = true;
|
||||
}
|
||||
|
||||
if need_abort {
|
||||
budget_exceeded.store(true, Ordering::Relaxed);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let content = file.get_content_for_search(
|
||||
buf,
|
||||
mmap_slot,
|
||||
ctx.arena_for_file(file),
|
||||
ctx.base_path,
|
||||
ctx.budget,
|
||||
)?;
|
||||
|
||||
// Fast whole-file memmem check before entering the
|
||||
// grep-searcher machinery. Skips Vec alloc, Searcher
|
||||
// setup, and line-splitting for files that can't match.
|
||||
if let Some(pf) = ctx.prefilter
|
||||
&& pf.find(content).is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let file_matches = search_file(content, options.max_matches_per_file);
|
||||
|
||||
if file_matches.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((chunk_offset + local_idx, *file, file_matches))
|
||||
},
|
||||
)
|
||||
.flatten()
|
||||
.collect();
|
||||
|
||||
// Every file in the chunk was visited by rayon (matched or not).
|
||||
files_consumed = chunk_offset + chunk.len();
|
||||
|
||||
// Flatten this chunk's results into the accumulator.
|
||||
for (batch_idx, file, file_matches) in chunk_results {
|
||||
let file_result_idx = result_files.len();
|
||||
result_files.push(file);
|
||||
|
||||
for mut m in file_matches {
|
||||
m.file_index = file_result_idx;
|
||||
if options.trim_whitespace {
|
||||
m.trim_leading_whitespace();
|
||||
}
|
||||
all_matches.push(m);
|
||||
}
|
||||
|
||||
if all_matches.len() >= page_limit {
|
||||
// Tighten files_consumed to the file that tipped us over so
|
||||
// the next page resumes right after it.
|
||||
files_consumed = batch_idx + 1;
|
||||
page_filled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if page_filled || budget_exceeded.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no file had any match, we searched the entire slice.
|
||||
if result_files.is_empty() {
|
||||
files_consumed = files_to_search.len();
|
||||
}
|
||||
|
||||
let has_more = budget_exceeded.load(Ordering::Relaxed)
|
||||
|| (page_filled && files_consumed < files_to_search.len());
|
||||
|
||||
let next_file_offset = if has_more {
|
||||
options.file_offset + files_consumed
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
GrepResult {
|
||||
matches: all_matches,
|
||||
files_with_matches: result_files.len(),
|
||||
files: result_files,
|
||||
total_files_searched: files_consumed,
|
||||
total_files: ctx.total_files,
|
||||
filtered_file_count: ctx.filtered_file_count,
|
||||
next_file_offset,
|
||||
regex_fallback_error: None,
|
||||
literal_fallback: false,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
use super::grep::replace_newline_escapes;
|
||||
use super::*;
|
||||
|
||||
use crate::file_picker::{FilePicker, FilePickerOptions};
|
||||
use crate::index::BigramIndexBuilder;
|
||||
use std::io::Write;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
#[test]
|
||||
fn test_replace_newline_escapes() {
|
||||
// Single \n → multiline: replaced with a real newline at byte 3
|
||||
assert_eq!(
|
||||
replace_newline_escapes("foo\\nbar"),
|
||||
Some(("foo\nbar".to_string(), 3))
|
||||
);
|
||||
// \\n → escaped backslash + literal n, NOT multiline
|
||||
// (this is what the user types when grepping Rust source with `\\nvim`)
|
||||
assert_eq!(replace_newline_escapes("foo\\\\nvim-data"), None);
|
||||
// Real-world: source file has literal \\AppData\\Local\\nvim-data
|
||||
// (double backslash in the file, so user types double backslash)
|
||||
assert_eq!(
|
||||
replace_newline_escapes(r#"format!("{}\\AppData\\Local\\nvim-data","#),
|
||||
None
|
||||
);
|
||||
// No \n at all
|
||||
assert_eq!(replace_newline_escapes("hello world"), None);
|
||||
// \\\\n → even number of backslashes before n → NOT multiline
|
||||
assert_eq!(replace_newline_escapes("foo\\\\\\\\nbar"), None);
|
||||
// \\\n → 3 backslashes: first two pair up, third + n = \n → multiline,
|
||||
// newline lands after "foo" + 2 kept backslashes = byte 5
|
||||
assert_eq!(
|
||||
replace_newline_escapes("foo\\\\\\nbar"),
|
||||
Some(("foo\\\\\nbar".to_string(), 5))
|
||||
);
|
||||
// Position is for the FIRST newline when there are several
|
||||
assert_eq!(
|
||||
replace_newline_escapes("a\\nb\\nc"),
|
||||
Some(("a\nb\nc".to_string(), 1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzzy_typo_scoring() {
|
||||
// Mirror the config from fuzzy_grep_search
|
||||
let needle = "schema";
|
||||
let max_typos = (needle.len() / 3).min(2); // 2
|
||||
let config = neo_frizbee::Config {
|
||||
max_typos: Some(max_typos as u16),
|
||||
sort: false,
|
||||
scoring: neo_frizbee::Scoring {
|
||||
exact_match_bonus: 100,
|
||||
..neo_frizbee::Scoring::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let min_matched = needle.len().saturating_sub(1).max(1); // 5
|
||||
let max_match_span = needle.len() + 4; // 10
|
||||
|
||||
// Helper: check if a match would pass our post-filters
|
||||
let passes = |n: &str, h: &str| -> bool {
|
||||
let Some(mut mi) = neo_frizbee::match_list_indices(n, &[h], &config)
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
// upstream returns indices in reverse order, sort ascending
|
||||
mi.indices.sort_unstable();
|
||||
if mi.indices.len() < min_matched {
|
||||
return false;
|
||||
}
|
||||
if let (Some(&first), Some(&last)) = (mi.indices.first(), mi.indices.last()) {
|
||||
let span = last - first + 1;
|
||||
if span > max_match_span {
|
||||
return false;
|
||||
}
|
||||
let density = (mi.indices.len() * 100) / span;
|
||||
if density < 70 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
};
|
||||
|
||||
// Exact match: must pass
|
||||
assert!(passes("schema", "schema"));
|
||||
// Exact in longer line: must pass
|
||||
assert!(passes("schema", " schema: String,"));
|
||||
// In identifier: must pass
|
||||
assert!(passes("schema", "pub fn validate_schema() {}"));
|
||||
// Transposition: must pass
|
||||
assert!(passes("shcema", "schema"));
|
||||
// Partial "ema" only line: must NOT pass
|
||||
assert!(!passes("schema", "it has ema in it"));
|
||||
// Completely unrelated: must NOT pass
|
||||
assert!(!passes("schema", "hello world foo bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_grep_search() {
|
||||
use crate::file_picker::{FilePicker, FilePickerOptions};
|
||||
use std::io::Write;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
// File 1: has "GrepMode" and "GrepMatch"
|
||||
{
|
||||
let mut f = std::fs::File::create(dir.path().join("grep.rs")).unwrap();
|
||||
writeln!(f, "pub enum GrepMode {{").unwrap();
|
||||
writeln!(f, " PlainText,").unwrap();
|
||||
writeln!(f, " Regex,").unwrap();
|
||||
writeln!(f, "}}").unwrap();
|
||||
writeln!(f, "pub struct GrepMatch {{").unwrap();
|
||||
writeln!(f, " pub line_number: u64,").unwrap();
|
||||
writeln!(f, "}}").unwrap();
|
||||
}
|
||||
|
||||
// File 2: has "PlainTextMatcher" only
|
||||
{
|
||||
let mut f = std::fs::File::create(dir.path().join("matcher.rs")).unwrap();
|
||||
writeln!(f, "struct PlainTextMatcher {{").unwrap();
|
||||
writeln!(f, " needle: Vec<u8>,").unwrap();
|
||||
writeln!(f, "}}").unwrap();
|
||||
}
|
||||
|
||||
// File 3: no matches
|
||||
{
|
||||
let mut f = std::fs::File::create(dir.path().join("other.rs")).unwrap();
|
||||
writeln!(f, "fn main() {{").unwrap();
|
||||
writeln!(f, " println!(\"hello\");").unwrap();
|
||||
writeln!(f, "}}").unwrap();
|
||||
}
|
||||
|
||||
let mut picker = FilePicker::new(FilePickerOptions {
|
||||
base_path: dir.path().to_str().unwrap().into(),
|
||||
watch: false,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
picker.collect_files().unwrap();
|
||||
|
||||
let files = picker.get_files();
|
||||
let arena = picker.arena_base_ptr();
|
||||
|
||||
let options = super::GrepSearchOptions {
|
||||
max_file_size: MAX_FFFILE_SIZE,
|
||||
max_matches_per_file: 0,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: 100,
|
||||
mode: super::GrepMode::PlainText,
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
abort_signal: None,
|
||||
};
|
||||
let no_cancel = AtomicBool::new(false);
|
||||
|
||||
// Test with 3 patterns
|
||||
let result = super::multi_grep_search(
|
||||
files,
|
||||
&["GrepMode", "GrepMatch", "PlainTextMatcher"],
|
||||
&[],
|
||||
&options,
|
||||
picker.cache_budget(),
|
||||
None,
|
||||
None,
|
||||
&no_cancel,
|
||||
dir.path(),
|
||||
arena,
|
||||
arena,
|
||||
);
|
||||
|
||||
assert!(
|
||||
result.matches.len() >= 3,
|
||||
"Expected at least 3 matches, got {}",
|
||||
result.matches.len()
|
||||
);
|
||||
|
||||
let has_grep_mode = result
|
||||
.matches
|
||||
.iter()
|
||||
.any(|m| m.line_content.contains("GrepMode"));
|
||||
let has_grep_match = result
|
||||
.matches
|
||||
.iter()
|
||||
.any(|m| m.line_content.contains("GrepMatch"));
|
||||
let has_plain_text_matcher = result
|
||||
.matches
|
||||
.iter()
|
||||
.any(|m| m.line_content.contains("PlainTextMatcher"));
|
||||
|
||||
assert!(has_grep_mode, "Should find GrepMode");
|
||||
assert!(has_grep_match, "Should find GrepMatch");
|
||||
assert!(has_plain_text_matcher, "Should find PlainTextMatcher");
|
||||
|
||||
assert_eq!(result.files.len(), 2, "Should match exactly 2 files");
|
||||
|
||||
// Test with single pattern
|
||||
let result2 = super::multi_grep_search(
|
||||
files,
|
||||
&["PlainTextMatcher"],
|
||||
&[],
|
||||
&options,
|
||||
picker.cache_budget(),
|
||||
None,
|
||||
None,
|
||||
&no_cancel,
|
||||
dir.path(),
|
||||
arena,
|
||||
arena,
|
||||
);
|
||||
assert_eq!(
|
||||
result2.matches.len(),
|
||||
1,
|
||||
"Single pattern should find 1 match"
|
||||
);
|
||||
|
||||
// Test with empty patterns
|
||||
let result3 = super::multi_grep_search(
|
||||
files,
|
||||
&[],
|
||||
&[],
|
||||
&options,
|
||||
picker.cache_budget(),
|
||||
None,
|
||||
None,
|
||||
&no_cancel,
|
||||
dir.path(),
|
||||
arena,
|
||||
arena,
|
||||
);
|
||||
assert_eq!(
|
||||
result3.matches.len(),
|
||||
0,
|
||||
"Empty patterns should find nothing"
|
||||
);
|
||||
}
|
||||
|
||||
/// E2E: multiline grep (`\n` in query) and escaped-backslash literals (`\\n`)
|
||||
/// through the full picker.grep pipeline, in both PlainText and Regex modes.
|
||||
#[test]
|
||||
fn test_grep_multiline_and_escaped_newline_e2e() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let base = crate::path_utils::canonicalize(dir.path()).unwrap();
|
||||
|
||||
// Content spanning two lines: "hello unicorn\nrainbow world"
|
||||
{
|
||||
let mut f = std::fs::File::create(base.join("multi.txt")).unwrap();
|
||||
writeln!(f, "hello unicorn").unwrap();
|
||||
writeln!(f, "rainbow world").unwrap();
|
||||
}
|
||||
// Content with a literal double backslash before "nvim": `C:\\Users\\nvim-data`
|
||||
{
|
||||
let mut f = std::fs::File::create(base.join("winpath.rs")).unwrap();
|
||||
writeln!(f, "let p = \"C:\\\\Users\\\\nvim-data\";").unwrap();
|
||||
}
|
||||
{
|
||||
let mut f = std::fs::File::create(base.join("noise.txt")).unwrap();
|
||||
writeln!(f, "nothing interesting here").unwrap();
|
||||
}
|
||||
|
||||
let mut picker = FilePicker::new(FilePickerOptions {
|
||||
base_path: base.to_str().unwrap().into(),
|
||||
watch: false,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
picker.collect_files().unwrap();
|
||||
|
||||
let options = crate::GrepSearchOptions {
|
||||
page_limit: 100,
|
||||
max_matches_per_file: 0,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// 1. PlainText + `\n`: needle becomes a real newline, match spans two lines
|
||||
let query = super::parse_grep_query("unicorn\\nrainbow");
|
||||
let result = picker.grep(&query, &options);
|
||||
assert_eq!(
|
||||
result.files.len(),
|
||||
1,
|
||||
"multiline plaintext should match multi.txt"
|
||||
);
|
||||
assert_eq!(result.files[0].relative_path(&picker), "multi.txt");
|
||||
let m = &result.matches[0];
|
||||
assert_eq!(m.line_content, "hello unicorn");
|
||||
// Auto after-context: the rest of the matched span is returned
|
||||
assert_eq!(m.context_after, vec!["rainbow world"]);
|
||||
// First needle segment highlighted as the line suffix
|
||||
assert_eq!(m.match_byte_offsets.as_slice(), &[(6, 13)]);
|
||||
assert_eq!(m.col, 6);
|
||||
|
||||
// 2. PlainText + `\\n`: literal backslash + n, must NOT be mangled to newline
|
||||
let query = super::parse_grep_query("\\\\nvim-data");
|
||||
let result = picker.grep(&query, &options);
|
||||
assert_eq!(
|
||||
result.files.len(),
|
||||
1,
|
||||
"escaped backslash should match winpath.rs literally"
|
||||
);
|
||||
assert_eq!(result.files[0].relative_path(&picker), "winpath.rs");
|
||||
assert!(result.matches[0].line_content.contains("\\\\nvim-data"));
|
||||
assert!(result.matches[0].context_after.is_empty());
|
||||
|
||||
// 3. Regex + `\n`: goes through the MultiLine searcher strategy
|
||||
let regex_options = super::GrepSearchOptions {
|
||||
mode: super::GrepMode::Regex,
|
||||
..options.clone()
|
||||
};
|
||||
let query = super::parse_grep_query("unicorn\\nrainbow");
|
||||
let result = picker.grep(&query, ®ex_options);
|
||||
assert!(result.regex_fallback_error.is_none());
|
||||
assert_eq!(
|
||||
result.files.len(),
|
||||
1,
|
||||
"multiline regex should match multi.txt"
|
||||
);
|
||||
assert_eq!(result.files[0].relative_path(&picker), "multi.txt");
|
||||
let m = &result.matches[0];
|
||||
// Blob is normalized: single-line content + remaining lines as context
|
||||
assert_eq!(m.line_content, "hello unicorn");
|
||||
assert_eq!(m.context_after, vec!["rainbow world"]);
|
||||
// Highlight clamped to the visible first line
|
||||
assert_eq!(m.match_byte_offsets.as_slice(), &[(6, 13)]);
|
||||
}
|
||||
|
||||
/// Regression test for issue #407: Live grep returns duplicate results
|
||||
/// when the bigram candidate bitset has trailing bits set beyond
|
||||
/// `base_file_count`. The bitset is rounded up to a multiple of 64 bits
|
||||
/// so any trailing bit that happens to be set (e.g. from overlay data)
|
||||
/// would previously map to an overflow file index, which was then also
|
||||
/// unconditionally appended by the overflow loop, producing duplicates.
|
||||
#[test]
|
||||
fn test_grep_no_duplicates_with_overflow_trailing_bits() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// Match the picker's internal dunce-canonicalize so paths passed to
|
||||
// on_create_or_modify resolve back to the same base_path on Windows.
|
||||
let base = crate::path_utils::canonicalize(dir.path()).unwrap();
|
||||
|
||||
// Five base files: only three contain the pattern "unicorn".
|
||||
// We need some files WITHOUT the pattern so the bigrams for
|
||||
// "unicorn" aren't treated as ubiquitous (≥90% of files) and
|
||||
// dropped from the index during compress().
|
||||
let base_contents: &[(&str, &str)] = &[
|
||||
("a.txt", "hello unicorn world"),
|
||||
("b.txt", "another unicorn line"),
|
||||
("c.txt", "one more unicorn here"),
|
||||
("d.txt", "nothing special in here"),
|
||||
("e.txt", "just some random content"),
|
||||
];
|
||||
for (name, content) in base_contents {
|
||||
let mut f = std::fs::File::create(base.join(name)).unwrap();
|
||||
writeln!(f, "{}", content).unwrap();
|
||||
}
|
||||
|
||||
let mut picker = FilePicker::new(FilePickerOptions {
|
||||
base_path: base.to_str().unwrap().into(),
|
||||
watch: false,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
picker.collect_files().unwrap();
|
||||
assert_eq!(picker.get_files().len(), 5);
|
||||
|
||||
// Manually build a bigram index over the 5 base files.
|
||||
let base_count = 5usize;
|
||||
let consec_builder = BigramIndexBuilder::new(base_count);
|
||||
let skip_builder = BigramIndexBuilder::new(base_count);
|
||||
for (i, (_, content)) in base_contents.iter().enumerate() {
|
||||
consec_builder.add_file_content(&skip_builder, i, content.as_bytes());
|
||||
}
|
||||
let mut index = consec_builder.compress(Some(0));
|
||||
index.set_skip_index(skip_builder.compress(Some(0)));
|
||||
picker.set_bigram_index(index);
|
||||
|
||||
// Add three overflow files (new after the bigram index was built),
|
||||
// all containing "unicorn".
|
||||
for name in ["f.txt", "g.txt", "h.txt"] {
|
||||
let path = base.join(name);
|
||||
let mut f = std::fs::File::create(&path).unwrap();
|
||||
writeln!(f, "overflow unicorn entry").unwrap();
|
||||
drop(f);
|
||||
picker.handle_create_or_modify(&path);
|
||||
}
|
||||
assert_eq!(picker.get_files().len(), 8);
|
||||
|
||||
// Inject a trailing bit into the overlay at a file index that
|
||||
// corresponds to an overflow file (i.e. >= base_file_count=5 but
|
||||
// < bitset_word_size=64). Without the fix, the bigram-candidate
|
||||
// merge would set this bit in the bitset, and the bitset loop would
|
||||
// push files[6] while the overflow loop also appends files[5..]
|
||||
// which includes files[6], producing a duplicate.
|
||||
let overflow_rel = "g.txt"; // middle overflow file
|
||||
let overflow_abs = picker
|
||||
.get_files()
|
||||
.iter()
|
||||
.position(|f| f.relative_path(&picker) == overflow_rel)
|
||||
.expect("overflow file should be present");
|
||||
assert!(overflow_abs >= base_count);
|
||||
assert!(
|
||||
overflow_abs < 64,
|
||||
"index must fit in the single bitset word"
|
||||
);
|
||||
|
||||
if let Some(overlay) = picker.bigram_overlay() {
|
||||
overlay
|
||||
.write()
|
||||
.modify_file(overflow_abs, b"overflow unicorn entry");
|
||||
}
|
||||
|
||||
// Run a grep for "unicorn": six files match
|
||||
// (a, b, c in base + f, g, h in overflow).
|
||||
let query = super::parse_grep_query("unicorn");
|
||||
let options = super::GrepSearchOptions {
|
||||
max_file_size: MAX_FFFILE_SIZE,
|
||||
max_matches_per_file: 0,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: 100,
|
||||
mode: super::GrepMode::PlainText,
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
abort_signal: Some(std::sync::Arc::new(AtomicBool::new(false))),
|
||||
};
|
||||
let result = picker.grep(&query, &options);
|
||||
|
||||
// Collect the matched relative paths via the returned files list.
|
||||
let mut paths: Vec<String> = result
|
||||
.files
|
||||
.iter()
|
||||
.map(|f| f.relative_path(&picker))
|
||||
.collect();
|
||||
paths.sort();
|
||||
|
||||
// Every file (base + overflow) should match exactly once.
|
||||
let mut dedup = paths.clone();
|
||||
dedup.dedup();
|
||||
assert_eq!(
|
||||
dedup, paths,
|
||||
"grep must not return duplicate results (issue #407): {:?}",
|
||||
paths
|
||||
);
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec!["a.txt", "b.txt", "c.txt", "f.txt", "g.txt", "h.txt"],
|
||||
);
|
||||
|
||||
// And the match count must equal the number of files (one line per
|
||||
// file). A duplicate entry in files_to_search would double-count
|
||||
// matches for the duplicated file.
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
6,
|
||||
"expected exactly one match per file, got {}",
|
||||
result.matches.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #756: an AI-mode regex query with an inline FilePath scope and
|
||||
/// top-level alternation. The regex fragments are swallowed as bogus Glob
|
||||
/// constraints, the constrained search finds nothing, and the literal/regex
|
||||
/// fallback must NOT drop the FilePath scope — otherwise the `|` branch leaks
|
||||
/// matches into files outside the pinned path.
|
||||
#[test]
|
||||
fn regex_fallback_keeps_file_path_scope_issue_756() {
|
||||
use fff_query_parser::{AiGrepConfig, QueryParser};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let base = crate::path_utils::canonicalize(dir.path()).unwrap();
|
||||
std::fs::create_dir(base.join("scope")).unwrap();
|
||||
std::fs::write(
|
||||
base.join("scope").join("target.css"),
|
||||
"/* ---------- target ---------- */\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
base.join("outside.css"),
|
||||
"/* ---------- outside ---------- */\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut picker = FilePicker::new(FilePickerOptions {
|
||||
base_path: base.to_str().unwrap().into(),
|
||||
watch: false,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
picker.collect_files().unwrap();
|
||||
|
||||
let options = crate::GrepSearchOptions {
|
||||
mode: super::GrepMode::Regex,
|
||||
smart_case: true,
|
||||
max_matches_per_file: 80,
|
||||
page_limit: 100,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let raw = r"scope/target.css ^/\* |^\s*/\* ----------";
|
||||
let query = QueryParser::new(AiGrepConfig).parse(raw);
|
||||
let result = picker.grep(&query, &options);
|
||||
let mut paths: Vec<String> = result
|
||||
.files
|
||||
.iter()
|
||||
.map(|f| f.relative_path(&picker))
|
||||
.collect();
|
||||
paths.sort();
|
||||
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec!["scope/target.css"],
|
||||
"regex fallback must not leak outside the FilePath scope"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Live grep. `grep.rs` implements the main plain-text path, the parallel
|
||||
//! scan engine, and the `grep_search` entry point that picks the matcher/sink
|
||||
//! for every mode in one place; `regex`, `multi_pattern`, and `fuzzy_grep`
|
||||
//! hold the mode-specific machinery on top of the shared `prefilter`/`sink`.
|
||||
|
||||
#[allow(clippy::module_inception)]
|
||||
mod grep;
|
||||
pub use grep::*;
|
||||
|
||||
mod fuzzy_grep;
|
||||
mod multi_pattern;
|
||||
mod prefilter;
|
||||
mod regex;
|
||||
mod sink;
|
||||
mod types;
|
||||
|
||||
#[cfg(feature = "definitions")]
|
||||
mod classify;
|
||||
#[cfg(feature = "definitions")]
|
||||
pub use classify::*;
|
||||
|
||||
pub(crate) use multi_pattern::multi_grep_search;
|
||||
pub use regex::has_regex_metacharacters;
|
||||
pub use types::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod grep_tests;
|
||||
@@ -0,0 +1,191 @@
|
||||
use super::grep::{GrepContext, perform_grep};
|
||||
use super::prefilter::prefilter_files;
|
||||
use super::sink::{SinkState, debug_assert_newline_terminator};
|
||||
use super::types::{GrepResult, GrepSearchOptions};
|
||||
use crate::index::{BigramFilter, BigramOverlay, bigram_boundary, literal_candidates};
|
||||
use crate::types::{ContentCacheBudget, FileItem, FileSliceExt};
|
||||
use aho_corasick::AhoCorasick;
|
||||
use fff_grep::{
|
||||
Searcher, SearcherBuilder, Sink, SinkMatch,
|
||||
matcher::{Match, Matcher, NoError},
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
/// A `grep_matcher::Matcher` backed by Aho-Corasick for multi-pattern search.
|
||||
///
|
||||
/// Finds the first occurrence of any pattern starting at the given offset.
|
||||
/// Always reports `\n` as the line terminator for the fast candidate-line path.
|
||||
struct AhoCorasickMatcher<'a> {
|
||||
ac: &'a AhoCorasick,
|
||||
}
|
||||
|
||||
impl Matcher for AhoCorasickMatcher<'_> {
|
||||
type Error = NoError;
|
||||
|
||||
#[inline]
|
||||
fn find_at(&self, haystack: &[u8], at: usize) -> std::result::Result<Option<Match>, NoError> {
|
||||
let hay = &haystack[at..];
|
||||
let found: Option<aho_corasick::Match> = self.ac.find(hay);
|
||||
Ok(found.map(|m| Match::new(at + m.start(), at + m.end())))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn line_terminator(&self) -> Option<fff_grep::LineTerminator> {
|
||||
Some(fff_grep::LineTerminator::byte(b'\n'))
|
||||
}
|
||||
}
|
||||
|
||||
/// Sink for Aho-Corasick multi-pattern mode.
|
||||
///
|
||||
/// Collects all pattern match positions on each matched line for highlighting.
|
||||
struct AhoCorasickSink<'a> {
|
||||
state: SinkState,
|
||||
ac: &'a AhoCorasick,
|
||||
}
|
||||
|
||||
impl Sink for AhoCorasickSink<'_> {
|
||||
type Error = std::io::Error;
|
||||
|
||||
fn matched(&mut self, searcher: &Searcher, mat: &SinkMatch<'_>) -> Result<bool, Self::Error> {
|
||||
debug_assert_newline_terminator(searcher);
|
||||
if self.state.max_matches != 0 && self.state.matches.len() >= self.state.max_matches {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let line_bytes = mat.bytes();
|
||||
let (display_bytes, display_len, line_number, byte_offset) =
|
||||
SinkState::prepare_line(line_bytes, mat);
|
||||
|
||||
let line_content = String::from_utf8_lossy(display_bytes).into_owned();
|
||||
let mut match_byte_offsets: SmallVec<[(u32, u32); 4]> = SmallVec::new();
|
||||
let mut col = 0usize;
|
||||
let mut first = true;
|
||||
|
||||
for m in self.ac.find_iter(display_bytes as &[u8]) {
|
||||
let abs_start = m.start() as u32;
|
||||
let abs_end = (m.end() as u32).min(display_len);
|
||||
if first {
|
||||
col = abs_start as usize;
|
||||
first = false;
|
||||
}
|
||||
match_byte_offsets.push((abs_start, abs_end));
|
||||
}
|
||||
|
||||
let (context_before, context_after) = self.state.extract_context(mat);
|
||||
self.state.push_match(
|
||||
line_number,
|
||||
col,
|
||||
byte_offset,
|
||||
line_content,
|
||||
match_byte_offsets,
|
||||
context_before,
|
||||
context_after,
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn finish(&mut self, _: &Searcher, _: &fff_grep::SinkFinish) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-pattern OR search using Aho-Corasick.
|
||||
///
|
||||
/// Builds a single automaton from all patterns and searches each file in one
|
||||
/// pass. This is significantly faster than regex alternation for literal text
|
||||
/// searches because Aho-Corasick uses SIMD-accelerated multi-needle matching.
|
||||
///
|
||||
/// Returns the same `GrepResult` type as `grep_search`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn multi_grep_search<'a>(
|
||||
files: &'a [FileItem],
|
||||
patterns: &[&str],
|
||||
constraints: &[fff_query_parser::Constraint<'_>],
|
||||
options: &GrepSearchOptions,
|
||||
budget: &ContentCacheBudget,
|
||||
bigram_index: Option<&BigramFilter>,
|
||||
bigram_overlay: Option<&BigramOverlay>,
|
||||
abort_signal: &AtomicBool,
|
||||
base_path: &Path,
|
||||
arena: crate::simd_path::ArenaPtr,
|
||||
overflow_arena: crate::simd_path::ArenaPtr,
|
||||
) -> GrepResult<'a> {
|
||||
let total_files = files.live_count();
|
||||
|
||||
if patterns.is_empty() || patterns.iter().all(|p| p.is_empty()) {
|
||||
return GrepResult::empty(total_files, total_files);
|
||||
}
|
||||
|
||||
let bigram_candidates = literal_candidates(bigram_index, bigram_overlay, patterns);
|
||||
let base_file_count = bigram_boundary(bigram_overlay, files.len());
|
||||
|
||||
// Constraints are separate from patterns, so a miss must not broaden the search.
|
||||
let (files_to_search, filtered_file_count) = prefilter_files(
|
||||
files,
|
||||
constraints,
|
||||
bigram_candidates.as_deref(),
|
||||
base_file_count,
|
||||
options,
|
||||
arena,
|
||||
overflow_arena,
|
||||
);
|
||||
|
||||
if files_to_search.is_empty() {
|
||||
return GrepResult::empty(total_files, filtered_file_count);
|
||||
}
|
||||
|
||||
// Smart case: case-insensitive when all patterns are lowercase
|
||||
let case_insensitive = if options.smart_case {
|
||||
!patterns.iter().any(|p| p.chars().any(|c| c.is_uppercase()))
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let ac = aho_corasick::AhoCorasickBuilder::new()
|
||||
.ascii_case_insensitive(case_insensitive)
|
||||
.build(patterns)
|
||||
.expect("Aho-Corasick build should not fail for literal patterns");
|
||||
|
||||
let searcher = {
|
||||
let mut b = SearcherBuilder::new();
|
||||
b.line_number(true);
|
||||
b
|
||||
}
|
||||
.build();
|
||||
|
||||
let ac_matcher = AhoCorasickMatcher { ac: &ac };
|
||||
perform_grep(
|
||||
&files_to_search,
|
||||
options,
|
||||
&GrepContext {
|
||||
total_files,
|
||||
filtered_file_count,
|
||||
budget,
|
||||
base_path,
|
||||
arena,
|
||||
overflow_arena,
|
||||
prefilter: None, // no memmem prefilter for multi-pattern search
|
||||
abort_signal,
|
||||
},
|
||||
|file_bytes: &[u8], max_matches: usize| {
|
||||
let state = SinkState {
|
||||
file_index: 0,
|
||||
matches: Vec::with_capacity(4),
|
||||
max_matches,
|
||||
before_context: options.before_context,
|
||||
after_context: options.after_context,
|
||||
classify_definitions: options.classify_definitions,
|
||||
};
|
||||
|
||||
let mut sink = AhoCorasickSink { state, ac: &ac };
|
||||
|
||||
if let Err(e) = searcher.search_slice(&ac_matcher, file_bytes, &mut sink) {
|
||||
tracing::error!(error = %e, "Grep (aho-corasick multi) search failed");
|
||||
}
|
||||
|
||||
sink.state.matches
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
use super::types::GrepSearchOptions;
|
||||
use crate::index::BigramFilter;
|
||||
use crate::index::constraints::{ConstraintPlan, ConstraintsBuffers};
|
||||
use crate::sort_buffer::sort_with_buffer;
|
||||
use crate::types::FileItem;
|
||||
use fff_query_parser::Constraint;
|
||||
|
||||
/// Prefilter with a FilePath-constraint fallback: when constraints yield 0
|
||||
/// files and the query had FilePath constraints, retry without them (the path
|
||||
/// token was likely part of the search text).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn prefilter_with_filepath_retry<'a>(
|
||||
files: &'a [FileItem],
|
||||
constraints: &[Constraint<'_>],
|
||||
bigram_candidates: Option<&[u64]>,
|
||||
base_count: usize,
|
||||
options: &GrepSearchOptions,
|
||||
arena: crate::simd_path::ArenaPtr,
|
||||
overflow_arena: crate::simd_path::ArenaPtr,
|
||||
) -> (Vec<&'a FileItem>, usize) {
|
||||
let (files_to_search, filtered_file_count) = prefilter_files(
|
||||
files,
|
||||
constraints,
|
||||
bigram_candidates,
|
||||
base_count,
|
||||
options,
|
||||
arena,
|
||||
overflow_arena,
|
||||
);
|
||||
|
||||
if !files_to_search.is_empty() {
|
||||
return (files_to_search, filtered_file_count);
|
||||
}
|
||||
|
||||
let Some(stripped) = strip_file_path_constraint_if_present(constraints) else {
|
||||
return (files_to_search, filtered_file_count);
|
||||
};
|
||||
|
||||
prefilter_files(
|
||||
files,
|
||||
&stripped,
|
||||
bigram_candidates,
|
||||
base_count,
|
||||
options,
|
||||
arena,
|
||||
overflow_arena,
|
||||
)
|
||||
}
|
||||
|
||||
/// Single pass prefilter that doesn't involve file reading
|
||||
/// allocates only amount of memory required for storing references of the FileItems have to be
|
||||
/// opened for grepping unaviodably, in the worst case allocates N * <word> memory if no prefilter needed
|
||||
pub(crate) fn prefilter_files<'a>(
|
||||
files: &'a [FileItem],
|
||||
constraints: &[Constraint<'_>],
|
||||
bigram_candidates: Option<&[u64]>,
|
||||
base_count: usize,
|
||||
options: &GrepSearchOptions,
|
||||
arena: crate::simd_path::ArenaPtr,
|
||||
overflow_arena: crate::simd_path::ArenaPtr,
|
||||
) -> (Vec<&'a FileItem>, usize) {
|
||||
let max_file_size = options.max_file_size;
|
||||
let plan = if constraints.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(ConstraintPlan::build(
|
||||
constraints,
|
||||
files,
|
||||
arena,
|
||||
overflow_arena,
|
||||
))
|
||||
};
|
||||
|
||||
let mut scratch = ConstraintsBuffers::new();
|
||||
|
||||
#[inline(always)]
|
||||
fn basic_prefilter(file: &FileItem, max: u64) -> bool {
|
||||
!file.is_deleted() && !file.is_binary() && file.size > 0 && file.size <= max
|
||||
}
|
||||
|
||||
// squeeze as much prefilters into a single loop as possible
|
||||
let mut prefiltered: Vec<&FileItem> = match bigram_candidates {
|
||||
Some(candidates) => {
|
||||
let boundary = base_count.min(files.len());
|
||||
let (indexed, tail) = files.split_at(boundary);
|
||||
|
||||
let cap = BigramFilter::count_candidates(candidates) + tail.len();
|
||||
let mut out: Vec<&FileItem> = Vec::with_capacity(cap);
|
||||
|
||||
let full_words = boundary / 64;
|
||||
let last_word_bits = boundary % 64;
|
||||
|
||||
// we need this because we already had a regression of the wrong bit
|
||||
// has been set for the very last word based on the overlay, it's pretty cheap
|
||||
macro_rules! evaluate_bigram_match_word {
|
||||
($word:expr, $base:expr) => {{
|
||||
let mut bits: u64 = $word;
|
||||
while bits != 0 {
|
||||
let bit = bits.trailing_zeros() as usize;
|
||||
let file_idx = $base + bit;
|
||||
bits &= bits - 1;
|
||||
|
||||
let f = unsafe { indexed.get_unchecked(file_idx) };
|
||||
if !basic_prefilter(f, max_file_size) {
|
||||
continue;
|
||||
}
|
||||
if let Some(plan) = plan.as_ref()
|
||||
&& !plan.matches(f, file_idx, arena, overflow_arena, &mut scratch)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.push(f);
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
// Full words: every set bit guaranteed `< boundary`.
|
||||
for (word_idx, &word) in candidates.iter().take(full_words).enumerate() {
|
||||
if word != 0 {
|
||||
evaluate_bigram_match_word!(word, word_idx * 64);
|
||||
}
|
||||
}
|
||||
|
||||
// Last partial word: mask bits past `boundary` once at word load.
|
||||
if last_word_bits != 0 {
|
||||
// this will get only (mod 64) bits from the last word guarantee that it's 0 padded
|
||||
let last_mask: u64 = (1u64 << last_word_bits) - 1;
|
||||
let word = candidates[full_words] & last_mask;
|
||||
if word != 0 {
|
||||
evaluate_bigram_match_word!(word, full_words * 64);
|
||||
}
|
||||
}
|
||||
|
||||
// Sequential processing for non-bigrammable files: they are always in the end
|
||||
for (offset, f) in tail.iter().enumerate() {
|
||||
if !basic_prefilter(f, max_file_size) {
|
||||
continue;
|
||||
}
|
||||
if let Some(ref p) = plan
|
||||
&& !p.matches(f, boundary + offset, arena, overflow_arena, &mut scratch)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.push(f);
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
// this will be executed if there is no bigram, in the worst case it will allocate
|
||||
// whole array of files but probability in the real repo of NO preflter working is so
|
||||
// low that we just ignore that, usually there would be at least a few files excluded
|
||||
None => {
|
||||
let mut out: Vec<&FileItem> = Vec::new();
|
||||
for (idx, f) in files.iter().enumerate() {
|
||||
if !basic_prefilter(f, max_file_size) {
|
||||
continue;
|
||||
}
|
||||
if let Some(ref p) = plan
|
||||
&& !p.matches(f, idx, arena, overflow_arena, &mut scratch)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.push(f);
|
||||
}
|
||||
out
|
||||
}
|
||||
};
|
||||
|
||||
let total_count = prefiltered.len();
|
||||
|
||||
sort_with_buffer(&mut prefiltered, |a, b| {
|
||||
b.total_frecency_score()
|
||||
.cmp(&a.total_frecency_score())
|
||||
.then(b.modified.cmp(&a.modified))
|
||||
});
|
||||
|
||||
if options.file_offset > 0 && options.file_offset < total_count {
|
||||
let paginated = prefiltered.split_off(options.file_offset);
|
||||
(paginated, total_count)
|
||||
} else if options.file_offset >= total_count {
|
||||
(Vec::new(), total_count)
|
||||
} else {
|
||||
(prefiltered, total_count)
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_file_path_constraint_if_present<'a>(
|
||||
constraints: &[Constraint<'a>],
|
||||
) -> Option<fff_query_parser::ConstraintVec<'a>> {
|
||||
if !constraints
|
||||
.iter()
|
||||
.any(|c| matches!(c, Constraint::FilePath(_)))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let filtered: fff_query_parser::ConstraintVec<'a> = constraints
|
||||
.iter()
|
||||
.filter(|c| !matches!(c, Constraint::FilePath(_)))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
Some(filtered)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
use super::sink::{SinkState, debug_assert_newline_terminator, split_multiline_blob};
|
||||
use fff_grep::{
|
||||
Searcher, Sink, SinkMatch,
|
||||
matcher::{Match, Matcher, NoError},
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
pub fn has_regex_metacharacters(text: &str) -> bool {
|
||||
regex::escape(text) != text
|
||||
}
|
||||
|
||||
pub(super) fn build_regex(pattern: &str, smart_case: bool) -> Result<regex::bytes::Regex, String> {
|
||||
if pattern.is_empty() {
|
||||
return Err("empty pattern".to_string());
|
||||
}
|
||||
|
||||
let regex_pattern = if pattern.contains("\\n") {
|
||||
pattern.replace("\\n", "\n")
|
||||
} else {
|
||||
pattern.to_string()
|
||||
};
|
||||
|
||||
let case_insensitive = if smart_case {
|
||||
!pattern.chars().any(|c| c.is_uppercase())
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
regex::bytes::RegexBuilder::new(®ex_pattern)
|
||||
.case_insensitive(case_insensitive)
|
||||
.multi_line(true)
|
||||
.unicode(false)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub(super) struct RegexMatcher<'r> {
|
||||
pub(super) regex: &'r regex::bytes::Regex,
|
||||
pub(super) is_multiline: bool,
|
||||
}
|
||||
|
||||
impl Matcher for RegexMatcher<'_> {
|
||||
type Error = NoError;
|
||||
|
||||
#[inline]
|
||||
fn find_at(&self, haystack: &[u8], at: usize) -> Result<Option<Match>, NoError> {
|
||||
Ok(self
|
||||
.regex
|
||||
.find_at(haystack, at)
|
||||
.map(|m| Match::new(m.start(), m.end())))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn line_terminator(&self) -> Option<fff_grep::LineTerminator> {
|
||||
if self.is_multiline {
|
||||
None
|
||||
} else {
|
||||
Some(fff_grep::LineTerminator::byte(b'\n'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct RegexSink<'r> {
|
||||
pub(super) state: SinkState,
|
||||
pub(super) re: &'r regex::bytes::Regex,
|
||||
}
|
||||
|
||||
impl Sink for RegexSink<'_> {
|
||||
type Error = std::io::Error;
|
||||
|
||||
fn matched(
|
||||
&mut self,
|
||||
searcher: &Searcher,
|
||||
sink_match: &SinkMatch<'_>,
|
||||
) -> Result<bool, Self::Error> {
|
||||
debug_assert_newline_terminator(searcher);
|
||||
if self.state.max_matches != 0 && self.state.matches.len() >= self.state.max_matches {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let line_bytes = sink_match.bytes();
|
||||
let (display_bytes, _, line_number, byte_offset) =
|
||||
SinkState::prepare_line(line_bytes, sink_match);
|
||||
|
||||
// MultiLine strategy hands over all matched lines as one blob: keep
|
||||
// `line_content` single-line, the remaining lines become after-context.
|
||||
let (first_line, extra_after) = split_multiline_blob(display_bytes);
|
||||
let first_len = first_line.len() as u32;
|
||||
let line_content = String::from_utf8_lossy(first_line).into_owned();
|
||||
let mut match_byte_offsets: SmallVec<[(u32, u32); 4]> = SmallVec::new();
|
||||
let mut col = 0usize;
|
||||
let mut first = true;
|
||||
|
||||
for m in self.re.find_iter(display_bytes) {
|
||||
let abs_start = m.start() as u32;
|
||||
if abs_start >= first_len {
|
||||
continue; // highlight only spans visible in the first line
|
||||
}
|
||||
let abs_end = (m.end() as u32).min(first_len);
|
||||
if first {
|
||||
col = abs_start as usize;
|
||||
first = false;
|
||||
}
|
||||
match_byte_offsets.push((abs_start, abs_end));
|
||||
}
|
||||
|
||||
let (context_before, context_after) = self.state.extract_context(sink_match);
|
||||
let context_after = if extra_after.is_empty() {
|
||||
context_after
|
||||
} else {
|
||||
let mut combined = extra_after;
|
||||
combined.extend(context_after);
|
||||
combined
|
||||
};
|
||||
self.state.push_match(
|
||||
line_number,
|
||||
col,
|
||||
byte_offset,
|
||||
line_content,
|
||||
match_byte_offsets,
|
||||
context_before,
|
||||
context_after,
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn finish(&mut self, _: &Searcher, _: &fff_grep::SinkFinish) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
use super::types::GrepMatch;
|
||||
use fff_grep::{Searcher, SinkMatch};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
/// Maximum bytes of a matched line to keep for display. Prevents minified
|
||||
/// JS or huge single-line files from blowing up memory.
|
||||
pub(super) const MAX_LINE_DISPLAY_LEN: usize = 512;
|
||||
|
||||
#[cfg(feature = "definitions")]
|
||||
#[inline]
|
||||
pub(super) fn classify_definition(enabled: bool, line: &str) -> bool {
|
||||
enabled && super::classify::is_definition_line(line)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "definitions"))]
|
||||
#[inline]
|
||||
pub(super) fn classify_definition(_enabled: bool, _line: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(super) fn debug_assert_newline_terminator(searcher: &Searcher) {
|
||||
debug_assert_eq!(
|
||||
searcher.line_terminator(),
|
||||
fff_grep::LineTerminator::byte(b'\n'),
|
||||
"sink helpers assume \\n line terminators (see module invariant)"
|
||||
);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(super) fn strip_line_terminators(bytes: &[u8]) -> &[u8] {
|
||||
let mut len = bytes.len();
|
||||
while len > 0 && matches!(bytes[len - 1], b'\n' | b'\r') {
|
||||
len -= 1;
|
||||
}
|
||||
&bytes[..len]
|
||||
}
|
||||
|
||||
pub(super) struct SinkState {
|
||||
pub(super) file_index: usize,
|
||||
pub(super) matches: Vec<GrepMatch>,
|
||||
pub(super) max_matches: usize,
|
||||
pub(super) before_context: usize,
|
||||
pub(super) after_context: usize,
|
||||
pub(super) classify_definitions: bool,
|
||||
}
|
||||
|
||||
impl SinkState {
|
||||
#[inline]
|
||||
pub(super) fn prepare_line<'a>(
|
||||
line_bytes: &'a [u8],
|
||||
mat: &SinkMatch<'_>,
|
||||
) -> (&'a [u8], u32, u64, u64) {
|
||||
let line_number = mat.line_number().unwrap_or(0);
|
||||
let byte_offset = mat.absolute_byte_offset();
|
||||
|
||||
// Trim trailing newline/CR directly on bytes to avoid UTF-8 conversion.
|
||||
let trimmed_bytes = strip_line_terminators(line_bytes);
|
||||
|
||||
// Truncate for display (floor to a char boundary).
|
||||
let display_bytes = truncate_display_bytes(trimmed_bytes);
|
||||
|
||||
let display_len = display_bytes.len() as u32;
|
||||
(display_bytes, display_len, line_number, byte_offset)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn push_match(
|
||||
&mut self,
|
||||
line_number: u64,
|
||||
col: usize,
|
||||
byte_offset: u64,
|
||||
line_content: String,
|
||||
match_byte_offsets: SmallVec<[(u32, u32); 4]>,
|
||||
context_before: Vec<String>,
|
||||
context_after: Vec<String>,
|
||||
) {
|
||||
let is_definition = classify_definition(self.classify_definitions, &line_content);
|
||||
self.matches.push(GrepMatch {
|
||||
file_index: self.file_index,
|
||||
line_number,
|
||||
col,
|
||||
byte_offset,
|
||||
line_content,
|
||||
match_byte_offsets,
|
||||
fuzzy_score: None,
|
||||
is_definition,
|
||||
context_before,
|
||||
context_after,
|
||||
});
|
||||
}
|
||||
|
||||
/// Extract context lines from the full buffer around a matched region.
|
||||
pub(super) fn extract_context(&self, mat: &SinkMatch<'_>) -> (Vec<String>, Vec<String>) {
|
||||
if self.before_context == 0 && self.after_context == 0 {
|
||||
return (Vec::new(), Vec::new());
|
||||
}
|
||||
|
||||
let buffer = mat.buffer();
|
||||
let range = mat.bytes_range_in_buffer();
|
||||
|
||||
let mut before = Vec::new();
|
||||
if self.before_context > 0 && range.start > 0 {
|
||||
// Walk backward from the start of the match line to find preceding lines
|
||||
let mut pos = range.start;
|
||||
let mut lines_found = 0;
|
||||
while lines_found < self.before_context && pos > 0 {
|
||||
// Skip the newline just before our current position
|
||||
pos -= 1;
|
||||
// Find the previous newline
|
||||
let line_start = match memchr::memrchr(b'\n', &buffer[..pos]) {
|
||||
Some(nl) => nl + 1,
|
||||
None => 0,
|
||||
};
|
||||
let line = &buffer[line_start..pos];
|
||||
// Trim trailing \r
|
||||
let line = if line.last() == Some(&b'\r') {
|
||||
&line[..line.len() - 1]
|
||||
} else {
|
||||
line
|
||||
};
|
||||
let truncated = truncate_display_bytes(line);
|
||||
before.push(String::from_utf8_lossy(truncated).into_owned());
|
||||
pos = line_start;
|
||||
lines_found += 1;
|
||||
}
|
||||
before.reverse();
|
||||
}
|
||||
|
||||
let mut after = Vec::new();
|
||||
if self.after_context > 0 && range.end < buffer.len() {
|
||||
let mut pos = range.end;
|
||||
let mut lines_found = 0;
|
||||
while lines_found < self.after_context && pos < buffer.len() {
|
||||
// Find the next newline
|
||||
let line_end = match memchr::memchr(b'\n', &buffer[pos..]) {
|
||||
Some(nl) => pos + nl,
|
||||
None => buffer.len(),
|
||||
};
|
||||
let line = &buffer[pos..line_end];
|
||||
// Trim trailing \r
|
||||
let line = if line.last() == Some(&b'\r') {
|
||||
&line[..line.len() - 1]
|
||||
} else {
|
||||
line
|
||||
};
|
||||
let truncated = truncate_display_bytes(line);
|
||||
after.push(String::from_utf8_lossy(truncated).into_owned());
|
||||
pos = if line_end < buffer.len() {
|
||||
line_end + 1 // skip past \n
|
||||
} else {
|
||||
buffer.len()
|
||||
};
|
||||
lines_found += 1;
|
||||
}
|
||||
}
|
||||
|
||||
(before, after)
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate a byte slice for display, respecting UTF-8 char boundaries.
|
||||
#[inline]
|
||||
pub(super) fn truncate_display_bytes(bytes: &[u8]) -> &[u8] {
|
||||
if bytes.len() <= MAX_LINE_DISPLAY_LEN {
|
||||
bytes
|
||||
} else {
|
||||
let mut end = MAX_LINE_DISPLAY_LEN;
|
||||
while end > 0 && !is_utf8_char_boundary(bytes[end]) {
|
||||
end -= 1;
|
||||
}
|
||||
&bytes[..end]
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a multiline match blob (from the MultiLine searcher strategy) into
|
||||
/// the first line and the remaining lines so `line_content` stays single-line.
|
||||
pub(super) fn split_multiline_blob(display_bytes: &[u8]) -> (&[u8], Vec<String>) {
|
||||
match memchr::memchr(b'\n', display_bytes) {
|
||||
None => (display_bytes, Vec::new()),
|
||||
Some(pos) => {
|
||||
let first = strip_line_terminators(&display_bytes[..pos + 1]);
|
||||
let extra = display_bytes[pos + 1..]
|
||||
.split(|&b| b == b'\n')
|
||||
.map(|l| String::from_utf8_lossy(strip_line_terminators(l)).into_owned())
|
||||
.collect();
|
||||
(first, extra)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert character-position indices from neo_frizbee into byte-offset
|
||||
/// pairs (start, end) suitable for `match_byte_offsets`.
|
||||
///
|
||||
/// frizbee returns character positions (0-based index into the char
|
||||
/// iterator). We need byte ranges because the UI renderer and Lua layer
|
||||
/// use byte offsets for extmark highlights.
|
||||
///
|
||||
/// Each matched character becomes its own (byte_start, byte_end) pair.
|
||||
/// Adjacent characters are merged into a single contiguous range.
|
||||
pub(super) fn char_indices_to_byte_offsets(
|
||||
line: &str,
|
||||
char_indices: &[usize],
|
||||
) -> SmallVec<[(u32, u32); 4]> {
|
||||
if char_indices.is_empty() {
|
||||
return SmallVec::new();
|
||||
}
|
||||
|
||||
// Build a map: char_index -> (byte_start, byte_end) for all chars.
|
||||
// Iterating all chars is O(n) in the line length which is bounded by MAX_LINE_DISPLAY_LEN (512).
|
||||
let char_byte_ranges: Vec<(usize, usize)> = line
|
||||
.char_indices()
|
||||
.map(|(byte_pos, ch)| (byte_pos, byte_pos + ch.len_utf8()))
|
||||
.collect();
|
||||
|
||||
// Convert char indices to byte ranges, merging adjacent ranges
|
||||
let mut result: SmallVec<[(u32, u32); 4]> = SmallVec::with_capacity(char_indices.len());
|
||||
|
||||
for &ci in char_indices {
|
||||
if ci >= char_byte_ranges.len() {
|
||||
continue; // out of bounds (shouldn't happen with valid data)
|
||||
}
|
||||
let (start, end) = char_byte_ranges[ci];
|
||||
// Merge with previous range if adjacent
|
||||
if let Some(last) = result.last_mut()
|
||||
&& last.1 == start as u32
|
||||
{
|
||||
last.1 = end as u32;
|
||||
continue;
|
||||
}
|
||||
result.push((start as u32, end as u32));
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// copied from the rust u8 private method
|
||||
#[inline]
|
||||
const fn is_utf8_char_boundary(b: u8) -> bool {
|
||||
(b as i8) >= -0x40
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
use crate::types::FileItem;
|
||||
use smallvec::SmallVec;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
pub use crate::constants::MAX_FFFILE_SIZE;
|
||||
|
||||
/// Controls how the grep pattern is interpreted.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum GrepMode {
|
||||
/// Literal plain text match: default path that doesn't require any regex machinery
|
||||
#[default]
|
||||
PlainText,
|
||||
/// Regex mode: uses the same exact matching engine as ripgrep
|
||||
Regex,
|
||||
/// Smart fuzzy mode, allows user to make either a couple of single char typos or long gaps
|
||||
/// e.g. shcema -> shcema, or UserController -> UserAuthController
|
||||
///
|
||||
/// Significatnly slower than plain text, especially on unindexed FilePicker
|
||||
Fuzzy,
|
||||
}
|
||||
|
||||
/// A single content match within a file
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GrepMatch {
|
||||
/// Index into the deduplicated `files` vec of the GrepResult.
|
||||
pub file_index: usize,
|
||||
/// 1-based line number.
|
||||
pub line_number: u64,
|
||||
/// 0-based byte column of first match start within the line.
|
||||
pub col: usize,
|
||||
/// Absolute byte offset of the matched line from the start of the file.
|
||||
/// Can be used by the preview to seek directly without scanning from the top.
|
||||
pub byte_offset: u64,
|
||||
/// The matched line text, truncated to `MAX_LINE_DISPLAY_LEN`.
|
||||
pub line_content: String,
|
||||
/// Byte offsets `(start, end)` within `line_content` for each match.
|
||||
/// Stack-allocated for the common case of ≤4 spans per line.
|
||||
pub match_byte_offsets: SmallVec<[(u32, u32); 4]>,
|
||||
/// Fuzzy match score from neo_frizbee (only set in Fuzzy grep mode).
|
||||
pub fuzzy_score: Option<u16>,
|
||||
/// Whether the matched line looks like a definition (struct, fn, class, etc.).
|
||||
/// Computed at match time so output formatters don't need to re-scan.
|
||||
pub is_definition: bool,
|
||||
/// Lines before the match (for context display). Empty when context is 0.
|
||||
pub context_before: Vec<String>,
|
||||
/// Lines after the match (for context display). Empty when context is 0.
|
||||
pub context_after: Vec<String>,
|
||||
}
|
||||
|
||||
impl GrepMatch {
|
||||
/// Strip leading whitespace from `line_content` and all context lines,
|
||||
/// adjusting `col` and `match_byte_offsets` so highlights remain correct.
|
||||
pub fn trim_leading_whitespace(&mut self) {
|
||||
let strip_len = self.line_content.len() - self.line_content.trim_start().len();
|
||||
if strip_len > 0 {
|
||||
self.line_content.drain(..strip_len);
|
||||
let off = strip_len as u32;
|
||||
self.col = self.col.saturating_sub(strip_len);
|
||||
for range in &mut self.match_byte_offsets {
|
||||
range.0 = range.0.saturating_sub(off);
|
||||
range.1 = range.1.saturating_sub(off);
|
||||
}
|
||||
}
|
||||
for line in &mut self.context_before {
|
||||
let n = line.len() - line.trim_start().len();
|
||||
if n > 0 {
|
||||
line.drain(..n);
|
||||
}
|
||||
}
|
||||
for line in &mut self.context_after {
|
||||
let n = line.len() - line.trim_start().len();
|
||||
if n > 0 {
|
||||
line.drain(..n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Options for grep search.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GrepSearchOptions {
|
||||
pub max_file_size: u64,
|
||||
pub max_matches_per_file: usize,
|
||||
pub smart_case: bool,
|
||||
/// File-based pagination offset: index into the sorted/filtered file list
|
||||
/// to start searching from. Pass 0 for the first page, then use
|
||||
/// `GrepResult::next_file_offset` for subsequent pages.
|
||||
pub file_offset: usize,
|
||||
/// Maximum number of matches to collect before stopping.
|
||||
pub page_limit: usize,
|
||||
/// How to interpret the search pattern. Defaults to `PlainText`.
|
||||
pub mode: GrepMode,
|
||||
/// Maximum time in milliseconds to spend searching before returning partial
|
||||
/// results. Prevents UI freezes on pathological queries. 0 = no limit.
|
||||
pub time_budget_ms: u64,
|
||||
/// Number of context lines to include before each match. 0 = disabled.
|
||||
pub before_context: usize,
|
||||
/// Number of context lines to include after each match. 0 = disabled.
|
||||
pub after_context: usize,
|
||||
/// Whether to classify each match as a definition line. Adds ~2% overhead
|
||||
/// on large repos; disable for interactive grep where it is not needed.
|
||||
pub classify_definitions: bool,
|
||||
/// Strip leading whitespace from matched lines and context lines, adjusting
|
||||
/// highlight byte offsets accordingly. Useful for AI/MCP consumers and UIs
|
||||
/// that don't need indentation. Default: false.
|
||||
pub trim_whitespace: bool,
|
||||
/// External abort signal. When provided, overrides the picker's internal
|
||||
/// cancellation flag. Set to `true` to stop the search early and return
|
||||
/// partial results. Omit (or use `..Default::default()`) to let the
|
||||
/// picker manage cancellation.
|
||||
pub abort_signal: Option<Arc<AtomicBool>>,
|
||||
}
|
||||
|
||||
impl Default for GrepSearchOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_file_size: MAX_FFFILE_SIZE,
|
||||
max_matches_per_file: 200,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: 50,
|
||||
mode: GrepMode::default(),
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
abort_signal: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a grep search with a list of matches, list of matched files, and metadata.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GrepResult<'a> {
|
||||
pub matches: Vec<GrepMatch>,
|
||||
/// Deduplicated file references for the returned matches.
|
||||
pub files: Vec<&'a FileItem>,
|
||||
/// Number of files actually searched in this call.
|
||||
pub total_files_searched: usize,
|
||||
/// Total number of indexed files (before filtering).
|
||||
pub total_files: usize,
|
||||
/// Total number of searchable files (after filtering out binary, too-large, etc.).
|
||||
pub filtered_file_count: usize,
|
||||
/// Number of files that contained at least one match.
|
||||
pub files_with_matches: usize,
|
||||
/// The file offset to pass for the next page. `0` if there are no more files.
|
||||
/// Callers should store this and pass it as `file_offset` in the next call.
|
||||
pub next_file_offset: usize,
|
||||
/// When regex mode fails to compile the pattern, the search falls back to
|
||||
/// literal matching and this field contains the compilation error message.
|
||||
/// The UI can display this to inform the user their regex was invalid.
|
||||
pub regex_fallback_error: Option<String>,
|
||||
/// Set to `true` if the constrained query found nothing and the results come from
|
||||
/// retrying the whole raw query as literal text (ignored all the inferred constraints)
|
||||
pub literal_fallback: bool,
|
||||
}
|
||||
|
||||
impl<'a> GrepResult<'a> {
|
||||
/// Empty result carrying only the file counts (empty query / prefilter miss)
|
||||
pub(crate) fn empty(total_files: usize, filtered_file_count: usize) -> Self {
|
||||
Self {
|
||||
total_files,
|
||||
filtered_file_count,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn collect(
|
||||
per_file_results: Vec<(usize, &'a FileItem, Vec<GrepMatch>)>,
|
||||
files_to_search_len: usize,
|
||||
options: &GrepSearchOptions,
|
||||
total_files: usize,
|
||||
filtered_file_count: usize,
|
||||
budget_exceeded: bool,
|
||||
) -> Self {
|
||||
let page_limit = options.page_limit;
|
||||
|
||||
// Each match stores a `file_index` pointing into `result_files` so that
|
||||
// consumers (FFI JSON, Lua) can look up file metadata without duplicating
|
||||
// it across every match from the same file
|
||||
let mut result_files: Vec<&'a FileItem> = Vec::new();
|
||||
let mut all_matches: Vec<GrepMatch> = Vec::new();
|
||||
// files_consumed tracks how far into files_to_search we have advanced,
|
||||
// counting every file whose results were emitted (with or without matches).
|
||||
// We use the batch_idx of the last consumed file + 1, which is correct
|
||||
// because per_file_results only contains files that had matches, and
|
||||
// files between them that had no matches were still searched and can be
|
||||
// safely skipped on the next page
|
||||
let mut files_consumed: usize = 0;
|
||||
|
||||
for (batch_idx, file, file_matches) in per_file_results {
|
||||
// batch_idx is the 0-based position in files_to_search.
|
||||
// Advance files_consumed to include this file and all no-match files before it.
|
||||
files_consumed = batch_idx + 1;
|
||||
|
||||
let file_result_idx = result_files.len();
|
||||
result_files.push(file);
|
||||
|
||||
for mut m in file_matches {
|
||||
m.file_index = file_result_idx;
|
||||
if options.trim_whitespace {
|
||||
m.trim_leading_whitespace();
|
||||
}
|
||||
all_matches.push(m);
|
||||
}
|
||||
|
||||
// page_limit is a soft cap: we always finish the current file before
|
||||
// stopping, so no matches are dropped. A page may return up to
|
||||
// page_limit + max_matches_per_file - 1 matches in the worst case
|
||||
if all_matches.len() >= page_limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no file had any match, we searched the entire slice.
|
||||
if result_files.is_empty() {
|
||||
files_consumed = files_to_search_len;
|
||||
}
|
||||
|
||||
let has_more = budget_exceeded
|
||||
|| (all_matches.len() >= page_limit && files_consumed < files_to_search_len);
|
||||
|
||||
let next_file_offset = if has_more {
|
||||
options.file_offset + files_consumed
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Self {
|
||||
matches: all_matches,
|
||||
files_with_matches: result_files.len(),
|
||||
files: result_files,
|
||||
total_files_searched: files_consumed,
|
||||
total_files,
|
||||
filtered_file_count,
|
||||
next_file_offset,
|
||||
regex_fallback_error: None,
|
||||
literal_fallback: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,75 @@
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) const NON_GIT_IGNORED_DIRS: &[&str] = &[
|
||||
/// Directories excluded when walking a non-git root. Entries are `cfg`-gated
|
||||
/// so a single iteration covers standard + platform-specific overrides.
|
||||
pub(crate) const IGNORED_DIRS: &[&str] = &[
|
||||
// various dev tools that can be meet in the developer app
|
||||
"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] = &[
|
||||
// Language package caches in non-git roots.
|
||||
"go/pkg/mod",
|
||||
".cargo/registry",
|
||||
".rustup/toolchains",
|
||||
".gradle/caches",
|
||||
".m2/repository",
|
||||
".npm/_cacache",
|
||||
".pub-cache",
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
".local/state", // this contains tons of logs which generate too much watcher noise
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/Application Support",
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/Caches",
|
||||
// App-group sandbox storage — used by iMessage, Photos, Notes, Calendar,
|
||||
// Electron apps, etc. for SQLite-WAL, LevelDB, protobuf files. These are
|
||||
// almost entirely extension-less binary files (~80k on a typical $HOME)
|
||||
// that never need to appear in a fuzzy or grep search.
|
||||
"Library/Group Containers",
|
||||
"Library/Containers",
|
||||
];
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) const PLATFORM_IGNORED_DIRS: &[&str] = &[
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/Containers", // sandboxed apps data
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/Group Containers", // random application data and networking
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/pnpm",
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/Metadata",
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/Developer/CoreSimulator",
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/Android",
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/Logs",
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/Daemon Containers",
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/Trial",
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/Preferences",
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/Messages",
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/IdentityServices",
|
||||
#[cfg(target_os = "windows")]
|
||||
"bin/Debug",
|
||||
#[cfg(target_os = "windows")]
|
||||
"bin/Release",
|
||||
#[cfg(target_os = "windows")]
|
||||
"Program Files",
|
||||
#[cfg(target_os = "windows")]
|
||||
"Program Files (x86)",
|
||||
#[cfg(target_os = "windows")]
|
||||
"AppData/Local",
|
||||
#[cfg(target_os = "windows")]
|
||||
"AppData/Roaming",
|
||||
];
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
pub(crate) const PLATFORM_IGNORED_DIRS: &[&str] = &[];
|
||||
|
||||
#[cfg(all(not(feature = "zlob"), feature = "ripgrep"))]
|
||||
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) {
|
||||
for dir in IGNORED_DIRS {
|
||||
let pattern = format!("!**/{dir}/");
|
||||
if let Err(e) = builder.add(&pattern) {
|
||||
tracing::warn!("failed to add ignore pattern {pattern}: {e}");
|
||||
@@ -54,16 +81,56 @@ pub(crate) fn non_git_repo_overrides(base_path: &Path) -> Option<ignore::overrid
|
||||
|
||||
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());
|
||||
IGNORED_DIRS.iter().any(|&dir| {
|
||||
// Entries are gitignore patterns for the walkers; here they are matched
|
||||
// as substrings, so a leading `*` wildcard has to come off first.
|
||||
let dir = dir.strip_prefix('*').unwrap_or(dir);
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
path_str.contains(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)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn home_machine_state_is_excluded_but_source_trees_are_not() {
|
||||
// Representative machine state from a home index.
|
||||
for rel in [
|
||||
"Library/pnpm/store/v3/files/00/abcdef",
|
||||
"Library/Preferences/com.apple.finder.plist",
|
||||
"Library/Messages/prewarm.db-shm",
|
||||
"Library/IdentityServices/TetraDB-identityservicesd.db-wal",
|
||||
"Library/Developer/CoreSimulator/Devices/X/data/f",
|
||||
"go/pkg/mod/github.com/x/y@v1/main.go",
|
||||
".cargo/registry/src/index.crates.io-1/serde-1.0/src/lib.rs",
|
||||
"Library/Android/sdk/platforms/android-34/data/x",
|
||||
".local/state/nvim/fff+123+456.log",
|
||||
] {
|
||||
assert!(
|
||||
is_non_code_directory(Path::new(rel)),
|
||||
"{rel} must not reach the index"
|
||||
);
|
||||
}
|
||||
|
||||
// Source trees under $HOME stay searchable.
|
||||
for rel in [
|
||||
"dev/chromium/third_party/blink/renderer/core/dom/node.cc",
|
||||
"dev/fff/crates/fff-core/src/lib.rs",
|
||||
"Documents/notes/todo.md",
|
||||
"dev/myproj/pkg/mod/thing.go",
|
||||
] {
|
||||
assert!(
|
||||
!is_non_code_directory(Path::new(rel)),
|
||||
"{rel} must stay searchable"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +264,8 @@ impl BigramIndexBuilder {
|
||||
fn flush_seen(&self, seen: &[u64; SEEN_WORDS], word_idx: usize, bit_mask: u64) {
|
||||
let col_base = self.col_data_ptr();
|
||||
let words = self.words;
|
||||
for (blk, block) in seen.chunks_exact(8).enumerate() {
|
||||
// SEEN_WORDS is a multiple of 8, so the remainder is always empty.
|
||||
for (blk, block) in seen.as_chunks::<8>().0.iter().enumerate() {
|
||||
// OR-test whole blocks so the mostly-empty bitmap scans fast.
|
||||
if block.iter().fold(0u64, |a, &w| a | w) == 0 {
|
||||
continue;
|
||||
@@ -593,7 +594,7 @@ fn normalize_byte_scalar(b: u8) -> u8 {
|
||||
#[inline(always)]
|
||||
fn normalize_bytes(src: &[u8], dst: &mut [u8]) {
|
||||
debug_assert!(dst.len() >= src.len());
|
||||
#[cfg(any(target_arch = "x86_64", target_arch = "x86"))]
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
{
|
||||
if std::is_x86_feature_detected!("avx2") {
|
||||
unsafe { normalize_bytes_avx2(src, dst) };
|
||||
@@ -620,7 +621,7 @@ fn normalize_bytes_scalar(src: &[u8], dst: &mut [u8]) {
|
||||
|
||||
/// AVX2 normalize: 32 bytes/iter. AVX2 only has signed cmp, so unsigned
|
||||
/// range checks use `min(max(v, lo), hi) == v`.
|
||||
#[cfg(any(target_arch = "x86_64", target_arch = "x86"))]
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
#[target_feature(enable = "avx2")]
|
||||
unsafe fn normalize_bytes_avx2(src: &[u8], dst: &mut [u8]) {
|
||||
use std::arch::x86_64::*;
|
||||
@@ -906,13 +907,20 @@ pub(crate) fn sniff_binary_for_non_indexable(
|
||||
files: &[FileItem],
|
||||
base_path: &std::path::Path,
|
||||
arena: crate::simd_path::ArenaPtr,
|
||||
cancelled: &std::sync::atomic::AtomicBool,
|
||||
) {
|
||||
// Non-indexable files are few in a typical repo, so a serial pass with a
|
||||
// single reused chunk buffer beats spinning up the thread pool.
|
||||
let mut path_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
|
||||
let mut chunk = vec![0u8; crate::types::BINARY_CLASSIFICATION_CHUNK_SIZE];
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
for (i, file) in files.iter().enumerate() {
|
||||
// check every 256 files to avoid useless work
|
||||
if (i & 0xFF) == 0 && cancelled.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
|
||||
for file in files {
|
||||
// check only the files that we are able to grep
|
||||
if file.size == 0 || file.size > constants::MAX_FFFILE_SIZE {
|
||||
continue;
|
||||
@@ -1,26 +1,48 @@
|
||||
//! Regex → bigram decomposition for the inverted bigram index.
|
||||
//!
|
||||
//! Parses a regex pattern with `regex-syntax`, walks the HIR to extract
|
||||
//! guaranteed bigram keys (u16), and evaluates them as an AND/OR query tree
|
||||
//! against [`BigramFilter`]'s inverted posting lists.
|
||||
//!
|
||||
//! Two bigram types are extracted:
|
||||
//! - **Consecutive** (gap=0): adjacent byte pairs `(pattern[i], pattern[i+1])`
|
||||
//! - **Sparse-1** (gap=1): pairs across a single-byte wildcard, e.g. `a.b → (a,b)`
|
||||
//!
|
||||
//! The sparse-1 extraction is the key feature: regex patterns like `foo.bar`
|
||||
//! yield the cross-boundary sparse-1 bigram `(o,b)` that provides strong
|
||||
//! filtering even when the `.` prevents any consecutive cross-boundary bigram.
|
||||
|
||||
use crate::bigram_filter::BigramFilter;
|
||||
use crate::index::bigram_filter::BigramFilter;
|
||||
use regex_syntax::hir::{Class, Hir, HirKind};
|
||||
use smallvec::SmallVec;
|
||||
use std::borrow::Cow;
|
||||
|
||||
/// Maximum byte values to enumerate from a character class.
|
||||
/// Larger classes are treated as unknown (no bigram extractable).
|
||||
const MAX_CLASS_EXPAND: usize = 16;
|
||||
|
||||
// stack inlined array padded with 0 and tracked length
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
struct InlineArray {
|
||||
bytes: [u8; MAX_CLASS_EXPAND],
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl InlineArray {
|
||||
const fn new() -> Self {
|
||||
Self {
|
||||
bytes: [0; MAX_CLASS_EXPAND],
|
||||
len: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_byte(b: u8) -> Self {
|
||||
let mut set = Self::new();
|
||||
set.push(b);
|
||||
set
|
||||
}
|
||||
|
||||
/// Append a byte; no-op if already full (callers guard against this).
|
||||
fn push(&mut self, b: u8) {
|
||||
if self.len < MAX_CLASS_EXPAND {
|
||||
self.bytes[self.len] = b;
|
||||
self.len += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for InlineArray {
|
||||
type Target = [u8];
|
||||
|
||||
fn deref(&self) -> &[u8] {
|
||||
&self.bytes[..self.len]
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn consec_key(a: u8, b: u8) -> Option<u16> {
|
||||
let al = a.to_ascii_lowercase();
|
||||
@@ -121,19 +143,15 @@ impl BigramQuery {
|
||||
}
|
||||
let mut result: Option<Vec<u64>> = None;
|
||||
for child in children {
|
||||
match child.evaluate_cow(index) {
|
||||
// Any branch can't be filtered → whole OR can't be filtered
|
||||
None => return None,
|
||||
Some(child_bits) => {
|
||||
result = Some(match result {
|
||||
None => child_bits.into_owned(),
|
||||
Some(mut r) => {
|
||||
bitset_or(&mut r, &child_bits);
|
||||
r
|
||||
}
|
||||
});
|
||||
// Any branch can't be filtered -> whole OR can't be filtered
|
||||
let child_bits = child.evaluate_cow(index)?;
|
||||
result = Some(match result {
|
||||
None => child_bits.into_owned(),
|
||||
Some(mut r) => {
|
||||
bitset_or(&mut r, &child_bits);
|
||||
r
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
result.map(Cow::Owned)
|
||||
}
|
||||
@@ -141,14 +159,10 @@ impl BigramQuery {
|
||||
}
|
||||
}
|
||||
|
||||
/// Intermediate state tracked during HIR traversal for bigram extraction.
|
||||
struct HirInfo {
|
||||
query: BigramQuery,
|
||||
/// Possible first bytes (lowercased, printable ASCII) when this node matches.
|
||||
first: Option<SmallVec<[u8; MAX_CLASS_EXPAND]>>,
|
||||
/// Possible last bytes.
|
||||
last: Option<SmallVec<[u8; MAX_CLASS_EXPAND]>>,
|
||||
/// Whether this node can match the empty string.
|
||||
first: Option<InlineArray>,
|
||||
last: Option<InlineArray>,
|
||||
can_be_empty: bool,
|
||||
}
|
||||
|
||||
@@ -185,7 +199,7 @@ pub(crate) fn fuzzy_to_bigram_query(query: &str, num_probes: usize) -> BigramQue
|
||||
return BigramQuery::Any;
|
||||
}
|
||||
|
||||
// For very short queries (0 typos), AND all bigrams — exact subsequence.
|
||||
// the simplest case, just check that every bigram is present either consec or not
|
||||
if max_typos == 0 {
|
||||
return simplify_and(
|
||||
bigram_keys
|
||||
@@ -225,7 +239,7 @@ pub(crate) fn fuzzy_to_bigram_query(query: &str, num_probes: usize) -> BigramQue
|
||||
return simplify_and(probes.iter().map(|&k| BigramQuery::Consec(k)).collect());
|
||||
}
|
||||
|
||||
// Generate all C(n, required) subsets → OR(AND(subset), ...)
|
||||
// Generate all C(n, required) subsets as OR(AND(subset), ...)
|
||||
let mut branches = Vec::new();
|
||||
let mut combo = vec![0u16; required];
|
||||
combine(&probes, required, 0, 0, &mut combo, &mut branches);
|
||||
@@ -282,7 +296,7 @@ fn decompose(hir: &Hir) -> HirInfo {
|
||||
match bytes {
|
||||
Some(b) if !b.is_empty() => HirInfo {
|
||||
query: BigramQuery::Any,
|
||||
first: Some(b.clone()),
|
||||
first: Some(b),
|
||||
last: Some(b),
|
||||
can_be_empty,
|
||||
},
|
||||
@@ -344,13 +358,13 @@ fn decompose_literal(bytes: &[u8]) -> HirInfo {
|
||||
if lower.len() == 1 {
|
||||
let b = lower[0];
|
||||
let first = if (32..=126).contains(&b) {
|
||||
Some(SmallVec::from_slice(&[b]))
|
||||
Some(InlineArray::from_byte(b))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
return HirInfo {
|
||||
query: BigramQuery::Any,
|
||||
first: first.clone(),
|
||||
first,
|
||||
last: first,
|
||||
can_be_empty: false,
|
||||
};
|
||||
@@ -380,12 +394,12 @@ fn decompose_literal(bytes: &[u8]) -> HirInfo {
|
||||
HirInfo {
|
||||
query: simplify_and(qs),
|
||||
first: if (32..=126).contains(&first_byte) {
|
||||
Some(SmallVec::from_slice(&[first_byte]))
|
||||
Some(InlineArray::from_byte(first_byte))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
last: if (32..=126).contains(&last_byte) {
|
||||
Some(SmallVec::from_slice(&[last_byte]))
|
||||
Some(InlineArray::from_byte(last_byte))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
@@ -401,22 +415,20 @@ fn decompose_concat(parts: &[Hir]) -> HirInfo {
|
||||
let infos: Vec<HirInfo> = parts.iter().map(decompose).collect();
|
||||
let mut qs: Vec<BigramQuery> = Vec::new();
|
||||
|
||||
// 1. Collect child bigrams
|
||||
for info in &infos {
|
||||
if !info.query.is_any() {
|
||||
qs.push(info.query.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Dense cross-boundary between adjacent mandatory parts
|
||||
// Dense cross-boundary between adjacent mandatory parts
|
||||
for pair in infos.windows(2) {
|
||||
if !pair[0].can_be_empty && !pair[1].can_be_empty {
|
||||
push_cross_consec(&mut qs, pair[0].last.as_deref(), pair[1].first.as_deref());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Sparse-1 cross-boundary: across a single 1-byte-wide middle part.
|
||||
// Catches `foo.bar` → sparse-1 `(o,b)` across the dot.
|
||||
// Sparse-1 cross-boundary: across a single 1 byte wide middle part
|
||||
if parts.len() >= 3 {
|
||||
for i in 0..parts.len() - 2 {
|
||||
let left = &infos[i];
|
||||
@@ -464,8 +476,8 @@ fn decompose_alternation(alts: &[Hir]) -> HirInfo {
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_class(class: &Class) -> Option<SmallVec<[u8; MAX_CLASS_EXPAND]>> {
|
||||
let mut bytes: SmallVec<[u8; MAX_CLASS_EXPAND]> = SmallVec::new();
|
||||
fn expand_class(class: &Class) -> Option<InlineArray> {
|
||||
let mut bytes = InlineArray::new();
|
||||
match class {
|
||||
Class::Bytes(bc) => {
|
||||
for range in bc.ranges() {
|
||||
@@ -473,6 +485,7 @@ fn expand_class(class: &Class) -> Option<SmallVec<[u8; MAX_CLASS_EXPAND]>> {
|
||||
if bytes.len() + count > MAX_CLASS_EXPAND {
|
||||
return None;
|
||||
}
|
||||
|
||||
for b in range.start()..=range.end() {
|
||||
if (32..=126).contains(&b) {
|
||||
let lower = b.to_ascii_lowercase();
|
||||
@@ -554,11 +567,11 @@ fn cross_product(last: Option<&[u8]>, first: Option<&[u8]>, skip: bool) -> Optio
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_first(infos: &[HirInfo]) -> Option<SmallVec<[u8; MAX_CLASS_EXPAND]>> {
|
||||
let mut result: SmallVec<[u8; MAX_CLASS_EXPAND]> = SmallVec::new();
|
||||
fn collect_first(infos: &[HirInfo]) -> Option<InlineArray> {
|
||||
let mut result = InlineArray::new();
|
||||
for info in infos {
|
||||
if let Some(ref bytes) = info.first {
|
||||
for &b in bytes {
|
||||
for &b in bytes.iter() {
|
||||
if !result.contains(&b) {
|
||||
if result.len() >= MAX_CLASS_EXPAND {
|
||||
return None;
|
||||
@@ -580,11 +593,11 @@ fn collect_first(infos: &[HirInfo]) -> Option<SmallVec<[u8; MAX_CLASS_EXPAND]>>
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_last(infos: &[HirInfo]) -> Option<SmallVec<[u8; MAX_CLASS_EXPAND]>> {
|
||||
let mut result: SmallVec<[u8; MAX_CLASS_EXPAND]> = SmallVec::new();
|
||||
fn collect_last(infos: &[HirInfo]) -> Option<InlineArray> {
|
||||
let mut result = InlineArray::new();
|
||||
for info in infos.iter().rev() {
|
||||
if let Some(ref bytes) = info.last {
|
||||
for &b in bytes {
|
||||
for &b in bytes.iter() {
|
||||
if !result.contains(&b) {
|
||||
if result.len() >= MAX_CLASS_EXPAND {
|
||||
return None;
|
||||
@@ -606,22 +619,17 @@ fn collect_last(infos: &[HirInfo]) -> Option<SmallVec<[u8; MAX_CLASS_EXPAND]>> {
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_byte_sets<'a>(
|
||||
iter: impl Iterator<Item = &'a Option<SmallVec<[u8; MAX_CLASS_EXPAND]>>>,
|
||||
) -> Option<SmallVec<[u8; MAX_CLASS_EXPAND]>> {
|
||||
let mut result: SmallVec<[u8; MAX_CLASS_EXPAND]> = SmallVec::new();
|
||||
fn merge_byte_sets<'a>(iter: impl Iterator<Item = &'a Option<InlineArray>>) -> Option<InlineArray> {
|
||||
let mut result = InlineArray::new();
|
||||
for opt in iter {
|
||||
match opt {
|
||||
None => return None,
|
||||
Some(bytes) => {
|
||||
for &b in bytes {
|
||||
if !result.contains(&b) {
|
||||
if result.len() >= MAX_CLASS_EXPAND {
|
||||
return None;
|
||||
}
|
||||
result.push(b);
|
||||
}
|
||||
let bytes = opt.as_ref()?;
|
||||
|
||||
for &b in bytes.iter() {
|
||||
if !result.contains(&b) {
|
||||
if result.len() >= MAX_CLASS_EXPAND {
|
||||
return None;
|
||||
}
|
||||
result.push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -669,7 +677,7 @@ fn simplify_or(children: Vec<BigramQuery>) -> BigramQuery {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::bigram_filter::BigramIndexBuilder;
|
||||
use crate::index::bigram_filter::BigramIndexBuilder;
|
||||
|
||||
/// Build a tiny index from the given file contents for testing.
|
||||
fn build_test_index(files: &[&[u8]]) -> BigramFilter {
|
||||
@@ -757,7 +765,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn sparse1_across_digit() {
|
||||
// "foo\dbar" → sparse-1 (o,b) across \d
|
||||
// "foo\dbar" -> sparse-1 (o,b) across \d
|
||||
let idx = build_test_index(&[
|
||||
b"foo3bar baz", // 0: has all bigrams
|
||||
b"foobar baz", // 1: has consecutive (o,b) but pattern needs sparse-1
|
||||
@@ -795,7 +803,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn optional_group_excluded() {
|
||||
// (bar)? is optional — its bigrams are not required
|
||||
let q = regex_to_bigram_query("foo(bar)?baz");
|
||||
assert!(!q.is_any());
|
||||
|
||||
@@ -813,7 +820,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn repetition_min2_cross_boundary() {
|
||||
// (ab){2,} → bigram "ab" + cross-boundary "b","a"
|
||||
// (ab){2,} -> bigram "ab" + cross-boundary "b","a"
|
||||
let q = regex_to_bigram_query("(ab){2,}");
|
||||
assert!(!q.is_any());
|
||||
|
||||
@@ -830,16 +837,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn two_dots_no_sparse1() {
|
||||
// "a..b" — two 1-byte parts between a and b, not a single 1-byte part
|
||||
// No sparse-1 (a,b) should be extracted
|
||||
let q = regex_to_bigram_query("a..b");
|
||||
// Single-char literals with 2 unknown bytes between → Any
|
||||
// Single-char literals with 2 unknown bytes between -> Any
|
||||
assert!(q.is_any());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_class_cross_boundary() {
|
||||
// [abc]de → cross-boundary OR(ad,bd,cd) + bigram de
|
||||
// [abc]de -> cross-boundary OR(ad,bd,cd) + bigram de
|
||||
// All three class variants must appear in the corpus so the OR
|
||||
// branches are tracked in the index (untracked bigrams make the
|
||||
// OR conservatively return None, which is correct but untestable).
|
||||
@@ -861,8 +866,6 @@ mod tests {
|
||||
assert!(!BigramFilter::is_candidate(&candidates, 3));
|
||||
}
|
||||
|
||||
// ── Helpers for inspecting query trees ──────────────────────────
|
||||
|
||||
fn has_consec(q: &BigramQuery, a: u8, b: u8) -> bool {
|
||||
let Some(key) = consec_key(a, b) else {
|
||||
return false;
|
||||
@@ -896,13 +899,12 @@ mod tests {
|
||||
/// plus typical grep patterns used by agentic tools.
|
||||
///
|
||||
/// Each entry: `(regex, Option<&[Bg]>)`.
|
||||
/// - `None` → pure classes / unsupported syntax, Any is acceptable.
|
||||
/// - `Some(&[..])` → must be non-Any, and every listed bigram must appear.
|
||||
/// - `None` -> pure classes / unsupported syntax, Any is acceptable.
|
||||
/// - `Some(&[..])` -> must be non-Any, and every listed bigram must appear.
|
||||
#[test]
|
||||
fn common_regex_patterns() {
|
||||
#[rustfmt::skip]
|
||||
let cases: &[(&str, Option<&[Bg]>)] = &[
|
||||
// ── Pure-class / anchor / unsupported → Any is fine ──────
|
||||
(r"^\d+$", None), // 1. whole numbers
|
||||
(r"^\d*\.\d+$", None), // 2. decimals
|
||||
(r"^\d*(\.\d+)?$", None), // 3. whole + decimal
|
||||
@@ -931,11 +933,9 @@ mod tests {
|
||||
(r"^[\w,\s-]+\.[A-Za-z]{3}$", None), // 27. filename
|
||||
(r"^[A-PR-WY][1-9]\d\s?\d{4}[1-9]$", None), // 28. HK ID
|
||||
|
||||
// ── Patterns with extractable literal bigrams ────────────
|
||||
|
||||
// 13. URL with required protocol
|
||||
(r"https?://(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)", Some(&[
|
||||
("ht", C), ("tt", C), ("tp", C), // from "http"
|
||||
("ht", C), ("tt", C), ("tp", C), // from "http"
|
||||
("ht", S), ("tp", S), // from "http" skip-1
|
||||
(":/", C), ("//", C), // from "://"
|
||||
])),
|
||||
@@ -943,31 +943,31 @@ mod tests {
|
||||
// 29. fn\s+\w+
|
||||
(r"fn\s+\w+", Some(&[
|
||||
("fn", C), // from "fn"
|
||||
("n ", C), // cross-boundary: 'n' → \s starts ' '
|
||||
("n ", C), // cross-boundary: 'n' -> \s starts ' '
|
||||
])),
|
||||
|
||||
// 30. use\s+crate::
|
||||
(r"use\s+crate::", Some(&[
|
||||
("us", C), ("se", C), ("ue", S), // from "use"
|
||||
("cr", C), ("ra", C), ("at", C), // from "crate"
|
||||
("us", C), ("se", C), ("ue", S), // from "use"
|
||||
("cr", C), ("ra", C), ("at", C), // from "crate"
|
||||
("te", C), ("::", C),
|
||||
("ca", S), ("rt", S), ("ae", S), // "crate" skip-1
|
||||
("ca", S), ("rt", S), ("ae", S), // "crate" skip-1
|
||||
])),
|
||||
|
||||
// 31. unwrap\(\)|expect\(
|
||||
(r"unwrap\(\)|expect\(", Some(&[
|
||||
("nw", C), ("wr", C), ("ra", C), // "unwrap("
|
||||
("nw", C), ("wr", C), ("ra", C), // "unwrap("
|
||||
("ap", C), ("p(", C),
|
||||
("xp", C), ("pe", C), ("ec", C), // "expect("
|
||||
("xp", C), ("pe", C), ("ec", C), // "expect("
|
||||
("ct", C), ("t(", C),
|
||||
])),
|
||||
|
||||
// 32. TODO|FIXME|HACK
|
||||
(r"TODO|FIXME|HACK", Some(&[
|
||||
("to", C), ("od", C), ("do", C), // "TODO"
|
||||
("fi", C), ("ix", C), ("xm", C), // "FIXME"
|
||||
("to", C), ("od", C), ("do", C), // "TODO"
|
||||
("fi", C), ("ix", C), ("xm", C), // "FIXME"
|
||||
("me", C),
|
||||
("ha", C), ("ac", C), ("ck", C), // "HACK"
|
||||
("ha", C), ("ac", C), ("ck", C), // "HACK"
|
||||
("hc", S), ("ak", S), // "HACK" skip-1
|
||||
])),
|
||||
];
|
||||
@@ -0,0 +1,118 @@
|
||||
use super::{BigramFilter, BigramOverlay, extract_bigrams};
|
||||
use super::{fuzzy_to_bigram_query, regex_to_bigram_query};
|
||||
|
||||
/// Number of evenly-spaced probe bigrams used by the fuzzy candidate query.
|
||||
const FUZZY_PROBE_COUNT: usize = 7;
|
||||
|
||||
#[inline]
|
||||
fn set_bit(candidates: &mut [u64], file_idx: usize) {
|
||||
let word = file_idx / 64;
|
||||
if word < candidates.len() {
|
||||
candidates[word] |= 1u64 << (file_idx % 64);
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn clear_tombstones(candidates: &mut [u64], overlay: &BigramOverlay) {
|
||||
for (r, t) in candidates.iter_mut().zip(overlay.tombstones().iter()) {
|
||||
*r &= !t;
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of base files covered by the bigram bitset; files past this
|
||||
/// boundary (overflow, max 1024) are always scanned.
|
||||
#[inline]
|
||||
pub(crate) fn bigram_boundary(overlay: Option<&BigramOverlay>, files_len: usize) -> usize {
|
||||
overlay.map(|o| o.base_file_count()).unwrap_or(files_len)
|
||||
}
|
||||
|
||||
/// Candidate bitset for literal patterns, OR-ed across all of them: a file is
|
||||
/// a candidate when it contains the bigrams of ANY pattern. Overlay-modified
|
||||
/// files are re-checked against each pattern's bigrams.
|
||||
pub(crate) fn literal_candidates(
|
||||
index: Option<&BigramFilter>,
|
||||
overlay: Option<&BigramOverlay>,
|
||||
patterns: &[&str],
|
||||
) -> Option<Vec<u64>> {
|
||||
let index = ready_index(index)?;
|
||||
|
||||
let mut combined: Option<Vec<u64>> = None;
|
||||
for pattern in patterns {
|
||||
if let Some(candidates) = index.query(pattern.as_bytes()) {
|
||||
combined = Some(match combined {
|
||||
None => candidates,
|
||||
Some(mut acc) => {
|
||||
acc.iter_mut()
|
||||
.zip(candidates.iter())
|
||||
.for_each(|(a, b)| *a |= *b);
|
||||
acc
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut candidates = combined?;
|
||||
if let Some(overlay) = overlay {
|
||||
clear_tombstones(&mut candidates, overlay);
|
||||
for pattern in patterns {
|
||||
let pattern_bigrams = extract_bigrams(pattern.as_bytes());
|
||||
for file_idx in overlay.query_modified(&pattern_bigrams) {
|
||||
set_bit(&mut candidates, file_idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(candidates)
|
||||
}
|
||||
|
||||
/// Candidate bitset for a regex pattern: the regex HIR is decomposed into an
|
||||
/// AND/OR bigram query tree (supports alternation, optional groups, character
|
||||
/// classes, and sparse-1 bigrams across single-byte wildcards). Since modified
|
||||
/// file contents can't be re-checked against a regex cheaply, all
|
||||
/// overlay-modified files are conservatively added.
|
||||
pub(crate) fn regex_candidates(
|
||||
index: Option<&BigramFilter>,
|
||||
overlay: Option<&BigramOverlay>,
|
||||
pattern: &str,
|
||||
) -> Option<Vec<u64>> {
|
||||
let index = ready_index(index)?;
|
||||
|
||||
let bq = regex_to_bigram_query(pattern);
|
||||
if bq.is_any() {
|
||||
return None;
|
||||
}
|
||||
let candidates = bq.evaluate(index)?;
|
||||
Some(add_all_modified(candidates, overlay))
|
||||
}
|
||||
|
||||
/// Candidate bitset for a fuzzy pattern: evenly-spaced probe bigrams with a
|
||||
/// typo allowance (widely-spaced probes are far more selective than sliding
|
||||
/// windows of adjacent bigrams). All overlay-modified files are added.
|
||||
pub(crate) fn fuzzy_candidates(
|
||||
index: Option<&BigramFilter>,
|
||||
overlay: Option<&BigramOverlay>,
|
||||
pattern: &str,
|
||||
) -> Option<Vec<u64>> {
|
||||
let index = ready_index(index)?;
|
||||
|
||||
let bq = fuzzy_to_bigram_query(pattern, FUZZY_PROBE_COUNT);
|
||||
if bq.is_any() {
|
||||
return None;
|
||||
}
|
||||
let candidates = bq.evaluate(index)?;
|
||||
Some(add_all_modified(candidates, overlay))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn ready_index(index: Option<&BigramFilter>) -> Option<&BigramFilter> {
|
||||
index.filter(|idx| idx.is_ready())
|
||||
}
|
||||
|
||||
fn add_all_modified(mut candidates: Vec<u64>, overlay: Option<&BigramOverlay>) -> Vec<u64> {
|
||||
if let Some(overlay) = overlay {
|
||||
clear_tombstones(&mut candidates, overlay);
|
||||
for file_idx in overlay.modified_indices() {
|
||||
set_bit(&mut candidates, file_idx);
|
||||
}
|
||||
}
|
||||
candidates
|
||||
}
|
||||
@@ -5,31 +5,7 @@ use smallvec::SmallVec;
|
||||
|
||||
use crate::git::is_modified_status;
|
||||
use crate::simd_path::ArenaPtr;
|
||||
|
||||
/// `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
|
||||
}
|
||||
use crate::simd_string_utils::memmem::find_case_insensitive_short;
|
||||
|
||||
const PAR_THRESHOLD: usize = 10_000;
|
||||
|
||||
@@ -40,17 +16,11 @@ pub(crate) trait Constrainable {
|
||||
fn is_overflow(&self) -> bool;
|
||||
}
|
||||
|
||||
/// Windows stores paths with `\\`; `/` comes from user queries.
|
||||
/// Stored/canonical paths use `/`; also accept `\` so a Windows user typing
|
||||
/// a native separator in a query still matches.
|
||||
#[inline]
|
||||
fn is_path_sep(b: u8) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
b == b'/' || b == b'\\'
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
b == b'/'
|
||||
}
|
||||
b == b'/' || b == b'\\'
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -174,9 +144,9 @@ pub(crate) fn apply_constraints<'a, T: Constrainable + Sync>(
|
||||
}
|
||||
|
||||
#[cfg(feature = "zlob")]
|
||||
type GlobPattern = zlob::ZlobPattern;
|
||||
#[cfg(not(feature = "zlob"))]
|
||||
type GlobPattern = globset::GlobMatcher;
|
||||
pub(crate) type GlobPattern = zlob::ZlobPattern;
|
||||
#[cfg(all(not(feature = "zlob"), feature = "ripgrep"))]
|
||||
pub(crate) type GlobPattern = globset::GlobMatcher;
|
||||
|
||||
/// How `Constraint::Glob` is evaluated for each item.
|
||||
enum GlobStrategy {
|
||||
@@ -222,7 +192,7 @@ impl<'q, 'c> ConstraintPlan<'q, 'c> {
|
||||
overflow_arena: ArenaPtr,
|
||||
) -> Self {
|
||||
let mut extensions = SmallVec::new();
|
||||
let mut rest = SmallVec::new();
|
||||
let mut rest: SmallVec<[&'c Constraint<'q>; 8]> = SmallVec::new();
|
||||
for c in constraints {
|
||||
match c {
|
||||
Constraint::Extension(ext) => extensions.push(*ext),
|
||||
@@ -290,56 +260,16 @@ impl<'q, 'c> ConstraintPlan<'q, 'c> {
|
||||
|
||||
let mut glob_idx = 0;
|
||||
self.rest.iter().all(|c| {
|
||||
let glob: &GlobStrategy = &self.glob;
|
||||
let glob_idx: &mut usize = &mut glob_idx;
|
||||
let negate = false;
|
||||
let raw = match c {
|
||||
Constraint::Glob(_) => {
|
||||
let m = match glob {
|
||||
GlobStrategy::None => true,
|
||||
GlobStrategy::Prepass(masks) => masks
|
||||
.get(*glob_idx)
|
||||
.and_then(|mask| mask.get(index).copied())
|
||||
.unwrap_or(false),
|
||||
GlobStrategy::Inline(patterns) => {
|
||||
item.write_relative_path(arena, &mut scratch.path);
|
||||
patterns
|
||||
.get(*glob_idx)
|
||||
.and_then(|p| p.as_ref())
|
||||
.map(|p| compiled_matches(p, &scratch.path))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
};
|
||||
*glob_idx += 1;
|
||||
m
|
||||
}
|
||||
// Reachable only via `Not(Extension(_))` — bare extensions are split out
|
||||
// up front and handled in `passes_extensions`.
|
||||
Constraint::Extension(ext) => {
|
||||
item.write_file_name(arena, &mut scratch.fname);
|
||||
file_has_extension(&scratch.fname, ext)
|
||||
}
|
||||
Constraint::PathSegment(segment) => {
|
||||
item.write_relative_path(arena, &mut scratch.path);
|
||||
path_contains_segment(&scratch.path, segment)
|
||||
}
|
||||
Constraint::FilePath(suffix) => {
|
||||
item.write_relative_path(arena, &mut scratch.path);
|
||||
path_ends_with_suffix(&scratch.path, suffix)
|
||||
}
|
||||
Constraint::Text(text) => {
|
||||
// Only meaningful under negation (used as exclude filter).
|
||||
item.write_relative_path(arena, &mut scratch.path);
|
||||
contains_ascii_ci(&scratch.path, text)
|
||||
}
|
||||
Constraint::GitStatus(filter) => matches_git_status(item.git_status(), filter),
|
||||
Constraint::Not(inner) => {
|
||||
return evaluate(item, index, inner, glob, glob_idx, !negate, arena, scratch);
|
||||
}
|
||||
// Pass-throughs — handled at higher levels.
|
||||
Constraint::Parts(_) | Constraint::Exclude(_) | Constraint::FileType(_) => true,
|
||||
};
|
||||
if negate { !raw } else { raw }
|
||||
evaluate(
|
||||
item,
|
||||
index,
|
||||
c,
|
||||
&self.glob,
|
||||
&mut glob_idx,
|
||||
false,
|
||||
arena,
|
||||
scratch,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -409,7 +339,7 @@ fn evaluate<T: Constrainable>(
|
||||
Constraint::Text(text) => {
|
||||
// Only meaningful under negation (used as exclude filter).
|
||||
item.write_relative_path(arena, &mut scratch.path);
|
||||
contains_ascii_ci(&scratch.path, text)
|
||||
find_case_insensitive_short(scratch.path.as_bytes(), text.as_bytes()).is_some()
|
||||
}
|
||||
Constraint::GitStatus(filter) => matches_git_status(item.git_status(), filter),
|
||||
Constraint::Not(inner) => {
|
||||
@@ -441,16 +371,34 @@ fn matches_git_status(status: Option<git2::Status>, filter: &GitStatusFilter) ->
|
||||
|
||||
#[inline]
|
||||
#[cfg(feature = "zlob")]
|
||||
fn compiled_matches(p: &GlobPattern, path: &str) -> bool {
|
||||
pub(crate) fn compiled_matches(p: &GlobPattern, path: &str) -> bool {
|
||||
p.matches_default(path)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[cfg(not(feature = "zlob"))]
|
||||
fn compiled_matches(p: &GlobPattern, path: &str) -> bool {
|
||||
#[cfg(all(not(feature = "zlob"), feature = "ripgrep"))]
|
||||
pub(crate) fn compiled_matches(p: &GlobPattern, path: &str) -> bool {
|
||||
p.is_match(path)
|
||||
}
|
||||
|
||||
/// Append indices (into `rels`) of paths matching `p`, in input order.
|
||||
/// zlob backend: ONE FFI call for the whole batch.
|
||||
#[cfg(feature = "zlob")]
|
||||
pub(crate) fn glob_matches_into(p: &GlobPattern, rels: &[&str], out: &mut Vec<usize>) {
|
||||
match p.match_indices(rels, p.flags()) {
|
||||
Ok(ix) => out.extend_from_slice(ix.as_slice()),
|
||||
Err(e) => {
|
||||
tracing::warn!(?e, "zlob batch match failed, falling back to per-path");
|
||||
out.extend((0..rels.len()).filter(|&i| p.matches_default(rels[i])));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(not(feature = "zlob"), feature = "ripgrep"))]
|
||||
pub(crate) fn glob_matches_into(p: &GlobPattern, rels: &[&str], out: &mut Vec<usize>) {
|
||||
out.extend((0..rels.len()).filter(|&i| p.is_match(rels[i])));
|
||||
}
|
||||
|
||||
/// Decide between batch prepass and inline compiled patterns.
|
||||
///
|
||||
/// `has_pre_filter` = true when something cheaper than glob can reject items first
|
||||
@@ -511,12 +459,6 @@ impl PathBuffer {
|
||||
let start = bytes.len();
|
||||
item.write_relative_path(item_arena, &mut tmp);
|
||||
bytes.extend_from_slice(tmp.as_bytes());
|
||||
#[cfg(windows)]
|
||||
for b in &mut bytes[start..] {
|
||||
if *b == b'\\' {
|
||||
*b = b'/';
|
||||
}
|
||||
}
|
||||
offsets.push((start, bytes.len() - start));
|
||||
}
|
||||
Self { bytes, offsets }
|
||||
@@ -561,12 +503,12 @@ fn walk_globs<F: FnMut(&str)>(c: &Constraint<'_>, f: &mut F) {
|
||||
}
|
||||
|
||||
#[cfg(feature = "zlob")]
|
||||
fn compile_one(pattern: &str) -> Option<GlobPattern> {
|
||||
pub(crate) fn compile_one(pattern: &str) -> Option<GlobPattern> {
|
||||
zlob::ZlobPattern::compile(pattern, zlob::ZlobFlags::RECOMMENDED).ok()
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "zlob"))]
|
||||
fn compile_one(pattern: &str) -> Option<GlobPattern> {
|
||||
#[cfg(all(not(feature = "zlob"), feature = "ripgrep"))]
|
||||
pub(crate) fn compile_one(pattern: &str) -> Option<GlobPattern> {
|
||||
globset::Glob::new(pattern)
|
||||
.ok()
|
||||
.map(|g| g.compile_matcher())
|
||||
@@ -589,7 +531,7 @@ fn match_glob_pattern(pattern: &str, paths: &[&str]) -> Vec<bool> {
|
||||
mask
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "zlob"))]
|
||||
#[cfg(all(not(feature = "zlob"), feature = "ripgrep"))]
|
||||
fn match_glob_pattern(pattern: &str, paths: &[&str]) -> Vec<bool> {
|
||||
let mut mask = vec![false; paths.len()];
|
||||
let Ok(glob) = globset::Glob::new(pattern) else {
|
||||
@@ -0,0 +1,11 @@
|
||||
#[doc(hidden)] // for bench
|
||||
pub mod bigram_filter;
|
||||
pub(crate) use bigram_filter::*;
|
||||
|
||||
mod bigram_query;
|
||||
pub use bigram_query::*;
|
||||
|
||||
mod candidates;
|
||||
pub(crate) use candidates::*;
|
||||
|
||||
pub mod constraints;
|
||||
+61
-44
@@ -1,10 +1,13 @@
|
||||
//! # FFF Search — High-performance file finder core
|
||||
//!
|
||||
//! This crate provides the core search engine for [FFF (Fast File Finder)](https://github.com/dmtrKovalenko/fff.nvim).
|
||||
//! This crate provides the core search engine for [FFF (Fast File Finder)](https://github.com/dmtrKovalenko/fff).
|
||||
//! It includes filesystem indexing with real-time watching, fuzzy matching powered
|
||||
//! by [frizbee](https://docs.rs/neo_frizbee), frecency scoring backed by LMDB,
|
||||
//! and multi-mode grep search.
|
||||
//!
|
||||
//! > [!Important performance information]
|
||||
//! > For the most optimized fff build use `zlob` feature. It requires zig v0.16.0 to be installed on the machine.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! - [`file_picker::FilePicker`] — Main entry point. Indexes a directory tree in a
|
||||
@@ -17,6 +20,9 @@
|
||||
//! - [`grep`] — Live grep search supporting regex, plain-text, and fuzzy modes
|
||||
//! with optional constraint filtering.
|
||||
//! - [`git`] — Git status caching and repository detection.
|
||||
//! - [`watch`] — Client-facing filesystem watch subscriptions: glob, exact
|
||||
//! path, or directory subtree with normalized batch delivery
|
||||
//! (see [`SharedFilePicker::watch`]).
|
||||
//!
|
||||
//! ## Shared State
|
||||
//!
|
||||
@@ -91,67 +97,78 @@
|
||||
//! # Ok::<(), Box<dyn std::error::Error>>(())
|
||||
//! ```
|
||||
|
||||
mod background_watcher;
|
||||
pub(crate) mod parallelism;
|
||||
mod scan;
|
||||
// public only for benchmarks — the inverted index is still re-exported via
|
||||
// `pub use bigram_filter::*` below for external consumers.
|
||||
#[doc(hidden)]
|
||||
pub mod bigram_filter;
|
||||
pub mod bigram_query;
|
||||
pub mod constants;
|
||||
mod constraints;
|
||||
mod error;
|
||||
mod score;
|
||||
mod sort_buffer;
|
||||
pub(crate) mod stable_vec;
|
||||
// this is pub only for benchmarks
|
||||
pub mod case_insensitive_memmem;
|
||||
#[cfg(not(any(feature = "ripgrep", feature = "zlob")))]
|
||||
compile_error!(
|
||||
"fff-search requires either the `ripgrep` (default) or `zlob` feature. \
|
||||
Enable one, e.g. `--features ripgrep` or `--features zlob`."
|
||||
);
|
||||
|
||||
pub(crate) mod simd_path;
|
||||
/// Primary entry points with thread-safe [`SharedFilePicker`](shared::FilePicker) instance
|
||||
pub mod shared;
|
||||
pub use shared::*;
|
||||
|
||||
/// Core file picker: filesystem indexing, background watching, and fuzzy search.
|
||||
///
|
||||
/// Core file picker single thread: filesystem indexing, background watching, and fuzzy search.
|
||||
/// See [`FilePicker`](file_picker::FilePicker) for the main entry point.
|
||||
pub mod file_picker;
|
||||
pub use file_picker::*;
|
||||
|
||||
/// Database-backed persistence: frecency, query history, LMDB plumbing.
|
||||
pub mod dbs;
|
||||
pub use dbs::frecency;
|
||||
pub use dbs::*;
|
||||
|
||||
/// Git status caching and repository detection utilities.
|
||||
pub mod git;
|
||||
|
||||
/// Live grep search with regex, plain-text, and fuzzy matching modes.
|
||||
///
|
||||
/// Supports constraint filtering (file extensions, path segments, globs)
|
||||
/// and parallel execution via rayon.
|
||||
pub mod grep;
|
||||
pub use grep::*;
|
||||
|
||||
/// Tracing/logging initialization and panic hook setup.
|
||||
/// Tracing/logging initialization
|
||||
pub mod log;
|
||||
|
||||
/// Path manipulation utilities: cross platform canonicalization, tilde expansion, and
|
||||
/// directory distance penalties for search scoring.
|
||||
/// Various path utils might be handy for you to work with fff paths
|
||||
pub mod path_utils;
|
||||
|
||||
pub use dbs::query_tracker;
|
||||
|
||||
/// Core data types shared across the crate.
|
||||
pub mod types;
|
||||
|
||||
mod ignore;
|
||||
/// Thread-safe shared handles for [`FilePicker`], [`FrecencyTracker`],
|
||||
/// and [`QueryTracker`].
|
||||
pub mod shared;
|
||||
|
||||
pub use bigram_filter::*;
|
||||
pub use dbs::db_healthcheck::{DbHealth, DbHealthChecker};
|
||||
pub use error::{Error, Result};
|
||||
pub use fff_query_parser::*;
|
||||
pub use file_picker::*;
|
||||
pub use frecency::*;
|
||||
pub use grep::*;
|
||||
pub use query_tracker::*;
|
||||
pub use shared::*;
|
||||
pub use types::*;
|
||||
|
||||
pub mod constants;
|
||||
|
||||
/// Watcher rescan request accounting.
|
||||
pub mod rescan_stats;
|
||||
pub use rescan_stats::{RESCAN_STATS_ENABLED, RescanReason, RescanStats};
|
||||
|
||||
mod rescan_throttle;
|
||||
|
||||
// ==================================
|
||||
// these are public only for benchmarks, no backward compatibility guaranteed
|
||||
#[doc(hidden)]
|
||||
pub use index::bigram_filter;
|
||||
#[doc(hidden)]
|
||||
pub mod simd_string_utils;
|
||||
// ==================================
|
||||
|
||||
mod error;
|
||||
mod git_status_worker;
|
||||
mod ignore;
|
||||
mod scan;
|
||||
mod score;
|
||||
mod sort_buffer;
|
||||
|
||||
pub(crate) mod index;
|
||||
pub(crate) mod parallelism;
|
||||
pub(crate) mod simd_path;
|
||||
pub(crate) mod stable_vec;
|
||||
pub(crate) mod walk;
|
||||
|
||||
/// Filesystem watch subscriptions with glob filtering and batched delivery,
|
||||
/// plus the background OS watcher.
|
||||
#[path = "watcher/mod.rs"]
|
||||
pub mod watch;
|
||||
pub use watch::{WatchEvent, WatchEventKind, WatchId, WatchOptions};
|
||||
|
||||
// fff error
|
||||
pub use error::{Error, Result};
|
||||
|
||||
pub use fff_query_parser::*;
|
||||
|
||||
@@ -34,8 +34,15 @@ mod sigsegv {
|
||||
|
||||
static LOG_FD: AtomicI32 = AtomicI32::new(-1);
|
||||
|
||||
// Must `create(true)` — this runs before init_tracing opens/creates the
|
||||
// writer file, so an append-only open on a non-existent path silently
|
||||
// fails, LOG_FD stays -1, and the SIGSEGV banner never reaches the log.
|
||||
pub fn set_log_fd(path: &Path) {
|
||||
if let Ok(file) = std::fs::OpenOptions::new().append(true).open(path) {
|
||||
if let Ok(file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
{
|
||||
let prev = LOG_FD.swap(file.into_raw_fd(), Ordering::Relaxed);
|
||||
if prev >= 0 {
|
||||
unsafe { libc::close(prev) };
|
||||
|
||||
@@ -10,6 +10,42 @@ pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
|
||||
std::fs::canonicalize(path)
|
||||
}
|
||||
|
||||
/// The index stores relative paths with `/` on every platform. These helpers
|
||||
/// convert between that canonical form and the OS-native separator, and are
|
||||
/// no-ops on non-Windows where `/` is already native.
|
||||
|
||||
/// Fold a relative path to the canonical `/` form (no-op off Windows).
|
||||
#[cfg(windows)]
|
||||
pub fn to_canonical_slashes(rel: &str) -> std::borrow::Cow<'_, str> {
|
||||
if rel.contains('\\') {
|
||||
std::borrow::Cow::Owned(rel.replace('\\', "/"))
|
||||
} else {
|
||||
std::borrow::Cow::Borrowed(rel)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[inline]
|
||||
pub fn to_canonical_slashes(rel: &str) -> std::borrow::Cow<'_, str> {
|
||||
std::borrow::Cow::Borrowed(rel)
|
||||
}
|
||||
|
||||
/// Rewrite canonical `/` bytes to the OS-native separator in place (no-op off
|
||||
/// Windows). Used at OS/state boundaries (absolute-path reconstruction).
|
||||
#[cfg(windows)]
|
||||
#[inline]
|
||||
pub fn nativize_slashes_in_place(bytes: &mut [u8]) {
|
||||
for b in bytes {
|
||||
if *b == b'/' {
|
||||
*b = b'\\';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[inline]
|
||||
pub fn nativize_slashes_in_place(_bytes: &mut [u8]) {}
|
||||
|
||||
/// Git requires a normalized forward-slashed paths on windows
|
||||
#[cfg(windows)]
|
||||
pub fn normalize(path: PathBuf) -> PathBuf {
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
#[cfg(rescan_stats)]
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// Whether rescan accounting is compiled in.
|
||||
pub const RESCAN_STATS_ENABLED: bool = cfg!(rescan_stats);
|
||||
|
||||
/// Cause recorded for a filesystem rescan request.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum RescanReason {
|
||||
/// Requested through the public API (refresh, directory change).
|
||||
Explicit,
|
||||
/// The kernel dropped events and asked us to re-read the subtree.
|
||||
KernelEventLoss,
|
||||
/// A `.gitignore`/`.ignore` changed, so the cached ignore rules are stale.
|
||||
IgnoreFileChanged,
|
||||
/// A single debounce batch touched more paths than we apply incrementally.
|
||||
EventBatchOverflow,
|
||||
/// The picker refused an incremental insert/update.
|
||||
IndexUpdateRejected,
|
||||
/// The post-scan overflow region ran out of slots.
|
||||
OverflowCapacity,
|
||||
}
|
||||
|
||||
impl RescanReason {
|
||||
pub const ALL: [RescanReason; 6] = [
|
||||
RescanReason::Explicit,
|
||||
RescanReason::KernelEventLoss,
|
||||
RescanReason::IgnoreFileChanged,
|
||||
RescanReason::EventBatchOverflow,
|
||||
RescanReason::IndexUpdateRejected,
|
||||
RescanReason::OverflowCapacity,
|
||||
];
|
||||
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
RescanReason::Explicit => "explicit",
|
||||
RescanReason::KernelEventLoss => "kernel_event_loss",
|
||||
RescanReason::IgnoreFileChanged => "ignore_file_changed",
|
||||
RescanReason::EventBatchOverflow => "event_batch_overflow",
|
||||
RescanReason::IndexUpdateRejected => "index_update_rejected",
|
||||
RescanReason::OverflowCapacity => "overflow_capacity",
|
||||
}
|
||||
}
|
||||
|
||||
const fn slot(self) -> usize {
|
||||
match self {
|
||||
RescanReason::Explicit => 0,
|
||||
RescanReason::KernelEventLoss => 1,
|
||||
RescanReason::IgnoreFileChanged => 2,
|
||||
RescanReason::EventBatchOverflow => 3,
|
||||
RescanReason::IndexUpdateRejected => 4,
|
||||
RescanReason::OverflowCapacity => 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RescanReason {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot of rescan requests grouped by reason.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct RescanStats {
|
||||
pub total: usize,
|
||||
/// Requests suppressed during the cooldown.
|
||||
pub throttled: usize,
|
||||
counts: [usize; RescanReason::ALL.len()],
|
||||
throttled_counts: [usize; RescanReason::ALL.len()],
|
||||
}
|
||||
|
||||
impl RescanStats {
|
||||
pub fn count(&self, reason: RescanReason) -> usize {
|
||||
self.counts[reason.slot()]
|
||||
}
|
||||
|
||||
pub fn count_throttled(&self, reason: RescanReason) -> usize {
|
||||
self.throttled_counts[reason.slot()]
|
||||
}
|
||||
|
||||
/// Admitted requests originating from watcher fallbacks.
|
||||
pub fn watcher_triggered(&self) -> usize {
|
||||
self.total - self.count(RescanReason::Explicit)
|
||||
}
|
||||
|
||||
/// Per-reason delta against an earlier snapshot.
|
||||
pub fn since(&self, earlier: &RescanStats) -> RescanStats {
|
||||
let mut counts = [0usize; RescanReason::ALL.len()];
|
||||
let mut throttled_counts = [0usize; RescanReason::ALL.len()];
|
||||
for slot in 0..RescanReason::ALL.len() {
|
||||
counts[slot] = self.counts[slot].saturating_sub(earlier.counts[slot]);
|
||||
throttled_counts[slot] =
|
||||
self.throttled_counts[slot].saturating_sub(earlier.throttled_counts[slot]);
|
||||
}
|
||||
|
||||
RescanStats {
|
||||
total: self.total.saturating_sub(earlier.total),
|
||||
throttled: self.throttled.saturating_sub(earlier.throttled),
|
||||
counts,
|
||||
throttled_counts,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RescanStats {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{} rescan(s)", self.total)?;
|
||||
let mut first = true;
|
||||
for reason in RescanReason::ALL {
|
||||
let count = self.count(reason);
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
f.write_str(if first { " [" } else { ", " })?;
|
||||
write!(f, "{reason}={count}")?;
|
||||
first = false;
|
||||
}
|
||||
if !first {
|
||||
f.write_str("]")?;
|
||||
}
|
||||
if self.throttled > 0 {
|
||||
write!(f, ", {} throttled", self.throttled)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(rescan_stats)]
|
||||
#[derive(Default)]
|
||||
pub(crate) struct RescanCounters {
|
||||
counters: [AtomicUsize; RescanReason::ALL.len()],
|
||||
throttled: [AtomicUsize; RescanReason::ALL.len()],
|
||||
}
|
||||
|
||||
#[cfg(rescan_stats)]
|
||||
impl RescanCounters {
|
||||
pub(crate) fn record(&self, reason: RescanReason) {
|
||||
self.counters[reason.slot()].fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn record_throttled(&self, reason: RescanReason) {
|
||||
self.throttled[reason.slot()].fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn snapshot(&self) -> RescanStats {
|
||||
let mut stats = RescanStats::default();
|
||||
for reason in RescanReason::ALL {
|
||||
let count = self.counters[reason.slot()].load(Ordering::Relaxed);
|
||||
stats.counts[reason.slot()] = count;
|
||||
stats.total += count;
|
||||
|
||||
let throttled = self.throttled[reason.slot()].load(Ordering::Relaxed);
|
||||
stats.throttled_counts[reason.slot()] = throttled;
|
||||
stats.throttled += throttled;
|
||||
}
|
||||
stats
|
||||
}
|
||||
|
||||
pub(crate) fn reset(&self) {
|
||||
for counter in self.counters.iter().chain(self.throttled.iter()) {
|
||||
counter.store(0, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Release builds retain the API without counter storage.
|
||||
#[cfg(not(rescan_stats))]
|
||||
#[derive(Default)]
|
||||
pub(crate) struct RescanCounters;
|
||||
|
||||
#[cfg(not(rescan_stats))]
|
||||
impl RescanCounters {
|
||||
pub(crate) fn record(&self, _reason: RescanReason) {}
|
||||
|
||||
pub(crate) fn record_throttled(&self, _reason: RescanReason) {}
|
||||
|
||||
pub(crate) fn snapshot(&self) -> RescanStats {
|
||||
RescanStats::default()
|
||||
}
|
||||
|
||||
pub(crate) fn reset(&self) {}
|
||||
}
|
||||
|
||||
#[cfg(all(test, rescan_stats))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn counters_attribute_and_diff_per_reason() {
|
||||
let counters = RescanCounters::default();
|
||||
counters.record(RescanReason::Explicit);
|
||||
let baseline = counters.snapshot();
|
||||
|
||||
counters.record(RescanReason::IgnoreFileChanged);
|
||||
counters.record(RescanReason::IgnoreFileChanged);
|
||||
counters.record(RescanReason::OverflowCapacity);
|
||||
|
||||
let stats = counters.snapshot();
|
||||
assert_eq!(stats.total, 4);
|
||||
assert_eq!(stats.watcher_triggered(), 3);
|
||||
|
||||
let delta = stats.since(&baseline);
|
||||
assert_eq!(delta.total, 3);
|
||||
assert_eq!(delta.count(RescanReason::Explicit), 0);
|
||||
assert_eq!(delta.count(RescanReason::IgnoreFileChanged), 2);
|
||||
assert_eq!(
|
||||
delta.to_string(),
|
||||
"3 rescan(s) [ignore_file_changed=2, overflow_capacity=1]"
|
||||
);
|
||||
|
||||
counters.reset();
|
||||
assert_eq!(counters.snapshot(), RescanStats::default());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::constants::{
|
||||
LARGE_INDEX_FILE_COUNT, RESCAN_MIN_INTERVAL, RESCAN_MIN_INTERVAL_LARGE_INDEX,
|
||||
};
|
||||
|
||||
const NEVER: u64 = u64::MAX;
|
||||
|
||||
// Drops watcher rescan requests inside the cooldown after the last scan.
|
||||
// A slightly stale index is fine: the next admitted event rescans everything.
|
||||
pub(crate) struct RescanThrottle {
|
||||
epoch: Instant,
|
||||
last_admitted: AtomicU64,
|
||||
}
|
||||
|
||||
impl Default for RescanThrottle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
epoch: Instant::now(),
|
||||
last_admitted: AtomicU64::new(NEVER),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RescanThrottle {
|
||||
/// Returns `true` if a rescan may start now and records it as the last scan
|
||||
pub(crate) fn admit(&self, live_files: usize, has_git_repo: bool) -> bool {
|
||||
let min_interval = if !has_git_repo && live_files >= LARGE_INDEX_FILE_COUNT {
|
||||
RESCAN_MIN_INTERVAL_LARGE_INDEX
|
||||
} else {
|
||||
RESCAN_MIN_INTERVAL
|
||||
};
|
||||
|
||||
let min_ms = min_interval.as_millis() as u64;
|
||||
let now = self.elapsed_ms();
|
||||
|
||||
loop {
|
||||
let last = self.last_admitted.load(Ordering::Acquire);
|
||||
if last != NEVER && now.saturating_sub(last) < min_ms {
|
||||
return false;
|
||||
}
|
||||
// CAS so two concurrent requests cannot both start a walk.
|
||||
if self
|
||||
.last_admitted
|
||||
.compare_exchange(last, now, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Records an explicit (unthrottled) scan so watcher requests right after
|
||||
/// it are dropped: the index is already fresh.
|
||||
pub(crate) fn note_explicit_scan(&self) {
|
||||
self.last_admitted
|
||||
.store(self.elapsed_ms(), Ordering::Release);
|
||||
}
|
||||
|
||||
fn elapsed_ms(&self) -> u64 {
|
||||
self.epoch.elapsed().as_millis() as u64
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
fn throttle_at(ms_ago: u64) -> RescanThrottle {
|
||||
let now = Instant::now();
|
||||
RescanThrottle {
|
||||
epoch: now
|
||||
.checked_sub(Duration::from_millis(ms_ago))
|
||||
.expect("monotonic clock older than the rewind"),
|
||||
last_admitted: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_request_is_always_admitted() {
|
||||
let throttle = RescanThrottle::default();
|
||||
assert!(throttle.admit(100, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_inside_the_cooldown_are_dropped() {
|
||||
let throttle = throttle_at(1_000);
|
||||
assert!(!throttle.admit(100, false));
|
||||
assert!(!throttle.admit(100, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_large_index_outside_a_git_repo_uses_the_slower_cadence() {
|
||||
// A minute is past the normal cooldown but not the large-index one.
|
||||
let throttle = throttle_at(60_000);
|
||||
assert!(throttle.admit(100, false));
|
||||
|
||||
let throttle = throttle_at(60_000);
|
||||
assert!(!throttle.admit(LARGE_INDEX_FILE_COUNT, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_git_repo_keeps_the_normal_cadence_at_any_size() {
|
||||
let throttle = throttle_at(60_000);
|
||||
assert!(throttle.admit(LARGE_INDEX_FILE_COUNT, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooldown_expiry_admits_again() {
|
||||
let throttle = throttle_at(RESCAN_MIN_INTERVAL.as_millis() as u64 + 1);
|
||||
assert!(throttle.admit(100, false));
|
||||
// Admission rearms the cooldown.
|
||||
assert!(!throttle.admit(100, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_scan_rearms_the_cooldown() {
|
||||
let throttle = RescanThrottle::default();
|
||||
throttle.note_explicit_scan();
|
||||
assert!(!throttle.admit(100, false));
|
||||
}
|
||||
}
|
||||
+51
-118
@@ -2,19 +2,16 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
use rayon::prelude::*;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::FileSync;
|
||||
use crate::background_watcher::BackgroundWatcher;
|
||||
use crate::bigram_filter::{build_bigram_index, sniff_binary_for_non_indexable};
|
||||
use crate::error::Error;
|
||||
use crate::file_picker::FFFMode;
|
||||
use crate::git::GitStatusCache;
|
||||
use crate::index::{build_bigram_index, sniff_binary_for_non_indexable};
|
||||
use crate::parallelism::BACKGROUND_THREAD_POOL;
|
||||
use crate::shared::{SharedFilePicker, SharedFrecency};
|
||||
use crate::simd_path::ArenaPtr;
|
||||
use crate::types::ContentCacheBudget;
|
||||
use crate::watch::BackgroundWatcher;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct ScanSignals {
|
||||
@@ -135,17 +132,21 @@ impl ScanJob {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the job on a dedicated OS thread. Returns immediately.
|
||||
pub fn spawn(self) -> std::thread::JoinHandle<()> {
|
||||
/// Run the job on `BACKGROUND_THREAD_POOL`. Returns immediately.
|
||||
///
|
||||
/// Routed through the pool — and not a fresh `std::thread::spawn` — so the
|
||||
/// orchestrator inherits rayon's QoS pin (USER_INITIATED). Without that
|
||||
/// pin, an interactive nvim's USER_INTERACTIVE main thread spawns a child
|
||||
/// at lower QoS, the walker's Zig worker pool inherits the demotion, and
|
||||
/// the kernel drifts those workers onto E-cores. On chromium that turns a
|
||||
/// ~800 ms walk into ~3 s.
|
||||
pub fn spawn(self) {
|
||||
self.signals.scanning.store(true, Ordering::Release);
|
||||
let span = self.trace_span.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("fff-scan".into())
|
||||
.spawn(move || {
|
||||
let _g = span.enter();
|
||||
self.run();
|
||||
})
|
||||
.expect("failed to spawn fff-scan thread")
|
||||
BACKGROUND_THREAD_POOL.spawn(move || {
|
||||
let _g = span.enter();
|
||||
self.run();
|
||||
});
|
||||
}
|
||||
|
||||
fn run(self) {
|
||||
@@ -160,15 +161,11 @@ impl ScanJob {
|
||||
trace_span: _,
|
||||
} = self;
|
||||
|
||||
let _scanning = ScanningGuard::new(&signals, config.install_watcher);
|
||||
|
||||
// Reset the UI-visible counter; the walker bumps it per file
|
||||
// and `get_scan_progress` reads it without locks.
|
||||
let _scanning = ScanningGuard::new(&signals);
|
||||
scanned_files_counter.store(0, Ordering::Relaxed);
|
||||
|
||||
// 1. Start git discovery and walk filesystem off-lock.
|
||||
// 1. Walk the file system and collect the list of files
|
||||
let git_workdir = FileSync::discover_git_workdir(&base_path);
|
||||
let status_handle = git_workdir.clone().map(FileSync::spawn_git_status);
|
||||
let sync = match FileSync::walk_filesystem(
|
||||
&base_path,
|
||||
git_workdir.clone(),
|
||||
@@ -184,7 +181,8 @@ impl ScanJob {
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Brief write to install the freshly-walked file list.
|
||||
// 2. Populate the file list
|
||||
let git_status_worker;
|
||||
if let Ok(mut guard) = shared_picker.write()
|
||||
&& let Some(picker) = guard.as_mut()
|
||||
{
|
||||
@@ -195,6 +193,7 @@ impl ScanJob {
|
||||
|
||||
let live_count = sync.live_count;
|
||||
picker.commit_new_sync(sync);
|
||||
git_status_worker = Arc::clone(&picker.git_status_worker);
|
||||
|
||||
if config.auto_cache_budget && !picker.has_explicit_cache_budget() {
|
||||
picker.set_cache_budget(ContentCacheBudget::new_for_repo(live_count));
|
||||
@@ -204,18 +203,16 @@ impl ScanJob {
|
||||
return;
|
||||
}
|
||||
|
||||
// Files are now searchable — flip the scan signal *early* so
|
||||
// UI progress polls see the picker as "ready" while we run the
|
||||
// optional post-scan steps in the background.
|
||||
signals.scanning.store(false, Ordering::Relaxed);
|
||||
|
||||
// in case we do a rescan, we have to resubscribe a watcher to the new set of directories
|
||||
// all the already watched directories are not going to be resubscribed
|
||||
if !config.install_watcher && !signals.cancelled.load(Ordering::Acquire) {
|
||||
rescubscribe_watcher_post_scan(&shared_picker);
|
||||
// Spawn the git status worker once. BUG PINNNING. If the user initiated git in the folder
|
||||
// which is a real use case we need to have a way to start the git worker background thread dynamically
|
||||
if git_workdir.is_some() && !signals.cancelled.load(Ordering::Acquire) {
|
||||
git_status_worker.spawn_once(shared_picker.weaken(), shared_frecency.clone());
|
||||
git_status_worker.request_full_rescan(); // this runs anyway
|
||||
}
|
||||
|
||||
let mut snapshot = if !signals.cancelled.load(Ordering::Acquire) {
|
||||
// BUG pinning: take the snapshot *before* the storing the scan=true, otherwise there is a tiny
|
||||
// race window when there scanned is set to true, but `post_scan_indexing_active` flag is `false`
|
||||
let snapshot = if !signals.cancelled.load(Ordering::Acquire) {
|
||||
shared_picker.read().ok().and_then(|guard| {
|
||||
guard
|
||||
.as_ref()
|
||||
@@ -225,26 +222,21 @@ impl ScanJob {
|
||||
None
|
||||
};
|
||||
|
||||
// 3. Post-scan warmup + bigram build — runs in parallel with the
|
||||
// git-status thread to overlap the two expensive phases.
|
||||
// Always runs (even with both flags off) so binary-content files
|
||||
// with unknown extensions get reclassified before user search hits.
|
||||
signals.scanning.store(false, Ordering::Relaxed); // file are searchable
|
||||
|
||||
// in case we do a rescan, we have to resubscribe a watcher to the new set of directories
|
||||
// all the already watched directories are not going to be resubscribed (this is internally deduped)
|
||||
if !config.install_watcher && !signals.cancelled.load(Ordering::Acquire) {
|
||||
rescubscribe_watcher_post_scan(&shared_picker);
|
||||
}
|
||||
|
||||
// 3. Runs post scna in parallel with git status collection
|
||||
if !signals.cancelled.load(Ordering::Acquire)
|
||||
&& let Some(snap) = snapshot.as_ref()
|
||||
{
|
||||
Self::run_post_scan(&shared_picker, &signals, &config, snap);
|
||||
}
|
||||
|
||||
// 4. Join and git status, this HAS to be done after the post scan
|
||||
if !signals.cancelled.load(Ordering::Acquire)
|
||||
&& let Some(status_handle) = status_handle
|
||||
&& let Some(snapshot) = snapshot.as_mut()
|
||||
// THIS DOES WAIT for potentially very long status query
|
||||
&& let Ok(Some(git_status)) = status_handle.join()
|
||||
{
|
||||
apply_git_status_and_frecency(git_status, &shared_frecency, mode, snapshot);
|
||||
}
|
||||
|
||||
drop(snapshot); // SNAPSHOT SHOULD NOT BE USED AFTER THIS POINT
|
||||
|
||||
// 5. Install filesystem watcher (initial scan only).
|
||||
@@ -261,13 +253,17 @@ impl ScanJob {
|
||||
mode,
|
||||
config.enable_fs_root_scanning,
|
||||
config.enable_home_dir_scanning,
|
||||
git_status_worker,
|
||||
tracing::Span::current(),
|
||||
) {
|
||||
Ok(watcher) => {
|
||||
if let Ok(mut guard) = shared_picker.write()
|
||||
&& let Some(picker) = guard.as_mut()
|
||||
&& picker.base_path() == base_path
|
||||
&& !signals.cancelled.load(Ordering::Acquire)
|
||||
{
|
||||
picker.background_watcher = Some(watcher);
|
||||
signals.watcher_ready.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
Err(e) => error!(?e, "failed to initialize background watcher"),
|
||||
@@ -342,11 +338,17 @@ impl ScanJob {
|
||||
non_indexable_files,
|
||||
&unsafe_snapshot.base_path,
|
||||
arena,
|
||||
&signals.cancelled,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// this potentially a long running as we are not parallelizing it but it's okay
|
||||
sniff_binary_for_non_indexable(files, &unsafe_snapshot.base_path, arena);
|
||||
sniff_binary_for_non_indexable(
|
||||
files,
|
||||
&unsafe_snapshot.base_path,
|
||||
arena,
|
||||
&signals.cancelled,
|
||||
);
|
||||
}
|
||||
|
||||
// TODO Skipped as potentially unsafe - figure this out later
|
||||
@@ -356,30 +358,21 @@ impl ScanJob {
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII helper that flips the `scanning` signal on construction and
|
||||
/// resets it on drop (so early-returns can't leave it stuck on `true`).
|
||||
/// Also drives the `watcher_ready` signal on the initial-scan path.
|
||||
// Ensures early returns clear the scanning signal.
|
||||
struct ScanningGuard<'a> {
|
||||
signals: &'a ScanSignals,
|
||||
release_watcher_ready_on_drop: bool,
|
||||
}
|
||||
|
||||
impl<'a> ScanningGuard<'a> {
|
||||
fn new(signals: &'a ScanSignals, release_watcher_ready_on_drop: bool) -> Self {
|
||||
fn new(signals: &'a ScanSignals) -> Self {
|
||||
signals.scanning.store(true, Ordering::Relaxed);
|
||||
Self {
|
||||
signals,
|
||||
release_watcher_ready_on_drop,
|
||||
}
|
||||
Self { signals }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ScanningGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.signals.scanning.store(false, Ordering::Relaxed);
|
||||
if self.release_watcher_ready_on_drop {
|
||||
self.signals.watcher_ready.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,63 +396,3 @@ fn rescubscribe_watcher_post_scan(shared_picker: &SharedFilePicker) {
|
||||
std::ops::ControlFlow::Continue(())
|
||||
});
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
level = "debug",
|
||||
skip_all,
|
||||
fields(file_count = tracing::field::Empty, dirty_count = tracing::field::Empty),
|
||||
)]
|
||||
fn apply_git_status_and_frecency(
|
||||
git_cache: GitStatusCache,
|
||||
shared_frecency: &SharedFrecency,
|
||||
mode: FFFMode,
|
||||
unsafe_snapshot: &mut crate::file_picker::PostScanUnsafeSnapshot,
|
||||
) {
|
||||
let frecency = shared_frecency.read().ok();
|
||||
let frecency_ref = frecency.as_ref().and_then(|f| f.as_ref());
|
||||
|
||||
let base_count = unsafe_snapshot.base_count;
|
||||
let files: &mut [crate::types::FileItem] = &mut unsafe_snapshot.files[..base_count];
|
||||
// Dir frecency goes through per-entry `AtomicI32`; a shared slice is
|
||||
// enough and avoids any `&mut` aliasing against the Arc-shared buffer.
|
||||
let dirs: &[crate::types::DirItem] = &unsafe_snapshot.dirs;
|
||||
let arena = unsafe_snapshot
|
||||
.arena
|
||||
.as_ref()
|
||||
.map(|s| s.as_arena_ptr())
|
||||
.unwrap_or(ArenaPtr::null());
|
||||
|
||||
// Reset dir frecency before recomputation.
|
||||
for dir in dirs.iter() {
|
||||
dir.reset_frecency();
|
||||
}
|
||||
|
||||
BACKGROUND_THREAD_POOL.install(|| {
|
||||
files.par_iter_mut().for_each(|file| {
|
||||
if unsafe_snapshot.cancelled.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
|
||||
let absolute_path =
|
||||
file.write_absolute_path(arena, &unsafe_snapshot.base_path, &mut buf);
|
||||
|
||||
file.git_status = git_cache.lookup_status(absolute_path);
|
||||
if let Some(frecency) = frecency_ref {
|
||||
let _ =
|
||||
file.update_frecency_scores(frecency, arena, &unsafe_snapshot.base_path, mode);
|
||||
}
|
||||
|
||||
let score = file.access_frecency_score as i32;
|
||||
if score > 0 {
|
||||
let dir_idx = file.parent_dir_index as usize;
|
||||
if let Some(dir) = dirs.get(dir_idx) {
|
||||
dir.update_frecency_if_larger(score);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let span = tracing::Span::current();
|
||||
span.record("dirty_count", git_cache.statuses_len());
|
||||
}
|
||||
|
||||
+186
-49
@@ -1,14 +1,15 @@
|
||||
use crate::{
|
||||
constraints::apply_constraints,
|
||||
git::is_modified_status,
|
||||
index::constraints::apply_constraints,
|
||||
path_utils::calculate_distance_penalty,
|
||||
simd_path::ArenaPtr,
|
||||
simd_path::{ArenaPtr, MAX_PATH_CHUNKS},
|
||||
sort_buffer::{sort_by_key_with_buffer, sort_with_buffer},
|
||||
types::{DirItem, FileItem, Score, ScoringContext},
|
||||
};
|
||||
use fff_query_parser::FuzzyQuery;
|
||||
use fff_query_parser::{FFFQuery, FuzzyQuery};
|
||||
use neo_frizbee::Scoring;
|
||||
use rayon::prelude::*;
|
||||
use smallvec::SmallVec;
|
||||
use std::{borrow::Cow, path::MAIN_SEPARATOR};
|
||||
|
||||
enum FileItems<'a> {
|
||||
@@ -32,7 +33,7 @@ impl<'a> FileItems<'a> {
|
||||
fn resolve_file_chunks(
|
||||
file: &FileItem,
|
||||
arena: ArenaPtr,
|
||||
buf: &mut [*const u8; 32],
|
||||
buf: &mut [*const u8; MAX_PATH_CHUNKS],
|
||||
) -> Option<(usize, u16)> {
|
||||
if file.is_deleted() {
|
||||
return None;
|
||||
@@ -60,15 +61,15 @@ fn match_fuzzy_parts(
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let resolve = |file: &FileItem, buf: &mut [*const u8; 32]| -> Option<(usize, u16)> {
|
||||
resolve_file_chunks(file, arena, buf)
|
||||
};
|
||||
let resolve = |file: &FileItem,
|
||||
buf: &mut [*const u8; MAX_PATH_CHUNKS]|
|
||||
-> Option<(usize, u16)> { resolve_file_chunks(file, arena, buf) };
|
||||
|
||||
// because we reassemble the vec of reference we have to use a different type
|
||||
// to narrow down the [&FileItem] which would be resolved by frizbee as &&
|
||||
let resolve_ref = |file: &&FileItem, buf: &mut [*const u8; 32]| -> Option<(usize, u16)> {
|
||||
resolve_file_chunks(file, arena, buf)
|
||||
};
|
||||
let resolve_ref = |file: &&FileItem,
|
||||
buf: &mut [*const u8; MAX_PATH_CHUNKS]|
|
||||
-> Option<(usize, u16)> { resolve_file_chunks(file, arena, buf) };
|
||||
|
||||
let first_part_matches = match working_files {
|
||||
FileItems::All(files) => neo_frizbee::match_list_parallel_resolved(
|
||||
@@ -168,13 +169,141 @@ pub(crate) fn fuzzy_match_and_score_files<'a>(
|
||||
sort_and_paginate(results, context)
|
||||
}
|
||||
|
||||
pub(crate) fn fuzzy_match_byte_offsets_for_page<'q>(
|
||||
query: &'q FFFQuery<'q>,
|
||||
items: &[&FileItem],
|
||||
max_typos: u16,
|
||||
base_arena: ArenaPtr,
|
||||
overflow_arena: ArenaPtr,
|
||||
) -> Vec<SmallVec<[(u32, u32); 4]>> {
|
||||
let parts: Vec<&str> = match &query.fuzzy_query {
|
||||
FuzzyQuery::Text(text) if text.len() >= 2 => vec![*text],
|
||||
FuzzyQuery::Parts(parts) => parts.iter().copied().filter(|p| p.len() >= 2).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
let mut ranges_by_item = vec![SmallVec::new(); items.len()];
|
||||
if parts.is_empty() || items.is_empty() {
|
||||
return ranges_by_item;
|
||||
}
|
||||
|
||||
let paths: Vec<String> = items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let arena = if item.is_overflow() {
|
||||
overflow_arena
|
||||
} else {
|
||||
base_arena
|
||||
};
|
||||
let mut path = String::with_capacity(item.relative_path_len());
|
||||
item.write_relative_path_from_arena(arena, &mut path);
|
||||
path
|
||||
})
|
||||
.collect();
|
||||
|
||||
let has_uppercase = parts
|
||||
.iter()
|
||||
.any(|part| part.chars().any(|ch| ch.is_uppercase()));
|
||||
let config = neo_frizbee::Config {
|
||||
max_typos: Some(max_typos),
|
||||
sort: false,
|
||||
scoring: Scoring {
|
||||
capitalization_bonus: if has_uppercase { 8 } else { 0 },
|
||||
matching_case_bonus: if has_uppercase { 4 } else { 0 },
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for (idx, part) in parts.iter().copied().enumerate() {
|
||||
let mut part_config = config;
|
||||
if idx > 0 {
|
||||
part_config.max_typos = config.max_typos.map(|t| t.min(part.len() as u16));
|
||||
}
|
||||
|
||||
let mut matcher = neo_frizbee::Matcher::new(part, &part_config);
|
||||
for mut matched in matcher.match_list_indices(&paths) {
|
||||
let item_idx = matched.index as usize;
|
||||
let Some(path) = paths.get(item_idx) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
matched.indices.sort_unstable();
|
||||
ranges_by_item[item_idx].extend(char_indices_to_byte_offsets(path, &matched.indices));
|
||||
}
|
||||
}
|
||||
|
||||
for ranges in &mut ranges_by_item {
|
||||
*ranges = merge_byte_offsets(std::mem::take(ranges));
|
||||
}
|
||||
|
||||
ranges_by_item
|
||||
}
|
||||
|
||||
fn char_indices_to_byte_offsets(line: &str, char_indices: &[usize]) -> SmallVec<[(u32, u32); 4]> {
|
||||
let char_byte_ranges: Vec<(usize, usize)> = line
|
||||
.char_indices()
|
||||
.map(|(byte_pos, ch)| (byte_pos, byte_pos + ch.len_utf8()))
|
||||
.collect();
|
||||
let mut result: SmallVec<[(u32, u32); 4]> = SmallVec::with_capacity(char_indices.len());
|
||||
|
||||
for &char_idx in char_indices {
|
||||
let Some(&(start, end)) = char_byte_ranges.get(char_idx) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some(last) = result.last_mut()
|
||||
&& last.1 == start as u32
|
||||
{
|
||||
last.1 = end as u32;
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push((start as u32, end as u32));
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn merge_byte_offsets(mut ranges: SmallVec<[(u32, u32); 4]>) -> SmallVec<[(u32, u32); 4]> {
|
||||
if ranges.len() <= 1 {
|
||||
return ranges;
|
||||
}
|
||||
|
||||
ranges.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
|
||||
let mut merged: SmallVec<[(u32, u32); 4]> = SmallVec::with_capacity(ranges.len());
|
||||
|
||||
for (start, end) in ranges {
|
||||
if end <= start {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(last) = merged.last_mut()
|
||||
&& start <= last.1
|
||||
{
|
||||
last.1 = last.1.max(end);
|
||||
continue;
|
||||
}
|
||||
|
||||
merged.push((start, end));
|
||||
}
|
||||
|
||||
merged
|
||||
}
|
||||
|
||||
/// Resolve a DirItem's chunked path into frizbee's pointer buffer.
|
||||
#[inline]
|
||||
fn resolve_dir_chunks(
|
||||
dir: &DirItem,
|
||||
arena: ArenaPtr,
|
||||
buf: &mut [*const u8; 32],
|
||||
overflow_arena: ArenaPtr,
|
||||
buf: &mut [*const u8; MAX_PATH_CHUNKS],
|
||||
) -> Option<(usize, u16)> {
|
||||
let arena = if dir.is_overflow() {
|
||||
overflow_arena
|
||||
} else {
|
||||
arena
|
||||
};
|
||||
let ptrs = dir.path.resolve_ptrs(arena, buf);
|
||||
Some((ptrs.len(), dir.path.byte_len))
|
||||
}
|
||||
@@ -187,6 +316,7 @@ fn match_fuzzy_parts_dirs(
|
||||
options: &neo_frizbee::Config,
|
||||
max_threads: usize,
|
||||
arena: ArenaPtr,
|
||||
overflow_arena: ArenaPtr,
|
||||
) -> Vec<neo_frizbee::Match> {
|
||||
let valid_parts: Vec<&str> = fuzzy_parts
|
||||
.iter()
|
||||
@@ -199,8 +329,8 @@ fn match_fuzzy_parts_dirs(
|
||||
}
|
||||
|
||||
let resolve_chunks_for_frizbee =
|
||||
|dir: &&DirItem, buf: &mut [*const u8; 32]| -> Option<(usize, u16)> {
|
||||
resolve_dir_chunks(dir, arena, buf)
|
||||
|dir: &&DirItem, buf: &mut [*const u8; MAX_PATH_CHUNKS]| -> Option<(usize, u16)> {
|
||||
resolve_dir_chunks(dir, arena, overflow_arena, buf)
|
||||
};
|
||||
|
||||
let first_part_matches = neo_frizbee::match_list_parallel_resolved(
|
||||
@@ -267,19 +397,23 @@ pub(crate) fn fuzzy_match_and_score_dirs<'a>(
|
||||
dirs: &'a [DirItem],
|
||||
context: &ScoringContext,
|
||||
arena: ArenaPtr,
|
||||
overflow_arena: ArenaPtr,
|
||||
) -> (Vec<&'a DirItem>, Vec<Score>, usize) {
|
||||
if dirs.is_empty() {
|
||||
return (vec![], vec![], 0);
|
||||
}
|
||||
|
||||
let parsed_query = context.query;
|
||||
// Ghost dirs (all files tombstoned) never surface in search results.
|
||||
let working_dirs: Vec<&DirItem> = if parsed_query.constraints.is_empty() {
|
||||
dirs.iter().collect()
|
||||
dirs.iter().filter(|d| !d.is_deleted()).collect()
|
||||
} else {
|
||||
match apply_constraints(dirs, &parsed_query.constraints, arena, arena) {
|
||||
Some(filtered) if !filtered.is_empty() => filtered,
|
||||
match apply_constraints(dirs, &parsed_query.constraints, arena, overflow_arena) {
|
||||
Some(filtered) if !filtered.is_empty() => {
|
||||
filtered.into_iter().filter(|d| !d.is_deleted()).collect()
|
||||
}
|
||||
Some(_) => return (vec![], vec![], 0),
|
||||
None => dirs.iter().collect(),
|
||||
None => dirs.iter().filter(|d| !d.is_deleted()).collect(),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -291,20 +425,6 @@ pub(crate) fn fuzzy_match_and_score_dirs<'a>(
|
||||
}
|
||||
};
|
||||
|
||||
// See `score_files` — stored dir paths are platform-native on Windows.
|
||||
#[cfg(windows)]
|
||||
let fuzzy_parts_owned: Option<Vec<String>> = if fuzzy_parts.iter().any(|p| p.contains('/')) {
|
||||
Some(fuzzy_parts.iter().map(|p| p.replace('/', "\\")).collect())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let fuzzy_parts_refs: Option<Vec<&str>> = fuzzy_parts_owned
|
||||
.as_ref()
|
||||
.map(|v| v.iter().map(String::as_str).collect());
|
||||
#[cfg(windows)]
|
||||
let fuzzy_parts: &[&str] = fuzzy_parts_refs.as_deref().unwrap_or(fuzzy_parts);
|
||||
|
||||
let valid_parts: Vec<&str> = fuzzy_parts
|
||||
.iter()
|
||||
.copied()
|
||||
@@ -327,6 +447,7 @@ pub(crate) fn fuzzy_match_and_score_dirs<'a>(
|
||||
matching_case_bonus: if has_uppercase { 4 } else { 0 },
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let path_matches = match_fuzzy_parts_dirs(
|
||||
@@ -335,6 +456,7 @@ pub(crate) fn fuzzy_match_and_score_dirs<'a>(
|
||||
&options,
|
||||
context.max_threads,
|
||||
arena,
|
||||
overflow_arena,
|
||||
);
|
||||
|
||||
let main_needle = valid_parts[0].as_bytes();
|
||||
@@ -347,12 +469,17 @@ pub(crate) fn fuzzy_match_and_score_dirs<'a>(
|
||||
.into_iter()
|
||||
.map(|path_match| {
|
||||
let dir = working_dirs[path_match.index as usize];
|
||||
let dir_arena = if dir.is_overflow() {
|
||||
overflow_arena
|
||||
} else {
|
||||
arena
|
||||
};
|
||||
let base_score = path_match.score as i32;
|
||||
let frecency_boost = base_score.saturating_mul(dir.max_access_frecency()) / 100;
|
||||
|
||||
// Distance penalty from current file's directory.
|
||||
let distance_penalty = if context.current_file.is_some() {
|
||||
dir.path.write_to_string(arena, &mut dir_buf);
|
||||
dir.path.write_to_string(dir_arena, &mut dir_buf);
|
||||
calculate_distance_penalty(context.current_file, &dir_buf)
|
||||
} else {
|
||||
0
|
||||
@@ -363,7 +490,7 @@ pub(crate) fn fuzzy_match_and_score_dirs<'a>(
|
||||
let match_start_approx = path_match.end_col.saturating_sub(main_needle_len - 1);
|
||||
let is_dirname_match = match_start_approx >= last_seg_offset;
|
||||
|
||||
dir.write_dir_name(arena, &mut dirname_buf);
|
||||
dir.write_dir_name(dir_arena, &mut dirname_buf);
|
||||
let dirname_len = dirname_buf.len();
|
||||
let is_exact_dirname = is_dirname_match
|
||||
&& main_needle_len as usize == dirname_len
|
||||
@@ -505,22 +632,6 @@ fn match_and_score_in_arena<'a>(
|
||||
}
|
||||
};
|
||||
|
||||
// On Windows, stored relative paths use the native `\\` separator while
|
||||
// users type `/`. Translate so frizbee sees the same bytes it would on
|
||||
// a path stored by the walker.
|
||||
#[cfg(windows)]
|
||||
let fuzzy_parts_owned: Option<Vec<String>> = if fuzzy_parts.iter().any(|p| p.contains('/')) {
|
||||
Some(fuzzy_parts.iter().map(|p| p.replace('/', "\\")).collect())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let fuzzy_parts_refs: Option<Vec<&str>> = fuzzy_parts_owned
|
||||
.as_ref()
|
||||
.map(|v| v.iter().map(String::as_str).collect());
|
||||
#[cfg(windows)]
|
||||
let fuzzy_parts: &[&str] = fuzzy_parts_refs.as_deref().unwrap_or(fuzzy_parts);
|
||||
|
||||
debug_assert!(!fuzzy_parts.is_empty());
|
||||
let has_uppercase = fuzzy_parts
|
||||
.iter()
|
||||
@@ -539,6 +650,7 @@ fn match_and_score_in_arena<'a>(
|
||||
matching_case_bonus: if has_uppercase { 4 } else { 0 },
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let path_matches = match_fuzzy_parts(
|
||||
@@ -1345,6 +1457,31 @@ mod filename_bonus_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: PR #652 / field panic in pi-fff v0.9.6.
|
||||
/// A path >512 bytes (but within PATH_MAX) overflows the fixed
|
||||
/// `[*const u8; 32]` chunk-pointer buffer during scoring and panics with
|
||||
/// "index out of bounds: the len is 32 but the index is 32".
|
||||
#[test]
|
||||
fn test_path_longer_than_512_bytes_does_not_panic_and_matches() {
|
||||
let mut long_path = String::new();
|
||||
while long_path.len() < 600 {
|
||||
long_path.push_str("deeply_nested_directory_segment/");
|
||||
}
|
||||
long_path.push_str("needle_file.rs");
|
||||
assert!(long_path.len() > 512 && long_path.len() < crate::simd_path::PATH_BUF_SIZE);
|
||||
|
||||
let (files, arena) = make_files(&[long_path.as_str(), "src/other.rs"]);
|
||||
|
||||
// Panics here on unfixed code: frizbee resolves chunk ptrs per file.
|
||||
let results = search(&files, "needle", arena);
|
||||
|
||||
assert!(
|
||||
results.iter().any(|(p, _)| p == &long_path),
|
||||
"filename at the tail of a >512-byte path must still match, got: {:?}",
|
||||
results.iter().map(|(p, _)| p).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[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";
|
||||
|
||||
+254
-134
@@ -2,13 +2,17 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard, Weak};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::dbs::lmdb::spawn_lmdb_gc;
|
||||
use crate::dbs::lmdb::{LmdbStore, spawn_lmdb_gc};
|
||||
use crate::error::Error;
|
||||
use crate::file_picker::FilePicker;
|
||||
use crate::frecency::FrecencyTracker;
|
||||
use crate::git::GitStatusCache;
|
||||
use crate::query_tracker::QueryTracker;
|
||||
use crate::rescan_stats::{RescanCounters, RescanReason, RescanStats};
|
||||
use crate::rescan_throttle::RescanThrottle;
|
||||
use crate::scan::ScanJob;
|
||||
use crate::watch::{WatchEvent, WatchId, WatchOptions, WatchRegistry};
|
||||
use git2::Repository;
|
||||
|
||||
/// Poll `.git/index.lock` until it disappears (git write completed), giving up
|
||||
/// after [`GIT_LOCK_MAX_WAIT`]. Used by [`SharedPicker::refresh_git_status`]
|
||||
@@ -38,6 +42,19 @@ fn wait_for_git_index_lock_release(git_root: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll `done` every 10ms until it returns `true`, or until `timeout` elapses.
|
||||
/// Returns `true` if the condition was met, `false` on timeout.
|
||||
fn poll_until(timeout: Duration, mut done: impl FnMut() -> bool) -> bool {
|
||||
let start = Instant::now();
|
||||
while !done() {
|
||||
if start.elapsed() >= timeout {
|
||||
return false;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Thread-safe shared handle to the [`FilePicker`] instance.
|
||||
/// This accumulates only asynchronous non-blocking operations against the
|
||||
/// file picker: creating, triggering various rescans and so on.
|
||||
@@ -59,12 +76,20 @@ pub struct SharedFilePicker(pub(crate) Arc<SharedPickerInner>);
|
||||
|
||||
pub struct SharedPickerInner {
|
||||
picker: parking_lot::RwLock<Option<FilePicker>>,
|
||||
/// Watch subscriptions live outside the picker lock so delivery and
|
||||
/// (un)subscribing never contend with searches.
|
||||
watchers: Arc<WatchRegistry>,
|
||||
rescans: RescanCounters,
|
||||
rescan_throttle: RescanThrottle,
|
||||
}
|
||||
|
||||
impl Default for SharedPickerInner {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
picker: parking_lot::RwLock::new(None),
|
||||
watchers: Arc::new(WatchRegistry::default()),
|
||||
rescans: RescanCounters::default(),
|
||||
rescan_throttle: RescanThrottle::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,6 +124,16 @@ impl SharedFilePicker {
|
||||
Ok(self.0.picker.write())
|
||||
}
|
||||
|
||||
/// Signal the background scan to cancel. Non-blocking: post-scan
|
||||
/// threads check this flag and bail out at their next cancellation point.
|
||||
pub fn cancel(&self) {
|
||||
if let Ok(guard) = self.read()
|
||||
&& let Some(picker) = guard.as_ref()
|
||||
{
|
||||
picker.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce a non-owning handle to the same inner picker.
|
||||
/// Use it if you don't need to block internal threads from dropping while owning this ref
|
||||
pub(crate) fn weaken(&self) -> WeakFilePicker {
|
||||
@@ -125,14 +160,9 @@ impl SharedFilePicker {
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
poll_until(timeout, || {
|
||||
!signal.load(std::sync::atomic::Ordering::Acquire)
|
||||
})
|
||||
}
|
||||
|
||||
/// Block until the background file watcher is ready.
|
||||
@@ -146,14 +176,9 @@ impl SharedFilePicker {
|
||||
}
|
||||
};
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
while !watch_ready_signal.load(std::sync::atomic::Ordering::Acquire) {
|
||||
if start.elapsed() >= timeout {
|
||||
return false;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
true
|
||||
poll_until(timeout, || {
|
||||
watch_ready_signal.load(std::sync::atomic::Ordering::Acquire)
|
||||
})
|
||||
}
|
||||
|
||||
/// Blocks until both the filesystem walk and post-scan indexing are done.
|
||||
@@ -170,24 +195,49 @@ impl SharedFilePicker {
|
||||
}
|
||||
};
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
if start.elapsed() >= timeout {
|
||||
return false;
|
||||
}
|
||||
let s = scanning.load(std::sync::atomic::Ordering::Acquire);
|
||||
let p = post_scan_active.load(std::sync::atomic::Ordering::Acquire);
|
||||
if !s && !p {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
poll_until(timeout, || {
|
||||
!scanning.load(std::sync::atomic::Ordering::Acquire)
|
||||
&& !post_scan_active.load(std::sync::atomic::Ordering::Acquire)
|
||||
})
|
||||
}
|
||||
|
||||
/// Trigger a full filesystem rescan without blocking the caller.
|
||||
/// Performs a safe async rescan. Guarantees only single active rescan per picker.
|
||||
/// If many rescans requested the last one guaranteed to be finished.
|
||||
pub fn trigger_full_rescan_async(&self, shared_frecency: &SharedFrecency) -> Result<(), Error> {
|
||||
self.trigger_full_rescan_with_reason(shared_frecency, RescanReason::Explicit)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Returns admitted and throttled rescan requests by reason.
|
||||
/// Counters start at picker creation or the last reset.
|
||||
pub fn rescan_stats(&self) -> RescanStats {
|
||||
self.0.rescans.snapshot()
|
||||
}
|
||||
|
||||
pub fn reset_rescan_stats(&self) {
|
||||
self.0.rescans.reset();
|
||||
}
|
||||
|
||||
/// Returns `Ok(true)` when a rescan was started (or queued behind an
|
||||
/// active scan) and `Ok(false)` when the request was throttled — the
|
||||
/// caller must then fall back to incremental event processing.
|
||||
pub(crate) fn trigger_full_rescan_with_reason(
|
||||
&self,
|
||||
shared_frecency: &SharedFrecency,
|
||||
reason: RescanReason,
|
||||
) -> Result<bool, Error> {
|
||||
// for giant folders we have no other choice other than throttling rescans
|
||||
// if user is running application in millions of files with a ton of rescan events
|
||||
// we drop / throttle some of requests to avoid constant burst of IO
|
||||
if reason == RescanReason::Explicit {
|
||||
self.0.rescan_throttle.note_explicit_scan();
|
||||
} else if !self.check_rescan_throttle(reason) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.0.rescans.record(reason);
|
||||
|
||||
match ScanJob::new_rescan(self, shared_frecency)? {
|
||||
Some(job) => {
|
||||
job.spawn();
|
||||
@@ -208,23 +258,108 @@ impl SharedFilePicker {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Refresh git statuses for all indexed files.
|
||||
fn check_rescan_throttle(&self, reason: RescanReason) -> bool {
|
||||
let (live_files, has_git) = self
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|guard| {
|
||||
guard
|
||||
.as_ref()
|
||||
.map(|picker| (picker.live_file_count(), picker.has_git_repo()))
|
||||
})
|
||||
.unwrap_or((0, false));
|
||||
|
||||
if self.0.rescan_throttle.admit(live_files, has_git) {
|
||||
return true;
|
||||
}
|
||||
|
||||
self.0.rescans.record_throttled(reason);
|
||||
tracing::debug!(%reason, live_files, "Rescan throttled, skipping");
|
||||
false
|
||||
}
|
||||
|
||||
/// Subscribe to filesystem changes matching `pattern`.
|
||||
///
|
||||
/// Patterns may be base-relative globs (./ works), exact paths inside the indexed
|
||||
/// tree, or existing directories. An empty pattern watches the whole tree.
|
||||
///
|
||||
/// Events are debounced and submitted in batches per 100-ms window at most 128 events.
|
||||
/// Gitignored and other ignored files are never triggering watcher.
|
||||
pub fn watch(
|
||||
&self,
|
||||
pattern: &str,
|
||||
options: WatchOptions,
|
||||
callback: impl Fn(WatchId, &[WatchEvent]) + Send + Sync + 'static,
|
||||
) -> Result<WatchId, Error> {
|
||||
let (base_path, has_watcher, watcher_ready) = {
|
||||
let guard = self.read()?;
|
||||
let picker = guard.as_ref().ok_or(Error::FilePickerMissing)?;
|
||||
|
||||
(
|
||||
picker.base_path().to_path_buf(),
|
||||
picker.has_watcher(),
|
||||
picker.is_watcher_ready(),
|
||||
)
|
||||
};
|
||||
|
||||
if !has_watcher {
|
||||
return Err(Error::WatcherDisabled);
|
||||
}
|
||||
if !watcher_ready {
|
||||
return Err(Error::WatcherNotReady);
|
||||
}
|
||||
|
||||
self.0
|
||||
.watchers
|
||||
.subscribe(&base_path, pattern, options, Box::new(callback))
|
||||
}
|
||||
|
||||
/// Remove a watch subscription. Returns `true` if the id was active.
|
||||
pub fn unwatch(&self, id: WatchId) -> bool {
|
||||
self.0.watchers.unsubscribe(id)
|
||||
}
|
||||
|
||||
/// Return whether a watch subscription is active.
|
||||
pub fn is_watch_active(&self, id: WatchId) -> bool {
|
||||
self.0.watchers.contains(id)
|
||||
}
|
||||
|
||||
/// Remove every subscription without waiting for an executing callback.
|
||||
pub fn shutdown_watches(&self) {
|
||||
self.0.watchers.shutdown();
|
||||
}
|
||||
|
||||
/// Remove every subscription and wait for an executing callback.
|
||||
/// When called by that callback, it does not wait on itself.
|
||||
pub fn shutdown_watches_and_wait(&self) {
|
||||
self.0.watchers.shutdown_and_wait();
|
||||
}
|
||||
|
||||
pub(crate) fn rebase_watches(&self, base_path: &Path) {
|
||||
self.0.watchers.rebase(base_path);
|
||||
}
|
||||
|
||||
pub(crate) fn watch_registry(&self) -> &Arc<WatchRegistry> {
|
||||
&self.0.watchers
|
||||
}
|
||||
|
||||
/// Refresh git statuses for all indexed files
|
||||
#[tracing::instrument(level = "info", skip_all)]
|
||||
pub fn refresh_git_status(&self, shared_frecency: &SharedFrecency) -> Result<usize, Error> {
|
||||
use tracing::debug;
|
||||
|
||||
let git_status = {
|
||||
let guard = self.read()?;
|
||||
let Some(ref picker) = *guard else {
|
||||
return Err(Error::FilePickerMissing);
|
||||
let git_root = {
|
||||
let guard = self.read()?;
|
||||
let Some(ref picker) = *guard else {
|
||||
return Err(Error::FilePickerMissing);
|
||||
};
|
||||
picker.git_root().map(|p| p.to_path_buf())
|
||||
};
|
||||
|
||||
let git_root = picker.git_root().map(|p| p.to_path_buf());
|
||||
drop(guard); // updating git status could take very long time, there is not risky as we
|
||||
// do not allow any mutations and deletions of files from the sync
|
||||
|
||||
debug!(?git_root, "Refreshing git status for picker");
|
||||
|
||||
if let Some(ref root) = git_root {
|
||||
@@ -250,16 +385,62 @@ impl SharedFilePicker {
|
||||
|
||||
Ok(statuses_count)
|
||||
}
|
||||
|
||||
/// Recompute and apply git status for a specific set of paths.
|
||||
pub fn update_git_status_for_paths(
|
||||
&self,
|
||||
paths: &[PathBuf],
|
||||
shared_frecency: &SharedFrecency,
|
||||
) -> Result<(), Error> {
|
||||
if paths.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let git_root = {
|
||||
let guard = self.read()?;
|
||||
let Some(ref picker) = *guard else {
|
||||
return Err(Error::FilePickerMissing);
|
||||
};
|
||||
picker.git_root().map(|p| p.to_path_buf())
|
||||
};
|
||||
let Some(git_root) = git_root else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
wait_for_git_index_lock_release(&git_root);
|
||||
|
||||
let repo = Repository::open(&git_root)?;
|
||||
let status = GitStatusCache::git_status_for_paths(&repo, paths)?;
|
||||
|
||||
let mut guard = self.write()?;
|
||||
let picker = guard.as_mut().ok_or(Error::FilePickerMissing)?;
|
||||
picker.update_git_statuses(status, shared_frecency)
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe shared handle to the [`FrecencyTracker`] instance.
|
||||
#[derive(Clone)]
|
||||
pub struct SharedFrecency {
|
||||
inner: Arc<RwLock<Option<FrecencyTracker>>>,
|
||||
/// Thread-safe shared handle to an LMDB-backed store. A disabled (`noop`)
|
||||
/// instance silently ignores writes. See the [`SharedFrecency`] and
|
||||
/// [`SharedQueryTracker`] aliases.
|
||||
///
|
||||
/// `LmdbStore` is intentionally crate-private, so the store type is sealed:
|
||||
/// only `FrecencyTracker` / `QueryTracker` can ever instantiate this.
|
||||
#[allow(private_bounds)]
|
||||
pub struct SharedDb<T: LmdbStore> {
|
||||
inner: Arc<RwLock<Option<T>>>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for SharedFrecency {
|
||||
// Hand-written to avoid a spurious `T: Clone` bound — `Arc` is always `Clone`.
|
||||
impl<T: LmdbStore> Clone for SharedDb<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
inner: self.inner.clone(),
|
||||
enabled: self.enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: LmdbStore> Default for SharedDb<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(None)),
|
||||
@@ -268,13 +449,14 @@ impl Default for SharedFrecency {
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SharedFrecency {
|
||||
impl<T: LmdbStore> std::fmt::Debug for SharedDb<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_tuple("SharedFrecency").field(&"..").finish()
|
||||
f.debug_tuple("SharedDb").field(&T::LABEL).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SharedFrecency {
|
||||
#[allow(private_bounds)]
|
||||
impl<T: LmdbStore> SharedDb<T> {
|
||||
/// Creates a disabled instance that silently ignores all writes.
|
||||
pub fn noop() -> Self {
|
||||
Self {
|
||||
@@ -283,15 +465,16 @@ impl SharedFrecency {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read(&self) -> Result<RwLockReadGuard<'_, Option<FrecencyTracker>>, Error> {
|
||||
pub fn read(&self) -> Result<RwLockReadGuard<'_, Option<T>>, Error> {
|
||||
self.inner.read().map_err(|_| Error::AcquireFrecencyLock)
|
||||
}
|
||||
|
||||
pub fn write(&self) -> Result<RwLockWriteGuard<'_, Option<FrecencyTracker>>, Error> {
|
||||
pub fn write(&self) -> Result<RwLockWriteGuard<'_, Option<T>>, Error> {
|
||||
self.inner.write().map_err(|_| Error::AcquireFrecencyLock)
|
||||
}
|
||||
|
||||
pub fn init(&self, tracker: FrecencyTracker) -> Result<(), Error> {
|
||||
/// Initialize the store + spawn GC in the background. No-op when disabled.
|
||||
pub fn init(&self, tracker: T) -> Result<(), Error> {
|
||||
if !self.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -301,29 +484,38 @@ impl SharedFrecency {
|
||||
*guard = Some(tracker);
|
||||
}
|
||||
|
||||
// GC holds a read guard on this lock, so destroy / re-init wait
|
||||
// for it naturally — no join handle, no race against file removal.
|
||||
// GC holds a read guard on this lock, so destroy / re-init wait won't race
|
||||
spawn_lmdb_gc(self.inner.clone());
|
||||
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.
|
||||
/// 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();
|
||||
|
||||
let closing_event = match tracker.shared_env().destroy() {
|
||||
Ok(closing) => closing,
|
||||
Err(e) => {
|
||||
*guard = Some(tracker);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let db_path = tracker.env().path().to_path_buf();
|
||||
// Drop closes the LMDB env and unmaps the files
|
||||
drop(tracker);
|
||||
drop(guard);
|
||||
|
||||
// Deleting before mdb_env_close finishes would race the unmap.
|
||||
if let Some(event) = closing_event {
|
||||
event.wait_timeout(Duration::from_secs(5));
|
||||
}
|
||||
|
||||
std::fs::remove_dir_all(&db_path).map_err(|source| Error::RemoveDbDir {
|
||||
path: db_path.clone(),
|
||||
source,
|
||||
@@ -332,80 +524,8 @@ impl SharedFrecency {
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe shared handle to the [`FrecencyTracker`] instance.
|
||||
pub type SharedFrecency = SharedDb<FrecencyTracker>;
|
||||
|
||||
/// 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 + spawn GC in the background.
|
||||
/// 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);
|
||||
}
|
||||
|
||||
spawn_lmdb_gc(self.inner.clone());
|
||||
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))
|
||||
}
|
||||
}
|
||||
pub type SharedQueryTracker = SharedDb<QueryTracker>;
|
||||
|
||||
@@ -62,6 +62,9 @@ impl std::fmt::Debug for SimdChunk {
|
||||
|
||||
pub use crate::constants::PATH_BUF_SIZE;
|
||||
|
||||
/// Chunk pointer capacity needed for the longest path the platform allows.
|
||||
pub(crate) const MAX_PATH_CHUNKS: usize = PATH_BUF_SIZE.div_ceil(SIMD_CHUNK_BYTES);
|
||||
|
||||
/// Indices into a shared `SimdChunk` arena representing a file path.
|
||||
///
|
||||
/// All read methods require an explicit `arena_base` pointer from the owning
|
||||
@@ -98,14 +101,10 @@ impl ChunkedString {
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn resolve_ptrs<'a>(
|
||||
&self,
|
||||
arena: ArenaPtr,
|
||||
buf: &'a mut [*const u8; 32],
|
||||
) -> &'a [*const u8] {
|
||||
let count = self.indices.len();
|
||||
pub fn resolve_ptrs<'a>(&self, arena: ArenaPtr, buf: &'a mut [*const u8]) -> &'a [*const u8] {
|
||||
let count = self.indices.len().min(buf.len());
|
||||
let base = arena.as_ptr();
|
||||
for (i, &idx) in self.indices.iter().enumerate() {
|
||||
for (i, &idx) in self.indices[..count].iter().enumerate() {
|
||||
buf[i] = unsafe { base.add(idx as usize * SIMD_CHUNK_BYTES) };
|
||||
}
|
||||
&buf[..count]
|
||||
@@ -416,7 +415,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_chunked_string_full_path() {
|
||||
let (store, strings, _files) = build_test_store(&["src/components/Button.tsx"]);
|
||||
let (store, strings, _files) = build_test_store(&[
|
||||
"src/components/Button.tsx",
|
||||
"src/components/Button.test.tsx",
|
||||
]);
|
||||
let arena = store.as_arena_ptr();
|
||||
let cs = &strings[0];
|
||||
|
||||
@@ -424,6 +426,15 @@ mod tests {
|
||||
assert_eq!(cs.read_to_buf(arena, &mut buf), "src/components/Button.tsx");
|
||||
assert_eq!(cs.byte_len, 25);
|
||||
assert_eq!(cs.filename_offset, 15);
|
||||
|
||||
let cs = &strings[1];
|
||||
let mut buf = [0u8; 512];
|
||||
assert_eq!(
|
||||
cs.read_to_buf(arena, &mut buf),
|
||||
"src/components/Button.test.tsx"
|
||||
);
|
||||
assert_eq!(cs.byte_len, 30);
|
||||
assert_eq!(cs.filename_offset, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -460,7 +471,7 @@ mod tests {
|
||||
let arena = store.as_arena_ptr();
|
||||
let cs = &strings[0];
|
||||
|
||||
let mut ptrs = [std::ptr::null::<u8>(); 32];
|
||||
let mut ptrs = [std::ptr::null::<u8>(); MAX_PATH_CHUNKS];
|
||||
let resolved = cs.resolve_ptrs(arena, &mut ptrs);
|
||||
assert_eq!(resolved.len(), 2); // 25 bytes = 2 chunks
|
||||
|
||||
@@ -478,6 +489,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_ptrs_path_exceeding_512_bytes() {
|
||||
// Regression: a fixed 32-ptr buffer covered only 512 bytes while
|
||||
// PATH_BUF_SIZE (libc::PATH_MAX) allows longer paths, panicking with
|
||||
// "index out of bounds: the len is 32 but the index is 32"
|
||||
let mut path = String::new();
|
||||
while path.len() < 600 {
|
||||
path.push_str("deeply_nested_directory_segment/");
|
||||
}
|
||||
path.push_str("needle_file.rs");
|
||||
assert!(path.len() > 512 && path.len() < PATH_BUF_SIZE);
|
||||
|
||||
let (store, strings, _files) = build_test_store(&[path.as_str()]);
|
||||
let arena = store.as_arena_ptr();
|
||||
let cs = &strings[0];
|
||||
assert!(cs.chunk_count() > 32, "path must span more than 32 chunks");
|
||||
|
||||
let mut ptrs = [std::ptr::null::<u8>(); MAX_PATH_CHUNKS];
|
||||
let resolved = cs.resolve_ptrs(arena, &mut ptrs);
|
||||
|
||||
// Truncation is not acceptable either: it silently drops the tail of
|
||||
// the path (including the filename here) from fuzzy matching.
|
||||
assert_eq!(
|
||||
resolved.len(),
|
||||
cs.chunk_count(),
|
||||
"resolve_ptrs must resolve every chunk of a PATH_MAX-legal path"
|
||||
);
|
||||
|
||||
let total = cs.byte_len as usize;
|
||||
let mut reconstructed = Vec::with_capacity(total);
|
||||
for (i, &ptr) in resolved.iter().enumerate() {
|
||||
let take = SIMD_CHUNK_BYTES.min(total - i * SIMD_CHUNK_BYTES);
|
||||
reconstructed.extend_from_slice(unsafe { std::slice::from_raw_parts(ptr, take) });
|
||||
}
|
||||
assert_eq!(std::str::from_utf8(&reconstructed).unwrap(), path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filename_cow_mid_chunk() {
|
||||
let (store, strings, _files) = build_test_store(&["src/components/Button.tsx"]);
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
#[inline]
|
||||
pub fn ascii_swap_case(b: u8) -> u8 {
|
||||
b ^ 0x20
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn eq_lowered_scalar(h: *const u8, needle_lower: &[u8]) -> bool {
|
||||
for (i, &n) in needle_lower.iter().enumerate() {
|
||||
if unsafe { *h.add(i) }.to_ascii_lowercase() != n {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// AVX2 only has a **signed** byte compare (`cmpgt`), but we need an
|
||||
/// **unsigned** range check (`'A' <= byte <= 'Z'`). XOR-ing every byte with
|
||||
/// `0x80` maps the unsigned range `[0, 255]` into the signed range
|
||||
/// `[-128, 127]` preserving order, so signed `cmpgt` becomes correct
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
#[target_feature(enable = "avx2")]
|
||||
unsafe fn eq_lowered_avx2(h: *const u8, needle_lower: &[u8]) -> bool {
|
||||
use core::arch::x86_64::*;
|
||||
|
||||
let len = needle_lower.len();
|
||||
let mut i = 0usize;
|
||||
|
||||
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 {
|
||||
let hv = unsafe { _mm256_loadu_si256(h.add(i) as *const __m256i) };
|
||||
let nv = unsafe { _mm256_loadu_si256(needle_lower.as_ptr().add(i) as *const __m256i) };
|
||||
|
||||
// Signed-domain range check selects uppercase lanes, OR bit 5 folds them.
|
||||
let x = _mm256_xor_si256(hv, flip);
|
||||
let ge_a = _mm256_cmpgt_epi8(x, a_minus_1);
|
||||
let le_z = _mm256_cmpgt_epi8(z_plus_1, x);
|
||||
let upper = _mm256_and_si256(ge_a, le_z);
|
||||
let folded = _mm256_or_si256(hv, _mm256_and_si256(upper, bit20));
|
||||
|
||||
let eq = _mm256_cmpeq_epi8(folded, nv);
|
||||
if _mm256_movemask_epi8(eq) != -1i32 {
|
||||
return false;
|
||||
}
|
||||
|
||||
i += 32;
|
||||
}
|
||||
|
||||
while i < len {
|
||||
if unsafe { *h.add(i) }.to_ascii_lowercase() != needle_lower[i] {
|
||||
return false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Unsigned range checks (`vcge`/`vcle`) detect uppercase ASCII, bit 5 folds
|
||||
/// to lowercase, then equality is checked via udot: xors 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. udot is emitted via inline
|
||||
/// asm because `vdotq_u32` is still behind an unstable feature gate.
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
#[target_feature(enable = "neon,dotprod")]
|
||||
unsafe fn eq_lowered_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)) };
|
||||
|
||||
let upper = vandq_u8(vcgeq_u8(hv, a_val), vcleq_u8(hv, z_val));
|
||||
let folded = vorrq_u8(hv, vandq_u8(upper, bit20));
|
||||
let xored = veorq_u8(folded, nv);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
while i < len {
|
||||
if unsafe { *h.add(i) }.to_ascii_lowercase() != needle_lower[i] {
|
||||
return false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Case-insensitive equality of `needle_lower` against the haystack bytes
|
||||
/// starting at `h`. `needle_lower` must be pre-lowercased (ASCII).
|
||||
///
|
||||
/// # Safety
|
||||
/// `h` must be valid for reads of `needle_lower.len()` bytes.
|
||||
#[inline]
|
||||
pub(crate) unsafe fn eq_lowered_case(haystack: *const u8, needle_lower: &[u8]) -> bool {
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
{
|
||||
if needle_lower.len() >= 32 && std::is_x86_feature_detected!("avx2") {
|
||||
return unsafe { eq_lowered_avx2(haystack, needle_lower) };
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
{
|
||||
if needle_lower.len() >= 16 && std::arch::is_aarch64_feature_detected!("dotprod") {
|
||||
return unsafe { eq_lowered_neon_dotprod(haystack, needle_lower) };
|
||||
}
|
||||
}
|
||||
|
||||
eq_lowered_scalar(haystack, needle_lower)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn eq_lowered(haystack: &[u8], needle_lower: &[u8]) -> bool {
|
||||
assert!(haystack.len() >= needle_lower.len());
|
||||
unsafe { eq_lowered_case(haystack.as_ptr(), needle_lower) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_case_toggles_letters() {
|
||||
assert_eq!(ascii_swap_case(b'n'), b'N');
|
||||
assert_eq!(ascii_swap_case(b'N'), b'n');
|
||||
assert_eq!(ascii_swap_case(b'z'), b'Z');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eq_matches_std_semantics() {
|
||||
assert!(eq_lowered(b"Hello", b"hello"));
|
||||
assert!(eq_lowered(b"HELLO WORLD", b"hello"));
|
||||
assert!(!eq_lowered(b"Hellp", b"hello"));
|
||||
// Non-letters must not fold: '[' (0x5B) vs '{' (0x7B) differ only in bit 5.
|
||||
assert!(!eq_lowered(b"A[", b"a{"));
|
||||
assert!(eq_lowered(b"A{", b"a{"));
|
||||
// Long inputs exercise the SIMD kernels.
|
||||
let hay = b"INT STRUCT MUTEX *LOCK(STRUCT MUTEX *LOCK) { RETURN 0; }";
|
||||
let needle: Vec<u8> = hay.iter().map(|b| b.to_ascii_lowercase()).collect();
|
||||
assert!(eq_lowered(hay, &needle));
|
||||
let mut bad = needle.clone();
|
||||
*bad.last_mut().unwrap() = b'!';
|
||||
assert!(!eq_lowered(hay, &bad));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
use super::case::{ascii_swap_case, eq_lowered_case};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
// Byte frequency table stolen from memchr
|
||||
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 rank(lower: u8) -> u8 {
|
||||
if lower.is_ascii_lowercase() {
|
||||
BYTE_FREQUENCIES[lower as usize].max(BYTE_FREQUENCIES[ascii_swap_case(lower) as usize])
|
||||
} else {
|
||||
BYTE_FREQUENCIES[lower as usize]
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick two needle positions with the rarest bytes (case-insensitive)
|
||||
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 = 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)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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)
|
||||
}
|
||||
|
||||
/// AVX2 packed-pair kernel: scan 32 haystack positions per iteration,
|
||||
/// checking two rare bytes (case-insensitive) simultaneously
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
#[target_feature(enable = "avx2")]
|
||||
unsafe fn find_packed_pair_avx2(
|
||||
haystack: &[u8],
|
||||
needle_lower: &[u8],
|
||||
i1: usize,
|
||||
i2: usize,
|
||||
) -> Option<usize> {
|
||||
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);
|
||||
|
||||
// Loads come 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;
|
||||
|
||||
// Candidates are visited in increasing position order, so the first
|
||||
// verified candidate is the leftmost match
|
||||
while mask != 0 {
|
||||
let bit = mask.trailing_zeros() as usize;
|
||||
let candidate = offset + bit;
|
||||
if candidate > last_start {
|
||||
return None;
|
||||
}
|
||||
if unsafe { eq_lowered_case(ptr.add(candidate), needle_lower) } {
|
||||
return Some(candidate);
|
||||
}
|
||||
mask &= mask - 1;
|
||||
}
|
||||
|
||||
offset += 32;
|
||||
}
|
||||
|
||||
// handle remaining characters
|
||||
if offset <= last_start {
|
||||
let rare_pos = if rank(needle_lower[i1]) <= 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 { eq_lowered_case(ptr.add(candidate), needle_lower) } {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for pos in memchr::memchr_iter(rare_byte, tail_space) {
|
||||
let candidate = offset + pos;
|
||||
if unsafe { eq_lowered_case(ptr.add(candidate), needle_lower) } {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
#[target_feature(enable = "neon")]
|
||||
unsafe fn find_packed_pair_neon(
|
||||
haystack: &[u8],
|
||||
needle_lower: &[u8],
|
||||
i1: usize,
|
||||
i2: usize,
|
||||
) -> Option<usize> {
|
||||
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 None;
|
||||
}
|
||||
if unsafe { eq_lowered_case(ptr.add(candidate), needle_lower) } {
|
||||
return Some(candidate);
|
||||
}
|
||||
mask &= mask - 1;
|
||||
}
|
||||
|
||||
offset += 16;
|
||||
}
|
||||
|
||||
// Tail: remaining positions that couldn't fill a full vector.
|
||||
if offset <= last_start {
|
||||
let rare_pos = if rank(needle_lower[i1]) <= 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 { eq_lowered_case(ptr.add(candidate), needle_lower) } {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for pos in memchr::memchr_iter(rare_byte, tail_space) {
|
||||
let candidate = offset + pos;
|
||||
if unsafe { eq_lowered_case(ptr.add(candidate), needle_lower) } {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn find_first_byte_with_memchr(haystack: &[u8], needle_lower: &[u8]) -> Option<usize> {
|
||||
let n = needle_lower.len();
|
||||
debug_assert!(n >= 1 && n <= haystack.len());
|
||||
|
||||
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 { eq_lowered_case(haystack.as_ptr().add(pos), needle_lower) } {
|
||||
return Some(pos);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for pos in memchr::memchr_iter(first, search_space) {
|
||||
if unsafe { eq_lowered_case(haystack.as_ptr().add(pos), needle_lower) } {
|
||||
return Some(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// ASCII case-insensitive substring search returning the leftmost match
|
||||
/// position. `needle_lower` must be pre-lowercased (ASCII).
|
||||
// pub because it is used in out of the crate benchmarks
|
||||
#[doc(hidden)] // it's pub only for benches
|
||||
pub fn find(haystack: &[u8], needle_lower: &[u8]) -> Option<usize> {
|
||||
let n = needle_lower.len();
|
||||
if n == 0 {
|
||||
return Some(0);
|
||||
}
|
||||
if n > haystack.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if n == 1 {
|
||||
let first = needle_lower[0];
|
||||
return if first.is_ascii_lowercase() {
|
||||
memchr::memchr2(first, ascii_swap_case(first), haystack)
|
||||
} else {
|
||||
memchr::memchr(first, haystack)
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg_attr(
|
||||
not(any(target_arch = "x86_64", target_arch = "aarch64")),
|
||||
allow(unused_variables)
|
||||
)]
|
||||
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 { find_packed_pair_avx2(haystack, needle_lower, i1, i2) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
{
|
||||
// Packed-pair wins when the first byte is common (memchr2 drowns in
|
||||
// false positives), but a rare first byte (z, q, x, ...) makes
|
||||
// memchr2's raw throughput dominate. Threshold 200 on the frequency
|
||||
// table splits common letters (s=243, e=253) from rare ones (z=152).
|
||||
let first_byte_rank = rank(needle_lower[0]);
|
||||
let max_idx = i1.max(i2);
|
||||
if first_byte_rank >= 200 && haystack.len() >= max_idx + 16 {
|
||||
return unsafe { find_packed_pair_neon(haystack, needle_lower, i1, i2) };
|
||||
}
|
||||
}
|
||||
|
||||
// fallbacks to memchr based implementation cause we still have it and it supports more SIMD backends
|
||||
// TODO convert all the supported backend by memchr and get rid of the fallback
|
||||
find_first_byte_with_memchr(haystack, needle_lower)
|
||||
}
|
||||
|
||||
/// A case insensitive find that works better with smaller strings, doesn't unwrap a complicated
|
||||
/// AVX backend we use for grep because only cpu flags check takes usually more time than find itself
|
||||
pub fn find_case_insensitive_short(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
debug_assert!(haystack.len() < 1024);
|
||||
let mut needle_lower: SmallVec<[u8; 64]> = SmallVec::from_slice(needle);
|
||||
needle_lower.make_ascii_lowercase();
|
||||
|
||||
find(haystack, &needle_lower)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn reference_find(haystack: &[u8], needle_lower: &[u8]) -> Option<usize> {
|
||||
if needle_lower.is_empty() {
|
||||
return Some(0);
|
||||
}
|
||||
if needle_lower.len() > haystack.len() {
|
||||
return None;
|
||||
}
|
||||
haystack
|
||||
.windows(needle_lower.len())
|
||||
.position(|w| w.eq_ignore_ascii_case(needle_lower))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_case_insensitive() {
|
||||
assert_eq!(find(b"Hello World", b"hello"), Some(0));
|
||||
assert_eq!(find(b"Hello World", b"world"), Some(6));
|
||||
assert_eq!(find(b"NOMORE bugs", b"nomore"), Some(0));
|
||||
assert_eq!(find(b"Hello World", b"xyz"), None);
|
||||
assert!(find(b"Hello World", b"o w").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_cases() {
|
||||
assert_eq!(find(b"ab", b"ab"), Some(0));
|
||||
assert_eq!(find(b"AB", b"ab"), Some(0));
|
||||
assert_eq!(find(b"a", b"ab"), None);
|
||||
assert_eq!(find(b"anything", b""), Some(0));
|
||||
assert_eq!(find(b"", b"x"), None);
|
||||
assert_eq!(find(b"xxA", b"a"), Some(2));
|
||||
assert_eq!(find(b"xx:", b":"), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_leftmost_match() {
|
||||
assert_eq!(find(b"foo FOO foo", b"foo"), Some(0));
|
||||
let mut big = vec![b'.'; 300];
|
||||
big[100..103].copy_from_slice(b"FoO");
|
||||
big[200..203].copy_from_slice(b"foo");
|
||||
assert_eq!(find(&big, b"foo"), Some(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_letter_bytes_do_not_case_fold() {
|
||||
// '[' (0x5B) and '{' (0x7B) differ only in bit 5 but are not letters.
|
||||
// A fold implemented as a bare `| 0x20` would falsely match these.
|
||||
assert_eq!(find(b"A[", b"a{"), None);
|
||||
assert_eq!(find(b"x@y", b"x`y"), None);
|
||||
assert_eq!(find(b"a]b", b"a}b"), None);
|
||||
assert_eq!(find(b"A{", b"a{"), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_reference_on_random_inputs() {
|
||||
// Deterministic xorshift PRNG — no external deps.
|
||||
let mut state = 0x9E3779B97F4A7C15u64;
|
||||
let mut next = move || {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state
|
||||
};
|
||||
|
||||
// Alphabet with letters, both-case pairs, and 0x20-differing symbols.
|
||||
let alphabet = b"aAbBzZ [{@`]}^~_0.\n";
|
||||
for _ in 0..2000 {
|
||||
let hlen = (next() % 200) as usize;
|
||||
let nlen = (next() % 8) as usize;
|
||||
let haystack: Vec<u8> = (0..hlen)
|
||||
.map(|_| alphabet[(next() % alphabet.len() as u64) as usize])
|
||||
.collect();
|
||||
let needle: Vec<u8> = (0..nlen)
|
||||
.map(|_| alphabet[(next() % alphabet.len() as u64) as usize].to_ascii_lowercase())
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
find(&haystack, &needle),
|
||||
reference_find(&haystack, &needle),
|
||||
"mismatch for haystack={:?} needle={:?}",
|
||||
haystack,
|
||||
needle,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_haystack_simd_paths() {
|
||||
let haystack =
|
||||
b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaTHIS_IS_A_LONG_NEEDLE_TESTbbbbbbbbbbbbbbbbbb";
|
||||
assert_eq!(find(haystack, b"this_is_a_long_needle_test"), Some(32));
|
||||
assert_eq!(find(haystack, b"this_is_a_long_needle_testz"), None);
|
||||
|
||||
// Needle >= 16 bytes exercises SIMD verify.
|
||||
let haystack2 = b"int STRUCT MUTEX *LOCK(struct mutex *lock) { return 0; }";
|
||||
assert_eq!(find(haystack2, b"struct mutex *lock"), Some(4));
|
||||
|
||||
let upper_hay = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
assert_eq!(find(upper_hay, b"qrstuvwxyz0123456789a"), Some(16));
|
||||
assert_eq!(find(upper_hay, b"qrstuvwxyz01234567899"), None);
|
||||
|
||||
// Needle at very end / very start.
|
||||
let end_hay = b"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxfind_me";
|
||||
assert_eq!(find(end_hay, b"find_me"), Some(end_hay.len() - 7));
|
||||
assert_eq!(find(end_hay, b"xx"), Some(0));
|
||||
|
||||
// 1KB haystack with needle near the end.
|
||||
let mut big = vec![b'z'; 1024];
|
||||
big[1000..1010].copy_from_slice(b"hElLo_WoRl");
|
||||
assert_eq!(find(&big, b"hello_wo"), Some(1000));
|
||||
assert_eq!(find(&big, b"hello_world"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rare_pair_selection() {
|
||||
let (i1, i2) = select_rare_pair(b"nomore");
|
||||
let ranks: Vec<u8> = b"nomore".iter().map(|&b| rank(b)).collect();
|
||||
let (r1, r2) = (ranks[i1], ranks[i2]);
|
||||
for (i, &r) in ranks.iter().enumerate() {
|
||||
if i != i1 && i != i2 {
|
||||
assert!(r1 <= r || r2 <= r, "pair ({i1},{i2}) not optimal");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! SIMD-accelerated string utilities: case flipping/folding and
|
||||
//! case-insensitive substring search.
|
||||
|
||||
pub mod case;
|
||||
pub mod memmem;
|
||||
@@ -7,7 +7,7 @@ use std::sync::atomic::{AtomicI32, AtomicU8, AtomicU64, AtomicUsize, Ordering};
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
use crate::constants::{FRESH_MMAP_THRESHOLD, MMAP_THRESHOLD};
|
||||
use crate::constants::{MAX_CACHED_CONTENT_BYTES, MAX_FFFILE_SIZE, PATH_BUF_SIZE};
|
||||
use crate::constraints::Constrainable;
|
||||
use crate::index::constraints::Constrainable;
|
||||
use crate::query_tracker::QueryMatchEntry;
|
||||
use crate::simd_path::ArenaPtr;
|
||||
use fff_query_parser::{FFFQuery, FuzzyQuery, Location};
|
||||
@@ -69,6 +69,7 @@ pub struct DirFlags;
|
||||
|
||||
impl DirFlags {
|
||||
pub const OVERFLOW: u8 = 1 << 0;
|
||||
pub const DELETED: u8 = 1 << 1;
|
||||
}
|
||||
|
||||
/// A directory in the file index. Shares chunk arena with file paths.
|
||||
@@ -101,6 +102,24 @@ impl DirItem {
|
||||
self.flags & DirFlags::OVERFLOW != 0
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn is_deleted(&self) -> bool {
|
||||
self.flags & DirFlags::DELETED != 0
|
||||
}
|
||||
|
||||
/// Marks the dir deleted/restored. Returns `true` when the state changed.
|
||||
pub(crate) fn set_deleted(&mut self, deleted: bool) -> bool {
|
||||
if self.is_deleted() == deleted {
|
||||
return false;
|
||||
}
|
||||
if deleted {
|
||||
self.flags |= DirFlags::DELETED;
|
||||
} else {
|
||||
self.flags &= !DirFlags::DELETED;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn new(path: crate::simd_path::ChunkedString, last_segment_offset: u16) -> Self {
|
||||
Self {
|
||||
path,
|
||||
@@ -110,6 +129,19 @@ impl DirItem {
|
||||
}
|
||||
}
|
||||
|
||||
/// A dir appended after the initial scan; its path lives in the overflow arena.
|
||||
pub(crate) fn new_overflow(
|
||||
path: crate::simd_path::ChunkedString,
|
||||
last_segment_offset: u16,
|
||||
) -> Self {
|
||||
Self {
|
||||
path,
|
||||
flags: DirFlags::OVERFLOW,
|
||||
last_segment_offset,
|
||||
max_access_frecency: AtomicI32::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte offset of the last path segment within the directory path.
|
||||
#[inline]
|
||||
pub fn last_segment_offset(&self) -> u16 {
|
||||
@@ -372,7 +404,12 @@ impl FileItem {
|
||||
|
||||
let base_end_idx = base_len + sep_len;
|
||||
let relative_portion_str = self.path.read_to_buf(arena, &mut buf[base_end_idx..]);
|
||||
let total = base_end_idx + relative_portion_str.len();
|
||||
let rel_len = relative_portion_str.len();
|
||||
let total = base_end_idx + rel_len;
|
||||
// Stored relative paths are '/'-canonical; rewrite to the OS-native
|
||||
// separator so the result matches git-cache keys, the frecency DB, and
|
||||
// Win32 file APIs. No-op off Windows.
|
||||
crate::path_utils::nativize_slashes_in_place(&mut buf[base_end_idx..total]);
|
||||
Path::new(unsafe { std::str::from_utf8_unchecked(&buf[..total]) })
|
||||
}
|
||||
|
||||
@@ -826,6 +863,7 @@ impl ScoringContext<'_> {
|
||||
pub struct SearchResult<'a> {
|
||||
pub items: Vec<&'a FileItem>,
|
||||
pub scores: Vec<Score>,
|
||||
pub match_byte_offsets: Vec<smallvec::SmallVec<[(u32, u32); 4]>>,
|
||||
pub total_matched: usize,
|
||||
pub total_files: usize,
|
||||
pub location: Option<Location>,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Filesystem traversal backend. Selects one implementation at compile time:
|
||||
//! - `zlob`: zlob's native parallel walker (requires the Zig toolchain).
|
||||
//! - `ripgrep`: the `ignore` crate (ripgrep's walker), used by default.
|
||||
//!
|
||||
//! Both expose [`walk_collect_files`] with identical semantics so the rest of
|
||||
//! the crate stays backend-agnostic.
|
||||
|
||||
use crate::types::FileItem;
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(feature = "zlob")]
|
||||
mod zlob;
|
||||
#[cfg(feature = "zlob")]
|
||||
pub(crate) use zlob::walk_collect_files;
|
||||
|
||||
#[cfg(all(not(feature = "zlob"), feature = "ripgrep"))]
|
||||
mod ripgrep;
|
||||
#[cfg(all(not(feature = "zlob"), feature = "ripgrep"))]
|
||||
pub(crate) use ripgrep::walk_collect_files;
|
||||
|
||||
pub(crate) struct WalkOutput {
|
||||
pub(crate) pairs: Vec<(FileItem, String)>,
|
||||
/// Every non-ignored directory the walk visited, relative, ending with /
|
||||
pub(crate) dirs: Vec<String>,
|
||||
pub(crate) ignore_rules: Option<WalkIgnoreRules>,
|
||||
}
|
||||
|
||||
pub(crate) struct WalkIgnoreRules {
|
||||
#[cfg(feature = "zlob")]
|
||||
inner: ::zlob::walk::WalkerOutcomeRules,
|
||||
#[cfg(not(feature = "zlob"))]
|
||||
_never: std::convert::Infallible,
|
||||
}
|
||||
|
||||
// SAFETY: the underlying storage is immutable, heap-owned, and thread-safe to
|
||||
// read from concurrently (mirrors zlob's `IgnoreRules: Send + Sync`).
|
||||
unsafe impl Send for WalkIgnoreRules {}
|
||||
unsafe impl Sync for WalkIgnoreRules {}
|
||||
|
||||
impl std::fmt::Debug for WalkIgnoreRules {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("WalkIgnoreRules")
|
||||
}
|
||||
}
|
||||
|
||||
// In ripgrep builds `WalkIgnoreRules` is never constructed (the `_never`
|
||||
// field is uninhabited), so its methods are legitimately dead there.
|
||||
#[cfg_attr(not(feature = "zlob"), allow(dead_code))]
|
||||
impl WalkIgnoreRules {
|
||||
/// Returns `true` if the provided path is ignored by the collected rule set
|
||||
///
|
||||
/// `relative_path` has to be relative to the walker's provided base path
|
||||
pub(crate) fn is_ignored(&self, relative_path: &Path) -> bool {
|
||||
#[cfg(feature = "zlob")]
|
||||
{
|
||||
self.inner
|
||||
.rules()
|
||||
.is_some_and(|rules| rules.is_ignored(relative_path))
|
||||
}
|
||||
#[cfg(not(feature = "zlob"))]
|
||||
{
|
||||
let _ = relative_path;
|
||||
match self._never {}
|
||||
}
|
||||
}
|
||||
|
||||
// The old `is_ignored_untrusted` variant was folded away when zlob's
|
||||
// ignore matcher moved to full ancestor enumeration — trailing-slash
|
||||
// sniffing on the input is now sufficient for external queries.
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::walk_collect_files;
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
// Backend-agnostic parity check: both the zlob and ripgrep walkers must
|
||||
// respect .gitignore, skip hidden files in a git repo, and surface the
|
||||
// expected file set with a correct synced count.
|
||||
#[test]
|
||||
fn collects_files_respecting_gitignore() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
fs::create_dir(root.join(".git")).unwrap();
|
||||
fs::create_dir(root.join("src")).unwrap();
|
||||
fs::create_dir(root.join("target")).unwrap();
|
||||
fs::write(root.join(".gitignore"), "target/\n*.log\n").unwrap();
|
||||
fs::write(root.join("Cargo.toml"), "x").unwrap();
|
||||
fs::write(root.join("debug.log"), "").unwrap();
|
||||
fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
|
||||
fs::write(root.join("target/out.bin"), "bin").unwrap();
|
||||
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let out = walk_collect_files(root, true, false, 1, &counter).unwrap();
|
||||
|
||||
let mut names: Vec<String> = out.pairs.into_iter().map(|(_, rel)| rel).collect();
|
||||
names.sort();
|
||||
|
||||
assert!(names.contains(&"Cargo.toml".to_string()));
|
||||
assert!(names.iter().any(|n| n.ends_with("main.rs")));
|
||||
// target/ and *.log are gitignored; .git/ is skipped.
|
||||
assert!(!names.iter().any(|n| n.contains("target")));
|
||||
assert!(!names.iter().any(|n| n.ends_with(".log")));
|
||||
assert!(!names.iter().any(|n| n.contains(".git/")));
|
||||
assert_eq!(counter.load(Ordering::Relaxed), names.len());
|
||||
}
|
||||
|
||||
// Non-git roots prune known non-code directories (node_modules).
|
||||
#[test]
|
||||
fn prunes_non_code_dirs_for_non_git_root() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
fs::create_dir(root.join("node_modules")).unwrap();
|
||||
fs::write(root.join("node_modules/lib.js"), "x").unwrap();
|
||||
fs::write(root.join("index.js"), "x").unwrap();
|
||||
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let out = walk_collect_files(root, false, false, 1, &counter).unwrap();
|
||||
let names: Vec<String> = out.pairs.into_iter().map(|(_, rel)| rel).collect();
|
||||
|
||||
assert!(names.iter().any(|n| n.ends_with("index.js")));
|
||||
assert!(!names.iter().any(|n| n.contains("node_modules")));
|
||||
}
|
||||
|
||||
// Only the zlob backend surfaces reusable ignore rules; they must match
|
||||
// the same tree the walk respected.
|
||||
#[cfg(feature = "zlob")]
|
||||
#[test]
|
||||
fn surfaces_reusable_ignore_rules() {
|
||||
use std::path::Path;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
fs::create_dir(root.join(".git")).unwrap();
|
||||
fs::write(root.join(".gitignore"), "target/\n*.log\n").unwrap();
|
||||
fs::write(root.join("Cargo.toml"), "x").unwrap();
|
||||
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let out = walk_collect_files(root, true, false, 1, &counter).unwrap();
|
||||
|
||||
let rules = out.ignore_rules.expect("zlob surfaces ignore rules");
|
||||
assert!(rules.is_ignored(Path::new("target/")));
|
||||
assert!(rules.is_ignored(Path::new("debug.log")));
|
||||
assert!(!rules.is_ignored(Path::new("Cargo.toml")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
use crate::ignore::non_git_repo_overrides;
|
||||
use crate::types::FileItem;
|
||||
use crate::walk::WalkOutput;
|
||||
use crate::watch::is_git_file;
|
||||
use ignore::WalkBuilder;
|
||||
use std::path::Path;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
#[tracing::instrument(skip_all, name = "ripgrep walker", level = "info")]
|
||||
pub(crate) fn walk_collect_files(
|
||||
base_path: &Path,
|
||||
is_git_repo: bool,
|
||||
follow_symlinks: bool,
|
||||
threads: usize,
|
||||
synced_files_count: &Arc<AtomicUsize>,
|
||||
) -> crate::Result<WalkOutput> {
|
||||
let mut walk_builder = WalkBuilder::new(base_path);
|
||||
walk_builder
|
||||
// this is a very important guard for the user opening ~/ or other root non-git dir
|
||||
.hidden(!is_git_repo)
|
||||
.git_ignore(true)
|
||||
.git_exclude(true)
|
||||
.git_global(true)
|
||||
.ignore(true)
|
||||
.follow_links(follow_symlinks)
|
||||
.threads(threads);
|
||||
|
||||
if !is_git_repo && let Some(overrides) = non_git_repo_overrides(base_path) {
|
||||
walk_builder.overrides(overrides);
|
||||
}
|
||||
|
||||
let walker = walk_builder.build_parallel();
|
||||
|
||||
// Single lock for both collections: every entry is either a file or a
|
||||
// dir, so this keeps one mutex acquisition per entry.
|
||||
let collected =
|
||||
parking_lot::Mutex::new((Vec::<(FileItem, String)>::new(), Vec::<String>::new()));
|
||||
walker.run(|| {
|
||||
let collected = &collected;
|
||||
let counter = Arc::clone(synced_files_count);
|
||||
let base_path = base_path.to_path_buf();
|
||||
|
||||
Box::new(move |result| {
|
||||
let Ok(entry) = result else {
|
||||
return ignore::WalkState::Continue;
|
||||
};
|
||||
|
||||
if entry.file_type().is_some_and(|ft| ft.is_file()) {
|
||||
let path = entry.path();
|
||||
|
||||
// Ignore walkers sometimes surface files inside `.git/`
|
||||
// when the base is itself a git repo — skip them.
|
||||
if is_git_file(path) {
|
||||
return ignore::WalkState::Continue;
|
||||
}
|
||||
|
||||
let metadata = entry.metadata().ok();
|
||||
let (file_item, rel_path) =
|
||||
FileItem::new_from_walk(path, &base_path, None, metadata.as_ref());
|
||||
|
||||
collected.lock().0.push((file_item, rel_path));
|
||||
counter.fetch_add(1, Ordering::Relaxed);
|
||||
} else if entry.depth() > 0 && entry.file_type().is_some_and(|ft| ft.is_dir()) {
|
||||
let path = entry.path();
|
||||
if !is_git_file(path)
|
||||
&& let Ok(rel) = path.strip_prefix(&base_path)
|
||||
{
|
||||
let mut rel = crate::path_utils::to_canonical_slashes(&rel.to_string_lossy())
|
||||
.into_owned();
|
||||
rel.push('/');
|
||||
collected.lock().1.push(rel);
|
||||
}
|
||||
}
|
||||
ignore::WalkState::Continue
|
||||
})
|
||||
});
|
||||
|
||||
let (pairs, dirs) = collected.into_inner();
|
||||
Ok(WalkOutput {
|
||||
pairs,
|
||||
dirs,
|
||||
ignore_rules: None,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
use crate::file_picker::is_known_binary_extension_basename;
|
||||
use crate::ignore::IGNORED_DIRS;
|
||||
use crate::types::FileItem;
|
||||
use crate::walk::{WalkIgnoreRules, WalkOutput};
|
||||
use parking_lot::Mutex;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use zlob::walk::{WalkBuilder, WalkFlags, WalkMetadata, WalkState};
|
||||
|
||||
const PROGRESS_STEP: usize = 13;
|
||||
|
||||
#[tracing::instrument(skip_all, name = "zlob walker", level = "info")]
|
||||
pub(crate) fn walk_collect_files(
|
||||
base_path: &Path,
|
||||
is_git_repo: bool,
|
||||
follow_symlinks: bool,
|
||||
threads: usize,
|
||||
synced_files_count: &Arc<AtomicUsize>,
|
||||
) -> crate::Result<WalkOutput> {
|
||||
// gitignore on; skip hidden on non-git roots (so `~/` doesn't recurse into
|
||||
// ~/.cache, ~/.config, etc.); optionally follow symlinks.
|
||||
let mut flags = WalkFlags::GITIGNORE;
|
||||
if !is_git_repo {
|
||||
flags |= WalkFlags::SKIP_HIDDEN;
|
||||
}
|
||||
if follow_symlinks {
|
||||
flags |= WalkFlags::FOLLOW_SYMLINKS;
|
||||
}
|
||||
|
||||
let mut builder = WalkBuilder::new(base_path)
|
||||
.map_err(|e| crate::Error::WalkFailed(format!("WalkBuilder::new: {e:?}")))?;
|
||||
builder
|
||||
.options(flags)
|
||||
.threads(threads)
|
||||
// Bulk-fetch the only metadata FileItem needs; zlob never stats more.
|
||||
.metadata(WalkMetadata::SIZE | WalkMetadata::MTIME);
|
||||
|
||||
if !is_git_repo
|
||||
&& !IGNORED_DIRS.is_empty()
|
||||
&& let Err(e) = builder.extra_ignore(IGNORED_DIRS)
|
||||
{
|
||||
// Interior NUL in one of the extra_ignore patterns would fail
|
||||
// here — treat as if no extras were supplied rather than
|
||||
// aborting the whole walk.
|
||||
tracing::warn!(?e, "zlob extra_ignore rejected; walking without it");
|
||||
}
|
||||
|
||||
// Single lock for both collections: every entry is either a file or a
|
||||
// dir, so this keeps one mutex acquisition per entry.
|
||||
let collected = Mutex::new((Vec::new(), Vec::new()));
|
||||
|
||||
let outcome = match builder.run(|entry| {
|
||||
if !entry.is_file() {
|
||||
// unlike ripgrep walker zlob doesnt show .git files
|
||||
if entry.is_dir() {
|
||||
let rel_bytes = entry.relative_path_bytes();
|
||||
if !rel_bytes.is_empty() {
|
||||
let mut rel = String::from_utf8_lossy(rel_bytes).into_owned();
|
||||
rel.push('/');
|
||||
collected.lock().1.push(rel);
|
||||
}
|
||||
}
|
||||
|
||||
return WalkState::Continue;
|
||||
}
|
||||
|
||||
let rel_bytes = entry.relative_path_bytes();
|
||||
|
||||
// `basename()` returns `&str` for files only.
|
||||
let basename = entry.basename().unwrap_or("");
|
||||
let is_binary = is_known_binary_extension_basename(basename);
|
||||
|
||||
let size = entry.size().unwrap_or(0);
|
||||
// zlob reports mtime in ns since the Unix epoch; FileItem wants secs.
|
||||
let modified = entry
|
||||
.modified_ns()
|
||||
.map(|ns| (ns / 1_000_000_000).max(0) as u64)
|
||||
.unwrap_or(0);
|
||||
|
||||
let basename_offset = entry.basename_offset_in_relative();
|
||||
// zlob emits '/'-separated relative paths, which is fff's canonical
|
||||
// internal form on every platform — store them verbatim.
|
||||
let rel_str = String::from_utf8_lossy(rel_bytes).into_owned();
|
||||
let item = FileItem::new_raw(basename_offset, size, modified, None, is_binary);
|
||||
|
||||
let mut guard = collected.lock();
|
||||
guard.0.push((item, rel_str));
|
||||
let n = guard.0.len();
|
||||
drop(guard);
|
||||
|
||||
if n % PROGRESS_STEP == 0 {
|
||||
synced_files_count.store(n, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
WalkState::Continue
|
||||
}) {
|
||||
Ok(outcome) => outcome,
|
||||
Err(e) => {
|
||||
// Preserve whatever we collected before the failure so the caller
|
||||
// can still surface a partial index instead of nothing.
|
||||
tracing::error!(?e, "zlob walk failed");
|
||||
return Err(crate::Error::WalkFailed(format!("{e:?}")));
|
||||
}
|
||||
};
|
||||
|
||||
let (pairs, dirs) = collected.into_inner();
|
||||
// Always report the exact final total regardless of the last step.
|
||||
synced_files_count.store(pairs.len(), Ordering::Relaxed);
|
||||
|
||||
// Retain the ignore rules only when the walk actually gathered some
|
||||
// (git roots with .gitignore/.ignore). Otherwise callers fall back.
|
||||
let ignore_rules = outcome
|
||||
.rules()
|
||||
.is_some()
|
||||
.then(|| WalkIgnoreRules { inner: outcome });
|
||||
|
||||
Ok(WalkOutput {
|
||||
pairs,
|
||||
dirs,
|
||||
ignore_rules,
|
||||
})
|
||||
}
|
||||
+511
-291
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
mod background_watcher;
|
||||
pub use background_watcher::*;
|
||||
|
||||
mod watch;
|
||||
pub use watch::*;
|
||||
|
||||
// The harness reads rescan counters, which release builds compile out.
|
||||
#[cfg(all(test, rescan_stats))]
|
||||
mod rescan_tests;
|
||||
@@ -0,0 +1,621 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use notify::Event;
|
||||
use notify::EventKind;
|
||||
use notify::event::{
|
||||
AccessKind, AccessMode, CreateKind, DataChange, Flag, ModifyKind, RemoveKind, RenameMode,
|
||||
};
|
||||
use notify_debouncer_full::DebouncedEvent;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::handle_debounced_events;
|
||||
use crate::constants::MAX_OVERFLOW_FILES;
|
||||
use crate::file_picker::{FFFMode, FilePicker, FilePickerOptions};
|
||||
use crate::git_status_worker::GitStatusWorker;
|
||||
use crate::rescan_stats::{RescanReason, RescanStats};
|
||||
use crate::shared::{SharedFilePicker, SharedFrecency};
|
||||
|
||||
#[test]
|
||||
fn saving_an_indexed_file_stays_incremental() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
f.write("src/main.rs", "fn main() { println!(); }");
|
||||
let delta = f.feed([modify(f.path("src/main.rs"))]);
|
||||
|
||||
f.assert_no_rescan(&delta, "saving a tracked file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_atomic_save_stays_incremental() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
// write-to-temp + rename-over-target, the way vim/VSCode/IntelliJ save.
|
||||
f.write("src/main.rs", "fn main() { println!(); }");
|
||||
let target = f.path("src/main.rs");
|
||||
let temp = f.path("src/.main.rs.swp");
|
||||
let delta = f.feed([
|
||||
DebouncedEvent::new(
|
||||
Event::new(EventKind::Create(CreateKind::File)).add_path(temp.clone()),
|
||||
Instant::now(),
|
||||
),
|
||||
DebouncedEvent::new(
|
||||
Event::new(EventKind::Modify(ModifyKind::Name(RenameMode::From)))
|
||||
.add_path(temp.clone()),
|
||||
Instant::now(),
|
||||
),
|
||||
DebouncedEvent::new(
|
||||
Event::new(EventKind::Modify(ModifyKind::Name(RenameMode::To)))
|
||||
.add_path(target.clone()),
|
||||
Instant::now(),
|
||||
),
|
||||
DebouncedEvent::new(
|
||||
Event::new(EventKind::Remove(RemoveKind::File)).add_path(temp),
|
||||
Instant::now(),
|
||||
),
|
||||
]);
|
||||
|
||||
f.assert_no_rescan(&delta, "an atomic editor save");
|
||||
assert!(f.is_indexed("src/main.rs"), "target must stay indexed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creating_and_deleting_files_stays_incremental() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
f.write("src/added.rs", "pub fn added() {}");
|
||||
let created = f.feed([create(f.path("src/added.rs"))]);
|
||||
f.assert_no_rescan(&created, "creating a file");
|
||||
assert!(f.is_indexed("src/added.rs"));
|
||||
|
||||
f.remove("src/added.rs");
|
||||
let removed = f.feed([remove_file(f.path("src/added.rs"))]);
|
||||
f.assert_no_rescan(&removed, "deleting a file");
|
||||
assert!(!f.is_indexed("src/added.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_directory_stays_incremental() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.write("src/nested/a.rs", "");
|
||||
f.write("src/nested/b.rs", "");
|
||||
f.index();
|
||||
|
||||
std::fs::remove_dir_all(f.path("src/nested")).unwrap();
|
||||
let delta = f.feed([DebouncedEvent::new(
|
||||
Event::new(EventKind::Remove(RemoveKind::Folder)).add_path(f.path("src/nested")),
|
||||
Instant::now(),
|
||||
)]);
|
||||
|
||||
f.assert_no_rescan(&delta, "deleting a directory");
|
||||
assert!(!f.is_indexed("src/nested/a.rs"));
|
||||
assert!(f.is_indexed("src/main.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_access_events_are_ignored() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
// fff's own preview + grep reads generate these; reacting to them would
|
||||
// make the picker rescan whenever the user scrolls the result list.
|
||||
let path = f.path("src/main.rs");
|
||||
let delta = f.feed([
|
||||
DebouncedEvent::new(
|
||||
Event::new(EventKind::Access(AccessKind::Read)).add_path(path.clone()),
|
||||
Instant::now(),
|
||||
),
|
||||
DebouncedEvent::new(
|
||||
Event::new(EventKind::Access(AccessKind::Open(AccessMode::Read)))
|
||||
.add_path(path.clone()),
|
||||
Instant::now(),
|
||||
),
|
||||
DebouncedEvent::new(
|
||||
Event::new(EventKind::Access(AccessKind::Close(AccessMode::Read))).add_path(path),
|
||||
Instant::now(),
|
||||
),
|
||||
]);
|
||||
|
||||
f.assert_no_rescan(&delta, "read-only access events");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recreating_the_same_paths_does_not_consume_overflow_capacity() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
// Recreated paths must reuse their overflow slots.
|
||||
for _ in 0..8 {
|
||||
for i in 0..200 {
|
||||
let rel = format!("gen/out{i}.rs");
|
||||
f.write(&rel, "generated");
|
||||
f.feed([create(f.path(&rel))]);
|
||||
}
|
||||
for i in 0..200 {
|
||||
let rel = format!("gen/out{i}.rs");
|
||||
f.remove(&rel);
|
||||
f.feed([remove_file(f.path(&rel))]);
|
||||
}
|
||||
}
|
||||
|
||||
let delta = f.all_rescans();
|
||||
f.assert_no_rescan(&delta, "1600 create/delete cycles over 200 stable paths");
|
||||
assert!(
|
||||
f.overflow_len() <= 200,
|
||||
"each path must claim one overflow slot at most, got {}",
|
||||
f.overflow_len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_inside_a_gitignored_directory_stay_incremental() {
|
||||
let f = Fixture::with_git();
|
||||
f.write(".gitignore", "target/\nnode_modules/\n");
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
let mut events = Vec::new();
|
||||
for i in 0..64 {
|
||||
let rel = format!("target/debug/artifact{i}.o");
|
||||
f.write(&rel, "binary");
|
||||
events.push(create(f.path(&rel)));
|
||||
}
|
||||
|
||||
let delta = f.feed(events);
|
||||
f.assert_no_rescan(&delta, "build output written into an ignored directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignored_event_batch_above_index_capacity_stays_incremental() {
|
||||
let f = Fixture::with_git();
|
||||
f.write(".gitignore", "node_modules/\n");
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
let events = (0..MAX_OVERFLOW_FILES + 1)
|
||||
.map(|i| {
|
||||
let rel = format!("node_modules/pkg/file{i}.js");
|
||||
f.write(&rel, "");
|
||||
create(f.path(&rel))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let delta = f.feed(events);
|
||||
f.assert_no_rescan(&delta, "ignored events above the index capacity");
|
||||
assert_eq!(f.overflow_len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_edits_above_index_capacity_stay_incremental() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
let path = f.path("src/main.rs");
|
||||
let events = (0..MAX_OVERFLOW_FILES + 1)
|
||||
.map(|_| modify(path.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let delta = f.feed(events);
|
||||
f.assert_no_rescan(&delta, "repeated edits above the index capacity");
|
||||
assert_eq!(f.overflow_len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_file_inside_an_ignored_directory_stays_incremental() {
|
||||
let f = Fixture::with_git();
|
||||
f.write(".gitignore", "node_modules/\n");
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
let ignore_files =
|
||||
["left-pad", "lodash", "typescript"].map(|pkg| format!("node_modules/{pkg}/.gitignore"));
|
||||
|
||||
for rel in &ignore_files {
|
||||
f.write(rel, "dist\n");
|
||||
}
|
||||
let delta = f.feed(ignore_files.iter().map(|rel| create(f.path(rel))));
|
||||
f.assert_no_rescan(&delta, "creating ignored .gitignore files");
|
||||
|
||||
for rel in &ignore_files {
|
||||
f.write(rel, "build\n");
|
||||
}
|
||||
let delta = f.feed(ignore_files.iter().map(|rel| modify(f.path(rel))));
|
||||
f.assert_no_rescan(&delta, "modifying ignored .gitignore files");
|
||||
|
||||
for rel in &ignore_files {
|
||||
f.remove(rel);
|
||||
}
|
||||
let delta = f.feed(ignore_files.iter().map(|rel| remove_file(f.path(rel))));
|
||||
f.assert_no_rescan(&delta, "removing ignored .gitignore files");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_file_inside_an_indexed_directory_triggers_a_rescan() {
|
||||
let f = Fixture::with_git();
|
||||
f.write("src/.gitignore", ".gitignore\ngenerated/\n");
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
f.write("src/.gitignore", ".gitignore\ngenerated/\nbuild/\n");
|
||||
let delta = f.feed([modify(f.path("src/.gitignore"))]);
|
||||
|
||||
assert_eq!(delta.count(RescanReason::IgnoreFileChanged), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_internal_churn_stays_incremental() {
|
||||
let f = Fixture::with_git();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
let git_dir = f.path(".git");
|
||||
let delta = f.feed([
|
||||
create(git_dir.join("index.lock")),
|
||||
modify(git_dir.join("index")),
|
||||
remove_file(git_dir.join("index.lock")),
|
||||
modify(git_dir.join("HEAD")),
|
||||
modify(git_dir.join("logs/HEAD")),
|
||||
modify(git_dir.join("COMMIT_EDITMSG")),
|
||||
modify(git_dir.join("refs/heads/main")),
|
||||
]);
|
||||
|
||||
f.assert_no_rescan(&delta, "git writing its own metadata");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_the_root_ignore_file_triggers_a_rescan() {
|
||||
let f = Fixture::with_git();
|
||||
f.write(".gitignore", "target/\n");
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
f.write(".gitignore", "target/\nsrc/\n");
|
||||
let delta = f.feed([modify(f.path(".gitignore"))]);
|
||||
|
||||
assert_eq!(
|
||||
delta.count(RescanReason::IgnoreFileChanged),
|
||||
1,
|
||||
"the indexed set depends on the root ignore rules, got {delta}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kernel_event_loss_on_a_directory_triggers_a_rescan() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
let delta = f.feed([DebouncedEvent::new(
|
||||
Event::new(EventKind::Modify(ModifyKind::Any))
|
||||
.add_path(f.path("src"))
|
||||
.set_flag(Flag::Rescan),
|
||||
Instant::now(),
|
||||
)]);
|
||||
|
||||
assert_eq!(
|
||||
delta.count(RescanReason::KernelEventLoss),
|
||||
1,
|
||||
"a dropped-events flag over a directory means unknown subtree state, got {delta}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_files_above_index_capacity_trigger_a_rescan() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
let events = (0..MAX_OVERFLOW_FILES + 1)
|
||||
.map(|i| {
|
||||
let rel = format!("src/bulk{i}.rs");
|
||||
f.write(&rel, "");
|
||||
create(f.path(&rel))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let delta = f.feed(events);
|
||||
assert_eq!(
|
||||
delta.count(RescanReason::IndexUpdateRejected),
|
||||
1,
|
||||
"new files above the overflow region cannot be applied incrementally, got {delta}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_at_the_overflow_boundary_stays_incremental() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
let events = (0..MAX_OVERFLOW_FILES)
|
||||
.map(|i| {
|
||||
let rel = format!("src/bulk{i}.rs");
|
||||
f.write(&rel, "");
|
||||
create(f.path(&rel))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let delta = f.feed(events);
|
||||
f.assert_no_rescan(&delta, "a batch exactly at the overflow limit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_batch_at_four_times_index_capacity_stays_incremental() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
let path = f.path("src/main.rs");
|
||||
let events = (0..MAX_OVERFLOW_FILES * 4)
|
||||
.map(|_| modify(path.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let delta = f.feed(events);
|
||||
f.assert_no_rescan(&delta, "an event batch exactly at the event limit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_batch_above_four_times_index_capacity_triggers_a_rescan() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
let path = f.path("src/main.rs");
|
||||
let events = (0..MAX_OVERFLOW_FILES * 4 + 1)
|
||||
.map(|_| modify(path.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let delta = f.feed(events);
|
||||
assert_eq!(
|
||||
delta.count(RescanReason::EventBatchOverflow),
|
||||
1,
|
||||
"an event batch above four times the index capacity must rescan, got {delta}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_triggers_inside_the_cooldown_collapse_to_one_rescan() {
|
||||
let f = Fixture::with_git();
|
||||
f.write(".gitignore", "target/\n");
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
// Repeated batches during the cooldown must share one walk.
|
||||
for round in 0..50 {
|
||||
f.write(".gitignore", &format!("target/\n# round {round}\n"));
|
||||
f.feed([modify(f.path(".gitignore"))]);
|
||||
}
|
||||
|
||||
let stats = f.all_rescans();
|
||||
assert_eq!(
|
||||
stats.total, 1,
|
||||
"50 triggers inside the cooldown must collapse to a single walk, got {stats}"
|
||||
);
|
||||
assert_eq!(
|
||||
stats.throttled, 49,
|
||||
"every suppressed request must be accounted for, got {stats}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_request_is_never_throttled() {
|
||||
let f = Fixture::with_git();
|
||||
f.write(".gitignore", "target/\n");
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
// Burn the cooldown with a watcher trigger, then confirm a user-initiated
|
||||
// refresh still goes through.
|
||||
f.write(".gitignore", "target/\nsrc/\n");
|
||||
f.feed([modify(f.path(".gitignore"))]);
|
||||
|
||||
for _ in 0..3 {
|
||||
f.picker.trigger_full_rescan_async(&f.frecency).unwrap();
|
||||
}
|
||||
|
||||
let stats = f.all_rescans();
|
||||
assert_eq!(
|
||||
stats.count(RescanReason::Explicit),
|
||||
3,
|
||||
"explicit refreshes must bypass the throttle, got {stats}"
|
||||
);
|
||||
assert_eq!(stats.count_throttled(RescanReason::Explicit), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_after_a_suppressed_kernel_rescan_are_still_applied() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
f.write("src/added.rs", "pub fn added() {}");
|
||||
let delta = f.feed([
|
||||
DebouncedEvent::new(
|
||||
Event::new(EventKind::Modify(ModifyKind::Data(DataChange::Content)))
|
||||
.add_path(f.path("src/main.rs"))
|
||||
.set_flag(Flag::Rescan),
|
||||
Instant::now(),
|
||||
),
|
||||
create(f.path("src/added.rs")),
|
||||
]);
|
||||
|
||||
f.assert_no_rescan(&delta, "a dropped-events flag over a single tracked file");
|
||||
assert!(
|
||||
f.is_indexed("src/added.rs"),
|
||||
"suppressing the rescan must not drop the rest of the batch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_throttled_ignore_file_event_is_still_applied_incrementally() {
|
||||
let f = Fixture::with_git();
|
||||
f.write(".gitignore", "target/\n");
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
// Burn the cooldown: deleting .gitignore admits a full rescan.
|
||||
f.remove(".gitignore");
|
||||
let delta = f.feed([remove_file(f.path(".gitignore"))]);
|
||||
assert_eq!(delta.count(RescanReason::IgnoreFileChanged), 1);
|
||||
f.picker.wait_for_indexing_complete(Duration::from_secs(10));
|
||||
|
||||
// Recreating it inside the cooldown throttles the rescan, but the file
|
||||
// itself must re-enter the index via the incremental fallback.
|
||||
f.write(".gitignore", "target/\n__ignored_x/\n");
|
||||
let delta = f.feed([create(f.path(".gitignore"))]);
|
||||
assert_eq!(delta.total, 0, "the rescan must be throttled, got {delta}");
|
||||
assert_eq!(delta.count_throttled(RescanReason::IgnoreFileChanged), 1);
|
||||
assert!(
|
||||
f.is_indexed(".gitignore"),
|
||||
"a throttled ignore-file event must still index the file itself"
|
||||
);
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
base: PathBuf,
|
||||
picker: SharedFilePicker,
|
||||
frecency: SharedFrecency,
|
||||
git_workdir: Option<PathBuf>,
|
||||
git_worker: Arc<GitStatusWorker>,
|
||||
// Dropped last so background work started by a triggered rescan still
|
||||
// sees the tree it was asked to walk.
|
||||
_tmp: TempDir,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
Self::build(false)
|
||||
}
|
||||
|
||||
fn with_git() -> Self {
|
||||
Self::build(true)
|
||||
}
|
||||
|
||||
fn build(git: bool) -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = crate::path_utils::canonicalize(tmp.path()).unwrap();
|
||||
let git_workdir = git.then(|| {
|
||||
let status = Command::new("git")
|
||||
.args(["init", "-b", "main"])
|
||||
.current_dir(&base)
|
||||
.output()
|
||||
.expect("git init");
|
||||
assert!(status.status.success(), "git init failed");
|
||||
base.clone()
|
||||
});
|
||||
|
||||
Self {
|
||||
base,
|
||||
picker: SharedFilePicker::default(),
|
||||
frecency: SharedFrecency::noop(),
|
||||
git_workdir,
|
||||
git_worker: GitStatusWorker::new(),
|
||||
_tmp: tmp,
|
||||
}
|
||||
}
|
||||
|
||||
fn index(&self) {
|
||||
let mut picker = FilePicker::new(FilePickerOptions {
|
||||
base_path: self.base.to_string_lossy().into_owned(),
|
||||
watch: false,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
picker.collect_files().unwrap();
|
||||
self.picker.rebase_watches(&self.base);
|
||||
*self.picker.write().unwrap() = Some(picker);
|
||||
}
|
||||
|
||||
fn feed(&self, events: impl IntoIterator<Item = DebouncedEvent>) -> RescanStats {
|
||||
let before = self.picker.rescan_stats();
|
||||
handle_debounced_events(
|
||||
FFFMode::Neovim,
|
||||
events.into_iter().collect(),
|
||||
&self.base,
|
||||
&self.git_workdir,
|
||||
&self.picker,
|
||||
&self.frecency,
|
||||
&self.git_worker,
|
||||
);
|
||||
|
||||
self.picker.rescan_stats().since(&before)
|
||||
}
|
||||
|
||||
fn assert_no_rescan(&self, delta: &RescanStats, what: &str) {
|
||||
assert_eq!(delta.total, 0, "{what} must not trigger a rescan: {delta}");
|
||||
}
|
||||
|
||||
fn path(&self, rel: &str) -> PathBuf {
|
||||
self.base.join(rel)
|
||||
}
|
||||
|
||||
fn write(&self, rel: &str, contents: &str) {
|
||||
let path = self.path(rel);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, contents).unwrap();
|
||||
}
|
||||
|
||||
fn remove(&self, rel: &str) {
|
||||
std::fs::remove_file(self.path(rel)).unwrap();
|
||||
}
|
||||
|
||||
fn is_indexed(&self, rel: &str) -> bool {
|
||||
let guard = self.picker.read().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.and_then(|p| p.get_file_by_path(self.path(rel)))
|
||||
.is_some_and(|file| !file.is_deleted())
|
||||
}
|
||||
|
||||
fn all_rescans(&self) -> RescanStats {
|
||||
self.picker.rescan_stats()
|
||||
}
|
||||
|
||||
fn overflow_len(&self) -> usize {
|
||||
let guard = self.picker.read().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.map(|p| p.get_overflow_files().len())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
// A test that intentionally triggers a rescan leaves a walk running on
|
||||
// the background pool; let it finish before the tree disappears.
|
||||
self.picker
|
||||
.wait_for_indexing_complete(Duration::from_secs(10));
|
||||
}
|
||||
}
|
||||
|
||||
fn event(kind: EventKind, path: PathBuf) -> DebouncedEvent {
|
||||
DebouncedEvent::new(Event::new(kind).add_path(path), Instant::now())
|
||||
}
|
||||
|
||||
fn create(path: PathBuf) -> DebouncedEvent {
|
||||
event(EventKind::Create(CreateKind::File), path)
|
||||
}
|
||||
|
||||
fn modify(path: PathBuf) -> DebouncedEvent {
|
||||
event(
|
||||
EventKind::Modify(ModifyKind::Data(DataChange::Content)),
|
||||
path,
|
||||
)
|
||||
}
|
||||
|
||||
fn remove_file(path: PathBuf) -> DebouncedEvent {
|
||||
event(EventKind::Remove(RemoveKind::File), path)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,301 @@
|
||||
use fff_search::file_picker::{FFFMode, FilePicker};
|
||||
use fff_search::{
|
||||
DirSearchConfig, FilePickerOptions, FuzzySearchOptions, PaginationArgs, QueryParser,
|
||||
SharedFilePicker, SharedFrecency,
|
||||
};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_watched_picker(base: &Path) -> (SharedFilePicker, SharedFrecency) {
|
||||
let shared_picker = SharedFilePicker::default();
|
||||
let shared_frecency = SharedFrecency::noop();
|
||||
|
||||
FilePicker::new_with_shared_state(
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: base.to_string_lossy().into_owned(),
|
||||
enable_mmap_cache: false,
|
||||
enable_content_indexing: false,
|
||||
mode: FFFMode::Neovim,
|
||||
watch: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("FilePicker::new_with_shared_state");
|
||||
|
||||
assert!(
|
||||
shared_picker.wait_for_scan(Duration::from_secs(30)),
|
||||
"initial scan did not complete"
|
||||
);
|
||||
assert!(
|
||||
shared_picker.wait_for_watcher(Duration::from_secs(30)),
|
||||
"watcher did not install"
|
||||
);
|
||||
// macOS FSEvents streams need a beat before they deliver reliably
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
|
||||
(shared_picker, shared_frecency)
|
||||
}
|
||||
|
||||
fn search_dirs(picker: &SharedFilePicker, query: &str) -> Vec<String> {
|
||||
let guard = picker.read().expect("picker read lock");
|
||||
let p = guard.as_ref().expect("picker initialized");
|
||||
let parser = QueryParser::new(DirSearchConfig);
|
||||
let parsed = parser.parse(query);
|
||||
let results = p.fuzzy_search_directories(
|
||||
&parsed,
|
||||
FuzzySearchOptions {
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
results.items.iter().map(|d| d.relative_path(p)).collect()
|
||||
}
|
||||
|
||||
fn wait_until<F: Fn() -> bool>(cond: F, timeout: Duration) -> bool {
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
if cond() {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
cond()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_directory_disappears_from_dir_search() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
|
||||
fs::create_dir_all(base.join("doomed/nested")).unwrap();
|
||||
fs::write(base.join("doomed/a.rs"), "x").unwrap();
|
||||
fs::write(base.join("doomed/nested/b.rs"), "x").unwrap();
|
||||
fs::write(base.join("keep.rs"), "x").unwrap();
|
||||
|
||||
let (picker, _frecency) = make_watched_picker(&base);
|
||||
assert!(
|
||||
search_dirs(&picker, "doomed")
|
||||
.iter()
|
||||
.any(|d| d.starts_with("doomed")),
|
||||
"sanity: dir indexed after scan"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(base.join("doomed")).unwrap();
|
||||
|
||||
assert!(
|
||||
wait_until(
|
||||
|| !search_dirs(&picker, "doomed")
|
||||
.iter()
|
||||
.any(|d| d.starts_with("doomed")),
|
||||
Duration::from_secs(10)
|
||||
),
|
||||
"removed dir must disappear from dir search, got: {:?}",
|
||||
search_dirs(&picker, "doomed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_out_directory_disappears_from_dir_search() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let trash = TempDir::new().unwrap();
|
||||
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
|
||||
fs::create_dir_all(base.join("doomed/nested")).unwrap();
|
||||
fs::write(base.join("doomed/a.rs"), "x").unwrap();
|
||||
fs::write(base.join("doomed/nested/b.rs"), "x").unwrap();
|
||||
fs::write(base.join("keep.rs"), "x").unwrap();
|
||||
|
||||
let (picker, _frecency) = make_watched_picker(&base);
|
||||
assert!(
|
||||
search_dirs(&picker, "doomed")
|
||||
.iter()
|
||||
.any(|d| d.starts_with("doomed")),
|
||||
"sanity: dir indexed after scan"
|
||||
);
|
||||
|
||||
fs::rename(base.join("doomed"), trash.path().join("doomed")).unwrap();
|
||||
|
||||
assert!(
|
||||
wait_until(
|
||||
|| !search_dirs(&picker, "doomed")
|
||||
.iter()
|
||||
.any(|d| d.starts_with("doomed")),
|
||||
Duration::from_secs(10)
|
||||
),
|
||||
"moved-out dir must disappear from dir search, got: {:?}",
|
||||
search_dirs(&picker, "doomed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_in_directory_appears_in_dir_search() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let staging = TempDir::new().unwrap();
|
||||
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
|
||||
fs::write(base.join("keep.rs"), "x").unwrap();
|
||||
|
||||
let incoming = staging.path().join("arrived");
|
||||
fs::create_dir_all(incoming.join("nested")).unwrap();
|
||||
fs::write(incoming.join("a.rs"), "x").unwrap();
|
||||
fs::write(incoming.join("nested/b.rs"), "x").unwrap();
|
||||
|
||||
let (picker, _frecency) = make_watched_picker(&base);
|
||||
assert!(search_dirs(&picker, "arrived").is_empty(), "sanity");
|
||||
|
||||
fs::rename(&incoming, base.join("arrived")).unwrap();
|
||||
|
||||
assert!(
|
||||
wait_until(
|
||||
|| {
|
||||
let dirs = search_dirs(&picker, "arrived");
|
||||
dirs.iter().any(|d| d.starts_with("arrived"))
|
||||
},
|
||||
Duration::from_secs(10)
|
||||
),
|
||||
"moved-in dir must appear in dir search, got: {:?}",
|
||||
search_dirs(&picker, "arrived")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_file_in_new_directory_surfaces_the_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
|
||||
fs::write(base.join("keep.rs"), "x").unwrap();
|
||||
|
||||
let (picker, _frecency) = make_watched_picker(&base);
|
||||
assert!(search_dirs(&picker, "brandnew").is_empty(), "sanity");
|
||||
|
||||
fs::create_dir_all(base.join("brandnew")).unwrap();
|
||||
fs::write(base.join("brandnew/file.rs"), "x").unwrap();
|
||||
|
||||
assert!(
|
||||
wait_until(
|
||||
|| search_dirs(&picker, "brandnew")
|
||||
.iter()
|
||||
.any(|d| d.starts_with("brandnew")),
|
||||
Duration::from_secs(10)
|
||||
),
|
||||
"new dir must appear in dir search, got: {:?}",
|
||||
search_dirs(&picker, "brandnew")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_last_file_keeps_directory_visible() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
|
||||
fs::create_dir_all(base.join("lonely")).unwrap();
|
||||
fs::write(base.join("lonely/only.rs"), "x").unwrap();
|
||||
fs::write(base.join("keep.rs"), "x").unwrap();
|
||||
|
||||
let (picker, _frecency) = make_watched_picker(&base);
|
||||
|
||||
// the file goes away but the directory itself still exists on disk
|
||||
fs::remove_file(base.join("lonely/only.rs")).unwrap();
|
||||
|
||||
assert!(
|
||||
wait_until(
|
||||
|| {
|
||||
let guard = picker.read().unwrap();
|
||||
let p = guard.as_ref().unwrap();
|
||||
p.get_file_by_path(base.join("lonely/only.rs"))
|
||||
.is_none_or(|f| f.is_deleted())
|
||||
},
|
||||
Duration::from_secs(10)
|
||||
),
|
||||
"file removal must be applied"
|
||||
);
|
||||
assert!(
|
||||
search_dirs(&picker, "lonely")
|
||||
.iter()
|
||||
.any(|d| d.starts_with("lonely")),
|
||||
"dir still exists on disk and must stay searchable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recreated_directory_reappears_in_dir_search() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
|
||||
fs::create_dir_all(base.join("phoenix")).unwrap();
|
||||
fs::write(base.join("phoenix/a.rs"), "x").unwrap();
|
||||
fs::write(base.join("keep.rs"), "x").unwrap();
|
||||
|
||||
let (picker, _frecency) = make_watched_picker(&base);
|
||||
|
||||
fs::remove_dir_all(base.join("phoenix")).unwrap();
|
||||
assert!(
|
||||
wait_until(
|
||||
|| !search_dirs(&picker, "phoenix")
|
||||
.iter()
|
||||
.any(|d| d.starts_with("phoenix")),
|
||||
Duration::from_secs(10)
|
||||
),
|
||||
"dir must disappear after removal"
|
||||
);
|
||||
|
||||
fs::create_dir_all(base.join("phoenix")).unwrap();
|
||||
fs::write(base.join("phoenix/a.rs"), "x").unwrap();
|
||||
|
||||
assert!(
|
||||
wait_until(
|
||||
|| search_dirs(&picker, "phoenix")
|
||||
.iter()
|
||||
.any(|d| d.starts_with("phoenix")),
|
||||
Duration::from_secs(10)
|
||||
),
|
||||
"recreated dir must reappear in dir search, got: {:?}",
|
||||
search_dirs(&picker, "phoenix")
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression for #725: a dir that is EMPTY at scan time must be indexed —
|
||||
/// searchable in dir search and watched so later file creations are seen.
|
||||
#[test]
|
||||
fn empty_directory_at_scan_is_searchable_and_watched() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
|
||||
fs::create_dir_all(base.join("commands")).unwrap();
|
||||
fs::write(base.join("keep.rs"), "x").unwrap();
|
||||
|
||||
let (picker, _frecency) = make_watched_picker(&base);
|
||||
|
||||
assert!(
|
||||
search_dirs(&picker, "commands")
|
||||
.iter()
|
||||
.any(|d| d.starts_with("commands")),
|
||||
"empty dir must be searchable right after the scan, got: {:?}",
|
||||
search_dirs(&picker, "commands")
|
||||
);
|
||||
|
||||
// The empty dir must reuse its scan-built DirItem when a file lands in it
|
||||
// and the watcher must have registered a watch on it (the #725 repro).
|
||||
fs::write(base.join("commands/review.md"), "# review").unwrap();
|
||||
assert!(
|
||||
wait_until(
|
||||
|| {
|
||||
let guard = picker.read().unwrap();
|
||||
let p = guard.as_ref().unwrap();
|
||||
p.get_file_by_path(base.join("commands/review.md"))
|
||||
.is_some()
|
||||
},
|
||||
Duration::from_secs(10)
|
||||
),
|
||||
"file created in a scan-time-empty dir must be indexed"
|
||||
);
|
||||
|
||||
let guard = picker.read().unwrap();
|
||||
let p = guard.as_ref().unwrap();
|
||||
let commands_dirs = p
|
||||
.get_dirs()
|
||||
.iter()
|
||||
.filter(|d| d.relative_path(p).starts_with("commands"))
|
||||
.count();
|
||||
assert_eq!(commands_dirs, 1, "no duplicate DirItem for the empty dir");
|
||||
}
|
||||
@@ -1,14 +1,3 @@
|
||||
//! 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;
|
||||
@@ -850,11 +839,16 @@ fn drop_during_post_scan_does_not_crash() {
|
||||
);
|
||||
}
|
||||
|
||||
// At least some rounds must have caught the post-scan active window
|
||||
assert!(
|
||||
caught_active > 0,
|
||||
"Test didn't catch post_scan_indexing_active=true in any round. \
|
||||
The test is not exercising the race. ({caught_active}/10)"
|
||||
);
|
||||
// The primary invariant — dropping while post-scan may be active must not
|
||||
// crash — is exercised every round regardless. Catching the active window
|
||||
// is timing-dependent: with a fast walker/scan the post-scan phase can
|
||||
// complete before the poll observes it, especially on loaded CI runners.
|
||||
// So we only warn (not fail) if no round observed it.
|
||||
if caught_active == 0 {
|
||||
eprintln!(
|
||||
"warning: never observed post_scan_indexing_active=true; \
|
||||
drop-safety was still exercised in all rounds ({caught_active}/10)"
|
||||
);
|
||||
}
|
||||
eprintln!("Caught post-scan active in {caught_active}/10 rounds");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc 2c9d1ea2efbf6161f84b69598e884dbf1bde6039c70625adde0374817e20e2ea
|
||||
cc 1ac0f8f02b160dce13ca3f3630266abd24bd32b4e36d72a6a0a5365139ded3a8
|
||||
@@ -192,8 +192,10 @@ fn op_strategy() -> impl Strategy<Value = AbstractOp> {
|
||||
}
|
||||
|
||||
fn ops_strategy() -> impl Strategy<Value = Vec<AbstractOp>> {
|
||||
let min = stress_min_ops();
|
||||
let max = stress_max_ops();
|
||||
ops_strategy_bounded(stress_min_ops(), stress_max_ops())
|
||||
}
|
||||
|
||||
fn ops_strategy_bounded(min: usize, max: usize) -> impl Strategy<Value = Vec<AbstractOp>> {
|
||||
prop::collection::vec(op_strategy(), min..=max)
|
||||
}
|
||||
|
||||
@@ -285,6 +287,65 @@ fn stress_seeded() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pinned deterministic regression for the git-status divergence found on
|
||||
/// Windows CI (run 28264744320): after a `GitCommit` the picker retained stale
|
||||
/// `INDEX_*` bits because a pre-commit per-path status snapshot was applied
|
||||
/// after the post-commit full rescan.
|
||||
///
|
||||
/// The op sequence is regenerated from the proptest seed persisted in the
|
||||
/// regressions file (`cc 2c9d...`) using the CI op bounds (30..=60) that were
|
||||
/// in effect when the failure was found. The fingerprint assertion fails
|
||||
/// loudly if `ops_strategy()` ever changes shape — a changed strategy would
|
||||
/// silently decode the same seed into a *different* scenario, turning this
|
||||
/// regression guard into a no-op.
|
||||
#[test]
|
||||
fn stress_regression_stale_index_after_commit() {
|
||||
let ops = ops_from_chacha_seed(REGRESSION_SEED_HEX, 30, 60);
|
||||
assert_eq!(
|
||||
(ops.len(), fingerprint_ops(&ops)),
|
||||
(59, 0xc73f_16ce_b249_78eb),
|
||||
"ops_strategy() changed shape: the pinned seed no longer decodes to \
|
||||
the original Windows-CI scenario. Either revert the strategy change \
|
||||
or re-pin this regression (the original literal op list is in git \
|
||||
history of this file).",
|
||||
);
|
||||
run_stress_scenario(&ops);
|
||||
}
|
||||
|
||||
/// 32-byte ChaCha seed persisted by proptest for the Windows CI failure
|
||||
/// (the `cc 2c9d...` entry in the regressions file).
|
||||
const REGRESSION_SEED_HEX: &str =
|
||||
"2c9d1ea2efbf6161f84b69598e884dbf1bde6039c70625adde0374817e20e2ea";
|
||||
|
||||
/// Regenerate an op sequence from a persisted proptest ChaCha seed by
|
||||
/// replaying `ops_strategy()` the same way proptest does for regressions.
|
||||
/// `min`/`max` must match the `FFF_STRESS_{MIN,MAX}_OPS` bounds that were
|
||||
/// in effect when the seed was persisted — the strategy's value tree
|
||||
/// depends on them.
|
||||
fn ops_from_chacha_seed(seed_hex: &str, min: usize, max: usize) -> Vec<AbstractOp> {
|
||||
let seed_bytes: Vec<u8> = (0..seed_hex.len() / 2)
|
||||
.map(|i| u8::from_str_radix(&seed_hex[2 * i..2 * i + 2], 16).expect("valid hex seed"))
|
||||
.collect();
|
||||
let mut config = proptest_config();
|
||||
config.failure_persistence = Some(Box::new(FileFailurePersistence::Off));
|
||||
let rng = TestRng::from_seed(RngAlgorithm::ChaCha, &seed_bytes);
|
||||
let mut runner = TestRunner::new_with_rng(config, rng);
|
||||
ops_strategy_bounded(min, max)
|
||||
.new_tree(&mut runner)
|
||||
.expect("ops_strategy::new_tree")
|
||||
.current()
|
||||
}
|
||||
|
||||
/// FNV-1a over the debug repr of the ops; stable across platforms and runs.
|
||||
fn fingerprint_ops(ops: &[AbstractOp]) -> u64 {
|
||||
let mut h = 0xcbf2_9ce4_8422_2325u64;
|
||||
for b in format!("{ops:?}").bytes() {
|
||||
h ^= b as u64;
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Parse `FFF_STRESS_SEED` as either decimal or `0x`-prefixed hex.
|
||||
fn parse_stress_seed() -> u64 {
|
||||
match std::env::var("FFF_STRESS_SEED") {
|
||||
@@ -712,7 +773,7 @@ fn read_truth_status(base: &Path) -> BTreeMap<String, Status> {
|
||||
|
||||
let mut out = BTreeMap::new();
|
||||
for entry in statuses.iter() {
|
||||
if let Some(p) = entry.path() {
|
||||
if let Ok(p) = entry.path() {
|
||||
// git2 returns forward-slash paths; accept as-is.
|
||||
out.insert(p.to_string(), entry.status());
|
||||
}
|
||||
@@ -1197,7 +1258,7 @@ fn get_baseline_status_from_git(base: &Path) -> Vec<Live> {
|
||||
Err(_) => return out,
|
||||
};
|
||||
for entry in statuses.iter() {
|
||||
if let Some(p) = entry.path() {
|
||||
if let Ok(p) = entry.path() {
|
||||
let abs = base.join(p);
|
||||
// Must be a real file *right now* — ignore stale WT_DELETED rows.
|
||||
if abs.is_file() {
|
||||
|
||||
@@ -3,17 +3,6 @@
|
||||
//! Clones real repository, runs the simulated close to real user sereies of file system ewvents and
|
||||
//! verifies that fff can still find the correct files. Test cases are randomized and preserved
|
||||
//! using proptest
|
||||
//!
|
||||
//! Run:
|
||||
//! ```sh
|
||||
//! RUSTFLAGS="--cfg stress" cargo test -p fff-search --test fuzz_real_repos -- --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! Increase coverage:
|
||||
//! ```sh
|
||||
//! FFF_FUZZ_CASES=4 FFF_FUZZ_MAX_OPS=60 \
|
||||
//! RUSTFLAGS="--cfg stress" cargo test -p fff-search --test fuzz_real_repos -- --nocapture
|
||||
//! ```
|
||||
#![cfg(stress)]
|
||||
|
||||
use std::fs;
|
||||
@@ -218,10 +207,6 @@ fn revert_marker(path: &Path, marker: &str, original_line: &str) {
|
||||
let _ = fs::write(path, result);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Search helpers
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
fn grep_opts(mode: GrepMode) -> GrepSearchOptions {
|
||||
GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
@@ -689,35 +674,22 @@ fn proptest_config() -> ProptestConfig {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Op {
|
||||
/// Create a new file with a unique marker
|
||||
CreateFile { seed: u32 },
|
||||
/// Edit a tracked file, replacing the marker line with a new marker
|
||||
EditTracked { seed: u32 },
|
||||
/// Edit a random repo file, injecting a marker at a deterministic line
|
||||
EditRandom { seed: u32 },
|
||||
/// Delete a tracked file
|
||||
DeleteTracked,
|
||||
/// Revert a tracked edit, restoring the original line (marker disappears)
|
||||
RevertTracked,
|
||||
/// Burst of writes into ignored directory
|
||||
IgnoredBurst { count: u8, seed: u32 },
|
||||
/// Search verification round (no mutation)
|
||||
Verify,
|
||||
}
|
||||
|
||||
fn op_strategy() -> impl Strategy<Value = Op> {
|
||||
prop_oneof![
|
||||
// Create new files — exercises overflow path
|
||||
12 => any::<u32>().prop_map(|s| Op::CreateFile { seed: s }),
|
||||
// Edit tracked files — exercises content invalidation
|
||||
18 => any::<u32>().prop_map(|s| Op::EditTracked { seed: s }),
|
||||
// Edit random repo files — exercises bigram overlay for base files
|
||||
18 => any::<u32>().prop_map(|s| Op::EditRandom { seed: s }),
|
||||
// Delete tracked files — exercises tombstoning
|
||||
8 => Just(Op::DeleteTracked),
|
||||
// Revert tracked edits — marker must disappear from search
|
||||
10 => Just(Op::RevertTracked),
|
||||
// Burst ignored writes — exercises .gitignore filtering under load
|
||||
9 => (1u8..20, any::<u32>()).prop_map(|(c, s)| Op::IgnoredBurst { count: c, seed: s }),
|
||||
// Explicit verification rounds
|
||||
25 => Just(Op::Verify),
|
||||
|
||||
@@ -1835,3 +1835,64 @@ fn plain_text_smart_case_finds_uppercase_content_with_lowercase_query() {
|
||||
"lowercase query should case-insensitively match 'VFIO-KVM'"
|
||||
);
|
||||
}
|
||||
|
||||
/// Bug pinning: `!=` was parsed as a Not("=") exclusion constraint, dropping it
|
||||
/// from the needle. Operator tokens must stay literal search text.
|
||||
#[test]
|
||||
fn plain_text_not_equals_operator_is_literal() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let picker = create_picker(
|
||||
tmp.path(),
|
||||
&[(
|
||||
"watch.rs",
|
||||
"if delivery.sub.epoch.load(Ordering::Acquire) != delivery.epoch {\n",
|
||||
)],
|
||||
);
|
||||
|
||||
let parsed = parse_grep_query("Ordering::Acquire) != delivery.epoch");
|
||||
let result = picker.grep(&parsed, &plain_opts());
|
||||
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
1,
|
||||
"operator `!=` must match literally"
|
||||
);
|
||||
assert!(!result.literal_fallback, "no fallback should be needed");
|
||||
assert!(result.matches[0].line_content.contains("!= delivery.epoch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_fallback_when_constraints_find_nothing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let picker = create_picker(tmp.path(), &[("a.txt", "foo !bar_baz qux\n")]);
|
||||
|
||||
// `!bar_baz` becomes Not(Text) so the constrained needle is "foo qux" → no
|
||||
// match; the search must retry the raw query as literal text.
|
||||
let parsed = parse_grep_query("foo !bar_baz qux");
|
||||
let result = picker.grep(&parsed, &plain_opts());
|
||||
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
assert!(
|
||||
result.literal_fallback,
|
||||
"literal fallback should be flagged"
|
||||
);
|
||||
assert!(result.matches[0].line_content.contains("!bar_baz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_fallback_not_triggered_when_constraints_match() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let picker = create_picker(
|
||||
tmp.path(),
|
||||
&[
|
||||
("src/lib.rs", "needle here\n"),
|
||||
("test/lib.rs", "needle here\n"),
|
||||
],
|
||||
);
|
||||
|
||||
let parsed = parse_grep_query("needle !test");
|
||||
let result = picker.grep(&parsed, &plain_opts());
|
||||
|
||||
assert_eq!(result.matches.len(), 1, "exclusion should still apply");
|
||||
assert!(!result.literal_fallback);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! One process must be able to hold many trackers over the same LMDB path
|
||||
//! (issues #700/#760): they share a single pooled env instead of failing
|
||||
//! with `EnvAlreadyOpened`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use fff_search::frecency::FrecencyTracker;
|
||||
use fff_search::query_tracker::QueryTracker;
|
||||
use fff_search::shared::SharedFrecency;
|
||||
|
||||
fn unique_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("fff-env-pool-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_path_trackers_share_one_env() {
|
||||
let dir = unique_dir("share");
|
||||
let file = Path::new("/virtual/env-pool/shared.rs");
|
||||
|
||||
let a = FrecencyTracker::open(&dir).expect("first open");
|
||||
let b = FrecencyTracker::open(&dir).expect("second open in the same process (#700/#760)");
|
||||
|
||||
a.track_access(file).expect("write via a");
|
||||
assert_eq!(b.access_count(file).expect("read via b"), 1);
|
||||
|
||||
drop(a);
|
||||
b.track_access(file)
|
||||
.expect("b must stay usable after a drops");
|
||||
assert_eq!(b.access_count(file).unwrap(), 2);
|
||||
drop(b);
|
||||
|
||||
let c = FrecencyTracker::open(&dir).expect("reopen after all handles dropped");
|
||||
assert_eq!(
|
||||
c.access_count(file).unwrap(),
|
||||
2,
|
||||
"data persisted across reopen"
|
||||
);
|
||||
|
||||
drop(c);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_open_and_drop_never_collide() {
|
||||
let dir = unique_dir("hammer");
|
||||
let file = Path::new("/virtual/env-pool/hammer.rs");
|
||||
|
||||
let mut handles = Vec::new();
|
||||
for t in 0..8 {
|
||||
let dir = dir.clone();
|
||||
handles.push(std::thread::spawn(move || {
|
||||
for i in 0..100 {
|
||||
let tracker = FrecencyTracker::open(&dir)
|
||||
.unwrap_or_else(|e| panic!("thread {t} iteration {i}: {e}"));
|
||||
if i % 20 == 0 {
|
||||
tracker.track_access(file).expect("track access");
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
for handle in handles {
|
||||
handle.join().expect("no thread may panic");
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_store_on_same_path_is_rejected_with_clear_error() {
|
||||
let dir = unique_dir("mismatch");
|
||||
|
||||
let _frecency = FrecencyTracker::open(&dir).expect("frecency open");
|
||||
let err = QueryTracker::open(&dir).expect_err("env options differ, must be rejected");
|
||||
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("frecency") && msg.contains("query"),
|
||||
"error must name both stores so the user can fix their config, got: {msg}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destroy_refuses_while_shared_then_succeeds_when_sole() {
|
||||
let dir = unique_dir("destroy");
|
||||
let file = Path::new("/virtual/env-pool/destroy.rs");
|
||||
|
||||
let shared = SharedFrecency::default();
|
||||
shared
|
||||
.init(FrecencyTracker::open(&dir).expect("init open"))
|
||||
.expect("init");
|
||||
let other = FrecencyTracker::open(&dir).expect("second handle over the same db");
|
||||
|
||||
shared
|
||||
.destroy()
|
||||
.expect_err("destroy must refuse while another tracker uses the env");
|
||||
|
||||
// Refusal must keep both the files and the shared handle intact.
|
||||
assert!(
|
||||
dir.join("data.mdb").exists(),
|
||||
"db files survive a refused destroy"
|
||||
);
|
||||
shared
|
||||
.read()
|
||||
.expect("read lock")
|
||||
.as_ref()
|
||||
.expect("tracker restored after refused destroy")
|
||||
.track_access(file)
|
||||
.expect("shared handle still works");
|
||||
|
||||
drop(other);
|
||||
let removed = shared
|
||||
.destroy()
|
||||
.expect("sole-owner destroy succeeds")
|
||||
.expect("a path was removed");
|
||||
assert!(
|
||||
!removed.exists(),
|
||||
"db dir deleted once nobody shares the env"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Repro for #783: fff opens LMDB envs with only map_size set, leaving heed's
|
||||
// default max_readers (126) and default TLS mode. Long-lived threads each pin a
|
||||
// reader slot for the thread's lifetime, so >126 live reader threads exhaust the
|
||||
// table with MDB_READERS_FULL.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Barrier, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
use heed::EnvOpenOptions;
|
||||
|
||||
// This binary links heed directly without the fff lib, so nothing pulls in
|
||||
// advapi32 for lmdb's security-descriptor calls in mdb_env_setup_locks.
|
||||
#[cfg(windows)]
|
||||
#[link(name = "advapi32")]
|
||||
unsafe extern "C" {}
|
||||
|
||||
fn temp_env_dir(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("fff-readers-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
// Regression for #783: with only map_size set (pre-fix), heed's default 126
|
||||
// reader slots are exhausted once >126 live threads each hold a read txn. fff now
|
||||
// raises max_readers, so this many live readers must all get a slot.
|
||||
const FFF_MAX_READERS: u32 = 1024;
|
||||
|
||||
#[test]
|
||||
fn raised_max_readers_admits_more_than_126_live_readers() {
|
||||
let dir = temp_env_dir("raised");
|
||||
let env = unsafe {
|
||||
EnvOpenOptions::new()
|
||||
.map_size(10 * 1024 * 1024)
|
||||
.max_readers(FFF_MAX_READERS)
|
||||
.open(&dir)
|
||||
}
|
||||
.unwrap();
|
||||
|
||||
const THREADS: usize = 200;
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let ready = Arc::new(Barrier::new(THREADS + 1));
|
||||
let readers_full = Arc::new(AtomicBool::new(false));
|
||||
let (tx, rx) = mpsc::channel::<bool>(); // true = read txn acquired
|
||||
|
||||
let mut handles = Vec::new();
|
||||
for _ in 0..THREADS {
|
||||
let env = env.clone();
|
||||
let stop = stop.clone();
|
||||
let ready = ready.clone();
|
||||
let readers_full = readers_full.clone();
|
||||
let tx = tx.clone();
|
||||
handles.push(std::thread::spawn(move || {
|
||||
match env.read_txn() {
|
||||
Ok(txn) => {
|
||||
tx.send(true).ok();
|
||||
ready.wait();
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
std::thread::park_timeout(Duration::from_millis(5));
|
||||
}
|
||||
drop(txn); // hold the slot for the whole test
|
||||
}
|
||||
Err(e) => {
|
||||
if e.to_string().contains("MDB_READERS_FULL") {
|
||||
readers_full.store(true, Ordering::Relaxed);
|
||||
}
|
||||
tx.send(false).ok();
|
||||
ready.wait();
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
drop(tx);
|
||||
|
||||
// Collect exactly one result per thread; parked threads keep their tx clone
|
||||
// alive, so we must not wait for the channel to close.
|
||||
let acquired = AtomicUsize::new(0);
|
||||
for _ in 0..THREADS {
|
||||
if rx.recv().unwrap() {
|
||||
acquired.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
ready.wait();
|
||||
|
||||
let acquired = acquired.load(Ordering::Relaxed);
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
for h in handles {
|
||||
h.join().unwrap();
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
// With max_readers raised, all 200 live reader threads must get a slot and
|
||||
// none may see MDB_READERS_FULL. On the pre-fix default of 126 this plateaus
|
||||
// at 126 and the rest fail.
|
||||
assert!(
|
||||
!readers_full.load(Ordering::Relaxed),
|
||||
"MDB_READERS_FULL hit: only {acquired}/{THREADS} live reader threads got a slot"
|
||||
);
|
||||
assert_eq!(
|
||||
acquired, THREADS,
|
||||
"all {THREADS} live reader threads should get a slot; got {acquired}"
|
||||
);
|
||||
}
|
||||
|
||||
// Structural fix for #783: with MDB_NOTLS a reader slot is tied to the txn
|
||||
// object and freed on drop, not pinned per thread. 200 long-lived threads each
|
||||
// open+drop a txn against the *default* 126-slot table; in TLS mode this
|
||||
// plateaus at 126, in NOTLS mode every thread must succeed.
|
||||
#[test]
|
||||
fn notls_releases_slots_of_live_threads() {
|
||||
let dir = temp_env_dir("notls");
|
||||
let env = unsafe {
|
||||
EnvOpenOptions::new()
|
||||
.read_txn_without_tls()
|
||||
.map_size(10 * 1024 * 1024)
|
||||
.open(&dir)
|
||||
}
|
||||
.unwrap();
|
||||
|
||||
const THREADS: usize = 200;
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let ready = Arc::new(Barrier::new(THREADS + 1));
|
||||
// Serialize txns so the test measures slot *release*, not concurrency.
|
||||
let txn_gate = Arc::new(std::sync::Mutex::new(()));
|
||||
let acquired = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let mut handles = Vec::new();
|
||||
for _ in 0..THREADS {
|
||||
let env = env.clone();
|
||||
let stop = stop.clone();
|
||||
let ready = ready.clone();
|
||||
let txn_gate = txn_gate.clone();
|
||||
let acquired = acquired.clone();
|
||||
handles.push(std::thread::spawn(move || {
|
||||
{
|
||||
let _gate = txn_gate.lock().unwrap();
|
||||
if let Ok(txn) = env.read_txn() {
|
||||
acquired.fetch_add(1, Ordering::Relaxed);
|
||||
drop(txn); // NOTLS: slot returns to the pool here
|
||||
}
|
||||
}
|
||||
// Stay alive: in TLS mode this thread would keep its slot pinned.
|
||||
ready.wait();
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
std::thread::park_timeout(Duration::from_millis(5));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
ready.wait();
|
||||
let got = acquired.load(Ordering::Relaxed);
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
for h in handles {
|
||||
h.join().unwrap();
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
assert_eq!(
|
||||
got, THREADS,
|
||||
"NOTLS must free slots on txn drop; only {got}/{THREADS} live threads got one"
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,9 @@
|
||||
//! 3. The watcher's event handler detects the directory Create event,
|
||||
//! collects it, and sends it to the owner thread via `watch_tx`.
|
||||
//! 4. The owner thread adds a NonRecursive watch on the new directory and
|
||||
//! does a flat (non-recursive) read_dir to inject files that already
|
||||
//! exist (race-window coverage).
|
||||
//! walks its subtree (`index_new_directory`) to inject files that
|
||||
//! already exist (race-window + burst/mv-in coverage) and to watch
|
||||
//! nested subdirectories.
|
||||
//! 5. Files created *after* the watch is established are picked up via
|
||||
//! normal event delivery.
|
||||
//!
|
||||
@@ -79,11 +80,11 @@ fn make_watched_picker(base: &Path) -> (SharedFilePicker, SharedFrecency) {
|
||||
/// Wait for the initial scan + watcher to be fully ready.
|
||||
fn wait_ready(shared_picker: &SharedFilePicker) {
|
||||
assert!(
|
||||
shared_picker.wait_for_scan(Duration::from_secs(10)),
|
||||
shared_picker.wait_for_scan(Duration::from_secs(30)),
|
||||
"Timed out waiting for initial scan"
|
||||
);
|
||||
assert!(
|
||||
shared_picker.wait_for_watcher(Duration::from_secs(10)),
|
||||
shared_picker.wait_for_watcher(Duration::from_secs(30)),
|
||||
"Timed out waiting for watcher"
|
||||
);
|
||||
}
|
||||
@@ -469,6 +470,160 @@ fn burst_file_creation_in_new_directory() {
|
||||
}
|
||||
}
|
||||
|
||||
/// bug pinning #725: a directory that already exists but is EMPTY at
|
||||
/// initial scan time is absent from `sync_data.dirs` and missing watch events
|
||||
#[test]
|
||||
fn file_created_in_preexisting_empty_directory() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path().canonicalize().unwrap();
|
||||
|
||||
// `commands/` is empty during the initial scan — only `init.lua` is indexed.
|
||||
fs::create_dir_all(base.join("commands")).unwrap();
|
||||
fs::write(base.join("init.lua"), "-- init\n").unwrap();
|
||||
|
||||
let (shared_picker, _frecency) = make_watched_picker(&base);
|
||||
wait_ready(&shared_picker);
|
||||
|
||||
// Now write a file into the directory that was empty at scan time.
|
||||
fs::write(
|
||||
base.join("commands/review.md"),
|
||||
"# Review\nEMPTY_DIR_REVIEW_TOKEN\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let elapsed = poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
"file commands/review.md created in a pre-existing empty directory",
|
||||
|picker| {
|
||||
picker
|
||||
.get_files()
|
||||
.iter()
|
||||
.any(|f| f.relative_path(picker).contains("review.md"))
|
||||
},
|
||||
);
|
||||
eprintln!(
|
||||
" File in pre-existing empty directory detected in {:.0}ms",
|
||||
elapsed.as_secs_f64() * 1000.0
|
||||
);
|
||||
}
|
||||
|
||||
/// Same as above but with a nested chain of empty directories under an
|
||||
/// indexed one: every level of the empty subtree must be watched.
|
||||
#[test]
|
||||
fn file_created_in_nested_preexisting_empty_directories() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path().canonicalize().unwrap();
|
||||
|
||||
// `src/` is indexed (has a file); `src/plugins/extra/` is an empty chain.
|
||||
fs::create_dir_all(base.join("src/plugins/extra")).unwrap();
|
||||
fs::write(base.join("src/main.rs"), "fn main() {}\n").unwrap();
|
||||
|
||||
git_init_and_commit(&base);
|
||||
|
||||
let (shared_picker, _frecency) = make_watched_picker(&base);
|
||||
wait_ready(&shared_picker);
|
||||
|
||||
fs::write(
|
||||
base.join("src/plugins/extra/loader.rs"),
|
||||
"pub fn load() {}\nconst TOKEN: &str = \"NESTED_EMPTY_DIR_TOKEN\";\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let elapsed = poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
"file src/plugins/extra/loader.rs created in nested empty directories",
|
||||
|picker| {
|
||||
picker
|
||||
.get_files()
|
||||
.iter()
|
||||
.any(|f| f.relative_path(picker).contains("loader.rs"))
|
||||
},
|
||||
);
|
||||
eprintln!(
|
||||
" File in nested empty directories detected in {:.0}ms",
|
||||
elapsed.as_secs_f64() * 1000.0
|
||||
);
|
||||
|
||||
poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
"grep finds NESTED_EMPTY_DIR_TOKEN",
|
||||
|picker| grep_plain_count(picker, "NESTED_EMPTY_DIR_TOKEN") >= 1,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_tree_created_in_one_burst_detected() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path().canonicalize().unwrap();
|
||||
|
||||
fs::write(base.join("root.txt"), "root file\n").unwrap();
|
||||
git_init_and_commit(&base);
|
||||
|
||||
let (shared_picker, _frecency) = make_watched_picker(&base);
|
||||
wait_ready(&shared_picker);
|
||||
|
||||
// No sleeps between levels: the watcher sees one Create for `pkg` and
|
||||
// must index the whole subtree from it.
|
||||
fs::create_dir_all(base.join("pkg/src/nested")).unwrap();
|
||||
fs::write(base.join("pkg/Cargo.toml"), "[package]\n").unwrap();
|
||||
fs::write(
|
||||
base.join("pkg/src/lib.rs"),
|
||||
"const TOKEN: &str = \"BURST_TREE_LIB_TOKEN\";\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
base.join("pkg/src/nested/deep.rs"),
|
||||
"const TOKEN: &str = \"BURST_TREE_DEEP_TOKEN\";\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
for rel in ["pkg/Cargo.toml", "pkg/src/lib.rs", "pkg/src/nested/deep.rs"] {
|
||||
let elapsed = poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
&format!("burst-created file {rel}"),
|
||||
|picker| {
|
||||
picker
|
||||
.get_files()
|
||||
.iter()
|
||||
.any(|f| f.relative_path(picker) == rel)
|
||||
},
|
||||
);
|
||||
eprintln!(
|
||||
" Burst file {rel} detected in {:.0}ms",
|
||||
elapsed.as_secs_f64() * 1000.0
|
||||
);
|
||||
}
|
||||
|
||||
// Files created later at the deepest level need the nested watches too.
|
||||
fs::write(
|
||||
base.join("pkg/src/nested/late.rs"),
|
||||
"const TOKEN: &str = \"BURST_TREE_LATE_TOKEN\";\n",
|
||||
)
|
||||
.unwrap();
|
||||
poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
"late file in burst-created nested dir",
|
||||
|picker| {
|
||||
picker
|
||||
.get_files()
|
||||
.iter()
|
||||
.any(|f| f.relative_path(picker).ends_with("late.rs"))
|
||||
},
|
||||
);
|
||||
|
||||
poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
"grep finds BURST_TREE_DEEP_TOKEN",
|
||||
|picker| grep_plain_count(picker, "BURST_TREE_DEEP_TOKEN") >= 1,
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that gitignored directories created at runtime are NOT watched
|
||||
/// and their files do NOT appear in the index.
|
||||
#[test]
|
||||
|
||||
@@ -222,6 +222,17 @@ fn multi_grep_with_file_path_suffix_constraint() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_grep_with_missing_file_path_constraint_returns_no_matches() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let picker = create_picker(tmp.path(), &[("other.lua", "handleRequest\n")]);
|
||||
|
||||
let constraints = [Constraint::FilePath("missing.lua")];
|
||||
let result = picker.multi_grep(&["handleRequest"], &constraints, &plain_opts());
|
||||
|
||||
assert!(result.matches.is_empty());
|
||||
}
|
||||
|
||||
/// Glob constraints must match native Windows paths — the picker normalises
|
||||
/// separators when handing paths to the glob matcher.
|
||||
#[test]
|
||||
|
||||
@@ -105,7 +105,10 @@ fn real_binary_fixtures_are_detected_and_excluded_from_grep() {
|
||||
)
|
||||
.expect("failed to create FilePicker");
|
||||
|
||||
shared_picker.wait_for_indexing_complete(Duration::from_secs(5));
|
||||
assert!(
|
||||
shared_picker.wait_for_indexing_complete(Duration::from_secs(10)),
|
||||
"indexing/post-scan did not complete in time — binary classification may not have run yet"
|
||||
);
|
||||
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
@@ -151,3 +154,61 @@ fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
.windows(needle.len())
|
||||
.any(|window| window == needle)
|
||||
}
|
||||
|
||||
/// Deterministic regression for the Windows-CI failure where `codex_view`
|
||||
/// (a >2 MB no-extension binary) was not flagged `is_binary`. Root cause was a
|
||||
/// readiness-signal gap: `scanning` was cleared before `post_scan_indexing_active`
|
||||
/// was set, so `wait_for_indexing_complete` could return before the binary sniff
|
||||
/// ran. Uses synthetic fixtures (no repo/fixture dependency) covering both the
|
||||
/// >2 MB non-indexable sniff path and the <2 MB bigram path, repeated to stress
|
||||
/// the signal ordering. With the fix it must pass every iteration.
|
||||
#[test]
|
||||
fn binary_classification_done_before_indexing_wait_returns() {
|
||||
const ITERATIONS: usize = 8;
|
||||
// NUL bytes => `detect_binary_content` classifies as binary on every path.
|
||||
let large = vec![0u8; 3 * 1024 * 1024]; // > 2 MB -> non-indexable sniff
|
||||
let small = vec![0u8; 64 * 1024]; // < 2 MB -> bigram path
|
||||
|
||||
for iteration in 0..ITERATIONS {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let base = tmp.path();
|
||||
fs::write(base.join("large_binary_no_ext"), &large).unwrap();
|
||||
fs::write(base.join("small.unknownext"), &small).unwrap();
|
||||
fs::write(base.join("readme.txt"), "hello world\n").unwrap();
|
||||
|
||||
let shared_picker = SharedFilePicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
FilePicker::new_with_shared_state(
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: base.to_string_lossy().to_string(),
|
||||
enable_mmap_cache: false,
|
||||
enable_content_indexing: true,
|
||||
mode: FFFMode::Neovim,
|
||||
watch: false,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("failed to create FilePicker");
|
||||
|
||||
assert!(
|
||||
shared_picker.wait_for_indexing_complete(Duration::from_secs(10)),
|
||||
"iteration {iteration}: indexing/post-scan did not complete in time"
|
||||
);
|
||||
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
for name in ["large_binary_no_ext", "small.unknownext"] {
|
||||
let flagged = picker
|
||||
.get_files()
|
||||
.iter()
|
||||
.any(|f| f.relative_path(picker).ends_with(name) && f.is_binary());
|
||||
assert!(
|
||||
flagged,
|
||||
"iteration {iteration}: {name} must be flagged is_binary once \
|
||||
wait_for_indexing_complete returns"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
#![cfg(rescan_stats)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use fff_search::file_picker::{FFFMode, FilePicker};
|
||||
use fff_search::{FilePickerOptions, RescanStats, SharedFilePicker, SharedFrecency};
|
||||
use tempfile::TempDir;
|
||||
|
||||
const SETTLE: Duration = Duration::from_millis(600);
|
||||
|
||||
#[test]
|
||||
fn saving_source_files_does_not_rescan() {
|
||||
let repo = WatchedRepo::new(|base| {
|
||||
write(base, ".gitignore", "target/\n");
|
||||
for i in 0..20 {
|
||||
write(base, &format!("src/mod{i}.rs"), "pub fn f() {}");
|
||||
}
|
||||
});
|
||||
|
||||
for round in 0..10 {
|
||||
for i in 0..20 {
|
||||
repo.write(
|
||||
&format!("src/mod{i}.rs"),
|
||||
&format!("pub fn f() {{ let _ = {round}; }}"),
|
||||
);
|
||||
}
|
||||
repo.settle();
|
||||
}
|
||||
|
||||
repo.assert_quiet("200 file saves");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_output_in_ignored_directories_does_not_rescan() {
|
||||
let repo = WatchedRepo::new(|base| {
|
||||
write(base, ".gitignore", "target/\nnode_modules/\ndist/\n");
|
||||
write(base, "src/main.rs", "fn main() {}");
|
||||
});
|
||||
|
||||
for round in 0..4 {
|
||||
for i in 0..150 {
|
||||
repo.write(&format!("target/debug/deps/unit-{round}-{i}.o"), "binary");
|
||||
repo.write(&format!("dist/chunk-{round}-{i}.js"), "bundled");
|
||||
}
|
||||
repo.settle();
|
||||
}
|
||||
|
||||
repo.assert_quiet("1200 build artifacts written into ignored directories");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_source_files_and_directories_does_not_rescan() {
|
||||
let repo = WatchedRepo::new(|base| {
|
||||
write(base, ".gitignore", "target/\n");
|
||||
write(base, "src/main.rs", "fn main() {}");
|
||||
});
|
||||
|
||||
for i in 0..40 {
|
||||
repo.write(&format!("src/feature{i}/mod.rs"), "pub mod inner;");
|
||||
repo.write(&format!("src/feature{i}/inner.rs"), "pub fn go() {}");
|
||||
}
|
||||
repo.settle();
|
||||
|
||||
assert!(
|
||||
repo.wait_indexed("src/feature39/inner.rs"),
|
||||
"watcher must index files in newly created directories"
|
||||
);
|
||||
repo.assert_quiet("40 new directories with 80 files");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recreating_generated_files_does_not_rescan() {
|
||||
let repo = WatchedRepo::new(|base| {
|
||||
write(base, ".gitignore", "target/\n");
|
||||
write(base, "src/main.rs", "fn main() {}");
|
||||
});
|
||||
|
||||
// Recreated paths must reuse their overflow slots.
|
||||
for round in 0..12 {
|
||||
for i in 0..40 {
|
||||
repo.write(&format!("src/generated/api{i}.rs"), "pub struct A;");
|
||||
}
|
||||
repo.settle();
|
||||
for i in 0..40 {
|
||||
repo.remove(&format!("src/generated/api{i}.rs"));
|
||||
}
|
||||
repo.settle();
|
||||
assert!(
|
||||
repo.overflow_len() <= 64,
|
||||
"round {round}: regenerating the same paths grew the overflow region to {}",
|
||||
repo.overflow_len()
|
||||
);
|
||||
}
|
||||
|
||||
repo.assert_quiet("12 codegen cycles over 40 stable paths");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_workflow_does_not_rescan() {
|
||||
let repo = WatchedRepo::new(|base| {
|
||||
write(base, ".gitignore", "target/\n");
|
||||
write(base, "src/main.rs", "fn main() {}");
|
||||
write(base, "src/lib.rs", "pub mod thing;");
|
||||
git(base, &["init", "-b", "main"]);
|
||||
git(base, &["add", "-A"]);
|
||||
git(base, &["commit", "-m", "initial"]);
|
||||
});
|
||||
|
||||
repo.write("src/main.rs", "fn main() { println!(\"hi\"); }");
|
||||
repo.settle();
|
||||
repo.git(&["add", "-A"]);
|
||||
repo.settle();
|
||||
repo.git(&["commit", "-m", "second"]);
|
||||
repo.settle();
|
||||
repo.git(&["checkout", "-b", "feature"]);
|
||||
repo.settle();
|
||||
repo.write("src/feature.rs", "pub fn feature() {}");
|
||||
repo.git(&["add", "-A"]);
|
||||
repo.git(&["commit", "-m", "feature"]);
|
||||
repo.settle();
|
||||
repo.git(&["checkout", "main"]);
|
||||
repo.settle();
|
||||
repo.git(&["merge", "feature"]);
|
||||
repo.settle();
|
||||
|
||||
repo.assert_quiet("a commit / branch / merge cycle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_files_does_not_rescan() {
|
||||
let repo = WatchedRepo::new(|base| {
|
||||
write(base, ".gitignore", "target/\n");
|
||||
for i in 0..50 {
|
||||
write(base, &format!("src/mod{i}.rs"), "pub fn f() {}");
|
||||
}
|
||||
});
|
||||
|
||||
// Preview rendering and grep open every file in the result list. Reacting
|
||||
// to those reads would make the picker rescan while the user scrolls.
|
||||
for _ in 0..5 {
|
||||
for i in 0..50 {
|
||||
let _ = std::fs::read(repo.path(&format!("src/mod{i}.rs"))).unwrap();
|
||||
}
|
||||
}
|
||||
repo.settle();
|
||||
|
||||
repo.assert_quiet("reading every indexed file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_install_style_churn_does_not_rescan() {
|
||||
let repo = WatchedRepo::new(|base| {
|
||||
write(base, ".gitignore", "node_modules/\n");
|
||||
write(base, "src/index.ts", "export const a = 1;");
|
||||
});
|
||||
|
||||
for pkg in 0..100 {
|
||||
repo.write(&format!("node_modules/pkg{pkg}/package.json"), "{}");
|
||||
repo.write(
|
||||
&format!("node_modules/pkg{pkg}/index.js"),
|
||||
"module.exports={}",
|
||||
);
|
||||
repo.write(&format!("node_modules/pkg{pkg}/.gitignore"), "dist\n");
|
||||
}
|
||||
repo.settle();
|
||||
repo.settle();
|
||||
|
||||
repo.assert_quiet("an npm install into an ignored node_modules");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_churning_root_is_capped_at_one_rescan_per_cooldown() {
|
||||
let repo = WatchedRepo::new(|base| {
|
||||
write(base, "src/main.rs", "fn main() {}");
|
||||
});
|
||||
|
||||
// Root ignore changes force watcher rescan requests.
|
||||
for round in 0..25 {
|
||||
repo.write(".gitignore", &format!("target/\n# round {round}\n"));
|
||||
std::thread::sleep(Duration::from_millis(120));
|
||||
}
|
||||
repo.settle();
|
||||
|
||||
let stats = repo.rescans();
|
||||
assert!(
|
||||
stats.total <= 1,
|
||||
"a churning root must not exceed one walk per cooldown, got {stats}"
|
||||
);
|
||||
assert!(
|
||||
stats.throttled > 0,
|
||||
"the suppressed triggers must be recorded, got {stats}"
|
||||
);
|
||||
}
|
||||
|
||||
struct WatchedRepo {
|
||||
base: PathBuf,
|
||||
picker: SharedFilePicker,
|
||||
_frecency: SharedFrecency,
|
||||
_tmp: TempDir,
|
||||
}
|
||||
|
||||
impl WatchedRepo {
|
||||
fn new(setup: impl FnOnce(&Path)) -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
|
||||
setup(&base);
|
||||
|
||||
let picker = SharedFilePicker::default();
|
||||
let frecency = SharedFrecency::noop();
|
||||
FilePicker::new_with_shared_state(
|
||||
picker.clone(),
|
||||
frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: base.to_string_lossy().into_owned(),
|
||||
enable_mmap_cache: false,
|
||||
mode: FFFMode::Neovim,
|
||||
watch: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("failed to create file picker");
|
||||
|
||||
assert!(
|
||||
picker.wait_for_scan(Duration::from_secs(60)),
|
||||
"timed out waiting for the initial scan"
|
||||
);
|
||||
assert!(
|
||||
picker.wait_for_watcher(Duration::from_secs(60)),
|
||||
"timed out waiting for the watcher"
|
||||
);
|
||||
|
||||
let repo = Self {
|
||||
base,
|
||||
picker,
|
||||
_frecency: frecency,
|
||||
_tmp: tmp,
|
||||
};
|
||||
repo.settle();
|
||||
repo.picker.reset_rescan_stats();
|
||||
repo
|
||||
}
|
||||
|
||||
fn settle(&self) {
|
||||
std::thread::sleep(SETTLE);
|
||||
assert!(
|
||||
self.picker
|
||||
.wait_for_indexing_complete(Duration::from_secs(60)),
|
||||
"timed out waiting for background indexing to finish"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_quiet(&self, workload: &str) {
|
||||
let stats = self.rescans();
|
||||
assert_eq!(
|
||||
stats.watcher_triggered(),
|
||||
0,
|
||||
"{workload} must be absorbed incrementally, but the watcher fell back to {stats}"
|
||||
);
|
||||
}
|
||||
|
||||
fn rescans(&self) -> RescanStats {
|
||||
self.picker.rescan_stats()
|
||||
}
|
||||
|
||||
fn path(&self, rel: &str) -> PathBuf {
|
||||
self.base.join(rel)
|
||||
}
|
||||
|
||||
fn write(&self, rel: &str, contents: &str) {
|
||||
write(&self.base, rel, contents);
|
||||
}
|
||||
|
||||
fn remove(&self, rel: &str) {
|
||||
std::fs::remove_file(self.path(rel)).unwrap();
|
||||
}
|
||||
|
||||
fn git(&self, args: &[&str]) {
|
||||
git(&self.base, args);
|
||||
}
|
||||
|
||||
fn wait_indexed(&self, rel: &str) -> bool {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
||||
while std::time::Instant::now() < deadline {
|
||||
if self.is_indexed(rel) {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_indexed(&self, rel: &str) -> bool {
|
||||
let guard = self.picker.read().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.and_then(|p| p.get_file_by_path(self.path(rel)))
|
||||
.is_some_and(|file| !file.is_deleted())
|
||||
}
|
||||
|
||||
fn overflow_len(&self) -> usize {
|
||||
let guard = self.picker.read().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.map(|p| p.get_overflow_files().len())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WatchedRepo {
|
||||
fn drop(&mut self) {
|
||||
// Stop the watcher before the tree disappears, otherwise a late batch
|
||||
// races the tempdir removal.
|
||||
if let Ok(mut guard) = self.picker.write() {
|
||||
guard.take();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write(base: &Path, rel: &str, contents: &str) {
|
||||
let path = base.join(rel);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, contents).unwrap();
|
||||
}
|
||||
|
||||
fn git(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 {args:?} failed to spawn: {e}"));
|
||||
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
use fff_search::file_picker::{FFFMode, FilePicker};
|
||||
use fff_search::{
|
||||
FilePickerOptions, SharedFilePicker, SharedFrecency, WatchEvent, WatchEventKind, WatchOptions,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_watched_picker(base: &Path) -> (SharedFilePicker, SharedFrecency) {
|
||||
let shared_picker = SharedFilePicker::default();
|
||||
let shared_frecency = SharedFrecency::noop();
|
||||
|
||||
FilePicker::new_with_shared_state(
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: base.to_string_lossy().into_owned(),
|
||||
enable_mmap_cache: false,
|
||||
enable_content_indexing: false,
|
||||
mode: FFFMode::Neovim,
|
||||
watch: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("FilePicker::new_with_shared_state");
|
||||
|
||||
assert!(
|
||||
shared_picker.wait_for_scan(Duration::from_secs(30)),
|
||||
"initial scan did not complete"
|
||||
);
|
||||
assert!(
|
||||
shared_picker.wait_for_watcher(Duration::from_secs(30)),
|
||||
"watcher did not install"
|
||||
);
|
||||
// macOS FSEvents streams need a beat before they deliver reliably
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
|
||||
(shared_picker, shared_frecency)
|
||||
}
|
||||
|
||||
fn wait_for<F: Fn() -> bool>(cond: F, timeout: Duration) -> bool {
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
if cond() {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
cond()
|
||||
}
|
||||
|
||||
fn seed(base: &Path) {
|
||||
fs::create_dir_all(base.join("src")).unwrap();
|
||||
fs::write(base.join("src/main.rs"), "fn main() {}\n").unwrap();
|
||||
fs::write(base.join("README.md"), "# seed\n").unwrap();
|
||||
}
|
||||
|
||||
type Collected = Arc<Mutex<Vec<WatchEvent>>>;
|
||||
|
||||
/// Subscribe with a collector callback; returns the shared event sink.
|
||||
fn watch_collect(picker: &SharedFilePicker, pattern: &str, options: WatchOptions) -> Collected {
|
||||
let collected: Collected = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = Arc::clone(&collected);
|
||||
picker
|
||||
.watch(pattern, options, move |_id, events| {
|
||||
sink.lock().extend_from_slice(events)
|
||||
})
|
||||
.expect("watch subscription failed");
|
||||
collected
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glob_subscription_receives_created_and_removed_events() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
|
||||
seed(&base);
|
||||
let (picker, _frecency) = make_watched_picker(&base);
|
||||
|
||||
let events: Arc<Mutex<Vec<WatchEvent>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let events_cb = Arc::clone(&events);
|
||||
let calls_cb = Arc::clone(&calls);
|
||||
let id = picker
|
||||
.watch("**/*.rs", WatchOptions::default(), move |_id, batch| {
|
||||
calls_cb.fetch_add(1, Ordering::SeqCst);
|
||||
events_cb.lock().extend_from_slice(batch);
|
||||
})
|
||||
.expect("subscribe glob");
|
||||
|
||||
let rs_file = base.join("src/new_module.rs");
|
||||
let ts_file = base.join("src/ignored_by_glob.ts");
|
||||
fs::write(&rs_file, "pub fn hi() {}\n").unwrap();
|
||||
fs::write(&ts_file, "export {};\n").unwrap();
|
||||
|
||||
assert!(
|
||||
wait_for(
|
||||
|| events.lock().iter().any(|e| e.path == rs_file),
|
||||
Duration::from_secs(10)
|
||||
),
|
||||
"did not receive event for created .rs file, got: {:?}",
|
||||
events.lock()
|
||||
);
|
||||
assert!(
|
||||
!events.lock().iter().any(|e| e.path == ts_file),
|
||||
".ts file must not match the *.rs glob"
|
||||
);
|
||||
|
||||
fs::remove_file(&rs_file).unwrap();
|
||||
assert!(
|
||||
wait_for(
|
||||
|| events
|
||||
.lock()
|
||||
.iter()
|
||||
.any(|e| e.path == rs_file && e.kind == WatchEventKind::Removed),
|
||||
Duration::from_secs(10)
|
||||
),
|
||||
"did not receive Removed event, got: {:?}",
|
||||
events.lock()
|
||||
);
|
||||
|
||||
// batching: each debounce window is one callback invocation, so the call
|
||||
// count must be well below the delivered event count + noise ceiling
|
||||
assert!(calls.load(Ordering::SeqCst) <= events.lock().len() + 2);
|
||||
|
||||
assert!(picker.unwatch(id));
|
||||
let count_after = events.lock().len();
|
||||
fs::write(base.join("src/after_unsub.rs"), "\n").unwrap();
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
assert_eq!(
|
||||
events.lock().len(),
|
||||
count_after,
|
||||
"no events after unsubscribe"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watch_events_reflect_applied_file_transitions() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
|
||||
let removed_path = base.join("removed.txt");
|
||||
let created_path = base.join("created.txt");
|
||||
let replaced_path = base.join("replaced.txt");
|
||||
fs::write(&removed_path, "remove me").unwrap();
|
||||
fs::write(&replaced_path, "before").unwrap();
|
||||
|
||||
let (picker, _frecency) = make_watched_picker(&base);
|
||||
let removed = watch_collect(
|
||||
&picker,
|
||||
removed_path.to_str().unwrap(),
|
||||
WatchOptions::default(),
|
||||
);
|
||||
let created = watch_collect(
|
||||
&picker,
|
||||
created_path.to_str().unwrap(),
|
||||
WatchOptions::default(),
|
||||
);
|
||||
let replaced = watch_collect(
|
||||
&picker,
|
||||
replaced_path.to_str().unwrap(),
|
||||
WatchOptions::default(),
|
||||
);
|
||||
|
||||
fs::remove_file(&removed_path).unwrap();
|
||||
assert!(
|
||||
wait_for(|| !removed.lock().is_empty(), Duration::from_secs(10)),
|
||||
"remove event was not delivered"
|
||||
);
|
||||
|
||||
fs::write(&created_path, "created").unwrap();
|
||||
assert!(
|
||||
wait_for(|| !created.lock().is_empty(), Duration::from_secs(10)),
|
||||
"create event was not delivered"
|
||||
);
|
||||
|
||||
fs::remove_file(&replaced_path).unwrap();
|
||||
fs::write(&replaced_path, "after").unwrap();
|
||||
assert!(
|
||||
wait_for(|| !replaced.lock().is_empty(), Duration::from_secs(10)),
|
||||
"replacement event was not delivered"
|
||||
);
|
||||
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
let removed = removed.lock();
|
||||
assert_eq!(removed.len(), 1, "unexpected remove events: {removed:?}");
|
||||
assert_eq!(removed[0].path, removed_path);
|
||||
assert_eq!(removed[0].kind, WatchEventKind::Removed);
|
||||
|
||||
let created = created.lock();
|
||||
assert_eq!(created.len(), 1, "unexpected create events: {created:?}");
|
||||
assert_eq!(created[0].path, created_path);
|
||||
assert_eq!(created[0].kind, WatchEventKind::Created);
|
||||
|
||||
let replaced = replaced.lock();
|
||||
assert_eq!(
|
||||
replaced.len(),
|
||||
1,
|
||||
"replacement must be one event: {replaced:?}"
|
||||
);
|
||||
assert_eq!(replaced[0].path, replaced_path);
|
||||
assert_eq!(replaced[0].kind, WatchEventKind::Modified);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_directory_delivers_removed_event_per_file() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
|
||||
seed(&base);
|
||||
let dir = base.join("doomed");
|
||||
fs::create_dir_all(dir.join("nested")).unwrap();
|
||||
let files = [dir.join("a.rs"), dir.join("b.txt"), dir.join("nested/c.rs")];
|
||||
for f in &files {
|
||||
fs::write(f, "content\n").unwrap();
|
||||
}
|
||||
|
||||
let (picker, _frecency) = make_watched_picker(&base);
|
||||
let events = watch_collect(&picker, "", WatchOptions::default());
|
||||
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
|
||||
assert!(
|
||||
wait_for(
|
||||
|| {
|
||||
let got = events.lock();
|
||||
files.iter().all(|f| {
|
||||
got.iter()
|
||||
.any(|e| e.path == *f && e.kind == WatchEventKind::Removed)
|
||||
})
|
||||
},
|
||||
Duration::from_secs(10)
|
||||
),
|
||||
"expected Removed for every file in the removed dir, got: {:?}",
|
||||
events.lock()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_out_directory_delivers_removed_event_per_file() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let trash = TempDir::new().unwrap();
|
||||
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
|
||||
seed(&base);
|
||||
let dir = base.join("doomed");
|
||||
fs::create_dir_all(dir.join("nested")).unwrap();
|
||||
let files = [dir.join("a.rs"), dir.join("b.txt"), dir.join("nested/c.rs")];
|
||||
for f in &files {
|
||||
fs::write(f, "content\n").unwrap();
|
||||
}
|
||||
|
||||
let (picker, _frecency) = make_watched_picker(&base);
|
||||
let events = watch_collect(&picker, "", WatchOptions::default());
|
||||
|
||||
// mimics `mv dir elsewhere` / Finder trash: one rename event on the dir,
|
||||
// no per-file remove events from the OS
|
||||
fs::rename(&dir, trash.path().join("doomed")).unwrap();
|
||||
|
||||
assert!(
|
||||
wait_for(
|
||||
|| {
|
||||
let got = events.lock();
|
||||
files.iter().all(|f| {
|
||||
got.iter()
|
||||
.any(|e| e.path == *f && e.kind == WatchEventKind::Removed)
|
||||
})
|
||||
},
|
||||
Duration::from_secs(10)
|
||||
),
|
||||
"expected Removed for every file in the moved-out dir, got: {:?}",
|
||||
events.lock()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pattern_watches_the_whole_tree() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
|
||||
seed(&base);
|
||||
let (picker, _frecency) = make_watched_picker(&base);
|
||||
|
||||
let events = watch_collect(
|
||||
&picker,
|
||||
"",
|
||||
WatchOptions {
|
||||
ignore: vec!["*.log".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let rs_file = base.join("src/anywhere.rs");
|
||||
let txt_file = base.join("notes.txt");
|
||||
let log_file = base.join("noise.log");
|
||||
fs::write(&rs_file, "\n").unwrap();
|
||||
fs::write(&txt_file, "\n").unwrap();
|
||||
fs::write(&log_file, "\n").unwrap();
|
||||
|
||||
assert!(
|
||||
wait_for(
|
||||
|| {
|
||||
let got = events.lock();
|
||||
got.iter().any(|e| e.path == rs_file) && got.iter().any(|e| e.path == txt_file)
|
||||
},
|
||||
Duration::from_secs(10)
|
||||
),
|
||||
"watch-all did not receive events for both files, got: {:?}",
|
||||
events.lock()
|
||||
);
|
||||
// the ignore option still filters within a watch-all subscription
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
assert!(
|
||||
!events.lock().iter().any(|e| e.path == log_file),
|
||||
"*.log must be filtered by the ignore option"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_out_of_tree_paths_are_rejected() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
|
||||
seed(&base);
|
||||
|
||||
let outside = TempDir::new().unwrap();
|
||||
let outside_file = fff_search::path_utils::canonicalize(outside.path())
|
||||
.unwrap()
|
||||
.join("config.txt");
|
||||
fs::write(&outside_file, "v1").unwrap();
|
||||
|
||||
let (picker, _frecency) = make_watched_picker(&base);
|
||||
|
||||
assert!(
|
||||
picker
|
||||
.watch(
|
||||
outside_file.to_str().unwrap(),
|
||||
WatchOptions::default(),
|
||||
|_, _| {}
|
||||
)
|
||||
.is_err(),
|
||||
"exact paths outside the indexed tree must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitignored_files_are_never_delivered() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
|
||||
seed(&base);
|
||||
fs::create_dir_all(base.join("dist")).unwrap();
|
||||
fs::write(base.join(".gitignore"), "dist/\n*.log\n").unwrap();
|
||||
git2::Repository::init(&base).unwrap();
|
||||
let (picker, _frecency) = make_watched_picker(&base);
|
||||
|
||||
let events = watch_collect(&picker, "", WatchOptions::default());
|
||||
|
||||
fs::write(base.join("dist/bundle.js"), "js").unwrap();
|
||||
fs::write(base.join("noise.log"), "log").unwrap();
|
||||
fs::write(base.join("visible.txt"), "txt").unwrap();
|
||||
|
||||
assert!(
|
||||
wait_for(
|
||||
|| events
|
||||
.lock()
|
||||
.iter()
|
||||
.any(|e| e.path == base.join("visible.txt")),
|
||||
Duration::from_secs(10)
|
||||
),
|
||||
"non-ignored file must be delivered, got {:?}",
|
||||
events.lock()
|
||||
);
|
||||
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
let collected = events.lock();
|
||||
assert!(
|
||||
!collected
|
||||
.iter()
|
||||
.any(|e| e.path == base.join("dist/bundle.js")),
|
||||
"gitignored directory content must not be delivered: {:?}",
|
||||
collected
|
||||
);
|
||||
assert!(
|
||||
!collected.iter().any(|e| e.path == base.join("noise.log")),
|
||||
"gitignored file must not be delivered: {:?}",
|
||||
collected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_subscription_with_ignore_option() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
|
||||
seed(&base);
|
||||
fs::create_dir_all(base.join("src/vendor")).unwrap();
|
||||
let (picker, _frecency) = make_watched_picker(&base);
|
||||
|
||||
// parcel-style: subscribe to a directory subtree with excludes
|
||||
let got = watch_collect(
|
||||
&picker,
|
||||
"src",
|
||||
WatchOptions {
|
||||
ignore: vec!["*.map".to_string(), "src/vendor".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
fs::write(base.join("src/feature.rs"), "pub fn f() {}\n").unwrap();
|
||||
fs::write(base.join("src/feature.js.map"), "{}\n").unwrap();
|
||||
fs::write(base.join("src/vendor/lib.js"), "x\n").unwrap();
|
||||
fs::write(base.join("outside_dir.txt"), "not in src\n").unwrap();
|
||||
|
||||
assert!(
|
||||
wait_for(
|
||||
|| got
|
||||
.lock()
|
||||
.iter()
|
||||
.any(|e| e.path == base.join("src/feature.rs")),
|
||||
Duration::from_secs(10)
|
||||
),
|
||||
"dir subscriber must see files in its subtree, got {:?}",
|
||||
got.lock()
|
||||
);
|
||||
let got = got.lock();
|
||||
assert!(
|
||||
!got.iter()
|
||||
.any(|e| e.path == base.join("src/feature.js.map")),
|
||||
"ignore glob leaked: {got:?}"
|
||||
);
|
||||
assert!(
|
||||
!got.iter().any(|e| e.path == base.join("src/vendor/lib.js")),
|
||||
"ignore prefix leaked: {got:?}"
|
||||
);
|
||||
assert!(
|
||||
!got.iter().any(|e| e.path == base.join("outside_dir.txt")),
|
||||
"event outside the subscribed dir leaked: {got:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_watches_stops_future_deliveries() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
|
||||
seed(&base);
|
||||
let (picker, _frecency) = make_watched_picker(&base);
|
||||
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let calls_cb = Arc::clone(&calls);
|
||||
picker
|
||||
.watch("**/*.txt", WatchOptions::default(), move |_, _| {
|
||||
calls_cb.fetch_add(1, Ordering::SeqCst);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
fs::write(base.join("one.txt"), "1\n").unwrap();
|
||||
assert!(
|
||||
wait_for(|| calls.load(Ordering::SeqCst) > 0, Duration::from_secs(10)),
|
||||
"callback never fired before shutdown"
|
||||
);
|
||||
|
||||
picker.shutdown_watches();
|
||||
let after = calls.load(Ordering::SeqCst);
|
||||
|
||||
fs::write(base.join("two.txt"), "2\n").unwrap();
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
assert_eq!(
|
||||
calls.load(Ordering::SeqCst),
|
||||
after,
|
||||
"callback fired after shutdown_watches returned"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_canonical_dir_pattern_resolves_into_the_tree() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
|
||||
seed(&base);
|
||||
let (picker, _) = make_watched_picker(&base);
|
||||
|
||||
// tmp.path() is the non-canonical spelling (e.g. /var/... symlinked to
|
||||
// /private/var/... on macOS, 8.3 short names on Windows); the watch must
|
||||
// canonicalize instead of rejecting it
|
||||
let events = watch_collect(
|
||||
&picker,
|
||||
tmp.path().to_str().unwrap(),
|
||||
WatchOptions::default(),
|
||||
);
|
||||
|
||||
fs::write(base.join("via-alias.txt"), "x\n").unwrap();
|
||||
assert!(
|
||||
wait_for(
|
||||
|| events
|
||||
.lock()
|
||||
.iter()
|
||||
.any(|e| e.path == base.join("via-alias.txt")),
|
||||
Duration::from_secs(10)
|
||||
),
|
||||
"non-canonical base-dir pattern must receive events, got {:?}",
|
||||
events.lock()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_patterns_are_rejected() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
|
||||
seed(&base);
|
||||
let (picker, _frecency) = make_watched_picker(&base);
|
||||
|
||||
assert!(
|
||||
picker
|
||||
.watch(
|
||||
"/somewhere/else/**/*.rs",
|
||||
WatchOptions::default(),
|
||||
|_, _| {}
|
||||
)
|
||||
.is_err(),
|
||||
"absolute glob outside base must be rejected"
|
||||
);
|
||||
|
||||
// relative exact path resolves against base
|
||||
let got = watch_collect(&picker, "README.md", WatchOptions::default());
|
||||
fs::write(base.join("README.md"), "# updated\n").unwrap();
|
||||
assert!(
|
||||
wait_for(
|
||||
|| got.lock().iter().any(|e| e.path == base.join("README.md")),
|
||||
Duration::from_secs(10)
|
||||
),
|
||||
"got {:?}",
|
||||
got.lock()
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,12 @@ name = "fff-grep"
|
||||
description = "File grepping logic for fff"
|
||||
license = "MIT"
|
||||
authors = ["Dmitriy Kovalenko <dmtr.kovalenko@outlok.com>"]
|
||||
version = "0.9.6"
|
||||
version = "0.10.5"
|
||||
edition = "2024"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
bstr = { version = "1.6.2", default-features = false, features = ["std"] }
|
||||
memchr = "2.6.3"
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
[package]
|
||||
name = "fff-mcp"
|
||||
version = "0.9.6"
|
||||
version = "0.10.5"
|
||||
edition = "2024"
|
||||
description = "MCP server for FFF file finder - drop-in replacement for AI code assistant search tools"
|
||||
license = "MIT"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "fff-mcp"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = ["zlob"]
|
||||
zlob = ["fff/zlob"]
|
||||
# Pure-Rust walker by default; opt into zlob explicitly (needs Zig).
|
||||
default = ["ripgrep"]
|
||||
ripgrep = ["fff/ripgrep", "fff-query-parser/ripgrep"]
|
||||
zlob = ["fff/zlob", "fff-query-parser/zlob"]
|
||||
|
||||
[dependencies]
|
||||
fff = { package = "fff-search", path = "../fff-core", default-features = false , version = "0.9.6" }
|
||||
fff-query-parser = { path = "../fff-query-parser", default-features = false , version = "0.9.6" }
|
||||
fff = { package = "fff-search", path = "../fff-core", default-features = false , version = "0.10.5", features = ["definitions"] }
|
||||
fff-query-parser = { path = "../fff-query-parser", default-features = false , version = "0.10.5" }
|
||||
mimalloc = { workspace = true }
|
||||
rmcp = { version = "1.7.0", features = ["server", "transport-io"] }
|
||||
schemars = "1.0"
|
||||
@@ -25,3 +30,13 @@ tokio = { version = "1", features = ["full"] }
|
||||
tracing = { workspace = true }
|
||||
git2 = { workspace = true }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.60", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.8"
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
use rmcp::model::{ClientRequest, ErrorCode, ErrorData, JsonRpcMessage};
|
||||
use rmcp::service::{RoleServer, RxJsonRpcMessage, TxJsonRpcMessage};
|
||||
use rmcp::transport::Transport;
|
||||
|
||||
/// rmcp aborts startup on any pre-`initialize` request except `ping`, which kills the process
|
||||
/// before the client can retry. Answer such probes with `-32601` and keep waiting for the legacy
|
||||
/// handshake, so clients speaking the stateless spec (`server/discover`, SEP-1442) can fall back.
|
||||
/// @see https://github.com/dmtrKovalenko/fff/issues/797
|
||||
pub(crate) struct ProbeTolerantTransport<T> {
|
||||
inner: T,
|
||||
initialized: bool,
|
||||
}
|
||||
|
||||
impl<T> ProbeTolerantTransport<T> {
|
||||
pub(crate) fn new(inner: T) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
initialized: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Transport<RoleServer> for ProbeTolerantTransport<T>
|
||||
where
|
||||
T: Transport<RoleServer>,
|
||||
{
|
||||
type Error = T::Error;
|
||||
|
||||
fn send(
|
||||
&mut self,
|
||||
item: TxJsonRpcMessage<RoleServer>,
|
||||
) -> impl Future<Output = Result<(), Self::Error>> + Send + 'static {
|
||||
self.inner.send(item)
|
||||
}
|
||||
|
||||
async fn receive(&mut self) -> Option<RxJsonRpcMessage<RoleServer>> {
|
||||
loop {
|
||||
let msg = self.inner.receive().await?;
|
||||
if self.initialized {
|
||||
return Some(msg);
|
||||
}
|
||||
|
||||
let JsonRpcMessage::Request(request) = &msg else {
|
||||
return Some(msg);
|
||||
};
|
||||
|
||||
match &request.request {
|
||||
ClientRequest::InitializeRequest(_) => {
|
||||
self.initialized = true;
|
||||
return Some(msg);
|
||||
}
|
||||
// rmcp answers pre-init pings itself
|
||||
ClientRequest::PingRequest(_) => return Some(msg),
|
||||
unsupported => {
|
||||
let error = unsupported_probe_error(unsupported.method());
|
||||
tracing::warn!(
|
||||
method = unsupported.method(),
|
||||
"rejecting pre-initialize request, awaiting initialize"
|
||||
);
|
||||
let id = request.id.clone();
|
||||
self.inner
|
||||
.send(JsonRpcMessage::error(error, Some(id)))
|
||||
.await
|
||||
.ok()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn close(&mut self) -> impl Future<Output = Result<(), Self::Error>> + Send {
|
||||
self.inner.close()
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_probe_error(method: &str) -> ErrorData {
|
||||
ErrorData::new(
|
||||
ErrorCode::METHOD_NOT_FOUND,
|
||||
format!(
|
||||
"{method} is not supported before initialize; this server uses the initialize handshake"
|
||||
),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rmcp::model::{
|
||||
ClientCapabilities, ClientJsonRpcMessage, CustomRequest, Implementation, InitializeRequest,
|
||||
InitializeRequestParams, NumberOrString, ServerJsonRpcMessage,
|
||||
};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
#[derive(Default)]
|
||||
struct MockTransport {
|
||||
incoming: VecDeque<ClientJsonRpcMessage>,
|
||||
sent: Vec<ServerJsonRpcMessage>,
|
||||
}
|
||||
|
||||
impl Transport<RoleServer> for MockTransport {
|
||||
type Error = std::io::Error;
|
||||
|
||||
fn send(
|
||||
&mut self,
|
||||
item: ServerJsonRpcMessage,
|
||||
) -> impl Future<Output = Result<(), Self::Error>> + Send + 'static {
|
||||
self.sent.push(item);
|
||||
async { Ok(()) }
|
||||
}
|
||||
|
||||
fn receive(&mut self) -> impl Future<Output = Option<ClientJsonRpcMessage>> + Send {
|
||||
let next = self.incoming.pop_front();
|
||||
async move { next }
|
||||
}
|
||||
|
||||
async fn close(&mut self) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn custom_request(id: i64, method: &str) -> ClientJsonRpcMessage {
|
||||
ClientJsonRpcMessage::request(
|
||||
ClientRequest::CustomRequest(CustomRequest::new(method, Some(serde_json::json!({})))),
|
||||
NumberOrString::Number(id),
|
||||
)
|
||||
}
|
||||
|
||||
fn initialize_request(id: i64) -> ClientJsonRpcMessage {
|
||||
ClientJsonRpcMessage::request(
|
||||
ClientRequest::InitializeRequest(InitializeRequest::new(InitializeRequestParams::new(
|
||||
ClientCapabilities::default(),
|
||||
Implementation::new("probe", "1"),
|
||||
))),
|
||||
NumberOrString::Number(id),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_init_probe_is_rejected_and_initialize_still_arrives() {
|
||||
let inner = MockTransport {
|
||||
incoming: VecDeque::from(vec![
|
||||
custom_request(1, "server/discover"),
|
||||
initialize_request(2),
|
||||
]),
|
||||
sent: Vec::new(),
|
||||
};
|
||||
let mut transport = ProbeTolerantTransport::new(inner);
|
||||
|
||||
let received = transport.receive().await.expect("initialize forwarded");
|
||||
assert!(matches!(
|
||||
received,
|
||||
JsonRpcMessage::Request(req)
|
||||
if matches!(req.request, ClientRequest::InitializeRequest(_))
|
||||
));
|
||||
|
||||
let JsonRpcMessage::Error(err) = &transport.inner.sent[0] else {
|
||||
panic!("expected an error response for the probe");
|
||||
};
|
||||
assert_eq!(err.id, Some(NumberOrString::Number(1)));
|
||||
assert_eq!(err.error.code, ErrorCode::METHOD_NOT_FOUND);
|
||||
assert!(err.error.message.contains("server/discover"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_init_requests_pass_through_untouched() {
|
||||
let inner = MockTransport {
|
||||
incoming: VecDeque::from(vec![
|
||||
initialize_request(1),
|
||||
custom_request(2, "server/discover"),
|
||||
]),
|
||||
sent: Vec::new(),
|
||||
};
|
||||
let mut transport = ProbeTolerantTransport::new(inner);
|
||||
|
||||
transport.receive().await.expect("initialize forwarded");
|
||||
let received = transport.receive().await.expect("custom request forwarded");
|
||||
assert!(matches!(
|
||||
received,
|
||||
JsonRpcMessage::Request(req)
|
||||
if matches!(req.request, ClientRequest::CustomRequest(_))
|
||||
));
|
||||
assert!(transport.inner.sent.is_empty());
|
||||
}
|
||||
}
|
||||
+137
-10
@@ -1,16 +1,22 @@
|
||||
mod cursor;
|
||||
mod handshake;
|
||||
mod healthcheck;
|
||||
mod output;
|
||||
mod parent;
|
||||
mod server;
|
||||
mod update_check;
|
||||
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use clap::Parser;
|
||||
use fff::file_picker::FilePicker;
|
||||
use fff::frecency::FrecencyTracker;
|
||||
use fff::{FFFMode, SharedFilePicker, SharedFrecency};
|
||||
use git2::Repository;
|
||||
use handshake::ProbeTolerantTransport;
|
||||
use mimalloc::MiMalloc;
|
||||
use rmcp::{ServiceExt, transport::stdio};
|
||||
use rmcp::ServiceExt;
|
||||
use rmcp::transport::async_rw::AsyncRwTransport;
|
||||
use server::FffServer;
|
||||
|
||||
#[global_allocator]
|
||||
@@ -92,7 +98,7 @@ pub const MCP_INSTRUCTIONS: &str = concat!(
|
||||
" !generated/ - exclude generated code",
|
||||
);
|
||||
|
||||
/// FFF MCP Server -- a high performance & accuracy file finder for AI code assistants.
|
||||
/// FFF MCP Server - a high performance & accuracy file finder for AI code assistants.
|
||||
#[derive(Parser)]
|
||||
#[command(name = "fff-mcp", version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("FFF_GIT_HASH"), ")"))]
|
||||
pub(crate) struct Args {
|
||||
@@ -131,6 +137,7 @@ pub(crate) struct Args {
|
||||
|
||||
/// Disable the content index built after the initial scan.
|
||||
/// This makes grep calls slower but consumes less RAM (recommended to not turn off)
|
||||
#[arg(long = "no-content-indexing")]
|
||||
no_content_indexing: bool,
|
||||
|
||||
/// Explicitly enable content indexing even when `--no-warmup` is set.
|
||||
@@ -149,9 +156,47 @@ pub(crate) struct Args {
|
||||
#[arg(long = "max-cached-files", env = "FFF_MAX_CACHED_FILES")]
|
||||
max_cached_files: Option<usize>,
|
||||
|
||||
/// Follow symlinks during scan and watcher walks. Off by default —
|
||||
/// enabling on cyclic symlink layouts can wedge the watcher.
|
||||
#[arg(long = "follow-symlinks")]
|
||||
follow_symlinks: bool,
|
||||
|
||||
/// Allow indexing the user's home directory. FFF refuses to init in `~`
|
||||
/// unless this is set. Also settable via FFF_ENABLE_HOME_SCAN=1.
|
||||
#[arg(
|
||||
long = "enable-home-scan",
|
||||
env = "FFF_ENABLE_HOME_SCAN",
|
||||
num_args = 0..=1,
|
||||
default_missing_value = "true",
|
||||
default_value_t = false,
|
||||
value_parser = clap::builder::BoolishValueParser::new()
|
||||
)]
|
||||
enable_home_scan: bool,
|
||||
|
||||
/// Allow indexing the filesystem root, off by default for the same reason.
|
||||
/// Also settable via FFF_ENABLE_ROOT_SCAN=1.
|
||||
#[arg(
|
||||
long = "enable-root-scan",
|
||||
env = "FFF_ENABLE_ROOT_SCAN",
|
||||
num_args = 0..=1,
|
||||
default_missing_value = "true",
|
||||
default_value_t = false,
|
||||
value_parser = clap::builder::BoolishValueParser::new()
|
||||
)]
|
||||
enable_root_scan: bool,
|
||||
|
||||
/// Run a health check and print diagnostic information, then exit.
|
||||
#[arg(long = "healthcheck")]
|
||||
pub(crate) healthcheck: bool,
|
||||
|
||||
/// Timeout of inactivity after which fff mcp will be exited. Even if the parent process
|
||||
/// is alive we don't want to occupy resources on index and file watches if fff is unused
|
||||
#[arg(
|
||||
long = "idle-timeout-secs",
|
||||
env = "FFF_MCP_IDLE_TIMEOUT_SECS",
|
||||
default_value_t = 60 * 60
|
||||
)]
|
||||
idle_timeout_secs: u64,
|
||||
}
|
||||
|
||||
/// Resolve default paths for the log file.
|
||||
@@ -252,7 +297,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Initialize file picker (spawns background scan + watcher)
|
||||
FilePicker::new_with_shared_state(
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
shared_frecency,
|
||||
fff::FilePickerOptions {
|
||||
base_path,
|
||||
enable_mmap_cache: !args.no_warmup,
|
||||
@@ -262,8 +307,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
cache_budget: args
|
||||
.max_cached_files
|
||||
.map(fff::ContentCacheBudget::new_for_repo),
|
||||
follow_symlinks: false,
|
||||
..Default::default()
|
||||
follow_symlinks: args.follow_symlinks,
|
||||
enable_home_dir_scanning: args.enable_home_scan,
|
||||
enable_fs_root_scanning: args.enable_root_scan,
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to init file picker: {}", e))?;
|
||||
@@ -273,7 +319,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
// Create and start the MCP server
|
||||
let server = FffServer::new(shared_picker.clone(), shared_frecency.clone());
|
||||
let server = FffServer::new(shared_picker.clone());
|
||||
let last_activity = server.last_activity();
|
||||
let idle_timeout_secs = args.idle_timeout_secs;
|
||||
|
||||
// Wait for initial scan in background — don't block server startup
|
||||
let picker_clone_for_scan = shared_picker.clone();
|
||||
@@ -294,10 +342,72 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
});
|
||||
|
||||
let service = server
|
||||
.serve(stdio())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start MCP server: {}", e))?;
|
||||
const STARTUP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
let stdio = AsyncRwTransport::new_server(tokio::io::stdin(), tokio::io::stdout());
|
||||
let transport = ProbeTolerantTransport::new(stdio);
|
||||
let service = match tokio::time::timeout(STARTUP_TIMEOUT, server.serve(transport)).await {
|
||||
Ok(res) => res.map_err(|e| format!("Failed to start MCP server: {}", e))?,
|
||||
Err(_) => {
|
||||
return Err("MCP initialize handshake did not complete within 60s".into());
|
||||
}
|
||||
};
|
||||
|
||||
let parent_watcher = parent::ParentWatcher::new();
|
||||
match &parent_watcher {
|
||||
Some(watcher) => tracing::info!(
|
||||
"Watching parent process (pid {}); will exit when it dies",
|
||||
watcher.parent_pid()
|
||||
),
|
||||
None => tracing::warn!(
|
||||
"Parent process liveness detection unavailable; idle timeout will exit unconditionally"
|
||||
),
|
||||
}
|
||||
|
||||
if idle_timeout_secs > 0 || parent_watcher.is_some() {
|
||||
last_activity.store(
|
||||
SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0),
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
|
||||
let last_activity_for_watchdog = last_activity.clone();
|
||||
tokio::spawn(async move {
|
||||
let tick = watchdog_interval();
|
||||
loop {
|
||||
tokio::time::sleep(tick).await;
|
||||
|
||||
if let Some(ref watcher) = parent_watcher {
|
||||
if !watcher.parent_alive() {
|
||||
tracing::info!(
|
||||
"Parent process (pid {}) exited, shutting down",
|
||||
watcher.parent_pid()
|
||||
);
|
||||
flush_logs_and_exit().await;
|
||||
}
|
||||
// Parent is alive: it owns our lifecycle, never exit on idle
|
||||
// Clients like Codex do not restart MCP servers @see #703
|
||||
continue;
|
||||
}
|
||||
|
||||
if idle_timeout_secs == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
let last = last_activity_for_watchdog.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if now.saturating_sub(last) >= idle_timeout_secs {
|
||||
tracing::info!(?idle_timeout_secs, "Exiting due to inactivity",);
|
||||
flush_logs_and_exit().await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let picker_for_shutdown = shared_picker.clone();
|
||||
tokio::spawn(async move {
|
||||
@@ -320,3 +430,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Tracing appender is non blocking, to get full log give it some time before hard exit
|
||||
async fn flush_logs_and_exit() -> ! {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
fn watchdog_interval() -> Duration {
|
||||
if cfg!(debug_assertions)
|
||||
&& let Some(milliseconds) = std::env::var("FFF_MCP_TEST_WATCHDOG_INTERVAL_MS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse().ok())
|
||||
{
|
||||
return Duration::from_millis(milliseconds);
|
||||
}
|
||||
Duration::from_secs(60)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
#[cfg(unix)]
|
||||
mod imp {
|
||||
pub struct ParentWatcher {
|
||||
ppid: u32,
|
||||
}
|
||||
|
||||
impl ParentWatcher {
|
||||
pub fn new() -> Option<Self> {
|
||||
let ppid = std::os::unix::process::parent_id();
|
||||
// ppid <= 1 means we were spawned by init and can't detect death
|
||||
(ppid > 1).then_some(Self { ppid })
|
||||
}
|
||||
|
||||
pub fn parent_pid(&self) -> u32 {
|
||||
self.ppid
|
||||
}
|
||||
|
||||
// When the parent dies the kernel reparents us, so getppid() changes.
|
||||
// Race-free and immune to PID reuse, unlike kill(ppid, 0).
|
||||
pub fn parent_alive(&self) -> bool {
|
||||
std::os::unix::process::parent_id() == self.ppid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod imp {
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE, WAIT_TIMEOUT};
|
||||
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, PROCESSENTRY32, Process32First, Process32Next, TH32CS_SNAPPROCESS,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
GetCurrentProcessId, OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject,
|
||||
};
|
||||
|
||||
pub struct ParentWatcher {
|
||||
handle: HANDLE,
|
||||
ppid: u32,
|
||||
}
|
||||
|
||||
// HANDLE is a raw pointer; it is only ever used via WaitForSingleObject
|
||||
// which is thread-safe, so moving/sharing the watcher across threads is fine.
|
||||
unsafe impl Send for ParentWatcher {}
|
||||
unsafe impl Sync for ParentWatcher {}
|
||||
|
||||
impl ParentWatcher {
|
||||
pub fn new() -> Option<Self> {
|
||||
let ppid = parent_pid_of_current()?;
|
||||
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, ppid) };
|
||||
if handle.is_null() {
|
||||
return None;
|
||||
}
|
||||
// Holding the handle pins the PID, preventing reuse for the process lifetime
|
||||
Some(Self { handle, ppid })
|
||||
}
|
||||
|
||||
pub fn parent_pid(&self) -> u32 {
|
||||
self.ppid
|
||||
}
|
||||
|
||||
pub fn parent_alive(&self) -> bool {
|
||||
unsafe { WaitForSingleObject(self.handle, 0) == WAIT_TIMEOUT }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ParentWatcher {
|
||||
fn drop(&mut self) {
|
||||
unsafe { CloseHandle(self.handle) };
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_pid_of_current() -> Option<u32> {
|
||||
unsafe {
|
||||
let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
||||
if snapshot == INVALID_HANDLE_VALUE {
|
||||
return None;
|
||||
}
|
||||
let mut entry: PROCESSENTRY32 = std::mem::zeroed();
|
||||
entry.dwSize = std::mem::size_of::<PROCESSENTRY32>() as u32;
|
||||
let current = GetCurrentProcessId();
|
||||
let mut found = None;
|
||||
if Process32First(snapshot, &mut entry) != 0 {
|
||||
loop {
|
||||
if entry.th32ProcessID == current {
|
||||
found = Some(entry.th32ParentProcessID);
|
||||
break;
|
||||
}
|
||||
if Process32Next(snapshot, &mut entry) == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
CloseHandle(snapshot);
|
||||
found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use imp::ParentWatcher;
|
||||
@@ -1,29 +1,18 @@
|
||||
//! FFF MCP server — tool definitions and handlers.
|
||||
//!
|
||||
//! Uses the `rmcp` crate's `#[tool_router]` / `#[tool_handler]` macros
|
||||
//! for declarative tool registration. Each tool method directly calls
|
||||
//! `fff-core` APIs (no C FFI overhead).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::cursor::CursorStore;
|
||||
use crate::output::{GrepFormatter, OutputMode, file_suffix};
|
||||
use fff::grep::{GrepMode, GrepSearchOptions, has_regex_metacharacters};
|
||||
use fff::types::{FileItem, PaginationArgs};
|
||||
use fff::{FuzzySearchOptions, QueryParser, SharedFilePicker, SharedFrecency};
|
||||
use fff::{FuzzySearchOptions, QueryParser, SharedFilePicker};
|
||||
use fff_query_parser::AiGrepConfig;
|
||||
use rmcp::handler::server::wrapper::Parameters;
|
||||
use rmcp::model::*;
|
||||
use rmcp::{ServerHandler, schemars, tool, tool_handler, tool_router};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const SCAN_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
/// Normalize the caller-supplied `maxResults`.
|
||||
///
|
||||
/// `None`, `Some(0)`, and non-positive / non-finite values fall back to
|
||||
/// `default`. Issue #400 reported that grep returned 0 items for
|
||||
/// `maxResults: 0` while `find_files` returned the entire dataset; treating
|
||||
/// 0 as "use the default" makes both tools behave consistently.
|
||||
fn normalize_max_results(raw: Option<f64>, default: usize) -> usize {
|
||||
match raw {
|
||||
None => default,
|
||||
@@ -181,34 +170,64 @@ pub struct MultiGrepParams {
|
||||
#[derive(Clone)]
|
||||
pub struct FffServer {
|
||||
picker: SharedFilePicker,
|
||||
#[allow(dead_code)]
|
||||
frecency: SharedFrecency,
|
||||
cursor_store: Arc<Mutex<CursorStore>>,
|
||||
update_notice_sent: Arc<AtomicBool>,
|
||||
last_activity: Arc<AtomicU64>,
|
||||
scan_ready: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
impl FffServer {
|
||||
pub fn new(picker: SharedFilePicker, frecency: SharedFrecency) -> Self {
|
||||
pub fn new(picker: SharedFilePicker) -> Self {
|
||||
Self {
|
||||
picker,
|
||||
frecency,
|
||||
cursor_store: Arc::new(Mutex::new(CursorStore::new())),
|
||||
update_notice_sent: Arc::new(AtomicBool::new(false)),
|
||||
last_activity: Arc::new(AtomicU64::new(now_secs())),
|
||||
scan_ready: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn wait_for_scan(&self) {
|
||||
pub fn last_activity(&self) -> Arc<AtomicU64> {
|
||||
self.last_activity.clone()
|
||||
}
|
||||
|
||||
fn bump_activity(&self) {
|
||||
self.last_activity.store(now_secs(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn wait_for_scan(&self, timeout: std::time::Duration) -> Result<(), ErrorData> {
|
||||
if self.scan_ready.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
|
||||
loop {
|
||||
let guard = self.picker.read().ok();
|
||||
let is_scanning = guard
|
||||
let is_scanning = self
|
||||
.picker
|
||||
.read()
|
||||
.ok()
|
||||
.as_ref()
|
||||
.and_then(|g| g.as_ref())
|
||||
.map(|p| p.is_scan_active())
|
||||
.unwrap_or(true);
|
||||
|
||||
if !is_scanning {
|
||||
break;
|
||||
self.scan_ready.store(true, Ordering::Relaxed);
|
||||
return Ok(());
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return Err(ErrorData::internal_error(
|
||||
"Index is still building; retry shortly",
|
||||
None,
|
||||
));
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
@@ -401,12 +420,20 @@ impl FffServer {
|
||||
/// IMPORTANT: Keep queries SHORT — prefer 1-2 terms max.
|
||||
#[tool(
|
||||
name = "find_files",
|
||||
description = "Fuzzy file search by name. Searches FILE NAMES, not file contents. Use it when you need to find a file, not a definition. Use grep instead for searching code content (definitions, usage patterns). Supports fuzzy matching, path prefixes ('src/'), and glob constraints ('name **/src/*.{ts,tsx} !test/'). IMPORTANT: Keep queries SHORT — prefer 1-2 terms max. Multiple words are a waterfall (each narrows results), NOT OR. If unsure, start broad with 1 term and refine."
|
||||
description = "Fuzzy file search by name. Searches FILE NAMES, not file contents. Use it when you need to find a file, not a definition. Use grep instead for searching code content (definitions, usage patterns). Supports fuzzy matching, path prefixes ('src/'), and glob constraints ('name **/src/*.{ts,tsx} !test/'). IMPORTANT: Keep queries SHORT — prefer 1-2 terms max. Multiple words are a waterfall (each narrows results), NOT OR. If unsure, start broad with 1 term and refine.",
|
||||
annotations(
|
||||
read_only_hint = true,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
fn find_files(
|
||||
&self,
|
||||
Parameters(params): Parameters<FindFilesParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
self.bump_activity();
|
||||
self.wait_for_scan(SCAN_READY_TIMEOUT)?;
|
||||
|
||||
let max_results = normalize_max_results(params.max_results, 20);
|
||||
let query = ¶ms.query;
|
||||
|
||||
@@ -513,12 +540,20 @@ impl FffServer {
|
||||
/// Prefer plain text over regex. Filter files with constraints.
|
||||
#[tool(
|
||||
name = "grep",
|
||||
description = "Search file contents. Search for bare identifiers (e.g. 'InProgressQuote', 'ActorAuth'), NOT code syntax or regex. Filter files with constraints (e.g. '*.rs query', 'src/ query'). Use filename, directory (ending with /) or glob expressions to prefilter. See server instructions for constraint syntax and core rules."
|
||||
description = "Search file contents. Search for bare identifiers (e.g. 'InProgressQuote', 'ActorAuth'), NOT code syntax or regex. Filter files with constraints (e.g. '*.rs query', 'src/ query'). Use filename, directory (ending with /) or glob expressions to prefilter. See server instructions for constraint syntax and core rules.",
|
||||
annotations(
|
||||
read_only_hint = true,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
fn grep(
|
||||
&self,
|
||||
Parameters(params): Parameters<GrepParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
self.bump_activity();
|
||||
self.wait_for_scan(SCAN_READY_TIMEOUT)?;
|
||||
|
||||
let max_results = normalize_max_results(params.max_results, 20);
|
||||
let output_mode = OutputMode::new(params.output_mode.as_deref());
|
||||
|
||||
@@ -547,12 +582,20 @@ impl FffServer {
|
||||
/// Patterns are literal text — NEVER escape special characters.
|
||||
#[tool(
|
||||
name = "multi_grep",
|
||||
description = "Search file contents for lines matching ANY of multiple patterns (OR logic). IMPORTANT: This returns files where ANY query matches, NOT all patterns. Patterns are literal text — NEVER escape special characters (no \\( \\) \\. etc). Faster than regex alternation for literal text. See server instructions for constraint syntax."
|
||||
description = "Search file contents for lines matching ANY of multiple patterns (OR logic). IMPORTANT: This returns files where ANY query matches, NOT all patterns. Patterns are literal text — NEVER escape special characters (no \\( \\) \\. etc). Faster than regex alternation for literal text. See server instructions for constraint syntax.",
|
||||
annotations(
|
||||
read_only_hint = true,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
fn multi_grep(
|
||||
&self,
|
||||
Parameters(params): Parameters<MultiGrepParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
self.bump_activity();
|
||||
self.wait_for_scan(SCAN_READY_TIMEOUT)?;
|
||||
|
||||
let mut result = self.multi_grep_inner(params)?;
|
||||
self.maybe_append_update_notice(&mut result);
|
||||
Ok(result)
|
||||
@@ -584,61 +627,12 @@ impl FffServer {
|
||||
.ok_or_else(|| ErrorData::internal_error("File picker not initialized", None))?;
|
||||
let patterns_refs: Vec<&str> = params.patterns.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let parser = fff_query_parser::QueryParser::new(fff_query_parser::AiGrepConfig);
|
||||
let parsed_constraints = parser.parse(constraint_query);
|
||||
let constraints = parsed_constraints.constraints.as_slice();
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let constraints = parser.parse_constraints(constraint_query);
|
||||
|
||||
let result = picker.multi_grep(&patterns_refs, constraints, &options);
|
||||
let result = picker.multi_grep(&patterns_refs, &constraints, &options);
|
||||
let file_refs: Vec<&FileItem> = result.files.to_vec();
|
||||
|
||||
if result.matches.is_empty() && file_offset == 0 {
|
||||
// Fallback: try individual patterns with plain grep
|
||||
let (fallback_options, _) =
|
||||
make_grep_options(output_mode, GrepMode::PlainText, 0, context);
|
||||
|
||||
let fallback_options = GrepSearchOptions {
|
||||
time_budget_ms: 3000,
|
||||
before_context: 0,
|
||||
..fallback_options
|
||||
};
|
||||
|
||||
for pat in ¶ms.patterns {
|
||||
let full_query: Cow<str> = if !constraint_query.is_empty() {
|
||||
Cow::Owned(format!("{} {}", constraint_query, pat))
|
||||
} else {
|
||||
Cow::Borrowed(pat)
|
||||
};
|
||||
|
||||
let parsed = parser.parse(&full_query);
|
||||
let fb_result = picker.grep(&parsed, &fallback_options);
|
||||
|
||||
if !fb_result.matches.is_empty() {
|
||||
let fb_file_refs: Vec<&FileItem> = fb_result.files.to_vec();
|
||||
let mut cs = self.lock_cursors()?;
|
||||
let text = &GrepFormatter {
|
||||
matches: &fb_result.matches,
|
||||
files: &fb_file_refs,
|
||||
total_matched: fb_result.matches.len(),
|
||||
next_file_offset: fb_result.next_file_offset,
|
||||
output_mode,
|
||||
max_results,
|
||||
show_context: false,
|
||||
auto_expand_defs: auto_expand,
|
||||
picker,
|
||||
}
|
||||
.format(&mut cs);
|
||||
return Ok(CallToolResult::success(vec![Content::text(format!(
|
||||
"0 multi-pattern matches. Plain grep fallback for \"{}\":\n{}",
|
||||
pat, text
|
||||
))]));
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(CallToolResult::success(vec![Content::text(
|
||||
"0 matches.".to_string(),
|
||||
)]));
|
||||
}
|
||||
|
||||
if result.matches.is_empty() {
|
||||
return Ok(CallToolResult::success(vec![Content::text(
|
||||
"0 matches.".to_string(),
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
//! Background update checker — compares the embedded build hash against
|
||||
//! the latest GitHub release tag to surface upgrade notices in MCP instructions.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const REPO: &str = "dmtrKovalenko/fff.nvim";
|
||||
const BUILD_HASH: &str = env!("FFF_GIT_HASH");
|
||||
const REPO: &str = "dmtrKovalenko/fff";
|
||||
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Holds the result of the update check (empty string = up to date or check failed).
|
||||
static UPDATE_NOTICE: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Returns the update notice if the check has completed, empty string otherwise.
|
||||
pub fn get_update_notice() -> &'static str {
|
||||
UPDATE_NOTICE.get().map(|s| s.as_str()).unwrap_or("")
|
||||
}
|
||||
|
||||
/// Kick off the update check in a background thread so it never blocks the server.
|
||||
pub fn spawn_update_check() {
|
||||
std::thread::spawn(|| {
|
||||
let notice = check_latest_release();
|
||||
@@ -22,34 +16,28 @@ pub fn spawn_update_check() {
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch the latest release tag from GitHub and compare against the build hash.
|
||||
fn check_latest_release() -> String {
|
||||
match fetch_latest_tag() {
|
||||
Ok(tag) => compare_versions(BUILD_HASH, &tag),
|
||||
match fetch_latest_stable_tag() {
|
||||
Ok(tag) => compare_versions(CURRENT_VERSION, &tag),
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare a build hash against a release tag.
|
||||
/// Returns an update notice string, or empty if up-to-date.
|
||||
fn compare_versions(build_hash: &str, release_tag: &str) -> String {
|
||||
fn compare_versions(current_version: &str, release_tag: &str) -> String {
|
||||
let tag = release_tag.trim();
|
||||
if tag.is_empty() || build_hash == "unknown" {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let our_short = &build_hash[..build_hash.len().min(tag.len())];
|
||||
if our_short == tag {
|
||||
let tag_version = tag.strip_prefix('v').unwrap_or(tag);
|
||||
if tag.is_empty() || tag_version == current_version {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
format!(
|
||||
"\n[fff update available: `curl -fsSL https://raw.githubusercontent.com/{REPO}/main/install-mcp.sh | bash`]\n"
|
||||
"\n[fff update available ({current_version} -> {tag_version}): `curl -fsSL https://raw.githubusercontent.com/{REPO}/main/install-mcp.sh | bash`]\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Shell out to curl to fetch the latest release tag name from GitHub API.
|
||||
fn fetch_latest_tag() -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Uses /releases/latest — GitHub excludes prereleases here, matching the
|
||||
// stable channel that install-mcp.sh installs from.
|
||||
fn fetch_latest_stable_tag() -> Result<String, Box<dyn std::error::Error>> {
|
||||
let output = std::process::Command::new("curl")
|
||||
.args([
|
||||
"-fsSL",
|
||||
@@ -57,7 +45,7 @@ fn fetch_latest_tag() -> Result<String, Box<dyn std::error::Error>> {
|
||||
"5",
|
||||
"-H",
|
||||
"Accept: application/vnd.github.v3+json",
|
||||
&format!("https://api.github.com/repos/{REPO}/releases?per_page=1"),
|
||||
&format!("https://api.github.com/repos/{REPO}/releases/latest"),
|
||||
])
|
||||
.output()?;
|
||||
|
||||
@@ -66,13 +54,47 @@ fn fetch_latest_tag() -> Result<String, Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
let body = String::from_utf8(output.stdout)?;
|
||||
let releases: Vec<serde_json::Value> = serde_json::from_str(&body)?;
|
||||
let tag = releases
|
||||
.first()
|
||||
.and_then(|r| r.get("tag_name"))
|
||||
let release: serde_json::Value = serde_json::from_str(&body)?;
|
||||
let tag = release
|
||||
.get("tag_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok(tag)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::compare_versions;
|
||||
|
||||
#[test]
|
||||
fn same_version_with_v_prefix_is_silent() {
|
||||
assert_eq!(compare_versions("0.10.1", "v0.10.1"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_version_without_v_prefix_is_silent() {
|
||||
assert_eq!(compare_versions("0.10.1", "0.10.1"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_tag_is_silent() {
|
||||
assert_eq!(compare_versions("0.10.1", ""), "");
|
||||
assert_eq!(compare_versions("0.10.1", " "), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_current_reports_update() {
|
||||
let notice = compare_versions("0.10.0", "v0.10.1");
|
||||
assert!(notice.contains("0.10.0 -> 0.10.1"), "got: {notice}");
|
||||
assert!(notice.contains("install-mcp.sh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nightly_tag_never_equals_stable_current() {
|
||||
let notice = compare_versions("0.10.1", "0.10.2-nightly.6a239e9");
|
||||
assert!(!notice.is_empty());
|
||||
assert!(notice.contains("0.10.1 -> 0.10.2-nightly.6a239e9"));
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user