Compare commits
203 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c4f84e198 | |||
| 67f27ace3c | |||
| f2c9559d1b | |||
| 366fa8ff15 | |||
| e538a4a419 | |||
| c1ec7ac6b9 | |||
| 3c75374fe4 | |||
| 8c25486c04 | |||
| 73c6b9e3c7 | |||
| c5f55c22b4 | |||
| 0143702531 | |||
| 15288542fd | |||
| dd35759eb0 | |||
| 7a97802cbe | |||
| 735dc17d5c | |||
| 733431e068 | |||
| 6f18b5ea04 | |||
| 6c46ee453e | |||
| 636cf61103 | |||
| cf171e067d | |||
| 60df2fcb33 | |||
| ec57eb02a6 | |||
| 373d240b13 | |||
| 4803002d91 | |||
| 7d7910b6ba | |||
| 6d5576e90e | |||
| 44ad95b430 | |||
| 535b44374e | |||
| 648f0168ec | |||
| 8092cfa3fa | |||
| c1d155afc0 | |||
| 8da90c6a93 | |||
| 0acbd18dce | |||
| 5f8c99dbca | |||
| 4e8f447811 | |||
| 467aff2f60 | |||
| b27368a0e6 | |||
| f56c38b5b8 | |||
| 6c7da240d1 | |||
| 88dbb84f04 | |||
| 1e055f9d7b | |||
| 4da1a22e14 | |||
| 0ee4ada512 | |||
| a0a93f2093 | |||
| c1a1ea6c2c | |||
| dab9246678 | |||
| 5bdc727e6d | |||
| 394a4dcb68 | |||
| 3e20e93d91 | |||
| 3bf2eea002 | |||
| ccb1b9d0c8 | |||
| ba8907f683 | |||
| a0c66d9bb1 | |||
| 785464eb9e | |||
| ae6d9e7bfe | |||
| 1055c05a89 | |||
| 5e53b6e8cf | |||
| 2853edf3f2 | |||
| 030a583b37 | |||
| ca6eae5f3c | |||
| 21a3078f69 | |||
| c23ccb39d7 | |||
| f26fe14fed | |||
| 5c3a615e76 | |||
| cf3dcf6d0b | |||
| 76dbd5fd47 | |||
| c84db53454 | |||
| 99f5160e1c | |||
| b5a7967fd2 | |||
| 3aaa9412cf | |||
| 800463c1b0 | |||
| fa1143b11b | |||
| d4f0331fb0 | |||
| 16b34f5eec | |||
| f30739b908 | |||
| 19ca421550 | |||
| 11dcdb589d | |||
| ffb39a8137 | |||
| 193a00a628 | |||
| 1db2b7de72 | |||
| a9be747c00 | |||
| 9a0deb39c3 | |||
| ee070aaecd | |||
| 6645a68ebc | |||
| e5cdf922fa | |||
| 97c1812668 | |||
| 398e6697bd | |||
| 4693adfe02 | |||
| d56006d26f | |||
| a5a2781bca | |||
| 1104a8deaf | |||
| a1efd5e011 | |||
| 39f67c0cf9 | |||
| b3e112de15 | |||
| 8cb1f4768b | |||
| bea54cca10 | |||
| ff817190d3 | |||
| e5662c1bf6 | |||
| f7daab7642 | |||
| ed0e84d4fc | |||
| e8dd50ce5a | |||
| 51d0583145 | |||
| bb4f337a78 | |||
| 4c5c92ac38 | |||
| 2f1e51e262 | |||
| 52b1e86b63 | |||
| 2e2a7a0ab9 | |||
| b94f90f923 | |||
| 362ba12517 | |||
| f401061fa6 | |||
| 3a52fc7c76 | |||
| c262af6923 | |||
| f73106cece | |||
| 24a4a7ae75 | |||
| 1bcbce2bc6 | |||
| 573a783d2f | |||
| bcd3c76285 | |||
| ca7bf03cfc | |||
| 37bda06c0c | |||
| 287d7b75a9 | |||
| 0f5ead1ab5 | |||
| 84ba575a3f | |||
| bca71efb32 | |||
| b23a0308eb | |||
| 2a03538e11 | |||
| e00b41df50 | |||
| 4b13a91aa5 | |||
| 5b0098a072 | |||
| 1b79eecb58 | |||
| 71c33e63b2 | |||
| acd2f0ce2f | |||
| 8298260c64 | |||
| 24d71a468a | |||
| a677d645fd | |||
| a24cf55c2b | |||
| 464f9d8b85 | |||
| 743ab60f48 | |||
| 4daf8bbc50 | |||
| 2201faee5f | |||
| 7bad9f2484 | |||
| 91545f832c | |||
| 8f23da638b | |||
| cfea91ce88 | |||
| cc9ce2501a | |||
| 2465c2cad6 | |||
| 59702f3c2e | |||
| e2ce56ae51 | |||
| ed4c60833b | |||
| cebacb3c35 | |||
| 689e31284a | |||
| c450a8d346 | |||
| 77881a1c92 | |||
| 7de55be63d | |||
| 2606dce6bc | |||
| 8912a1abdb | |||
| d25ea7562b | |||
| ea1f9802d7 | |||
| 07e90e5898 | |||
| 0fd47ab72d | |||
| 7979004d77 | |||
| 210bc9e1c7 | |||
| 205f9d6db9 | |||
| 406bf2531c | |||
| 42a12051d8 | |||
| 477a8a2c96 | |||
| 9a6ee3d18a | |||
| 29585a4aa3 | |||
| 1c2c0633cd | |||
| 5158ba64b8 | |||
| 51e0ef7a64 | |||
| cd0efe50d3 | |||
| 541c3f5722 | |||
| 59d626dacd | |||
| 29e6480ea0 | |||
| db4cd2825c | |||
| b1c4f8e7d7 | |||
| feaae7de28 | |||
| c2d76b5466 | |||
| d4b9d16073 | |||
| 094a35e435 | |||
| 697481fd29 | |||
| bb6f32a2ad | |||
| 335394f4b5 | |||
| 538c593b7b | |||
| 2dc8b30d92 | |||
| d54b17ba81 | |||
| cbf260d082 | |||
| 371d54a478 | |||
| e83b137be5 | |||
| eecb795a0e | |||
| 7dc1f86d71 | |||
| 38712e2607 | |||
| eb577ea4f3 | |||
| dd56a3a8a8 | |||
| aee5fbb8c4 | |||
| 85130958bd | |||
| a3f3e6a265 | |||
| b005c0a790 | |||
| 2ba8415039 | |||
| b1be35cc5f | |||
| 9ff925e31e | |||
| e64d2e2a55 | |||
| 2b6ace888d |
@@ -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
|
||||
@@ -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
|
||||
@@ -3,8 +3,14 @@ name: e2e Tests
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'doc/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'doc/**'
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -30,11 +36,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- name: Install Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
version: 0.16.0
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1.15.4
|
||||
@@ -90,6 +96,117 @@ jobs:
|
||||
shell: bash
|
||||
run: make test-lua
|
||||
|
||||
- name: Run bun tests
|
||||
- name: Dump fff trace log on failure
|
||||
if: failure()
|
||||
shell: bash
|
||||
run: make test-bun
|
||||
run: |
|
||||
echo "=== fff-test.log ==="
|
||||
if [ -f fff-test.log ]; then
|
||||
cat fff-test.log
|
||||
else
|
||||
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
|
||||
|
||||
@@ -3,8 +3,14 @@ name: Lua CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'doc/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'doc/**'
|
||||
|
||||
jobs:
|
||||
lua-ls:
|
||||
|
||||
@@ -3,8 +3,14 @@ name: Nix CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'doc/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'doc/**'
|
||||
|
||||
jobs:
|
||||
check:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- main
|
||||
schedule:
|
||||
- cron: "0 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
name: docs
|
||||
|
||||
jobs:
|
||||
@@ -9,27 +10,76 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
# fetch last 2 commits required for auto force push back
|
||||
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: Get last commit message
|
||||
id: last-commit
|
||||
run: |
|
||||
echo "message=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT
|
||||
echo "author=$(git log -1 --pretty=\"%an <%ae>\")" >> $GITHUB_OUTPUT
|
||||
- name: Cleanup intermediate file
|
||||
run: rm -f .panvimdoc-input.md
|
||||
|
||||
- uses: stefanzweifel/git-auto-commit-action@v6
|
||||
# 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:
|
||||
commit_author: ${{ steps.last-commit.outputs.author }}
|
||||
commit_message: "chore: Update docs for - ${{ steps.last-commit.outputs.message }}"
|
||||
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 }}"
|
||||
|
||||
@@ -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
|
||||
+325
-166
@@ -2,8 +2,17 @@ name: Prebuild
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, feat/mcp-ai]
|
||||
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
|
||||
@@ -14,54 +23,57 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
## Linux builds (using cargo-zigbuild)
|
||||
# Glibc 2.17 (RHEL 7, CentOS 7 compatible)
|
||||
# Glibc 2.31 (Ubuntu 20.04, Debian 11, RHEL 9).
|
||||
# Rust 1.91+ requires glibc >= 2.31 for std::sys::random::getrandom,
|
||||
# copy_file_range, and statx; earlier targets (2.17) no longer link.
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
zigbuild_target: x86_64-unknown-linux-gnu.2.17
|
||||
artifact_name: target/x86_64-unknown-linux-gnu/release/libfff_nvim.so
|
||||
zigbuild_target: x86_64-unknown-linux-gnu.2.31
|
||||
artifact_name: target/x86_64-unknown-linux-gnu/ci/libfff_nvim.so
|
||||
ext: so
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-gnu
|
||||
zigbuild_target: aarch64-unknown-linux-gnu.2.17
|
||||
artifact_name: target/aarch64-unknown-linux-gnu/release/libfff_nvim.so
|
||||
zigbuild_target: aarch64-unknown-linux-gnu.2.31
|
||||
artifact_name: target/aarch64-unknown-linux-gnu/ci/libfff_nvim.so
|
||||
ext: so
|
||||
# Musl (statically linked)
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
artifact_name: target/x86_64-unknown-linux-musl/release/libfff_nvim.so
|
||||
artifact_name: target/x86_64-unknown-linux-musl/ci/libfff_nvim.so
|
||||
ext: so
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
artifact_name: target/aarch64-unknown-linux-musl/release/libfff_nvim.so
|
||||
artifact_name: target/aarch64-unknown-linux-musl/ci/libfff_nvim.so
|
||||
ext: so
|
||||
|
||||
## Android (Termux)
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-linux-android
|
||||
artifact_name: target/aarch64-linux-android/release/libfff_nvim.so
|
||||
artifact_name: target/aarch64-linux-android/ci/libfff_nvim.so
|
||||
ext: so
|
||||
|
||||
## macOS builds
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
artifact_name: target/x86_64-apple-darwin/release/libfff_nvim.dylib
|
||||
artifact_name: target/x86_64-apple-darwin/ci/libfff_nvim.dylib
|
||||
ext: dylib
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
artifact_name: target/aarch64-apple-darwin/release/libfff_nvim.dylib
|
||||
artifact_name: target/aarch64-apple-darwin/ci/libfff_nvim.dylib
|
||||
ext: dylib
|
||||
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
artifact_name: target/x86_64-pc-windows-msvc/release/fff_nvim.dll
|
||||
artifact_name: target/x86_64-pc-windows-msvc/ci/fff_nvim.dll
|
||||
ext: dll
|
||||
- os: windows-latest
|
||||
target: aarch64-pc-windows-msvc
|
||||
artifact_name: target/aarch64-pc-windows-msvc/release/fff_nvim.dll
|
||||
artifact_name: target/aarch64-pc-windows-msvc/ci/fff_nvim.dll
|
||||
ext: dll
|
||||
|
||||
steps:
|
||||
@@ -73,9 +85,9 @@ jobs:
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Install Zig
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
version: 0.16.0
|
||||
|
||||
- name: Install cargo-zigbuild
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
@@ -84,7 +96,7 @@ jobs:
|
||||
- name: Build for Linux
|
||||
if: contains(matrix.os, 'ubuntu') && !contains(matrix.target, 'android')
|
||||
run: |
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-nvim --features zlob
|
||||
cargo zigbuild --profile ci --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-nvim --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for Android (Termux)
|
||||
@@ -98,13 +110,13 @@ jobs:
|
||||
export AR_aarch64_linux_android="$NDK_BIN/llvm-ar"
|
||||
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$NDK_BIN/aarch64-linux-android24-clang"
|
||||
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
cargo build --profile ci --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for macOS
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: |
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --profile ci --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Ad-hoc sign macOS binary
|
||||
@@ -115,7 +127,7 @@ jobs:
|
||||
if: contains(matrix.os, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
cargo build --profile ci --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Upload artifacts
|
||||
@@ -135,63 +147,63 @@ jobs:
|
||||
## Linux builds
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
zigbuild_target: x86_64-unknown-linux-gnu.2.17
|
||||
artifact_name: target/x86_64-unknown-linux-gnu/release/libfff_c.so
|
||||
npm_package: fff-bun-linux-x64-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.17
|
||||
artifact_name: target/aarch64-unknown-linux-gnu/release/libfff_c.so
|
||||
npm_package: fff-bun-linux-arm64-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/release/libfff_c.so
|
||||
npm_package: fff-bun-linux-x64-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/release/libfff_c.so
|
||||
npm_package: fff-bun-linux-arm64-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/release/libfff_c.so
|
||||
artifact_name: target/aarch64-linux-android/ci/libfff_c.so
|
||||
lib_filename: libfff_c.so
|
||||
ext: so
|
||||
|
||||
## macOS builds
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
artifact_name: target/x86_64-apple-darwin/release/libfff_c.dylib
|
||||
npm_package: fff-bun-darwin-x64
|
||||
artifact_name: target/x86_64-apple-darwin/ci/libfff_c.dylib
|
||||
npm_package: fff-bin-darwin-x64
|
||||
lib_filename: libfff_c.dylib
|
||||
ext: dylib
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
artifact_name: target/aarch64-apple-darwin/release/libfff_c.dylib
|
||||
npm_package: fff-bun-darwin-arm64
|
||||
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/release/fff_c.dll
|
||||
npm_package: fff-bun-win32-x64
|
||||
artifact_name: target/x86_64-pc-windows-msvc/ci/fff_c.dll
|
||||
npm_package: fff-bin-win32-x64
|
||||
lib_filename: fff_c.dll
|
||||
ext: dll
|
||||
- os: windows-latest
|
||||
target: aarch64-pc-windows-msvc
|
||||
artifact_name: target/aarch64-pc-windows-msvc/release/fff_c.dll
|
||||
npm_package: fff-bun-win32-arm64
|
||||
artifact_name: target/aarch64-pc-windows-msvc/ci/fff_c.dll
|
||||
npm_package: fff-bin-win32-arm64
|
||||
lib_filename: fff_c.dll
|
||||
ext: dll
|
||||
|
||||
@@ -204,9 +216,9 @@ jobs:
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Install Zig
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
version: 0.16.0
|
||||
|
||||
- name: Install cargo-zigbuild
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
@@ -215,7 +227,7 @@ jobs:
|
||||
- name: Build for Linux
|
||||
if: contains(matrix.os, 'ubuntu') && !contains(matrix.target, 'android')
|
||||
run: |
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-c --features zlob
|
||||
cargo zigbuild --profile ci --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for Android (Termux)
|
||||
@@ -228,13 +240,13 @@ jobs:
|
||||
export AR_aarch64_linux_android="$NDK_BIN/llvm-ar"
|
||||
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$NDK_BIN/aarch64-linux-android24-clang"
|
||||
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
cargo build --profile ci --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for macOS
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: |
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --profile ci --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Ad-hoc sign macOS binary
|
||||
@@ -245,7 +257,7 @@ jobs:
|
||||
if: contains(matrix.os, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
cargo build --profile ci --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Prepare npm package
|
||||
@@ -279,34 +291,34 @@ jobs:
|
||||
## Linux builds (using cargo-zigbuild)
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
zigbuild_target: x86_64-unknown-linux-gnu.2.17
|
||||
artifact_name: target/x86_64-unknown-linux-gnu/release/fff-mcp
|
||||
zigbuild_target: x86_64-unknown-linux-gnu.2.31
|
||||
artifact_name: target/x86_64-unknown-linux-gnu/ci/fff-mcp
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-gnu
|
||||
zigbuild_target: aarch64-unknown-linux-gnu.2.17
|
||||
artifact_name: target/aarch64-unknown-linux-gnu/release/fff-mcp
|
||||
zigbuild_target: aarch64-unknown-linux-gnu.2.31
|
||||
artifact_name: target/aarch64-unknown-linux-gnu/ci/fff-mcp
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
artifact_name: target/x86_64-unknown-linux-musl/release/fff-mcp
|
||||
artifact_name: target/x86_64-unknown-linux-musl/ci/fff-mcp
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
artifact_name: target/aarch64-unknown-linux-musl/release/fff-mcp
|
||||
artifact_name: target/aarch64-unknown-linux-musl/ci/fff-mcp
|
||||
|
||||
## macOS builds
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
artifact_name: target/x86_64-apple-darwin/release/fff-mcp
|
||||
artifact_name: target/x86_64-apple-darwin/ci/fff-mcp
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
artifact_name: target/aarch64-apple-darwin/release/fff-mcp
|
||||
artifact_name: target/aarch64-apple-darwin/ci/fff-mcp
|
||||
|
||||
## Windows builds
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
artifact_name: target/x86_64-pc-windows-msvc/release/fff-mcp.exe
|
||||
artifact_name: target/x86_64-pc-windows-msvc/ci/fff-mcp.exe
|
||||
- os: windows-latest
|
||||
target: aarch64-pc-windows-msvc
|
||||
artifact_name: target/aarch64-pc-windows-msvc/release/fff-mcp.exe
|
||||
artifact_name: target/aarch64-pc-windows-msvc/ci/fff-mcp.exe
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
@@ -317,9 +329,9 @@ jobs:
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Install Zig
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
version: 0.16.0
|
||||
|
||||
- name: Install cargo-zigbuild
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
@@ -328,13 +340,13 @@ jobs:
|
||||
- name: Build for Linux
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
run: |
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-mcp --features zlob
|
||||
cargo zigbuild --profile ci --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-mcp --features zlob
|
||||
cp "${{ matrix.artifact_name }}" "fff-mcp-${{ matrix.target }}"
|
||||
|
||||
- name: Build for macOS
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: |
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-mcp --features zlob
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --profile ci --target ${{ matrix.target }} -p fff-mcp --features zlob
|
||||
cp "${{ matrix.artifact_name }}" "fff-mcp-${{ matrix.target }}"
|
||||
|
||||
- name: Ad-hoc sign macOS binary
|
||||
@@ -345,7 +357,7 @@ jobs:
|
||||
if: contains(matrix.os, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-mcp --features zlob
|
||||
cargo build --profile ci --target ${{ matrix.target }} -p fff-mcp --features zlob
|
||||
cp "${{ matrix.artifact_name }}" "fff-mcp-${{ matrix.target }}.exe"
|
||||
|
||||
- name: Upload artifact
|
||||
@@ -354,17 +366,111 @@ jobs:
|
||||
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 --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]
|
||||
needs: [build-nvim, build-c, build-mcp, build-python, build-python-sdist]
|
||||
runs-on: ubuntu-latest
|
||||
# do not create releases on the forks (no permissions)
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.user.login == 'dmtrKovalenko'
|
||||
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:
|
||||
@@ -413,6 +519,20 @@ jobs:
|
||||
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: |
|
||||
@@ -422,31 +542,41 @@ jobs:
|
||||
working-directory: ./binaries
|
||||
run: |
|
||||
ls -la
|
||||
for file in *; do
|
||||
for file in * python/*; do
|
||||
if [ -f "$file" ] && [[ ! "$file" == *.sha256 ]]; then
|
||||
sha256sum "$file" > "${file}.sha256"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Prepare tag
|
||||
id: vars
|
||||
shell: bash
|
||||
- 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: |
|
||||
sha="$(git rev-parse --short HEAD)"
|
||||
echo "tag=$sha" >> $GITHUB_OUTPUT
|
||||
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.vars.outputs.tag }}"
|
||||
tag_name: "${{ steps.vars.outputs.tag }}"
|
||||
name: "${{ steps.version.outputs.version }}"
|
||||
tag_name: "${{ steps.version.outputs.release_tag }}"
|
||||
token: ${{ github.token }}
|
||||
files: ./binaries/*
|
||||
files: |
|
||||
./binaries/*
|
||||
./binaries/python/*
|
||||
draft: false
|
||||
prerelease: true
|
||||
generate_release_notes: false
|
||||
prerelease: ${{ steps.version.outputs.is_release != 'true' }}
|
||||
generate_release_notes: ${{ steps.version.outputs.is_release == 'true' }}
|
||||
body: |
|
||||
Nightly release from commit: ${{ github.sha }}
|
||||
${{ steps.version.outputs.is_release == 'true' && format('Release {0}', steps.version.outputs.version) || format('Nightly release from commit: {0}', github.sha) }}
|
||||
|
||||
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
|
||||
@@ -457,44 +587,117 @@ jobs:
|
||||
## MCP Server
|
||||
- `fff-mcp-{target}` - MCP server binary
|
||||
|
||||
Install with:
|
||||
## 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 }} [skip ci]"
|
||||
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' && startsWith(github.ref, 'refs/tags/v') && vars.PUBLISH_TO_PYPI == 'true') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.publish_pypi == true)
|
||||
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/feat/interchangable-ffi'))
|
||||
|| (github.event_name == 'pull_request' && (github.head_ref == 'main' || github.head_ref == 'feat/interchangable-ffi'))
|
||||
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: "20"
|
||||
node-version: "25"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
run: |
|
||||
# Read the base version from fff-core Cargo.toml (single source of truth)
|
||||
base_version=$(grep '^version' crates/fff-core/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
|
||||
short_sha=$(git rev-parse --short HEAD)
|
||||
|
||||
# Always publish as nightly prerelease: X.Y.Z-nightly.<short-sha>
|
||||
echo "version=${base_version}-nightly.${short_sha}" >> $GITHUB_OUTPUT
|
||||
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
echo "tag=nightly" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tag=dev" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
run: lua scripts/determine-version.lua
|
||||
|
||||
- name: Download npm package artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -503,98 +706,54 @@ jobs:
|
||||
path: ./npm-packages
|
||||
|
||||
- name: Publish platform packages
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
|
||||
TAG="${{ steps.version.outputs.npm_tag }}"
|
||||
|
||||
for pkg_dir in ./npm-packages/npm-*/; do
|
||||
if [ -d "$pkg_dir" ]; then
|
||||
pkg_name=$(node -p "require('${pkg_dir}package.json').name")
|
||||
echo "Publishing ${pkg_name}@${VERSION} with tag ${TAG}..."
|
||||
|
||||
# Update version in package.json
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('${pkg_dir}package.json', 'utf8'));
|
||||
pkg.version = '${VERSION}';
|
||||
fs.writeFileSync('${pkg_dir}package.json', JSON.stringify(pkg, null, 2) + '\n');
|
||||
"
|
||||
|
||||
|
||||
make set-npm-version PKG="$pkg_dir" VERSION="$VERSION"
|
||||
|
||||
cd "$pkg_dir"
|
||||
npm publish --tag "$TAG" --access public || echo "Failed to publish ${pkg_name} (may already exist)"
|
||||
npm publish --tag "$TAG" --access public --provenance
|
||||
cd -
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Publish main package
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
- name: Publish bun package
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
|
||||
TAG="${{ steps.version.outputs.npm_tag }}"
|
||||
|
||||
echo "Publishing @ff-labs/fff-bun@${VERSION} with tag ${TAG}..."
|
||||
|
||||
# Update version and optionalDependencies versions in the main package
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('./packages/fff-bun/package.json', 'utf8'));
|
||||
pkg.version = '${VERSION}';
|
||||
if (pkg.optionalDependencies) {
|
||||
for (const dep of Object.keys(pkg.optionalDependencies)) {
|
||||
pkg.optionalDependencies[dep] = '${VERSION}';
|
||||
}
|
||||
}
|
||||
fs.writeFileSync('./packages/fff-bun/package.json', JSON.stringify(pkg, null, 2) + '\n');
|
||||
"
|
||||
|
||||
make set-npm-version PKG=packages/fff-bun VERSION="$VERSION"
|
||||
|
||||
cd packages/fff-bun
|
||||
npm publish --tag "$TAG" --access public || echo "Failed to publish @ff-labs/fff-bun (may already exist)"
|
||||
npm publish --tag "$TAG" --access public --provenance
|
||||
|
||||
comment-on-pr:
|
||||
name: Comment on PR
|
||||
needs: [build-nvim, build-c, build-mcp]
|
||||
runs-on: ubuntu-latest
|
||||
# comments doesn't work on forks
|
||||
if: github.event_name == 'pull_request' && github.repository == 'dmtrKovalenko/fff.nvim' && github.event.pull_request.user.login == 'dmtrKovalenko'
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Get short SHA
|
||||
id: vars
|
||||
run: echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
|
||||
- name: Publish Node.js package
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
TAG="${{ steps.version.outputs.npm_tag }}"
|
||||
|
||||
- name: Find existing comment
|
||||
uses: peter-evans/find-comment@v3
|
||||
id: find-comment
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-author: "github-actions[bot]"
|
||||
body-includes: "<!-- fff-nvim-build-comment -->"
|
||||
echo "Publishing @ff-labs/fff-node@${VERSION} with tag ${TAG}..."
|
||||
make set-npm-version PKG=packages/fff-node VERSION="$VERSION"
|
||||
|
||||
- name: Create or update PR comment
|
||||
uses: peter-evans/create-or-update-comment@v4
|
||||
with:
|
||||
comment-id: ${{ steps.find-comment.outputs.comment-id }}
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
edit-mode: replace
|
||||
body: |
|
||||
<!-- fff-nvim-build-comment -->
|
||||
## Build Artifacts for your PR
|
||||
cd packages/fff-node
|
||||
npm install
|
||||
npm run build
|
||||
npm publish --tag "$TAG" --access public --provenance
|
||||
|
||||
### Neovim Plugin
|
||||
Test with lazy.nvim:
|
||||
```lua
|
||||
{
|
||||
"dmtrKovalenko/fff.nvim",
|
||||
tag = "${{ steps.vars.outputs.short_sha }}",
|
||||
}
|
||||
```
|
||||
- name: Publish pi-fff package
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
TAG="${{ steps.version.outputs.npm_tag }}"
|
||||
|
||||
### Bun/TypeScript Package
|
||||
The `fff` npm package will download binaries from this release automatically.
|
||||
echo "Publishing @ff-labs/pi-fff@${VERSION} with tag ${TAG}..."
|
||||
make set-npm-version PKG=packages/pi-fff VERSION="$VERSION"
|
||||
|
||||
---
|
||||
*Built from ${{ github.sha }}*
|
||||
cd packages/pi-fff
|
||||
npm publish --tag "$TAG" --access public --provenance
|
||||
|
||||
@@ -3,8 +3,14 @@ name: Rust CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'doc/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'doc/**'
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -17,8 +23,12 @@ jobs:
|
||||
name: Test
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
# Guard against deadlocks in the shared-picker / watcher teardown
|
||||
# path: a stuck test would otherwise consume a full 6h CI slot.
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
@@ -26,7 +36,7 @@ jobs:
|
||||
- name: Install Zig
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
version: 0.16.0
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1.15.4
|
||||
@@ -37,9 +47,59 @@ jobs:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cargo test --verbose -p fff-core -p fff-query-parser -p fff-c --features zlob
|
||||
cargo test --verbose -p grep-searcher
|
||||
run: cargo test --features zlob --workspace --exclude fff-nvim
|
||||
|
||||
stress-test:
|
||||
name: Stress Test (Watcher + Git)
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
# Keep going after one OS fails so we can see whether a bug
|
||||
# reproduces everywhere or is platform-specific.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
# Long-running; don't let a stuck watcher thread burn a full CI
|
||||
# timeout. Two scenarios should finish well under this limit.
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Zig
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
with:
|
||||
version: 0.16.0
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1.15.4
|
||||
with:
|
||||
cache: true
|
||||
cache-on-failure: true
|
||||
cache-key: "v1-rust-stress-${{ matrix.os }}"
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Stress test (seeded / deterministic)
|
||||
shell: bash
|
||||
run: make test-stress-seeded
|
||||
env:
|
||||
FFF_STRESS_CASES: "3"
|
||||
FFF_STRESS_MIN_OPS: "30"
|
||||
FFF_STRESS_MAX_OPS: "50"
|
||||
|
||||
- name: Stress test (random / fuzzy)
|
||||
shell: bash
|
||||
run: make test-stress-random
|
||||
env:
|
||||
FFF_STRESS_CASES: "5"
|
||||
FFF_STRESS_MIN_OPS: "30"
|
||||
FFF_STRESS_MAX_OPS: "60"
|
||||
|
||||
- name: Upload proptest regressions on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: proptest-regressions-${{ matrix.os }}
|
||||
path: crates/fff-core/tests/fuzz_git_watcher_stress.proptest-regressions
|
||||
if-no-files-found: ignore
|
||||
|
||||
fmt:
|
||||
name: cargo fmt
|
||||
@@ -64,7 +124,7 @@ jobs:
|
||||
- name: Install Zig
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
version: 0.16.0
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
@@ -73,6 +133,4 @@ jobs:
|
||||
components: clippy
|
||||
|
||||
- name: Run clippy
|
||||
run: |
|
||||
cargo clippy -p fff-core -p fff-query-parser -p fff-nvim -p fff-c --features zlob -- -D warnings
|
||||
cargo clippy -p grep-searcher -- -D warnings
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
+42
-1
@@ -1,4 +1,5 @@
|
||||
doc/tags
|
||||
big-repo
|
||||
target/
|
||||
.archive.lua
|
||||
_*.lua
|
||||
@@ -10,10 +11,12 @@ result
|
||||
.repro/
|
||||
.wrangler/
|
||||
*.so
|
||||
big-repo/
|
||||
*.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/
|
||||
@@ -22,3 +25,41 @@ scripts/benchmark-results/
|
||||
*.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
|
||||
*~
|
||||
|
||||
+2
-2
@@ -5,14 +5,14 @@
|
||||
},
|
||||
"workspace": {
|
||||
"library": [
|
||||
"/opt/homebrew/share/nvim/runtime/lua/vim/_meta",
|
||||
"/opt/homebrew/share/nvim/runtime/lua/vim/shared.lua",
|
||||
"$VIMRUNTIME/lua",
|
||||
"${3rd}/luv/library",
|
||||
"${3rd}/busted/library"
|
||||
],
|
||||
"checkThirdParty": false
|
||||
},
|
||||
"diagnostics": {
|
||||
"globals": ["vim"],
|
||||
"severity": {
|
||||
"undefined-global": "Error",
|
||||
"undefined-field": "Warning",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"mcpServers": {
|
||||
"fff": {
|
||||
"type": "stdio",
|
||||
"command": "/Users/neogoose/dev/fff.nvim/target/release/fff-mcp",
|
||||
"command": "./target/release/fff-mcp",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# To Clankers
|
||||
|
||||
This repository contains **FFF.nvim (Fast File Finder)**, a high-performance file picker for Neovim inspired by blink.cmp's fuzzy matching technology. It's NOT a completion plugin, but rather a standalone file finder with advanced fuzzy search and frecency scoring. The project aims to be the drop-in replacement for telescope, fzf-lua, snacks.picker and similar plugins, focusing on speed, accuracy search and usability features.
|
||||
|
||||
## Development Commands
|
||||
|
||||
Always prefer Makefile commands listed to the cargo/bun/node if possible.
|
||||
|
||||
### Building
|
||||
|
||||
- `make build` - build everything
|
||||
|
||||
### Testing and Development Tools
|
||||
|
||||
This project does not have a traditional test suite. Testing is done through:
|
||||
|
||||
- Create e2e local test file for Neovim: Load any Lua test file with `nvim -l <test_file>`
|
||||
- Write inline rust unit tests for any functionality that is standalone and scoped within a single function
|
||||
|
||||
### Code Quality
|
||||
|
||||
- `make lint` - Rust linting and code analysis
|
||||
- `make format` - Format all code
|
||||
- `make test` - Run unit tests (limited coverage, primarily integration testing)
|
||||
|
||||
When doing code make sure to REDUCE SIZE OF COMMENTS. This is very important. Every comment should be concise 1-2 liner maximum 4 lines if describes really extensive and unnatural concept.
|
||||
|
||||
### Important coding rules
|
||||
|
||||
- Do not add doc comments to the private structs and functions.
|
||||
- Do not make public structs if something can be private
|
||||
|
||||
|
||||
## Architecture
|
||||
|
||||
Everything that is performance critical happens in rust world, everything that is neovim specific happens in the lua code.
|
||||
|
||||
There are 3 main components:
|
||||
|
||||
- Rust binary with the global file picker state containing index of all files
|
||||
- Background thread with the file system watcher that updates the index in real time
|
||||
- Lua UI layer that renders the picker, handles user input, and calls the rust functions via FFI
|
||||
|
||||
There are 2 databases:
|
||||
|
||||
- Frecency database (LMDB) that tracks file access patterns for scoring
|
||||
- Query history database used to track the user's previous search queries
|
||||
|
||||
### Key Files
|
||||
|
||||
- `lua/fff.lua` - Entry point, delegates to main.lua
|
||||
- `lua/fff/main.lua` - Public API (find_files, search, change_directory)
|
||||
- `lua/fff/core.lua` - Initialization, autocmds, global state management
|
||||
- `lua/fff/picker_ui.lua` - UI rendering, layout calculation, keymaps
|
||||
- `lua/fff/file_picker/preview.lua` - File preview with syntax highlighting
|
||||
- `lua/fff/file_picker/image.lua` - Image preview (snacks.nvim integration)
|
||||
- `lua/fff/conf.lua` - Default config
|
||||
- `lua/fff/rust/init.lua` - Loads compiled Rust shared library
|
||||
|
||||
**Rust Side:**
|
||||
|
||||
- `lua/fff/rust/lib.rs` - FFI bindings, global state (FILE_PICKER, FRECENCY)
|
||||
- `lua/fff/rust/file_picker.rs` - Core FilePicker struct, indexing, background watcher
|
||||
- `lua/fff/rust/frecency.rs` - Frecency database (LMDB) and scoring
|
||||
- `lua/fff/rust/query_tracker.rs` - Search query history tracking
|
||||
- `lua/fff/rust/score.rs` - Fuzzy match scoring with frizbee integration
|
||||
- `lua/fff/rust/git.rs` - Git status caching and repository detection
|
||||
- `lua/fff/rust/background_watcher.rs` - File system watcher thread
|
||||
|
||||
### Scoring Algorithm
|
||||
|
||||
Located at the score.rs file
|
||||
|
||||
### Build System
|
||||
|
||||
- `Cargo.toml` - Rust dependencies and build configuration (package name: `fff_nvim`)
|
||||
- `rust-toolchain.toml` - Specifies Rust nightly toolchain with required components
|
||||
- `Cross.toml` - Cross-compilation settings using Zig for Linux targets
|
||||
- **CI/CD Workflows**:
|
||||
- `.github/workflows/rust.yml` - Rust testing, formatting, and clippy checks
|
||||
- `.github/workflows/release.yaml` - Automated multi-platform builds
|
||||
- `.github/workflows/stylua.yaml` - Lua code formatting validation
|
||||
- `.github/workflows/nix.yml` - Nix build validation
|
||||
- **Cross-compilation Support**: Uses `cross` tool with Zig backend for efficient cross-compilation
|
||||
|
||||
## Development Notes
|
||||
|
||||
### Working with Rust Code
|
||||
|
||||
- Prefer struct methods over functions
|
||||
- If there is more than 2 impls in the file - create new file
|
||||
- Smaller concise comments over giant comment blocks
|
||||
- Do not add doc comments to the private functions/structs
|
||||
- Be very careful around locking and better double check with the human if something is going to require potentially long lock on a mutex/rwlock
|
||||
|
||||
### Working with lua code
|
||||
|
||||
- Document the types of public functions in every module
|
||||
- Use `vim.validate()` for validating user inputs in public functions
|
||||
- Try to reuse as much of existing functions as possible
|
||||
- When working on new features for the UI **IT IS EXTREMELY IMPORTANT** to keep the core functionality of navigating between files, selecting, and seeing the preview working as is. NEVER break anything from the core UI functionality, only add new features on top of the current UI.
|
||||
- When making a large chunk of code make lua test that opens neovim at `~/dev/lightsource` and opens the picker to test the ui functionality across the actual code.
|
||||
- When adding a new highlights or any new shortcuts and configurable UI options add them to the neovim config. AND IMPORTANT: update the README.md with the new configuration options.
|
||||
|
||||
### UI rendering
|
||||
|
||||
When working on the UI changeds IT IS EXTREMELY important for you to test it for both prompt_position="bottom" and prompt_position="top" as the rendering logic is different for both of them in both rust and lua world. When the prompt is positioed in the bottom everything should work the same way as the top but would be reversed in order. (though navigation is same for both)
|
||||
|
||||
## Top level API that can not introduce breaking changes under any circumstance
|
||||
|
||||
Top level rust, lua, C, and bun APIs can not be changed under any circumstance
|
||||
Generated
+922
-684
File diff suppressed because it is too large
Load Diff
+28
-14
@@ -4,15 +4,19 @@ members = [
|
||||
"crates/fff-core",
|
||||
"crates/fff-mcp",
|
||||
"crates/fff-nvim",
|
||||
"crates/fff-python",
|
||||
"crates/fff-query-parser",
|
||||
"crates/fff-searcher",
|
||||
"crates/fff-grep",
|
||||
]
|
||||
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
fff-grep = { version = "0.9.4", path = "crates/fff-grep" }
|
||||
fff-query-parser = { version = "0.9.4", path = "crates/fff-query-parser", default-features = false }
|
||||
|
||||
# Shared dependencies
|
||||
ahash = "0.8"
|
||||
bindet = "0.3"
|
||||
blake3 = "1.8.2"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
ctrlc = "3.4.2"
|
||||
@@ -24,28 +28,24 @@ git2 = { version = "0.20.2", default-features = false, features = [
|
||||
] }
|
||||
glidesort = "0.1"
|
||||
globset = "0.4"
|
||||
grep-matcher = "0.1.8"
|
||||
grep-searcher = { path = "crates/fff-searcher" }
|
||||
heed = "0.22.0"
|
||||
ignore = "0.4.22"
|
||||
memmap2 = "0.9"
|
||||
mimalloc = "0.1.47"
|
||||
zlob = "1.3.0"
|
||||
signal-hook-registry = "1.4"
|
||||
zlob = "1.4.1"
|
||||
|
||||
mlua = { version = "0.11.1", features = ["module", "luajit"] }
|
||||
neo_frizbee = "0.8.2"
|
||||
notify = "8.1.0"
|
||||
notify-debouncer-full = "0.7"
|
||||
neo_frizbee = { version = "0.10.2", 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"
|
||||
smallvec = { version = "1.13", features = [
|
||||
"const_generics",
|
||||
"union",
|
||||
"may_dangle",
|
||||
] }
|
||||
regex-syntax = "0.8"
|
||||
smallvec = { version = "1.13", features = ["const_generics", "union"] }
|
||||
thiserror = "2.0.10"
|
||||
tracing = "0.1"
|
||||
|
||||
@@ -53,7 +53,21 @@ tracing = "0.1"
|
||||
opt-level = 3
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
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"
|
||||
|
||||
@@ -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.4"
|
||||
|
||||
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 "90a7007d378583531cb3ca03037303ce0bd1ec7b31ca86a5b90d5683440df5b3"
|
||||
end
|
||||
|
||||
on_intel do
|
||||
url "#{RELEASE_BASE}/v#{version}/fff-mcp-x86_64-apple-darwin"
|
||||
sha256 "20a91c0421ac05b9d32f0349ff147e5d0ad118ce6cd8a831ba9a873d98676cfd"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
on_arm do
|
||||
url "#{RELEASE_BASE}/v#{version}/fff-mcp-aarch64-unknown-linux-gnu"
|
||||
sha256 "9d942894be613901ddea645337dcdd47e30ef78bf5a80530e01fe9b25b56c925"
|
||||
end
|
||||
|
||||
on_intel do
|
||||
url "#{RELEASE_BASE}/v#{version}/fff-mcp-x86_64-unknown-linux-gnu"
|
||||
sha256 "b5dfc5b3c17cb35b681022a2b318ebc4e280d7c8c9b517177befdb79a31d6ec3"
|
||||
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
|
||||
@@ -1,38 +1,270 @@
|
||||
PLENARY_DIR ?= ../plenary.nvim
|
||||
MINI_DIR ?= ../mini.nvim
|
||||
|
||||
.PHONY: build test test-rust test-lua test-bun test-setup prepare-bun
|
||||
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 -ec
|
||||
|
||||
.PHONY: build build-c-lib install uninstall test test-rust test-c-smoke test-c-api test-lua test-lua-snap test-version test-bun test-node prepare-bun prepare-bun-packaged prepare-node set-npm-version header test-stress test-stress-seeded test-stress-random test-stress-repos test-node-stress sync-js-api sync-js-api-check bump-homebrew-formula bump-install-mcp-sh test-bun-compile
|
||||
|
||||
all: format test lint
|
||||
|
||||
# Single source of truth for the shared FileFinder TS interface lives in
|
||||
# packages/shared/fff-api.ts. tsc cannot import across a package's
|
||||
# rootDir and the bun package publishes its raw src/, so the file is copied
|
||||
# into each package instead of symlinked.
|
||||
SYNC_API_SRC := packages/shared/fff-api.ts
|
||||
SYNC_API_TARGETS := packages/fff-node/src/fff-api.ts packages/fff-bun/src/fff-api.ts
|
||||
SYNC_API_BANNER := // ----------------------------------------------------------------------------\n// GENERATED FILE - DO NOT EDIT.\n// Source of truth: packages/shared/fff-api.ts\n// Run make sync-js-api from the repo root to regenerate.\n// ----------------------------------------------------------------------------\n\n
|
||||
|
||||
sync-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 --features zlob
|
||||
|
||||
build-c-lib:
|
||||
cargo build --release -p fff-c --features zlob
|
||||
|
||||
header:
|
||||
cbindgen --config crates/fff-c/cbindgen.toml --crate fff-c --output crates/fff-c/include/fff.h
|
||||
|
||||
# Install the C library and header under $(PREFIX) (default /usr/local).
|
||||
# Override PREFIX for user-local installs, e.g. `make install PREFIX=$$HOME/.local`.
|
||||
# DESTDIR is honoured for packagers.
|
||||
install: build-c-lib
|
||||
install -d $(DESTDIR)$(LIBDIR)
|
||||
install -d $(DESTDIR)$(INCLUDEDIR)
|
||||
install -m 0644 crates/fff-c/include/fff.h $(DESTDIR)$(INCLUDEDIR)/fff.h
|
||||
@if [ -f target/release/libfff_c.dylib ]; then \
|
||||
install -m 0755 target/release/libfff_c.dylib $(DESTDIR)$(LIBDIR)/libfff_c.dylib; \
|
||||
echo "Installed $(DESTDIR)$(LIBDIR)/libfff_c.dylib"; \
|
||||
fi
|
||||
@if [ -f target/release/libfff_c.so ]; then \
|
||||
install -m 0755 target/release/libfff_c.so $(DESTDIR)$(LIBDIR)/libfff_c.so; \
|
||||
echo "Installed $(DESTDIR)$(LIBDIR)/libfff_c.so"; \
|
||||
fi
|
||||
@if [ -f target/release/fff_c.dll ]; then \
|
||||
install -m 0755 target/release/fff_c.dll $(DESTDIR)$(LIBDIR)/fff_c.dll; \
|
||||
echo "Installed $(DESTDIR)$(LIBDIR)/fff_c.dll"; \
|
||||
fi
|
||||
@echo "Installed header $(DESTDIR)$(INCLUDEDIR)/fff.h"
|
||||
|
||||
uninstall:
|
||||
rm -f $(DESTDIR)$(LIBDIR)/libfff_c.dylib
|
||||
rm -f $(DESTDIR)$(LIBDIR)/libfff_c.so
|
||||
rm -f $(DESTDIR)$(LIBDIR)/fff_c.dll
|
||||
rm -f $(DESTDIR)$(INCLUDEDIR)/fff.h
|
||||
@echo "Removed fff-c from $(DESTDIR)$(PREFIX)"
|
||||
|
||||
test-setup:
|
||||
@if [ ! -d "$(PLENARY_DIR)" ]; then \
|
||||
echo "Cloning plenary.nvim..."; \
|
||||
git clone --depth 1 https://github.com/nvim-lua/plenary.nvim $(PLENARY_DIR); \
|
||||
fi
|
||||
|
||||
test-rust:
|
||||
cargo test --workspace --features zlob
|
||||
|
||||
test-lua: test-setup build
|
||||
nvim --headless -u tests/minimal_init.lua \
|
||||
-c "PlenaryBustedFile tests/fff_core_spec.lua" 2>&1
|
||||
|
||||
prepare-bun: build
|
||||
mkdir -p packages/fff-bun/bin
|
||||
cp target/release/libfff_c.dylib packages/fff-bun/bin/ 2>/dev/null; \
|
||||
cp target/release/libfff_c.so packages/fff-bun/bin/ 2>/dev/null; \
|
||||
cp target/release/fff_c.dll packages/fff-bun/bin/ 2>/dev/null; \
|
||||
true
|
||||
@# Re-sign on macOS: cp can invalidate ad-hoc code signatures
|
||||
@if [ "$$(uname)" = "Darwin" ] && command -v codesign >/dev/null 2>&1; then \
|
||||
codesign --sign - packages/fff-bun/bin/libfff_c.dylib 2>/dev/null || true; \
|
||||
@if [ ! -d "$(MINI_DIR)" ]; then \
|
||||
echo "Cloning mini.nvim..."; \
|
||||
git clone --depth 1 https://github.com/echasnovski/mini.nvim $(MINI_DIR); \
|
||||
fi
|
||||
|
||||
test-bun: prepare-bun
|
||||
cd packages/fff-bun && bun test src/
|
||||
test-rust:
|
||||
cargo test --workspace --features zlob --exclude fff-nvim
|
||||
|
||||
test: test-rust test-lua test-bun
|
||||
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
|
||||
|
||||
# mini.test reference_screenshot snapshots. Separate runner because mini.test
|
||||
# spawns child processes and uses its own collector (incompatible with
|
||||
# PlenaryBustedDirectory). Streams output live via `tee` so failure diffs
|
||||
# appear as they happen instead of after a long capture-buffered silence.
|
||||
# `pcall` catches collect-time errors (e.g. parse error in the test file)
|
||||
# that would otherwise leave headless nvim hanging in its event loop because
|
||||
# the reporter's `cquit` never fires.
|
||||
test-lua-snap: test-setup build
|
||||
@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 \
|
||||
--features zlob \
|
||||
-- --nocapture stress_seeded
|
||||
|
||||
test-stress-random:
|
||||
RUSTFLAGS="$(STRESS_RUSTFLAGS)" \
|
||||
cargo test --release \
|
||||
-p fff-search \
|
||||
--test fuzz_git_watcher_stress \
|
||||
--features zlob \
|
||||
-- --nocapture stress_random
|
||||
|
||||
test-stress-repos:
|
||||
RUSTFLAGS="$(STRESS_RUSTFLAGS)" \
|
||||
cargo test --release \
|
||||
-p fff-search \
|
||||
--test fuzz_real_repos \
|
||||
--features zlob \
|
||||
-- --nocapture
|
||||
|
||||
test-stress: test-stress-seeded test-stress-random 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
|
||||
@@ -53,3 +285,67 @@ lint-ts:
|
||||
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
|
||||
|
||||
@@ -4,6 +4,14 @@ extend-exclude = ["/CHANGELOG.md", "data/filetypes/base.lua"]
|
||||
[default.extend-words]
|
||||
noice = "noice"
|
||||
fo = "fo"
|
||||
ba = "ba"
|
||||
ue = "ue"
|
||||
# file extensions that look like typos
|
||||
thm = "thm"
|
||||
# some typos we use for tests
|
||||
comparsion = "comparsion"
|
||||
modfiers = "modfiers"
|
||||
shcema = "shcema"
|
||||
|
||||
[default]
|
||||
extend-ignore-re = [
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
|
||||
"files": {
|
||||
"includes": ["packages/**/*.ts", "!packages/*/dist"],
|
||||
"ignoreUnknown": true
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"name": "@ff-labs/fff-bun",
|
||||
"version": "0.1.37",
|
||||
"bin": {
|
||||
"fff": "./scripts/cli.ts",
|
||||
"fff-demo": "./examples/search.ts",
|
||||
"fff-grep": "./examples/grep.ts",
|
||||
},
|
||||
@@ -20,40 +19,109 @@
|
||||
"typescript": "^5.0.0",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@ff-labs/fff-bun-darwin-arm64": "0.0.0",
|
||||
"@ff-labs/fff-bun-darwin-x64": "0.0.0",
|
||||
"@ff-labs/fff-bun-linux-arm64-gnu": "0.0.0",
|
||||
"@ff-labs/fff-bun-linux-arm64-musl": "0.0.0",
|
||||
"@ff-labs/fff-bun-linux-x64-gnu": "0.0.0",
|
||||
"@ff-labs/fff-bun-linux-x64-musl": "0.0.0",
|
||||
"@ff-labs/fff-bun-win32-arm64": "0.0.0",
|
||||
"@ff-labs/fff-bun-win32-x64": "0.0.0",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bun": ">=1.0.0",
|
||||
"@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-mcp": {
|
||||
"name": "@ff-labs/fff-mcp",
|
||||
"version": "0.1.0",
|
||||
"bin": {
|
||||
"fff-mcp": "./src/index.ts",
|
||||
},
|
||||
"packages/fff-node": {
|
||||
"name": "@ff-labs/fff-node",
|
||||
"version": "0.1.37",
|
||||
"dependencies": {
|
||||
"@ff-labs/fff-bun": "workspace:*",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"zod": "^3.24.0",
|
||||
"ffi-rs": "^1.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.8",
|
||||
"@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": "*",
|
||||
},
|
||||
},
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ff-labs/fff-bun",
|
||||
],
|
||||
"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=="],
|
||||
@@ -72,226 +140,278 @@
|
||||
|
||||
"@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-mcp": ["@ff-labs/fff-mcp@workspace:packages/fff-mcp"],
|
||||
"@ff-labs/fff-node": ["@ff-labs/fff-node@workspace:packages/fff-node"],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
|
||||
"@ff-labs/pi-fff": ["@ff-labs/pi-fff@workspace:packages/pi-fff"],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
|
||||
"@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=="],
|
||||
|
||||
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PXgg5gqcS/rHwa1hF0JdM1y5TiyejVrMHoBmWY/DjtfYZoFTXie1RCFOkoG0b5diOOmUcuYarMpH7CSNTqwj+w=="],
|
||||
"@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=="],
|
||||
|
||||
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-Nhssuh7GBpP5PiDSOl3+qnoIG7PJo+ec2oomDevnl9pRY6x6aD2gRt0JE+uf+A8Om2D6gjeHCxjEdrw5ZHE8mA=="],
|
||||
"@mariozechner/clipboard-darwin-arm64": ["@mariozechner/clipboard-darwin-arm64@0.3.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ=="],
|
||||
|
||||
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-w1gaTlqU0IJCmJ1X+PGHkdNU1n8Gemx5YKkjhkJIguvFINXEBB5U1KG82QsT65Tk4KyNMfbLTlmy4giAvUoKfA=="],
|
||||
"@mariozechner/clipboard-darwin-universal": ["@mariozechner/clipboard-darwin-universal@0.3.9", "", { "os": "darwin" }, "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ=="],
|
||||
|
||||
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-OUgPHfL6+PM2Q+tFZjcaycN3D7gdQdYlWnwMI31DXZKY1r4HINWk9aEz9t/rNaHg65edwNrt7dsv9TF7xK8xIA=="],
|
||||
"@mariozechner/clipboard-darwin-x64": ["@mariozechner/clipboard-darwin-x64@0.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg=="],
|
||||
|
||||
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Ui5pAgM7JE9MzHokF0VglRMkbak3lTisY4Mf1AZutPACXWgKJC5aGrgnHBfkl7QS6fEeYb0juy1q4eRznRHOsw=="],
|
||||
"@mariozechner/clipboard-linux-arm64-gnu": ["@mariozechner/clipboard-linux-arm64-gnu@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw=="],
|
||||
|
||||
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-bzUgYj/PIZziB/ZesIP9HUyfvh6Vlf3od+TrbTTyVEuCSMKzDPQVW/yEbRp0tcHO3alwiEXwJDrWrHAguXlgiQ=="],
|
||||
"@mariozechner/clipboard-linux-arm64-musl": ["@mariozechner/clipboard-linux-arm64-musl@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ=="],
|
||||
|
||||
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-oqvMDYpX6dGJO03HgO5bXuccEsH3qbdO3MaAiAlO4CfkBPLUXz3N0DDElg5hz0L6ktdDVKbQVE5lfe+LAUISQg=="],
|
||||
"@mariozechner/clipboard-linux-riscv64-gnu": ["@mariozechner/clipboard-linux-riscv64-gnu@0.3.9", "", { "os": "linux", "cpu": "none" }, "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw=="],
|
||||
|
||||
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-poVXvOShekbexHq45b4MH/mRjQKwACAC8lHp3Tz/hEDuz0/20oncqScnmKwzhBPEpqJvydXficXfBYuSim8opw=="],
|
||||
"@mariozechner/clipboard-linux-x64-gnu": ["@mariozechner/clipboard-linux-x64-gnu@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw=="],
|
||||
|
||||
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-/hOZ6S1VsTX6vtbhWVL9aAnOrdpuO54mAGUWpTdMz7dFG5UBZ/VUEiK0pBkq9A1rlBk0GeD/6Y4NBFl8Ha7cRA=="],
|
||||
"@mariozechner/clipboard-linux-x64-musl": ["@mariozechner/clipboard-linux-x64-musl@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ=="],
|
||||
|
||||
"@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.3.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-GXbz2swvN2DLw2dXZFeedMxSJtI64xQ9xp9Eg7Hjejg6mS2E4dP1xoQ2yAo2aZPi/2OBPAVaGzppI2q20XumHA=="],
|
||||
"@mariozechner/clipboard-win32-arm64-msvc": ["@mariozechner/clipboard-win32-arm64-msvc@0.3.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ=="],
|
||||
|
||||
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.10", "", { "os": "win32", "cpu": "x64" }, "sha512-qaS1In3yfC/Z/IGQriVmF8GWwKuNqiw7feTSJWaQhH5IbL6ENR+4wGNPniZSJFaM/SKUO0e/YCRdoVBvgU4C1g=="],
|
||||
"@mariozechner/clipboard-win32-x64-msvc": ["@mariozechner/clipboard-win32-x64-msvc@0.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA=="],
|
||||
|
||||
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.10", "", { "os": "win32", "cpu": "x64" }, "sha512-gh3UAHbUdDUG6fhLc1Csa4IGdtghue6U8oAIXWnUqawp6lwb3gOCRvp25IUnLF5vUHtgfMxuEUYV7YA2WxVutw=="],
|
||||
"@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@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
|
||||
"@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
|
||||
|
||||
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
"@yuuang/ffi-rs-android-arm64": ["@yuuang/ffi-rs-android-arm64@1.3.1", "", { "os": "android", "cpu": "arm64" }, "sha512-V4nmlXdOYZEa7GOxSExVG95SLp8FE0iTq2yKeN54UlfNMr3Sik+1Ff57LcCv7qYcn4TBqnBAt5rT3FAM6T6caQ=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
"@yuuang/ffi-rs-darwin-arm64": ["@yuuang/ffi-rs-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YlnTMIyzfW3mAULC5ZA774nzQfFlYXM0rrfq/8ZzWt+IMbYk55a++jrI+6JeKV+1EqlDS3TFBEFtjdBNG94KzQ=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
"@yuuang/ffi-rs-darwin-x64": ["@yuuang/ffi-rs-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-sI3LpQQ34SX4nyOHc5yxA7FSqs9qPEUMqW/y/wWo9cuyPpaHMFsi/BeOVYsnC0syp3FrY7gzn6RnD6PlXCktXg=="],
|
||||
|
||||
"bun": ["bun@1.3.10", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.10", "@oven/bun-darwin-x64": "1.3.10", "@oven/bun-darwin-x64-baseline": "1.3.10", "@oven/bun-linux-aarch64": "1.3.10", "@oven/bun-linux-aarch64-musl": "1.3.10", "@oven/bun-linux-x64": "1.3.10", "@oven/bun-linux-x64-baseline": "1.3.10", "@oven/bun-linux-x64-musl": "1.3.10", "@oven/bun-linux-x64-musl-baseline": "1.3.10", "@oven/bun-windows-aarch64": "1.3.10", "@oven/bun-windows-x64": "1.3.10", "@oven/bun-windows-x64-baseline": "1.3.10" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-S/CXaXXIyA4CMjdMkYQ4T2YMqnAn4s0ysD3mlsY4bUiOCqGlv28zck4Wd4H4kpvbekx15S9mUeLQ7Uxd0tYTLA=="],
|
||||
"@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=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
"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=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
"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=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
"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=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
"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=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
"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=="],
|
||||
|
||||
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
"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=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
||||
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
|
||||
|
||||
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
"glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="],
|
||||
"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=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
"highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
"hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="],
|
||||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"hono": ["hono@4.12.3", "", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
|
||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
"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=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
"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=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
"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=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
"lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
"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=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
"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=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
"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=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
"partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
"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-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
|
||||
"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=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
"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=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
"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=="],
|
||||
|
||||
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
|
||||
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
"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=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
||||
"strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
||||
"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-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
"undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
"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=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
"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=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
"xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
|
||||
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-http/@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.7", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A=="],
|
||||
|
||||
"@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1063.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/nested-clients": "^3.997.17", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-nYDaWWdzjKiDP5xj8k4oUgcYd4WPgzfAOgdU5vJsaqH/07Dfvm7ffisHCFJ+NEl7kUC9JEIUxh0kznvenbo3NQ=="],
|
||||
|
||||
"@aws-sdk/nested-clients/@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.7", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A=="],
|
||||
|
||||
"bun-types/@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
|
||||
|
||||
"p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
|
||||
|
||||
"protobufjs/@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
|
||||
|
||||
"bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"protobufjs/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "fff-c"
|
||||
version = "0.1.0"
|
||||
version = "0.9.4"
|
||||
edition = "2024"
|
||||
description = "C FFI bindings for fff-core - use from any language with C FFI support"
|
||||
description = "Raw C api of FFF file finder"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
@@ -10,14 +10,11 @@ crate-type = ["cdylib"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
zlob = ["fff-core/zlob"]
|
||||
zlob = ["fff/zlob"]
|
||||
|
||||
[dependencies]
|
||||
mimalloc.workspace = true
|
||||
tracing.workspace = true
|
||||
git2.workspace = true
|
||||
|
||||
fff-core = { path = "../fff-core" }
|
||||
fff-query-parser = { path = "../fff-query-parser" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
fff = { package = "fff-search", path = "../fff-core" , version = "0.9.4" }
|
||||
fff-query-parser = { path = "../fff-query-parser" , version = "0.9.4" }
|
||||
serde_json = "1.0"
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+724
-304
File diff suppressed because it is too large
Load Diff
+1042
-317
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
+36
-17
@@ -1,43 +1,57 @@
|
||||
[package]
|
||||
name = "fff-core"
|
||||
version = "0.1.0"
|
||||
name = "fff-search"
|
||||
version = "0.9.4"
|
||||
edition = "2024"
|
||||
description = "High-performance file finder core library"
|
||||
license = "MIT"
|
||||
authors = ["Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>"]
|
||||
description = "Faboulous & Fast File Finder - a fast and extremely correct file finder SDK with typo resistance, SIMD, prefiltering, and more"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
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]
|
||||
default = []
|
||||
# Enable C FFI exports
|
||||
ffi = []
|
||||
# Call mi_collect(true) after large allocator churn (bigram build).
|
||||
# Requires mimalloc to be the global allocator (linked by fff-nvim).
|
||||
mimalloc-collect = ["dep:libmimalloc-sys"]
|
||||
# Use zlob (Zig-compiled C globbing library) for glob matching.
|
||||
# Requires Zig to be installed. When disabled, falls back to globset (pure Rust).
|
||||
zlob = ["dep:zlob", "fff-query-parser/zlob"]
|
||||
|
||||
[dependencies]
|
||||
# Workspace dependencies
|
||||
ahash = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
smallvec = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
# Local crates
|
||||
fff-query-parser = { path = "../fff-query-parser", default-features = false }
|
||||
|
||||
# External dependencies
|
||||
bindet = { workspace = true }
|
||||
fff-query-parser = { workspace = true , version = "0.9.0" }
|
||||
blake3 = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
libc = "0.2"
|
||||
git2 = { workspace = true }
|
||||
glidesort = { workspace = true }
|
||||
globset = { workspace = true }
|
||||
grep-matcher = { workspace = true }
|
||||
grep-searcher = { workspace = true }
|
||||
fff-grep = { workspace = true , version = "0.9.0" }
|
||||
aho-corasick = "1"
|
||||
memchr = "2"
|
||||
heed = { workspace = true }
|
||||
@@ -46,22 +60,27 @@ memmap2 = { workspace = true }
|
||||
neo_frizbee = { workspace = true }
|
||||
notify = { workspace = true }
|
||||
notify-debouncer-full = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
pathdiff = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
regex-syntax = "0.8"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
smartstring = { version = "1.0.1", features = ["serde"] }
|
||||
toml = "0.8"
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
zlob = { workspace = true, optional = true }
|
||||
libmimalloc-sys = { version = "0.1", optional = true, features = ["extended", "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"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# fff
|
||||
|
||||
fff is a file search toolkit. It is faster than ripgrep and fzf and designed for a long running applications like file editors, ai agents, or file exploerers.
|
||||
|
||||
## Features
|
||||
|
||||
- Fuzzy file name search
|
||||
- Typo resistance
|
||||
- Frecency and query history ranking
|
||||
- Native git support via libgit
|
||||
- Advanced ranking
|
||||
- Grep functionality with SIMD optimized plain matcher and regex
|
||||
- Multi grep using aho-corasick algorithm
|
||||
- Efficient memory mapping for file system
|
||||
- Cross platform support (Linux, Windows, MacOS)
|
||||
- Advnaced constraints syntax allowing to prefilter based on git status, glob, extension, size, timing and more
|
||||
|
||||
## Performance
|
||||
|
||||
FFF is designed for high performance and low latency. SIMD optimized where needed, parallelized for multi core systems, efficient sorting and ranking algorithms, memaps and much more.
|
||||
|
||||
On MacOS FFF is about 20-50 times faster than ripgrep for content search and around 10 times faster than fzf for file name search.
|
||||
|
||||
## Documentation
|
||||
|
||||
Refer rust docs https://docs.rs/crate/fff-search/latest
|
||||
@@ -0,0 +1,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);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,93 @@
|
||||
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
|
||||
use fff_search::case_insensitive_memmem;
|
||||
use std::path::Path;
|
||||
|
||||
/// Load real source files from the repository as benchmark haystacks.
|
||||
/// Falls back to concatenating all .rs files under crates/ if specific files are missing.
|
||||
fn load_real_files() -> Vec<(&'static str, Vec<u8>)> {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR"); // crates/fff-core
|
||||
let repo_root = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||
|
||||
let files: &[(&str, &str)] = &[
|
||||
("grep.rs/80KB", "crates/fff-core/src/grep.rs"),
|
||||
("file_picker.rs/53KB", "crates/fff-core/src/file_picker.rs"),
|
||||
("picker_ui.lua/96KB", "lua/fff/picker_ui.lua"),
|
||||
];
|
||||
|
||||
let mut result = Vec::new();
|
||||
for &(label, rel_path) in files {
|
||||
let full_path = repo_root.join(rel_path);
|
||||
if let Ok(data) = std::fs::read(&full_path) {
|
||||
result.push((label, data));
|
||||
}
|
||||
}
|
||||
|
||||
// Also create a large synthetic file by concatenating all three
|
||||
if result.len() == 3 {
|
||||
let mut combined = Vec::new();
|
||||
for (_, data) in &result {
|
||||
combined.extend_from_slice(data);
|
||||
}
|
||||
// Repeat to get ~1MB
|
||||
let base = combined.clone();
|
||||
while combined.len() < 1024 * 1024 {
|
||||
combined.extend_from_slice(&base);
|
||||
}
|
||||
combined.truncate(1024 * 1024);
|
||||
result.push(("combined/1MB", combined));
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn bench_memmem(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("case_insensitive_memmem");
|
||||
|
||||
let files = load_real_files();
|
||||
assert!(!files.is_empty(), "No source files found for benchmarking");
|
||||
|
||||
// Needles chosen to exercise different false-positive rates:
|
||||
//
|
||||
// "hit" needles: strings that actually appear in these source files.
|
||||
// "miss" needles: strings with common first-bytes (lots of false positives
|
||||
// for memchr2) but that don't exist in any of the files.
|
||||
let needles: &[(&str, &[u8])] = &[
|
||||
// Hits — real identifiers from the codebase
|
||||
("short/hit/fn", b"fn"),
|
||||
("short/hit/self", b"self"),
|
||||
("medium/hit", b"search_file"),
|
||||
("long/hit", b"content_cache_budget"),
|
||||
// Misses — common first-bytes, guaranteed not in source
|
||||
("short/miss", b"zqxjv"),
|
||||
("medium/miss", b"fluxcapacitor"),
|
||||
("long/miss", b"quantum_entanglement_resolver"),
|
||||
];
|
||||
|
||||
for (file_label, haystack) in &files {
|
||||
for &(needle_label, needle) in needles {
|
||||
let needle_lower: Vec<u8> = needle.iter().map(|b| b.to_ascii_lowercase()).collect();
|
||||
let id = format!("{file_label}/{needle_label}");
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("packed_pair", &id),
|
||||
&(haystack, &needle_lower),
|
||||
|b, &(h, n)| {
|
||||
b.iter(|| black_box(case_insensitive_memmem::search_packed_pair(h, n)));
|
||||
},
|
||||
);
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("memchr2_search", &id),
|
||||
&(haystack, &needle_lower),
|
||||
|b, &(h, n)| {
|
||||
b.iter(|| black_box(case_insensitive_memmem::search(h, n)));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_memmem);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,180 @@
|
||||
use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main};
|
||||
use fff_query_parser::*;
|
||||
|
||||
fn bench_parse_simple(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
c.bench_function("parse_simple_text", |b| {
|
||||
b.iter(|| parser.parse(black_box("hello world")));
|
||||
});
|
||||
|
||||
c.bench_function("parse_extension", |b| {
|
||||
b.iter(|| parser.parse(black_box("*.rs")));
|
||||
});
|
||||
|
||||
c.bench_function("parse_text_with_extension", |b| {
|
||||
b.iter(|| parser.parse(black_box("name *.rs")));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_parse_complex(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
c.bench_function("parse_complex_mixed", |b| {
|
||||
b.iter(|| parser.parse(black_box("src name *.rs !test /lib/ status:modified")));
|
||||
});
|
||||
|
||||
c.bench_function("parse_glob", |b| {
|
||||
b.iter(|| parser.parse(black_box("**/*.rs")));
|
||||
});
|
||||
|
||||
c.bench_function("parse_multiple_constraints", |b| {
|
||||
b.iter(|| parser.parse(black_box("*.rs *.toml *.md !test !node_modules /src/")));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_parse_realistic_queries(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
let queries = vec![
|
||||
"file",
|
||||
"test",
|
||||
"mod.rs",
|
||||
"src/*.rs",
|
||||
"lib test",
|
||||
"*.rs !test",
|
||||
"src/lib/*.rs",
|
||||
"/src/ name",
|
||||
"status:modified *.rs",
|
||||
"type:rust test !node_modules",
|
||||
];
|
||||
|
||||
let mut group = c.benchmark_group("realistic_queries");
|
||||
for query in queries.iter() {
|
||||
group.throughput(Throughput::Bytes(query.len() as u64));
|
||||
group.bench_with_input(BenchmarkId::from_parameter(query), query, |b, q| {
|
||||
b.iter(|| parser.parse(black_box(q)));
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_parse_various_lengths(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
let short = "*.rs";
|
||||
let medium = "src name *.rs !test";
|
||||
let long = "src lib test name *.rs *.toml !node_modules !test /src/ /lib/ status:modified";
|
||||
let very_long =
|
||||
"a b c d e f g h i j k l m n o p q r s t u v w x y z *.rs *.toml *.md *.txt *.js";
|
||||
|
||||
let mut group = c.benchmark_group("query_lengths");
|
||||
|
||||
group.throughput(Throughput::Bytes(short.len() as u64));
|
||||
group.bench_with_input(BenchmarkId::new("short", short.len()), &short, |b, q| {
|
||||
b.iter(|| parser.parse(black_box(q)));
|
||||
});
|
||||
|
||||
group.throughput(Throughput::Bytes(medium.len() as u64));
|
||||
group.bench_with_input(BenchmarkId::new("medium", medium.len()), &medium, |b, q| {
|
||||
b.iter(|| parser.parse(black_box(q)));
|
||||
});
|
||||
|
||||
group.throughput(Throughput::Bytes(long.len() as u64));
|
||||
group.bench_with_input(BenchmarkId::new("long", long.len()), &long, |b, q| {
|
||||
b.iter(|| parser.parse(black_box(q)));
|
||||
});
|
||||
|
||||
group.throughput(Throughput::Bytes(very_long.len() as u64));
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("very_long", very_long.len()),
|
||||
&very_long,
|
||||
|b, q| {
|
||||
b.iter(|| parser.parse(black_box(q)));
|
||||
},
|
||||
);
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_config_comparison(c: &mut Criterion) {
|
||||
let file_picker = QueryParser::new(FileSearchConfig);
|
||||
let grep = QueryParser::new(GrepConfig);
|
||||
|
||||
let query = "src name *.rs !test";
|
||||
|
||||
let mut group = c.benchmark_group("config_comparison");
|
||||
|
||||
group.bench_function("file_picker_config", |b| {
|
||||
b.iter(|| file_picker.parse(black_box(query)));
|
||||
});
|
||||
|
||||
group.bench_function("grep_config", |b| {
|
||||
b.iter(|| grep.parse(black_box(query)));
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_constraint_types(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
let mut group = c.benchmark_group("constraint_types");
|
||||
|
||||
group.bench_function("extension", |b| {
|
||||
b.iter(|| parser.parse(black_box("*.rs")));
|
||||
});
|
||||
|
||||
group.bench_function("glob", |b| {
|
||||
b.iter(|| parser.parse(black_box("**/*.rs")));
|
||||
});
|
||||
|
||||
group.bench_function("exclude", |b| {
|
||||
b.iter(|| parser.parse(black_box("!test")));
|
||||
});
|
||||
|
||||
group.bench_function("path_segment", |b| {
|
||||
b.iter(|| parser.parse(black_box("/src/")));
|
||||
});
|
||||
|
||||
group.bench_function("git_status", |b| {
|
||||
b.iter(|| parser.parse(black_box("status:modified")));
|
||||
});
|
||||
|
||||
group.bench_function("file_type", |b| {
|
||||
b.iter(|| parser.parse(black_box("type:rust")));
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_worst_case(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
// Worst case: many constraints that all need to be checked
|
||||
let worst_case = "a b c d e f g h i j k l m n o p q r s t u v w x y z";
|
||||
|
||||
c.bench_function("worst_case_many_text_tokens", |b| {
|
||||
b.iter(|| parser.parse(black_box(worst_case)));
|
||||
});
|
||||
|
||||
// Many constraints
|
||||
let many_constraints = "*.rs *.toml *.md *.txt *.js *.ts *.jsx *.tsx *.vue *.svelte";
|
||||
|
||||
c.bench_function("worst_case_many_constraints", |b| {
|
||||
b.iter(|| parser.parse(black_box(many_constraints)));
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_parse_simple,
|
||||
bench_parse_complex,
|
||||
bench_parse_realistic_queries,
|
||||
bench_parse_various_lengths,
|
||||
bench_config_comparison,
|
||||
bench_constraint_types,
|
||||
bench_worst_case,
|
||||
);
|
||||
|
||||
criterion_main!(benches);
|
||||
+19
-19
@@ -1,36 +1,36 @@
|
||||
fn main() {
|
||||
// Opt-in cfg for the long-running randomized stress tests
|
||||
// used by tests/fuzz_git_watcher_stress.rs
|
||||
println!("cargo::rustc-check-cfg=cfg(stress)");
|
||||
|
||||
// When the `zlob` feature is enabled (Zig-compiled C library):
|
||||
// On Windows MSVC, explicitly link the C runtime libraries.
|
||||
// Zig-compiled static libraries don't emit /DEFAULTLIB directives for the
|
||||
// MSVC CRT, so symbols like strcmp, memcpy etc. would be unresolved.
|
||||
if std::env::var("CARGO_FEATURE_ZLOB").is_ok() {
|
||||
if !zig_available() {
|
||||
panic!(
|
||||
"The `zlob` feature is enabled but Zig is not installed. \
|
||||
Install Zig (https://ziglang.org/download/) or build without \
|
||||
`--features zlob`."
|
||||
);
|
||||
}
|
||||
|
||||
let target = std::env::var("TARGET").unwrap_or_default();
|
||||
if target.contains("windows") && target.contains("msvc") {
|
||||
println!("cargo:rustc-link-lib=msvcrt");
|
||||
println!("cargo:rustc-link-lib=ucrt");
|
||||
println!("cargo:rustc-link-lib=vcruntime");
|
||||
}
|
||||
} else if std::env::var("CI").is_ok() {
|
||||
// CI must always build with zlob for production-quality binaries.
|
||||
if !zig_available() {
|
||||
panic!(
|
||||
"CI detected but Zig is not installed. \
|
||||
Please install Zig and build with `--features zlob`."
|
||||
);
|
||||
}
|
||||
panic!(
|
||||
"CI detected but `zlob` feature is not enabled. \
|
||||
Build with `--features zlob`."
|
||||
);
|
||||
} else {
|
||||
} else if std::env::var("CARGO_PRIMARY_PACKAGE").is_ok() && zig_available() {
|
||||
// Hint: if Zig is available but the zlob feature wasn't enabled,
|
||||
// let the developer know they can get faster glob matching.
|
||||
if zig_available() {
|
||||
println!(
|
||||
"cargo:warning=Zig detected but `zlob` feature is not enabled. \
|
||||
Build with `--features zlob` for faster glob matching."
|
||||
);
|
||||
}
|
||||
// Only emit this hint when this crate is the primary package to
|
||||
// avoid noisy warnings for downstream consumers.
|
||||
println!(
|
||||
"cargo:warning=Zig detected but `zlob` feature is not enabled. \
|
||||
Build with `--features zlob` for faster glob matching."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
use crate::constants::MAX_OVERFLOW_FILES;
|
||||
use crate::error::Error;
|
||||
use crate::file_picker::{FFFMode, FilePicker};
|
||||
use crate::file_picker::FFFMode;
|
||||
use crate::git::GitStatusCache;
|
||||
use crate::shared::{SharedFilePicker, SharedFrecency};
|
||||
use crate::sort_buffer::sort_with_buffer;
|
||||
use crate::{SharedFrecency, SharedPicker};
|
||||
use git2::Repository;
|
||||
use notify::event::{AccessKind, AccessMode};
|
||||
use notify::{Config, EventKind, RecursiveMode};
|
||||
use notify::{Config, EventKind, EventKindMask, RecursiveMode};
|
||||
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, NoCache, new_debouncer_opt};
|
||||
use parking_lot::Mutex;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
use tracing::{Level, debug, error, info, warn};
|
||||
|
||||
@@ -17,37 +19,32 @@ type Debouncer = notify_debouncer_full::Debouncer<notify::RecommendedWatcher, No
|
||||
|
||||
/// Owns the file-system watcher and guarantees that all background threads
|
||||
/// are fully joined before `stop()` / `Drop` returns.
|
||||
///
|
||||
/// Architecture:
|
||||
/// - The debouncer (and its internal watcher) live inside an **owner thread**
|
||||
/// that we spawn and hold the `JoinHandle` for.
|
||||
/// - `stop()` sets a flag, unparks the owner thread, and **joins** it.
|
||||
/// - Inside the owner thread, `Debouncer::stop()` is called which joins the
|
||||
/// debouncer's event-processing thread.
|
||||
/// - On Windows an additional short sleep is added after `Debouncer::stop()`
|
||||
/// because `notify`'s `ReadDirectoryChangesWatcher` discards its thread
|
||||
/// `JoinHandle`, so we cannot join it directly. The watcher's `Drop` does
|
||||
/// signal the thread via semaphore so it exits almost immediately, but we
|
||||
/// need to give the OS a moment to reclaim it.
|
||||
pub struct BackgroundWatcher {
|
||||
stop_signal: Arc<AtomicBool>,
|
||||
debouncer: Arc<Mutex<Option<Debouncer>>>,
|
||||
watch_tx: Option<mpsc::Sender<PathBuf>>,
|
||||
owner_thread: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(250);
|
||||
const MAX_PATHS_THRESHOLD: usize = 1024;
|
||||
const MAX_SELECTIVE_WATCH_DIRS: usize = 100;
|
||||
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: SharedPicker,
|
||||
shared_picker: SharedFilePicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
mode: FFFMode,
|
||||
enable_fs_root_scanning: bool,
|
||||
enable_home_dir_scanning: bool,
|
||||
trace_span: tracing::Span,
|
||||
) -> Result<Self, Error> {
|
||||
info!(
|
||||
"Initializing background watcher for path: {}, mode: {:?}",
|
||||
@@ -55,36 +52,115 @@ impl BackgroundWatcher {
|
||||
mode,
|
||||
);
|
||||
|
||||
let debouncer =
|
||||
Self::create_debouncer(base_path, git_workdir, shared_picker, shared_frecency, 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_frecency = shared_frecency.clone();
|
||||
let owner_git_workdir = git_workdir.clone();
|
||||
|
||||
let debouncer = Self::create_debouncer(
|
||||
base_path,
|
||||
git_workdir,
|
||||
shared_picker,
|
||||
shared_frecency,
|
||||
mode,
|
||||
use_recursive,
|
||||
watch_tx_for_debouncer,
|
||||
)?;
|
||||
|
||||
info!("Background file watcher initialized successfully");
|
||||
|
||||
let stop_signal = Arc::new(AtomicBool::new(false));
|
||||
let stop_clone = Arc::clone(&stop_signal);
|
||||
// debouncer is shared with the owner thread, once it's dropped the thread is closed
|
||||
let debouncer = Arc::new(Mutex::new(Some(debouncer)));
|
||||
// Only the Linux per-dir-watch branch needs this clone; on other
|
||||
// platforms the owner thread never touches the debouncer.
|
||||
#[cfg(target_os = "linux")]
|
||||
let owner_debouncer = Arc::clone(&debouncer);
|
||||
|
||||
// The owner thread keeps the debouncer alive and ensures proper
|
||||
// cleanup: `Debouncer::stop()` joins its internal thread, then the
|
||||
// watcher `Drop` signals its I/O thread to exit.
|
||||
let owner_span = trace_span.clone();
|
||||
let owner_thread = std::thread::Builder::new()
|
||||
.name("fff-watcher-owner".into())
|
||||
.name("fff-watcher-own".into())
|
||||
.spawn(move || {
|
||||
while !stop_clone.load(Ordering::Acquire) {
|
||||
std::thread::park_timeout(Duration::from_secs(1));
|
||||
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_frecency,
|
||||
&owner_git_workdir,
|
||||
);
|
||||
|
||||
// Transient strong ref drops here, back
|
||||
// to weak-only before the next `recv()`.
|
||||
}
|
||||
// Debouncer::stop() joins the debouncer's event thread, then
|
||||
// drops the watcher (whose Drop signals the I/O thread).
|
||||
debouncer.stop();
|
||||
// On Windows the notify crate discards the ReadDirectoryChangesW
|
||||
// thread's JoinHandle — we cannot join it. Its Drop signals the
|
||||
// thread via semaphore so it exits almost immediately; give the
|
||||
// OS a moment to fully reclaim it.
|
||||
#[cfg(windows)]
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
|
||||
tracing::info!("Background watcher is stopped");
|
||||
})
|
||||
.expect("failed to spawn fff-watcher-owner thread");
|
||||
|
||||
Ok(Self {
|
||||
stop_signal,
|
||||
debouncer,
|
||||
watch_tx: Some(watch_tx),
|
||||
owner_thread: Some(owner_thread),
|
||||
})
|
||||
}
|
||||
@@ -92,29 +168,61 @@ impl BackgroundWatcher {
|
||||
fn create_debouncer(
|
||||
base_path: PathBuf,
|
||||
git_workdir: Option<PathBuf>,
|
||||
shared_picker: SharedPicker,
|
||||
shared_picker: SharedFilePicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
mode: FFFMode,
|
||||
use_recursive: bool,
|
||||
watch_tx: mpsc::Sender<PathBuf>,
|
||||
) -> Result<Debouncer, Error> {
|
||||
// do not follow symlinks as then notifiers spawns a bunch of events for symlinked
|
||||
// files that could be git ignored, we have to property differentiate those and if
|
||||
// the file was edited through a
|
||||
let config = Config::default().with_follow_symlinks(false);
|
||||
let config = Config::default()
|
||||
// do not follow symlinks as then notifiers spawns a bunch of events for symlinked
|
||||
// files that could be git ignored, we have to property differentiate those and if
|
||||
// the file was edited through a
|
||||
.with_follow_symlinks(false)
|
||||
// only the actual modification events, ignore the open syscals that we can generate by
|
||||
// our own grep calls and preview window rendering
|
||||
.with_event_kinds(EventKindMask::CORE);
|
||||
|
||||
// `use_recursive` was decided by the caller from a cheap size hint,
|
||||
// so the event-handler closure can capture it directly.
|
||||
//
|
||||
// The closure lives on the debouncer's internal event thread
|
||||
// for as long as the debouncer exists — i.e. the full
|
||||
// lifetime of `BackgroundWatcher`. Capturing a strong
|
||||
// `SharedFilePicker` here would re-introduce the Arc cycle
|
||||
// we just broke with `owner_picker`'s `downgrade()` above.
|
||||
// Capture a weak handle instead and upgrade per-batch.
|
||||
let git_workdir_for_handler = git_workdir.clone();
|
||||
let shared_picker_for_watching = shared_picker.clone();
|
||||
let event_picker = shared_picker.weaken();
|
||||
let mut debouncer = new_debouncer_opt(
|
||||
DEBOUNCE_TIMEOUT,
|
||||
Some(DEBOUNCE_TIMEOUT / 2), // tick rate for the event span
|
||||
{
|
||||
move |result: DebounceEventResult| match result {
|
||||
Ok(events) => {
|
||||
handle_debounced_events(
|
||||
// Upgrade just long enough to drive one
|
||||
// debounced batch. Failure means every
|
||||
// external `SharedFilePicker` has already
|
||||
// dropped and teardown is already underway.
|
||||
let Some(strong_picker) = event_picker.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let new_dirs = handle_debounced_events(
|
||||
events,
|
||||
&git_workdir_for_handler,
|
||||
&shared_picker,
|
||||
&strong_picker,
|
||||
&shared_frecency,
|
||||
mode,
|
||||
);
|
||||
|
||||
// every new directory creates had to be reflected in the picker state
|
||||
for dir in new_dirs {
|
||||
if let Err(e) = watch_tx.send(dir) {
|
||||
warn!(?e, "Failed to send directory update error");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(errors) => {
|
||||
error!("File watcher errors: {:?}", errors);
|
||||
@@ -129,59 +237,155 @@ impl BackgroundWatcher {
|
||||
config,
|
||||
)?;
|
||||
|
||||
// Watch only non-ignored directories to avoid flooding the OS event buffer.
|
||||
// On macOS, FSEvents has a fixed-size kernel buffer — watching huge gitignored
|
||||
// directories like `target/` in rust causes buffer overflow, which drops real source file
|
||||
// events. Instead we watch the root non-recursively (for top-level file changes
|
||||
// and new directory detection) and each non-ignored subdirectory recursively.
|
||||
let watch_dirs = collect_non_ignored_dirs(&base_path);
|
||||
// Watching strategy:
|
||||
//
|
||||
// For small-to-medium repos we watch each indexed directory individually
|
||||
// (NonRecursive). This avoids receiving events for gitignored paths like
|
||||
// node_modules/ and keeps the event volume low.
|
||||
//
|
||||
// On macOS, each `watch()` call creates a separate FSEventStream. Large
|
||||
// repos (e.g. Chromium with 487K+ files) can have tens of thousands of
|
||||
// directories, which exhausts the per-process FSEvents stream limit and
|
||||
// causes "unable to start FSEvent stream" errors. When the directory
|
||||
// count exceeds the threshold we fall back to a single Recursive watch
|
||||
// on the base path. FSEvents handles this efficiently with one kernel
|
||||
// stream for the entire subtree. Gitignored paths are already filtered
|
||||
// in the event handler via `should_include_file()`.
|
||||
//
|
||||
// On Linux (inotify), RecursiveMode::Recursive creates one kernel watch
|
||||
// per subdirectory *including* gitignored ones, wasting file descriptors.
|
||||
// The per-directory NonRecursive approach is always used on Linux.
|
||||
//
|
||||
// New directories created at runtime are detected via Create events on
|
||||
// the parent and dynamically added by the owner thread via watch_tx.
|
||||
|
||||
if watch_dirs.len() > MAX_SELECTIVE_WATCH_DIRS {
|
||||
tracing::warn!(
|
||||
"Too many non-ignored directories ({}/{}) can't efficiently watch them",
|
||||
watch_dirs.len(),
|
||||
MAX_SELECTIVE_WATCH_DIRS
|
||||
);
|
||||
if use_recursive {
|
||||
debouncer.watch(base_path.as_path(), RecursiveMode::Recursive)?;
|
||||
info!(
|
||||
"File watcher initialized with single recursive watch on {} \
|
||||
(exceeded threshold of {})",
|
||||
base_path.display(),
|
||||
MAX_MACOS_NONRECURSIVE_WATCHES,
|
||||
);
|
||||
} else {
|
||||
debouncer.watch(base_path.as_path(), RecursiveMode::NonRecursive)?;
|
||||
|
||||
for dir in &watch_dirs {
|
||||
match debouncer.watch(dir.as_path(), RecursiveMode::Recursive) {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
// Non-fatal: directory may have been removed between discovery and watch
|
||||
warn!("Failed to watch directory {}: {}", dir.display(), e);
|
||||
// Stream watch-dir registration directly under the picker
|
||||
// read lock. Only Linux (inotify) reaches this branch —
|
||||
// macOS always takes the recursive path above. `inotify`'s
|
||||
// `inotify_add_watch()` is fast-fail: on ENOSPC it returns
|
||||
// immediately, no kernel retry loop, so holding the read
|
||||
// lock across the stream is O(ms) even for large repos.
|
||||
//
|
||||
// Abort the loop after a run of failures. Once ENOSPC hits,
|
||||
// further calls won't succeed until the user raises
|
||||
// `fs.inotify.max_user_watches`, so there's no value in
|
||||
// continuing.
|
||||
const MAX_CONSECUTIVE_WATCH_FAILURES: usize = 16;
|
||||
|
||||
let mut watched = 0usize;
|
||||
let mut consecutive_failures = 0usize;
|
||||
let mut aborted_early = false;
|
||||
|
||||
if let Some(guard) = shared_picker_for_watching.read().ok()
|
||||
&& let Some(picker) = guard.as_ref()
|
||||
{
|
||||
use std::ops::ControlFlow;
|
||||
picker.for_each_dir(|dir| {
|
||||
match debouncer.watch(dir, RecursiveMode::NonRecursive) {
|
||||
Ok(()) => {
|
||||
watched += 1;
|
||||
consecutive_failures = 0;
|
||||
ControlFlow::Continue(())
|
||||
}
|
||||
Err(e) => {
|
||||
consecutive_failures += 1;
|
||||
if consecutive_failures <= 4 {
|
||||
warn!("Failed to watch directory {}: {}", dir.display(), e);
|
||||
}
|
||||
|
||||
if consecutive_failures >= MAX_CONSECUTIVE_WATCH_FAILURES {
|
||||
warn!(
|
||||
consecutive_failures,
|
||||
watched,
|
||||
"Aborting NonRecursive watch loop — per-process \
|
||||
watch cap exhausted, further dirs would just burn \
|
||||
kernel time for no coverage"
|
||||
);
|
||||
aborted_early = true;
|
||||
ControlFlow::Break(())
|
||||
} else {
|
||||
ControlFlow::Continue(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// In selective mode the .git directory is excluded from the non-ignored
|
||||
// dirs, but we still need to observe changes that affect git status
|
||||
// (staging, unstaging, committing, branch switches, merges, etc.).
|
||||
watch_git_status_paths(&mut debouncer, git_workdir.as_ref());
|
||||
info!(
|
||||
"File watcher initialized for {} directories (NonRecursive) under {} (aborted_early={})",
|
||||
watched,
|
||||
base_path.display(),
|
||||
aborted_early,
|
||||
);
|
||||
}
|
||||
|
||||
info!(
|
||||
"File watcher initialized for {} directories under {}",
|
||||
watch_dirs.len(),
|
||||
base_path.display()
|
||||
);
|
||||
// The .git directory is excluded from the file list but we still need
|
||||
// to observe changes that affect git status (staging, unstaging,
|
||||
// committing, branch switches, merges, etc.).
|
||||
// When using recursive mode the base watch already covers .git/,
|
||||
// but these targeted watches are cheap (at most 3 extra streams)
|
||||
// and ensure we catch status changes even if the recursive backend
|
||||
// coalesces or delays .git events.
|
||||
watch_git_status_paths(&mut debouncer, git_workdir.as_ref());
|
||||
|
||||
Ok(debouncer)
|
||||
}
|
||||
|
||||
/// Signal the watcher to shut down without blocking on its worker
|
||||
/// threads. Safe to call from any context, including while holding
|
||||
/// the [`SharedFilePicker`] write lock.
|
||||
///
|
||||
/// Both the debouncer's internal event loop and our owner thread
|
||||
/// may call `SharedFilePicker::write()` inside their handlers. A
|
||||
/// blocking join here would deadlock against a caller that already
|
||||
/// holds that lock (e.g. `stop_background_monitor` under a
|
||||
/// `shared_picker.write()` guard). Instead we:
|
||||
///
|
||||
/// * drop the `watch_tx` Sender — the owner thread's
|
||||
/// `watch_rx.recv()` returns `Err` and the thread exits at
|
||||
/// its next `recv`.
|
||||
/// * call `debouncer.stop_nonblocking()` — signals the debouncer
|
||||
/// event loop to exit on its next tick and drops the watcher,
|
||||
/// closing the FSEvent / inotify / ReadDirectoryChangesW stream.
|
||||
/// * detach both `JoinHandle`s.
|
||||
///
|
||||
/// In-flight handler invocations finish on their own (at most one
|
||||
/// more batch) once the caller releases any locks they hold.
|
||||
pub fn stop(&mut self) {
|
||||
self.stop_signal.store(true, Ordering::Release);
|
||||
if let Some(handle) = self.owner_thread.take() {
|
||||
handle.thread().unpark();
|
||||
|
||||
if let Err(e) = handle.join() {
|
||||
error!("Watcher owner thread panicked: {:?}", e);
|
||||
}
|
||||
self.watch_tx.take();
|
||||
if let Some(debouncer) = self.debouncer.lock().take() {
|
||||
debouncer.stop_nonblocking();
|
||||
}
|
||||
|
||||
info!("Background file watcher stopped successfully");
|
||||
self.owner_thread.take();
|
||||
|
||||
info!("Background file watcher stop signaled");
|
||||
}
|
||||
|
||||
/// Queue a non-recursive watch registration on `dir`.
|
||||
///
|
||||
/// The owner thread is always blocked on `watch_rx.recv()`, so
|
||||
/// the `send()` here wakes it immediately via the channel's
|
||||
/// condvar — no external unpark needed.
|
||||
///
|
||||
/// Returns `false` once `stop()` has dropped our `Sender` — any
|
||||
/// further request is silently discarded.
|
||||
pub(crate) fn request_watch_dir(&self, dir: PathBuf) -> bool {
|
||||
match self.watch_tx.as_ref() {
|
||||
Some(tx) => tx.send(dir).is_ok(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,16 +399,18 @@ impl Drop for BackgroundWatcher {
|
||||
fn handle_debounced_events(
|
||||
events: Vec<DebouncedEvent>,
|
||||
git_workdir: &Option<PathBuf>,
|
||||
shared_picker: &SharedPicker,
|
||||
shared_picker: &SharedFilePicker,
|
||||
shared_frecency: &SharedFrecency,
|
||||
mode: FFFMode,
|
||||
) {
|
||||
) -> Vec<PathBuf> {
|
||||
// this will be called very often, we have to minimiy the lock time for file picker
|
||||
let repo = git_workdir.as_ref().and_then(|p| Repository::open(p).ok());
|
||||
let mut need_full_rescan = false;
|
||||
let mut need_full_git_rescan = false;
|
||||
let mut paths_to_remove = Vec::new();
|
||||
let mut dirs_to_remove: Vec<PathBuf> = Vec::new();
|
||||
let mut paths_to_add_or_modify = Vec::new();
|
||||
let mut new_dirs_to_watch = Vec::new();
|
||||
let mut affected_paths_count = 0usize;
|
||||
|
||||
for debounced_event in &events {
|
||||
@@ -265,8 +471,26 @@ fn handle_debounced_events(
|
||||
// Modify(Name(Any)) instead of Remove).
|
||||
let is_removal = matches!(debounced_event.event.kind, EventKind::Remove(_));
|
||||
|
||||
if is_removal || !path.exists() {
|
||||
// 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() {
|
||||
// New directory — collect it so the caller can register a
|
||||
// watcher. No filesystem scanning: files that arrive later
|
||||
// will be handled by the newly registered watch.
|
||||
if !is_path_ignored(path, &repo) {
|
||||
new_dirs_to_watch.push(path.to_path_buf());
|
||||
}
|
||||
} else {
|
||||
// For additions/modifications, still filter gitignored files.
|
||||
if should_include_file(path, &repo) {
|
||||
@@ -276,10 +500,11 @@ fn handle_debounced_events(
|
||||
}
|
||||
|
||||
affected_paths_count += debounced_event.event.paths.len();
|
||||
if affected_paths_count > MAX_PATHS_THRESHOLD {
|
||||
if affected_paths_count > MAX_OVERFLOW_FILES {
|
||||
warn!(
|
||||
"Too many affected paths ({}) in a single batch, triggering full rescan",
|
||||
affected_paths_count
|
||||
?affected_paths_count,
|
||||
max = MAX_OVERFLOW_FILES,
|
||||
"Too many affected paths in a single batch, triggering full rescan",
|
||||
);
|
||||
|
||||
need_full_rescan = true;
|
||||
@@ -293,8 +518,10 @@ fn handle_debounced_events(
|
||||
|
||||
if need_full_rescan {
|
||||
info!(?affected_paths_count, "Triggering full rescan");
|
||||
trigger_full_rescan(shared_picker, shared_frecency);
|
||||
return;
|
||||
if let Err(e) = shared_picker.trigger_full_rescan_async(shared_frecency) {
|
||||
error!("Failed to trigger full rescan: {:?}", e);
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// It's important to get the allocated sort
|
||||
@@ -304,64 +531,78 @@ fn handle_debounced_events(
|
||||
paths_to_add_or_modify.dedup_by(|a, b| a.as_os_str().eq(b.as_os_str()));
|
||||
|
||||
info!(
|
||||
"Event processing summary: {} to remove, {} to add/modify",
|
||||
"Event processing summary: {} to remove, {} dirs to remove, {} to add/modify, {} new dirs",
|
||||
paths_to_remove.len(),
|
||||
paths_to_add_or_modify.len()
|
||||
dirs_to_remove.len(),
|
||||
paths_to_add_or_modify.len(),
|
||||
new_dirs_to_watch.len()
|
||||
);
|
||||
|
||||
// Apply file index updates (add/remove) unconditionally — these must
|
||||
// happen even when there is no git repository.
|
||||
let files_to_update_git_status =
|
||||
if !paths_to_remove.is_empty() || !paths_to_add_or_modify.is_empty() {
|
||||
debug!(
|
||||
"Applying file index changes: {} to remove, {} to add/modify",
|
||||
paths_to_remove.len(),
|
||||
paths_to_add_or_modify.len(),
|
||||
);
|
||||
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 apply_changes = |picker: &mut FilePicker| -> Vec<PathBuf> {
|
||||
for path in &paths_to_remove {
|
||||
let removed = picker.remove_file_by_path(path);
|
||||
debug!("remove_file_by_path({:?}) -> {}", path, removed);
|
||||
}
|
||||
let mut files_to_update_git_status = Vec::new();
|
||||
let mut need_full_rescan = false;
|
||||
let mut overflow_count = 0;
|
||||
|
||||
let mut files_to_update = Vec::with_capacity(paths_to_add_or_modify.len());
|
||||
for path in &paths_to_add_or_modify {
|
||||
let result = picker.on_create_or_modify(path);
|
||||
match result {
|
||||
Some(file) => {
|
||||
debug!(
|
||||
"on_create_or_modify({:?}) -> Some({})",
|
||||
path,
|
||||
file.path.display()
|
||||
);
|
||||
files_to_update.push(file.path.clone());
|
||||
}
|
||||
None => {
|
||||
error!("on_create_or_modify({:?}) -> None (file not added!)", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
info!(
|
||||
"apply_changes complete: {} files to update git status",
|
||||
files_to_update.len()
|
||||
);
|
||||
files_to_update
|
||||
};
|
||||
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;
|
||||
};
|
||||
let Some(ref mut picker) = *guard else {
|
||||
error!("File picker not initialized");
|
||||
return;
|
||||
};
|
||||
apply_changes(picker)
|
||||
} else {
|
||||
debug!("No file index changes to apply");
|
||||
Vec::new()
|
||||
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
|
||||
@@ -407,70 +648,123 @@ fn handle_debounced_events(
|
||||
}
|
||||
}
|
||||
|
||||
// Git status updates require a repository.
|
||||
let Some(repo) = repo.as_ref() else {
|
||||
debug!("No git repo available, skipping git status updates");
|
||||
// do not try to update the paths if we anyway going to rescan everything from scratch
|
||||
if !need_full_rescan && (need_full_git_rescan || !files_to_update_git_status.is_empty()) {
|
||||
let git_workdir = repo
|
||||
.as_ref()
|
||||
.map(|r| r.workdir().unwrap_or_else(|| r.path()).to_path_buf());
|
||||
|
||||
let shared_picker = shared_picker.clone();
|
||||
let shared_frecency = shared_frecency.clone();
|
||||
|
||||
// git status query even with a pathspec could be really slow, if we do this syncrhronously
|
||||
// within the event handler, we actually risk of forming a snow ball of conflicting events
|
||||
crate::parallelism::BACKGROUND_THREAD_POOL.spawn(move || {
|
||||
let Some(git_path) = git_workdir else { return };
|
||||
let Ok(repo) = Repository::open(&git_path) else {
|
||||
error!("Failed to open git repo for async status update");
|
||||
return;
|
||||
};
|
||||
|
||||
if need_full_git_rescan && !need_full_rescan {
|
||||
info!("Async: triggering full git rescan");
|
||||
if let Err(e) = shared_picker.refresh_git_status(&shared_frecency) {
|
||||
error!("Failed to refresh git status: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
if !files_to_update_git_status.is_empty() {
|
||||
let status = match GitStatusCache::git_status_for_paths(
|
||||
&repo,
|
||||
&files_to_update_git_status,
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Failed to query git status: {:?}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(mut guard) = shared_picker.write()
|
||||
&& let Some(ref mut picker) = *guard
|
||||
{
|
||||
if let Err(e) = picker.update_git_statuses(status, &shared_frecency) {
|
||||
error!("Failed to update git statuses: {:?}", e);
|
||||
} else {
|
||||
info!("Async: git statuses updated");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
shared_frecency: &SharedFrecency,
|
||||
git_workdir: &Option<PathBuf>,
|
||||
) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if need_full_git_rescan {
|
||||
info!("Triggering full git rescan");
|
||||
let repo = git_workdir.as_ref().and_then(|p| Repository::open(p).ok());
|
||||
let mut files_to_add = Vec::new();
|
||||
|
||||
let result = FilePicker::refresh_git_status(shared_picker, shared_frecency);
|
||||
if let Err(e) = result {
|
||||
error!("Failed to refresh git status: {:?}", e);
|
||||
for entry in entries.flatten() {
|
||||
if entry.file_type().is_ok_and(|ft| ft.is_file()) {
|
||||
let path = entry.path();
|
||||
if should_include_file(&path, &repo) {
|
||||
files_to_add.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if files_to_add.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if !files_to_update_git_status.is_empty() {
|
||||
info!(
|
||||
"Fetching git status for {} files",
|
||||
files_to_update_git_status.len()
|
||||
);
|
||||
{
|
||||
let Ok(mut guard) = shared_picker.write() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let status = match GitStatusCache::git_status_for_paths(repo, &files_to_update_git_status) {
|
||||
let Some(ref mut picker) = *guard else {
|
||||
return;
|
||||
};
|
||||
|
||||
for path in &files_to_add {
|
||||
picker.handle_create_or_modify(path);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(repo) = repo.as_ref() {
|
||||
let status = match GitStatusCache::git_status_for_paths(repo, &files_to_add) {
|
||||
Ok(status) => status,
|
||||
Err(e) => {
|
||||
tracing::error!(?e, "Failed to query git status");
|
||||
tracing::error!(?e, "inject_existing_files: git status query failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(mut guard) = shared_picker.write()
|
||||
&& let Some(ref mut picker) = *guard
|
||||
&& let Err(e) = picker.update_git_statuses(status, shared_frecency)
|
||||
{
|
||||
if let Err(e) = picker.update_git_statuses(status, shared_frecency) {
|
||||
error!("Failed to update git statuses: {:?}", e);
|
||||
} else {
|
||||
info!("Successfully updated git statuses in picker");
|
||||
}
|
||||
} else {
|
||||
error!("Failed to acquire picker lock for git status update");
|
||||
error!("inject_existing_files: failed to update git statuses: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn trigger_full_rescan(shared_picker: &SharedPicker, shared_frecency: &SharedFrecency) {
|
||||
info!("Triggering full filesystem rescan");
|
||||
|
||||
// Note: no need to clear mmaps — they are backed by the kernel page cache
|
||||
// and automatically reflect file changes. Old FileItems (and their mmaps)
|
||||
// are dropped when the picker rebuilds its file list.
|
||||
|
||||
let Ok(mut guard) = shared_picker.write() else {
|
||||
error!("Failed to acquire file picker write lock for full rescan");
|
||||
return;
|
||||
};
|
||||
let Some(ref mut picker) = *guard else {
|
||||
error!("File picker not initialized, cannot trigger rescan");
|
||||
return;
|
||||
};
|
||||
if let Err(e) = picker.trigger_rescan(shared_frecency) {
|
||||
error!("Failed to trigger full rescan: {:?}", e);
|
||||
} else {
|
||||
info!("Full filesystem rescan completed successfully");
|
||||
}
|
||||
debug!(
|
||||
"Injected {} existing files from new directory {}",
|
||||
files_to_add.len(),
|
||||
dir.display(),
|
||||
);
|
||||
}
|
||||
|
||||
fn should_include_file(path: &Path, repo: &Option<Repository>) -> bool {
|
||||
@@ -479,45 +773,62 @@ fn should_include_file(path: &Path, repo: &Option<Repository>) -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If there is a git repo, respect its ignore rules.
|
||||
// If there is no repo (or the check fails), include the file.
|
||||
match repo.as_ref() {
|
||||
Some(repo) => repo.is_path_ignored(path) != Ok(true),
|
||||
None => true,
|
||||
None => {
|
||||
// No git repo — apply basic sanity filters.
|
||||
// Hidden directories are skipped by the watcher setup (hidden(true)),
|
||||
// but events can still arrive for files in known non-code directories.
|
||||
!is_non_code_directory(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_non_code_directory(path: &Path) -> bool {
|
||||
crate::ignore::is_non_code_directory(path)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_path_ignored(path: &Path, repo: &Option<Repository>) -> bool {
|
||||
match repo.as_ref() {
|
||||
Some(repo) => repo.is_path_ignored(path) == Ok(true),
|
||||
None => is_non_code_directory(path),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_git_file(path: &Path) -> bool {
|
||||
pub(crate) fn is_git_file(path: &Path) -> bool {
|
||||
path.components()
|
||||
.any(|component| component.as_os_str() == ".git")
|
||||
}
|
||||
|
||||
pub fn is_dotgit_change_affecting_status(changed: &Path, repo: &Option<Repository>) -> bool {
|
||||
fn is_dotgit_change_affecting_status(changed: &Path, repo: &Option<Repository>) -> bool {
|
||||
let Some(repo) = repo.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let git_dir = repo.path();
|
||||
|
||||
if let Ok(rel) = changed.strip_prefix(git_dir) {
|
||||
if rel.starts_with("objects") || rel.starts_with("logs") || rel.starts_with("hooks") {
|
||||
return false;
|
||||
}
|
||||
if rel == Path::new("index") || rel == Path::new("index.lock") {
|
||||
if let Ok(path_in_git_dir) = changed.strip_prefix(git_dir) {
|
||||
// Only react to changes that rewrite the worktree state: commits,
|
||||
// staging, checkouts, merges, conflict resolution. Ref-only updates
|
||||
// under refs/ (fetch, push, tag writes, pack-refs) do not change
|
||||
// which files are modified/untracked, so we deliberately skip them —
|
||||
// watching refs/ recursively would cost one inotify watch per ref
|
||||
// namespace on repos with many branches/remotes.
|
||||
if path_in_git_dir == Path::new("index") || path_in_git_dir == Path::new("index.lock") {
|
||||
return true;
|
||||
}
|
||||
if rel == Path::new("HEAD") {
|
||||
if path_in_git_dir == Path::new("HEAD") {
|
||||
return true;
|
||||
}
|
||||
if rel.starts_with("refs") || rel == Path::new("packed-refs") {
|
||||
return true;
|
||||
}
|
||||
if rel == Path::new("info/exclude") || rel == Path::new("info/sparse-checkout") {
|
||||
if path_in_git_dir == Path::new("info/exclude")
|
||||
|| path_in_git_dir == Path::new("info/sparse-checkout")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(fname) = rel.file_name().and_then(|f| f.to_str())
|
||||
if let Some(fname) = path_in_git_dir.file_name().and_then(|f| f.to_str())
|
||||
&& matches!(fname, "MERGE_HEAD" | "CHERRY_PICK_HEAD" | "REVERT_HEAD")
|
||||
{
|
||||
return true;
|
||||
@@ -545,20 +856,15 @@ fn watch_git_status_paths(debouncer: &mut Debouncer, git_workdir: Option<&PathBu
|
||||
}
|
||||
|
||||
// Watch .git/ non-recursively to catch top-level files:
|
||||
// index, index.lock, HEAD, packed-refs, MERGE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD
|
||||
// index, index.lock, HEAD, MERGE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD.
|
||||
// We intentionally do NOT watch refs/ — individual ref updates don't
|
||||
// affect worktree status, and a recursive watch there blows up inotify
|
||||
// watch counts on repos with many branches/remotes/tags.
|
||||
if let Err(e) = debouncer.watch(&git_dir, RecursiveMode::NonRecursive) {
|
||||
warn!("Failed to watch .git directory: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Watch refs/ recursively to catch branch/tag changes
|
||||
let refs_dir = git_dir.join("refs");
|
||||
if refs_dir.is_dir()
|
||||
&& let Err(e) = debouncer.watch(&refs_dir, RecursiveMode::Recursive)
|
||||
{
|
||||
warn!("Failed to watch .git/refs: {}", e);
|
||||
}
|
||||
|
||||
// Watch info/ non-recursively for exclude and sparse-checkout
|
||||
let info_dir = git_dir.join("info");
|
||||
if info_dir.is_dir()
|
||||
@@ -567,38 +873,3 @@ fn watch_git_status_paths(debouncer: &mut Debouncer, git_workdir: Option<&PathBu
|
||||
warn!("Failed to watch .git/info: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects immediate non-ignored subdirectories of `base_path` using the `ignore` crate
|
||||
/// to respect .gitignore, .ignore, and global gitignore rules. This is used to set up
|
||||
/// selective file watching — only non-ignored directories get a recursive watcher,
|
||||
/// preventing gitignored directories like `target/` from flooding the OS event buffer.
|
||||
fn collect_non_ignored_dirs(base_path: &Path) -> Vec<PathBuf> {
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
let walker = WalkBuilder::new(base_path)
|
||||
.hidden(false)
|
||||
.git_ignore(true)
|
||||
.git_exclude(true)
|
||||
.git_global(true)
|
||||
.ignore(true)
|
||||
.follow_links(false)
|
||||
.max_depth(Some(1))
|
||||
.build();
|
||||
|
||||
let mut dirs = Vec::new();
|
||||
for entry in walker {
|
||||
let Ok(entry) = entry else { continue };
|
||||
let path = entry.path();
|
||||
|
||||
// Skip the root directory itself
|
||||
if path == base_path {
|
||||
continue;
|
||||
}
|
||||
|
||||
if path.is_dir() && !is_git_file(path) {
|
||||
dirs.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
dirs
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,998 @@
|
||||
//! Regex → bigram decomposition for the inverted bigram index.
|
||||
//!
|
||||
//! Parses a regex pattern with `regex-syntax`, walks the HIR to extract
|
||||
//! guaranteed bigram keys (u16), and evaluates them as an AND/OR query tree
|
||||
//! against [`BigramFilter`]'s inverted posting lists.
|
||||
//!
|
||||
//! Two bigram types are extracted:
|
||||
//! - **Consecutive** (gap=0): adjacent byte pairs `(pattern[i], pattern[i+1])`
|
||||
//! - **Sparse-1** (gap=1): pairs across a single-byte wildcard, e.g. `a.b → (a,b)`
|
||||
//!
|
||||
//! The sparse-1 extraction is the key feature: regex patterns like `foo.bar`
|
||||
//! yield the cross-boundary sparse-1 bigram `(o,b)` that provides strong
|
||||
//! filtering even when the `.` prevents any consecutive cross-boundary bigram.
|
||||
|
||||
use crate::bigram_filter::BigramFilter;
|
||||
use regex_syntax::hir::{Class, Hir, HirKind};
|
||||
use smallvec::SmallVec;
|
||||
use std::borrow::Cow;
|
||||
|
||||
/// Maximum byte values to enumerate from a character class.
|
||||
/// Larger classes are treated as unknown (no bigram extractable).
|
||||
const MAX_CLASS_EXPAND: usize = 16;
|
||||
|
||||
#[inline]
|
||||
fn consec_key(a: u8, b: u8) -> Option<u16> {
|
||||
let al = a.to_ascii_lowercase();
|
||||
let bl = b.to_ascii_lowercase();
|
||||
if (32..=126).contains(&al) && (32..=126).contains(&bl) {
|
||||
Some((al as u16) << 8 | bl as u16)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BigramQuery {
|
||||
Any,
|
||||
/// A consecutive bigram key to look up in the main index.
|
||||
Consec(u16),
|
||||
/// A skip-1 bigram key to look up in the skip sub-index.
|
||||
Skip1(u16),
|
||||
/// All children must match (intersect posting lists).
|
||||
And(Vec<BigramQuery>),
|
||||
/// At least one child must match (union posting lists).
|
||||
Or(Vec<BigramQuery>),
|
||||
}
|
||||
|
||||
/// SIMD-friendly bitwise OR of two equal-length bitsets.
|
||||
#[inline]
|
||||
fn bitset_or(a: &mut [u64], b: &[u64]) {
|
||||
a.iter_mut().zip(b.iter()).for_each(|(x, y)| *x |= *y);
|
||||
}
|
||||
|
||||
/// SIMD-friendly bitwise AND of two equal-length bitsets.
|
||||
#[inline]
|
||||
fn bitset_and(a: &mut [u64], b: &[u64]) {
|
||||
a.iter_mut().zip(b.iter()).for_each(|(x, y)| *x &= *y);
|
||||
}
|
||||
|
||||
impl BigramQuery {
|
||||
pub fn is_any(&self) -> bool {
|
||||
matches!(self, BigramQuery::Any)
|
||||
}
|
||||
|
||||
pub(crate) fn evaluate(&self, index: &BigramFilter) -> Option<Vec<u64>> {
|
||||
self.evaluate_cow(index).map(Cow::into_owned)
|
||||
}
|
||||
|
||||
fn evaluate_cow<'a>(&self, index: &'a BigramFilter) -> Option<Cow<'a, [u64]>> {
|
||||
match self {
|
||||
BigramQuery::Any => None,
|
||||
|
||||
BigramQuery::Consec(key) => {
|
||||
let col = index.lookup()[*key as usize];
|
||||
if col == u16::MAX {
|
||||
return None;
|
||||
}
|
||||
let words = index.words();
|
||||
let offset = col as usize * words;
|
||||
let data = index.dense_data();
|
||||
if offset + words > data.len() {
|
||||
return None;
|
||||
}
|
||||
Some(Cow::Borrowed(&data[offset..offset + words]))
|
||||
}
|
||||
|
||||
BigramQuery::Skip1(key) => {
|
||||
let skip = index.skip_index()?;
|
||||
let col = skip.lookup()[*key as usize];
|
||||
if col == u16::MAX {
|
||||
return None;
|
||||
}
|
||||
let words = skip.words();
|
||||
let offset = col as usize * words;
|
||||
let data = skip.dense_data();
|
||||
if offset + words > data.len() {
|
||||
return None;
|
||||
}
|
||||
Some(Cow::Borrowed(&data[offset..offset + words]))
|
||||
}
|
||||
|
||||
BigramQuery::And(children) => {
|
||||
let mut result: Option<Vec<u64>> = None;
|
||||
for child in children {
|
||||
if let Some(child_bits) = child.evaluate_cow(index) {
|
||||
result = Some(match result {
|
||||
None => child_bits.into_owned(),
|
||||
Some(mut r) => {
|
||||
bitset_and(&mut r, &child_bits);
|
||||
r
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
result.map(Cow::Owned)
|
||||
}
|
||||
|
||||
BigramQuery::Or(children) => {
|
||||
if children.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut result: Option<Vec<u64>> = None;
|
||||
for child in children {
|
||||
match child.evaluate_cow(index) {
|
||||
// Any branch can't be filtered → whole OR can't be filtered
|
||||
None => return None,
|
||||
Some(child_bits) => {
|
||||
result = Some(match result {
|
||||
None => child_bits.into_owned(),
|
||||
Some(mut r) => {
|
||||
bitset_or(&mut r, &child_bits);
|
||||
r
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
result.map(Cow::Owned)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Intermediate state tracked during HIR traversal for bigram extraction.
|
||||
struct HirInfo {
|
||||
query: BigramQuery,
|
||||
/// Possible first bytes (lowercased, printable ASCII) when this node matches.
|
||||
first: Option<SmallVec<[u8; MAX_CLASS_EXPAND]>>,
|
||||
/// Possible last bytes.
|
||||
last: Option<SmallVec<[u8; MAX_CLASS_EXPAND]>>,
|
||||
/// Whether this node can match the empty string.
|
||||
can_be_empty: bool,
|
||||
}
|
||||
|
||||
impl HirInfo {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
query: BigramQuery::Any,
|
||||
first: None,
|
||||
last: None,
|
||||
can_be_empty: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefilter fuzzy query. The algorithm is the following:
|
||||
/// we allow max_typos = min(len/3,2) every typo destroys at most 2 consecutive bigrams
|
||||
/// So out of N bigrams at least N - 2 * max_typos have to present in the matching fil
|
||||
pub(crate) fn fuzzy_to_bigram_query(query: &str, num_probes: usize) -> BigramQuery {
|
||||
let lower: Vec<u8> = query.bytes().map(|b| b.to_ascii_lowercase()).collect();
|
||||
|
||||
if lower.len() < 2 {
|
||||
return BigramQuery::Any;
|
||||
}
|
||||
|
||||
let max_typos = (lower.len() / 3).min(2);
|
||||
|
||||
// Extract all consecutive bigram keys.
|
||||
let bigram_keys: Vec<u16> = lower
|
||||
.windows(2)
|
||||
.filter_map(|w| consec_key(w[0], w[1]))
|
||||
.collect();
|
||||
|
||||
if bigram_keys.is_empty() {
|
||||
return BigramQuery::Any;
|
||||
}
|
||||
|
||||
// For very short queries (0 typos), AND all bigrams — exact subsequence.
|
||||
if max_typos == 0 {
|
||||
return simplify_and(
|
||||
bigram_keys
|
||||
.iter()
|
||||
.map(|&k| BigramQuery::Consec(k))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
// Pick evenly-spaced probe bigrams.
|
||||
let n = num_probes.min(bigram_keys.len());
|
||||
if n <= max_typos {
|
||||
// Too few probes to require anything useful.
|
||||
return simplify_or(
|
||||
bigram_keys
|
||||
.iter()
|
||||
.map(|&k| BigramQuery::Consec(k))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
let probes: Vec<u16> = if n == bigram_keys.len() {
|
||||
bigram_keys
|
||||
} else {
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let idx = i * (bigram_keys.len() - 1) / (n - 1);
|
||||
bigram_keys[idx]
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let required = n - max_typos;
|
||||
|
||||
// If required == n, just AND all probes.
|
||||
if required >= n {
|
||||
return simplify_and(probes.iter().map(|&k| BigramQuery::Consec(k)).collect());
|
||||
}
|
||||
|
||||
// Generate all C(n, required) subsets → OR(AND(subset), ...)
|
||||
let mut branches = Vec::new();
|
||||
let mut combo = vec![0u16; required];
|
||||
combine(&probes, required, 0, 0, &mut combo, &mut branches);
|
||||
|
||||
simplify_or(branches)
|
||||
}
|
||||
|
||||
/// Build C(n, k) combination branches in-place on a fixed-size slice.
|
||||
fn combine(
|
||||
items: &[u16],
|
||||
k: usize,
|
||||
start: usize,
|
||||
depth: usize,
|
||||
combo: &mut [u16],
|
||||
branches: &mut Vec<BigramQuery>,
|
||||
) {
|
||||
if depth == k {
|
||||
branches.push(simplify_and(
|
||||
combo.iter().map(|&key| BigramQuery::Consec(key)).collect(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
let remaining = k - depth;
|
||||
for i in start..=items.len() - remaining {
|
||||
combo[depth] = items[i];
|
||||
combine(items, k, i + 1, depth + 1, combo, branches);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn regex_to_bigram_query(pattern: &str) -> BigramQuery {
|
||||
let mut parser = regex_syntax::ParserBuilder::new()
|
||||
.unicode(false)
|
||||
.utf8(false)
|
||||
.build();
|
||||
|
||||
let hir = match parser.parse(pattern) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return BigramQuery::Any,
|
||||
};
|
||||
|
||||
decompose(&hir).query
|
||||
}
|
||||
|
||||
fn decompose(hir: &Hir) -> HirInfo {
|
||||
let can_be_empty = hir.properties().minimum_len().is_none_or(|n| n == 0);
|
||||
|
||||
match hir.kind() {
|
||||
HirKind::Empty => HirInfo::empty(),
|
||||
|
||||
HirKind::Literal(lit) => decompose_literal(lit.0.as_ref()),
|
||||
|
||||
HirKind::Class(class) => {
|
||||
let bytes = expand_class(class);
|
||||
match bytes {
|
||||
Some(b) if !b.is_empty() => HirInfo {
|
||||
query: BigramQuery::Any,
|
||||
first: Some(b.clone()),
|
||||
last: Some(b),
|
||||
can_be_empty,
|
||||
},
|
||||
_ => HirInfo {
|
||||
query: BigramQuery::Any,
|
||||
first: None,
|
||||
last: None,
|
||||
can_be_empty,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
HirKind::Look(_) => HirInfo::empty(),
|
||||
|
||||
HirKind::Repetition(rep) => {
|
||||
let inner = decompose(&rep.sub);
|
||||
if rep.min == 0 {
|
||||
HirInfo {
|
||||
query: BigramQuery::Any,
|
||||
first: inner.first,
|
||||
last: inner.last,
|
||||
can_be_empty: true,
|
||||
}
|
||||
} else {
|
||||
// min >= 1: inner bigrams guaranteed
|
||||
let mut qs = Vec::new();
|
||||
if !inner.query.is_any() {
|
||||
qs.push(inner.query.clone());
|
||||
}
|
||||
// min >= 2: cross-boundary between consecutive occurrences
|
||||
if rep.min >= 2 {
|
||||
push_cross_consec(&mut qs, inner.last.as_deref(), inner.first.as_deref());
|
||||
}
|
||||
HirInfo {
|
||||
query: simplify_and(qs),
|
||||
first: inner.first,
|
||||
last: inner.last,
|
||||
can_be_empty,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HirKind::Capture(cap) => decompose(&cap.sub),
|
||||
|
||||
HirKind::Concat(parts) => decompose_concat(parts),
|
||||
|
||||
HirKind::Alternation(alts) => decompose_alternation(alts),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract bigrams from a literal byte sequence.
|
||||
fn decompose_literal(bytes: &[u8]) -> HirInfo {
|
||||
if bytes.is_empty() {
|
||||
return HirInfo::empty();
|
||||
}
|
||||
|
||||
let lower: SmallVec<[u8; 64]> = bytes.iter().map(|b| b.to_ascii_lowercase()).collect();
|
||||
|
||||
if lower.len() == 1 {
|
||||
let b = lower[0];
|
||||
let first = if (32..=126).contains(&b) {
|
||||
Some(SmallVec::from_slice(&[b]))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
return HirInfo {
|
||||
query: BigramQuery::Any,
|
||||
first: first.clone(),
|
||||
last: first,
|
||||
can_be_empty: false,
|
||||
};
|
||||
}
|
||||
|
||||
let mut qs: Vec<BigramQuery> = Vec::new();
|
||||
|
||||
// Consecutive bigrams
|
||||
for w in lower.windows(2) {
|
||||
if let Some(k) = consec_key(w[0], w[1]) {
|
||||
qs.push(BigramQuery::Consec(k));
|
||||
}
|
||||
}
|
||||
|
||||
// Skip-1 bigrams from the literal itself
|
||||
if lower.len() >= 3 {
|
||||
for i in 0..lower.len() - 2 {
|
||||
if let Some(k) = consec_key(lower[i], lower[i + 2]) {
|
||||
qs.push(BigramQuery::Skip1(k));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let first_byte = lower[0];
|
||||
let last_byte = *lower.last().unwrap();
|
||||
|
||||
HirInfo {
|
||||
query: simplify_and(qs),
|
||||
first: if (32..=126).contains(&first_byte) {
|
||||
Some(SmallVec::from_slice(&[first_byte]))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
last: if (32..=126).contains(&last_byte) {
|
||||
Some(SmallVec::from_slice(&[last_byte]))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
can_be_empty: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn decompose_concat(parts: &[Hir]) -> HirInfo {
|
||||
if parts.is_empty() {
|
||||
return HirInfo::empty();
|
||||
}
|
||||
|
||||
let infos: Vec<HirInfo> = parts.iter().map(decompose).collect();
|
||||
let mut qs: Vec<BigramQuery> = Vec::new();
|
||||
|
||||
// 1. Collect child bigrams
|
||||
for info in &infos {
|
||||
if !info.query.is_any() {
|
||||
qs.push(info.query.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Dense cross-boundary between adjacent mandatory parts
|
||||
for pair in infos.windows(2) {
|
||||
if !pair[0].can_be_empty && !pair[1].can_be_empty {
|
||||
push_cross_consec(&mut qs, pair[0].last.as_deref(), pair[1].first.as_deref());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Sparse-1 cross-boundary: across a single 1-byte-wide middle part.
|
||||
// Catches `foo.bar` → sparse-1 `(o,b)` across the dot.
|
||||
if parts.len() >= 3 {
|
||||
for i in 0..parts.len() - 2 {
|
||||
let left = &infos[i];
|
||||
let mid = &parts[i + 1];
|
||||
let right = &infos[i + 2];
|
||||
|
||||
let min_len = mid.properties().minimum_len();
|
||||
let max_len = mid.properties().maximum_len();
|
||||
let is_1byte = min_len == Some(1) && max_len == Some(1);
|
||||
|
||||
if is_1byte && !left.can_be_empty && !right.can_be_empty {
|
||||
push_cross_skip1(&mut qs, left.last.as_deref(), right.first.as_deref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let first = collect_first(&infos);
|
||||
let last = collect_last(&infos);
|
||||
let can_be_empty = infos.iter().all(|i| i.can_be_empty);
|
||||
|
||||
HirInfo {
|
||||
query: simplify_and(qs),
|
||||
first,
|
||||
last,
|
||||
can_be_empty,
|
||||
}
|
||||
}
|
||||
|
||||
fn decompose_alternation(alts: &[Hir]) -> HirInfo {
|
||||
if alts.is_empty() {
|
||||
return HirInfo::empty();
|
||||
}
|
||||
|
||||
let infos: Vec<HirInfo> = alts.iter().map(decompose).collect();
|
||||
let query = simplify_or(infos.iter().map(|i| i.query.clone()).collect());
|
||||
let first = merge_byte_sets(infos.iter().map(|i| &i.first));
|
||||
let last = merge_byte_sets(infos.iter().map(|i| &i.last));
|
||||
let can_be_empty = infos.iter().any(|i| i.can_be_empty);
|
||||
|
||||
HirInfo {
|
||||
query,
|
||||
first,
|
||||
last,
|
||||
can_be_empty,
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_class(class: &Class) -> Option<SmallVec<[u8; MAX_CLASS_EXPAND]>> {
|
||||
let mut bytes: SmallVec<[u8; MAX_CLASS_EXPAND]> = SmallVec::new();
|
||||
match class {
|
||||
Class::Bytes(bc) => {
|
||||
for range in bc.ranges() {
|
||||
let count = (range.end() as usize) - (range.start() as usize) + 1;
|
||||
if bytes.len() + count > MAX_CLASS_EXPAND {
|
||||
return None;
|
||||
}
|
||||
for b in range.start()..=range.end() {
|
||||
if (32..=126).contains(&b) {
|
||||
let lower = b.to_ascii_lowercase();
|
||||
if !bytes.contains(&lower) {
|
||||
bytes.push(lower);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Class::Unicode(uc) => {
|
||||
for range in uc.ranges() {
|
||||
let start = range.start() as u32;
|
||||
let end = range.end() as u32;
|
||||
if start > 127 {
|
||||
continue;
|
||||
}
|
||||
let ascii_end = end.min(126) as u8;
|
||||
let ascii_start = start.max(32) as u8;
|
||||
if ascii_start > ascii_end {
|
||||
continue;
|
||||
}
|
||||
let count = (ascii_end - ascii_start) as usize + 1;
|
||||
if bytes.len() + count > MAX_CLASS_EXPAND {
|
||||
return None;
|
||||
}
|
||||
for b in ascii_start..=ascii_end {
|
||||
let lower = b.to_ascii_lowercase();
|
||||
if !bytes.contains(&lower) {
|
||||
bytes.push(lower);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if bytes.is_empty() { None } else { Some(bytes) }
|
||||
}
|
||||
|
||||
/// Push consecutive cross-product bigrams into `qs`.
|
||||
fn push_cross_consec(qs: &mut Vec<BigramQuery>, last: Option<&[u8]>, first: Option<&[u8]>) {
|
||||
if let Some(q) = cross_product(last, first, false) {
|
||||
qs.push(q);
|
||||
}
|
||||
}
|
||||
|
||||
/// Push skip-1 cross-product bigrams into `qs`.
|
||||
fn push_cross_skip1(qs: &mut Vec<BigramQuery>, last: Option<&[u8]>, first: Option<&[u8]>) {
|
||||
if let Some(q) = cross_product(last, first, true) {
|
||||
qs.push(q);
|
||||
}
|
||||
}
|
||||
|
||||
fn cross_product(last: Option<&[u8]>, first: Option<&[u8]>, skip: bool) -> Option<BigramQuery> {
|
||||
let last = last?;
|
||||
let first = first?;
|
||||
let n = last.len() * first.len();
|
||||
if n == 0 || n > MAX_CLASS_EXPAND * MAX_CLASS_EXPAND {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut bigrams: Vec<BigramQuery> = Vec::with_capacity(n);
|
||||
for &l in last {
|
||||
for &f in first {
|
||||
if let Some(k) = consec_key(l, f) {
|
||||
let node = if skip {
|
||||
BigramQuery::Skip1(k)
|
||||
} else {
|
||||
BigramQuery::Consec(k)
|
||||
};
|
||||
bigrams.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match bigrams.len() {
|
||||
0 => None,
|
||||
1 => Some(bigrams.into_iter().next().unwrap()),
|
||||
_ => Some(simplify_or(bigrams)),
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_first(infos: &[HirInfo]) -> Option<SmallVec<[u8; MAX_CLASS_EXPAND]>> {
|
||||
let mut result: SmallVec<[u8; MAX_CLASS_EXPAND]> = SmallVec::new();
|
||||
for info in infos {
|
||||
if let Some(ref bytes) = info.first {
|
||||
for &b in bytes {
|
||||
if !result.contains(&b) {
|
||||
if result.len() >= MAX_CLASS_EXPAND {
|
||||
return None;
|
||||
}
|
||||
result.push(b);
|
||||
}
|
||||
}
|
||||
} else if !info.can_be_empty {
|
||||
return None;
|
||||
}
|
||||
if !info.can_be_empty {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if result.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_last(infos: &[HirInfo]) -> Option<SmallVec<[u8; MAX_CLASS_EXPAND]>> {
|
||||
let mut result: SmallVec<[u8; MAX_CLASS_EXPAND]> = SmallVec::new();
|
||||
for info in infos.iter().rev() {
|
||||
if let Some(ref bytes) = info.last {
|
||||
for &b in bytes {
|
||||
if !result.contains(&b) {
|
||||
if result.len() >= MAX_CLASS_EXPAND {
|
||||
return None;
|
||||
}
|
||||
result.push(b);
|
||||
}
|
||||
}
|
||||
} else if !info.can_be_empty {
|
||||
return None;
|
||||
}
|
||||
if !info.can_be_empty {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if result.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_byte_sets<'a>(
|
||||
iter: impl Iterator<Item = &'a Option<SmallVec<[u8; MAX_CLASS_EXPAND]>>>,
|
||||
) -> Option<SmallVec<[u8; MAX_CLASS_EXPAND]>> {
|
||||
let mut result: SmallVec<[u8; MAX_CLASS_EXPAND]> = SmallVec::new();
|
||||
for opt in iter {
|
||||
match opt {
|
||||
None => return None,
|
||||
Some(bytes) => {
|
||||
for &b in bytes {
|
||||
if !result.contains(&b) {
|
||||
if result.len() >= MAX_CLASS_EXPAND {
|
||||
return None;
|
||||
}
|
||||
result.push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if result.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn simplify_and(children: Vec<BigramQuery>) -> BigramQuery {
|
||||
let mut flat: Vec<BigramQuery> = Vec::new();
|
||||
for child in children {
|
||||
match child {
|
||||
BigramQuery::Any => {}
|
||||
BigramQuery::And(inner) => flat.extend(inner),
|
||||
other => flat.push(other),
|
||||
}
|
||||
}
|
||||
match flat.len() {
|
||||
0 => BigramQuery::Any,
|
||||
1 => flat.into_iter().next().unwrap(),
|
||||
_ => BigramQuery::And(flat),
|
||||
}
|
||||
}
|
||||
|
||||
fn simplify_or(children: Vec<BigramQuery>) -> BigramQuery {
|
||||
if children.iter().any(|c| c.is_any()) {
|
||||
return BigramQuery::Any;
|
||||
}
|
||||
let mut flat: Vec<BigramQuery> = Vec::new();
|
||||
for child in children {
|
||||
match child {
|
||||
BigramQuery::Or(inner) => flat.extend(inner),
|
||||
other => flat.push(other),
|
||||
}
|
||||
}
|
||||
match flat.len() {
|
||||
0 => BigramQuery::Any,
|
||||
1 => flat.into_iter().next().unwrap(),
|
||||
_ => BigramQuery::Or(flat),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::bigram_filter::BigramIndexBuilder;
|
||||
|
||||
/// Build a tiny index from the given file contents for testing.
|
||||
fn build_test_index(files: &[&[u8]]) -> BigramFilter {
|
||||
let n = files.len();
|
||||
let consec_builder = BigramIndexBuilder::new(n);
|
||||
let skip_builder = BigramIndexBuilder::new(n);
|
||||
for (i, content) in files.iter().enumerate() {
|
||||
consec_builder.add_file_content(&skip_builder, i, content);
|
||||
}
|
||||
let mut idx = consec_builder.compress(Some(0));
|
||||
idx.set_skip_index(skip_builder.compress(Some(0)));
|
||||
idx
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_pattern() {
|
||||
let idx = build_test_index(&[
|
||||
b"hello world", // 0: contains "hello"
|
||||
b"goodbye world", // 1: no "hello"
|
||||
b"say hello there", // 2: contains "hello"
|
||||
]);
|
||||
|
||||
let q = regex_to_bigram_query("hello");
|
||||
assert!(!q.is_any());
|
||||
|
||||
let candidates = q.evaluate(&idx).unwrap();
|
||||
assert!(BigramFilter::is_candidate(&candidates, 0));
|
||||
assert!(!BigramFilter::is_candidate(&candidates, 1));
|
||||
assert!(BigramFilter::is_candidate(&candidates, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternation() {
|
||||
let idx = build_test_index(&[
|
||||
b"has foo in it", // 0
|
||||
b"has bar in it", // 1
|
||||
b"has xyz in it", // 2
|
||||
]);
|
||||
|
||||
let q = regex_to_bigram_query("foo|bar");
|
||||
assert!(!q.is_any());
|
||||
|
||||
let candidates = q.evaluate(&idx).unwrap();
|
||||
assert!(BigramFilter::is_candidate(&candidates, 0));
|
||||
assert!(BigramFilter::is_candidate(&candidates, 1));
|
||||
// xyz doesn't contain foo or bar bigrams
|
||||
assert!(!BigramFilter::is_candidate(&candidates, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildcard_concat() {
|
||||
let idx = build_test_index(&[
|
||||
b"foo something bar", // 0
|
||||
b"foo only", // 1: has foo but not bar
|
||||
b"only bar", // 2: has bar but not foo
|
||||
]);
|
||||
|
||||
let q = regex_to_bigram_query("foo.*bar");
|
||||
assert!(!q.is_any());
|
||||
|
||||
let candidates = q.evaluate(&idx).unwrap();
|
||||
assert!(BigramFilter::is_candidate(&candidates, 0));
|
||||
// file 1 and 2 should be filtered (missing bigrams from "bar" / "foo")
|
||||
assert!(!BigramFilter::is_candidate(&candidates, 1));
|
||||
assert!(!BigramFilter::is_candidate(&candidates, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse1_across_dot() {
|
||||
// "a.b" should produce a skip-1 bigram (a,b)
|
||||
let idx = build_test_index(&[
|
||||
b"axb", // 0: has sparse-1 (a,b)
|
||||
b"ayb", // 1: has sparse-1 (a,b)
|
||||
b"xyz", // 2: no (a,b) at all
|
||||
]);
|
||||
|
||||
let q = regex_to_bigram_query("a.b");
|
||||
assert!(!q.is_any());
|
||||
|
||||
let candidates = q.evaluate(&idx).unwrap();
|
||||
assert!(BigramFilter::is_candidate(&candidates, 0));
|
||||
assert!(BigramFilter::is_candidate(&candidates, 1));
|
||||
assert!(!BigramFilter::is_candidate(&candidates, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse1_across_digit() {
|
||||
// "foo\dbar" → sparse-1 (o,b) across \d
|
||||
let idx = build_test_index(&[
|
||||
b"foo3bar baz", // 0: has all bigrams
|
||||
b"foobar baz", // 1: has consecutive (o,b) but pattern needs sparse-1
|
||||
b"xyz only", // 2: no relevant bigrams
|
||||
]);
|
||||
|
||||
let q = regex_to_bigram_query(r"foo\dbar");
|
||||
assert!(!q.is_any());
|
||||
|
||||
let candidates = q.evaluate(&idx).unwrap();
|
||||
assert!(BigramFilter::is_candidate(&candidates, 0));
|
||||
// file 1 may or may not match depending on what bigrams are in the index
|
||||
// (it has all the literal bigrams and also o,b as both consec and skip-1)
|
||||
// The important thing is file 2 is excluded:
|
||||
assert!(!BigramFilter::is_candidate(&candidates, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_wildcard_is_any() {
|
||||
let q = regex_to_bigram_query(".*");
|
||||
assert!(q.is_any());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_char_is_any() {
|
||||
let q = regex_to_bigram_query("a");
|
||||
assert!(q.is_any());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_regex_is_any() {
|
||||
let q = regex_to_bigram_query("[invalid");
|
||||
assert!(q.is_any());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_group_excluded() {
|
||||
// (bar)? is optional — its bigrams are not required
|
||||
let q = regex_to_bigram_query("foo(bar)?baz");
|
||||
assert!(!q.is_any());
|
||||
|
||||
let idx = build_test_index(&[
|
||||
b"foobaz content", // 0: has foo+baz bigrams (bar absent)
|
||||
b"foobarbaz content", // 1: has everything
|
||||
b"xyz only", // 2: nothing
|
||||
]);
|
||||
|
||||
let candidates = q.evaluate(&idx).unwrap();
|
||||
assert!(BigramFilter::is_candidate(&candidates, 0));
|
||||
assert!(BigramFilter::is_candidate(&candidates, 1));
|
||||
assert!(!BigramFilter::is_candidate(&candidates, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repetition_min2_cross_boundary() {
|
||||
// (ab){2,} → bigram "ab" + cross-boundary "b","a"
|
||||
let q = regex_to_bigram_query("(ab){2,}");
|
||||
assert!(!q.is_any());
|
||||
|
||||
let idx = build_test_index(&[
|
||||
b"ababab", // 0: has "ab" and "b"->"a"
|
||||
b"abonly", // 1: has "ab" but not "b"->"a"
|
||||
b"xyz", // 2: nothing
|
||||
]);
|
||||
|
||||
let candidates = q.evaluate(&idx).unwrap();
|
||||
assert!(BigramFilter::is_candidate(&candidates, 0));
|
||||
assert!(!BigramFilter::is_candidate(&candidates, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_dots_no_sparse1() {
|
||||
// "a..b" — two 1-byte parts between a and b, not a single 1-byte part
|
||||
// No sparse-1 (a,b) should be extracted
|
||||
let q = regex_to_bigram_query("a..b");
|
||||
// Single-char literals with 2 unknown bytes between → Any
|
||||
assert!(q.is_any());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_class_cross_boundary() {
|
||||
// [abc]de → cross-boundary OR(ad,bd,cd) + bigram de
|
||||
// All three class variants must appear in the corpus so the OR
|
||||
// branches are tracked in the index (untracked bigrams make the
|
||||
// OR conservatively return None, which is correct but untestable).
|
||||
let idx = build_test_index(&[
|
||||
b"ade content", // 0: has ad
|
||||
b"bde content", // 1: has bd
|
||||
b"cde content", // 2: has cd
|
||||
b"xde content", // 3: has de but not ad/bd/cd
|
||||
]);
|
||||
|
||||
let q = regex_to_bigram_query("[abc]de");
|
||||
assert!(!q.is_any());
|
||||
|
||||
let candidates = q.evaluate(&idx).unwrap();
|
||||
assert!(BigramFilter::is_candidate(&candidates, 0));
|
||||
assert!(BigramFilter::is_candidate(&candidates, 1));
|
||||
assert!(BigramFilter::is_candidate(&candidates, 2));
|
||||
// file 3 doesn't have ad/bd/cd so should be filtered
|
||||
assert!(!BigramFilter::is_candidate(&candidates, 3));
|
||||
}
|
||||
|
||||
// ── Helpers for inspecting query trees ──────────────────────────
|
||||
|
||||
fn has_consec(q: &BigramQuery, a: u8, b: u8) -> bool {
|
||||
let Some(key) = consec_key(a, b) else {
|
||||
return false;
|
||||
};
|
||||
match q {
|
||||
BigramQuery::Consec(k) => *k == key,
|
||||
BigramQuery::And(cs) | BigramQuery::Or(cs) => cs.iter().any(|c| has_consec(c, a, b)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_skip1(q: &BigramQuery, a: u8, b: u8) -> bool {
|
||||
let Some(key) = consec_key(a, b) else {
|
||||
return false;
|
||||
};
|
||||
match q {
|
||||
BigramQuery::Skip1(k) => *k == key,
|
||||
BigramQuery::And(cs) | BigramQuery::Or(cs) => cs.iter().any(|c| has_skip1(c, a, b)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bigram expectation: `("ab", is_skip1)`.
|
||||
/// The 2-char str is the byte pair; C = consecutive, S = skip-1.
|
||||
type Bg = (&'static str, bool);
|
||||
const C: bool = false;
|
||||
const S: bool = true;
|
||||
|
||||
/// Top 15+ commonly used regex patterns from
|
||||
/// https://digitalfortress.tech/tips/top-15-commonly-used-regex/
|
||||
/// plus typical grep patterns used by agentic tools.
|
||||
///
|
||||
/// Each entry: `(regex, Option<&[Bg]>)`.
|
||||
/// - `None` → pure classes / unsupported syntax, Any is acceptable.
|
||||
/// - `Some(&[..])` → must be non-Any, and every listed bigram must appear.
|
||||
#[test]
|
||||
fn common_regex_patterns() {
|
||||
#[rustfmt::skip]
|
||||
let cases: &[(&str, Option<&[Bg]>)] = &[
|
||||
// ── Pure-class / anchor / unsupported → Any is fine ──────
|
||||
(r"^\d+$", None), // 1. whole numbers
|
||||
(r"^\d*\.\d+$", None), // 2. decimals
|
||||
(r"^\d*(\.\d+)?$", None), // 3. whole + decimal
|
||||
(r"^-?\d*(\.\d+)?$", None), // 4. neg/pos decimal
|
||||
(r"[-]?[0-9]+[,.]?[0-9]*([/][0-9]+[,.]?[0-9]*)*", None), // 5. fractions
|
||||
(r"^[a-zA-Z0-9]*$", None), // 6. alphanumeric
|
||||
(r"^[a-zA-Z0-9 ]*$", None), // 7. alphanum + space
|
||||
(r"^([a-zA-Z0-9._%-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6})*$", None), // 8. email
|
||||
(r"^([a-z0-9_\.\+-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})$", None), // 9. email v2
|
||||
(r"(?=(.*[0-9]))(?=.*[!@#$%^&*()\[\]{}\-_+=~`|:;<>,./?\x5c])(?=.*[a-z])(?=(.*[A-Z]))(?=(.*)).{8,}", None), // 10. complex pw
|
||||
(r"(?=(.*[0-9]))((?=.*[A-Za-z0-9])(?=.*[A-Z])(?=.*[a-z]))^.{8,}$", None), // 11. moderate pw
|
||||
(r"^[a-z0-9_-]{3,16}$", None), // 12. username
|
||||
(r"(https?://)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)", None), // 14. URL optional
|
||||
(r"^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$", None), // 15. IPv4
|
||||
(r"(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))", None), // 16. IPv6
|
||||
(r"[12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])", None), // 17. date
|
||||
(r"^(0?[1-9]|1[0-2]):[0-5][0-9]$", None), // 18. time 12h
|
||||
(r"((1[0-2]|0?[1-9]):([0-5][0-9]) ?([AaPp][Mm]))", None), // 19. time AM/PM
|
||||
(r"^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$", None), // 20. time 24h
|
||||
(r"^([0-9]|0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$", None), // 21. time 24h v2
|
||||
(r"(?:[01]\d|2[0123]):(?:[012345]\d):(?:[012345]\d)", None), // 22. time+sec
|
||||
(r"</?[\w\s]*>|<.+[\W]>", None), // 23. HTML tag
|
||||
(r"\bon\w+=\S+(?=.*>)", None), // 24. inline JS
|
||||
(r"^[a-z0-9]+(?:-[a-z0-9]+)*$", None), // 25. slug
|
||||
(r"(\b\w+\b)(?=.*\b\1\b)", None), // 26. dup words
|
||||
(r"^[\w,\s-]+\.[A-Za-z]{3}$", None), // 27. filename
|
||||
(r"^[A-PR-WY][1-9]\d\s?\d{4}[1-9]$", None), // 28. HK ID
|
||||
|
||||
// ── Patterns with extractable literal bigrams ────────────
|
||||
|
||||
// 13. URL with required protocol
|
||||
(r"https?://(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)", Some(&[
|
||||
("ht", C), ("tt", C), ("tp", C), // from "http"
|
||||
("ht", S), ("tp", S), // from "http" skip-1
|
||||
(":/", C), ("//", C), // from "://"
|
||||
])),
|
||||
|
||||
// 29. fn\s+\w+
|
||||
(r"fn\s+\w+", Some(&[
|
||||
("fn", C), // from "fn"
|
||||
("n ", C), // cross-boundary: 'n' → \s starts ' '
|
||||
])),
|
||||
|
||||
// 30. use\s+crate::
|
||||
(r"use\s+crate::", Some(&[
|
||||
("us", C), ("se", C), ("ue", S), // from "use"
|
||||
("cr", C), ("ra", C), ("at", C), // from "crate"
|
||||
("te", C), ("::", C),
|
||||
("ca", S), ("rt", S), ("ae", S), // "crate" skip-1
|
||||
])),
|
||||
|
||||
// 31. unwrap\(\)|expect\(
|
||||
(r"unwrap\(\)|expect\(", Some(&[
|
||||
("nw", C), ("wr", C), ("ra", C), // "unwrap("
|
||||
("ap", C), ("p(", C),
|
||||
("xp", C), ("pe", C), ("ec", C), // "expect("
|
||||
("ct", C), ("t(", C),
|
||||
])),
|
||||
|
||||
// 32. TODO|FIXME|HACK
|
||||
(r"TODO|FIXME|HACK", Some(&[
|
||||
("to", C), ("od", C), ("do", C), // "TODO"
|
||||
("fi", C), ("ix", C), ("xm", C), // "FIXME"
|
||||
("me", C),
|
||||
("ha", C), ("ac", C), ("ck", C), // "HACK"
|
||||
("hc", S), ("ak", S), // "HACK" skip-1
|
||||
])),
|
||||
];
|
||||
|
||||
for (i, &(pattern, expected)) in cases.iter().enumerate() {
|
||||
let q = regex_to_bigram_query(pattern);
|
||||
|
||||
if let Some(bigrams) = expected {
|
||||
assert!(
|
||||
!q.is_any(),
|
||||
"#{i} {pattern:?}: expected bigrams but got Any"
|
||||
);
|
||||
|
||||
for &(pair, skip) in bigrams {
|
||||
let b = pair.as_bytes();
|
||||
debug_assert_eq!(b.len(), 2, "bigram must be 2 chars: {pair:?}");
|
||||
let found = if skip {
|
||||
has_skip1(&q, b[0], b[1])
|
||||
} else {
|
||||
has_consec(&q, b[0], b[1])
|
||||
};
|
||||
let kind = if skip { "skip-1" } else { "consec" };
|
||||
assert!(found, "#{i} {pattern:?}: missing {kind} bigram {pair:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
//! SIMD-accelerated case-insensitive substring search.
|
||||
//!
|
||||
//! Implementations (fastest → simplest):
|
||||
//! - `search_packed_pair`: AVX2 packed-pair scan (two rare bytes at known offsets)
|
||||
//! - `search`: memchr2 first-byte scan + verify
|
||||
//!
|
||||
//! The packed-pair approach mirrors what `memchr::memmem` does internally for
|
||||
//! case-sensitive search — pick two rare bytes from the needle, SIMD-scan for
|
||||
//! both simultaneously, verify candidates. This gives quadratic selectivity
|
||||
//! over the single-byte memchr2 approach.
|
||||
|
||||
// this is stolen from the memchr2 crate
|
||||
const BYTE_FREQUENCIES: [u8; 256] = [
|
||||
55, 52, 51, 50, 49, 48, 47, 46, 45, 103, 242, 66, 67, 229, 44, 43, // 0x00
|
||||
42, 41, 40, 39, 38, 37, 36, 35, 34, 33, 56, 32, 31, 30, 29, 28, // 0x10
|
||||
255, 148, 164, 149, 136, 160, 155, 173, 221, 222, 134, 122, 232, 202, 215, 224, // 0x20
|
||||
208, 220, 204, 187, 183, 179, 177, 168, 178, 200, 226, 195, 154, 184, 174, 126, // 0x30
|
||||
120, 191, 157, 194, 170, 189, 162, 161, 150, 193, 142, 137, 171, 176, 185,
|
||||
167, // 0x40 A-O
|
||||
186, 112, 175, 192, 188, 156, 140, 143, 123, 133, 128, 147, 138, 146, 114,
|
||||
223, // 0x50 P-_
|
||||
151, 249, 216, 238, 236, 253, 227, 218, 230, 247, 135, 180, 241, 233, 246,
|
||||
244, // 0x60 a-o
|
||||
231, 139, 245, 243, 251, 235, 201, 196, 240, 214, 152, 182, 205, 181, 127,
|
||||
27, // 0x70 p-DEL
|
||||
212, 211, 210, 213, 228, 197, 169, 159, 131, 172, 105, 80, 98, 96, 97, 81, // 0x80
|
||||
207, 145, 116, 115, 144, 130, 153, 121, 107, 132, 109, 110, 124, 111, 82, 108, // 0x90
|
||||
118, 141, 113, 129, 119, 125, 165, 117, 92, 106, 83, 72, 99, 93, 65, 79, // 0xa0
|
||||
166, 237, 163, 199, 190, 225, 209, 203, 198, 217, 219, 206, 234, 248, 158, 239, // 0xb0
|
||||
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, // 0xc0
|
||||
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, // 0xd0
|
||||
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, // 0xe0
|
||||
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, // 0xf0
|
||||
];
|
||||
|
||||
#[inline]
|
||||
fn ascii_fold_byte(b: u8) -> u8 {
|
||||
if b.is_ascii_uppercase() { b | 0x20 } else { b }
|
||||
}
|
||||
|
||||
/// Toggle ASCII letter case by flipping bit 5.
|
||||
/// `'n' → 'N'`, `'N' → 'n'`.
|
||||
#[inline]
|
||||
fn ascii_swap_case(b: u8) -> u8 {
|
||||
b ^ 0x20
|
||||
}
|
||||
|
||||
/// Effective frequency rank for a case-insensitive byte position.
|
||||
/// Takes the max of lower/upper ranks because we must scan for both.
|
||||
#[inline]
|
||||
fn case_insensitive_rank(lower: u8) -> u8 {
|
||||
if lower.is_ascii_lowercase() {
|
||||
let upper = ascii_swap_case(lower);
|
||||
BYTE_FREQUENCIES[lower as usize].max(BYTE_FREQUENCIES[upper as usize])
|
||||
} else {
|
||||
BYTE_FREQUENCIES[lower as usize]
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick two needle positions with the rarest bytes (case-insensitive).
|
||||
/// Returns (index1, index2) where index1 <= index2.
|
||||
fn select_rare_pair(needle_lower: &[u8]) -> (usize, usize) {
|
||||
debug_assert!(needle_lower.len() >= 2);
|
||||
|
||||
let mut best1 = (u8::MAX, 0usize); // (rank, position)
|
||||
let mut best2 = (u8::MAX, 1usize);
|
||||
|
||||
for (i, &b) in needle_lower.iter().enumerate() {
|
||||
let r = case_insensitive_rank(b);
|
||||
if r < best1.0 {
|
||||
best2 = best1;
|
||||
best1 = (r, i);
|
||||
} else if r < best2.0 && i != best1.1 {
|
||||
best2 = (r, i);
|
||||
}
|
||||
}
|
||||
|
||||
let i1 = best1.1.min(best2.1);
|
||||
let i2 = best1.1.max(best2.1);
|
||||
(i1, i2)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn verify_scalar(h: *const u8, needle_lower: &[u8]) -> bool {
|
||||
for (i, _) in needle_lower.iter().enumerate() {
|
||||
if ascii_fold_byte(unsafe { *h.add(i) }) != needle_lower[i] {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// AVX2 case-insensitive verify: checks whether `needle_lower` matches
|
||||
/// the haystack bytes starting at `h`, treating ASCII uppercase as lowercase.
|
||||
///
|
||||
/// Processes 32 bytes at a time using a SIMD trick: AVX2 only has a
|
||||
/// **signed** byte compare (`cmpgt`), but we need an **unsigned** range
|
||||
/// check (`'A' <= byte <= 'Z'`). The trick is to XOR every byte with
|
||||
/// `0x80`, which maps the unsigned range `[0, 255]` into the signed range
|
||||
/// `[-128, 127]` while preserving order. After the flip, signed `cmpgt`
|
||||
/// gives correct unsigned comparisons.
|
||||
///
|
||||
/// Once we know which bytes are uppercase, we set bit 5 (`0x20`) on them
|
||||
/// — this converts `'A'..'Z'` to `'a'..'z'` — then compare against the
|
||||
/// pre-lowered needle.
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
#[target_feature(enable = "avx2")]
|
||||
unsafe fn verify_avx2(h: *const u8, needle_lower: &[u8]) -> bool {
|
||||
use core::arch::x86_64::*;
|
||||
|
||||
let len = needle_lower.len();
|
||||
let mut i = 0usize;
|
||||
|
||||
// Broadcast constants used every iteration:
|
||||
//
|
||||
// flip = 0x80 in every lane — XOR converts unsigned→signed domain
|
||||
// a_minus_1 = ('A' - 1) ^ 0x80 — lower bound for the range check (signed)
|
||||
// z_plus_1 = ('Z' + 1) ^ 0x80 — upper bound for the range check (signed)
|
||||
// bit20 = 0x20 in every lane — OR this onto uppercase bytes to lowercase them
|
||||
let flip = _mm256_set1_epi8(0x80u8 as i8);
|
||||
let a_minus_1 = _mm256_set1_epi8((b'A' - 1) as i8 ^ 0x80u8 as i8);
|
||||
let z_plus_1 = _mm256_set1_epi8((b'Z' + 1) as i8 ^ 0x80u8 as i8);
|
||||
let bit20 = _mm256_set1_epi8(0x20u8 as i8);
|
||||
|
||||
while i + 32 <= len {
|
||||
// Load 32 bytes from the haystack candidate position.
|
||||
let hv = unsafe { _mm256_loadu_si256(h.add(i) as *const __m256i) };
|
||||
// Load 32 bytes from the pre-lowercased needle.
|
||||
let nv = unsafe { _mm256_loadu_si256(needle_lower.as_ptr().add(i) as *const __m256i) };
|
||||
|
||||
// Flip into signed domain: x = hv ^ 0x80.
|
||||
// After this, unsigned ordering is preserved under signed compare.
|
||||
let x = _mm256_xor_si256(hv, flip);
|
||||
|
||||
// ge_a[lane] = 0xFF if x[lane] > a_minus_1, i.e. hv[lane] >= 'A' (unsigned).
|
||||
let ge_a = _mm256_cmpgt_epi8(x, a_minus_1);
|
||||
// le_z[lane] = 0xFF if z_plus_1 > x[lane], i.e. hv[lane] <= 'Z' (unsigned).
|
||||
let le_z = _mm256_cmpgt_epi8(z_plus_1, x);
|
||||
// upper[lane] = 0xFF only for bytes in the range 'A'..='Z'.
|
||||
let upper = _mm256_and_si256(ge_a, le_z);
|
||||
|
||||
// Case-fold: set bit 5 on uppercase bytes → converts 'A'..'Z' to 'a'..'z'.
|
||||
// Non-letter bytes are untouched because their `upper` lane is 0x00.
|
||||
let folded = _mm256_or_si256(hv, _mm256_and_si256(upper, bit20));
|
||||
|
||||
// Compare the folded haystack against the lowercase needle.
|
||||
let eq = _mm256_cmpeq_epi8(folded, nv);
|
||||
// movemask extracts the high bit of each lane into a 32-bit mask.
|
||||
// All-equal → all high bits set → mask == 0xFFFFFFFF == -1i32.
|
||||
if _mm256_movemask_epi8(eq) != -1i32 {
|
||||
return false;
|
||||
}
|
||||
|
||||
i += 32;
|
||||
}
|
||||
|
||||
// Scalar tail: handle remaining bytes that don't fill a full 32-byte vector.
|
||||
while i < len {
|
||||
if ascii_fold_byte(unsafe { *h.add(i) }) != needle_lower[i] {
|
||||
return false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ======== NEON + dotprod (aarch64) ===========================================
|
||||
|
||||
/// Extract a 16-bit bitmask from a NEON comparison result (each byte 0x00 or 0xFF).
|
||||
/// Bit *i* of the result corresponds to byte *i* of the input vector.
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
#[target_feature(enable = "neon")]
|
||||
#[inline]
|
||||
unsafe fn neon_movemask(v: core::arch::aarch64::uint8x16_t) -> u16 {
|
||||
use core::arch::aarch64::*;
|
||||
|
||||
// AND each byte with its bit-position mask, then horizontally sum each half.
|
||||
// Max possible sum per half = 1+2+4+8+16+32+64+128 = 255, fits in u8.
|
||||
static BITS: [u8; 16] = [1, 2, 4, 8, 16, 32, 64, 128, 1, 2, 4, 8, 16, 32, 64, 128];
|
||||
let bit_mask = unsafe { vld1q_u8(BITS.as_ptr()) };
|
||||
let masked = vandq_u8(v, bit_mask);
|
||||
let lo = vaddv_u8(vget_low_u8(masked));
|
||||
let hi = vaddv_u8(vget_high_u8(masked));
|
||||
(lo as u16) | ((hi as u16) << 8)
|
||||
}
|
||||
|
||||
/// NEON + dotprod case-insensitive verify.
|
||||
///
|
||||
/// Uses unsigned range checks (NEON has `vcge`/`vcle` for unsigned bytes
|
||||
/// no XOR-0x80 trick needed unlike AVX2) to detect uppercase ASCII, folds
|
||||
/// to lowercase, then checks equality via UDOT: XOR the folded haystack
|
||||
/// with the pre-lowered needle and dot-product the difference with itself.
|
||||
/// Any non-zero byte produces a non-zero u32 lane.
|
||||
///
|
||||
/// The UDOT instruction is emitted via inline asm because the `vdotq_u32`
|
||||
/// intrinsic is still behind an unstable feature gate on stable Rust.
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
#[target_feature(enable = "neon,dotprod")]
|
||||
unsafe fn verify_neon_dotprod(h: *const u8, needle_lower: &[u8]) -> bool {
|
||||
use core::arch::aarch64::*;
|
||||
|
||||
let len = needle_lower.len();
|
||||
let mut i = 0usize;
|
||||
|
||||
let a_val = vdupq_n_u8(b'A');
|
||||
let z_val = vdupq_n_u8(b'Z');
|
||||
let bit20 = vdupq_n_u8(0x20);
|
||||
|
||||
while i + 16 <= len {
|
||||
let hv = unsafe { vld1q_u8(h.add(i)) };
|
||||
let nv = unsafe { vld1q_u8(needle_lower.as_ptr().add(i)) };
|
||||
|
||||
// Unsigned range check: 'A' <= byte <= 'Z'
|
||||
let upper = vandq_u8(vcgeq_u8(hv, a_val), vcleq_u8(hv, z_val));
|
||||
// Case-fold: set bit 5 on uppercase bytes → 'A'..'Z' → 'a'..'z'
|
||||
let folded = vorrq_u8(hv, vandq_u8(upper, bit20));
|
||||
|
||||
// XOR with needle — all-zero iff every byte matches.
|
||||
let xored = veorq_u8(folded, nv);
|
||||
|
||||
// UDOT: dot(xored, xored) sums squares of 4 consecutive byte
|
||||
// differences into each of the 4 u32 lanes (accumulates into zero).
|
||||
// Any non-zero byte produces a positive u32 contribution.
|
||||
let dots: uint32x4_t;
|
||||
let zero = vdupq_n_u32(0);
|
||||
unsafe {
|
||||
core::arch::asm!(
|
||||
"udot {d:v}.4s, {a:v}.16b, {b:v}.16b",
|
||||
d = inlateout(vreg) zero => dots,
|
||||
a = in(vreg) xored,
|
||||
b = in(vreg) xored,
|
||||
);
|
||||
}
|
||||
|
||||
if vmaxvq_u32(dots) != 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
i += 16;
|
||||
}
|
||||
|
||||
// Scalar tail
|
||||
while i < len {
|
||||
if ascii_fold_byte(unsafe { *h.add(i) }) != needle_lower[i] {
|
||||
return false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// NEON packed-pair kernel: scan 16 haystack positions per iteration,
|
||||
/// checking two rare bytes (case-insensitive) simultaneously.
|
||||
/// Same algorithm as the AVX2 version but with 128-bit vectors.
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
#[target_feature(enable = "neon")]
|
||||
unsafe fn search_packed_pair_neon(
|
||||
haystack: &[u8],
|
||||
needle_lower: &[u8],
|
||||
i1: usize,
|
||||
i2: usize,
|
||||
) -> bool {
|
||||
use core::arch::aarch64::*;
|
||||
|
||||
let n = needle_lower.len();
|
||||
let hlen = haystack.len();
|
||||
let ptr = haystack.as_ptr();
|
||||
let last_start = hlen - n;
|
||||
|
||||
let b1 = needle_lower[i1];
|
||||
let b1_alt = if b1.is_ascii_lowercase() {
|
||||
ascii_swap_case(b1)
|
||||
} else {
|
||||
b1
|
||||
};
|
||||
let b2 = needle_lower[i2];
|
||||
let b2_alt = if b2.is_ascii_lowercase() {
|
||||
ascii_swap_case(b2)
|
||||
} else {
|
||||
b2
|
||||
};
|
||||
|
||||
let v1_lo = vdupq_n_u8(b1);
|
||||
let v1_hi = vdupq_n_u8(b1_alt);
|
||||
let v2_lo = vdupq_n_u8(b2);
|
||||
let v2_hi = vdupq_n_u8(b2_alt);
|
||||
|
||||
let max_idx = i1.max(i2);
|
||||
let max_offset = hlen.saturating_sub(max_idx + 16);
|
||||
let mut offset = 0usize;
|
||||
|
||||
while offset <= max_offset {
|
||||
let chunk1 = unsafe { vld1q_u8(ptr.add(offset + i1)) };
|
||||
let chunk2 = unsafe { vld1q_u8(ptr.add(offset + i2)) };
|
||||
|
||||
// Case-insensitive match: OR both case variants, then AND the two positions.
|
||||
let eq1 = vorrq_u8(vceqq_u8(chunk1, v1_lo), vceqq_u8(chunk1, v1_hi));
|
||||
let eq2 = vorrq_u8(vceqq_u8(chunk2, v2_lo), vceqq_u8(chunk2, v2_hi));
|
||||
|
||||
let mut mask = unsafe { neon_movemask(vandq_u8(eq1, eq2)) };
|
||||
|
||||
while mask != 0 {
|
||||
let bit = mask.trailing_zeros() as usize;
|
||||
let candidate = offset + bit;
|
||||
if candidate > last_start {
|
||||
return false;
|
||||
}
|
||||
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
mask &= mask - 1;
|
||||
}
|
||||
|
||||
offset += 16;
|
||||
}
|
||||
|
||||
// Tail: remaining positions that couldn't fill a full vector.
|
||||
if offset <= last_start {
|
||||
let rare_pos =
|
||||
if case_insensitive_rank(needle_lower[i1]) <= case_insensitive_rank(needle_lower[i2]) {
|
||||
i1
|
||||
} else {
|
||||
i2
|
||||
};
|
||||
let rare_byte = needle_lower[rare_pos];
|
||||
let tail_start = offset + rare_pos;
|
||||
let tail_end = last_start + rare_pos + 1;
|
||||
if tail_start < tail_end {
|
||||
let tail_space = &haystack[tail_start..tail_end];
|
||||
if rare_byte.is_ascii_lowercase() {
|
||||
for pos in memchr::memchr2_iter(rare_byte, ascii_swap_case(rare_byte), tail_space) {
|
||||
let candidate = offset + pos;
|
||||
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for pos in memchr::memchr_iter(rare_byte, tail_space) {
|
||||
let candidate = offset + pos;
|
||||
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[inline]
|
||||
unsafe fn verify_dispatch(h: *const u8, needle_lower: &[u8]) -> bool {
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
{
|
||||
if needle_lower.len() >= 32 && std::is_x86_feature_detected!("avx2") {
|
||||
return unsafe { verify_avx2(h, needle_lower) };
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
{
|
||||
if needle_lower.len() >= 16 && std::arch::is_aarch64_feature_detected!("dotprod") {
|
||||
return unsafe { verify_neon_dotprod(h, needle_lower) };
|
||||
}
|
||||
}
|
||||
|
||||
verify_scalar(h, needle_lower)
|
||||
}
|
||||
|
||||
// ── Packed-pair search (AVX2) ───────────────────────────────────────────
|
||||
|
||||
/// AVX2 packed-pair kernel: scan 32 haystack positions per iteration,
|
||||
/// checking two rare bytes (case-insensitive) simultaneously.
|
||||
/// 4 cmpeq + 2 or + 1 and + 1 movemask per 32 bytes — same memory
|
||||
/// bandwidth as memchr2 but quadratic selectivity.
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
#[target_feature(enable = "avx2")]
|
||||
unsafe fn search_packed_pair_avx2(
|
||||
haystack: &[u8],
|
||||
needle_lower: &[u8],
|
||||
i1: usize,
|
||||
i2: usize,
|
||||
) -> bool {
|
||||
use core::arch::x86_64::*;
|
||||
|
||||
let n = needle_lower.len();
|
||||
let hlen = haystack.len();
|
||||
let ptr = haystack.as_ptr();
|
||||
let last_start = hlen - n; // last valid match-start position
|
||||
|
||||
let b1 = needle_lower[i1];
|
||||
let b1_alt = if b1.is_ascii_lowercase() {
|
||||
ascii_swap_case(b1)
|
||||
} else {
|
||||
b1
|
||||
};
|
||||
let b2 = needle_lower[i2];
|
||||
let b2_alt = if b2.is_ascii_lowercase() {
|
||||
ascii_swap_case(b2)
|
||||
} else {
|
||||
b2
|
||||
};
|
||||
|
||||
let v1_lo = _mm256_set1_epi8(b1 as i8);
|
||||
let v1_hi = _mm256_set1_epi8(b1_alt as i8);
|
||||
let v2_lo = _mm256_set1_epi8(b2 as i8);
|
||||
let v2_hi = _mm256_set1_epi8(b2_alt as i8);
|
||||
|
||||
// Main loop: process 32 candidate positions per iteration.
|
||||
// We load from ptr+offset+i1 and ptr+offset+i2, so we need
|
||||
// offset + max(i1,i2) + 31 < hlen.
|
||||
let max_idx = i1.max(i2);
|
||||
let max_offset = hlen.saturating_sub(max_idx + 32);
|
||||
let mut offset = 0usize;
|
||||
|
||||
while offset <= max_offset {
|
||||
let chunk1 = unsafe { _mm256_loadu_si256(ptr.add(offset + i1) as *const __m256i) };
|
||||
let chunk2 = unsafe { _mm256_loadu_si256(ptr.add(offset + i2) as *const __m256i) };
|
||||
|
||||
// Case-insensitive match: OR both case variants, then AND the two positions.
|
||||
let eq1 = _mm256_or_si256(
|
||||
_mm256_cmpeq_epi8(chunk1, v1_lo),
|
||||
_mm256_cmpeq_epi8(chunk1, v1_hi),
|
||||
);
|
||||
let eq2 = _mm256_or_si256(
|
||||
_mm256_cmpeq_epi8(chunk2, v2_lo),
|
||||
_mm256_cmpeq_epi8(chunk2, v2_hi),
|
||||
);
|
||||
|
||||
let mut mask = _mm256_movemask_epi8(_mm256_and_si256(eq1, eq2)) as u32;
|
||||
|
||||
while mask != 0 {
|
||||
let bit = mask.trailing_zeros() as usize;
|
||||
let candidate = offset + bit;
|
||||
if candidate > last_start {
|
||||
// Past the end — no more valid positions in this or future chunks.
|
||||
return false;
|
||||
}
|
||||
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
mask &= mask - 1;
|
||||
}
|
||||
|
||||
offset += 32;
|
||||
}
|
||||
|
||||
// Tail: remaining positions that couldn't fill a full vector.
|
||||
// Use memchr2 on the rarest byte for these last few positions.
|
||||
if offset <= last_start {
|
||||
let rare_pos =
|
||||
if case_insensitive_rank(needle_lower[i1]) <= case_insensitive_rank(needle_lower[i2]) {
|
||||
i1
|
||||
} else {
|
||||
i2
|
||||
};
|
||||
let rare_byte = needle_lower[rare_pos];
|
||||
let tail_start = offset + rare_pos;
|
||||
let tail_end = last_start + rare_pos + 1;
|
||||
if tail_start < tail_end {
|
||||
let tail_space = &haystack[tail_start..tail_end];
|
||||
if rare_byte.is_ascii_lowercase() {
|
||||
for pos in memchr::memchr2_iter(rare_byte, ascii_swap_case(rare_byte), tail_space) {
|
||||
let candidate = offset + pos;
|
||||
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for pos in memchr::memchr_iter(rare_byte, tail_space) {
|
||||
let candidate = offset + pos;
|
||||
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Packed-pair case-insensitive substring search.
|
||||
///
|
||||
/// Selects the two rarest bytes from the needle (using the memchr byte
|
||||
/// frequency heuristic), then SIMD-scans for both at their known offsets
|
||||
/// simultaneously. Falls back to `search` for needles shorter than 2 bytes.
|
||||
pub fn search_packed_pair(haystack: &[u8], needle_lower: &[u8]) -> bool {
|
||||
let n = needle_lower.len();
|
||||
if n == 0 {
|
||||
return true;
|
||||
}
|
||||
if n < 2 {
|
||||
return search(haystack, needle_lower);
|
||||
}
|
||||
if n > haystack.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let (i1, i2) = select_rare_pair(needle_lower);
|
||||
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
{
|
||||
if std::is_x86_feature_detected!("avx2") {
|
||||
// Need enough haystack for at least one vector load.
|
||||
let max_idx = i1.max(i2);
|
||||
if haystack.len() >= max_idx + 32 {
|
||||
return unsafe { search_packed_pair_avx2(haystack, needle_lower, i1, i2) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
{
|
||||
// The NEON packed-pair scan checks 16 bytes/iteration with ~7 ops,
|
||||
// while memchr's optimized loop processes more bytes with fewer ops.
|
||||
// Packed-pair wins when the first byte is common (lots of false
|
||||
// positives for memchr2 that we avoid). But when the first byte is
|
||||
// rare (z, q, x, ...) memchr2 has no false positives and its raw
|
||||
// throughput dominates. Threshold 200 on the frequency table splits
|
||||
// common letters (s=243, e=253, f=227) from rare ones (z=152, q=139).
|
||||
let first_byte_rank = case_insensitive_rank(needle_lower[0]);
|
||||
let max_idx = i1.max(i2);
|
||||
if first_byte_rank >= 200 && haystack.len() >= max_idx + 16 {
|
||||
return unsafe { search_packed_pair_neon(haystack, needle_lower, i1, i2) };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for short haystacks or non-SIMD platforms.
|
||||
search(haystack, needle_lower)
|
||||
}
|
||||
|
||||
// ── Original memchr2 first-byte search ──────────────────────────────────
|
||||
|
||||
/// Case-insensitive search using memchr2 on the first byte.
|
||||
pub fn search(haystack: &[u8], needle_lower: &[u8]) -> bool {
|
||||
let n = needle_lower.len();
|
||||
if n == 0 {
|
||||
return true;
|
||||
}
|
||||
if n > haystack.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let search_space = &haystack[..=haystack.len() - n];
|
||||
let first = needle_lower[0];
|
||||
|
||||
if first.is_ascii_lowercase() {
|
||||
let alt = ascii_swap_case(first);
|
||||
for pos in memchr::memchr2_iter(first, alt, search_space) {
|
||||
if unsafe { verify_dispatch(haystack.as_ptr().add(pos), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for pos in memchr::memchr_iter(first, search_space) {
|
||||
if unsafe { verify_dispatch(haystack.as_ptr().add(pos), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn basic_case_insensitive() {
|
||||
assert!(search_packed_pair(b"Hello World", b"hello"));
|
||||
assert!(search_packed_pair(b"Hello World", b"world"));
|
||||
assert!(search_packed_pair(b"NOMORE bugs", b"nomore"));
|
||||
assert!(!search_packed_pair(b"Hello World", b"xyz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_cases() {
|
||||
assert!(search_packed_pair(b"ab", b"ab"));
|
||||
assert!(search_packed_pair(b"AB", b"ab"));
|
||||
assert!(!search_packed_pair(b"a", b"ab"));
|
||||
assert!(search_packed_pair(b"anything", b""));
|
||||
assert!(!search_packed_pair(b"", b"x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_pair_matches_search() {
|
||||
let haystacks: &[&[u8]] = &[
|
||||
b"The quick brown fox jumps over the lazy dog",
|
||||
b"int mutex_lock(struct mutex *lock) { return 0; }",
|
||||
b"#define NOMORE_RETRIES 5\nif (nomore) return;",
|
||||
b"abcdefghijklmnopqrstuvwxyz",
|
||||
b"short",
|
||||
];
|
||||
let needles: &[&[u8]] = &[b"fox", b"mutex", b"nomore", b"xyz", b"the", b"short", b"qr"];
|
||||
for h in haystacks {
|
||||
for n in needles {
|
||||
let lower: Vec<u8> = n.iter().map(|b| b.to_ascii_lowercase()).collect();
|
||||
assert_eq!(
|
||||
search_packed_pair(h, &lower),
|
||||
search(h, &lower),
|
||||
"mismatch for haystack={:?} needle={:?}",
|
||||
std::str::from_utf8(h),
|
||||
std::str::from_utf8(n),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_haystack_neon_path() {
|
||||
// Haystack > 16 bytes exercises NEON packed-pair search loop
|
||||
let haystack =
|
||||
b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaTHIS_IS_A_LONG_NEEDLE_TESTbbbbbbbbbbbbbbbbbb";
|
||||
assert!(search_packed_pair(haystack, b"this_is_a_long_needle_test"));
|
||||
assert!(!search_packed_pair(
|
||||
haystack,
|
||||
b"this_is_a_long_needle_testz"
|
||||
));
|
||||
|
||||
// Needle >= 16 bytes exercises NEON dotprod verify
|
||||
let long_needle = b"struct mutex *lock";
|
||||
let haystack2 = b"int STRUCT MUTEX *LOCK(struct mutex *lock) { return 0; }";
|
||||
assert!(search_packed_pair(haystack2, long_needle));
|
||||
|
||||
// All uppercase haystack, lowercase needle
|
||||
let upper_hay = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
assert!(search_packed_pair(upper_hay, b"qrstuvwxyz0123456789a"));
|
||||
assert!(!search_packed_pair(upper_hay, b"qrstuvwxyz01234567899"));
|
||||
|
||||
// Needle at very end
|
||||
let end_hay = b"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxfind_me";
|
||||
assert!(search_packed_pair(end_hay, b"find_me"));
|
||||
|
||||
// Needle at very start
|
||||
assert!(search_packed_pair(end_hay, b"xx"));
|
||||
|
||||
// 1KB haystack with needle near the end
|
||||
let mut big = vec![b'z'; 1024];
|
||||
big[1000..1010].copy_from_slice(b"hElLo_WoRl");
|
||||
assert!(search_packed_pair(&big, b"hello_wo"));
|
||||
assert!(!search_packed_pair(&big, b"hello_world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rare_pair_selection() {
|
||||
// For "nomore": n=246, o=244, m=233, o=244, r=245, e=253
|
||||
// Rarest positions should include 'm' (pos 2, rank 233)
|
||||
let (i1, i2) = select_rare_pair(b"nomore");
|
||||
let ranks: Vec<u8> = b"nomore"
|
||||
.iter()
|
||||
.map(|&b| case_insensitive_rank(b))
|
||||
.collect();
|
||||
let r1 = ranks[i1];
|
||||
let r2 = ranks[i2];
|
||||
// Both selected ranks should be <= all other ranks
|
||||
for (i, &r) in ranks.iter().enumerate() {
|
||||
if i != i1 && i != i2 {
|
||||
assert!(r1 <= r || r2 <= r, "pair ({i1},{i2}) not optimal");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
+771
-242
File diff suppressed because it is too large
Load Diff
@@ -9,16 +9,23 @@ pub struct DbHealth {
|
||||
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::EnvOpen)?;
|
||||
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()?;
|
||||
|
||||
@@ -26,6 +33,7 @@ pub trait DbHealthChecker {
|
||||
path,
|
||||
disk_size: size,
|
||||
entry_counts,
|
||||
healthy: self.is_healthy(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,17 @@
|
||||
use crate::db_healthcheck::DbHealthChecker;
|
||||
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::{SharedFrecency, error::Error, git::is_modified_status};
|
||||
use heed::{Database, Env, EnvOpenOptions};
|
||||
use heed::{
|
||||
EnvFlags,
|
||||
types::{Bytes, SerdeBincode},
|
||||
};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
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
|
||||
@@ -23,6 +21,7 @@ const AI_MAX_HISTORY_DAYS: f64 = 7.0; // Only consider accesses within 7 days
|
||||
pub struct FrecencyTracker {
|
||||
env: Env,
|
||||
db: Database<Bytes, SerdeBincode<VecDeque<u64>>>,
|
||||
health: DbHealth,
|
||||
}
|
||||
|
||||
const MODIFICATION_THRESHOLDS: [(i64, u64); 5] = [
|
||||
@@ -47,208 +46,113 @@ impl DbHealthChecker for FrecencyTracker {
|
||||
&self.env
|
||||
}
|
||||
|
||||
fn count_entries(&self) -> Result<Vec<(&'static str, u64)>, Error> {
|
||||
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
let count = self.db.len(&rtxn).map_err(Error::DbRead)?;
|
||||
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 FrecencyTracker {
|
||||
pub fn new(db_path: &str, use_unsafe_no_lock: bool) -> Result<Self, Error> {
|
||||
fs::create_dir_all(db_path).map_err(Error::CreateDir)?;
|
||||
let env = unsafe {
|
||||
let mut opts = EnvOpenOptions::new();
|
||||
opts.map_size(24 * 1024 * 1024); // 24 MiB
|
||||
if use_unsafe_no_lock {
|
||||
opts.flags(EnvFlags::NO_LOCK | EnvFlags::NO_SYNC | EnvFlags::NO_META_SYNC);
|
||||
}
|
||||
opts.open(db_path).map_err(Error::EnvOpen)?
|
||||
};
|
||||
env.clear_stale_readers()
|
||||
.map_err(Error::DbClearStaleReaders)?;
|
||||
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;
|
||||
|
||||
// we will open the default unnamed database
|
||||
let mut wtxn = env.write_txn().map_err(Error::DbStartWriteTxn)?;
|
||||
let db = env
|
||||
.create_database(&mut wtxn, None)
|
||||
.map_err(Error::DbCreate)?;
|
||||
|
||||
Ok(FrecencyTracker {
|
||||
db,
|
||||
env: env.clone(),
|
||||
})
|
||||
fn env(&self) -> &Env {
|
||||
&self.env
|
||||
}
|
||||
|
||||
/// Spawns a background thread to purge stale frecency entries and compact the database.
|
||||
///
|
||||
/// Phase 1 (read lock): purge stale entries — deletes expired entries and prunes old timestamps.
|
||||
/// Phase 2 (write lock): compact the database by re-writing entries into a fresh LMDB env.
|
||||
/// We can't use LMDB's copy_to_path with NO_LOCK envs (MDB_INCOMPATIBLE),
|
||||
/// so instead we: read all entries → drop env → delete files → reopen → write back.
|
||||
pub fn spawn_gc(shared: SharedFrecency, db_path: String, use_unsafe_no_lock: bool) {
|
||||
std::thread::Builder::new()
|
||||
.name("fff-frecency-gc".into())
|
||||
.spawn(move || Self::run_frecency_gc(shared, db_path, use_unsafe_no_lock))
|
||||
.ok();
|
||||
fn health(&self) -> &DbHealth {
|
||||
&self.health
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(shared), fields(db_path = %db_path))]
|
||||
fn run_frecency_gc(shared: SharedFrecency, db_path: String, use_unsafe_no_lock: bool) {
|
||||
let start = std::time::Instant::now();
|
||||
let data_path = PathBuf::from(&db_path).join("data.mdb");
|
||||
|
||||
// Phase 1: Purge stale entries.
|
||||
// The RwLock protects the Option<FrecencyTracker> (not the DB itself),
|
||||
// so a read lock is sufficient — LMDB handles its own write serialization.
|
||||
let (deleted, pruned) = {
|
||||
let guard = match shared.read() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
tracing::debug!("Failed to acquire read lock: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(ref tracker) = *guard else {
|
||||
return;
|
||||
};
|
||||
match tracker.purge_stale_entries() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
tracing::debug!("Purge failed: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fn purge_stale_data(env: &Env) -> Result<()> {
|
||||
let (deleted, pruned) = Self::purge_stale_entries(env)?;
|
||||
if deleted > 0 || pruned > 0 {
|
||||
tracing::info!(deleted, pruned, elapsed = ?start.elapsed(), "Frecency GC purged entries");
|
||||
tracing::info!(deleted, pruned, "Frecency GC purged entries");
|
||||
}
|
||||
|
||||
// Compact if we purged entries OR the file has significant freelist bloat
|
||||
let file_size = fs::metadata(&data_path).map(|m| m.len()).unwrap_or(0);
|
||||
if deleted == 0 && pruned == 0 && file_size <= 512 * 1024 {
|
||||
return;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Manual compaction under a single write lock
|
||||
let mut guard = match shared.write() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
tracing::debug!("Failed to acquire write lock: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
impl FrecencyTracker {
|
||||
/// Returns the on-disk path of the LMDB environment directory.
|
||||
pub fn db_path(&self) -> &Path {
|
||||
self.env.path()
|
||||
}
|
||||
|
||||
// Read all entries from current env
|
||||
let entries: Vec<(Vec<u8>, VecDeque<u64>)> = match guard.as_ref() {
|
||||
Some(tracker) => {
|
||||
let rtxn = match tracker.env.read_txn() {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::debug!("Compaction read_txn failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let iter = match tracker.db.iter(&rtxn) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
tracing::debug!("Compaction iter failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut entries = Vec::new();
|
||||
let mut read_errors = 0u32;
|
||||
for result in iter {
|
||||
match result {
|
||||
Ok((key, value)) => entries.push((key.to_vec(), value)),
|
||||
Err(_) => read_errors += 1,
|
||||
}
|
||||
}
|
||||
if read_errors > 0 {
|
||||
tracing::warn!(
|
||||
read_errors,
|
||||
"Skipped corrupted entries during compaction read"
|
||||
);
|
||||
}
|
||||
entries
|
||||
}
|
||||
None => return,
|
||||
};
|
||||
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)?;
|
||||
|
||||
// Drop old tracker, delete files, create fresh env, write back
|
||||
*guard = None;
|
||||
let db = Self::open_database_safe(&env, None)?;
|
||||
Ok(FrecencyTracker { db, env, health })
|
||||
}
|
||||
|
||||
let lock_path = PathBuf::from(&db_path).join("lock.mdb");
|
||||
let _ = fs::remove_file(&data_path);
|
||||
let _ = fs::remove_file(&lock_path);
|
||||
|
||||
let tracker = match FrecencyTracker::new(&db_path, use_unsafe_no_lock) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::error!("Compaction reopen failed, frecency disabled: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let write_result = (|| -> std::result::Result<(), heed::Error> {
|
||||
let mut wtxn = tracker.env.write_txn()?;
|
||||
for (key, value) in &entries {
|
||||
tracker.db.put(&mut wtxn, key.as_slice(), value)?;
|
||||
}
|
||||
wtxn.commit()?;
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
match write_result {
|
||||
Ok(()) => {
|
||||
let new_size = fs::metadata(&data_path).map(|m| m.len()).unwrap_or(0);
|
||||
*guard = Some(tracker);
|
||||
tracing::debug!(
|
||||
entries = entries.len(),
|
||||
old_size = file_size,
|
||||
new_size,
|
||||
elapsed = ?start.elapsed(),
|
||||
"Frecency DB compacted"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Compaction write failed, frecency data may be incomplete: {e}");
|
||||
*guard = Some(tracker);
|
||||
}
|
||||
}
|
||||
#[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(&self) -> Result<(usize, usize), Error> {
|
||||
let now = self.get_now();
|
||||
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);
|
||||
|
||||
// Collect entries to delete or update
|
||||
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
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 = self.db.iter(&rtxn).map_err(Error::DbRead)?;
|
||||
let iter = db.iter(&rtxn).map_err(|source| Error::DbRead {
|
||||
db: Self::LABEL,
|
||||
source,
|
||||
})?;
|
||||
for result in iter {
|
||||
let (key, accesses) = result.map_err(Error::DbRead)?;
|
||||
let (key, accesses) = result.map_err(|source| Error::DbRead {
|
||||
db: Self::LABEL,
|
||||
source,
|
||||
})?;
|
||||
|
||||
// Timestamps are chronologically ordered (oldest at front).
|
||||
// Find the first timestamp that is still within the retention window.
|
||||
// Timestamps chronologically ordered (oldest at front).
|
||||
let fresh_start = accesses.iter().position(|&ts| ts >= cutoff_time);
|
||||
match fresh_start {
|
||||
None => {
|
||||
// All timestamps are stale — delete the entire entry
|
||||
to_delete.push(key.to_vec());
|
||||
}
|
||||
Some(0) => {
|
||||
// All timestamps are fresh — nothing to do
|
||||
}
|
||||
None => to_delete.push(key.to_vec()),
|
||||
Some(0) => {}
|
||||
Some(start) => {
|
||||
// Some timestamps are stale — keep only the fresh ones
|
||||
let pruned: VecDeque<u64> = accesses.iter().skip(start).copied().collect();
|
||||
to_update.push((key.to_vec(), pruned));
|
||||
}
|
||||
@@ -260,26 +164,56 @@ impl FrecencyTracker {
|
||||
return Ok((0, 0));
|
||||
}
|
||||
|
||||
// Apply all changes in a single write transaction
|
||||
let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?;
|
||||
let mut wtxn = env.write_txn().map_err(|source| Error::DbStartWriteTxn {
|
||||
db: Self::LABEL,
|
||||
source,
|
||||
})?;
|
||||
|
||||
for key in &to_delete {
|
||||
self.db.delete(&mut wtxn, key).map_err(Error::DbWrite)?;
|
||||
db.delete(&mut wtxn, key).map_err(|source| Error::DbWrite {
|
||||
db: Self::LABEL,
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
|
||||
for (key, accesses) in &to_update {
|
||||
self.db
|
||||
.put(&mut wtxn, key, accesses)
|
||||
.map_err(Error::DbWrite)?;
|
||||
db.put(&mut wtxn, key, accesses)
|
||||
.map_err(|source| Error::DbWrite {
|
||||
db: Self::LABEL,
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
wtxn.commit().map_err(Error::DbCommit)?;
|
||||
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>>, Error> {
|
||||
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
|
||||
fn get_accesses(&self, path: &Path) -> Result<Option<VecDeque<u64>>> {
|
||||
let key_hash = Self::path_to_hash_bytes(path)?;
|
||||
self.db.get(&rtxn, &key_hash).map_err(Error::DbRead)
|
||||
|
||||
let rtxn = self
|
||||
.env
|
||||
.read_txn()
|
||||
.map_err(|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 {
|
||||
@@ -289,7 +223,7 @@ impl FrecencyTracker {
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn path_to_hash_bytes(path: &Path) -> Result<[u8; 32], Error> {
|
||||
fn path_to_hash_bytes(path: &Path) -> Result<[u8; 32]> {
|
||||
let Some(key) = path.to_str() else {
|
||||
return Err(Error::InvalidPath(path.to_path_buf()));
|
||||
};
|
||||
@@ -299,22 +233,28 @@ impl FrecencyTracker {
|
||||
|
||||
/// 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>, Error> {
|
||||
pub fn seconds_since_last_access(&self, path: &Path) -> Result<Option<u64>> {
|
||||
let accesses = self.get_accesses(path)?;
|
||||
let last = accesses.and_then(|a| a.back().copied());
|
||||
Ok(last.map(|ts| self.get_now().saturating_sub(ts)))
|
||||
}
|
||||
|
||||
pub fn track_access(&self, path: &Path) -> Result<(), Error> {
|
||||
let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?;
|
||||
/// 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 {
|
||||
if front_time < cutoff_time || accesses.len() >= MAX_TIMESTAMPS_PER_FILE {
|
||||
accesses.pop_front();
|
||||
} else {
|
||||
break;
|
||||
@@ -324,13 +264,43 @@ impl FrecencyTracker {
|
||||
accesses.push_back(now);
|
||||
tracing::debug!(?path, accesses = accesses.len(), "Tracking access");
|
||||
|
||||
self.db
|
||||
.put(&mut wtxn, &key_hash, &accesses)
|
||||
.map_err(Error::DbWrite)?;
|
||||
let mut wtxn = self
|
||||
.env
|
||||
.write_txn()
|
||||
.map_err(|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().map_err(Error::DbCommit)?;
|
||||
|
||||
Ok(())
|
||||
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 {
|
||||
@@ -499,7 +469,7 @@ mod tests {
|
||||
fn test_modification_score_interpolation() {
|
||||
let temp_dir = std::env::temp_dir().join("fff_test_interpolation");
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
let tracker = FrecencyTracker::new(temp_dir.to_str().unwrap(), true).unwrap();
|
||||
let tracker = FrecencyTracker::open(temp_dir.to_str().unwrap()).unwrap();
|
||||
|
||||
let current_time = tracker.get_now();
|
||||
let git_status = Some(git2::Status::WT_MODIFIED);
|
||||
@@ -0,0 +1,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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod db_healthcheck;
|
||||
pub mod frecency;
|
||||
pub(crate) mod lmdb;
|
||||
pub mod query_tracker;
|
||||
@@ -1,11 +1,10 @@
|
||||
use crate::db_healthcheck::DbHealthChecker;
|
||||
use super::db_healthcheck::DbHealthChecker;
|
||||
use super::lmdb::{DbHealth, LmdbStore, is_map_full};
|
||||
use crate::error::Error;
|
||||
use heed::types::Bytes;
|
||||
use heed::{Database, Env, EnvOpenOptions};
|
||||
use heed::{EnvFlags, types::SerdeBincode};
|
||||
use heed::types::{Bytes, SerdeBincode};
|
||||
use heed::{Database, Env};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::VecDeque;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -35,6 +34,7 @@ pub struct QueryTracker {
|
||||
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 {
|
||||
@@ -42,15 +42,40 @@ impl DbHealthChecker for QueryTracker {
|
||||
&self.env
|
||||
}
|
||||
|
||||
fn count_entries(&self) -> Result<Vec<(&'static str, u64)>, Error> {
|
||||
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
fn is_healthy(&self) -> bool {
|
||||
self.health.is_healthy()
|
||||
}
|
||||
|
||||
let count_queries = self.query_file_db.len(&rtxn).map_err(Error::DbRead)?;
|
||||
let count_histories = self.query_history_db.len(&rtxn).map_err(Error::DbRead)?;
|
||||
let count_grep_histories = self
|
||||
.grep_query_history_db
|
||||
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(Error::DbRead)?;
|
||||
.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),
|
||||
@@ -60,45 +85,54 @@ impl DbHealthChecker for QueryTracker {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
pub fn new(db_path: &str, use_unsafe_no_lock: bool) -> Result<Self, Error> {
|
||||
fs::create_dir_all(db_path).map_err(Error::CreateDir)?;
|
||||
let env = unsafe {
|
||||
let mut opts = EnvOpenOptions::new();
|
||||
opts.map_size(10 * 1024 * 1024); // 100 MiB
|
||||
opts.max_dbs(16); // Allow up to 16 databases per environment
|
||||
if use_unsafe_no_lock {
|
||||
opts.flags(EnvFlags::NO_LOCK | EnvFlags::NO_SYNC | EnvFlags::NO_META_SYNC);
|
||||
}
|
||||
opts.open(db_path).map_err(Error::EnvOpen)?
|
||||
};
|
||||
/// Returns the on-disk path of the LMDB environment directory.
|
||||
pub fn db_path(&self) -> &Path {
|
||||
self.env.path()
|
||||
}
|
||||
|
||||
env.clear_stale_readers()
|
||||
.map_err(Error::DbClearStaleReaders)?;
|
||||
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 mut wtxn = env.write_txn().map_err(Error::DbStartWriteTxn)?;
|
||||
|
||||
// Create two named databases
|
||||
let query_file_db = env
|
||||
.create_database(&mut wtxn, Some("query_file_associations"))
|
||||
.map_err(Error::DbCreate)?;
|
||||
let query_history_db = env
|
||||
.create_database(&mut wtxn, Some("query_history"))
|
||||
.map_err(Error::DbCreate)?;
|
||||
let grep_query_history_db = env
|
||||
.create_database(&mut wtxn, Some("grep_query_history"))
|
||||
.map_err(Error::DbCreate)?;
|
||||
|
||||
wtxn.commit().map_err(Error::DbCommit)?;
|
||||
let query_file_db = Self::open_database_safe(&env, Some("query_file_associations"))?;
|
||||
let query_history_db = Self::open_database_safe(&env, Some("query_history"))?;
|
||||
let grep_query_history_db = Self::open_database_safe(&env, Some("grep_query_history"))?;
|
||||
|
||||
Ok(QueryTracker {
|
||||
env,
|
||||
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)
|
||||
@@ -137,7 +171,10 @@ impl QueryTracker {
|
||||
) -> Result<(), Error> {
|
||||
let mut history = db
|
||||
.get(wtxn, project_key)
|
||||
.map_err(Error::DbRead)?
|
||||
.map_err(|source| Error::DbRead {
|
||||
db: Self::LABEL,
|
||||
source,
|
||||
})?
|
||||
.unwrap_or_default();
|
||||
|
||||
history.push_back(HistoryEntry {
|
||||
@@ -149,7 +186,10 @@ impl QueryTracker {
|
||||
}
|
||||
|
||||
db.put(wtxn, project_key, &history)
|
||||
.map_err(Error::DbWrite)?;
|
||||
.map_err(|source| Error::DbWrite {
|
||||
db: Self::LABEL,
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -161,11 +201,17 @@ impl QueryTracker {
|
||||
project_key: &[u8; 32],
|
||||
offset: usize,
|
||||
) -> Result<Option<String>, Error> {
|
||||
let rtxn = env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
let rtxn = env.read_txn().map_err(|source| Error::DbStartReadTxn {
|
||||
db: Self::LABEL,
|
||||
source,
|
||||
})?;
|
||||
|
||||
let mut history = db
|
||||
.get(&rtxn, project_key)
|
||||
.map_err(Error::DbRead)?
|
||||
.map_err(|source| Error::DbRead {
|
||||
db: Self::LABEL,
|
||||
source,
|
||||
})?
|
||||
.unwrap_or_default();
|
||||
|
||||
// history is FIFO, last element is most recent
|
||||
@@ -188,12 +234,21 @@ impl QueryTracker {
|
||||
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(Error::DbStartWriteTxn)?;
|
||||
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(Error::DbRead)?
|
||||
.map_err(|source| Error::DbRead {
|
||||
db: Self::LABEL,
|
||||
source,
|
||||
})?
|
||||
.unwrap_or_else(|| QueryMatchEntry {
|
||||
file_path: file_path_buf.clone(),
|
||||
open_count: 0,
|
||||
@@ -223,15 +278,50 @@ impl QueryTracker {
|
||||
|
||||
entry.last_opened = now;
|
||||
|
||||
self.query_file_db
|
||||
.put(&mut wtxn, &query_key, &entry)
|
||||
.map_err(Error::DbWrite)?;
|
||||
if let Err(e) = self.query_file_db.put(&mut wtxn, &query_key, &entry) {
|
||||
if is_map_full(&e) {
|
||||
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)?;
|
||||
Self::append_to_history(&self.query_history_db, &mut wtxn, &project_key, query, now)?;
|
||||
if let Err(e) =
|
||||
Self::append_to_history(&self.query_history_db, &mut wtxn, &project_key, query, now)
|
||||
{
|
||||
if let Error::DbWrite {
|
||||
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);
|
||||
}
|
||||
|
||||
wtxn.commit().map_err(Error::DbCommit)?;
|
||||
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(())
|
||||
@@ -244,13 +334,21 @@ impl QueryTracker {
|
||||
min_combo_count: u32,
|
||||
) -> Result<Option<QueryMatchEntry>, Error> {
|
||||
let query_key = Self::create_query_key(project_path, query)?;
|
||||
tracing::debug!(?query_key, "HASH");
|
||||
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
let 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(Error::DbRead)?;
|
||||
.map_err(|source| Error::DbRead {
|
||||
db: Self::LABEL,
|
||||
source,
|
||||
})?;
|
||||
|
||||
Ok(last_match.filter(|entry| entry.open_count >= min_combo_count))
|
||||
}
|
||||
@@ -264,13 +362,21 @@ impl QueryTracker {
|
||||
) -> 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(Error::DbStartReadTxn)?;
|
||||
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(Error::DbRead)?
|
||||
{
|
||||
.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 {
|
||||
@@ -299,17 +405,45 @@ impl QueryTracker {
|
||||
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(Error::DbStartWriteTxn)?;
|
||||
let mut wtxn = self
|
||||
.env
|
||||
.write_txn()
|
||||
.map_err(|source| Error::DbStartWriteTxn {
|
||||
db: Self::LABEL,
|
||||
source,
|
||||
})?;
|
||||
|
||||
Self::append_to_history(
|
||||
if let Err(e) = Self::append_to_history(
|
||||
&self.grep_query_history_db,
|
||||
&mut wtxn,
|
||||
&project_key,
|
||||
query,
|
||||
now,
|
||||
)?;
|
||||
) {
|
||||
if let Error::DbWrite {
|
||||
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);
|
||||
}
|
||||
|
||||
wtxn.commit().map_err(Error::DbCommit)?;
|
||||
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(())
|
||||
@@ -337,7 +471,7 @@ mod tests {
|
||||
let temp_dir = env::temp_dir().join("fff_test_query_tracking_new");
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
|
||||
let mut tracker = QueryTracker::new(temp_dir.to_str().unwrap(), true).unwrap();
|
||||
let mut tracker = QueryTracker::open(temp_dir.to_str().unwrap()).unwrap();
|
||||
|
||||
let project_path = PathBuf::from("/test/project");
|
||||
let file_path = PathBuf::from("/test/project/src/main.rs");
|
||||
@@ -7,6 +7,10 @@ pub enum Error {
|
||||
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")]
|
||||
@@ -17,24 +21,68 @@ pub enum Error {
|
||||
AcquirePathCacheLock,
|
||||
#[error("Failed to create directory: {0}")]
|
||||
CreateDir(#[from] std::io::Error),
|
||||
#[error("Failed to open frecency database env: {0}")]
|
||||
EnvOpen(#[source] heed::Error),
|
||||
#[error("Failed to create frecency database: {0}")]
|
||||
DbCreate(#[source] heed::Error),
|
||||
#[error("Failed to clear stale readers for frecency database: {0}")]
|
||||
DbClearStaleReaders(#[source] heed::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 frecency database: {0}")]
|
||||
DbStartReadTxn(#[source] heed::Error),
|
||||
#[error("Failed to start write transaction for frecency database: {0}")]
|
||||
DbStartWriteTxn(#[source] heed::Error),
|
||||
|
||||
#[error("Failed to read from frecency database: {0}")]
|
||||
DbRead(#[source] heed::Error),
|
||||
#[error("Failed to write to frecency database: {0}")]
|
||||
DbWrite(#[source] heed::Error),
|
||||
#[error("Failed to commit write transaction to frecency database: {0}")]
|
||||
DbCommit(#[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),
|
||||
|
||||
|
||||
+1913
-601
File diff suppressed because it is too large
Load Diff
+127
-33
@@ -1,20 +1,40 @@
|
||||
use crate::error::Result;
|
||||
use ahash::AHashMap;
|
||||
use git2::{Repository, Status, StatusOptions};
|
||||
use std::{
|
||||
fmt::Debug,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use tracing::debug;
|
||||
|
||||
/// Represents a cache of a single git status query, if there is no
|
||||
/// status aka file is clear but it was specifically requested to updated
|
||||
/// the status is `None` otherwise contains only actual file statuses.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitStatusCache(Vec<(PathBuf, Status)>);
|
||||
pub(crate) fn default_status_options() -> StatusOptions {
|
||||
let mut opts = StatusOptions::new();
|
||||
opts.include_untracked(true)
|
||||
.recurse_untracked_dirs(true)
|
||||
.include_unmodified(true)
|
||||
.exclude_submodules(true);
|
||||
opts
|
||||
}
|
||||
|
||||
/// 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 = std::vec::IntoIter<Self::Item>;
|
||||
type IntoIter = <AHashMap<PathBuf, Status> as IntoIterator>::IntoIter;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.0.into_iter()
|
||||
@@ -26,25 +46,27 @@ impl GitStatusCache {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn lookup_status(&self, full_path: &Path) -> Option<Status> {
|
||||
self.0
|
||||
.binary_search_by(|(path, _)| path.as_path().cmp(full_path))
|
||||
.ok()
|
||||
.and_then(|idx| self.0.get(idx).map(|(_, status)| *status))
|
||||
self.0.get(full_path).copied()
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(repo, status_options))]
|
||||
fn read_status_impl(repo: &Repository, status_options: &mut StatusOptions) -> Result<Self> {
|
||||
let statuses = repo.statuses(Some(status_options))?;
|
||||
let Some(repo_path) = repo.workdir() else {
|
||||
return Ok(Self(vec![])); // repo is bare
|
||||
return Ok(Self(AHashMap::new())); // repo is bare
|
||||
};
|
||||
|
||||
let mut entries = Vec::with_capacity(statuses.len());
|
||||
let repo_path = crate::path_utils::normalize(repo_path.to_path_buf());
|
||||
|
||||
let mut entries = AHashMap::with_capacity(statuses.len());
|
||||
for entry in &statuses {
|
||||
if let Some(entry_path) = entry.path() {
|
||||
let full_path = repo_path.join(entry_path);
|
||||
entries.push((full_path, entry.status()));
|
||||
// libgit2 returns entry paths with forward slashes on every platform
|
||||
// fff stores native paths - meaning we have forward slash issue on windows
|
||||
let full_path = crate::path_utils::normalize(repo_path.join(entry_path));
|
||||
entries.insert(full_path, entry.status());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,46 +92,38 @@ impl GitStatusCache {
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(repo), level = tracing::Level::DEBUG)]
|
||||
#[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(vec![]));
|
||||
return Ok(Self(AHashMap::new()));
|
||||
}
|
||||
|
||||
let Some(workdir) = repo.workdir() else {
|
||||
return Ok(Self(vec![]));
|
||||
return Ok(Self(AHashMap::new()));
|
||||
};
|
||||
let workdir = crate::path_utils::normalize(workdir.to_path_buf());
|
||||
|
||||
// git pathspec is pretty slow and requires to walk the whole directory
|
||||
// so for a single file which is the most general use case we query directly the file
|
||||
if paths.len() == 1 {
|
||||
let full_path = paths[0].as_ref();
|
||||
let relative_path = full_path.strip_prefix(workdir)?;
|
||||
let relative_path = full_path.strip_prefix(&workdir)?;
|
||||
let status = repo.status_file(relative_path)?;
|
||||
|
||||
return Ok(Self(vec![(full_path.to_path_buf(), status)]));
|
||||
let mut map = AHashMap::with_capacity(1);
|
||||
map.insert(full_path.to_path_buf(), status);
|
||||
return Ok(Self(map));
|
||||
}
|
||||
|
||||
let mut status_options = StatusOptions::new();
|
||||
status_options
|
||||
.include_untracked(true)
|
||||
.recurse_untracked_dirs(true)
|
||||
// when reading partial status it's important to include all files requested
|
||||
.include_unmodified(true);
|
||||
|
||||
let mut status_options = default_status_options();
|
||||
for path in paths {
|
||||
status_options.pathspec(path.as_ref().strip_prefix(workdir)?);
|
||||
status_options.pathspec(path.as_ref().strip_prefix(&workdir)?);
|
||||
}
|
||||
|
||||
let git_status_cache = Self::read_status_impl(repo, &mut status_options)?;
|
||||
debug!(
|
||||
status_len = git_status_cache.statuses_len(),
|
||||
"Multiple files git status"
|
||||
);
|
||||
|
||||
Ok(git_status_cache)
|
||||
}
|
||||
}
|
||||
@@ -157,3 +171,83 @@ pub fn format_git_status_opt(status: Option<Status>) -> Option<&'static str> {
|
||||
pub fn format_git_status(status: Option<Status>) -> &'static str {
|
||||
format_git_status_opt(status).unwrap_or("unknown")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) {
|
||||
let out = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.env("GIT_AUTHOR_NAME", "t")
|
||||
.env("GIT_AUTHOR_EMAIL", "t@t")
|
||||
.env("GIT_COMMITTER_NAME", "t")
|
||||
.env("GIT_COMMITTER_EMAIL", "t@t")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "git {args:?} failed");
|
||||
}
|
||||
|
||||
/// Regression: on case-insensitive filesystems libgit2 returns
|
||||
/// statuses in a case-insensitive order. Our previous sorted-`Vec` +
|
||||
/// `binary_search_by(Path::cmp)` lookup silently missed entries
|
||||
/// because `Path::cmp` is byte-wise.
|
||||
///
|
||||
/// This test uses deliberately mixed-case filenames so the two
|
||||
/// orderings disagree, then checks every lookup succeeds.
|
||||
#[test]
|
||||
fn lookup_is_case_exact_regardless_of_libgit2_sort_order() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// `std::fs::canonicalize` on Windows adds a `\\?\` UNC prefix that
|
||||
// libgit2's workdir string lacks. Use dunce so both sides match.
|
||||
let base = crate::path_utils::canonicalize(tmp.path()).unwrap();
|
||||
|
||||
// Mixed-case names that sort differently under byte-wise vs
|
||||
// case-insensitive comparators.
|
||||
let names = [
|
||||
"README.md",
|
||||
"a_lower.rs",
|
||||
"Z_upper.rs",
|
||||
"mixed_Case.txt",
|
||||
"nested/Inner_File.rs",
|
||||
];
|
||||
for n in &names {
|
||||
let p = base.join(n);
|
||||
fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
fs::write(&p, format!("// {n}\n")).unwrap();
|
||||
}
|
||||
|
||||
git(&base, &["init", "-b", "main"]);
|
||||
git(&base, &["add", "-A"]);
|
||||
git(&base, &["commit", "-m", "seed", "--no-gpg-sign"]);
|
||||
|
||||
// Modify every file so they all end up in the status output as
|
||||
// WT_MODIFIED — guarantees a non-trivial map we have to look up.
|
||||
for n in &names {
|
||||
let p = base.join(n);
|
||||
fs::write(&p, format!("// {n}\n// edit\n")).unwrap();
|
||||
}
|
||||
|
||||
let repo = Repository::open(&base).unwrap();
|
||||
let paths: Vec<PathBuf> = names.iter().map(|n| base.join(n)).collect();
|
||||
let cache = GitStatusCache::git_status_for_paths(&repo, &paths).unwrap();
|
||||
|
||||
for (n, abs) in names.iter().zip(paths.iter()) {
|
||||
let status = cache.lookup_status(abs);
|
||||
assert!(
|
||||
status.is_some(),
|
||||
"lookup for {n} returned None; cache holds {} entries",
|
||||
cache.statuses_len(),
|
||||
);
|
||||
assert!(
|
||||
status.unwrap().contains(Status::WT_MODIFIED),
|
||||
"expected WT_MODIFIED for {n}, got {:?}",
|
||||
status
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+900
-250
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) const NON_GIT_IGNORED_DIRS: &[&str] = &[
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
"venv",
|
||||
".venv",
|
||||
// Rust (these are glob-only patterns for non_git_repo_overrides,
|
||||
// is_non_code_directory matches the "target" component separately)
|
||||
"target/debug",
|
||||
"target/release",
|
||||
"target/rust-analyzer",
|
||||
"target/criterion",
|
||||
];
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) const PLATFORM_IGNORED_DIRS: &[&str] = &[
|
||||
"Library/Application Support",
|
||||
"Library/Caches",
|
||||
// App-group sandbox storage — used by iMessage, Photos, Notes, Calendar,
|
||||
// Electron apps, etc. for SQLite-WAL, LevelDB, protobuf files. These are
|
||||
// almost entirely extension-less binary files (~80k on a typical $HOME)
|
||||
// that never need to appear in a fuzzy or grep search.
|
||||
"Library/Group Containers",
|
||||
"Library/Containers",
|
||||
];
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) const PLATFORM_IGNORED_DIRS: &[&str] = &[
|
||||
"bin/Debug",
|
||||
"bin/Release",
|
||||
"Program Files",
|
||||
"Program Files (x86)",
|
||||
"AppData/Local",
|
||||
"AppData/Roaming",
|
||||
];
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
pub(crate) const PLATFORM_IGNORED_DIRS: &[&str] = &[];
|
||||
|
||||
pub(crate) fn non_git_repo_overrides(base_path: &Path) -> Option<ignore::overrides::Override> {
|
||||
use ignore::overrides::OverrideBuilder;
|
||||
|
||||
let mut builder = OverrideBuilder::new(base_path);
|
||||
for dir in NON_GIT_IGNORED_DIRS.iter().chain(PLATFORM_IGNORED_DIRS) {
|
||||
let pattern = format!("!**/{dir}/");
|
||||
if let Err(e) = builder.add(&pattern) {
|
||||
tracing::warn!("failed to add ignore pattern {pattern}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
builder.build().ok()
|
||||
}
|
||||
|
||||
pub(crate) fn is_non_code_directory(path: &Path) -> bool {
|
||||
let path_str = path.as_os_str().to_str().unwrap_or("");
|
||||
NON_GIT_IGNORED_DIRS
|
||||
.iter()
|
||||
.chain(PLATFORM_IGNORED_DIRS)
|
||||
.any(|&dir| {
|
||||
#[cfg(target_os = "windows")]
|
||||
let dir = dir.replace('/', std::path::MAIN_SEPARATOR_STR);
|
||||
#[cfg(target_os = "windows")]
|
||||
return path_str.contains(dir.as_str());
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
path_str.contains(dir)
|
||||
})
|
||||
}
|
||||
+146
-35
@@ -1,46 +1,157 @@
|
||||
//! fff-core - High-performance file finder library
|
||||
//! # FFF Search — High-performance file finder core
|
||||
//!
|
||||
//! This crate provides the core file indexing and fuzzy search functionality.
|
||||
//! This crate provides the core search engine for [FFF (Fast File Finder)](https://github.com/dmtrKovalenko/fff.nvim).
|
||||
//! It includes filesystem indexing with real-time watching, fuzzy matching powered
|
||||
//! by [frizbee](https://docs.rs/neo_frizbee), frecency scoring backed by LMDB,
|
||||
//! and multi-mode grep search.
|
||||
//!
|
||||
//! # State management
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! All state is instance-based. Callers create their own `SharedPicker` /
|
||||
//! `SharedFrecency` / `SharedQueryTracker` and pass them into
|
||||
//! `FilePicker::new_with_shared_state`. Multiple independent instances can
|
||||
//! coexist in the same process.
|
||||
//! - [`file_picker::FilePicker`] — Main entry point. Indexes a directory tree in a
|
||||
//! background thread, maintains a sorted file list, watches the filesystem for
|
||||
//! changes, and performs fuzzy search with frecency-weighted scoring.
|
||||
//! - [`frecency::FrecencyTracker`] — LMDB-backed database that tracks file access
|
||||
//! and modification patterns for intelligent result ranking.
|
||||
//! - [`query_tracker::QueryTracker`] — Tracks search query history and provides
|
||||
//! "combo-boost" scoring for repeatedly matched files.
|
||||
//! - [`grep`] — Live grep search supporting regex, plain-text, and fuzzy modes
|
||||
//! with optional constraint filtering.
|
||||
//! - [`git`] — Git status caching and repository detection.
|
||||
//!
|
||||
//! ## Shared State
|
||||
//!
|
||||
//! [`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>>(())
|
||||
//! ```
|
||||
|
||||
mod background_watcher;
|
||||
pub mod constraints;
|
||||
mod db_healthcheck;
|
||||
pub(crate) mod parallelism;
|
||||
mod scan;
|
||||
// public only for benchmarks — the inverted index is still re-exported via
|
||||
// `pub use bigram_filter::*` below for external consumers.
|
||||
#[doc(hidden)]
|
||||
pub mod bigram_filter;
|
||||
pub mod bigram_query;
|
||||
pub mod constants;
|
||||
mod constraints;
|
||||
mod error;
|
||||
pub mod file_picker;
|
||||
pub mod frecency;
|
||||
pub mod git;
|
||||
pub mod grep;
|
||||
pub mod log;
|
||||
pub mod path_utils;
|
||||
pub mod query_tracker;
|
||||
pub mod score;
|
||||
mod score;
|
||||
mod sort_buffer;
|
||||
pub(crate) mod stable_vec;
|
||||
// this is pub only for benchmarks
|
||||
pub mod case_insensitive_memmem;
|
||||
|
||||
pub(crate) mod simd_path;
|
||||
|
||||
/// Core file picker: filesystem indexing, background watching, and fuzzy search.
|
||||
///
|
||||
/// See [`FilePicker`](file_picker::FilePicker) for the main entry point.
|
||||
pub mod file_picker;
|
||||
|
||||
/// Database-backed persistence: frecency, query history, LMDB plumbing.
|
||||
pub mod dbs;
|
||||
pub use dbs::frecency;
|
||||
|
||||
/// Git status caching and repository detection utilities.
|
||||
pub mod git;
|
||||
|
||||
/// Live grep search with regex, plain-text, and fuzzy matching modes.
|
||||
///
|
||||
/// Supports constraint filtering (file extensions, path segments, globs)
|
||||
/// and parallel execution via rayon.
|
||||
pub mod grep;
|
||||
|
||||
/// Tracing/logging initialization and panic hook setup.
|
||||
pub mod log;
|
||||
|
||||
/// Path manipulation utilities: cross platform canonicalization, tilde expansion, and
|
||||
/// directory distance penalties for search scoring.
|
||||
pub mod path_utils;
|
||||
|
||||
pub use dbs::query_tracker;
|
||||
|
||||
/// Core data types shared across the crate.
|
||||
pub mod types;
|
||||
|
||||
use file_picker::FilePicker;
|
||||
use frecency::FrecencyTracker;
|
||||
use query_tracker::QueryTracker;
|
||||
use std::sync::{Arc, RwLock};
|
||||
mod ignore;
|
||||
/// Thread-safe shared handles for [`FilePicker`], [`FrecencyTracker`],
|
||||
/// and [`QueryTracker`].
|
||||
pub mod shared;
|
||||
|
||||
pub type SharedPicker = Arc<RwLock<Option<FilePicker>>>;
|
||||
pub type SharedFrecency = Arc<RwLock<Option<FrecencyTracker>>>;
|
||||
pub type SharedQueryTracker = Arc<RwLock<Option<QueryTracker>>>;
|
||||
|
||||
pub use db_healthcheck::{DbHealth, DbHealthChecker};
|
||||
pub use bigram_filter::*;
|
||||
pub use dbs::db_healthcheck::{DbHealth, DbHealthChecker};
|
||||
pub use error::{Error, Result};
|
||||
pub use fff_query_parser::{
|
||||
Constraint, FFFQuery, FuzzyQuery, Location, QueryParser, location::parse_location,
|
||||
};
|
||||
pub use file_picker::{FFFMode, FuzzySearchOptions, ScanProgress};
|
||||
pub use grep::{
|
||||
GrepMatch, GrepMode, GrepResult, GrepSearchOptions, has_regex_metacharacters,
|
||||
is_definition_line, is_import_line, multi_grep_search,
|
||||
};
|
||||
pub use types::{FileItem, PaginationArgs, Score, ScoringContext, SearchResult};
|
||||
pub use fff_query_parser::*;
|
||||
pub use file_picker::*;
|
||||
pub use frecency::*;
|
||||
pub use grep::*;
|
||||
pub use query_tracker::*;
|
||||
pub use shared::*;
|
||||
pub use types::*;
|
||||
|
||||
+240
-130
@@ -1,93 +1,118 @@
|
||||
//! Shared logging utilities for FFF crates.
|
||||
//!
|
||||
//! Provides file-based tracing initialization and a panic hook that writes
|
||||
//! to both stderr and a fallback log file.
|
||||
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
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::*};
|
||||
|
||||
static TRACING_INITIALIZED: std::sync::OnceLock<tracing_appender::non_blocking::WorkerGuard> =
|
||||
std::sync::OnceLock::new();
|
||||
// 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();
|
||||
|
||||
static PANIC_HOOK_INSTALLED: std::sync::OnceLock<()> = std::sync::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()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Install panic hook that writes to both stderr and a fallback file.
|
||||
/// This is called separately from init_tracing to ensure panics are always logged.
|
||||
pub fn install_panic_hook() {
|
||||
PANIC_HOOK_INSTALLED.get_or_init(|| {
|
||||
let default_panic = std::panic::take_hook();
|
||||
// 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};
|
||||
|
||||
std::panic::set_hook(Box::new(move |panic_info| {
|
||||
let payload = panic_info.payload();
|
||||
let message = if let Some(s) = payload.downcast_ref::<&str>() {
|
||||
s.to_string()
|
||||
} else if let Some(s) = payload.downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
"Unknown panic payload".to_string()
|
||||
};
|
||||
static LOG_FD: AtomicI32 = AtomicI32::new(-1);
|
||||
|
||||
let location = if let Some(location) = panic_info.location() {
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
location.file(),
|
||||
location.line(),
|
||||
location.column()
|
||||
)
|
||||
} else {
|
||||
"unknown location".to_string()
|
||||
};
|
||||
|
||||
// Always log to tracing (if initialized)
|
||||
tracing::error!(
|
||||
panic.message = %message,
|
||||
panic.location = %location,
|
||||
"PANIC occurred in FFF"
|
||||
);
|
||||
|
||||
// Always print to stderr
|
||||
eprintln!("=== FFF PANIC ===");
|
||||
eprintln!("Message: {}", message);
|
||||
eprintln!("Location: {}", location);
|
||||
eprintln!("=================");
|
||||
|
||||
// Try to write to fallback panic log file
|
||||
if let Some(cache_dir) = dirs::cache_dir() {
|
||||
let panic_log = cache_dir.join("fff_panic.log");
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
let panic_entry = format!(
|
||||
"\n[{}] PANIC at {}\nMessage: {}\n",
|
||||
timestamp, location, message
|
||||
);
|
||||
|
||||
let _ = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&panic_log)
|
||||
.and_then(|mut f| {
|
||||
use std::io::Write;
|
||||
f.write_all(panic_entry.as_bytes())
|
||||
});
|
||||
|
||||
eprintln!("Panic logged to: {}", panic_log.display());
|
||||
pub fn set_log_fd(path: &Path) {
|
||||
if let Ok(file) = std::fs::OpenOptions::new().append(true).open(path) {
|
||||
let prev = LOG_FD.swap(file.into_raw_fd(), Ordering::Relaxed);
|
||||
if prev >= 0 {
|
||||
unsafe { libc::close(prev) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default_panic(panic_info);
|
||||
}));
|
||||
});
|
||||
// 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`.
|
||||
///
|
||||
/// Accepts "trace", "debug", "info", "warn", "error" (case-insensitive).
|
||||
/// Returns `tracing::Level::INFO` for unrecognised values.
|
||||
pub fn parse_log_level(level: Option<&str>) -> tracing::Level {
|
||||
match level.as_ref().map(|s| s.trim().to_lowercase()).as_deref() {
|
||||
Some("trace") => tracing::Level::TRACE,
|
||||
@@ -99,66 +124,151 @@ pub fn parse_log_level(level: Option<&str>) -> tracing::Level {
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize tracing with a single log file.
|
||||
///
|
||||
/// Creates the parent directory if it doesn't exist, truncates the log file,
|
||||
/// and sets up a non-blocking file appender with structured formatting.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `log_file_path` - Full path to the log file
|
||||
/// * `log_level` - Log level (trace, debug, info, warn, error)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Result<String, io::Error>` - Full path to the log file on success
|
||||
pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<String, io::Error> {
|
||||
// Install panic hook first (does nothing if already installed)
|
||||
install_panic_hook();
|
||||
/// Default retention: how many prior nvim sessions' log files to keep.
|
||||
const DEFAULT_RETAIN_RUNS: usize = 20;
|
||||
|
||||
let log_path = Path::new(log_file_path);
|
||||
if let Some(parent) = log_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
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());
|
||||
}
|
||||
|
||||
let file_appender = std::fs::OpenOptions::new()
|
||||
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)
|
||||
.write(true)
|
||||
.truncate(true) // creates a new file on every setup
|
||||
.open(log_path)?;
|
||||
.append(true)
|
||||
.open(&session_path)?;
|
||||
|
||||
let level = parse_log_level(log_level);
|
||||
// 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));
|
||||
|
||||
TRACING_INITIALIZED.get_or_init(|| {
|
||||
let (non_blocking_appender, guard) = non_blocking(file_appender);
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
.with(
|
||||
fmt::layer()
|
||||
.with_writer(non_blocking_appender)
|
||||
.with_target(true)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(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(),
|
||||
);
|
||||
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
.with(
|
||||
fmt::layer()
|
||||
.with_writer(non_blocking_appender)
|
||||
.with_target(true)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false)
|
||||
.with_file(true)
|
||||
.with_line_number(true)
|
||||
.with_ansi(false)
|
||||
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
|
||||
)
|
||||
.with(
|
||||
EnvFilter::builder()
|
||||
.with_default_directive(level.into())
|
||||
.from_env_lossy(),
|
||||
);
|
||||
if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
|
||||
eprintln!("Failed to set tracing subscriber: {}", e);
|
||||
} else {
|
||||
tracing::info!(
|
||||
"FFF tracing initialized: {} (pid={}, retain_runs={})",
|
||||
session_path.display(),
|
||||
std::process::id(),
|
||||
retain_runs.unwrap_or(DEFAULT_RETAIN_RUNS),
|
||||
);
|
||||
}
|
||||
|
||||
if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
|
||||
eprintln!("Failed to set tracing subscriber: {}", e);
|
||||
} else {
|
||||
tracing::info!(
|
||||
"FFF tracing initialized with log file: {}",
|
||||
log_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
guard
|
||||
});
|
||||
|
||||
Ok(log_file_path.to_string())
|
||||
Ok(session_path.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
});
|
||||
@@ -1,12 +1,5 @@
|
||||
//! Path utility functions for file picker scoring
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Canonicalize a path, resolving symlinks and producing an absolute path.
|
||||
///
|
||||
/// On Windows, uses `dunce::canonicalize` to avoid the `\\?\` extended-length path prefix
|
||||
/// that `std::fs::canonicalize` produces. Neovim cannot open paths with this prefix.
|
||||
/// On other platforms, delegates directly to `std::fs::canonicalize`.
|
||||
#[cfg(windows)]
|
||||
pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
|
||||
dunce::canonicalize(path)
|
||||
@@ -17,6 +10,20 @@ pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
|
||||
std::fs::canonicalize(path)
|
||||
}
|
||||
|
||||
/// Git requires a normalized forward-slashed paths on windows
|
||||
#[cfg(windows)]
|
||||
pub fn normalize(path: PathBuf) -> PathBuf {
|
||||
let as_str = path.to_string_lossy();
|
||||
let with_backslashes: String = as_str.replace('/', "\\");
|
||||
let buf = PathBuf::from(with_backslashes);
|
||||
dunce::canonicalize(&buf).unwrap_or(buf)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn normalize(path: PathBuf) -> PathBuf {
|
||||
path
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn expand_tilde(path: &str) -> PathBuf {
|
||||
return PathBuf::from(path);
|
||||
@@ -33,53 +40,58 @@ pub fn expand_tilde(path: &str) -> PathBuf {
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
/// Calculate distance penalty based on directory proximity
|
||||
/// Returns a negative penalty score based on how far the candidate is from the current file
|
||||
pub fn calculate_distance_penalty(current_file: Option<&str>, candidate_path: &str) -> i32 {
|
||||
let Some(ref current_path) = current_file else {
|
||||
return 0; // No penalty if no current file
|
||||
/// Calculate distance penalty based on directory proximity.
|
||||
/// Returns a negative penalty score based on how far the candidate is from the current file.
|
||||
///
|
||||
/// `candidate_dir` is the directory portion of the candidate path (e.g. `"src/components/"`).
|
||||
/// It may have a trailing `/` which is stripped internally.
|
||||
///
|
||||
/// Zero-allocation: walks both directory part iterators in lockstep.
|
||||
pub fn calculate_distance_penalty(current_file: Option<&str>, candidate_dir: &str) -> i32 {
|
||||
let Some(current_path) = current_file else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
let current_dir = if let Some(parent) = std::path::Path::new(current_path).parent() {
|
||||
parent.to_string_lossy().to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let current_dir = Path::new(current_path).parent().unwrap_or(Path::new(""));
|
||||
let candidate = Path::new(candidate_dir);
|
||||
|
||||
let candidate_dir = if let Some(parent) = std::path::Path::new(candidate_path).parent() {
|
||||
parent.to_string_lossy().to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
if current_dir == candidate_dir {
|
||||
return 0; // Same directory, no penalty
|
||||
if current_dir == candidate {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let current_parts: Vec<&str> = current_dir
|
||||
.split(std::path::MAIN_SEPARATOR)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
let candidate_parts: Vec<&str> = candidate_dir
|
||||
.split(std::path::MAIN_SEPARATOR)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
let mut current_parts = current_dir.components();
|
||||
let mut candidate_parts = candidate.components();
|
||||
|
||||
let common_len = current_parts
|
||||
.iter()
|
||||
.zip(candidate_parts.iter())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
let mut common_len = 0usize;
|
||||
let mut current_total = 0usize;
|
||||
|
||||
let current_depth_from_common = current_parts.len() - common_len;
|
||||
|
||||
if current_depth_from_common == 0 {
|
||||
return 0; // Current file is at the common ancestor level
|
||||
loop {
|
||||
match (current_parts.next(), candidate_parts.next()) {
|
||||
(Some(a), Some(b)) => {
|
||||
current_total += 1;
|
||||
if a == b {
|
||||
common_len += 1;
|
||||
} else {
|
||||
current_total += current_parts.count();
|
||||
break;
|
||||
}
|
||||
}
|
||||
(Some(_), None) => {
|
||||
current_total += 1 + current_parts.count();
|
||||
break;
|
||||
}
|
||||
(None, _) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let penalty = -(current_depth_from_common as i32);
|
||||
let depth_from_common = current_total - common_len;
|
||||
if depth_from_common == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
penalty.max(-20)
|
||||
(-(depth_from_common as i32)).max(-20)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -89,16 +101,11 @@ mod tests {
|
||||
#[test]
|
||||
#[cfg(not(target_family = "windows"))]
|
||||
fn test_calculate_distance_penalty() {
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(None, "examples/user/test/mod.rs"),
|
||||
0
|
||||
);
|
||||
// candidate_dir is now just the directory portion (with or without trailing /)
|
||||
assert_eq!(calculate_distance_penalty(None, "examples/user/test/"), 0);
|
||||
// Same directory
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples/user/test/main.rs"),
|
||||
"examples/user/test/mod.rs"
|
||||
),
|
||||
calculate_distance_penalty(Some("examples/user/test/main.rs"), "examples/user/test/"),
|
||||
0
|
||||
);
|
||||
//
|
||||
@@ -106,7 +113,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples/user/test/subdir/file.rs"),
|
||||
"examples/user/test/mod.rs"
|
||||
"examples/user/test/"
|
||||
),
|
||||
-1
|
||||
);
|
||||
@@ -115,7 +122,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples/user/test/dir1/file.rs"),
|
||||
"examples/user/test/dir2/mod.rs"
|
||||
"examples/user/test/dir2/"
|
||||
),
|
||||
-1
|
||||
);
|
||||
@@ -123,7 +130,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples/audio-announce/src/lib/audio-announce.rs"),
|
||||
"examples/audio-announce/src/main.rs"
|
||||
"examples/audio-announce/src/"
|
||||
),
|
||||
-1
|
||||
);
|
||||
@@ -131,27 +138,27 @@ mod tests {
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples/audio-announce/src/audio-announce.rs"),
|
||||
"examples/pixel/src/main.rs"
|
||||
"examples/pixel/src/"
|
||||
),
|
||||
-2
|
||||
);
|
||||
|
||||
// Root level files
|
||||
assert_eq!(calculate_distance_penalty(Some("main.rs"), "lib.rs"), 0);
|
||||
// Root level files (empty dir)
|
||||
assert_eq!(calculate_distance_penalty(Some("main.rs"), ""), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_family = "windows")]
|
||||
fn distance_penalty_works_on_windows() {
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(None, "examples\\user\\test\\mod.rs"),
|
||||
calculate_distance_penalty(None, "examples\\user\\test\\"),
|
||||
0
|
||||
);
|
||||
// Same directory
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples\\user\\test\\main.rs"),
|
||||
"examples\\user\\test\\mod.rs"
|
||||
"examples\\user\\test\\"
|
||||
),
|
||||
0
|
||||
);
|
||||
@@ -160,7 +167,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples\\user\\test\\subdir\\file.rs"),
|
||||
"examples\\user\\test\\mod.rs"
|
||||
"examples\\user\\test\\"
|
||||
),
|
||||
-1
|
||||
);
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
use rayon::prelude::*;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::FileSync;
|
||||
use crate::background_watcher::BackgroundWatcher;
|
||||
use crate::bigram_filter::{build_bigram_index, sniff_binary_for_non_indexable};
|
||||
use crate::error::Error;
|
||||
use crate::file_picker::FFFMode;
|
||||
use crate::git::GitStatusCache;
|
||||
use crate::parallelism::BACKGROUND_THREAD_POOL;
|
||||
use crate::shared::{SharedFilePicker, SharedFrecency};
|
||||
use crate::simd_path::ArenaPtr;
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the job on a dedicated OS thread. Returns immediately.
|
||||
pub fn spawn(self) -> std::thread::JoinHandle<()> {
|
||||
self.signals.scanning.store(true, Ordering::Release);
|
||||
let span = self.trace_span.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("fff-scan".into())
|
||||
.spawn(move || {
|
||||
let _g = span.enter();
|
||||
self.run();
|
||||
})
|
||||
.expect("failed to spawn fff-scan thread")
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Reset the UI-visible counter; the walker bumps it per file
|
||||
// and `get_scan_progress` reads it without locks.
|
||||
scanned_files_counter.store(0, Ordering::Relaxed);
|
||||
|
||||
// 1. Start git discovery and walk filesystem off-lock.
|
||||
let git_workdir = FileSync::discover_git_workdir(&base_path);
|
||||
let status_handle = git_workdir.clone().map(FileSync::spawn_git_status);
|
||||
let sync = match FileSync::walk_filesystem(
|
||||
&base_path,
|
||||
git_workdir.clone(),
|
||||
&scanned_files_counter,
|
||||
&shared_frecency,
|
||||
mode,
|
||||
config.follow_symlinks,
|
||||
) {
|
||||
Ok(sync) => sync,
|
||||
Err(e) => {
|
||||
error!(?e, "scan walk failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Brief write to install the freshly-walked file list.
|
||||
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);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Files are now searchable — flip the scan signal *early* so
|
||||
// UI progress polls see the picker as "ready" while we run the
|
||||
// optional post-scan steps in the background.
|
||||
signals.scanning.store(false, Ordering::Relaxed);
|
||||
|
||||
// in case we do a rescan, we have to resubscribe a watcher to the new set of directories
|
||||
// all the already watched directories are not going to be resubscribed
|
||||
if !config.install_watcher && !signals.cancelled.load(Ordering::Acquire) {
|
||||
rescubscribe_watcher_post_scan(&shared_picker);
|
||||
}
|
||||
|
||||
let mut 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
|
||||
};
|
||||
|
||||
// 3. Post-scan warmup + bigram build — runs in parallel with the
|
||||
// git-status thread to overlap the two expensive phases.
|
||||
// Always runs (even with both flags off) so binary-content files
|
||||
// with unknown extensions get reclassified before user search hits.
|
||||
if !signals.cancelled.load(Ordering::Acquire)
|
||||
&& let Some(snap) = snapshot.as_ref()
|
||||
{
|
||||
Self::run_post_scan(&shared_picker, &signals, &config, snap);
|
||||
}
|
||||
|
||||
// 4. Join and git status, this HAS to be done after the post scan
|
||||
if !signals.cancelled.load(Ordering::Acquire)
|
||||
&& let Some(status_handle) = status_handle
|
||||
&& let Some(snapshot) = snapshot.as_mut()
|
||||
// THIS DOES WAIT for potentially very long status query
|
||||
&& let Ok(Some(git_status)) = status_handle.join()
|
||||
{
|
||||
apply_git_status_and_frecency(git_status, &shared_frecency, mode, snapshot);
|
||||
}
|
||||
|
||||
drop(snapshot); // SNAPSHOT SHOULD NOT BE USED AFTER THIS POINT
|
||||
|
||||
// 5. Install filesystem watcher (initial scan only).
|
||||
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,
|
||||
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,
|
||||
);
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
|
||||
// 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(())
|
||||
});
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
level = "debug",
|
||||
skip_all,
|
||||
fields(file_count = tracing::field::Empty, dirty_count = tracing::field::Empty),
|
||||
)]
|
||||
fn apply_git_status_and_frecency(
|
||||
git_cache: GitStatusCache,
|
||||
shared_frecency: &SharedFrecency,
|
||||
mode: FFFMode,
|
||||
unsafe_snapshot: &mut crate::file_picker::PostScanUnsafeSnapshot,
|
||||
) {
|
||||
let frecency = shared_frecency.read().ok();
|
||||
let frecency_ref = frecency.as_ref().and_then(|f| f.as_ref());
|
||||
|
||||
let base_count = unsafe_snapshot.base_count;
|
||||
let files: &mut [crate::types::FileItem] = &mut unsafe_snapshot.files[..base_count];
|
||||
// Dir frecency goes through per-entry `AtomicI32`; a shared slice is
|
||||
// enough and avoids any `&mut` aliasing against the Arc-shared buffer.
|
||||
let dirs: &[crate::types::DirItem] = &unsafe_snapshot.dirs;
|
||||
let arena = unsafe_snapshot
|
||||
.arena
|
||||
.as_ref()
|
||||
.map(|s| s.as_arena_ptr())
|
||||
.unwrap_or(ArenaPtr::null());
|
||||
|
||||
// Reset dir frecency before recomputation.
|
||||
for dir in dirs.iter() {
|
||||
dir.reset_frecency();
|
||||
}
|
||||
|
||||
BACKGROUND_THREAD_POOL.install(|| {
|
||||
files.par_iter_mut().for_each(|file| {
|
||||
if unsafe_snapshot.cancelled.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
|
||||
let absolute_path =
|
||||
file.write_absolute_path(arena, &unsafe_snapshot.base_path, &mut buf);
|
||||
|
||||
file.git_status = git_cache.lookup_status(absolute_path);
|
||||
if let Some(frecency) = frecency_ref {
|
||||
let _ =
|
||||
file.update_frecency_scores(frecency, arena, &unsafe_snapshot.base_path, mode);
|
||||
}
|
||||
|
||||
let score = file.access_frecency_score as i32;
|
||||
if score > 0 {
|
||||
let dir_idx = file.parent_dir_index as usize;
|
||||
if let Some(dir) = dirs.get(dir_idx) {
|
||||
dir.update_frecency_if_larger(score);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let span = tracing::Span::current();
|
||||
span.record("dirty_count", git_cache.statuses_len());
|
||||
}
|
||||
+1134
-268
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,411 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard, Weak};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::dbs::lmdb::spawn_lmdb_gc;
|
||||
use crate::error::Error;
|
||||
use crate::file_picker::FilePicker;
|
||||
use crate::frecency::FrecencyTracker;
|
||||
use crate::git::GitStatusCache;
|
||||
use crate::query_tracker::QueryTracker;
|
||||
use crate::scan::ScanJob;
|
||||
|
||||
/// Poll `.git/index.lock` until it disappears (git write completed), giving up
|
||||
/// after [`GIT_LOCK_MAX_WAIT`]. Used by [`SharedPicker::refresh_git_status`]
|
||||
/// to avoid reading a half-updated index when the watcher fires mid-`git add`.
|
||||
///
|
||||
/// The wait is bounded and cheap: the lock file is typically cleared within
|
||||
/// a few milliseconds of the git command exiting.
|
||||
fn wait_for_git_index_lock_release(git_root: &Path) {
|
||||
const GIT_LOCK_POLL: Duration = Duration::from_millis(10);
|
||||
const GIT_LOCK_MAX_WAIT: Duration = Duration::from_millis(500);
|
||||
|
||||
let lock = git_root.join(".git").join("index.lock");
|
||||
// Fast path: no lock present.
|
||||
if !lock.exists() {
|
||||
return;
|
||||
}
|
||||
let deadline = Instant::now() + GIT_LOCK_MAX_WAIT;
|
||||
while lock.exists() && Instant::now() < deadline {
|
||||
std::thread::sleep(GIT_LOCK_POLL);
|
||||
}
|
||||
if lock.exists() {
|
||||
tracing::warn!(
|
||||
"Proceeding with git status refresh despite lingering \
|
||||
.git/index.lock at {} — will retry once it clears",
|
||||
lock.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe shared handle to the [`FilePicker`] instance.
|
||||
/// This accumulates only asynchronous non-blocking operations against the
|
||||
/// file picker: creating, triggering various rescans and so on.
|
||||
///
|
||||
/// 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())
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
};
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
while signal.load(std::sync::atomic::Ordering::Acquire) {
|
||||
if start.elapsed() >= timeout {
|
||||
return false;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Block until the background file watcher is ready.
|
||||
/// Returns `true` if watcher ready, `false` on timeout.
|
||||
pub fn wait_for_watcher(&self, timeout: Duration) -> bool {
|
||||
let watch_ready_signal = {
|
||||
let guard = self.0.picker.read();
|
||||
match &*guard {
|
||||
Some(picker) => Arc::clone(&picker.signals.watcher_ready),
|
||||
None => return true,
|
||||
}
|
||||
};
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
while !watch_ready_signal.load(std::sync::atomic::Ordering::Acquire) {
|
||||
if start.elapsed() >= timeout {
|
||||
return false;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
};
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
if start.elapsed() >= timeout {
|
||||
return false;
|
||||
}
|
||||
let s = scanning.load(std::sync::atomic::Ordering::Acquire);
|
||||
let p = post_scan_active.load(std::sync::atomic::Ordering::Acquire);
|
||||
if !s && !p {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn refresh_git_status(&self, shared_frecency: &SharedFrecency) -> Result<usize, Error> {
|
||||
use tracing::debug;
|
||||
|
||||
let git_status = {
|
||||
let guard = self.read()?;
|
||||
let Some(ref picker) = *guard else {
|
||||
return Err(Error::FilePickerMissing);
|
||||
};
|
||||
|
||||
let git_root = picker.git_root().map(|p| p.to_path_buf());
|
||||
drop(guard); // updating git status could take very long time, there is not risky as we
|
||||
// do not allow any mutations and deletions of files from the sync
|
||||
|
||||
debug!(?git_root, "Refreshing git status for picker");
|
||||
|
||||
if let Some(ref root) = git_root {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe shared handle to the [`FrecencyTracker`] instance.
|
||||
#[derive(Clone)]
|
||||
pub struct SharedFrecency {
|
||||
inner: Arc<RwLock<Option<FrecencyTracker>>>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for SharedFrecency {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(None)),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SharedFrecency {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_tuple("SharedFrecency").field(&"..").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SharedFrecency {
|
||||
/// Creates a disabled instance that silently ignores all writes.
|
||||
pub fn noop() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(None)),
|
||||
enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read(&self) -> Result<RwLockReadGuard<'_, Option<FrecencyTracker>>, Error> {
|
||||
self.inner.read().map_err(|_| Error::AcquireFrecencyLock)
|
||||
}
|
||||
|
||||
pub fn write(&self) -> Result<RwLockWriteGuard<'_, Option<FrecencyTracker>>, Error> {
|
||||
self.inner.write().map_err(|_| Error::AcquireFrecencyLock)
|
||||
}
|
||||
|
||||
pub fn init(&self, tracker: FrecencyTracker) -> 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
|
||||
// for it naturally — no join handle, no race against file removal.
|
||||
spawn_lmdb_gc(self.inner.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop the in-memory tracker and delete the on-disk database directory.
|
||||
///
|
||||
/// Acquires the write lock, ensuring all readers (including any active mmap
|
||||
/// access) are finished before the LMDB environment is closed and the files
|
||||
/// are removed.
|
||||
///
|
||||
/// Returns `Ok(Some(path))` with the deleted path, or `Ok(None)` if no
|
||||
/// tracker was initialized.
|
||||
pub fn destroy(&self) -> Result<Option<PathBuf>, Error> {
|
||||
let mut guard = self.write()?;
|
||||
let Some(tracker) = guard.take() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let db_path = tracker.db_path().to_path_buf();
|
||||
// Drop closes the LMDB env and unmaps the files
|
||||
drop(tracker);
|
||||
drop(guard);
|
||||
std::fs::remove_dir_all(&db_path).map_err(|source| Error::RemoveDbDir {
|
||||
path: db_path.clone(),
|
||||
source,
|
||||
})?;
|
||||
Ok(Some(db_path))
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe shared handle to the [`QueryTracker`] instance.
|
||||
#[derive(Clone)]
|
||||
pub struct SharedQueryTracker {
|
||||
inner: Arc<RwLock<Option<QueryTracker>>>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for SharedQueryTracker {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(None)),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SharedQueryTracker {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_tuple("SharedQueryTracker").field(&"..").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SharedQueryTracker {
|
||||
/// Creates a disabled instance that silently ignores all writes.
|
||||
pub fn noop() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(None)),
|
||||
enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read(&self) -> Result<RwLockReadGuard<'_, Option<QueryTracker>>, Error> {
|
||||
self.inner.read().map_err(|_| Error::AcquireFrecencyLock)
|
||||
}
|
||||
|
||||
pub fn write(&self) -> Result<RwLockWriteGuard<'_, Option<QueryTracker>>, Error> {
|
||||
self.inner.write().map_err(|_| Error::AcquireFrecencyLock)
|
||||
}
|
||||
|
||||
/// Initialize the query tracker + spawn GC in the background.
|
||||
/// No-op if this is a disabled instance.
|
||||
pub fn init(&self, tracker: QueryTracker) -> Result<(), Error> {
|
||||
if !self.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
{
|
||||
let mut guard = self.write()?;
|
||||
*guard = Some(tracker);
|
||||
}
|
||||
|
||||
spawn_lmdb_gc(self.inner.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
///Drop the in-memory tracker and delete the on-disk database directory.
|
||||
///
|
||||
/// Acquires the write lock, ensuring all readers (including any active mmap
|
||||
/// access) are finished before the LMDB environment is closed and the files
|
||||
/// are removed.
|
||||
///
|
||||
/// Returns `Ok(Some(path))` with the deleted path, or `Ok(None)` if no
|
||||
/// tracker was initialized.
|
||||
pub fn destroy(&self) -> Result<Option<PathBuf>, Error> {
|
||||
let mut guard = self.write()?;
|
||||
let Some(tracker) = guard.take() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let db_path = tracker.db_path().to_path_buf();
|
||||
drop(tracker);
|
||||
drop(guard);
|
||||
std::fs::remove_dir_all(&db_path).map_err(|source| Error::RemoveDbDir {
|
||||
path: db_path.clone(),
|
||||
source,
|
||||
})?;
|
||||
Ok(Some(db_path))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
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;
|
||||
|
||||
/// Indices into a shared `SimdChunk` arena representing a file path.
|
||||
///
|
||||
/// All read methods require an explicit `arena_base` pointer from the owning
|
||||
/// `ChunkedPathStore`. The struct itself contains no raw pointers to the arena
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ChunkedString {
|
||||
indices: ChunkIndices,
|
||||
pub byte_len: u16,
|
||||
/// Byte offset where the filename begins. 0 for root-level files.
|
||||
pub filename_offset: u16,
|
||||
}
|
||||
|
||||
impl ChunkedString {
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
indices: SmallVec::new(),
|
||||
byte_len: 0,
|
||||
filename_offset: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn new(indices: ChunkIndices, byte_len: u16, filename_offset: u16) -> Self {
|
||||
Self {
|
||||
indices,
|
||||
byte_len,
|
||||
filename_offset,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn chunk_count(&self) -> usize {
|
||||
self.indices.len()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn resolve_ptrs<'a>(
|
||||
&self,
|
||||
arena: ArenaPtr,
|
||||
buf: &'a mut [*const u8; 32],
|
||||
) -> &'a [*const u8] {
|
||||
let count = self.indices.len();
|
||||
let base = arena.as_ptr();
|
||||
for (i, &idx) in self.indices.iter().enumerate() {
|
||||
buf[i] = unsafe { base.add(idx as usize * SIMD_CHUNK_BYTES) };
|
||||
}
|
||||
&buf[..count]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn write_slice_to_vec(
|
||||
indices: &[u32],
|
||||
base: *const u8,
|
||||
offset_in_chunk: usize,
|
||||
len: usize,
|
||||
vec: &mut Vec<u8>,
|
||||
) {
|
||||
let mut written = 0usize;
|
||||
for (i, &idx) in indices.iter().enumerate() {
|
||||
let src = unsafe { base.add(idx as usize * SIMD_CHUNK_BYTES) };
|
||||
let chunk_bytes = unsafe { core::slice::from_raw_parts(src, SIMD_CHUNK_BYTES) };
|
||||
let start = if i == 0 { offset_in_chunk } else { 0 };
|
||||
let end = SIMD_CHUNK_BYTES.min(start + (len - written));
|
||||
vec.extend_from_slice(&chunk_bytes[start..end]);
|
||||
written += end - start;
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the filename portion as a `Cow<str>`.
|
||||
///
|
||||
/// When the filename starts at a chunk boundary and fits in one chunk we
|
||||
/// borrow directly from the arena (zero-copy). Otherwise we allocate.
|
||||
/// Filenames are almost always <=16 bytes so the fast path dominates.
|
||||
#[inline]
|
||||
pub fn filename_cow<'a>(&self, arena: ArenaPtr) -> Cow<'a, str> {
|
||||
let fname_offset = self.filename_offset as usize;
|
||||
let fname_len = self.byte_len as usize - fname_offset;
|
||||
if fname_len == 0 {
|
||||
return Cow::Borrowed("");
|
||||
}
|
||||
|
||||
let base = arena.as_ptr();
|
||||
let start_chunk = fname_offset / SIMD_CHUNK_BYTES;
|
||||
let offset_in_chunk = fname_offset % SIMD_CHUNK_BYTES;
|
||||
|
||||
if offset_in_chunk == 0 && fname_len <= SIMD_CHUNK_BYTES {
|
||||
let ptr = unsafe { base.add(self.indices[start_chunk] as usize * SIMD_CHUNK_BYTES) };
|
||||
let slice = unsafe { core::slice::from_raw_parts(ptr, fname_len) };
|
||||
return Cow::Borrowed(unsafe { core::str::from_utf8_unchecked(slice) });
|
||||
}
|
||||
|
||||
let mut out = String::with_capacity(fname_len);
|
||||
let needed_chunks = chunks_needed(offset_in_chunk + fname_len);
|
||||
Self::write_slice_to_vec(
|
||||
&self.indices[start_chunk..start_chunk + needed_chunks],
|
||||
base,
|
||||
offset_in_chunk,
|
||||
fname_len,
|
||||
unsafe { out.as_mut_vec() },
|
||||
);
|
||||
Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// Truncates at `buf.len()` if exceeded -- use `[u8; PATH_BUF_SIZE]` to avoid.
|
||||
#[inline]
|
||||
pub fn read_to_buf<'a>(&self, arena: ArenaPtr, buf: &'a mut [u8]) -> &'a str {
|
||||
let total = (self.byte_len as usize).min(buf.len());
|
||||
let usable_chunks = total.div_ceil(SIMD_CHUNK_BYTES);
|
||||
let chunks_to_copy = usable_chunks.min(self.indices.len());
|
||||
let base = arena.as_ptr();
|
||||
|
||||
for (i, &idx) in self.indices[..chunks_to_copy].iter().enumerate() {
|
||||
let src = unsafe { base.add(idx as usize * SIMD_CHUNK_BYTES) };
|
||||
let dst_offset = i * SIMD_CHUNK_BYTES;
|
||||
let take = SIMD_CHUNK_BYTES.min(total - dst_offset);
|
||||
|
||||
unsafe {
|
||||
core::ptr::copy_nonoverlapping(src, buf.as_mut_ptr().add(dst_offset), take);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe { core::str::from_utf8_unchecked(&buf[..total]) }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn write_dir_to(&self, arena: ArenaPtr, out: &mut String) {
|
||||
out.clear();
|
||||
|
||||
let dir_len = self.filename_offset as usize;
|
||||
out.reserve(dir_len);
|
||||
let dir_chunks = chunks_needed(dir_len).min(self.indices.len());
|
||||
let base = arena.as_ptr();
|
||||
let vec = unsafe { out.as_mut_vec() };
|
||||
for (i, &idx) in self.indices[..dir_chunks].iter().enumerate() {
|
||||
let src = unsafe { base.add(idx as usize * SIMD_CHUNK_BYTES) };
|
||||
let take = SIMD_CHUNK_BYTES.min(dir_len - i * SIMD_CHUNK_BYTES);
|
||||
vec.extend_from_slice(unsafe { core::slice::from_raw_parts(src, take) });
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn write_filename_to(&self, arena: ArenaPtr, out: &mut String) {
|
||||
out.clear();
|
||||
|
||||
let fname_offset = self.filename_offset as usize;
|
||||
let fname_len = self.byte_len as usize - fname_offset;
|
||||
out.reserve(fname_len);
|
||||
let start_chunk = fname_offset / SIMD_CHUNK_BYTES;
|
||||
let offset_in_chunk = fname_offset % SIMD_CHUNK_BYTES;
|
||||
let needed_chunks = chunks_needed(offset_in_chunk + fname_len);
|
||||
Self::write_slice_to_vec(
|
||||
&self.indices[start_chunk..start_chunk + needed_chunks],
|
||||
arena.as_ptr(),
|
||||
offset_in_chunk,
|
||||
fname_len,
|
||||
unsafe { out.as_mut_vec() },
|
||||
);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn write_to_string(&self, arena: ArenaPtr, out: &mut String) {
|
||||
out.clear();
|
||||
|
||||
let total = self.byte_len as usize;
|
||||
if total == 0 {
|
||||
return;
|
||||
}
|
||||
out.reserve(total);
|
||||
let base = arena.as_ptr();
|
||||
let vec = unsafe { out.as_mut_vec() };
|
||||
for (i, &idx) in self.indices.iter().enumerate() {
|
||||
let src = unsafe { base.add(idx as usize * SIMD_CHUNK_BYTES) };
|
||||
let take = SIMD_CHUNK_BYTES.min(total - i * SIMD_CHUNK_BYTES);
|
||||
vec.extend_from_slice(unsafe { core::slice::from_raw_parts(src, take) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ChunkedString {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ChunkedString")
|
||||
.field("indices", &self.indices.as_slice())
|
||||
.field("chunks", &self.indices.len())
|
||||
.field("byte_len", &self.byte_len)
|
||||
.field("filename_offset", &self.filename_offset)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
const fn chunks_needed(byte_len: usize) -> usize {
|
||||
if byte_len == 0 {
|
||||
0
|
||||
} else {
|
||||
byte_len.div_ceil(SIMD_CHUNK_BYTES)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ChunkedPathStore {
|
||||
arena: Vec<SimdChunk>,
|
||||
}
|
||||
|
||||
// SAFETY: arena is immutable after construction. Pointers derived from it are
|
||||
// only read during scoring (no mutation, no reallocation).
|
||||
unsafe impl Send for ChunkedPathStore {}
|
||||
unsafe impl Sync for ChunkedPathStore {}
|
||||
|
||||
impl ChunkedPathStore {
|
||||
pub fn heap_bytes(&self) -> usize {
|
||||
self.arena.len() * SIMD_CHUNK_BYTES
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn unique_chunks(&self) -> usize {
|
||||
self.arena.len()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn as_arena_ptr(&self) -> ArenaPtr {
|
||||
ArenaPtr::new(self.arena.as_ptr() as *const u8)
|
||||
}
|
||||
}
|
||||
|
||||
/// At runtime the builder should be split out from the store after `finish()`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ChunkedPathStoreBuilder {
|
||||
arena: Vec<SimdChunk>,
|
||||
chunk_dedup: AHashMap<[u8; SIMD_CHUNK_BYTES], u32>,
|
||||
}
|
||||
|
||||
impl ChunkedPathStoreBuilder {
|
||||
pub fn new(estimated_files: usize) -> Self {
|
||||
let est_chunks = estimated_files * 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>(); 32];
|
||||
let resolved = cs.resolve_ptrs(arena, &mut ptrs);
|
||||
assert_eq!(resolved.len(), 2); // 25 bytes = 2 chunks
|
||||
|
||||
// Verify we can read back the bytes
|
||||
let mut reconstructed = Vec::new();
|
||||
for (i, &ptr) in resolved.iter().enumerate() {
|
||||
let chunk = unsafe { std::slice::from_raw_parts(ptr, SIMD_CHUNK_BYTES) };
|
||||
let start = i * SIMD_CHUNK_BYTES;
|
||||
let take = SIMD_CHUNK_BYTES.min(25 - start);
|
||||
reconstructed.extend_from_slice(&chunk[..take]);
|
||||
}
|
||||
assert_eq!(
|
||||
std::str::from_utf8(&reconstructed).unwrap(),
|
||||
"src/components/Button.tsx"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filename_cow_mid_chunk() {
|
||||
let (store, strings, _files) = build_test_store(&["src/components/Button.tsx"]);
|
||||
let arena = store.as_arena_ptr();
|
||||
let cs = &strings[0];
|
||||
|
||||
assert_eq!(cs.filename_offset, 15);
|
||||
assert_eq!(cs.byte_len, 25);
|
||||
|
||||
let fname = cs.filename_cow(arena);
|
||||
assert_eq!(&*fname, "Button.tsx");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filename_cow_chunk_aligned() {
|
||||
let path = "0123456789abcdef/file.txt";
|
||||
let (store, strings, _files) = build_test_store(&[path]);
|
||||
let arena = store.as_arena_ptr();
|
||||
let cs = &strings[0];
|
||||
|
||||
assert_eq!(cs.filename_offset, 17);
|
||||
let fname = cs.filename_cow(arena);
|
||||
assert_eq!(&*fname, "file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filename_cow_root_file() {
|
||||
let (store, strings, _files) = build_test_store(&["Cargo.toml"]);
|
||||
let arena = store.as_arena_ptr();
|
||||
let cs = &strings[0];
|
||||
|
||||
assert_eq!(cs.filename_offset, 0);
|
||||
let fname = cs.filename_cow(arena);
|
||||
assert_eq!(&*fname, "Cargo.toml");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunked_string_long_path() {
|
||||
let path = "very/deeply/nested/directory/structure/with/many/levels/file.txt";
|
||||
let (store, strings, _files) = build_test_store(&[path]);
|
||||
let arena = store.as_arena_ptr();
|
||||
let cs = &strings[0];
|
||||
|
||||
let mut buf = [0u8; 512];
|
||||
assert_eq!(cs.read_to_buf(arena, &mut buf), path);
|
||||
assert!(
|
||||
cs.chunk_count() <= 6,
|
||||
"should fit inline in ChunkIndices (INLINE_CHUNKS={})",
|
||||
INLINE_CHUNKS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunked_string_clone() {
|
||||
let (store, strings, _files) = build_test_store(&["src/main.rs"]);
|
||||
let arena = store.as_arena_ptr();
|
||||
let cs = &strings[0];
|
||||
let cs2 = cs.clone();
|
||||
|
||||
let mut buf1 = [0u8; 512];
|
||||
let mut buf2 = [0u8; 512];
|
||||
assert_eq!(
|
||||
cs.read_to_buf(arena, &mut buf1),
|
||||
cs2.read_to_buf(arena, &mut buf2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunked_string_full_path_roundtrip() {
|
||||
let paths = [
|
||||
"src/components/Button.tsx",
|
||||
"src/components/ui/DatePicker.tsx",
|
||||
"very/deeply/nested/directory/structure/file.txt",
|
||||
"Cargo.toml",
|
||||
"a.rs",
|
||||
];
|
||||
let (store, strings, _files) = build_test_store(&paths);
|
||||
let arena = store.as_arena_ptr();
|
||||
|
||||
for (i, expected) in paths.iter().enumerate() {
|
||||
let mut buf = [0u8; 512];
|
||||
let got = strings[i].read_to_buf(arena, &mut buf);
|
||||
assert_eq!(got, *expected, "full path roundtrip failed for file {i}");
|
||||
|
||||
let mut ds = String::new();
|
||||
let mut fs = String::new();
|
||||
strings[i].write_dir_to(arena, &mut ds);
|
||||
strings[i].write_filename_to(arena, &mut fs);
|
||||
assert_eq!(
|
||||
format!("{ds}{fs}"),
|
||||
*expected,
|
||||
"dir+fname mismatch for file {i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,56 @@
|
||||
//! Thread-local sort buffer management for glidesort optimization
|
||||
//!
|
||||
//! This module provides thread-local buffers for glidesort's with_buffer API,
|
||||
//! eliminating allocations in the hot path of fuzzy search operations.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use parking_lot::Mutex;
|
||||
use std::mem::MaybeUninit;
|
||||
|
||||
// glidesort requires a buffer to allocate, we use one reused buffer as it can grow pretty big
|
||||
// for a large projects, this effectively saves 12kb of allocation on every search in linux repo
|
||||
thread_local! {
|
||||
static SORT_BUFFER: RefCell<Vec<u8>> = RefCell::new(Vec::with_capacity(1024));
|
||||
// this originally happen to be in TLS but there is a limit of TLS
|
||||
// + the storage itself is not free, so now we rely on the fact that most calls
|
||||
// are sequential in practice and allocate ONLY when we have a parallel access
|
||||
static SORT_BUFFER: Mutex<Vec<u8>> = Mutex::new(Vec::new());
|
||||
|
||||
fn ensure_capacity(buf: &mut Vec<u8>, required: usize) {
|
||||
if buf.capacity() < required {
|
||||
let len = buf.len();
|
||||
buf.reserve(required - len);
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedSortBuf {
|
||||
guard: parking_lot::MutexGuard<'static, Vec<u8>>,
|
||||
}
|
||||
|
||||
impl SharedSortBuf {
|
||||
fn as_slice_mut<T>(&mut self, len: usize) -> &mut [MaybeUninit<T>] {
|
||||
let align = std::mem::align_of::<MaybeUninit<T>>();
|
||||
let size = std::mem::size_of::<MaybeUninit<T>>();
|
||||
let required = len.saturating_mul(size).saturating_add(align);
|
||||
ensure_capacity(&mut self.guard, required);
|
||||
|
||||
// SAFETY: the Vec<u8> is only 1-byte aligned, so we over-allocate by
|
||||
// `align` bytes and shift the pointer to satisfy T's alignment.
|
||||
// Callers never read uninitialised data through the returned slice.
|
||||
unsafe {
|
||||
let ptr = self.guard.as_mut_ptr();
|
||||
let offset = ptr.align_offset(align);
|
||||
debug_assert!(offset != usize::MAX && offset + len * size <= self.guard.capacity());
|
||||
std::slice::from_raw_parts_mut(ptr.add(offset) as *mut MaybeUninit<T>, len)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_lock_shared_buf() -> Option<SharedSortBuf> {
|
||||
SORT_BUFFER.try_lock().map(|guard| SharedSortBuf { guard })
|
||||
}
|
||||
|
||||
pub fn sort_with_buffer<T, F>(slice: &mut [T], compare: F)
|
||||
where
|
||||
F: FnMut(&T, &T) -> std::cmp::Ordering,
|
||||
{
|
||||
SORT_BUFFER.with(|buffer| {
|
||||
let mut buffer = buffer.borrow_mut();
|
||||
|
||||
// Calculate required buffer size in u8 units
|
||||
let size_of_t = std::mem::size_of::<MaybeUninit<T>>();
|
||||
let size_of_usize = std::mem::size_of::<u8>();
|
||||
let required_usizes = (slice.len() * size_of_t).div_ceil(size_of_usize);
|
||||
|
||||
// Ensure buffer has enough capacity
|
||||
if buffer.len() < required_usizes {
|
||||
buffer.resize(required_usizes, 0);
|
||||
match try_lock_shared_buf() {
|
||||
Some(mut buf) => {
|
||||
let typed = buf.as_slice_mut::<T>(slice.len());
|
||||
glidesort::sort_with_buffer_by(slice, typed, compare);
|
||||
}
|
||||
|
||||
// Cast u8 buffer to MaybeUninit<T> slice
|
||||
// SAFETY: u8 provides sufficient alignment for most types, and we've ensured
|
||||
// the buffer is large enough
|
||||
let typed_buffer = unsafe {
|
||||
std::slice::from_raw_parts_mut(buffer.as_mut_ptr() as *mut MaybeUninit<T>, slice.len())
|
||||
};
|
||||
|
||||
glidesort::sort_with_buffer_by(slice, typed_buffer, compare);
|
||||
});
|
||||
None => glidesort::sort_by(slice, compare),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sort_by_key_with_buffer<T, K, F>(slice: &mut [T], key_fn: F)
|
||||
@@ -45,28 +58,13 @@ where
|
||||
K: Ord,
|
||||
F: FnMut(&T) -> K,
|
||||
{
|
||||
SORT_BUFFER.with(|buffer| {
|
||||
let mut buffer = buffer.borrow_mut();
|
||||
|
||||
// Calculate required buffer size in u8 units
|
||||
let size_of_t = std::mem::size_of::<MaybeUninit<T>>();
|
||||
let size_of_usize = std::mem::size_of::<u8>();
|
||||
let required_usizes = (slice.len() * size_of_t).div_ceil(size_of_usize);
|
||||
|
||||
// Ensure buffer has enough capacity
|
||||
if buffer.len() < required_usizes {
|
||||
buffer.resize(required_usizes, 0);
|
||||
match try_lock_shared_buf() {
|
||||
Some(mut buf) => {
|
||||
let typed = buf.as_slice_mut::<T>(slice.len());
|
||||
glidesort::sort_with_buffer_by_key(slice, typed, key_fn);
|
||||
}
|
||||
|
||||
// Cast u8 buffer to MaybeUninit<T> slice
|
||||
// SAFETY: u8 provides sufficient alignment for most types, and we've ensured
|
||||
// the buffer is large enough
|
||||
let typed_buffer = unsafe {
|
||||
std::slice::from_raw_parts_mut(buffer.as_mut_ptr() as *mut MaybeUninit<T>, slice.len())
|
||||
};
|
||||
|
||||
glidesort::sort_with_buffer_by_key(slice, typed_buffer, key_fn);
|
||||
});
|
||||
None => glidesort::sort_by_key(slice, key_fn),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -82,9 +80,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_sort_by_key_with_buffer() {
|
||||
let mut data = vec![(2, "b"), (1, "a"), (3, "c")];
|
||||
sort_by_key_with_buffer(&mut data, |item| item.0);
|
||||
assert_eq!(data, vec![(1, "a"), (2, "b"), (3, "c")]);
|
||||
let mut data = vec![(1, 50), (2, 20), (3, 80), (4, 10), (5, 90)];
|
||||
sort_by_key_with_buffer(&mut data, |a| a.1);
|
||||
assert_eq!(data, vec![(4, 10), (2, 20), (1, 50), (3, 80), (5, 90)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -94,19 +92,6 @@ mod tests {
|
||||
assert_eq!(data, vec![5, 4, 3, 2, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_sorts_reuse_buffer() {
|
||||
// This test verifies that multiple sorts on the same thread reuse the buffer
|
||||
let mut data1 = vec![5, 2, 8, 1, 9];
|
||||
sort_with_buffer(&mut data1, |a, b| a.cmp(b));
|
||||
|
||||
let mut data2 = vec![15, 12, 18, 11, 19];
|
||||
sort_with_buffer(&mut data2, |a, b| a.cmp(b));
|
||||
|
||||
assert_eq!(data1, vec![1, 2, 5, 8, 9]);
|
||||
assert_eq!(data2, vec![11, 12, 15, 18, 19]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_slice() {
|
||||
let mut data: Vec<i32> = vec![];
|
||||
@@ -121,13 +106,6 @@ mod tests {
|
||||
assert_eq!(data, vec![42]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_already_sorted() {
|
||||
let mut data = vec![1, 2, 3, 4, 5];
|
||||
sort_with_buffer(&mut data, |a, b| a.cmp(b));
|
||||
assert_eq!(data, vec![1, 2, 3, 4, 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_duplicates() {
|
||||
let mut data = vec![3, 1, 4, 1, 5, 9, 2, 6, 5];
|
||||
@@ -144,11 +122,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_simple_descending() {
|
||||
// Simple test to verify highest scores come first
|
||||
let mut data = vec![100, 300, 200];
|
||||
sort_with_buffer(&mut data, |a, b| b.cmp(a));
|
||||
assert_eq!(data[0], 300, "Highest should be first");
|
||||
assert_eq!(data[1], 200, "Middle should be second");
|
||||
assert_eq!(data[2], 100, "Lowest should be last");
|
||||
assert_eq!(data[0], 300);
|
||||
assert_eq!(data[1], 200);
|
||||
assert_eq!(data[2], 100);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
+815
-110
File diff suppressed because it is too large
Load Diff
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,860 @@
|
||||
//! Randomized file-system mutation stress test.
|
||||
//!
|
||||
//! Seeds a directory with ~40 files across diverse content domains, builds the
|
||||
//! picker + bigram index, then runs 20 rounds of randomized create / edit /
|
||||
//! delete / rename / read-only operations. After every round the test verifies
|
||||
//! that plain-text grep, regex grep, and fuzzy file search all return correct
|
||||
//! results for every live and dead file.
|
||||
//!
|
||||
//! Uses a seeded RNG (`SmallRng::seed_from_u64`) for deterministic
|
||||
//! reproduction.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tempfile::TempDir;
|
||||
|
||||
use rand::rngs::SmallRng;
|
||||
use rand::{RngCore, SeedableRng};
|
||||
|
||||
use fff_search::file_picker::{FFFMode, FilePicker, FuzzySearchOptions};
|
||||
use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query};
|
||||
use fff_search::{
|
||||
FilePickerOptions, PaginationArgs, QueryParser, SharedFilePicker, SharedFrecency,
|
||||
};
|
||||
|
||||
const DOMAINS: &[&str] = &[
|
||||
r#"
|
||||
use std::net::{TcpStream, SocketAddr};
|
||||
fn establish_connection(addr: SocketAddr) -> Result<TcpStream, std::io::Error> {
|
||||
let stream = TcpStream::connect(addr)?;
|
||||
stream.set_nodelay(true)?;
|
||||
Ok(stream)
|
||||
}
|
||||
fn parse_http_header(raw: &[u8]) -> Option<(&str, &str)> {
|
||||
let line = std::str::from_utf8(raw).ok()?;
|
||||
let (key, val) = line.split_once(':')?;
|
||||
Some((key.trim(), val.trim()))
|
||||
}
|
||||
"#,
|
||||
r#"
|
||||
use sqlx::{PgPool, Row};
|
||||
async fn query_users(pool: &PgPool, limit: i64) -> Vec<String> {
|
||||
sqlx::query("SELECT name FROM users ORDER BY created_at DESC LIMIT $1")
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|row| row.get("name"))
|
||||
.collect()
|
||||
}
|
||||
async fn insert_record(pool: &PgPool, name: &str) -> i64 {
|
||||
sqlx::query_scalar("INSERT INTO records (name) VALUES ($1) RETURNING id")
|
||||
.bind(name)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
"#,
|
||||
r#"
|
||||
fn verify_jwt_token(token: &str, secret: &[u8]) -> Result<Claims, AuthError> {
|
||||
let parts: Vec<&str> = token.splitn(3, '.').collect();
|
||||
if parts.len() != 3 { return Err(AuthError::MalformedToken); }
|
||||
let payload = base64_decode(parts[1])?;
|
||||
let signature = hmac_sha256(secret, &format!("{}.{}", parts[0], parts[1]));
|
||||
if signature != base64_decode(parts[2])? { return Err(AuthError::InvalidSignature); }
|
||||
serde_json::from_slice(&payload).map_err(AuthError::Deserialize)
|
||||
}
|
||||
fn hash_password(password: &str, salt: &[u8]) -> Vec<u8> {
|
||||
argon2::hash_encoded(password.as_bytes(), salt, &argon2::Config::default())
|
||||
.unwrap().into_bytes()
|
||||
}
|
||||
"#,
|
||||
r#"
|
||||
struct Renderer { framebuffer: Vec<u32>, width: usize, height: usize }
|
||||
impl Renderer {
|
||||
fn clear(&mut self, color: u32) { self.framebuffer.fill(color); }
|
||||
fn draw_pixel(&mut self, x: usize, y: usize, color: u32) {
|
||||
if x < self.width && y < self.height {
|
||||
self.framebuffer[y * self.width + x] = color;
|
||||
}
|
||||
}
|
||||
fn draw_line(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, color: u32) {
|
||||
let dx = (x1 - x0).abs(); let dy = -(y1 - y0).abs();
|
||||
let mut err = dx + dy;
|
||||
let (mut cx, mut cy) = (x0, y0);
|
||||
loop {
|
||||
self.draw_pixel(cx as usize, cy as usize, color);
|
||||
if cx == x1 && cy == y1 { break; }
|
||||
let e2 = 2 * err;
|
||||
if e2 >= dy { err += dy; cx += if x0 < x1 { 1 } else { -1 }; }
|
||||
if e2 <= dx { err += dx; cy += if y0 < y1 { 1 } else { -1 }; }
|
||||
}
|
||||
}
|
||||
}
|
||||
"#,
|
||||
r#"
|
||||
use serde::{Serialize, Deserialize};
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct ConfigFile { log_level: String, max_retries: u32, timeout_ms: u64 }
|
||||
fn load_config(path: &std::path::Path) -> Result<ConfigFile, Box<dyn std::error::Error>> {
|
||||
let contents = std::fs::read_to_string(path)?;
|
||||
let config: ConfigFile = toml::from_str(&contents)?;
|
||||
Ok(config)
|
||||
}
|
||||
fn merge_configs(base: ConfigFile, overlay: ConfigFile) -> ConfigFile {
|
||||
ConfigFile {
|
||||
log_level: if overlay.log_level.is_empty() { base.log_level } else { overlay.log_level },
|
||||
max_retries: overlay.max_retries.max(base.max_retries),
|
||||
timeout_ms: overlay.timeout_ms.max(base.timeout_ms),
|
||||
}
|
||||
}
|
||||
"#,
|
||||
r#"
|
||||
struct PhysicsBody { position: [f64; 3], velocity: [f64; 3], mass: f64 }
|
||||
fn apply_gravity(bodies: &mut [PhysicsBody], dt: f64) {
|
||||
let gravity_constant = 6.674e-11;
|
||||
let len = bodies.len();
|
||||
let mut forces = vec![[0.0f64; 3]; len];
|
||||
for i in 0..len {
|
||||
for j in (i+1)..len {
|
||||
let dx = bodies[j].position[0] - bodies[i].position[0];
|
||||
let dy = bodies[j].position[1] - bodies[i].position[1];
|
||||
let dz = bodies[j].position[2] - bodies[i].position[2];
|
||||
let dist_sq = dx*dx + dy*dy + dz*dz;
|
||||
let force_mag = gravity_constant * bodies[i].mass * bodies[j].mass / dist_sq;
|
||||
let dist = dist_sq.sqrt();
|
||||
for k in 0..3 {
|
||||
let f = force_mag * [dx, dy, dz][k] / dist;
|
||||
forces[i][k] += f; forces[j][k] -= f;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (body, force) in bodies.iter_mut().zip(forces.iter()) {
|
||||
for k in 0..3 {
|
||||
body.velocity[k] += force[k] / body.mass * dt;
|
||||
body.position[k] += body.velocity[k] * dt;
|
||||
}
|
||||
}
|
||||
}
|
||||
"#,
|
||||
r#"
|
||||
use std::collections::BTreeMap;
|
||||
struct CacheEntry<V> { value: V, frequency: u64, last_access: u64 }
|
||||
struct LFUCache<K: Ord, V> { map: BTreeMap<K, CacheEntry<V>>, capacity: usize, clock: u64 }
|
||||
impl<K: Ord, V> LFUCache<K, V> {
|
||||
fn new(capacity: usize) -> Self { Self { map: BTreeMap::new(), capacity, clock: 0 } }
|
||||
fn get(&mut self, key: &K) -> Option<&V> {
|
||||
self.clock += 1;
|
||||
let entry = self.map.get_mut(key)?;
|
||||
entry.frequency += 1;
|
||||
entry.last_access = self.clock;
|
||||
Some(&entry.value)
|
||||
}
|
||||
fn insert(&mut self, key: K, value: V) {
|
||||
self.clock += 1;
|
||||
if self.map.len() >= self.capacity { self.evict(); }
|
||||
self.map.insert(key, CacheEntry { value, frequency: 1, last_access: self.clock });
|
||||
}
|
||||
fn evict(&mut self) {
|
||||
if let Some(victim) = self.map.keys().min_by_key(|k| {
|
||||
let e = &self.map[*k]; (e.frequency, e.last_access)
|
||||
}).cloned() { self.map.remove(&victim); }
|
||||
}
|
||||
}
|
||||
"#,
|
||||
r#"
|
||||
fn tokenize_expression(input: &str) -> Vec<Token> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut chars = input.chars().peekable();
|
||||
while let Some(&ch) = chars.peek() {
|
||||
match ch {
|
||||
'0'..='9' => {
|
||||
let mut num = String::new();
|
||||
while let Some(&d) = chars.peek() {
|
||||
if d.is_ascii_digit() || d == '.' { num.push(d); chars.next(); }
|
||||
else { break; }
|
||||
}
|
||||
tokens.push(Token::Number(num.parse().unwrap()));
|
||||
}
|
||||
'+' => { tokens.push(Token::Plus); chars.next(); }
|
||||
'-' => { tokens.push(Token::Minus); chars.next(); }
|
||||
'*' => { tokens.push(Token::Star); chars.next(); }
|
||||
'/' => { tokens.push(Token::Slash); chars.next(); }
|
||||
'(' => { tokens.push(Token::LParen); chars.next(); }
|
||||
')' => { tokens.push(Token::RParen); chars.next(); }
|
||||
_ if ch.is_whitespace() => { chars.next(); }
|
||||
_ => { chars.next(); }
|
||||
}
|
||||
}
|
||||
tokens
|
||||
}
|
||||
"#,
|
||||
r#"
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
fn parallel_map<T: Send + 'static, R: Send + 'static>(
|
||||
items: Vec<T>, num_threads: usize, f: fn(T) -> R
|
||||
) -> Vec<R> {
|
||||
let chunk_size = (items.len() + num_threads - 1) / num_threads;
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut handles = Vec::new();
|
||||
for (chunk_idx, chunk) in items.into_iter().collect::<Vec<_>>()
|
||||
.chunks(chunk_size).enumerate()
|
||||
{
|
||||
let tx = tx.clone();
|
||||
let chunk = chunk.to_vec();
|
||||
handles.push(thread::spawn(move || {
|
||||
for (i, item) in chunk.into_iter().enumerate() {
|
||||
tx.send((chunk_idx * chunk_size + i, f(item))).unwrap();
|
||||
}
|
||||
}));
|
||||
}
|
||||
drop(tx);
|
||||
let mut results: Vec<Option<R>> = vec![None; handles.len() * chunk_size];
|
||||
for (idx, result) in rx { if idx < results.len() { results[idx] = Some(result); } }
|
||||
for h in handles { h.join().unwrap(); }
|
||||
results.into_iter().flatten().collect()
|
||||
}
|
||||
"#,
|
||||
r#"
|
||||
struct Compressor { window: Vec<u8>, window_size: usize }
|
||||
impl Compressor {
|
||||
fn new(window_size: usize) -> Self {
|
||||
Self { window: Vec::with_capacity(window_size), window_size }
|
||||
}
|
||||
fn find_longest_match(&self, data: &[u8], pos: usize) -> (usize, usize) {
|
||||
let mut best_offset = 0; let mut best_length = 0;
|
||||
let start = pos.saturating_sub(self.window_size);
|
||||
for offset in start..pos {
|
||||
let mut length = 0;
|
||||
while pos + length < data.len()
|
||||
&& data[offset + length] == data[pos + length]
|
||||
&& length < 258
|
||||
{ length += 1; }
|
||||
if length > best_length { best_offset = pos - offset; best_length = length; }
|
||||
}
|
||||
(best_offset, best_length)
|
||||
}
|
||||
fn compress(&mut self, data: &[u8]) -> Vec<u8> {
|
||||
let mut output = Vec::new();
|
||||
let mut pos = 0;
|
||||
while pos < data.len() {
|
||||
let (offset, length) = self.find_longest_match(data, pos);
|
||||
if length >= 3 {
|
||||
output.push(1); output.extend_from_slice(&(offset as u16).to_le_bytes());
|
||||
output.push(length as u8); pos += length;
|
||||
} else { output.push(0); output.push(data[pos]); pos += 1; }
|
||||
}
|
||||
output
|
||||
}
|
||||
}
|
||||
"#,
|
||||
];
|
||||
|
||||
struct FileState {
|
||||
name: String,
|
||||
token: String,
|
||||
#[allow(dead_code)]
|
||||
is_base: bool,
|
||||
/// Epoch second when this file was last written (used to detect same-second
|
||||
/// re-edits that wouldn't bump mtime and thus wouldn't invalidate the mmap).
|
||||
last_write_sec: u64,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzz_file_operations_stress() {
|
||||
const SEED: u64 = 0xDEAD_BEEF_CAFE_1234;
|
||||
const INITIAL_FILE_COUNT: usize = 40;
|
||||
const NUM_ROUNDS: usize = 20;
|
||||
|
||||
let mut rng = SmallRng::seed_from_u64(SEED);
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path();
|
||||
|
||||
// Timing accumulators.
|
||||
let mut t_sleep = Duration::ZERO;
|
||||
let mut t_git = Duration::ZERO;
|
||||
let mut t_bigram_wait = Duration::ZERO;
|
||||
let mut t_grep_plain = Duration::ZERO;
|
||||
let mut t_grep_regex = Duration::ZERO;
|
||||
let mut t_fuzzy = Duration::ZERO;
|
||||
let mut t_dead_check = Duration::ZERO;
|
||||
let grep_plain_calls = std::sync::atomic::AtomicUsize::new(0);
|
||||
let grep_regex_calls = std::sync::atomic::AtomicUsize::new(0);
|
||||
let fuzzy_calls = std::sync::atomic::AtomicUsize::new(0);
|
||||
let dead_calls = std::sync::atomic::AtomicUsize::new(0);
|
||||
let test_start = std::time::Instant::now();
|
||||
|
||||
let mut live_files: Vec<FileState> = Vec::with_capacity(INITIAL_FILE_COUNT + NUM_ROUNDS);
|
||||
let mut dead_tokens: Vec<String> = Vec::new();
|
||||
let mut next_file_id: usize = 0;
|
||||
|
||||
for i in 0..INITIAL_FILE_COUNT {
|
||||
let name = format!("seed_{i:04}.rs");
|
||||
let token = format!("FUZZ_SEED_{i:04}");
|
||||
write_diverse_file(base, &name, &token, i);
|
||||
live_files.push(FileState {
|
||||
name,
|
||||
token,
|
||||
is_base: true,
|
||||
last_write_sec: 0, // set before index build, doesn't matter
|
||||
});
|
||||
next_file_id += 1;
|
||||
}
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
git_init_and_commit(base);
|
||||
t_git += t0.elapsed();
|
||||
|
||||
let shared_picker = SharedFilePicker::default();
|
||||
|
||||
FilePicker::new_with_shared_state(
|
||||
shared_picker.clone(),
|
||||
SharedFrecency::noop(),
|
||||
FilePickerOptions {
|
||||
watch: false, // we do not need the backgrodun monitor
|
||||
base_path: base.to_string_lossy().to_string(),
|
||||
enable_mmap_cache: true,
|
||||
enable_content_indexing: true,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("Failed to create FilePicker");
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
wait_for_bigram(&shared_picker);
|
||||
t_bigram_wait += t0.elapsed();
|
||||
|
||||
// Sanity: all initial tokens findable via plain grep.
|
||||
{
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
for fs in &live_files {
|
||||
assert!(
|
||||
grep_plain_count(picker, &fs.token) >= 1,
|
||||
"initial sanity: plain grep should find token {} in {}",
|
||||
fs.token,
|
||||
fs.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Sleep so mtime advances past the scan snapshot timestamp.
|
||||
let t0 = std::time::Instant::now();
|
||||
std::thread::sleep(Duration::from_millis(1100));
|
||||
t_sleep += t0.elapsed();
|
||||
|
||||
let mut op_counter: usize = 0;
|
||||
|
||||
for round in 0..NUM_ROUNDS {
|
||||
let roll: u32 = rng.next_u32() % 100;
|
||||
|
||||
if roll < 40 && !live_files.is_empty() {
|
||||
// ── EDIT existing file (40%) ──
|
||||
let idx = rng.next_u32() as usize % live_files.len();
|
||||
|
||||
// on_create_or_modify uses mtime (seconds granularity) to decide
|
||||
// whether to invalidate the mmap cache. If we re-edit a file in
|
||||
// the same second it was last written, the mtime won't change and
|
||||
// the stale cached content will be returned. Sleep to advance mtime.
|
||||
let now_sec = epoch_secs();
|
||||
if live_files[idx].last_write_sec >= now_sec {
|
||||
let t0 = std::time::Instant::now();
|
||||
std::thread::sleep(Duration::from_millis(1100));
|
||||
t_sleep += t0.elapsed();
|
||||
}
|
||||
|
||||
let old_token = live_files[idx].token.clone();
|
||||
let new_token = format!("FUZZ_{round:02}_{op_counter:04}");
|
||||
let name = &live_files[idx].name;
|
||||
let domain_idx = rng.next_u32() as usize % DOMAINS.len();
|
||||
write_diverse_file_with_domain(base, name, &new_token, domain_idx);
|
||||
|
||||
{
|
||||
let mut guard = shared_picker.write().unwrap();
|
||||
let picker = guard.as_mut().unwrap();
|
||||
assert!(
|
||||
picker.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, ®ex_pattern);
|
||||
t_grep_regex += t0.elapsed();
|
||||
grep_regex_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
assert!(
|
||||
regex_count >= 1,
|
||||
"round {round}: regex grep '{}' should find live token {} in {} (got {regex_count})",
|
||||
regex_pattern,
|
||||
fs.token,
|
||||
fs.name
|
||||
);
|
||||
|
||||
// Fuzzy file search: every live file must be findable by name.
|
||||
let stem = extract_stem(&fs.name);
|
||||
let t0 = std::time::Instant::now();
|
||||
let fuzzy_results = fuzzy_search_paths(picker, &stem);
|
||||
t_fuzzy += t0.elapsed();
|
||||
fuzzy_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
assert!(
|
||||
fuzzy_results.iter().any(|p| p.contains(&fs.name)),
|
||||
"round {round}: fuzzy search '{}' should find file {} in results: {:?}",
|
||||
stem,
|
||||
fs.name,
|
||||
fuzzy_results
|
||||
);
|
||||
}
|
||||
|
||||
// Dead tokens must return 0 grep results.
|
||||
for dead in &dead_tokens {
|
||||
let t0 = std::time::Instant::now();
|
||||
let count = grep_plain_count(picker, dead);
|
||||
t_dead_check += t0.elapsed();
|
||||
dead_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
assert_eq!(
|
||||
count, 0,
|
||||
"round {round}: dead token {dead} should NOT be findable (got {count})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let total = test_start.elapsed();
|
||||
let t_overhead = t_sleep + t_bigram_wait + t_git;
|
||||
let t_search = t_grep_plain + t_grep_regex + t_fuzzy + t_dead_check;
|
||||
let t_mutations = total.saturating_sub(t_overhead + t_search);
|
||||
let n_grep_plain = grep_plain_calls.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let n_grep_regex = grep_regex_calls.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let n_fuzzy = fuzzy_calls.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let n_dead = dead_calls.load(std::sync::atomic::Ordering::Relaxed);
|
||||
eprintln!("\n╔══════════════════════════════════════════════════════╗");
|
||||
eprintln!("║ Fuzz Test Performance Breakdown ║");
|
||||
eprintln!("╠══════════════════════════════════════════════════════╣");
|
||||
eprintln!(
|
||||
"║ Total wall time: {:>8.1}ms ║",
|
||||
total.as_secs_f64() * 1000.0
|
||||
);
|
||||
eprintln!("║ ── Overhead ─────────────────────────────────────── ║");
|
||||
eprintln!(
|
||||
"║ Sleep (mtime waits): {:>8.1}ms ║",
|
||||
t_sleep.as_secs_f64() * 1000.0
|
||||
);
|
||||
eprintln!(
|
||||
"║ Git init+commit: {:>8.1}ms ║",
|
||||
t_git.as_secs_f64() * 1000.0
|
||||
);
|
||||
eprintln!(
|
||||
"║ Bigram index build+scan: {:>8.1}ms ║",
|
||||
t_bigram_wait.as_secs_f64() * 1000.0
|
||||
);
|
||||
eprintln!(
|
||||
"║ ── Search ({:>3} live files, {:>3} dead tokens) ────── ║",
|
||||
live_files.len(),
|
||||
dead_tokens.len()
|
||||
);
|
||||
eprintln!(
|
||||
"║ Plain grep: {:>4} calls {:>8.1}ms ({:>6.1}µs/call) ║",
|
||||
n_grep_plain,
|
||||
t_grep_plain.as_secs_f64() * 1000.0,
|
||||
t_grep_plain.as_secs_f64() * 1_000_000.0 / n_grep_plain.max(1) as f64
|
||||
);
|
||||
eprintln!(
|
||||
"║ Regex grep: {:>4} calls {:>8.1}ms ({:>6.1}µs/call) ║",
|
||||
n_grep_regex,
|
||||
t_grep_regex.as_secs_f64() * 1000.0,
|
||||
t_grep_regex.as_secs_f64() * 1_000_000.0 / n_grep_regex.max(1) as f64
|
||||
);
|
||||
eprintln!(
|
||||
"║ Fuzzy find: {:>4} calls {:>8.1}ms ({:>6.1}µs/call) ║",
|
||||
n_fuzzy,
|
||||
t_fuzzy.as_secs_f64() * 1000.0,
|
||||
t_fuzzy.as_secs_f64() * 1_000_000.0 / n_fuzzy.max(1) as f64
|
||||
);
|
||||
eprintln!(
|
||||
"║ Dead checks: {:>4} calls {:>8.1}ms ({:>6.1}µs/call) ║",
|
||||
n_dead,
|
||||
t_dead_check.as_secs_f64() * 1000.0,
|
||||
t_dead_check.as_secs_f64() * 1_000_000.0 / n_dead.max(1) as f64
|
||||
);
|
||||
eprintln!("║ ── Other ────────────────────────────────────────── ║");
|
||||
eprintln!(
|
||||
"║ Mutations + FS I/O: {:>8.1}ms ║",
|
||||
t_mutations.as_secs_f64() * 1000.0
|
||||
);
|
||||
eprintln!("╚══════════════════════════════════════════════════════╝");
|
||||
}
|
||||
|
||||
fn write_diverse_file(dir: &Path, name: &str, token: &str, index: usize) {
|
||||
let domain_idx = index % DOMAINS.len();
|
||||
write_diverse_file_with_domain(dir, name, token, domain_idx);
|
||||
}
|
||||
|
||||
fn write_diverse_file_with_domain(dir: &Path, name: &str, token: &str, domain_idx: usize) {
|
||||
let domain = DOMAINS[domain_idx % DOMAINS.len()];
|
||||
let content = format!(
|
||||
"// File: {name}\n\
|
||||
// Domain content for bigram diversity\n\
|
||||
{domain}\n\
|
||||
// === Unique searchable token below ===\n\
|
||||
const MARKER: &str = \"{token}\";\n\
|
||||
fn marker_function_{token}() {{ println!(\"{token}\"); }}\n"
|
||||
);
|
||||
|
||||
if let Some(parent) = PathBuf::from(name).parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
fs::create_dir_all(dir.join(parent)).unwrap();
|
||||
}
|
||||
}
|
||||
fs::write(dir.join(name), content).unwrap();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Search helpers
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
fn grep_plain_opts() -> GrepSearchOptions {
|
||||
GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: 200,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: 500,
|
||||
mode: GrepMode::PlainText,
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
abort_signal: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn grep_regex_opts() -> GrepSearchOptions {
|
||||
GrepSearchOptions {
|
||||
mode: GrepMode::Regex,
|
||||
..grep_plain_opts()
|
||||
}
|
||||
}
|
||||
|
||||
fn grep_plain_count(picker: &FilePicker, query: &str) -> usize {
|
||||
let parsed = parse_grep_query(query);
|
||||
picker.grep(&parsed, &grep_plain_opts()).matches.len()
|
||||
}
|
||||
|
||||
fn grep_regex_count(picker: &FilePicker, regex_query: &str) -> usize {
|
||||
let parsed = parse_grep_query(regex_query);
|
||||
picker.grep(&parsed, &grep_regex_opts()).matches.len()
|
||||
}
|
||||
|
||||
/// Build a regex pattern from a token: `{first5}.*{last5}`.
|
||||
/// For tokens shorter than 10 chars, just use the literal (escaped).
|
||||
fn build_regex_pattern(token: &str) -> String {
|
||||
if token.len() >= 10 {
|
||||
let first5 = &token[..5];
|
||||
let last5 = &token[token.len() - 5..];
|
||||
format!("{}.*{}", regex_escape(first5), regex_escape(last5))
|
||||
} else {
|
||||
regex_escape(token)
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape regex metacharacters in a string.
|
||||
fn regex_escape(s: &str) -> String {
|
||||
let mut escaped = String::with_capacity(s.len() + 4);
|
||||
for ch in s.chars() {
|
||||
match ch {
|
||||
'.' | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '\\' | '^' | '$' | '|' => {
|
||||
escaped.push('\\');
|
||||
escaped.push(ch);
|
||||
}
|
||||
_ => escaped.push(ch),
|
||||
}
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
/// Extract a fuzzy-searchable stem from a filename.
|
||||
/// Strips the extension and any leading path components, keeping the bare name.
|
||||
fn extract_stem(name: &str) -> String {
|
||||
let p = PathBuf::from(name);
|
||||
p.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn fuzzy_search_paths(picker: &FilePicker, query: &str) -> Vec<String> {
|
||||
let parser = QueryParser::default();
|
||||
let parsed = parser.parse(query);
|
||||
let result = picker.fuzzy_search(
|
||||
&parsed,
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 1,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
result
|
||||
.items
|
||||
.iter()
|
||||
.map(|f| f.relative_path(picker))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn wait_for_bigram(shared_picker: &SharedFilePicker) {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let ready = shared_picker
|
||||
.read()
|
||||
.ok()
|
||||
.map(|guard| {
|
||||
guard
|
||||
.as_ref()
|
||||
.map_or(false, |p| !p.is_scan_active() && p.bigram_index().is_some())
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if ready {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"Timed out waiting for bigram build"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn git_run(dir: &Path, args: &[&str]) {
|
||||
let out = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.env("GIT_AUTHOR_NAME", "test")
|
||||
.env("GIT_AUTHOR_EMAIL", "test@test.com")
|
||||
.env("GIT_COMMITTER_NAME", "test")
|
||||
.env("GIT_COMMITTER_EMAIL", "test@test.com")
|
||||
.output()
|
||||
.unwrap_or_else(|e| panic!("git {:?} failed: {}", args, e));
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {:?} failed: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn epoch_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn git_init_and_commit(dir: &Path) {
|
||||
git_run(dir, &["init"]);
|
||||
git_run(dir, &["add", "-A"]);
|
||||
git_run(dir, &["commit", "-m", "initial"]);
|
||||
}
|
||||
|
||||
/// 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"
|
||||
);
|
||||
}
|
||||
|
||||
// At least some rounds must have caught the post-scan active window
|
||||
assert!(
|
||||
caught_active > 0,
|
||||
"Test didn't catch post_scan_indexing_active=true in any round. \
|
||||
The test is not exercising the race. ({caught_active}/10)"
|
||||
);
|
||||
eprintln!("Caught post-scan active in {caught_active}/10 rounds");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,740 @@
|
||||
//! 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
|
||||
//!
|
||||
//! Run:
|
||||
//! ```sh
|
||||
//! RUSTFLAGS="--cfg stress" cargo test -p fff-search --test fuzz_real_repos -- --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! Increase coverage:
|
||||
//! ```sh
|
||||
//! FFF_FUZZ_CASES=4 FFF_FUZZ_MAX_OPS=60 \
|
||||
//! RUSTFLAGS="--cfg stress" cargo test -p fff-search --test fuzz_real_repos -- --nocapture
|
||||
//! ```
|
||||
#![cfg(stress)]
|
||||
|
||||
use std::fs;
|
||||
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);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Search helpers
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
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 {
|
||||
/// Create a new file with a unique marker
|
||||
CreateFile { seed: u32 },
|
||||
/// Edit a tracked file, replacing the marker line with a new marker
|
||||
EditTracked { seed: u32 },
|
||||
/// Edit a random repo file, injecting a marker at a deterministic line
|
||||
EditRandom { seed: u32 },
|
||||
/// Delete a tracked file
|
||||
DeleteTracked,
|
||||
/// Revert a tracked edit, restoring the original line (marker disappears)
|
||||
RevertTracked,
|
||||
/// Burst of writes into ignored directory
|
||||
IgnoredBurst { count: u8, seed: u32 },
|
||||
/// Search verification round (no mutation)
|
||||
Verify,
|
||||
}
|
||||
|
||||
fn op_strategy() -> impl Strategy<Value = Op> {
|
||||
prop_oneof![
|
||||
// Create new files — exercises overflow path
|
||||
12 => any::<u32>().prop_map(|s| Op::CreateFile { seed: s }),
|
||||
// Edit tracked files — exercises content invalidation
|
||||
18 => any::<u32>().prop_map(|s| Op::EditTracked { seed: s }),
|
||||
// Edit random repo files — exercises bigram overlay for base files
|
||||
18 => any::<u32>().prop_map(|s| Op::EditRandom { seed: s }),
|
||||
// Delete tracked files — exercises tombstoning
|
||||
8 => Just(Op::DeleteTracked),
|
||||
// Revert tracked edits — marker must disappear from search
|
||||
10 => Just(Op::RevertTracked),
|
||||
// Burst ignored writes — exercises .gitignore filtering under load
|
||||
9 => (1u8..20, any::<u32>()).prop_map(|(c, s)| Op::IgnoredBurst { count: c, seed: s }),
|
||||
// Explicit verification rounds
|
||||
25 => Just(Op::Verify),
|
||||
]
|
||||
}
|
||||
|
||||
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
@@ -0,0 +1,455 @@
|
||||
//! Reproduces the deadlock/hang caused by LMDB writer mutex contention.
|
||||
//!
|
||||
//! When another process holds the LMDB writer mutex (via a long-running write
|
||||
//! transaction or because it crashed without releasing it), any call to
|
||||
//! `write_txn()` blocks indefinitely — including on the neovim main thread
|
||||
//! during `QueryTracker::open()` or frecency `track_access()`.
|
||||
//!
|
||||
//! In production this manifests as neovim hanging on startup:
|
||||
//! require('fff.core').ensure_initialized()
|
||||
//! → init_db() → QueryTracker::open() → write_txn() → HANGS
|
||||
//!
|
||||
//! Or during normal use when BufEnter fires:
|
||||
//! track_access → frecency.track_access() → write_txn() → HANGS
|
||||
//!
|
||||
//! Reproduction: fork a child process that holds the LMDB write lock
|
||||
//! indefinitely, then attempt to use the same database from the parent.
|
||||
//! The parent's `write_txn()` blocks on the cross-process writer mutex.
|
||||
//!
|
||||
//! This test confirms that the current code has NO timeout or fallback when the
|
||||
//! LMDB writer mutex is unavailable — making it vulnerable to indefinite hangs
|
||||
//! whenever another process (fff-mcp, another neovim, or a crashed instance)
|
||||
//! holds or has stuck the mutex.
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use fff_search::frecency::FrecencyTracker;
|
||||
use fff_search::query_tracker::QueryTracker;
|
||||
|
||||
/// Returns whether `f` completes within `timeout`.
|
||||
fn completes_within(
|
||||
label: &'static str,
|
||||
timeout: Duration,
|
||||
f: impl FnOnce() + Send + 'static,
|
||||
) -> bool {
|
||||
let (tx, rx) = mpsc::channel::<()>();
|
||||
let _worker = std::thread::Builder::new()
|
||||
.name(format!("deadlock-repro-{label}"))
|
||||
.spawn(move || {
|
||||
f();
|
||||
let _ = tx.send(());
|
||||
})
|
||||
.expect("spawn worker");
|
||||
|
||||
rx.recv_timeout(timeout).is_ok()
|
||||
}
|
||||
|
||||
/// Fork a child that opens the LMDB env and holds a write transaction
|
||||
/// indefinitely (simulating a stuck/long-running process). Returns the
|
||||
/// child PID so the parent can kill it during cleanup.
|
||||
fn fork_child_holding_write_lock(db_path: &Path) -> libc::pid_t {
|
||||
let db_path_str = db_path.to_str().unwrap().to_owned();
|
||||
|
||||
let mut pipe_fds: [libc::c_int; 2] = [0; 2];
|
||||
assert_eq!(unsafe { libc::pipe(pipe_fds.as_mut_ptr()) }, 0);
|
||||
let read_fd = pipe_fds[0];
|
||||
let write_fd = pipe_fds[1];
|
||||
|
||||
let child_pid = unsafe { libc::fork() };
|
||||
match child_pid {
|
||||
-1 => panic!("fork() failed: {}", std::io::Error::last_os_error()),
|
||||
0 => {
|
||||
// === CHILD PROCESS ===
|
||||
unsafe { libc::close(read_fd) };
|
||||
|
||||
let env = unsafe {
|
||||
let mut opts = heed::EnvOpenOptions::new();
|
||||
opts.map_size(10 * 1024 * 1024);
|
||||
opts.open(Path::new(&db_path_str)).expect("child: open env")
|
||||
};
|
||||
|
||||
// Acquire the cross-process writer mutex via write_txn
|
||||
let _wtxn = env.write_txn().expect("child: write_txn");
|
||||
|
||||
// Signal parent that the lock is held
|
||||
unsafe { libc::write(write_fd, b"R".as_ptr() as *const libc::c_void, 1) };
|
||||
|
||||
// Hold the lock forever — parent will eventually kill us
|
||||
loop {
|
||||
unsafe { libc::pause() };
|
||||
}
|
||||
}
|
||||
pid => {
|
||||
// === PARENT PROCESS ===
|
||||
unsafe { libc::close(write_fd) };
|
||||
|
||||
// Wait for child to confirm it holds the write lock
|
||||
let mut buf = [0u8; 1];
|
||||
let n = unsafe { libc::read(read_fd, buf.as_mut_ptr() as *mut libc::c_void, 1) };
|
||||
assert_eq!(n, 1, "child didn't signal readiness");
|
||||
assert_eq!(buf[0], b'R');
|
||||
unsafe { libc::close(read_fd) };
|
||||
|
||||
pid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill and reap the child process.
|
||||
fn kill_child(pid: libc::pid_t) {
|
||||
unsafe {
|
||||
libc::kill(pid, libc::SIGKILL);
|
||||
let mut status: libc::c_int = 0;
|
||||
libc::waitpid(pid, &mut status, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify QueryTracker works correctly after close+reopen — the
|
||||
/// open_database_safe path must find existing named databases via read txn.
|
||||
#[test]
|
||||
fn lmdb_reopen_finds_existing_databases() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let db_path = tmp.path().join("lmdb_reopen");
|
||||
fs::create_dir_all(&db_path).unwrap();
|
||||
|
||||
// First open: creates the databases via write_txn fallback
|
||||
{
|
||||
let mut tracker = QueryTracker::open(&db_path).unwrap();
|
||||
let project = Path::new("/test/project");
|
||||
let file = Path::new("/test/project/src/main.rs");
|
||||
tracker
|
||||
.track_query_completion("hello", project, file)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Second open: must find existing databases via read txn (no write_txn needed)
|
||||
{
|
||||
let tracker = QueryTracker::open(&db_path).unwrap();
|
||||
let project = Path::new("/test/project");
|
||||
let result = tracker.get_historical_query(project, 0).unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
Some("hello".to_string()),
|
||||
"Query history should persist across close/reopen"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Env var the test binary checks on startup. When set, the binary skips the
|
||||
/// test harness and runs as a child worker instead. This avoids fork() in a
|
||||
/// multi-threaded parent — which copies mutex/allocator state from threads
|
||||
/// that no longer exist in the child and can deadlock heed/libc.
|
||||
const CHILD_MODE_ENV: &str = "FFF_PARALLEL_OPEN_CLOSE_CHILD";
|
||||
|
||||
/// Runs before the test harness when `CHILD_MODE_ENV` is set. Re-exec of
|
||||
/// the test binary lets us start child workers without forking from a
|
||||
/// multi-threaded parent.
|
||||
#[ctor::ctor]
|
||||
fn maybe_enter_child_mode() {
|
||||
if let Ok(spec) = std::env::var(CHILD_MODE_ENV) {
|
||||
let code = run_child_from_spec(&spec);
|
||||
std::process::exit(code);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spec format: `db_path|idx|iterations|writer(0|1)`
|
||||
fn run_child_from_spec(spec: &str) -> i32 {
|
||||
let parts: Vec<&str> = spec.split('|').collect();
|
||||
if parts.len() != 4 {
|
||||
return CHILD_BAD_SPEC;
|
||||
}
|
||||
let db_path = parts[0];
|
||||
let idx: usize = match parts[1].parse() {
|
||||
Ok(v) => v,
|
||||
Err(_) => return CHILD_BAD_SPEC,
|
||||
};
|
||||
let iterations: usize = match parts[2].parse() {
|
||||
Ok(v) => v,
|
||||
Err(_) => return CHILD_BAD_SPEC,
|
||||
};
|
||||
let is_writer = parts[3] == "1";
|
||||
child_open_close_loop(db_path, iterations, idx, is_writer)
|
||||
}
|
||||
|
||||
const CHILD_OK: i32 = 0;
|
||||
const CHILD_OPEN_FAILED: i32 = 10;
|
||||
const CHILD_READ_FAILED: i32 = 11;
|
||||
const CHILD_WRITE_FAILED: i32 = 12;
|
||||
const CHILD_BAD_SPEC: i32 = 13;
|
||||
|
||||
fn child_open_close_loop(db_path: &str, iterations: usize, idx: usize, is_writer: bool) -> i32 {
|
||||
let project = Path::new("/test/project");
|
||||
for i in 0..iterations {
|
||||
let tracker = match QueryTracker::open(Path::new(db_path)) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
eprintln!("child {idx} iter {i} reader open failed: {e:?}");
|
||||
return CHILD_OPEN_FAILED;
|
||||
}
|
||||
};
|
||||
if let Err(e) = tracker.get_historical_query(project, 0) {
|
||||
eprintln!("child {idx} iter {i} read failed: {e:?}");
|
||||
return CHILD_READ_FAILED;
|
||||
}
|
||||
drop(tracker);
|
||||
|
||||
if is_writer {
|
||||
let mut tracker = match QueryTracker::open(Path::new(db_path)) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
eprintln!("child {idx} iter {i} writer open failed: {e:?}");
|
||||
return CHILD_OPEN_FAILED;
|
||||
}
|
||||
};
|
||||
let file = PathBuf::from(format!("/test/project/c{idx}_{i}.rs"));
|
||||
if let Err(e) = tracker.track_query_completion(&format!("q{idx}_{i}"), project, &file) {
|
||||
eprintln!("child {idx} iter {i} write failed: {e:?}");
|
||||
return CHILD_WRITE_FAILED;
|
||||
}
|
||||
drop(tracker);
|
||||
}
|
||||
}
|
||||
CHILD_OK
|
||||
}
|
||||
|
||||
/// Spawn `n` child processes via `Command::new(current_exe)`. No fork, so
|
||||
/// mutex/allocator state is not inherited. `writers` children also issue
|
||||
/// writes; the rest only read.
|
||||
fn spawn_open_close_children(
|
||||
db_path: &Path,
|
||||
n: usize,
|
||||
writers: usize,
|
||||
ops_per_child: usize,
|
||||
) -> Vec<std::process::Child> {
|
||||
assert!(writers <= n);
|
||||
let exe = std::env::current_exe().expect("current_exe");
|
||||
let db_path_str = db_path.to_str().unwrap().to_owned();
|
||||
|
||||
(0..n)
|
||||
.map(|idx| {
|
||||
let is_writer = idx < writers;
|
||||
let spec = format!(
|
||||
"{db_path_str}|{idx}|{ops_per_child}|{}",
|
||||
if is_writer { 1 } else { 0 }
|
||||
);
|
||||
std::process::Command::new(&exe)
|
||||
.env(CHILD_MODE_ENV, spec)
|
||||
.env_remove("RUST_LOG")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.spawn()
|
||||
.expect("spawn child")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Wait for every child with a per-call deadline. On timeout, kill and reap
|
||||
/// remaining children and return an Err describing the stuck set.
|
||||
fn wait_all_with_deadline(
|
||||
mut children: Vec<std::process::Child>,
|
||||
deadline: std::time::Instant,
|
||||
) -> Result<(), String> {
|
||||
let mut failures: Vec<(u32, Option<i32>)> = Vec::new();
|
||||
let mut remaining: Vec<std::process::Child> = Vec::new();
|
||||
|
||||
for mut child in children.drain(..) {
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let code = status.code();
|
||||
if code != Some(CHILD_OK) {
|
||||
failures.push((child.id(), code));
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(None) => {
|
||||
if std::time::Instant::now() >= deadline {
|
||||
remaining.push(child);
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
Err(e) => {
|
||||
failures.push((child.id(), None));
|
||||
let _ = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !remaining.is_empty() {
|
||||
let stuck: Vec<u32> = remaining.iter().map(|c| c.id()).collect();
|
||||
for child in &mut remaining {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
return Err(format!(
|
||||
"deadline exceeded; children still running: {stuck:?}"
|
||||
));
|
||||
}
|
||||
|
||||
if !failures.is_empty() {
|
||||
return Err(format!("children failed: {failures:?}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Many processes open/close `QueryTracker` against the same DB path.
|
||||
/// Readers only: seeds once, then spawns N reader children.
|
||||
///
|
||||
/// heed 0.22 forbids opening the same env twice *within* one process
|
||||
/// (EnvAlreadyOpened), so cross-process contention is the right axis.
|
||||
#[test]
|
||||
fn query_tracker_many_parallel_open_close_same_path_readers() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let db_path = tmp.path().join("parallel_open_close_readers");
|
||||
fs::create_dir_all(&db_path).unwrap();
|
||||
|
||||
{
|
||||
let mut tracker = QueryTracker::open(&db_path).unwrap();
|
||||
tracker
|
||||
.track_query_completion(
|
||||
"seed",
|
||||
Path::new("/test/project"),
|
||||
Path::new("/test/project/src/main.rs"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
const N: usize = 8;
|
||||
const OPS: usize = 4;
|
||||
|
||||
let children = spawn_open_close_children(&db_path, N, 0, OPS);
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
||||
wait_all_with_deadline(children, deadline).expect("parallel open/close (readers)");
|
||||
|
||||
let tracker = QueryTracker::open(&db_path).expect("post-storm reopen");
|
||||
let project = Path::new("/test/project");
|
||||
let result = tracker.get_historical_query(project, 0).unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
Some("seed".to_string()),
|
||||
"Seed query should still be readable after parallel open/close storm"
|
||||
);
|
||||
}
|
||||
|
||||
/// Stronger variant: multiple processes race opens that both read AND write.
|
||||
/// LMDB serializes writers via a cross-process mutex; test that serialization
|
||||
/// makes forward progress and open/close pairs don't deadlock.
|
||||
#[test]
|
||||
fn query_tracker_parallel_open_write_close_same_path() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let db_path = tmp.path().join("parallel_open_write_close");
|
||||
fs::create_dir_all(&db_path).unwrap();
|
||||
|
||||
{
|
||||
let mut tracker = QueryTracker::open(&db_path).unwrap();
|
||||
tracker
|
||||
.track_query_completion(
|
||||
"seed",
|
||||
Path::new("/test/project"),
|
||||
Path::new("/test/project/src/main.rs"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
const N: usize = 4;
|
||||
const WRITERS: usize = 4;
|
||||
const OPS: usize = 3;
|
||||
|
||||
let children = spawn_open_close_children(&db_path, N, WRITERS, OPS);
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
||||
wait_all_with_deadline(children, deadline).expect("parallel open/write/close");
|
||||
|
||||
let tracker = QueryTracker::open(&db_path).expect("post-storm reopen");
|
||||
let project = Path::new("/test/project");
|
||||
let seed = tracker.get_historical_query(project, 0).unwrap();
|
||||
assert!(
|
||||
seed.is_some(),
|
||||
"Env unreadable after parallel open/write/close storm"
|
||||
);
|
||||
}
|
||||
|
||||
/// Within a single process, opening the same env path twice concurrently is
|
||||
/// forbidden by heed — but a strict sequential open→use→drop→open loop must
|
||||
/// succeed every iteration. Regression guard for the reopen path.
|
||||
#[test]
|
||||
fn query_tracker_sequential_reopen_loop_same_path() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let db_path = tmp.path().join("sequential_reopen_loop");
|
||||
fs::create_dir_all(&db_path).unwrap();
|
||||
|
||||
{
|
||||
let mut tracker = QueryTracker::open(&db_path).unwrap();
|
||||
tracker
|
||||
.track_query_completion(
|
||||
"seed",
|
||||
Path::new("/test/project"),
|
||||
Path::new("/test/project/src/main.rs"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
for i in 0..64 {
|
||||
let mut tracker = QueryTracker::open(&db_path).expect("sequential reopen");
|
||||
let project = Path::new("/test/project");
|
||||
let file = PathBuf::from(format!("/test/project/iter_{i}.rs"));
|
||||
tracker
|
||||
.track_query_completion(&format!("iter_{i}"), project, &file)
|
||||
.expect("sequential track");
|
||||
drop(tracker);
|
||||
}
|
||||
|
||||
let tracker = QueryTracker::open(&db_path).expect("final reopen");
|
||||
let project = Path::new("/test/project");
|
||||
assert!(tracker.get_historical_query(project, 0).unwrap().is_some());
|
||||
}
|
||||
|
||||
/// When the frecency DB doesn't exist yet, `FrecencyTracker::open()` falls
|
||||
/// through to `write_txn()` + `create_database()`. This blocks if another
|
||||
/// process holds the writer mutex. This is the first-launch path.
|
||||
///
|
||||
/// NOTE: this test is disabled because heed 0.22 appears to use a
|
||||
/// try-then-create pattern for unnamed databases that doesn't always block.
|
||||
/// The QueryTracker test above (named databases, always needs write_txn)
|
||||
/// reliably demonstrates the same underlying issue.
|
||||
#[test]
|
||||
#[ignore = "heed 0.22 unnamed db creation may not require writer mutex in all cases"]
|
||||
fn frecency_open_blocks_on_fresh_db_when_another_process_holds_write_lock() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let db_path = tmp.path().join("frecency_fresh_deadlock");
|
||||
fs::create_dir_all(&db_path).unwrap();
|
||||
|
||||
let env = unsafe {
|
||||
let mut opts = heed::EnvOpenOptions::new();
|
||||
opts.map_size(10 * 1024 * 1024);
|
||||
opts.open(&db_path).unwrap()
|
||||
};
|
||||
drop(env);
|
||||
|
||||
let child_pid = fork_child_holding_write_lock(&db_path);
|
||||
|
||||
let db_path_clone = db_path.clone();
|
||||
let completed = completes_within(
|
||||
"FrecencyTracker::open (fresh db) while writer held",
|
||||
Duration::from_secs(3),
|
||||
move || {
|
||||
let _result = FrecencyTracker::open(&db_path_clone);
|
||||
},
|
||||
);
|
||||
|
||||
kill_child(child_pid);
|
||||
|
||||
assert!(
|
||||
!completed,
|
||||
"Expected FrecencyTracker::open() on a fresh DB to block (writer mutex \
|
||||
held by another process), but it completed."
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
//! Integration test: verifying that the background watcher dynamically detects
|
||||
//! newly created directories and picks up files written inside them.
|
||||
//!
|
||||
//! This covers the NonRecursive watching behavior where:
|
||||
//! 1. The watcher starts with watches on directories discovered during the
|
||||
//! initial scan.
|
||||
//! 2. A brand-new subdirectory is created at runtime (after the scan).
|
||||
//! 3. The watcher's event handler detects the directory Create event,
|
||||
//! collects it, and sends it to the owner thread via `watch_tx`.
|
||||
//! 4. The owner thread adds a NonRecursive watch on the new directory and
|
||||
//! does a flat (non-recursive) read_dir to inject files that already
|
||||
//! exist (race-window coverage).
|
||||
//! 5. Files created *after* the watch is established are picked up via
|
||||
//! normal event delivery.
|
||||
//!
|
||||
//! The test uses the real `BackgroundWatcher` (via `watch: true`) and polls
|
||||
//! the picker until the expected files appear or a timeout expires.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
use tempfile::TempDir;
|
||||
|
||||
use fff_search::file_picker::{FFFMode, FilePicker};
|
||||
use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query};
|
||||
use fff_search::{
|
||||
FilePickerOptions, PaginationArgs, QueryParser, SharedFilePicker, SharedFrecency,
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
fn git_run(dir: &Path, args: &[&str]) {
|
||||
let out = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.env("GIT_AUTHOR_NAME", "test")
|
||||
.env("GIT_AUTHOR_EMAIL", "test@test.com")
|
||||
.env("GIT_COMMITTER_NAME", "test")
|
||||
.env("GIT_COMMITTER_EMAIL", "test@test.com")
|
||||
.output()
|
||||
.unwrap_or_else(|e| panic!("git {:?} failed: {}", args, e));
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {:?} failed: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn git_init_and_commit(dir: &Path) {
|
||||
git_run(dir, &["init", "-b", "main"]);
|
||||
git_run(dir, &["add", "-A"]);
|
||||
git_run(dir, &["commit", "-m", "initial"]);
|
||||
}
|
||||
|
||||
fn make_watched_picker(base: &Path) -> (SharedFilePicker, SharedFrecency) {
|
||||
let shared_picker = SharedFilePicker::default();
|
||||
let shared_frecency = SharedFrecency::noop();
|
||||
|
||||
FilePicker::new_with_shared_state(
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: base.to_string_lossy().to_string(),
|
||||
enable_mmap_cache: false,
|
||||
mode: FFFMode::Neovim,
|
||||
watch: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("Failed to create FilePicker");
|
||||
|
||||
(shared_picker, shared_frecency)
|
||||
}
|
||||
|
||||
/// Wait for the initial scan + watcher to be fully ready.
|
||||
fn wait_ready(shared_picker: &SharedFilePicker) {
|
||||
assert!(
|
||||
shared_picker.wait_for_scan(Duration::from_secs(10)),
|
||||
"Timed out waiting for initial scan"
|
||||
);
|
||||
assert!(
|
||||
shared_picker.wait_for_watcher(Duration::from_secs(10)),
|
||||
"Timed out waiting for watcher"
|
||||
);
|
||||
}
|
||||
|
||||
/// Poll the picker until `predicate` returns true or timeout expires.
|
||||
/// Returns the elapsed duration if successful, panics on timeout.
|
||||
fn poll_until(
|
||||
shared_picker: &SharedFilePicker,
|
||||
timeout: Duration,
|
||||
description: &str,
|
||||
predicate: impl Fn(&FilePicker) -> bool,
|
||||
) -> Duration {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
{
|
||||
let guard = shared_picker.read().unwrap();
|
||||
if let Some(ref picker) = *guard {
|
||||
if predicate(picker) {
|
||||
return start.elapsed();
|
||||
}
|
||||
}
|
||||
}
|
||||
if start.elapsed() >= timeout {
|
||||
// One final attempt to give a useful error message.
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
let file_count = picker.get_files().len();
|
||||
let paths: Vec<String> = picker
|
||||
.get_files()
|
||||
.iter()
|
||||
.map(|f| f.relative_path(picker))
|
||||
.collect();
|
||||
panic!(
|
||||
"Timed out after {:?} waiting for: {}\n\
|
||||
Current file count: {}\n\
|
||||
Current files: {:?}",
|
||||
timeout, description, file_count, paths
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
fn grep_plain_count(picker: &FilePicker, query: &str) -> usize {
|
||||
let parsed = parse_grep_query(query);
|
||||
let opts = GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: 200,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: 500,
|
||||
mode: GrepMode::PlainText,
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
abort_signal: None,
|
||||
};
|
||||
picker.grep(&parsed, &opts).matches.len()
|
||||
}
|
||||
|
||||
fn fuzzy_search_paths(picker: &FilePicker, query: &str) -> Vec<String> {
|
||||
let parser = QueryParser::default();
|
||||
let parsed = parser.parse(query);
|
||||
let result = picker.fuzzy_search(
|
||||
&parsed,
|
||||
None,
|
||||
fff_search::FuzzySearchOptions {
|
||||
max_threads: 1,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
result
|
||||
.items
|
||||
.iter()
|
||||
.map(|f| f.relative_path(picker))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Debounce timeout in the watcher is 250ms. Events need to propagate through
|
||||
/// the debouncer, the owner thread park loop (1s), and the picker write lock.
|
||||
/// We use a generous timeout for CI environments.
|
||||
const WATCHER_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Create a new directory and immediately write a file inside it.
|
||||
/// The file is written before the watch is registered, so the flat
|
||||
/// inject_existing_files scan in the owner thread must catch it.
|
||||
#[test]
|
||||
fn new_directory_and_file_detected_by_watcher() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path().canonicalize().unwrap();
|
||||
|
||||
// Seed the repo with some initial files so the scan has something.
|
||||
fs::create_dir_all(base.join("src")).unwrap();
|
||||
fs::write(
|
||||
base.join("src/main.rs"),
|
||||
"fn main() { println!(\"INITIAL_MARKER\"); }\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(base.join("README.md"), "# Test project\n").unwrap();
|
||||
|
||||
git_init_and_commit(&base);
|
||||
|
||||
let (shared_picker, _frecency) = make_watched_picker(&base);
|
||||
wait_ready(&shared_picker);
|
||||
|
||||
// Sanity: initial file is indexed.
|
||||
poll_until(
|
||||
&shared_picker,
|
||||
Duration::from_secs(5),
|
||||
"initial file src/main.rs indexed",
|
||||
|picker| {
|
||||
picker
|
||||
.get_files()
|
||||
.iter()
|
||||
.any(|f| f.relative_path(picker).contains("main.rs"))
|
||||
},
|
||||
);
|
||||
|
||||
// Create a new directory and write a file into it immediately.
|
||||
// The file exists before the watch is registered — inject_existing_files
|
||||
// in the owner thread catches it via a flat read_dir.
|
||||
let new_dir = base.join("src/components");
|
||||
fs::create_dir_all(&new_dir).unwrap();
|
||||
fs::write(
|
||||
new_dir.join("button.rs"),
|
||||
"pub struct Button;\nconst TOKEN: &str = \"NEW_DIR_BUTTON_TOKEN\";\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Wait for the watcher to detect the new directory + file.
|
||||
let elapsed = poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
"file src/components/button.rs in new directory",
|
||||
|picker| {
|
||||
picker
|
||||
.get_files()
|
||||
.iter()
|
||||
.any(|f| f.relative_path(picker).contains("button.rs"))
|
||||
},
|
||||
);
|
||||
eprintln!(
|
||||
" New directory + file detected in {:.0}ms",
|
||||
elapsed.as_secs_f64() * 1000.0
|
||||
);
|
||||
|
||||
// Also verify via grep that the content is accessible.
|
||||
poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
"grep finds NEW_DIR_BUTTON_TOKEN",
|
||||
|picker| grep_plain_count(picker, "NEW_DIR_BUTTON_TOKEN") >= 1,
|
||||
);
|
||||
|
||||
// And via fuzzy search.
|
||||
poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
"fuzzy search finds button.rs",
|
||||
|picker| {
|
||||
let results = fuzzy_search_paths(picker, "button");
|
||||
results.iter().any(|p| p.contains("button.rs"))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a new directory, then create files AFTER a delay to ensure the
|
||||
/// watch was established on the directory.
|
||||
#[test]
|
||||
fn file_created_after_directory_watch_established() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path().canonicalize().unwrap();
|
||||
|
||||
fs::create_dir_all(base.join("lib")).unwrap();
|
||||
fs::write(base.join("lib/utils.rs"), "pub fn helper() {}\n").unwrap();
|
||||
|
||||
git_init_and_commit(&base);
|
||||
|
||||
let (shared_picker, _frecency) = make_watched_picker(&base);
|
||||
wait_ready(&shared_picker);
|
||||
|
||||
// Create the directory first, wait for the watcher to register it.
|
||||
let new_dir = base.join("lib/models");
|
||||
fs::create_dir(&new_dir).unwrap();
|
||||
|
||||
// Wait long enough for the debouncer to flush + owner thread to add watch.
|
||||
std::thread::sleep(Duration::from_millis(2000));
|
||||
|
||||
// Now write a file into the already-watched directory.
|
||||
fs::write(
|
||||
new_dir.join("user.rs"),
|
||||
"pub struct User { name: String }\nconst TOKEN: &str = \"POST_WATCH_USER_TOKEN\";\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let elapsed = poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
"file lib/models/user.rs created after directory watch",
|
||||
|picker| {
|
||||
picker
|
||||
.get_files()
|
||||
.iter()
|
||||
.any(|f| f.relative_path(picker).contains("user.rs"))
|
||||
},
|
||||
);
|
||||
eprintln!(
|
||||
" Post-watch file detected in {:.0}ms",
|
||||
elapsed.as_secs_f64() * 1000.0
|
||||
);
|
||||
|
||||
// Grep sanity.
|
||||
poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
"grep finds POST_WATCH_USER_TOKEN",
|
||||
|picker| grep_plain_count(picker, "POST_WATCH_USER_TOKEN") >= 1,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a deeply nested directory tree all at once with create_dir_all
|
||||
/// and write a file at the leaf. The watcher must detect the top-level
|
||||
/// directory via the parent's watch, inject_existing_files finds the file
|
||||
/// at the leaf (and intermediate dirs get their own watches from Create
|
||||
/// events on subsequent levels).
|
||||
#[test]
|
||||
fn deeply_nested_new_directories_detected() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path().canonicalize().unwrap();
|
||||
|
||||
fs::write(base.join("root.txt"), "root file\n").unwrap();
|
||||
|
||||
git_init_and_commit(&base);
|
||||
|
||||
let (shared_picker, _frecency) = make_watched_picker(&base);
|
||||
wait_ready(&shared_picker);
|
||||
|
||||
// Create each level one at a time, waiting for each watch to register.
|
||||
// inject_existing_files is flat (non-recursive), so deeply nested dirs
|
||||
// need each parent to be watched before we can see files at the leaf.
|
||||
fs::create_dir(base.join("app")).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(2000));
|
||||
|
||||
fs::create_dir(base.join("app/services")).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(2000));
|
||||
|
||||
fs::create_dir(base.join("app/services/auth")).unwrap();
|
||||
// Write the file immediately — inject_existing_files catches it.
|
||||
fs::write(
|
||||
base.join("app/services/auth/jwt.rs"),
|
||||
"pub fn verify_token() {}\nconst TOKEN: &str = \"DEEP_NESTED_JWT_TOKEN\";\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let elapsed = poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
"deeply nested file app/services/auth/jwt.rs",
|
||||
|picker| {
|
||||
picker
|
||||
.get_files()
|
||||
.iter()
|
||||
.any(|f| f.relative_path(picker).contains("jwt.rs"))
|
||||
},
|
||||
);
|
||||
eprintln!(
|
||||
" Deeply nested file detected in {:.0}ms",
|
||||
elapsed.as_secs_f64() * 1000.0
|
||||
);
|
||||
|
||||
// Verify content is grepable.
|
||||
poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
"grep finds DEEP_NESTED_JWT_TOKEN",
|
||||
|picker| grep_plain_count(picker, "DEEP_NESTED_JWT_TOKEN") >= 1,
|
||||
);
|
||||
|
||||
// Now create a sibling at the same depth — the parent (app/services)
|
||||
// is already watched, so this just needs the flat inject.
|
||||
let sibling_dir = base.join("app/services/database");
|
||||
fs::create_dir(&sibling_dir).unwrap();
|
||||
fs::write(
|
||||
sibling_dir.join("pool.rs"),
|
||||
"pub struct ConnectionPool;\nconst TOKEN: &str = \"SIBLING_POOL_TOKEN\";\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let elapsed = poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
"sibling nested file app/services/database/pool.rs",
|
||||
|picker| {
|
||||
picker
|
||||
.get_files()
|
||||
.iter()
|
||||
.any(|f| f.relative_path(picker).contains("pool.rs"))
|
||||
},
|
||||
);
|
||||
eprintln!(
|
||||
" Sibling nested file detected in {:.0}ms",
|
||||
elapsed.as_secs_f64() * 1000.0
|
||||
);
|
||||
|
||||
poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
"grep finds SIBLING_POOL_TOKEN",
|
||||
|picker| grep_plain_count(picker, "SIBLING_POOL_TOKEN") >= 1,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a new directory and immediately burst-write multiple files.
|
||||
/// inject_existing_files catches all of them in one flat read_dir.
|
||||
#[test]
|
||||
fn burst_file_creation_in_new_directory() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path().canonicalize().unwrap();
|
||||
|
||||
fs::create_dir_all(base.join("src")).unwrap();
|
||||
fs::write(base.join("src/lib.rs"), "// lib\n").unwrap();
|
||||
|
||||
git_init_and_commit(&base);
|
||||
|
||||
let (shared_picker, _frecency) = make_watched_picker(&base);
|
||||
wait_ready(&shared_picker);
|
||||
|
||||
// Create a new directory and immediately write 5 files.
|
||||
let batch_dir = base.join("src/batch");
|
||||
fs::create_dir(&batch_dir).unwrap();
|
||||
|
||||
let file_count = 5;
|
||||
for i in 0..file_count {
|
||||
fs::write(
|
||||
batch_dir.join(format!("item_{i}.rs")),
|
||||
format!("pub struct Item{i};\nconst TOKEN: &str = \"BATCH_ITEM_{i}\";\n"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Wait for ALL files to appear.
|
||||
let elapsed = poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
&format!("all {file_count} batch files in src/batch/"),
|
||||
|picker| {
|
||||
let batch_count = picker
|
||||
.get_files()
|
||||
.iter()
|
||||
.filter(|f| {
|
||||
let p = f.relative_path(picker);
|
||||
p.starts_with("src/batch/") || p.starts_with("src\\batch\\")
|
||||
})
|
||||
.count();
|
||||
batch_count >= file_count
|
||||
},
|
||||
);
|
||||
eprintln!(
|
||||
" All {} burst files detected in {:.0}ms",
|
||||
file_count,
|
||||
elapsed.as_secs_f64() * 1000.0
|
||||
);
|
||||
|
||||
// Verify each file's content is grepable.
|
||||
for i in 0..file_count {
|
||||
let token = format!("BATCH_ITEM_{i}");
|
||||
poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
&format!("grep finds {token}"),
|
||||
|picker| grep_plain_count(picker, &token) >= 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that gitignored directories created at runtime are NOT watched
|
||||
/// and their files do NOT appear in the index.
|
||||
#[test]
|
||||
fn gitignored_new_directory_excluded() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path().canonicalize().unwrap();
|
||||
|
||||
fs::write(base.join("main.rs"), "fn main() {}\n").unwrap();
|
||||
// Ignore the build/ directory.
|
||||
fs::write(base.join(".gitignore"), "build/\n").unwrap();
|
||||
|
||||
git_init_and_commit(&base);
|
||||
|
||||
let (shared_picker, _frecency) = make_watched_picker(&base);
|
||||
wait_ready(&shared_picker);
|
||||
|
||||
// Create a gitignored directory with files.
|
||||
let ignored_dir = base.join("build");
|
||||
fs::create_dir(&ignored_dir).unwrap();
|
||||
fs::write(
|
||||
ignored_dir.join("output.rs"),
|
||||
"const TOKEN: &str = \"IGNORED_BUILD_TOKEN\";\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Also create a non-ignored directory to confirm the watcher works.
|
||||
let good_dir = base.join("src");
|
||||
fs::create_dir(&good_dir).unwrap();
|
||||
fs::write(
|
||||
good_dir.join("app.rs"),
|
||||
"const TOKEN: &str = \"GOOD_SRC_TOKEN\";\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Wait for the non-ignored file to appear (proves watcher is working).
|
||||
poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
"non-ignored file src/app.rs appears",
|
||||
|picker| {
|
||||
picker
|
||||
.get_files()
|
||||
.iter()
|
||||
.any(|f| f.relative_path(picker).contains("app.rs"))
|
||||
},
|
||||
);
|
||||
|
||||
// Give extra time for any straggler events from the ignored dir.
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
|
||||
// The gitignored file must NOT be in the index.
|
||||
{
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
|
||||
let has_ignored = picker
|
||||
.get_files()
|
||||
.iter()
|
||||
.any(|f| f.relative_path(picker).contains("output.rs"));
|
||||
assert!(
|
||||
!has_ignored,
|
||||
"Gitignored file build/output.rs should NOT be in the index"
|
||||
);
|
||||
|
||||
let grep_count = grep_plain_count(picker, "IGNORED_BUILD_TOKEN");
|
||||
assert_eq!(grep_count, 0, "Gitignored content should NOT be grepable");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
use fff_search::file_picker::{FFFMode, FilePicker};
|
||||
use fff_search::{
|
||||
FFFQuery, FilePickerOptions, FileSearchConfig, FrecencyTracker, FuzzySearchOptions,
|
||||
SharedFilePicker, SharedFrecency,
|
||||
};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// regression pinning test: if base count=0 updating frecency should not panic
|
||||
#[test]
|
||||
fn update_single_file_frecency_on_overflow_file_does_not_segfault() {
|
||||
let base = TempDir::new().expect("mktemp base");
|
||||
let db = TempDir::new().expect("mktemp db");
|
||||
|
||||
// EMPTY base tree. The initial scan indexes zero files, so `base_count == 0`
|
||||
// and the base path arena is an empty store whose pointer is the dangling,
|
||||
// 16-byte-aligned `0x10`. This is the exact state observed at the crash:
|
||||
// base_count=0 is_overflow=true base_arena=0x10 overflow_arena=0x9f5400000
|
||||
let base_path = base.path().canonicalize().expect("canonicalize base");
|
||||
|
||||
let shared_picker = SharedFilePicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
let tracker = FrecencyTracker::open(db.path().join("frecency.mdb")).expect("open frecency db");
|
||||
shared_frecency.init(tracker).expect("init frecency");
|
||||
|
||||
FilePicker::new_with_shared_state(
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: base_path.to_string_lossy().to_string(),
|
||||
enable_mmap_cache: false,
|
||||
enable_content_indexing: false,
|
||||
// AI mode is the mode that drives per-file frecency updates from the
|
||||
// watcher; we exercise the same picker method directly.
|
||||
mode: FFFMode::Ai,
|
||||
// No watcher: we add an overflow file and update frecency by hand so
|
||||
// the repro is deterministic and timing-independent.
|
||||
watch: false,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("FilePicker::new_with_shared_state");
|
||||
|
||||
assert!(
|
||||
shared_picker.wait_for_scan(Duration::from_secs(10)),
|
||||
"initial scan did not complete"
|
||||
);
|
||||
|
||||
// Create ONE new file after the (empty) scan. It is added to the overflow
|
||||
// arena (`FileItem::is_overflow()` is true), with valid overflow chunk
|
||||
// indices into the overflow arena.
|
||||
let new_file = base_path.join("created_after_scan.txt");
|
||||
fs::write(&new_file, "hello\n").unwrap();
|
||||
|
||||
{
|
||||
let mut guard = shared_picker.write().expect("picker write lock");
|
||||
let picker: &mut FilePicker = guard.as_mut().expect("picker initialized");
|
||||
picker.handle_create_or_modify(&new_file);
|
||||
}
|
||||
|
||||
// simulate watcher events
|
||||
{
|
||||
let frecency_guard = shared_frecency.read().expect("frecency read lock");
|
||||
let frecency = frecency_guard.as_ref().expect("frecency initialized");
|
||||
|
||||
let mut guard = shared_picker.write().expect("picker write lock");
|
||||
let picker: &mut FilePicker = guard.as_mut().expect("picker initialized");
|
||||
|
||||
let _ = frecency.track_access(new_file.as_path());
|
||||
let _ = picker.update_single_file_frecency(new_file.as_path(), frecency);
|
||||
}
|
||||
|
||||
// Reaching here means the overflow file was read through the correct arena.
|
||||
let _: &Path = base_path.as_path();
|
||||
|
||||
{
|
||||
let mut guard = shared_picker.write().expect("picker write lock");
|
||||
let picker: &mut FilePicker = guard.as_mut().expect("picker initialized");
|
||||
let results = picker.fuzzy_search(
|
||||
&FFFQuery::parse("created_after_scan *.txt", FileSearchConfig),
|
||||
None,
|
||||
FuzzySearchOptions::default(),
|
||||
);
|
||||
|
||||
assert_eq!(results.total_matched, 1);
|
||||
}
|
||||
|
||||
drop(shared_picker);
|
||||
drop(shared_frecency);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//! Regression test for https://github.com/dmtrKovalenko/fff/issues/381
|
||||
//!
|
||||
//! Directory (`PathSegment`) and file-path (`FilePath`) constraints must
|
||||
//! return results on every platform. Indexed paths on Windows use native
|
||||
//! backslash separators, so constraint matching has to accept either `/`
|
||||
//! or `\\` as a path boundary.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use fff_search::file_picker::FilePicker;
|
||||
use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query};
|
||||
use fff_search::{Constraint, FilePickerOptions, FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
|
||||
fn create_picker(base: &Path, specs: &[(&str, &str)]) -> FilePicker {
|
||||
for (rel, contents) in specs {
|
||||
let full_path = base.join(rel);
|
||||
if let Some(parent) = full_path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(&full_path, contents).unwrap();
|
||||
}
|
||||
let mut picker = FilePicker::new(FilePickerOptions {
|
||||
base_path: base.to_string_lossy().to_string(),
|
||||
enable_mmap_cache: false,
|
||||
watch: false,
|
||||
..Default::default()
|
||||
})
|
||||
.expect("failed to create FilePicker");
|
||||
picker.collect_files().expect("failed to collect files");
|
||||
picker
|
||||
}
|
||||
|
||||
fn plain_opts() -> GrepSearchOptions {
|
||||
GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: 200,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: 200,
|
||||
mode: GrepMode::PlainText,
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
abort_signal: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn fuzzy_search_paths(picker: &FilePicker, query: &str) -> Vec<String> {
|
||||
let parser = QueryParser::default();
|
||||
let parsed = parser.parse(query);
|
||||
let result = picker.fuzzy_search(
|
||||
&parsed,
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 1,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
result
|
||||
.items
|
||||
.iter()
|
||||
.map(|f| f.relative_path(picker))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Treat a relative path as a sequence of components regardless of the
|
||||
/// native separator so assertions are portable across Linux, macOS, Windows.
|
||||
fn has_segment(path: &str, segment: &str) -> bool {
|
||||
path.split(['/', '\\']).any(|s| s == segment)
|
||||
}
|
||||
|
||||
/// `grep handleRequest src/` — PathSegment constraint must match a nested
|
||||
/// `src` directory on every platform.
|
||||
#[test]
|
||||
fn grep_with_path_segment_constraint_nested() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let picker = create_picker(
|
||||
tmp.path(),
|
||||
&[
|
||||
("app/modules/src/services/handler.lua", "handleRequest()\n"),
|
||||
("app/modules/lib/util.lua", "handleRequest()\n"),
|
||||
("src/main.rs", "fn handleRequest() {}\n"),
|
||||
],
|
||||
);
|
||||
|
||||
let parsed = parse_grep_query("handleRequest src/");
|
||||
let result = picker.grep(&parsed, &plain_opts());
|
||||
|
||||
let matched_paths: Vec<String> = result
|
||||
.files
|
||||
.iter()
|
||||
.map(|f| f.relative_path(&picker))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
2,
|
||||
"expected matches in two src/ files, got {matched_paths:?}"
|
||||
);
|
||||
for p in &matched_paths {
|
||||
assert!(
|
||||
has_segment(p, "src"),
|
||||
"every matched file must live under a `src` segment, got {p:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `multi_grep` with a `PathSegment` constraint.
|
||||
#[test]
|
||||
fn multi_grep_with_path_segment_constraint() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let picker = create_picker(
|
||||
tmp.path(),
|
||||
&[
|
||||
(
|
||||
"app/modules/src/controller.lua",
|
||||
"handleRequest\nprocessJob\n",
|
||||
),
|
||||
("app/modules/lib/helper.lua", "handleRequest\n"),
|
||||
("app/src/legacy.lua", "processJob\n"),
|
||||
],
|
||||
);
|
||||
|
||||
let constraints = [Constraint::PathSegment("src")];
|
||||
let patterns = ["handleRequest", "processJob"];
|
||||
let result = picker.multi_grep(&patterns, &constraints, &plain_opts());
|
||||
|
||||
assert!(
|
||||
!result.matches.is_empty(),
|
||||
"multi_grep with `src/` constraint should return matches"
|
||||
);
|
||||
|
||||
let matched_paths: Vec<String> = result
|
||||
.files
|
||||
.iter()
|
||||
.map(|f| f.relative_path(&picker))
|
||||
.collect();
|
||||
for p in &matched_paths {
|
||||
assert!(
|
||||
has_segment(p, "src"),
|
||||
"every matched file must live under a `src` segment, got {p:?}"
|
||||
);
|
||||
}
|
||||
assert!(matched_paths.iter().any(|p| p.contains("controller.lua")));
|
||||
assert!(matched_paths.iter().any(|p| p.contains("legacy.lua")));
|
||||
}
|
||||
|
||||
/// Fuzzy search (`find_files src/ Controller`) must apply the path-segment
|
||||
/// filter to paths stored during indexing.
|
||||
#[test]
|
||||
fn fuzzy_search_with_path_segment_constraint() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let picker = create_picker(
|
||||
tmp.path(),
|
||||
&[
|
||||
("app/modules/src/services/BaseController.lua", "base\n"),
|
||||
("app/modules/src/services/UserController.lua", "user\n"),
|
||||
("app/modules/lib/BaseController.lua", "lib base\n"),
|
||||
("tests/src/MockController.lua", "mock\n"),
|
||||
],
|
||||
);
|
||||
|
||||
let results = fuzzy_search_paths(&picker, "src/ Controller");
|
||||
|
||||
assert!(
|
||||
!results.is_empty(),
|
||||
"fuzzy search with `src/` constraint should return results"
|
||||
);
|
||||
for p in &results {
|
||||
assert!(
|
||||
has_segment(p, "src"),
|
||||
"every result must live under `src`, got {p:?}"
|
||||
);
|
||||
}
|
||||
assert!(results.iter().any(|p| p.contains("BaseController")));
|
||||
assert!(results.iter().any(|p| p.contains("UserController")));
|
||||
assert!(results.iter().any(|p| p.contains("MockController")));
|
||||
}
|
||||
|
||||
/// `FilePath` suffix constraint must match stored paths even when components
|
||||
/// are separated by the platform-native separator during indexing.
|
||||
#[test]
|
||||
fn multi_grep_with_file_path_suffix_constraint() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let picker = create_picker(
|
||||
tmp.path(),
|
||||
&[
|
||||
("app/modules/src/services/handler.lua", "handleRequest\n"),
|
||||
("other/src/services/handler.lua", "handleRequest\n"),
|
||||
("app/modules/src/services/other.lua", "handleRequest\n"),
|
||||
],
|
||||
);
|
||||
|
||||
let constraints = [Constraint::FilePath("services/handler.lua")];
|
||||
let patterns = ["handleRequest"];
|
||||
let result = picker.multi_grep(&patterns, &constraints, &plain_opts());
|
||||
|
||||
let paths: Vec<String> = result
|
||||
.files
|
||||
.iter()
|
||||
.map(|f| f.relative_path(&picker))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
paths.len(),
|
||||
2,
|
||||
"expected two matches for services/handler.lua, got {paths:?}"
|
||||
);
|
||||
for p in &paths {
|
||||
let ends_with_services_handler =
|
||||
p.ends_with("services/handler.lua") || p.ends_with("services\\handler.lua");
|
||||
assert!(
|
||||
ends_with_services_handler,
|
||||
"matched path must end with services/handler.lua, got {p:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Glob constraints must match native Windows paths — the picker normalises
|
||||
/// separators when handing paths to the glob matcher.
|
||||
#[test]
|
||||
fn fuzzy_search_with_glob_constraint_matches_on_windows_paths() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let picker = create_picker(
|
||||
tmp.path(),
|
||||
&[
|
||||
("app/src/components/Button.lua", "\n"),
|
||||
("app/src/services/handler.lua", "\n"),
|
||||
("app/lib/components/Ignored.lua", "\n"),
|
||||
],
|
||||
);
|
||||
|
||||
let results = fuzzy_search_paths(&picker, "**/src/**/*.lua");
|
||||
assert!(
|
||||
results.iter().any(|p| p.contains("Button.lua")),
|
||||
"glob `**/src/**/*.lua` must match files below any `src/`, got {results:?}"
|
||||
);
|
||||
assert!(
|
||||
results.iter().any(|p| p.contains("handler.lua")),
|
||||
"glob `**/src/**/*.lua` must match services/handler.lua, got {results:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Real-world binary fixture regression.
|
||||
//!
|
||||
//! Reproduces the exact bug chain we hit with `codex_view` (4.5 MB ELF, no
|
||||
//! extension) and `codex_view.codex` (127 KB, unknown extension): both are
|
||||
//! binary by content but slip past extension-only triage, so a plain grep
|
||||
//! used to surface their NUL-laden bytes as "text" matches.
|
||||
//!
|
||||
//! The fixtures live in `tests/fixtures/binaries/`. `MARKER` is a string that
|
||||
//! is present (as raw bytes) in BOTH binaries — the test first asserts that,
|
||||
//! then drops the two binaries plus a single plain-text file containing the
|
||||
//! same marker into a closed temp dir and greps for it. Only the text file may
|
||||
//! come back; if binary detection ever regresses, a binary file re-enters the
|
||||
//! results and this test fails.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use fff_search::file_picker::{FFFMode, FilePicker};
|
||||
use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query};
|
||||
use fff_search::{FilePickerOptions, SharedFilePicker, SharedFrecency};
|
||||
|
||||
const MARKER: &str = "__jai_runtime_init";
|
||||
|
||||
fn fixtures_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/binaries")
|
||||
}
|
||||
|
||||
fn plain_opts() -> GrepSearchOptions {
|
||||
GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: 200,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: 200,
|
||||
mode: GrepMode::PlainText,
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
abort_signal: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_binary_fixtures_are_detected_and_excluded_from_grep() {
|
||||
let fixtures = fixtures_dir();
|
||||
let large = fixtures.join("codex_view"); // 4.5 MB ELF, no extension (> 2 MB)
|
||||
let small = fixtures.join("codex_view.codex"); // 127 KB, unknown extension (< 2 MB)
|
||||
|
||||
assert!(
|
||||
large.exists() && small.exists(),
|
||||
"missing binary fixtures in {}",
|
||||
fixtures.display()
|
||||
);
|
||||
|
||||
// Both fixtures must really contain the marker bytes, otherwise the grep
|
||||
// exclusion assertion below would be vacuous.
|
||||
let large_bytes = fs::read(&large).unwrap();
|
||||
let small_bytes = fs::read(&small).unwrap();
|
||||
assert!(
|
||||
contains_subslice(&large_bytes, MARKER.as_bytes()),
|
||||
"fixture codex_view no longer contains the marker {MARKER:?}"
|
||||
);
|
||||
assert!(
|
||||
contains_subslice(&small_bytes, MARKER.as_bytes()),
|
||||
"fixture codex_view.codex no longer contains the marker {MARKER:?}"
|
||||
);
|
||||
// Sanity on the size split that drives the two distinct code paths.
|
||||
assert!(
|
||||
large_bytes.len() > 2 * 1024 * 1024,
|
||||
"codex_view must exceed the 2 MB non-indexable threshold"
|
||||
);
|
||||
assert!(
|
||||
small_bytes.len() < 2 * 1024 * 1024,
|
||||
"codex_view.codex must stay under the 2 MB bigram cap"
|
||||
);
|
||||
|
||||
// Closed environment: the two real binaries + one plain-text file that
|
||||
// legitimately contains the marker.
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let base = tmp.path();
|
||||
fs::copy(&large, base.join("codex_view")).unwrap();
|
||||
fs::copy(&small, base.join("codex_view.codex")).unwrap();
|
||||
fs::write(
|
||||
base.join("marker.txt"),
|
||||
format!("the only legitimate hit lives here: {MARKER}\n"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let shared_picker = SharedFilePicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
FilePicker::new_with_shared_state(
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: base.to_string_lossy().to_string(),
|
||||
enable_mmap_cache: false,
|
||||
enable_content_indexing: true,
|
||||
mode: FFFMode::Neovim,
|
||||
watch: false,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("failed to create FilePicker");
|
||||
|
||||
shared_picker.wait_for_indexing_complete(Duration::from_secs(5));
|
||||
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
|
||||
// Both binaries must be classified binary.
|
||||
for name in ["codex_view", "codex_view.codex"] {
|
||||
let flagged = picker
|
||||
.get_files()
|
||||
.iter()
|
||||
.any(|f| f.relative_path(picker).ends_with(name) && f.is_binary());
|
||||
assert!(flagged, "{name} must be flagged is_binary");
|
||||
}
|
||||
|
||||
// we need to make sure that marker.txt ONLY can match as we have to match
|
||||
// grep as binaries are excluded from the matching process
|
||||
let parsed = parse_grep_query(MARKER);
|
||||
let result = picker.grep(&parsed, &plain_opts());
|
||||
|
||||
let matched: Vec<String> = result
|
||||
.files
|
||||
.iter()
|
||||
.map(|f| f.relative_path(picker))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
result.files.len(),
|
||||
1,
|
||||
"exactly one file should match {MARKER:?}, got: {matched:?}"
|
||||
);
|
||||
assert!(
|
||||
matched[0].ends_with("marker.txt"),
|
||||
"the only match must be marker.txt, got {:?}",
|
||||
matched[0]
|
||||
);
|
||||
}
|
||||
|
||||
/// Tiny substring search over raw bytes (the marker may be surrounded by NULs).
|
||||
fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
if needle.is_empty() || haystack.len() < needle.len() {
|
||||
return false;
|
||||
}
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.any(|window| window == needle)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Regression test: stopping the background watcher while the caller
|
||||
//! holds the [`SharedFilePicker`] write lock must NOT deadlock.
|
||||
//!
|
||||
//! There are two lock-ordering hazards the watcher has to handle:
|
||||
//!
|
||||
//! 1. The debouncer's event thread calls our handler, which wants
|
||||
//! `shared_picker.write()` to apply events. `stop()` used to
|
||||
//! `join()` that thread under the caller's write guard.
|
||||
//!
|
||||
//! 2. The owner thread registers new-directory watches and injects
|
||||
//! their existing files. Previously it held the debouncer mutex
|
||||
//! across `shared_picker.write()`, while `stop()` takes the
|
||||
//! debouncer mutex under the caller's write guard — inverse
|
||||
//! lock orders, classic deadlock.
|
||||
//!
|
||||
//! macOS FSEvents is the reliable reproducer for (1) because fresh
|
||||
//! `fs::write()` calls inside a just-watched temp dir queue events
|
||||
//! faster than the debounce tick can drain them. Creating new
|
||||
//! subdirectories exercises (2) via the owner thread's `watch_tx`.
|
||||
|
||||
use std::fs;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use fff_search::file_picker::{FFFMode, FilePicker};
|
||||
use fff_search::{FilePickerOptions, SharedFilePicker, SharedFrecency};
|
||||
|
||||
/// Run `f` on a worker thread, require it to finish within `timeout`,
|
||||
/// panic with `msg` otherwise. The caller gets to describe what the
|
||||
/// worker is doing so a hung test produces an actionable message.
|
||||
fn run_with_deadlock_guard(
|
||||
msg: &'static str,
|
||||
timeout: Duration,
|
||||
f: impl FnOnce() + Send + 'static,
|
||||
) {
|
||||
let (done_tx, done_rx) = mpsc::channel::<()>();
|
||||
let worker = std::thread::Builder::new()
|
||||
.name("deadlock-guard-worker".into())
|
||||
.spawn(move || {
|
||||
f();
|
||||
let _ = done_tx.send(());
|
||||
})
|
||||
.expect("spawn worker");
|
||||
|
||||
match done_rx.recv_timeout(timeout) {
|
||||
Ok(()) => {}
|
||||
Err(_) => panic!("{msg}"),
|
||||
}
|
||||
worker.join().expect("worker panicked");
|
||||
}
|
||||
|
||||
fn make_watched_picker(base: &std::path::Path) -> (SharedFilePicker, SharedFrecency) {
|
||||
let shared_picker = SharedFilePicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
FilePicker::new_with_shared_state(
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: base.to_string_lossy().to_string(),
|
||||
enable_mmap_cache: false,
|
||||
enable_content_indexing: false,
|
||||
mode: FFFMode::Neovim,
|
||||
watch: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("Failed to create FilePicker");
|
||||
|
||||
assert!(
|
||||
shared_picker.wait_for_scan(Duration::from_secs(10)),
|
||||
"initial scan never completed"
|
||||
);
|
||||
assert!(
|
||||
shared_picker.wait_for_watcher(Duration::from_secs(10)),
|
||||
"watcher never installed"
|
||||
);
|
||||
|
||||
(shared_picker, shared_frecency)
|
||||
}
|
||||
|
||||
/// Hazard (1): debouncer event handler is waiting on `shared_picker.write()`
|
||||
/// while the caller joins it from under the same guard.
|
||||
#[test]
|
||||
fn stop_background_monitor_under_write_lock_does_not_deadlock_file_events() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path().to_path_buf();
|
||||
|
||||
for i in 0..4 {
|
||||
fs::write(base.join(format!("file_{i}.txt")), format!("seed {i}\n")).unwrap();
|
||||
}
|
||||
|
||||
let (shared_picker, _shared_frecency) = make_watched_picker(&base);
|
||||
|
||||
// Produce enough filesystem churn that the debouncer has events
|
||||
// queued and is likely mid-handler by the time we call stop.
|
||||
for round in 0..8 {
|
||||
for i in 0..4 {
|
||||
let path = base.join(format!("file_{i}.txt"));
|
||||
fs::write(&path, format!("edit {round}-{i}\n")).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// Give the kernel time to deliver events into the debouncer queue
|
||||
// (50 ms = default debouncer tick).
|
||||
std::thread::sleep(Duration::from_millis(60));
|
||||
|
||||
let sp = shared_picker.clone();
|
||||
run_with_deadlock_guard(
|
||||
"stop_background_monitor() deadlocked under shared_picker.write() — \
|
||||
the debouncer thread is likely waiting on the same write lock \
|
||||
while we join it",
|
||||
Duration::from_secs(5),
|
||||
move || {
|
||||
let mut guard = sp.write().expect("write lock");
|
||||
if let Some(ref mut picker) = *guard {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Hazard (2): owner thread holds the debouncer mutex while waiting
|
||||
/// on `shared_picker.write()`, and `stop()` takes the debouncer mutex
|
||||
/// under the caller's write guard.
|
||||
#[test]
|
||||
fn stop_background_monitor_under_write_lock_does_not_deadlock_new_dirs() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path().to_path_buf();
|
||||
|
||||
fs::write(base.join("seed.txt"), "seed\n").unwrap();
|
||||
|
||||
let (shared_picker, _shared_frecency) = make_watched_picker(&base);
|
||||
|
||||
// Create a burst of new subdirectories with files inside. On Linux
|
||||
// the watcher event thread sends each new dir to `watch_tx`, and
|
||||
// the owner thread processes them (taking the debouncer mutex +
|
||||
// `shared_picker.write()`). On macOS the owner thread still runs
|
||||
// `track_files_from_new_directories`, which takes the write lock.
|
||||
for d in 0..8 {
|
||||
let sub = base.join(format!("sub_{d}"));
|
||||
fs::create_dir(&sub).unwrap();
|
||||
for f in 0..4 {
|
||||
fs::write(sub.join(format!("f_{f}.txt")), format!("{d}-{f}\n")).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(120));
|
||||
|
||||
let sp = shared_picker.clone();
|
||||
run_with_deadlock_guard(
|
||||
"stop_background_monitor() deadlocked under shared_picker.write() — \
|
||||
the watcher owner thread is likely holding the debouncer mutex and \
|
||||
waiting on the same write lock while we try to take the debouncer \
|
||||
mutex to tear it down",
|
||||
Duration::from_secs(5),
|
||||
move || {
|
||||
let mut guard = sp.write().expect("write lock");
|
||||
if let Some(ref mut picker) = *guard {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use fff_search::file_picker::{FFFMode, FilePicker};
|
||||
use fff_search::{FilePickerOptions, SharedFilePicker, SharedFrecency};
|
||||
|
||||
/// Thread comm names Linux exposes via `/proc/self/task/*/comm` are
|
||||
/// capped at `TASK_COMM_LEN - 1 = 15` bytes. Our owner thread is named
|
||||
/// `"fff-watcher-owner"` (17 bytes), so what actually appears in
|
||||
/// `/proc` is the 15-byte truncation below.
|
||||
const WATCHER_OWNER_THREAD_NAME: &str = "fff-watcher-own";
|
||||
|
||||
/// Walk `/proc/self/task/*/comm` and return how many live threads
|
||||
/// carry `name` as their `comm`.
|
||||
fn count_live_threads_named(name: &str) -> usize {
|
||||
let Ok(dir) = fs::read_dir("/proc/self/task") else {
|
||||
return 0;
|
||||
};
|
||||
let mut count = 0usize;
|
||||
for entry in dir.flatten() {
|
||||
let comm_path = entry.path().join("comm");
|
||||
if let Ok(content) = fs::read_to_string(&comm_path) {
|
||||
if content.trim_end() == name {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Poll until the thread count matches `expected` or we hit `timeout`.
|
||||
fn wait_for_thread_count(name: &str, expected: usize, timeout: Duration) -> usize {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let count = count_live_threads_named(name);
|
||||
if count == expected {
|
||||
return count;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return count;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_repo(base: &std::path::Path) {
|
||||
fs::create_dir_all(base.join("src")).unwrap();
|
||||
fs::write(base.join("README.md"), "# seed\n").unwrap();
|
||||
fs::write(base.join("src/main.rs"), "fn main() {}\n").unwrap();
|
||||
fs::write(base.join("src/lib.rs"), "// lib\n").unwrap();
|
||||
|
||||
let _ = std::process::Command::new("git")
|
||||
.args(["init", "-q", "-b", "main"])
|
||||
.current_dir(base)
|
||||
.output();
|
||||
}
|
||||
|
||||
fn spawn_watched_picker(base: PathBuf) -> (SharedFilePicker, SharedFrecency) {
|
||||
let shared_picker = SharedFilePicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
FilePicker::new_with_shared_state(
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: base.to_string_lossy().to_string(),
|
||||
enable_mmap_cache: false,
|
||||
enable_content_indexing: false,
|
||||
mode: FFFMode::Neovim,
|
||||
watch: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("FilePicker::new_with_shared_state");
|
||||
|
||||
assert!(
|
||||
shared_picker.wait_for_scan(Duration::from_secs(10)),
|
||||
"initial scan did not complete"
|
||||
);
|
||||
assert!(
|
||||
shared_picker.wait_for_watcher(Duration::from_secs(10)),
|
||||
"watcher did not install"
|
||||
);
|
||||
|
||||
(shared_picker, shared_frecency)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watcher_threads_do_not_leak_across_picker_lifetimes() {
|
||||
// this is needed because I run this within neovim with it's own fff owner thread lmao
|
||||
let baseline = count_live_threads_named(WATCHER_OWNER_THREAD_NAME);
|
||||
|
||||
const PICKER_COUNT: usize = 4;
|
||||
|
||||
let mut tmpdirs: Vec<TempDir> = (0..PICKER_COUNT)
|
||||
.map(|_| TempDir::new().expect("mktemp"))
|
||||
.collect();
|
||||
for td in &tmpdirs {
|
||||
seed_repo(td.path());
|
||||
}
|
||||
|
||||
let mut pickers: Vec<(SharedFilePicker, SharedFrecency)> = tmpdirs
|
||||
.iter()
|
||||
.map(|td| spawn_watched_picker(td.path().canonicalize().expect("canonicalize tmp")))
|
||||
.collect();
|
||||
|
||||
let peak = wait_for_thread_count(
|
||||
WATCHER_OWNER_THREAD_NAME,
|
||||
baseline + PICKER_COUNT,
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
assert_eq!(
|
||||
peak,
|
||||
baseline + PICKER_COUNT,
|
||||
"expected {} watcher-owner threads alive (baseline {} + {} pickers), saw {}",
|
||||
baseline + PICKER_COUNT,
|
||||
baseline,
|
||||
PICKER_COUNT,
|
||||
peak,
|
||||
);
|
||||
|
||||
for i in 0..PICKER_COUNT {
|
||||
let expected_remaining = baseline + PICKER_COUNT - (i + 1);
|
||||
let (sp, sf) = pickers.remove(0);
|
||||
drop(sp);
|
||||
drop(sf);
|
||||
let count = wait_for_thread_count(
|
||||
WATCHER_OWNER_THREAD_NAME,
|
||||
expected_remaining,
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
assert_eq!(
|
||||
count,
|
||||
expected_remaining,
|
||||
"after dropping picker {}/{}: expected {} owner threads, saw {}",
|
||||
i + 1,
|
||||
PICKER_COUNT,
|
||||
expected_remaining,
|
||||
count,
|
||||
);
|
||||
}
|
||||
tmpdirs.clear();
|
||||
|
||||
let after_stage1 = count_live_threads_named(WATCHER_OWNER_THREAD_NAME);
|
||||
assert_eq!(
|
||||
after_stage1, baseline,
|
||||
"stage 1 leaked watcher-owner threads: baseline {}, observed {}",
|
||||
baseline, after_stage1,
|
||||
);
|
||||
|
||||
const ROUNDS: usize = 3;
|
||||
|
||||
for round in 0..ROUNDS {
|
||||
let tmp = TempDir::new().expect("mktemp");
|
||||
seed_repo(tmp.path());
|
||||
let base = tmp.path().canonicalize().expect("canonicalize tmp");
|
||||
|
||||
let (sp, sf) = spawn_watched_picker(base);
|
||||
|
||||
let during = wait_for_thread_count(
|
||||
WATCHER_OWNER_THREAD_NAME,
|
||||
baseline + 1,
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
assert_eq!(
|
||||
during,
|
||||
baseline + 1,
|
||||
"round {round}: expected 1 owner thread during run, saw {during} \
|
||||
(baseline {baseline})",
|
||||
);
|
||||
|
||||
drop(sp);
|
||||
drop(sf);
|
||||
drop(tmp);
|
||||
|
||||
let after =
|
||||
wait_for_thread_count(WATCHER_OWNER_THREAD_NAME, baseline, Duration::from_secs(5));
|
||||
assert_eq!(
|
||||
after, baseline,
|
||||
"round {round}: owner thread leaked after teardown \
|
||||
(baseline {baseline}, observed {after})",
|
||||
);
|
||||
}
|
||||
|
||||
let final_count = count_live_threads_named(WATCHER_OWNER_THREAD_NAME);
|
||||
assert_eq!(
|
||||
final_count, baseline,
|
||||
"watcher-owner threads leaked past the end of the test \
|
||||
(baseline {}, final {})",
|
||||
baseline, final_count,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "fff-grep"
|
||||
description = "File grepping logic for fff"
|
||||
license = "MIT"
|
||||
authors = ["Dmitriy Kovalenko <dmtr.kovalenko@outlok.com>"]
|
||||
version = "0.9.4"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
bstr = { version = "1.6.2", default-features = false, features = ["std"] }
|
||||
memchr = "2.6.3"
|
||||
@@ -8,10 +8,12 @@ Only `search_slice` is supported -- no file/reader/mmap search.
|
||||
#![deny(missing_docs)]
|
||||
|
||||
pub use crate::{
|
||||
matcher::{LineTerminator, Match, Matcher, NoError},
|
||||
searcher::{Searcher, SearcherBuilder},
|
||||
sink::{Sink, SinkError, SinkFinish, SinkMatch},
|
||||
};
|
||||
|
||||
pub mod lines;
|
||||
pub mod matcher;
|
||||
mod searcher;
|
||||
mod sink;
|
||||
@@ -2,10 +2,9 @@
|
||||
A collection of routines for performing operations on lines.
|
||||
*/
|
||||
|
||||
use {
|
||||
bstr::ByteSlice,
|
||||
grep_matcher::{LineTerminator, Match},
|
||||
};
|
||||
use bstr::ByteSlice;
|
||||
|
||||
use crate::matcher::{LineTerminator, Match};
|
||||
|
||||
/// An explicit iterator over lines in a particular slice of bytes.
|
||||
///
|
||||
@@ -69,8 +68,7 @@ pub fn count(bytes: &[u8], line_term: u8) -> u64 {
|
||||
memchr::memchr_iter(line_term, bytes).count() as u64
|
||||
}
|
||||
|
||||
/// Given a line that possibly ends with a terminator, return that line without
|
||||
/// the terminator.
|
||||
/// Given a line that possibly ends with a terminator, return that line without the terminator.
|
||||
#[inline(always)]
|
||||
pub fn without_terminator(bytes: &[u8], line_term: LineTerminator) -> &[u8] {
|
||||
let line_term = line_term.as_bytes();
|
||||
@@ -104,7 +102,7 @@ pub fn locate(bytes: &[u8], line_term: u8, range: Match) -> Match {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SHERLOCK: &'static str = "\
|
||||
const SHERLOCK: &str = "\
|
||||
For the Doctor Watsons of this world, as opposed to the Sherlock
|
||||
Holmeses, success in the province of detective work must always
|
||||
be, to a very large extent, the result of luck. Sherlock Holmes
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Matcher trait inspired by ripgrep's `Matcher` just simpler
|
||||
|
||||
/// A byte range representing a match.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct Match {
|
||||
start: usize,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
impl Match {
|
||||
/// Create a new match from start/end byte offsets.
|
||||
#[inline]
|
||||
pub fn new(start: usize, end: usize) -> Match {
|
||||
debug_assert!(start <= end);
|
||||
Match { start, end }
|
||||
}
|
||||
|
||||
/// Create a zero-width match at `offset`.
|
||||
#[inline]
|
||||
pub fn zero(offset: usize) -> Match {
|
||||
Match {
|
||||
start: offset,
|
||||
end: offset,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start byte offset.
|
||||
#[inline]
|
||||
pub fn start(&self) -> usize {
|
||||
self.start
|
||||
}
|
||||
|
||||
/// End byte offset (exclusive).
|
||||
#[inline]
|
||||
pub fn end(&self) -> usize {
|
||||
self.end
|
||||
}
|
||||
|
||||
/// Return a copy with a different end offset.
|
||||
#[inline]
|
||||
pub fn with_end(&self, end: usize) -> Match {
|
||||
debug_assert!(self.start <= end);
|
||||
Match { end, ..*self }
|
||||
}
|
||||
|
||||
/// Shift both offsets forward by `amount`.
|
||||
#[inline]
|
||||
pub fn offset(&self, amount: usize) -> Match {
|
||||
Match {
|
||||
start: self.start + amount,
|
||||
end: self.end + amount,
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte length of the match.
|
||||
#[inline]
|
||||
pub fn len(&self) -> usize {
|
||||
self.end - self.start
|
||||
}
|
||||
|
||||
/// True if this is a zero-width match.
|
||||
#[inline]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Index<Match> for [u8] {
|
||||
type Output = [u8];
|
||||
|
||||
#[inline]
|
||||
fn index(&self, index: Match) -> &[u8] {
|
||||
&self[index.start..index.end]
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::IndexMut<Match> for [u8] {
|
||||
#[inline]
|
||||
fn index_mut(&mut self, index: Match) -> &mut [u8] {
|
||||
&mut self[index.start..index.end]
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Index<Match> for str {
|
||||
type Output = str;
|
||||
|
||||
#[inline]
|
||||
fn index(&self, index: Match) -> &str {
|
||||
&self[index.start..index.end]
|
||||
}
|
||||
}
|
||||
|
||||
/// A line terminator (always a single byte for fff — no CRLF support needed).
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct LineTerminator(u8);
|
||||
|
||||
impl LineTerminator {
|
||||
/// Create a line terminator from a single byte.
|
||||
#[inline]
|
||||
pub fn byte(byte: u8) -> LineTerminator {
|
||||
LineTerminator(byte)
|
||||
}
|
||||
|
||||
/// Return the terminator byte.
|
||||
#[inline]
|
||||
pub fn as_byte(&self) -> u8 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Return the terminator as a single-element byte slice.
|
||||
#[inline]
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
std::slice::from_ref(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LineTerminator {
|
||||
#[inline]
|
||||
fn default() -> LineTerminator {
|
||||
LineTerminator(b'\n')
|
||||
}
|
||||
}
|
||||
|
||||
/// An error type for matchers that never produce errors.
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub struct NoError(());
|
||||
|
||||
impl std::error::Error for NoError {}
|
||||
|
||||
impl std::fmt::Display for NoError {
|
||||
fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
unreachable!("NoError should never be instantiated")
|
||||
}
|
||||
}
|
||||
|
||||
/// A matcher finds byte-level matches in a haystack.
|
||||
pub trait Matcher {
|
||||
/// The error type (use [`NoError`] for infallible matchers).
|
||||
type Error: std::fmt::Display;
|
||||
|
||||
/// Find the first match at or after `at` in `haystack`.
|
||||
fn find_at(&self, haystack: &[u8], at: usize) -> Result<Option<Match>, Self::Error>;
|
||||
|
||||
/// Find the first match in `haystack`.
|
||||
#[inline]
|
||||
fn find(&self, haystack: &[u8]) -> Result<Option<Match>, Self::Error> {
|
||||
self.find_at(haystack, 0)
|
||||
}
|
||||
|
||||
/// The line terminator this matcher guarantees will never appear in a match.
|
||||
/// Return `None` if the matcher can match across lines.
|
||||
#[inline]
|
||||
fn line_terminator(&self) -> Option<LineTerminator> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: Matcher> Matcher for &M {
|
||||
type Error = M::Error;
|
||||
|
||||
#[inline]
|
||||
fn find_at(&self, haystack: &[u8], at: usize) -> Result<Option<Match>, Self::Error> {
|
||||
(*self).find_at(haystack, at)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn find(&self, haystack: &[u8]) -> Result<Option<Match>, Self::Error> {
|
||||
(*self).find(haystack)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn line_terminator(&self) -> Option<LineTerminator> {
|
||||
(*self).line_terminator()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
use crate::{
|
||||
lines,
|
||||
matcher::Matcher,
|
||||
searcher::{Config, Range, Searcher},
|
||||
sink::{Sink, SinkError, SinkFinish, SinkMatch},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Core<'s, M: 's, S> {
|
||||
config: &'s Config,
|
||||
matcher: M,
|
||||
searcher: &'s Searcher,
|
||||
sink: S,
|
||||
pos: usize,
|
||||
absolute_byte_offset: u64,
|
||||
line_number: Option<u64>,
|
||||
last_line_counted: usize,
|
||||
last_line_visited: usize,
|
||||
}
|
||||
|
||||
impl<'s, M: Matcher, S: Sink> Core<'s, M, S> {
|
||||
pub(crate) fn new(searcher: &'s Searcher, matcher: M, sink: S) -> Core<'s, M, S> {
|
||||
let line_number = if searcher.config.line_number {
|
||||
Some(1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Core {
|
||||
config: &searcher.config,
|
||||
matcher,
|
||||
searcher,
|
||||
sink,
|
||||
pos: 0,
|
||||
absolute_byte_offset: 0,
|
||||
line_number,
|
||||
last_line_counted: 0,
|
||||
last_line_visited: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pos(&self) -> usize {
|
||||
self.pos
|
||||
}
|
||||
|
||||
pub(crate) fn set_pos(&mut self, pos: usize) {
|
||||
self.pos = pos;
|
||||
}
|
||||
|
||||
pub(crate) fn matched(&mut self, buf: &[u8], range: &Range) -> Result<bool, S::Error> {
|
||||
self.sink_matched(buf, range)
|
||||
}
|
||||
|
||||
pub(crate) fn find(&mut self, slice: &[u8]) -> Result<Option<Range>, S::Error> {
|
||||
self.matcher.find(slice).map_err(S::Error::error_message)
|
||||
}
|
||||
|
||||
pub(crate) fn begin(&mut self) -> Result<bool, S::Error> {
|
||||
self.sink.begin(self.searcher)
|
||||
}
|
||||
|
||||
pub(crate) fn finish(&mut self, byte_count: u64) -> Result<(), S::Error> {
|
||||
self.sink.finish(self.searcher, &SinkFinish { byte_count })
|
||||
}
|
||||
|
||||
pub(crate) fn match_by_line(&mut self, buf: &[u8]) -> Result<bool, S::Error> {
|
||||
while !buf[self.pos()..].is_empty() {
|
||||
if let Some(line) = self.find_by_line(buf)? {
|
||||
self.set_pos(line.end());
|
||||
if !self.sink_matched(buf, &line)? {
|
||||
return Ok(false);
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.set_pos(buf.len());
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn find_by_line(&mut self, buf: &[u8]) -> Result<Option<Range>, S::Error> {
|
||||
let mut pos = self.pos();
|
||||
while !buf[pos..].is_empty() {
|
||||
let mat = match self
|
||||
.matcher
|
||||
.find(&buf[pos..])
|
||||
.map_err(S::Error::error_message)?
|
||||
{
|
||||
None => return Ok(None),
|
||||
Some(m) => m,
|
||||
};
|
||||
let line = lines::locate(
|
||||
buf,
|
||||
self.config.line_term.as_byte(),
|
||||
Range::zero(mat.start()).offset(pos),
|
||||
);
|
||||
if line.start() == buf.len() {
|
||||
pos = buf.len();
|
||||
continue;
|
||||
}
|
||||
return Ok(Some(line));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn sink_matched(&mut self, buf: &[u8], range: &Range) -> Result<bool, S::Error> {
|
||||
self.count_lines(buf, range.start());
|
||||
let offset = self.absolute_byte_offset + range.start() as u64;
|
||||
let linebuf = &buf[*range];
|
||||
let keepgoing = self.sink.matched(
|
||||
self.searcher,
|
||||
&SinkMatch {
|
||||
bytes: linebuf,
|
||||
absolute_byte_offset: offset,
|
||||
line_number: self.line_number,
|
||||
buffer: buf,
|
||||
bytes_range_in_buffer: range.start()..range.end(),
|
||||
},
|
||||
)?;
|
||||
if !keepgoing {
|
||||
return Ok(false);
|
||||
}
|
||||
self.last_line_visited = range.end();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn count_lines(&mut self, buf: &[u8], upto: usize) {
|
||||
if let Some(ref mut line_number) = self.line_number {
|
||||
if self.last_line_counted >= upto {
|
||||
return;
|
||||
}
|
||||
let slice = &buf[self.last_line_counted..upto];
|
||||
let count = lines::count(slice, self.config.line_term.as_byte());
|
||||
*line_number += count;
|
||||
self.last_line_counted = upto;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
use grep_matcher::Matcher;
|
||||
|
||||
use crate::{
|
||||
lines,
|
||||
matcher::Matcher,
|
||||
searcher::{Config, Range, Searcher, core::Core},
|
||||
sink::Sink,
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
use grep_matcher::{LineTerminator, Match, Matcher};
|
||||
|
||||
use crate::{
|
||||
matcher::{LineTerminator, Match, Matcher},
|
||||
searcher::glue::{MultiLine, SliceByLine},
|
||||
sink::{Sink, SinkError},
|
||||
};
|
||||
@@ -190,11 +189,6 @@ impl Searcher {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(non_matching) = matcher.non_matching_bytes()
|
||||
&& non_matching.contains(self.line_terminator().as_byte())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "fff-mcp"
|
||||
version = "0.1.0"
|
||||
version = "0.9.4"
|
||||
edition = "2024"
|
||||
description = "MCP server for FFF file finder - drop-in replacement for AI code assistant search tools"
|
||||
license = "MIT"
|
||||
@@ -11,17 +11,17 @@ path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = ["zlob"]
|
||||
zlob = ["fff-core/zlob"]
|
||||
zlob = ["fff/zlob"]
|
||||
|
||||
[dependencies]
|
||||
fff-core = { path = "../fff-core", default-features = false }
|
||||
fff-query-parser = { path = "../fff-query-parser", default-features = false }
|
||||
fff = { package = "fff-search", path = "../fff-core", default-features = false , version = "0.9.4" }
|
||||
fff-query-parser = { path = "../fff-query-parser", default-features = false , version = "0.9.4" }
|
||||
mimalloc = { workspace = true }
|
||||
rmcp = { version = "1.1.0", features = ["server", "transport-io"] }
|
||||
rmcp = { version = "1.7.0", features = ["server", "transport-io"] }
|
||||
schemars = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tracing = { workspace = true }
|
||||
git2 = { workspace = true }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
use crate::Args;
|
||||
use git2::Repository;
|
||||
|
||||
fn check(label: &str, ok: bool, detail: &str) -> bool {
|
||||
let marker = if ok { "+" } else { "x" };
|
||||
println!(" [{marker}] {label}: {detail}");
|
||||
ok
|
||||
}
|
||||
|
||||
fn warn(label: &str, detail: &str) {
|
||||
println!(" [!] {label}: {detail}");
|
||||
}
|
||||
|
||||
pub fn run_healthcheck(args: &Args) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("FFF_GIT_HASH"), ")");
|
||||
println!("fff-mcp {version}\n");
|
||||
|
||||
let mut all_ok = true;
|
||||
|
||||
// 1. Base path
|
||||
let base_path = args.base_path.clone().unwrap_or_else(|| {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
let path_exists = std::path::Path::new(&base_path).is_dir();
|
||||
all_ok &= check(
|
||||
"Base path",
|
||||
path_exists,
|
||||
if path_exists {
|
||||
&base_path
|
||||
} else {
|
||||
"directory does not exist"
|
||||
},
|
||||
);
|
||||
|
||||
// 2. Git repository
|
||||
match Repository::discover(&base_path) {
|
||||
Ok(repo) => {
|
||||
if let Some(workdir) = repo.workdir() {
|
||||
all_ok &= check("Git repository", true, &format!("{}", workdir.display()));
|
||||
} else {
|
||||
all_ok &= check("Git repository", true, "bare repository");
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Not fatal — fff-mcp works without git, but worth flagging.
|
||||
warn(
|
||||
"Git repository",
|
||||
"not found (fff-mcp will still work, but git-status features are disabled)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Frecency database
|
||||
if let Some(ref db_path) = args.frecency_db_path {
|
||||
let parent_ok = std::path::Path::new(db_path)
|
||||
.parent()
|
||||
.is_some_and(|p| p.is_dir());
|
||||
all_ok &= check(
|
||||
"Frecency DB",
|
||||
parent_ok,
|
||||
if parent_ok {
|
||||
db_path
|
||||
} else {
|
||||
"parent directory does not exist"
|
||||
},
|
||||
);
|
||||
} else {
|
||||
check("Frecency DB", false, "path not resolved");
|
||||
}
|
||||
|
||||
// 4. Query history database
|
||||
if let Some(ref db_path) = args.history_db_path {
|
||||
let parent_ok = std::path::Path::new(db_path)
|
||||
.parent()
|
||||
.is_some_and(|p| p.is_dir());
|
||||
all_ok &= check(
|
||||
"History DB",
|
||||
parent_ok,
|
||||
if parent_ok {
|
||||
db_path
|
||||
} else {
|
||||
"parent directory does not exist"
|
||||
},
|
||||
);
|
||||
} else {
|
||||
check("History DB", false, "path not resolved");
|
||||
}
|
||||
|
||||
// 5. Log path hint (per-session files written next to this path)
|
||||
if let Some(ref log_path) = args.log_file {
|
||||
let parent_ok = std::path::Path::new(log_path)
|
||||
.parent()
|
||||
.is_some_and(|p| p.is_dir() || p.parent().is_some());
|
||||
all_ok &= check(
|
||||
"Log path",
|
||||
parent_ok,
|
||||
if parent_ok {
|
||||
log_path
|
||||
} else {
|
||||
"parent directory does not exist"
|
||||
},
|
||||
);
|
||||
} else {
|
||||
check("Log path", false, "path not resolved");
|
||||
}
|
||||
|
||||
if all_ok {
|
||||
println!("All checks passed.");
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Some checks failed — review the items marked [x] above.".into())
|
||||
}
|
||||
}
|
||||
+103
-75
@@ -1,22 +1,13 @@
|
||||
//! FFF MCP Server — high-performance file finder for AI code assistants.
|
||||
//!
|
||||
//! Drop-in replacement for AI code assistant file search tools (Glob/Grep).
|
||||
//! Provides frecency-ranked, fuzzy-matched, git-aware file finding and
|
||||
//! code search via the Model Context Protocol (MCP).
|
||||
//!
|
||||
//! Uses `fff-core` directly (zero FFI overhead) for all search operations.
|
||||
|
||||
mod cursor;
|
||||
mod healthcheck;
|
||||
mod output;
|
||||
mod server;
|
||||
mod update_check;
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use clap::Parser;
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::frecency::FrecencyTracker;
|
||||
use fff_core::{FFFMode, SharedFrecency, SharedPicker};
|
||||
use fff::file_picker::FilePicker;
|
||||
use fff::frecency::FrecencyTracker;
|
||||
use fff::{FFFMode, SharedFilePicker, SharedFrecency};
|
||||
use git2::Repository;
|
||||
use mimalloc::MiMalloc;
|
||||
use rmcp::{ServiceExt, transport::stdio};
|
||||
@@ -101,10 +92,10 @@ pub const MCP_INSTRUCTIONS: &str = concat!(
|
||||
" !generated/ - exclude generated code",
|
||||
);
|
||||
|
||||
/// FFF MCP Server — high-performance file finder for AI code assistants.
|
||||
/// FFF MCP Server -- a high performance & accuracy file finder for AI code assistants.
|
||||
#[derive(Parser)]
|
||||
#[command(name = "fff-mcp", version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("FFF_GIT_HASH"), ")"))]
|
||||
struct Args {
|
||||
pub(crate) struct Args {
|
||||
/// Base directory to index. Defaults to the current working directory.
|
||||
#[arg(value_name = "PATH")]
|
||||
base_path: Option<String>,
|
||||
@@ -118,7 +109,8 @@ struct Args {
|
||||
#[allow(dead_code)]
|
||||
history_db_path: Option<String>,
|
||||
|
||||
/// Path to the log file.
|
||||
/// Path-shape hint for per-session log files.
|
||||
/// Each fff-mcp startup writes a fresh sibling file `<stem>+<UTC-timestamp>+<pid>.<ext>`
|
||||
#[arg(long = "log-file")]
|
||||
log_file: Option<String>,
|
||||
|
||||
@@ -129,45 +121,43 @@ struct Args {
|
||||
/// Disable automatic update checks on startup.
|
||||
#[arg(long = "no-update-check")]
|
||||
no_update_check: bool,
|
||||
|
||||
/// Disable eager mmap warmup after the initial scan. Grep results will
|
||||
/// still work (files are mmap'd lazily on first access), but the first
|
||||
/// search may be slightly slower. Useful on very large repos where the
|
||||
/// warmup would consume too many kernel resources.
|
||||
#[arg(long = "no-warmup")]
|
||||
no_warmup: bool,
|
||||
|
||||
/// Disable the content index built after the initial scan.
|
||||
/// This makes grep calls slower but consumes less RAM (recommended to not turn off)
|
||||
no_content_indexing: bool,
|
||||
|
||||
/// Explicitly enable content indexing even when `--no-warmup` is set.
|
||||
#[arg(long = "content-indexing")]
|
||||
content_indexing: bool,
|
||||
|
||||
/// Disable the background file-system watcher. Files are scanned once
|
||||
/// at startup but not monitored for changes.
|
||||
#[arg(long = "no-watch")]
|
||||
no_watch: bool,
|
||||
|
||||
/// Maximum number of files whose content is kept persistently in memory.
|
||||
/// Files beyond this limit are still searchable via temporary mmaps that
|
||||
/// are released after each grep. Defaults to 30 000.
|
||||
/// Also settable via the FFF_MAX_CACHED_FILES environment variable.
|
||||
#[arg(long = "max-cached-files", env = "FFF_MAX_CACHED_FILES")]
|
||||
max_cached_files: Option<usize>,
|
||||
|
||||
/// Run a health check and print diagnostic information, then exit.
|
||||
#[arg(long = "healthcheck")]
|
||||
pub(crate) healthcheck: bool,
|
||||
}
|
||||
|
||||
/// Resolve default paths for frecency db, history db, and log file.
|
||||
/// Shares Neovim's standard data locations when they exist so the MCP
|
||||
/// server and fff.nvim plugin use the same databases.
|
||||
/// Resolve default paths for the log file.
|
||||
/// Database paths (frecency, history) must be explicitly provided via flags.
|
||||
fn resolve_defaults(args: &mut Args) {
|
||||
let home = dirs_home();
|
||||
let is_windows = cfg!(target_os = "windows");
|
||||
|
||||
let nvim_cache_dir = if is_windows {
|
||||
format!("{}\\AppData\\Local\\nvim-data", home)
|
||||
} else {
|
||||
format!("{}/.cache/nvim", home)
|
||||
};
|
||||
let nvim_data_dir = if is_windows {
|
||||
format!("{}\\AppData\\Local\\nvim-data", home)
|
||||
} else {
|
||||
format!("{}/.local/share/nvim", home)
|
||||
};
|
||||
|
||||
let use_nvim_paths = std::path::Path::new(&nvim_cache_dir).exists()
|
||||
|| std::path::Path::new(&nvim_data_dir).exists();
|
||||
|
||||
if args.frecency_db_path.is_none() {
|
||||
args.frecency_db_path = Some(if use_nvim_paths {
|
||||
format!("{}/fff_nvim", nvim_cache_dir)
|
||||
} else {
|
||||
format!("{}/.fff/frecency.mdb", home)
|
||||
});
|
||||
}
|
||||
if args.history_db_path.is_none() {
|
||||
args.history_db_path = Some(if use_nvim_paths {
|
||||
format!("{}/fff_queries", nvim_data_dir)
|
||||
} else {
|
||||
format!("{}/.fff/history.mdb", home)
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure parent directories exist for database paths
|
||||
// Ensure parent directories exist for database paths when provided
|
||||
for path in [&args.frecency_db_path, &args.history_db_path]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -178,6 +168,8 @@ fn resolve_defaults(args: &mut Args) {
|
||||
}
|
||||
|
||||
if args.log_file.is_none() {
|
||||
let home = dirs_home();
|
||||
let is_windows = cfg!(target_os = "windows");
|
||||
args.log_file = Some(if is_windows {
|
||||
format!("{}\\AppData\\Local\\fff_mcp.log", home)
|
||||
} else {
|
||||
@@ -197,8 +189,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut args = Args::parse();
|
||||
resolve_defaults(&mut args);
|
||||
|
||||
if args.healthcheck {
|
||||
return healthcheck::run_healthcheck(&args);
|
||||
}
|
||||
|
||||
let log_file = args.log_file.as_deref().unwrap_or("");
|
||||
if let Err(e) = fff_core::log::init_tracing(log_file, args.log_level.as_deref()) {
|
||||
if let Err(e) = fff::log::init_tracing(log_file, args.log_level.as_deref(), None) {
|
||||
eprintln!("Warning: Failed to init tracing: {}", e);
|
||||
}
|
||||
|
||||
@@ -209,34 +205,66 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.to_string()
|
||||
});
|
||||
|
||||
if Repository::discover(&base_path).is_err() {
|
||||
tracing::error!("MCP server must be run within a Git repository");
|
||||
return Err(format!("Not a Git repository: {}", base_path).into());
|
||||
}
|
||||
|
||||
let frecency_db_path = args.frecency_db_path.unwrap_or_default();
|
||||
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
match FrecencyTracker::new(&frecency_db_path, false) {
|
||||
Ok(tracker) => {
|
||||
if let Ok(mut guard) = shared_frecency.write() {
|
||||
*guard = Some(tracker);
|
||||
let base_path = match Repository::discover(&base_path) {
|
||||
Ok(repo) => {
|
||||
if let Some(workdir) = repo.workdir() {
|
||||
let git_root = workdir.to_string_lossy().to_string();
|
||||
tracing::info!("Discovered git root: {}", git_root);
|
||||
git_root
|
||||
} else {
|
||||
tracing::info!("Git repository is bare, using base path: {}", base_path);
|
||||
base_path
|
||||
}
|
||||
FrecencyTracker::spawn_gc(Arc::clone(&shared_frecency), frecency_db_path, false);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Warning: Failed to init frecency db: {}", e);
|
||||
Err(_) => {
|
||||
tracing::info!(
|
||||
"No git repository found, indexing from base path: {}",
|
||||
base_path
|
||||
);
|
||||
base_path
|
||||
}
|
||||
};
|
||||
|
||||
let shared_picker = SharedFilePicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
if let Some(frecency_db_path) = args.frecency_db_path {
|
||||
match FrecencyTracker::open(&frecency_db_path) {
|
||||
Ok(tracker) => {
|
||||
let _ = shared_frecency.init(tracker);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Warning: Failed to init frecency db: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content indexing follows warmup by default (backward compat), unless
|
||||
// the user explicitly opts in via --content-indexing or out via
|
||||
// --no-content-indexing.
|
||||
let enable_content_indexing = if args.content_indexing {
|
||||
true
|
||||
} else if args.no_content_indexing {
|
||||
false
|
||||
} else {
|
||||
!args.no_warmup
|
||||
};
|
||||
|
||||
// Initialize file picker (spawns background scan + watcher)
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path,
|
||||
true, // warmup_mmap_cache
|
||||
FFFMode::Ai,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
fff::FilePickerOptions {
|
||||
base_path,
|
||||
enable_mmap_cache: !args.no_warmup,
|
||||
enable_content_indexing,
|
||||
watch: !args.no_watch,
|
||||
mode: FFFMode::Ai,
|
||||
cache_budget: args
|
||||
.max_cached_files
|
||||
.map(fff::ContentCacheBudget::new_for_repo),
|
||||
follow_symlinks: false,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to init file picker: {}", e))?;
|
||||
|
||||
@@ -248,7 +276,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let server = FffServer::new(shared_picker.clone(), shared_frecency.clone());
|
||||
|
||||
// Wait for initial scan in background — don't block server startup
|
||||
let picker_clone_for_scan = Arc::clone(&shared_picker);
|
||||
let picker_clone_for_scan = shared_picker.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
//! Output formatting for MCP grep/search results.
|
||||
//!
|
||||
//! Port of `packages/fff-mcp/src/output.ts` — token-efficient formatting
|
||||
//! with definition auto-expansion, frecency/git annotations, and Read suggestions.
|
||||
|
||||
use fff_core::GrepMatch;
|
||||
use fff_core::git::format_git_status_opt;
|
||||
use fff_core::grep::is_import_line;
|
||||
use fff_core::types::FileItem;
|
||||
use fff::GrepMatch;
|
||||
use fff::file_picker::FilePicker;
|
||||
use fff::git::format_git_status_opt;
|
||||
use fff::grep::is_import_line;
|
||||
use fff::types::FileItem;
|
||||
|
||||
use crate::cursor::CursorStore;
|
||||
|
||||
/// Frecency score → single-token word. `None` for low-scoring files.
|
||||
fn frecency_word(score: i64) -> Option<&'static str> {
|
||||
fn frecency_word(score: i32) -> Option<&'static str> {
|
||||
if score >= 100 {
|
||||
Some("hot")
|
||||
} else if score >= 50 {
|
||||
@@ -23,8 +20,7 @@ fn frecency_word(score: i64) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build " - hot git:modified" style suffix. Empty when nothing to report.
|
||||
pub fn file_suffix(git_status: Option<git2::Status>, frecency_score: i64) -> String {
|
||||
pub fn file_suffix(git_status: Option<git2::Status>, frecency_score: i32) -> String {
|
||||
match (
|
||||
frecency_word(frecency_score),
|
||||
format_git_status_opt(git_status),
|
||||
@@ -57,7 +53,6 @@ impl OutputMode {
|
||||
|
||||
const LARGE_FILE_BYTES: u64 = 20_000;
|
||||
|
||||
/// Tag for large files — nudges model to use offset/limit when reading.
|
||||
fn size_tag(bytes: u64) -> String {
|
||||
if bytes < LARGE_FILE_BYTES {
|
||||
String::new()
|
||||
@@ -69,11 +64,8 @@ fn size_tag(bytes: u64) -> String {
|
||||
|
||||
const MAX_PREVIEW: usize = 120;
|
||||
const MAX_LINE_LEN: usize = 180;
|
||||
/// Max context lines to show when auto-expanding the first definition
|
||||
const MAX_DEF_EXPAND_FIRST: usize = 8;
|
||||
/// Max context lines for subsequent definitions
|
||||
const MAX_DEF_EXPAND: usize = 5;
|
||||
/// Max context lines for non-definition first match in small result sets
|
||||
const MAX_FIRST_MATCH_EXPAND: usize = 8;
|
||||
|
||||
fn trauncate_line_for_ai(
|
||||
@@ -81,34 +73,19 @@ fn trauncate_line_for_ai(
|
||||
match_ranges: Option<&[(u32, u32)]>,
|
||||
max_len: usize,
|
||||
) -> String {
|
||||
// Strip leading/trailing whitespace to save tokens — the LLM has file:line for location.
|
||||
let trimmed = line.trim();
|
||||
// Leading whitespace is already stripped by core (trim_whitespace option).
|
||||
// Only strip trailing whitespace here.
|
||||
let trimmed = line.trim_end();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let strip_offset = line.len() - line.trim_start().len();
|
||||
|
||||
if trimmed.len() <= max_len {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
|
||||
// Adjust match ranges for the stripped leading whitespace
|
||||
let adjusted: Vec<(u32, u32)>;
|
||||
let ranges = match match_ranges {
|
||||
Some(r) if strip_offset > 0 => {
|
||||
let off = strip_offset as u32;
|
||||
adjusted = r
|
||||
.iter()
|
||||
.map(|&(s, e)| (s.saturating_sub(off), e.saturating_sub(off)))
|
||||
.collect();
|
||||
Some(adjusted.as_slice())
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
|
||||
// Use first match range to center the window
|
||||
if let Some(ranges) = ranges
|
||||
if let Some(ranges) = match_ranges
|
||||
&& let Some(&(match_start, match_end)) = ranges.first()
|
||||
{
|
||||
let match_start = match_start as usize;
|
||||
@@ -141,7 +118,6 @@ fn trauncate_line_for_ai(
|
||||
format!("{}…", &trimmed[..end])
|
||||
}
|
||||
|
||||
/// Floor to a valid char boundary
|
||||
fn floor_char_boundary(s: &str, index: usize) -> usize {
|
||||
if index >= s.len() {
|
||||
return s.len();
|
||||
@@ -153,7 +129,6 @@ fn floor_char_boundary(s: &str, index: usize) -> usize {
|
||||
i
|
||||
}
|
||||
|
||||
/// Ceil to a valid char boundary
|
||||
fn ceil_char_boundary(s: &str, index: usize) -> usize {
|
||||
if index >= s.len() {
|
||||
return s.len();
|
||||
@@ -165,7 +140,6 @@ fn ceil_char_boundary(s: &str, index: usize) -> usize {
|
||||
i
|
||||
}
|
||||
|
||||
/// Collected file metadata for the first match per file.
|
||||
struct FileMeta<'a> {
|
||||
file: &'a FileItem,
|
||||
line_number: u64,
|
||||
@@ -175,19 +149,16 @@ struct FileMeta<'a> {
|
||||
context_after: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parameters for [`format_grep_results`].
|
||||
///
|
||||
/// Groups the read-only inputs so callers don't juggle 10 positional args.
|
||||
pub struct GrepFormatter<'a> {
|
||||
pub matches: &'a [GrepMatch],
|
||||
pub files: &'a [&'a FileItem],
|
||||
pub total_matched: usize,
|
||||
pub next_file_offset: usize,
|
||||
pub regex_fallback_error: Option<&'a str>,
|
||||
pub output_mode: OutputMode,
|
||||
pub max_results: usize,
|
||||
pub show_context: bool,
|
||||
pub auto_expand_defs: bool,
|
||||
pub picker: &'a FilePicker,
|
||||
}
|
||||
|
||||
impl GrepFormatter<'_> {
|
||||
@@ -197,11 +168,11 @@ impl GrepFormatter<'_> {
|
||||
files,
|
||||
total_matched,
|
||||
next_file_offset,
|
||||
regex_fallback_error,
|
||||
output_mode,
|
||||
max_results,
|
||||
show_context,
|
||||
auto_expand_defs,
|
||||
picker,
|
||||
} = *self;
|
||||
|
||||
let items = if matches.len() > max_results {
|
||||
@@ -217,11 +188,12 @@ impl GrepFormatter<'_> {
|
||||
next_file_offset,
|
||||
auto_expand_defs,
|
||||
cursor_store,
|
||||
picker,
|
||||
);
|
||||
}
|
||||
|
||||
if output_mode == OutputMode::Count {
|
||||
return format_count(items, files, next_file_offset, cursor_store);
|
||||
return format_count(items, files, next_file_offset, cursor_store, picker);
|
||||
}
|
||||
|
||||
// output_mode == usage
|
||||
@@ -242,27 +214,23 @@ impl GrepFormatter<'_> {
|
||||
2500
|
||||
};
|
||||
|
||||
if let Some(err) = regex_fallback_error {
|
||||
lines.push(format!("! regex failed: {}, using literal match", err));
|
||||
}
|
||||
|
||||
// File overview: collect first match per file
|
||||
let file_preview = collect_file_preview(items, files);
|
||||
let mut content_def_file = "";
|
||||
let mut content_first_file = "";
|
||||
let file_preview = collect_file_preview(items, files, picker);
|
||||
let mut content_def_file = String::new();
|
||||
let mut content_first_file = String::new();
|
||||
for fm in &file_preview {
|
||||
if content_first_file.is_empty() {
|
||||
content_first_file = &fm.file.relative_path;
|
||||
content_first_file = fm.file.relative_path(picker);
|
||||
}
|
||||
if content_def_file.is_empty() && fm.is_definition {
|
||||
content_def_file = &fm.file.relative_path;
|
||||
content_def_file = fm.file.relative_path(picker);
|
||||
}
|
||||
}
|
||||
|
||||
let content_suggest = if !content_def_file.is_empty() {
|
||||
content_def_file
|
||||
&content_def_file
|
||||
} else {
|
||||
content_first_file
|
||||
&content_first_file
|
||||
};
|
||||
if !content_suggest.is_empty() {
|
||||
let file_count = file_preview.len();
|
||||
@@ -285,7 +253,7 @@ impl GrepFormatter<'_> {
|
||||
// Detailed content (subject to budget)
|
||||
let mut char_count = 0usize;
|
||||
let mut shown_count = 0usize;
|
||||
let mut current_file = "";
|
||||
let mut current_file = String::new();
|
||||
|
||||
// Reorder: definitions first, then usages, then imports (when auto-expanding)
|
||||
let sorted_items: Vec<usize> = if auto_expand_defs {
|
||||
@@ -310,8 +278,9 @@ impl GrepFormatter<'_> {
|
||||
let file = files[m.file_index];
|
||||
let mut match_lines: Vec<String> = Vec::new();
|
||||
|
||||
if file.relative_path.as_str() != current_file {
|
||||
current_file = &file.relative_path;
|
||||
let file_rel_path = file.relative_path(picker);
|
||||
if file_rel_path != current_file {
|
||||
current_file = file_rel_path;
|
||||
match_lines.push(current_file.to_string());
|
||||
}
|
||||
|
||||
@@ -358,18 +327,19 @@ impl GrepFormatter<'_> {
|
||||
}
|
||||
|
||||
// Auto-expand definitions with body context
|
||||
let file_rel_for_expand = file.relative_path(picker);
|
||||
if auto_expand_defs
|
||||
&& !show_context
|
||||
&& m.is_definition
|
||||
&& !m.context_after.is_empty()
|
||||
&& !def_expanded_files.contains(file.relative_path.as_str())
|
||||
&& !def_expanded_files.contains(&file_rel_for_expand)
|
||||
{
|
||||
let expand_limit = if def_expanded_files.is_empty() {
|
||||
MAX_DEF_EXPAND_FIRST
|
||||
} else {
|
||||
MAX_DEF_EXPAND
|
||||
};
|
||||
def_expanded_files.insert(file.relative_path.as_str());
|
||||
def_expanded_files.insert(file_rel_for_expand);
|
||||
let start_line = m.line_number + 1;
|
||||
for (i, ctx) in m.context_after.iter().take(expand_limit).enumerate() {
|
||||
if ctx.trim().is_empty() {
|
||||
@@ -408,27 +378,28 @@ fn format_files_with_matches(
|
||||
next_file_offset: usize,
|
||||
auto_expand_defs: bool,
|
||||
cursor_store: &mut CursorStore,
|
||||
picker: &FilePicker,
|
||||
) -> String {
|
||||
let file_map = collect_file_preview(items, files);
|
||||
let file_map = collect_file_preview(items, files, picker);
|
||||
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let file_count = file_map.len();
|
||||
|
||||
// Find best Read target
|
||||
let mut first_def_file = "";
|
||||
let mut first_file = "";
|
||||
let mut first_def_file = String::new();
|
||||
let mut first_file = String::new();
|
||||
for fm in &file_map {
|
||||
if first_file.is_empty() {
|
||||
first_file = &fm.file.relative_path;
|
||||
first_file = fm.file.relative_path(picker);
|
||||
}
|
||||
if first_def_file.is_empty() && fm.is_definition {
|
||||
first_def_file = &fm.file.relative_path;
|
||||
first_def_file = fm.file.relative_path(picker);
|
||||
}
|
||||
}
|
||||
let suggest_path = if !first_def_file.is_empty() {
|
||||
first_def_file
|
||||
&first_def_file
|
||||
} else {
|
||||
first_file
|
||||
&first_file
|
||||
};
|
||||
|
||||
if !suggest_path.is_empty() {
|
||||
@@ -456,7 +427,7 @@ fn format_files_with_matches(
|
||||
let def_tag = if is_def { " [def]" } else { "" };
|
||||
lines.push(format!(
|
||||
"{}{}{}",
|
||||
fm.file.relative_path,
|
||||
fm.file.relative_path(picker),
|
||||
def_tag,
|
||||
size_tag(fm.file.size)
|
||||
));
|
||||
@@ -522,13 +493,15 @@ fn format_count(
|
||||
files: &[&FileItem],
|
||||
next_file_offset: usize,
|
||||
cursor_store: &mut CursorStore,
|
||||
picker: &FilePicker,
|
||||
) -> String {
|
||||
let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
|
||||
let mut order: Vec<&str> = Vec::new();
|
||||
let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
let mut order: Vec<String> = Vec::new();
|
||||
for m in items {
|
||||
let path = files[m.file_index].relative_path.as_str();
|
||||
let count = counts.entry(path).or_insert_with(|| {
|
||||
order.push(path);
|
||||
let file = files[m.file_index];
|
||||
let path = file.relative_path(picker);
|
||||
let count = counts.entry(path.to_string()).or_insert_with(|| {
|
||||
order.push(path.to_string());
|
||||
0
|
||||
});
|
||||
*count += 1;
|
||||
@@ -536,7 +509,7 @@ fn format_count(
|
||||
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
for path in &order {
|
||||
lines.push(format!("{}: {}", path, counts[*path]));
|
||||
lines.push(format!("{}: {}", path, counts[path.as_str()]));
|
||||
}
|
||||
if next_file_offset > 0 {
|
||||
let cursor_id = cursor_store.store(next_file_offset);
|
||||
@@ -545,12 +518,16 @@ fn format_count(
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn collect_file_preview<'a>(items: &[GrepMatch], files: &[&'a FileItem]) -> Vec<FileMeta<'a>> {
|
||||
fn collect_file_preview<'a>(
|
||||
items: &[GrepMatch],
|
||||
files: &[&'a FileItem],
|
||||
picker: &FilePicker,
|
||||
) -> Vec<FileMeta<'a>> {
|
||||
let mut file_preview: Vec<FileMeta<'a>> = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for m in items {
|
||||
let file = files[m.file_index];
|
||||
if seen.insert(&file.relative_path) {
|
||||
if seen.insert(file.relative_path(picker)) {
|
||||
file_preview.push(FileMeta {
|
||||
file,
|
||||
line_number: m.line_number,
|
||||
@@ -569,26 +546,29 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn trunc_strips_whitespace() {
|
||||
assert_eq!(trauncate_line_for_ai(" foo()", None, 180), "foo()");
|
||||
assert_eq!(trauncate_line_for_ai(" bar ", None, 180), "bar");
|
||||
fn trunc_strips_trailing_whitespace() {
|
||||
// Leading whitespace is now stripped by core's trim_whitespace option.
|
||||
// This function only strips trailing whitespace.
|
||||
assert_eq!(trauncate_line_for_ai("foo()", None, 180), "foo()");
|
||||
assert_eq!(trauncate_line_for_ai("bar ", None, 180), "bar");
|
||||
assert_eq!(trauncate_line_for_ai(" ", None, 180), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trunc_adjusts_match_ranges_after_strip() {
|
||||
// " hello" — match on "hello" at bytes 4..9
|
||||
let line = " hello";
|
||||
let ranges = [(4, 9)];
|
||||
fn trunc_preserves_pre_trimmed_match_ranges() {
|
||||
// Core already stripped leading whitespace and adjusted offsets,
|
||||
// so "hello" arrives with match at bytes 0..5.
|
||||
let line = "hello";
|
||||
let ranges = [(0, 5)];
|
||||
let result = trauncate_line_for_ai(line, Some(&ranges), 180);
|
||||
// After stripping 4 leading spaces, the trimmed line is "hello"
|
||||
assert_eq!(result, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trunc_long_line_centered() {
|
||||
let line = format!("{}match_here{}", " ".repeat(8), "x".repeat(200));
|
||||
let ranges = [(8u32, 18u32)];
|
||||
// Core already stripped leading whitespace; offsets are pre-adjusted.
|
||||
let line = format!("match_here{}", "x".repeat(200));
|
||||
let ranges = [(0u32, 10u32)];
|
||||
let result = trauncate_line_for_ai(&line, Some(&ranges), 50);
|
||||
assert!(result.contains("match_here"));
|
||||
assert!(result.len() <= 55); // budget + ellipsis chars
|
||||
|
||||
+124
-64
@@ -8,20 +8,30 @@ use std::borrow::Cow;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::grep::{self, GrepMode, GrepSearchOptions, has_regex_metacharacters};
|
||||
use fff_core::types::{FileItem, PaginationArgs};
|
||||
use fff_core::{FuzzySearchOptions, QueryParser, SharedFrecency, SharedPicker};
|
||||
use crate::cursor::CursorStore;
|
||||
use crate::output::{GrepFormatter, OutputMode, file_suffix};
|
||||
use fff::grep::{GrepMode, GrepSearchOptions, has_regex_metacharacters};
|
||||
use fff::types::{FileItem, PaginationArgs};
|
||||
use fff::{FuzzySearchOptions, QueryParser, SharedFilePicker, SharedFrecency};
|
||||
use fff_query_parser::AiGrepConfig;
|
||||
use rmcp::handler::server::router::tool::ToolRouter;
|
||||
use rmcp::handler::server::wrapper::Parameters;
|
||||
use rmcp::model::*;
|
||||
use rmcp::{ServerHandler, schemars, tool, tool_handler, tool_router};
|
||||
|
||||
use crate::cursor::CursorStore;
|
||||
use crate::output::{GrepFormatter, OutputMode, file_suffix};
|
||||
/// Normalize the caller-supplied `maxResults`.
|
||||
///
|
||||
/// `None`, `Some(0)`, and non-positive / non-finite values fall back to
|
||||
/// `default`. Issue #400 reported that grep returned 0 items for
|
||||
/// `maxResults: 0` while `find_files` returned the entire dataset; treating
|
||||
/// 0 as "use the default" makes both tools behave consistently.
|
||||
fn normalize_max_results(raw: Option<f64>, default: usize) -> usize {
|
||||
match raw {
|
||||
None => default,
|
||||
Some(v) if v <= 0.0 || !v.is_finite() => default,
|
||||
Some(v) => (v.round() as usize).max(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip common delimiters and lowercase for fuzzy fallback queries.
|
||||
fn cleanup_fuzzy_query(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
@@ -32,7 +42,6 @@ fn cleanup_fuzzy_query(s: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute grep search options from output mode and context settings.
|
||||
fn make_grep_options(
|
||||
output_mode: OutputMode,
|
||||
mode: GrepMode,
|
||||
@@ -65,6 +74,8 @@ fn make_grep_options(
|
||||
before_context: ctx_lines,
|
||||
after_context: after_ctx,
|
||||
classify_definitions: true,
|
||||
trim_whitespace: true,
|
||||
abort_signal: None,
|
||||
},
|
||||
auto_expand,
|
||||
)
|
||||
@@ -73,10 +84,14 @@ fn make_grep_options(
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct FindFilesParams {
|
||||
/// Fuzzy search query. Supports path prefixes and glob constraints.
|
||||
// `pattern` alias for consistency with grep's alias and the common
|
||||
// file-search parameter name (#311).
|
||||
#[serde(alias = "pattern")]
|
||||
pub query: String,
|
||||
/// Max results (default 20).
|
||||
#[serde(rename = "maxResults")]
|
||||
pub max_results: Option<usize>,
|
||||
// this has to be float because llms are stupid
|
||||
pub max_results: Option<f64>,
|
||||
/// Cursor from previous result. Only use if previous results weren't sufficient.
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
@@ -85,10 +100,14 @@ pub struct FindFilesParams {
|
||||
pub struct GrepParams {
|
||||
/// Search text or regex query with optional constraint prefixes.
|
||||
/// Matches within single lines only — use ONE specific term, not multiple words.
|
||||
// `pattern` alias: LLMs that have seen multi_grep (which uses `patterns`)
|
||||
// routinely call grep with `pattern`; accept it instead of erroring out
|
||||
// with an unhelpful "missing field `query`" (#311).
|
||||
#[serde(alias = "pattern")]
|
||||
pub query: String,
|
||||
/// Max matching lines (default 20).
|
||||
#[serde(rename = "maxResults")]
|
||||
pub max_results: Option<usize>,
|
||||
pub max_results: Option<f64>, // this has to be float because llms are stupid
|
||||
/// Cursor from previous result. Only use if previous results weren't sufficient.
|
||||
pub cursor: Option<String>,
|
||||
/// Output format (default 'content').
|
||||
@@ -150,37 +169,34 @@ pub struct MultiGrepParams {
|
||||
pub constraints: Option<String>,
|
||||
/// Max matching lines (default 20).
|
||||
#[serde(rename = "maxResults")]
|
||||
pub max_results: Option<usize>,
|
||||
pub max_results: Option<f64>,
|
||||
/// Cursor from previous result.
|
||||
pub cursor: Option<String>,
|
||||
/// Output format (default 'content').
|
||||
pub output_mode: Option<String>,
|
||||
/// Context lines before/after each match.
|
||||
pub context: Option<usize>,
|
||||
pub context: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FffServer {
|
||||
picker: SharedPicker,
|
||||
picker: SharedFilePicker,
|
||||
#[allow(dead_code)]
|
||||
frecency: SharedFrecency,
|
||||
cursor_store: Arc<Mutex<CursorStore>>,
|
||||
update_notice_sent: Arc<AtomicBool>,
|
||||
tool_router: ToolRouter<Self>,
|
||||
}
|
||||
|
||||
impl FffServer {
|
||||
pub fn new(picker: SharedPicker, frecency: SharedFrecency) -> Self {
|
||||
pub fn new(picker: SharedFilePicker, frecency: SharedFrecency) -> Self {
|
||||
Self {
|
||||
picker,
|
||||
frecency,
|
||||
cursor_store: Arc::new(Mutex::new(CursorStore::new())),
|
||||
update_notice_sent: Arc::new(AtomicBool::new(false)),
|
||||
tool_router: Self::tool_router(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for the initial file scan to complete.
|
||||
#[allow(dead_code)]
|
||||
pub fn wait_for_scan(&self) {
|
||||
loop {
|
||||
@@ -198,15 +214,12 @@ impl FffServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock the cursor store, returning an MCP error on poisoned mutex.
|
||||
fn lock_cursors(&self) -> Result<std::sync::MutexGuard<'_, CursorStore>, ErrorData> {
|
||||
self.cursor_store.lock().map_err(|e| {
|
||||
ErrorData::internal_error(format!("Failed to acquire cursor store lock: {e}"), None)
|
||||
})
|
||||
}
|
||||
|
||||
/// If an update notice is available and hasn't been sent yet, append it
|
||||
/// to the tool result. Called once per server lifetime (first tool call).
|
||||
fn maybe_append_update_notice(&self, result: &mut CallToolResult) {
|
||||
if self.update_notice_sent.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
@@ -220,11 +233,6 @@ impl FffServer {
|
||||
result.content.push(Content::text(notice));
|
||||
}
|
||||
|
||||
/// Perform grep with auto-retry logic.
|
||||
///
|
||||
/// Acquires the picker read-lock once and holds it for the entire
|
||||
/// operation, so `GrepResult` references are used directly — no cloning.
|
||||
/// Always uses AI query parsing since this is an MCP server for AI agents.
|
||||
fn perform_grep(
|
||||
&self,
|
||||
query: &str,
|
||||
@@ -249,11 +257,9 @@ impl FffServer {
|
||||
.as_ref()
|
||||
.ok_or_else(|| ErrorData::internal_error("File picker not initialized", None))?;
|
||||
|
||||
let files = picker.get_files();
|
||||
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let parsed = parser.parse(query);
|
||||
let result = grep::grep_search(files, &parsed, &options);
|
||||
let result = picker.grep(&parsed, &options);
|
||||
|
||||
if result.matches.is_empty() && file_offset == 0 {
|
||||
// Auto-retry: try broadening multi-word queries by dropping first non-constraint word
|
||||
@@ -276,7 +282,7 @@ impl FffServer {
|
||||
};
|
||||
|
||||
let (retry_options, _) = make_grep_options(output_mode, retry_mode, 0, context);
|
||||
let retry_result = grep::grep_search(files, &rest_parsed, &retry_options);
|
||||
let retry_result = picker.grep(&rest_parsed, &retry_options);
|
||||
|
||||
if !retry_result.matches.is_empty() && retry_result.matches.len() <= 10 {
|
||||
let mut cs = self.lock_cursors()?;
|
||||
@@ -285,11 +291,11 @@ impl FffServer {
|
||||
files: &retry_result.files,
|
||||
total_matched: retry_result.matches.len(),
|
||||
next_file_offset: retry_result.next_file_offset,
|
||||
regex_fallback_error: retry_result.regex_fallback_error.as_deref(),
|
||||
output_mode,
|
||||
max_results,
|
||||
show_context: ctx_lines > 0,
|
||||
auto_expand_defs: auto_expand,
|
||||
picker,
|
||||
}
|
||||
.format(&mut cs);
|
||||
return Ok(CallToolResult::success(vec![Content::text(format!(
|
||||
@@ -304,7 +310,7 @@ impl FffServer {
|
||||
let fuzzy_query = cleanup_fuzzy_query(query);
|
||||
let (fuzzy_options, _) = make_grep_options(output_mode, GrepMode::Fuzzy, 0, Some(0));
|
||||
let fuzzy_parsed = parser.parse(&fuzzy_query);
|
||||
let fuzzy_result = grep::grep_search(files, &fuzzy_parsed, &fuzzy_options);
|
||||
let fuzzy_result = picker.grep(&fuzzy_parsed, &fuzzy_options);
|
||||
|
||||
if !fuzzy_result.matches.is_empty() {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
@@ -312,11 +318,12 @@ impl FffServer {
|
||||
"0 exact matches. {} approximate:",
|
||||
fuzzy_result.matches.len()
|
||||
));
|
||||
let mut current_file = "";
|
||||
let mut current_file = String::new();
|
||||
for m in fuzzy_result.matches.iter().take(3) {
|
||||
let file = fuzzy_result.files[m.file_index];
|
||||
if file.relative_path.as_str() != current_file {
|
||||
current_file = &file.relative_path;
|
||||
let file_rel = file.relative_path(picker);
|
||||
if file_rel != current_file {
|
||||
current_file = file_rel;
|
||||
lines.push(current_file.to_string());
|
||||
}
|
||||
lines.push(format!(" {}: {}", m.line_number, m.line_content));
|
||||
@@ -334,7 +341,6 @@ impl FffServer {
|
||||
max_threads: 0,
|
||||
current_file: None,
|
||||
project_path: Some(picker.base_path()),
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
@@ -342,7 +348,7 @@ impl FffServer {
|
||||
limit: 1,
|
||||
},
|
||||
};
|
||||
let file_result = FilePicker::fuzzy_search(files, &file_query, file_opts);
|
||||
let file_result = picker.fuzzy_search(&file_query, None, file_opts);
|
||||
if let (Some(top), Some(score)) =
|
||||
(file_result.items.first(), file_result.scores.first())
|
||||
{
|
||||
@@ -351,7 +357,7 @@ impl FffServer {
|
||||
if score.base_score > query_len * 10 {
|
||||
return Ok(CallToolResult::success(vec![Content::text(format!(
|
||||
"0 content matches. But there is a relevant file path: {}",
|
||||
top.relative_path
|
||||
top.relative_path(picker)
|
||||
))]));
|
||||
}
|
||||
}
|
||||
@@ -374,11 +380,11 @@ impl FffServer {
|
||||
files: &result.files,
|
||||
total_matched: result.matches.len(),
|
||||
next_file_offset: result.next_file_offset,
|
||||
regex_fallback_error: result.regex_fallback_error.as_deref(),
|
||||
output_mode,
|
||||
max_results,
|
||||
show_context: ctx_lines > 0,
|
||||
auto_expand_defs: auto_expand,
|
||||
picker,
|
||||
}
|
||||
.format(&mut cs);
|
||||
|
||||
@@ -401,7 +407,7 @@ impl FffServer {
|
||||
&self,
|
||||
Parameters(params): Parameters<FindFilesParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let max_results = params.max_results.unwrap_or(20);
|
||||
let max_results = normalize_max_results(params.max_results, 20);
|
||||
let query = ¶ms.query;
|
||||
|
||||
let page_offset = params
|
||||
@@ -416,14 +422,11 @@ impl FffServer {
|
||||
let picker = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| ErrorData::internal_error("File picker not initialized", None))?;
|
||||
|
||||
let files = picker.get_files();
|
||||
let base_path = picker.base_path();
|
||||
let make_opts = |offset: usize| FuzzySearchOptions {
|
||||
max_threads: 0,
|
||||
current_file: None,
|
||||
project_path: Some(base_path),
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
@@ -434,7 +437,7 @@ impl FffServer {
|
||||
|
||||
let parser = QueryParser::default();
|
||||
let fff_query = parser.parse(query);
|
||||
let result = FilePicker::fuzzy_search(files, &fff_query, make_opts(page_offset));
|
||||
let result = picker.fuzzy_search(&fff_query, None, make_opts(page_offset));
|
||||
let total_files = result.total_files;
|
||||
|
||||
// Auto-retry with fewer terms if 3+ words return 0 results
|
||||
@@ -445,7 +448,7 @@ impl FffServer {
|
||||
if result.items.is_empty() && words.len() >= 3 && page_offset == 0 {
|
||||
if let Some(shorter) = &shorter {
|
||||
let shorter_query = parser.parse(shorter);
|
||||
let retry = FilePicker::fuzzy_search(files, &shorter_query, make_opts(0));
|
||||
let retry = picker.fuzzy_search(&shorter_query, None, make_opts(0));
|
||||
|
||||
(retry.items, retry.scores, retry.total_matched)
|
||||
} else {
|
||||
@@ -468,11 +471,14 @@ impl FffServer {
|
||||
|
||||
if page_offset == 0 {
|
||||
if is_exact_match {
|
||||
lines.push(format!("→ Read {} (exact match!)", top_item.relative_path));
|
||||
lines.push(format!(
|
||||
"→ Read {} (exact match!)",
|
||||
top_item.relative_path(picker)
|
||||
));
|
||||
} else if scores.len() < 2 || scores[0].total > scores[1].total.saturating_mul(2) {
|
||||
lines.push(format!(
|
||||
"→ Read {} (best match — Read this file directly)",
|
||||
top_item.relative_path
|
||||
top_item.relative_path(picker)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -487,8 +493,8 @@ impl FffServer {
|
||||
for item in &items {
|
||||
lines.push(format!(
|
||||
"{}{}",
|
||||
item.relative_path,
|
||||
file_suffix(item.git_status, item.total_frecency_score)
|
||||
item.relative_path(picker),
|
||||
file_suffix(item.git_status, item.total_frecency_score())
|
||||
));
|
||||
}
|
||||
|
||||
@@ -513,7 +519,7 @@ impl FffServer {
|
||||
&self,
|
||||
Parameters(params): Parameters<GrepParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let max_results = params.max_results.unwrap_or(20);
|
||||
let max_results = normalize_max_results(params.max_results, 20);
|
||||
let output_mode = OutputMode::new(params.output_mode.as_deref());
|
||||
|
||||
let parsed = QueryParser::new(AiGrepConfig).parse(¶ms.query);
|
||||
@@ -555,7 +561,8 @@ impl FffServer {
|
||||
|
||||
impl FffServer {
|
||||
fn multi_grep_inner(&self, params: MultiGrepParams) -> Result<CallToolResult, ErrorData> {
|
||||
let max_results = params.max_results.unwrap_or(20);
|
||||
let max_results = normalize_max_results(params.max_results, 20);
|
||||
let context = params.context.map(|v| v.round() as usize);
|
||||
let output_mode = OutputMode::new(params.output_mode.as_deref());
|
||||
|
||||
let file_offset = params
|
||||
@@ -564,12 +571,8 @@ impl FffServer {
|
||||
.and_then(|id| self.cursor_store.lock().ok()?.get(id))
|
||||
.unwrap_or(0);
|
||||
|
||||
let (options, auto_expand) = make_grep_options(
|
||||
output_mode,
|
||||
GrepMode::PlainText,
|
||||
file_offset,
|
||||
params.context,
|
||||
);
|
||||
let (options, auto_expand) =
|
||||
make_grep_options(output_mode, GrepMode::PlainText, file_offset, context);
|
||||
|
||||
let ctx_lines = options.before_context;
|
||||
let constraint_query = params.constraints.as_deref().unwrap_or("");
|
||||
@@ -579,21 +582,19 @@ impl FffServer {
|
||||
let picker = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| ErrorData::internal_error("File picker not initialized", None))?;
|
||||
|
||||
let patterns_refs: Vec<&str> = params.patterns.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let parser = fff_query_parser::QueryParser::new(fff_query_parser::AiGrepConfig);
|
||||
let parsed_constraints = parser.parse(constraint_query);
|
||||
let constraints = parsed_constraints.constraints.as_slice();
|
||||
|
||||
let files = picker.get_files();
|
||||
let result = grep::multi_grep_search(files, &patterns_refs, constraints, &options);
|
||||
let result = picker.multi_grep(&patterns_refs, constraints, &options);
|
||||
let file_refs: Vec<&FileItem> = result.files.to_vec();
|
||||
|
||||
if result.matches.is_empty() && file_offset == 0 {
|
||||
// Fallback: try individual patterns with plain grep
|
||||
let (fallback_options, _) =
|
||||
make_grep_options(output_mode, GrepMode::PlainText, 0, params.context);
|
||||
make_grep_options(output_mode, GrepMode::PlainText, 0, context);
|
||||
|
||||
let fallback_options = GrepSearchOptions {
|
||||
time_budget_ms: 3000,
|
||||
@@ -609,7 +610,7 @@ impl FffServer {
|
||||
};
|
||||
|
||||
let parsed = parser.parse(&full_query);
|
||||
let fb_result = grep::grep_search(files, &parsed, &fallback_options);
|
||||
let fb_result = picker.grep(&parsed, &fallback_options);
|
||||
|
||||
if !fb_result.matches.is_empty() {
|
||||
let fb_file_refs: Vec<&FileItem> = fb_result.files.to_vec();
|
||||
@@ -619,11 +620,11 @@ impl FffServer {
|
||||
files: &fb_file_refs,
|
||||
total_matched: fb_result.matches.len(),
|
||||
next_file_offset: fb_result.next_file_offset,
|
||||
regex_fallback_error: None,
|
||||
output_mode,
|
||||
max_results,
|
||||
show_context: false,
|
||||
auto_expand_defs: auto_expand,
|
||||
picker,
|
||||
}
|
||||
.format(&mut cs);
|
||||
return Ok(CallToolResult::success(vec![Content::text(format!(
|
||||
@@ -650,11 +651,11 @@ impl FffServer {
|
||||
files: &file_refs,
|
||||
total_matched: result.matches.len(),
|
||||
next_file_offset: result.next_file_offset,
|
||||
regex_fallback_error: None,
|
||||
output_mode,
|
||||
max_results,
|
||||
show_context: ctx_lines > 0,
|
||||
auto_expand_defs: auto_expand,
|
||||
picker,
|
||||
}
|
||||
.format(&mut cs);
|
||||
|
||||
@@ -677,3 +678,62 @@ impl ServerHandler for FffServer {
|
||||
.with_instructions(instructions)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_max_results_none_uses_default() {
|
||||
assert_eq!(normalize_max_results(None, 20), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_max_results_zero_uses_default() {
|
||||
// Issue #400: `maxResults: 0` must not return zero items for grep
|
||||
// while `find_files` returns the full set. Both tools now map 0 to
|
||||
// the default limit.
|
||||
assert_eq!(normalize_max_results(Some(0.0), 20), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_max_results_negative_uses_default() {
|
||||
assert_eq!(normalize_max_results(Some(-5.0), 20), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_max_results_non_finite_uses_default() {
|
||||
assert_eq!(normalize_max_results(Some(f64::NAN), 20), 20);
|
||||
assert_eq!(normalize_max_results(Some(f64::INFINITY), 20), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_max_results_rounds_and_clamps() {
|
||||
assert_eq!(normalize_max_results(Some(0.4), 20), 1);
|
||||
assert_eq!(normalize_max_results(Some(10.0), 20), 10);
|
||||
assert_eq!(normalize_max_results(Some(10.7), 20), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grep_params_accepts_pattern_alias() {
|
||||
// Issue #311: LLMs flip between `query` and `pattern`; accept both.
|
||||
let via_query: GrepParams =
|
||||
serde_json::from_str(r#"{"query":"foo"}"#).expect("query field");
|
||||
assert_eq!(via_query.query, "foo");
|
||||
|
||||
let via_pattern: GrepParams =
|
||||
serde_json::from_str(r#"{"pattern":"foo"}"#).expect("pattern alias");
|
||||
assert_eq!(via_pattern.query, "foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_files_params_accepts_pattern_alias() {
|
||||
let via_query: FindFilesParams =
|
||||
serde_json::from_str(r#"{"query":"foo"}"#).expect("query field");
|
||||
assert_eq!(via_query.query, "foo");
|
||||
|
||||
let via_pattern: FindFilesParams =
|
||||
serde_json::from_str(r#"{"pattern":"foo"}"#).expect("pattern alias");
|
||||
assert_eq!(via_pattern.query, "foo");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,43 +76,3 @@ fn fetch_latest_tag() -> Result<String, Box<dyn std::error::Error>> {
|
||||
|
||||
Ok(tag)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_update_check_end_to_end() {
|
||||
// Fetch the actual latest release tag from GitHub
|
||||
let tag = fetch_latest_tag().expect("Failed to fetch latest release tag from GitHub");
|
||||
assert!(!tag.is_empty(), "Release tag should not be empty");
|
||||
|
||||
let notice = compare_versions(BUILD_HASH, &tag);
|
||||
let build_short = &BUILD_HASH[..BUILD_HASH.len().min(7)];
|
||||
|
||||
if BUILD_HASH.starts_with(tag.trim()) || tag.trim().starts_with(BUILD_HASH) {
|
||||
// If by chance we're on the exact release commit
|
||||
assert!(notice.is_empty(), "Should be empty when hashes match");
|
||||
} else {
|
||||
assert!(
|
||||
notice.contains("fff update available"),
|
||||
"Expected update notice for mismatched hashes (build: {}, release: {}), got: '{}'",
|
||||
build_short,
|
||||
tag.trim(),
|
||||
notice
|
||||
);
|
||||
assert!(
|
||||
notice.contains(tag.trim()),
|
||||
"Notice should contain release tag"
|
||||
);
|
||||
assert!(
|
||||
notice.contains(build_short),
|
||||
"Notice should contain our short hash"
|
||||
);
|
||||
assert!(
|
||||
notice.contains("install-mcp.sh"),
|
||||
"Notice should contain install command"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user