chore: import upstream snapshot with attribution
e2e Tests / e2e (macos-latest) (push) Waiting to run
Nix CI / check (push) Waiting to run
Python CI / Python bindings (macos-latest) (push) Waiting to run
e2e Tests / e2e (windows-latest) (push) Waiting to run
Python CI / Python bindings (windows-latest) (push) Waiting to run
Build & Publish / Build Neovim aarch64-apple-darwin (push) Waiting to run
Build & Publish / Build Neovim aarch64-pc-windows-msvc (push) Waiting to run
Build & Publish / Build Neovim x86_64-apple-darwin (push) Waiting to run
Build & Publish / Build Neovim x86_64-pc-windows-msvc (push) Waiting to run
Build & Publish / Build C FFI aarch64-apple-darwin (push) Waiting to run
Build & Publish / Build C FFI aarch64-pc-windows-msvc (push) Waiting to run
Build & Publish / Build C FFI x86_64-apple-darwin (push) Waiting to run
Build & Publish / Build C FFI x86_64-pc-windows-msvc (push) Waiting to run
Build & Publish / Build MCP aarch64-apple-darwin (push) Waiting to run
Build & Publish / Build MCP aarch64-pc-windows-msvc (push) Waiting to run
Build & Publish / Build MCP x86_64-apple-darwin (push) Waiting to run
Build & Publish / Build MCP x86_64-pc-windows-msvc (push) Waiting to run
Build & Publish / Build Python wheels aarch64 (macos-latest) (push) Waiting to run
Build & Publish / Build Python wheels x86_64 (macos-latest) (push) Waiting to run
Build & Publish / Build Python wheels x86_64 (windows-latest) (push) Waiting to run
Build & Publish / Release (push) Blocked by required conditions
Build & Publish / Publish Python wheels to PyPI (push) Blocked by required conditions
Build & Publish / Publish Rust crates (push) Blocked by required conditions
Build & Publish / Publish npm packages (push) Blocked by required conditions
Rust CI / Fuzz Tests (windows-latest) (push) Waiting to run
Rust CI / Test (macos-latest) (push) Waiting to run
Rust CI / Test (windows-latest) (push) Waiting to run
Rust CI / Fuzz Tests (macos-latest) (push) Waiting to run
Python CI / Python bindings (ubuntu-latest) (push) Failing after 0s
Build & Publish / Build Neovim aarch64-linux-android (push) Failing after 0s
Build & Publish / Build Neovim aarch64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build MCP x86_64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build Python wheels aarch64 (ubuntu-latest) (push) Failing after 0s
Build & Publish / Build Python wheels x86_64 (ubuntu-latest) (push) Failing after 1s
Build & Publish / Build Python sdist (push) Failing after 1s
Rust CI / Fuzz Tests (ubuntu-latest) (push) Failing after 1s
Rust CI / Build i686-unknown-linux-gnu (push) Failing after 0s
Rust CI / cargo clippy (push) Failing after 1s
e2e Tests / e2e (ubuntu-latest) (push) Failing after 1s
Lua CI / luacheck lint (push) Failing after 1s
Build & Publish / Build Neovim aarch64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build Neovim x86_64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build Neovim x86_64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build C FFI aarch64-linux-android (push) Failing after 0s
Build & Publish / Build C FFI aarch64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build C FFI aarch64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build C FFI x86_64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build C FFI x86_64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build MCP aarch64-unknown-linux-gnu (push) Failing after 1s
Build & Publish / Build MCP aarch64-unknown-linux-musl (push) Failing after 0s
Build & Publish / Build MCP x86_64-unknown-linux-gnu (push) Failing after 1s
Spelling / Spell Check with Typos (push) Failing after 0s
Rust CI / Test (ubuntu-latest) (push) Failing after 0s
Rust CI / cargo fmt (push) Failing after 0s
Stylua / Check lua files using Stylua (push) Failing after 1s
Lua CI / lua-language-server type check (push) Failing after 12m29s
e2e Tests / e2e (alpine-musl) (push) Successful in 57m31s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:27:16 +08:00
commit d5f207424b
328 changed files with 87463 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
[target.'cfg(target_os = "macos")']
rustflags = [
"-C", "link-arg=-undefined",
"-C", "link-arg=dynamic_lookup",
]
[target.x86_64-unknown-linux-musl]
rustflags = ["-C", "target-feature=-crt-static"]
[target.aarch64-unknown-linux-musl]
rustflags = ["-C", "target-feature=-crt-static"]
# Android/Termux: no hardcoded linker so native Termux builds use the system cc.
# For CI cross-compilation the linker is set via CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER env var.
[target.aarch64-linux-android]
rustflags = ["-C", "link-args=-rdynamic"]
+58
View File
@@ -0,0 +1,58 @@
name: Bug report
description: Report a crash, hang, or incorrect behavior in fff (any frontend — nvim plugin, Node/Bun SDK, MCP server, C SDK).
title: "[Bug]: "
labels: ["bug"]
body:
- type: dropdown
id: frontend
attributes:
label: Which fff frontend?
options:
- Neovim plugin (fff.nvim)
- MCP server (fff-mcp)
- Node SDK (@ff-labs/fff-node)
- Bun SDK
- C SDK (libfff)
- Other / multiple
validations:
required: true
- type: textarea
id: logs
attributes:
label: has logs
description: |
Attach your fff log file — the single most useful thing for debugging.
fff writes a fresh log file on every process startup, named `fff+<unix-ts>+<pid>.log`, and keeps the last 20. Find the file matching your crashed/buggy run and drag-and-drop it here (or paste its contents).
Where the log files live:
| Frontend | Linux / macOS | Windows |
|---|---|---|
| Neovim plugin | `~/.local/state/nvim/log/fff+*.log` | `%LOCALAPPDATA%\nvim-data\log\fff+*.log` |
| MCP server (`fff-mcp`) | `~/.cache/fff_mcp+*.log` (override with `--log-file`) | `%LOCALAPPDATA%\fff_mcp+*.log` |
| Node / Bun SDK | path you passed as `logFilePath` to `FileFinder.create({...})` | same |
| C SDK | path you passed as `log_file_path` in `FffCreateOptions` | same |
Neovim users: run `:FFFOpenLog` to open the current session's log directly.
Also some useful commands:
```sh
# Neovim plugin
ls -t ~/.local/state/nvim/log/fff+*.log | head -1
# MCP server
ls -t ~/.cache/fff_mcp+*.log | head -1
```
validations:
required: false
- type: textarea
id: body
attributes:
label: Description
description: Please provide as much helpful information as you can
validations:
required: true
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Discussion / question
url: https://github.com/dmtrKovalenko/fff/discussions
about: For usage questions, design discussion, or anything that's not a bug or feature request.
@@ -0,0 +1,34 @@
name: Feature request
description: Suggest something new for fff
title: "[Suggestion]: "
labels: ["enhancement"]
body:
- type: dropdown
id: frontend
attributes:
label: Which fff frontend(s)?
multiple: true
options:
- Neovim plugin (fff.nvim)
- MCP server (fff-mcp)
- Node SDK (@ff-labs/fff-node)
- Bun SDK (@ff-labs/fff-bun)
- C lib (libfff)
- Core or Rust crate
validations:
required: true
- type: textarea
id: problem
attributes:
label: What problem are you trying to solve?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: If you have an idea of the shape of the API, describe it here.
validations:
required: false
+217
View File
@@ -0,0 +1,217 @@
name: e2e Tests
on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'doc/**'
pull_request:
branches: [main]
paths-ignore:
- '**.md'
- 'doc/**'
env:
CARGO_TERM_COLOR: always
MACOSX_DEPLOYMENT_TARGET: "13"
# Force Node 24 for all JS-based actions to avoid the libuv
# process_title assertion crash on Windows (known Node 20 bug).
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
lua-tests:
name: e2e (${{ matrix.os }})
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' }}
strategy:
fail-fast: false
matrix:
include:
- 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
- uses: actions/setup-node@v6
- name: Install Zig
uses: mlugg/setup-zig@v2
with:
version: 0.16.0
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@v1.15.4
with:
cache: true
cache-on-failure: 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 --no-default-features --features zlob
- name: Copy binary to target/release (Windows)
if: matrix.target
shell: bash
run: |
cp target/${{ matrix.target }}/release/fff_nvim.dll target/release/fff_nvim.dll
- name: Verify Windows DLL has no unexpected dependencies
if: matrix.target
shell: pwsh
run: |
# Find dumpbin via vswhere (always available on GitHub Actions Windows runners)
$vsPath = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -property installationPath
$dumpbin = Get-ChildItem "$vsPath" -Recurse -Filter "dumpbin.exe" | Select-Object -First 1
if (-not $dumpbin) { Write-Error "dumpbin.exe not found"; exit 1 }
$deps = & $dumpbin.FullName /DEPENDENTS target\release\fff_nvim.dll | Out-String
Write-Host $deps
# zlob must be statically linked - fail if zlob.dll appears as a dependency
if ($deps -match 'zlob\.dll') {
Write-Error "fff_nvim.dll has unexpected dynamic dependency on zlob.dll - zlob should be statically linked"
exit 1
}
- name: Build Rust binary
if: ${{ !matrix.target }}
run: cargo build --release -p fff-nvim --no-default-features --features zlob
- name: Install Neovim
uses: rhysd/action-setup-vim@v1
with:
neovim: true
version: v0.10.4
- name: Clone plenary.nvim
shell: bash
run: git clone --depth 1 https://github.com/nvim-lua/plenary.nvim ../plenary.nvim
- name: Run Lua tests
shell: bash
run: make test-lua
- name: Dump fff trace log on failure
if: failure()
shell: bash
run: |
# 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
- name: Run version resolution tests
shell: bash
run: make test-version
- name: Run non windows tests
shell: bash
if: ${{ matrix.os != 'windows-latest' }}
run: |
make test-bun
make test-c-api
- name: Verify bun --compile
shell: bash
run: make test-bun-compile
- name: Install Node.js
if: ${{ matrix.os != 'ubuntu-latest' }}
uses: actions/setup-node@v6
with:
node-version: "25"
- name: Install node dependencies
shell: bash
run: cd packages/fff-node && npm install
- name: Run node tests
shell: bash
run: make test-node
# Regression for https://github.com/dmtrKovalenko/fff/issues/480: build &
# run @ff-labs/fff-node end-to-end on real Alpine Linux (musl). Forces
# findBinary() through the npm-package resolver so detectLinuxLibc()
# actually runs.
alpine-musl:
name: e2e (alpine-musl)
runs-on: ubuntu-latest
container: node:22-alpine
continue-on-error: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
defaults:
run:
shell: sh
steps:
- name: Install build deps
run: apk add --no-cache git rust cargo musl-dev
- uses: actions/checkout@v5
# libgit2 refuses repos owned by a different user; checkout in a
# container can land at a uid mismatch, so opt every dir in.
- name: Mark workspace safe for git
run: git config --global --add safe.directory '*'
- name: Sanity check libc is musl
run: |
if ! ldd --version 2>&1 | grep -qi musl; then
echo "FAIL: container is not running musl libc"
exit 1
fi
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: alpine-musl-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
alpine-musl-cargo-
- name: Build libfff_c (musl)
run: cargo build --release -p fff-c
- name: Install workspace npm deps
run: npm install --no-package-lock
# 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
run: |
FFI_RS_VERSION=$(node -p "require('ffi-rs/package.json').version")
npm install --no-package-lock --no-save --force \
"@yuuang/ffi-rs-linux-x64-musl@${FFI_RS_VERSION}"
# Stage the freshly built libfff_c.so as the platform npm package
# so findBinary() resolves through the @ff-labs/fff-bin-* path —
# this is what exercises detectLinuxLibc().
- name: Stage musl bin package
run: |
PKG_DIR=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
- name: Build fff-node
working-directory: packages/fff-node
run: npm run build
- name: Run fff-node e2e suite
working-directory: packages/fff-node
run: node test/e2e.mjs
+56
View File
@@ -0,0 +1,56 @@
name: Lua CI
on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'doc/**'
pull_request:
branches: [main]
paths-ignore:
- '**.md'
- 'doc/**'
jobs:
lua-ls:
name: lua-language-server type check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Neovim
run: |
curl -L https://github.com/neovim/neovim/releases/download/v0.11.5/nvim-linux-x86_64.tar.gz -o /opt/nvim.tar.gz
mkdir /opt/nvim
tar xzf /opt/nvim.tar.gz -C /opt/nvim
mv /opt/nvim/nvim-linux-x86_64/* /opt/nvim
echo "/opt/nvim/bin" >> $GITHUB_PATH
- name: Install lua-language-server
run: |
curl -L "https://github.com/LuaLS/lua-language-server/releases/download/3.17.1/lua-language-server-3.17.1-linux-x64.tar.gz" -o /opt/lls.tar.gz
mkdir /opt/lls
tar -xzf /opt/lls.tar.gz -C /opt/lls
echo "/opt/lls/bin" >> $GITHUB_PATH
- name: Clone snacks.nvim
run: git clone --depth=1 https://github.com/folke/snacks.nvim /opt/snacks.nvim
- name: Run lua-language-server
run: lua-language-server --configpath .luarc.ci.json --check=.
luacheck:
name: luacheck lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install luacheck
run: |
sudo apt-get update -qq
sudo apt-get install -y luarocks
sudo luarocks install luacheck
- name: Run luacheck
run: luacheck lua/
+37
View File
@@ -0,0 +1,37 @@
name: Nix CI
on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'doc/**'
pull_request:
branches: [main]
paths-ignore:
- '**.md'
- 'doc/**'
jobs:
check:
runs-on: ubuntu-22.04
permissions:
id-token: "write"
contents: "read"
steps:
- uses: actions/checkout@v5
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- uses: DeterminateSystems/flake-checker-action@main
- name: Run `nix flake check`
run: nix flake check
- name: Run `nix build`
run: nix build
- name: Run `nix build .#fff-nvim`
run: nix build .#fff-nvim
- name: Run `nix run .#release`
run: nix run .#release
+85
View File
@@ -0,0 +1,85 @@
on:
schedule:
- cron: "0 4 * * *"
workflow_dispatch:
name: docs
jobs:
docs:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v5
with:
ref: main
fetch-depth: 2
- name: Extract Neovim section from README.md
run: |
awk '
/^<details id="neovim-plugin">/ { capture=1; next }
capture && /^<\/details>/ { capture=0; exit }
capture && /^<summary>$/ { next }
capture && /^<\/summary>$/ { next }
capture && /<h2>.*<\/h2>/ {
gsub(/<\/?h2>/, "")
sub(/^[[:space:]]+/, "")
print "# " $0
print ""
print "The best file search picker for Neovim. Frecency-ranked, typo-resistant, git-award, very fast."
print ""
next
}
capture { print }
' README.md > .panvimdoc-input.md
test -s .panvimdoc-input.md
- name: panvimdoc
uses: kdheepak/panvimdoc@main
with:
vimdoc: fff.nvim
pandoc: .panvimdoc-input.md
version: "Neovim >= 0.10.0"
demojify: true
treesitter: true
- name: Cleanup intermediate file
run: rm -f .panvimdoc-input.md
# panvimdoc stamps "Last change: <today>" every run, so a daily cron always
# produces a one-line diff. Skip the PR unless a non-date line changed.
- name: Detect real doc changes
id: docdiff
run: |
if git diff --quiet -I 'Last change:' -- doc/fff.nvim.txt; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Create pull request
id: cpr
if: steps.docdiff.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v7
with:
branch: bot/regenerate-vimdoc
token: ${{ secrets.GUSTAV_PAT }}
delete-branch: true
title: "chore: regenerate Neovim vimdoc"
commit-message: |
chore: regenerate Neovim vimdoc
Co-authored-by: Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>
author: "gustav-fff <66k7bxj9m6@privaterelay.appleid.com>"
committer: "gustav-fff <66k7bxj9m6@privaterelay.appleid.com>"
body: Automated vimdoc regeneration from README.md, scribed by Gustav.
add-paths: doc/fff.nvim.txt
- name: Enable auto-merge
if: steps.cpr.outputs.pull-request-number
env:
GH_TOKEN: ${{ secrets.GUSTAV_PAT }}
run: gh pr merge --auto --squash "${{ steps.cpr.outputs.pull-request-number }}"
+48
View File
@@ -0,0 +1,48 @@
name: Python CI
on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'doc/**'
pull_request:
branches: [main]
paths-ignore:
- '**.md'
- 'doc/**'
env:
CARGO_TERM_COLOR: always
MACOSX_DEPLOYMENT_TARGET: "13.0"
jobs:
test:
name: Python bindings (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Setup uv
uses: astral-sh/setup-uv@v5
with:
version: "0.11.14"
enable-cache: true
- name: Build and test Python bindings
working-directory: packages/fff-python
shell: bash
run: |
uv sync --all-extras
uv run maturin develop --release
uv run pytest -v
+757
View File
@@ -0,0 +1,757 @@
name: Build & Publish
on:
push:
branches: [main, fix/use-trusted-publishing]
tags:
- "v*"
pull_request:
workflow_dispatch:
inputs:
publish_pypi:
description: "Manually build and publish Python wheels to PyPI"
required: false
default: false
type: boolean
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build-nvim:
name: Build Neovim ${{ matrix.target }}
runs-on: ${{ matrix.os }}
permissions:
contents: read
id-token: write
strategy:
matrix:
include:
## Linux builds (using cargo-zigbuild)
# Glibc 2.31 (Ubuntu 20.04, Debian 11, RHEL 9).
# Rust 1.91+ requires glibc >= 2.31 for std::sys::random::getrandom,
# copy_file_range, and statx; earlier targets (2.17) no longer link.
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
zigbuild_target: x86_64-unknown-linux-gnu.2.31
artifact_name: target/x86_64-unknown-linux-gnu/ci/libfff_nvim.so
ext: so
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
zigbuild_target: aarch64-unknown-linux-gnu.2.31
artifact_name: target/aarch64-unknown-linux-gnu/ci/libfff_nvim.so
ext: so
# Musl (statically linked)
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
artifact_name: target/x86_64-unknown-linux-musl/ci/libfff_nvim.so
ext: so
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
artifact_name: target/aarch64-unknown-linux-musl/ci/libfff_nvim.so
ext: so
## Android (Termux)
- os: ubuntu-latest
target: aarch64-linux-android
artifact_name: target/aarch64-linux-android/ci/libfff_nvim.so
ext: so
## macOS builds
- os: macos-latest
target: x86_64-apple-darwin
artifact_name: target/x86_64-apple-darwin/ci/libfff_nvim.dylib
ext: dylib
- os: macos-latest
target: aarch64-apple-darwin
artifact_name: target/aarch64-apple-darwin/ci/libfff_nvim.dylib
ext: dylib
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact_name: target/x86_64-pc-windows-msvc/ci/fff_nvim.dll
ext: dll
- os: windows-latest
target: aarch64-pc-windows-msvc
artifact_name: target/aarch64-pc-windows-msvc/ci/fff_nvim.dll
ext: dll
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Install Rust
run: rustup target add ${{ matrix.target }}
- name: Install Zig
uses: mlugg/setup-zig@v2
with:
version: 0.16.0
- name: Install cargo-zigbuild
if: contains(matrix.os, 'ubuntu')
run: cargo install cargo-zigbuild
- 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 --no-default-features --features zlob
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
- name: Build for Android (Termux)
if: contains(matrix.target, 'android')
run: |
NDK_BIN="$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
# NDK clang for C deps (libgit2, lmdb, blake3) that need Bionic sysroot headers
export CC_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang"
export CXX_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang++"
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 --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 --no-default-features --features zlob
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
- name: Ad-hoc sign macOS binary
if: contains(matrix.os, 'macos')
run: codesign --force --sign - "${{ matrix.target }}.${{ matrix.ext }}"
- name: Build for Windows
if: contains(matrix.os, 'windows')
shell: bash
run: |
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
uses: actions/upload-artifact@v4
with:
name: nvim-${{ matrix.target }}
path: ${{ matrix.target }}.*
build-c:
name: Build C FFI ${{ matrix.target }}
runs-on: ${{ matrix.os }}
permissions:
contents: read
strategy:
matrix:
include:
## Linux builds
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
zigbuild_target: x86_64-unknown-linux-gnu.2.31
artifact_name: target/x86_64-unknown-linux-gnu/ci/libfff_c.so
npm_package: fff-bin-linux-x64-gnu
lib_filename: libfff_c.so
ext: so
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
zigbuild_target: aarch64-unknown-linux-gnu.2.31
artifact_name: target/aarch64-unknown-linux-gnu/ci/libfff_c.so
npm_package: fff-bin-linux-arm64-gnu
lib_filename: libfff_c.so
ext: so
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
artifact_name: target/x86_64-unknown-linux-musl/ci/libfff_c.so
npm_package: fff-bin-linux-x64-musl
lib_filename: libfff_c.so
ext: so
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
artifact_name: target/aarch64-unknown-linux-musl/ci/libfff_c.so
npm_package: fff-bin-linux-arm64-musl
lib_filename: libfff_c.so
ext: so
## Android (Termux)
- os: ubuntu-latest
target: aarch64-linux-android
artifact_name: target/aarch64-linux-android/ci/libfff_c.so
lib_filename: libfff_c.so
ext: so
## macOS builds
- os: macos-latest
target: x86_64-apple-darwin
artifact_name: target/x86_64-apple-darwin/ci/libfff_c.dylib
npm_package: fff-bin-darwin-x64
lib_filename: libfff_c.dylib
ext: dylib
- os: macos-latest
target: aarch64-apple-darwin
artifact_name: target/aarch64-apple-darwin/ci/libfff_c.dylib
npm_package: fff-bin-darwin-arm64
lib_filename: libfff_c.dylib
ext: dylib
## Windows builds
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact_name: target/x86_64-pc-windows-msvc/ci/fff_c.dll
npm_package: fff-bin-win32-x64
lib_filename: fff_c.dll
ext: dll
- os: windows-latest
target: aarch64-pc-windows-msvc
artifact_name: target/aarch64-pc-windows-msvc/ci/fff_c.dll
npm_package: fff-bin-win32-arm64
lib_filename: fff_c.dll
ext: dll
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Install Rust
run: rustup target add ${{ matrix.target }}
- name: Install Zig
uses: mlugg/setup-zig@v2
with:
version: 0.16.0
- name: Install cargo-zigbuild
if: contains(matrix.os, 'ubuntu')
run: cargo install cargo-zigbuild
- 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 --no-default-features --features zlob
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
- name: Build for Android (Termux)
if: contains(matrix.target, 'android')
run: |
NDK_BIN="$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
export CC_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang"
export CXX_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang++"
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 --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 --no-default-features --features zlob
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
- name: Ad-hoc sign macOS binary
if: contains(matrix.os, 'macos')
run: codesign --force --sign - "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
- name: Build for Windows
if: contains(matrix.os, 'windows')
shell: bash
run: |
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
cp "c-lib-${{ matrix.target }}.${{ matrix.ext }}" "packages/${{ matrix.npm_package }}/${{ matrix.lib_filename }}"
- name: Upload C library artifact
uses: actions/upload-artifact@v4
with:
name: c-lib-${{ matrix.target }}
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 }}
path: packages/${{ matrix.npm_package }}/
build-mcp:
name: Build MCP ${{ matrix.target }}
runs-on: ${{ matrix.os }}
permissions:
contents: read
strategy:
matrix:
include:
## Linux builds (using cargo-zigbuild)
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
zigbuild_target: x86_64-unknown-linux-gnu.2.31
artifact_name: target/x86_64-unknown-linux-gnu/ci/fff-mcp
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
zigbuild_target: aarch64-unknown-linux-gnu.2.31
artifact_name: target/aarch64-unknown-linux-gnu/ci/fff-mcp
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
artifact_name: target/x86_64-unknown-linux-musl/ci/fff-mcp
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
artifact_name: target/aarch64-unknown-linux-musl/ci/fff-mcp
## macOS builds
- os: macos-latest
target: x86_64-apple-darwin
artifact_name: target/x86_64-apple-darwin/ci/fff-mcp
- os: macos-latest
target: aarch64-apple-darwin
artifact_name: target/aarch64-apple-darwin/ci/fff-mcp
## Windows builds
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact_name: target/x86_64-pc-windows-msvc/ci/fff-mcp.exe
- os: windows-latest
target: aarch64-pc-windows-msvc
artifact_name: target/aarch64-pc-windows-msvc/ci/fff-mcp.exe
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Install Rust
run: rustup target add ${{ matrix.target }}
- name: Install Zig
uses: mlugg/setup-zig@v2
with:
version: 0.16.0
- name: Install cargo-zigbuild
if: contains(matrix.os, 'ubuntu')
run: cargo install cargo-zigbuild
- name: Build for Linux
if: contains(matrix.os, 'ubuntu')
run: |
cargo zigbuild --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 --no-default-features --features zlob
cp "${{ matrix.artifact_name }}" "fff-mcp-${{ matrix.target }}"
- name: Ad-hoc sign macOS binary
if: contains(matrix.os, 'macos')
run: codesign --force --sign - "fff-mcp-${{ matrix.target }}"
- name: Build for Windows
if: contains(matrix.os, 'windows')
shell: bash
run: |
cargo build --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
uses: actions/upload-artifact@v4
with:
name: mcp-${{ matrix.target }}
path: fff-mcp-${{ matrix.target }}*
build-python:
name: Build Python wheels ${{ matrix.target }} (${{ matrix.os }})
# Wheels are release artifacts; PR validation uses the develop build in
# python.yml, so skip the cross-compile matrix on pull requests.
if: github.event_name != 'pull_request'
runs-on: ${{ matrix.os }}
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64
container: "off"
- os: ubuntu-latest
target: aarch64
container: "off"
- os: macos-latest
target: x86_64
- os: macos-latest
target: aarch64
- os: windows-latest
target: x86_64
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install Zig
uses: mlugg/setup-zig@v2
with:
version: 0.16.0
- name: Install cargo-zigbuild
if: contains(matrix.os, 'ubuntu')
run: cargo install cargo-zigbuild
- name: Install aarch64 cross compiler
if: matrix.target == 'aarch64' && contains(matrix.os, 'ubuntu')
run: |
sudo apt-get update -qq
sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu
- name: Build wheels
uses: PyO3/maturin-action@v1
env:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
CXX_aarch64_unknown_linux_gnu: aarch64-linux-gnu-g++
AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar
with:
target: ${{ matrix.target }}
args: --release --out dist --no-default-features --features zlob
sccache: "true"
working-directory: packages/fff-python
container: ${{ matrix.container || '' }}
- name: Upload wheels
uses: actions/upload-artifact@v4
with:
name: python-wheels-${{ matrix.os }}-${{ matrix.target }}
path: packages/fff-python/dist/
build-python-sdist:
name: Build Python sdist
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Build sdist
uses: PyO3/maturin-action@v1
with:
command: sdist
args: --out dist
working-directory: packages/fff-python
- name: Upload sdist
uses: actions/upload-artifact@v4
with:
name: python-sdist
path: packages/fff-python/dist/
release:
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'))
permissions:
contents: write
steps:
- uses: actions/checkout@v5
- name: Install Lua
uses: leafo/gh-actions-lua@v12
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: ./binaries
- name: Flatten and rename Neovim artifacts
working-directory: ./binaries
run: |
# Move nvim artifacts to root level with original naming
for dir in nvim-*/; do
target="${dir#nvim-}"
target="${target%/}"
for file in "$dir"*; do
if [ -f "$file" ]; then
filename=$(basename "$file")
mv "$file" "./$filename"
fi
done
rmdir "$dir" 2>/dev/null || true
done
- name: Flatten C library artifacts
working-directory: ./binaries
run: |
# Move c-lib artifacts to root level
for dir in c-lib-*/; do
for file in "$dir"*; do
if [ -f "$file" ]; then
filename=$(basename "$file")
mv "$file" "./$filename"
fi
done
rmdir "$dir" 2>/dev/null || true
done
- name: Flatten MCP artifacts
working-directory: ./binaries
run: |
for dir in mcp-*/; do
for file in "$dir"*; do
if [ -f "$file" ]; then
filename=$(basename "$file")
mv "$file" "./$filename"
fi
done
rmdir "$dir" 2>/dev/null || true
done
- name: Move Python wheels to release directory
working-directory: ./binaries
run: |
mkdir -p python
for dir in python-wheels-*/ python-sdist/; do
[ -d "$dir" ] || continue
for file in "$dir"*; do
if [ -f "$file" ]; then
mv "$file" "python/$(basename "$file")"
fi
done
rmdir "$dir" 2>/dev/null || true
done
- name: Remove npm package artifacts from release binaries
working-directory: ./binaries
run: |
rm -rf npm-*
- name: Generate checksums
working-directory: ./binaries
run: |
ls -la
for file in * python/*; do
if [ -f "$file" ] && [[ ! "$file" == *.sha256 ]]; then
sha256sum "$file" > "${file}.sha256"
fi
done
- name: Determine version
id: version
run: lua scripts/determine-version.lua
# Nightlies publish to a permanent per-sha tag (release_tag == version) so
# pinned/stale installs always fetch the binary built for their own commit.
# The rolling `nightly` tag is also moved to HEAD for "give me latest" tooling.
- name: Move rolling nightly tag to current commit
if: steps.version.outputs.is_release != 'true'
run: |
git tag -f nightly "${{ github.sha }}"
git push -f origin refs/tags/nightly
- name: Upload Release Assets
uses: softprops/action-gh-release@v2
with:
name: "${{ steps.version.outputs.version }}"
tag_name: "${{ steps.version.outputs.release_tag }}"
token: ${{ github.token }}
files: |
./binaries/*
./binaries/python/*
draft: false
prerelease: ${{ steps.version.outputs.is_release != 'true' }}
generate_release_notes: ${{ steps.version.outputs.is_release == 'true' }}
body: |
${{ steps.version.outputs.is_release == 'true' && format('Release {0}', steps.version.outputs.version) || format('Nightly release from commit: {0}', github.sha) }}
npm packages, rust crates and python wheels are available under this version ${{ steps.version.outputs.version }}
## Neovim Plugin
- `{target}.so` / `.dylib` / `.dll` - Lua module for Neovim
## C FFI Library (for Bun/Node/Python)
- `c-lib-{target}.so` / `.dylib` / `.dll` - C FFI library
## MCP Server
- `fff-mcp-{target}` - MCP server binary
## Python Package
- `python/*.whl` / `python/*.tar.gz` - Python wheels and sdist
- Install from PyPI: `pip install fff-search` (when published)
Update mcp via:
```sh
curl -fsSL https://raw.githubusercontent.com/dmtrKovalenko/fff.nvim/main/install-mcp.sh | bash
```
- name: Bump Homebrew formula (uses local checksums)
if: steps.version.outputs.is_release == 'true' && github.repository_owner == 'dmtrKovalenko'
run: make bump-homebrew-formula VERSION="${{ steps.version.outputs.version }}" BINARIES_DIR=./binaries
- name: Pin SHAs in install-mcp.sh (uses local checksums)
if: steps.version.outputs.is_release == 'true' && github.repository_owner == 'dmtrKovalenko'
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 }}"
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
pypi-publish:
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'))
environment:
name: pypi
url: https://pypi.org/p/fff-search
permissions:
contents: read
id-token: write
steps:
- name: Download Python wheels and sdist
uses: actions/download-artifact@v4
with:
pattern: python-*
path: dist
merge-multiple: true
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist/
skip-existing: true
crates-publish:
name: Publish Rust crates
needs: [build-nvim, build-c, build-mcp]
runs-on: ubuntu-latest
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/fix/use-trusted-publishing' || startsWith(github.ref, 'refs/tags/v'))
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v5
- uses: rust-lang/crates-io-auth-action@v1
id: auth
- name: Install Lua
uses: leafo/gh-actions-lua@v12
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-edit
run: cargo install cargo-edit --force --locked
- name: Determine version
id: version
run: lua scripts/determine-version.lua
- name: Publish crates
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
run: make publish-crates V="${{ steps.version.outputs.version }}"
npm-publish:
name: Publish npm packages
needs: [build-c]
runs-on: ubuntu-latest
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/fix/use-trusted-publishing' || startsWith(github.ref, 'refs/tags/v'))
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v5
- name: Install Lua
uses: leafo/gh-actions-lua@v12
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "25"
registry-url: "https://registry.npmjs.org"
- name: Determine version
id: version
run: lua scripts/determine-version.lua
- name: Download npm package artifacts
uses: actions/download-artifact@v4
with:
pattern: npm-*
path: ./npm-packages
- name: Publish platform packages
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="${{ steps.version.outputs.npm_tag }}"
for pkg_dir in ./npm-packages/npm-*/; do
if [ -d "$pkg_dir" ]; then
pkg_name=$(node -p "require('${pkg_dir}package.json').name")
echo "Publishing ${pkg_name}@${VERSION} with tag ${TAG}..."
make set-npm-version PKG="$pkg_dir" VERSION="$VERSION"
cd "$pkg_dir"
npm publish --tag "$TAG" --access public --provenance
cd -
fi
done
- name: Publish bun package
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="${{ steps.version.outputs.npm_tag }}"
echo "Publishing @ff-labs/fff-bun@${VERSION} with tag ${TAG}..."
make set-npm-version PKG=packages/fff-bun VERSION="$VERSION"
cd packages/fff-bun
npm publish --tag "$TAG" --access public --provenance
- name: Publish Node.js package
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="${{ steps.version.outputs.npm_tag }}"
echo "Publishing @ff-labs/fff-node@${VERSION} with tag ${TAG}..."
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
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="${{ steps.version.outputs.npm_tag }}"
echo "Publishing @ff-labs/pi-fff@${VERSION} with tag ${TAG}..."
make set-npm-version PKG=packages/pi-fff VERSION="$VERSION"
cd packages/pi-fff
npm publish --tag "$TAG" --access public --provenance
+162
View File
@@ -0,0 +1,162 @@
name: Rust CI
on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'doc/**'
pull_request:
branches: [main]
paths-ignore:
- '**.md'
- 'doc/**'
env:
CARGO_TERM_COLOR: always
# 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"
jobs:
test:
name: Test
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
# Guard against deadlocks in the shared-picker / watcher teardown
# path: a stuck test would otherwise consume a full 6h CI slot.
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
# Zig is required to compile zlob
- name: Install Zig
uses: goto-bus-stop/setup-zig@v2
with:
version: 0.16.0
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@v1.15.4
with:
cache: true
cache-on-failure: true
cache-key: "v1-rust"
components: rustfmt, clippy
- name: Run tests
run: cargo test --no-default-features --features zlob --workspace --exclude fff-nvim
stress-test:
name: Fuzz Tests
runs-on: ${{ matrix.os }}
strategy:
# Keep going after one OS fails so we can see whether a bug
# reproduces everywhere or is platform-specific.
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
# Long-running; don't let a stuck watcher thread burn a full CI
# timeout. Two scenarios should finish well under this limit.
timeout-minutes: 20
env:
FFF_STRESS_CASES: "5"
FFF_STRESS_MIN_OPS: "30"
FFF_STRESS_MAX_OPS: "60"
steps:
- uses: actions/checkout@v5
- name: Install Zig
uses: goto-bus-stop/setup-zig@v2
with:
version: 0.16.0
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@v1.15.4
with:
cache: true
cache-on-failure: true
cache-key: "v1-rust-stress-${{ matrix.os }}"
components: rustfmt, clippy
- name: Stress test seeded
shell: bash
run: make test-stress-seeded
- name: Stress test random
shell: bash
run: make test-stress-random
- name: Stress test regressions
shell: bash
run: make test-stress-regressions
- name: Upload proptest regressions on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: proptest-regressions-${{ matrix.os }}
path: crates/fff-core/tests/fuzz_git_watcher_stress.proptest-regressions
if-no-files-found: ignore
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: Build fff-search for i686
run: cargo build -p fff-search --target i686-unknown-linux-gnu
fmt:
name: cargo fmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
components: rustfmt
- name: Check formatting
run: cargo fmt -- --check
clippy:
name: cargo clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
# Zig is required to compile zlob
- name: Install Zig
uses: goto-bus-stop/setup-zig@v2
with:
version: 0.16.0
- name: Install Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
components: clippy
- name: Run clippy
run: cargo clippy --no-default-features --features zlob -- -D warnings
+25
View File
@@ -0,0 +1,25 @@
name: Spelling
permissions:
contents: read
on:
push:
branches:
- main
pull_request:
env:
CLICOLOR: 1
jobs:
spelling:
name: Spell Check with Typos
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Spell Check Repo
uses: crate-ci/typos@685eb3d55be2f85191e8c84acb9f44d7756f84ab # v1.29.4
+37
View File
@@ -0,0 +1,37 @@
name: Stylua
permissions:
contents: read
on:
push:
branches:
- main
paths:
- "**/*.lua"
- .stylua.toml
- .github/workflows/stylua.yaml
pull_request:
paths:
- "**/*.lua"
- .stylua.toml
- .github/workflows/stylua.yaml
env:
CLICOLOR: 1
jobs:
stylua:
name: Check lua files using Stylua
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Stylua Check Repo
uses: JohnnyMorganz/stylua-action@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
version: latest
args: --color=always --check .
+65
View File
@@ -0,0 +1,65 @@
doc/tags
big-repo
target/
.archive.lua
_*.lua
.lazy.lua
dual/
result
.direnv
.devenv
.repro/
.wrangler/
*.so
*.dylib
# all the perf like utility files
*.data
node_modules/
crates/fff-notify-debouncer-full/
packages/fff-bun/glob-bench-bin
dist/
scripts/benchmark-results/
# Native binaries (downloaded at install)
*.dylib
*.so
*.dll
*.pdb
# Instruments traces
*.trace/
# Test logs
fff-test.log
# Python
__pycache__/
*.py[cod]
*$py.class
*.pyd
*.egg-info/
*.egg
.eggs/
build/
*.whl
# Virtual environments
.venv/
venv/
env/
ENV/
# uv
# Testing / linting
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
.tox/
.nox/
# IDEs
.idea/
.vscode/
*.swp
*.swo
*~
+25
View File
@@ -0,0 +1,25 @@
-- luacheck configuration for fff.nvim
-- https://luacheck.readthedocs.io/en/stable/config.html
-- Neovim globals
globals = { "vim" }
-- Standard library
std = "luajit"
-- Ignore line length (handled by stylua)
max_line_length = false
-- Ignore unused self argument in methods
self = false
-- Files/directories to ignore
exclude_files = {
".luarocks/",
}
-- Warn about unused variables, but allow _ prefix convention
unused_args = true
ignore = {
"212", -- unused argument (too noisy for callback-heavy code)
}
+44
View File
@@ -0,0 +1,44 @@
{
"$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
"runtime": {
"version": "LuaJIT",
"pathStrict": true
},
"workspace": {
"library": [
"/opt/nvim/share/nvim/runtime/lua/vim/_meta",
"/opt/nvim/share/nvim/runtime/lua/vim/shared.lua",
"${3rd}/luv/library",
"${3rd}/busted/library",
"/opt/snacks.nvim/lua"
],
"checkThirdParty": false
},
"diagnostics": {
"severity": {
"undefined-global": "Error",
"undefined-field": "Warning",
"missing-return": "Warning",
"redundant-parameter": "Warning",
"param-type-mismatch": "Warning",
"assign-type-mismatch": "Warning",
"cast-type-mismatch": "Warning",
"deprecated": "Warning",
"undefined-doc-param": "Warning"
},
"neededFileStatus": {
"undefined-global": "Any",
"undefined-field": "Any",
"missing-return": "Any",
"redundant-parameter": "Any",
"param-type-mismatch": "Any",
"assign-type-mismatch": "Any",
"cast-type-mismatch": "Any",
"deprecated": "Any",
"undefined-doc-param": "Any"
}
},
"type": {
"checkTableShape": true
}
}
+42
View File
@@ -0,0 +1,42 @@
{
"$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
"runtime": {
"version": "LuaJIT"
},
"workspace": {
"library": [
"$VIMRUNTIME/lua",
"${3rd}/luv/library",
"${3rd}/busted/library"
],
"checkThirdParty": false
},
"diagnostics": {
"globals": ["vim"],
"severity": {
"undefined-global": "Error",
"undefined-field": "Warning",
"missing-return": "Warning",
"redundant-parameter": "Warning",
"param-type-mismatch": "Warning",
"assign-type-mismatch": "Warning",
"cast-type-mismatch": "Warning",
"deprecated": "Warning",
"undefined-doc-param": "Warning"
},
"neededFileStatus": {
"undefined-global": "Any",
"undefined-field": "Any",
"missing-return": "Any",
"redundant-parameter": "Any",
"param-type-mismatch": "Any",
"assign-type-mismatch": "Any",
"cast-type-mismatch": "Any",
"deprecated": "Any",
"undefined-doc-param": "Any"
}
},
"type": {
"checkTableShape": true
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"mcpServers": {
"fff": {
"type": "stdio",
"command": "./target/release/fff-mcp",
"args": []
}
}
}
+4
View File
@@ -0,0 +1,4 @@
empty_config.lua
benches/
doc/
+7
View File
@@ -0,0 +1,7 @@
column_width = 120
line_endings = "Unix"
indent_type = "Spaces"
indent_width = 2
quote_style = "AutoPreferSingle"
call_parentheses = "Always"
collapse_simple_statement = "Always"
+111
View File
@@ -0,0 +1,111 @@
# To Clankers
This repository contains **FFF.nvim (Fast File Finder)**, a high-performance file picker for Neovim inspired by blink.cmp's fuzzy matching technology. It's NOT a completion plugin, but rather a standalone file finder with advanced fuzzy search and frecency scoring. The project aims to be the drop-in replacement for telescope, fzf-lua, snacks.picker and similar plugins, focusing on speed, accuracy search and usability features.
## Development Commands
Always prefer Makefile commands listed to the cargo/bun/node if possible.
### Building
- `make build` - build everything
### Testing and Development Tools
This project does not have a traditional test suite. Testing is done through:
- Create e2e local test file for Neovim: Load any Lua test file with `nvim -l <test_file>`
- Write inline rust unit tests for any functionality that is standalone and scoped within a single function
### Code Quality
- `make lint` - Rust linting and code analysis
- `make format` - Format all code
- `make test` - Run unit tests (limited coverage, primarily integration testing)
When doing code make sure to REDUCE SIZE OF COMMENTS. This is very important. Every comment should be concise 1-2 liner maximum 4 lines if describes really extensive and unnatural concept.
### Important coding rules
- Do not add doc comments to the private structs and functions.
- Do not make public structs if something can be private
## Architecture
Everything that is performance critical happens in rust world, everything that is neovim specific happens in the lua code.
There are 3 main components:
- Rust binary with the global file picker state containing index of all files
- Background thread with the file system watcher that updates the index in real time
- Lua UI layer that renders the picker, handles user input, and calls the rust functions via FFI
There are 2 databases:
- Frecency database (LMDB) that tracks file access patterns for scoring
- Query history database used to track the user's previous search queries
### Key Files
- `lua/fff.lua` - Entry point, delegates to main.lua
- `lua/fff/main.lua` - Public API (find_files, search, change_directory)
- `lua/fff/core.lua` - Initialization, autocmds, global state management
- `lua/fff/picker_ui.lua` - UI rendering, layout calculation, keymaps
- `lua/fff/file_picker/preview.lua` - File preview with syntax highlighting
- `lua/fff/file_picker/image.lua` - Image preview (snacks.nvim integration)
- `lua/fff/conf.lua` - Default config
- `lua/fff/rust/init.lua` - Loads compiled Rust shared library
**Rust Side:**
- `lua/fff/rust/lib.rs` - FFI bindings, global state (FILE_PICKER, FRECENCY)
- `lua/fff/rust/file_picker.rs` - Core FilePicker struct, indexing, background watcher
- `lua/fff/rust/frecency.rs` - Frecency database (LMDB) and scoring
- `lua/fff/rust/query_tracker.rs` - Search query history tracking
- `lua/fff/rust/score.rs` - Fuzzy match scoring with frizbee integration
- `lua/fff/rust/git.rs` - Git status caching and repository detection
- `lua/fff/rust/background_watcher.rs` - File system watcher thread
### Scoring Algorithm
Located at the score.rs file
### Build System
- `Cargo.toml` - Rust dependencies and build configuration (package name: `fff_nvim`)
- `rust-toolchain.toml` - Specifies Rust nightly toolchain with required components
- `Cross.toml` - Cross-compilation settings using Zig for Linux targets
- **CI/CD Workflows**:
- `.github/workflows/rust.yml` - Rust testing, formatting, and clippy checks
- `.github/workflows/release.yaml` - Automated multi-platform builds
- `.github/workflows/stylua.yaml` - Lua code formatting validation
- `.github/workflows/nix.yml` - Nix build validation
- **Cross-compilation Support**: Uses `cross` tool with Zig backend for efficient cross-compilation
## Development Notes
### Working with Rust Code
- Prefer struct methods over functions
- If there is more than 2 impls in the file - create new file
- Smaller concise comments over giant comment blocks
- Do not add doc comments to the private functions/structs
- Be very careful around locking and better double check with the human if something is going to require potentially long lock on a mutex/rwlock
### Working with lua code
- Document the types of public functions in every module
- Use `vim.validate()` for validating user inputs in public functions
- Try to reuse as much of existing functions as possible
- When working on new features for the UI **IT IS EXTREMELY IMPORTANT** to keep the core functionality of navigating between files, selecting, and seeing the preview working as is. NEVER break anything from the core UI functionality, only add new features on top of the current UI.
- When making a large chunk of code make lua test that opens neovim at `~/dev/lightsource` and opens the picker to test the ui functionality across the actual code.
- When adding a new highlights or any new shortcuts and configurable UI options add them to the neovim config. AND IMPORTANT: update the README.md with the new configuration options.
### UI rendering
When working on the UI changeds IT IS EXTREMELY important for you to test it for both prompt_position="bottom" and prompt_position="top" as the rendering logic is different for both of them in both rust and lua world. When the prompt is positioed in the bottom everything should work the same way as the top but would be reversed in order. (though navigation is same for both)
## Top level API that can not introduce breaking changes under any circumstance
Top level rust, lua, C, and bun APIs can not be changed under any circumstance
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
Generated
+3251
View File
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
[workspace]
members = [
"crates/fff-c",
"crates/fff-core",
"crates/fff-mcp",
"crates/fff-nvim",
"crates/fff-python",
"crates/fff-query-parser",
"crates/fff-grep",
]
resolver = "2"
[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 }
# Shared dependencies
ahash = "0.8"
blake3 = "1.8.2"
chrono = { version = "0.4", features = ["serde"] }
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 = [
"vendored-libgit2",
] }
glidesort = "0.1"
globset = "0.4"
heed = "0.22.0"
ignore = "0.4.22"
memmap2 = "0.9"
mimalloc = "0.1.47"
signal-hook-registry = "1.4"
zlob = { version = "=1.6.1" }
mlua = { version = "0.11.1", features = ["module", "luajit"] }
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"
parking_lot = "0.12"
pathdiff = "0.2.1"
rayon = "1.8.0"
regex = "1.11"
regex-syntax = "0.8"
smallvec = { version = "1.13", features = ["const_generics", "union"] }
thiserror = "2.0.10"
tracing = "0.1"
[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
strip = "debuginfo"
[profile.ci]
inherits = "release"
# we use lto=fat locally for better SIMD for the march=native but
# on CI when we cross compiling we should not exclude any cpu flags checking
lto = "thin"
[profile.bench]
inherits = "release"
# For Instruments / xctrace: release-level optimization but keep debuginfo
# and symbols so sampled frames resolve to real Rust names.
[profile.prof]
inherits = "release"
debug = "full"
strip = false
lto = "thin"
+60
View File
@@ -0,0 +1,60 @@
# Originally authored by @jellydn (https://github.com/jellydn/homebrew-tap).
# 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"
license "MIT"
version "0.9.6"
LIVECHECK_REPO = "dmtrKovalenko/fff.nvim".freeze
RELEASE_BASE = "https://github.com/dmtrKovalenko/fff.nvim/releases/download".freeze
on_macos do
on_arm do
url "#{RELEASE_BASE}/v#{version}/fff-mcp-aarch64-apple-darwin"
sha256 "29a7fadeafb062f3e5954b1ab8c69e14dca24f5e061cd8d3b1ea1bab385a3754"
end
on_intel do
url "#{RELEASE_BASE}/v#{version}/fff-mcp-x86_64-apple-darwin"
sha256 "58259324c2c13a1b6f24f13138c2cd3eae9ff20e05201a539beb8f2044a651aa"
end
end
on_linux do
on_arm do
url "#{RELEASE_BASE}/v#{version}/fff-mcp-aarch64-unknown-linux-gnu"
sha256 "91e6fa14e040588dc92de854e35020536f1e2458ce3386b2b727b2e7a88f6684"
end
on_intel do
url "#{RELEASE_BASE}/v#{version}/fff-mcp-x86_64-unknown-linux-gnu"
sha256 "d1bd2b89a79e8eda71b1754260499cec1feaafd2adf372e92371c8d6b68509a3"
end
end
livecheck do
url "https://github.com/#{LIVECHECK_REPO}/releases/latest"
strategy :github_latest
end
def install
if OS.mac?
if Hardware::CPU.arm?
bin.install "fff-mcp-aarch64-apple-darwin" => "fff-mcp"
elsif Hardware::CPU.intel?
bin.install "fff-mcp-x86_64-apple-darwin" => "fff-mcp"
end
elsif OS.linux?
if Hardware::CPU.arm?
bin.install "fff-mcp-aarch64-unknown-linux-gnu" => "fff-mcp"
elsif Hardware::CPU.intel?
bin.install "fff-mcp-x86_64-unknown-linux-gnu" => "fff-mcp"
end
end
end
test do
system bin/"fff-mcp", "--healthcheck"
end
end
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Dmitriy Kovalenko
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+348
View File
@@ -0,0 +1,348 @@
PLENARY_DIR ?= ../plenary.nvim
MINI_DIR ?= ../mini.nvim
PREFIX ?= /usr/local
LIBDIR ?= $(PREFIX)/lib
INCLUDEDIR ?= $(PREFIX)/include
# Compile-time cfg that gates the watcher + git-status fuzz stress test.
STRESS_RUSTFLAGS := --cfg stress
FFF_STRESS_DEFAULT_SEED ?= 0xDEADBEEFCAFEBABE
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 -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-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
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// 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 \
printf '$(SYNC_API_BANNER)' > "$$target"; \
cat $(SYNC_API_SRC) >> "$$target"; \
echo "synced: $$target"; \
done
sync-js-api-check:
@status=0; \
for target in $(SYNC_API_TARGETS); do \
tmp=$$(mktemp); \
printf '$(SYNC_API_BANNER)' > "$$tmp"; \
cat $(SYNC_API_SRC) >> "$$tmp"; \
if ! cmp -s "$$tmp" "$$target"; then \
echo "out of date: $$target (run make sync-js-api)"; status=1; \
fi; \
rm -f "$$tmp"; \
done; \
exit $$status
build:
cargo build --release --no-default-features --features zlob
build-c-lib:
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
# Install the C library and header under $(PREFIX) (default /usr/local).
# Override PREFIX for user-local installs, e.g. `make install PREFIX=$$HOME/.local`.
# DESTDIR is honoured for packagers.
install: build-c-lib
install -d $(DESTDIR)$(LIBDIR)
install -d $(DESTDIR)$(INCLUDEDIR)
install -m 0644 crates/fff-c/include/fff.h $(DESTDIR)$(INCLUDEDIR)/fff.h
@if [ -f target/release/libfff_c.dylib ]; then \
install -m 0755 target/release/libfff_c.dylib $(DESTDIR)$(LIBDIR)/libfff_c.dylib; \
echo "Installed $(DESTDIR)$(LIBDIR)/libfff_c.dylib"; \
fi
@if [ -f target/release/libfff_c.so ]; then \
install -m 0755 target/release/libfff_c.so $(DESTDIR)$(LIBDIR)/libfff_c.so; \
echo "Installed $(DESTDIR)$(LIBDIR)/libfff_c.so"; \
fi
@if [ -f target/release/fff_c.dll ]; then \
install -m 0755 target/release/fff_c.dll $(DESTDIR)$(LIBDIR)/fff_c.dll; \
echo "Installed $(DESTDIR)$(LIBDIR)/fff_c.dll"; \
fi
@echo "Installed header $(DESTDIR)$(INCLUDEDIR)/fff.h"
uninstall:
rm -f $(DESTDIR)$(LIBDIR)/libfff_c.dylib
rm -f $(DESTDIR)$(LIBDIR)/libfff_c.so
rm -f $(DESTDIR)$(LIBDIR)/fff_c.dll
rm -f $(DESTDIR)$(INCLUDEDIR)/fff.h
@echo "Removed fff-c from $(DESTDIR)$(PREFIX)"
test-setup:
@if [ ! -d "$(PLENARY_DIR)" ]; then \
echo "Cloning plenary.nvim..."; \
git clone --depth 1 https://github.com/nvim-lua/plenary.nvim $(PLENARY_DIR); \
fi
@if [ ! -d "$(MINI_DIR)" ]; then \
echo "Cloning mini.nvim..."; \
git clone --depth 1 https://github.com/echasnovski/mini.nvim $(MINI_DIR); \
fi
test-rust:
cargo test --workspace --no-default-features --features zlob --exclude fff-nvim
CC ?= cc
CFLAGS ?= -O0 -g -Wall -Wextra -std=c99
TARGET_DIR ?= target/release
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
$(CC) $(CFLAGS) -I $(SMOKE_INCLUDE) -L $(TARGET_DIR) \
-Wl,-rpath,@loader_path/../target/release \
-Wl,-rpath,$$(pwd)/$(TARGET_DIR) \
$(SMOKE_SRC) -lfff_c -o $(SMOKE_BIN)
$(SMOKE_BIN) .
# Alias kept for the `external-tests.yml` workflow naming.
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
@logfile=$$(mktemp); \
trap 'rm -f "$$logfile"' EXIT; \
nvim --headless -u tests/minimal_init.lua \
-c "PlenaryBustedDirectory tests/ {minimal_init = 'tests/minimal_init.lua'}" 2>&1 \
| tee "$$logfile"; \
if grep -qE "SIG(SEGV|ABRT|BUS|FPE|ILL)" "$$logfile"; then \
echo ""; \
echo "FAIL: native crash detected during lua tests"; \
exit 1; \
fi
test-lua-snap: test-setup build
@logfile=$$(mktemp); \
trap 'rm -f "$$logfile"' EXIT; \
nvim --headless -u tests/minimal_init.lua \
-c "lua local ok,err=pcall(require('mini.test').run_file,'tests/picker_ui_snap.lua'); if not ok then io.stderr:write('mini.test failed to load: '..tostring(err)..'\\n'); vim.cmd('cquit 2') end" 2>&1 \
| tee "$$logfile"; \
if grep -qE "SIG(SEGV|ABRT|BUS|FPE|ILL)" "$$logfile"; then \
echo ""; \
echo "FAIL: native crash detected during snapshot tests"; \
exit 1; \
fi
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
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
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; \
cp target/release/fff_c.dll packages/fff-node/bin/ 2>/dev/null || true
test-bun: prepare-bun
cd packages/fff-bun && bun test test/
cd packages/pi-fff && bun test test/
# Same as prepare-bun but puts the compiled binary into the actual npm package location
prepare-bun-packaged: prepare-bun
@machine=$$(uname -m); \
case "$$machine" in \
x86_64|amd64) arch=x64 ;; \
aarch64|arm64) arch=arm64 ;; \
*) echo "unsupported arch: $$machine" >&2; exit 1 ;; \
esac; \
case "$$(uname -s)" in \
Darwin) lib=libfff_c.dylib; pkg=fff-bin-darwin-$$arch ;; \
Linux) lib=libfff_c.so; \
if ldd --version 2>&1 | grep -qi musl; then libc=musl; else libc=gnu; fi; \
pkg=fff-bin-linux-$$arch-$$libc ;; \
MINGW*|MSYS*|CYGWIN*|Windows_NT) lib=fff_c.dll; pkg=fff-bin-win32-$$arch ;; \
*) echo "unsupported OS: $$(uname -s)" >&2; exit 1 ;; \
esac; \
src=target/release/$$lib; \
[ -f "$$src" ] || { echo "missing built library: $$src" >&2; exit 1; }; \
dest=packages/fff-bun/node_modules/@ff-labs/$$pkg; \
rm -rf "$$dest"; mkdir -p "$$dest"; \
cp "$$src" "$$dest/$$lib"; \
printf '{ "name": "@ff-labs/%s", "version": "0.0.0", "main": "%s" }\n' "$$pkg" "$$lib" > "$$dest/package.json"
# Compile a bun example to a standalone executable and run it. Verifies the
# native libfff_c is embedded + loaded from a `bun build --compile` binary.
# The staged bin package is removed before running so success proves the lib
# was embedded, not resolved from disk.
test-bun-compile: prepare-bun-packaged
cd packages/fff-bun && \
if [ "$$(uname -s)" = "Linux" ]; then \
if ldd --version 2>&1 | grep -qi musl; then DEFINE='--define FFF_LIBC="musl"'; \
else DEFINE='--define FFF_LIBC="gnu"'; fi; \
else DEFINE=""; fi; \
bun build --compile $$DEFINE ./examples/glob-bench.ts --outfile ./glob-bench-bin && \
EXE=./glob-bench-bin; [ -f "$$EXE.exe" ] && EXE="$$EXE.exe"; \
rm -rf bin node_modules/@ff-labs; \
"$$EXE" . '**/*.ts' 1 | tee /tmp/fff-compile-e2e.log && \
grep -q 'fff.glob' /tmp/fff-compile-e2e.log
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
test-js: test-bun test-node
# Bug pinning stress test script over fff-node for issue #515
# Just keep it untouched because it's good enough + some stress for SDK
FFF_STRESS_ITERS ?= 50
test-node-stress: prepare-node
cd packages/fff-node && npm run build && \
FFF_STRESS_ITERS=$(FFF_STRESS_ITERS) node test/stress-515.mjs
test: test-rust test-lua test-lua-snap test-version test-bun test-node test-node-stress
test-stress-seeded:
FFF_STRESS_SEED="$${FFF_STRESS_SEED:-$(FFF_STRESS_DEFAULT_SEED)}" \
RUSTFLAGS="$(STRESS_RUSTFLAGS)" \
cargo test --release \
-p fff-search \
--test fuzz_git_watcher_stress \
--no-default-features --features zlob \
-- --nocapture stress_seeded
test-stress-random:
RUSTFLAGS="$(STRESS_RUSTFLAGS)" \
cargo test --release \
-p fff-search \
--test fuzz_git_watcher_stress \
--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 \
--no-default-features --features zlob \
-- --nocapture
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'); \
"
@echo "Set $(PKG) to $(VERSION)"
format-rust:
cargo fmt --all
format-lua:
stylua .
format-ts:
bun format
format: format-rust format-lua format-ts
lint-rust:
cargo clippy --workspace --no-default-features --features zlob -- -D warnings
lint-lua:
~/.luarocks/bin/luacheck .
lint-ts:
bun lint
lint: lint-rust lint-lua lint-ts
check: format lint
FFF_RELEASE_REPO ?= dmtrKovalenko/fff.nvim
FFF_FORMULA_PATH ?= Formula/fff-mcp.rb
FFF_INSTALL_SCRIPT_PATH ?= install-mcp.sh
# Read the sha256 for $1 (filename, no .sha256 suffix). Reads from
# BINARIES_DIR/$1.sha256 when set; otherwise curls the GitHub release.
define fff_fetch_sha
if [ -n "$$BINARIES_DIR" ]; then \
awk '{print $$1}' "$$BINARIES_DIR/$$1.sha256" \
|| { echo "Missing checksum file: $$BINARIES_DIR/$$1.sha256" >&2; exit 1; }; \
else \
curl -fsSL "https://github.com/$(FFF_RELEASE_REPO)/releases/download/v$(VERSION)/$$1.sha256" \
| awk '{print $$1}'; \
fi
endef
bump-homebrew-formula:
@test -n "$(VERSION)" || (echo "VERSION is required. Usage: make bump-homebrew-formula VERSION=0.9.1 [BINARIES_DIR=./binaries]" && exit 1)
@export BINARIES_DIR="$(BINARIES_DIR)"; \
fetch_sha() { $(fff_fetch_sha); }; \
sha_darwin_arm="$$(fetch_sha fff-mcp-aarch64-apple-darwin)"; \
sha_darwin_intel="$$(fetch_sha fff-mcp-x86_64-apple-darwin)"; \
sha_linux_arm="$$(fetch_sha fff-mcp-aarch64-unknown-linux-gnu)"; \
sha_linux_intel="$$(fetch_sha fff-mcp-x86_64-unknown-linux-gnu)"; \
sed -i.bak \
-e 's/^ version "[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*"$$/ version "$(VERSION)"/' \
-e '/fff-mcp-aarch64-apple-darwin"$$/{n;s/sha256 "[a-f0-9]*"/sha256 "'"$$sha_darwin_arm"'"/;}' \
-e '/fff-mcp-x86_64-apple-darwin"$$/{n;s/sha256 "[a-f0-9]*"/sha256 "'"$$sha_darwin_intel"'"/;}' \
-e '/fff-mcp-aarch64-unknown-linux-gnu"$$/{n;s/sha256 "[a-f0-9]*"/sha256 "'"$$sha_linux_arm"'"/;}' \
-e '/fff-mcp-x86_64-unknown-linux-gnu"$$/{n;s/sha256 "[a-f0-9]*"/sha256 "'"$$sha_linux_intel"'"/;}' \
"$(FFF_FORMULA_PATH)" && rm -f "$(FFF_FORMULA_PATH).bak"; \
echo "Bumped $(FFF_FORMULA_PATH) to v$(VERSION)"
bump-install-mcp-sh:
@test -n "$(VERSION)" || (echo "VERSION is required. Usage: make bump-install-mcp-sh VERSION=0.9.1 [BINARIES_DIR=./binaries]" && exit 1)
@export BINARIES_DIR="$(BINARIES_DIR)"; \
fetch_sha() { $(fff_fetch_sha); }; \
sha_linux_intel="$$(fetch_sha fff-mcp-x86_64-unknown-linux-musl)"; \
sha_linux_arm="$$(fetch_sha fff-mcp-aarch64-unknown-linux-musl)"; \
sha_darwin_intel="$$(fetch_sha fff-mcp-x86_64-apple-darwin)"; \
sha_darwin_arm="$$(fetch_sha fff-mcp-aarch64-apple-darwin)"; \
sha_win_intel="$$(fetch_sha fff-mcp-x86_64-pc-windows-msvc.exe)"; \
sha_win_arm="$$(fetch_sha fff-mcp-aarch64-pc-windows-msvc.exe)"; \
sed -i.bak \
-e 's|^PINNED_RELEASE_TAG=".*"|PINNED_RELEASE_TAG="v$(VERSION)"|' \
-e 's|^SHA256_X86_64_UNKNOWN_LINUX_MUSL=".*"|SHA256_X86_64_UNKNOWN_LINUX_MUSL="'"$$sha_linux_intel"'"|' \
-e 's|^SHA256_AARCH64_UNKNOWN_LINUX_MUSL=".*"|SHA256_AARCH64_UNKNOWN_LINUX_MUSL="'"$$sha_linux_arm"'"|' \
-e 's|^SHA256_X86_64_APPLE_DARWIN=".*"|SHA256_X86_64_APPLE_DARWIN="'"$$sha_darwin_intel"'"|' \
-e 's|^SHA256_AARCH64_APPLE_DARWIN=".*"|SHA256_AARCH64_APPLE_DARWIN="'"$$sha_darwin_arm"'"|' \
-e 's|^SHA256_X86_64_PC_WINDOWS_MSVC=".*"|SHA256_X86_64_PC_WINDOWS_MSVC="'"$$sha_win_intel"'"|' \
-e 's|^SHA256_AARCH64_PC_WINDOWS_MSVC=".*"|SHA256_AARCH64_PC_WINDOWS_MSVC="'"$$sha_win_arm"'"|' \
"$(FFF_INSTALL_SCRIPT_PATH)" && rm -f "$(FFF_INSTALL_SCRIPT_PATH).bak"; \
echo "Bumped $(FFF_INSTALL_SCRIPT_PATH) tag + checksums to v$(VERSION)"
CRATES_TO_PUBLISH= fff-grep fff-query-parser fff-search
publish-crates:
@test -n "$(V)" || (echo "V is required. Usage: make publish-crates V=0.2.0" && exit 1)
cargo install cargo-edit --force --locked
cargo set-version $(V) || exit 1;
@for crate in $(CRATES_TO_PUBLISH); do \
cargo publish -p $$crate --allow-dirty $$(if [ -n "$$CI" ]; then echo "--no-verify"; fi) || exit 1; \
done
+825
View File
@@ -0,0 +1,825 @@
<img alt="FFF" src="./assets/logo-orange.png" width="300">
<p>
<i>A file search toolkit for humans and AI agents. Really fast.</i>
</p>
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.
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.
---
Pick what you are interested in:
<details id="mcp-server">
<summary>
<h2>MCP server</h2>
</summary>
Works with Claude Code, Codex, OpenCode, Cursor, Cline, and any MCP-capable client. Fewer grep roundtrips, less wasted context, faster answers.
![Benchmark chart comparing FFF against the built-in AI file-search tools](./chart.png)
### One-line install
Linux / macOS:
```bash
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
```
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.
### Homebrew (macOS / Linux)
```bash
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).
Once the server is connected, ask the agent to "use fff" and it picks up the `ffgrep`, `fffind`, and `fff-multi-grep` tools.
### Recommended agent prompt
Drop this into your project's `CLAUDE.md` or equivalent:
```markdown
For any file search or grep in the current git-indexed directory, use fff tools.
```
### What changes
- Frecency memory. Files you actually open rank higher next time. Warm-up from git touch history runs automatically.
- Definition-first hinting. Lines that look like code definitions are classified on the Rust side, no regex overhead in your prompt.
- Smart-case with auto-fuzzy fallback. `IsOffTheRecord` finds snake_case variants; zero-match queries retry as fuzzy and surface the best approximate hits.
- Git-aware annotations. Modified, untracked, and staged files are tagged so the agent reaches for what you are actively changing.
Source: [`crates/fff-mcp/`](./crates/fff-mcp/).
</details>
The MCP server gives any agent a file search tool that is faster and more token-efficient than the built-in one.
<details id="pi-extension">
<summary>
<h2>Pi agent extension</h2>
</summary>
### Install
```bash
pi install npm:@ff-labs/pi-fff
```
### Modes
Three operating modes, switchable at runtime with `/fff-mode`:
| Mode | What it does |
| ------------------------ | --------------------------------------------------------------------------------- |
| `tools-and-ui` (default) | Adds `ffgrep` and `fffind` tools, replaces `@`-mention autocomplete with FFF. |
| `tools-only` | Only tool injection. Keeps pi's native editor autocomplete. |
| `override` | Replaces pi's built-in `grep`, `find`, and `multi_grep` with FFF implementations. |
Env vars: `PI_FFF_MODE`, `FFF_FRECENCY_DB`, `FFF_HISTORY_DB`. Flags: `--fff-mode`, `--fff-frecency-db`, `--fff-history-db`.
### Agent-facing tools
- `ffgrep`. Content search. Accepts `path`, `exclude` (comma, space, or array; leading `!` optional), `caseSensitive`, `context`, and cursor pagination. Auto-detects regex, falls back to fuzzy on zero exact matches, rejects `.*`-style wildcard-only patterns up front.
- `fffind`. Path and filename search. Matches the whole repo-relative path, not just the filename. Frecency-aware. The weak-match detector flags scattered fuzzy noise before it floods the agent's context.
### Commands
- `/fff-mode [tools-and-ui | tools-only | override]`. Show or switch the mode.
- `/fff-health`. Picker, frecency, and git integration status.
- `/fff-rescan`. Force a rescan.
Source: [`packages/pi-fff/`](./packages/pi-fff/).
</details>
The Pi extension swaps pi's native tools for FFF implementations and feeds the interactive editor's `@`-mention autocomplete from the frecency-ranked index.
<details id="neovim-plugin">
<summary>
<h2>fff.nvim</h2>
</summary>
Demo on the Linux kernel repo (100k files, 8GB):
https://github.com/user-attachments/assets/5d0e1ce9-642c-4c44-aa88-01b05bb86abb
### Installation
#### lazy.nvim
```lua
{
'dmtrKovalenko/fff.nvim',
build = function()
-- downloads a prebuilt binary or falls back to cargo build
require("fff.download").download_or_build_binary()
end,
-- for nixos:
-- build = "nix run .#release",
opts = {
debug = {
enabled = true,
show_scores = true,
},
},
lazy = false, -- the plugin lazy-initialises itself
keys = {
{ "ff", function() require('fff').find_files() end, desc = 'FFFind files' },
{ "fg", function() require('fff').live_grep() end, desc = 'LiFFFe grep' },
{ "fz",
function() require('fff').live_grep({ grep = { modes = { 'fuzzy', 'plain' } } }) end,
desc = 'Live fffuzy grep',
},
{ "fw",
function() require('fff').live_grep_under_cursor() end,
mode = { 'n', 'x' },
desc = 'Search current word / selection',
},
},
}
```
#### vim.pack
```lua
vim.pack.add({ 'https://github.com/dmtrKovalenko/fff.nvim' })
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
require('fff.download').download_or_build_binary()
end
end,
})
vim.g.fff = {
lazy_sync = true,
debug = { enabled = true, show_scores = true },
}
vim.keymap.set('n', 'ff', function() require('fff').find_files() end, { desc = 'FFFind files' })
```
### Public API
```lua
require('fff').find_files() -- find files in current repo
require('fff').live_grep() -- live content grep
require('fff').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
require('fff').change_indexing_directory(new_path) -- change root
-- Programmatic search (no UI). Useful for plugin integrations.
require('fff').file_search(query, opts) -- fuzzy search files / dirs / mixed
require('fff').content_search(query, opts) -- programmatic grep
```
#### `file_search(query, opts)`
Returns a structured result `{ items, scores, total_matched, total_files?, total_dirs?, location? }`. Each item has a `type` field (`"file"` or `"directory"`) and `name` / `relative_path`. File items also expose `size`, `modified`, `git_status`, `is_binary`, and frecency scores.
```lua
local r = require('fff').file_search('button', {
mode = 'mixed', -- 'files' (default) | 'directories' | 'mixed'
max_results = 50,
page = 0, -- 0-based pagination
current_file = nil, -- path to deprioritize for distance scoring
max_threads = 4,
cwd = nil, -- switch indexed root if different (see below)
wait_for_index_ms = nil, -- override the default scan wait timeout
})
for _, item in ipairs(r.items) do
print(item.type, item.relative_path)
end
```
#### `content_search(query, opts)`
Returns a `GrepResult` `{ items, total_matched, total_files_searched, total_files, filtered_file_count, next_file_offset, regex_fallback_error? }`. Each match item has `relative_path`, `name`, `line_number`, `col`, `line_content`, `match_ranges`, plus the same file metadata as `file_search`.
```lua
local r = require('fff').content_search('TODO', {
mode = 'plain', -- 'plain' (default) | 'regex' | 'fuzzy'
max_file_size = 10 * 1024 * 1024,
max_matches_per_file = 100,
smart_case = true,
page_size = 50,
file_offset = 0,
time_budget_ms = 0,
trim_whitespace = false,
cwd = nil, -- switch indexed root if different
wait_for_index_ms = nil, -- override the default scan wait timeout
})
for _, m in ipairs(r.items) do
print(string.format('%s:%d %s', m.relative_path, m.line_number, m.line_content))
end
```
Both functions accept the same constraint syntax as the UI pickers (e.g. `git:modified`, `*.rs`, `!test/`, glob patterns).
#### `cwd` and indexing
Both `file_search` and `content_search` honour an optional `cwd` field. The first call to either function lazily initialises the picker at `config.base_path` (your Neovim cwd by default).
- If `cwd` matches the currently indexed root, the call returns immediately against the existing index.
- If `cwd` differs, the picker is re-indexed at the new root and the call **blocks** (default up to 10 s) until the new picker is installed and its initial scan completes — so callers always get results from the right tree.
- If the index is still warming up after a `change_indexing_directory`, you can pass `wait_for_index_ms = N` to block for up to `N` ms regardless of whether `cwd` triggered the swap. Pass `0` to skip waiting entirely (useful for fire-and-forget calls where partial results are acceptable).
- Invalid or non-existent `cwd` paths return an empty result and emit an error via `vim.notify`.
### Commands
- `:FFFScan`. Rescan files.
- `:FFFRefreshGit`. Refresh git status.
- `:FFFClearCache [all|frecency|files]`. Clear caches.
- `:FFFHealth`. Health check.
- `:FFFDebug [on|off|toggle]`. Toggle the scoring display.
- `:FFFOpenLog`. Open `~/.local/state/nvim/log/fff.log`.
### Configuration
Defaults are sensible. Override only what you care about.
```lua
require('fff').setup({
base_path = vim.fn.getcwd(),
prompt = '> ',
title = 'FFFiles',
max_results = 100,
max_threads = 4,
lazy_sync = true,
prompt_vim_mode = false,
follow_symlinks = false,
-- Allow indexing the user's $HOME directory. Enabled by default.
-- Disable if you strictly sure you don't want this, as it makes whole fff error hard
enable_home_dir_scanning = true,
-- Allow indexing a filesystem root (e.g. `/`, `C:\`). Disabled by default
enable_fs_root_scanning = false,
layout = {
height = 0.8,
width = 0.8,
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'
flex = { size = 130, wrap = 'top' },
min_list_height = 10, -- do not display anything except the list below this threshold
show_scrollbar = true,
path_shorten_strategy = 'middle_number', -- 'middle_number' | 'middle' | 'end' | 'start'
anchor = 'center',
},
preview = {
enabled = true,
max_size = 10 * 1024 * 1024,
chunk_size = 8192,
binary_file_threshold = 1024,
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
line_numbers = false,
cursorlineopt = 'both',
wrap_lines = false,
filetypes = {
svg = { wrap_lines = true },
markdown = { wrap_lines = true },
text = { wrap_lines = true },
},
},
keymaps = {
close = '<Esc>',
select = '<CR>',
select_split = '<C-s>',
select_vsplit = '<C-v>',
select_tab = '<C-t>',
move_up = { '<Up>', '<C-p>' },
move_down = { '<Down>', '<C-n>' },
preview_scroll_up = '<C-u>',
preview_scroll_down = '<C-d>',
toggle_debug = '<F2>',
cycle_grep_modes = '<S-Tab>',
-- 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>' },
cycle_previous_query = '<C-Up>',
toggle_select = '<Tab>',
send_to_quickfix = '<C-q>',
focus_list = '<leader>l',
focus_preview = '<leader>p',
},
frecency = {
enabled = true,
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
},
history = {
enabled = true,
db_path = vim.fn.stdpath('data') .. '/fff_queries',
min_combo_count = 3,
combo_boost_score_multiplier = 100,
},
git = {
status_text_color = false, -- true to color filenames by git status
},
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,
},
grep = {
max_file_size = 10 * 1024 * 1024,
max_matches_per_file = 100,
smart_case = true,
time_budget_ms = 150,
modes = { 'plain', 'regex', 'fuzzy' },
trim_whitespace = false,
enable_filename_constraint = false, -- treat filename-like tokens (e.g. `score.rs`) in a grep query as a file-path filter scoping the search; off = searched as literal text
location_format = ':%d:%d', -- printf format for line:col prefix in grep results, e.g. ':%d' for line-only
},
debug = {
enabled = false, -- show the file info panel next to the preview
show_scores = false, -- inline scores in the file list
-- Per-section toggles for the file info panel. Accepts a boolean shorthand
-- (`show_file_info = true|false`) to flip everything at once. The panel
-- adapts to width: narrow renders sections vertically, wide renders them
-- as a two-column grid. Disable a section to also shrink the panel.
show_file_info = {
file_info = true, -- size, type, git status, frecency
score_breakdown = true, -- total + match type, bonuses, modifiers, penalty
-- modified + accessed timestamps; pass a table to hide individual rows:
-- timings = { modified = false, accessed = true }
timings = true,
full_path = true, -- relative path at the bottom (wraps if too long)
},
},
logging = {
-- logs will be written in a parent directory of this file path in files like
-- `<stem>+<UTC-timestamp>+<pid>.<ext>`. Run :FFFOpenLog to open current one
log_file = vim.fn.stdpath('log') .. '/fff.log',
log_level = 'info',
retain_runs = 20,
},
})
```
### Live grep modes
`<S-Tab>` cycles between `plain`, `regex`, and `fuzzy`. The list is configurable via `grep.modes`, and single-mode setups hide the indicator entirely.
Per-call override:
```lua
require('fff').live_grep({ grep = { modes = { 'fuzzy', 'plain' } } })
require('fff').live_grep({ query = 'search term' }) -- pre-fill
```
### Constraints
Both find and grep accept these tokens to refine a query:
- `git:modified`. One of `modified`, `staged`, `deleted`, `renamed`, `untracked`, `ignored`.
- `test/`. Any deeply nested children of `test/`.
- `!something`, `!test/`, `!git:modified`. Exclusion.
- `./**/*.{rs,lua}`. Any valid glob, powered by [zlob](https://github.com/dmtrKovalenko/zlob).
Grep-only:
- `*.md`, `*.{c,h}`. Extension filter.
- `src/main.rs`. Grep inside a single file.
Mix freely: `git:modified src/**/*.rs !src/**/mod.rs user controller`.
### Open in invoking window
By default fff.nvim will try to open a file in the most suitable window, so any non-file buffers are not affected. You can customize or disable this by providing:
```lua
require('fff').setup({
select = {
select_window = function(_current_buf, _action) return nil end,
},
})
```
Caveat: the chosen file replaces the buffer in the invoking window even if it's a non-modifiable / special buftype. `winfixbuf` windows still fall back to `:split` to avoid `E1513`.
### Multi-select and quickfix
- `<Tab>`. Toggle selection (shows a thick `▊` in the signcolumn).
- `<C-q>`. Send selected files to the quickfix list and close the picker.
### Git status highlighting
Sign-column indicators are on by default. To color filename text by git status, set `git.status_text_color = true` and adjust the `hl.git_*` groups. See `:help fff.nvim` for the full list.
### Float colors
The picker maps its float content to `NormalFloat` (via `hl.normal`) and the border to `FloatBorder`. Default `FloatBorder` links to `NormalFloat`, so border and content share a background out of the box and the picker reads as a single popup. Override `hl.normal = 'Normal'` to make the picker blend with the editor instead.
For finer control, set `hl.winhl` to override the per-window `winhighlight`. It accepts either a single string applied to every picker window, or a table with optional `prompt`, `list`, `preview`, and `file_info` keys. Missing keys fall back to the default built from `hl.normal`, `hl.border`, and `hl.title`.
```lua
-- Apply the same winhighlight to all picker windows
hl = { winhl = 'Normal:NormalFloat,FloatBorder:FloatBorder,FloatTitle:Title' }
-- Or override specific windows only
hl = {
winhl = {
prompt = 'Normal:Pmenu,FloatBorder:FloatBorder',
list = 'Normal:NormalFloat,FloatBorder:FloatBorder',
preview = 'Normal:NormalFloat,FloatBorder:FloatBorder',
},
}
```
### File info panel
Enable with `debug.enabled = true`. The panel sits above the preview and shows
file metadata, score breakdown, timestamps and the full absolute path. It
adapts to the panel width: at narrow widths sections stack vertically (B2),
at wide widths sections render as a two-column grid (H2). Each section can be
disabled individually via `debug.show_file_info`.
Customise the panel via `hl`:
| key | default | used for |
| ----------------------- | ----------------- | ---------------------------------- |
| `file_info_section` | `Title` | section header label |
| `file_info_separator` | `FloatBorder` | dashes that act as section borders |
| `file_info_label` | `Comment` | row labels (Size, Type, Git, ...) |
| `file_info_value` | `Normal` fg | plain values |
| `file_info_value_dim` | `NonText` | dim values, separators inside rows |
| `file_info_size` | `Number` | file size value |
| `file_info_type` | `Type` | filetype value |
| `file_info_path` | `Directory` | full path |
| `file_info_total_score` | bold + `Number` | total score (bold) |
| `file_info_match_type` | bold + `Special` | match type (bold) |
| `file_info_score_pos` | `DiagnosticOk` | positive score components |
| `file_info_score_neg` | `DiagnosticError` | negative score components |
### File filtering
FFF honours `.gitignore`. For picker-only ignores that do not touch git, add a sibling `.ignore` file:
```gitignore
*.md
docs/archive/**/*.md
```
Run `:FFFScan` to force a rescan.
### Troubleshooting
- `:FFFHealth` verifies picker init, optional dependencies, and DB connectivity.
- `:FFFOpenLog` opens the current session's log file.
- Historical log files are stored near the main log file `<state>/log/fff+<UTC-timestamp>+<pid>.log` (up to 20 files)
- For a crash backtrace, run `lldb -- nvim` or `gdb -- nvim` and reproduce
</details>
The best file search picker for neovim. Period. Faster and more intuitive queries, frecency ranking, definition classification and much more.
<details id="node-sdk">
<summary>
<h2>Node & Bun SDK</h2>
</summary>
```bash
npm install @ff-labs/fff-node
# or
bun add @ff-labs/fff-node
```
```ts
import { FileFinder } from "@ff-labs/fff-node";
const finder = FileFinder.create({ basePath: process.cwd(), aiMode: true });
if (!finder.ok) throw new Error(finder.error);
await finder.value.waitForScan(10_000);
const files = finder.value.fileSearch("incognito profile", { pageSize: 20 });
const hits = finder.value.grep("GetOffTheRecordProfile", {
mode: "plain",
smartCase: true,
beforeContext: 1,
afterContext: 1,
classifyDefinitions: true,
});
// Run extremely fast glob matching which is significantly (10-100 times) faster than Bun's and Node implementation
const rustFiles = finder.value.glob("**/*.rs", { pageSize: 100 });
finder.value.destroy();
```
Every method returns a `Result<T>` (`{ ok: true, value } | { ok: false, error }`). Full type reference: [`packages/fff-node/src/types.ts`](./packages/fff-node/src/types.ts).
</details>
TypeScript wrapper over the C library for nodejs and bun. Build custom agent tools, CLIs, or IDE integrations on top of FFF.
<details id="rust-crate">
<summary>
<h2>Rust crate</h2>
</summary>
### Add the dependency
FFF is written in Rust, so this is the lowest-overhead way to use it.
```toml
[dependencies]
fff-search = "0.6"
```
Full API documentation: [docs.rs/fff-search](https://docs.rs/fff-search/latest/fff_search/).
</details>
Native rust crate that is performing all the search. Stable and well documented.
<details id="c-library">
<summary>
<h2>C library</h2>
</summary>
### Build
```bash
# Builds only the C cdylib (fastest):
make build-c-lib
# or directly with cargo:
cargo build --release -p fff-c --features zlob
```
> The `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.
### Install
```bash
# System-wide (needs sudo):
sudo make install
# User-local, no sudo:
make install PREFIX=$HOME/.local
# Staged install for packagers:
make install DESTDIR=/tmp/pkgroot PREFIX=/usr
```
Drops `libfff_c.{so,dylib,dll}` into `$(PREFIX)/lib` and the header into `$(PREFIX)/include/fff.h`. Remove with `make uninstall`, which honours the same `PREFIX` and `DESTDIR`.
Link against it after install:
```bash
cc my_app.c -lfff_c -o my_app
```
Ensure `$(PREFIX)/lib` is on your runtime library search path (`LD_LIBRARY_PATH` on Linux, `DYLD_LIBRARY_PATH` on macOS, or an entry in `/etc/ld.so.conf.d/`).
### Minimal example
```c
#include <fff.h>
#include <stdio.h>
int main(void) {
FffResult *res = fff_create_instance(
".", // base_path
"", // frecency_db_path (empty = default)
"", // history_db_path
false, // use_unsafe_no_lock
true, // enable_mmap_cache
true, // enable_content_indexing
true, // watch
false // ai_mode
);
if (!res->success) {
fprintf(stderr, "init failed: %s\n", res->error);
fff_free_result(res);
return 1;
}
void *handle = res->handle;
fff_free_result(res);
// Search
FffResult *search = fff_search(handle, "main.rs", "", 0, 0, 20, 100, 3);
// ... read FffSearchResult from search->handle, then fff_free_search_result()
fff_destroy(handle);
return 0;
}
```
### Versioned options struct (preferred)
For instance creation use [`FffCreateOptions`](./crates/fff-c/include/fff.h) — a
versioned struct that evolves without ABI breaks. C99 designated
initializers keep call sites readable and zero-init unspecified fields:
```c
FffResult *res = fff_create_instance_with(&(FffCreateOptions){
.version = FFF_CREATE_OPTIONS_VERSION,
.base_path = "/path/to/repo",
.ai_mode = true,
.watch = true,
.enable_fs_root_scanning = false, // off by default
.enable_home_dir_scanning = false, // off by default
});
```
### Glob-only search
`fff_glob` filters indexed files by a single glob pattern, ranks by frecency,
paginates — bypasses the regular query parser entirely. Use this when you
already have a literal glob (`*.rs`, `**/*.test.ts`, `src/**`) and don't want
fuzzy matching layered on top.
```c
FffResult *res = fff_glob(handle, "**/*.rs", "", 0, 0, 100);
// FffSearchResult in res->handle, free with fff_free_search_result.
```
### Notes
- Every function returning `FffResult*` allocates with Rust's `Box`. Free with `fff_free_result`, do not use malloc's free
- Payloads (search results, grep results, scan progress) have their own dedicated free functions listed in the header.
- C strings returned in the `handle` field (e.g. from `fff_get_base_path`) are freed with `fff_free_string`.
Source: [`crates/fff-c/`](./crates/fff-c/).
</details>
Stable C ABI. Bind from C/C++, Zig, Go via cgo, Python via ctypes, or anything with C FFI.
<details id="python-bindings">
<summary>
<h2>Python bindings</h2>
</summary>
### Install
```bash
pip install fff-search
```
Or build and install from source:
```bash
cd packages/fff-python
uv sync --all-extras
uv run maturin develop --release
```
### Basic usage
```python
from fff import FileFinder
with FileFinder("/path/to/project", watch=False) as finder:
finder.wait_for_scan_blocking(timeout_ms=5000)
result = finder.search("main")
for item, score in zip(result.items, result.scores):
print(f"{item.relative_path}: {score.total}")
hits = finder.grep("class Profile", mode="plain", before_context=1, after_context=1)
```
### Async usage
`wait_for_scan` is a coroutine that polls scan status and yields to the event
loop, so it never blocks other tasks. Use `wait_for_scan_blocking` from
synchronous code.
```python
import asyncio
from fff import FileFinder
async def main():
with FileFinder("/path/to/project", watch=False) as finder:
await finder.wait_for_scan(timeout_ms=5000)
result = finder.search("main")
print(result)
asyncio.run(main())
```
### What you get
- `search`, `glob`, `directory_search`, `mixed_search` — frecency-ranked fuzzy file/dir search
- `grep` / `multi_grep` — plain, regex, or fuzzy content search with context lines and cursor pagination
- `track_query` / `get_historical_query` — optional frecency and query-history databases
- `reindex`, `refresh_git_status`, `scan_progress`, `health_check` — lifecycle and diagnostics
Typed result objects (`FileItem`, `Score`, `GrepMatch`, …) with `py.typed`
stubs included. Ships as an `abi3` wheel compatible with Python 3.10+.
Source: [`packages/fff-python/`](./packages/fff-python/).
</details>
Native Python bindings built with PyO3. Use them for notebooks, agent scripts, or any Python tool that needs fast file search.
---
## What is FFF and why use it over ripgrep or fzf?
FFF is a file search library, not a CLI. Ripgrep and fzf are great tools, but they are command-line programs: every call forks a new process, re-reads `.gitignore`, re-stats directories, and rebuilds whatever state it needs in memory before it can answer. That is fine when you grep once from a shell. It is bad when an editor or an AI agent wants to run hundreds of searches per session.
FFF keeps the index and the file cache resident in one long-lived process and exposes the same Rust core through four thin layers: a native crate (`fff-search`), a C library (`libfff_c`), a Node/Bun SDK (`@ff-labs/fff-node`), and an MCP server. You call `FileFinder.create()` once, then every subsequent search hits warm memory. On a 500k-file Chromium checkout, that is the difference between 3-9 **SECONDS** per ripgrep spawn and sub-10 ms per FFF query.
Algorithm for fuzzy matching is much more comprehensive than fzf's algorithm it is **typo-resistant** and we provide a query language with additional constraint parsing for prefiltering e.g. "\*.rs !test/ shcema" is a perfectly valid query for fff, but fzf wouldn't find anything even for a single typo in "shcema".
### Why a programmatic API matters
- No process spawn. Every call stays in-process and avoids the fork, exec, argv parsing, and stdout pipe setup that dominates short `rg` invocations.
- One FS walk, metadata collection, and parse of `.gitignore`. The ignore walker runs once at scan time and the result is reused for every search.
- Results come back as typed objects, not text you have to re-parse. The SDK gives you `{ relativePath, lineNumber, lineContent, gitStatus, totalFrecencyScore, isDefinition, ... }` directly.
- Cursor pagination that survives across calls. Ripgrep has no concept of "page 2 of these matches"; FFF does.
- A long-lived process opens up optimisations that a one-shot CLI cannot apply: warm caches, incremental re-indexing, cross-query frecency, and shared SIMD state.
### What the core actually does
- **Frecency-ranked fuzzy matching.** Every indexed file carries an access score and a modification score. Searches rank files you have opened recently and frequently above cold results. This is the same idea as VS Code's recently-opened list, but applied to every search result, not just a sidebar.
- **Typo-resistant matching for both paths and content.** Smith-Waterman fuzzy scoring is available on the grep path; path search uses SIMD-accelerated fuzzy matching (via the [`frizbee`](https://github.com/saghen/frizbee)-derived core) that survives dropped characters and reorderings.
- **Content grep with three modes.** Plain literal (SIMD memmem), regex (the Rust `regex` crate), and fuzzy (Smith-Waterman per line). Auto-detects which mode to use from the pattern, falls back to fuzzy when a plain search returns zero hits.
- **Multi-pattern OR search.** SIMD Aho-Corasick for "find any of these 20 identifiers at once", which is faster than regex alternation and a lot faster than 20 separate ripgrep runs.
- **Background file watcher.** The index updates as files change. You never pay for a rescan on the hot path.
- **Git status awareness.** Modified, staged, untracked, and ignored states are cached and returned with every result, so callers can sort or filter them without shelling out to git. The watcher talks to libgit2 directly instead of spawning the `git` CLI.
- **Definition classifier.** A byte-level scanner on the Rust side tags lines that start with `struct`, `fn`, `class`, `def`, `impl`, and friends.
### Performance choices that matter
- Efficient memory allocator and memory allocation strategy (see next paragraph). By default we use `mimaloc`
- Parallel multi thread search pipeline that is not contaganted by the orchistration logic
- SIMD first algorithms for everything. Efficinet & non-allocating sorting.
- Platform specific optimizations for FS ([getdents64](https://linux.die.net/man/2/getdents64), NTFS api on windows and others)
- Lightweight on the flight content index for realtime even typo resistant grep
- Memory mapped content cache. We store some of the files in virtual memory (the amount is limited)
- Single contiguous arena storage of string chunks. Significantly reduces the amount of memory to work with and dramatically increases CPU cache hits.
### Memory allocation
Yes, fff fundamentally requires more memory than calling a single child process. That is the primary source of the speedup. In practice, alongside one of the most popular file search pickers for Neovim, [fff ends up using less RAM than a burst of ripgrep invocations](https://x.com/neogoose_btw/status/2041606853155811442).
FFF also keeps a content index, around 360 bytes per indexed file, so roughly 36 MB for a 100k-file repo. Not every file is indexed - binaries, oversized files, and anything not eligible for grep are skipped. If even that footprint is too much, the index can be backed by a memory-mapped file instead of anonymous RAM.
### What this means in practice
If you are building an agent, an IDE extension, a pre-commit check, or any long-running tool that searches the same repository many times, calling FFF as a library is dramatically cheaper than shelling out to ripgrep. The tradeoff is real memory: FFF keeps the index in RAM and warms the content cache. On a 14k-file repo that costs about 26 MB resident. On a 500k-file repo like Chromium, expect a few hundred MB. In exchange, every single search is enriched with git status, frecency ranking, file metadata, timestamps of last access and edit and so on.
If you are running one grep from a terminal, `rg` is still the right tool. If you run dozens of them inside the same process, FFF will pay for itself starting from the second call. If you work on AI agent fff will finish preparation work before your AI will have a chance to call it.
### How it compares
- **ripgrep**: FFF uses the same underlying regex engine and more advanced plain text matching algorithms. Stores content index and file tree. Main wins on repeated-search workloads. Loses on "grep once from bash and exit."
- **fzf**: FFF's path search is fuzzy like fzf, but it is also frecency-aware and git-aware, and ships a more typo-tolerant algorithm. fzf is a pure match-and-filter tool; FFF ranks results by how often you actually open them.
- **Telescope / fzf-lua / snacks.picker**: FFF ships its own Neovim picker with the same ranking the MCP server and SDK use. The picker is optional; the core is the same.
- **Tantivy or other full-text search engines**: different class of tool. Tantivy indexes documents for query-time scoring at scale. FFF is scoped to one repository and optimised for sub-10 ms response. It does not persist an inverted index on disk.
---
## Repository layout
- `crates/fff-search`, `crates/fff-grep`, `crates/fff-query-parser` - Rust core.
- `crates/fff-c` - C FFI used by every language binding.
- `crates/fff-nvim` - Lua/mlua bindings for the Neovim plugin.
- `crates/fff-mcp` - MCP server binary.
- `packages/fff-node` - Node.js SDK (`@ff-labs/fff-node`).
- `packages/fff-bun` - Bun SDK (`@ff-labs/fff-bun`).
- `packages/pi-fff` - pi extension (`@ff-labs/pi-fff`).
- `lua/` - Neovim-side plugin code.
## Contributing
Bug reports and pull requests welcome. Agentic coding tools are welcome to be used, but human review is mandatory.
## License
[MIT](./LICENSE) & open source forever.
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`dmtrKovalenko/fff`
- 原始仓库:https://github.com/dmtrKovalenko/fff
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+26
View File
@@ -0,0 +1,26 @@
[files]
extend-exclude = ["/CHANGELOG.md", "data/filetypes/base.lua"]
[default.extend-words]
noice = "noice"
fo = "fo"
ba = "ba"
ue = "ue"
# file extensions that look like typos
thm = "thm"
# some typos we use for tests
comparsion = "comparsion"
modfiers = "modfiers"
shcema = "shcema"
[default]
extend-ignore-re = [
# git commit hashes in CHANGELOG.md
"\\[[0-9a-f]{7}\\]",
# Line ignore with trailing # spellchecker:disable-line
"(?Rm)^.*(#|--|//)\\s*spellchecker:disable-line$",
# Line block with # spellchecker:<on|off>
"(?s)(#|--|//|)\\s*spellchecker:off.*?\\n\\s*(#|--|//)\\s*spellchecker:on",
# Vim doc formatting
"vim:.+:"
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

+35
View File
@@ -0,0 +1,35 @@
{
"$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"
}
}
}
}
+417
View File
@@ -0,0 +1,417 @@
{
"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=="],
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

+21
View File
@@ -0,0 +1,21 @@
[package]
name = "fff-c"
version = "0.9.6"
edition = "2024"
description = "Raw C api of FFF file finder"
license = "MIT"
[lib]
crate-type = ["cdylib"]
[features]
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", default-features = false }
fff-query-parser = { path = "../fff-query-parser", version = "0.9.6", default-features = false }
serde_json = "1.0"
+33
View File
@@ -0,0 +1,33 @@
language = "C"
header = "/* Generated by cbindgen — do not edit manually. */"
include_guard = "FFF_C_H"
include_version = true
no_includes = true
sys_includes = ["stdint.h", "stdbool.h", "stddef.h"]
[export]
include = [
"FffResult",
"FffSearchResult", "FffFileItem", "FffScore", "FffLocation",
"FffGrepResult", "FffGrepMatch", "FffMatchRange",
"FffScanProgress",
]
[export.rename]
"FffResult" = "FffResult"
"FffSearchResult" = "FffSearchResult"
"FffFileItem" = "FffFileItem"
"FffScore" = "FffScore"
"FffLocation" = "FffLocation"
"FffGrepResult" = "FffGrepResult"
"FffGrepMatch" = "FffGrepMatch"
"FffMatchRange" = "FffMatchRange"
"FffScanProgress" = "FffScanProgress"
[fn]
sort_by = "None"
# Translate `#[deprecated]` on extern "C" fns into a real C compiler
# attribute so callers get a warning when they use a removed/legacy entry.
# `{}` is substituted with the Rust deprecation note as a C string literal
# (already quoted) — do not wrap in extra quotes.
deprecated_with_note = "__attribute__((deprecated({})))"
File diff suppressed because it is too large Load Diff
+882
View File
@@ -0,0 +1,882 @@
//! Stable accessor functions for `fff-c` FFI struct fields.
//!
//! # Why this exists
//!
//! `fff-c` exposes its result types as plain `#[repr(C)]` structs. External
//! consumers (Emacs Lisp via `emacs-ffi`, Python `ctypes`, etc.) that access
//! fields by hardcoding byte offsets break silently whenever the struct layout
//! changes — a new field shifts every subsequent offset with no compile-time
//! warning.
//!
//! These functions turn field access into a **stable named API**: callers bind
//! to a symbol name once and are fully insulated from layout changes.
//!
//! # Usage from Emacs Lisp (example)
//!
//! ```elisp
//! (define-ffi-function fff--grep-match-line-content
//! "fff_grep_match_get_line_content" :pointer [:pointer] fff--library)
//!
//! (ffi-get-c-string (fff--grep-match-line-content match-ptr))
//! ```
//!
//! # Array iteration
//!
//! To walk result arrays use `fff_search_result_get_item`,
//! `fff_grep_result_get_match`, and `fff_search_result_get_score` — these are
//! defined in the main `lib.rs` FFI surface alongside the search functions.
use std::ffi::c_char;
use std::ptr;
use crate::ffi_types::{FffFileItem, FffGrepMatch, FffGrepResult, FffMatchRange, FffSearchResult};
// ── FffFileItem ──────────────────────────────────────────────────────────────
/// Returns the relative path of a file item (e.g. `"src/main.rs"`).
///
/// Returns null if `item` is null. The returned pointer is valid for the
/// lifetime of the owning `FffSearchResult`; do not free it directly.
///
/// ## Safety
/// `item` must be a valid `FffFileItem` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_file_item_get_relative_path(
item: *const FffFileItem,
) -> *const c_char {
if item.is_null() {
return ptr::null();
}
unsafe { (*item).relative_path }
}
/// Returns the file-name component of a file item (e.g. `"main.rs"`).
///
/// Returns null if `item` is null. Do not free the returned pointer.
///
/// ## Safety
/// `item` must be a valid `FffFileItem` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_file_item_get_file_name(item: *const FffFileItem) -> *const c_char {
if item.is_null() {
return ptr::null();
}
unsafe { (*item).file_name }
}
/// Returns the git status string for a file item (e.g. `"M "`, `"??"`)
/// or null if git is unavailable, the file is untracked, or `item` is null.
///
/// Do not free the returned pointer.
///
/// ## Safety
/// `item` must be a valid `FffFileItem` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_file_item_get_git_status(item: *const FffFileItem) -> *const c_char {
if item.is_null() {
return ptr::null();
}
unsafe { (*item).git_status }
}
/// Returns the file size in bytes. Returns `0` if `item` is null.
///
/// ## Safety
/// `item` must be a valid `FffFileItem` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_file_item_get_size(item: *const FffFileItem) -> u64 {
if item.is_null() {
return 0;
}
unsafe { (*item).size }
}
/// Returns the last-modified time as seconds since the UNIX epoch.
/// Returns `0` if `item` is null.
///
/// ## Safety
/// `item` must be a valid `FffFileItem` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_file_item_get_modified(item: *const FffFileItem) -> u64 {
if item.is_null() {
return 0;
}
unsafe { (*item).modified }
}
/// Returns the combined frecency score. Returns `0` if `item` is null.
///
/// ## Safety
/// `item` must be a valid `FffFileItem` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_file_item_get_total_frecency_score(item: *const FffFileItem) -> i64 {
if item.is_null() {
return 0;
}
unsafe { (*item).total_frecency_score }
}
/// Returns the access-based frecency score. Returns `0` if `item` is null.
///
/// ## Safety
/// `item` must be a valid `FffFileItem` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_file_item_get_access_frecency_score(item: *const FffFileItem) -> i64 {
if item.is_null() {
return 0;
}
unsafe { (*item).access_frecency_score }
}
/// Returns the modification-based frecency score. Returns `0` if `item` is null.
///
/// ## Safety
/// `item` must be a valid `FffFileItem` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_file_item_get_modification_frecency_score(
item: *const FffFileItem,
) -> i64 {
if item.is_null() {
return 0;
}
unsafe { (*item).modification_frecency_score }
}
/// Returns `true` if the file was detected as binary. Returns `false` if `item` is null.
///
/// ## Safety
/// `item` must be a valid `FffFileItem` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_file_item_get_is_binary(item: *const FffFileItem) -> bool {
if item.is_null() {
return false;
}
unsafe { (*item).is_binary }
}
// ── FffGrepMatch ─────────────────────────────────────────────────────────────
/// Returns the relative path of the file containing this grep match.
///
/// Returns null if `m` is null. Do not free the returned pointer.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_match_get_relative_path(m: *const FffGrepMatch) -> *const c_char {
if m.is_null() {
return ptr::null();
}
unsafe { (*m).relative_path }
}
/// Returns the file-name component of the file containing this grep match.
///
/// Returns null if `m` is null. Do not free the returned pointer.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_match_get_file_name(m: *const FffGrepMatch) -> *const c_char {
if m.is_null() {
return ptr::null();
}
unsafe { (*m).file_name }
}
/// Returns the git status string for the matched file (e.g. `"M "`, `"??"`)
/// or null if git is unavailable, the file is untracked, or `m` is null.
///
/// Do not free the returned pointer.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_match_get_git_status(m: *const FffGrepMatch) -> *const c_char {
if m.is_null() {
return ptr::null();
}
unsafe { (*m).git_status }
}
/// Returns the full text content of the matched line.
///
/// Returns null if `m` is null. Do not free the returned pointer.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_match_get_line_content(m: *const FffGrepMatch) -> *const c_char {
if m.is_null() {
return ptr::null();
}
unsafe { (*m).line_content }
}
/// Returns the 1-based line number of the match within its file.
/// Returns `0` if `m` is null.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_match_get_line_number(m: *const FffGrepMatch) -> u64 {
if m.is_null() {
return 0;
}
unsafe { (*m).line_number }
}
/// Returns the 0-based column of the match start within its line.
/// Returns `0` if `m` is null.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_match_get_col(m: *const FffGrepMatch) -> u32 {
if m.is_null() {
return 0;
}
unsafe { (*m).col }
}
/// Returns the byte offset of the match start from the beginning of the file.
/// Returns `0` if `m` is null.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_match_get_byte_offset(m: *const FffGrepMatch) -> u64 {
if m.is_null() {
return 0;
}
unsafe { (*m).byte_offset }
}
/// Returns the file size in bytes for the matched file. Returns `0` if `m` is null.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_match_get_size(m: *const FffGrepMatch) -> u64 {
if m.is_null() {
return 0;
}
unsafe { (*m).size }
}
/// Returns the combined frecency score for the matched file.
/// Returns `0` if `m` is null.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_match_get_total_frecency_score(m: *const FffGrepMatch) -> i64 {
if m.is_null() {
return 0;
}
unsafe { (*m).total_frecency_score }
}
/// Returns the access-based frecency score for the matched file.
/// Returns `0` if `m` is null.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_match_get_access_frecency_score(m: *const FffGrepMatch) -> i64 {
if m.is_null() {
return 0;
}
unsafe { (*m).access_frecency_score }
}
/// Returns the modification-based frecency score for the matched file.
/// Returns `0` if `m` is null.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_match_get_modification_frecency_score(
m: *const FffGrepMatch,
) -> i64 {
if m.is_null() {
return 0;
}
unsafe { (*m).modification_frecency_score }
}
/// Returns the last-modified time as seconds since the UNIX epoch for the matched file.
/// Returns `0` if `m` is null.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_match_get_modified(m: *const FffGrepMatch) -> u64 {
if m.is_null() {
return 0;
}
unsafe { (*m).modified }
}
/// Returns the number of highlight ranges in this match. Returns `0` if `m` is null.
///
/// Use with [`fff_grep_match_get_match_range`] to iterate the highlight spans.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_match_get_match_ranges_count(m: *const FffGrepMatch) -> u32 {
if m.is_null() {
return 0;
}
unsafe { (*m).match_ranges_count }
}
/// Returns a pointer to the `index`-th [`FffMatchRange`] highlight span.
///
/// Returns null if `m` is null, `index >= match_ranges_count`, or the
/// ranges array is null. The returned pointer is valid until the owning
/// `FffGrepResult` is freed; do not free it directly.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_match_get_match_range(
m: *const FffGrepMatch,
index: u32,
) -> *const FffMatchRange {
if m.is_null() {
return ptr::null();
}
let m = unsafe { &*m };
if index >= m.match_ranges_count || m.match_ranges.is_null() {
return ptr::null();
}
unsafe { m.match_ranges.add(index as usize) }
}
/// Returns the number of context lines captured before the match.
/// Returns `0` if `m` is null.
///
/// Use with [`fff_grep_match_get_context_before`] to read each line.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_match_get_context_before_count(m: *const FffGrepMatch) -> u32 {
if m.is_null() {
return 0;
}
unsafe { (*m).context_before_count }
}
/// Returns the `index`-th context line before the match.
///
/// Returns null if `m` is null, `index >= context_before_count`, or the
/// context array is null. Do not free the returned pointer.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_match_get_context_before(
m: *const FffGrepMatch,
index: u32,
) -> *const c_char {
if m.is_null() {
return ptr::null();
}
let m = unsafe { &*m };
if index >= m.context_before_count || m.context_before.is_null() {
return ptr::null();
}
unsafe { *m.context_before.add(index as usize) }
}
/// Returns the number of context lines captured after the match.
/// Returns `0` if `m` is null.
///
/// Use with [`fff_grep_match_get_context_after`] to read each line.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_match_get_context_after_count(m: *const FffGrepMatch) -> u32 {
if m.is_null() {
return 0;
}
unsafe { (*m).context_after_count }
}
/// Returns the `index`-th context line after the match.
///
/// Returns null if `m` is null, `index >= context_after_count`, or the
/// context array is null. Do not free the returned pointer.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_match_get_context_after(
m: *const FffGrepMatch,
index: u32,
) -> *const c_char {
if m.is_null() {
return ptr::null();
}
let m = unsafe { &*m };
if index >= m.context_after_count || m.context_after.is_null() {
return ptr::null();
}
unsafe { *m.context_after.add(index as usize) }
}
/// Returns the fuzzy match score. Returns `0` if `m` is null or no fuzzy
/// score is present.
///
/// Always check [`fff_grep_match_get_has_fuzzy_score`] first; `0` is
/// ambiguous without that flag.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_match_get_fuzzy_score(m: *const FffGrepMatch) -> u16 {
if m.is_null() {
return 0;
}
unsafe { (*m).fuzzy_score }
}
/// Returns `true` if this match carries a valid fuzzy score.
/// Returns `false` if `m` is null.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_match_get_has_fuzzy_score(m: *const FffGrepMatch) -> bool {
if m.is_null() {
return false;
}
unsafe { (*m).has_fuzzy_score }
}
/// Returns `true` if the match was identified as a symbol definition.
/// Returns `false` if `m` is null.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_match_get_is_definition(m: *const FffGrepMatch) -> bool {
if m.is_null() {
return false;
}
unsafe { (*m).is_definition }
}
/// Returns `true` if the matched file was detected as binary.
/// Returns `false` if `m` is null.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_match_get_is_binary(m: *const FffGrepMatch) -> bool {
if m.is_null() {
return false;
}
unsafe { (*m).is_binary }
}
// ── FffSearchResult ──────────────────────────────────────────────────────────
/// Returns the number of items in the result. Returns `0` if `r` is null.
///
/// ## Safety
/// `r` must be a valid `FffSearchResult` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_search_result_get_count(r: *const FffSearchResult) -> u32 {
if r.is_null() {
return 0;
}
unsafe { (*r).count }
}
/// Returns the total number of files that matched before the result was
/// truncated to the page size. Returns `0` if `r` is null.
///
/// ## Safety
/// `r` must be a valid `FffSearchResult` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_search_result_get_total_matched(r: *const FffSearchResult) -> u32 {
if r.is_null() {
return 0;
}
unsafe { (*r).total_matched }
}
/// Returns the total number of indexed files considered during search.
/// Returns `0` if `r` is null.
///
/// ## Safety
/// `r` must be a valid `FffSearchResult` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_search_result_get_total_files(r: *const FffSearchResult) -> u32 {
if r.is_null() {
return 0;
}
unsafe { (*r).total_files }
}
// ── FffGrepResult ─────────────────────────────────────────────────────────────
/// Returns the number of matches in the result. Returns `0` if `r` is null.
///
/// ## Safety
/// `r` must be a valid `FffGrepResult` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_result_get_count(r: *const FffGrepResult) -> u32 {
if r.is_null() {
return 0;
}
unsafe { (*r).count }
}
/// Returns the total number of matches found across all pages.
/// Returns `0` if `r` is null.
///
/// ## Safety
/// `r` must be a valid `FffGrepResult` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_result_get_total_matched(r: *const FffGrepResult) -> u32 {
if r.is_null() {
return 0;
}
unsafe { (*r).total_matched }
}
/// Returns the number of files actually opened and searched in this call.
/// Returns `0` if `r` is null.
///
/// ## Safety
/// `r` must be a valid `FffGrepResult` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_result_get_total_files_searched(r: *const FffGrepResult) -> u32 {
if r.is_null() {
return 0;
}
unsafe { (*r).total_files_searched }
}
/// Returns the total number of indexed files before any filtering.
/// Returns `0` if `r` is null.
///
/// ## Safety
/// `r` must be a valid `FffGrepResult` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_result_get_total_files(r: *const FffGrepResult) -> u32 {
if r.is_null() {
return 0;
}
unsafe { (*r).total_files }
}
/// Returns the number of files eligible for search after path/type filtering.
/// Returns `0` if `r` is null.
///
/// ## Safety
/// `r` must be a valid `FffGrepResult` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_result_get_filtered_file_count(r: *const FffGrepResult) -> u32 {
if r.is_null() {
return 0;
}
unsafe { (*r).filtered_file_count }
}
/// Returns the file offset for the next page, or `0` if all files have been
/// searched or `r` is null. Pass this value as `file_offset` to a subsequent
/// `fff_live_grep` or `fff_multi_grep` call to continue pagination.
///
/// ## Safety
/// `r` must be a valid `FffGrepResult` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_result_get_next_file_offset(r: *const FffGrepResult) -> u32 {
if r.is_null() {
return 0;
}
unsafe { (*r).next_file_offset }
}
/// Returns the regex compilation error string if the engine fell back to
/// literal matching, or null if there was no error or `r` is null.
///
/// Do not free the returned pointer.
///
/// ## Safety
/// `r` must be a valid `FffGrepResult` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_grep_result_get_regex_fallback_error(
r: *const FffGrepResult,
) -> *const c_char {
if r.is_null() {
return ptr::null();
}
unsafe { (*r).regex_fallback_error }
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::CString;
use std::ptr;
// ── helpers ──────────────────────────────────────────────────────────────
fn make_file_item(path: &str, name: &str) -> FffFileItem {
FffFileItem {
relative_path: CString::new(path).unwrap().into_raw(),
file_name: CString::new(name).unwrap().into_raw(),
git_status: ptr::null_mut(),
size: 1024,
modified: 1_700_000_000,
access_frecency_score: 10,
modification_frecency_score: 20,
total_frecency_score: 30,
is_binary: false,
}
}
unsafe fn free_file_item(item: &mut FffFileItem) {
unsafe {
if !item.relative_path.is_null() {
drop(CString::from_raw(item.relative_path));
}
if !item.file_name.is_null() {
drop(CString::from_raw(item.file_name));
}
if !item.git_status.is_null() {
drop(CString::from_raw(item.git_status));
}
}
}
fn make_grep_match(path: &str, line: &str) -> FffGrepMatch {
FffGrepMatch {
relative_path: CString::new(path).unwrap().into_raw(),
file_name: CString::new("file.rs").unwrap().into_raw(),
git_status: ptr::null_mut(),
line_content: CString::new(line).unwrap().into_raw(),
match_ranges: ptr::null_mut(),
context_before: ptr::null_mut(),
context_after: ptr::null_mut(),
size: 512,
modified: 1_600_000_000,
total_frecency_score: 5,
access_frecency_score: 6,
modification_frecency_score: 7,
line_number: 42,
byte_offset: 100,
col: 8,
match_ranges_count: 0,
context_before_count: 0,
context_after_count: 0,
fuzzy_score: 0,
has_fuzzy_score: false,
is_binary: false,
is_definition: true,
}
}
unsafe fn free_grep_match(m: &mut FffGrepMatch) {
unsafe {
if !m.relative_path.is_null() {
drop(CString::from_raw(m.relative_path));
}
if !m.file_name.is_null() {
drop(CString::from_raw(m.file_name));
}
if !m.line_content.is_null() {
drop(CString::from_raw(m.line_content));
}
}
}
fn make_search_result(count: u32, total: u32, files: u32) -> FffSearchResult {
FffSearchResult {
items: ptr::null_mut(),
scores: ptr::null_mut(),
count,
total_matched: total,
total_files: files,
location: crate::ffi_types::FffLocation {
tag: 0,
line: 0,
col: 0,
end_line: 0,
end_col: 0,
},
}
}
fn make_grep_result() -> FffGrepResult {
FffGrepResult {
items: ptr::null_mut(),
count: 3,
total_matched: 10,
total_files_searched: 50,
total_files: 200,
filtered_file_count: 80,
next_file_offset: 51,
regex_fallback_error: ptr::null_mut(),
}
}
// ── null-guard tests: every function returns its zero-value on NULL ───────
#[test]
fn null_file_item_returns_null_or_zero() {
let null: *const FffFileItem = ptr::null();
unsafe {
assert!(fff_file_item_get_relative_path(null).is_null());
assert!(fff_file_item_get_file_name(null).is_null());
assert!(fff_file_item_get_git_status(null).is_null());
assert_eq!(fff_file_item_get_size(null), 0);
assert_eq!(fff_file_item_get_modified(null), 0);
assert_eq!(fff_file_item_get_access_frecency_score(null), 0);
assert_eq!(fff_file_item_get_modification_frecency_score(null), 0);
assert_eq!(fff_file_item_get_total_frecency_score(null), 0);
assert!(!fff_file_item_get_is_binary(null));
}
}
#[test]
fn null_grep_match_returns_null_or_zero() {
let null: *const FffGrepMatch = ptr::null();
unsafe {
assert!(fff_grep_match_get_relative_path(null).is_null());
assert!(fff_grep_match_get_file_name(null).is_null());
assert!(fff_grep_match_get_git_status(null).is_null());
assert!(fff_grep_match_get_line_content(null).is_null());
assert_eq!(fff_grep_match_get_line_number(null), 0);
assert_eq!(fff_grep_match_get_byte_offset(null), 0);
assert_eq!(fff_grep_match_get_col(null), 0);
assert_eq!(fff_grep_match_get_size(null), 0);
assert_eq!(fff_grep_match_get_modified(null), 0);
assert_eq!(fff_grep_match_get_total_frecency_score(null), 0);
assert_eq!(fff_grep_match_get_access_frecency_score(null), 0);
assert_eq!(fff_grep_match_get_modification_frecency_score(null), 0);
assert_eq!(fff_grep_match_get_match_ranges_count(null), 0);
assert_eq!(fff_grep_match_get_context_before_count(null), 0);
assert_eq!(fff_grep_match_get_context_after_count(null), 0);
assert!(!fff_grep_match_get_has_fuzzy_score(null));
assert_eq!(fff_grep_match_get_fuzzy_score(null), 0);
assert!(!fff_grep_match_get_is_binary(null));
assert!(!fff_grep_match_get_is_definition(null));
assert!(fff_grep_match_get_context_before(null, 0).is_null());
assert!(fff_grep_match_get_context_after(null, 0).is_null());
assert!(fff_grep_match_get_match_range(null, 0).is_null());
}
}
#[test]
fn null_search_result_returns_zero() {
let null: *const FffSearchResult = ptr::null();
unsafe {
assert_eq!(fff_search_result_get_count(null), 0);
assert_eq!(fff_search_result_get_total_matched(null), 0);
assert_eq!(fff_search_result_get_total_files(null), 0);
}
}
#[test]
fn null_grep_result_returns_zero_or_null() {
let null: *const FffGrepResult = ptr::null();
unsafe {
assert_eq!(fff_grep_result_get_count(null), 0);
assert_eq!(fff_grep_result_get_total_matched(null), 0);
assert_eq!(fff_grep_result_get_total_files_searched(null), 0);
assert_eq!(fff_grep_result_get_total_files(null), 0);
assert_eq!(fff_grep_result_get_filtered_file_count(null), 0);
assert_eq!(fff_grep_result_get_next_file_offset(null), 0);
assert!(fff_grep_result_get_regex_fallback_error(null).is_null());
}
}
// ── data correctness tests ────────────────────────────────────────────────
#[test]
fn file_item_getters_return_correct_values() {
let mut item = make_file_item("src/main.rs", "main.rs");
let p = &item as *const FffFileItem;
unsafe {
let path = std::ffi::CStr::from_ptr(fff_file_item_get_relative_path(p));
assert_eq!(path.to_str().unwrap(), "src/main.rs");
let name = std::ffi::CStr::from_ptr(fff_file_item_get_file_name(p));
assert_eq!(name.to_str().unwrap(), "main.rs");
assert!(fff_file_item_get_git_status(p).is_null());
assert_eq!(fff_file_item_get_size(p), 1024);
assert_eq!(fff_file_item_get_modified(p), 1_700_000_000);
assert_eq!(fff_file_item_get_access_frecency_score(p), 10);
assert_eq!(fff_file_item_get_modification_frecency_score(p), 20);
assert_eq!(fff_file_item_get_total_frecency_score(p), 30);
assert!(!fff_file_item_get_is_binary(p));
free_file_item(&mut item);
}
}
#[test]
fn grep_match_getters_return_correct_values() {
let mut m = make_grep_match("src/lib.rs", "fn hello()");
let p = &m as *const FffGrepMatch;
unsafe {
let path = std::ffi::CStr::from_ptr(fff_grep_match_get_relative_path(p));
assert_eq!(path.to_str().unwrap(), "src/lib.rs");
let line = std::ffi::CStr::from_ptr(fff_grep_match_get_line_content(p));
assert_eq!(line.to_str().unwrap(), "fn hello()");
assert_eq!(fff_grep_match_get_line_number(p), 42);
assert_eq!(fff_grep_match_get_byte_offset(p), 100);
assert_eq!(fff_grep_match_get_col(p), 8);
assert_eq!(fff_grep_match_get_size(p), 512);
assert_eq!(fff_grep_match_get_modified(p), 1_600_000_000);
assert_eq!(fff_grep_match_get_total_frecency_score(p), 5);
assert_eq!(fff_grep_match_get_access_frecency_score(p), 6);
assert_eq!(fff_grep_match_get_modification_frecency_score(p), 7);
assert_eq!(fff_grep_match_get_match_ranges_count(p), 0);
assert!(!fff_grep_match_get_has_fuzzy_score(p));
assert!(!fff_grep_match_get_is_binary(p));
assert!(fff_grep_match_get_is_definition(p));
free_grep_match(&mut m);
}
}
#[test]
fn search_result_getters_return_correct_values() {
let r = make_search_result(5, 20, 100);
let p = &r as *const FffSearchResult;
unsafe {
assert_eq!(fff_search_result_get_count(p), 5);
assert_eq!(fff_search_result_get_total_matched(p), 20);
assert_eq!(fff_search_result_get_total_files(p), 100);
}
}
#[test]
fn grep_result_getters_return_correct_values() {
let r = make_grep_result();
let p = &r as *const FffGrepResult;
unsafe {
assert_eq!(fff_grep_result_get_count(p), 3);
assert_eq!(fff_grep_result_get_total_matched(p), 10);
assert_eq!(fff_grep_result_get_total_files_searched(p), 50);
assert_eq!(fff_grep_result_get_total_files(p), 200);
assert_eq!(fff_grep_result_get_filtered_file_count(p), 80);
assert_eq!(fff_grep_result_get_next_file_offset(p), 51);
assert!(fff_grep_result_get_regex_fallback_error(p).is_null());
}
}
}
+831
View File
@@ -0,0 +1,831 @@
//! 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.
use std::ffi::{CString, c_char, c_void};
use std::ptr;
use fff::file_picker::FilePicker;
use fff::git::format_git_status;
use fff::{
DirItem, DirSearchResult, FileItem, GrepMatch, GrepResult, Location, MixedItemRef,
MixedSearchResult, Score, SearchResult,
};
/// Current used version of [`FffCreateOptions`].
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!
#[repr(C)]
pub struct FffCreateOptions {
/// Set to [`FFF_CREATE_OPTIONS_VERSION`] when allocating. Used by the
/// library to determine which trailing fields are populated.
pub version: u32,
/// Directory to index (required, non-NULL).
pub base_path: *const c_char,
/// Frecency LMDB database path. NULL/empty to skip frecency tracking.
pub frecency_db_path: *const c_char,
/// Query history LMDB database path. NULL/empty to skip query tracking.
pub history_db_path: *const c_char,
/// Pre-populate mmap caches for top-frecency files after the initial scan.
pub enable_mmap_cache: bool,
/// Build content index after the initial scan for faster grep.
pub enable_content_indexing: bool,
/// Start a background file-system watcher for live updates.
pub watch: bool,
/// Enable AI-agent optimizations.
pub ai_mode: bool,
/// Tracing log file path. NULL/empty to skip log init.
pub log_file_path: *const c_char,
/// Log level: `"trace" | "debug" | "info" | "warn" | "error"`.
/// NULL/empty defaults to `"info"`. Ignored when `log_file_path` is unset.
pub log_level: *const c_char,
/// Content cache file-count cap. 0 = auto.
pub cache_budget_max_files: u64,
/// Content cache byte cap. 0 = auto.
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.
pub enable_fs_root_scanning: bool,
/// Allow indexing the user's home directory. Same trade-off as
/// `enable_fs_root_scanning`.
pub enable_home_dir_scanning: bool,
// ----- v2 fields -----
/// Follow symlinks during scan and watcher walks. Off by default —
/// enabling this without external loop protection can wedge the watcher
/// on cyclic symlink graphs. Caller is responsible for the trade-off.
pub follow_symlinks: bool,
// ----- new version 3+ fields go here, ALWAYS appended -----
}
impl FffCreateOptions {
/// Default values for a v1 options struct.
pub fn defaults() -> Self {
Self {
version: FFF_CREATE_OPTIONS_VERSION,
base_path: ptr::null(),
frecency_db_path: ptr::null(),
history_db_path: ptr::null(),
enable_mmap_cache: true,
enable_content_indexing: true,
watch: true,
ai_mode: false,
log_file_path: ptr::null(),
log_level: ptr::null(),
cache_budget_max_files: 0,
cache_budget_max_bytes: 0,
cache_budget_max_file_size: 0,
enable_fs_root_scanning: false,
enable_home_dir_scanning: false,
follow_symlinks: false,
}
}
}
/// Allocate a heap CString from a `&str`, returning a raw pointer.
fn cstring_new(s: &str) -> *mut c_char {
CString::new(s).unwrap_or_default().into_raw()
}
/// Convert a `Vec<T>` into a raw pointer + count, leaking the memory.
fn vec_to_raw<T>(v: Vec<T>) -> (*mut T, u32) {
if v.is_empty() {
return (ptr::null_mut(), 0);
}
let count = v.len() as u32;
let mut boxed = v.into_boxed_slice();
let p = boxed.as_mut_ptr();
std::mem::forget(boxed);
(p, count)
}
/// Convert a `&[String]` into a heap-allocated array of C strings.
fn strings_to_raw(v: &[String]) -> (*mut *mut c_char, u32) {
if v.is_empty() {
return (ptr::null_mut(), 0);
}
let ptrs: Vec<*mut c_char> = v.iter().map(|s| cstring_new(s)).collect();
vec_to_raw(ptrs)
}
/// Free a heap-allocated array of C strings.
///
/// ## Safety
/// `arr` must have been produced by `strings_to_raw`.
unsafe fn free_cstring_array(arr: *mut *mut c_char, count: u32) {
if arr.is_null() {
return;
}
unsafe {
let ptrs = Vec::from_raw_parts(arr, count as usize, count as usize);
for p in ptrs {
if !p.is_null() {
drop(CString::from_raw(p));
}
}
}
}
/// A file item returned by `fff_search`.
///
/// All string fields are heap-allocated and owned by the parent `FffSearchResult`.
/// Free the entire result with `fff_free_search_result`.
#[repr(C)]
pub struct FffFileItem {
pub relative_path: *mut c_char,
pub file_name: *mut c_char,
pub git_status: *mut c_char,
pub size: u64,
pub modified: u64,
pub access_frecency_score: i64,
pub modification_frecency_score: i64,
pub total_frecency_score: i64,
pub is_binary: bool,
}
impl FffFileItem {
pub fn from_item(item: &FileItem, picker: &FilePicker) -> Self {
FffFileItem {
relative_path: cstring_new(&item.relative_path(picker)),
file_name: cstring_new(&item.file_name(picker)),
git_status: cstring_new(format_git_status(item.git_status)),
size: item.size,
modified: item.modified,
access_frecency_score: item.access_frecency_score as i64,
modification_frecency_score: item.modification_frecency_score as i64,
total_frecency_score: item.total_frecency_score() as i64,
is_binary: item.is_binary(),
}
}
}
impl FffFileItem {
/// ## Safety
/// All string pointers must have been allocated by `CString::into_raw`.
pub unsafe fn free_strings(&mut self) {
unsafe {
if !self.relative_path.is_null() {
drop(CString::from_raw(self.relative_path));
}
if !self.file_name.is_null() {
drop(CString::from_raw(self.file_name));
}
if !self.git_status.is_null() {
drop(CString::from_raw(self.git_status));
}
}
}
}
/// Score breakdown for a search result.
#[repr(C)]
pub struct FffScore {
pub total: i32,
pub base_score: i32,
pub filename_bonus: i32,
pub special_filename_bonus: i32,
pub frecency_boost: i32,
pub distance_penalty: i32,
pub current_file_penalty: i32,
pub combo_match_boost: i32,
pub path_alignment_bonus: i32,
pub exact_match: bool,
pub match_type: *mut c_char,
}
impl From<&Score> for FffScore {
fn from(score: &Score) -> Self {
FffScore {
total: score.total,
base_score: score.base_score,
filename_bonus: score.filename_bonus,
special_filename_bonus: score.special_filename_bonus,
frecency_boost: score.frecency_boost,
distance_penalty: score.distance_penalty,
current_file_penalty: score.current_file_penalty,
combo_match_boost: score.combo_match_boost,
path_alignment_bonus: score.path_alignment_bonus,
exact_match: score.exact_match,
match_type: cstring_new(score.match_type),
}
}
}
impl FffScore {
/// ## Safety
/// `match_type` must have been allocated by `CString::into_raw`.
pub unsafe fn free_strings(&mut self) {
unsafe {
if !self.match_type.is_null() {
drop(CString::from_raw(self.match_type));
}
}
}
}
/// Location parsed from a query string (e.g. `"file.ts:42:10"`).
///
/// `tag` encodes the variant:
/// 0 = no location,
/// 1 = line only (`line` is set),
/// 2 = position (`line` + `col`),
/// 3 = range (`line`/`col` = start, `end_line`/`end_col` = end).
#[repr(C)]
pub struct FffLocation {
pub tag: u8,
pub line: i32,
pub col: i32,
pub end_line: i32,
pub end_col: i32,
}
impl From<Option<&Location>> for FffLocation {
fn from(loc: Option<&Location>) -> Self {
match loc {
None => FffLocation {
tag: 0,
line: 0,
col: 0,
end_line: 0,
end_col: 0,
},
Some(Location::Line(line)) => FffLocation {
tag: 1,
line: *line,
col: 0,
end_line: 0,
end_col: 0,
},
Some(Location::Position { line, col }) => FffLocation {
tag: 2,
line: *line,
col: *col,
end_line: 0,
end_col: 0,
},
Some(Location::Range { start, end }) => FffLocation {
tag: 3,
line: start.0,
col: start.1,
end_line: end.0,
end_col: end.1,
},
}
}
}
/// Search result returned by `fff_search`.
///
/// The caller must free this with `fff_free_search_result`.
#[repr(C)]
pub struct FffSearchResult {
/// Pointer to a heap-allocated array of `FffFileItem` (length = `count`).
pub items: *mut FffFileItem,
/// Pointer to a heap-allocated array of `FffScore` (length = `count`).
pub scores: *mut FffScore,
/// Number of items/scores in the arrays.
pub count: u32,
/// Total number of files that matched the query.
pub total_matched: u32,
/// Total number of indexed files.
pub total_files: u32,
/// Location parsed from the query string.
pub location: FffLocation,
}
impl FffSearchResult {
/// Convert a core `SearchResult` into a heap-allocated `FffSearchResult`.
pub fn from_core(result: &SearchResult, picker: &FilePicker) -> *mut Self {
let items: Vec<FffFileItem> = result
.items
.iter()
.map(|i| FffFileItem::from_item(i, picker))
.collect();
let scores: Vec<FffScore> = result.scores.iter().map(FffScore::from).collect();
let count = items.len() as u32;
let (items_ptr, _) = vec_to_raw(items);
let (scores_ptr, _) = vec_to_raw(scores);
Box::into_raw(Box::new(FffSearchResult {
items: items_ptr,
scores: scores_ptr,
count,
total_matched: result.total_matched as u32,
total_files: result.total_files as u32,
location: FffLocation::from(result.location.as_ref()),
}))
}
}
// ---------------------------------------------------------------------------
// Grep result types
// ---------------------------------------------------------------------------
/// A byte range within a matched line, used for highlighting.
#[repr(C)]
pub struct FffMatchRange {
pub start: u32,
pub end: u32,
}
/// A single grep match with file and line information.
///
/// All string fields and arrays are heap-allocated. Free the parent
/// `FffGrepResult` with `fff_free_grep_result` to release everything.
#[repr(C)]
pub struct FffGrepMatch {
// -- pointers (8 bytes each) --
pub relative_path: *mut c_char,
pub file_name: *mut c_char,
pub git_status: *mut c_char,
pub line_content: *mut c_char,
pub match_ranges: *mut FffMatchRange,
pub context_before: *mut *mut c_char,
pub context_after: *mut *mut c_char,
// -- 8-byte numeric fields --
pub size: u64,
pub modified: u64,
pub total_frecency_score: i64,
pub access_frecency_score: i64,
pub modification_frecency_score: i64,
pub line_number: u64,
pub byte_offset: u64,
// -- 4-byte fields --
pub col: u32,
pub match_ranges_count: u32,
pub context_before_count: u32,
pub context_after_count: u32,
// -- 2-byte fields --
pub fuzzy_score: u16,
// -- 1-byte fields --
pub has_fuzzy_score: bool,
pub is_binary: bool,
pub is_definition: bool,
}
impl FffGrepMatch {
fn from_core_with_file(m: &GrepMatch, file: &FileItem, picker: &FilePicker) -> Self {
let ranges: Vec<FffMatchRange> = m
.match_byte_offsets
.iter()
.map(|&(start, end)| FffMatchRange { start, end })
.collect();
let (match_ranges, match_ranges_count) = vec_to_raw(ranges);
let (context_before, context_before_count) = strings_to_raw(&m.context_before);
let (context_after, context_after_count) = strings_to_raw(&m.context_after);
let (has_fuzzy_score, fuzzy_score) = match m.fuzzy_score {
Some(s) => (true, s),
None => (false, 0),
};
FffGrepMatch {
relative_path: cstring_new(&file.relative_path(picker)),
file_name: cstring_new(&file.file_name(picker)),
git_status: cstring_new(format_git_status(file.git_status)),
line_content: cstring_new(&m.line_content),
match_ranges,
context_before,
context_after,
size: file.size,
modified: file.modified,
total_frecency_score: file.total_frecency_score() as i64,
access_frecency_score: file.access_frecency_score as i64,
modification_frecency_score: file.modification_frecency_score as i64,
line_number: m.line_number,
byte_offset: m.byte_offset,
col: m.col as u32,
match_ranges_count,
context_before_count,
context_after_count,
fuzzy_score,
has_fuzzy_score,
is_binary: file.is_binary(),
is_definition: m.is_definition,
}
}
/// ## Safety
/// All pointers must have been allocated by the corresponding `from_core`.
pub unsafe fn free_fields(&mut self) {
unsafe {
if !self.relative_path.is_null() {
drop(CString::from_raw(self.relative_path));
}
if !self.file_name.is_null() {
drop(CString::from_raw(self.file_name));
}
if !self.git_status.is_null() {
drop(CString::from_raw(self.git_status));
}
if !self.line_content.is_null() {
drop(CString::from_raw(self.line_content));
}
if !self.match_ranges.is_null() {
drop(Vec::from_raw_parts(
self.match_ranges,
self.match_ranges_count as usize,
self.match_ranges_count as usize,
));
}
free_cstring_array(self.context_before, self.context_before_count);
free_cstring_array(self.context_after, self.context_after_count);
}
}
}
/// Grep result returned by `fff_live_grep` and `fff_multi_grep`.
///
/// The caller must free this with `fff_free_grep_result`.
#[repr(C)]
pub struct FffGrepResult {
/// Pointer to a heap-allocated array of `FffGrepMatch` (length = `count`).
pub items: *mut FffGrepMatch,
/// Number of matches in the `items` array.
pub count: u32,
/// Total number of matches (always equal to `count`).
pub total_matched: u32,
/// Number of files actually opened and searched in this call.
pub total_files_searched: u32,
/// Total number of indexed files (before any filtering).
pub total_files: u32,
/// Number of files eligible for search after filtering.
pub filtered_file_count: u32,
/// File offset for the next page. 0 if all files have been searched.
pub next_file_offset: u32,
/// Regex compilation error when falling back to literal matching. Null if none.
pub regex_fallback_error: *mut c_char,
}
impl FffGrepResult {
/// Convert a core `GrepResult` into a heap-allocated `FffGrepResult`.
pub fn from_core(result: &GrepResult, picker: &FilePicker) -> *mut Self {
let items: Vec<FffGrepMatch> = result
.matches
.iter()
.map(|m| {
let file = result.files[m.file_index];
FffGrepMatch::from_core_with_file(m, file, picker)
})
.collect();
let (items_ptr, count) = vec_to_raw(items);
Box::into_raw(Box::new(FffGrepResult {
items: items_ptr,
count,
total_matched: result.matches.len() as u32,
total_files_searched: result.total_files_searched as u32,
total_files: result.total_files as u32,
filtered_file_count: result.filtered_file_count as u32,
next_file_offset: result.next_file_offset as u32,
regex_fallback_error: match &result.regex_fallback_error {
Some(e) => cstring_new(e),
None => ptr::null_mut(),
},
}))
}
}
/// Result envelope returned by all `fff_*` functions.
///
/// Heap-allocated. The caller must free it with `fff_free_result`. Calling `fff_free_result`
/// **does not** deallocate the underlying `handle` pointer. It needs to be cleaned separately.
/// see (`fff_destroy`, `fff_free_search_result`, `fff_free_grep_result`, `fff_free_string`, etc.).
///
/// Depending on the function, the payload is delivered through different fields:
///
/// | Function | Payload field | Type |
/// |----------------------------|---------------|-------------------------------|
/// | `fff_create_instance` | `handle` | opaque instance pointer |
/// | `fff_search` | `handle` | `*mut FffSearchResult` |
/// | `fff_live_grep` | `handle` | `*mut FffGrepResult` |
/// | `fff_multi_grep` | `handle` | `*mut FffGrepResult` |
/// | `fff_get_scan_progress` | `handle` | `*mut FffScanProgress` |
/// | `fff_health_check` | `handle` | `*mut c_char` (JSON string) |
/// | `fff_get_historical_query` | `handle` | `*mut c_char` (string or null)|
/// | `fff_wait_for_scan` | `int_value` | 1 = completed, 0 = timed out |
/// | `fff_track_query` | `int_value` | 1 = success, 0 = failure |
/// | `fff_refresh_git_status` | `int_value` | number of files updated |
/// | `fff_scan_files` | (none) | success flag only |
/// | `fff_restart_index` | (none) | success flag only |
///
/// On failure, `success` is false and `error` contains the message.
#[repr(C)]
pub struct FffResult {
/// Whether the operation succeeded.
pub success: bool,
/// Error message on failure. Null on success.
pub error: *mut c_char,
/// Opaque pointer payload. May be null.
pub handle: *mut c_void,
/// Integer payload for simple return values (bool as 0/1, counts, etc.).
pub int_value: i64,
}
impl FffResult {
/// Create a successful result with no payload, returned as heap pointer.
pub fn ok_empty() -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: true,
error: ptr::null_mut(),
handle: ptr::null_mut(),
int_value: 0,
}))
}
/// Create a successful result with an integer value.
pub fn ok_int(value: i64) -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: true,
error: ptr::null_mut(),
handle: ptr::null_mut(),
int_value: value,
}))
}
/// Create a successful result carrying an opaque pointer (handle, typed struct, or string).
pub fn ok_handle(handle: *mut c_void) -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: true,
error: ptr::null_mut(),
handle,
int_value: 0,
}))
}
/// Create a successful result carrying a C string in the `handle` field.
/// The caller must free it with `fff_free_string`.
pub fn ok_string(s: &str) -> *mut Self {
let cstr = CString::new(s).unwrap_or_default().into_raw();
Box::into_raw(Box::new(FffResult {
success: true,
error: ptr::null_mut(),
handle: cstr as *mut c_void,
int_value: 0,
}))
}
/// Create an error result, returned as heap pointer.
pub fn err(error: &str) -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: false,
error: CString::new(error).unwrap_or_default().into_raw(),
handle: ptr::null_mut(),
int_value: 0,
}))
}
}
/// A directory item returned by `fff_search_directories`.
///
/// All string fields are heap-allocated and owned by the parent `FffDirSearchResult`.
/// Free the entire result with `fff_free_dir_search_result`.
#[repr(C)]
pub struct FffDirItem {
pub relative_path: *mut c_char,
pub dir_name: *mut c_char,
pub max_access_frecency: i32,
}
impl FffDirItem {
pub fn from_item(item: &DirItem, picker: &FilePicker) -> Self {
FffDirItem {
relative_path: cstring_new(&item.relative_path(picker)),
dir_name: cstring_new(&item.dir_name(picker)),
max_access_frecency: item.max_access_frecency(),
}
}
/// ## Safety
/// All string pointers must have been allocated by the rust side
pub unsafe fn free_strings(&mut self) {
unsafe {
if !self.relative_path.is_null() {
drop(CString::from_raw(self.relative_path));
}
if !self.dir_name.is_null() {
drop(CString::from_raw(self.dir_name));
}
}
}
}
/// Directory search result returned by `fff_search_directories`.
///
/// The caller must free this with `fff_free_dir_search_result`.
#[repr(C)]
pub struct FffDirSearchResult {
/// Pointer to a heap-allocated array of `FffDirItem` (length = `count`).
pub items: *mut FffDirItem,
/// Pointer to a heap-allocated array of `FffScore` (length = `count`).
pub scores: *mut FffScore,
/// Number of items/scores in the arrays.
pub count: u32,
/// Total number of directories that matched the query.
pub total_matched: u32,
/// Total number of indexed directories.
pub total_dirs: u32,
}
impl FffDirSearchResult {
/// Convert a core `DirSearchResult` into a heap-allocated `FffDirSearchResult`.
pub fn from_core(result: &DirSearchResult, picker: &FilePicker) -> *mut Self {
let items: Vec<FffDirItem> = result
.items
.iter()
.map(|i| FffDirItem::from_item(i, picker))
.collect();
let scores: Vec<FffScore> = result.scores.iter().map(FffScore::from).collect();
let count = items.len() as u32;
let (items_ptr, _) = vec_to_raw(items);
let (scores_ptr, _) = vec_to_raw(scores);
Box::into_raw(Box::new(FffDirSearchResult {
items: items_ptr,
scores: scores_ptr,
count,
total_matched: result.total_matched as u32,
total_dirs: result.total_dirs as u32,
}))
}
}
/// A single item in a mixed (files + directories) search result.
///
/// `item_type`: 0 = file, 1 = directory.
/// All string fields are heap-allocated and owned by the parent `FffMixedSearchResult`.
#[repr(C)]
pub struct FffMixedItem {
/// 0 = file, 1 = directory.
pub item_type: u8,
pub relative_path: *mut c_char,
/// Filename for files, last directory segment for directories.
pub display_name: *mut c_char,
pub git_status: *mut c_char,
pub size: u64,
pub modified: u64,
/// The access frecency score for files, or max access frecency among all the immediate
/// children for directories.
pub access_frecency_score: i64,
/// Always 0 for directories
pub modification_frecency_score: i64,
/// Always 0 for directories
pub total_frecency_score: i64,
/// Always 0 for directories
pub is_binary: bool,
}
impl FffMixedItem {
pub fn from_mixed_ref(item: &MixedItemRef<'_>, picker: &FilePicker) -> Self {
match item {
MixedItemRef::File(file) => FffMixedItem {
item_type: 0,
relative_path: cstring_new(&file.relative_path(picker)),
display_name: cstring_new(&file.file_name(picker)),
git_status: cstring_new(format_git_status(file.git_status)),
size: file.size,
modified: file.modified,
access_frecency_score: file.access_frecency_score as i64,
modification_frecency_score: file.modification_frecency_score as i64,
total_frecency_score: file.total_frecency_score() as i64,
is_binary: file.is_binary(),
},
MixedItemRef::Dir(dir) => FffMixedItem {
item_type: 1,
relative_path: cstring_new(&dir.relative_path(picker)),
display_name: cstring_new(&dir.dir_name(picker)),
git_status: cstring_new(""),
size: 0,
modified: 0,
access_frecency_score: dir.max_access_frecency() as i64,
modification_frecency_score: 0,
total_frecency_score: dir.max_access_frecency() as i64,
is_binary: false,
},
}
}
/// ## Safety
/// All string pointers must have been allocated by rust side
pub unsafe fn free_strings(&mut self) {
unsafe {
if !self.relative_path.is_null() {
drop(CString::from_raw(self.relative_path));
}
if !self.display_name.is_null() {
drop(CString::from_raw(self.display_name));
}
if !self.git_status.is_null() {
drop(CString::from_raw(self.git_status));
}
}
}
}
/// Mixed search result returned by `fff_search_mixed`.
///
/// The caller must free this with `fff_free_mixed_search_result`.
#[repr(C)]
pub struct FffMixedSearchResult {
/// Pointer to a heap-allocated array of `FffMixedItem` (length = `count`).
pub items: *mut FffMixedItem,
/// Pointer to a heap-allocated array of `FffScore` (length = `count`).
pub scores: *mut FffScore,
/// Number of items/scores in the arrays.
pub count: u32,
/// Total number of items (files + dirs) that matched the query.
pub total_matched: u32,
/// Total number of indexed files.
pub total_files: u32,
/// Total number of indexed directories.
pub total_dirs: u32,
/// Location parsed from the query string.
pub location: FffLocation,
}
impl FffMixedSearchResult {
/// Convert a core `MixedSearchResult` into a heap-allocated `FffMixedSearchResult`.
pub fn from_core(result: &MixedSearchResult, picker: &FilePicker) -> *mut Self {
let items: Vec<FffMixedItem> = result
.items
.iter()
.map(|i| FffMixedItem::from_mixed_ref(i, picker))
.collect();
let scores: Vec<FffScore> = result.scores.iter().map(FffScore::from).collect();
let count = items.len() as u32;
let (items_ptr, _) = vec_to_raw(items);
let (scores_ptr, _) = vec_to_raw(scores);
Box::into_raw(Box::new(FffMixedSearchResult {
items: items_ptr,
scores: scores_ptr,
count,
total_matched: result.total_matched as u32,
total_files: result.total_files as u32,
total_dirs: result.total_dirs as u32,
location: FffLocation::from(result.location.as_ref()),
}))
}
}
/// Scan progress returned by `fff_get_scan_progress`.
/// The caller must free this with `fff_free_scan_progress`.
#[repr(C)]
pub struct FffScanProgress {
pub scanned_files_count: u64,
pub is_scanning: bool,
pub is_watcher_ready: bool,
pub is_warmup_complete: bool,
}
impl From<fff::file_picker::ScanProgress> for FffScanProgress {
fn from(p: fff::file_picker::ScanProgress) -> Self {
Self {
scanned_files_count: p.scanned_files_count as u64,
is_scanning: p.is_scanning,
is_watcher_ready: p.is_watcher_ready,
is_warmup_complete: p.is_warmup_complete,
}
}
}
#[cfg(test)]
mod options_layout_tests {
use super::FffCreateOptions;
use std::mem::{align_of, offset_of, size_of};
// THIS TEST HAVE TO BE NEVER UPDATED ONLY ADDED NEW FIELDS
// this is needed to ensure ABI backward compatibility
#[test]
#[cfg(target_pointer_width = "64")]
fn fff_create_options_layout_is_stable_64bit() {
assert_eq!(size_of::<FffCreateOptions>(), 88);
assert_eq!(align_of::<FffCreateOptions>(), 8);
assert_eq!(offset_of!(FffCreateOptions, version), 0);
assert_eq!(offset_of!(FffCreateOptions, base_path), 8);
assert_eq!(offset_of!(FffCreateOptions, frecency_db_path), 16);
assert_eq!(offset_of!(FffCreateOptions, history_db_path), 24);
assert_eq!(offset_of!(FffCreateOptions, enable_mmap_cache), 32);
assert_eq!(offset_of!(FffCreateOptions, enable_content_indexing), 33);
assert_eq!(offset_of!(FffCreateOptions, watch), 34);
assert_eq!(offset_of!(FffCreateOptions, ai_mode), 35);
assert_eq!(offset_of!(FffCreateOptions, log_file_path), 40);
assert_eq!(offset_of!(FffCreateOptions, log_level), 48);
assert_eq!(offset_of!(FffCreateOptions, cache_budget_max_files), 56);
assert_eq!(offset_of!(FffCreateOptions, cache_budget_max_bytes), 64);
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);
}
}
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
/*
* Smoke test for libfff_c — the smallest possible end-to-end exercise of
* the public C API. We:
*
* 1. Create a picker with an `FffCreateOptions` populated via C99
* designated initializers (the recommended idiom for direct C use).
* 2. Wait for the initial scan to complete.
* 3. Search for "smoke.c".
* 4. Fail unless this very file appears in the results.
*
* Build + run via `make test-c-smoke`. Override $(CC) to test other
* compilers.
*/
#include <fff.h>
#include <stdio.h>
#include <string.h>
int main(int argc, char **argv) {
const char *base_path = argc > 1 ? argv[1] : ".";
// make sure that FFF C api is designed more for FFI rather than for direct C usage (I'm sorry)
struct FffResult *create_result = fff_create_instance_with(&(struct FffCreateOptions){
.version = FFF_CREATE_OPTIONS_VERSION,
.base_path = base_path,
.enable_mmap_cache = false,
.enable_content_indexing = false,
.watch = false,
});
if (!create_result->success) {
fprintf(stderr, "fff couldn't create instance: %s\n",
create_result->error ? create_result->error : "?");
fff_free_result(create_result);
return 1;
}
void *file_picker = create_result->handle;
fff_free_result(create_result); // safe to drop now: handle outlives the envelope
struct FffResult *scan_result = fff_wait_for_scan(file_picker, 5000);
if (!scan_result->success) {
fprintf(stderr, "wait_for_scan failed: %s\n",
scan_result->error ? scan_result->error : "?");
fff_free_result(scan_result);
fff_destroy(file_picker);
return 1;
}
// int_value: 1 = scan completed in time, 0 = timed out.
if (scan_result->int_value == 0) {
fprintf(stderr, "wait_for_scan: timed out before initial scan finished\n");
fff_free_result(scan_result);
fff_destroy(file_picker);
return 1;
}
fff_free_result(scan_result);
struct FffResult *res = fff_search(file_picker, "smkoe.c", "", 0, 0, 50, 0, 0);
if (!res->success) {
fprintf(stderr, "search failed: %s\n", res->error ? res->error : "?");
fff_free_result(res);
fff_destroy(file_picker);
return 1;
}
struct FffSearchResult *sr = (struct FffSearchResult *)res->handle;
uint32_t total = sr->count;
int found = 0;
for (uint32_t i = 0; i < sr->count; i++) {
const char *path = sr->items[i].relative_path;
if (path && strstr(path, "smoke.c")) {
found = 1;
fprintf(stderr, "found self: %s\n", path);
break;
}
}
fff_free_search_result(sr);
fff_free_result(res);
fff_destroy(file_picker);
if (!found) {
fprintf(stderr, "FAIL: smoke.c not in search results (count=%u)\n", total);
return 1;
}
fprintf(stderr, "PASS\n");
return 0;
}
+95
View File
@@ -0,0 +1,95 @@
[package]
name = "fff-search"
version = "0.9.6"
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"
[lib]
path = "src/lib.rs"
crate-type = ["rlib", "staticlib", "cdylib"]
[[bench]]
name = "parse_bench"
harness = false
[[bench]]
name = "bigram_bench"
harness = false
[[bench]]
name = "memmem_bench"
harness = false
[[bench]]
name = "glob_bench"
harness = false
required-features = ["zlob"]
[features]
# `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 = []
# 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"]
# Use zlob (Zig-compiled C globbing library) for glob matching.
# Requires Zig to be installed. When disabled, falls back to globset (pure Rust).
zlob = ["dep:zlob", "fff-query-parser/zlob"]
[dependencies]
ahash = { workspace = true }
rayon = { workspace = true }
smallvec = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
fff-query-parser = { workspace = true , version = "0.9.0" }
blake3 = { workspace = true }
dirs = { workspace = true }
libc = "0.2"
git2 = { workspace = true }
glidesort = { 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, optional = true }
memmap2 = { workspace = true }
neo_frizbee = { workspace = true }
notify = { workspace = true }
notify-debouncer-full = { workspace = true }
parking_lot = { workspace = true }
pathdiff = { workspace = true }
regex = { workspace = true }
regex-syntax = "0.8"
serde = { version = "1.0", features = ["derive"] }
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
zlob = { workspace = true, optional = true }
libmimalloc-sys = { version = "0.1", optional = true, features = ["extended", "local_dynamic_tls"] }
mimalloc = { version = "0.1", optional = true, features = ["local_dynamic_tls"] }
# Platform-specific: dunce for Windows to avoid \\?\ extended path prefix
[target.'cfg(windows)'.dependencies]
dunce = { workspace = true }
# signal-hook only compiles on unix; we wrap the SIGSEGV handler behind cfg(unix)
[target.'cfg(unix)'.dependencies]
signal-hook-registry = { workspace = true }
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
ctor = "0.2"
proptest = { version = "1", default-features = false, features = ["std", "fork"] }
rand = { version = "0.8", features = ["small_rng"] }
tempfile = "3.8"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+29
View File
@@ -0,0 +1,29 @@
# fff
fff is a file search toolkit. It is faster than ripgrep and fzf and designed for a long running applications like file editors, ai agents, or file exploerers.
> [!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
- Typo resistance
- Frecency and query history ranking
- Native git support via libgit
- Advanced ranking
- Grep functionality with SIMD optimized plain matcher and regex
- Multi grep using aho-corasick algorithm
- Efficient memory mapping for file system
- Cross platform support (Linux, Windows, MacOS)
- Advnaced constraints syntax allowing to prefilter based on git status, glob, extension, size, timing and more
## Performance
FFF is designed for high performance and low latency. SIMD optimized where needed, parallelized for multi core systems, efficient sorting and ranking algorithms, memaps and much more.
On MacOS FFF is about 20-50 times faster than ripgrep for content search and around 10 times faster than fzf for file name search.
## Documentation
Refer rust docs https://docs.rs/crate/fff-search/latest
+164
View File
@@ -0,0 +1,164 @@
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use fff_search::bigram_filter::{BigramFilter, BigramIndexBuilder};
/// Build a realistic bigram index for benchmarking.
/// Simulates a large repo by generating varied content per file.
fn build_test_index(file_count: usize) -> BigramFilter {
let builder = BigramIndexBuilder::new(file_count);
let skip_builder = BigramIndexBuilder::new(file_count);
for i in 0..file_count {
// Generate varied content so we get a mix of sparse and dense columns
let content = format!(
"struct File{i} {{ fn process() {{ let controller = read(path); }} }} // module {i}"
);
builder.add_file_content(&skip_builder, i, content.as_bytes());
}
let mut index = builder.compress(None);
let skip_index = skip_builder.compress(Some(12));
index.set_skip_index(skip_index);
index
}
fn bench_bigram_query(c: &mut Criterion) {
let file_counts = [10_000, 100_000, 500_000];
for &file_count in &file_counts {
let index = build_test_index(file_count);
eprintln!(
"Index ({} files): {} columns",
file_count,
index.columns_used(),
);
let mut group = c.benchmark_group(format!("bigram_query_{file_count}"));
group.sample_size(500);
let queries: &[(&str, &[u8])] = &[
("short_2char", b"st"),
("medium_6char", b"struct"),
("long_14char", b"let controller"),
("multi_word", b"fn process"),
];
for (name, query) in queries {
group.bench_with_input(BenchmarkId::from_parameter(name), query, |b, q| {
b.iter(|| {
let result = index.query(black_box(q));
black_box(&result);
});
});
}
group.finish();
}
}
fn bench_bigram_is_candidate(c: &mut Criterion) {
let index = build_test_index(500_000);
let candidates = match index.query(b"struct") {
Some(c) => c,
None => {
// All bigrams ubiquitous at this size — skip candidate benches
eprintln!("Skipping is_candidate bench: query returned None (all bigrams ubiquitous)");
return;
}
};
c.bench_function("is_candidate_500k", |b| {
b.iter(|| {
let mut count = 0u32;
for i in 0..500_000 {
if BigramFilter::is_candidate(black_box(&candidates), i) {
count += 1;
}
}
black_box(count)
});
});
c.bench_function("count_candidates_500k", |b| {
b.iter(|| BigramFilter::count_candidates(black_box(&candidates)));
});
}
fn bench_bigram_build(c: &mut Criterion) {
let mut group = c.benchmark_group("bigram_build");
group.sample_size(10);
let file_counts = [10_000, 100_000];
for &file_count in &file_counts {
// Pre-generate content so we only measure index building.
// Short content (~85 bytes/file) exercises the scalar fast path.
let contents: Vec<String> = (0..file_count)
.map(|i| {
format!(
"struct File{i} {{ fn process() {{ let controller = read(path); }} }} // mod {i}"
)
})
.collect();
group.bench_with_input(
BenchmarkId::new("short_content", file_count),
&file_count,
|b, &fc| {
b.iter(|| {
let builder = BigramIndexBuilder::new(fc);
let skip_builder = BigramIndexBuilder::new(fc);
for (i, content) in contents.iter().enumerate() {
builder.add_file_content(&skip_builder, i, content.as_bytes());
}
let index = builder.compress(None);
black_box(index.columns_used())
});
},
);
// Long content (~4 KB/file) exercises the SIMD pre-pass path.
// Build a realistic-looking source-like blob by repeating snippets.
let long_contents: Vec<String> = (0..file_count)
.map(|i| {
let mut s = String::with_capacity(4096);
for j in 0..50 {
s.push_str(&format!(
"pub fn handler_{i}_{j}(ctx: &Context) -> Result<Response, Error> {{\n"
));
s.push_str(" let parsed = ctx.parse()?;\n");
s.push_str(" let validated = parsed.validate()?;\n");
s.push_str(&format!(" ctx.respond(validated, {}).await\n", j));
s.push_str("}\n\n");
}
s
})
.collect();
group.bench_with_input(
BenchmarkId::new("long_content", file_count),
&file_count,
|b, &fc| {
b.iter(|| {
let builder = BigramIndexBuilder::new(fc);
let skip_builder = BigramIndexBuilder::new(fc);
for (i, content) in long_contents.iter().enumerate() {
builder.add_file_content(&skip_builder, i, content.as_bytes());
}
let index = builder.compress(None);
black_box(index.columns_used())
});
},
);
}
group.finish();
}
criterion_group!(
benches,
bench_bigram_query,
bench_bigram_is_candidate,
bench_bigram_build,
);
criterion_main!(benches);
+386
View File
@@ -0,0 +1,386 @@
//! 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).
use ahash::AHashSet;
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use zlob::{ZlobFlags, ZlobPattern, zlob_match_paths, zlob_match_paths_indices};
fn make_paths(n: usize) -> Vec<String> {
let exts = ["rs", "ts", "lua", "md", "toml", "go", "py", "c", "h", "txt"];
let dirs = [
"src/core",
"src/ui",
"crates/fff-core/src",
"lua/fff",
"tests/integration",
"vendor/lib",
"node_modules/foo/bar",
"docs/internal",
];
let mut out = Vec::with_capacity(n);
for i in 0..n {
let dir = dirs[i % dirs.len()];
let ext = exts[i % exts.len()];
out.push(format!("{dir}/file_{i}.{ext}"));
}
out
}
fn current_impl(pattern: &str, paths: &[&str]) -> AHashSet<usize> {
let Ok(Some(matches)) = zlob_match_paths(pattern, paths, ZlobFlags::RECOMMENDED) else {
return AHashSet::new();
};
let matched_set: AHashSet<usize> = matches.iter().map(|s| s.as_ptr() as usize).collect();
paths
.iter()
.enumerate()
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
.map(|(i, _)| i)
.collect()
}
fn indices_free_fn(pattern: &str, paths: &[&str]) -> AHashSet<usize> {
let Ok(hits) = zlob_match_paths_indices(pattern, paths, ZlobFlags::RECOMMENDED) else {
return AHashSet::new();
};
hits.to_iter().collect()
}
fn compiled_pattern(pattern: &str, paths: &[&str]) -> AHashSet<usize> {
let Ok(p) = ZlobPattern::compile(pattern, ZlobFlags::RECOMMENDED) else {
return AHashSet::new();
};
let Ok(hits) = p.match_indices(paths, ZlobFlags::RECOMMENDED) else {
return AHashSet::new();
};
hits.to_iter().collect()
}
fn bench_glob_strategies(c: &mut Criterion) {
let path_counts = [1_000usize, 10_000, 100_000];
let patterns: &[(&str, &str)] = &[
("ext_rs", "**/*.rs"),
("dir_glob", "src/**/*.{ts,lua}"),
("literal_seg", "**/node_modules/**"),
("brace_multi", "**/*.{rs,ts,lua,md}"),
];
for &count in &path_counts {
let owned = make_paths(count);
let paths: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
let mut group = c.benchmark_group(format!("glob_{count}"));
group.sample_size(50);
for &(name, pat) in patterns {
let id_curr = BenchmarkId::new("current_ptr_trick", name);
group.bench_with_input(id_curr, &pat, |b, &pat| {
b.iter(|| {
let r = current_impl(black_box(pat), black_box(&paths));
black_box(r);
});
});
let id_idx = BenchmarkId::new("match_indices_fn", name);
group.bench_with_input(id_idx, &pat, |b, &pat| {
b.iter(|| {
let r = indices_free_fn(black_box(pat), black_box(&paths));
black_box(r);
});
});
let id_comp = BenchmarkId::new("compiled_pattern", name);
group.bench_with_input(id_comp, &pat, |b, &pat| {
b.iter(|| {
let r = compiled_pattern(black_box(pat), black_box(&paths));
black_box(r);
});
});
}
group.finish();
}
}
/// Hot-loop: pattern compiled ONCE, matched many times against fresh path slices.
/// Models a hypothetical change where we cache compiled patterns across calls.
fn bench_compiled_reuse(c: &mut Criterion) {
let owned = make_paths(10_000);
let paths: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
let pat = "**/*.{rs,ts,lua,md}";
let mut group = c.benchmark_group("glob_reuse_10k");
group.sample_size(100);
group.bench_function("recompile_each_time", |b| {
b.iter(|| {
let p = ZlobPattern::compile(black_box(pat), ZlobFlags::RECOMMENDED).unwrap();
let hits = p
.match_indices(black_box(&paths), ZlobFlags::RECOMMENDED)
.unwrap();
black_box(hits.len());
});
});
let compiled = ZlobPattern::compile(pat, ZlobFlags::RECOMMENDED).unwrap();
group.bench_function("reuse_compiled", |b| {
b.iter(|| {
let hits = compiled
.match_indices(black_box(&paths), ZlobFlags::RECOMMENDED)
.unwrap();
black_box(hits.len());
});
});
group.finish();
}
/// End-to-end: build the lookup AND iterate items checking membership, modeling the
/// real call shape in `apply_constraints` (filter loop reads the result for every item).
fn bench_full_pipeline(c: &mut Criterion) {
bench_full_pipeline_size(c, 100_000);
bench_full_pipeline_size(c, 500_000);
}
fn bench_full_pipeline_size(c: &mut Criterion, count: usize) {
let owned = make_paths(count);
let paths: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
let pat = "**/*.{rs,ts,lua,md}";
let mut group = c.benchmark_group(format!("glob_full_pipeline_{count}"));
group.sample_size(50);
// (A) current: indices -> AHashSet -> per-item set.contains
group.bench_function("indices_to_ahashset_then_filter", |b| {
b.iter(|| {
let hits =
zlob_match_paths_indices(black_box(pat), &paths, ZlobFlags::RECOMMENDED).unwrap();
let set: AHashSet<usize> = hits.to_iter().collect();
let count = (0..paths.len()).filter(|i| set.contains(i)).count();
black_box(count);
});
});
// (B) indices -> Vec<bool> bitmap -> per-item array lookup
group.bench_function("indices_to_bitmap_then_filter", |b| {
b.iter(|| {
let hits =
zlob_match_paths_indices(black_box(pat), &paths, ZlobFlags::RECOMMENDED).unwrap();
let mut mask = vec![false; paths.len()];
for i in hits.to_iter() {
mask[i] = true;
}
let count = (0..paths.len()).filter(|&i| mask[i]).count();
black_box(count);
});
});
// (C) compiled pattern + per-item matches() inside the filter loop. No batch.
group.bench_function("compiled_per_item_matches", |b| {
b.iter(|| {
let p = ZlobPattern::compile(black_box(pat), ZlobFlags::RECOMMENDED).unwrap();
let count = paths.iter().filter(|path| p.matches_default(path)).count();
black_box(count);
});
});
// (D) compiled pattern + chunked batch -> Vec<bool> bitmap. Best of both:
// SIMD batch wins inside chunks, no global allocation pressure, O(1) lookup.
group.bench_function("compiled_chunked_to_bitmap", |b| {
b.iter(|| {
let p = ZlobPattern::compile(black_box(pat), ZlobFlags::RECOMMENDED).unwrap();
let mut mask = vec![false; paths.len()];
for (chunk_idx, chunk) in paths.chunks(512).enumerate() {
let base = chunk_idx * 512;
let hits = p.match_indices(chunk, ZlobFlags::RECOMMENDED).unwrap();
for i in hits.to_iter() {
mask[base + i] = true;
}
}
let count = (0..paths.len()).filter(|&i| mask[i]).count();
black_box(count);
});
});
// (E') indices -> bit-packed Vec<u64> -> per-item bit test
group.bench_function("indices_to_bitset_then_filter", |b| {
b.iter(|| {
let hits =
zlob_match_paths_indices(black_box(pat), &paths, ZlobFlags::RECOMMENDED).unwrap();
let words = paths.len().div_ceil(64);
let mut bits = vec![0u64; words];
for i in hits.to_iter() {
bits[i >> 6] |= 1u64 << (i & 63);
}
let count = (0..paths.len())
.filter(|&i| (bits[i >> 6] >> (i & 63)) & 1 == 1)
.count();
black_box(count);
});
});
// (E) (D) but larger chunk
group.bench_function("compiled_chunked_4096_to_bitmap", |b| {
b.iter(|| {
let p = ZlobPattern::compile(black_box(pat), ZlobFlags::RECOMMENDED).unwrap();
let mut mask = vec![false; paths.len()];
for (chunk_idx, chunk) in paths.chunks(4096).enumerate() {
let base = chunk_idx * 4096;
let hits = p.match_indices(chunk, ZlobFlags::RECOMMENDED).unwrap();
for i in hits.to_iter() {
mask[base + i] = true;
}
}
let count = (0..paths.len()).filter(|&i| mask[i]).count();
black_box(count);
});
});
group.finish();
}
/// Mixed-constraint pipeline: glob + ext. Compare pre-pass batch (current) vs
/// inline `ZlobPattern::matches` after the cheap ext check rejects items.
///
/// Variables: ext rejection rate. Extreme cases reveal where each strategy wins.
fn bench_mixed_pipeline(c: &mut Criterion) {
let count = 100_000;
let owned = make_paths(count);
let paths: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
let glob_pat = "**/*.{rs,ts,lua,md}";
// 4 ext sets: from very selective (1/10 paths kept) to permissive (kept all).
let scenarios: &[(&str, &[&str])] = &[
("ext_1of10", &["rs"]),
("ext_4of10", &["rs", "ts", "lua", "md"]),
(
"ext_8of10",
&["rs", "ts", "lua", "md", "toml", "go", "py", "c"],
),
(
"ext_all",
&["rs", "ts", "lua", "md", "toml", "go", "py", "c", "h", "txt"],
),
];
fn ext_match(name: &str, exts: &[&str]) -> bool {
exts.iter().any(|e| {
let bytes = name.as_bytes();
let elen = e.len();
bytes.len() > elen + 1
&& bytes[bytes.len() - elen - 1] == b'.'
&& bytes[bytes.len() - elen..].eq_ignore_ascii_case(e.as_bytes())
})
}
let mut group = c.benchmark_group("glob_mixed_100k");
group.sample_size(50);
for &(name, exts) in scenarios {
// (A) PRE-PASS: build bitmap for ALL paths, then per-item ext-then-bitmap.
let id_pre = BenchmarkId::new("prepass_bitmap", name);
group.bench_with_input(id_pre, &exts, |b, &exts| {
b.iter(|| {
let hits =
zlob_match_paths_indices(black_box(glob_pat), &paths, ZlobFlags::RECOMMENDED)
.unwrap();
let mut mask = vec![false; paths.len()];
for i in hits.to_iter() {
mask[i] = true;
}
let count = paths
.iter()
.enumerate()
.filter(|&(_, p)| ext_match(p, exts))
.filter(|&(i, _)| mask[i])
.count();
black_box(count);
});
});
// (B) INLINE: compile once, per-item ext check first, then matches() only on survivors.
let id_inline = BenchmarkId::new("inline_compiled", name);
group.bench_with_input(id_inline, &exts, |b, &exts| {
b.iter(|| {
let p = ZlobPattern::compile(black_box(glob_pat), ZlobFlags::RECOMMENDED).unwrap();
let count = paths
.iter()
.filter(|path| ext_match(path, exts))
.filter(|path| p.matches_default(path))
.count();
black_box(count);
});
});
}
group.finish();
}
/// Compare hand-rolled `file_has_extension` byte compare vs compiling extensions
/// into a single brace glob `**/*.{rs,ts,lua,md}` and dispatching through zlob.
/// Both share the same per-item "filter then count" shape.
fn bench_extensions_vs_glob(c: &mut Criterion) {
let owned = make_paths(100_000);
let paths: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
let exts = ["rs", "ts", "lua", "md"];
let glob_pat = "**/*.{rs,ts,lua,md}";
fn ext_match(name: &str, exts: &[&str]) -> bool {
let bytes = name.as_bytes();
exts.iter().any(|e| {
let elen = e.len();
bytes.len() > elen + 1
&& bytes[bytes.len() - elen - 1] == b'.'
&& bytes[bytes.len() - elen..].eq_ignore_ascii_case(e.as_bytes())
})
}
let mut group = c.benchmark_group("ext_vs_glob_100k");
group.sample_size(50);
group.bench_function("file_has_extension_loop", |b| {
b.iter(|| {
let count = paths.iter().filter(|p| ext_match(p, &exts)).count();
black_box(count);
});
});
group.bench_function("compiled_brace_glob_inline", |b| {
b.iter(|| {
let p = ZlobPattern::compile(black_box(glob_pat), ZlobFlags::RECOMMENDED).unwrap();
let count = paths.iter().filter(|path| p.matches_default(path)).count();
black_box(count);
});
});
group.bench_function("brace_glob_prepass_bitmap", |b| {
b.iter(|| {
let hits =
zlob_match_paths_indices(black_box(glob_pat), &paths, ZlobFlags::RECOMMENDED)
.unwrap();
let mut mask = vec![false; paths.len()];
for i in hits.to_iter() {
mask[i] = true;
}
let count = (0..paths.len()).filter(|&i| mask[i]).count();
black_box(count);
});
});
group.finish();
}
criterion_group!(
benches,
bench_glob_strategies,
bench_compiled_reuse,
bench_full_pipeline,
bench_mixed_pipeline,
bench_extensions_vs_glob
);
criterion_main!(benches);
+85
View File
@@ -0,0 +1,85 @@
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use fff_search::simd_string_utils::memmem;
use std::path::Path;
/// Load real source files from the repository as benchmark haystacks.
/// Falls back to concatenating all .rs files under crates/ if specific files are missing.
fn load_real_files() -> Vec<(&'static str, Vec<u8>)> {
let manifest_dir = env!("CARGO_MANIFEST_DIR"); // crates/fff-core
let repo_root = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let files: &[(&str, &str)] = &[
("grep.rs/80KB", "crates/fff-core/src/grep.rs"),
("file_picker.rs/53KB", "crates/fff-core/src/file_picker.rs"),
("picker_ui.lua/96KB", "lua/fff/picker_ui.lua"),
];
let mut result = Vec::new();
for &(label, rel_path) in files {
let full_path = repo_root.join(rel_path);
if let Ok(data) = std::fs::read(&full_path) {
result.push((label, data));
}
}
// Also create a large synthetic file by concatenating all three
if result.len() == 3 {
let mut combined = Vec::new();
for (_, data) in &result {
combined.extend_from_slice(data);
}
// Repeat to get ~1MB
let base = combined.clone();
while combined.len() < 1024 * 1024 {
combined.extend_from_slice(&base);
}
combined.truncate(1024 * 1024);
result.push(("combined/1MB", combined));
}
result
}
fn bench_memmem(c: &mut Criterion) {
let mut group = c.benchmark_group("simd_string_utils_memmem");
let files = load_real_files();
assert!(!files.is_empty(), "No source files found for benchmarking");
// Needles chosen to exercise different false-positive rates:
//
// "hit" needles: strings that actually appear in these source files.
// "miss" needles: strings with common first-bytes (lots of false positives
// for memchr2) but that don't exist in any of the files.
let needles: &[(&str, &[u8])] = &[
// Hits — real identifiers from the codebase
("short/hit/fn", b"fn"),
("short/hit/self", b"self"),
("medium/hit", b"search_file"),
("long/hit", b"content_cache_budget"),
// Misses — common first-bytes, guaranteed not in source
("short/miss", b"zqxjv"),
("medium/miss", b"fluxcapacitor"),
("long/miss", b"quantum_entanglement_resolver"),
];
for (file_label, haystack) in &files {
for &(needle_label, needle) in needles {
let needle_lower: Vec<u8> = needle.iter().map(|b| b.to_ascii_lowercase()).collect();
let id = format!("{file_label}/{needle_label}");
group.bench_with_input(
BenchmarkId::new("find", &id),
&(haystack, &needle_lower),
|b, &(h, n)| {
b.iter(|| black_box(memmem::find(h, n)));
},
);
}
}
group.finish();
}
criterion_group!(benches, bench_memmem);
criterion_main!(benches);
+180
View File
@@ -0,0 +1,180 @@
use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main};
use fff_query_parser::*;
fn bench_parse_simple(c: &mut Criterion) {
let parser = QueryParser::default();
c.bench_function("parse_simple_text", |b| {
b.iter(|| parser.parse(black_box("hello world")));
});
c.bench_function("parse_extension", |b| {
b.iter(|| parser.parse(black_box("*.rs")));
});
c.bench_function("parse_text_with_extension", |b| {
b.iter(|| parser.parse(black_box("name *.rs")));
});
}
fn bench_parse_complex(c: &mut Criterion) {
let parser = QueryParser::default();
c.bench_function("parse_complex_mixed", |b| {
b.iter(|| parser.parse(black_box("src name *.rs !test /lib/ status:modified")));
});
c.bench_function("parse_glob", |b| {
b.iter(|| parser.parse(black_box("**/*.rs")));
});
c.bench_function("parse_multiple_constraints", |b| {
b.iter(|| parser.parse(black_box("*.rs *.toml *.md !test !node_modules /src/")));
});
}
fn bench_parse_realistic_queries(c: &mut Criterion) {
let parser = QueryParser::default();
let queries = vec![
"file",
"test",
"mod.rs",
"src/*.rs",
"lib test",
"*.rs !test",
"src/lib/*.rs",
"/src/ name",
"status:modified *.rs",
"type:rust test !node_modules",
];
let mut group = c.benchmark_group("realistic_queries");
for query in queries.iter() {
group.throughput(Throughput::Bytes(query.len() as u64));
group.bench_with_input(BenchmarkId::from_parameter(query), query, |b, q| {
b.iter(|| parser.parse(black_box(q)));
});
}
group.finish();
}
fn bench_parse_various_lengths(c: &mut Criterion) {
let parser = QueryParser::default();
let short = "*.rs";
let medium = "src name *.rs !test";
let long = "src lib test name *.rs *.toml !node_modules !test /src/ /lib/ status:modified";
let very_long =
"a b c d e f g h i j k l m n o p q r s t u v w x y z *.rs *.toml *.md *.txt *.js";
let mut group = c.benchmark_group("query_lengths");
group.throughput(Throughput::Bytes(short.len() as u64));
group.bench_with_input(BenchmarkId::new("short", short.len()), &short, |b, q| {
b.iter(|| parser.parse(black_box(q)));
});
group.throughput(Throughput::Bytes(medium.len() as u64));
group.bench_with_input(BenchmarkId::new("medium", medium.len()), &medium, |b, q| {
b.iter(|| parser.parse(black_box(q)));
});
group.throughput(Throughput::Bytes(long.len() as u64));
group.bench_with_input(BenchmarkId::new("long", long.len()), &long, |b, q| {
b.iter(|| parser.parse(black_box(q)));
});
group.throughput(Throughput::Bytes(very_long.len() as u64));
group.bench_with_input(
BenchmarkId::new("very_long", very_long.len()),
&very_long,
|b, q| {
b.iter(|| parser.parse(black_box(q)));
},
);
group.finish();
}
fn bench_config_comparison(c: &mut Criterion) {
let file_picker = QueryParser::new(FileSearchConfig);
let grep = QueryParser::new(GrepConfig);
let query = "src name *.rs !test";
let mut group = c.benchmark_group("config_comparison");
group.bench_function("file_picker_config", |b| {
b.iter(|| file_picker.parse(black_box(query)));
});
group.bench_function("grep_config", |b| {
b.iter(|| grep.parse(black_box(query)));
});
group.finish();
}
fn bench_constraint_types(c: &mut Criterion) {
let parser = QueryParser::default();
let mut group = c.benchmark_group("constraint_types");
group.bench_function("extension", |b| {
b.iter(|| parser.parse(black_box("*.rs")));
});
group.bench_function("glob", |b| {
b.iter(|| parser.parse(black_box("**/*.rs")));
});
group.bench_function("exclude", |b| {
b.iter(|| parser.parse(black_box("!test")));
});
group.bench_function("path_segment", |b| {
b.iter(|| parser.parse(black_box("/src/")));
});
group.bench_function("git_status", |b| {
b.iter(|| parser.parse(black_box("status:modified")));
});
group.bench_function("file_type", |b| {
b.iter(|| parser.parse(black_box("type:rust")));
});
group.finish();
}
fn bench_worst_case(c: &mut Criterion) {
let parser = QueryParser::default();
// Worst case: many constraints that all need to be checked
let worst_case = "a b c d e f g h i j k l m n o p q r s t u v w x y z";
c.bench_function("worst_case_many_text_tokens", |b| {
b.iter(|| parser.parse(black_box(worst_case)));
});
// Many constraints
let many_constraints = "*.rs *.toml *.md *.txt *.js *.ts *.jsx *.tsx *.vue *.svelte";
c.bench_function("worst_case_many_constraints", |b| {
b.iter(|| parser.parse(black_box(many_constraints)));
});
}
criterion_group!(
benches,
bench_parse_simple,
bench_parse_complex,
bench_parse_realistic_queries,
bench_parse_various_lengths,
bench_config_comparison,
bench_constraint_types,
bench_worst_case,
);
criterion_main!(benches);
+45
View File
@@ -0,0 +1,45 @@
fn main() {
// Opt-in cfg for the long-running randomized stress tests
// used by tests/fuzz_git_watcher_stress.rs
println!("cargo::rustc-check-cfg=cfg(stress)");
// When the `zlob` feature is enabled (Zig-compiled C library):
// On Windows MSVC, explicitly link the C runtime libraries.
// Zig-compiled static libraries don't emit /DEFAULTLIB directives for the
// MSVC CRT, so symbols like strcmp, memcpy etc. would be unresolved.
if std::env::var("CARGO_FEATURE_ZLOB").is_ok() {
if !zig_available() {
panic!(
"The `zlob` feature is enabled but Zig is not installed. \
Install Zig (https://ziglang.org/download/) or build without \
`--features zlob`."
);
}
let target = std::env::var("TARGET").unwrap_or_default();
if target.contains("windows") && target.contains("msvc") {
println!("cargo:rustc-link-lib=msvcrt");
println!("cargo:rustc-link-lib=ucrt");
println!("cargo:rustc-link-lib=vcruntime");
}
} else if std::env::var("CARGO_PRIMARY_PACKAGE").is_ok() && zig_available() {
// Hint: if Zig is available but the zlob feature wasn't enabled,
// let the developer know they can get faster glob matching.
// Only emit this hint when this crate is the primary package to
// avoid noisy warnings for downstream consumers.
println!(
"cargo:warning=Zig detected but `zlob` feature is not enabled. \
Build with `--features zlob` for faster glob matching."
);
}
}
/// Probe the system for a working Zig installation.
fn zig_available() -> bool {
std::process::Command::new("zig")
.arg("version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok()
}
+853
View File
@@ -0,0 +1,853 @@
use crate::constants::MAX_OVERFLOW_FILES;
use crate::error::Error;
use crate::file_picker::FFFMode;
use crate::git_status_worker::GitStatusWorker;
use crate::shared::{SharedFilePicker, SharedFrecency};
use crate::sort_buffer::sort_with_buffer;
use git2::Repository;
use notify::event::{AccessKind, AccessMode};
use notify::{Config, EventKind, EventKindMask, RecursiveMode};
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, NoCache, new_debouncer_opt};
use parking_lot::Mutex;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::mpsc;
use std::time::Duration;
use tracing::{Level, debug, error, info, warn};
type Debouncer = notify_debouncer_full::Debouncer<notify::RecommendedWatcher, NoCache>;
/// Owns the file-system watcher and guarantees that all background threads
/// are fully joined before `stop()` / `Drop` returns.
pub struct BackgroundWatcher {
debouncer: Arc<Mutex<Option<Debouncer>>>,
watch_tx: Option<mpsc::Sender<PathBuf>>,
owner_thread: Option<std::thread::JoinHandle<()>>,
}
const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(50);
/// On macOS, each `watch()` call creates a separate FSEventStream. When the
/// number of directories exceeds this threshold we fall back to a single
/// recursive watch to avoid exhausting the per-process stream limit.
const MAX_MACOS_NONRECURSIVE_WATCHES: usize = 4096;
/// Minimum seconds between frecency tracks of the same file in AI mode.
/// Prevents score inflation from rapid burst edits by AI agents.
const AI_MODE_COOLDOWN_SECS: u64 = 5 * 60;
impl BackgroundWatcher {
#[allow(clippy::too_many_arguments)]
pub fn new(
base_path: PathBuf,
git_workdir: Option<PathBuf>,
shared_picker: SharedFilePicker,
shared_frecency: SharedFrecency,
mode: FFFMode,
enable_fs_root_scanning: bool,
enable_home_dir_scanning: bool,
git_status_worker: Arc<GitStatusWorker>,
trace_span: tracing::Span,
) -> Result<Self, Error> {
info!(
"Initializing background watcher for path: {}, mode: {:?}",
base_path.display(),
mode,
);
// by default we do not want to allow users to search their FS root, this is very error prone
// though some consumers would specifically allow that e.g. unikernels, windows disc
// partition or sub file systems. By default - fail, unless user permits
let is_fs_root = base_path.parent().is_none();
// use rust's path api for maximum reliability of the comparison
let is_home_dir = Some(&base_path) == dirs::home_dir().as_ref();
if (is_fs_root && !enable_fs_root_scanning) || (is_home_dir && !enable_home_dir_scanning) {
return Err(Error::FilesystemRoot(base_path));
}
// macOS: always use a single recursive FSEvent stream.
// Per-dir NonRecursive watches create one FSEvent stream per dir.
// The per-process FSEvent cap is lower than expected in practice
// (4096 per process, but FFF usually is running within code editors),
// and each failed `watch()` after the cap blocks ~40 ms on kernel retry.
// Yes we pay for filtering events on handler phase but it is usable
//
// Windows doesn't seem to have a hard cap, but in practice non recursive watching
// does a way worse job and often looses events which is not an option for us.
//
// Linux keeps the per-dir NonRecursive strategy: inotify has no
// kernel-level watcher recursion, so we have to manually watch every single interested
// directory for watch events which is in practice stable and fast if system has enough
// spare watcher (configurable by the user, usually 100k - 1m)
let use_recursive = cfg!(any(target_os = "macos", target_os = "windows"));
let (watch_tx, watch_rx) = mpsc::channel::<PathBuf>();
let watch_tx_for_debouncer = watch_tx.clone();
let owner_weak_picker = shared_picker.weaken();
let owner_git_workdir = git_workdir.clone();
let owner_git_worker = Arc::clone(&git_status_worker);
let debouncer = Self::create_debouncer(
base_path,
git_workdir,
shared_picker,
shared_frecency,
mode,
use_recursive,
watch_tx_for_debouncer,
git_status_worker,
)?;
info!("Background file watcher initialized successfully");
// debouncer is shared with the owner thread, once it's dropped the thread is closed
let debouncer = Arc::new(Mutex::new(Some(debouncer)));
// Only the Linux per-dir-watch branch needs this clone; on other
// platforms the owner thread never touches the debouncer.
#[cfg(target_os = "linux")]
let owner_debouncer = Arc::clone(&debouncer);
let owner_span = trace_span.clone();
let owner_thread = std::thread::Builder::new()
.name("fff-watcher-own".into())
.spawn(move || {
let _g = owner_span.enter();
while let Ok(dir) = watch_rx.recv() {
// if the picker is dropped we do need to exit the loop
let Some(strong_picker) = owner_weak_picker.upgrade() else {
break;
};
// Only inotify (Linux) has no kernel-level recursion, so
// it's the only platform that needs a per-subdir watch to
// be registered at runtime. macOS FSEvents and Windows
// ReadDirectoryChangesW are already watching recursively
// from the base path (see `create_debouncer`), and
// registering a second overlapping stream there produces
// duplicate/out-of-order events.
#[cfg(target_os = "linux")]
{
// Register the new directory with the debouncer, then
// drop the mutex BEFORE doing picker-side work — see
// the comment on `BackgroundWatcher::stop` for the
// lock-ordering rationale.
let mut guard = owner_debouncer.lock();
let Some(debouncer) = guard.as_mut() else {
break;
};
if let Err(e) = debouncer.watch(&dir, RecursiveMode::NonRecursive) {
warn!(
?e,
dir = %dir.display(),
"Failed to init watcher for new directory"
);
}
}
track_files_from_new_directories(
&dir,
&strong_picker,
&owner_git_workdir,
&owner_git_worker,
);
// Transient strong ref drops here, back
// to weak-only before the next `recv()`.
}
tracing::info!("Background watcher is stopped");
})
.expect("failed to spawn fff-watcher-owner thread");
Ok(Self {
debouncer,
watch_tx: Some(watch_tx),
owner_thread: Some(owner_thread),
})
}
#[allow(clippy::too_many_arguments)]
fn create_debouncer(
base_path: PathBuf,
git_workdir: Option<PathBuf>,
shared_picker: SharedFilePicker,
shared_frecency: SharedFrecency,
mode: FFFMode,
use_recursive: bool,
watch_tx: mpsc::Sender<PathBuf>,
git_status_worker: Arc<GitStatusWorker>,
) -> Result<Debouncer, Error> {
let config = Config::default()
// do not follow symlinks as then notifiers spawns a bunch of events for symlinked
// files that could be git ignored, we have to property differentiate those and if
// the file was edited through a
.with_follow_symlinks(false)
// only the actual modification events, ignore the open syscals that we can generate by
// our own grep calls and preview window rendering
.with_event_kinds(EventKindMask::CORE);
let git_workdir_for_handler = git_workdir.clone();
let base_path_for_handler = base_path.clone();
let shared_picker_for_watching = shared_picker.clone();
let file_picker = shared_picker.weaken();
let mut debouncer = new_debouncer_opt(
DEBOUNCE_TIMEOUT,
Some(DEBOUNCE_TIMEOUT / 2), // tick rate for the event span
{
move |result: DebounceEventResult| match result {
Ok(events) => {
let Some(file_picker) = file_picker.upgrade() else {
return;
};
let new_dirs = handle_debounced_events(
mode,
events,
&base_path_for_handler,
&git_workdir_for_handler,
&file_picker,
&shared_frecency,
&git_status_worker,
);
// every new directory created has to be reflected in the picker state
for dir in new_dirs {
if let Err(e) = watch_tx.send(dir) {
error!(?e, "Failed to send directory update error");
}
}
}
Err(errors) => {
error!("File watcher errors: {:?}", errors);
}
}
},
// There is an issue with recommended cache implementation on macos
// it keeps track of all the files added to the watcher which is not a problem
// for us because any rename to the file will anyway require the removing from the
// ordedred index and adding it back with the new name
NoCache::new(),
config,
)?;
if use_recursive {
// if the platform supports native watcher recursion
debouncer.watch(base_path.as_path(), RecursiveMode::Recursive)?;
info!(
"File watcher initialized with single recursive watch on {} \
(exceeded threshold of {})",
base_path.display(),
MAX_MACOS_NONRECURSIVE_WATCHES,
);
} else {
debouncer.watch(base_path.as_path(), RecursiveMode::NonRecursive)?;
const MAX_CONSECUTIVE_WATCH_FAILURES: usize = 16;
let mut watched = 0usize;
let mut consecutive_failures = 0usize;
// `inotify` is fast-fail: on ENOSPC it returns
// immediately, no kernel retry loop, so holding this lock is free
if let Some(guard) = shared_picker_for_watching.read().ok()
&& let Some(picker) = guard.as_ref()
{
use std::ops::ControlFlow;
picker.for_each_dir(|dir| {
match debouncer.watch(dir, RecursiveMode::NonRecursive) {
Ok(()) => {
watched += 1;
consecutive_failures = 0;
ControlFlow::Continue(())
}
Err(e) => {
consecutive_failures += 1;
if consecutive_failures <= 4 {
warn!("Failed to watch directory {}: {}", dir.display(), e);
}
if consecutive_failures >= MAX_CONSECUTIVE_WATCH_FAILURES {
warn!(
consecutive_failures,
watched,
"Giving up setting file watcher for all the directories. Check if your system has enough fs watchers limit."
);
ControlFlow::Break(())
} else {
ControlFlow::Continue(())
}
}
}
});
}
tracing::info!(
?watched,
path = ?base_path.display(),
"File watcher initialized"
);
}
// The .git directory is excluded from the file list but we still need
// to observe changes that affect git status (staging, unstaging,
// committing, branch switches, merges, etc)
watch_git_status_paths(&mut debouncer, git_workdir.as_ref());
Ok(debouncer)
}
/// Signals the background watcher threads to shut down, doesn't guarantee to deallocate immediately
pub fn stop(&mut self) {
self.watch_tx.take();
if let Some(debouncer) = self.debouncer.lock().take() {
debouncer.stop_nonblocking();
}
self.owner_thread.take();
info!("Background file watcher stop signaled");
}
pub(crate) fn request_watch_dir(&self, dir: PathBuf) -> bool {
match self.watch_tx.as_ref() {
Some(tx) => tx.send(dir).is_ok(),
None => false,
}
}
}
impl Drop for BackgroundWatcher {
fn drop(&mut self) {
self.stop();
}
}
#[tracing::instrument(name = "fs_events", skip(events, shared_picker, shared_frecency, git_status_worker), level = Level::DEBUG)]
fn handle_debounced_events(
mode: FFFMode,
events: Vec<DebouncedEvent>,
base_path: &Path,
git_workdir: &Option<PathBuf>,
shared_picker: &SharedFilePicker,
shared_frecency: &SharedFrecency,
git_status_worker: &Arc<GitStatusWorker>,
) -> Vec<PathBuf> {
// this will be called very often, we have to minimiy the lock time for file picker
let repo = git_workdir.as_ref().and_then(|p| Repository::open(p).ok());
// Prefer the walker's own ignore rules (zlob); grab a cheap Arc clone once
// per batch so we don't hold the picker lock during filtering.
let walker_rules = shared_picker
.read()
.ok()
.and_then(|g| g.as_ref().and_then(|p| p.ignore_rules()));
let filter = IgnoreFilter::new(base_path, walker_rules, repo.as_ref());
let mut need_full_rescan = false;
let mut need_full_git_rescan = false;
let mut paths_to_remove = Vec::new();
let mut dirs_to_remove: Vec<PathBuf> = Vec::new();
let mut paths_to_add_or_modify = Vec::new();
let mut new_dirs_to_watch = Vec::new();
let mut affected_paths_count = 0usize;
for debounced_event in &events {
// It is very important to not react to the access errors because we inevitably
// gonna trigger the sync by our own preview or other unnecessary noise
if matches!(
debounced_event.event.kind,
EventKind::Access(
AccessKind::Read
| AccessKind::Open(_)
| AccessKind::Close(AccessMode::Read | AccessMode::Execute)
)
) {
continue;
}
// When macOS FSEvents (or other backends) overflow their event buffer, the kernel
// drops individual events and emits a rescan flag telling us to re-scan the subtree
if debounced_event.event.need_rescan() {
if debounced_event.event.paths.len() < 16 // this should be usually one event
&& debounced_event
.paths
.iter()
// but we are smart enough and not falling into the paths
.all(|p| should_include_file(p, &filter))
{
break;
}
warn!(
"Received rescan event for paths {:?}, triggering full rescan",
debounced_event.event.paths
);
need_full_rescan = true;
break;
}
tracing::debug!(event = ?debounced_event.event, "Processing FS event");
for path in &debounced_event.event.paths {
if matches!(
path.file_name().and_then(|f| f.to_str()),
Some(".ignore") | Some(".gitignore")
) {
info!(
"Detected change in ignore definition file: {}",
path.display()
);
need_full_rescan = true;
break;
}
if is_dotgit_change_affecting_status(path, &repo) {
need_full_git_rescan = true;
}
if is_git_file(path) {
continue;
}
// Use a combination of event kind and filesystem state to decide
// whether a path is an addition/modification or a removal.
//
// We cannot rely on `path.exists()` alone because:
// - A freshly created file might not be visible yet (race).
// - macOS FSEvents uses Modify(Name(Any)) for both rename-in
// and rename-out, so we must stat the path to disambiguate.
//
// We cannot rely on event kind alone because:
// - Remove events are not always emitted (macOS often sends
// Modify(Name(Any)) instead of Remove).
let is_removal = matches!(debounced_event.event.kind, EventKind::Remove(_));
// Directory-level remove: both fsevents and inotify delivers a single
// `Remove(Folder)` event for a whole directory tree (e.g.
// after `git reset --hard` wipes a dir full of staged-but-
// uncommitted files).
let is_folder_removal = matches!(
debounced_event.event.kind,
EventKind::Remove(notify::event::RemoveKind::Folder)
);
if is_folder_removal {
dirs_to_remove.push(path.to_path_buf());
} else if is_removal || !path.exists() {
paths_to_remove.push(path.as_path());
} else if path.is_dir() {
if !is_path_ignored(path, &filter) {
new_dirs_to_watch.push(path.to_path_buf());
}
} else {
// For additions/modifications, still filter gitignored files.
if should_include_file(path, &filter) {
paths_to_add_or_modify.push(path.as_path());
}
}
}
affected_paths_count += debounced_event.event.paths.len();
if affected_paths_count > MAX_OVERFLOW_FILES {
warn!(
?affected_paths_count,
max = MAX_OVERFLOW_FILES,
"Too many affected paths in a single batch, triggering full rescan",
);
need_full_rescan = true;
break;
}
if need_full_rescan {
break;
}
}
if need_full_rescan {
info!(?affected_paths_count, "Triggering full rescan");
if let Err(e) = shared_picker.trigger_full_rescan_async(shared_frecency) {
error!("Failed to trigger full rescan: {:?}", e);
}
return Vec::new();
}
// It's important to get the allocated sort
sort_with_buffer(paths_to_add_or_modify.as_mut_slice(), |a, b| {
a.as_os_str().cmp(b.as_os_str())
});
paths_to_add_or_modify.dedup_by(|a, b| a.as_os_str().eq(b.as_os_str()));
info!(
"Event processing summary: {} to remove, {} dirs to remove, {} to add/modify, {} new dirs",
paths_to_remove.len(),
dirs_to_remove.len(),
paths_to_add_or_modify.len(),
new_dirs_to_watch.len()
);
if paths_to_remove.is_empty()
&& dirs_to_remove.is_empty()
&& paths_to_add_or_modify.is_empty()
&& !need_full_git_rescan
{
debug!("No file index changes to apply");
return new_dirs_to_watch;
}
let mut files_to_update_git_status = Vec::new();
let mut need_full_rescan = false;
let mut overflow_count = 0;
if !paths_to_remove.is_empty()
|| !dirs_to_remove.is_empty()
|| !paths_to_add_or_modify.is_empty()
{
debug!(
"Applying file index changes: {} to remove, {} dirs to remove, {} to add/modify",
paths_to_remove.len(),
dirs_to_remove.len(),
paths_to_add_or_modify.len(),
);
let Ok(mut guard) = shared_picker.write() else {
error!("Failed to acquire file picker write lock");
return new_dirs_to_watch;
};
let Some(ref mut picker) = *guard else {
error!("File picker not initialized");
return new_dirs_to_watch;
};
for dir in &dirs_to_remove {
let count = picker.remove_all_files_in_dir(dir);
debug!("remove_all_files_in_dir({:?}) -> {} files", dir, count);
}
for path in &paths_to_remove {
let removed = picker.remove_file_by_path(path);
debug!("remove_file_by_path({:?}) -> {}", path, removed);
}
files_to_update_git_status.reserve(paths_to_add_or_modify.len());
for path in &paths_to_add_or_modify {
if picker.handle_create_or_modify(path).is_some() {
files_to_update_git_status.push(path.to_path_buf());
} else {
need_full_rescan = true;
}
}
overflow_count = picker.get_overflow_files().len();
}
info!(
files_updated = files_to_update_git_status.len(),
overflow_count, "File index changes applied",
);
if need_full_rescan || overflow_count > MAX_OVERFLOW_FILES {
info!("Watcher faced limit of index overflow. Triggering rescan");
if let Err(e) = shared_picker.trigger_full_rescan_async(shared_frecency) {
error!("Failed to trigger full rescan: {:?}", e);
}
}
// AI mode: auto-track frecency for all modified/created files.
// Uses a 5-minute cooldown per file to prevent score inflation from rapid
// burst edits (AI agents often edit the same file many times in minutes).
// This runs after apply_changes so the picker write lock is released.
if mode.is_ai() && !paths_to_add_or_modify.is_empty() {
let mut tracked_count = 0usize;
if let Ok(frecency_guard) = shared_frecency.read()
&& let Some(ref frecency) = *frecency_guard
{
for path in &paths_to_add_or_modify {
// Skip if this file was tracked less than 5 minutes ago
let should_track = match frecency.seconds_since_last_access(path) {
Ok(Some(secs)) => secs >= AI_MODE_COOLDOWN_SECS,
Ok(None) => true, // Never tracked before
Err(_) => true, // DB error, track anyway
};
if !should_track {
continue;
}
if let Err(e) = frecency.track_access(path) {
error!("Failed to track frecency for {:?}: {:?}", path, e);
} else {
tracked_count += 1;
}
}
if tracked_count > 0 {
info!("AI mode: tracked frecency for {} files", tracked_count);
}
}
// Update in-memory frecency scores for tracked files
if tracked_count > 0
&& let Ok(mut picker_guard) = shared_picker.write()
&& let Some(ref mut picker) = *picker_guard
&& let Ok(frecency_guard) = shared_frecency.read()
&& let Some(ref frecency) = *frecency_guard
{
for path in &paths_to_add_or_modify {
let _ = picker.update_single_file_frecency(path, frecency);
}
}
}
// do not try to update the paths if we anyway going to rescan everything from scratch
// no repo => no consumer thread, so don't accumulate paths nobody will drain
if !need_full_rescan && repo.is_some() {
if need_full_git_rescan {
// A full git rescan re-reads every tracked path (including ones that just
// went clean after a commit), so it already subsumes the per-path update.
git_status_worker.request_full_rescan();
} else if !files_to_update_git_status.is_empty() {
git_status_worker.enqueue_paths(files_to_update_git_status);
}
}
new_dirs_to_watch
}
/// After registering a watch on a newly created directory, list its
/// immediate children and add any files to the picker.
fn track_files_from_new_directories(
dir: &Path,
shared_picker: &SharedFilePicker,
git_workdir: &Option<PathBuf>,
git_status_worker: &Arc<GitStatusWorker>,
) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let repo = git_workdir.as_ref().and_then(|p| Repository::open(p).ok());
// Prefer the walker's ignore rules; read base_path + rules from the picker.
let (base_path, walker_rules) = match shared_picker.read().ok().and_then(|g| {
g.as_ref()
.map(|p| (p.base_path().to_path_buf(), p.ignore_rules()))
}) {
Some(pair) => pair,
None => return,
};
let filter = IgnoreFilter::new(&base_path, walker_rules, repo.as_ref());
let mut files_to_add = Vec::new();
for entry in entries.flatten() {
if entry.file_type().is_ok_and(|ft| ft.is_file()) {
let path = entry.path();
if should_include_file(&path, &filter) {
files_to_add.push(path);
}
}
}
if files_to_add.is_empty() {
return;
}
let added = files_to_add.len();
{
let Ok(mut guard) = shared_picker.write() else {
return;
};
let Some(ref mut picker) = *guard else {
return;
};
for path in &files_to_add {
picker.handle_create_or_modify(path);
}
}
if repo.is_some() {
git_status_worker.enqueue_paths(files_to_add);
}
debug!(
"Injected {} existing files from new directory {}",
added,
dir.display(),
);
}
fn should_include_file(path: &Path, filter: &IgnoreFilter) -> bool {
// Directories are not indexed — only regular files (and symlinks to files).
if path.is_dir() {
return false;
}
!filter.is_ignored(path)
}
#[inline]
fn is_path_ignored(path: &Path, filter: &IgnoreFilter) -> bool {
filter.is_ignored(path)
}
struct IgnoreFilter<'a> {
base_path: &'a Path,
/// Reusable ignore rules from the last walk (zlob backend only).
rules: Option<Arc<crate::walk::WalkIgnoreRules>>,
/// libgit2 repo, consulted only when `rules` is `None`. Borrowed from the
/// caller's repo (also used for git-status queries) to avoid re-opening.
repo: Option<&'a Repository>,
}
impl<'a> IgnoreFilter<'a> {
fn new(
base_path: &'a Path,
rules: Option<Arc<crate::walk::WalkIgnoreRules>>,
repo: Option<&'a Repository>,
) -> Self {
Self {
base_path,
rules,
repo,
}
}
/// Whether `path` (absolute) is ignored.
fn is_ignored(&self, path: &Path) -> bool {
if let Some(rules) = self.rules.as_ref() {
let Ok(rel) = path.strip_prefix(self.base_path) else {
return false;
};
// `IgnoreRules::is_ignored` enumerates every ancestor .gitignore
// layer internally, so a leaf under an ignored directory (rule
// `build/`, path `build/out.rs`) is caught in one call.
return rules.is_ignored(rel);
}
match self.repo {
Some(repo) => repo.is_path_ignored(path) == Ok(true),
// No repo and no rules: fall back to the non-code-dir heuristic.
None => crate::ignore::is_non_code_directory(path),
}
}
}
#[inline]
pub(crate) fn is_git_file(path: &Path) -> bool {
// it could be in submodule
path.components()
.any(|component| component.as_os_str() == ".git")
}
fn is_dotgit_change_affecting_status(changed: &Path, repo: &Option<Repository>) -> bool {
let Some(repo) = repo.as_ref() else {
return false;
};
let git_dir = repo.path();
if let Ok(path_in_git_dir) = changed.strip_prefix(git_dir) {
// Only react to changes that rewrite the worktree state: commits,
// staging, checkouts, merges, conflict resolution. Ref-only updates
// under refs/ (fetch, push, tag writes, pack-refs) do not change
// which files are modified/untracked, so we deliberately skip them
// watching refs/ recursively would cost one inotify watch per ref
// namespace on repos with many branches/remotes.
if path_in_git_dir == Path::new("index") || path_in_git_dir == Path::new("index.lock") {
return true;
}
if path_in_git_dir == Path::new("HEAD") {
return true;
}
// some of the git ops are not involving nethier index nor HEAD change, or sometimes
// index updates can arrive too late after the change - that's why we track the log
// the actual user action, once user
if path_in_git_dir == Path::new("logs/HEAD") {
return true;
}
if let Some(fname) = path_in_git_dir.file_name().and_then(|f| f.to_str())
&& matches!(fname, "MERGE_HEAD" | "CHERRY_PICK_HEAD" | "REVERT_HEAD")
{
return true;
}
}
false
}
fn watch_git_status_paths(debouncer: &mut Debouncer, git_workdir: Option<&PathBuf>) {
let Some(workdir) = git_workdir else {
return;
};
let git_dir = workdir.join(".git");
if !git_dir.is_dir() {
return;
}
// We have tried to be smart about the internal git state but
// it appeared more harmful that it's worth it, so we just watch
// for the most obvious paths like HEAD, MERGE_HEAD, index.lock
if let Err(e) = debouncer.watch(&git_dir, RecursiveMode::NonRecursive) {
warn!("Failed to watch .git directory: {}", e);
}
// `.git` above is non-recursive, so on Linux (per-dir inotify watches)
// events for `logs/HEAD` — the commit-finished signal used by
// `is_dotgit_change_affecting_status` — would never be delivered without
// watching `.git/logs` itself. On macOS/Windows the recursive base watch
// already covers it; an extra watch is harmless there.
let logs_dir = git_dir.join("logs");
if logs_dir.is_dir()
&& let Err(e) = debouncer.watch(&logs_dir, RecursiveMode::NonRecursive)
{
warn!("Failed to watch .git/logs directory: {}", e);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dotgit_status_filter_matches_worktree_state_changes() {
let tmp = tempfile::tempdir().unwrap();
let repo = git2::Repository::init(tmp.path()).unwrap();
let git_dir = repo.path().to_path_buf();
let repo = Some(repo);
let affecting = ["index", "index.lock", "HEAD", "logs/HEAD", "MERGE_HEAD"];
for p in affecting {
assert!(
is_dotgit_change_affecting_status(&git_dir.join(p), &repo),
"{p} must trigger a git status rescan"
);
}
// Ref-only updates (fetch/push/tags) and commit scratch files must not.
let non_affecting = [
"refs/heads/main",
"refs/heads/main.lock",
"logs/refs/remotes/origin/main",
"COMMIT_EDITMSG",
"packed-refs",
];
for p in non_affecting {
assert!(
!is_dotgit_change_affecting_status(&git_dir.join(p), &repo),
"{p} must NOT trigger a git status rescan"
);
}
// Worktree paths outside .git never match.
assert!(!is_dotgit_change_affecting_status(
&tmp.path().join("src/main.rs"),
&repo
));
assert!(!is_dotgit_change_affecting_status(
&git_dir.join("index"),
&None
));
}
}
File diff suppressed because it is too large Load Diff
+998
View File
@@ -0,0 +1,998 @@
use crate::bigram_filter::BigramFilter;
use regex_syntax::hir::{Class, Hir, HirKind};
use smallvec::SmallVec;
use std::borrow::Cow;
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();
let bl = b.to_ascii_lowercase();
if (32..=126).contains(&al) && (32..=126).contains(&bl) {
Some((al as u16) << 8 | bl as u16)
} else {
None
}
}
#[derive(Debug, Clone)]
pub enum BigramQuery {
Any,
/// A consecutive bigram key to look up in the main index.
Consec(u16),
/// A skip-1 bigram key to look up in the skip sub-index.
Skip1(u16),
/// All children must match (intersect posting lists).
And(Vec<BigramQuery>),
/// At least one child must match (union posting lists).
Or(Vec<BigramQuery>),
}
/// SIMD-friendly bitwise OR of two equal-length bitsets.
#[inline]
fn bitset_or(a: &mut [u64], b: &[u64]) {
a.iter_mut().zip(b.iter()).for_each(|(x, y)| *x |= *y);
}
/// SIMD-friendly bitwise AND of two equal-length bitsets.
#[inline]
fn bitset_and(a: &mut [u64], b: &[u64]) {
a.iter_mut().zip(b.iter()).for_each(|(x, y)| *x &= *y);
}
impl BigramQuery {
pub fn is_any(&self) -> bool {
matches!(self, BigramQuery::Any)
}
pub(crate) fn evaluate(&self, index: &BigramFilter) -> Option<Vec<u64>> {
self.evaluate_cow(index).map(Cow::into_owned)
}
fn evaluate_cow<'a>(&self, index: &'a BigramFilter) -> Option<Cow<'a, [u64]>> {
match self {
BigramQuery::Any => None,
BigramQuery::Consec(key) => {
let col = index.lookup()[*key as usize];
if col == u16::MAX {
return None;
}
let words = index.words();
let offset = col as usize * words;
let data = index.dense_data();
if offset + words > data.len() {
return None;
}
Some(Cow::Borrowed(&data[offset..offset + words]))
}
BigramQuery::Skip1(key) => {
let skip = index.skip_index()?;
let col = skip.lookup()[*key as usize];
if col == u16::MAX {
return None;
}
let words = skip.words();
let offset = col as usize * words;
let data = skip.dense_data();
if offset + words > data.len() {
return None;
}
Some(Cow::Borrowed(&data[offset..offset + words]))
}
BigramQuery::And(children) => {
let mut result: Option<Vec<u64>> = None;
for child in children {
if let Some(child_bits) = child.evaluate_cow(index) {
result = Some(match result {
None => child_bits.into_owned(),
Some(mut r) => {
bitset_and(&mut r, &child_bits);
r
}
});
}
}
result.map(Cow::Owned)
}
BigramQuery::Or(children) => {
if children.is_empty() {
return None;
}
let mut result: Option<Vec<u64>> = None;
for child in children {
// 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)
}
}
}
}
struct HirInfo {
query: BigramQuery,
first: Option<InlineArray>,
last: Option<InlineArray>,
can_be_empty: bool,
}
impl HirInfo {
fn empty() -> Self {
Self {
query: BigramQuery::Any,
first: None,
last: None,
can_be_empty: true,
}
}
}
/// Prefilter fuzzy query. The algorithm is the following:
/// we allow max_typos = min(len/3,2) every typo destroys at most 2 consecutive bigrams
/// So out of N bigrams at least N - 2 * max_typos have to present in the matching fil
pub(crate) fn fuzzy_to_bigram_query(query: &str, num_probes: usize) -> BigramQuery {
let lower: Vec<u8> = query.bytes().map(|b| b.to_ascii_lowercase()).collect();
if lower.len() < 2 {
return BigramQuery::Any;
}
let max_typos = (lower.len() / 3).min(2);
// Extract all consecutive bigram keys.
let bigram_keys: Vec<u16> = lower
.windows(2)
.filter_map(|w| consec_key(w[0], w[1]))
.collect();
if bigram_keys.is_empty() {
return BigramQuery::Any;
}
// the simplest case, just check that every bigram is present either consec or not
if max_typos == 0 {
return simplify_and(
bigram_keys
.iter()
.map(|&k| BigramQuery::Consec(k))
.collect(),
);
}
// Pick evenly-spaced probe bigrams.
let n = num_probes.min(bigram_keys.len());
if n <= max_typos {
// Too few probes to require anything useful.
return simplify_or(
bigram_keys
.iter()
.map(|&k| BigramQuery::Consec(k))
.collect(),
);
}
let probes: Vec<u16> = if n == bigram_keys.len() {
bigram_keys
} else {
(0..n)
.map(|i| {
let idx = i * (bigram_keys.len() - 1) / (n - 1);
bigram_keys[idx]
})
.collect()
};
let required = n - max_typos;
// If required == n, just AND all probes.
if required >= n {
return simplify_and(probes.iter().map(|&k| BigramQuery::Consec(k)).collect());
}
// Generate all C(n, required) subsets as OR(AND(subset), ...)
let mut branches = Vec::new();
let mut combo = vec![0u16; required];
combine(&probes, required, 0, 0, &mut combo, &mut branches);
simplify_or(branches)
}
/// Build C(n, k) combination branches in-place on a fixed-size slice.
fn combine(
items: &[u16],
k: usize,
start: usize,
depth: usize,
combo: &mut [u16],
branches: &mut Vec<BigramQuery>,
) {
if depth == k {
branches.push(simplify_and(
combo.iter().map(|&key| BigramQuery::Consec(key)).collect(),
));
return;
}
let remaining = k - depth;
for i in start..=items.len() - remaining {
combo[depth] = items[i];
combine(items, k, i + 1, depth + 1, combo, branches);
}
}
pub(crate) fn regex_to_bigram_query(pattern: &str) -> BigramQuery {
let mut parser = regex_syntax::ParserBuilder::new()
.unicode(false)
.utf8(false)
.build();
let hir = match parser.parse(pattern) {
Ok(h) => h,
Err(_) => return BigramQuery::Any,
};
decompose(&hir).query
}
fn decompose(hir: &Hir) -> HirInfo {
let can_be_empty = hir.properties().minimum_len().is_none_or(|n| n == 0);
match hir.kind() {
HirKind::Empty => HirInfo::empty(),
HirKind::Literal(lit) => decompose_literal(lit.0.as_ref()),
HirKind::Class(class) => {
let bytes = expand_class(class);
match bytes {
Some(b) if !b.is_empty() => HirInfo {
query: BigramQuery::Any,
first: Some(b),
last: Some(b),
can_be_empty,
},
_ => HirInfo {
query: BigramQuery::Any,
first: None,
last: None,
can_be_empty,
},
}
}
HirKind::Look(_) => HirInfo::empty(),
HirKind::Repetition(rep) => {
let inner = decompose(&rep.sub);
if rep.min == 0 {
HirInfo {
query: BigramQuery::Any,
first: inner.first,
last: inner.last,
can_be_empty: true,
}
} else {
// min >= 1: inner bigrams guaranteed
let mut qs = Vec::new();
if !inner.query.is_any() {
qs.push(inner.query.clone());
}
// min >= 2: cross-boundary between consecutive occurrences
if rep.min >= 2 {
push_cross_consec(&mut qs, inner.last.as_deref(), inner.first.as_deref());
}
HirInfo {
query: simplify_and(qs),
first: inner.first,
last: inner.last,
can_be_empty,
}
}
}
HirKind::Capture(cap) => decompose(&cap.sub),
HirKind::Concat(parts) => decompose_concat(parts),
HirKind::Alternation(alts) => decompose_alternation(alts),
}
}
/// Extract bigrams from a literal byte sequence.
fn decompose_literal(bytes: &[u8]) -> HirInfo {
if bytes.is_empty() {
return HirInfo::empty();
}
let lower: SmallVec<[u8; 64]> = bytes.iter().map(|b| b.to_ascii_lowercase()).collect();
if lower.len() == 1 {
let b = lower[0];
let first = if (32..=126).contains(&b) {
Some(InlineArray::from_byte(b))
} else {
None
};
return HirInfo {
query: BigramQuery::Any,
first,
last: first,
can_be_empty: false,
};
}
let mut qs: Vec<BigramQuery> = Vec::new();
// Consecutive bigrams
for w in lower.windows(2) {
if let Some(k) = consec_key(w[0], w[1]) {
qs.push(BigramQuery::Consec(k));
}
}
// Skip-1 bigrams from the literal itself
if lower.len() >= 3 {
for i in 0..lower.len() - 2 {
if let Some(k) = consec_key(lower[i], lower[i + 2]) {
qs.push(BigramQuery::Skip1(k));
}
}
}
let first_byte = lower[0];
let last_byte = *lower.last().unwrap();
HirInfo {
query: simplify_and(qs),
first: if (32..=126).contains(&first_byte) {
Some(InlineArray::from_byte(first_byte))
} else {
None
},
last: if (32..=126).contains(&last_byte) {
Some(InlineArray::from_byte(last_byte))
} else {
None
},
can_be_empty: false,
}
}
fn decompose_concat(parts: &[Hir]) -> HirInfo {
if parts.is_empty() {
return HirInfo::empty();
}
let infos: Vec<HirInfo> = parts.iter().map(decompose).collect();
let mut qs: Vec<BigramQuery> = Vec::new();
for info in &infos {
if !info.query.is_any() {
qs.push(info.query.clone());
}
}
// 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());
}
}
// 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];
let mid = &parts[i + 1];
let right = &infos[i + 2];
let min_len = mid.properties().minimum_len();
let max_len = mid.properties().maximum_len();
let is_1byte = min_len == Some(1) && max_len == Some(1);
if is_1byte && !left.can_be_empty && !right.can_be_empty {
push_cross_skip1(&mut qs, left.last.as_deref(), right.first.as_deref());
}
}
}
let first = collect_first(&infos);
let last = collect_last(&infos);
let can_be_empty = infos.iter().all(|i| i.can_be_empty);
HirInfo {
query: simplify_and(qs),
first,
last,
can_be_empty,
}
}
fn decompose_alternation(alts: &[Hir]) -> HirInfo {
if alts.is_empty() {
return HirInfo::empty();
}
let infos: Vec<HirInfo> = alts.iter().map(decompose).collect();
let query = simplify_or(infos.iter().map(|i| i.query.clone()).collect());
let first = merge_byte_sets(infos.iter().map(|i| &i.first));
let last = merge_byte_sets(infos.iter().map(|i| &i.last));
let can_be_empty = infos.iter().any(|i| i.can_be_empty);
HirInfo {
query,
first,
last,
can_be_empty,
}
}
fn expand_class(class: &Class) -> Option<InlineArray> {
let mut bytes = InlineArray::new();
match class {
Class::Bytes(bc) => {
for range in bc.ranges() {
let count = (range.end() as usize) - (range.start() as usize) + 1;
if bytes.len() + count > MAX_CLASS_EXPAND {
return None;
}
for b in range.start()..=range.end() {
if (32..=126).contains(&b) {
let lower = b.to_ascii_lowercase();
if !bytes.contains(&lower) {
bytes.push(lower);
}
}
}
}
}
Class::Unicode(uc) => {
for range in uc.ranges() {
let start = range.start() as u32;
let end = range.end() as u32;
if start > 127 {
continue;
}
let ascii_end = end.min(126) as u8;
let ascii_start = start.max(32) as u8;
if ascii_start > ascii_end {
continue;
}
let count = (ascii_end - ascii_start) as usize + 1;
if bytes.len() + count > MAX_CLASS_EXPAND {
return None;
}
for b in ascii_start..=ascii_end {
let lower = b.to_ascii_lowercase();
if !bytes.contains(&lower) {
bytes.push(lower);
}
}
}
}
}
if bytes.is_empty() { None } else { Some(bytes) }
}
/// Push consecutive cross-product bigrams into `qs`.
fn push_cross_consec(qs: &mut Vec<BigramQuery>, last: Option<&[u8]>, first: Option<&[u8]>) {
if let Some(q) = cross_product(last, first, false) {
qs.push(q);
}
}
/// Push skip-1 cross-product bigrams into `qs`.
fn push_cross_skip1(qs: &mut Vec<BigramQuery>, last: Option<&[u8]>, first: Option<&[u8]>) {
if let Some(q) = cross_product(last, first, true) {
qs.push(q);
}
}
fn cross_product(last: Option<&[u8]>, first: Option<&[u8]>, skip: bool) -> Option<BigramQuery> {
let last = last?;
let first = first?;
let n = last.len() * first.len();
if n == 0 || n > MAX_CLASS_EXPAND * MAX_CLASS_EXPAND {
return None;
}
let mut bigrams: Vec<BigramQuery> = Vec::with_capacity(n);
for &l in last {
for &f in first {
if let Some(k) = consec_key(l, f) {
let node = if skip {
BigramQuery::Skip1(k)
} else {
BigramQuery::Consec(k)
};
bigrams.push(node);
}
}
}
match bigrams.len() {
0 => None,
1 => Some(bigrams.into_iter().next().unwrap()),
_ => Some(simplify_or(bigrams)),
}
}
fn collect_first(infos: &[HirInfo]) -> Option<InlineArray> {
let mut result = InlineArray::new();
for info in infos {
if let Some(ref bytes) = info.first {
for &b in bytes.iter() {
if !result.contains(&b) {
if result.len() >= MAX_CLASS_EXPAND {
return None;
}
result.push(b);
}
}
} else if !info.can_be_empty {
return None;
}
if !info.can_be_empty {
break;
}
}
if result.is_empty() {
None
} else {
Some(result)
}
}
fn collect_last(infos: &[HirInfo]) -> Option<InlineArray> {
let mut result = InlineArray::new();
for info in infos.iter().rev() {
if let Some(ref bytes) = info.last {
for &b in bytes.iter() {
if !result.contains(&b) {
if result.len() >= MAX_CLASS_EXPAND {
return None;
}
result.push(b);
}
}
} else if !info.can_be_empty {
return None;
}
if !info.can_be_empty {
break;
}
}
if result.is_empty() {
None
} else {
Some(result)
}
}
fn merge_byte_sets<'a>(iter: impl Iterator<Item = &'a Option<InlineArray>>) -> Option<InlineArray> {
let mut result = InlineArray::new();
for opt in iter {
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);
}
}
}
if result.is_empty() {
None
} else {
Some(result)
}
}
fn simplify_and(children: Vec<BigramQuery>) -> BigramQuery {
let mut flat: Vec<BigramQuery> = Vec::new();
for child in children {
match child {
BigramQuery::Any => {}
BigramQuery::And(inner) => flat.extend(inner),
other => flat.push(other),
}
}
match flat.len() {
0 => BigramQuery::Any,
1 => flat.into_iter().next().unwrap(),
_ => BigramQuery::And(flat),
}
}
fn simplify_or(children: Vec<BigramQuery>) -> BigramQuery {
if children.iter().any(|c| c.is_any()) {
return BigramQuery::Any;
}
let mut flat: Vec<BigramQuery> = Vec::new();
for child in children {
match child {
BigramQuery::Or(inner) => flat.extend(inner),
other => flat.push(other),
}
}
match flat.len() {
0 => BigramQuery::Any,
1 => flat.into_iter().next().unwrap(),
_ => BigramQuery::Or(flat),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bigram_filter::BigramIndexBuilder;
/// Build a tiny index from the given file contents for testing.
fn build_test_index(files: &[&[u8]]) -> BigramFilter {
let n = files.len();
let consec_builder = BigramIndexBuilder::new(n);
let skip_builder = BigramIndexBuilder::new(n);
for (i, content) in files.iter().enumerate() {
consec_builder.add_file_content(&skip_builder, i, content);
}
let mut idx = consec_builder.compress(Some(0));
idx.set_skip_index(skip_builder.compress(Some(0)));
idx
}
#[test]
fn literal_pattern() {
let idx = build_test_index(&[
b"hello world", // 0: contains "hello"
b"goodbye world", // 1: no "hello"
b"say hello there", // 2: contains "hello"
]);
let q = regex_to_bigram_query("hello");
assert!(!q.is_any());
let candidates = q.evaluate(&idx).unwrap();
assert!(BigramFilter::is_candidate(&candidates, 0));
assert!(!BigramFilter::is_candidate(&candidates, 1));
assert!(BigramFilter::is_candidate(&candidates, 2));
}
#[test]
fn alternation() {
let idx = build_test_index(&[
b"has foo in it", // 0
b"has bar in it", // 1
b"has xyz in it", // 2
]);
let q = regex_to_bigram_query("foo|bar");
assert!(!q.is_any());
let candidates = q.evaluate(&idx).unwrap();
assert!(BigramFilter::is_candidate(&candidates, 0));
assert!(BigramFilter::is_candidate(&candidates, 1));
// xyz doesn't contain foo or bar bigrams
assert!(!BigramFilter::is_candidate(&candidates, 2));
}
#[test]
fn wildcard_concat() {
let idx = build_test_index(&[
b"foo something bar", // 0
b"foo only", // 1: has foo but not bar
b"only bar", // 2: has bar but not foo
]);
let q = regex_to_bigram_query("foo.*bar");
assert!(!q.is_any());
let candidates = q.evaluate(&idx).unwrap();
assert!(BigramFilter::is_candidate(&candidates, 0));
// file 1 and 2 should be filtered (missing bigrams from "bar" / "foo")
assert!(!BigramFilter::is_candidate(&candidates, 1));
assert!(!BigramFilter::is_candidate(&candidates, 2));
}
#[test]
fn sparse1_across_dot() {
// "a.b" should produce a skip-1 bigram (a,b)
let idx = build_test_index(&[
b"axb", // 0: has sparse-1 (a,b)
b"ayb", // 1: has sparse-1 (a,b)
b"xyz", // 2: no (a,b) at all
]);
let q = regex_to_bigram_query("a.b");
assert!(!q.is_any());
let candidates = q.evaluate(&idx).unwrap();
assert!(BigramFilter::is_candidate(&candidates, 0));
assert!(BigramFilter::is_candidate(&candidates, 1));
assert!(!BigramFilter::is_candidate(&candidates, 2));
}
#[test]
fn sparse1_across_digit() {
// "foo\dbar" -> sparse-1 (o,b) across \d
let idx = build_test_index(&[
b"foo3bar baz", // 0: has all bigrams
b"foobar baz", // 1: has consecutive (o,b) but pattern needs sparse-1
b"xyz only", // 2: no relevant bigrams
]);
let q = regex_to_bigram_query(r"foo\dbar");
assert!(!q.is_any());
let candidates = q.evaluate(&idx).unwrap();
assert!(BigramFilter::is_candidate(&candidates, 0));
// file 1 may or may not match depending on what bigrams are in the index
// (it has all the literal bigrams and also o,b as both consec and skip-1)
// The important thing is file 2 is excluded:
assert!(!BigramFilter::is_candidate(&candidates, 2));
}
#[test]
fn pure_wildcard_is_any() {
let q = regex_to_bigram_query(".*");
assert!(q.is_any());
}
#[test]
fn single_char_is_any() {
let q = regex_to_bigram_query("a");
assert!(q.is_any());
}
#[test]
fn invalid_regex_is_any() {
let q = regex_to_bigram_query("[invalid");
assert!(q.is_any());
}
#[test]
fn optional_group_excluded() {
let q = regex_to_bigram_query("foo(bar)?baz");
assert!(!q.is_any());
let idx = build_test_index(&[
b"foobaz content", // 0: has foo+baz bigrams (bar absent)
b"foobarbaz content", // 1: has everything
b"xyz only", // 2: nothing
]);
let candidates = q.evaluate(&idx).unwrap();
assert!(BigramFilter::is_candidate(&candidates, 0));
assert!(BigramFilter::is_candidate(&candidates, 1));
assert!(!BigramFilter::is_candidate(&candidates, 2));
}
#[test]
fn repetition_min2_cross_boundary() {
// (ab){2,} -> bigram "ab" + cross-boundary "b","a"
let q = regex_to_bigram_query("(ab){2,}");
assert!(!q.is_any());
let idx = build_test_index(&[
b"ababab", // 0: has "ab" and "b"->"a"
b"abonly", // 1: has "ab" but not "b"->"a"
b"xyz", // 2: nothing
]);
let candidates = q.evaluate(&idx).unwrap();
assert!(BigramFilter::is_candidate(&candidates, 0));
assert!(!BigramFilter::is_candidate(&candidates, 2));
}
#[test]
fn two_dots_no_sparse1() {
let q = regex_to_bigram_query("a..b");
// Single-char literals with 2 unknown bytes between -> Any
assert!(q.is_any());
}
#[test]
fn character_class_cross_boundary() {
// [abc]de -> cross-boundary OR(ad,bd,cd) + bigram de
// All three class variants must appear in the corpus so the OR
// branches are tracked in the index (untracked bigrams make the
// OR conservatively return None, which is correct but untestable).
let idx = build_test_index(&[
b"ade content", // 0: has ad
b"bde content", // 1: has bd
b"cde content", // 2: has cd
b"xde content", // 3: has de but not ad/bd/cd
]);
let q = regex_to_bigram_query("[abc]de");
assert!(!q.is_any());
let candidates = q.evaluate(&idx).unwrap();
assert!(BigramFilter::is_candidate(&candidates, 0));
assert!(BigramFilter::is_candidate(&candidates, 1));
assert!(BigramFilter::is_candidate(&candidates, 2));
// file 3 doesn't have ad/bd/cd so should be filtered
assert!(!BigramFilter::is_candidate(&candidates, 3));
}
fn has_consec(q: &BigramQuery, a: u8, b: u8) -> bool {
let Some(key) = consec_key(a, b) else {
return false;
};
match q {
BigramQuery::Consec(k) => *k == key,
BigramQuery::And(cs) | BigramQuery::Or(cs) => cs.iter().any(|c| has_consec(c, a, b)),
_ => false,
}
}
fn has_skip1(q: &BigramQuery, a: u8, b: u8) -> bool {
let Some(key) = consec_key(a, b) else {
return false;
};
match q {
BigramQuery::Skip1(k) => *k == key,
BigramQuery::And(cs) | BigramQuery::Or(cs) => cs.iter().any(|c| has_skip1(c, a, b)),
_ => false,
}
}
/// Bigram expectation: `("ab", is_skip1)`.
/// The 2-char str is the byte pair; C = consecutive, S = skip-1.
type Bg = (&'static str, bool);
const C: bool = false;
const S: bool = true;
/// Top 15+ commonly used regex patterns from
/// https://digitalfortress.tech/tips/top-15-commonly-used-regex/
/// plus typical grep patterns used by agentic tools.
///
/// Each entry: `(regex, Option<&[Bg]>)`.
/// - `None` -> pure classes / unsupported syntax, Any is acceptable.
/// - `Some(&[..])` -> must be non-Any, and every listed bigram must appear.
#[test]
fn common_regex_patterns() {
#[rustfmt::skip]
let cases: &[(&str, Option<&[Bg]>)] = &[
(r"^\d+$", None), // 1. whole numbers
(r"^\d*\.\d+$", None), // 2. decimals
(r"^\d*(\.\d+)?$", None), // 3. whole + decimal
(r"^-?\d*(\.\d+)?$", None), // 4. neg/pos decimal
(r"[-]?[0-9]+[,.]?[0-9]*([/][0-9]+[,.]?[0-9]*)*", None), // 5. fractions
(r"^[a-zA-Z0-9]*$", None), // 6. alphanumeric
(r"^[a-zA-Z0-9 ]*$", None), // 7. alphanum + space
(r"^([a-zA-Z0-9._%-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6})*$", None), // 8. email
(r"^([a-z0-9_\.\+-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})$", None), // 9. email v2
(r"(?=(.*[0-9]))(?=.*[!@#$%^&*()\[\]{}\-_+=~`|:;<>,./?\x5c])(?=.*[a-z])(?=(.*[A-Z]))(?=(.*)).{8,}", None), // 10. complex pw
(r"(?=(.*[0-9]))((?=.*[A-Za-z0-9])(?=.*[A-Z])(?=.*[a-z]))^.{8,}$", None), // 11. moderate pw
(r"^[a-z0-9_-]{3,16}$", None), // 12. username
(r"(https?://)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)", None), // 14. URL optional
(r"^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$", None), // 15. IPv4
(r"(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))", None), // 16. IPv6
(r"[12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])", None), // 17. date
(r"^(0?[1-9]|1[0-2]):[0-5][0-9]$", None), // 18. time 12h
(r"((1[0-2]|0?[1-9]):([0-5][0-9]) ?([AaPp][Mm]))", None), // 19. time AM/PM
(r"^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$", None), // 20. time 24h
(r"^([0-9]|0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$", None), // 21. time 24h v2
(r"(?:[01]\d|2[0123]):(?:[012345]\d):(?:[012345]\d)", None), // 22. time+sec
(r"</?[\w\s]*>|<.+[\W]>", None), // 23. HTML tag
(r"\bon\w+=\S+(?=.*>)", None), // 24. inline JS
(r"^[a-z0-9]+(?:-[a-z0-9]+)*$", None), // 25. slug
(r"(\b\w+\b)(?=.*\b\1\b)", None), // 26. dup words
(r"^[\w,\s-]+\.[A-Za-z]{3}$", None), // 27. filename
(r"^[A-PR-WY][1-9]\d\s?\d{4}[1-9]$", None), // 28. HK ID
// 13. URL with required protocol
(r"https?://(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)", Some(&[
("ht", C), ("tt", C), ("tp", C), // from "http"
("ht", S), ("tp", S), // from "http" skip-1
(":/", C), ("//", C), // from "://"
])),
// 29. fn\s+\w+
(r"fn\s+\w+", Some(&[
("fn", C), // from "fn"
("n ", C), // cross-boundary: 'n' -> \s starts ' '
])),
// 30. use\s+crate::
(r"use\s+crate::", Some(&[
("us", C), ("se", C), ("ue", S), // from "use"
("cr", C), ("ra", C), ("at", C), // from "crate"
("te", C), ("::", C),
("ca", S), ("rt", S), ("ae", S), // "crate" skip-1
])),
// 31. unwrap\(\)|expect\(
(r"unwrap\(\)|expect\(", Some(&[
("nw", C), ("wr", C), ("ra", C), // "unwrap("
("ap", C), ("p(", C),
("xp", C), ("pe", C), ("ec", C), // "expect("
("ct", C), ("t(", C),
])),
// 32. TODO|FIXME|HACK
(r"TODO|FIXME|HACK", Some(&[
("to", C), ("od", C), ("do", C), // "TODO"
("fi", C), ("ix", C), ("xm", C), // "FIXME"
("me", C),
("ha", C), ("ac", C), ("ck", C), // "HACK"
("hc", S), ("ak", S), // "HACK" skip-1
])),
];
for (i, &(pattern, expected)) in cases.iter().enumerate() {
let q = regex_to_bigram_query(pattern);
if let Some(bigrams) = expected {
assert!(
!q.is_any(),
"#{i} {pattern:?}: expected bigrams but got Any"
);
for &(pair, skip) in bigrams {
let b = pair.as_bytes();
debug_assert_eq!(b.len(), 2, "bigram must be 2 chars: {pair:?}");
let found = if skip {
has_skip1(&q, b[0], b[1])
} else {
has_consec(&q, b[0], b[1])
};
let kind = if skip { "skip-1" } else { "consec" };
assert!(found, "#{i} {pattern:?}: missing {kind} bigram {pair:?}");
}
}
}
}
}
+39
View File
@@ -0,0 +1,39 @@
/// Largest file whose full content fff will touch: the default grep read cap
/// (`GrepSearchOptions::max_file_size`) and the content-cache mmap cap
/// (`ContentCacheBudget::max_file_size`). Binary detection also streams up to
/// this far so nothing grep would read is left unclassified.
pub const MAX_FFFILE_SIZE: u64 = 10 * 1024 * 1024;
/// Upper bound on a file the bigram builder will build, if the file is very large there is a
/// big probability it will only bloat the available bigrams and will anyway pop ut from the prefilter
pub const MAX_INDEXABLE_FILE_SIZE: usize = 2 * 1024 * 1024;
/// Total bytes the persistent content mmap cache may hold for a small repo.
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).
#[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.
pub const MAX_OVERFLOW_FILES: usize = 1024;
/// 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
/// constant is gated to non-Windows targets to keep `-D unused-imports` happy.
#[cfg(target_os = "macos")]
pub const FRESH_MMAP_THRESHOLD: u64 = 1024 * 1024;
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
pub const FRESH_MMAP_THRESHOLD: u64 = 256 * 1024;
// we do not support 32kb path limit on windows
#[cfg(target_os = "windows")]
pub const PATH_BUF_SIZE: usize = 4096;
#[cfg(not(target_os = "windows"))]
pub const PATH_BUF_SIZE: usize = libc::PATH_MAX as usize;
+932
View File
@@ -0,0 +1,932 @@
//! Constraint-based prefiltering for search queries.
use fff_query_parser::{Constraint, GitStatusFilter};
use smallvec::SmallVec;
use crate::git::is_modified_status;
use crate::simd_path::ArenaPtr;
use crate::simd_string_utils::memmem::find_case_insensitive_short;
const PAR_THRESHOLD: usize = 10_000;
pub(crate) trait Constrainable {
fn write_file_name(&self, arena: ArenaPtr, out: &mut String);
fn git_status(&self) -> Option<git2::Status>;
fn write_relative_path(&self, arena: ArenaPtr, out: &mut String);
fn is_overflow(&self) -> bool;
}
/// 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 {
b == b'/' || b == b'\\'
}
#[inline]
fn path_slice_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b).all(|(x, y)| {
if is_path_sep(*x) && is_path_sep(*y) {
true
} else {
x.eq_ignore_ascii_case(y)
}
})
}
/// Path ends with suffix at a path-separator boundary (case-insensitive).
#[inline]
pub fn path_ends_with_suffix(path: &str, suffix: &str) -> bool {
let path_bytes = path.as_bytes();
let suffix_bytes = suffix.as_bytes();
if path_bytes.len() < suffix_bytes.len() {
return false;
}
let start = path.len() - suffix.len();
// Multi-byte UTF-8 may put `start` inside a char.
if !path.is_char_boundary(start) {
return false;
}
if !path_slice_eq(&path_bytes[start..], suffix_bytes) {
return false;
}
// Exact or preceded by a separator. Scan backward past any multi-byte
// continuation bytes to find the preceding ASCII byte.
if start == 0 {
return true;
}
let mut i = start;
while i > 0 {
i -= 1;
if path_bytes[i] < 128 {
return is_path_sep(path_bytes[i]);
}
}
false
}
#[inline]
pub fn file_has_extension(file_name: &str, ext: &str) -> bool {
let name_bytes = file_name.as_bytes();
let ext_bytes = ext.as_bytes();
if name_bytes.len() <= ext_bytes.len() + 1 {
return false;
}
let start = name_bytes.len() - ext_bytes.len() - 1;
if start > 0 && !file_name.is_char_boundary(start) {
return false;
}
name_bytes.get(start) == Some(&b'.') && name_bytes[start + 1..].eq_ignore_ascii_case(ext_bytes)
}
/// Matches multi-segment queries like `libswscale/aarch64`.
#[inline]
pub fn path_contains_segment(path: &str, segment: &str) -> bool {
let path_bytes = path.as_bytes();
let segment_bytes = segment.as_bytes();
let segment_len = segment_bytes.len();
if path_bytes.len() > segment_len
&& is_path_sep(path_bytes[segment_len])
&& path.is_char_boundary(segment_len)
&& path_slice_eq(&path_bytes[..segment_len], segment_bytes)
{
return true;
}
if path_bytes.len() < segment_len + 2 {
return false;
}
for i in 0..path_bytes.len().saturating_sub(segment_len + 1) {
if is_path_sep(path_bytes[i]) {
let start = i + 1;
let end = start + segment_len;
if end < path_bytes.len()
&& is_path_sep(path_bytes[end])
&& path.is_char_boundary(start)
&& path.is_char_boundary(end)
&& path_slice_eq(&path_bytes[start..end], segment_bytes)
{
return true;
}
}
}
false
}
/// Returns `None` if no constraints are present, `Some(filtered)` otherwise.
///
/// Constraint semantics:
/// - All `Extension` constraints OR together (file matches if ANY extension hits).
/// They're split out up front so the per-item loop reads the OR predicate as a
/// single short-circuit check, not as N AND-merged sub-constraints.
/// - Every other constraint kind ANDs (file matches only if ALL hold). They're
/// evaluated in order with short-circuit on first failure.
pub(crate) fn apply_constraints<'a, T: Constrainable + Sync>(
items: &'a [T],
constraints: &[Constraint<'_>],
base_arena: ArenaPtr,
overflow_arena: ArenaPtr,
) -> Option<Vec<&'a T>> {
if constraints.is_empty() {
return None;
}
let plan = ConstraintPlan::build(constraints, items, base_arena, overflow_arena);
Some(plan.run(items, base_arena, overflow_arena))
}
#[cfg(feature = "zlob")]
type GlobPattern = zlob::ZlobPattern;
#[cfg(all(not(feature = "zlob"), feature = "ripgrep"))]
type GlobPattern = globset::GlobMatcher;
/// How `Constraint::Glob` is evaluated for each item.
enum GlobStrategy {
/// No Glob constraint present.
None,
/// Pure-glob workload (no Extension filter to reject items first).
/// Batch all paths through zlob/globset once; per-item check is a Vec<bool> lookup.
Prepass(Vec<Vec<bool>>),
/// Mixed workload (Extension filter present). Compile patterns up front, then
/// only run them on items that survive the cheap Extension OR check.
/// `None` slot = compile failure -> never matches; preserves index alignment.
Inline(Vec<Option<GlobPattern>>),
}
/// Bundles preprocessed constraints for the per-item evaluator.
pub(crate) struct ConstraintPlan<'q, 'c> {
/// OR semantics — file passes if ANY extension matches. Empty = no ext filter.
extensions: SmallVec<[&'q str; 8]>,
/// AND semantics — file passes only if ALL match.
rest: SmallVec<[&'c Constraint<'q>; 8]>,
glob: GlobStrategy,
}
pub(crate) struct ConstraintsBuffers {
fname: String,
path: String,
}
impl ConstraintsBuffers {
pub(crate) fn new() -> Self {
Self {
fname: String::with_capacity(64),
path: String::with_capacity(64),
}
}
}
impl<'q, 'c> ConstraintPlan<'q, 'c> {
pub(crate) fn build<T: Constrainable>(
constraints: &'c [Constraint<'q>],
items: &[T],
base_arena: ArenaPtr,
overflow_arena: ArenaPtr,
) -> Self {
let mut extensions = SmallVec::new();
let mut rest: SmallVec<[&'c Constraint<'q>; 8]> = SmallVec::new();
for c in constraints {
match c {
Constraint::Extension(ext) => extensions.push(*ext),
_ => rest.push(c),
}
}
let has_pre_filter = !extensions.is_empty() || rest.iter().any(|&c| !is_glob_node(c));
let glob = build_glob_strategy(&rest, has_pre_filter, items, base_arena, overflow_arena);
Self {
extensions,
rest,
glob,
}
}
fn run<'a, T: Constrainable + Sync>(
&self,
items: &'a [T],
base_arean: ArenaPtr,
overflow_arena: ArenaPtr,
) -> Vec<&'a T> {
if items.len() >= PAR_THRESHOLD {
use rayon::prelude::*;
items
.par_iter()
.enumerate()
.map_init(ConstraintsBuffers::new, |scratch, (i, item)| {
self.matches(item, i, base_arean, overflow_arena, scratch)
.then_some(item)
})
.flatten()
.collect()
} else {
let mut scratch = ConstraintsBuffers::new();
items
.iter()
.enumerate()
.filter_map(|(i, item)| {
self.matches(item, i, base_arean, overflow_arena, &mut scratch)
.then_some(item)
})
.collect()
}
}
#[inline]
pub(crate) fn matches<T: Constrainable>(
&self,
item: &T,
index: usize,
base_arena: ArenaPtr,
overflow_arena: ArenaPtr,
scratch: &mut ConstraintsBuffers,
) -> bool {
let arena = if item.is_overflow() {
overflow_arena
} else {
base_arena
};
if !self.passes_extensions(item, arena, scratch) {
return false;
}
let mut glob_idx = 0;
self.rest.iter().all(|c| {
evaluate(
item,
index,
c,
&self.glob,
&mut glob_idx,
false,
arena,
scratch,
)
})
}
#[inline]
fn passes_extensions<T: Constrainable>(
&self,
item: &T,
arena: ArenaPtr,
scratch: &mut ConstraintsBuffers,
) -> bool {
if self.extensions.is_empty() {
return true;
}
item.write_file_name(arena, &mut scratch.fname);
self.extensions
.iter()
.any(|ext| file_has_extension(&scratch.fname, ext))
}
}
#[inline]
#[allow(clippy::too_many_arguments)]
fn evaluate<T: Constrainable>(
item: &T,
index: usize,
constraint: &Constraint<'_>,
glob: &GlobStrategy,
glob_idx: &mut usize,
negate: bool,
arena: ArenaPtr,
scratch: &mut ConstraintsBuffers,
) -> bool {
let raw = match constraint {
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);
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) => {
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 }
}
#[inline]
fn matches_git_status(status: Option<git2::Status>, filter: &GitStatusFilter) -> bool {
match (status, filter) {
(Some(s), GitStatusFilter::Modified) => is_modified_status(s),
(Some(s), GitStatusFilter::Untracked) => s.contains(git2::Status::WT_NEW),
(Some(s), GitStatusFilter::Staged) => s.intersects(
git2::Status::INDEX_NEW
| git2::Status::INDEX_MODIFIED
| git2::Status::INDEX_DELETED
| git2::Status::INDEX_RENAMED
| git2::Status::INDEX_TYPECHANGE,
),
(Some(s), GitStatusFilter::Unmodified) => s.is_empty(),
(None, GitStatusFilter::Unmodified) => true,
(None, _) => false,
}
}
#[inline]
#[cfg(feature = "zlob")]
fn compiled_matches(p: &GlobPattern, path: &str) -> bool {
p.matches_default(path)
}
#[inline]
#[cfg(all(not(feature = "zlob"), feature = "ripgrep"))]
fn compiled_matches(p: &GlobPattern, path: &str) -> bool {
p.is_match(path)
}
/// Decide between batch prepass and inline compiled patterns.
///
/// `has_pre_filter` = true when something cheaper than glob can reject items first
/// (extensions OR non-glob constraints in `rest`). In that case inline pays glob
/// cost only on survivors and beats prepass on every workload we benched. Pure-glob
/// (no pre-filter) takes prepass — single batched zlob call beats N inline matches.
fn build_glob_strategy<T: Constrainable>(
rest: &[&Constraint<'_>],
has_pre_filter: bool,
items: &[T],
arena: ArenaPtr,
overflow_arena: ArenaPtr,
) -> GlobStrategy {
if !contains_glob(rest) {
return GlobStrategy::None;
}
if has_pre_filter {
return GlobStrategy::Inline(compile_globs(rest));
}
let buf = PathBuffer::collect(items, arena, overflow_arena);
let path_refs = buf.as_strs();
GlobStrategy::Prepass(precompute_masks(rest, &path_refs))
}
/// `Glob` or `Not(Glob)` — the constraint kinds whose evaluation goes through
/// the GlobStrategy. Everything else can pre-reject items before glob runs.
fn is_glob_node(c: &Constraint<'_>) -> bool {
match c {
Constraint::Glob(_) => true,
Constraint::Not(inner) => is_glob_node(inner),
_ => false,
}
}
fn contains_glob(rest: &[&Constraint<'_>]) -> bool {
rest.iter().any(|c| is_glob_node(c))
}
/// Contiguous byte buffer holding every item's `relative_path`. Single allocation
/// instead of N `String`s. On Windows the in-place pass folds `\\` -> `/` so the
/// glob library sees a canonical separator.
struct PathBuffer {
bytes: Vec<u8>,
offsets: Vec<(usize, usize)>,
}
impl PathBuffer {
fn collect<T: Constrainable>(items: &[T], arena: ArenaPtr, overflow_arena: ArenaPtr) -> Self {
let mut bytes = Vec::<u8>::new();
let mut offsets = Vec::with_capacity(items.len());
let mut tmp = String::with_capacity(64);
for item in items {
let item_arena = if item.is_overflow() {
overflow_arena
} else {
arena
};
let start = bytes.len();
item.write_relative_path(item_arena, &mut tmp);
bytes.extend_from_slice(tmp.as_bytes());
offsets.push((start, bytes.len() - start));
}
Self { bytes, offsets }
}
fn as_strs(&self) -> Vec<&str> {
self.offsets
.iter()
.map(|&(off, len)| unsafe {
std::str::from_utf8_unchecked(&self.bytes[off..off + len])
})
.collect()
}
}
fn precompute_masks(rest: &[&Constraint<'_>], paths: &[&str]) -> Vec<Vec<bool>> {
let mut out = Vec::new();
for c in rest {
walk_globs(c, &mut |pattern| {
out.push(match_glob_pattern(pattern, paths))
});
}
out
}
fn compile_globs(rest: &[&Constraint<'_>]) -> Vec<Option<GlobPattern>> {
let mut out = Vec::new();
for c in rest {
walk_globs(c, &mut |pattern| out.push(compile_one(pattern)));
}
out
}
/// Visit every Glob (including ones nested under Not) in constraint walk order.
/// Order matters: `glob_idx` in the per-item evaluator increments by one per Glob node.
fn walk_globs<F: FnMut(&str)>(c: &Constraint<'_>, f: &mut F) {
match c {
Constraint::Glob(p) => f(p),
Constraint::Not(inner) => walk_globs(inner, f),
_ => {}
}
}
#[cfg(feature = "zlob")]
fn compile_one(pattern: &str) -> Option<GlobPattern> {
zlob::ZlobPattern::compile(pattern, zlob::ZlobFlags::RECOMMENDED).ok()
}
#[cfg(all(not(feature = "zlob"), feature = "ripgrep"))]
fn compile_one(pattern: &str) -> Option<GlobPattern> {
globset::Glob::new(pattern)
.ok()
.map(|g| g.compile_matcher())
}
/// Build a `paths.len()`-sized bitmap. Vec<bool> beats AHashSet ~2× in the per-item
/// filter loop — no hashing, plain array indexing, sequential prefetcher-friendly.
#[cfg(feature = "zlob")]
fn match_glob_pattern(pattern: &str, paths: &[&str]) -> Vec<bool> {
let mut mask = vec![false; paths.len()];
let Ok(hits) = zlob::zlob_match_paths_indices(pattern, paths, zlob::ZlobFlags::RECOMMENDED)
else {
return mask;
};
for i in hits.to_iter() {
if i < mask.len() {
mask[i] = true;
}
}
mask
}
#[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 {
return mask;
};
let matcher = glob.compile_matcher();
if paths.len() >= PAR_THRESHOLD {
use rayon::prelude::*;
mask.par_iter_mut()
.zip(paths.par_iter())
.for_each(|(slot, p)| *slot = matcher.is_match(p));
} else {
for (slot, p) in mask.iter_mut().zip(paths.iter()) {
*slot = matcher.is_match(p);
}
}
mask
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone)]
struct TestItem {
relative_path: &'static str,
file_name: &'static str,
}
impl Constrainable for TestItem {
fn write_file_name(&self, _arena: ArenaPtr, out: &mut String) {
out.clear();
out.push_str(self.file_name);
}
fn write_relative_path(&self, _arena: ArenaPtr, out: &mut String) {
out.clear();
out.push_str(self.relative_path);
}
fn git_status(&self) -> Option<git2::Status> {
None
}
fn is_overflow(&self) -> bool {
false
}
}
#[test]
fn test_file_has_extension() {
assert!(file_has_extension("file.rs", "rs"));
assert!(file_has_extension("file.RS", "rs")); // case-insensitive
assert!(file_has_extension("file.test.rs", "rs"));
assert!(file_has_extension("a.rs", "rs"));
assert!(!file_has_extension("file.tsx", "rs"));
assert!(!file_has_extension("rs", "rs")); // too short
assert!(!file_has_extension(".rs", "rs")); // just extension
assert!(!file_has_extension("file.rsx", "rs")); // different extension
assert!(!file_has_extension("filers", "rs")); // no dot
}
#[test]
fn test_path_contains_segment() {
// Segment at start
assert!(path_contains_segment("src/lib.rs", "src"));
assert!(path_contains_segment("SRC/lib.rs", "src")); // case-insensitive
// Segment in middle
assert!(path_contains_segment("app/src/lib.rs", "src"));
assert!(path_contains_segment("app/SRC/lib.rs", "src"));
// Multiple levels
assert!(path_contains_segment("core/workflow/src/main.rs", "src"));
assert!(path_contains_segment(
"core/workflow/src/main.rs",
"workflow"
));
assert!(path_contains_segment("core/workflow/src/main.rs", "core"));
// Should not match partial segments
assert!(!path_contains_segment("source/lib.rs", "src"));
assert!(!path_contains_segment("mysrc/lib.rs", "src"));
// Should not match filename
assert!(!path_contains_segment("lib/src", "src"));
// Multi-segment constraints
assert!(path_contains_segment(
"libswscale/aarch64/input.S",
"libswscale/aarch64"
));
assert!(path_contains_segment(
"foo/libswscale/aarch64/input.S",
"libswscale/aarch64"
));
assert!(path_contains_segment(
"foo/LibSwscale/AArch64/input.S",
"libswscale/aarch64"
)); // case-insensitive
assert!(!path_contains_segment(
"xlibswscale/aarch64/input.S",
"libswscale/aarch64"
)); // partial match at start
assert!(!path_contains_segment(
"foo/libswscale/aarch64x/input.S",
"libswscale/aarch64"
)); // partial match at end
assert!(path_contains_segment(
"crates/fff-core/src/grep.rs",
"fff-core/src"
));
// Edge cases
assert!(!path_contains_segment("", "src"));
assert!(!path_contains_segment("src", "src")); // no trailing slash
}
#[cfg(windows)]
#[test]
fn test_path_contains_segment_accepts_backslash() {
assert!(path_contains_segment("src\\lib.rs", "src"));
assert!(path_contains_segment(
"app\\modules\\src\\services\\x.lua",
"src"
));
assert!(path_contains_segment("app\\SRC\\x.lua", "src"));
assert!(path_contains_segment(
"foo\\libswscale\\aarch64\\input.S",
"libswscale/aarch64"
));
assert!(path_contains_segment(
"crates\\fff-core\\src\\grep.rs",
"fff-core/src"
));
assert!(!path_contains_segment("mysrc\\lib.rs", "src"));
assert!(!path_contains_segment(
"xlibswscale\\aarch64\\in.S",
"libswscale/aarch64"
));
}
#[test]
fn test_path_ends_with_suffix() {
// Exact match
assert!(path_ends_with_suffix(
"libswscale/input.c",
"libswscale/input.c"
));
// Suffix match at / boundary
assert!(path_ends_with_suffix(
"foo/libswscale/input.c",
"libswscale/input.c"
));
// Deep nesting
assert!(path_ends_with_suffix(
"a/b/c/libswscale/input.c",
"libswscale/input.c"
));
// No boundary — partial directory name
assert!(!path_ends_with_suffix(
"xlibswscale/input.c",
"libswscale/input.c"
));
// Case insensitive
assert!(path_ends_with_suffix(
"foo/LibSwscale/Input.C",
"libswscale/input.c"
));
// Single file name
assert!(path_ends_with_suffix("input.c", "input.c"));
assert!(!path_ends_with_suffix("xinput.c", "input.c"));
// Suffix longer than path
assert!(!path_ends_with_suffix("input.c", "foo/input.c"));
// Simple path
assert!(path_ends_with_suffix("src/main.rs", "src/main.rs"));
assert!(path_ends_with_suffix("crates/src/main.rs", "src/main.rs"));
}
#[cfg(windows)]
#[test]
fn test_path_ends_with_suffix_accepts_backslash() {
assert!(path_ends_with_suffix(
"app\\modules\\src\\services\\handler.lua",
"services/handler.lua"
));
assert!(path_ends_with_suffix(
"foo\\libswscale\\input.c",
"libswscale/input.c"
));
assert!(!path_ends_with_suffix(
"xlibswscale\\input.c",
"libswscale/input.c"
));
}
#[test]
fn test_path_ends_with_suffix_does_not_panic_on_unicode_suffix() {
assert!(!path_ends_with_suffix("유니코드_파일_테스트.csv", "트.c"));
assert!(path_ends_with_suffix(
"data/유니코드_파일_테스트.csv",
"유니코드_파일_테스트.csv"
));
}
#[test]
fn test_path_ends_with_suffix_unicode_apostrophe_mismatch() {
assert!(!path_ends_with_suffix(
"dir/\u{2019}bar/file.txt",
"'bar/file.txt"
));
}
#[test]
fn test_path_ends_with_suffix_unicode_space_mismatch() {
assert!(!path_ends_with_suffix(
"dir/\u{202f}am/file.txt",
" am/file.txt"
));
}
#[test]
fn test_path_contains_segment_does_not_panic_on_unicode_segment() {
assert!(!path_contains_segment("문서/notes.txt", "문x"));
assert!(path_contains_segment("프로젝트/문서/notes.txt", "문서"));
}
#[test]
fn test_path_contains_segment_unicode_no_panic() {
assert!(!path_contains_segment(
"Library/Cloud/Project\u{2019}s Folder/books.ttl",
"Project's Folder"
));
}
#[test]
fn test_file_has_extension_unicode_no_panic() {
assert!(!file_has_extension("cat\u{00e9}.rs", "s"));
}
#[test]
fn test_file_has_extension_unicode_filename() {
assert!(file_has_extension("운영-가이드.md", "md"));
assert!(file_has_extension("테스트.csv", "csv"));
assert!(!file_has_extension("테스트.csv", "md"));
}
#[test]
fn test_apply_constraints_file_path_with_unicode_suffix() {
let arena_ptr = ArenaPtr(std::ptr::null());
let item = TestItem {
relative_path: "data/유니코드_파일_테스트.csv",
file_name: "유니코드_파일_테스트.csv",
};
let exact = [Constraint::FilePath("유니코드_파일_테스트.csv")];
let mismatch = [Constraint::FilePath("트.c")];
let exact_items = [item.clone()];
let exact_matches = apply_constraints(&exact_items, &exact, arena_ptr, arena_ptr)
.expect("constraints applied");
assert_eq!(exact_matches.len(), 1);
let mismatch_items = [item];
let mismatch_matches = apply_constraints(&mismatch_items, &mismatch, arena_ptr, arena_ptr)
.expect("constraints applied");
assert!(mismatch_matches.is_empty());
}
#[test]
fn test_unicode_path_no_panic_real_korean_cases() {
// Real Korean paths that caused panics
let path1 = "Downloads/(커리큘럼) hermes agent_정승현님 - 1차 커리큘럼 (강사님 작성).csv";
let path2 = "hermes-agent-lecture-materials/세부_커리큘럼_최종.csv";
let path3 = "projects/fastcampus-hermes-agent-curriculum/chapters/part-02-Hermes-설치-및-기본-사용/section-02-doctor로-설치-상태-검증/research/03-fix가-자동-수정하는-것과-못하는-것.md";
// These must not panic regardless of segment/suffix used
assert!(!path_contains_segment(path1, "작성"));
assert!(!path_ends_with_suffix(path1, "작성.csv"));
assert!(!path_contains_segment(path2, "최종"));
assert!(!path_ends_with_suffix(path2, "최종.csv"));
assert!(!path_contains_segment(path3, "수정"));
assert!(!path_ends_with_suffix(path3, "것.md"));
// Positive cases should still work
assert!(path_contains_segment(
path2,
"hermes-agent-lecture-materials"
));
assert!(path_ends_with_suffix(
path1,
"(커리큘럼) hermes agent_정승현님 - 1차 커리큘럼 (강사님 작성).csv"
));
assert!(path_ends_with_suffix(path2, "세부_커리큘럼_최종.csv"));
}
#[test]
fn test_negated_glob_excludes_matching_files() {
let arena_ptr = ArenaPtr(std::ptr::null());
let items = vec![
TestItem {
relative_path: "src/main.rs",
file_name: "main.rs",
},
TestItem {
relative_path: "src/lib.ts",
file_name: "lib.ts",
},
TestItem {
relative_path: "include/fff.h",
file_name: "fff.h",
},
];
// Not(Glob("**/*.rs")) should exclude .rs files
let constraints = vec![Constraint::Not(Box::new(Constraint::Glob("**/*.rs")))];
let result = apply_constraints(&items, &constraints, arena_ptr, arena_ptr).unwrap();
let paths: Vec<&str> = result.iter().map(|i| i.relative_path).collect();
assert!(
!paths.contains(&"src/main.rs"),
"rs file should be excluded"
);
assert!(paths.contains(&"src/lib.ts"), "ts file should be included");
assert!(
paths.contains(&"include/fff.h"),
"h file should be included"
);
}
#[test]
fn test_inline_glob_path_matches_prepass() {
// Mixed (extensions + glob) takes the inline-compiled path.
// Pure glob takes the prepass bitmap path. Both must give identical results.
let arena_ptr = ArenaPtr(std::ptr::null());
let items = vec![
TestItem {
relative_path: "src/main.rs",
file_name: "main.rs",
},
TestItem {
relative_path: "src/lib.ts",
file_name: "lib.ts",
},
TestItem {
relative_path: "tests/foo.rs",
file_name: "foo.rs",
},
TestItem {
relative_path: "docs/readme.md",
file_name: "readme.md",
},
];
let mixed = vec![Constraint::Extension("rs"), Constraint::Glob("src/**")];
let mixed_paths: Vec<&str> = apply_constraints(&items, &mixed, arena_ptr, arena_ptr)
.unwrap()
.iter()
.map(|i| i.relative_path)
.collect();
assert_eq!(mixed_paths, vec!["src/main.rs"]);
let pure_glob = vec![Constraint::Glob("src/**")];
let glob_paths: Vec<&str> = apply_constraints(&items, &pure_glob, arena_ptr, arena_ptr)
.unwrap()
.iter()
.map(|i| i.relative_path)
.collect();
assert!(glob_paths.contains(&"src/main.rs"));
assert!(glob_paths.contains(&"src/lib.ts"));
assert_eq!(glob_paths.len(), 2);
}
#[test]
fn test_inline_negated_glob_with_extension() {
// Mixed Not(Glob) on inline path — exercise the negate=true branch in
// glob_matches_inline through the Not->Glob recursion.
let arena_ptr = ArenaPtr(std::ptr::null());
let items = vec![
TestItem {
relative_path: "src/main.rs",
file_name: "main.rs",
},
TestItem {
relative_path: "vendor/foo.rs",
file_name: "foo.rs",
},
TestItem {
relative_path: "vendor/foo.ts",
file_name: "foo.ts",
},
];
let constraints = vec![
Constraint::Extension("rs"),
Constraint::Not(Box::new(Constraint::Glob("vendor/**"))),
];
let paths: Vec<&str> = apply_constraints(&items, &constraints, arena_ptr, arena_ptr)
.unwrap()
.iter()
.map(|i| i.relative_path)
.collect();
assert_eq!(paths, vec!["src/main.rs"]);
}
}
+39
View File
@@ -0,0 +1,39 @@
use crate::error::Result;
/// Health information about a database
#[derive(Debug, Clone)]
pub struct DbHealth {
/// Path to the database file
pub path: String,
/// Size on disk in bytes
pub disk_size: u64,
/// Entry counts by table name
pub entry_counts: Vec<(&'static str, u64)>,
/// Set to `false` if can not acquire the write lock
pub healthy: bool,
}
pub trait DbHealthChecker {
fn get_env(&self) -> &heed::Env;
fn is_healthy(&self) -> bool;
/// Entries per database, each group has a static string label
fn count_entries(&self) -> Result<Vec<(&'static str, u64)>>;
/// Health summary of the database, returns summary struct
fn get_health(&self) -> Result<DbHealth> {
let env = self.get_env();
let size = env
.real_disk_size()
.map_err(crate::error::Error::GenericDbError)?;
let path = env.path().to_string_lossy().to_string();
let entry_counts = self.count_entries()?;
Ok(DbHealth {
path,
disk_size: size,
entry_counts,
healthy: self.is_healthy(),
})
}
}
+531
View File
@@ -0,0 +1,531 @@
use super::db_healthcheck::DbHealthChecker;
use super::lmdb::{DbHealth, LmdbStore, is_map_full};
use crate::error::{Error, Result};
use crate::file_picker::FFFMode;
use crate::git::is_modified_status;
use heed::types::{Bytes, SerdeBincode};
use heed::{Database, Env};
use std::time::{SystemTime, UNIX_EPOCH};
use std::{collections::VecDeque, path::Path};
const DECAY_CONSTANT: f64 = 0.0693; // ln(2)/10 for 10-day half-life
const SECONDS_PER_DAY: f64 = 86400.0;
const MAX_HISTORY_DAYS: f64 = 30.0; // Only consider accesses within 30 days
const MAX_TIMESTAMPS_PER_FILE: usize = 128;
// AI mode: faster decay since AI sessions are shorter and more intense
const AI_DECAY_CONSTANT: f64 = 0.231; // ln(2)/3 for 3-day half-life
const AI_MAX_HISTORY_DAYS: f64 = 7.0; // Only consider accesses within 7 days
#[derive(Debug)]
pub struct FrecencyTracker {
env: Env,
db: Database<Bytes, SerdeBincode<VecDeque<u64>>>,
health: DbHealth,
}
const MODIFICATION_THRESHOLDS: [(i64, u64); 5] = [
(16, 60 * 2), // 2 minutes
(8, 60 * 15), // 15 minutes
(4, 60 * 60), // 1 hour
(2, 60 * 60 * 24), // 1 day
(1, 60 * 60 * 24 * 7), // 1 week
];
// AI mode: compressed thresholds since AI edits happen in rapid bursts
const AI_MODIFICATION_THRESHOLDS: [(i64, u64); 5] = [
(16, 30), // 30 seconds
(8, 60 * 5), // 5 minutes
(4, 60 * 15), // 15 minutes
(2, 60 * 60), // 1 hour
(1, 60 * 60 * 4), // 4 hours
];
impl DbHealthChecker for FrecencyTracker {
fn get_env(&self) -> &heed::Env {
&self.env
}
fn is_healthy(&self) -> bool {
self.health.is_healthy()
}
fn count_entries(&self) -> Result<Vec<(&'static str, u64)>> {
let rtxn = self
.env
.read_txn()
.map_err(|source| Error::DbStartReadTxn {
db: Self::LABEL,
source,
})?;
let count = self.db.len(&rtxn).map_err(|source| Error::DbRead {
db: Self::LABEL,
source,
})?;
Ok(vec![("absolute_frecency_entries", count)])
}
}
impl LmdbStore for FrecencyTracker {
const LABEL: &'static str = "frecency";
// 10 MiB hard ceiling. Owner's db after years of use is ~560 KiB, so this
// leaves ~18× headroom while capping runaway growth (see GH issue #437).
const MAP_SIZE: usize = 10 * 1024 * 1024;
const MAX_DBS: u32 = 0;
// Nuke the db when it exceeds 8 MiB on disk — leaves a small margin under
// MAP_SIZE so we don't hit MDB_MAP_FULL before the open-time erase fires.
const SIZE_CAP_BYTES: u64 = 12 * 1024 * 1024;
fn env(&self) -> &Env {
&self.env
}
fn health(&self) -> &DbHealth {
&self.health
}
fn purge_stale_data(env: &Env) -> Result<()> {
let (deleted, pruned) = Self::purge_stale_entries(env)?;
if deleted > 0 || pruned > 0 {
tracing::info!(deleted, pruned, "Frecency GC purged entries");
}
Ok(())
}
}
impl FrecencyTracker {
/// Returns the on-disk path of the LMDB environment directory.
pub fn db_path(&self) -> &Path {
self.env.path()
}
pub fn open(db_path: impl AsRef<Path>) -> Result<Self> {
let db_path = db_path.as_ref();
let (env, health) = Self::open_env(db_path)?;
let db = Self::open_database_safe(&env, None)?;
Ok(FrecencyTracker { db, env, health })
}
#[deprecated(
since = "0.7.0",
note = "LMDB unsafe no-lock mode is no longer supported; use `FrecencyTracker::open` instead. \
The `_use_unsafe_no_lock` argument is ignored."
)]
pub fn new(db_path: impl AsRef<Path>, _use_unsafe_no_lock: bool) -> Result<Self> {
Self::open(db_path)
}
/// 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)> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let cutoff_time = now.saturating_sub((MAX_HISTORY_DAYS * SECONDS_PER_DAY) as u64);
let db: Database<Bytes, SerdeBincode<VecDeque<u64>>> = Self::open_database_safe(env, None)?;
let rtxn = env.read_txn().map_err(|source| Error::DbStartReadTxn {
db: Self::LABEL,
source,
})?;
let mut to_delete: Vec<Vec<u8>> = Vec::new();
let mut to_update: Vec<(Vec<u8>, VecDeque<u64>)> = Vec::new();
let iter = db.iter(&rtxn).map_err(|source| Error::DbRead {
db: Self::LABEL,
source,
})?;
for result in iter {
let (key, accesses) = result.map_err(|source| Error::DbRead {
db: Self::LABEL,
source,
})?;
// Timestamps chronologically ordered (oldest at front).
let fresh_start = accesses.iter().position(|&ts| ts >= cutoff_time);
match fresh_start {
None => to_delete.push(key.to_vec()),
Some(0) => {}
Some(start) => {
let pruned: VecDeque<u64> = accesses.iter().skip(start).copied().collect();
to_update.push((key.to_vec(), pruned));
}
}
}
drop(rtxn);
if to_delete.is_empty() && to_update.is_empty() {
return Ok((0, 0));
}
let mut wtxn = env.write_txn().map_err(|source| Error::DbStartWriteTxn {
db: Self::LABEL,
source,
})?;
for key in &to_delete {
db.delete(&mut wtxn, key).map_err(|source| Error::DbWrite {
db: Self::LABEL,
source,
})?;
}
for (key, accesses) in &to_update {
db.put(&mut wtxn, key, accesses)
.map_err(|source| Error::DbWrite {
db: Self::LABEL,
source,
})?;
}
wtxn.commit().map_err(|source| Error::DbCommit {
db: Self::LABEL,
source,
})?;
Ok((to_delete.len(), to_update.len()))
}
fn get_accesses(&self, path: &Path) -> Result<Option<VecDeque<u64>>> {
let key_hash = Self::path_to_hash_bytes(path)?;
let rtxn = self
.env
.read_txn()
.map_err(|source| Error::DbStartReadTxn {
db: Self::LABEL,
source,
})?;
let result = self
.db
.get(&rtxn, &key_hash)
.map_err(|source| Error::DbRead {
db: Self::LABEL,
source,
})?;
rtxn.commit().map_err(|source| Error::DbCommit {
db: Self::LABEL,
source,
})?;
Ok(result)
}
fn get_now(&self) -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
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()));
};
Ok(*blake3::hash(key.as_bytes()).as_bytes())
}
/// Returns seconds since the most recent tracked access, or `None` if the
/// file has never been tracked.
pub fn seconds_since_last_access(&self, path: &Path) -> Result<Option<u64>> {
let accesses = self.get_accesses(path)?;
let last = accesses.and_then(|a| a.back().copied());
Ok(last.map(|ts| self.get_now().saturating_sub(ts)))
}
/// Number of tracked access for file path
pub fn access_count(&self, path: &Path) -> Result<usize> {
Ok(self.get_accesses(path)?.map_or(0, |a| a.len()))
}
pub fn track_access(&self, path: &Path) -> Result<()> {
let key_hash = Self::path_to_hash_bytes(path)?;
let mut accesses = self.get_accesses(path)?.unwrap_or_default();
let now = self.get_now();
let cutoff_time = now.saturating_sub((MAX_HISTORY_DAYS * SECONDS_PER_DAY) as u64);
// Drop stale timestamps from the front while also enforcing the
// per-file cap. Reserves one slot for the `push_back` below.
while let Some(&front_time) = accesses.front() {
if front_time < cutoff_time || accesses.len() >= MAX_TIMESTAMPS_PER_FILE {
accesses.pop_front();
} else {
break;
}
}
accesses.push_back(now);
tracing::debug!(?path, accesses = accesses.len(), "Tracking access");
let mut wtxn = self
.env
.write_txn()
.map_err(|source| Error::DbStartWriteTxn {
db: Self::LABEL,
source,
})?;
if let Err(e) = self.db.put(&mut wtxn, &key_hash, &accesses) {
if is_map_full(&e) {
self.health.mark_unhealthy("MDB_MAP_FULL on put");
tracing::error!(
?path,
"Frecency DB hit MDB_MAP_FULL; dropping write — db will be \
erased on next open via LmdbStore::erase_if_oversized"
);
return Ok(());
}
return Err(Error::DbWrite {
db: Self::LABEL,
source: e,
});
}
wtxn.commit()
.inspect_err(|e| {
if is_map_full(e) {
self.health.mark_unhealthy("MDB_MAP_FULL on commit");
tracing::error!(
?path,
"Frecency DB hit MDB_MAP_FULL on commit; dropping write"
);
}
})
.map_err(|source| Error::DbCommit {
db: Self::LABEL,
source,
})
}
pub fn get_access_score(&self, file_path: &Path, mode: FFFMode) -> i64 {
let accesses = self
.get_accesses(file_path)
.ok()
.flatten()
.unwrap_or_default();
if accesses.is_empty() {
return 0;
}
let decay_constant = if mode.is_ai() {
AI_DECAY_CONSTANT
} else {
DECAY_CONSTANT
};
let max_history_days = if mode.is_ai() {
AI_MAX_HISTORY_DAYS
} else {
MAX_HISTORY_DAYS
};
let now = self.get_now();
let mut total_frecency = 0.0;
let cutoff_time = now.saturating_sub((max_history_days * SECONDS_PER_DAY) as u64);
for &access_time in accesses.iter().rev() {
if access_time < cutoff_time {
break; // All remaining entries are older, stop processing
}
let days_ago = (now.saturating_sub(access_time) as f64) / SECONDS_PER_DAY;
let decay_factor = (-decay_constant * days_ago).exp();
total_frecency += decay_factor;
}
let normalized_frecency = if total_frecency <= 10.0 {
total_frecency
} else {
10.0 + (total_frecency - 10.0).sqrt() // Diminishing: >10 accesses grow slowly
};
normalized_frecency.round() as i64
}
/// Calculating modification score but only if the file is modified in the current git dir
pub fn get_modification_score(
&self,
modified_time: u64,
git_status: Option<git2::Status>,
mode: FFFMode,
) -> i64 {
let is_modified_git_status = git_status.is_some_and(is_modified_status);
if !is_modified_git_status {
return 0;
}
let thresholds = if mode.is_ai() {
&AI_MODIFICATION_THRESHOLDS
} else {
&MODIFICATION_THRESHOLDS
};
let now = self.get_now();
let duration_since = now.saturating_sub(modified_time);
for i in 0..thresholds.len() {
let (current_points, current_threshold) = thresholds[i];
if duration_since <= current_threshold {
if i == 0 || duration_since == current_threshold {
return current_points;
}
let (prev_points, prev_threshold) = thresholds[i - 1];
let time_range = current_threshold - prev_threshold;
let time_offset = duration_since - prev_threshold;
let points_diff = prev_points - current_points;
let interpolated_score =
prev_points - (points_diff * time_offset as i64) / time_range as i64;
return interpolated_score;
}
}
0
}
}
#[cfg(test)]
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;
for &access_time in access_timestamps {
let days_ago = (current_time.saturating_sub(access_time) as f64) / SECONDS_PER_DAY;
let decay_factor = (-DECAY_CONSTANT * days_ago).exp();
total_frecency += decay_factor;
}
let normalized_frecency = if total_frecency <= 20.0 {
total_frecency
} else {
20.0 + (total_frecency - 10.0).sqrt()
};
normalized_frecency.round() as i64
}
#[test]
fn test_frecency_calculation() {
let current_time = 1000000000; // Base timestamp
let score = calculate_test_frecency_score(&[], current_time);
assert_eq!(score, 0);
let accesses = [current_time]; // Accessed right now
let score = calculate_test_frecency_score(&accesses, current_time);
assert_eq!(score, 1); // 1.0 decay factor = 1
let ten_days_seconds = 10 * 86400; // 10 days in seconds
let accesses = [current_time - ten_days_seconds];
let score = calculate_test_frecency_score(&accesses, current_time);
assert_eq!(score, 1); // ~0.5 decay factor rounds to 1
let accesses = [
current_time, // Today
current_time - 86400, // 1 day ago
current_time - 172800, // 2 days ago
];
let score = calculate_test_frecency_score(&accesses, current_time);
assert!(score > 2 && score < 4, "Score: {}", score); // About 3 accesses with decay
let thirty_days = 30 * 86400;
let accesses = [current_time - thirty_days]; // 30 days ago
let score = calculate_test_frecency_score(&accesses, current_time);
assert!(
score < 2,
"Old access should have minimal score, got: {}",
score
);
let recent_frequent = [current_time, current_time - 86400, current_time - 172800];
let old_single = [current_time - ten_days_seconds];
let recent_score = calculate_test_frecency_score(&recent_frequent, current_time);
let old_score = calculate_test_frecency_score(&old_single, current_time);
assert!(
recent_score > old_score,
"Recent frequent access ({}) should score higher than old single access ({})",
recent_score,
old_score
);
}
#[test]
fn test_modification_score_interpolation() {
let temp_dir = std::env::temp_dir().join("fff_test_interpolation");
let _ = std::fs::remove_dir_all(&temp_dir);
let tracker = FrecencyTracker::open(temp_dir.to_str().unwrap()).unwrap();
let current_time = tracker.get_now();
let git_status = Some(git2::Status::WT_MODIFIED);
// At 5 minutes: should interpolate between 16 and 8 points
let five_minutes_ago = current_time - (5 * 60);
let score = tracker.get_modification_score(five_minutes_ago, git_status, FFFMode::Neovim);
// Expected: 16 - (8 * 3 / 13) = 16 - 1 = 15 points
// (time_offset = 5-2 = 3, time_range = 15-2 = 13, points_diff = 16-8 = 8)
assert_eq!(score, 15, "5 minutes should interpolate to 15 points");
let two_minutes_ago = current_time - (2 * 60);
let score = tracker.get_modification_score(two_minutes_ago, git_status, FFFMode::Neovim);
assert_eq!(score, 16, "2 minutes should be exactly 16 points");
let fifteen_minutes_ago = current_time - (15 * 60);
let score =
tracker.get_modification_score(fifteen_minutes_ago, git_status, FFFMode::Neovim);
assert_eq!(score, 8, "15 minutes should be exactly 8 points");
// At 12 hours: should interpolate between 4 and 2 points
let twelve_hours_ago = current_time - (12 * 60 * 60);
let score = tracker.get_modification_score(twelve_hours_ago, git_status, FFFMode::Neovim);
// Expected: 4 - (2 * 11 / 23) = 4 - 0 = 4 points (integer division)
// (time_offset = 12-1 = 11 hours, time_range = 24-1 = 23 hours, points_diff = 4-2 = 2)
assert_eq!(score, 4, "12 hours should interpolate to 4 points");
// at 18 hours for more significant interpolation
let eighteen_hours_ago = current_time - (18 * 60 * 60);
let score = tracker.get_modification_score(eighteen_hours_ago, git_status, FFFMode::Neovim);
// Expected: 4 - (2 * 17 / 23) = 4 - 1 = 3 points
assert_eq!(score, 3, "18 hours should interpolate to 3 points");
let score = tracker.get_modification_score(five_minutes_ago, None, FFFMode::Neovim);
assert_eq!(score, 0, "No git status should return 0");
let _ = std::fs::remove_dir_all(&temp_dir);
}
}
+259
View File
@@ -0,0 +1,259 @@
use heed::{Database, Env, EnvOpenOptions};
use std::fs;
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 crate::error::{Error, Result};
pub(crate) fn is_map_full(err: &heed::Error) -> bool {
matches!(err, heed::Error::Mdb(heed::MdbError::MapFull))
}
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DbHealthState {
Pending = 0,
Healthy = 1,
Degraded = 2,
}
impl DbHealthState {
fn from_u8(v: u8) -> Self {
debug_assert!(v <= 2);
match v {
0 => Self::Pending,
1 => Self::Healthy,
_ => Self::Degraded,
}
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct DbHealth(Arc<AtomicU8>);
impl DbHealth {
pub(crate) fn new() -> Self {
Self(Arc::new(AtomicU8::new(DbHealthState::Pending as u8)))
}
pub(crate) fn is_healthy(&self) -> bool {
// Pending counts as unhealthy: if the GC thread never flipped to
// Healthy, something's wrong (deadlocked clear_stale_readers, stuck
// writer mutex, etc.) and we want that surfaced to the user.
DbHealthState::from_u8(self.0.load(Ordering::Acquire)) == DbHealthState::Healthy
}
pub(crate) fn mark_healthy(&self) {
let _ = self.0.compare_exchange(
DbHealthState::Pending as u8,
DbHealthState::Healthy as u8,
Ordering::AcqRel,
Ordering::Acquire,
);
}
pub(crate) fn mark_unhealthy(&self, reason: &'static str) {
let prev = self.0.swap(DbHealthState::Degraded as u8, Ordering::AcqRel);
if DbHealthState::from_u8(prev) != DbHealthState::Degraded {
tracing::error!(reason, "LMDB tracker marked unhealthy");
}
}
}
/// Spawns a background thread that is ensuring that the environment that was previously
/// open is safe, accessible and doesn't have a corrupted lock.md file. If it does this thread will
/// hang indefinitely but we will have the information that the database is in failure mode
pub(crate) fn spawn_lmdb_gc<T: LmdbStore>(shared: Arc<RwLock<Option<T>>>) {
let thread_shared = shared.clone();
let spawn_result = thread::Builder::new()
.name("fff-lmdb-gc".into())
.spawn(move || {
// Holding a read guard blocks `destroy` / re-init's write
// guard until this thread finishes — natural serialization.
let guard = match thread_shared.read() {
Ok(g) => g,
Err(e) => {
tracing::debug!("gc: read lock poisoned: {e}");
return;
}
};
let Some(ref tracker) = *guard else {
return; // destroyed before we started
};
let env = tracker.env();
if let Err(e) = T::purge_stale_data(env) {
tracing::debug!("purge_stale_data failed: {e}");
}
tracker.health().mark_healthy();
});
if let Err(e) = spawn_result {
tracing::debug!(?e, "failed to spawn fff-lmdb-gc thread");
// No thread = mark healthy now so healthcheck isn't stuck Pending.
if let Ok(guard) = shared.read()
&& let Some(ref tracker) = *guard
{
tracker.health().mark_healthy();
}
}
}
// 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;
/// LMDB map size in bytes. Must be a multiple of the OS page size.
const MAP_SIZE: usize;
/// Number of named sub-databases. `0` for single-db envs.
const MAX_DBS: u32;
/// Hard cap on `data.mdb` size.
const SIZE_CAP_BYTES: u64;
/// Borrow the env in the read lock
fn env(&self) -> &Env;
/// Borrow the health flag from the tracker.
fn health(&self) -> &DbHealth;
/// 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<()> {
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.
#[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()))
}
/// Open or create a database without blocking on the LMDB writer mutex
/// when the database already exists.
fn open_database_safe<KC, DC>(env: &Env, name: Option<&str>) -> Result<Database<KC, DC>>
where
KC: 'static,
DC: 'static,
{
let db = Self::LABEL;
let rtxn = env
.read_txn()
.map_err(|source| Error::DbStartReadTxn { db, source })?;
let maybe_db: Option<Database<KC, DC>> = env
.open_database(&rtxn, name)
.map_err(|source| Error::DbOpen { db, source })?;
// do not drop the DB here
rtxn.commit()
.map_err(|source| Error::DbCommit { db, source })?;
match maybe_db {
Some(handle) => Ok(handle),
None => {
// First time: create the database (requires write lock).
// unfortunately this CAN be deadlocking and this is what we see happens
// if the other part of the code is segfaulting, so the only rule to prevent this
// write the good code mf, okay?
let mut wtxn = env
.write_txn()
.map_err(|source| Error::DbStartWriteTxn { db, source })?;
let handle = env
.create_database(&mut wtxn, name)
.map_err(|source| Error::DbCreate { db, source })?;
wtxn.commit()
.map_err(|source| Error::DbCommit { db, source })?;
Ok(handle)
}
}
}
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"));
}
}
+10
View File
@@ -0,0 +1,10 @@
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::*;
+548
View File
@@ -0,0 +1,548 @@
use super::db_healthcheck::DbHealthChecker;
use super::lmdb::{DbHealth, LmdbStore, is_map_full};
use crate::error::Error;
use heed::types::{Bytes, SerdeBincode};
use heed::{Database, Env};
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const MAX_HISTORY_ENTRIES: usize = 128;
/// Simplified QueryFileEntry without redundant fields
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct QueryMatchEntry {
pub file_path: PathBuf, // File that was actually opened
pub open_count: u32, // Number of times opened with this query
pub last_opened: u64, // Unix timestamp
}
/// Entry for query history tracking
#[derive(Debug, Serialize, Deserialize, Clone)]
struct HistoryEntry {
query: String,
timestamp: u64,
}
#[derive(Debug)]
pub struct QueryTracker {
env: Env,
// Database for (project_path, query) -> QueryMatchEntry mappings
query_file_db: Database<Bytes, SerdeBincode<QueryMatchEntry>>,
// Database for project_path -> VecDeque<HistoryEntry> mappings (file picker)
query_history_db: Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
// Database for project_path -> VecDeque<HistoryEntry> mappings (grep)
grep_query_history_db: Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
health: DbHealth,
}
impl DbHealthChecker for QueryTracker {
fn get_env(&self) -> &Env {
&self.env
}
fn is_healthy(&self) -> bool {
self.health.is_healthy()
}
fn count_entries(&self) -> Result<Vec<(&'static str, u64)>, Error> {
let rtxn = self
.env
.read_txn()
.map_err(|source| Error::DbStartReadTxn {
db: Self::LABEL,
source,
})?;
let count_queries = self
.query_file_db
.len(&rtxn)
.map_err(|source| Error::DbRead {
db: Self::LABEL,
source,
})?;
let count_histories = self
.query_history_db
.len(&rtxn)
.map_err(|source| Error::DbRead {
db: Self::LABEL,
source,
})?;
let count_grep_histories =
self.grep_query_history_db
.len(&rtxn)
.map_err(|source| Error::DbRead {
db: Self::LABEL,
source,
})?;
Ok(vec![
("query_file_entries", count_queries),
("query_history_entries", count_histories),
("grep_query_history_entries", count_grep_histories),
])
}
}
impl LmdbStore for QueryTracker {
const LABEL: &'static str = "query";
// 10 MiB hard ceiling. Same reasoning as FrecencyTracker (GH issue #437).
const MAP_SIZE: usize = 10 * 1024 * 1024;
const MAX_DBS: u32 = 16;
const SIZE_CAP_BYTES: u64 = 8 * 1024 * 1024;
fn env(&self) -> &Env {
&self.env
}
fn health(&self) -> &DbHealth {
&self.health
}
}
impl QueryTracker {
/// Returns the on-disk path of the LMDB environment directory.
pub fn db_path(&self) -> &Path {
self.env.path()
}
pub fn open(db_path: impl AsRef<Path>) -> Result<Self, Error> {
let db_path = db_path.as_ref();
let (env, health) = Self::open_env(db_path)?;
let query_file_db = Self::open_database_safe(&env, Some("query_file_associations"))?;
let query_history_db = Self::open_database_safe(&env, Some("query_history"))?;
let grep_query_history_db = Self::open_database_safe(&env, Some("grep_query_history"))?;
Ok(QueryTracker {
env,
query_file_db,
query_history_db,
grep_query_history_db,
health,
})
}
#[deprecated(
since = "0.7.0",
note = "LMDB unsafe no-lock mode is no longer supported; use `QueryTracker::open` instead. \
The `_use_unsafe_no_lock` argument is ignored."
)]
pub fn new(db_path: impl AsRef<Path>, _use_unsafe_no_lock: bool) -> Result<Self, Error> {
Self::open(db_path)
}
fn get_now(&self) -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
fn create_query_key(project_path: &Path, query: &str) -> Result<[u8; 32], Error> {
let project_str = project_path
.to_str()
.ok_or_else(|| Error::InvalidPath(project_path.to_path_buf()))?;
let mut hasher = blake3::Hasher::default();
hasher.update(project_str.as_bytes());
hasher.update(b"::");
hasher.update(query.as_bytes());
Ok(*hasher.finalize().as_bytes())
}
fn create_project_key(project_path: &Path) -> Result<[u8; 32], Error> {
let project_str = project_path
.to_str()
.ok_or_else(|| Error::InvalidPath(project_path.to_path_buf()))?;
Ok(*blake3::hash(project_str.as_bytes()).as_bytes())
}
/// Append a query to a history database within an existing write transaction.
fn append_to_history(
db: &Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
wtxn: &mut heed::RwTxn,
project_key: &[u8; 32],
query: &str,
now: u64,
) -> Result<(), Error> {
let mut history = db
.get(wtxn, project_key)
.map_err(|source| Error::DbRead {
db: Self::LABEL,
source,
})?
.unwrap_or_default();
history.push_back(HistoryEntry {
query: query.to_string(),
timestamp: now,
});
while history.len() > MAX_HISTORY_ENTRIES {
history.pop_front();
}
db.put(wtxn, project_key, &history)
.map_err(|source| Error::DbWrite {
db: Self::LABEL,
source,
})?;
Ok(())
}
/// Read a query from a history database at a specific offset.
/// 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,
project_key: &[u8; 32],
offset: usize,
) -> Result<Option<String>, Error> {
let rtxn = env.read_txn().map_err(|source| Error::DbStartReadTxn {
db: Self::LABEL,
source,
})?;
let mut history = db
.get(&rtxn, project_key)
.map_err(|source| Error::DbRead {
db: Self::LABEL,
source,
})?
.unwrap_or_default();
// history is FIFO, last element is most recent
if history.len() > offset {
let index = history.len() - 1 - offset;
let record = history.remove(index);
Ok(record.map(|r| r.query))
} else {
Ok(None)
}
}
pub fn track_query_completion(
&mut self,
query: &str,
project_path: &Path,
file_path: &Path,
) -> Result<(), Error> {
let now = self.get_now();
let file_path_buf = file_path.to_path_buf();
let query_key = Self::create_query_key(project_path, query)?;
let mut wtxn = self
.env
.write_txn()
.map_err(|source| Error::DbStartWriteTxn {
db: Self::LABEL,
source,
})?;
let mut entry = self
.query_file_db
.get(&wtxn, &query_key)
.map_err(|source| Error::DbRead {
db: Self::LABEL,
source,
})?
.unwrap_or_else(|| QueryMatchEntry {
file_path: file_path_buf.clone(),
open_count: 0,
last_opened: now,
});
if entry.file_path == file_path_buf {
tracing::debug!(
?query,
?file_path,
"Query completed for same file as last time"
);
// Same file - just increment count
entry.open_count += 1;
} else {
tracing::debug!(
?query,
?file_path,
"Query completed for different file than last time"
);
// Different file - replace and reset count to 1
entry.file_path = file_path_buf;
entry.open_count = 1;
}
entry.last_opened = now;
if let Err(e) = self.query_file_db.put(&mut wtxn, &query_key, &entry) {
if is_map_full(&e) {
self.health.mark_unhealthy("MDB_MAP_FULL on put");
tracing::error!(
?query,
"Query tracker DB hit MDB_MAP_FULL; dropping write — db will \
be erased on next open"
);
return Ok(());
}
return Err(Error::DbWrite {
db: Self::LABEL,
source: e,
});
}
// Update query history database
let project_key = Self::create_project_key(project_path)?;
if let Err(e) =
Self::append_to_history(&self.query_history_db, &mut wtxn, &project_key, query, now)
{
if let Error::DbWrite {
source: ref inner, ..
} = e
&& is_map_full(inner)
{
self.health.mark_unhealthy("MDB_MAP_FULL on history append");
tracing::error!(?query, "Query tracker DB map full while appending history");
return Ok(());
}
return Err(e);
}
if let Err(e) = wtxn.commit() {
if is_map_full(&e) {
self.health.mark_unhealthy("MDB_MAP_FULL on commit");
tracing::error!(?query, "Query tracker DB map full on commit");
return Ok(());
}
return Err(Error::DbCommit {
db: Self::LABEL,
source: e,
});
}
tracing::debug!(?query, ?file_path, "Tracked query completion");
Ok(())
}
pub fn get_last_query_entry(
&self,
query: &str,
project_path: &Path,
min_combo_count: u32,
) -> Result<Option<QueryMatchEntry>, Error> {
let query_key = Self::create_query_key(project_path, query)?;
let rtxn = self
.env
.read_txn()
.map_err(|source| Error::DbStartReadTxn {
db: Self::LABEL,
source,
})?;
let last_match = self
.query_file_db
.get(&rtxn, &query_key)
.map_err(|source| Error::DbRead {
db: Self::LABEL,
source,
})?;
Ok(last_match.filter(|entry| entry.open_count >= min_combo_count))
}
pub fn get_last_query_path(
&self,
query: &str,
project_path: &Path,
file_path: &Path,
combo_boost: i32,
) -> Result<i32, Error> {
let query_key = Self::create_query_key(project_path, query)?;
tracing::debug!(?query_key, "HASH");
let rtxn = self
.env
.read_txn()
.map_err(|source| Error::DbStartReadTxn {
db: Self::LABEL,
source,
})?;
match self
.query_file_db
.get(&rtxn, &query_key)
.map_err(|source| Error::DbRead {
db: Self::LABEL,
source,
})? {
Some(entry) => {
// Check if the file path matches and return boost
if entry.file_path == file_path && entry.open_count >= 2 {
Ok(combo_boost)
} else {
Ok(0)
}
}
None => Ok(0), // Query not found
}
}
/// Get query from file picker history at a specific offset.
/// offset=0 returns most recent query, offset=1 returns 2nd most recent, etc.
pub fn get_historical_query(
&self,
project_path: &Path,
offset: usize,
) -> Result<Option<String>, Error> {
let project_key = Self::create_project_key(project_path)?;
Self::read_history_at_offset(&self.query_history_db, &self.env, &project_key, offset)
}
/// Track a grep query in the grep-specific history.
/// Only records query history (no file association tracking needed for grep).
pub fn track_grep_query(&mut self, query: &str, project_path: &Path) -> Result<(), Error> {
let now = self.get_now();
let project_key = Self::create_project_key(project_path)?;
let mut wtxn = self
.env
.write_txn()
.map_err(|source| Error::DbStartWriteTxn {
db: Self::LABEL,
source,
})?;
if let Err(e) = Self::append_to_history(
&self.grep_query_history_db,
&mut wtxn,
&project_key,
query,
now,
) {
if let Error::DbWrite {
source: ref inner, ..
} = e
&& is_map_full(inner)
{
self.health
.mark_unhealthy("MDB_MAP_FULL on grep history append");
tracing::error!(?query, "Grep query history DB map full; dropping write");
return Ok(());
}
return Err(e);
}
if let Err(e) = wtxn.commit() {
if is_map_full(&e) {
self.health.mark_unhealthy("MDB_MAP_FULL on commit");
tracing::error!(?query, "Grep query history DB map full on commit");
return Ok(());
}
return Err(Error::DbCommit {
db: Self::LABEL,
source: e,
});
}
tracing::debug!(?query, "Tracked grep query");
Ok(())
}
/// Get grep query from history at a specific offset.
/// offset=0 returns most recent grep query, offset=1 returns 2nd most recent, etc.
pub fn get_historical_grep_query(
&self,
project_path: &Path,
offset: usize,
) -> Result<Option<String>, Error> {
let project_key = Self::create_project_key(project_path)?;
Self::read_history_at_offset(&self.grep_query_history_db, &self.env, &project_key, offset)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[test]
fn test_query_tracking() {
let temp_dir = env::temp_dir().join("fff_test_query_tracking_new");
let _ = std::fs::remove_dir_all(&temp_dir);
let mut tracker = QueryTracker::open(temp_dir.to_str().unwrap()).unwrap();
let project_path = PathBuf::from("/test/project");
let file_path = PathBuf::from("/test/project/src/main.rs");
// First completion
tracker
.track_query_completion("main", &project_path, &file_path)
.unwrap();
let boost = tracker
.get_last_query_path("main", &project_path, &file_path, 10000)
.unwrap();
assert_eq!(boost, 0, "First completion should not boost");
// Second completion - should boost now
tracker
.track_query_completion("main", &project_path, &file_path)
.unwrap();
let boost = tracker
.get_last_query_path("main", &project_path, &file_path, 10000)
.unwrap();
assert_eq!(boost, 10000, "Second completion should boost");
// Different file for same query - should reset count and no boost
let other_file = PathBuf::from("/test/project/src/lib.rs");
tracker
.track_query_completion("main", &project_path, &other_file)
.unwrap();
let boost = tracker
.get_last_query_path("main", &project_path, &other_file, 10000)
.unwrap();
assert_eq!(boost, 0, "Different file should reset boost");
// Original file should no longer get boost (replaced by new file)
let boost = tracker
.get_last_query_path("main", &project_path, &file_path, 10000)
.unwrap();
assert_eq!(boost, 0, "Original file should not boost after replacement");
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_hashing_functions() {
let project_path = PathBuf::from("/test/project");
// Test project key hashing
let key1 = QueryTracker::create_project_key(&project_path).unwrap();
let key2 = QueryTracker::create_project_key(&project_path).unwrap();
assert_eq!(key1, key2, "Same project should hash to same key");
// Test query key hashing
let query_key1 = QueryTracker::create_query_key(&project_path, "test").unwrap();
let query_key2 = QueryTracker::create_query_key(&project_path, "test").unwrap();
assert_eq!(
query_key1, query_key2,
"Same project+query should hash to same key"
);
// Different queries should hash differently
let query_key3 = QueryTracker::create_query_key(&project_path, "different").unwrap();
assert_ne!(
query_key1, query_key3,
"Different queries should hash to different keys"
);
// Different projects should hash differently
let other_project = PathBuf::from("/other/project");
let query_key4 = QueryTracker::create_query_key(&other_project, "test").unwrap();
assert_ne!(
query_key1, query_key4,
"Different projects should hash to different keys"
);
}
}
+99
View File
@@ -0,0 +1,99 @@
use std::path::StripPrefixError;
#[derive(thiserror::Error, Debug)]
#[non_exhaustive]
pub enum Error {
#[error("Thread panicked")]
ThreadPanic,
#[error("Invalid path {0}")]
InvalidPath(std::path::PathBuf),
#[error(
"Can not run certain FFF features in a file system root or home directories. Consider smaller per-project directories."
)]
FilesystemRoot(std::path::PathBuf),
#[error("File picker not initialized")]
FilePickerMissing,
#[error("Failed to acquire lock for frecency")]
AcquireFrecencyLock,
#[error("Failed to acquire lock for items by provider")]
AcquireItemLock,
#[error("Failed to acquire lock for path cache")]
AcquirePathCacheLock,
#[error("Failed to create directory: {0}")]
CreateDir(#[from] std::io::Error),
#[error("Failed to remove database directory {path}: {source}")]
RemoveDbDir {
path: std::path::PathBuf,
source: std::io::Error,
},
#[error("Something is wrong with the local db instance: {0}")]
GenericDbError(#[from] heed::Error),
#[error("Failed to open {db} database env: {source}")]
EnvOpen {
db: &'static str,
#[source]
source: heed::Error,
},
#[error("Failed to create {db} database: {source}")]
DbCreate {
db: &'static str,
#[source]
source: heed::Error,
},
#[error("Failed to open {db} database: {source}")]
DbOpen {
db: &'static str,
#[source]
source: heed::Error,
},
#[error("Failed to clear stale readers for {db} database: {source}")]
DbClearStaleReaders {
db: &'static str,
#[source]
source: heed::Error,
},
#[error("Failed to start read transaction for {db} database: {source}")]
DbStartReadTxn {
db: &'static str,
#[source]
source: heed::Error,
},
#[error("Failed to start write transaction for {db} database: {source}")]
DbStartWriteTxn {
db: &'static str,
#[source]
source: heed::Error,
},
#[error("Failed to read from {db} database: {source}")]
DbRead {
db: &'static str,
#[source]
source: heed::Error,
},
#[error("Failed to write to {db} database: {source}")]
DbWrite {
db: &'static str,
#[source]
source: heed::Error,
},
#[error("Failed to commit write transaction to {db} database: {source}")]
DbCommit {
db: &'static str,
#[source]
source: heed::Error,
},
#[error("Failed to start file system watcher: {0}")]
FileSystemWatch(#[from] notify::Error),
#[error("Expected a path to be child of another path: {0}")]
StripPrefixError(#[from] StripPrefixError),
#[error("libgit2 error occurred: {0}")]
Git(#[from] git2::Error),
#[error("Filesystem walk failed: {0}")]
WalkFailed(String),
}
pub type Result<T> = std::result::Result<T, Error>;
File diff suppressed because it is too large Load Diff
+253
View File
@@ -0,0 +1,253 @@
use crate::error::Result;
use ahash::AHashMap;
use git2::{Repository, Status, StatusOptions};
use std::{
fmt::Debug,
path::{Path, PathBuf},
};
pub(crate) fn default_status_options() -> StatusOptions {
let mut opts = StatusOptions::new();
opts.include_untracked(true)
.recurse_untracked_dirs(true)
.include_unmodified(true)
.exclude_submodules(true);
opts
}
/// Status options for the initial scan / rescan.
///
/// Skips `include_unmodified` because every `FileItem` starts with
/// `git_status: None` (== clean), so a missing cache entry already means
/// "clean" — no need to ask libgit2 to enumerate every tracked path.
/// Saves seconds on huge dirty trees (e.g. chromium with 400k+ entries).
pub(crate) fn initial_scan_status_options() -> StatusOptions {
let mut opts = StatusOptions::new();
opts.include_untracked(true)
.recurse_untracked_dirs(true)
.exclude_submodules(true);
opts
}
#[derive(Debug, Clone, Default)]
pub(crate) struct GitStatusCache(AHashMap<PathBuf, Status>);
impl IntoIterator for GitStatusCache {
type Item = (PathBuf, Status);
type IntoIter = <AHashMap<PathBuf, Status> as IntoIterator>::IntoIter;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
impl GitStatusCache {
pub fn statuses_len(&self) -> usize {
self.0.len()
}
#[inline]
pub fn lookup_status(&self, full_path: &Path) -> Option<Status> {
self.0.get(full_path).copied()
}
#[tracing::instrument(skip(repo, status_options))]
fn read_status_impl(repo: &Repository, status_options: &mut StatusOptions) -> Result<Self> {
let statuses = repo.statuses(Some(status_options))?;
let Some(repo_path) = repo.workdir() else {
return Ok(Self(AHashMap::new())); // repo is bare
};
let repo_path = crate::path_utils::normalize(repo_path.to_path_buf());
let mut entries = AHashMap::with_capacity(statuses.len());
for entry in &statuses {
if let Some(entry_path) = entry.path() {
// libgit2 returns entry paths with forward slashes on every platform
// fff stores native paths - meaning we have forward slash issue on windows
let full_path = crate::path_utils::normalize(repo_path.join(entry_path));
entries.insert(full_path, entry.status());
}
}
Ok(Self(entries))
}
pub fn read_git_status(
git_workdir: Option<&Path>,
status_options: &mut StatusOptions,
) -> Option<Self> {
let git_workdir = git_workdir.as_ref()?;
let repository = Repository::open(git_workdir).ok()?;
let status = Self::read_status_impl(&repository, status_options);
match status {
Ok(status) => Some(status),
Err(e) => {
tracing::error!(?e, "Failed to read git status");
None
}
}
}
#[tracing::instrument(skip(repo), fields(paths_count = paths.len()), level = tracing::Level::DEBUG)]
pub fn git_status_for_paths<TPath: AsRef<Path> + Debug>(
repo: &Repository,
paths: &[TPath],
) -> Result<Self> {
if paths.is_empty() {
return Ok(Self(AHashMap::new()));
}
let Some(workdir) = repo.workdir() else {
return Ok(Self(AHashMap::new()));
};
let workdir = crate::path_utils::normalize(workdir.to_path_buf());
// git pathspec is pretty slow and requires to walk the whole directory
// so for a single file which is the most general use case we query directly the file
if paths.len() == 1 {
let full_path = paths[0].as_ref();
let relative_path = full_path.strip_prefix(&workdir)?;
let status = repo.status_file(relative_path)?;
let mut map = AHashMap::with_capacity(1);
map.insert(full_path.to_path_buf(), status);
return Ok(Self(map));
}
let mut status_options = default_status_options();
for path in paths {
status_options.pathspec(path.as_ref().strip_prefix(&workdir)?);
}
let git_status_cache = Self::read_status_impl(repo, &mut status_options)?;
Ok(git_status_cache)
}
}
#[inline]
pub fn is_modified_status(status: Status) -> bool {
status.intersects(
Status::WT_MODIFIED
| Status::INDEX_MODIFIED
| Status::WT_NEW
| Status::INDEX_NEW
| Status::WT_RENAMED,
)
}
pub fn format_git_status_opt(status: Option<Status>) -> Option<&'static str> {
match status {
None => Some("clean"),
Some(status) => {
if status.contains(Status::WT_NEW) {
Some("untracked")
} else if status.contains(Status::WT_MODIFIED) {
Some("modified")
} else if status.contains(Status::WT_DELETED) {
Some("deleted")
} else if status.contains(Status::WT_RENAMED) {
Some("renamed")
} else if status.contains(Status::INDEX_NEW) {
Some("staged_new")
} else if status.contains(Status::INDEX_MODIFIED) {
Some("staged_modified")
} else if status.contains(Status::INDEX_DELETED) {
Some("staged_deleted")
} else if status.contains(Status::IGNORED) {
Some("ignored")
} else if status.contains(Status::CURRENT) || status.is_empty() {
Some("clean")
} else {
None
}
}
}
}
pub fn format_git_status(status: Option<Status>) -> &'static str {
format_git_status_opt(status).unwrap_or("unknown")
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn git(dir: &Path, args: &[&str]) {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t")
.output()
.unwrap();
assert!(out.status.success(), "git {args:?} failed");
}
/// Regression: on case-insensitive filesystems libgit2 returns
/// statuses in a case-insensitive order. Our previous sorted-`Vec` +
/// `binary_search_by(Path::cmp)` lookup silently missed entries
/// because `Path::cmp` is byte-wise.
///
/// This test uses deliberately mixed-case filenames so the two
/// orderings disagree, then checks every lookup succeeds.
#[test]
fn lookup_is_case_exact_regardless_of_libgit2_sort_order() {
let tmp = TempDir::new().unwrap();
// `std::fs::canonicalize` on Windows adds a `\\?\` UNC prefix that
// libgit2's workdir string lacks. Use dunce so both sides match.
let base = crate::path_utils::canonicalize(tmp.path()).unwrap();
// Mixed-case names that sort differently under byte-wise vs
// case-insensitive comparators.
let names = [
"README.md",
"a_lower.rs",
"Z_upper.rs",
"mixed_Case.txt",
"nested/Inner_File.rs",
];
for n in &names {
let p = base.join(n);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(&p, format!("// {n}\n")).unwrap();
}
git(&base, &["init", "-b", "main"]);
git(&base, &["add", "-A"]);
git(&base, &["commit", "-m", "seed", "--no-gpg-sign"]);
// Modify every file so they all end up in the status output as
// WT_MODIFIED — guarantees a non-trivial map we have to look up.
for n in &names {
let p = base.join(n);
fs::write(&p, format!("// {n}\n// edit\n")).unwrap();
}
let repo = Repository::open(&base).unwrap();
let paths: Vec<PathBuf> = names.iter().map(|n| base.join(n)).collect();
let cache = GitStatusCache::git_status_for_paths(&repo, &paths).unwrap();
for (n, abs) in names.iter().zip(paths.iter()) {
let status = cache.lookup_status(abs);
assert!(
status.is_some(),
"lookup for {n} returned None; cache holds {} entries",
cache.statuses_len(),
);
assert!(
status.unwrap().contains(Status::WT_MODIFIED),
"expected WT_MODIFIED for {n}, got {:?}",
status
);
}
}
}
+120
View File
@@ -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"));
}
}
+131
View File
@@ -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")
}
+358
View File
@@ -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::grep::{
GrepMatch, GrepSearchOptions, char_indices_to_byte_offsets, classify_definition,
truncate_display_bytes,
};
use super::utils::{GrepResult, strip_line_terminators};
#[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),
)
}
File diff suppressed because it is too large Load Diff
+372
View File
@@ -0,0 +1,372 @@
use super::grep::*;
use crate::bigram_filter::BigramIndexBuilder;
use crate::file_picker::{FilePicker, FilePickerOptions};
use std::io::Write;
use std::sync::atomic::AtomicBool;
#[test]
fn test_unescaped_newline_detection() {
// Single \n → multiline
assert!(has_unescaped_newline_escape("foo\\nbar"));
// \\n → escaped backslash + literal n, NOT multiline
// (this is what the user types when grepping Rust source with `\\nvim`)
assert!(!has_unescaped_newline_escape("foo\\\\nvim-data"));
// Real-world: source file has literal \\AppData\\Local\\nvim-data
// (double backslash in the file, so user types double backslash)
assert!(!has_unescaped_newline_escape(
r#"format!("{}\\AppData\\Local\\nvim-data","#
));
// No \n at all
assert!(!has_unescaped_newline_escape("hello world"));
// \\\\n → even number of backslashes before n → NOT multiline
assert!(!has_unescaped_newline_escape("foo\\\\\\\\nbar"));
// \\\n → 3 backslashes: first two pair up, third + n = \n → multiline
assert!(has_unescaped_newline_escape("foo\\\\\\nbar"));
}
#[test]
fn test_replace_unescaped_newline() {
// \n → real newline
assert_eq!(replace_unescaped_newline_escapes("foo\\nbar"), "foo\nbar");
// \\n → preserved as-is
assert_eq!(
replace_unescaped_newline_escapes("foo\\\\nvim"),
"foo\\\\nvim"
);
}
#[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"
);
}
/// 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()
);
}
+16
View File
@@ -0,0 +1,16 @@
mod fuzzy_grep;
mod utils;
pub use utils::*; // contains some of the generally available functions and types
#[allow(clippy::module_inception)]
mod grep;
pub use grep::*;
#[cfg(feature = "definitions")]
mod classify;
#[cfg(feature = "definitions")]
pub use classify::*;
#[cfg(test)]
mod grep_tests;
+122
View File
@@ -0,0 +1,122 @@
use super::grep::{GrepMatch, GrepSearchOptions};
use crate::types::FileItem;
#[inline]
pub(crate) 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]
}
/// 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>,
}
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,
}
}
}
pub fn has_regex_metacharacters(text: &str) -> bool {
regex::escape(text) != text
}
+68
View File
@@ -0,0 +1,68 @@
use std::path::Path;
/// 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] = &[
"node_modules",
"__pycache__",
"venv",
".venv",
// Rust (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")]
"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.
#[cfg(target_os = "macos")]
"Library/Group Containers",
#[cfg(target_os = "macos")]
"Library/Containers",
#[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(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 IGNORED_DIRS {
let pattern = format!("!**/{dir}/");
if let Err(e) = builder.add(&pattern) {
tracing::warn!("failed to add ignore pattern {pattern}: {e}");
}
}
builder.build().ok()
}
pub(crate) fn is_non_code_directory(path: &Path) -> bool {
let path_str = path.as_os_str().to_str().unwrap_or("");
IGNORED_DIRS.iter().any(|&dir| {
#[cfg(target_os = "windows")]
let dir = dir.replace('/', std::path::MAIN_SEPARATOR_STR);
#[cfg(target_os = "windows")]
return path_str.contains(dir.as_str());
#[cfg(not(target_os = "windows"))]
path_str.contains(dir)
})
}
+161
View File
@@ -0,0 +1,161 @@
//! # 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).
//! 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
//! background thread, maintains a sorted file list, watches the filesystem for
//! changes, and performs fuzzy search with frecency-weighted scoring.
//! - [`frecency::FrecencyTracker`] — LMDB-backed database that tracks file access
//! and modification patterns for intelligent result ranking.
//! - [`query_tracker::QueryTracker`] — Tracks search query history and provides
//! "combo-boost" scoring for repeatedly matched files.
//! - [`grep`] — Live grep search supporting regex, plain-text, and fuzzy modes
//! with optional constraint filtering.
//! - [`git`] — Git status caching and repository detection.
//!
//! ## Shared State
//!
//! [`SharedFilePicker`], [`SharedFrecency`], and [`SharedQueryTracker`] are
//! newtype wrappers around `Arc<RwLock<Option<T>>>` for thread-safe shared
//! access. They provide `read()` / `write()` methods with built-in error
//! conversion and convenience helpers like `wait_for_scan()`.
//!
//! ## Quick Start
//!
//! ```
//! use fff_search::file_picker::FilePicker;
//! use fff_search::frecency::FrecencyTracker;
//! use fff_search::query_tracker::QueryTracker;
//! use fff_search::{
//! FFFMode, FilePickerOptions, FuzzySearchOptions, PaginationArgs, QueryParser,
//! SharedFrecency, SharedFilePicker, SharedQueryTracker,
//! };
//!
//! let shared_picker = SharedFilePicker::default();
//! let shared_frecency = SharedFrecency::default();
//! let shared_query_tracker = SharedQueryTracker::default();
//!
//! let tmp = std::env::temp_dir().join("fff-doctest");
//! std::fs::create_dir_all(&tmp).unwrap();
//!
//! // 1. Optionally initialize frecency and query tracker databases
//! let frecency = FrecencyTracker::open(tmp.join("frecency"))?;
//! shared_frecency.init(frecency)?;
//!
//! let query_tracker = QueryTracker::open(tmp.join("queries"))?;
//! shared_query_tracker.init(query_tracker)?;
//!
//! // 2. Init the file picker (spawns background scan + watcher)
//! FilePicker::new_with_shared_state(
//! shared_picker.clone(),
//! shared_frecency.clone(),
//! FilePickerOptions {
//! base_path: ".".into(),
//! mode: FFFMode::Ai,
//! ..Default::default()
//! },
//! )?;
//!
//! // 3. Wait for scan
//! shared_picker.wait_for_scan(std::time::Duration::from_secs(10));
//!
//! // 4. Search: lock the picker and query tracker
//! let picker_guard = shared_picker.read()?;
//! let picker = picker_guard.as_ref().unwrap();
//! let qt_guard = shared_query_tracker.read()?;
//!
//! // 5. Parse the query and perform fuzzy search
//! let parser = QueryParser::default();
//! let query = parser.parse("lib.rs");
//!
//! let results = picker.fuzzy_search(
//! &query,
//! qt_guard.as_ref(),
//! FuzzySearchOptions {
//! max_threads: 0,
//! current_file: None,
//! pagination: PaginationArgs { offset: 0, limit: 50 },
//! ..Default::default()
//! },
//! );
//!
//! assert!(results.total_matched > 0);
//! assert!(results.items.first().unwrap().relative_path(picker).ends_with("lib.rs"));
//!
//! let _ = std::fs::remove_dir_all(&tmp);
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```
#[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`."
);
/// Primary entry points with thread-safe [`SharedFilePicker`](shared::FilePicker) instance
pub mod shared;
pub use shared::*;
/// 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::*;
/// Git status caching and repository detection utilities.
pub mod git;
/// Live grep search with regex, plain-text, and fuzzy matching modes.
pub mod grep;
pub use grep::*;
/// Tracing/logging initialization and panic hook setup.
pub mod log;
/// Various path utils might be handy for you to work with fff paths
pub mod path_utils;
/// Core data types shared across the crate.
pub mod types;
pub use types::*;
pub mod constants;
// ==================================
// these are public only for benchmarks, no backward compatibility guaranteed
#[doc(hidden)]
pub mod bigram_filter;
#[doc(hidden)]
pub mod simd_string_utils;
// ==================================
mod background_watcher;
mod constraints;
mod error;
mod git_status_worker;
mod ignore;
mod scan;
mod score;
mod sort_buffer;
pub(crate) mod bigram_query;
pub(crate) mod parallelism;
pub(crate) mod simd_path;
pub(crate) mod stable_vec;
pub(crate) mod walk;
// fff error
pub use error::{Error, Result};
pub use fff_query_parser::*;
+281
View File
@@ -0,0 +1,281 @@
use std::io;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use tracing_appender::non_blocking;
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
// Set once on first init_tracing; doubles as the init-once gate.
static LOG_FILE_PATH: OnceLock<PathBuf> = OnceLock::new();
static CRASH_HOOKS: OnceLock<()> = OnceLock::new();
fn write_crash_report(header: &str, body: &str) {
let msg = format!(
"\n=== CRASH (this might NOT BE fff related) {} ===\n{}\n=== CRASH END {} ===\n",
header, body, header
);
let _ = std::io::Write::write_all(&mut std::io::stderr(), msg.as_bytes());
if let Some(path) = LOG_FILE_PATH.get() {
let _ = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.and_then(|mut f| std::io::Write::write_all(&mut f, msg.as_bytes()));
}
}
// SIGSEGV handler writes a banner to a pre-opened fd (open(2) inside a signal
// handler is unsafe due to path-resolution allocs). Unix only.
#[cfg(unix)]
mod sigsegv {
use std::os::fd::IntoRawFd;
use std::path::Path;
use std::sync::atomic::{AtomicI32, Ordering};
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()
.create(true)
.append(true)
.open(path)
{
let prev = LOG_FD.swap(file.into_raw_fd(), Ordering::Relaxed);
if prev >= 0 {
unsafe { libc::close(prev) };
}
}
}
// Body must be async-signal-safe: write(2), atomic load, signal(2). Nothing else.
fn handler(_info: &libc::siginfo_t) {
const BANNER: &[u8] = b"\n=== CRASH SIGSEGV (fff) ===\n\
fff.nvim's rust extension hit a segfault and is about to die.\n\
Please file the bug at https://github.com/dmtrKovalenko/fff/issues with this banner attached.\n\
=== CRASH END SIGSEGV ===\n";
unsafe {
libc::write(2, BANNER.as_ptr().cast(), BANNER.len());
let log_fd = LOG_FD.load(Ordering::Relaxed);
if log_fd >= 0 {
libc::write(log_fd, BANNER.as_ptr().cast(), BANNER.len());
}
// Reset to default so handler return → kernel kills us instead of
// re-running the faulting instruction in an infinite loop.
libc::signal(libc::SIGSEGV, libc::SIG_DFL);
}
}
pub fn install() {
// signal-hook-registry chains to LuaJIT's prior handler automatically.
unsafe {
let _ = signal_hook_registry::register_unchecked(libc::SIGSEGV, handler);
}
}
}
#[cfg(not(unix))]
mod sigsegv {
use std::path::Path;
pub fn set_log_fd(_path: &Path) {}
pub fn install() {}
}
pub fn install_panic_hook() {
CRASH_HOOKS.get_or_init(install_crash_hooks);
}
fn install_crash_hooks() {
let default_panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let message = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
s.clone()
} else {
"Unknown panic payload".to_string()
};
let location = panic_info
.location()
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
.unwrap_or_else(|| "unknown location".to_string());
tracing::error!(
panic.message = %message,
panic.location = %location,
"PANIC occurred in FFF"
);
write_crash_report(
"RUST PANIC",
&format!("Message: {}\nLocation: {}", message, location),
);
default_panic(panic_info);
}));
sigsegv::install();
}
/// Parse a log level string into a `tracing::Level`.
pub fn parse_log_level(level: Option<&str>) -> tracing::Level {
match level.as_ref().map(|s| s.trim().to_lowercase()).as_deref() {
Some("trace") => tracing::Level::TRACE,
Some("debug") => tracing::Level::DEBUG,
Some("info") => tracing::Level::INFO,
Some("warn") => tracing::Level::WARN,
Some("error") => tracing::Level::ERROR,
_ => tracing::Level::INFO,
}
}
/// Default retention: how many prior nvim sessions' log files to keep.
const DEFAULT_RETAIN_RUNS: usize = 20;
pub fn generate_trace_id() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static TRACE_COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let pid = std::process::id() as u64;
let counter = TRACE_COUNTER.fetch_add(1, Ordering::Relaxed);
// very simple hash functions helps to distinguish trace ids visually
let id = nanos ^ (pid.wrapping_mul(0x9E37_79B9_7F4A_7C15)) ^ (counter << 32);
format!("{:016x}", id)
}
pub fn trace_span(trace_id: &str, label: &'static str) -> tracing::Span {
tracing::info_span!("fff.trace", trace_id = trace_id, label = label)
}
fn unix_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn session_path_from_hint(hint: &Path) -> PathBuf {
let stem = hint.file_stem().and_then(|s| s.to_str()).unwrap_or("fff");
let ext = hint.extension().and_then(|e| e.to_str()).unwrap_or("log");
let parent = hint.parent().unwrap_or_else(|| Path::new("."));
parent.join(format!(
"{stem}+{ts}+{pid}.{ext}",
ts = unix_secs(),
pid = std::process::id(),
))
}
fn rotate_logs(dir: &Path, stem: &str, ext: &str, retain_runs: usize) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let prefix = format!("{stem}+");
let suffix = format!(".{ext}");
let mut files: Vec<(std::time::SystemTime, PathBuf)> = entries
.filter_map(|res| {
let entry = res.ok()?;
let name = entry.file_name();
let name = name.to_str()?;
if !name.starts_with(&prefix) || !name.ends_with(&suffix) {
return None;
}
let mtime = entry.metadata().ok()?.modified().ok()?;
Some((mtime, entry.path()))
})
.collect();
if files.len() <= retain_runs {
return;
}
// Newest first, then drop everything past retain_runs.
files.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime));
for (_, path) in files.into_iter().skip(retain_runs) {
let _ = std::fs::remove_file(path);
}
}
/// `log_file_path` is a path-shape hint. Each call writes a unique sibling
/// `<stem>+<unix-secs>+<pid>.<ext>` so concurrent processes never collide.
/// Returns the absolute path of the session file.
pub fn init_tracing(
log_file_path: &str,
log_level: Option<&str>,
retain_runs: Option<usize>,
) -> Result<String, io::Error> {
let hint = Path::new(log_file_path);
let session_dir = hint
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
std::fs::create_dir_all(&session_dir)?;
let session_path = session_path_from_hint(hint);
// First init wins; repeat callers no-op and return the original path.
if LOG_FILE_PATH.set(session_path.clone()).is_err() {
return Ok(LOG_FILE_PATH
.get()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default());
}
sigsegv::set_log_fd(&session_path);
install_panic_hook();
let stem = hint.file_stem().and_then(|s| s.to_str()).unwrap_or("fff");
let ext = hint.extension().and_then(|e| e.to_str()).unwrap_or("log");
rotate_logs(
&session_dir,
stem,
ext,
retain_runs.unwrap_or(DEFAULT_RETAIN_RUNS),
);
let writer_file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&session_path)?;
// we intinionally leark the guard we don't ever want to stop logging
let (non_blocking_appender, guard) = non_blocking(writer_file);
Box::leak(Box::new(guard));
let subscriber = tracing_subscriber::registry()
.with(
fmt::layer()
.with_writer(non_blocking_appender)
.with_target(true)
.with_thread_ids(false)
.with_thread_names(true)
.with_ansi(false)
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
)
.with(
EnvFilter::builder()
.with_default_directive(parse_log_level(log_level).into())
.from_env_lossy(),
);
if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
eprintln!("Failed to set tracing subscriber: {}", e);
} else {
tracing::info!(
"FFF tracing initialized: {} (pid={}, retain_runs={})",
session_path.display(),
std::process::id(),
retain_runs.unwrap_or(DEFAULT_RETAIN_RUNS),
);
}
Ok(session_path.to_string_lossy().into_owned())
}
+84
View File
@@ -0,0 +1,84 @@
//! Dedicated rayon pools. The global pool spans every logical core, which
//! oversubscribes asymmetric chips (Apple P+E): E-cores are ~2× slower and
//! `open()` contends on a per-VFS lock past P-core count, so a larger pool is
//! slower on file-heavy work.
use std::sync::LazyLock;
/// Dedicated thread pool for background work (scan, warmup, bigram build).
pub static BACKGROUND_THREAD_POOL: LazyLock<rayon::ThreadPool> = LazyLock::new(|| {
let total = std::thread::available_parallelism()
.map(|p| p.get())
.unwrap_or(4);
// Background work is mostly syscall-bound; halving parallelism leaves
// cores for search/UI at negligible throughput cost.
let bg_threads = (total / 2).max(2);
rayon::ThreadPoolBuilder::new()
.num_threads(bg_threads)
.thread_name(|i| format!("fff-bg-{i}"))
.start_handler(|_| {
// QoS pin keeps workers on P-cores; the kernel otherwise drifts
// them to ~2× slower E-cores.
#[cfg(target_os = "macos")]
unsafe {
let _ = libc::pthread_set_qos_class_self_np(
libc::qos_class_t::QOS_CLASS_USER_INITIATED,
0,
);
}
})
.build()
.expect("failed to create background rayon pool")
});
/// Physical performance-core count via sysctl, falling back to logical cores.
/// On a 12P+4E M4 Max, grep runs 16t=6.2s vs 13t=4.9s — fewer threads win.
#[cfg(target_os = "macos")]
fn performance_core_count() -> usize {
let mut count: libc::c_int = 0;
let mut size = std::mem::size_of::<libc::c_int>();
let name = c"hw.perflevel0.physicalcpu";
let ok = unsafe {
libc::sysctlbyname(
name.as_ptr(),
&mut count as *mut _ as *mut libc::c_void,
&mut size,
std::ptr::null_mut(),
0,
)
};
if ok == 0 && count > 0 {
count as usize
} else {
std::thread::available_parallelism()
.map(|p| p.get())
.unwrap_or(4)
}
}
/// Pool for grep content search: P-core sized and QoS-pinned on macOS, full
/// parallelism elsewhere. Avoids E-core drag and VFS-lock contention.
pub static SEARCH_THREAD_POOL: LazyLock<rayon::ThreadPool> = LazyLock::new(|| {
#[cfg(target_os = "macos")]
let threads = performance_core_count();
#[cfg(not(target_os = "macos"))]
let threads = std::thread::available_parallelism()
.map(|p| p.get())
.unwrap_or(4);
rayon::ThreadPoolBuilder::new()
.num_threads(threads)
.thread_name(|i| format!("fff-search-{i}"))
.start_handler(|_| {
#[cfg(target_os = "macos")]
unsafe {
let _ = libc::pthread_set_qos_class_self_np(
libc::qos_class_t::QOS_CLASS_USER_INITIATED,
0,
);
}
})
.build()
.expect("failed to create search rayon pool")
});
+211
View File
@@ -0,0 +1,211 @@
use std::path::{Path, PathBuf};
#[cfg(windows)]
pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
dunce::canonicalize(path)
}
#[cfg(not(windows))]
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 {
let as_str = path.to_string_lossy();
let with_backslashes: String = as_str.replace('/', "\\");
let buf = PathBuf::from(with_backslashes);
dunce::canonicalize(&buf).unwrap_or(buf)
}
#[cfg(not(windows))]
pub fn normalize(path: PathBuf) -> PathBuf {
path
}
#[cfg(windows)]
pub fn expand_tilde(path: &str) -> PathBuf {
return PathBuf::from(path);
}
#[cfg(not(windows))]
pub fn expand_tilde(path: &str) -> PathBuf {
if let Some(stripped) = path.strip_prefix("~/")
&& let Some(home_dir) = dirs::home_dir()
{
return home_dir.join(stripped);
}
PathBuf::from(path)
}
/// Calculate distance penalty based on directory proximity.
/// Returns a negative penalty score based on how far the candidate is from the current file.
///
/// `candidate_dir` is the directory portion of the candidate path (e.g. `"src/components/"`).
/// It may have a trailing `/` which is stripped internally.
///
/// Zero-allocation: walks both directory part iterators in lockstep.
pub fn calculate_distance_penalty(current_file: Option<&str>, candidate_dir: &str) -> i32 {
let Some(current_path) = current_file else {
return 0;
};
let current_dir = Path::new(current_path).parent().unwrap_or(Path::new(""));
let candidate = Path::new(candidate_dir);
if current_dir == candidate {
return 0;
}
let mut current_parts = current_dir.components();
let mut candidate_parts = candidate.components();
let mut common_len = 0usize;
let mut current_total = 0usize;
loop {
match (current_parts.next(), candidate_parts.next()) {
(Some(a), Some(b)) => {
current_total += 1;
if a == b {
common_len += 1;
} else {
current_total += current_parts.count();
break;
}
}
(Some(_), None) => {
current_total += 1 + current_parts.count();
break;
}
(None, _) => {
break;
}
}
}
let depth_from_common = current_total - common_len;
if depth_from_common == 0 {
return 0;
}
(-(depth_from_common as i32)).max(-20)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(not(target_family = "windows"))]
fn test_calculate_distance_penalty() {
// candidate_dir is now just the directory portion (with or without trailing /)
assert_eq!(calculate_distance_penalty(None, "examples/user/test/"), 0);
// Same directory
assert_eq!(
calculate_distance_penalty(Some("examples/user/test/main.rs"), "examples/user/test/"),
0
);
//
// One level apart
assert_eq!(
calculate_distance_penalty(
Some("examples/user/test/subdir/file.rs"),
"examples/user/test/"
),
-1
);
//
// Different subdirectories (same parent)
assert_eq!(
calculate_distance_penalty(
Some("examples/user/test/dir1/file.rs"),
"examples/user/test/dir2/"
),
-1
);
assert_eq!(
calculate_distance_penalty(
Some("examples/audio-announce/src/lib/audio-announce.rs"),
"examples/audio-announce/src/"
),
-1
);
assert_eq!(
calculate_distance_penalty(
Some("examples/audio-announce/src/audio-announce.rs"),
"examples/pixel/src/"
),
-2
);
// Root level files (empty dir)
assert_eq!(calculate_distance_penalty(Some("main.rs"), ""), 0);
}
#[test]
#[cfg(target_family = "windows")]
fn distance_penalty_works_on_windows() {
assert_eq!(
calculate_distance_penalty(None, "examples\\user\\test\\"),
0
);
// Same directory
assert_eq!(
calculate_distance_penalty(
Some("examples\\user\\test\\main.rs"),
"examples\\user\\test\\"
),
0
);
//
// One level apart
assert_eq!(
calculate_distance_penalty(
Some("examples\\user\\test\\subdir\\file.rs"),
"examples\\user\\test\\"
),
-1
);
}
}
+404
View File
@@ -0,0 +1,404 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use tracing::{error, info};
use crate::FileSync;
use crate::background_watcher::BackgroundWatcher;
use crate::bigram_filter::{build_bigram_index, sniff_binary_for_non_indexable};
use crate::error::Error;
use crate::file_picker::FFFMode;
use crate::parallelism::BACKGROUND_THREAD_POOL;
use crate::shared::{SharedFilePicker, SharedFrecency};
use crate::types::ContentCacheBudget;
#[derive(Clone, Default)]
pub(crate) struct ScanSignals {
/// Set to `true` while any scan phase is running
pub(crate) scanning: Arc<AtomicBool>,
/// Set to `true` once the filesystem watcher has been installed
pub(crate) watcher_ready: Arc<AtomicBool>,
/// Indicates that that owning picker was requested to shut down
pub(crate) cancelled: Arc<AtomicBool>,
/// Used to resolve conflicts if multiple rescans were triggered in a queue
pub(crate) rescan_pending: Arc<AtomicBool>,
/// Set by `post_scan_snapshot`, cleared by `PostScanSnapshot::drop`.
/// DO NOT set or clear this manually — it is managed exclusively by the
/// PostScanSnapshot lifecycle.
pub(crate) post_scan_indexing_active: Arc<AtomicBool>,
}
/// Which optional phases a scan should run.
#[derive(Clone, Copy, Default, Debug)]
pub(crate) struct ScanConfig {
pub(crate) warmup: bool,
pub(crate) content_indexing: bool,
pub(crate) watch: bool,
pub(crate) auto_cache_budget: bool,
pub(crate) install_watcher: bool,
pub(crate) follow_symlinks: bool,
pub(crate) enable_fs_root_scanning: bool,
pub(crate) enable_home_dir_scanning: bool,
}
/// A fully-configured scan job ready to run on a background thread.
///
/// Build with [`ScanJob::from_picker`] (reads all state from the
/// current `FilePicker`) or [`ScanJob::initial`] (for the bootstrap
/// scan, before the picker is published to `SharedPicker`).
pub(crate) struct ScanJob {
shared_picker: SharedFilePicker,
shared_frecency: SharedFrecency,
base_path: PathBuf,
mode: FFFMode,
signals: ScanSignals,
config: ScanConfig,
/// Walker-maintained counter backing `get_scan_progress` on the UI
/// side. Reset to 0 at scan start, incremented per-file by the
/// walker. Shared `Arc` so the UI polls the same atomic.
scanned_files_counter: Arc<AtomicUsize>,
trace_span: tracing::Span,
}
impl ScanJob {
pub fn new_rescan(
shared_picker: &SharedFilePicker,
shared_frecency: &SharedFrecency,
) -> Result<Option<Self>, Error> {
let guard = shared_picker.read()?;
let picker = guard.as_ref().ok_or(Error::FilePickerMissing)?;
if picker.is_scan_active()
|| picker
.signals
.post_scan_indexing_active
.load(Ordering::Acquire)
{
return Ok(None);
}
let mode = picker.mode();
let signals = picker.scan_signals();
let scanned_files_counter = picker.scanned_files_counter();
let base_path = picker.base_path().to_path_buf();
let trace_span = picker.trace_span();
let new_scan_config = ScanConfig {
warmup: picker.has_mmap_cache(),
content_indexing: picker.has_content_indexing(),
watch: picker.has_watcher(),
auto_cache_budget: !picker.has_explicit_cache_budget(),
install_watcher: false, // the watcher is independent of rescan, it is not restarting EVER
follow_symlinks: picker.follows_symlinks(),
enable_fs_root_scanning: picker.fs_root_scanning_enabled(),
enable_home_dir_scanning: picker.home_dir_scanning_enabled(),
};
drop(guard); // just a sanity check
Ok(Some(Self {
mode,
signals,
base_path,
scanned_files_counter,
config: new_scan_config,
shared_picker: shared_picker.clone(),
shared_frecency: shared_frecency.clone(),
trace_span,
}))
}
#[allow(clippy::too_many_arguments)]
pub fn new_initial(
shared_picker: SharedFilePicker,
shared_frecency: SharedFrecency,
base_path: PathBuf,
mode: FFFMode,
signals: ScanSignals,
scanned_files_counter: Arc<AtomicUsize>,
trace_span: tracing::Span,
config: ScanConfig,
) -> Self {
Self {
shared_picker,
shared_frecency,
base_path,
mode,
signals,
scanned_files_counter,
config,
trace_span,
}
}
/// 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();
BACKGROUND_THREAD_POOL.spawn(move || {
let _g = span.enter();
self.run();
});
}
fn run(self) {
let Self {
shared_picker,
shared_frecency,
base_path,
mode,
signals,
scanned_files_counter,
config,
trace_span: _,
} = self;
let _scanning = ScanningGuard::new(&signals, config.install_watcher);
scanned_files_counter.store(0, Ordering::Relaxed);
// 1. Walk the file system and collect the list of files
let git_workdir = FileSync::discover_git_workdir(&base_path);
let sync = match FileSync::walk_filesystem(
&base_path,
git_workdir.clone(),
&scanned_files_counter,
&shared_frecency,
mode,
config.follow_symlinks,
) {
Ok(sync) => sync,
Err(e) => {
error!(?e, "scan walk failed");
return;
}
};
// 2. Populate the file list
let git_status_worker;
if let Ok(mut guard) = shared_picker.write()
&& let Some(picker) = guard.as_mut()
{
if signals.cancelled.load(Ordering::Acquire) {
info!("scan cancelled between walk and commit, discarding");
return;
}
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));
}
} else {
error!("failed to install scan results into picker");
return;
}
// 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
}
// 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()
.and_then(|picker| unsafe { picker.post_scan_snapshot() })
})
} else {
None
};
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);
}
drop(snapshot); // SNAPSHOT SHOULD NOT BE USED AFTER THIS POINT
// 5. Install filesystem watcher (initial scan only).
if config.install_watcher && config.watch && !signals.cancelled.load(Ordering::Acquire) {
let shared_picker: &SharedFilePicker = &shared_picker;
let shared_frecency: &SharedFrecency = &shared_frecency;
let base_path: &std::path::Path = &base_path;
match BackgroundWatcher::new(
base_path.to_path_buf(),
git_workdir,
shared_picker.clone(),
shared_frecency.clone(),
mode,
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.background_watcher = Some(watcher);
}
}
Err(e) => error!(?e, "failed to initialize background watcher"),
};
}
// 6. Drain any rescan that arrived while we were busy.
// if user initiated a new rescan we had no way to cancel current post scan, so do it again
if !signals.cancelled.load(Ordering::Acquire)
&& signals.rescan_pending.swap(false, Ordering::AcqRel)
{
match Self::new_rescan(&shared_picker, &shared_frecency) {
Ok(Some(follow_up)) => {
info!("Rescheduling deferred rescan after current scan finished");
follow_up.spawn();
}
Ok(None) => {
// this should be practically impossible because we do not have any
// queue, but if somehow a new rescan was triggered JUST IN THIS MOMENT
// just ignore it because the ongoing one is fresh enough
tracing::warn!("Post scan was re-triggered, ignoring");
}
Err(e) => {
error!(?e, "Failed to reschedule deferred rescan");
}
}
}
}
/// THIS IS VERY VERY IMPORTANT THAT ANYTHING INSIDE THIS FUNCTION TO NOT READ ANYTHING CLEARABLE OUTSIDE
/// this is a very silly off lock implementation that actually matters, and that's why it is crafted
/// to never read anything from the picker, it can only WRITE information using single instructions
///
/// Things that are safe and immutable - file list, indexes of files, paths, and signals.
#[tracing::instrument(skip_all, fields(warmup = ?config.warmup, indexing = ?config.content_indexing))]
fn run_post_scan(
shared_picker: &SharedFilePicker,
signals: &ScanSignals,
config: &ScanConfig,
unsafe_snapshot: &crate::file_picker::PostScanUnsafeSnapshot,
) {
let Some(arena) = unsafe_snapshot
.arena // we are never touching overlays so this arena is always correct
.as_ref()
.map(|s| s.as_arena_ptr())
else {
tracing::error!("Failed to run post scan: arena is invalid");
return;
};
let files: &[crate::types::FileItem] = &unsafe_snapshot.files[..unsafe_snapshot.base_count];
if signals.cancelled.load(Ordering::Acquire) {
return;
}
if config.content_indexing {
let indexable_count = unsafe_snapshot.indexable_count.min(files.len());
let (indexable_files, non_indexable_files) = files.split_at(indexable_count);
let index = build_bigram_index(indexable_files, &unsafe_snapshot.base_path, arena);
if let Ok(mut guard) = shared_picker.write()
&& let Some(picker) = guard.as_mut()
{
picker.set_bigram_index(index);
}
// Bigram only sniffs files <= MAX_INDEXABLE_FILE_SIZE; large
// unknown-extension binaries slip past it and would otherwise be
// grep-able as text. Cheap header sniff catches those.
if !signals.cancelled.load(Ordering::Acquire) {
sniff_binary_for_non_indexable(
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,
&signals.cancelled,
);
}
// TODO Skipped as potentially unsafe - figure this out later
// if config.warmup && !signals.cancelled.load(Ordering::Acquire) {
// warmup_mmaps(files, budget, &unsafe_snapshot.base_path, arena);
// }
}
}
/// RAII helper that flips the `scanning` signal on construction and
/// resets it on drop (so early-returns can't leave it stuck on `true`).
/// Also drives the `watcher_ready` signal on the initial-scan path.
struct ScanningGuard<'a> {
signals: &'a ScanSignals,
release_watcher_ready_on_drop: bool,
}
impl<'a> ScanningGuard<'a> {
fn new(signals: &'a ScanSignals, release_watcher_ready_on_drop: bool) -> Self {
signals.scanning.store(true, Ordering::Relaxed);
Self {
signals,
release_watcher_ready_on_drop,
}
}
}
impl Drop for ScanningGuard<'_> {
fn drop(&mut self) {
self.signals.scanning.store(false, Ordering::Relaxed);
if self.release_watcher_ready_on_drop {
self.signals.watcher_ready.store(true, Ordering::Release);
}
}
}
/// If the scan encounters new directories created we have to add them to the watch list
/// this is fine because the watcher does deduplicate the entries and doesn't add a lot of
/// garbage notify watchers / fs events streams
#[tracing::instrument(skip_all)]
fn rescubscribe_watcher_post_scan(shared_picker: &SharedFilePicker) {
let Ok(guard) = shared_picker.read() else {
return;
};
let Some(picker) = guard.as_ref() else {
return;
};
let Some(watcher) = picker.background_watcher.as_ref() else {
return;
};
picker.for_each_dir(|dir: &std::path::Path| {
watcher.request_watch_dir(dir.to_path_buf());
std::ops::ControlFlow::Continue(())
});
}
File diff suppressed because it is too large Load Diff
+391
View File
@@ -0,0 +1,391 @@
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard, Weak};
use std::time::{Duration, Instant};
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::scan::ScanJob;
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`]
/// to avoid reading a half-updated index when the watcher fires mid-`git add`.
///
/// The wait is bounded and cheap: the lock file is typically cleared within
/// a few milliseconds of the git command exiting.
fn wait_for_git_index_lock_release(git_root: &Path) {
const GIT_LOCK_POLL: Duration = Duration::from_millis(10);
const GIT_LOCK_MAX_WAIT: Duration = Duration::from_millis(500);
let lock = git_root.join(".git").join("index.lock");
// Fast path: no lock present.
if !lock.exists() {
return;
}
let deadline = Instant::now() + GIT_LOCK_MAX_WAIT;
while lock.exists() && Instant::now() < deadline {
std::thread::sleep(GIT_LOCK_POLL);
}
if lock.exists() {
tracing::warn!(
"Proceeding with git status refresh despite lingering \
.git/index.lock at {} — will retry once it clears",
lock.display()
);
}
}
/// 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.
///
/// For blocking access use internal picker via `.read()` or `.write()`
///
/// ```ignore
/// let shared_picker = SharedFilePicker::default();
///
/// if let Some(picker) = shared_picker.read()?.as_ref() {
/// let files = picker.fuzzy_search(&query, options);
/// println!("Found {} files", files.len());
/// } else {
/// println!("Picker not initialized");
/// }
/// ```
#[derive(Clone, Default)]
pub struct SharedFilePicker(pub(crate) Arc<SharedPickerInner>);
pub struct SharedPickerInner {
picker: parking_lot::RwLock<Option<FilePicker>>,
}
impl Default for SharedPickerInner {
fn default() -> Self {
Self {
picker: parking_lot::RwLock::new(None),
}
}
}
/// Non-owning handle to a [`SharedPicker`].
#[derive(Clone)]
pub(crate) struct WeakFilePicker(Weak<SharedPickerInner>);
impl WeakFilePicker {
/// Try to promote the weak handle back to a strong [`SharedPicker`].
///
/// Returns `None` once every strong `SharedPicker` clone has been
/// dropped. Callers should treat that as "the picker is being
/// torn down" and exit their current iteration cleanly.
pub(crate) fn upgrade(&self) -> Option<SharedFilePicker> {
self.0.upgrade().map(SharedFilePicker)
}
}
impl std::fmt::Debug for SharedFilePicker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("SharedPicker").field(&"..").finish()
}
}
impl SharedFilePicker {
pub fn read(&self) -> Result<parking_lot::RwLockReadGuard<'_, Option<FilePicker>>, Error> {
Ok(self.0.picker.read())
}
pub fn write(&self) -> Result<parking_lot::RwLockWriteGuard<'_, Option<FilePicker>>, Error> {
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 {
WeakFilePicker(Arc::downgrade(&self.0))
}
/// Return `true` if this is an instance of the picker that requires a complicated post-scan
/// indexing/cache warmup job. The indexing is not crazy but it takes time.
pub fn need_complex_rebuild(&self) -> bool {
let guard = self.0.picker.read();
guard
.as_ref()
.is_some_and(|p| p.has_mmap_cache() || p.has_content_indexing())
}
/// Block until the background filesystem scan finishes.
/// Returns `true` if scan completed, `false` on timeout.
pub fn wait_for_scan(&self, timeout: Duration) -> bool {
let signal = {
let guard = self.0.picker.read();
match &*guard {
Some(picker) => Arc::clone(&picker.signals.scanning),
None => return true,
}
};
poll_until(timeout, || {
!signal.load(std::sync::atomic::Ordering::Acquire)
})
}
/// Block until the background file watcher is ready.
/// Returns `true` if watcher ready, `false` on timeout.
pub fn wait_for_watcher(&self, timeout: Duration) -> bool {
let watch_ready_signal = {
let guard = self.0.picker.read();
match &*guard {
Some(picker) => Arc::clone(&picker.signals.watcher_ready),
None => return 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.
/// Returns true once scanning=false AND post_scan_indexing_active=false.
pub fn wait_for_indexing_complete(&self, timeout: Duration) -> bool {
let (scanning, post_scan_active) = {
let guard = self.0.picker.read();
match &*guard {
Some(picker) => (
Arc::clone(&picker.signals.scanning),
Arc::clone(&picker.signals.post_scan_indexing_active),
),
None => return true,
}
};
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> {
match ScanJob::new_rescan(self, shared_frecency)? {
Some(job) => {
job.spawn();
}
None => {
// we can not abort the ongoing sync, but if the events
if let Ok(guard) = self.read()
&& let Some(picker) = guard.as_ref()
{
picker
.scan_signals()
.rescan_pending
.store(true, std::sync::atomic::Ordering::Release);
tracing::info!(
"Full rescan requested while another scan is active — \
deferred via rescan_pending flag"
);
}
}
}
Ok(())
}
/// Refresh git statuses for all indexed files
#[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 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())
};
debug!(?git_root, "Refreshing git status for picker");
if let Some(ref root) = git_root {
wait_for_git_index_lock_release(root);
}
GitStatusCache::read_git_status(
git_root.as_deref(),
&mut crate::git::default_status_options(),
)
};
let mut guard = self.write()?;
let picker = guard.as_mut().ok_or(Error::FilePickerMissing)?;
let statuses_count = if let Some(git_status) = git_status {
let count = git_status.statuses_len();
picker.update_git_statuses(git_status, shared_frecency)?;
count
} else {
0
};
Ok(statuses_count)
}
/// 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 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,
}
// 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)),
enabled: true,
}
}
}
impl<T: LmdbStore> std::fmt::Debug for SharedDb<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("SharedDb").field(&T::LABEL).finish()
}
}
#[allow(private_bounds)]
impl<T: LmdbStore> SharedDb<T> {
/// 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<T>>, Error> {
self.inner.read().map_err(|_| Error::AcquireFrecencyLock)
}
pub fn write(&self) -> Result<RwLockWriteGuard<'_, Option<T>>, Error> {
self.inner.write().map_err(|_| Error::AcquireFrecencyLock)
}
/// 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(());
}
{
let mut guard = self.write()?;
*guard = Some(tracker);
}
// 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.
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.env().path().to_path_buf();
// Drop closes the LMDB env and unmaps the files
drop(tracker);
drop(guard);
std::fs::remove_dir_all(&db_path).map_err(|source| Error::RemoveDbDir {
path: db_path.clone(),
source,
})?;
Ok(Some(db_path))
}
}
/// Thread-safe shared handle to the [`FrecencyTracker`] instance.
pub type SharedFrecency = SharedDb<FrecencyTracker>;
/// Thread-safe shared handle to the [`QueryTracker`] instance.
pub type SharedQueryTracker = SharedDb<QueryTracker>;
+612
View File
@@ -0,0 +1,612 @@
use ahash::AHashMap;
use smallvec::SmallVec;
use std::borrow::Cow;
/// SIMD chunk size in bytes (matches NEON/SSE2 register width).
/// This must stay in sync with neo_frizbee's internal chunk size.
pub(crate) const SIMD_CHUNK_BYTES: usize = 16;
/// 4 chunks = 64 bytes inline, covers ~85% of paths without heap fallback.
const INLINE_CHUNKS: usize = 4;
pub(crate) type ChunkIndices = SmallVec<[u32; INLINE_CHUNKS]>;
#[derive(Clone, Copy)]
pub struct ArenaPtr(pub(crate) *const u8);
// SAFETY: The arena is a read-only immutable part of file sync
unsafe impl Send for ArenaPtr {}
unsafe impl Sync for ArenaPtr {}
impl ArenaPtr {
#[inline]
pub fn new(ptr: *const u8) -> Self {
Self(ptr)
}
#[inline]
pub fn null() -> Self {
Self(std::ptr::null())
}
#[inline]
pub fn as_ptr(self) -> *const u8 {
self.0
}
}
impl std::fmt::Debug for ArenaPtr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "--arena-raw-pointer-0x({:?})", self.0)
}
}
#[repr(C, align(16))]
#[derive(Clone, Copy)]
pub(crate) struct SimdChunk(pub(crate) [u8; SIMD_CHUNK_BYTES]);
impl Default for SimdChunk {
#[inline]
fn default() -> Self {
Self([0u8; SIMD_CHUNK_BYTES])
}
}
impl std::fmt::Debug for SimdChunk {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Show the actual bytes, trimming trailing zeros for readability
let end = self.0.iter().rposition(|&b| b != 0).map_or(0, |i| i + 1);
write!(f, "SimdChunk({:?})", &self.0[..end])
}
}
pub 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
/// `ChunkedPathStore`. The struct itself contains no raw pointers to the arena
#[derive(Clone)]
pub(crate) struct ChunkedString {
indices: ChunkIndices,
pub byte_len: u16,
/// Byte offset where the filename begins. 0 for root-level files.
pub filename_offset: u16,
}
impl ChunkedString {
pub fn empty() -> Self {
Self {
indices: SmallVec::new(),
byte_len: 0,
filename_offset: 0,
}
}
#[inline]
pub fn new(indices: ChunkIndices, byte_len: u16, filename_offset: u16) -> Self {
Self {
indices,
byte_len,
filename_offset,
}
}
#[cfg(test)]
pub fn chunk_count(&self) -> usize {
self.indices.len()
}
#[inline]
pub fn resolve_ptrs<'a>(&self, arena: ArenaPtr, buf: &'a mut [*const u8]) -> &'a [*const u8] {
let count = self.indices.len().min(buf.len());
let base = arena.as_ptr();
for (i, &idx) in self.indices[..count].iter().enumerate() {
buf[i] = unsafe { base.add(idx as usize * SIMD_CHUNK_BYTES) };
}
&buf[..count]
}
#[inline]
fn write_slice_to_vec(
indices: &[u32],
base: *const u8,
offset_in_chunk: usize,
len: usize,
vec: &mut Vec<u8>,
) {
let mut written = 0usize;
for (i, &idx) in indices.iter().enumerate() {
let src = unsafe { base.add(idx as usize * SIMD_CHUNK_BYTES) };
let chunk_bytes = unsafe { core::slice::from_raw_parts(src, SIMD_CHUNK_BYTES) };
let start = if i == 0 { offset_in_chunk } else { 0 };
let end = SIMD_CHUNK_BYTES.min(start + (len - written));
vec.extend_from_slice(&chunk_bytes[start..end]);
written += end - start;
}
}
/// Return the filename portion as a `Cow<str>`.
///
/// When the filename starts at a chunk boundary and fits in one chunk we
/// borrow directly from the arena (zero-copy). Otherwise we allocate.
/// Filenames are almost always <=16 bytes so the fast path dominates.
#[inline]
pub fn filename_cow<'a>(&self, arena: ArenaPtr) -> Cow<'a, str> {
let fname_offset = self.filename_offset as usize;
let fname_len = self.byte_len as usize - fname_offset;
if fname_len == 0 {
return Cow::Borrowed("");
}
let base = arena.as_ptr();
let start_chunk = fname_offset / SIMD_CHUNK_BYTES;
let offset_in_chunk = fname_offset % SIMD_CHUNK_BYTES;
if offset_in_chunk == 0 && fname_len <= SIMD_CHUNK_BYTES {
let ptr = unsafe { base.add(self.indices[start_chunk] as usize * SIMD_CHUNK_BYTES) };
let slice = unsafe { core::slice::from_raw_parts(ptr, fname_len) };
return Cow::Borrowed(unsafe { core::str::from_utf8_unchecked(slice) });
}
let mut out = String::with_capacity(fname_len);
let needed_chunks = chunks_needed(offset_in_chunk + fname_len);
Self::write_slice_to_vec(
&self.indices[start_chunk..start_chunk + needed_chunks],
base,
offset_in_chunk,
fname_len,
unsafe { out.as_mut_vec() },
);
Cow::Owned(out)
}
/// Truncates at `buf.len()` if exceeded -- use `[u8; PATH_BUF_SIZE]` to avoid.
#[inline]
pub fn read_to_buf<'a>(&self, arena: ArenaPtr, buf: &'a mut [u8]) -> &'a str {
let total = (self.byte_len as usize).min(buf.len());
let usable_chunks = total.div_ceil(SIMD_CHUNK_BYTES);
let chunks_to_copy = usable_chunks.min(self.indices.len());
let base = arena.as_ptr();
for (i, &idx) in self.indices[..chunks_to_copy].iter().enumerate() {
let src = unsafe { base.add(idx as usize * SIMD_CHUNK_BYTES) };
let dst_offset = i * SIMD_CHUNK_BYTES;
let take = SIMD_CHUNK_BYTES.min(total - dst_offset);
unsafe {
core::ptr::copy_nonoverlapping(src, buf.as_mut_ptr().add(dst_offset), take);
}
}
unsafe { core::str::from_utf8_unchecked(&buf[..total]) }
}
#[inline]
pub fn write_dir_to(&self, arena: ArenaPtr, out: &mut String) {
out.clear();
let dir_len = self.filename_offset as usize;
out.reserve(dir_len);
let dir_chunks = chunks_needed(dir_len).min(self.indices.len());
let base = arena.as_ptr();
let vec = unsafe { out.as_mut_vec() };
for (i, &idx) in self.indices[..dir_chunks].iter().enumerate() {
let src = unsafe { base.add(idx as usize * SIMD_CHUNK_BYTES) };
let take = SIMD_CHUNK_BYTES.min(dir_len - i * SIMD_CHUNK_BYTES);
vec.extend_from_slice(unsafe { core::slice::from_raw_parts(src, take) });
}
}
#[inline]
pub fn write_filename_to(&self, arena: ArenaPtr, out: &mut String) {
out.clear();
let fname_offset = self.filename_offset as usize;
let fname_len = self.byte_len as usize - fname_offset;
out.reserve(fname_len);
let start_chunk = fname_offset / SIMD_CHUNK_BYTES;
let offset_in_chunk = fname_offset % SIMD_CHUNK_BYTES;
let needed_chunks = chunks_needed(offset_in_chunk + fname_len);
Self::write_slice_to_vec(
&self.indices[start_chunk..start_chunk + needed_chunks],
arena.as_ptr(),
offset_in_chunk,
fname_len,
unsafe { out.as_mut_vec() },
);
}
#[inline]
pub fn write_to_string(&self, arena: ArenaPtr, out: &mut String) {
out.clear();
let total = self.byte_len as usize;
if total == 0 {
return;
}
out.reserve(total);
let base = arena.as_ptr();
let vec = unsafe { out.as_mut_vec() };
for (i, &idx) in self.indices.iter().enumerate() {
let src = unsafe { base.add(idx as usize * SIMD_CHUNK_BYTES) };
let take = SIMD_CHUNK_BYTES.min(total - i * SIMD_CHUNK_BYTES);
vec.extend_from_slice(unsafe { core::slice::from_raw_parts(src, take) });
}
}
}
impl std::fmt::Debug for ChunkedString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ChunkedString")
.field("indices", &self.indices.as_slice())
.field("chunks", &self.indices.len())
.field("byte_len", &self.byte_len)
.field("filename_offset", &self.filename_offset)
.finish()
}
}
#[inline]
const fn chunks_needed(byte_len: usize) -> usize {
if byte_len == 0 {
0
} else {
byte_len.div_ceil(SIMD_CHUNK_BYTES)
}
}
#[derive(Clone, Debug)]
pub(crate) struct ChunkedPathStore {
arena: Vec<SimdChunk>,
}
// SAFETY: arena is immutable after construction. Pointers derived from it are
// only read during scoring (no mutation, no reallocation).
unsafe impl Send for ChunkedPathStore {}
unsafe impl Sync for ChunkedPathStore {}
impl ChunkedPathStore {
pub fn heap_bytes(&self) -> usize {
self.arena.len() * SIMD_CHUNK_BYTES
}
#[cfg(test)]
fn unique_chunks(&self) -> usize {
self.arena.len()
}
#[inline]
pub fn as_arena_ptr(&self) -> ArenaPtr {
ArenaPtr::new(self.arena.as_ptr() as *const u8)
}
}
/// At runtime the builder should be split out from the store after `finish()`.
#[derive(Clone, Debug)]
pub(crate) struct ChunkedPathStoreBuilder {
arena: Vec<SimdChunk>,
chunk_dedup: AHashMap<[u8; SIMD_CHUNK_BYTES], u32>,
}
impl ChunkedPathStoreBuilder {
pub fn new(estimated_files: usize) -> Self {
let est_chunks = estimated_files * INLINE_CHUNKS; // we know that most of repos will fit
// most paths into 64 = 16 * INLINE_CHUNKS
Self {
arena: Vec::with_capacity(est_chunks),
chunk_dedup: AHashMap::with_capacity(est_chunks),
}
}
pub fn finish(self) -> ChunkedPathStore {
ChunkedPathStore { arena: self.arena }
}
pub fn as_arena_ptr(&self) -> ArenaPtr {
ArenaPtr::new(self.arena.as_ptr() as *const u8)
}
/// Like [`add_file_immediate`] but for directory paths where the entire
/// string is the "directory" portion (filename_offset == byte_len).
pub fn add_dir_immediate(&mut self, dir_rel_path: &str) -> ChunkedString {
self.add_file_immediate(dir_rel_path, dir_rel_path.len() as u16)
}
pub fn add_file_immediate(&mut self, rel_path: &str, filename_offset: u16) -> ChunkedString {
let path_bytes = rel_path.as_bytes();
let byte_len = rel_path.len();
let mut indices = ChunkIndices::with_capacity(chunks_needed(byte_len));
for chunk in path_bytes.chunks(SIMD_CHUNK_BYTES) {
let mut chunk_bytes = [0u8; SIMD_CHUNK_BYTES];
chunk_bytes[..chunk.len()].copy_from_slice(chunk);
let arena_idx = match self.chunk_dedup.get(&chunk_bytes) {
Some(&idx) => idx,
None => {
let idx = self.arena.len() as u32;
self.arena.push(SimdChunk(chunk_bytes));
self.chunk_dedup.insert(chunk_bytes, idx);
idx
}
};
indices.push(arena_idx);
}
ChunkedString::new(indices, byte_len as u16, filename_offset)
}
}
#[cfg(test)]
pub(crate) fn build_chunked_path_store_from_strings(
rel_paths: &[String],
files: &[crate::types::FileItem],
) -> (ChunkedPathStore, Vec<ChunkedString>) {
assert_eq!(rel_paths.len(), files.len());
let mut builder = ChunkedPathStoreBuilder::new(rel_paths.len());
let strings: Vec<ChunkedString> = rel_paths
.iter()
.zip(files.iter())
.map(|(rel_path, file)| builder.add_file_immediate(rel_path, file.path.filename_offset))
.collect();
(builder.finish(), strings)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_file_item(path: &str) -> crate::types::FileItem {
let filename_start = path
.rfind(std::path::is_separator)
.map(|i| i + 1)
.unwrap_or(0) as u16;
crate::types::FileItem::new_raw(filename_start, 0, 0, None, false)
}
fn build_test_store(
paths: &[&str],
) -> (
ChunkedPathStore,
Vec<ChunkedString>,
Vec<crate::types::FileItem>,
) {
let mut files: Vec<crate::types::FileItem> =
paths.iter().map(|p| make_file_item(p)).collect();
let path_strings: Vec<String> = paths.iter().map(|p| p.to_string()).collect();
let (store, strings) = build_chunked_path_store_from_strings(&path_strings, &files);
for (i, file) in files.iter_mut().enumerate() {
file.set_path(strings[i].clone());
}
(store, strings, files)
}
#[test]
fn test_chunked_store_empty() {
let (store, strings, _files) = build_test_store(&[]);
assert_eq!(strings.len(), 0);
assert_eq!(store.unique_chunks(), 0);
}
#[test]
fn test_chunked_store_basic() {
let (store, strings, _files) =
build_test_store(&["src/lib.rs", "src/main.rs", "Cargo.toml"]);
let arena = store.as_arena_ptr();
assert_eq!(strings.len(), 3);
assert!(store.unique_chunks() >= 2);
let mut buf = [0u8; 512];
assert_eq!(
strings[0].read_to_buf(arena, &mut buf).len(),
"src/lib.rs".len()
);
assert_eq!(
strings[2].read_to_buf(arena, &mut buf).len(),
"Cargo.toml".len()
);
}
#[test]
fn test_chunked_string_full_path() {
let (store, strings, _files) = build_test_store(&["src/components/Button.tsx"]);
let arena = store.as_arena_ptr();
let cs = &strings[0];
let mut buf = [0u8; 512];
assert_eq!(cs.read_to_buf(arena, &mut buf), "src/components/Button.tsx");
assert_eq!(cs.byte_len, 25);
assert_eq!(cs.filename_offset, 15);
}
#[test]
fn test_chunked_string_dir_and_filename() {
let (store, strings, _files) = build_test_store(&["src/components/Button.tsx"]);
let arena = store.as_arena_ptr();
let cs = &strings[0];
let mut s = String::new();
cs.write_dir_to(arena, &mut s);
assert_eq!(s, "src/components/");
cs.write_filename_to(arena, &mut s);
assert_eq!(s, "Button.tsx");
}
#[test]
fn test_chunked_string_root_file() {
let (store, strings, _files) = build_test_store(&["Cargo.toml"]);
let arena = store.as_arena_ptr();
let cs = &strings[0];
let mut s = String::new();
cs.write_dir_to(arena, &mut s);
assert_eq!(s, "");
cs.write_filename_to(arena, &mut s);
assert_eq!(s, "Cargo.toml");
let mut buf = [0u8; 512];
assert_eq!(cs.read_to_buf(arena, &mut buf), "Cargo.toml");
}
#[test]
fn test_chunked_string_resolve_ptrs() {
let (store, strings, _files) = build_test_store(&["src/components/Button.tsx"]);
let arena = store.as_arena_ptr();
let cs = &strings[0];
let mut ptrs = [std::ptr::null::<u8>(); MAX_PATH_CHUNKS];
let resolved = cs.resolve_ptrs(arena, &mut ptrs);
assert_eq!(resolved.len(), 2); // 25 bytes = 2 chunks
// Verify we can read back the bytes
let mut reconstructed = Vec::new();
for (i, &ptr) in resolved.iter().enumerate() {
let chunk = unsafe { std::slice::from_raw_parts(ptr, SIMD_CHUNK_BYTES) };
let start = i * SIMD_CHUNK_BYTES;
let take = SIMD_CHUNK_BYTES.min(25 - start);
reconstructed.extend_from_slice(&chunk[..take]);
}
assert_eq!(
std::str::from_utf8(&reconstructed).unwrap(),
"src/components/Button.tsx"
);
}
#[test]
fn test_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"]);
let arena = store.as_arena_ptr();
let cs = &strings[0];
assert_eq!(cs.filename_offset, 15);
assert_eq!(cs.byte_len, 25);
let fname = cs.filename_cow(arena);
assert_eq!(&*fname, "Button.tsx");
}
#[test]
fn test_filename_cow_chunk_aligned() {
let path = "0123456789abcdef/file.txt";
let (store, strings, _files) = build_test_store(&[path]);
let arena = store.as_arena_ptr();
let cs = &strings[0];
assert_eq!(cs.filename_offset, 17);
let fname = cs.filename_cow(arena);
assert_eq!(&*fname, "file.txt");
}
#[test]
fn test_filename_cow_root_file() {
let (store, strings, _files) = build_test_store(&["Cargo.toml"]);
let arena = store.as_arena_ptr();
let cs = &strings[0];
assert_eq!(cs.filename_offset, 0);
let fname = cs.filename_cow(arena);
assert_eq!(&*fname, "Cargo.toml");
}
#[test]
fn test_chunked_string_long_path() {
let path = "very/deeply/nested/directory/structure/with/many/levels/file.txt";
let (store, strings, _files) = build_test_store(&[path]);
let arena = store.as_arena_ptr();
let cs = &strings[0];
let mut buf = [0u8; 512];
assert_eq!(cs.read_to_buf(arena, &mut buf), path);
assert!(
cs.chunk_count() <= 6,
"should fit inline in ChunkIndices (INLINE_CHUNKS={})",
INLINE_CHUNKS
);
}
#[test]
fn test_chunked_string_clone() {
let (store, strings, _files) = build_test_store(&["src/main.rs"]);
let arena = store.as_arena_ptr();
let cs = &strings[0];
let cs2 = cs.clone();
let mut buf1 = [0u8; 512];
let mut buf2 = [0u8; 512];
assert_eq!(
cs.read_to_buf(arena, &mut buf1),
cs2.read_to_buf(arena, &mut buf2)
);
}
#[test]
fn test_chunked_string_full_path_roundtrip() {
let paths = [
"src/components/Button.tsx",
"src/components/ui/DatePicker.tsx",
"very/deeply/nested/directory/structure/file.txt",
"Cargo.toml",
"a.rs",
];
let (store, strings, _files) = build_test_store(&paths);
let arena = store.as_arena_ptr();
for (i, expected) in paths.iter().enumerate() {
let mut buf = [0u8; 512];
let got = strings[i].read_to_buf(arena, &mut buf);
assert_eq!(got, *expected, "full path roundtrip failed for file {i}");
let mut ds = String::new();
let mut fs = String::new();
strings[i].write_dir_to(arena, &mut ds);
strings[i].write_filename_to(arena, &mut fs);
assert_eq!(
format!("{ds}{fs}"),
*expected,
"dir+fname mismatch for file {i}"
);
}
}
}
@@ -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;
+131
View File
@@ -0,0 +1,131 @@
use parking_lot::Mutex;
use std::mem::MaybeUninit;
// this originally happen to be in TLS but there is a limit of TLS
// + the storage itself is not free, so now we rely on the fact that most calls
// are sequential in practice and allocate ONLY when we have a parallel access
static SORT_BUFFER: Mutex<Vec<u8>> = Mutex::new(Vec::new());
fn ensure_capacity(buf: &mut Vec<u8>, required: usize) {
if buf.capacity() < required {
let len = buf.len();
buf.reserve(required - len);
}
}
struct SharedSortBuf {
guard: parking_lot::MutexGuard<'static, Vec<u8>>,
}
impl SharedSortBuf {
fn as_slice_mut<T>(&mut self, len: usize) -> &mut [MaybeUninit<T>] {
let align = std::mem::align_of::<MaybeUninit<T>>();
let size = std::mem::size_of::<MaybeUninit<T>>();
let required = len.saturating_mul(size).saturating_add(align);
ensure_capacity(&mut self.guard, required);
// SAFETY: the Vec<u8> is only 1-byte aligned, so we over-allocate by
// `align` bytes and shift the pointer to satisfy T's alignment.
// Callers never read uninitialised data through the returned slice.
unsafe {
let ptr = self.guard.as_mut_ptr();
let offset = ptr.align_offset(align);
debug_assert!(offset != usize::MAX && offset + len * size <= self.guard.capacity());
std::slice::from_raw_parts_mut(ptr.add(offset) as *mut MaybeUninit<T>, len)
}
}
}
fn try_lock_shared_buf() -> Option<SharedSortBuf> {
SORT_BUFFER.try_lock().map(|guard| SharedSortBuf { guard })
}
pub fn sort_with_buffer<T, F>(slice: &mut [T], compare: F)
where
F: FnMut(&T, &T) -> std::cmp::Ordering,
{
match try_lock_shared_buf() {
Some(mut buf) => {
let typed = buf.as_slice_mut::<T>(slice.len());
glidesort::sort_with_buffer_by(slice, typed, compare);
}
None => glidesort::sort_by(slice, compare),
}
}
pub fn sort_by_key_with_buffer<T, K, F>(slice: &mut [T], key_fn: F)
where
K: Ord,
F: FnMut(&T) -> K,
{
match try_lock_shared_buf() {
Some(mut buf) => {
let typed = buf.as_slice_mut::<T>(slice.len());
glidesort::sort_with_buffer_by_key(slice, typed, key_fn);
}
None => glidesort::sort_by_key(slice, key_fn),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sort_with_buffer() {
let mut data = vec![5, 2, 8, 1, 9];
sort_with_buffer(&mut data, |a, b| a.cmp(b));
assert_eq!(data, vec![1, 2, 5, 8, 9]);
}
#[test]
fn test_sort_by_key_with_buffer() {
let mut data = vec![(1, 50), (2, 20), (3, 80), (4, 10), (5, 90)];
sort_by_key_with_buffer(&mut data, |a| a.1);
assert_eq!(data, vec![(4, 10), (2, 20), (1, 50), (3, 80), (5, 90)]);
}
#[test]
fn test_reverse_sort() {
let mut data = vec![1, 2, 3, 4, 5];
sort_with_buffer(&mut data, |a, b| b.cmp(a));
assert_eq!(data, vec![5, 4, 3, 2, 1]);
}
#[test]
fn test_empty_slice() {
let mut data: Vec<i32> = vec![];
sort_with_buffer(&mut data, |a, b| a.cmp(b));
assert_eq!(data, Vec::<i32>::new());
}
#[test]
fn test_single_element() {
let mut data = vec![42];
sort_with_buffer(&mut data, |a, b| a.cmp(b));
assert_eq!(data, vec![42]);
}
#[test]
fn test_with_duplicates() {
let mut data = vec![3, 1, 4, 1, 5, 9, 2, 6, 5];
sort_with_buffer(&mut data, |a, b| a.cmp(b));
assert_eq!(data, vec![1, 1, 2, 3, 4, 5, 5, 6, 9]);
}
#[test]
fn test_descending_order() {
let mut data = vec![3, 1, 4, 1, 5, 9, 2, 6, 5];
sort_with_buffer(&mut data, |a, b| b.cmp(a));
assert_eq!(data, vec![9, 6, 5, 5, 4, 3, 2, 1, 1]);
}
#[test]
fn test_simple_descending() {
let mut data = vec![100, 300, 200];
sort_with_buffer(&mut data, |a, b| b.cmp(a));
assert_eq!(data[0], 300);
assert_eq!(data[1], 200);
assert_eq!(data[2], 100);
}
}
+177
View File
@@ -0,0 +1,177 @@
use std::alloc::{self, Layout};
use std::ptr::NonNull;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
/// Vector that guarantees no re-alloc happening at runtime
pub(crate) struct StableVec<T> {
inner: Arc<StableBuf<T>>,
}
struct StableBuf<T> {
ptr: NonNull<T>,
cap: usize,
/// Atomic because:
/// 1. `push(&self)` must mutate this through a shared `&StableBuf`,
/// which requires interior mutability.
/// 2. Arc clones (e.g. post-scan snapshots) read `len` outside the
/// picker lock, concurrent with an appending writer. Acquire/Release
/// on len is what makes "observed len ⇒ element bytes initialized"
/// actually hold.
///
/// Arc wrapping only shares ownership of the buffer; it does NOT
/// synchronize access to fields inside the shared buffer.
len: AtomicUsize,
}
// SAFETY: StableBuf is a thread-safe container when T is send + sync
// There is another application level constraint: mutations are safe
// when they are atomic updates, not read + update.
unsafe impl<T: Send> Send for StableBuf<T> {}
unsafe impl<T: Sync> Sync for StableBuf<T> {}
impl<T> Drop for StableBuf<T> {
fn drop(&mut self) {
let len = *self.len.get_mut();
unsafe {
std::ptr::drop_in_place(std::ptr::slice_from_raw_parts_mut(self.ptr.as_ptr(), len));
if self.cap > 0 {
let layout = Layout::array::<T>(self.cap).expect("layout");
alloc::dealloc(self.ptr.as_ptr().cast(), layout);
}
}
}
}
impl<T> StableVec<T> {
pub fn from_vec_with_reserve(mut vec: Vec<T>, extra: usize) -> Self {
vec.reserve(extra);
let cap = vec.capacity();
let len = vec.len();
let inner = if cap == 0 {
StableBuf {
ptr: NonNull::dangling(),
cap: 0,
len: AtomicUsize::new(0),
}
} else {
// Take ownership of the Vec's buffer without running element
// drops; we hand them off to the StableBuf.
let mut vec = std::mem::ManuallyDrop::new(vec);
let ptr = NonNull::new(vec.as_mut_ptr()).expect("non-null");
StableBuf {
ptr,
cap,
len: AtomicUsize::new(len),
}
};
Self {
inner: Arc::new(inner),
}
}
/// Append. Returns `false` if capacity is exhausted (item dropped).
///
/// Safe to call via `&self` as long as the caller holds the outer
/// picker write lock (single-writer invariant).
#[inline]
pub fn push(&self, item: T) -> bool {
let cap = self.inner.cap;
let len = self.inner.len.load(Ordering::Acquire);
if len >= cap {
debug_assert!(
false,
"StableVec: push would exceed capacity ({len} at capacity {cap})"
);
tracing::error!(
len,
capacity = cap,
"StableVec: capacity exhausted — dropping item to prevent reallocation"
);
return false;
}
unsafe {
std::ptr::write(self.inner.ptr.as_ptr().add(len), item);
}
self.inner.len.store(len + 1, Ordering::Release);
true
}
// this method is specifically private because you probably need to use
// live_count if you are trying to access this method
#[inline]
pub fn len(&self) -> usize {
self.inner.len.load(Ordering::Acquire)
}
/// Mutable element access for in-place field updates. Never shifts.
///
/// LATENT UB: produces `&mut T` aliasing Arc-shared memory; the
/// `&mut self` on StableVec does NOT imply unique access to the
/// `StableBuf` when sibling Arc clones exist. Safe in practice
/// because callers hold the picker write lock and writes target
/// disjoint fields, but strictly forbidden by the aliasing model.
#[inline]
pub fn get_mut(&mut self, index: usize) -> Option<&mut T> {
let len = self.inner.len.load(Ordering::Acquire);
if index >= len {
return None;
}
unsafe { Some(&mut *self.inner.ptr.as_ptr().add(index)) }
}
#[inline]
pub fn last(&self) -> Option<&T> {
let len = self.len();
if len == 0 {
None
} else {
unsafe { Some(&*self.inner.ptr.as_ptr().add(len - 1)) }
}
}
/// Iterate mutably for in-place field updates. Never shifts storage.
/// Same latent-UB caveat as [`get_mut`]: `&mut T` into Arc-shared memory.
#[inline]
pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, T> {
let len = self.inner.len.load(Ordering::Acquire);
unsafe { std::slice::from_raw_parts_mut(self.inner.ptr.as_ptr(), len).iter_mut() }
}
}
impl<T> Clone for StableVec<T> {
#[inline]
fn clone(&self) -> Self {
Self {
inner: Arc::clone(&self.inner),
}
}
}
impl<T: std::fmt::Debug> std::fmt::Debug for StableVec<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("StableVec").field(&self.len()).finish()
}
}
impl<T> std::ops::Deref for StableVec<T> {
type Target = [T];
#[inline]
fn deref(&self) -> &[T] {
let len = self.len();
unsafe { std::slice::from_raw_parts(self.inner.ptr.as_ptr(), len) }
}
}
impl<T> std::ops::DerefMut for StableVec<T> {
/// LATENT UB: `&mut [T]` aliases Arc-shared memory. Kept for
/// Index/IndexMut ergonomics at call sites that write disjoint
/// fields under the picker write lock. See module-level doc.
#[inline]
fn deref_mut(&mut self) -> &mut [T] {
let len = self.inner.len.load(Ordering::Acquire);
unsafe { std::slice::from_raw_parts_mut(self.inner.ptr.as_ptr(), len) }
}
}
+971
View File
@@ -0,0 +1,971 @@
use std::io::Read;
use std::path::{Path, PathBuf};
#[cfg(not(target_os = "windows"))]
use std::sync::OnceLock;
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::query_tracker::QueryMatchEntry;
use crate::simd_path::ArenaPtr;
use fff_query_parser::{FFFQuery, FuzzyQuery, Location};
/// Different sources of the string storage used by FFF
/// implements as a deduplicated 16-bytes alined heap
/// can be stored in RAM or on disk
pub trait FFFStringStorage {
/// Resolve the arena for a [`FileItem`] (handles base vs overflow split).
fn arena_for(&self, file: &FileItem) -> ArenaPtr;
/// The base arena (scan-time paths).
fn base_arena(&self) -> ArenaPtr;
/// The overflow arena (paths added after the last full scan).
fn overflow_arena(&self) -> ArenaPtr;
}
impl FFFStringStorage for ArenaPtr {
#[inline]
fn arena_for(&self, _file: &FileItem) -> ArenaPtr {
*self
}
#[inline]
fn base_arena(&self) -> ArenaPtr {
*self
}
#[inline]
fn overflow_arena(&self) -> ArenaPtr {
*self
}
}
pub trait FileSliceExt {
fn live_count(&self) -> usize;
}
impl FileSliceExt for [FileItem] {
#[inline]
fn live_count(&self) -> usize {
self.iter().filter(|f| !f.is_deleted()).count()
}
}
pub struct FileItemFlags;
impl FileItemFlags {
pub const BINARY: u8 = 1 << 0;
/// Tombstone — file was deleted but index slot is preserved so
/// bigram indices for other files stay valid.
pub const DELETED: u8 = 1 << 1;
/// File was added after the last full reindex; its indices point
/// into the overflow builder arena, not the base arena.
pub const OVERFLOW: u8 = 1 << 2;
}
pub struct DirFlags;
impl DirFlags {
pub const OVERFLOW: u8 = 1 << 0;
}
/// A directory in the file index. Shares chunk arena with file paths.
#[derive(Debug)]
pub struct DirItem {
flags: u8,
pub(crate) path: crate::simd_path::ChunkedString,
/// Byte offset where the last path segment begins (e.g. for `src/components/`
/// this is 4, pointing to `components/`). Used for dirname-bonus scoring.
last_segment_offset: u16,
/// Maximum `access_frecency_score` among direct child files.
/// Atomic so parallel frecency updates can write directly without juggling.
max_access_frecency: AtomicI32,
}
impl Clone for DirItem {
fn clone(&self) -> Self {
Self {
flags: self.flags,
path: self.path.clone(),
last_segment_offset: self.last_segment_offset,
max_access_frecency: AtomicI32::new(self.max_access_frecency()),
}
}
}
impl DirItem {
#[inline(always)]
pub fn is_overflow(&self) -> bool {
self.flags & DirFlags::OVERFLOW != 0
}
pub(crate) fn new(path: crate::simd_path::ChunkedString, last_segment_offset: u16) -> Self {
Self {
path,
flags: 0,
last_segment_offset,
max_access_frecency: AtomicI32::new(0),
}
}
/// Byte offset of the last path segment within the directory path.
#[inline]
pub fn last_segment_offset(&self) -> u16 {
self.last_segment_offset
}
/// Current max access frecency score.
#[inline]
pub fn max_access_frecency(&self) -> i32 {
self.max_access_frecency.load(Ordering::Relaxed)
}
/// Atomically update the directory's frecency score if the given score is larger.
/// Safe to call from parallel threads.
#[inline]
pub fn update_frecency_if_larger(&self, score: i32) {
self.max_access_frecency.fetch_max(score, Ordering::Relaxed);
}
/// Reset frecency to zero (used before full recomputation).
#[inline]
pub fn reset_frecency(&self) {
self.max_access_frecency.store(0, Ordering::Relaxed);
}
pub(crate) fn read_relative_path<'a>(&self, arena: ArenaPtr, buf: &'a mut [u8]) -> &'a str {
self.path.read_to_buf(arena, buf)
}
/// Relative dir path as owned String (cold path).
pub fn relative_path(&self, arena: impl FFFStringStorage) -> String {
let mut out = String::new();
let ptr = if self.is_overflow() {
arena.overflow_arena()
} else {
arena.base_arena()
};
self.path.write_to_string(ptr, &mut out);
out
}
/// Write the last segment (dirname) of this directory path to `out`.
pub fn write_dir_name(&self, arena: ArenaPtr, out: &mut String) {
out.clear();
let total = self.path.byte_len as usize;
let offset = self.last_segment_offset as usize;
if offset >= total {
return;
}
// Read the full path, then slice from last_segment_offset
let mut buf = [0u8; PATH_BUF_SIZE];
let full = self.path.read_to_buf(arena, &mut buf);
out.push_str(&full[offset..]);
}
/// The dirname (last segment) as an owned String. Cold path.
pub fn dir_name(&self, arena: impl FFFStringStorage) -> String {
let mut out = String::new();
let ptr = if self.is_overflow() {
arena.overflow_arena()
} else {
arena.base_arena()
};
self.write_dir_name(ptr, &mut out);
out
}
/// A path = base_path + "/" + relative. Cold path, allocates.
pub fn absolute_path(&self, arena: impl FFFStringStorage, base_path: &Path) -> PathBuf {
let rel = self.relative_path(arena);
if rel.is_empty() {
base_path.to_path_buf()
} else {
base_path.join(&rel)
}
}
}
impl Constrainable for DirItem {
#[inline]
fn write_file_name(&self, arena: ArenaPtr, out: &mut String) {
// For dirs, the "file name" equivalent is the last path segment
self.write_dir_name(arena, out);
}
#[inline]
fn write_relative_path(&self, arena: ArenaPtr, out: &mut String) {
self.path.write_to_string(arena, out);
}
#[inline]
fn git_status(&self) -> Option<git2::Status> {
None
}
#[inline]
fn is_overflow(&self) -> bool {
DirItem::is_overflow(self)
}
}
#[derive(Debug)]
pub struct FileItem {
pub size: u64,
pub modified: u64,
pub access_frecency_score: i16,
pub modification_frecency_score: i16,
pub git_status: Option<git2::Status>,
pub(crate) path: crate::simd_path::ChunkedString,
pub(crate) parent_dir_index: u32,
flags: AtomicU8,
/// Lazy mmap cache. Only populated by the actual file read, controlled by the budget.
#[cfg(not(target_os = "windows"))]
content: OnceLock<memmap2::Mmap>,
}
impl Clone for FileItem {
fn clone(&self) -> Self {
Self {
path: self.path.clone(),
parent_dir_index: self.parent_dir_index,
size: self.size,
modified: self.modified,
access_frecency_score: self.access_frecency_score,
modification_frecency_score: self.modification_frecency_score,
git_status: self.git_status,
flags: AtomicU8::new(self.flags.load(Ordering::Relaxed)),
// on clone we have to reset the content lock
#[cfg(not(target_os = "windows"))]
content: OnceLock::new(),
}
}
}
/// Single-block read used by the binary classifier. Most binaries reveal a
/// NUL byte within the first filesystem block, so 16 KB lets one read settle
/// the classification for typical files while keeping the scratch buffer
/// small enough to live on the stack.
pub const BINARY_CLASSIFICATION_CHUNK_SIZE: usize = 16 * 1024;
/// A file is treated as binary if any NUL byte appears in the scanned prefix.
#[inline]
pub(crate) fn detect_binary_content(content: &[u8]) -> bool {
memchr::memchr(0, content).is_some()
}
impl FileItem {
pub fn new_raw(
filename_start: u16,
size: u64,
modified: u64,
git_status: Option<git2::Status>,
is_binary: bool,
) -> Self {
let mut flags = 0u8;
if is_binary {
flags |= FileItemFlags::BINARY;
}
let mut path = crate::simd_path::ChunkedString::empty();
path.filename_offset = filename_start;
Self {
path,
parent_dir_index: u32::MAX,
size,
modified,
access_frecency_score: 0,
modification_frecency_score: 0,
git_status,
flags: AtomicU8::new(flags),
#[cfg(not(target_os = "windows"))]
content: OnceLock::new(),
}
}
/// Returns an absolute path of the file
pub fn absolute_path(&self, arena: impl FFFStringStorage, base_path: &Path) -> PathBuf {
let mut buf = [0u8; PATH_BUF_SIZE];
let rel = self.path.read_to_buf(arena.arena_for(self), &mut buf);
base_path.join(rel)
}
pub(crate) fn set_path(&mut self, path: crate::simd_path::ChunkedString) {
self.path = path;
}
pub fn dir_str(&self, arena: impl FFFStringStorage) -> String {
let mut s = String::with_capacity(64);
self.path.write_dir_to(arena.arena_for(self), &mut s);
s
}
pub(crate) fn write_dir_str(&self, arena: ArenaPtr, out: &mut String) {
self.path.write_dir_to(arena, out);
}
pub fn file_name(&self, arena: impl FFFStringStorage) -> String {
let mut s = String::with_capacity(32);
self.path.write_filename_to(arena.arena_for(self), &mut s);
s
}
pub(crate) fn write_file_name_from_arena(&self, arena: ArenaPtr, out: &mut String) {
self.path.write_filename_to(arena, out);
}
pub fn relative_path(&self, arena: impl FFFStringStorage) -> String {
let mut s = String::with_capacity(64);
self.path.write_to_string(arena.arena_for(self), &mut s);
s
}
pub(crate) fn write_relative_path_from_arena(&self, arena: ArenaPtr, out: &mut String) {
self.path.write_to_string(arena, out);
}
pub fn relative_path_len(&self) -> usize {
self.path.byte_len as usize
}
pub fn filename_offset_in_relative_path(&self) -> usize {
self.path.filename_offset as usize
}
pub(crate) fn relative_path_eq(&self, arena: ArenaPtr, other: &str) -> bool {
if other.len() != self.path.byte_len as usize {
return false;
}
let mut buf = [0u8; 512];
let mine = self.path.read_to_buf(arena, &mut buf);
mine == other
}
pub(crate) fn relative_path_starts_with(&self, arena: ArenaPtr, prefix: &str) -> bool {
let mut buf = [0u8; PATH_BUF_SIZE];
let path = self.path.read_to_buf(arena, &mut buf);
path.starts_with(prefix)
}
/// Write `base_path + '/' + relative_path` into `buf` and return it
/// as `&Path`. Takes a fixed-size array so the buffer can live on
/// the stack (no heap allocation, no bounds checks in the hot loop).
pub(crate) fn write_absolute_path<'a>(
&self,
arena: ArenaPtr,
base_path: &Path,
buf: &'a mut [u8; PATH_BUF_SIZE],
) -> &'a Path {
let base = base_path.as_os_str().as_encoded_bytes();
let base_len = base.len();
buf[..base_len].copy_from_slice(base);
let sep_len = if base_len > 0 && base[base_len - 1] != std::path::MAIN_SEPARATOR as u8 {
buf[base_len] = std::path::MAIN_SEPARATOR as u8;
1
} else {
0
};
let base_end_idx = base_len + sep_len;
let relative_portion_str = self.path.read_to_buf(arena, &mut buf[base_end_idx..]);
let 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]) })
}
/// Write the relative path into `buf` and NUL-terminate, returning
/// a `&CStr`. Fixed-size array so the buffer is stack-allocatable.
///
/// Paired with a parent-directory fd this eliminates the per-file
/// absolute-path memcpy: `openat(dir_fd, cstr.as_ptr(), O_RDONLY)`
/// resolves the name relative to `dir_fd`. Unix-only.
#[cfg(unix)]
pub(crate) fn write_relative_cstr<'a>(
&self,
arena: ArenaPtr,
buf: &'a mut [u8; PATH_BUF_SIZE],
) -> &'a std::ffi::CStr {
// Reserve the last byte for the NUL terminator.
let rel = self.path.read_to_buf(arena, &mut buf[..PATH_BUF_SIZE - 1]);
let n = rel.len();
buf[n] = 0;
// SAFETY: `buf[..=n]` ends with the NUL we just wrote and
// filesystem paths never contain interior NULs.
unsafe { std::ffi::CStr::from_bytes_with_nul_unchecked(&buf[..=n]) }
}
#[inline]
pub fn total_frecency_score(&self) -> i32 {
self.access_frecency_score as i32 + self.modification_frecency_score as i32
}
#[allow(dead_code)]
#[inline]
pub(crate) fn is_likely_hot(&self) -> bool {
self.access_frecency_score > 0 || self.git_status.is_some()
}
/// Reads a fixed bytes count from the file optimized for quick speed of opening
#[inline]
pub(crate) fn read_trimmed_into_buf(
&self,
base_fd: i32,
base_path: &Path,
arena: ArenaPtr,
path_buf: &mut [u8; PATH_BUF_SIZE],
buf: &mut [u8],
) -> usize {
#[cfg(unix)]
{
self.read_into_buf_unix(base_fd, base_path, arena, path_buf, buf)
}
#[cfg(not(unix))]
{
let _ = base_fd;
self.read_into_buf_std(base_path, arena, path_buf, buf)
}
}
#[cfg(unix)]
fn read_into_buf_unix(
&self,
base_fd: libc::c_int,
base_path: &Path,
arena: ArenaPtr,
path_buf: &mut [u8; PATH_BUF_SIZE],
buf: &mut [u8],
) -> usize {
let fd = if base_fd >= 0 {
let relative_path = self.write_relative_cstr(arena, path_buf);
// SAFETY: `relative_path` is NUL-terminated, `base_fd` is a
// valid directory descriptor owned by the caller.
unsafe { libc::openat(base_fd, relative_path.as_ptr(), libc::O_RDONLY) }
} else {
use std::os::unix::io::IntoRawFd;
let abs = self.write_absolute_path(arena, base_path, path_buf);
match std::fs::File::open(abs) {
Ok(f) => f.into_raw_fd(),
Err(e) => {
tracing::error!(?e, "Failed to fopen file");
return 0;
}
}
};
if fd < 0 {
return 0;
}
let mut filled = 0usize;
while filled < buf.len() {
// SAFETY: `fd` is an owned descriptor, `buf[filled..]` is a
// valid writable slice for `buf.len() - filled` bytes.
let n = unsafe {
libc::read(
fd,
buf[filled..].as_mut_ptr() as *mut libc::c_void,
(buf.len() - filled) as libc::size_t,
)
};
if n <= 0 {
break;
}
filled += n as usize;
}
// SAFETY: matching close for the owned descriptor.
unsafe { libc::close(fd) };
filled
}
#[cfg(not(unix))]
fn read_into_buf_std(
&self,
base_path: &Path,
arena: ArenaPtr,
path_buf: &mut [u8; PATH_BUF_SIZE],
buf: &mut [u8],
) -> usize {
let abs = self.write_absolute_path(arena, base_path, path_buf);
let Ok(mut f) = std::fs::File::open(abs) else {
return 0;
};
let mut filled = 0usize;
while filled < buf.len() {
match f.read(&mut buf[filled..]) {
Ok(0) => break,
Ok(n) => filled += n,
Err(_) => return 0,
}
}
filled
}
#[inline]
pub fn is_binary(&self) -> bool {
self.flags.load(Ordering::Relaxed) & FileItemFlags::BINARY != 0
}
#[inline]
pub fn set_binary(&self, val: bool) {
if val {
self.flags
.fetch_or(FileItemFlags::BINARY, Ordering::Relaxed);
} else {
self.flags
.fetch_and(!FileItemFlags::BINARY, Ordering::Relaxed);
}
}
/// Chunked classifier of the binary content of the file chunk by chunk
/// accepts path which to reuse the allocated buffer for absolute path read
pub(crate) fn detect_binary_per_byte(&self, path: &Path, chunk: &mut [u8]) {
if self.size == 0 {
return;
}
let Ok(mut file) = std::fs::OpenOptions::new()
.write(false)
.read(true)
.open(path)
else {
tracing::error!(path = ?path.display(), "Failed to open indexed file");
return;
};
loop {
match file.read(chunk) {
Ok(0) => break,
Err(e) => {
tracing::error!(?e, "Failed to read file chunk");
break;
}
Ok(n) => {
if detect_binary_content(&chunk[..n]) {
self.set_binary(true);
}
}
}
}
}
#[inline]
pub fn is_deleted(&self) -> bool {
self.flags.load(Ordering::Relaxed) & FileItemFlags::DELETED != 0
}
#[inline]
#[doc(hidden)]
/// Don't use it, use FilePicker::delete_file
pub fn set_deleted(&self, val: bool) {
if val {
self.flags
.fetch_or(FileItemFlags::DELETED, Ordering::Relaxed);
} else {
self.flags
.fetch_and(!FileItemFlags::DELETED, Ordering::Relaxed);
}
}
#[inline]
pub fn is_overflow(&self) -> bool {
self.flags.load(Ordering::Relaxed) & FileItemFlags::OVERFLOW != 0
}
#[inline]
pub fn set_overflow(&self, val: bool) {
if val {
self.flags
.fetch_or(FileItemFlags::OVERFLOW, Ordering::Relaxed);
} else {
self.flags
.fetch_and(!FileItemFlags::OVERFLOW, Ordering::Relaxed);
}
}
}
impl FileItem {
/// Invalidate the cached mmap content, has to be called every time the file is updated.
///
/// Call this when the background watcher detects that the file has been modified.
/// On Unix, a file that is truncated while mapped can cause SIGBUS. On Windows,
/// the stale buffer simply won't reflect the new contents. In both cases,
/// invalidating ensures a fresh read on the next access.
#[cfg(not(target_os = "windows"))]
pub fn invalidate_mmap(&mut self, budget: &ContentCacheBudget) {
if self.content.get().is_some() {
budget.cached_count.fetch_sub(1, Ordering::Relaxed);
budget.cached_bytes.fetch_sub(self.size, Ordering::Relaxed);
}
self.content = OnceLock::new();
}
#[cfg(target_os = "windows")]
pub fn invalidate_mmap(&mut self, _: &ContentCacheBudget) {}
pub fn update_metadata(
&mut self,
budget: &ContentCacheBudget,
modified_secs: Option<u64>,
new_size: Option<u64>,
) {
if let Some(modified) = modified_secs
&& self.modified < modified
{
self.modified = modified;
}
self.invalidate_mmap(budget);
if let Some(size) = new_size {
self.size = size;
}
}
/// Get the cached file contents or lazily load and cache them.
///
/// Returns `None` if the file is too large, empty, can't be opened, **or
/// the cache budget is exhausted**. Callers that need content regardless
/// of the budget should use [`get_content_for_search`].
///
/// After the first call, this is lock-free (just an atomic load + pointer deref).
///
/// On Windows we never back this cache — `memmap2` would require a full
/// `std::fs::read` heap copy and the OS page cache already absorbs repeat
/// reopens. Returning `None` keeps callers on the scratch-read slow path
/// and avoids duplicating every indexed file on the heap.
#[cfg(target_os = "windows")]
pub(crate) fn get_cached_content(
&self,
_arena: ArenaPtr,
_base_path: &Path,
_budget: &ContentCacheBudget,
) -> Option<&[u8]> {
None
}
/// Returns a reference to a cached mmap of the file's contents.
///
/// SAFETY-CRITICAL: callers must hold the picker read lock for as long as the returned slice is in use.
#[cfg(not(target_os = "windows"))]
pub(crate) fn get_cached_content(
&self,
arena: ArenaPtr,
base_path: &Path,
budget: &ContentCacheBudget,
) -> Option<&[u8]> {
if let Some(content) = self.content.get() {
return Some(content);
}
if self.size < MMAP_THRESHOLD || self.size > budget.max_file_size {
return None;
}
// Check cache budget before creating a new persistent cache entry.
let count = budget.cached_count.load(Ordering::Relaxed);
let bytes = budget.cached_bytes.load(Ordering::Relaxed);
let max_files = budget.max_files;
let max_bytes = budget.max_bytes;
if count >= max_files || bytes + self.size > max_bytes {
return None;
}
let path = self.absolute_path(arena, base_path);
let file = std::fs::File::open(&path).ok()?;
// SAFETY: the mmap is backed by the kernel page cache and reflects
// file updates; the only risk is SIGBUS on a concurrent truncate,
// which the watcher mitigates by invalidating on modification.
let mmap = unsafe { memmap2::Mmap::map(&file) }.ok()?;
let result = self.content.get_or_init(|| mmap);
budget.cached_count.fetch_add(1, Ordering::Relaxed);
budget.cached_bytes.fetch_add(self.size, Ordering::Relaxed);
Some(result)
}
/// Get file content for searching — **always returns content** for eligible
/// files, even when the persistent cache budget is exhausted.
///
/// The caller provides a reusable `path_buf` (pre-filled with `base_path/`)
/// and its `base_len` to avoid allocations when constructing the absolute path.
#[inline]
pub(crate) fn get_content_for_search<'a>(
&'a self,
buf: &'a mut Vec<u8>,
#[cfg_attr(target_os = "windows", allow(unused_variables))] mmap_slot: &'a mut MmapSlot,
arena: ArenaPtr,
base_path: &Path,
budget: &ContentCacheBudget,
) -> Option<&'a [u8]> {
#[cfg(not(target_os = "windows"))]
{
// Fast path: persistent cache hit (zero-copy). Safe here because
// grep callers hold the picker read lock for the lifetime of the
// returned slice — see [`Self::get_cached_content`] safety note.
if let Some(cached) = self.get_cached_content(arena, base_path, budget) {
return Some(cached);
}
}
let max_file_size = budget.max_file_size;
if self.is_binary() || self.size == 0 || self.size > max_file_size {
return None;
}
let abs = self.absolute_path(arena, base_path);
#[cfg(not(target_os = "windows"))]
if self.size >= FRESH_MMAP_THRESHOLD {
let file = std::fs::File::open(&abs).ok()?;
let mmap = unsafe { memmap2::Mmap::map(&file) }.ok()?;
let stored = mmap_slot.insert(mmap);
return Some(&stored[..]);
} else {
let _ = (mmap_slot, arena);
}
let len = self.size as usize;
buf.resize(len, 0);
let mut file = std::fs::File::open(&abs).ok()?;
file.read_exact(buf).ok()?;
Some(buf.as_slice())
}
}
/// Per-thread scratch slot owning a transient mmap returned from
/// [`FileItem::get_content_for_search`]. `Option<Mmap>` on Unix,
/// unit on Windows where mmap is unused.
#[cfg(not(target_os = "windows"))]
pub type MmapSlot = Option<memmap2::Mmap>;
#[cfg(target_os = "windows")]
pub type MmapSlot = ();
impl Constrainable for FileItem {
#[inline]
fn write_file_name(&self, arena: ArenaPtr, out: &mut String) {
self.path.write_filename_to(arena, out);
}
#[inline]
fn write_relative_path(&self, arena: ArenaPtr, out: &mut String) {
self.path.write_to_string(arena, out);
}
#[inline]
fn git_status(&self) -> Option<git2::Status> {
self.git_status
}
#[inline]
fn is_overflow(&self) -> bool {
FileItem::is_overflow(self)
}
}
#[derive(Debug, Clone, Default)]
pub struct Score {
pub total: i32,
pub base_score: i32,
pub filename_bonus: i32,
pub special_filename_bonus: i32,
pub frecency_boost: i32,
pub git_status_boost: i32,
pub distance_penalty: i32,
pub current_file_penalty: i32,
pub combo_match_boost: i32,
pub path_alignment_bonus: i32,
pub exact_match: bool,
pub match_type: &'static str,
}
#[derive(Debug, Clone, Copy)]
pub struct PaginationArgs {
pub offset: usize,
pub limit: usize,
}
impl Default for PaginationArgs {
fn default() -> Self {
Self {
offset: 0,
limit: 100,
}
}
}
#[derive(Debug, Clone)]
pub struct ScoringContext<'a> {
pub query: &'a FFFQuery<'a>,
pub project_path: Option<&'a Path>,
pub current_file: Option<&'a str>,
pub max_typos: u16,
pub max_threads: usize,
pub last_same_query_match: Option<QueryMatchEntry>,
pub combo_boost_score_multiplier: i32,
pub min_combo_count: u32,
pub pagination: PaginationArgs,
}
impl ScoringContext<'_> {
pub fn effective_query(&self) -> &str {
match &self.query.fuzzy_query {
FuzzyQuery::Text(t) => t,
FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
_ => self.query.raw_query.trim(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct SearchResult<'a> {
pub items: Vec<&'a FileItem>,
pub scores: Vec<Score>,
pub total_matched: usize,
pub total_files: usize,
pub location: Option<Location>,
}
/// Search result for directory-only fuzzy search.
#[derive(Debug, Clone, Default)]
pub struct DirSearchResult<'a> {
pub items: Vec<&'a DirItem>,
pub scores: Vec<Score>,
pub total_matched: usize,
pub total_dirs: usize,
}
/// A single item in a mixed (files + directories) search result.
#[derive(Debug, Clone)]
pub enum MixedItemRef<'a> {
File(&'a FileItem),
Dir(&'a DirItem),
}
/// Search result for mixed (files + directories) fuzzy search.
/// Items are interleaved by total score in descending order.
#[derive(Debug, Clone, Default)]
pub struct MixedSearchResult<'a> {
pub items: Vec<MixedItemRef<'a>>,
pub scores: Vec<Score>,
pub total_matched: usize,
pub total_files: usize,
pub total_dirs: usize,
pub location: Option<Location>,
}
impl Default for MixedItemRef<'_> {
fn default() -> Self {
// Should never be used, exists only for Default derive on MixedSearchResult
unreachable!("MixedItemRef::default should not be called")
}
}
#[derive(Debug)]
pub struct ContentCacheBudget {
pub max_files: usize,
pub max_bytes: u64,
pub max_file_size: u64,
pub cached_count: AtomicUsize,
pub cached_bytes: AtomicU64,
}
impl ContentCacheBudget {
pub fn unlimited() -> Self {
Self {
max_files: usize::MAX,
max_bytes: u64::MAX,
max_file_size: MAX_FFFILE_SIZE,
cached_count: AtomicUsize::new(0),
cached_bytes: AtomicU64::new(0),
}
}
pub fn zero() -> Self {
Self {
max_files: 0,
max_bytes: 0,
max_file_size: 0,
cached_count: AtomicUsize::new(0),
cached_bytes: AtomicU64::new(0),
}
}
// Byte budget
pub fn is_exhausted(&self) -> bool {
self.cached_count.load(Ordering::Relaxed) >= self.max_files
|| self.cached_bytes.load(Ordering::Relaxed) >= self.max_bytes
}
pub fn new_for_repo(file_count: usize) -> Self {
let max_files = if file_count > 50_000 {
5_000
} else if file_count > 10_000 {
10_000
} else {
30_000 // effectively unlimited for small repos
};
let max_bytes = if file_count > 50_000 {
128 * 1024 * 1024 // 128 MB
} else if file_count > 10_000 {
256 * 1024 * 1024 // 256 MB
} else {
MAX_CACHED_CONTENT_BYTES // 512 MB
};
Self {
max_files,
max_bytes,
max_file_size: MAX_FFFILE_SIZE,
cached_count: AtomicUsize::new(0),
cached_bytes: AtomicU64::new(0),
}
}
/// Build a budget from caller-supplied overrides.
///
/// Each argument is a cap; `0` means "use the library default for that
/// cap" (inherits from [`Self::default`], which is `new_for_repo(30_000)`).
/// Returns `None` when every cap is `0`, signalling to the picker that it
/// should auto-size the budget from the final scanned file count rather
/// than applying an explicit override.
pub fn from_overrides(max_files: usize, max_bytes: u64, max_file_size: u64) -> Option<Self> {
if max_files == 0 && max_bytes == 0 && max_file_size == 0 {
return None;
}
let mut budget = Self::default();
if max_files > 0 {
budget.max_files = max_files;
}
if max_bytes > 0 {
budget.max_bytes = max_bytes;
}
if max_file_size > 0 {
budget.max_file_size = max_file_size;
}
Some(budget)
}
pub fn reset(&self) {
self.cached_count.store(0, Ordering::Relaxed);
self.cached_bytes.store(0, Ordering::Relaxed);
}
}
impl Default for ContentCacheBudget {
fn default() -> Self {
Self::new_for_repo(30_000)
}
}
+146
View File
@@ -0,0 +1,146 @@
//! 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)>,
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")));
}
}
+72
View File
@@ -0,0 +1,72 @@
use crate::background_watcher::is_git_file;
use crate::ignore::non_git_repo_overrides;
use crate::types::FileItem;
use crate::walk::WalkOutput;
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();
let pairs = parking_lot::Mutex::new(Vec::<(FileItem, String)>::new());
walker.run(|| {
let pairs = &pairs;
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());
pairs.lock().push((file_item, rel_path));
counter.fetch_add(1, Ordering::Relaxed);
}
ignore::WalkState::Continue
})
});
Ok(WalkOutput {
pairs: pairs.into_inner(),
ignore_rules: None,
})
}
+111
View File
@@ -0,0 +1,111 @@
//! Filesystem traversal backed by zlob's native parallel walker.
//! Active when the `zlob` feature is enabled (requires the Zig toolchain).
use crate::file_picker::is_known_binary_extension_basename;
use crate::ignore::IGNORED_DIRS;
use crate::types::FileItem;
use crate::walk::{WalkIgnoreRules, WalkOutput};
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");
}
let pairs = parking_lot::Mutex::new(Vec::<(FileItem, String)>::new());
let outcome = match builder.run(|entry| {
if !entry.is_file() {
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 = pairs.lock();
guard.push((item, rel_str));
let n = guard.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 = pairs.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,
ignore_rules,
})
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,410 @@
//! Integration test: verify that modifying a file after the bigram index is built
//! still makes the new content findable via grep (through the overlay layer).
use std::fs;
use std::time::Duration;
use tempfile::TempDir;
use fff_search::file_picker::{FFFMode, FilePicker};
use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query};
use fff_search::{FilePickerOptions, SharedFilePicker, SharedFrecency};
/// Create a temp directory with some initial files, run the full picker lifecycle,
/// then modify a file and verify grep finds the new content.
#[test]
fn modified_file_findable_via_overlay() {
let tmp = TempDir::new().unwrap();
let base = tmp.path();
// Create initial files with known content.
fs::write(base.join("alpha.txt"), "hello world\nfoo bar\n").unwrap();
fs::write(
base.join("beta.txt"),
"some other content\nnothing special\n",
)
.unwrap();
fs::write(base.join("gamma.txt"), "yet another file\nmore lines\n").unwrap();
let shared_picker = SharedFilePicker::default();
let shared_frecency = SharedFrecency::default();
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().to_string(),
enable_mmap_cache: true,
enable_content_indexing: true,
mode: FFFMode::Neovim,
watch: false, // we drive events manually
..Default::default()
},
)
.expect("Failed to create FilePicker");
// Wait for scan + bigram build to complete.
let deadline = std::time::Instant::now() + Duration::from_secs(30);
loop {
std::thread::sleep(Duration::from_millis(50));
let ready = shared_picker
.read()
.ok()
.map(|guard| {
guard
.as_ref()
.map_or(false, |p| !p.is_scan_active() && p.bigram_index().is_some())
})
.unwrap_or(false);
if ready {
break;
}
assert!(
std::time::Instant::now() < deadline,
"Timed out waiting for scan + bigram build"
);
}
// Sanity check: the 3 files are indexed.
{
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
assert_eq!(picker.get_files().len(), 3, "Expected 3 files after scan");
assert!(
picker.bigram_index().is_some(),
"Bigram index should be built"
);
assert!(
picker.bigram_overlay().is_some(),
"Overlay should be initialized"
);
}
// "UNIQUE_NEEDLE" should NOT exist in any file yet.
{
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let parsed = parse_grep_query("UNIQUE_NEEDLE");
let opts = grep_opts();
let result = picker.grep(&parsed, &opts);
assert_eq!(
result.matches.len(),
0,
"UNIQUE_NEEDLE should not exist before modification"
);
}
// Sleep so the filesystem mtime (seconds granularity) advances past the
// value recorded during scan. Without this, on_create_or_modify skips
// mmap invalidation and grep reads stale cached content.
std::thread::sleep(Duration::from_millis(1100));
// Write new content containing the needle.
let modified_path = base.join("beta.txt");
fs::write(
&modified_path,
"some other content\nUNIQUE_NEEDLE is here\nnothing special\n",
)
.unwrap();
// Simulate watcher event: call on_create_or_modify.
// This updates the overlay's bigrams and invalidates the mmap cache.
{
let mut guard = shared_picker.write().unwrap();
let picker = guard.as_mut().unwrap();
let result = picker.handle_create_or_modify(&modified_path);
assert!(
result.is_some(),
"on_create_or_modify should return the file"
);
}
// The bigram index was built BEFORE the modification, so without the
// overlay, beta.txt would be filtered out (its old bigrams don't contain
// "UNIQUE_NEEDLE"). The overlay should fix that.
{
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let parsed = parse_grep_query("UNIQUE_NEEDLE");
let opts = grep_opts();
let result = picker.grep(&parsed, &opts);
assert!(
!result.matches.is_empty(),
"UNIQUE_NEEDLE should be findable after modification (overlay adds the candidate back)"
);
// May find 1 or 2 matches depending on mmap cache state — the important
// thing is that the modified content IS found.
assert!(
result
.matches
.iter()
.any(|m| m.line_content.contains("UNIQUE_NEEDLE")),
"At least one match should contain UNIQUE_NEEDLE"
);
}
// Cleanup: stop background watcher.
if let Ok(mut guard) = shared_picker.write() {
if let Some(ref mut picker) = *guard {
picker.stop_background_monitor();
}
}
}
/// Verify that deleting a file makes its content un-findable via grep.
#[test]
fn deleted_file_excluded_via_overlay() {
let tmp = TempDir::new().unwrap();
let base = tmp.path();
fs::write(base.join("keep.txt"), "keep this content\n").unwrap();
fs::write(base.join("remove.txt"), "DELETEME_TOKEN is here\n").unwrap();
let shared_picker = SharedFilePicker::default();
let shared_frecency = SharedFrecency::default();
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().to_string(),
enable_mmap_cache: true,
enable_content_indexing: true,
mode: FFFMode::Neovim,
watch: false, // we drive events manually
..Default::default()
},
)
.unwrap();
wait_for_bigram(&shared_picker);
// Sanity: DELETEME_TOKEN is findable.
{
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let result = grep_for(picker, "DELETEME_TOKEN");
assert_eq!(
result.matches.len(),
1,
"Token should be found before delete"
);
}
// Delete the file on disk and via picker.
let remove_path = base.join("remove.txt");
fs::remove_file(&remove_path).unwrap();
{
let mut guard = shared_picker.write().unwrap();
let picker = guard.as_mut().unwrap();
assert!(
picker.remove_file_by_path(&remove_path),
"remove should succeed"
);
}
// Token should no longer be found (tombstone in overlay clears the candidate).
{
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let result = grep_for(picker, "DELETEME_TOKEN");
assert_eq!(
result.matches.len(),
0,
"DELETEME_TOKEN should not be found after deletion (tombstone in overlay)"
);
}
if let Ok(mut guard) = shared_picker.write() {
if let Some(ref mut picker) = *guard {
picker.stop_background_monitor();
}
}
}
/// Verify that a newly added file (in overflow) is findable via grep.
#[test]
fn new_file_findable_after_add() {
let tmp = TempDir::new().unwrap();
let base = tmp.path();
fs::write(base.join("existing.txt"), "original content\n").unwrap();
let shared_picker = SharedFilePicker::default();
let shared_frecency = SharedFrecency::default();
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().to_string(),
enable_mmap_cache: true,
enable_content_indexing: true,
mode: FFFMode::Neovim,
watch: false, // we drive events manually
..Default::default()
},
)
.unwrap();
wait_for_bigram(&shared_picker);
// Create a new file on disk after the index was built.
let new_path = base.join("newcomer.txt");
fs::write(&new_path, "BRAND_NEW_TOKEN lives here\n").unwrap();
// Simulate watcher detecting the new file.
{
let mut guard = shared_picker.write().unwrap();
let picker = guard.as_mut().unwrap();
let result = picker.handle_create_or_modify(&new_path);
assert!(
result.is_some(),
"on_create_or_modify should return the new file"
);
}
// The new file is in overflow, not in the base files slice.
// grep_search currently only searches base files, so we need to verify
// the overflow file is accessible.
{
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let overflow = picker.get_overflow_files();
assert_eq!(overflow.len(), 1, "Should have 1 overflow file");
assert!(
overflow[0].relative_path(picker).ends_with("newcomer.txt"),
"Overflow file should be newcomer.txt"
);
}
if let Ok(mut guard) = shared_picker.write() {
if let Some(ref mut picker) = *guard {
picker.stop_background_monitor();
}
}
}
/// Verify that a file modified after index build is findable via regex grep
/// through the overlay. This catches a regression where `extract_bigrams` on
/// the raw regex string (e.g. "NEEDLE.*HERE") produces bogus bigrams containing
/// `.` and `*`, causing `query_modified` to miss the file.
#[test]
fn modified_file_findable_via_regex_overlay() {
let tmp = TempDir::new().unwrap();
let base = tmp.path();
fs::write(base.join("alpha.txt"), "hello world\nfoo bar\n").unwrap();
fs::write(
base.join("beta.txt"),
"some other content\nnothing special\n",
)
.unwrap();
let shared_picker = SharedFilePicker::default();
let shared_frecency = SharedFrecency::default();
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().to_string(),
enable_mmap_cache: true,
enable_content_indexing: true,
mode: FFFMode::Neovim,
watch: false, // we drive events manually
..Default::default()
},
)
.unwrap();
wait_for_bigram(&shared_picker);
// Advance mtime past the scan timestamp so the cache is invalidated.
std::thread::sleep(Duration::from_millis(1100));
// Write content that matches the regex "NEEDLE.*HERE" into beta.txt.
let modified_path = base.join("beta.txt");
fs::write(
&modified_path,
"some other content\nNEEDLE is right HERE\nnothing special\n",
)
.unwrap();
{
let mut guard = shared_picker.write().unwrap();
let picker = guard.as_mut().unwrap();
assert!(picker.handle_create_or_modify(&modified_path).is_some());
}
// Regex grep should find the modified file through the overlay.
{
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let parsed = parse_grep_query("NEEDLE.*HERE");
let opts = GrepSearchOptions {
mode: GrepMode::Regex,
..grep_opts()
};
let result = picker.grep(&parsed, &opts);
assert!(
!result.matches.is_empty(),
"Regex grep should find NEEDLE.*HERE in modified file via overlay"
);
assert!(result.matches[0].line_content.contains("NEEDLE"));
}
if let Ok(mut guard) = shared_picker.write() {
if let Some(ref mut picker) = *guard {
picker.stop_background_monitor();
}
}
}
// ── Helpers ─────────────────────────────────────────────────────────────
fn grep_opts() -> GrepSearchOptions {
GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 200,
smart_case: true,
file_offset: 0,
page_limit: 200,
mode: GrepMode::PlainText,
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
abort_signal: None,
}
}
fn grep_for<'a>(picker: &'a FilePicker, query: &str) -> fff_search::grep::GrepResult<'a> {
let parsed = parse_grep_query(query);
picker.grep(&parsed, &grep_opts())
}
fn wait_for_bigram(shared_picker: &SharedFilePicker) {
let deadline = std::time::Instant::now() + Duration::from_secs(30);
loop {
std::thread::sleep(Duration::from_millis(50));
let ready = shared_picker
.read()
.ok()
.map(|guard| {
guard
.as_ref()
.map_or(false, |p| !p.is_scan_active() && p.bigram_index().is_some())
})
.unwrap_or(false);
if ready {
break;
}
assert!(
std::time::Instant::now() < deadline,
"Timed out waiting for bigram build"
);
}
}
@@ -0,0 +1,161 @@
// Regression pinning: dropping a picker during poset scan off-lock time
use std::fs;
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tempfile::TempDir;
use fff_search::file_picker::{FFFMode, FilePicker, FuzzySearchOptions};
use fff_search::{FilePickerOptions, QueryParser, SharedFilePicker, SharedFrecency};
fn seed_files(dir: &Path, count: usize) {
for i in 0..count {
let subdir = dir.join(format!("dir_{}", i / 20));
fs::create_dir_all(&subdir).unwrap();
fs::write(
subdir.join(format!("file_{i}.rs")),
format!("pub fn func_{i}() {{ /* token_{i} */ }}\n"),
)
.unwrap();
}
}
fn git_init(dir: &Path) {
let run = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "t@t")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "t@t")
.output()
.unwrap();
};
run(&["init"]);
run(&["add", "-A"]);
run(&["commit", "-m", "init"]);
}
fn make_picker(base: &Path) -> (SharedFilePicker, SharedFrecency) {
let sp = SharedFilePicker::default();
let sf = SharedFrecency::default();
FilePicker::new_with_shared_state(
sp.clone(),
sf.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().to_string(),
enable_mmap_cache: true,
enable_content_indexing: true,
mode: FFFMode::Neovim,
watch: false,
..Default::default()
},
)
.expect("init");
(sp, sf)
}
/// Drop picker immediately after scan starts — scan thread will find
/// the picker gone and exit cleanly.
#[test]
fn drop_picker_during_walk_no_segfault() {
let tmp = TempDir::new().unwrap();
seed_files(tmp.path(), 500);
git_init(tmp.path());
let (sp, _sf) = make_picker(tmp.path());
// Don't wait — drop immediately while walk is likely in progress
drop(sp);
// If we get here without SIGSEGV, the test passes.
std::thread::sleep(Duration::from_millis(200));
}
/// Drop picker while post-scan indexing is running. The snapshot holds
/// Arc clones that keep the buffers alive.
#[test]
fn drop_picker_during_post_scan_no_segfault() {
let tmp = TempDir::new().unwrap();
seed_files(tmp.path(), 500);
git_init(tmp.path());
let (sp, _sf) = make_picker(tmp.path());
// Wait for walk to finish (files are searchable) but post-scan is
// still running (bigram not yet built).
sp.wait_for_scan(Duration::from_secs(10));
// At this point post_scan_indexing_active is likely true.
// Drop the picker — this releases the picker's Arc clones, but the
// post-scan snapshot's clones keep the buffers alive.
if let Ok(mut guard) = sp.write() {
guard.take(); // drop the FilePicker
}
// Give post-scan threads time to run against the "dead" picker.
// They must not segfault.
std::thread::sleep(Duration::from_secs(2));
}
/// Drop picker from a second thread while the first thread is doing
/// fuzzy searches. Verifies no segfault from interleaved access.
#[test]
fn drop_picker_concurrent_with_search_no_segfault() {
let tmp = TempDir::new().unwrap();
seed_files(tmp.path(), 500);
git_init(tmp.path());
let (sp, _sf) = make_picker(tmp.path());
sp.wait_for_scan(Duration::from_secs(10));
let sp_clone = sp.clone();
let running = Arc::new(AtomicBool::new(true));
let running_clone = running.clone();
// Searcher thread: continuously queries while the picker lives
let searcher = std::thread::spawn(move || {
let parser = QueryParser::default();
while running_clone.load(Ordering::Relaxed) {
if let Ok(guard) = sp_clone.read() {
if let Some(picker) = guard.as_ref() {
let query = parser.parse("func");
let _ = picker.fuzzy_search(&query, None, FuzzySearchOptions::default());
}
}
std::thread::sleep(Duration::from_millis(1));
}
});
// Let searches run for a bit, then drop
std::thread::sleep(Duration::from_millis(100));
if let Ok(mut guard) = sp.write() {
guard.take();
}
std::thread::sleep(Duration::from_millis(100));
running.store(false, Ordering::Relaxed);
searcher.join().unwrap();
}
/// Repeated init + wait + clean-drop cycle. This is the pattern that
/// SIGSEGV'd on the pre-refactor code in the benchmark.
#[test]
fn repeated_init_and_drop_no_segfault() {
let tmp = TempDir::new().unwrap();
seed_files(tmp.path(), 200);
git_init(tmp.path());
for _ in 0..5 {
let (sp, _sf) = make_picker(tmp.path());
sp.wait_for_scan(Duration::from_secs(10));
sp.wait_for_indexing_complete(Duration::from_secs(30));
if let Ok(mut guard) = sp.write()
&& let Some(mut picker) = guard.take()
{
picker.stop_background_monitor();
}
}
}
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,320 @@
//! Reproducer: macOS FSEvents does not deliver Remove events for files
//! deleted from NonRecursive-watched directories when multiple directories
//! are watched via stop/restart cycles.
//!
//! This test watches a temp directory NonRecursively, creates a file,
//! verifies the Create event, deletes the file, and checks whether a
//! Remove (or any) event is delivered.
use notify::event::*;
use notify::{Config, EventKindMask, RecommendedWatcher, RecursiveMode, Watcher};
use std::fs;
use std::path::PathBuf;
use std::sync::mpsc;
use std::time::Duration;
fn setup_temp_git_repo() -> (PathBuf, tempfile::TempDir) {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().canonicalize().unwrap();
// Create a git repo like the bun test does
std::process::Command::new("git")
.args(["init", "-b", "main"])
.current_dir(&dir)
.output()
.unwrap();
fs::write(dir.join("hello.txt"), "hello\n").unwrap();
fs::create_dir_all(dir.join("src")).unwrap();
fs::write(dir.join("src/main.rs"), "fn main() {}\n").unwrap();
std::process::Command::new("git")
.args(["add", "-A"])
.current_dir(&dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(&dir)
.output()
.unwrap();
(dir, tmp)
}
/// Raw notify watcher: single NonRecursive watch on a directory.
/// Create a file, delete it, check if Remove event is delivered.
#[test]
fn raw_notify_nonrecursive_detects_deletion() {
let (dir, _tmp) = setup_temp_git_repo();
let (tx, rx) = mpsc::channel();
let config = Config::default()
.with_follow_symlinks(false)
.with_event_kinds(EventKindMask::CORE);
let mut watcher = RecommendedWatcher::new(
move |res: notify::Result<Event>| {
if let Ok(ev) = res {
let _ = tx.send(ev);
}
},
config,
)
.unwrap();
// Watch ONLY the root dir NonRecursively (like fff does)
watcher
.watch(dir.as_path(), RecursiveMode::NonRecursive)
.unwrap();
// Let the watcher stabilize
std::thread::sleep(Duration::from_millis(500));
// Drain any startup events
while rx.try_recv().is_ok() {}
// Create a file
let file_path = dir.join("testfile.txt");
fs::write(&file_path, "content\n").unwrap();
// Wait for create event
let mut got_create = false;
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(ev) => {
eprintln!(" [create phase] event: {:?} paths={:?}", ev.kind, ev.paths);
if matches!(ev.kind, EventKind::Create(_)) && ev.paths.contains(&file_path) {
got_create = true;
break;
}
}
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(_) => break,
}
}
assert!(got_create, "Expected Create event for testfile.txt");
// Drain remaining events from the create
std::thread::sleep(Duration::from_millis(300));
while rx.try_recv().is_ok() {}
// Delete the file
fs::remove_file(&file_path).unwrap();
eprintln!(" File deleted: {}", file_path.display());
// Wait for any event related to the deletion
let mut got_removal_event = false;
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(ev) => {
eprintln!(" [delete phase] event: {:?} paths={:?}", ev.kind, ev.paths);
if ev.paths.contains(&file_path) {
got_removal_event = true;
break;
}
}
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(_) => break,
}
}
assert!(
got_removal_event,
"Expected some event for deleted testfile.txt but got none within 5s"
);
}
/// Same test but with MULTIPLE NonRecursive watches (base + src + .git)
/// to match what fff actually does. Each watch() call stops/restarts the FSEvents stream.
#[test]
fn raw_notify_multi_nonrecursive_detects_deletion() {
let (dir, _tmp) = setup_temp_git_repo();
let (tx, rx) = mpsc::channel();
let config = Config::default()
.with_follow_symlinks(false)
.with_event_kinds(EventKindMask::CORE);
let mut watcher = RecommendedWatcher::new(
move |res: notify::Result<Event>| {
if let Ok(ev) = res {
let _ = tx.send(ev);
}
},
config,
)
.unwrap();
// Watch multiple directories NonRecursively — EACH call restarts the FSEvents stream
watcher
.watch(dir.as_path(), RecursiveMode::NonRecursive)
.unwrap();
watcher
.watch(&dir.join("src"), RecursiveMode::NonRecursive)
.unwrap();
watcher
.watch(&dir.join(".git"), RecursiveMode::NonRecursive)
.unwrap();
// Let the watcher stabilize
std::thread::sleep(Duration::from_millis(500));
while rx.try_recv().is_ok() {}
// Create a file in root dir
let file_path = dir.join("testfile.txt");
fs::write(&file_path, "content\n").unwrap();
// Wait for create event
let mut got_create = false;
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(ev) => {
eprintln!(" [multi-create] event: {:?} paths={:?}", ev.kind, ev.paths);
if matches!(ev.kind, EventKind::Create(_)) && ev.paths.contains(&file_path) {
got_create = true;
break;
}
}
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(_) => break,
}
}
assert!(
got_create,
"Expected Create event for testfile.txt with multi-watch"
);
// Drain
std::thread::sleep(Duration::from_millis(300));
while rx.try_recv().is_ok() {}
// Delete the file
fs::remove_file(&file_path).unwrap();
eprintln!(" File deleted: {}", file_path.display());
// Wait for any event related to the deletion
let mut got_removal_event = false;
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(ev) => {
eprintln!(" [multi-delete] event: {:?} paths={:?}", ev.kind, ev.paths);
if ev.paths.contains(&file_path) {
got_removal_event = true;
break;
}
}
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(_) => break,
}
}
assert!(
got_removal_event,
"Expected some event for deleted testfile.txt with multi-watch but got none within 5s"
);
}
/// Test with debouncer (matching exactly what fff uses)
#[test]
fn debounced_nonrecursive_detects_deletion() {
use notify_debouncer_full::{DebounceEventResult, NoCache, new_debouncer_opt};
let (dir, _tmp) = setup_temp_git_repo();
let (tx, rx) = mpsc::channel();
let config = Config::default()
.with_follow_symlinks(false)
.with_event_kinds(EventKindMask::CORE);
let mut debouncer: notify_debouncer_full::Debouncer<RecommendedWatcher, NoCache> =
new_debouncer_opt(
Duration::from_millis(250),
Some(Duration::from_millis(125)),
move |result: DebounceEventResult| {
if let Ok(events) = result {
for ev in events {
eprintln!(
" [debounced-cb] kind={:?} paths={:?}",
ev.event.kind, ev.event.paths
);
let _ = tx.send(ev);
}
}
},
NoCache::new(),
config,
)
.unwrap();
// Watch like fff does
debouncer
.watch(dir.as_path(), RecursiveMode::NonRecursive)
.unwrap();
debouncer
.watch(&dir.join("src"), RecursiveMode::NonRecursive)
.unwrap();
debouncer
.watch(&dir.join(".git"), RecursiveMode::NonRecursive)
.unwrap();
// Longer stabilization — each watch() restarts the FSEvents stream
std::thread::sleep(Duration::from_secs(1));
while rx.try_recv().is_ok() {}
// Create file
let file_path = dir.join("testfile.txt");
fs::write(&file_path, "content\n").unwrap();
let mut got_create = false;
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(ev) => {
if ev.event.paths.contains(&file_path) {
got_create = true;
break;
}
}
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(_) => break,
}
}
assert!(got_create, "Expected Create event via debouncer");
// Wait for debounce to fully flush
std::thread::sleep(Duration::from_millis(500));
while rx.try_recv().is_ok() {}
// Delete
fs::remove_file(&file_path).unwrap();
eprintln!(" File deleted: {}", file_path.display());
// Wait for ANY event for this path
let mut got_event = false;
let mut event_kind = String::new();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(ev) => {
if ev.event.paths.contains(&file_path) {
event_kind = format!("{:?}", ev.event.kind);
got_event = true;
break;
}
}
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(_) => break,
}
}
assert!(
got_event,
"Expected some event for deleted testfile.txt via debouncer but got none within 5s"
);
eprintln!(" Got event kind: {}", event_kind);
}
@@ -0,0 +1,854 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tempfile::TempDir;
use rand::rngs::SmallRng;
use rand::{RngCore, SeedableRng};
use fff_search::file_picker::{FFFMode, FilePicker, FuzzySearchOptions};
use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query};
use fff_search::{
FilePickerOptions, PaginationArgs, QueryParser, SharedFilePicker, SharedFrecency,
};
const DOMAINS: &[&str] = &[
r#"
use std::net::{TcpStream, SocketAddr};
fn establish_connection(addr: SocketAddr) -> Result<TcpStream, std::io::Error> {
let stream = TcpStream::connect(addr)?;
stream.set_nodelay(true)?;
Ok(stream)
}
fn parse_http_header(raw: &[u8]) -> Option<(&str, &str)> {
let line = std::str::from_utf8(raw).ok()?;
let (key, val) = line.split_once(':')?;
Some((key.trim(), val.trim()))
}
"#,
r#"
use sqlx::{PgPool, Row};
async fn query_users(pool: &PgPool, limit: i64) -> Vec<String> {
sqlx::query("SELECT name FROM users ORDER BY created_at DESC LIMIT $1")
.bind(limit)
.fetch_all(pool)
.await
.unwrap()
.iter()
.map(|row| row.get("name"))
.collect()
}
async fn insert_record(pool: &PgPool, name: &str) -> i64 {
sqlx::query_scalar("INSERT INTO records (name) VALUES ($1) RETURNING id")
.bind(name)
.fetch_one(pool)
.await
.unwrap()
}
"#,
r#"
fn verify_jwt_token(token: &str, secret: &[u8]) -> Result<Claims, AuthError> {
let parts: Vec<&str> = token.splitn(3, '.').collect();
if parts.len() != 3 { return Err(AuthError::MalformedToken); }
let payload = base64_decode(parts[1])?;
let signature = hmac_sha256(secret, &format!("{}.{}", parts[0], parts[1]));
if signature != base64_decode(parts[2])? { return Err(AuthError::InvalidSignature); }
serde_json::from_slice(&payload).map_err(AuthError::Deserialize)
}
fn hash_password(password: &str, salt: &[u8]) -> Vec<u8> {
argon2::hash_encoded(password.as_bytes(), salt, &argon2::Config::default())
.unwrap().into_bytes()
}
"#,
r#"
struct Renderer { framebuffer: Vec<u32>, width: usize, height: usize }
impl Renderer {
fn clear(&mut self, color: u32) { self.framebuffer.fill(color); }
fn draw_pixel(&mut self, x: usize, y: usize, color: u32) {
if x < self.width && y < self.height {
self.framebuffer[y * self.width + x] = color;
}
}
fn draw_line(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, color: u32) {
let dx = (x1 - x0).abs(); let dy = -(y1 - y0).abs();
let mut err = dx + dy;
let (mut cx, mut cy) = (x0, y0);
loop {
self.draw_pixel(cx as usize, cy as usize, color);
if cx == x1 && cy == y1 { break; }
let e2 = 2 * err;
if e2 >= dy { err += dy; cx += if x0 < x1 { 1 } else { -1 }; }
if e2 <= dx { err += dx; cy += if y0 < y1 { 1 } else { -1 }; }
}
}
}
"#,
r#"
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
struct ConfigFile { log_level: String, max_retries: u32, timeout_ms: u64 }
fn load_config(path: &std::path::Path) -> Result<ConfigFile, Box<dyn std::error::Error>> {
let contents = std::fs::read_to_string(path)?;
let config: ConfigFile = toml::from_str(&contents)?;
Ok(config)
}
fn merge_configs(base: ConfigFile, overlay: ConfigFile) -> ConfigFile {
ConfigFile {
log_level: if overlay.log_level.is_empty() { base.log_level } else { overlay.log_level },
max_retries: overlay.max_retries.max(base.max_retries),
timeout_ms: overlay.timeout_ms.max(base.timeout_ms),
}
}
"#,
r#"
struct PhysicsBody { position: [f64; 3], velocity: [f64; 3], mass: f64 }
fn apply_gravity(bodies: &mut [PhysicsBody], dt: f64) {
let gravity_constant = 6.674e-11;
let len = bodies.len();
let mut forces = vec![[0.0f64; 3]; len];
for i in 0..len {
for j in (i+1)..len {
let dx = bodies[j].position[0] - bodies[i].position[0];
let dy = bodies[j].position[1] - bodies[i].position[1];
let dz = bodies[j].position[2] - bodies[i].position[2];
let dist_sq = dx*dx + dy*dy + dz*dz;
let force_mag = gravity_constant * bodies[i].mass * bodies[j].mass / dist_sq;
let dist = dist_sq.sqrt();
for k in 0..3 {
let f = force_mag * [dx, dy, dz][k] / dist;
forces[i][k] += f; forces[j][k] -= f;
}
}
}
for (body, force) in bodies.iter_mut().zip(forces.iter()) {
for k in 0..3 {
body.velocity[k] += force[k] / body.mass * dt;
body.position[k] += body.velocity[k] * dt;
}
}
}
"#,
r#"
use std::collections::BTreeMap;
struct CacheEntry<V> { value: V, frequency: u64, last_access: u64 }
struct LFUCache<K: Ord, V> { map: BTreeMap<K, CacheEntry<V>>, capacity: usize, clock: u64 }
impl<K: Ord, V> LFUCache<K, V> {
fn new(capacity: usize) -> Self { Self { map: BTreeMap::new(), capacity, clock: 0 } }
fn get(&mut self, key: &K) -> Option<&V> {
self.clock += 1;
let entry = self.map.get_mut(key)?;
entry.frequency += 1;
entry.last_access = self.clock;
Some(&entry.value)
}
fn insert(&mut self, key: K, value: V) {
self.clock += 1;
if self.map.len() >= self.capacity { self.evict(); }
self.map.insert(key, CacheEntry { value, frequency: 1, last_access: self.clock });
}
fn evict(&mut self) {
if let Some(victim) = self.map.keys().min_by_key(|k| {
let e = &self.map[*k]; (e.frequency, e.last_access)
}).cloned() { self.map.remove(&victim); }
}
}
"#,
r#"
fn tokenize_expression(input: &str) -> Vec<Token> {
let mut tokens = Vec::new();
let mut chars = input.chars().peekable();
while let Some(&ch) = chars.peek() {
match ch {
'0'..='9' => {
let mut num = String::new();
while let Some(&d) = chars.peek() {
if d.is_ascii_digit() || d == '.' { num.push(d); chars.next(); }
else { break; }
}
tokens.push(Token::Number(num.parse().unwrap()));
}
'+' => { tokens.push(Token::Plus); chars.next(); }
'-' => { tokens.push(Token::Minus); chars.next(); }
'*' => { tokens.push(Token::Star); chars.next(); }
'/' => { tokens.push(Token::Slash); chars.next(); }
'(' => { tokens.push(Token::LParen); chars.next(); }
')' => { tokens.push(Token::RParen); chars.next(); }
_ if ch.is_whitespace() => { chars.next(); }
_ => { chars.next(); }
}
}
tokens
}
"#,
r#"
use std::sync::mpsc;
use std::thread;
fn parallel_map<T: Send + 'static, R: Send + 'static>(
items: Vec<T>, num_threads: usize, f: fn(T) -> R
) -> Vec<R> {
let chunk_size = (items.len() + num_threads - 1) / num_threads;
let (tx, rx) = mpsc::channel();
let mut handles = Vec::new();
for (chunk_idx, chunk) in items.into_iter().collect::<Vec<_>>()
.chunks(chunk_size).enumerate()
{
let tx = tx.clone();
let chunk = chunk.to_vec();
handles.push(thread::spawn(move || {
for (i, item) in chunk.into_iter().enumerate() {
tx.send((chunk_idx * chunk_size + i, f(item))).unwrap();
}
}));
}
drop(tx);
let mut results: Vec<Option<R>> = vec![None; handles.len() * chunk_size];
for (idx, result) in rx { if idx < results.len() { results[idx] = Some(result); } }
for h in handles { h.join().unwrap(); }
results.into_iter().flatten().collect()
}
"#,
r#"
struct Compressor { window: Vec<u8>, window_size: usize }
impl Compressor {
fn new(window_size: usize) -> Self {
Self { window: Vec::with_capacity(window_size), window_size }
}
fn find_longest_match(&self, data: &[u8], pos: usize) -> (usize, usize) {
let mut best_offset = 0; let mut best_length = 0;
let start = pos.saturating_sub(self.window_size);
for offset in start..pos {
let mut length = 0;
while pos + length < data.len()
&& data[offset + length] == data[pos + length]
&& length < 258
{ length += 1; }
if length > best_length { best_offset = pos - offset; best_length = length; }
}
(best_offset, best_length)
}
fn compress(&mut self, data: &[u8]) -> Vec<u8> {
let mut output = Vec::new();
let mut pos = 0;
while pos < data.len() {
let (offset, length) = self.find_longest_match(data, pos);
if length >= 3 {
output.push(1); output.extend_from_slice(&(offset as u16).to_le_bytes());
output.push(length as u8); pos += length;
} else { output.push(0); output.push(data[pos]); pos += 1; }
}
output
}
}
"#,
];
struct FileState {
name: String,
token: String,
#[allow(dead_code)]
is_base: bool,
/// Epoch second when this file was last written (used to detect same-second
/// re-edits that wouldn't bump mtime and thus wouldn't invalidate the mmap).
last_write_sec: u64,
}
#[test]
fn fuzz_file_operations_stress() {
const SEED: u64 = 0xDEAD_BEEF_CAFE_1234;
const INITIAL_FILE_COUNT: usize = 40;
const NUM_ROUNDS: usize = 20;
let mut rng = SmallRng::seed_from_u64(SEED);
let tmp = TempDir::new().unwrap();
let base = tmp.path();
// Timing accumulators.
let mut t_sleep = Duration::ZERO;
let mut t_git = Duration::ZERO;
let mut t_bigram_wait = Duration::ZERO;
let mut t_grep_plain = Duration::ZERO;
let mut t_grep_regex = Duration::ZERO;
let mut t_fuzzy = Duration::ZERO;
let mut t_dead_check = Duration::ZERO;
let grep_plain_calls = std::sync::atomic::AtomicUsize::new(0);
let grep_regex_calls = std::sync::atomic::AtomicUsize::new(0);
let fuzzy_calls = std::sync::atomic::AtomicUsize::new(0);
let dead_calls = std::sync::atomic::AtomicUsize::new(0);
let test_start = std::time::Instant::now();
let mut live_files: Vec<FileState> = Vec::with_capacity(INITIAL_FILE_COUNT + NUM_ROUNDS);
let mut dead_tokens: Vec<String> = Vec::new();
let mut next_file_id: usize = 0;
for i in 0..INITIAL_FILE_COUNT {
let name = format!("seed_{i:04}.rs");
let token = format!("FUZZ_SEED_{i:04}");
write_diverse_file(base, &name, &token, i);
live_files.push(FileState {
name,
token,
is_base: true,
last_write_sec: 0, // set before index build, doesn't matter
});
next_file_id += 1;
}
let t0 = std::time::Instant::now();
git_init_and_commit(base);
t_git += t0.elapsed();
let shared_picker = SharedFilePicker::default();
FilePicker::new_with_shared_state(
shared_picker.clone(),
SharedFrecency::noop(),
FilePickerOptions {
watch: false, // we do not need the backgrodun monitor
base_path: base.to_string_lossy().to_string(),
enable_mmap_cache: true,
enable_content_indexing: true,
mode: FFFMode::Neovim,
..Default::default()
},
)
.expect("Failed to create FilePicker");
let t0 = std::time::Instant::now();
wait_for_bigram(&shared_picker);
t_bigram_wait += t0.elapsed();
// Sanity: all initial tokens findable via plain grep.
{
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
for fs in &live_files {
assert!(
grep_plain_count(picker, &fs.token) >= 1,
"initial sanity: plain grep should find token {} in {}",
fs.token,
fs.name
);
}
}
// Sleep so mtime advances past the scan snapshot timestamp.
let t0 = std::time::Instant::now();
std::thread::sleep(Duration::from_millis(1100));
t_sleep += t0.elapsed();
let mut op_counter: usize = 0;
for round in 0..NUM_ROUNDS {
let roll: u32 = rng.next_u32() % 100;
if roll < 40 && !live_files.is_empty() {
// ── EDIT existing file (40%) ──
let idx = rng.next_u32() as usize % live_files.len();
// on_create_or_modify uses mtime (seconds granularity) to decide
// whether to invalidate the mmap cache. If we re-edit a file in
// the same second it was last written, the mtime won't change and
// the stale cached content will be returned. Sleep to advance mtime.
let now_sec = epoch_secs();
if live_files[idx].last_write_sec >= now_sec {
let t0 = std::time::Instant::now();
std::thread::sleep(Duration::from_millis(1100));
t_sleep += t0.elapsed();
}
let old_token = live_files[idx].token.clone();
let new_token = format!("FUZZ_{round:02}_{op_counter:04}");
let name = &live_files[idx].name;
let domain_idx = rng.next_u32() as usize % DOMAINS.len();
write_diverse_file_with_domain(base, name, &new_token, domain_idx);
{
let mut guard = shared_picker.write().unwrap();
let picker = guard.as_mut().unwrap();
assert!(
picker.handle_create_or_modify(base.join(name)).is_some(),
"round {round}: on_create_or_modify({name}) should succeed for edit"
);
}
dead_tokens.push(old_token);
live_files[idx].token = new_token;
live_files[idx].last_write_sec = epoch_secs();
op_counter += 1;
} else if roll < 60 {
// ── CREATE new file (20%) ──
let name = format!("created_{next_file_id:04}.rs");
let token = format!("FUZZ_{round:02}_{op_counter:04}");
let domain_idx = rng.next_u32() as usize % DOMAINS.len();
write_diverse_file_with_domain(base, &name, &token, domain_idx);
{
let mut guard = shared_picker.write().unwrap();
let picker = guard.as_mut().unwrap();
assert!(
picker.handle_create_or_modify(base.join(&name)).is_some(),
"round {round}: on_create_or_modify({name}) should succeed for create"
);
}
live_files.push(FileState {
name,
token,
is_base: false,
last_write_sec: epoch_secs(),
});
next_file_id += 1;
op_counter += 1;
} else if roll < 75 && !live_files.is_empty() {
// ── DELETE existing file (15%) ──
let idx = rng.next_u32() as usize % live_files.len();
let removed = live_files.swap_remove(idx);
let path = base.join(&removed.name);
fs::remove_file(&path).unwrap();
{
let mut guard = shared_picker.write().unwrap();
let picker = guard.as_mut().unwrap();
assert!(
picker.remove_file_by_path(&path),
"round {round}: remove_file_by_path({}) should succeed",
removed.name
);
}
dead_tokens.push(removed.token);
op_counter += 1;
} else if roll < 85 && !live_files.is_empty() {
// ── RENAME file (10%) ──
let idx = rng.next_u32() as usize % live_files.len();
let old_name = live_files[idx].name.clone();
let old_path = base.join(&old_name);
let content = fs::read_to_string(&old_path).unwrap();
// Remove old file from disk + picker.
fs::remove_file(&old_path).unwrap();
{
let mut guard = shared_picker.write().unwrap();
let picker = guard.as_mut().unwrap();
picker.remove_file_by_path(&old_path);
}
// Create new file with same content but different name.
let new_name = format!("renamed_{next_file_id:04}.rs");
fs::write(base.join(&new_name), &content).unwrap();
{
let mut guard = shared_picker.write().unwrap();
let picker = guard.as_mut().unwrap();
assert!(
picker
.handle_create_or_modify(base.join(&new_name))
.is_some(),
"round {round}: on_create_or_modify({new_name}) should succeed for rename"
);
}
live_files[idx].name = new_name;
live_files[idx].is_base = false;
live_files[idx].last_write_sec = epoch_secs();
next_file_id += 1;
op_counter += 1;
}
// else: no-op / read-only (15%) — just run verification below.
// ── VERIFY after every round ──
{
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
for fs in &live_files {
// Plain text grep: every live token must be found.
let t0 = std::time::Instant::now();
let plain_count = grep_plain_count(picker, &fs.token);
t_grep_plain += t0.elapsed();
grep_plain_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
assert!(
plain_count >= 1,
"round {round}: plain grep should find live token {} in {} (got {plain_count})",
fs.token,
fs.name
);
// Regex grep: search with `{first5}.*{last5}` pattern.
let regex_pattern = build_regex_pattern(&fs.token);
let t0 = std::time::Instant::now();
let regex_count = grep_regex_count(picker, &regex_pattern);
t_grep_regex += t0.elapsed();
grep_regex_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
assert!(
regex_count >= 1,
"round {round}: regex grep '{}' should find live token {} in {} (got {regex_count})",
regex_pattern,
fs.token,
fs.name
);
// Fuzzy file search: every live file must be findable by name.
let stem = extract_stem(&fs.name);
let t0 = std::time::Instant::now();
let fuzzy_results = fuzzy_search_paths(picker, &stem);
t_fuzzy += t0.elapsed();
fuzzy_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
assert!(
fuzzy_results.iter().any(|p| p.contains(&fs.name)),
"round {round}: fuzzy search '{}' should find file {} in results: {:?}",
stem,
fs.name,
fuzzy_results
);
}
// Dead tokens must return 0 grep results.
for dead in &dead_tokens {
let t0 = std::time::Instant::now();
let count = grep_plain_count(picker, dead);
t_dead_check += t0.elapsed();
dead_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
assert_eq!(
count, 0,
"round {round}: dead token {dead} should NOT be findable (got {count})"
);
}
}
}
let total = test_start.elapsed();
let t_overhead = t_sleep + t_bigram_wait + t_git;
let t_search = t_grep_plain + t_grep_regex + t_fuzzy + t_dead_check;
let t_mutations = total.saturating_sub(t_overhead + t_search);
let n_grep_plain = grep_plain_calls.load(std::sync::atomic::Ordering::Relaxed);
let n_grep_regex = grep_regex_calls.load(std::sync::atomic::Ordering::Relaxed);
let n_fuzzy = fuzzy_calls.load(std::sync::atomic::Ordering::Relaxed);
let n_dead = dead_calls.load(std::sync::atomic::Ordering::Relaxed);
eprintln!("\n╔══════════════════════════════════════════════════════╗");
eprintln!("║ Fuzz Test Performance Breakdown ║");
eprintln!("╠══════════════════════════════════════════════════════╣");
eprintln!(
"║ Total wall time: {:>8.1}ms ║",
total.as_secs_f64() * 1000.0
);
eprintln!("║ ── Overhead ─────────────────────────────────────── ║");
eprintln!(
"║ Sleep (mtime waits): {:>8.1}ms ║",
t_sleep.as_secs_f64() * 1000.0
);
eprintln!(
"║ Git init+commit: {:>8.1}ms ║",
t_git.as_secs_f64() * 1000.0
);
eprintln!(
"║ Bigram index build+scan: {:>8.1}ms ║",
t_bigram_wait.as_secs_f64() * 1000.0
);
eprintln!(
"║ ── Search ({:>3} live files, {:>3} dead tokens) ────── ║",
live_files.len(),
dead_tokens.len()
);
eprintln!(
"║ Plain grep: {:>4} calls {:>8.1}ms ({:>6.1}µs/call) ║",
n_grep_plain,
t_grep_plain.as_secs_f64() * 1000.0,
t_grep_plain.as_secs_f64() * 1_000_000.0 / n_grep_plain.max(1) as f64
);
eprintln!(
"║ Regex grep: {:>4} calls {:>8.1}ms ({:>6.1}µs/call) ║",
n_grep_regex,
t_grep_regex.as_secs_f64() * 1000.0,
t_grep_regex.as_secs_f64() * 1_000_000.0 / n_grep_regex.max(1) as f64
);
eprintln!(
"║ Fuzzy find: {:>4} calls {:>8.1}ms ({:>6.1}µs/call) ║",
n_fuzzy,
t_fuzzy.as_secs_f64() * 1000.0,
t_fuzzy.as_secs_f64() * 1_000_000.0 / n_fuzzy.max(1) as f64
);
eprintln!(
"║ Dead checks: {:>4} calls {:>8.1}ms ({:>6.1}µs/call) ║",
n_dead,
t_dead_check.as_secs_f64() * 1000.0,
t_dead_check.as_secs_f64() * 1_000_000.0 / n_dead.max(1) as f64
);
eprintln!("║ ── Other ────────────────────────────────────────── ║");
eprintln!(
"║ Mutations + FS I/O: {:>8.1}ms ║",
t_mutations.as_secs_f64() * 1000.0
);
eprintln!("╚══════════════════════════════════════════════════════╝");
}
fn write_diverse_file(dir: &Path, name: &str, token: &str, index: usize) {
let domain_idx = index % DOMAINS.len();
write_diverse_file_with_domain(dir, name, token, domain_idx);
}
fn write_diverse_file_with_domain(dir: &Path, name: &str, token: &str, domain_idx: usize) {
let domain = DOMAINS[domain_idx % DOMAINS.len()];
let content = format!(
"// File: {name}\n\
// Domain content for bigram diversity\n\
{domain}\n\
// === Unique searchable token below ===\n\
const MARKER: &str = \"{token}\";\n\
fn marker_function_{token}() {{ println!(\"{token}\"); }}\n"
);
if let Some(parent) = PathBuf::from(name).parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(dir.join(parent)).unwrap();
}
}
fs::write(dir.join(name), content).unwrap();
}
// ═══════════════════════════════════════════════════════════════════════
// Search helpers
// ═══════════════════════════════════════════════════════════════════════
fn grep_plain_opts() -> GrepSearchOptions {
GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 200,
smart_case: true,
file_offset: 0,
page_limit: 500,
mode: GrepMode::PlainText,
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
abort_signal: None,
}
}
fn grep_regex_opts() -> GrepSearchOptions {
GrepSearchOptions {
mode: GrepMode::Regex,
..grep_plain_opts()
}
}
fn grep_plain_count(picker: &FilePicker, query: &str) -> usize {
let parsed = parse_grep_query(query);
picker.grep(&parsed, &grep_plain_opts()).matches.len()
}
fn grep_regex_count(picker: &FilePicker, regex_query: &str) -> usize {
let parsed = parse_grep_query(regex_query);
picker.grep(&parsed, &grep_regex_opts()).matches.len()
}
/// Build a regex pattern from a token: `{first5}.*{last5}`.
/// For tokens shorter than 10 chars, just use the literal (escaped).
fn build_regex_pattern(token: &str) -> String {
if token.len() >= 10 {
let first5 = &token[..5];
let last5 = &token[token.len() - 5..];
format!("{}.*{}", regex_escape(first5), regex_escape(last5))
} else {
regex_escape(token)
}
}
/// Escape regex metacharacters in a string.
fn regex_escape(s: &str) -> String {
let mut escaped = String::with_capacity(s.len() + 4);
for ch in s.chars() {
match ch {
'.' | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '\\' | '^' | '$' | '|' => {
escaped.push('\\');
escaped.push(ch);
}
_ => escaped.push(ch),
}
}
escaped
}
/// Extract a fuzzy-searchable stem from a filename.
/// Strips the extension and any leading path components, keeping the bare name.
fn extract_stem(name: &str) -> String {
let p = PathBuf::from(name);
p.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string()
}
fn fuzzy_search_paths(picker: &FilePicker, query: &str) -> Vec<String> {
let parser = QueryParser::default();
let parsed = parser.parse(query);
let result = picker.fuzzy_search(
&parsed,
None,
FuzzySearchOptions {
max_threads: 1,
pagination: PaginationArgs {
offset: 0,
limit: 200,
},
..Default::default()
},
);
result
.items
.iter()
.map(|f| f.relative_path(picker))
.collect()
}
fn wait_for_bigram(shared_picker: &SharedFilePicker) {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
std::thread::sleep(Duration::from_millis(50));
let ready = shared_picker
.read()
.ok()
.map(|guard| {
guard
.as_ref()
.map_or(false, |p| !p.is_scan_active() && p.bigram_index().is_some())
})
.unwrap_or(false);
if ready {
break;
}
assert!(
std::time::Instant::now() < deadline,
"Timed out waiting for bigram build"
);
}
}
fn git_run(dir: &Path, args: &[&str]) {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.output()
.unwrap_or_else(|e| panic!("git {:?} failed: {}", args, e));
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
}
fn epoch_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
fn git_init_and_commit(dir: &Path) {
git_run(dir, &["init"]);
git_run(dir, &["add", "-A"]);
git_run(dir, &["commit", "-m", "initial"]);
}
/// Proves that dropping the picker while post-scan (warmup + bigram build)
/// is actively iterating raw pointers does NOT segfault. The Drop impl
/// sets `cancelled`, waits for `post_scan_indexing_active` to clear, and
/// only then frees the backing Vec.
///
/// Runs 10 iterations to exercise the race window reliably.
#[test]
fn drop_during_post_scan_does_not_crash() {
let mut caught_active = 0u32;
for round in 0..10 {
let tmp = TempDir::new().unwrap();
let base = tmp.path();
// Create enough files so bigram build takes measurable time
for i in 0..2000 {
let dir = base.join(format!("d_{:02}", i % 20));
fs::create_dir_all(&dir).unwrap();
let content = format!(
"fn func_{i}() {{ let x = {i}; println!(\"{{x}}\"); }}\n\
const T_{i}: &str = \"TOKEN_{i}\";\n"
);
fs::write(dir.join(format!("f_{i:04}.rs")), content).unwrap();
}
git_init_and_commit(base);
let shared_picker = SharedFilePicker::default();
FilePicker::new_with_shared_state(
shared_picker.clone(),
SharedFrecency::noop(),
FilePickerOptions {
base_path: base.to_string_lossy().to_string(),
enable_mmap_cache: true,
enable_content_indexing: true,
watch: false,
mode: FFFMode::Neovim,
..Default::default()
},
)
.unwrap();
// Wait for scan but NOT for bigram — drop while post-scan is active
shared_picker.wait_for_scan(Duration::from_secs(10));
// Poll until post_scan_indexing_active is true (bigram started)
let deadline = std::time::Instant::now() + Duration::from_secs(5);
let mut was_active = false;
loop {
if let Ok(guard) = shared_picker.read() {
if let Some(picker) = guard.as_ref() {
if picker.is_post_scan_active() {
was_active = true;
break;
}
}
}
if std::time::Instant::now() > deadline {
break;
}
std::thread::sleep(Duration::from_millis(1));
}
if was_active {
caught_active += 1;
}
// Drop the picker while post_scan_indexing_active is set.
// Take it out of the shared handle first, then drop outside the lock —
// Drop spins until post-scan finishes, which needs the write lock for
// bigram install, so we can't hold it during Drop.
let old_picker = shared_picker.write().unwrap().take();
drop(old_picker); // Drop fires here — spins until post-scan exits
assert!(
shared_picker.read().unwrap().is_none(),
"round {round}: picker should be None after drop"
);
}
// 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
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+712
View File
@@ -0,0 +1,712 @@
//! Proptest-driven fuzz test against real GitHub repos with a live watcher.
//!
//! 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
#![cfg(stress)]
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use proptest::prelude::*;
use proptest::test_runner::{Config as ProptestConfig, FileFailurePersistence};
use fff_search::file_picker::{FFFMode, FilePicker, is_known_binary_extension};
use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query};
use fff_search::{FilePickerOptions, SharedFilePicker, SharedFrecency};
const REPO_POOL: &[(&str, &str)] = &[
("dmtrKovalenko/fff", "fff"),
("BurntSushi/ripgrep", "ripgrep"),
("sharkdp/fd", "fd"),
("ogham/exa", "exa"),
("casey/just", "just"),
("ajeetdsouza/zoxide", "zoxide"),
("helix-editor/helix", "helix"),
("astral-sh/ruff", "ruff"),
("biomejs/biome", "biome"),
("denoland/deno_lint", "deno_lint"),
("nickel-lang/nickel", "nickel"),
("typst/typst", "typst"),
("gleam-lang/gleam", "gleam"),
("pretzelhammer/rust-blog", "rust-blog"),
("tokio-rs/mini-redis", "mini-redis"),
];
const CACHE_DIR: &str = "/tmp/fff_fuzz_repos";
/// Fixed settle time for watcher event propagation.
const WATCHER_SETTLE: Duration = Duration::from_millis(100);
/// Maximum time to wait for watcher to process all pending events.
const CONVERGE_TIMEOUT: Duration = Duration::from_secs(30);
fn fuzz_cases() -> u32 {
std::env::var("FFF_FUZZ_CASES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(2)
}
fn fuzz_max_ops() -> usize {
std::env::var("FFF_FUZZ_MAX_OPS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(30)
}
fn fuzz_min_ops() -> usize {
std::env::var("FFF_FUZZ_MIN_OPS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(15)
}
fn ensure_repo_cloned(repo_url: &str, local_name: &str) -> PathBuf {
let cache = PathBuf::from(CACHE_DIR);
fs::create_dir_all(&cache).unwrap();
let repo_path = cache.join(local_name);
if repo_path.join(".git").exists() {
return repo_path;
}
let full_url = format!("https://github.com/{}.git", repo_url);
eprintln!(" Cloning {} ...", full_url);
let out = Command::new("git")
.args(["clone", "--depth=1", "--single-branch", &full_url])
.arg(&repo_path)
.output()
.expect("git clone failed");
assert!(
out.status.success(),
"git clone {} failed: {}",
full_url,
String::from_utf8_lossy(&out.stderr)
);
repo_path
}
fn copy_repo_to_workdir(cached: &Path, workdir: &Path) {
let out = Command::new("cp")
.args(["-r"])
.arg(cached)
.arg(workdir)
.output()
.expect("cp -r failed");
assert!(
out.status.success(),
"cp -r failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn collect_text_files(base: &Path) -> Vec<PathBuf> {
// Use `git ls-files` without --cached to get only files that are both
// tracked AND not gitignored. Files like Cargo.lock that are committed
// but in .gitignore would appear with --cached but the fff picker skips
// them during walk (respects .gitignore), causing false test failures.
let out = Command::new("git")
.args(["ls-files", "--others", "--exclude-standard", "-z"])
.current_dir(base)
.output()
.unwrap();
// Get tracked files that aren't ignored
let tracked = Command::new("git")
.args(["ls-files", "-z"])
.current_dir(base)
.output()
.unwrap();
// Check which tracked files are actually ignored
let ignored_check = Command::new("git")
.args(["check-ignore", "--stdin", "-z"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.current_dir(base)
.spawn();
let mut ignored_set: std::collections::HashSet<String> = std::collections::HashSet::new();
if let Ok(mut child) = ignored_check {
use std::io::Write;
if let Some(ref mut stdin) = child.stdin {
let _ = stdin.write_all(&tracked.stdout);
}
if let Ok(output) = child.wait_with_output() {
for path in output.stdout.split(|&b| b == 0) {
if !path.is_empty() {
if let Ok(s) = std::str::from_utf8(path) {
ignored_set.insert(s.to_string());
}
}
}
}
}
// Combine: tracked non-ignored non-binary files
let mut files: Vec<PathBuf> = Vec::new();
for path in tracked.stdout.split(|&b| b == 0) {
if path.is_empty() {
continue;
}
let Ok(s) = std::str::from_utf8(path) else {
continue;
};
if ignored_set.contains(s) {
continue;
}
let full = base.join(s);
if full.is_file() && !is_known_binary_extension(&full) {
files.push(full);
}
}
files
}
/// Edit a file by injecting a marker line at a deterministic position,
/// preserving the rest of the content. Returns the original line that was
/// replaced so it can be restored on revert.
fn inject_marker(path: &Path, marker: &str, seed: u32) -> Option<String> {
let content = fs::read_to_string(path).ok()?;
let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() {
fs::write(path, format!("// {marker}\n")).ok()?;
return Some(String::new());
}
// Pick a stable line position based on seed and file length
let line_idx = seed as usize % lines.len();
let original_line = lines[line_idx].to_string();
let mut result = String::with_capacity(content.len() + marker.len() + 10);
for (i, line) in lines.iter().enumerate() {
if i == line_idx {
result.push_str(&format!("// {marker}"));
} else {
result.push_str(line);
}
result.push('\n');
}
fs::write(path, &result).ok()?;
Some(original_line)
}
/// Revert a file by restoring the original line at the same position
/// where inject_marker placed the marker.
fn revert_marker(path: &Path, marker: &str, original_line: &str) {
let Ok(content) = fs::read_to_string(path) else {
return;
};
let marker_line = format!("// {marker}");
let result: String = content
.lines()
.map(|l| if l == marker_line { original_line } else { l })
.collect::<Vec<_>>()
.join("\n")
+ "\n";
let _ = fs::write(path, result);
}
fn grep_opts(mode: GrepMode) -> GrepSearchOptions {
GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 200,
smart_case: true,
file_offset: 0,
page_limit: 500,
mode,
time_budget_ms: 5000,
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
abort_signal: None,
}
}
fn grep_finds(picker: &FilePicker, query: &str, mode: GrepMode) -> bool {
let parsed = parse_grep_query(query);
let result = picker.grep(&parsed, &grep_opts(mode));
!result.matches.is_empty()
}
fn grep_file_list(picker: &FilePicker, query: &str, mode: GrepMode) -> Vec<String> {
let parsed = parse_grep_query(query);
let result = picker.grep(&parsed, &grep_opts(mode));
result
.files
.iter()
.map(|f| f.relative_path(picker))
.collect()
}
// ═══════════════════════════════════════════════════════════════════════════
// Infrastructure
// ═══════════════════════════════════════════════════════════════════════════
fn wait_for_bigram(sp: &SharedFilePicker) {
let deadline = Instant::now() + Duration::from_secs(120);
loop {
std::thread::sleep(Duration::from_millis(50));
let ready = sp
.read()
.ok()
.map(|g| {
g.as_ref()
.map_or(false, |p| !p.is_scan_active() && p.bigram_index().is_some())
})
.unwrap_or(false);
if ready {
return;
}
assert!(
Instant::now() < deadline,
"Timed out waiting for bigram index"
);
}
}
fn epoch_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
struct TrackedFile {
relative: String,
marker: String,
/// The original line content that was replaced, for revert
original_line: String,
is_created: bool,
last_write_sec: u64,
}
fn run_scenario(ops: &[Op]) {
// Stream fff logs at info+ level by default. Override with RUST_LOG.
let _ = tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn,fff_search=info")),
)
.with_test_writer()
.try_init();
// Allow forcing a specific repo via env for reproduction
let repo_idx = std::env::var("FFF_FUZZ_REPO_IDX")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or_else(|| ops.len() % REPO_POOL.len());
let (repo_url, local_name) = REPO_POOL[repo_idx];
eprintln!("=== fuzz_real_repos: repo={repo_url} ops={} ===", ops.len());
let scenario_start = Instant::now();
let cached = ensure_repo_cloned(repo_url, local_name);
let tmp = tempfile::TempDir::new().unwrap();
let workdir = tmp.path().join(local_name);
copy_repo_to_workdir(&cached, &workdir);
// Ensure target/ is gitignored
let gitignore = workdir.join(".gitignore");
let mut gi = fs::read_to_string(&gitignore).unwrap_or_default();
if !gi.contains("target/") {
gi.push_str("\ntarget/\n");
fs::write(&gitignore, &gi).unwrap();
}
let shared_picker = SharedFilePicker::default();
FilePicker::new_with_shared_state(
shared_picker.clone(),
SharedFrecency::noop(),
FilePickerOptions {
base_path: workdir.to_string_lossy().to_string(),
enable_mmap_cache: true,
enable_content_indexing: true,
watch: true,
mode: FFFMode::Neovim,
..Default::default()
},
)
.expect("FilePicker init");
let t0 = Instant::now();
wait_for_bigram(&shared_picker);
let bigram_ms = t0.elapsed().as_secs_f64() * 1000.0;
{
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let file_count = picker.get_files().len();
eprintln!(" indexed {file_count} files, bigram ready in {bigram_ms:.0}ms");
}
// Advance mtime past scan timestamp
std::thread::sleep(Duration::from_millis(1100));
let mut tracked: Vec<TrackedFile> = Vec::new();
let mut dead_markers: Vec<String> = Vec::new();
let mut ignored_markers: Vec<String> = Vec::new();
let mut ops_since_verify: usize = 0;
let mut text_files: Option<Vec<PathBuf>> = None;
for (op_idx, op) in ops.iter().enumerate() {
match op {
Op::CreateFile { seed } => {
let name = format!("fff_fuzz_new_{seed:08x}.rs");
let marker = format!("FFF_FUZZ_NEW_{seed:08x}");
// Marker appears only once on its own line
let content = format!("// {marker}\nfn placeholder() {{}}\n");
fs::write(workdir.join(&name), content).unwrap();
tracked.push(TrackedFile {
relative: name,
marker,
original_line: String::new(),
is_created: true,
last_write_sec: epoch_secs(),
});
ops_since_verify += 1;
}
Op::EditTracked { seed } => {
if tracked.is_empty() {
continue;
}
let idx = *seed as usize % tracked.len();
if tracked[idx].last_write_sec >= epoch_secs() {
std::thread::sleep(Duration::from_millis(1100));
}
let new_marker = format!("FFF_FUZZ_EDIT_{seed:08x}");
let path = workdir.join(&tracked[idx].relative);
// Replace the line containing our old marker with the new one
let old_marker_line = format!("// {}", tracked[idx].marker);
let content = fs::read_to_string(&path).unwrap_or_default();
let new_content = content
.lines()
.map(|l| {
if l == old_marker_line {
format!("// {new_marker}")
} else {
l.to_string()
}
})
.collect::<Vec<_>>()
.join("\n")
+ "\n";
fs::write(&path, new_content).unwrap();
dead_markers.push(tracked[idx].marker.clone());
tracked[idx].marker = new_marker;
tracked[idx].last_write_sec = epoch_secs();
ops_since_verify += 1;
}
Op::EditRandom { seed } => {
let files = text_files.get_or_insert_with(|| collect_text_files(&workdir));
if files.is_empty() {
continue;
}
let target = &files[*seed as usize % files.len()];
let relative = target
.strip_prefix(&workdir)
.unwrap()
.to_string_lossy()
.to_string();
if let Some(t) = tracked.iter().find(|t| t.relative == relative) {
if t.last_write_sec >= epoch_secs() {
std::thread::sleep(Duration::from_millis(1100));
}
}
let marker = format!("FFF_FUZZ_RAND_{seed:08x}");
// If already tracked, replace old marker line
if let Some(pos) = tracked.iter().position(|t| t.relative == relative) {
let old_marker_line = format!("// {}", tracked[pos].marker);
let content = fs::read_to_string(target).unwrap_or_default();
let new_content = content
.lines()
.map(|l| {
if l == old_marker_line {
format!("// {marker}")
} else {
l.to_string()
}
})
.collect::<Vec<_>>()
.join("\n")
+ "\n";
fs::write(target, new_content).unwrap();
dead_markers.push(tracked[pos].marker.clone());
tracked[pos].marker = marker;
tracked[pos].last_write_sec = epoch_secs();
} else {
// First edit: inject marker at a deterministic line
let original = inject_marker(target, &marker, *seed).unwrap_or_default();
tracked.push(TrackedFile {
relative,
marker,
original_line: original,
is_created: false,
last_write_sec: epoch_secs(),
});
}
ops_since_verify += 1;
}
Op::DeleteTracked => {
if tracked.is_empty() {
continue;
}
let removed = tracked.swap_remove(0);
let abs = workdir.join(&removed.relative);
if abs.exists() {
if removed.is_created {
fs::remove_file(&abs).ok();
} else {
let _ = Command::new("git")
.args(["rm", "-f", &removed.relative])
.current_dir(&workdir)
.output();
}
}
dead_markers.push(removed.marker);
text_files = None; // invalidate cache after deletion
ops_since_verify += 1;
}
Op::RevertTracked => {
// Revert a non-created tracked file using `git checkout`
// (restores original content, marker disappears)
let revertable = tracked.iter().position(|t| !t.is_created);
let Some(idx) = revertable else { continue };
if tracked[idx].last_write_sec >= epoch_secs() {
std::thread::sleep(Duration::from_millis(1100));
}
let _ = Command::new("git")
.args(["checkout", "--", &tracked[idx].relative])
.current_dir(&workdir)
.output();
let reverted = tracked.swap_remove(idx);
dead_markers.push(reverted.marker);
text_files = None; // invalidate cache after revert
ops_since_verify += 1;
}
Op::IgnoredBurst { count, seed } => {
let dir = workdir.join("target/debug/build");
fs::create_dir_all(&dir).unwrap();
for i in 0..*count {
let marker = format!("FFF_IGN_{seed:08x}_{i}");
fs::write(
dir.join(format!("ign_{seed:08x}_{i}.rs")),
format!("// {marker}\nfn {marker}() {{}}\n"),
)
.unwrap();
ignored_markers.push(marker);
}
ops_since_verify += 1;
}
Op::Verify => {
if tracked.is_empty() && dead_markers.is_empty() {
continue;
}
// Poll until the watcher has propagated all pending events:
// all live markers findable, all dead markers gone, no ignored leaks.
let modes = [
(GrepMode::PlainText, "Plain"),
(GrepMode::Regex, "Regex"),
(GrepMode::Fuzzy, "Fuzzy"),
];
let (mode, mode_name) = modes[op_idx % modes.len()];
let deadline = Instant::now() + CONVERGE_TIMEOUT;
let mut last_failure: Option<String> = None;
loop {
std::thread::sleep(WATCHER_SETTLE);
// Write trigger to force a watcher batch
let trigger = workdir.join("fff_fuzz_trigger.rs");
let _ = fs::write(&trigger, format!("// trigger {}\n", op_idx));
std::thread::sleep(WATCHER_SETTLE);
let mut all_ok = true;
// Check live markers (drop lock between each grep)
for tf in &tracked {
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let found = grep_finds(picker, &tf.marker, mode);
drop(guard);
if !found {
last_failure = Some(format!(
"{mode_name} grep for {:?} in {:?} not found\n\
is_created={} exists={} on_disk_has_marker={}",
tf.marker,
tf.relative,
tf.is_created,
workdir.join(&tf.relative).exists(),
fs::read_to_string(workdir.join(&tf.relative))
.map(|c| c.contains(&tf.marker))
.unwrap_or(false),
));
all_ok = false;
break;
}
}
// Check dead markers (only sample a few per iteration to
// avoid holding the lock too long with many dead markers)
if all_ok {
let sample_size = dead_markers.len().min(20);
for dead in dead_markers.iter().take(sample_size) {
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let found = grep_finds(picker, dead, GrepMode::PlainText);
drop(guard);
if found {
last_failure = Some(format!("dead marker {dead:?} still findable"));
all_ok = false;
break;
}
}
}
// Check ignored markers (sample first 5)
if all_ok {
for ig in ignored_markers.iter().take(5) {
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let files = grep_file_list(picker, ig, GrepMode::PlainText);
drop(guard);
if !files.is_empty() {
last_failure =
Some(format!("ignored marker {ig:?} found in {files:?}"));
all_ok = false;
break;
}
}
}
if all_ok {
eprintln!(
" op[{op_idx}] verify OK: {mode_name} mode, {} live, {} dead, {} ignored",
tracked.len(),
dead_markers.len(),
ignored_markers.len(),
);
break;
}
if Instant::now() >= deadline {
panic!(
"op[{op_idx}] verify TIMEOUT after {CONVERGE_TIMEOUT:?}:\n {}\n ops_since_last_verify={}",
last_failure.unwrap_or_default(),
ops_since_verify,
);
}
}
ops_since_verify = 0;
}
}
}
// Final convergence: poll until everything is consistent
let deadline = Instant::now() + CONVERGE_TIMEOUT;
loop {
std::thread::sleep(WATCHER_SETTLE);
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let live_ok = tracked
.iter()
.all(|tf| grep_finds(picker, &tf.marker, GrepMode::PlainText));
let dead_ok = dead_markers
.iter()
.all(|d| !grep_finds(picker, d, GrepMode::PlainText));
drop(guard);
if live_ok && dead_ok {
break;
}
assert!(
Instant::now() < deadline,
"final verify TIMEOUT: live_ok={live_ok} dead_ok={dead_ok}"
);
}
// Teardown
shared_picker.wait_for_indexing_complete(Duration::from_secs(30));
if let Ok(mut guard) = shared_picker.write() {
if let Some(mut picker) = guard.take() {
picker.stop_background_monitor();
}
}
eprintln!(
" PASSED: {} ops, {} tracked, {} dead, {} ignored ({:.1}s)",
ops.len(),
tracked.len(),
dead_markers.len(),
ignored_markers.len(),
scenario_start.elapsed().as_secs_f64(),
);
}
// ================
// Proptest harness
// =================
//
fn proptest_config() -> ProptestConfig {
ProptestConfig {
cases: fuzz_cases(),
max_shrink_iters: 0,
fork: false,
failure_persistence: Some(Box::new(FileFailurePersistence::Direct(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fuzz_real_repos.proptest-regressions",
)))),
..ProptestConfig::default()
}
}
#[derive(Debug, Clone)]
enum Op {
CreateFile { seed: u32 },
EditTracked { seed: u32 },
EditRandom { seed: u32 },
DeleteTracked,
RevertTracked,
IgnoredBurst { count: u8, seed: u32 },
Verify,
}
fn op_strategy() -> impl Strategy<Value = Op> {
prop_oneof![
12 => any::<u32>().prop_map(|s| Op::CreateFile { seed: s }),
18 => any::<u32>().prop_map(|s| Op::EditTracked { seed: s }),
18 => any::<u32>().prop_map(|s| Op::EditRandom { seed: s }),
8 => Just(Op::DeleteTracked),
10 => Just(Op::RevertTracked),
9 => (1u8..20, any::<u32>()).prop_map(|(c, s)| Op::IgnoredBurst { count: c, seed: s }),
// Explicit verification rounds
25 => Just(Op::Verify),
]
}
fn ops_strategy() -> impl Strategy<Value = Vec<Op>> {
let min = fuzz_min_ops();
let max = fuzz_max_ops();
prop::collection::vec(op_strategy(), min..=max)
}
proptest! {
#![proptest_config(proptest_config())]
#[test]
fn fuzz_real_repos_proptest(ops in ops_strategy()) {
run_scenario(&ops);
}
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More