Compare commits

...

52 Commits

Author SHA1 Message Date
Dmitriy Kovalenko 3ead91ec9d chore: Improve chunking when reading files for grep
docs / docs (push) Has been cancelled
2026-04-15 16:15:34 -07:00
Rodrigo Santa Cruz 7de55be63d feat: create new qflist instead of replacing (#377) 2026-04-14 19:49:01 -07:00
Dmitriy Kovalenko 2606dce6bc fix: Perfromance regression (#376)
* fix: Perfromance regression

* chore: Update docs for - fix: Perfromance regression
2026-04-14 17:03:10 -07:00
Dmitriy Kovalenko 8912a1abdb fix: Unicode segmentation crash (#373)
* fix: Unicode segmentation crash

closes https://github.com/dmtrKovalenko/fff.nvim/issues/369

* chore: Update docs for - fix: Unicode segmentation crash
2026-04-13 16:26:29 -07:00
Dmitriy Kovalenko d25ea7562b feat: Correct bonuses for actual path prefix (#372)
* feat: Correct bonuses for actual path prefix

The issue is related to the fact that long prefix similarity the suffix
difference got neglected as a small typo which is wrong

* chore: Update docs for - feat: Correct bonuses for actual path prefix
2026-04-13 14:32:13 -07:00
Cocoon-Break ea1f9802d7 fix: restore FFFClearCache command implementation (#338) 2026-04-10 05:56:58 -07:00
Dmitriy Kovalenko 07e90e5898 feat: fuzzy perfilter for grep (#362)
* feat: Prefilter for grep

* chore: Update docs for - feat: Prefilter for grep
2026-04-09 19:26:33 -07:00
Dmitriy Kovalenko 0fd47ab72d feat: Prefiltering for regex and multigrep (#361) 2026-04-09 10:33:28 -07:00
Dmitriy Kovalenko 7979004d77 feat: trim_whitespace option for grep (#356)
* feat: trim_whitespace optin for grep

closes https://github.com/dmtrKovalenko/fff.nvim/issues/307

* chore: Update docs for - feat: trim_whitespace optin for grep
2026-04-09 07:23:44 -07:00
aiwao 210bc9e1c7 docs: fix syntax error in the installation guide for vim.pack (#358) 2026-04-08 21:10:37 -07:00
Dmitriy Kovalenko 205f9d6db9 fix: Excessive usage of system file watchers (#357)
* fix: Excessive usage of system file watchers

* chore: Update docs for - fix: Excessive usage of system file watchers
2026-04-08 17:53:03 -07:00
Dmitriy Kovalenko 406bf2531c feat: Cross compile with thin lto (#355)
* feat: Compile with thin lto

* chore: Update docs for - feat: Compile with thin lto
2026-04-08 16:45:07 -07:00
Dmitriy Kovalenko 42a12051d8 fix: Single parenthesis error (#354)
fixes https://github.com/dmtrKovalenko/fff.nvim/issues/351
2026-04-08 15:00:17 -07:00
Matt Van Horn 477a8a2c96 fix(grep): align preview highlight stripping with Rust query parser (#343)
* fix(grep): align preview highlight stripping with Rust query parser

The Lua heuristic in highlight_grep_matches used a simple prefix check
(^[*!/] or ^.) to strip constraints. This diverged from the Rust
GrepConfig parser in several ways:

- Multi-word queries like 'foo bar *.rs' only highlighted 'foo'
- Constraint prefixes like type:rust were not stripped
- Tokens starting with '.' were incorrectly treated as constraints
- Escaped constraint tokens (e.g. \*.config) were not handled

Replace the heuristic with _is_grep_constraint() that matches the
Rust parser's actual GrepConfig rules: extensions (*.rs), path segments
(/src/), exclusions (!test), type filters (type:rust), and path-oriented
globs. Use all text parts joined with space for highlighting, matching
grep_text() on the Rust side.

Fixes #331

* refactor(grep): expose parsed query to lua via Rust function

Replace the Lua-side constraint detection (_is_grep_constraint) with a
new parse_grep_query() function that delegates to the Rust GrepConfig
parser. This keeps the Rust parser as the single source of truth for
query parsing, avoiding drift when new token types are added.

The new function is exposed to Lua as fff.parse_grep_query(query) and
returns a table with the grep_text field (the search text with all
constraints stripped).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:21:11 -07:00
Dmitriy Kovalenko 9a6ee3d18a fix: Highglight application for termgui themes (#353)
closes https://github.com/dmtrKovalenko/fff.nvim/issues/346
2026-04-08 14:20:13 -07:00
aiwao 29585a4aa3 docs: fix installation guide for vim.pack (#352)
* doc: fix installation guide for vim.pack

* doc: fix installation guide for vim.pack
2026-04-08 14:20:01 -07:00
Dmitriy Kovalenko 1c2c0633cd fix: Added files sometimes not greppable (#350)
* fix: Added files sometimes not greppable

* chore: Update docs for - fix: Added files sometimes not greppable

* chore: add test
2026-04-07 17:57:49 -07:00
Dmitriy Kovalenko 5158ba64b8 fix: Crash if rescan triggered before bigramming is over (#349) 2026-04-07 15:23:13 -07:00
Dmitriy Kovalenko 51e0ef7a64 perf: Hint allocator to release memory back to OS (#348)
* perf: Hint allocator to release memory back to OS

* chore: Update docs for - perf: Hint allocator to release memory back to OS
2026-04-07 12:28:11 -07:00
Antonio Nuno Monteiro cd0efe50d3 fix: suspend/restore set paste when loading picker UI (#334) 2026-04-07 09:21:30 -07:00
Dmitriy Kovalenko 541c3f5722 perf: Packed byte layout for file item (reduce ram usage) (#344)
* perf: Packed byte layout for file item (reduce ram usage)

* chore: Update docs for - perf: Packed byte layout for file item (reduce ram usage)
2026-04-06 12:29:40 -07:00
Trevin Chow 59d626dacd fix: coerce Blob values to string in format_file_display (#342)
vim.json.decode returns Blobs instead of strings when the decoded
value contains NUL bytes. Passing a Blob to strdisplaywidth triggers
E976. Guard item.name and item.directory the same way
grep_renderer.lua already guards line_content.

Fixes #310
2026-04-06 07:57:29 -07:00
Dmitriy Kovalenko 29e6480ea0 chore: release 0.5.2
Prebuild / Build Neovim aarch64-linux-android (push) Has been cancelled
Prebuild / Build Neovim aarch64-apple-darwin (push) Has been cancelled
Prebuild / Build Neovim aarch64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build Neovim aarch64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build Neovim aarch64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build Neovim x86_64-apple-darwin (push) Has been cancelled
Prebuild / Build Neovim x86_64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build Neovim x86_64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build Neovim x86_64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build C FFI aarch64-apple-darwin (push) Has been cancelled
Prebuild / Build C FFI aarch64-linux-android (push) Has been cancelled
Prebuild / Build C FFI aarch64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build C FFI aarch64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build C FFI aarch64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build C FFI x86_64-apple-darwin (push) Has been cancelled
Prebuild / Build C FFI x86_64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build C FFI x86_64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build C FFI x86_64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build MCP aarch64-apple-darwin (push) Has been cancelled
Prebuild / Build MCP aarch64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build MCP aarch64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build MCP aarch64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build MCP x86_64-apple-darwin (push) Has been cancelled
Prebuild / Build MCP x86_64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build MCP x86_64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build MCP x86_64-unknown-linux-musl (push) Has been cancelled
Prebuild / Publish Rust crates (push) Has been cancelled
Prebuild / Publish npm packages (push) Has been cancelled
Prebuild / Release (push) Has been cancelled
2026-04-04 18:12:17 -07:00
Antonio Nuno Monteiro db4cd2825c fix(preview): avoid ftplugin side effects in scratch preview buffers (#335) 2026-04-04 02:53:04 -07:00
Magnus Malm b1c4f8e7d7 chore: add serialization accessors to BigramFilter (#330)
* feat: add serialization accessors to BigramFilter

Add read-only accessors and a from_raw_parts constructor to
BigramFilter, enabling external tools to serialize/deserialize
the bigram index to/from disk without reaching into private fields.

New public methods:
- lookup(), dense_data(), words(), dense_count(), populated()
- skip_index() -> Option<&BigramFilter>
- from_raw_parts(lookup, dense_data, ...) -> Self

* Remove section header, rename from_raw_parts to reconstruct

* fix lint and rename

* fix ci

---------

Co-authored-by: Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>
2026-04-03 16:41:44 -07:00
RoomWithOutRoof feaae7de28 fix(mcp): accept whole-number float maxResults from MCP clients (#328)
* fix(mcp): accept whole-number float maxResults from MCP clients

Some MCP clients serialize integer values as floats (e.g., 30.0 instead of 30).
This change allows the MCP server to accept both usize and whole-number f64
values for the maxResults parameter, while rejecting fractional values.

* fix it properly

* fix ci

* fix spellcheck

---------

Co-authored-by: RoomWithOutRoof <taizi@sparklab.io>
Co-authored-by: Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>
2026-04-03 16:16:12 -07:00
Dmitriy Kovalenko c2d76b5466 fix: Indexing on root dirs consumes a lot of CPU and memory (#332)
* feat: Improve indexing in the root folder

* chore: Update docs for - feat: Improve indexing in the root folder
2026-04-03 12:58:43 -07:00
Dmitriy Kovalenko d4b9d16073 perf: -256kb RAM consumption for persistent index 2026-04-02 14:27:33 -07:00
John Zinno 094a35e435 fix: Decode grep context arrays in fff-node (#321)
* fix: Decode grep context arrays in fff-node

* test: Normalize Windows paths in fff-node grep context e2e
2026-04-02 11:37:39 -07:00
Nico Domino 697481fd29 fix: tostring() the result in vim.notify calls (#322) 2026-04-02 08:34:34 -07:00
Dmitriy Kovalenko bb6f32a2ad chore: release 0.5.1
Prebuild / Build Neovim aarch64-apple-darwin (push) Has been cancelled
Prebuild / Build Neovim aarch64-linux-android (push) Has been cancelled
Prebuild / Build Neovim aarch64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build Neovim aarch64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build Neovim aarch64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build Neovim x86_64-apple-darwin (push) Has been cancelled
Prebuild / Build Neovim x86_64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build Neovim x86_64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build Neovim x86_64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build C FFI aarch64-apple-darwin (push) Has been cancelled
Prebuild / Build C FFI aarch64-linux-android (push) Has been cancelled
Prebuild / Build C FFI aarch64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build C FFI aarch64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build C FFI aarch64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build C FFI x86_64-apple-darwin (push) Has been cancelled
Prebuild / Build C FFI x86_64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build C FFI x86_64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build C FFI x86_64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build MCP aarch64-apple-darwin (push) Has been cancelled
Prebuild / Build MCP aarch64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build MCP aarch64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build MCP aarch64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build MCP x86_64-apple-darwin (push) Has been cancelled
Prebuild / Build MCP x86_64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build MCP x86_64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build MCP x86_64-unknown-linux-musl (push) Has been cancelled
Prebuild / Publish npm packages (push) Has been cancelled
Prebuild / Release (push) Has been cancelled
Prebuild / Publish Rust crates (push) Has been cancelled
2026-04-01 17:06:57 -07:00
Dmitriy Kovalenko 335394f4b5 fix: Prevent scattered match filename bonus 2026-04-01 17:05:50 -07:00
Dmitriy Kovalenko 538c593b7b chore: release 0.5.0
Prebuild / Build MCP x86_64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build Neovim aarch64-apple-darwin (push) Has been cancelled
Prebuild / Build Neovim aarch64-linux-android (push) Has been cancelled
Prebuild / Build Neovim aarch64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build Neovim aarch64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build Neovim aarch64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build Neovim x86_64-apple-darwin (push) Has been cancelled
Prebuild / Build Neovim x86_64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build Neovim x86_64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build Neovim x86_64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build C FFI aarch64-apple-darwin (push) Has been cancelled
Prebuild / Build C FFI aarch64-linux-android (push) Has been cancelled
Prebuild / Build C FFI aarch64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build C FFI aarch64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build C FFI aarch64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build C FFI x86_64-apple-darwin (push) Has been cancelled
Prebuild / Build C FFI x86_64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build C FFI x86_64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build C FFI x86_64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build MCP aarch64-apple-darwin (push) Has been cancelled
Prebuild / Build MCP aarch64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build MCP aarch64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build MCP aarch64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build MCP x86_64-apple-darwin (push) Has been cancelled
Prebuild / Build MCP x86_64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build MCP x86_64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Release (push) Has been cancelled
Prebuild / Publish Rust crates (push) Has been cancelled
Prebuild / Publish npm packages (push) Has been cancelled
2026-04-01 15:09:55 -07:00
Dmitriy Kovalenko 2dc8b30d92 feat(sdk): Improve public api (#319) 2026-04-01 15:09:18 -07:00
Dmitriy Kovalenko d54b17ba81 perf: SIMD optimized perfilter for grep (#318)
* perf: SIMD optimized perfilter for grep

* chore: Update docs for - perf: SIMD optimized perfilter for grep
2026-04-01 14:50:48 -07:00
Dmitriy Kovalenko cbf260d082 perf: Improve grep line terminaition (#317)
* perf: Improve grep line terminaition

* chore: Update docs for - perf: Improve grep line terminaition
2026-03-31 12:52:20 -07:00
Dmitriy Kovalenko 371d54a478 perf: Optimize filename bonus scoring (#316)
* perf: Optimize filename bonus scoring

* iter 2

* chore: Update docs for - iter 2
2026-03-31 10:28:06 -07:00
Dmitriy Kovalenko e83b137be5 perf: Reduce index memory footprint (#314)
* perf: Reduce index memory footprint

* chore: Update docs for - perf: Reduce index memory footprint
2026-03-29 13:27:09 -07:00
Dmitriy Kovalenko eecb795a0e chore: Update docs for - perf: Reduce memory footprint of grep index 2026-03-27 16:22:47 -07:00
Dmitriy Kovalenko 7dc1f86d71 perf: Reduce memory footprint of grep index 2026-03-27 16:22:47 -07:00
Dmitriy Kovalenko 38712e2607 perf: Chunk-based prefiltering (+50% faster on small queries) 2026-03-27 16:22:47 -07:00
Dmitriy Kovalenko eb577ea4f3 fix: Crash on large repos (#309)
* fix: Crash on large repos

* chore: Update docs for - fix: Crash on large repos
2026-03-24 16:14:21 -07:00
Dmitriy Kovalenko dd56a3a8a8 fix: Double prompt character (#305)
* fix: Double prompt character

closes https://github.com/dmtrKovalenko/fff.nvim/issues/304

* chore: Update docs for - fix: Double prompt character
2026-03-20 09:18:41 -07:00
Dmitriy Kovalenko aee5fbb8c4 fix: Download binaries for new versioning system (#302) 2026-03-19 16:29:04 -07:00
Dmitriy Kovalenko 85130958bd chore: release 0.4.2
Prebuild / Build Neovim aarch64-apple-darwin (push) Has been cancelled
Prebuild / Build Neovim aarch64-linux-android (push) Has been cancelled
Prebuild / Build Neovim aarch64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build Neovim aarch64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build Neovim aarch64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build Neovim x86_64-apple-darwin (push) Has been cancelled
Prebuild / Build Neovim x86_64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build Neovim x86_64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build Neovim x86_64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build C FFI aarch64-apple-darwin (push) Has been cancelled
Prebuild / Build C FFI aarch64-linux-android (push) Has been cancelled
Prebuild / Build C FFI aarch64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build C FFI aarch64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build C FFI aarch64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build C FFI x86_64-apple-darwin (push) Has been cancelled
Prebuild / Build C FFI x86_64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build C FFI x86_64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build C FFI x86_64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build MCP aarch64-apple-darwin (push) Has been cancelled
Prebuild / Build MCP aarch64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build MCP aarch64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build MCP aarch64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build MCP x86_64-apple-darwin (push) Has been cancelled
Prebuild / Build MCP x86_64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build MCP x86_64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build MCP x86_64-unknown-linux-musl (push) Has been cancelled
Prebuild / Release (push) Has been cancelled
Prebuild / Publish Rust crates (push) Has been cancelled
Prebuild / Publish npm packages (push) Has been cancelled
2026-03-19 10:59:59 -07:00
Dmitriy Kovalenko a3f3e6a265 chore: release 0.4.1 2026-03-19 10:59:37 -07:00
Dmitriy Kovalenko b005c0a790 fix: Add reasonable memap cache limits (#301)
* fix: Allow fff to work outside of git repo

* chore: Update docs for - fix: Allow fff to work outside of git repo
2026-03-18 19:55:42 -07:00
Dmitriy Kovalenko 2ba8415039 chore: release 0.4.0
Prebuild / Build C FFI aarch64-apple-darwin (push) Has been cancelled
Prebuild / Build C FFI aarch64-linux-android (push) Has been cancelled
Prebuild / Build C FFI aarch64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build C FFI aarch64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build C FFI aarch64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build C FFI x86_64-apple-darwin (push) Has been cancelled
Prebuild / Build C FFI x86_64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build C FFI x86_64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build Neovim aarch64-apple-darwin (push) Has been cancelled
Prebuild / Build Neovim aarch64-linux-android (push) Has been cancelled
Prebuild / Build Neovim aarch64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build Neovim aarch64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build Neovim aarch64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build Neovim x86_64-apple-darwin (push) Has been cancelled
Prebuild / Build Neovim x86_64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build Neovim x86_64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build Neovim x86_64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build C FFI x86_64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build MCP aarch64-apple-darwin (push) Has been cancelled
Prebuild / Build MCP aarch64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build MCP aarch64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build MCP aarch64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build MCP x86_64-apple-darwin (push) Has been cancelled
Prebuild / Build MCP x86_64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build MCP x86_64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build MCP x86_64-unknown-linux-musl (push) Has been cancelled
Prebuild / Release (push) Has been cancelled
Prebuild / Publish Rust crates (push) Has been cancelled
Prebuild / Publish npm packages (push) Has been cancelled
2026-03-18 15:59:10 -07:00
Dmitriy Kovalenko b1be35cc5f chore: release 0.3.0 2026-03-18 15:59:00 -07:00
Dmitriy Kovalenko 9ff925e31e chore: fix relese script 2026-03-18 15:58:03 -07:00
Dmitriy Kovalenko e64d2e2a55 feat: Migrate to stable rust (#300) 2026-03-18 15:56:40 -07:00
Dmitriy Kovalenko 2b6ace888d feat: SDKs for Nodejs FFI, Rust, and C (#295)
* feat: Nodejs FFI package

* chore: Update docs for - feat: Nodejs FFI package
2026-03-18 15:39:43 -07:00
139 changed files with 18923 additions and 4124 deletions
+21
View File
@@ -90,6 +90,27 @@ jobs:
shell: bash
run: make test-lua
- name: Run version resolution tests
shell: bash
run: make test-version
- name: Run bun tests
shell: bash
if: ${{ matrix.os == 'macos-latest' }}
run: make test-bun
- name: Install Node.js
if: ${{ matrix.os != 'ubuntu-latest' }}
uses: actions/setup-node@v6
with:
node-version: "24"
- name: Install node deps
if: ${{ matrix.os != 'ubuntu-latest' }}
shell: bash
run: npm install
- name: Run node tests
if: ${{ matrix.os != 'ubuntu-latest' }}
shell: bash
run: make test-node
+120 -147
View File
@@ -2,7 +2,9 @@ name: Prebuild
on:
push:
branches: [main, feat/mcp-ai]
branches: [main, fix/download-version]
tags:
- "v*"
pull_request:
env:
@@ -22,46 +24,46 @@ jobs:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
zigbuild_target: x86_64-unknown-linux-gnu.2.17
artifact_name: target/x86_64-unknown-linux-gnu/release/libfff_nvim.so
artifact_name: target/x86_64-unknown-linux-gnu/ci/libfff_nvim.so
ext: so
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
zigbuild_target: aarch64-unknown-linux-gnu.2.17
artifact_name: target/aarch64-unknown-linux-gnu/release/libfff_nvim.so
artifact_name: target/aarch64-unknown-linux-gnu/ci/libfff_nvim.so
ext: so
# Musl (statically linked)
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
artifact_name: target/x86_64-unknown-linux-musl/release/libfff_nvim.so
artifact_name: target/x86_64-unknown-linux-musl/ci/libfff_nvim.so
ext: so
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
artifact_name: target/aarch64-unknown-linux-musl/release/libfff_nvim.so
artifact_name: target/aarch64-unknown-linux-musl/ci/libfff_nvim.so
ext: so
## Android (Termux)
- os: ubuntu-latest
target: aarch64-linux-android
artifact_name: target/aarch64-linux-android/release/libfff_nvim.so
artifact_name: target/aarch64-linux-android/ci/libfff_nvim.so
ext: so
## macOS builds
- os: macos-latest
target: x86_64-apple-darwin
artifact_name: target/x86_64-apple-darwin/release/libfff_nvim.dylib
artifact_name: target/x86_64-apple-darwin/ci/libfff_nvim.dylib
ext: dylib
- os: macos-latest
target: aarch64-apple-darwin
artifact_name: target/aarch64-apple-darwin/release/libfff_nvim.dylib
artifact_name: target/aarch64-apple-darwin/ci/libfff_nvim.dylib
ext: dylib
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact_name: target/x86_64-pc-windows-msvc/release/fff_nvim.dll
artifact_name: target/x86_64-pc-windows-msvc/ci/fff_nvim.dll
ext: dll
- os: windows-latest
target: aarch64-pc-windows-msvc
artifact_name: target/aarch64-pc-windows-msvc/release/fff_nvim.dll
artifact_name: target/aarch64-pc-windows-msvc/ci/fff_nvim.dll
ext: dll
steps:
@@ -73,7 +75,7 @@ jobs:
run: rustup target add ${{ matrix.target }}
- name: Install Zig
uses: goto-bus-stop/setup-zig@v2
uses: mlugg/setup-zig@v2
with:
version: 0.15.2
@@ -84,7 +86,7 @@ jobs:
- name: Build for Linux
if: contains(matrix.os, 'ubuntu') && !contains(matrix.target, 'android')
run: |
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-nvim --features zlob
cargo zigbuild --profile ci --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-nvim --features zlob
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
- name: Build for Android (Termux)
@@ -98,13 +100,13 @@ jobs:
export AR_aarch64_linux_android="$NDK_BIN/llvm-ar"
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$NDK_BIN/aarch64-linux-android24-clang"
cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
cargo build --profile ci --target ${{ matrix.target }} -p fff-nvim --features zlob
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
- name: Build for macOS
if: contains(matrix.os, 'macos')
run: |
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
MACOSX_DEPLOYMENT_TARGET="13" cargo build --profile ci --target ${{ matrix.target }} -p fff-nvim --features zlob
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
- name: Ad-hoc sign macOS binary
@@ -115,7 +117,7 @@ jobs:
if: contains(matrix.os, 'windows')
shell: bash
run: |
cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
cargo build --profile ci --target ${{ matrix.target }} -p fff-nvim --features zlob
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
- name: Upload artifacts
@@ -136,62 +138,62 @@ jobs:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
zigbuild_target: x86_64-unknown-linux-gnu.2.17
artifact_name: target/x86_64-unknown-linux-gnu/release/libfff_c.so
npm_package: fff-bun-linux-x64-gnu
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
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,7 +206,7 @@ jobs:
run: rustup target add ${{ matrix.target }}
- name: Install Zig
uses: goto-bus-stop/setup-zig@v2
uses: mlugg/setup-zig@v2
with:
version: 0.15.2
@@ -215,7 +217,7 @@ jobs:
- name: Build for Linux
if: contains(matrix.os, 'ubuntu') && !contains(matrix.target, 'android')
run: |
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-c --features zlob
cargo zigbuild --profile ci --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-c --features zlob
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
- name: Build for Android (Termux)
@@ -228,13 +230,13 @@ jobs:
export AR_aarch64_linux_android="$NDK_BIN/llvm-ar"
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$NDK_BIN/aarch64-linux-android24-clang"
cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
cargo build --profile ci --target ${{ matrix.target }} -p fff-c --features zlob
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
- name: Build for macOS
if: contains(matrix.os, 'macos')
run: |
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
MACOSX_DEPLOYMENT_TARGET="13" cargo build --profile ci --target ${{ matrix.target }} -p fff-c --features zlob
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
- name: Ad-hoc sign macOS binary
@@ -245,7 +247,7 @@ jobs:
if: contains(matrix.os, 'windows')
shell: bash
run: |
cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
cargo build --profile ci --target ${{ matrix.target }} -p fff-c --features zlob
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
- name: Prepare npm package
@@ -280,33 +282,33 @@ jobs:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
zigbuild_target: x86_64-unknown-linux-gnu.2.17
artifact_name: target/x86_64-unknown-linux-gnu/release/fff-mcp
artifact_name: target/x86_64-unknown-linux-gnu/ci/fff-mcp
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
zigbuild_target: aarch64-unknown-linux-gnu.2.17
artifact_name: target/aarch64-unknown-linux-gnu/release/fff-mcp
artifact_name: target/aarch64-unknown-linux-gnu/ci/fff-mcp
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
artifact_name: target/x86_64-unknown-linux-musl/release/fff-mcp
artifact_name: target/x86_64-unknown-linux-musl/ci/fff-mcp
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
artifact_name: target/aarch64-unknown-linux-musl/release/fff-mcp
artifact_name: target/aarch64-unknown-linux-musl/ci/fff-mcp
## macOS builds
- os: macos-latest
target: x86_64-apple-darwin
artifact_name: target/x86_64-apple-darwin/release/fff-mcp
artifact_name: target/x86_64-apple-darwin/ci/fff-mcp
- os: macos-latest
target: aarch64-apple-darwin
artifact_name: target/aarch64-apple-darwin/release/fff-mcp
artifact_name: target/aarch64-apple-darwin/ci/fff-mcp
## Windows builds
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact_name: target/x86_64-pc-windows-msvc/release/fff-mcp.exe
artifact_name: target/x86_64-pc-windows-msvc/ci/fff-mcp.exe
- os: windows-latest
target: aarch64-pc-windows-msvc
artifact_name: target/aarch64-pc-windows-msvc/release/fff-mcp.exe
artifact_name: target/aarch64-pc-windows-msvc/ci/fff-mcp.exe
steps:
- uses: actions/checkout@v5
@@ -317,7 +319,7 @@ jobs:
run: rustup target add ${{ matrix.target }}
- name: Install Zig
uses: goto-bus-stop/setup-zig@v2
uses: mlugg/setup-zig@v2
with:
version: 0.15.2
@@ -328,13 +330,13 @@ jobs:
- name: Build for Linux
if: contains(matrix.os, 'ubuntu')
run: |
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-mcp --features zlob
cargo zigbuild --profile ci --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-mcp --features zlob
cp "${{ matrix.artifact_name }}" "fff-mcp-${{ matrix.target }}"
- name: Build for macOS
if: contains(matrix.os, 'macos')
run: |
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-mcp --features zlob
MACOSX_DEPLOYMENT_TARGET="13" cargo build --profile ci --target ${{ matrix.target }} -p fff-mcp --features zlob
cp "${{ matrix.artifact_name }}" "fff-mcp-${{ matrix.target }}"
- name: Ad-hoc sign macOS binary
@@ -345,7 +347,7 @@ jobs:
if: contains(matrix.os, 'windows')
shell: bash
run: |
cargo build --release --target ${{ matrix.target }} -p fff-mcp --features zlob
cargo build --profile ci --target ${{ matrix.target }} -p fff-mcp --features zlob
cp "${{ matrix.artifact_name }}" "fff-mcp-${{ matrix.target }}.exe"
- name: Upload artifact
@@ -365,6 +367,9 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Install Lua
uses: leafo/gh-actions-lua@v12
- name: Download artifacts
uses: actions/download-artifact@v4
with:
@@ -428,25 +433,22 @@ jobs:
fi
done
- name: Prepare tag
id: vars
shell: bash
run: |
sha="$(git rev-parse --short HEAD)"
echo "tag=$sha" >> $GITHUB_OUTPUT
- name: Determine version
id: version
run: lua scripts/determine-version.lua
- 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.is_release == 'true' && format('v{0}', steps.version.outputs.version) || steps.version.outputs.version }}"
token: ${{ github.token }}
files: ./binaries/*
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) }}
## Neovim Plugin
- `{target}.so` / `.dylib` / `.dll` - Lua module for Neovim
@@ -462,39 +464,59 @@ jobs:
curl -fsSL https://raw.githubusercontent.com/dmtrKovalenko/fff.nvim/main/install-mcp.sh | bash
```
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/download-version' || startsWith(github.ref, 'refs/tags/v')))
|| (github.event_name == 'pull_request' && github.head_ref == 'main')
steps:
- uses: actions/checkout@v5
- name: Install Lua
uses: leafo/gh-actions-lua@v12
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-edit
run: cargo install cargo-edit
- name: Determine version
id: version
run: lua scripts/determine-version.lua
- name: Publish crates
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_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'))
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/fix/download-version' || startsWith(github.ref, 'refs/tags/v')))
|| (github.event_name == 'pull_request' && github.head_ref == 'main')
permissions:
contents: read
steps:
- uses: actions/checkout@v5
- name: Install Lua
uses: leafo/gh-actions-lua@v12
- name: Setup Node.js
uses: actions/setup-node@v4
with:
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
@@ -507,94 +529,45 @@ jobs:
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)"
cd -
fi
done
- name: Publish main package
- name: Publish bun package
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
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)"
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
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
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
### Neovim Plugin
Test with lazy.nvim:
```lua
{
"dmtrKovalenko/fff.nvim",
tag = "${{ steps.vars.outputs.short_sha }}",
}
```
### Bun/TypeScript Package
The `fff` npm package will download binaries from this release automatically.
---
*Built from ${{ github.sha }}*
cd packages/fff-node
npm install
npm run build
npm publish --tag "$TAG" --access public || echo "Failed to publish @ff-labs/fff-node (may already exist)"
+2 -6
View File
@@ -37,9 +37,7 @@ 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
fmt:
name: cargo fmt
@@ -73,6 +71,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
+2 -1
View File
@@ -1,4 +1,5 @@
doc/tags
big-repo
target/
.archive.lua
_*.lua
@@ -10,7 +11,7 @@ result
.repro/
.wrangler/
*.so
big-repo/
*.dylib
# all the perf like utility files
*.data
node_modules/
+1 -1
View File
@@ -2,7 +2,7 @@
"mcpServers": {
"fff": {
"type": "stdio",
"command": "/Users/neogoose/dev/fff.nvim/target/release/fff-mcp",
"command": "./target/release/fff-mcp",
"args": []
}
}
Generated
+730 -548
View File
File diff suppressed because it is too large Load Diff
+15 -11
View File
@@ -5,11 +5,14 @@ members = [
"crates/fff-mcp",
"crates/fff-nvim",
"crates/fff-query-parser",
"crates/fff-searcher",
"crates/fff-grep",
]
resolver = "2"
[workspace.dependencies]
fff-grep = { version = "0.5.1", path = "crates/fff-grep" }
fff-query-parser = { version = "0.5.1", path = "crates/fff-query-parser", default-features = false }
# Shared dependencies
ahash = "0.8"
bindet = "0.3"
@@ -24,8 +27,6 @@ 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"
@@ -33,19 +34,16 @@ mimalloc = "0.1.47"
zlob = "1.3.0"
mlua = { version = "0.11.1", features = ["module", "luajit"] }
neo_frizbee = "0.8.2"
notify = "8.1.0"
notify-debouncer-full = "0.7"
neo_frizbee = { version = "0.9.1", features = ["match_end_col"] }
notify = { version = "9.0.0-rc.2" }
notify-debouncer-full = { package="fff-notify-debouncer-full", version = "0.9.0" }
once_cell = "1.20.2"
parking_lot = "0.12"
pathdiff = "0.2.1"
rayon = "1.8.0"
regex = "1.11"
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"
@@ -55,5 +53,11 @@ lto = "fat"
codegen-units = 1
strip = true
[profile.ci]
inherits = "release"
# we use lto=fat locally for better SIMD for the march=native but
# on CI when we cross compiling we should not exclude any cpu flags checking
lto = "thin"
[profile.bench]
inherits = "release"
+50 -7
View File
@@ -1,10 +1,15 @@
PLENARY_DIR ?= ../plenary.nvim
.PHONY: build test test-rust test-lua test-bun test-setup prepare-bun
.PHONY: build test test-rust test-lua test-version test-bun test-node prepare-bun prepare-node set-npm-version header
all: format test lint
build:
cargo build --release --features zlob
header:
cbindgen --config crates/fff-c/cbindgen.toml --crate fff-c --output crates/fff-c/include/fff.h
test-setup:
@if [ ! -d "$(PLENARY_DIR)" ]; then \
echo "Cloning plenary.nvim..."; \
@@ -12,27 +17,55 @@ test-setup:
fi
test-rust:
cargo test --workspace --features zlob
cargo test --workspace --features zlob --exclude fff-nvim
test-lua: test-setup build
nvim --headless -u tests/minimal_init.lua \
-c "PlenaryBustedFile tests/fff_core_spec.lua" 2>&1
test-version: test-setup
nvim --headless -u tests/minimal_init.lua \
-c "PlenaryBustedFile tests/version_spec.lua" 2>&1
prepare-bun: build
mkdir -p packages/fff-bun/bin
cp target/release/libfff_c.dylib packages/fff-bun/bin/ 2>/dev/null; \
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; \
fi
prepare-node: build
mkdir -p packages/fff-node/bin
cp target/release/libfff_c.dylib packages/fff-node/bin/ 2>/dev/null; \
cp target/release/libfff_c.so packages/fff-node/bin/ 2>/dev/null; \
cp target/release/fff_c.dll packages/fff-node/bin/ 2>/dev/null; \
true
test-bun: prepare-bun
cd packages/fff-bun && bun test src/
test: test-rust test-lua test-bun
test-node: prepare-node
cd packages/fff-node && npm run build && node test/e2e.mjs
test: test-rust test-lua test-version test-bun test-node
# 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 +86,13 @@ lint-ts:
lint: lint-rust lint-lua lint-ts
check: format lint
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
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
+7 -2
View File
@@ -112,8 +112,12 @@ FFF.nvim requires neovim 0.10.0 or higher
vim.pack.add({ 'https://github.com/dmtrKovalenko/fff.nvim' })
vim.api.nvim_create_autocmd('PackChanged', {
callback = function(event)
if event.data.updated then
callback = function(ev)
local name, kind = ev.data.spec.name, ev.data.kind
if name == 'fff.nvim' and (kind == 'install' or kind == 'update') then
if not ev.data.active then
vim.cmd.packadd('fff.nvim')
end
require('fff.download').download_or_build_binary()
end
end,
@@ -285,6 +289,7 @@ require('fff').setup({
smart_case = true, -- Case-insensitive unless query has uppercase
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
trim_whitespace = false, -- Strip leading whitespace from matched lines
},
})
```
+2
View File
@@ -4,6 +4,8 @@ extend-exclude = ["/CHANGELOG.md", "data/filetypes/base.lua"]
[default.extend-words]
noice = "noice"
fo = "fo"
ba = "ba"
ue = "ue"
[default]
extend-ignore-re = [
+40 -189
View File
@@ -33,26 +33,31 @@
"bun": ">=1.0.0",
},
},
"packages/fff-mcp": {
"name": "@ff-labs/fff-mcp",
"version": "0.1.0",
"packages/fff-node": {
"name": "@ff-labs/fff-node",
"version": "0.1.37",
"bin": {
"fff-mcp": "./src/index.ts",
"fff-node": "./dist/scripts/cli.js",
},
"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-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",
},
},
},
"trustedDependencies": [
"@ff-labs/fff-bun",
],
"packages": {
"@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=="],
@@ -74,11 +79,7 @@
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@workspace:packages/fff-bun"],
"@ff-labs/fff-mcp": ["@ff-labs/fff-mcp@workspace:packages/fff-mcp"],
"@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
"@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=="],
"@ff-labs/fff-node": ["@ff-labs/fff-node@workspace:packages/fff-node"],
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PXgg5gqcS/rHwa1hF0JdM1y5TiyejVrMHoBmWY/DjtfYZoFTXie1RCFOkoG0b5diOOmUcuYarMpH7CSNTqwj+w=="],
@@ -106,192 +107,42 @@
"@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=="],
"@yuuang/ffi-rs-android-arm64": ["@yuuang/ffi-rs-android-arm64@1.3.1", "", { "os": "android", "cpu": "arm64" }, "sha512-V4nmlXdOYZEa7GOxSExVG95SLp8FE0iTq2yKeN54UlfNMr3Sik+1Ff57LcCv7qYcn4TBqnBAt5rT3FAM6T6caQ=="],
"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-darwin-arm64": ["@yuuang/ffi-rs-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YlnTMIyzfW3mAULC5ZA774nzQfFlYXM0rrfq/8ZzWt+IMbYk55a++jrI+6JeKV+1EqlDS3TFBEFtjdBNG94KzQ=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"@yuuang/ffi-rs-darwin-x64": ["@yuuang/ffi-rs-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-sI3LpQQ34SX4nyOHc5yxA7FSqs9qPEUMqW/y/wWo9cuyPpaHMFsi/BeOVYsnC0syp3FrY7gzn6RnD6PlXCktXg=="],
"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-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=="],
"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=="],
"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=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"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=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"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=="],
"express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"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=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"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=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"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=="],
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
"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=="],
"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=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"bun-types/@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"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=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}
+5 -5
View File
@@ -1,8 +1,8 @@
[package]
name = "fff-c"
version = "0.1.0"
version = "0.5.2"
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,14 @@ 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" }
fff = { package = "fff-search", path = "../fff-core" , version = "0.5.1" }
fff-query-parser = { path = "../fff-query-parser" , version = "0.5.2" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
+28
View File
@@ -0,0 +1,28 @@
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"
+539
View File
@@ -0,0 +1,539 @@
/* Generated by cbindgen — do not edit manually. */
#ifndef FFF_C_H
#define FFF_C_H
/* Generated with cbindgen:0.29.2 */
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
/**
* Result envelope returned by all `fff_*` functions.
*
* Heap-allocated — the caller must free it with `fff_free_result`.
*
* Depending on the function, the payload is delivered through different fields:
*
* | Function | Payload field | Type |
* |----------------------------|---------------|-------------------------------|
* | `fff_create_instance` | `handle` | opaque instance pointer |
* | `fff_search` | `handle` | `*mut FffSearchResult` |
* | `fff_live_grep` | `handle` | `*mut FffGrepResult` |
* | `fff_multi_grep` | `handle` | `*mut FffGrepResult` |
* | `fff_get_scan_progress` | `handle` | `*mut FffScanProgress` |
* | `fff_health_check` | `handle` | `*mut c_char` (JSON string) |
* | `fff_get_historical_query` | `handle` | `*mut c_char` (string or null)|
* | `fff_wait_for_scan` | `int_value` | 1 = completed, 0 = timed out |
* | `fff_track_query` | `int_value` | 1 = success, 0 = failure |
* | `fff_refresh_git_status` | `int_value` | number of files updated |
* | `fff_scan_files` | (none) | success flag only |
* | `fff_restart_index` | (none) | success flag only |
*
* On failure, `success` is false and `error` contains the message.
*
* **Important:** `fff_free_result` frees `error` but does **not** free `handle`.
* The caller must free the handle with the appropriate function
* (`fff_destroy`, `fff_free_search_result`, `fff_free_grep_result`,
* `fff_free_string`, etc.).
*/
typedef struct FffResult {
/**
* Whether the operation succeeded.
*/
bool success;
/**
* Error message on failure. Null on success.
*/
char *error;
/**
* Opaque pointer payload (instance handle, typed result struct, or string). May be null.
*/
void *handle;
/**
* Integer payload for simple return values (bool as 0/1, counts, etc.).
*/
int64_t int_value;
} FffResult;
/**
* A file item returned by `fff_search`.
*
* All string fields are heap-allocated and owned by the parent `FffSearchResult`.
* Free the entire result with `fff_free_search_result`.
*/
typedef struct FffFileItem {
char *path;
char *relative_path;
char *file_name;
char *git_status;
uint64_t size;
uint64_t modified;
int64_t access_frecency_score;
int64_t modification_frecency_score;
int64_t total_frecency_score;
bool is_binary;
} FffFileItem;
/**
* Score breakdown for a search result.
*/
typedef struct FffScore {
int32_t total;
int32_t base_score;
int32_t filename_bonus;
int32_t special_filename_bonus;
int32_t frecency_boost;
int32_t distance_penalty;
int32_t current_file_penalty;
int32_t combo_match_boost;
bool exact_match;
char *match_type;
} FffScore;
/**
* Location parsed from a query string (e.g. `"file.ts:42:10"`).
*
* `tag` encodes the variant:
* 0 = no location,
* 1 = line only (`line` is set),
* 2 = position (`line` + `col`),
* 3 = range (`line`/`col` = start, `end_line`/`end_col` = end).
*/
typedef struct FffLocation {
uint8_t tag;
int32_t line;
int32_t col;
int32_t end_line;
int32_t end_col;
} FffLocation;
/**
* Search result returned by `fff_search`.
*
* The caller must free this with `fff_free_search_result`.
*/
typedef struct FffSearchResult {
/**
* Pointer to a heap-allocated array of `FffFileItem` (length = `count`).
*/
struct FffFileItem *items;
/**
* Pointer to a heap-allocated array of `FffScore` (length = `count`).
*/
struct FffScore *scores;
/**
* Number of items/scores in the arrays.
*/
uint32_t count;
/**
* Total number of files that matched the query.
*/
uint32_t total_matched;
/**
* Total number of indexed files.
*/
uint32_t total_files;
/**
* Location parsed from the query string.
*/
struct FffLocation location;
} FffSearchResult;
/**
* A byte range within a matched line, used for highlighting.
*/
typedef struct FffMatchRange {
uint32_t start;
uint32_t end;
} FffMatchRange;
/**
* A single grep match with file and line information.
*
* All string fields and arrays are heap-allocated. Free the parent
* `FffGrepResult` with `fff_free_grep_result` to release everything.
*/
typedef struct FffGrepMatch {
char *path;
char *relative_path;
char *file_name;
char *git_status;
char *line_content;
struct FffMatchRange *match_ranges;
char **context_before;
char **context_after;
uint64_t size;
uint64_t modified;
int64_t total_frecency_score;
int64_t access_frecency_score;
int64_t modification_frecency_score;
uint64_t line_number;
uint64_t byte_offset;
uint32_t col;
uint32_t match_ranges_count;
uint32_t context_before_count;
uint32_t context_after_count;
uint16_t fuzzy_score;
bool has_fuzzy_score;
bool is_binary;
bool is_definition;
} FffGrepMatch;
/**
* Grep result returned by `fff_live_grep` and `fff_multi_grep`.
*
* The caller must free this with `fff_free_grep_result`.
*/
typedef struct FffGrepResult {
/**
* Pointer to a heap-allocated array of `FffGrepMatch` (length = `count`).
*/
struct FffGrepMatch *items;
/**
* Number of matches in the `items` array.
*/
uint32_t count;
/**
* Total number of matches (always equal to `count`).
*/
uint32_t total_matched;
/**
* Number of files actually opened and searched in this call.
*/
uint32_t total_files_searched;
/**
* Total number of indexed files (before any filtering).
*/
uint32_t total_files;
/**
* Number of files eligible for search after filtering.
*/
uint32_t filtered_file_count;
/**
* File offset for the next page. 0 if all files have been searched.
*/
uint32_t next_file_offset;
/**
* Regex compilation error when falling back to literal matching. Null if none.
*/
char *regex_fallback_error;
} FffGrepResult;
/**
* Scan progress returned by `fff_get_scan_progress`.
*
* The caller must free this with `fff_free_scan_progress`.
*/
typedef struct FffScanProgress {
uint64_t scanned_files_count;
bool is_scanning;
} FffScanProgress;
/**
* Create a new file finder instance.
*
* Returns an opaque pointer that must be passed to all other `fff_*` calls
* and eventually freed with `fff_destroy`.
*
* # Parameters
*
* * `base_path` directory to index (required)
* * `frecency_db_path` path to frecency LMDB database (NULL/empty to skip)
* * `history_db_path` path to query history LMDB database (NULL/empty to skip)
* * `use_unsafe_no_lock` use MDB_NOLOCK for LMDB (useful in single-process setups)
* * `warmup_mmap_cache` pre-populate mmap caches after the initial scan
* * `ai_mode` enable AI-agent optimizations (auto-track frecency on modifications)
*
* ## Safety
* String parameters must be valid null-terminated UTF-8 or NULL.
*/
struct FffResult *fff_create_instance(const char *base_path,
const char *frecency_db_path,
const char *history_db_path,
bool use_unsafe_no_lock,
bool warmup_mmap_cache,
bool ai_mode);
/**
* Destroy a file finder instance and free all its resources.
*
* ## Safety
* `fff_handle` must be a valid pointer returned by `fff_create_instance`, or null (no-op).
*/
void fff_destroy(void *fff_handle);
/**
* Perform fuzzy search on indexed files.
*
* # Parameters
*
* * `fff_handle` instance from `fff_create_instance`
* * `query` search query string
* * `current_file` path of the currently open file for deprioritization (NULL/empty to skip)
* * `max_threads` maximum worker threads (0 = auto-detect)
* * `page_index` pagination offset (0 = first page)
* * `page_size` results per page (0 = default 100)
* * `combo_boost_multiplier` score multiplier for combo matches (0 = default 100)
* * `min_combo_count` minimum combo count before boost applies (0 = default 3)
*
* ## Safety
* * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
* * `query` and `current_file` must be valid null-terminated UTF-8 strings or NULL.
*/
struct FffResult *fff_search(void *fff_handle,
const char *query,
const char *current_file,
uint32_t max_threads,
uint32_t page_index,
uint32_t page_size,
int32_t combo_boost_multiplier,
uint32_t min_combo_count);
/**
* Perform content search (grep) across indexed files.
*
* # Parameters
*
* * `fff_handle` instance from `fff_create_instance`
* * `query` search query (supports constraint syntax like `*.rs pattern`)
* * `mode` 0 = plain text (SIMD), 1 = regex, 2 = fuzzy
* * `max_file_size` skip files larger than this in bytes (0 = default 10 MB)
* * `max_matches_per_file` max matches per file (0 = unlimited)
* * `smart_case` case-insensitive when query is all lowercase
* * `file_offset` file-based pagination offset (0 = start)
* * `page_limit` max matches to return (0 = default 50)
* * `time_budget_ms` wall-clock budget in ms (0 = unlimited)
* * `before_context` context lines before each match
* * `after_context` context lines after each match
* * `classify_definitions` tag matches that are code definitions
*
* ## Safety
* * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
* * `query` must be a valid null-terminated UTF-8 string.
*/
struct FffResult *fff_live_grep(void *fff_handle,
const char *query,
uint8_t mode,
uint64_t max_file_size,
uint32_t max_matches_per_file,
bool smart_case,
uint32_t file_offset,
uint32_t page_limit,
uint64_t time_budget_ms,
uint32_t before_context,
uint32_t after_context,
bool classify_definitions);
/**
* Perform multi-pattern OR search (Aho-Corasick) across indexed files.
*
* Searches for lines matching ANY of the provided patterns using
* SIMD-accelerated multi-needle matching.
*
* # Parameters
*
* * `fff_handle` instance from `fff_create_instance`
* * `patterns_joined` patterns separated by `\n` (e.g. `"foo\nbar\nbaz"`)
* * `constraints` file filter like `"*.rs"` or `"/src/"` (NULL/empty to skip)
* * `max_file_size` skip files larger than this in bytes (0 = default 10 MB)
* * `max_matches_per_file` max matches per file (0 = unlimited)
* * `smart_case` case-insensitive when all patterns are lowercase
* * `file_offset` file-based pagination offset (0 = start)
* * `page_limit` max matches to return (0 = default 50)
* * `time_budget_ms` wall-clock budget in ms (0 = unlimited)
* * `before_context` context lines before each match
* * `after_context` context lines after each match
* * `classify_definitions` tag matches that are code definitions
*
* ## Safety
* * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
* * `patterns_joined` and `constraints` must be valid null-terminated UTF-8 or NULL.
*/
struct FffResult *fff_multi_grep(void *fff_handle,
const char *patterns_joined,
const char *constraints,
uint64_t max_file_size,
uint32_t max_matches_per_file,
bool smart_case,
uint32_t file_offset,
uint32_t page_limit,
uint64_t time_budget_ms,
uint32_t before_context,
uint32_t after_context,
bool classify_definitions);
/**
* Trigger a rescan of the file index.
*
* ## Safety
* `fff_handle` must be a valid instance pointer from `fff_create_instance`.
*/
struct FffResult *fff_scan_files(void *fff_handle);
/**
* Check if a scan is currently in progress.
*
* ## Safety
* `fff_handle` must be a valid instance pointer from `fff_create_instance`.
*/
bool fff_is_scanning(void *fff_handle);
/**
* Get scan progress information.
*
* ## Safety
* `fff_handle` must be a valid instance pointer from `fff_create_instance`.
*/
struct FffResult *fff_get_scan_progress(void *fff_handle);
/**
* Wait for initial scan to complete.
*
* ## Safety
* `fff_handle` must be a valid instance pointer from `fff_create_instance`.
*/
struct FffResult *fff_wait_for_scan(void *fff_handle, uint64_t timeout_ms);
/**
* Restart indexing in a new directory.
*
* ## Safety
* * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
* * `new_path` must be a valid null-terminated UTF-8 string.
*/
struct FffResult *fff_restart_index(void *fff_handle, const char *new_path);
/**
* Refresh git status cache.
*
* ## Safety
* `fff_handle` must be a valid instance pointer from `fff_create_instance`.
*/
struct FffResult *fff_refresh_git_status(void *fff_handle);
/**
* Track query completion for smart suggestions.
*
* ## Safety
* * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
* * `query` and `file_path` must be valid null-terminated UTF-8 strings.
*/
struct FffResult *fff_track_query(void *fff_handle, const char *query, const char *file_path);
/**
* Get historical query by offset (0 = most recent).
*
* ## Safety
* `fff_handle` must be a valid instance pointer from `fff_create_instance`.
*/
struct FffResult *fff_get_historical_query(void *fff_handle, uint64_t offset);
/**
* Get health check information.
*
* ## Safety
* * `fff_handle` must be a valid instance pointer from `fff_create_instance`, or null for
* a limited health check (version + git only).
* * `test_path` can be null or a valid null-terminated UTF-8 string.
*/
struct FffResult *fff_health_check(void *fff_handle, const char *test_path);
/**
* Free a search result returned by `fff_search`.
*
* This frees the `FffSearchResult` struct, its `items` and `scores` arrays,
* and all heap-allocated strings within each item and score.
*
* ## Safety
* `result` must be a valid pointer previously returned via `FffResult.handle`
* from `fff_search`, or null (no-op).
*/
void fff_free_search_result(struct FffSearchResult *result);
/**
* Get a pointer to the `index`-th `FffFileItem` in a search result.
*
* Returns null if `result` is null or `index >= result->count`.
* The returned pointer is valid until the search result is freed.
*
* ## Safety
* `result` must be a valid `FffSearchResult` pointer from `fff_search`.
*/
const struct FffFileItem *fff_search_result_get_item(const struct FffSearchResult *result,
uint32_t index);
/**
* Get a pointer to the `index`-th `FffScore` in a search result.
*
* Returns null if `result` is null or `index >= result->count`.
* The returned pointer is valid until the search result is freed.
*
* ## Safety
* `result` must be a valid `FffSearchResult` pointer from `fff_search`.
*/
const struct FffScore *fff_search_result_get_score(const struct FffSearchResult *result,
uint32_t index);
/**
* Free a grep result returned by `fff_live_grep` or `fff_multi_grep`.
*
* This frees the `FffGrepResult` struct, its `items` array, and all
* heap-allocated strings, match ranges, and context arrays within each match.
*
* ## Safety
* `result` must be a valid pointer previously returned via `FffResult.handle`
* from `fff_live_grep` or `fff_multi_grep`, or null (no-op).
*/
void fff_free_grep_result(struct FffGrepResult *result);
/**
* Get a pointer to the `index`-th `FffGrepMatch` in a grep result.
*
* Returns null if `result` is null or `index >= result->count`.
* The returned pointer is valid until the grep result is freed.
*
* ## Safety
* `result` must be a valid `FffGrepResult` pointer from `fff_live_grep` or `fff_multi_grep`.
*/
const struct FffGrepMatch *fff_grep_result_get_match(const struct FffGrepResult *result,
uint32_t index);
/**
* Free a scan progress result returned by `fff_get_scan_progress`.
*
* ## Safety
* `result` must be a valid pointer previously returned via `FffResult.handle`
* from `fff_get_scan_progress`, or null (no-op).
*/
void fff_free_scan_progress(struct FffScanProgress *result);
/**
* Offset a pointer by `byte_offset` bytes.
*
* General-purpose utility for FFI consumers that need pointer arithmetic
* (e.g. iterating over arrays). Returns null if `base` is null.
*
* ## Safety
* The resulting pointer must be within the bounds of the original allocation.
*/
const void *fff_ptr_offset(const void *base, uintptr_t byte_offset);
/**
* Free a result returned by any `fff_*` function.
*
* ## Safety
* `result_ptr` must be a valid pointer returned by a `fff_*` function.
*/
void fff_free_result(struct FffResult *result_ptr);
/**
* Free a string returned by `fff_*` functions.
*
* ## Safety
* `s` must be a valid C string allocated by this library.
*/
void fff_free_string(char *s);
#endif /* FFF_C_H */
+446 -311
View File
@@ -1,152 +1,122 @@
//! FFI-compatible type definitions
//!
//! These types use #[repr(C)] for C ABI compatibility and implement
//! serde traits for JSON serialization.
//! All result types use `#[repr(C)]` structs for direct memory access from any
//! language with C FFI support. No JSON serialization is used for search or grep
//! results — callers read struct fields directly.
use std::ffi::{CString, c_char, c_void};
use std::ptr;
use fff_core::git::format_git_status;
use fff_core::{FileItem, GrepMatch, GrepResult, Location, Score, SearchResult};
use serde::{Deserialize, Serialize};
use fff::git::format_git_status;
use fff::{FileItem, GrepMatch, GrepResult, Location, Score, SearchResult};
/// Result type returned by all FFI functions
/// Returned as a heap-allocated pointer that must be freed with fff_free_result
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Allocate a heap CString from a `&str`, returning a raw pointer.
fn cstring_new(s: &str) -> *mut c_char {
CString::new(s).unwrap_or_default().into_raw()
}
/// Convert a `Vec<T>` into a raw pointer + count, leaking the memory.
fn vec_to_raw<T>(v: Vec<T>) -> (*mut T, u32) {
if v.is_empty() {
return (ptr::null_mut(), 0);
}
let count = v.len() as u32;
let mut boxed = v.into_boxed_slice();
let p = boxed.as_mut_ptr();
std::mem::forget(boxed);
(p, count)
}
/// Convert a `&[String]` into a heap-allocated array of C strings.
fn strings_to_raw(v: &[String]) -> (*mut *mut c_char, u32) {
if v.is_empty() {
return (ptr::null_mut(), 0);
}
let ptrs: Vec<*mut c_char> = v.iter().map(|s| cstring_new(s)).collect();
vec_to_raw(ptrs)
}
/// Free a heap-allocated array of C strings.
///
/// ## Safety
/// `arr` must have been produced by `strings_to_raw`.
unsafe fn free_cstring_array(arr: *mut *mut c_char, count: u32) {
if arr.is_null() {
return;
}
unsafe {
let ptrs = Vec::from_raw_parts(arr, count as usize, count as usize);
for p in ptrs {
if !p.is_null() {
drop(CString::from_raw(p));
}
}
}
}
/// A file item returned by `fff_search`.
///
/// All string fields are heap-allocated and owned by the parent `FffSearchResult`.
/// Free the entire result with `fff_free_search_result`.
#[repr(C)]
pub struct FffResult {
/// Whether the operation succeeded
pub success: bool,
/// JSON data on success (null-terminated string, caller must free)
pub data: *mut c_char,
/// Error message on failure (null-terminated string, caller must free)
pub error: *mut c_char,
/// Opaque handle pointer (used by fff_create to return the instance)
pub handle: *mut c_void,
}
impl FffResult {
/// Create a successful result with no data, returned as heap pointer
pub fn ok_empty() -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: true,
data: ptr::null_mut(),
error: ptr::null_mut(),
handle: ptr::null_mut(),
}))
}
/// Create a successful result with data, returned as heap pointer
pub fn ok_data(data: &str) -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: true,
data: CString::new(data).unwrap_or_default().into_raw(),
error: ptr::null_mut(),
handle: ptr::null_mut(),
}))
}
/// Create a successful result carrying an opaque instance handle.
pub fn ok_handle(handle: *mut c_void) -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: true,
data: ptr::null_mut(),
error: ptr::null_mut(),
handle,
}))
}
/// Create an error result, returned as heap pointer
pub fn err(error: &str) -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: false,
data: ptr::null_mut(),
error: CString::new(error).unwrap_or_default().into_raw(),
handle: ptr::null_mut(),
}))
}
}
/// Initialization options (JSON-deserializable)
#[derive(Debug, Deserialize)]
pub struct InitOptions {
/// Base directory to index (required)
pub base_path: String,
/// Path to frecency database (optional, omit to skip frecency initialization)
pub frecency_db_path: Option<String>,
/// Path to query history database (optional, omit to skip query tracker initialization)
pub history_db_path: Option<String>,
/// Use unsafe no-lock mode for databases (optional, defaults to false)
#[serde(default)]
pub use_unsafe_no_lock: bool,
/// Pre-populate mmap caches for all files after initial scan so the first
/// grep search is as fast as subsequent ones (optional, defaults to false)
#[serde(default)]
pub warmup_mmap_cache: bool,
/// AI mode: automatically track frecency for all file modifications detected
/// by the background watcher (optional, defaults to false)
#[serde(default)]
pub ai_mode: bool,
}
/// Search options (JSON-deserializable)
#[derive(Debug, Default, Deserialize)]
pub struct SearchOptions {
/// Maximum threads for parallel search (0 = auto)
pub max_threads: Option<usize>,
/// Current file path (for deprioritization)
pub current_file: Option<String>,
/// Combo boost score multiplier
pub combo_boost_multiplier: Option<i32>,
/// Minimum combo count for boost
pub min_combo_count: Option<u32>,
/// Page index for pagination
pub page_index: Option<usize>,
/// Page size for pagination
pub page_size: Option<usize>,
}
/// Scan progress (JSON-serializable)
#[derive(Debug, Serialize)]
pub struct ScanProgress {
pub scanned_files_count: usize,
pub is_scanning: bool,
}
/// File item for JSON serialization
#[derive(Debug, Serialize)]
pub struct FileItemJson {
pub path: String,
pub relative_path: String,
pub file_name: String,
pub struct FffFileItem {
pub path: *mut c_char,
pub relative_path: *mut c_char,
pub file_name: *mut c_char,
pub git_status: *mut c_char,
pub size: u64,
pub modified: u64,
pub access_frecency_score: i64,
pub modification_frecency_score: i64,
pub total_frecency_score: i64,
pub git_status: String,
pub is_binary: bool,
}
impl FileItemJson {
pub fn from_file_item(item: &FileItem) -> Self {
FileItemJson {
path: item.path.to_string_lossy().to_string(),
relative_path: item.relative_path.clone(),
file_name: item.file_name.clone(),
impl From<&FileItem> for FffFileItem {
fn from(item: &FileItem) -> Self {
FffFileItem {
path: cstring_new(item.path_str()),
relative_path: cstring_new(item.relative_path()),
file_name: cstring_new(item.file_name()),
git_status: cstring_new(format_git_status(item.git_status)),
size: item.size,
modified: item.modified,
access_frecency_score: item.access_frecency_score,
modification_frecency_score: item.modification_frecency_score,
total_frecency_score: item.total_frecency_score,
git_status: format_git_status(item.git_status).to_string(),
is_binary: item.is_binary,
access_frecency_score: item.access_frecency_score as i64,
modification_frecency_score: item.modification_frecency_score as i64,
total_frecency_score: item.total_frecency_score() as i64,
is_binary: item.is_binary(),
}
}
}
/// Score for JSON serialization
#[derive(Debug, Serialize)]
pub struct ScoreJson {
impl FffFileItem {
/// ## Safety
/// All string pointers must have been allocated by `CString::into_raw`.
pub unsafe fn free_strings(&mut self) {
unsafe {
if !self.path.is_null() {
drop(CString::from_raw(self.path));
}
if !self.relative_path.is_null() {
drop(CString::from_raw(self.relative_path));
}
if !self.file_name.is_null() {
drop(CString::from_raw(self.file_name));
}
if !self.git_status.is_null() {
drop(CString::from_raw(self.git_status));
}
}
}
}
/// Score breakdown for a search result.
#[repr(C)]
pub struct FffScore {
pub total: i32,
pub base_score: i32,
pub filename_bonus: i32,
@@ -155,13 +125,14 @@ pub struct ScoreJson {
pub distance_penalty: i32,
pub current_file_penalty: i32,
pub combo_match_boost: i32,
pub path_alignment_bonus: i32,
pub exact_match: bool,
pub match_type: String,
pub match_type: *mut c_char,
}
impl ScoreJson {
pub fn from_score(score: &Score) -> Self {
ScoreJson {
impl From<&Score> for FffScore {
fn from(score: &Score) -> Self {
FffScore {
total: score.total,
base_score: score.base_score,
filename_bonus: score.filename_bonus,
@@ -170,235 +141,399 @@ impl ScoreJson {
distance_penalty: score.distance_penalty,
current_file_penalty: score.current_file_penalty,
combo_match_boost: score.combo_match_boost,
path_alignment_bonus: score.path_alignment_bonus,
exact_match: score.exact_match,
match_type: score.match_type.to_string(),
match_type: cstring_new(score.match_type),
}
}
}
/// Location for JSON serialization
#[derive(Debug, Serialize)]
#[serde(tag = "type")]
pub enum LocationJson {
#[serde(rename = "line")]
Line { line: i32 },
#[serde(rename = "position")]
Position { line: i32, col: i32 },
#[serde(rename = "range")]
Range {
start: PositionJson,
end: PositionJson,
},
impl FffScore {
/// ## Safety
/// `match_type` must have been allocated by `CString::into_raw`.
pub unsafe fn free_strings(&mut self) {
unsafe {
if !self.match_type.is_null() {
drop(CString::from_raw(self.match_type));
}
}
}
}
#[derive(Debug, Serialize)]
pub struct PositionJson {
/// Location parsed from a query string (e.g. `"file.ts:42:10"`).
///
/// `tag` encodes the variant:
/// 0 = no location,
/// 1 = line only (`line` is set),
/// 2 = position (`line` + `col`),
/// 3 = range (`line`/`col` = start, `end_line`/`end_col` = end).
#[repr(C)]
pub struct FffLocation {
pub tag: u8,
pub line: i32,
pub col: i32,
pub end_line: i32,
pub end_col: i32,
}
impl LocationJson {
pub fn from_location(loc: &Location) -> Self {
impl From<Option<&Location>> for FffLocation {
fn from(loc: Option<&Location>) -> Self {
match loc {
Location::Line(line) => LocationJson::Line { line: *line },
Location::Position { line, col } => LocationJson::Position {
None => FffLocation {
tag: 0,
line: 0,
col: 0,
end_line: 0,
end_col: 0,
},
Some(Location::Line(line)) => FffLocation {
tag: 1,
line: *line,
col: 0,
end_line: 0,
end_col: 0,
},
Some(Location::Position { line, col }) => FffLocation {
tag: 2,
line: *line,
col: *col,
end_line: 0,
end_col: 0,
},
Location::Range { start, end } => LocationJson::Range {
start: PositionJson {
line: start.0,
col: start.1,
},
end: PositionJson {
line: end.0,
col: end.1,
},
Some(Location::Range { start, end }) => FffLocation {
tag: 3,
line: start.0,
col: start.1,
end_line: end.0,
end_col: end.1,
},
}
}
}
/// Search result for JSON serialization
#[derive(Debug, Serialize)]
pub struct SearchResultJson {
pub items: Vec<FileItemJson>,
pub scores: Vec<ScoreJson>,
pub total_matched: usize,
pub total_files: usize,
pub location: Option<LocationJson>,
/// Search result returned by `fff_search`.
///
/// The caller must free this with `fff_free_search_result`.
#[repr(C)]
pub struct FffSearchResult {
/// Pointer to a heap-allocated array of `FffFileItem` (length = `count`).
pub items: *mut FffFileItem,
/// Pointer to a heap-allocated array of `FffScore` (length = `count`).
pub scores: *mut FffScore,
/// Number of items/scores in the arrays.
pub count: u32,
/// Total number of files that matched the query.
pub total_matched: u32,
/// Total number of indexed files.
pub total_files: u32,
/// Location parsed from the query string.
pub location: FffLocation,
}
impl SearchResultJson {
pub fn from_search_result(result: &SearchResult) -> Self {
SearchResultJson {
items: result
.items
.iter()
.map(|item| FileItemJson::from_file_item(item))
.collect(),
scores: result.scores.iter().map(ScoreJson::from_score).collect(),
total_matched: result.total_matched,
total_files: result.total_files,
location: result.location.as_ref().map(LocationJson::from_location),
}
impl FffSearchResult {
/// Convert a core `SearchResult` into a heap-allocated `FffSearchResult`.
pub fn from_core(result: &SearchResult) -> *mut Self {
let items: Vec<FffFileItem> = result.items.iter().map(|i| FffFileItem::from(*i)).collect();
let scores: Vec<FffScore> = result.scores.iter().map(FffScore::from).collect();
let count = items.len() as u32;
let (items_ptr, _) = vec_to_raw(items);
let (scores_ptr, _) = vec_to_raw(scores);
Box::into_raw(Box::new(FffSearchResult {
items: items_ptr,
scores: scores_ptr,
count,
total_matched: result.total_matched as u32,
total_files: result.total_files as u32,
location: FffLocation::from(result.location.as_ref()),
}))
}
}
// ============================================================================
// Multi-grep (Aho-Corasick multi-pattern) types
// ============================================================================
// ---------------------------------------------------------------------------
// Grep result types
// ---------------------------------------------------------------------------
/// Multi-grep search options (JSON-deserializable)
#[derive(Debug, Default, Deserialize)]
pub struct MultiGrepOptionsJson {
/// Patterns to search (OR logic — matches lines containing any pattern)
pub patterns: Vec<String>,
/// Optional constraint query like "*.rs" or "/src/"
pub constraints: Option<String>,
/// Maximum file size to search (bytes, default: 10MB)
pub max_file_size: Option<u64>,
/// Maximum matches per file (default: 0 = unlimited)
pub max_matches_per_file: Option<usize>,
/// Smart case: case-insensitive if all patterns are lowercase (default: true)
pub smart_case: Option<bool>,
/// File-based pagination offset (default: 0)
pub file_offset: Option<usize>,
/// Maximum matches to return per page (default: 50)
pub page_limit: Option<usize>,
/// Time budget in milliseconds, 0 = unlimited (default: 0)
pub time_budget_ms: Option<u64>,
/// Number of context lines before each match (default: 0)
pub before_context: Option<usize>,
/// Number of context lines after each match (default: 0)
pub after_context: Option<usize>,
/// Whether to classify matches as definition lines (default: false)
pub classify_definitions: Option<bool>,
/// A byte range within a matched line, used for highlighting.
#[repr(C)]
pub struct FffMatchRange {
pub start: u32,
pub end: u32,
}
// ============================================================================
// Grep (live search) types
// ============================================================================
/// Grep search options (JSON-deserializable)
#[derive(Debug, Default, Deserialize)]
pub struct GrepSearchOptionsJson {
/// Maximum file size to search (bytes, default: 10MB)
pub max_file_size: Option<u64>,
/// Maximum matches per file (default: 200)
pub max_matches_per_file: Option<usize>,
/// Smart case: case-insensitive if query is lowercase (default: true)
pub smart_case: Option<bool>,
/// File-based pagination offset (default: 0)
pub file_offset: Option<usize>,
/// Maximum matches to return (default: 50)
pub page_limit: Option<usize>,
/// Search mode: "plain", "regex", or "fuzzy" (default: "plain")
pub mode: Option<String>,
/// Time budget in milliseconds, 0 = unlimited (default: 0)
pub time_budget_ms: Option<u64>,
/// Number of context lines before each match (default: 0)
pub before_context: Option<usize>,
/// Number of context lines after each match (default: 0)
pub after_context: Option<usize>,
/// Whether to classify matches as definition lines (default: false)
pub classify_definitions: Option<bool>,
}
/// A single grep match for JSON serialization
#[derive(Debug, Serialize)]
pub struct GrepMatchJson {
/// File metadata
pub path: String,
pub relative_path: String,
pub file_name: String,
pub git_status: String,
/// A single grep match with file and line information.
///
/// All string fields and arrays are heap-allocated. Free the parent
/// `FffGrepResult` with `fff_free_grep_result` to release everything.
#[repr(C)]
pub struct FffGrepMatch {
// -- pointers (8 bytes each) --
pub path: *mut c_char,
pub relative_path: *mut c_char,
pub file_name: *mut c_char,
pub git_status: *mut c_char,
pub line_content: *mut c_char,
pub match_ranges: *mut FffMatchRange,
pub context_before: *mut *mut c_char,
pub context_after: *mut *mut c_char,
// -- 8-byte numeric fields --
pub size: u64,
pub modified: u64,
pub is_binary: bool,
pub total_frecency_score: i64,
pub access_frecency_score: i64,
pub modification_frecency_score: i64,
/// Match metadata
pub line_number: u64,
pub col: usize,
pub byte_offset: u64,
pub line_content: String,
/// Byte offset pairs (start, end) within line_content for highlighting
pub match_ranges: Vec<[u32; 2]>,
/// Fuzzy match score (only in fuzzy mode)
#[serde(skip_serializing_if = "Option::is_none")]
pub fuzzy_score: Option<u16>,
/// Whether the matched line is a code definition (struct, fn, class, etc.)
// -- 4-byte fields --
pub col: u32,
pub match_ranges_count: u32,
pub context_before_count: u32,
pub context_after_count: u32,
// -- 2-byte fields --
pub fuzzy_score: u16,
// -- 1-byte fields --
pub has_fuzzy_score: bool,
pub is_binary: bool,
pub is_definition: bool,
/// Lines before the match (context)
#[serde(skip_serializing_if = "Vec::is_empty")]
pub context_before: Vec<String>,
/// Lines after the match (context)
#[serde(skip_serializing_if = "Vec::is_empty")]
pub context_after: Vec<String>,
}
impl GrepMatchJson {
pub fn from_grep_match(m: &GrepMatch, file: &FileItem) -> Self {
GrepMatchJson {
path: file.path.to_string_lossy().to_string(),
relative_path: file.relative_path.clone(),
file_name: file.file_name.clone(),
git_status: format_git_status(file.git_status).to_string(),
impl FffGrepMatch {
fn from_core_with_file(m: &GrepMatch, file: &FileItem) -> Self {
let ranges: Vec<FffMatchRange> = m
.match_byte_offsets
.iter()
.map(|&(start, end)| FffMatchRange { start, end })
.collect();
let (match_ranges, match_ranges_count) = vec_to_raw(ranges);
let (context_before, context_before_count) = strings_to_raw(&m.context_before);
let (context_after, context_after_count) = strings_to_raw(&m.context_after);
let (has_fuzzy_score, fuzzy_score) = match m.fuzzy_score {
Some(s) => (true, s),
None => (false, 0),
};
FffGrepMatch {
path: cstring_new(file.path_str()),
relative_path: cstring_new(file.relative_path()),
file_name: cstring_new(file.file_name()),
git_status: cstring_new(format_git_status(file.git_status)),
line_content: cstring_new(&m.line_content),
match_ranges,
context_before,
context_after,
size: file.size,
modified: file.modified,
is_binary: file.is_binary,
total_frecency_score: file.total_frecency_score,
access_frecency_score: file.access_frecency_score,
modification_frecency_score: file.modification_frecency_score,
total_frecency_score: file.total_frecency_score() as i64,
access_frecency_score: file.access_frecency_score as i64,
modification_frecency_score: file.modification_frecency_score as i64,
line_number: m.line_number,
col: m.col,
byte_offset: m.byte_offset,
line_content: m.line_content.clone(),
match_ranges: m
.match_byte_offsets
.iter()
.map(|&(start, end)| [start, end])
.collect(),
fuzzy_score: m.fuzzy_score,
col: m.col as u32,
match_ranges_count,
context_before_count,
context_after_count,
fuzzy_score,
has_fuzzy_score,
is_binary: file.is_binary(),
is_definition: m.is_definition,
context_before: m.context_before.clone(),
context_after: m.context_after.clone(),
}
}
/// ## Safety
/// All pointers must have been allocated by the corresponding `from_core`.
pub unsafe fn free_fields(&mut self) {
unsafe {
if !self.path.is_null() {
drop(CString::from_raw(self.path));
}
if !self.relative_path.is_null() {
drop(CString::from_raw(self.relative_path));
}
if !self.file_name.is_null() {
drop(CString::from_raw(self.file_name));
}
if !self.git_status.is_null() {
drop(CString::from_raw(self.git_status));
}
if !self.line_content.is_null() {
drop(CString::from_raw(self.line_content));
}
if !self.match_ranges.is_null() {
drop(Vec::from_raw_parts(
self.match_ranges,
self.match_ranges_count as usize,
self.match_ranges_count as usize,
));
}
free_cstring_array(self.context_before, self.context_before_count);
free_cstring_array(self.context_after, self.context_after_count);
}
}
}
/// Grep result for JSON serialization
#[derive(Debug, Serialize)]
pub struct GrepResultJson {
pub items: Vec<GrepMatchJson>,
pub total_matched: usize,
pub total_files_searched: usize,
pub total_files: usize,
pub filtered_file_count: usize,
pub next_file_offset: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub regex_fallback_error: Option<String>,
/// Grep result returned by `fff_live_grep` and `fff_multi_grep`.
///
/// The caller must free this with `fff_free_grep_result`.
#[repr(C)]
pub struct FffGrepResult {
/// Pointer to a heap-allocated array of `FffGrepMatch` (length = `count`).
pub items: *mut FffGrepMatch,
/// Number of matches in the `items` array.
pub count: u32,
/// Total number of matches (always equal to `count`).
pub total_matched: u32,
/// Number of files actually opened and searched in this call.
pub total_files_searched: u32,
/// Total number of indexed files (before any filtering).
pub total_files: u32,
/// Number of files eligible for search after filtering.
pub filtered_file_count: u32,
/// File offset for the next page. 0 if all files have been searched.
pub next_file_offset: u32,
/// Regex compilation error when falling back to literal matching. Null if none.
pub regex_fallback_error: *mut c_char,
}
impl GrepResultJson {
pub fn from_grep_result(result: &GrepResult) -> Self {
GrepResultJson {
items: result
.matches
.iter()
.map(|m| {
let file = result.files[m.file_index];
GrepMatchJson::from_grep_match(m, file)
})
.collect(),
total_matched: result.matches.len(),
total_files_searched: result.total_files_searched,
total_files: result.total_files,
filtered_file_count: result.filtered_file_count,
next_file_offset: result.next_file_offset,
regex_fallback_error: result.regex_fallback_error.clone(),
impl FffGrepResult {
/// Convert a core `GrepResult` into a heap-allocated `FffGrepResult`.
pub fn from_core(result: &GrepResult) -> *mut Self {
let items: Vec<FffGrepMatch> = result
.matches
.iter()
.map(|m| {
let file = result.files[m.file_index];
FffGrepMatch::from_core_with_file(m, file)
})
.collect();
let (items_ptr, count) = vec_to_raw(items);
Box::into_raw(Box::new(FffGrepResult {
items: items_ptr,
count,
total_matched: result.matches.len() as u32,
total_files_searched: result.total_files_searched as u32,
total_files: result.total_files as u32,
filtered_file_count: result.filtered_file_count as u32,
next_file_offset: result.next_file_offset as u32,
regex_fallback_error: match &result.regex_fallback_error {
Some(e) => cstring_new(e),
None => ptr::null_mut(),
},
}))
}
}
/// Result envelope returned by all `fff_*` functions.
///
/// Heap-allocated — the caller must free it with `fff_free_result`.
///
/// Depending on the function, the payload is delivered through different fields:
///
/// | Function | Payload field | Type |
/// |----------------------------|---------------|-------------------------------|
/// | `fff_create_instance` | `handle` | opaque instance pointer |
/// | `fff_search` | `handle` | `*mut FffSearchResult` |
/// | `fff_live_grep` | `handle` | `*mut FffGrepResult` |
/// | `fff_multi_grep` | `handle` | `*mut FffGrepResult` |
/// | `fff_get_scan_progress` | `handle` | `*mut FffScanProgress` |
/// | `fff_health_check` | `handle` | `*mut c_char` (JSON string) |
/// | `fff_get_historical_query` | `handle` | `*mut c_char` (string or null)|
/// | `fff_wait_for_scan` | `int_value` | 1 = completed, 0 = timed out |
/// | `fff_track_query` | `int_value` | 1 = success, 0 = failure |
/// | `fff_refresh_git_status` | `int_value` | number of files updated |
/// | `fff_scan_files` | (none) | success flag only |
/// | `fff_restart_index` | (none) | success flag only |
///
/// On failure, `success` is false and `error` contains the message.
///
/// **Important:** `fff_free_result` frees `error` but does **not** free `handle`.
/// The caller must free the handle with the appropriate function
/// (`fff_destroy`, `fff_free_search_result`, `fff_free_grep_result`,
/// `fff_free_string`, etc.).
#[repr(C)]
pub struct FffResult {
/// Whether the operation succeeded.
pub success: bool,
/// Error message on failure. Null on success.
pub error: *mut c_char,
/// Opaque pointer payload (instance handle, typed result struct, or string). May be null.
pub handle: *mut c_void,
/// Integer payload for simple return values (bool as 0/1, counts, etc.).
pub int_value: i64,
}
impl FffResult {
/// Create a successful result with no payload, returned as heap pointer.
pub fn ok_empty() -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: true,
error: ptr::null_mut(),
handle: ptr::null_mut(),
int_value: 0,
}))
}
/// Create a successful result with an integer value.
pub fn ok_int(value: i64) -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: true,
error: ptr::null_mut(),
handle: ptr::null_mut(),
int_value: value,
}))
}
/// Create a successful result carrying an opaque pointer (handle, typed struct, or string).
pub fn ok_handle(handle: *mut c_void) -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: true,
error: ptr::null_mut(),
handle,
int_value: 0,
}))
}
/// Create a successful result carrying a C string in the `handle` field.
/// The caller must free it with `fff_free_string`.
pub fn ok_string(s: &str) -> *mut Self {
let cstr = CString::new(s).unwrap_or_default().into_raw();
Box::into_raw(Box::new(FffResult {
success: true,
error: ptr::null_mut(),
handle: cstr as *mut c_void,
int_value: 0,
}))
}
/// Create an error result, returned as heap pointer.
pub fn err(error: &str) -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: false,
error: CString::new(error).unwrap_or_default().into_raw(),
handle: ptr::null_mut(),
int_value: 0,
}))
}
}
/// Scan progress returned by `fff_get_scan_progress`.
/// The caller must free this with `fff_free_scan_progress`.
#[repr(C)]
pub struct FffScanProgress {
pub scanned_files_count: u64,
pub is_scanning: bool,
pub is_watcher_ready: bool,
pub is_warmup_complete: bool,
}
impl From<fff::file_picker::ScanProgress> for FffScanProgress {
fn from(p: fff::file_picker::ScanProgress) -> Self {
Self {
scanned_files_count: p.scanned_files_count as u64,
is_scanning: p.is_scanning,
is_watcher_ready: p.is_watcher_ready,
is_warmup_complete: p.is_warmup_complete,
}
}
}
+487 -286
View File
File diff suppressed because it is too large Load Diff
+24 -9
View File
@@ -1,9 +1,10 @@
[package]
name = "fff-core"
version = "0.1.0"
name = "fff-search"
version = "0.5.2"
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"
@@ -13,31 +14,31 @@ crate-type = ["rlib", "staticlib", "cdylib"]
default = []
# Enable C FFI exports
ffi = []
# Call mi_collect(true) after large allocator churn (bigram build).
# Requires mimalloc to be the global allocator (linked by fff-nvim).
mimalloc-collect = ["dep:libmimalloc-sys"]
# Use zlob (Zig-compiled C globbing library) for glob matching.
# Requires Zig to be installed. When disabled, falls back to globset (pure Rust).
zlob = ["dep:zlob", "fff-query-parser/zlob"]
[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 }
fff-query-parser = { workspace = true , version = "0.5.2" }
# External dependencies
bindet = { workspace = true }
blake3 = { workspace = true }
chrono = { workspace = true }
dirs = { workspace = true }
libc = "0.2"
git2 = { workspace = true }
glidesort = { workspace = true }
globset = { workspace = true }
grep-matcher = { workspace = true }
grep-searcher = { workspace = true }
fff-grep = { workspace = true , version = "0.5.2" }
aho-corasick = "1"
memchr = "2"
heed = { workspace = true }
@@ -50,6 +51,7 @@ once_cell = { workspace = true }
parking_lot = { workspace = true }
pathdiff = { workspace = true }
regex = { workspace = true }
regex-syntax = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
smartstring = { version = "1.0.1", features = ["serde"] }
@@ -57,6 +59,7 @@ toml = "0.8"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
zlob = { workspace = true, optional = true }
libmimalloc-sys = { version = "0.1", optional = true, features = ["extended"] }
# Platform-specific: dunce for Windows to avoid \\?\ extended path prefix
[target.'cfg(windows)'.dependencies]
dunce = { workspace = true }
@@ -65,3 +68,15 @@ dunce = { workspace = true }
criterion = { version = "0.5", features = ["html_reports"] }
rand = { version = "0.8", features = ["small_rng"] }
tempfile = "3.8"
[[bench]]
name = "parse_bench"
harness = false
[[bench]]
name = "bigram_bench"
harness = false
[[bench]]
name = "memmem_bench"
harness = false
+26
View File
@@ -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
+129
View File
@@ -0,0 +1,129 @@
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use fff_search::bigram_filter::{BigramFilter, BigramIndexBuilder};
/// Build a realistic bigram index for benchmarking.
/// Simulates a large repo by generating varied content per file.
fn build_test_index(file_count: usize) -> BigramFilter {
let builder = BigramIndexBuilder::new(file_count);
let skip_builder = BigramIndexBuilder::new(file_count);
for i in 0..file_count {
// Generate varied content so we get a mix of sparse and dense columns
let content = format!(
"struct File{i} {{ fn process() {{ let controller = read(path); }} }} // module {i}"
);
builder.add_file_content(&skip_builder, i, content.as_bytes());
}
let mut index = builder.compress(None);
let skip_index = skip_builder.compress(Some(12));
index.set_skip_index(skip_index);
index
}
fn bench_bigram_query(c: &mut Criterion) {
let file_counts = [10_000, 100_000, 500_000];
for &file_count in &file_counts {
let index = build_test_index(file_count);
eprintln!(
"Index ({} files): {} columns",
file_count,
index.columns_used(),
);
let mut group = c.benchmark_group(format!("bigram_query_{file_count}"));
group.sample_size(500);
let queries: &[(&str, &[u8])] = &[
("short_2char", b"st"),
("medium_6char", b"struct"),
("long_14char", b"let controller"),
("multi_word", b"fn process"),
];
for (name, query) in queries {
group.bench_with_input(BenchmarkId::from_parameter(name), query, |b, q| {
b.iter(|| {
let result = index.query(black_box(q));
black_box(&result);
});
});
}
group.finish();
}
}
fn bench_bigram_is_candidate(c: &mut Criterion) {
let index = build_test_index(500_000);
let candidates = match index.query(b"struct") {
Some(c) => c,
None => {
// All bigrams ubiquitous at this size — skip candidate benches
eprintln!("Skipping is_candidate bench: query returned None (all bigrams ubiquitous)");
return;
}
};
c.bench_function("is_candidate_500k", |b| {
b.iter(|| {
let mut count = 0u32;
for i in 0..500_000 {
if BigramFilter::is_candidate(black_box(&candidates), i) {
count += 1;
}
}
black_box(count)
});
});
c.bench_function("count_candidates_500k", |b| {
b.iter(|| BigramFilter::count_candidates(black_box(&candidates)));
});
}
fn bench_bigram_build(c: &mut Criterion) {
let mut group = c.benchmark_group("bigram_build");
group.sample_size(10);
let file_counts = [10_000, 100_000];
for &file_count in &file_counts {
// Pre-generate content so we only measure index building
let contents: Vec<String> = (0..file_count)
.map(|i| {
format!(
"struct File{i} {{ fn process() {{ let controller = read(path); }} }} // mod {i}"
)
})
.collect();
group.bench_with_input(
BenchmarkId::new("build_and_compress", file_count),
&file_count,
|b, &fc| {
b.iter(|| {
let builder = BigramIndexBuilder::new(fc);
let skip_builder = BigramIndexBuilder::new(fc);
for (i, content) in contents.iter().enumerate() {
builder.add_file_content(&skip_builder, i, content.as_bytes());
}
let index = builder.compress(None);
black_box(index.columns_used())
});
},
);
}
group.finish();
}
criterion_group!(
benches,
bench_bigram_query,
bench_bigram_is_candidate,
bench_bigram_build,
);
criterion_main!(benches);
+101
View File
@@ -0,0 +1,101 @@
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use fff_search::case_insensitive_memmem;
use std::path::Path;
/// Load real source files from the repository as benchmark haystacks.
/// Falls back to concatenating all .rs files under crates/ if specific files are missing.
fn load_real_files() -> Vec<(&'static str, Vec<u8>)> {
let manifest_dir = env!("CARGO_MANIFEST_DIR"); // crates/fff-core
let repo_root = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let files: &[(&str, &str)] = &[
("grep.rs/80KB", "crates/fff-core/src/grep.rs"),
("file_picker.rs/53KB", "crates/fff-core/src/file_picker.rs"),
("picker_ui.lua/96KB", "lua/fff/picker_ui.lua"),
];
let mut result = Vec::new();
for &(label, rel_path) in files {
let full_path = repo_root.join(rel_path);
if let Ok(data) = std::fs::read(&full_path) {
result.push((label, data));
}
}
// Also create a large synthetic file by concatenating all three
if result.len() == 3 {
let mut combined = Vec::new();
for (_, data) in &result {
combined.extend_from_slice(data);
}
// Repeat to get ~1MB
let base = combined.clone();
while combined.len() < 1024 * 1024 {
combined.extend_from_slice(&base);
}
combined.truncate(1024 * 1024);
result.push(("combined/1MB", combined));
}
result
}
fn bench_memmem(c: &mut Criterion) {
let mut group = c.benchmark_group("case_insensitive_memmem");
let files = load_real_files();
assert!(!files.is_empty(), "No source files found for benchmarking");
// Needles chosen to exercise different false-positive rates:
//
// "hit" needles: strings that actually appear in these source files.
// "miss" needles: strings with common first-bytes (lots of false positives
// for memchr2) but that don't exist in any of the files.
let needles: &[(&str, &[u8])] = &[
// Hits — real identifiers from the codebase
("short/hit/fn", b"fn"),
("short/hit/self", b"self"),
("medium/hit", b"search_file"),
("long/hit", b"content_cache_budget"),
// Misses — common first-bytes, guaranteed not in source
("short/miss", b"zqxjv"),
("medium/miss", b"fluxcapacitor"),
("long/miss", b"quantum_entanglement_resolver"),
];
for (file_label, haystack) in &files {
for &(needle_label, needle) in needles {
let needle_lower: Vec<u8> = needle.iter().map(|b| b.to_ascii_lowercase()).collect();
let id = format!("{file_label}/{needle_label}");
group.bench_with_input(
BenchmarkId::new("packed_pair", &id),
&(haystack, &needle_lower),
|b, &(h, n)| {
b.iter(|| black_box(case_insensitive_memmem::search_packed_pair(h, n)));
},
);
group.bench_with_input(
BenchmarkId::new("memchr2_search", &id),
&(haystack, &needle_lower),
|b, &(h, n)| {
b.iter(|| black_box(case_insensitive_memmem::search(h, n)));
},
);
group.bench_with_input(
BenchmarkId::new("scalar_baseline", &id),
&(haystack, &needle_lower),
|b, &(h, n)| {
b.iter(|| black_box(case_insensitive_memmem::search_scalar(h, n)));
},
);
}
}
group.finish();
}
criterion_group!(benches, bench_memmem);
criterion_main!(benches);
+180
View File
@@ -0,0 +1,180 @@
use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main};
use fff_query_parser::*;
fn bench_parse_simple(c: &mut Criterion) {
let parser = QueryParser::default();
c.bench_function("parse_simple_text", |b| {
b.iter(|| parser.parse(black_box("hello world")));
});
c.bench_function("parse_extension", |b| {
b.iter(|| parser.parse(black_box("*.rs")));
});
c.bench_function("parse_text_with_extension", |b| {
b.iter(|| parser.parse(black_box("name *.rs")));
});
}
fn bench_parse_complex(c: &mut Criterion) {
let parser = QueryParser::default();
c.bench_function("parse_complex_mixed", |b| {
b.iter(|| parser.parse(black_box("src name *.rs !test /lib/ status:modified")));
});
c.bench_function("parse_glob", |b| {
b.iter(|| parser.parse(black_box("**/*.rs")));
});
c.bench_function("parse_multiple_constraints", |b| {
b.iter(|| parser.parse(black_box("*.rs *.toml *.md !test !node_modules /src/")));
});
}
fn bench_parse_realistic_queries(c: &mut Criterion) {
let parser = QueryParser::default();
let queries = vec![
"file",
"test",
"mod.rs",
"src/*.rs",
"lib test",
"*.rs !test",
"src/lib/*.rs",
"/src/ name",
"status:modified *.rs",
"type:rust test !node_modules",
];
let mut group = c.benchmark_group("realistic_queries");
for query in queries.iter() {
group.throughput(Throughput::Bytes(query.len() as u64));
group.bench_with_input(BenchmarkId::from_parameter(query), query, |b, q| {
b.iter(|| parser.parse(black_box(q)));
});
}
group.finish();
}
fn bench_parse_various_lengths(c: &mut Criterion) {
let parser = QueryParser::default();
let short = "*.rs";
let medium = "src name *.rs !test";
let long = "src lib test name *.rs *.toml !node_modules !test /src/ /lib/ status:modified";
let very_long =
"a b c d e f g h i j k l m n o p q r s t u v w x y z *.rs *.toml *.md *.txt *.js";
let mut group = c.benchmark_group("query_lengths");
group.throughput(Throughput::Bytes(short.len() as u64));
group.bench_with_input(BenchmarkId::new("short", short.len()), &short, |b, q| {
b.iter(|| parser.parse(black_box(q)));
});
group.throughput(Throughput::Bytes(medium.len() as u64));
group.bench_with_input(BenchmarkId::new("medium", medium.len()), &medium, |b, q| {
b.iter(|| parser.parse(black_box(q)));
});
group.throughput(Throughput::Bytes(long.len() as u64));
group.bench_with_input(BenchmarkId::new("long", long.len()), &long, |b, q| {
b.iter(|| parser.parse(black_box(q)));
});
group.throughput(Throughput::Bytes(very_long.len() as u64));
group.bench_with_input(
BenchmarkId::new("very_long", very_long.len()),
&very_long,
|b, q| {
b.iter(|| parser.parse(black_box(q)));
},
);
group.finish();
}
fn bench_config_comparison(c: &mut Criterion) {
let file_picker = QueryParser::new(FileSearchConfig);
let grep = QueryParser::new(GrepConfig);
let query = "src name *.rs !test";
let mut group = c.benchmark_group("config_comparison");
group.bench_function("file_picker_config", |b| {
b.iter(|| file_picker.parse(black_box(query)));
});
group.bench_function("grep_config", |b| {
b.iter(|| grep.parse(black_box(query)));
});
group.finish();
}
fn bench_constraint_types(c: &mut Criterion) {
let parser = QueryParser::default();
let mut group = c.benchmark_group("constraint_types");
group.bench_function("extension", |b| {
b.iter(|| parser.parse(black_box("*.rs")));
});
group.bench_function("glob", |b| {
b.iter(|| parser.parse(black_box("**/*.rs")));
});
group.bench_function("exclude", |b| {
b.iter(|| parser.parse(black_box("!test")));
});
group.bench_function("path_segment", |b| {
b.iter(|| parser.parse(black_box("/src/")));
});
group.bench_function("git_status", |b| {
b.iter(|| parser.parse(black_box("status:modified")));
});
group.bench_function("file_type", |b| {
b.iter(|| parser.parse(black_box("type:rust")));
});
group.finish();
}
fn bench_worst_case(c: &mut Criterion) {
let parser = QueryParser::default();
// Worst case: many constraints that all need to be checked
let worst_case = "a b c d e f g h i j k l m n o p q r s t u v w x y z";
c.bench_function("worst_case_many_text_tokens", |b| {
b.iter(|| parser.parse(black_box(worst_case)));
});
// Many constraints
let many_constraints = "*.rs *.toml *.md *.txt *.js *.ts *.jsx *.tsx *.vue *.svelte";
c.bench_function("worst_case_many_constraints", |b| {
b.iter(|| parser.parse(black_box(many_constraints)));
});
}
criterion_group!(
benches,
bench_parse_simple,
bench_parse_complex,
bench_parse_realistic_queries,
bench_parse_various_lengths,
bench_config_comparison,
bench_constraint_types,
bench_worst_case,
);
criterion_main!(benches);
+181 -81
View File
@@ -1,15 +1,16 @@
use crate::error::Error;
use crate::file_picker::{FFFMode, FilePicker};
use crate::git::GitStatusCache;
use crate::shared::{SharedFrecency, SharedPicker};
use crate::sort_buffer::sort_with_buffer;
use crate::{SharedFrecency, SharedPicker};
use git2::Repository;
use notify::event::{AccessKind, AccessMode};
use notify::{Config, EventKind, RecursiveMode};
use notify::{Config, EventKind, EventKindMask, RecursiveMode};
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, NoCache, new_debouncer_opt};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::time::Duration;
use tracing::{Level, debug, error, info, warn};
@@ -36,7 +37,6 @@ pub struct BackgroundWatcher {
const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(250);
const MAX_PATHS_THRESHOLD: usize = 1024;
const MAX_SELECTIVE_WATCH_DIRS: usize = 100;
/// Minimum seconds between frecency tracks of the same file in AI mode.
/// Prevents score inflation from rapid burst edits by AI agents.
const AI_MODE_COOLDOWN_SECS: u64 = 5 * 60;
@@ -48,6 +48,7 @@ impl BackgroundWatcher {
shared_picker: SharedPicker,
shared_frecency: SharedFrecency,
mode: FFFMode,
watch_dirs: Vec<PathBuf>,
) -> Result<Self, Error> {
info!(
"Initializing background watcher for path: {}, mode: {:?}",
@@ -55,8 +56,21 @@ impl BackgroundWatcher {
mode,
);
let debouncer =
Self::create_debouncer(base_path, git_workdir, shared_picker, shared_frecency, mode)?;
let (watch_tx, watch_rx) = mpsc::channel::<PathBuf>();
// Clone shared state for the owner thread
let owner_picker = shared_picker.clone();
let owner_git_workdir = git_workdir.clone();
let debouncer = Self::create_debouncer(
base_path,
git_workdir,
shared_picker,
shared_frecency,
mode,
watch_dirs,
watch_tx,
)?;
info!("Background file watcher initialized successfully");
let stop_signal = Arc::new(AtomicBool::new(false));
@@ -68,7 +82,23 @@ impl BackgroundWatcher {
let owner_thread = std::thread::Builder::new()
.name("fff-watcher-owner".into())
.spawn(move || {
let mut debouncer = debouncer;
while !stop_clone.load(Ordering::Acquire) {
// Process pending watch requests from the event handler
// (new directories that need to be watched).
while let Ok(dir) = watch_rx.try_recv() {
match debouncer.watch(dir.as_path(), RecursiveMode::NonRecursive) {
Ok(()) => {
debug!("Added watch for new directory: {}", dir.display());
}
Err(e) => {
warn!("Failed to watch new directory {}: {}", dir.display(), e);
}
}
// Files/dirs created before the watch was set up won't
// generate inotify events. Scan and inject them now.
scan_new_directory(&dir, &mut debouncer, &owner_picker, &owner_git_workdir);
}
std::thread::park_timeout(Duration::from_secs(1));
}
// Debouncer::stop() joins the debouncer's event thread, then
@@ -95,11 +125,17 @@ impl BackgroundWatcher {
shared_picker: SharedPicker,
shared_frecency: SharedFrecency,
mode: FFFMode,
watch_dirs: Vec<PathBuf>,
watch_tx: mpsc::Sender<PathBuf>,
) -> Result<Debouncer, Error> {
// do not follow symlinks as then notifiers spawns a bunch of events for symlinked
// files that could be git ignored, we have to property differentiate those and if
// the file was edited through a
let config = Config::default().with_follow_symlinks(false);
let config = Config::default()
// do not follow symlinks as then notifiers spawns a bunch of events for symlinked
// files that could be git ignored, we have to property differentiate those and if
// the file was edited through a
.with_follow_symlinks(false)
// only the actual modification events, ignore the open syscals that we can generate by
// our own grep calls and preview window rendering
.with_event_kinds(EventKindMask::CORE);
let git_workdir_for_handler = git_workdir.clone();
let mut debouncer = new_debouncer_opt(
@@ -108,6 +144,30 @@ impl BackgroundWatcher {
{
move |result: DebounceEventResult| match result {
Ok(events) => {
// Detect newly created directories and request NonRecursive
// watches on them so we see files created inside.
// Skip gitignored directories (e.g. node_modules/) to
// avoid re-inflating the inotify watch set.
let repo = git_workdir_for_handler
.as_ref()
.and_then(|p| Repository::open(p).ok());
for debounced_event in &events {
if matches!(
debounced_event.event.kind,
EventKind::Create(_)
| EventKind::Modify(notify::event::ModifyKind::Name(_))
) {
for path in &debounced_event.event.paths {
if path.is_dir()
&& !is_git_file(path)
&& !is_path_ignored(path, &repo)
{
let _ = watch_tx.send(path.clone());
}
}
}
}
handle_debounced_events(
events,
&git_workdir_for_handler,
@@ -129,41 +189,33 @@ impl BackgroundWatcher {
config,
)?;
// Watch only non-ignored directories to avoid flooding the OS event buffer.
// On macOS, FSEvents has a fixed-size kernel buffer — watching huge gitignored
// directories like `target/` in rust causes buffer overflow, which drops real source file
// events. Instead we watch the root non-recursively (for top-level file changes
// and new directory detection) and each non-ignored subdirectory recursively.
let watch_dirs = collect_non_ignored_dirs(&base_path);
// Watch all directories NonRecursively. The watch_dirs are derived from
// the already-scanned file list so they respect .gitignore at every depth.
// On Linux (inotify) RecursiveMode::Recursive creates one watch per subdirectory
// including gitignored ones like node_modules/, which wastes kernel resources.
// NonRecursive watches only the directories that actually contain indexed files.
//
// New directories created at runtime are detected via Create events on the
// parent and dynamically added by the owner thread via the watch_tx channel.
debouncer.watch(base_path.as_path(), RecursiveMode::NonRecursive)?;
if watch_dirs.len() > MAX_SELECTIVE_WATCH_DIRS {
tracing::warn!(
"Too many non-ignored directories ({}/{}) can't efficiently watch them",
watch_dirs.len(),
MAX_SELECTIVE_WATCH_DIRS
);
debouncer.watch(base_path.as_path(), RecursiveMode::Recursive)?;
} else {
debouncer.watch(base_path.as_path(), RecursiveMode::NonRecursive)?;
for dir in &watch_dirs {
match debouncer.watch(dir.as_path(), RecursiveMode::Recursive) {
Ok(()) => {}
Err(e) => {
// Non-fatal: directory may have been removed between discovery and watch
warn!("Failed to watch directory {}: {}", dir.display(), e);
}
for dir in &watch_dirs {
match debouncer.watch(dir.as_path(), RecursiveMode::NonRecursive) {
Ok(()) => {}
Err(e) => {
// Non-fatal: directory may have been removed between discovery and watch
warn!("Failed to watch directory {}: {}", dir.display(), e);
}
}
// In selective mode the .git directory is excluded from the non-ignored
// dirs, but we still need to observe changes that affect git status
// (staging, unstaging, committing, branch switches, merges, etc.).
watch_git_status_paths(&mut debouncer, git_workdir.as_ref());
}
// The .git directory is excluded from the file list but we still need
// to observe changes that affect git status (staging, unstaging,
// committing, branch switches, merges, etc.).
watch_git_status_paths(&mut debouncer, git_workdir.as_ref());
info!(
"File watcher initialized for {} directories under {}",
"File watcher initialized for {} directories (NonRecursive) under {}",
watch_dirs.len(),
base_path.display()
);
@@ -333,9 +385,9 @@ fn handle_debounced_events(
debug!(
"on_create_or_modify({:?}) -> Some({})",
path,
file.path.display()
file.path_str()
);
files_to_update.push(file.path.clone());
files_to_update.push(PathBuf::from(file.path_str()));
}
None => {
error!("on_create_or_modify({:?}) -> None (file not added!)", path);
@@ -416,7 +468,7 @@ fn handle_debounced_events(
if need_full_git_rescan {
info!("Triggering full git rescan");
let result = FilePicker::refresh_git_status(shared_picker, shared_frecency);
let result = shared_picker.refresh_git_status(shared_frecency);
if let Err(e) = result {
error!("Failed to refresh git status: {:?}", e);
}
@@ -468,9 +520,14 @@ fn trigger_full_rescan(shared_picker: &SharedPicker, shared_frecency: &SharedFre
};
if let Err(e) = picker.trigger_rescan(shared_frecency) {
error!("Failed to trigger full rescan: {:?}", e);
} else {
info!("Full filesystem rescan completed successfully");
return;
}
info!("Full filesystem rescan completed successfully");
// Spawn background warmup + bigram rebuild (mirrors the initial scan's
// post-scan phase). The write lock is still held here but the spawned
// thread re-acquires it later — safe because the guard drops at function end.
picker.spawn_post_rescan_rebuild(shared_picker.clone());
}
fn should_include_file(path: &Path, repo: &Option<Repository>) -> bool {
@@ -479,11 +536,89 @@ fn should_include_file(path: &Path, repo: &Option<Repository>) -> bool {
return false;
}
// If there is a git repo, respect its ignore rules.
// If there is no repo (or the check fails), include the file.
match repo.as_ref() {
Some(repo) => repo.is_path_ignored(path) != Ok(true),
None => true,
None => {
// No git repo — apply basic sanity filters.
// Hidden directories are skipped by the watcher setup (hidden(true)),
// but events can still arrive for files in known non-code directories.
!is_non_code_directory(path)
}
}
}
fn is_non_code_directory(path: &Path) -> bool {
crate::ignore::is_non_code_directory(path)
}
/// After adding a NonRecursive watch on a newly created directory, scan it for
/// files and subdirectories that were created before the watch was set up.
/// This closes the race where `mkdir foo && echo > foo/bar.txt` both happen
/// before the owner thread adds a watch on `foo/`.
fn scan_new_directory(
dir: &Path,
debouncer: &mut Debouncer,
shared_picker: &SharedPicker,
git_workdir: &Option<PathBuf>,
) {
let repo = git_workdir.as_ref().and_then(|p| Repository::open(p).ok());
let mut files_to_add = Vec::new();
collect_new_entries(dir, &repo, debouncer, &mut files_to_add);
if files_to_add.is_empty() {
return;
}
let Ok(mut guard) = shared_picker.write() else {
return;
};
let Some(ref mut picker) = *guard else {
return;
};
for path in &files_to_add {
picker.on_create_or_modify(path);
}
info!(
"Scanned new directory {}: added {} files",
dir.display(),
files_to_add.len(),
);
}
fn collect_new_entries(
dir: &Path,
repo: &Option<Repository>,
debouncer: &mut Debouncer,
files: &mut Vec<PathBuf>,
) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
if !is_git_file(&path) && !is_path_ignored(&path, repo) {
let _ = debouncer.watch(&path, RecursiveMode::NonRecursive);
collect_new_entries(&path, repo, debouncer, files);
}
} else if file_type.is_file() && should_include_file(&path, repo) {
files.push(path);
}
}
}
#[inline]
fn is_path_ignored(path: &Path, repo: &Option<Repository>) -> bool {
match repo.as_ref() {
Some(repo) => repo.is_path_ignored(path) == Ok(true),
None => is_non_code_directory(path),
}
}
@@ -567,38 +702,3 @@ fn watch_git_status_paths(debouncer: &mut Debouncer, git_workdir: Option<&PathBu
warn!("Failed to watch .git/info: {}", e);
}
}
/// Collects immediate non-ignored subdirectories of `base_path` using the `ignore` crate
/// to respect .gitignore, .ignore, and global gitignore rules. This is used to set up
/// selective file watching — only non-ignored directories get a recursive watcher,
/// preventing gitignored directories like `target/` from flooding the OS event buffer.
fn collect_non_ignored_dirs(base_path: &Path) -> Vec<PathBuf> {
use ignore::WalkBuilder;
let walker = WalkBuilder::new(base_path)
.hidden(false)
.git_ignore(true)
.git_exclude(true)
.git_global(true)
.ignore(true)
.follow_links(false)
.max_depth(Some(1))
.build();
let mut dirs = Vec::new();
for entry in walker {
let Ok(entry) = entry else { continue };
let path = entry.path();
// Skip the root directory itself
if path == base_path {
continue;
}
if path.is_dir() && !is_git_file(path) {
dirs.push(path.to_path_buf());
}
}
dirs
}
+527
View File
@@ -0,0 +1,527 @@
use std::sync::atomic::{AtomicU16, AtomicU64, AtomicUsize, Ordering};
use ahash::AHashMap;
/// Maximum number of distinct bigrams tracked in the inverted index.
/// 95 printable ASCII chars (32..=126) after lowercasing → ~70 distinct → 4900 possible.
/// We cap at 5000 to cover all printable bigrams with margin.
/// 5000 columns × 62.5KB (500k files) = 305MB. For 50k files: 30MB.
const MAX_BIGRAM_COLUMNS: usize = 5000;
/// Sentinel value: bigram has no allocated column.
const NO_COLUMN: u16 = u16::MAX;
/// Temporary sync dense builder for the bigram index.
/// Builds from the many threads reading file contents in parallel
pub struct BigramIndexBuilder {
// we use lookup as atomics only in the builder because it is filled by the rayon threads
// the actual index uses pure u16 for the allocations
lookup: Vec<AtomicU16>,
/// Per-column bitset data, lazily allocated via OnceLock.
col_data: Vec<AtomicU64>,
next_column: AtomicU16,
words: usize,
file_count: usize,
populated: AtomicUsize,
}
impl BigramIndexBuilder {
pub fn new(file_count: usize) -> Self {
let words = file_count.div_ceil(64);
let mut lookup = Vec::with_capacity(65536);
lookup.resize_with(65536, || AtomicU16::new(NO_COLUMN));
let mut col_data = Vec::with_capacity(MAX_BIGRAM_COLUMNS * words);
col_data.resize_with(MAX_BIGRAM_COLUMNS * words, || AtomicU64::new(0));
Self {
lookup,
col_data,
next_column: AtomicU16::new(0),
words,
file_count,
populated: AtomicUsize::new(0),
}
}
#[inline]
fn get_or_alloc_column(&self, key: u16) -> u16 {
let current = self.lookup[key as usize].load(Ordering::Relaxed);
if current != NO_COLUMN {
return current;
}
let new_col = self.next_column.fetch_add(1, Ordering::Relaxed);
if new_col >= MAX_BIGRAM_COLUMNS as u16 {
return NO_COLUMN;
}
match self.lookup[key as usize].compare_exchange(
NO_COLUMN,
new_col,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => new_col,
Err(existing) => existing,
}
}
#[inline]
fn column_bitset(&self, col: u16) -> &[AtomicU64] {
let start = col as usize * self.words;
&self.col_data[start..start + self.words]
}
pub(crate) fn add_file_content(&self, skip_builder: &Self, file_idx: usize, content: &[u8]) {
if content.len() < 2 {
return;
}
debug_assert!(file_idx < self.file_count);
let word_idx = file_idx / 64;
let bit_mask = 1u64 << (file_idx % 64);
// Stack-local dedup bitsets: 1024 × u64 = 8 KB each, covers all 65536 bigrams with margin
// have to fit in L1 cache
let mut seen_consec = [0u64; 1024];
let mut seen_skip = [0u64; 1024];
let bytes = content;
let len = bytes.len();
// First consecutive pair (no skip bigram possible yet).
let (a, b) = (bytes[0], bytes[1]);
if (32..=126).contains(&a) && (32..=126).contains(&b) {
let key = (a.to_ascii_lowercase() as u16) << 8 | b.to_ascii_lowercase() as u16;
let w = key as usize >> 6;
let bit = 1u64 << (key as usize & 63);
seen_consec[w] |= bit;
let col = self.get_or_alloc_column(key);
if col != NO_COLUMN {
self.column_bitset(col)[word_idx].fetch_or(bit_mask, Ordering::Relaxed);
}
}
// Main loop: consecutive (i-1, i) and skip-1 (i-2, i)
for i in 2..len {
let cur = bytes[i];
// Consecutive bigram: (bytes[i-1], bytes[i])
let prev = bytes[i - 1];
if (32..=126).contains(&prev) && (32..=126).contains(&cur) {
let key = (prev.to_ascii_lowercase() as u16) << 8 | cur.to_ascii_lowercase() as u16;
let w = key as usize >> 6;
let bit = 1u64 << (key as usize & 63);
if seen_consec[w] & bit == 0 {
seen_consec[w] |= bit;
let col = self.get_or_alloc_column(key);
if col != NO_COLUMN {
self.column_bitset(col)[word_idx].fetch_or(bit_mask, Ordering::Relaxed);
}
}
}
// Skip-1 bigram: (bytes[i-2], bytes[i])
let skip_prev = bytes[i - 2];
if (32..=126).contains(&skip_prev) && (32..=126).contains(&cur) {
let key =
(skip_prev.to_ascii_lowercase() as u16) << 8 | cur.to_ascii_lowercase() as u16;
let w = key as usize >> 6;
let bit = 1u64 << (key as usize & 63);
if seen_skip[w] & bit == 0 {
seen_skip[w] |= bit;
let col = skip_builder.get_or_alloc_column(key);
if col != NO_COLUMN {
skip_builder.column_bitset(col)[word_idx]
.fetch_or(bit_mask, Ordering::Relaxed);
}
}
}
}
self.populated.fetch_add(1, Ordering::Relaxed);
skip_builder.populated.fetch_add(1, Ordering::Relaxed);
}
pub fn is_ready(&self) -> bool {
self.populated.load(Ordering::Relaxed) > 0
}
pub fn columns_used(&self) -> u16 {
self.next_column
.load(Ordering::Relaxed)
.min(MAX_BIGRAM_COLUMNS as u16)
}
/// Compress the dense builder into a compact `BigramFilter`.
///
/// Retains columns where the bigram appears in ≥`min_density_pct`% (or
/// the default ~3.1% heuristic when `None`) and <90% of indexed files.
/// Sparse columns carry too little data to justify their memory;
/// ubiquitous columns (≥90%) are nearly all-ones and barely filter.
pub fn compress(self, min_density_pct: Option<u32>) -> BigramFilter {
let cols = self.columns_used() as usize;
let words = self.words;
let file_count = self.file_count;
let populated = self.populated.load(Ordering::Relaxed);
let dense_bytes = words * 8; // cost of one dense column
let old_lookup = self.lookup;
let col_data = self.col_data;
let mut lookup: Vec<u16> = vec![NO_COLUMN; 65536];
let mut dense_data: Vec<u64> = Vec::with_capacity(cols * words);
let mut dense_count: usize = 0;
for key in 0..65536usize {
let old_col = old_lookup[key].load(Ordering::Relaxed);
if old_col == NO_COLUMN || old_col as usize >= cols {
continue;
}
let col_start = old_col as usize * words;
let bitset = &col_data[col_start..col_start + words];
// count set bits to decide if this column is worth keeping.
let mut popcount = 0u32;
for column in bitset.iter().take(words) {
popcount += column.load(Ordering::Relaxed).count_ones();
}
// drop bigrams appearing in too few files
let not_to_rare = if let Some(min_pct) = min_density_pct {
// Percentage-based: require ≥ min_pct% of populated files.
populated > 0 && (popcount as usize) * 100 >= populated * min_pct as usize
} else {
// Default: popcount ≥ words × 2 (~3.1% of files).
(popcount as usize * 4) >= dense_bytes
};
if !not_to_rare {
continue;
}
// Drop ubiquitous bigrams — columns ≥90% ones carry almost no
// filtering power and just waste memory + AND cycles.
if populated > 0 && (popcount as usize) * 10 >= populated * 9 {
continue;
}
let dense_idx = dense_count as u16;
lookup[key] = dense_idx;
dense_count += 1;
for column in bitset.iter().take(words) {
dense_data.push(column.load(Ordering::Relaxed));
}
}
// col_data + old_lookup dropped here — single deallocation each,
// no fragmentation.
BigramFilter {
lookup,
dense_data,
dense_count,
words,
file_count,
populated,
skip_index: None,
}
}
}
unsafe impl Send for BigramIndexBuilder {}
unsafe impl Sync for BigramIndexBuilder {}
/// Inverted bigram index with optional "skip-1" extension
/// Copmressed into bitset for minimal usage, the layout of this struct actually matters
#[derive(Debug)]
pub struct BigramFilter {
lookup: Vec<u16>,
/// Flat buffer of all dense column data laid out at fixed stride `words`.
/// Column `i` starts at `i * words`.
dense_data: Vec<u64>, // do not try to change this to u8 it has to be wordsize
dense_count: usize,
words: usize,
file_count: usize,
populated: usize,
/// Optional skip-1 bigram index (stride 2). Built from character pairs
/// at distance 2, e.g. "ABCDE" → (A,C),(B,D),(C,E). ANDead with the
/// consecutive bigram candidates during query to dramatically reduce
/// false positives.
skip_index: Option<Box<BigramFilter>>,
}
/// SIMD-friendly bitwise AND of two equal-length bitsets.
// Auto vectorized (don't touch)
#[inline]
fn bitset_and(result: &mut [u64], bitset: &[u64]) {
result
.iter_mut()
.zip(bitset.iter())
.for_each(|(r, b)| *r &= *b);
}
impl BigramFilter {
/// AND the posting lists for all query bigrams (consecutive + skip).
/// Returns None if no query bigrams are tracked.
pub fn query(&self, pattern: &[u8]) -> Option<Vec<u64>> {
if pattern.len() < 2 {
return None;
}
let mut result = vec![u64::MAX; self.words];
if !self.file_count.is_multiple_of(64) {
let last = self.words - 1;
result[last] = (1u64 << (self.file_count % 64)) - 1;
}
let words = self.words;
let mut has_filter = false;
let mut prev = pattern[0];
for &b in &pattern[1..] {
if (32..=126).contains(&prev) && (32..=126).contains(&b) {
let key = (prev.to_ascii_lowercase() as u16) << 8 | b.to_ascii_lowercase() as u16;
let col = self.lookup[key as usize];
if col != NO_COLUMN {
let offset = col as usize * words;
// SAFETY: compress() guarantees offset + words <= dense_data.len()
let slice = unsafe { self.dense_data.get_unchecked(offset..offset + words) };
bitset_and(&mut result, slice);
has_filter = true;
}
}
prev = b;
}
// strid-1 bigrams
if let Some(skip) = &self.skip_index
&& pattern.len() >= 3
&& let Some(skip_candidates) = skip.query_skip(pattern)
{
bitset_and(&mut result, &skip_candidates);
has_filter = true;
}
has_filter.then_some(result)
}
/// Query using stride-2 bigrams from the pattern.
/// For "ABCDE" queries with keys (A,C), (B,D), (C,E).
fn query_skip(&self, pattern: &[u8]) -> Option<Vec<u64>> {
let mut result = vec![u64::MAX; self.words];
if !self.file_count.is_multiple_of(64) {
let last = self.words - 1;
result[last] = (1u64 << (self.file_count % 64)) - 1;
}
let words = self.words;
let mut has_filter = false;
for i in 0..pattern.len().saturating_sub(2) {
let a = pattern[i];
let b = pattern[i + 2];
if (32..=126).contains(&a) && (32..=126).contains(&b) {
let key = (a.to_ascii_lowercase() as u16) << 8 | b.to_ascii_lowercase() as u16;
let col = self.lookup[key as usize];
if col != NO_COLUMN {
let offset = col as usize * words;
let slice = unsafe { self.dense_data.get_unchecked(offset..offset + words) };
bitset_and(&mut result, slice);
has_filter = true;
}
}
}
has_filter.then_some(result)
}
/// Attach a skip-1 bigram index for tighter candidate filtering.
pub fn set_skip_index(&mut self, skip: BigramFilter) {
self.skip_index = Some(Box::new(skip));
}
#[inline]
pub fn is_candidate(candidates: &[u64], file_idx: usize) -> bool {
let word = file_idx / 64;
let bit = file_idx % 64;
word < candidates.len() && candidates[word] & (1u64 << bit) != 0
}
pub fn count_candidates(candidates: &[u64]) -> usize {
candidates.iter().map(|w| w.count_ones() as usize).sum()
}
pub fn is_ready(&self) -> bool {
self.populated > 0
}
pub fn file_count(&self) -> usize {
self.file_count
}
pub fn columns_used(&self) -> usize {
self.dense_count
}
/// Total heap bytes used by this index (lookup + dense data + skip).
pub fn heap_bytes(&self) -> usize {
let lookup_bytes = self.lookup.len() * std::mem::size_of::<u16>();
let dense_bytes = self.dense_data.len() * std::mem::size_of::<u64>();
let skip_bytes = self.skip_index.as_ref().map_or(0, |s| s.heap_bytes());
lookup_bytes + dense_bytes + skip_bytes
}
/// Check whether a bigram key is present in this index.
pub fn has_key(&self, key: u16) -> bool {
self.lookup[key as usize] != NO_COLUMN
}
/// Raw lookup table (65536 entries mapping bigram key → column index).
pub fn lookup(&self) -> &[u16] {
&self.lookup
}
/// Flat dense bitset data at fixed stride `words`.
pub fn dense_data(&self) -> &[u64] {
&self.dense_data
}
/// Number of u64 words per column (= ceil(file_count / 64)).
pub fn words(&self) -> usize {
self.words
}
/// Number of dense columns retained after compression.
pub fn dense_count(&self) -> usize {
self.dense_count
}
/// Number of files that contributed content to the index.
pub fn populated(&self) -> usize {
self.populated
}
/// Reference to the optional skip-1 bigram sub-index.
pub fn skip_index(&self) -> Option<&BigramFilter> {
self.skip_index.as_deref()
}
/// Create a new bigram filter from the internal data
pub fn new(
lookup: Vec<u16>,
dense_data: Vec<u64>,
dense_count: usize,
words: usize,
file_count: usize,
populated: usize,
) -> Self {
Self {
lookup,
dense_data,
dense_count,
words,
file_count,
populated,
skip_index: None,
}
}
}
pub fn extract_bigrams(content: &[u8]) -> Vec<u16> {
if content.len() < 2 {
return Vec::new();
}
// Use a flat bitset (65536 bits = 8 KB) for dedup — faster than HashSet.
let mut seen = vec![0u64; 1024]; // 1024 * 64 = 65536 bits
let mut bigrams = Vec::new();
let mut prev = content[0];
for &b in &content[1..] {
if (32..=126).contains(&prev) && (32..=126).contains(&b) {
let key = (prev.to_ascii_lowercase() as u16) << 8 | b.to_ascii_lowercase() as u16;
let word = key as usize / 64;
let bit = 1u64 << (key as usize % 64);
if seen[word] & bit == 0 {
seen[word] |= bit;
bigrams.push(key);
}
}
prev = b;
}
bigrams
}
/// Modified and added files store their own bigram sets. Deleted files are
/// tombstoned in a bitset so they can be excluded from base query results.
/// This overlay is updated by the background watcher on every file event
/// and cleared when the base index is rebuilt.
#[derive(Debug)]
pub struct BigramOverlay {
/// Per-file bigram sets for files modified since the base was built.
/// Key = file index in the base `Vec<FileItem>`.
modified: AHashMap<usize, Vec<u16>>,
/// Tombstone bitset — one bit per base file. Set bits are excluded
/// from base query results.
tombstones: Vec<u64>,
/// Original files count this overlay was created for.
base_file_count: usize,
}
impl BigramOverlay {
pub(crate) fn new(base_file_count: usize) -> Self {
let words = base_file_count.div_ceil(64);
Self {
modified: AHashMap::new(),
tombstones: vec![0u64; words],
base_file_count,
}
}
pub(crate) fn modify_file(&mut self, file_idx: usize, content: &[u8]) {
self.modified.insert(file_idx, extract_bigrams(content));
}
pub(crate) fn delete_file(&mut self, file_idx: usize) {
if file_idx < self.base_file_count {
let word = file_idx / 64;
self.tombstones[word] |= 1u64 << (file_idx % 64);
}
self.modified.remove(&file_idx);
}
/// Return base file indices of modified files whose bigrams match ALL
/// of the given `pattern_bigrams`.
pub(crate) fn query_modified(&self, pattern_bigrams: &[u16]) -> Vec<usize> {
if pattern_bigrams.is_empty() {
return self.modified.keys().copied().collect();
}
self.modified
.iter()
.filter_map(|(&file_idx, bigrams)| {
pattern_bigrams
.iter()
.all(|pb| bigrams.contains(pb))
.then_some(file_idx)
})
.collect()
}
/// Number of base files this overlay was created for.
pub(crate) fn base_file_count(&self) -> usize {
self.base_file_count
}
/// Get the tombstone bitset for clearing base candidates.
pub(crate) fn tombstones(&self) -> &[u64] {
&self.tombstones
}
/// Get all modified file indices (for conservative overlay merging when
/// we can't extract precise bigrams, e.g. regex patterns).
pub(crate) fn modified_indices(&self) -> Vec<usize> {
self.modified.keys().copied().collect()
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,662 @@
//! SIMD-accelerated case-insensitive substring search.
//!
//! Implementations (fastest → simplest):
//! - `search_packed_pair`: AVX2 packed-pair scan (two rare bytes at known offsets)
//! - `search`: memchr2 first-byte scan + verify
//!
//! The packed-pair approach mirrors what `memchr::memmem` does internally for
//! case-sensitive search — pick two rare bytes from the needle, SIMD-scan for
//! both simultaneously, verify candidates. This gives quadratic selectivity
//! over the single-byte memchr2 approach.
// this is stolen from the memchr2 crate
const BYTE_FREQUENCIES: [u8; 256] = [
55, 52, 51, 50, 49, 48, 47, 46, 45, 103, 242, 66, 67, 229, 44, 43, // 0x00
42, 41, 40, 39, 38, 37, 36, 35, 34, 33, 56, 32, 31, 30, 29, 28, // 0x10
255, 148, 164, 149, 136, 160, 155, 173, 221, 222, 134, 122, 232, 202, 215, 224, // 0x20
208, 220, 204, 187, 183, 179, 177, 168, 178, 200, 226, 195, 154, 184, 174, 126, // 0x30
120, 191, 157, 194, 170, 189, 162, 161, 150, 193, 142, 137, 171, 176, 185,
167, // 0x40 A-O
186, 112, 175, 192, 188, 156, 140, 143, 123, 133, 128, 147, 138, 146, 114,
223, // 0x50 P-_
151, 249, 216, 238, 236, 253, 227, 218, 230, 247, 135, 180, 241, 233, 246,
244, // 0x60 a-o
231, 139, 245, 243, 251, 235, 201, 196, 240, 214, 152, 182, 205, 181, 127,
27, // 0x70 p-DEL
212, 211, 210, 213, 228, 197, 169, 159, 131, 172, 105, 80, 98, 96, 97, 81, // 0x80
207, 145, 116, 115, 144, 130, 153, 121, 107, 132, 109, 110, 124, 111, 82, 108, // 0x90
118, 141, 113, 129, 119, 125, 165, 117, 92, 106, 83, 72, 99, 93, 65, 79, // 0xa0
166, 237, 163, 199, 190, 225, 209, 203, 198, 217, 219, 206, 234, 248, 158, 239, // 0xb0
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, // 0xc0
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, // 0xd0
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, // 0xe0
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, // 0xf0
];
#[inline]
fn ascii_fold_byte(b: u8) -> u8 {
if b.is_ascii_uppercase() { b | 0x20 } else { b }
}
/// Toggle ASCII letter case by flipping bit 5.
/// `'n' → 'N'`, `'N' → 'n'`.
#[inline]
fn ascii_swap_case(b: u8) -> u8 {
b ^ 0x20
}
/// Effective frequency rank for a case-insensitive byte position.
/// Takes the max of lower/upper ranks because we must scan for both.
#[inline]
fn case_insensitive_rank(lower: u8) -> u8 {
if lower.is_ascii_lowercase() {
let upper = ascii_swap_case(lower);
BYTE_FREQUENCIES[lower as usize].max(BYTE_FREQUENCIES[upper as usize])
} else {
BYTE_FREQUENCIES[lower as usize]
}
}
/// Pick two needle positions with the rarest bytes (case-insensitive).
/// Returns (index1, index2) where index1 <= index2.
fn select_rare_pair(needle_lower: &[u8]) -> (usize, usize) {
debug_assert!(needle_lower.len() >= 2);
let mut best1 = (u8::MAX, 0usize); // (rank, position)
let mut best2 = (u8::MAX, 1usize);
for (i, &b) in needle_lower.iter().enumerate() {
let r = case_insensitive_rank(b);
if r < best1.0 {
best2 = best1;
best1 = (r, i);
} else if r < best2.0 && i != best1.1 {
best2 = (r, i);
}
}
let i1 = best1.1.min(best2.1);
let i2 = best1.1.max(best2.1);
(i1, i2)
}
#[inline]
fn verify_scalar(h: *const u8, needle_lower: &[u8]) -> bool {
for (i, _) in needle_lower.iter().enumerate() {
if ascii_fold_byte(unsafe { *h.add(i) }) != needle_lower[i] {
return false;
}
}
true
}
/// AVX2 case-insensitive verify: checks whether `needle_lower` matches
/// the haystack bytes starting at `h`, treating ASCII uppercase as lowercase.
///
/// Processes 32 bytes at a time using a SIMD trick: AVX2 only has a
/// **signed** byte compare (`cmpgt`), but we need an **unsigned** range
/// check (`'A' <= byte <= 'Z'`). The trick is to XOR every byte with
/// `0x80`, which maps the unsigned range `[0, 255]` into the signed range
/// `[-128, 127]` while preserving order. After the flip, signed `cmpgt`
/// gives correct unsigned comparisons.
///
/// Once we know which bytes are uppercase, we set bit 5 (`0x20`) on them
/// — this converts `'A'..'Z'` to `'a'..'z'` — then compare against the
/// pre-lowered needle.
#[cfg(target_arch = "x86_64")]
#[target_feature(enable = "avx2")]
unsafe fn verify_avx2(h: *const u8, needle_lower: &[u8]) -> bool {
use core::arch::x86_64::*;
let len = needle_lower.len();
let mut i = 0usize;
// Broadcast constants used every iteration:
//
// flip = 0x80 in every lane — XOR converts unsigned→signed domain
// a_minus_1 = ('A' - 1) ^ 0x80 — lower bound for the range check (signed)
// z_plus_1 = ('Z' + 1) ^ 0x80 — upper bound for the range check (signed)
// bit20 = 0x20 in every lane — OR this onto uppercase bytes to lowercase them
let flip = _mm256_set1_epi8(0x80u8 as i8);
let a_minus_1 = _mm256_set1_epi8((b'A' - 1) as i8 ^ 0x80u8 as i8);
let z_plus_1 = _mm256_set1_epi8((b'Z' + 1) as i8 ^ 0x80u8 as i8);
let bit20 = _mm256_set1_epi8(0x20u8 as i8);
while i + 32 <= len {
// Load 32 bytes from the haystack candidate position.
let hv = unsafe { _mm256_loadu_si256(h.add(i) as *const __m256i) };
// Load 32 bytes from the pre-lowercased needle.
let nv = unsafe { _mm256_loadu_si256(needle_lower.as_ptr().add(i) as *const __m256i) };
// Flip into signed domain: x = hv ^ 0x80.
// After this, unsigned ordering is preserved under signed compare.
let x = _mm256_xor_si256(hv, flip);
// ge_a[lane] = 0xFF if x[lane] > a_minus_1, i.e. hv[lane] >= 'A' (unsigned).
let ge_a = _mm256_cmpgt_epi8(x, a_minus_1);
// le_z[lane] = 0xFF if z_plus_1 > x[lane], i.e. hv[lane] <= 'Z' (unsigned).
let le_z = _mm256_cmpgt_epi8(z_plus_1, x);
// upper[lane] = 0xFF only for bytes in the range 'A'..='Z'.
let upper = _mm256_and_si256(ge_a, le_z);
// Case-fold: set bit 5 on uppercase bytes → converts 'A'..'Z' to 'a'..'z'.
// Non-letter bytes are untouched because their `upper` lane is 0x00.
let folded = _mm256_or_si256(hv, _mm256_and_si256(upper, bit20));
// Compare the folded haystack against the lowercase needle.
let eq = _mm256_cmpeq_epi8(folded, nv);
// movemask extracts the high bit of each lane into a 32-bit mask.
// All-equal → all high bits set → mask == 0xFFFFFFFF == -1i32.
if _mm256_movemask_epi8(eq) != -1i32 {
return false;
}
i += 32;
}
// Scalar tail: handle remaining bytes that don't fill a full 32-byte vector.
while i < len {
if ascii_fold_byte(unsafe { *h.add(i) }) != needle_lower[i] {
return false;
}
i += 1;
}
true
}
// ======== NEON + dotprod (aarch64) ===========================================
/// Extract a 16-bit bitmask from a NEON comparison result (each byte 0x00 or 0xFF).
/// Bit *i* of the result corresponds to byte *i* of the input vector.
#[cfg(target_arch = "aarch64")]
#[target_feature(enable = "neon")]
#[inline]
unsafe fn neon_movemask(v: core::arch::aarch64::uint8x16_t) -> u16 {
use core::arch::aarch64::*;
// AND each byte with its bit-position mask, then horizontally sum each half.
// Max possible sum per half = 1+2+4+8+16+32+64+128 = 255, fits in u8.
static BITS: [u8; 16] = [1, 2, 4, 8, 16, 32, 64, 128, 1, 2, 4, 8, 16, 32, 64, 128];
let bit_mask = unsafe { vld1q_u8(BITS.as_ptr()) };
let masked = vandq_u8(v, bit_mask);
let lo = vaddv_u8(vget_low_u8(masked));
let hi = vaddv_u8(vget_high_u8(masked));
(lo as u16) | ((hi as u16) << 8)
}
/// NEON + dotprod case-insensitive verify.
///
/// Uses unsigned range checks (NEON has `vcge`/`vcle` for unsigned bytes
/// no XOR-0x80 trick needed unlike AVX2) to detect uppercase ASCII, folds
/// to lowercase, then checks equality via UDOT: XOR the folded haystack
/// with the pre-lowered needle and dot-product the difference with itself.
/// Any non-zero byte produces a non-zero u32 lane.
///
/// The UDOT instruction is emitted via inline asm because the `vdotq_u32`
/// intrinsic is still behind an unstable feature gate on stable Rust.
#[cfg(target_arch = "aarch64")]
#[target_feature(enable = "neon,dotprod")]
unsafe fn verify_neon_dotprod(h: *const u8, needle_lower: &[u8]) -> bool {
use core::arch::aarch64::*;
let len = needle_lower.len();
let mut i = 0usize;
let a_val = vdupq_n_u8(b'A');
let z_val = vdupq_n_u8(b'Z');
let bit20 = vdupq_n_u8(0x20);
while i + 16 <= len {
let hv = unsafe { vld1q_u8(h.add(i)) };
let nv = unsafe { vld1q_u8(needle_lower.as_ptr().add(i)) };
// Unsigned range check: 'A' <= byte <= 'Z'
let upper = vandq_u8(vcgeq_u8(hv, a_val), vcleq_u8(hv, z_val));
// Case-fold: set bit 5 on uppercase bytes → 'A'..'Z' → 'a'..'z'
let folded = vorrq_u8(hv, vandq_u8(upper, bit20));
// XOR with needle — all-zero iff every byte matches.
let xored = veorq_u8(folded, nv);
// UDOT: dot(xored, xored) sums squares of 4 consecutive byte
// differences into each of the 4 u32 lanes (accumulates into zero).
// Any non-zero byte produces a positive u32 contribution.
let dots: uint32x4_t;
let zero = vdupq_n_u32(0);
unsafe {
core::arch::asm!(
"udot {d:v}.4s, {a:v}.16b, {b:v}.16b",
d = inlateout(vreg) zero => dots,
a = in(vreg) xored,
b = in(vreg) xored,
);
}
if vmaxvq_u32(dots) != 0 {
return false;
}
i += 16;
}
// Scalar tail
while i < len {
if ascii_fold_byte(unsafe { *h.add(i) }) != needle_lower[i] {
return false;
}
i += 1;
}
true
}
/// NEON packed-pair kernel: scan 16 haystack positions per iteration,
/// checking two rare bytes (case-insensitive) simultaneously.
/// Same algorithm as the AVX2 version but with 128-bit vectors.
#[cfg(target_arch = "aarch64")]
#[target_feature(enable = "neon")]
unsafe fn search_packed_pair_neon(
haystack: &[u8],
needle_lower: &[u8],
i1: usize,
i2: usize,
) -> bool {
use core::arch::aarch64::*;
let n = needle_lower.len();
let hlen = haystack.len();
let ptr = haystack.as_ptr();
let last_start = hlen - n;
let b1 = needle_lower[i1];
let b1_alt = if b1.is_ascii_lowercase() {
ascii_swap_case(b1)
} else {
b1
};
let b2 = needle_lower[i2];
let b2_alt = if b2.is_ascii_lowercase() {
ascii_swap_case(b2)
} else {
b2
};
let v1_lo = vdupq_n_u8(b1);
let v1_hi = vdupq_n_u8(b1_alt);
let v2_lo = vdupq_n_u8(b2);
let v2_hi = vdupq_n_u8(b2_alt);
let max_idx = i1.max(i2);
let max_offset = hlen.saturating_sub(max_idx + 16);
let mut offset = 0usize;
while offset <= max_offset {
let chunk1 = unsafe { vld1q_u8(ptr.add(offset + i1)) };
let chunk2 = unsafe { vld1q_u8(ptr.add(offset + i2)) };
// Case-insensitive match: OR both case variants, then AND the two positions.
let eq1 = vorrq_u8(vceqq_u8(chunk1, v1_lo), vceqq_u8(chunk1, v1_hi));
let eq2 = vorrq_u8(vceqq_u8(chunk2, v2_lo), vceqq_u8(chunk2, v2_hi));
let mut mask = unsafe { neon_movemask(vandq_u8(eq1, eq2)) };
while mask != 0 {
let bit = mask.trailing_zeros() as usize;
let candidate = offset + bit;
if candidate > last_start {
return false;
}
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
return true;
}
mask &= mask - 1;
}
offset += 16;
}
// Tail: remaining positions that couldn't fill a full vector.
if offset <= last_start {
let rare_pos =
if case_insensitive_rank(needle_lower[i1]) <= case_insensitive_rank(needle_lower[i2]) {
i1
} else {
i2
};
let rare_byte = needle_lower[rare_pos];
let tail_start = offset + rare_pos;
let tail_end = last_start + rare_pos + 1;
if tail_start < tail_end {
let tail_space = &haystack[tail_start..tail_end];
if rare_byte.is_ascii_lowercase() {
for pos in memchr::memchr2_iter(rare_byte, ascii_swap_case(rare_byte), tail_space) {
let candidate = offset + pos;
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
return true;
}
}
} else {
for pos in memchr::memchr_iter(rare_byte, tail_space) {
let candidate = offset + pos;
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
return true;
}
}
}
}
}
false
}
#[inline]
unsafe fn verify_dispatch(h: *const u8, needle_lower: &[u8]) -> bool {
#[cfg(target_arch = "x86_64")]
{
if needle_lower.len() >= 32 && std::is_x86_feature_detected!("avx2") {
return unsafe { verify_avx2(h, needle_lower) };
}
}
#[cfg(target_arch = "aarch64")]
{
if needle_lower.len() >= 16 && std::arch::is_aarch64_feature_detected!("dotprod") {
return unsafe { verify_neon_dotprod(h, needle_lower) };
}
}
verify_scalar(h, needle_lower)
}
// ── Packed-pair search (AVX2) ───────────────────────────────────────────
/// AVX2 packed-pair kernel: scan 32 haystack positions per iteration,
/// checking two rare bytes (case-insensitive) simultaneously.
/// 4 cmpeq + 2 or + 1 and + 1 movemask per 32 bytes — same memory
/// bandwidth as memchr2 but quadratic selectivity.
#[cfg(target_arch = "x86_64")]
#[target_feature(enable = "avx2")]
unsafe fn search_packed_pair_avx2(
haystack: &[u8],
needle_lower: &[u8],
i1: usize,
i2: usize,
) -> bool {
use core::arch::x86_64::*;
let n = needle_lower.len();
let hlen = haystack.len();
let ptr = haystack.as_ptr();
let last_start = hlen - n; // last valid match-start position
let b1 = needle_lower[i1];
let b1_alt = if b1.is_ascii_lowercase() {
ascii_swap_case(b1)
} else {
b1
};
let b2 = needle_lower[i2];
let b2_alt = if b2.is_ascii_lowercase() {
ascii_swap_case(b2)
} else {
b2
};
let v1_lo = _mm256_set1_epi8(b1 as i8);
let v1_hi = _mm256_set1_epi8(b1_alt as i8);
let v2_lo = _mm256_set1_epi8(b2 as i8);
let v2_hi = _mm256_set1_epi8(b2_alt as i8);
// Main loop: process 32 candidate positions per iteration.
// We load from ptr+offset+i1 and ptr+offset+i2, so we need
// offset + max(i1,i2) + 31 < hlen.
let max_idx = i1.max(i2);
let max_offset = hlen.saturating_sub(max_idx + 32);
let mut offset = 0usize;
while offset <= max_offset {
let chunk1 = unsafe { _mm256_loadu_si256(ptr.add(offset + i1) as *const __m256i) };
let chunk2 = unsafe { _mm256_loadu_si256(ptr.add(offset + i2) as *const __m256i) };
// Case-insensitive match: OR both case variants, then AND the two positions.
let eq1 = _mm256_or_si256(
_mm256_cmpeq_epi8(chunk1, v1_lo),
_mm256_cmpeq_epi8(chunk1, v1_hi),
);
let eq2 = _mm256_or_si256(
_mm256_cmpeq_epi8(chunk2, v2_lo),
_mm256_cmpeq_epi8(chunk2, v2_hi),
);
let mut mask = _mm256_movemask_epi8(_mm256_and_si256(eq1, eq2)) as u32;
while mask != 0 {
let bit = mask.trailing_zeros() as usize;
let candidate = offset + bit;
if candidate > last_start {
// Past the end — no more valid positions in this or future chunks.
return false;
}
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
return true;
}
mask &= mask - 1;
}
offset += 32;
}
// Tail: remaining positions that couldn't fill a full vector.
// Use memchr2 on the rarest byte for these last few positions.
if offset <= last_start {
let rare_pos =
if case_insensitive_rank(needle_lower[i1]) <= case_insensitive_rank(needle_lower[i2]) {
i1
} else {
i2
};
let rare_byte = needle_lower[rare_pos];
let tail_start = offset + rare_pos;
let tail_end = last_start + rare_pos + 1;
if tail_start < tail_end {
let tail_space = &haystack[tail_start..tail_end];
if rare_byte.is_ascii_lowercase() {
for pos in memchr::memchr2_iter(rare_byte, ascii_swap_case(rare_byte), tail_space) {
let candidate = offset + pos;
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
return true;
}
}
} else {
for pos in memchr::memchr_iter(rare_byte, tail_space) {
let candidate = offset + pos;
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
return true;
}
}
}
}
}
false
}
/// Packed-pair case-insensitive substring search.
///
/// Selects the two rarest bytes from the needle (using the memchr byte
/// frequency heuristic), then SIMD-scans for both at their known offsets
/// simultaneously. Falls back to `search` for needles shorter than 2 bytes.
pub fn search_packed_pair(haystack: &[u8], needle_lower: &[u8]) -> bool {
let n = needle_lower.len();
if n == 0 {
return true;
}
if n < 2 {
return search(haystack, needle_lower);
}
if n > haystack.len() {
return false;
}
let (i1, i2) = select_rare_pair(needle_lower);
#[cfg(target_arch = "x86_64")]
{
if std::is_x86_feature_detected!("avx2") {
// Need enough haystack for at least one vector load.
let max_idx = i1.max(i2);
if haystack.len() >= max_idx + 32 {
return unsafe { search_packed_pair_avx2(haystack, needle_lower, i1, i2) };
}
}
}
#[cfg(target_arch = "aarch64")]
{
// The NEON packed-pair scan checks 16 bytes/iteration with ~7 ops,
// while memchr's optimized loop processes more bytes with fewer ops.
// Packed-pair wins when the first byte is common (lots of false
// positives for memchr2 that we avoid). But when the first byte is
// rare (z, q, x, ...) memchr2 has no false positives and its raw
// throughput dominates. Threshold 200 on the frequency table splits
// common letters (s=243, e=253, f=227) from rare ones (z=152, q=139).
let first_byte_rank = case_insensitive_rank(needle_lower[0]);
let max_idx = i1.max(i2);
if first_byte_rank >= 200 && haystack.len() >= max_idx + 16 {
return unsafe { search_packed_pair_neon(haystack, needle_lower, i1, i2) };
}
}
// Fallback for short haystacks or non-SIMD platforms.
search(haystack, needle_lower)
}
// ── Original memchr2 first-byte search ──────────────────────────────────
/// Case-insensitive search using memchr2 on the first byte.
pub fn search(haystack: &[u8], needle_lower: &[u8]) -> bool {
let n = needle_lower.len();
if n == 0 {
return true;
}
if n > haystack.len() {
return false;
}
let search_space = &haystack[..=haystack.len() - n];
let first = needle_lower[0];
if first.is_ascii_lowercase() {
let alt = ascii_swap_case(first);
for pos in memchr::memchr2_iter(first, alt, search_space) {
if unsafe { verify_dispatch(haystack.as_ptr().add(pos), needle_lower) } {
return true;
}
}
} else {
for pos in memchr::memchr_iter(first, search_space) {
if unsafe { verify_dispatch(haystack.as_ptr().add(pos), needle_lower) } {
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_case_insensitive() {
assert!(search_packed_pair(b"Hello World", b"hello"));
assert!(search_packed_pair(b"Hello World", b"world"));
assert!(search_packed_pair(b"NOMORE bugs", b"nomore"));
assert!(!search_packed_pair(b"Hello World", b"xyz"));
}
#[test]
fn edge_cases() {
assert!(search_packed_pair(b"ab", b"ab"));
assert!(search_packed_pair(b"AB", b"ab"));
assert!(!search_packed_pair(b"a", b"ab"));
assert!(search_packed_pair(b"anything", b""));
assert!(!search_packed_pair(b"", b"x"));
}
#[test]
fn packed_pair_matches_search() {
let haystacks: &[&[u8]] = &[
b"The quick brown fox jumps over the lazy dog",
b"int mutex_lock(struct mutex *lock) { return 0; }",
b"#define NOMORE_RETRIES 5\nif (nomore) return;",
b"abcdefghijklmnopqrstuvwxyz",
b"short",
];
let needles: &[&[u8]] = &[b"fox", b"mutex", b"nomore", b"xyz", b"the", b"short", b"qr"];
for h in haystacks {
for n in needles {
let lower: Vec<u8> = n.iter().map(|b| b.to_ascii_lowercase()).collect();
assert_eq!(
search_packed_pair(h, &lower),
search(h, &lower),
"mismatch for haystack={:?} needle={:?}",
std::str::from_utf8(h),
std::str::from_utf8(n),
);
}
}
}
#[test]
fn long_haystack_neon_path() {
// Haystack > 16 bytes exercises NEON packed-pair search loop
let haystack =
b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaTHIS_IS_A_LONG_NEEDLE_TESTbbbbbbbbbbbbbbbbbb";
assert!(search_packed_pair(haystack, b"this_is_a_long_needle_test"));
assert!(!search_packed_pair(
haystack,
b"this_is_a_long_needle_testz"
));
// Needle >= 16 bytes exercises NEON dotprod verify
let long_needle = b"struct mutex *lock";
let haystack2 = b"int STRUCT MUTEX *LOCK(struct mutex *lock) { return 0; }";
assert!(search_packed_pair(haystack2, long_needle));
// All uppercase haystack, lowercase needle
let upper_hay = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
assert!(search_packed_pair(upper_hay, b"qrstuvwxyz0123456789a"));
assert!(!search_packed_pair(upper_hay, b"qrstuvwxyz01234567899"));
// Needle at very end
let end_hay = b"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxfind_me";
assert!(search_packed_pair(end_hay, b"find_me"));
// Needle at very start
assert!(search_packed_pair(end_hay, b"xx"));
// 1KB haystack with needle near the end
let mut big = vec![b'z'; 1024];
big[1000..1010].copy_from_slice(b"hElLo_WoRl");
assert!(search_packed_pair(&big, b"hello_wo"));
assert!(!search_packed_pair(&big, b"hello_world"));
}
#[test]
fn rare_pair_selection() {
// For "nomore": n=246, o=244, m=233, o=244, r=245, e=253
// Rarest positions should include 'm' (pos 2, rank 233)
let (i1, i2) = select_rare_pair(b"nomore");
let ranks: Vec<u8> = b"nomore"
.iter()
.map(|&b| case_insensitive_rank(b))
.collect();
let r1 = ranks[i1];
let r2 = ranks[i2];
// Both selected ranks should be <= all other ranks
for (i, &r) in ranks.iter().enumerate() {
if i != i1 && i != i2 {
assert!(r1 <= r || r2 <= r, "pair ({i1},{i2}) not optimal");
}
}
}
}
+75 -19
View File
@@ -12,6 +12,32 @@ use smallvec::SmallVec;
use crate::git::is_modified_status;
/// Case-insensitive ASCII substring search without allocation.
/// `needle` must already be lowercase.
#[inline]
fn contains_ascii_ci(haystack: &str, needle: &str) -> bool {
let h = haystack.as_bytes();
let n = needle.as_bytes();
if n.len() > h.len() {
return false;
}
if n.is_empty() {
return true;
}
let first = n[0];
for i in 0..=(h.len() - n.len()) {
if h[i].to_ascii_lowercase() == first
&& h[i..i + n.len()]
.iter()
.zip(n)
.all(|(a, b)| a.to_ascii_lowercase() == *b)
{
return true;
}
}
false
}
/// Minimum item count before switching to parallel iteration with rayon.
/// Below this threshold, the overhead of thread pool dispatch outweighs the benefit.
const PAR_THRESHOLD: usize = 10_000;
@@ -22,9 +48,6 @@ pub trait Constrainable {
/// The file's relative path (e.g. "src/main.rs")
fn relative_path(&self) -> &str;
/// The file's lowercased relative path for case-insensitive matching
fn relative_path_lower(&self) -> &str;
/// The file name component (e.g. "main.rs")
fn file_name(&self) -> &str;
@@ -43,26 +66,29 @@ pub trait Constrainable {
/// - `path_ends_with_suffix("xlibswscale/input.c", "libswscale/input.c")` → false (no boundary)
#[inline]
pub fn path_ends_with_suffix(path: &str, suffix: &str) -> bool {
if path.len() < suffix.len() {
let path_bytes = path.as_bytes();
let suffix_bytes = suffix.as_bytes();
if path_bytes.len() < suffix_bytes.len() {
return false;
}
let start = path.len() - suffix.len();
if !path[start..].eq_ignore_ascii_case(suffix) {
let start = path_bytes.len() - suffix_bytes.len();
if !path_bytes[start..].eq_ignore_ascii_case(suffix_bytes) {
return false;
}
// Exact match, or the character before is /
start == 0 || path.as_bytes()[start - 1] == b'/'
start == 0 || path_bytes[start - 1] == b'/'
}
/// Check if file extension matches (without allocation)
#[inline]
pub fn file_has_extension(file_name: &str, ext: &str) -> bool {
if file_name.len() <= ext.len() + 1 {
let name_bytes = file_name.as_bytes();
let ext_bytes = ext.as_bytes();
if name_bytes.len() <= ext_bytes.len() + 1 {
return false;
}
let start = file_name.len() - ext.len() - 1;
file_name.as_bytes().get(start) == Some(&b'.')
&& file_name[start + 1..].eq_ignore_ascii_case(ext)
let start = name_bytes.len() - ext_bytes.len() - 1;
name_bytes.get(start) == Some(&b'.') && name_bytes[start + 1..].eq_ignore_ascii_case(ext_bytes)
}
/// Check if path contains segment (without allocation)
@@ -71,28 +97,29 @@ pub fn file_has_extension(file_name: &str, ext: &str) -> bool {
#[inline]
pub fn path_contains_segment(path: &str, segment: &str) -> bool {
let path_bytes = path.as_bytes();
let segment_len = segment.len();
let segment_bytes = segment.as_bytes();
let segment_len = segment_bytes.len();
// Check segment/ at start of path
if path.len() > segment_len
if path_bytes.len() > segment_len
&& path_bytes.get(segment_len) == Some(&b'/')
&& path[..segment_len].eq_ignore_ascii_case(segment)
&& path_bytes[..segment_len].eq_ignore_ascii_case(segment_bytes)
{
return true;
}
// Check /segment/ anywhere using byte scanning
if path.len() < segment_len + 2 {
if path_bytes.len() < segment_len + 2 {
return false;
}
for i in 0..path.len().saturating_sub(segment_len + 1) {
for i in 0..path_bytes.len().saturating_sub(segment_len + 1) {
if path_bytes[i] == b'/' {
let start = i + 1;
let end = start + segment_len;
if end < path.len()
if end < path_bytes.len()
&& path_bytes[end] == b'/'
&& path[start..end].eq_ignore_ascii_case(segment)
&& path_bytes[start..end].eq_ignore_ascii_case(segment_bytes)
{
return true;
}
@@ -152,7 +179,7 @@ fn item_matches_constraint_at_index<T: Constrainable>(
}
// only works with negation
Constraint::Text(text) => item.relative_path_lower().contains(text),
Constraint::Text(text) => contains_ascii_ci(item.relative_path(), text),
// Parts and Exclude are handled at a higher level
Constraint::Parts(_) | Constraint::Exclude(_) | Constraint::FileType(_) => true,
@@ -446,4 +473,33 @@ mod tests {
assert!(path_ends_with_suffix("src/main.rs", "src/main.rs"));
assert!(path_ends_with_suffix("crates/src/main.rs", "src/main.rs"));
}
#[test]
fn test_path_ends_with_suffix_unicode_apostrophe_mismatch() {
assert!(!path_ends_with_suffix(
"dir/\u{2019}bar/file.txt",
"'bar/file.txt"
));
}
#[test]
fn test_path_ends_with_suffix_unicode_space_mismatch() {
assert!(!path_ends_with_suffix(
"dir/\u{202f}am/file.txt",
" am/file.txt"
));
}
#[test]
fn test_path_contains_segment_unicode_no_panic() {
assert!(!path_contains_segment(
"Library/Cloud/Project\u{2019}s Folder/books.ttl",
"Project's Folder"
));
}
#[test]
fn test_file_has_extension_unicode_no_panic() {
assert!(!file_has_extension("cat\u{00e9}.rs", "s"));
}
}
+11
View File
@@ -7,6 +7,10 @@ pub enum Error {
ThreadPanic,
#[error("Invalid path {0}")]
InvalidPath(std::path::PathBuf),
#[error(
"Can not start fff at the file system root {0} — pass a project or at least home directory instead"
)]
FilesystemRoot(std::path::PathBuf),
#[error("File picker not initialized")]
FilePickerMissing,
#[error("Failed to acquire lock for frecency")]
@@ -17,10 +21,17 @@ pub enum Error {
AcquirePathCacheLock,
#[error("Failed to create directory: {0}")]
CreateDir(#[from] std::io::Error),
#[error("Failed to remove database directory {path}: {source}")]
RemoveDbDir {
path: std::path::PathBuf,
source: std::io::Error,
},
#[error("Failed to open frecency database env: {0}")]
EnvOpen(#[source] heed::Error),
#[error("Failed to create frecency database: {0}")]
DbCreate(#[source] heed::Error),
#[error("Failed to open frecency database: {0}")]
DbOpen(#[source] heed::Error),
#[error("Failed to clear stale readers for frecency database: {0}")]
DbClearStaleReaders(#[source] heed::Error),
File diff suppressed because it is too large Load Diff
+55 -21
View File
@@ -1,6 +1,8 @@
use crate::db_healthcheck::DbHealthChecker;
use crate::error::{Error, Result};
use crate::file_picker::FFFMode;
use crate::{SharedFrecency, error::Error, git::is_modified_status};
use crate::git::is_modified_status;
use crate::shared::SharedFrecency;
use heed::{Database, Env, EnvOpenOptions};
use heed::{
EnvFlags,
@@ -47,7 +49,7 @@ impl DbHealthChecker for FrecencyTracker {
&self.env
}
fn count_entries(&self) -> Result<Vec<(&'static str, u64)>, Error> {
fn count_entries(&self) -> Result<Vec<(&'static str, u64)>> {
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
let count = self.db.len(&rtxn).map_err(Error::DbRead)?;
@@ -56,8 +58,15 @@ impl DbHealthChecker for FrecencyTracker {
}
impl FrecencyTracker {
pub fn new(db_path: &str, use_unsafe_no_lock: bool) -> Result<Self, Error> {
/// Returns the on-disk path of the LMDB environment directory.
pub fn db_path(&self) -> &Path {
self.env.path()
}
pub fn new(db_path: impl AsRef<Path>, use_unsafe_no_lock: bool) -> Result<Self> {
let db_path = db_path.as_ref();
fs::create_dir_all(db_path).map_err(Error::CreateDir)?;
let env = unsafe {
let mut opts = EnvOpenOptions::new();
opts.map_size(24 * 1024 * 1024); // 24 MiB
@@ -69,11 +78,28 @@ impl FrecencyTracker {
env.clear_stale_readers()
.map_err(Error::DbClearStaleReaders)?;
// we will open the default unnamed database
let mut wtxn = env.write_txn().map_err(Error::DbStartWriteTxn)?;
let db = env
.create_database(&mut wtxn, None)
.map_err(Error::DbCreate)?;
// Try read-only open first — avoids blocking on the LMDB write lock
// when another process (Neovim, another fff-mcp) already has it.
// Only fall back to create_database (which needs a write txn) if the
// database doesn't exist yet.
let rtxn = env.read_txn().map_err(Error::DbStartReadTxn)?;
let maybe_db: Option<Database<Bytes, SerdeBincode<VecDeque<u64>>>> =
env.open_database(&rtxn, None).map_err(Error::DbOpen)?;
drop(rtxn);
let db = match maybe_db {
Some(db) => db,
None => {
// First time: create the database (requires write lock).
let mut wtxn = env.write_txn().map_err(Error::DbStartWriteTxn)?;
let db = env
.create_database(&mut wtxn, None)
.map_err(Error::DbCreate)?;
wtxn.commit().map_err(Error::DbCommit)?;
db
}
};
Ok(FrecencyTracker {
db,
@@ -82,16 +108,24 @@ impl FrecencyTracker {
}
/// Spawns a background thread to purge stale frecency entries and compact the database.
/// Run it once in a while to purge old pages and keep DB file size reasonable.
///
/// 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()
/// It's okay to not join this thread since it acquires locks for the db access
///
/// ```
/// use fff_search::frecency::FrecencyTracker;
/// use fff_search::SharedFrecency;
/// let shared_frecency: SharedFrecency = Default::default();
/// let _ = FrecencyTracker::spawn_gc(shared_frecency, "/path/to/frecency_db".into(), true).ok();
/// ```
pub fn spawn_gc(
shared: SharedFrecency,
db_path: String,
use_unsafe_no_lock: bool,
) -> Result<std::thread::JoinHandle<()>> {
Ok(std::thread::Builder::new()
.name("fff-frecency-gc".into())
.spawn(move || Self::run_frecency_gc(shared, db_path, use_unsafe_no_lock))
.ok();
.spawn(move || Self::run_frecency_gc(shared, db_path, use_unsafe_no_lock))?)
}
#[tracing::instrument(skip(shared), fields(db_path = %db_path))]
@@ -223,7 +257,7 @@ impl FrecencyTracker {
/// Removes entries where all timestamps are older than MAX_HISTORY_DAYS,
/// and prunes stale timestamps from entries that still have recent ones.
/// Returns (deleted_count, pruned_count).
fn purge_stale_entries(&self) -> Result<(usize, usize), Error> {
fn purge_stale_entries(&self) -> Result<(usize, usize)> {
let now = self.get_now();
let cutoff_time = now.saturating_sub((MAX_HISTORY_DAYS * SECONDS_PER_DAY) as u64);
@@ -275,7 +309,7 @@ impl FrecencyTracker {
Ok((to_delete.len(), to_update.len()))
}
fn get_accesses(&self, path: &Path) -> Result<Option<VecDeque<u64>>, Error> {
fn get_accesses(&self, path: &Path) -> Result<Option<VecDeque<u64>>> {
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
let key_hash = Self::path_to_hash_bytes(path)?;
@@ -289,7 +323,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,13 +333,13 @@ 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> {
pub fn track_access(&self, path: &Path) -> Result<()> {
let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?;
let key_hash = Self::path_to_hash_bytes(path)?;
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
use std::path::Path;
pub(crate) const NON_GIT_IGNORED_DIRS: &[&str] = &[
"node_modules",
"__pycache__",
"venv",
".venv",
// Rust (these are glob-only patterns for non_git_repo_overrides,
// is_non_code_directory matches the "target" component separately)
"target/debug",
"target/release",
"target/rust-analyzer",
"target/criterion",
];
#[cfg(target_os = "macos")]
pub(crate) const PLATFORM_IGNORED_DIRS: &[&str] =
&["Library/Application Support", "Library/Caches"];
#[cfg(target_os = "windows")]
pub(crate) const PLATFORM_IGNORED_DIRS: &[&str] = &[
"bin/Debug",
"bin/Release",
"Program Files",
"Program Files (x86)",
"AppData/Local",
"AppData/Roaming",
];
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub(crate) const PLATFORM_IGNORED_DIRS: &[&str] = &[];
pub(crate) fn non_git_repo_overrides(base_path: &Path) -> Option<ignore::overrides::Override> {
use ignore::overrides::OverrideBuilder;
let mut builder = OverrideBuilder::new(base_path);
for dir in NON_GIT_IGNORED_DIRS.iter().chain(PLATFORM_IGNORED_DIRS) {
let pattern = format!("!**/{dir}/");
if let Err(e) = builder.add(&pattern) {
tracing::warn!("failed to add ignore pattern {pattern}: {e}");
}
}
builder.build().ok()
}
pub(crate) fn is_non_code_directory(path: &Path) -> bool {
let path_str = path.as_os_str().to_str().unwrap_or("");
NON_GIT_IGNORED_DIRS
.iter()
.chain(PLATFORM_IGNORED_DIRS)
.any(|&dir| {
#[cfg(target_os = "windows")]
let dir = dir.replace('/', std::path::MAIN_SEPARATOR_STR);
#[cfg(target_os = "windows")]
return path_str.contains(dir.as_str());
#[cfg(not(target_os = "windows"))]
path_str.contains(dir)
})
}
+142 -33
View File
@@ -1,46 +1,155 @@
//! 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
//!
//! [`SharedPicker`], [`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, SharedPicker, SharedQueryTracker,
//! };
//!
//! let shared_picker = SharedPicker::default();
//! let shared_frecency = SharedFrecency::default();
//! let shared_query_tracker = SharedQueryTracker::default();
//!
//! let tmp = std::env::temp_dir().join("fff-doctest");
//! std::fs::create_dir_all(&tmp).unwrap();
//!
//! // 1. Optionally initialize frecency and query tracker databases
//! let frecency = FrecencyTracker::new(tmp.join("frecency"), false)?;
//! shared_frecency.init(frecency)?;
//!
//! let query_tracker = QueryTracker::new(tmp.join("queries"), false)?;
//! 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 = FilePicker::fuzzy_search(
//! picker.get_files(),
//! &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().as_path().ends_with("lib.rs"));
//!
//! let _ = std::fs::remove_dir_all(&tmp);
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```
mod background_watcher;
pub mod constraints;
mod bigram_filter;
pub mod bigram_query;
mod constraints;
mod db_healthcheck;
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;
// this is pub only for benchmarks
pub mod case_insensitive_memmem;
/// Core file picker: filesystem indexing, background watching, and fuzzy search.
///
/// See [`FilePicker`](file_picker::FilePicker) for the main entry point.
pub mod file_picker;
/// Frecency (frequency + recency) database for file access scoring.
///
/// Backed by LMDB for persistent, crash-safe storage.
pub mod frecency;
/// 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;
/// Search query history tracker for combo-boost scoring.
///
/// Records which files a user selects for each query, enabling the scorer
/// to boost files that were previously chosen for similar searches.
pub mod query_tracker;
/// 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};
pub type SharedPicker = Arc<RwLock<Option<FilePicker>>>;
pub type SharedFrecency = Arc<RwLock<Option<FrecencyTracker>>>;
pub type SharedQueryTracker = Arc<RwLock<Option<QueryTracker>>>;
mod ignore;
/// Thread-safe shared handles for [`FilePicker`], [`FrecencyTracker`],
/// and [`QueryTracker`].
pub mod shared;
pub use bigram_filter::*;
pub use db_healthcheck::{DbHealth, DbHealthChecker};
pub use error::{Error, Result};
pub use fff_query_parser::{
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::*;
+58 -72
View File
@@ -1,10 +1,11 @@
//! Shared logging utilities for FFF crates.
//!
//! Provides file-based tracing initialization and a panic hook that writes
//! to both stderr and a fallback log file.
//! Provides file-based tracing initialization and crash handlers (panic hook
//! + SIGSEGV signal handler) that write diagnostics to both stderr and the
//! configured log file.
use std::io;
use std::path::Path;
use std::path::{Path, PathBuf};
use tracing_appender::non_blocking;
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
@@ -12,82 +13,79 @@ use tracing_subscriber::{EnvFilter, fmt, prelude::*};
static TRACING_INITIALIZED: std::sync::OnceLock<tracing_appender::non_blocking::WorkerGuard> =
std::sync::OnceLock::new();
static PANIC_HOOK_INSTALLED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
static CRASH_HANDLERS_INSTALLED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
/// Install panic hook that writes to both stderr and a fallback file.
/// This is called separately from init_tracing to ensure panics are always logged.
/// The log file path set by `init_tracing`. Crash handlers append to this file.
static LOG_FILE_PATH: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
fn write_crash_report(header: &str, body: &str) {
let msg = format!(
"\n=== CRASH {} ===\n{}\n=== CRASH END {} ===\n",
header, body, header
);
let _ = std::io::Write::write_all(&mut std::io::stderr(), msg.as_bytes());
if let Some(path) = LOG_FILE_PATH.get() {
let _ = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.and_then(|mut f| std::io::Write::write_all(&mut f, msg.as_bytes()));
}
}
extern "C" fn sigsegv_handler(sig: libc::c_int) {
let bt = std::backtrace::Backtrace::force_capture();
write_crash_report("SIGSEGV", &format!("signal {}\n{}", sig, bt));
unsafe {
libc::signal(sig, libc::SIG_DFL);
libc::raise(sig);
}
}
/// Install both the panic hook and the SIGSEGV signal handler.
pub fn install_panic_hook() {
PANIC_HOOK_INSTALLED.get_or_init(|| {
CRASH_HANDLERS_INSTALLED.get_or_init(|| {
let default_panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let payload = panic_info.payload();
let message = if let Some(s) = payload.downcast_ref::<&str>() {
let message = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
} else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
s.clone()
} else {
"Unknown panic payload".to_string()
};
let location = if let Some(location) = panic_info.location() {
format!(
"{}:{}:{}",
location.file(),
location.line(),
location.column()
)
} else {
"unknown location".to_string()
};
let location = panic_info
.location()
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
.unwrap_or_else(|| "unknown location".to_string());
// Always log to tracing (if initialized)
tracing::error!(
panic.message = %message,
panic.location = %location,
"PANIC occurred in FFF"
);
// Always print to stderr
eprintln!("=== FFF PANIC ===");
eprintln!("Message: {}", message);
eprintln!("Location: {}", location);
eprintln!("=================");
// Try to write to fallback panic log file
if let Some(cache_dir) = dirs::cache_dir() {
let panic_log = cache_dir.join("fff_panic.log");
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let panic_entry = format!(
"\n[{}] PANIC at {}\nMessage: {}\n",
timestamp, location, message
);
let _ = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&panic_log)
.and_then(|mut f| {
use std::io::Write;
f.write_all(panic_entry.as_bytes())
});
eprintln!("Panic logged to: {}", panic_log.display());
}
write_crash_report(
"RUST PANIC",
&format!("Message: {}\nLocation: {}", message, location),
);
default_panic(panic_info);
}));
unsafe {
libc::signal(
libc::SIGSEGV,
sigsegv_handler as *const () as libc::sighandler_t,
);
}
});
}
/// Parse a log level string into a `tracing::Level`.
///
/// Accepts "trace", "debug", "info", "warn", "error" (case-insensitive).
/// Returns `tracing::Level::INFO` for unrecognised values.
pub fn parse_log_level(level: Option<&str>) -> tracing::Level {
match level.as_ref().map(|s| s.trim().to_lowercase()).as_deref() {
Some("trace") => tracing::Level::TRACE,
@@ -100,29 +98,19 @@ pub fn parse_log_level(level: Option<&str>) -> tracing::Level {
}
/// Initialize tracing with a single log file.
///
/// Creates the parent directory if it doesn't exist, truncates the log file,
/// and sets up a non-blocking file appender with structured formatting.
///
/// # Arguments
/// * `log_file_path` - Full path to the log file
/// * `log_level` - Log level (trace, debug, info, warn, error)
///
/// # Returns
/// * `Result<String, io::Error>` - Full path to the log file on success
pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<String, io::Error> {
// Install panic hook first (does nothing if already installed)
install_panic_hook();
let log_path = Path::new(log_file_path);
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent)?;
}
let _ = LOG_FILE_PATH.set(log_path.to_path_buf());
install_panic_hook();
let file_appender = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true) // creates a new file on every setup
.truncate(true) // truncates a file on restart (instead of appending)
.open(log_path)?;
let level = parse_log_level(log_level);
@@ -137,8 +125,6 @@ pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<Stri
.with_target(true)
.with_thread_ids(false)
.with_thread_names(false)
.with_file(true)
.with_line_number(true)
.with_ansi(false)
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
)
-7
View File
@@ -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)
+8 -2
View File
@@ -61,8 +61,15 @@ impl DbHealthChecker for QueryTracker {
}
impl QueryTracker {
pub fn new(db_path: &str, use_unsafe_no_lock: bool) -> Result<Self, Error> {
/// Returns the on-disk path of the LMDB environment directory.
pub fn db_path(&self) -> &Path {
self.env.path()
}
pub fn new(db_path: impl AsRef<Path>, use_unsafe_no_lock: bool) -> Result<Self, Error> {
let db_path = db_path.as_ref();
fs::create_dir_all(db_path).map_err(Error::CreateDir)?;
let env = unsafe {
let mut opts = EnvOpenOptions::new();
opts.map_size(10 * 1024 * 1024); // 100 MiB
@@ -244,7 +251,6 @@ impl QueryTracker {
min_combo_count: u32,
) -> Result<Option<QueryMatchEntry>, Error> {
let query_key = Self::create_query_key(project_path, query)?;
tracing::debug!(?query_key, "HASH");
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
let last_match = self
+345 -172
View File
@@ -34,24 +34,6 @@ impl<'a> FileItems<'a> {
self.len() == 0
}
#[inline]
fn get(&self, index: usize) -> Option<&'a FileItem> {
match self {
FileItems::All(s) => s.get(index),
FileItems::Filtered(v) => v.get(index).copied(),
}
}
/// Build the haystack of relative paths (original casing) for fuzzy matching.
/// neo_frizbee lowercases internally for comparison but preserves original casing
/// for capitalization_bonus and matching_case_bonus scoring.
fn relative_paths(&self) -> Vec<&'a str> {
match self {
FileItems::All(s) => s.iter().map(|f| f.relative_path.as_str()).collect(),
FileItems::Filtered(v) => v.iter().map(|f| f.relative_path.as_str()).collect(),
}
}
/// Index into the file list. Panics if out of bounds (like slice indexing).
#[inline]
fn index(&self, index: usize) -> &'a FileItem {
@@ -66,17 +48,17 @@ impl<'a> FileItems<'a> {
/// Single part: use optimized batch matching.
/// Multiple parts: each part must match, scores are summed (Nucleo-style).
/// Parts with less than 2 characters are skipped.
///
/// Files are passed directly to frizbee via the `Matchable` trait —
/// deleted files return `None` from `match_str()` and are skipped
/// without any intermediate allocation.
#[inline]
fn match_fuzzy_parts(
fuzzy_parts: &[&str],
working_files: &FileItems<'_>,
options: &neo_frizbee::Config,
max_threads: usize,
) -> Vec<neo_frizbee::Match> {
if fuzzy_parts.is_empty() {
return vec![];
}
let haystack: Vec<&str> = working_files.relative_paths();
// Filter out parts that are too short (< 2 chars)
let valid_parts: Vec<&str> = fuzzy_parts
.iter()
@@ -89,14 +71,21 @@ fn match_fuzzy_parts(
return vec![];
}
let first_part_matches = match working_files {
FileItems::All(files) => {
neo_frizbee::match_list_parallel(valid_parts[0], files, options, max_threads)
}
FileItems::Filtered(files) => {
neo_frizbee::match_list_parallel(valid_parts[0], files, options, max_threads)
}
};
if valid_parts.len() == 1 {
let matches = neo_frizbee::match_list(valid_parts[0], &haystack, options);
return matches;
return first_part_matches;
}
// Multiple parts - match first part, then filter by remaining parts
// TODO figure out if we can move this logic to my frizbee fork at least
let mut matches = neo_frizbee::match_list(valid_parts[0], &haystack, options);
let mut matches = first_part_matches;
for part in valid_parts[1..].iter() {
let mut part_options = *options;
part_options.max_typos = options.max_typos.map(|t| t.min(part.len() as u16));
@@ -104,8 +93,9 @@ fn match_fuzzy_parts(
matches = matches
.into_iter()
.filter_map(|mut m| {
let path = haystack.get(m.index as usize)?;
let part_matches = neo_frizbee::match_list(part, &[*path], &part_options);
let file = working_files.index(m.index as usize);
let path = file.relative_path();
let part_matches = neo_frizbee::match_list(part, &[path], &part_options);
let part_match = part_matches.first()?;
// Sum scores
@@ -144,22 +134,14 @@ pub fn match_and_score_files<'a>(
}
};
let query_trimmed: &str = parsed.raw_query.trim();
let single_part_storage: [&str; 1] = [query_trimmed];
let fuzzy_parts: &[&str] = match &parsed.fuzzy_query {
FuzzyQuery::Text(t) if t.len() >= 2 => std::slice::from_ref(t),
FuzzyQuery::Parts(parts) if !parts.is_empty() => parts.as_slice(),
FuzzyQuery::Text(_) | FuzzyQuery::Parts(_) => {
_ => {
return score_filtered_by_frecency(&working_files, context);
}
FuzzyQuery::Empty => {
if query_trimmed.len() < 2 {
return score_filtered_by_frecency(&working_files, context);
}
&single_part_storage
}
};
debug_assert!(!fuzzy_parts.is_empty());
let has_uppercase = fuzzy_parts
.iter()
@@ -176,54 +158,62 @@ pub fn match_and_score_files<'a>(
},
};
let path_matches = match_fuzzy_parts(fuzzy_parts, &working_files, &options);
let primary_text = fuzzy_parts[0]; // Use first part for filename matching
let haystack_of_filenames: Vec<&str> = path_matches
.iter()
.filter_map(|m| {
working_files
.get(m.index as usize)
.map(|f| f.file_name.as_str())
})
.collect();
let path_matches =
match_fuzzy_parts(fuzzy_parts, &working_files, &options, context.max_threads);
// if there is a / in the query we don't even match filenames
let filename_matches = if query_contains_path_separator {
let main_needle = fuzzy_parts[0].as_bytes(); // safe
let main_needle_len = main_needle.len() as u16;
// Filename match detection: two tiers, cursor-based (no intermediate bitset/Vec<bool>).
// 1) Collect filenames only where match_end_col didn't land in the filename region.
// 2) Batch SIMD on that subset, remap indices, sort for cursor walk in the scoring loop.
let mut fallback_indices: Vec<u32> = Vec::new();
let filename_fallback_matches = if query_contains_path_separator || path_matches.len() > 15_000
{
vec![]
} else {
// Use parallel matching only if we have enough filenames to justify overhead
// Sequential matching is faster for small result sets (< 1000 matches)
let mut list = if haystack_of_filenames.len() > 1000 {
neo_frizbee::match_list_parallel(
primary_text,
&haystack_of_filenames,
&options,
context.max_threads,
)
} else {
neo_frizbee::match_list(primary_text, &haystack_of_filenames, &options)
};
let mut fallback_filenames: Vec<&str> = Vec::new();
// Sequential sort is faster for small lists
if list.len() > 1000 {
list.par_sort_unstable_by_key(|m| m.index);
} else {
sort_by_key_with_buffer(&mut list, |m| m.index);
for (i, path_match) in path_matches.iter().enumerate() {
let file = working_files.index(path_match.index as usize);
let filename_start = file.filename_offset_in_relative() as u16;
let match_start_approx = path_match.end_col.saturating_sub(main_needle_len - 1);
if match_start_approx < filename_start {
fallback_indices.push(i as u32);
fallback_filenames.push(file.file_name());
}
}
list
if fallback_filenames.is_empty() {
vec![]
} else {
let mut matches = neo_frizbee::match_list_parallel(
fuzzy_parts[0],
&fallback_filenames,
&options,
if path_matches.len() > 10_000 {
context.max_threads
} else {
1
},
);
sort_by_key_with_buffer(&mut matches, |m| fallback_indices[m.index as usize]);
matches
}
};
let mut next_filename_match_index = 0;
let mut next_filename_match_cursor = 0;
let results: Vec<_> = path_matches
.into_iter()
.enumerate()
.map(|(index, path_match)| {
.map(|(match_idx, path_match)| {
let file_idx = path_match.index as usize;
let file = working_files.index(file_idx);
let mut base_score = path_match.score as i32;
let frecency_boost = base_score.saturating_mul(file.total_frecency_score as i32) / 100;
let base_score = path_match.score as i32;
let frecency_boost = base_score.saturating_mul(file.total_frecency_score()) / 100;
// Give modified/dirty files a 15% boost to make them appear higher in results
let git_status_boost = if file.git_status.is_some_and(is_modified_status) {
@@ -233,55 +223,68 @@ pub fn match_and_score_files<'a>(
};
let distance_penalty =
calculate_distance_penalty(context.current_file, &file.relative_path);
calculate_distance_penalty(context.current_file, file.relative_path());
let filename_match = filename_matches
.get(next_filename_match_index)
.and_then(|m| {
if m.index == index as u32 {
next_filename_match_index += 1;
Some(m)
} else {
None
}
});
let filename_start = file.filename_offset_in_relative() as u16;
let match_start_approx = path_match.end_col.saturating_sub(main_needle_len - 1);
let mut has_special_filename_bonus = false;
let filename_bonus = match filename_match {
Some(filename_match) if filename_match.exact => {
filename_match.score as i32 / 5 * 2 // 40% bonus for exact filename match
}
// 16% bonus for fuzzy filename match but only if the score of matched path is
// equal or greater than the score of matched filename, thus we are not allowing
// typoed filename to score higher than the path match
Some(filename_match)
if filename_match.score >= path_match.score
&& !query_contains_path_separator =>
{
base_score = filename_match.score as i32;
(base_score / 6)
// for large queries around ~300 score the bonus is too big
// it might lead to situations when much more fitting path with a larger
// base score getting filtered out by combination of score + filename bonus
// so we cap it at 10% of the roughly largest score you can get
.min(30)
}
// 5% bonus for special file but not as much as file name to avoid sitatuions
// when you have /user_service/server.rs and /user_service/server/mod.rs
None if is_special_entry_point_file(&file.file_name) => {
has_special_filename_bonus = true;
base_score * 5 / 100
}
_ => 0,
let end_col_filename_match = match_start_approx >= filename_start;
let simd_filename_match = if !end_col_filename_match {
filename_fallback_matches
.get(next_filename_match_cursor)
.and_then(|m| {
if fallback_indices[m.index as usize] == match_idx as u32 {
next_filename_match_cursor += 1;
Some(m)
} else {
None
}
})
} else {
None
};
let current_file_penalty = calculate_current_file_penalty(file, base_score, context);
let is_filename_match = end_col_filename_match || simd_filename_match.is_some();
let is_exact_filename = simd_filename_match.is_some_and(|m| m.exact)
|| (end_col_filename_match
&& main_needle_len as usize == file.file_name().len()
&& main_needle.eq_ignore_ascii_case(file.file_name().as_bytes()));
let mut has_special_filename_bonus = false;
let filename_bonus = if is_exact_filename {
base_score / 5 * 2 // 40% bonus for exact filename match
} else if is_filename_match {
// 16% bonus for fuzzy filename match that landed in the filename region.
// For fallback matches (where the path match landed in a directory segment),
// scale the bonus by the quality of the filename match — a contiguous match
// like "rename" in "rename.ts" gets the full bonus, while a scattered
// subsequence like r-e-n-a-m-e in "generateSessionName.ts" gets much less.
let max_bonus = (base_score / 6).min(30);
if let Some(fm) = simd_filename_match {
let max_possible = main_needle_len as i32 * 16;
let quality = (fm.score as i32).min(max_possible);
max_bonus * quality / max_possible
} else {
max_bonus
}
} else if !is_filename_match && is_special_entry_point_file(file.file_name()) {
// 5% bonus for special file but not as much as file name to avoid situations
// when you have /user_service/server.rs and /user_service/server/mod.rs
has_special_filename_bonus = true;
base_score * 5 / 100
} else {
0
};
// Light penalty for the current file — just enough to demote it slightly,
// not enough to bury it when the query is a good match.
let current_file_penalty =
calculate_current_file_penalty(file, base_score / 4, context);
let combo_match_boost = {
let last_same_query_match = context
.last_same_query_match
.filter(|m| m.file_path.as_os_str() == file.path.as_os_str());
.as_ref()
.filter(|m| m.file_path.as_os_str() == file.as_path().as_os_str());
match last_same_query_match {
// if we request a combo match without a boost we have to render it anyway
@@ -296,13 +299,42 @@ pub fn match_and_score_files<'a>(
}
};
// Path alignment bonus: when the query looks like a file path,
// reward candidates whose path closely matches the typed query.
// Uses suffix overlap — bytes matching from the end. A full prefix
// match is just the 100% coverage case, so no separate branch needed.
let path_alignment_bonus = if query_contains_path_separator {
let path_bytes = file.relative_path().as_bytes();
let common_suffix = main_needle
.iter()
.rev()
.zip(path_bytes.iter().rev())
.take_while(|(n, p)| n.eq_ignore_ascii_case(p))
.count();
let needle_len = main_needle.len();
if common_suffix > 10 && needle_len > 0 {
let coverage = common_suffix * 100 / needle_len;
if coverage >= 30 {
base_score * coverage as i32 / 100
} else {
0
}
} else {
0
}
} else {
0
};
let total = base_score
.saturating_add(frecency_boost)
.saturating_add(git_status_boost)
.saturating_add(distance_penalty)
.saturating_add(filename_bonus)
.saturating_add(current_file_penalty)
.saturating_add(combo_match_boost);
.saturating_add(combo_match_boost)
.saturating_add(path_alignment_bonus);
let score = Score {
total,
@@ -318,12 +350,16 @@ pub fn match_and_score_files<'a>(
git_status_boost,
distance_penalty,
combo_match_boost,
exact_match: path_match.exact || filename_match.is_some_and(|m| m.exact),
match_type: match filename_match {
Some(filename_match) if filename_match.exact => "exact_filename",
Some(_) => "fuzzy_filename",
None if path_match.exact => "exact_path",
None => "fuzzy_path",
path_alignment_bonus,
exact_match: is_exact_filename || path_match.exact,
match_type: if is_exact_filename {
"exact_filename"
} else if is_filename_match {
"fuzzy_filename"
} else if path_match.exact {
"exact_path"
} else {
"fuzzy_path"
},
};
@@ -388,6 +424,7 @@ pub(crate) fn score_filtered_by_frecency<'a>(
distance_penalty: 0,
special_filename_bonus: 0,
combo_match_boost: 0,
path_alignment_bonus: 0,
current_file_penalty,
frecency_boost: total_frecency_score,
git_status_boost,
@@ -399,8 +436,16 @@ pub(crate) fn score_filtered_by_frecency<'a>(
};
let results: Vec<_> = match files {
FileItems::All(s) => s.par_iter().map(&score_file).collect(),
FileItems::Filtered(v) => v.iter().map(|&file| score_file(file)).collect(),
FileItems::All(s) => s
.par_iter()
.filter(|f| !f.is_deleted())
.map(&score_file)
.collect(),
FileItems::Filtered(v) => v
.iter()
.filter(|f| !f.is_deleted())
.map(|&file| score_file(file))
.collect(),
};
sort_and_paginate(results, context)
@@ -415,12 +460,9 @@ fn calculate_current_file_penalty(
let mut penalty = 0i32;
if let Some(current) = context.current_file
&& file.relative_path.as_str() == current
&& file.relative_path() == current
{
penalty -= match file.git_status {
Some(status) if is_modified_status(status) => base_score / 2,
_ => base_score,
};
penalty -= base_score;
}
penalty
@@ -498,14 +540,15 @@ mod tests {
use super::*;
use crate::types::PaginationArgs;
use fff_query_parser::QueryParser;
use std::path::PathBuf;
// ── Helpers ──────────────────────────────────────────────────────────
fn create_test_file(path: &str, score: i32, modified: u64) -> (FileItem, Score) {
let file_name = path.split('/').next_back().unwrap_or(path).to_string();
let filename_start = path.rfind('/').map(|i| i + 1).unwrap_or(0) as u16;
let file = FileItem::new_raw(
PathBuf::from(path),
path.to_string(),
file_name,
0,
filename_start,
0,
modified,
None,
@@ -523,13 +566,61 @@ mod tests {
exact_match: false,
match_type: "test",
combo_match_boost: 0,
path_alignment_bonus: 0,
};
(file, score_obj)
}
fn make_file(path: &str) -> FileItem {
let filename_start = path.rfind('/').map(|i| i + 1).unwrap_or(0) as u16;
FileItem::new_raw(path.to_string(), 0, filename_start, 0, 0, None, false)
}
fn make_file_with_frecency(path: &str, access_frecency: i16) -> FileItem {
let filename_start = path.rfind('/').map(|i| i + 1).unwrap_or(0) as u16;
let mut file = FileItem::new_raw(path.to_string(), 0, filename_start, 0, 0, None, false);
file.access_frecency_score = access_frecency;
file
}
/// Run `match_and_score_files` with production-like max_typos scaling.
fn search(files: &[FileItem], query: &str) -> Vec<(String, Score)> {
let parser = QueryParser::default();
let parsed = parser.parse(query);
let effective_query = match &parsed.fuzzy_query {
FuzzyQuery::Text(t) => *t,
FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
_ => query,
};
let max_typos = (effective_query.len() as u16 / 4).clamp(2, 6);
let ctx = ScoringContext {
query: &parsed,
max_threads: 1,
max_typos,
current_file: None,
last_same_query_match: None,
project_path: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 100,
},
};
let (items, scores, _) = match_and_score_files(files, &ctx);
items
.iter()
.zip(scores.iter())
.map(|(f, s)| (f.relative_path().to_string(), s.clone()))
.collect()
}
// ── Sort / pagination ───────────────────────────────────────────────
#[test]
fn test_partial_sort_descending() {
// Create test data with known scores
let test_data = vec![
create_test_file("file1.rs", 100, 1000),
create_test_file("file2.rs", 200, 2000),
@@ -543,7 +634,6 @@ mod tests {
create_test_file("file10.rs", 90, 10000),
];
// Convert to references like the actual function uses
let results: Vec<(&FileItem, Score)> = test_data
.iter()
.map(|(file, score)| (file, score.clone()))
@@ -561,38 +651,32 @@ mod tests {
project_path: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 0,
},
};
// Test with full sort - returns all results sorted descending
let (items, scores, total) = sort_and_paginate(results.clone(), &context);
// Should return all 10 items sorted by score descending
assert_eq!(total, 10);
assert_eq!(scores.len(), 10);
assert_eq!(scores[0].total, 300, "First should be highest score");
assert_eq!(scores[1].total, 250, "Second should be second highest");
assert_eq!(scores[2].total, 200, "Third should be third highest");
// Verify the files match
assert_eq!(items[0].relative_path, "file4.rs");
assert_eq!(items[1].relative_path, "file6.rs");
assert_eq!(items[2].relative_path, "file2.rs");
assert_eq!(items[0].relative_path(), "file4.rs");
assert_eq!(items[1].relative_path(), "file6.rs");
assert_eq!(items[2].relative_path(), "file2.rs");
}
#[test]
fn test_partial_sort_with_same_scores() {
// Test tiebreaker with modified time
let test_data = [
create_test_file("file1.rs", 100, 5000), // Same score, older
create_test_file("file2.rs", 100, 8000), // Same score, newer
create_test_file("file3.rs", 100, 3000), // Same score, oldest
create_test_file("file1.rs", 100, 5000),
create_test_file("file2.rs", 100, 8000),
create_test_file("file3.rs", 100, 3000),
create_test_file("file4.rs", 200, 1000),
create_test_file("file5.rs", 200, 9000), // Higher score, newest
create_test_file("file5.rs", 200, 9000),
];
let results: Vec<(&FileItem, Score)> = test_data
@@ -612,7 +696,6 @@ mod tests {
project_path: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 0,
@@ -621,7 +704,6 @@ mod tests {
let (items, scores, _) = sort_and_paginate(results, &context);
// Should return all 5 items sorted: 200(9000), 200(1000), 100(8000), 100(5000), 100(3000)
assert_eq!(scores.len(), 5);
assert_eq!(scores[0].total, 200);
assert_eq!(items[0].modified, 9000, "First 200 should be newest");
@@ -637,7 +719,6 @@ mod tests {
#[test]
fn test_no_partial_sort_for_small_results() {
// When results.len() <= threshold, should use regular sort
let test_data = [
create_test_file("file1.rs", 100, 1000),
create_test_file("file2.rs", 200, 2000),
@@ -661,62 +742,158 @@ mod tests {
project_path: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 0,
},
};
// Returns all results sorted descending
let (items, scores, _) = sort_and_paginate(results, &context);
assert_eq!(scores.len(), 3);
assert_eq!(scores[0].total, 200);
assert_eq!(scores[1].total, 100);
assert_eq!(scores[2].total, 50);
assert_eq!(items[0].relative_path, "file2.rs");
assert_eq!(items[1].relative_path, "file1.rs");
assert_eq!(items[2].relative_path, "file3.rs");
assert_eq!(items[0].relative_path(), "file2.rs");
assert_eq!(items[1].relative_path(), "file1.rs");
assert_eq!(items[2].relative_path(), "file3.rs");
}
#[test]
fn test_filename_match_ranks_above_path_only_match() {
let files = vec![
make_file("src/username/handler.rs"),
make_file("src/username/username.rs"),
];
let results = search(&files, "usrnmea");
assert!(
results.len() >= 2,
"both files should match, got {}",
results.len()
);
assert_eq!(
results[0].0, "src/username/username.rs",
"filename match should rank first"
);
assert!(
results[0].1.filename_bonus > 0,
"username.rs should have filename bonus"
);
assert_eq!(
results[1].1.filename_bonus, 0,
"handler.rs should have no filename bonus"
);
}
#[test]
fn test_exact_filename_beats_fuzzy_filename() {
let files = vec![
make_file("src/user_name_handler.rs"),
make_file("src/username.rs"),
];
let results = search(&files, "username.rs");
assert!(results.len() >= 2);
assert_eq!(
results[0].0, "src/username.rs",
"exact filename should rank first"
);
assert_eq!(results[0].1.match_type, "exact_filename");
assert!(results[0].1.filename_bonus > results[1].1.filename_bonus);
}
#[test]
fn test_same_length_filename_no_false_exact() {
let files = vec![
make_file("src/item_sync/file.rs"),
make_file("src/models/item.rs"),
];
let results = search(&files, "item.rs");
assert!(results.len() >= 2);
assert_eq!(results[0].0, "src/models/item.rs");
assert_eq!(results[0].1.match_type, "exact_filename");
assert_ne!(
results[1].1.match_type, "exact_filename",
"file.rs should not get exact_filename"
);
}
#[test]
fn test_path_separator_disables_filename_bonus() {
let files = vec![make_file("src/controllers/user.rs")];
let results = search(&files, "src/user");
assert!(!results.is_empty());
assert_eq!(
results[0].1.filename_bonus, 0,
"path-like query should not get filename bonus"
);
}
/// Regression: full-path query should rank the near-exact path match first.
/// https://x.com/mbarneyjr/status/2043474268390817861
#[test]
fn test_full_path_query_prefers_closer_filename_match() {
let files = vec![
make_file("test-utils/completion/condition-key/yaml_partial-svc-colon.yml"),
make_file("test-utils/test-cases/completion/condition-key/yaml_partial-svc.yml"),
make_file("test-utils/action-value/yaml_inline_partial-svc-colon.yml"),
make_file("test-utils/completion/action-value/yaml_array_partial-svc-colon.yml"),
make_file("test-utils/completion/action-value/yaml_array_partial-svc.yml"),
make_file("test-utils/completion/condition-key/yaml_global-tag-keys.yml"),
make_file_with_frecency(
"test-utils/test-cases/completion/condition-key/yaml_partial.yml",
10,
),
];
let results = search(
&files,
"t-utils/test-cases/completion/condition-key/yaml_partial-svc.yml",
);
assert!(!results.is_empty(), "query should match at least one file");
assert_eq!(
results[0].0, "test-utils/test-cases/completion/condition-key/yaml_partial-svc.yml",
"near-exact full-path match should rank first, but got: {} \
(total={}, base={}, frecency={})",
results[0].0, results[0].1.total, results[0].1.base_score, results[0].1.frecency_boost,
);
}
}
#[cfg(test)]
mod multi_part_tests {
#[test]
fn test_single_path_matching() {
let path = "core_workflow_service/kafka_event_consumer/src/ai_part_extraction_request/ai_part_extraction_request_handler.rs";
// Test with max_typos = 2 (safe for short needles)
let options = neo_frizbee::Config {
max_typos: Some(2),
sort: false,
..Default::default()
};
// Test "aipart" matching
let matches = neo_frizbee::match_list("aipart", &[path], &options);
println!("'aipart' matches (max_typos=2): {:?}", matches);
assert!(!matches.is_empty(), "'aipart' should match the path");
// Test "core" matching
let matches = neo_frizbee::match_list("core", &[path], &options);
println!("'core' matches (max_typos=2): {:?}", matches);
assert!(!matches.is_empty(), "'core' should match the path");
// Test "co" matching - need max_typos <= needle.len()
let co_options = neo_frizbee::Config {
max_typos: Some(2), // Safe: 2 <= len("co") = 2
max_typos: Some(2),
..options
};
let matches = neo_frizbee::match_list("co", &[path], &co_options);
println!("'co' matches (max_typos=2): {:?}", matches);
assert!(!matches.is_empty(), "'co' should match the path");
}
#[test]
fn test_lowercase_path_matching() {
// The actual paths are lowercased
let path = "core_workflow_service/kafka_event_consumer/src/ai_part_extraction_request/ai_part_extraction_request_handler.rs".to_lowercase();
let options = neo_frizbee::Config {
@@ -725,14 +902,10 @@ mod multi_part_tests {
..Default::default()
};
// Test "co" matching on lowercase path
let matches = neo_frizbee::match_list("co", &[path.as_str()], &options);
println!("'co' matches lowercase path (max_typos=2): {:?}", matches);
assert!(!matches.is_empty(), "'co' should match the lowercase path");
// Test "core" matching on lowercase path
let matches = neo_frizbee::match_list("core", &[path.as_str()], &options);
println!("'core' matches lowercase path (max_typos=2): {:?}", matches);
assert!(
!matches.is_empty(),
"'core' should match the lowercase path"
+274
View File
@@ -0,0 +1,274 @@
use std::path::PathBuf;
use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard};
use std::time::Duration;
use crate::error::Error;
use crate::file_picker::FilePicker;
use crate::frecency::FrecencyTracker;
use crate::git::GitStatusCache;
use crate::query_tracker::QueryTracker;
/// Thread-safe shared handle to the [`FilePicker`] instance.
///
/// Uses `parking_lot::RwLock` which is reader-fair — new readers are not
/// blocked when a writer is waiting, preventing search query stalls during
/// background bigram builds or watcher writes.
///
/// `Clone` gives a new handle to the same picker (Arc clone).
/// `Default` creates an empty handle suitable for `Lazy::new(SharedPicker::default)`.
#[derive(Clone, Default)]
pub struct SharedPicker(pub(crate) Arc<parking_lot::RwLock<Option<FilePicker>>>);
impl std::fmt::Debug for SharedPicker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("SharedPicker").field(&"..").finish()
}
}
impl SharedPicker {
pub fn read(&self) -> Result<parking_lot::RwLockReadGuard<'_, Option<FilePicker>>, Error> {
Ok(self.0.read())
}
pub fn write(&self) -> Result<parking_lot::RwLockWriteGuard<'_, Option<FilePicker>>, Error> {
Ok(self.0.write())
}
/// Block until the background filesystem scan finishes.
/// Returns `true` if scan completed, `false` on timeout.
pub fn wait_for_scan(&self, timeout: Duration) -> bool {
let signal = {
let guard = self.0.read();
match &*guard {
Some(picker) => picker.scan_signal(),
None => return true,
}
};
let start = std::time::Instant::now();
while signal.load(std::sync::atomic::Ordering::Acquire) {
if start.elapsed() >= timeout {
return false;
}
std::thread::sleep(Duration::from_millis(10));
}
true
}
/// Block until the background file watcher is ready.
/// Returns `true` if watcher ready, `false` on timeout.
pub fn wait_for_watcher(&self, timeout: Duration) -> bool {
let signal = {
let guard = self.0.read();
match &*guard {
Some(picker) => picker.watcher_signal(),
None => return true,
}
};
let start = std::time::Instant::now();
while !signal.load(std::sync::atomic::Ordering::Acquire) {
if start.elapsed() >= timeout {
return false;
}
std::thread::sleep(Duration::from_millis(10));
}
true
}
/// Refresh git statuses for all indexed files.
pub fn refresh_git_status(&self, shared_frecency: &SharedFrecency) -> Result<usize, Error> {
use git2::StatusOptions;
use tracing::debug;
let git_status = {
let guard = self.read()?;
let Some(ref picker) = *guard else {
return Err(Error::FilePickerMissing);
};
debug!(
"Refreshing git statuses for picker: {:?}",
picker.git_root()
);
GitStatusCache::read_git_status(
picker.git_root(),
StatusOptions::new()
.include_untracked(true)
.recurse_untracked_dirs(true)
.include_unmodified(true)
.exclude_submodules(true),
)
};
let mut guard = self.write()?;
let picker = guard.as_mut().ok_or(Error::FilePickerMissing)?;
let statuses_count = if let Some(git_status) = git_status {
let count = git_status.statuses_len();
picker.update_git_statuses(git_status, shared_frecency)?;
count
} else {
0
};
Ok(statuses_count)
}
}
/// Thread-safe shared handle to the [`FrecencyTracker`] instance.
#[derive(Clone)]
pub struct SharedFrecency {
inner: Arc<RwLock<Option<FrecencyTracker>>>,
enabled: bool,
}
impl Default for SharedFrecency {
fn default() -> Self {
Self {
inner: Arc::new(RwLock::new(None)),
enabled: true,
}
}
}
impl std::fmt::Debug for SharedFrecency {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("SharedFrecency").field(&"..").finish()
}
}
impl SharedFrecency {
/// Creates a disabled instance that silently ignores all writes.
pub fn noop() -> Self {
Self {
inner: Arc::new(RwLock::new(None)),
enabled: false,
}
}
pub fn read(&self) -> Result<RwLockReadGuard<'_, Option<FrecencyTracker>>, Error> {
self.inner.read().map_err(|_| Error::AcquireFrecencyLock)
}
pub fn write(&self) -> Result<RwLockWriteGuard<'_, Option<FrecencyTracker>>, Error> {
self.inner.write().map_err(|_| Error::AcquireFrecencyLock)
}
/// Initialize the frecency tracker. No-op if this is a disabled instance.
pub fn init(&self, tracker: FrecencyTracker) -> Result<(), Error> {
if !self.enabled {
return Ok(());
}
let mut guard = self.write()?;
*guard = Some(tracker);
Ok(())
}
/// Spawn a background GC thread for this frecency tracker.
pub fn spawn_gc(
&self,
db_path: String,
use_unsafe_no_lock: bool,
) -> crate::Result<std::thread::JoinHandle<()>> {
FrecencyTracker::spawn_gc(self.clone(), db_path, use_unsafe_no_lock)
}
/// Drop the in-memory tracker and delete the on-disk database directory.
///
/// Acquires the write lock, ensuring all readers (including any active mmap
/// access) are finished before the LMDB environment is closed and the files
/// are removed.
///
/// Returns `Ok(Some(path))` with the deleted path, or `Ok(None)` if no
/// tracker was initialized.
pub fn destroy(&self) -> Result<Option<PathBuf>, Error> {
let mut guard = self.write()?;
let Some(tracker) = guard.take() else {
return Ok(None);
};
let db_path = tracker.db_path().to_path_buf();
// Drop closes the LMDB env and unmaps the files
drop(tracker);
drop(guard);
std::fs::remove_dir_all(&db_path).map_err(|source| Error::RemoveDbDir {
path: db_path.clone(),
source,
})?;
Ok(Some(db_path))
}
}
/// Thread-safe shared handle to the [`QueryTracker`] instance.
#[derive(Clone)]
pub struct SharedQueryTracker {
inner: Arc<RwLock<Option<QueryTracker>>>,
enabled: bool,
}
impl Default for SharedQueryTracker {
fn default() -> Self {
Self {
inner: Arc::new(RwLock::new(None)),
enabled: true,
}
}
}
impl std::fmt::Debug for SharedQueryTracker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("SharedQueryTracker").field(&"..").finish()
}
}
impl SharedQueryTracker {
/// Creates a disabled instance that silently ignores all writes.
pub fn noop() -> Self {
Self {
inner: Arc::new(RwLock::new(None)),
enabled: false,
}
}
pub fn read(&self) -> Result<RwLockReadGuard<'_, Option<QueryTracker>>, Error> {
self.inner.read().map_err(|_| Error::AcquireFrecencyLock)
}
pub fn write(&self) -> Result<RwLockWriteGuard<'_, Option<QueryTracker>>, Error> {
self.inner.write().map_err(|_| Error::AcquireFrecencyLock)
}
/// Initialize the query tracker. No-op if this is a disabled instance.
pub fn init(&self, tracker: QueryTracker) -> Result<(), Error> {
if !self.enabled {
return Ok(());
}
let mut guard = self.write()?;
*guard = Some(tracker);
Ok(())
}
/// Drop the in-memory tracker and delete the on-disk database directory.
///
/// Acquires the write lock, ensuring all readers (including any active mmap
/// access) are finished before the LMDB environment is closed and the files
/// are removed.
///
/// Returns `Ok(Some(path))` with the deleted path, or `Ok(None)` if no
/// tracker was initialized.
pub fn destroy(&self) -> Result<Option<PathBuf>, Error> {
let mut guard = self.write()?;
let Some(tracker) = guard.take() else {
return Ok(None);
};
let db_path = tracker.db_path().to_path_buf();
drop(tracker);
drop(guard);
std::fs::remove_dir_all(&db_path).map_err(|source| Error::RemoveDbDir {
path: db_path.clone(),
source,
})?;
Ok(Some(db_path))
}
}
+3 -3
View File
@@ -82,9 +82,9 @@ mod tests {
#[test]
fn test_sort_by_key_with_buffer() {
let mut data = vec![(2, "b"), (1, "a"), (3, "c")];
sort_by_key_with_buffer(&mut data, |item| item.0);
assert_eq!(data, vec![(1, "a"), (2, "b"), (3, "c")]);
let mut data = vec![(1, 50), (2, 20), (3, 80), (4, 10), (5, 90)];
sort_by_key_with_buffer(&mut data, |a| a.1);
assert_eq!(data, vec![(4, 10), (2, 20), (1, 50), (3, 80), (5, 90)]);
}
#[test]
+318 -60
View File
@@ -1,21 +1,26 @@
use std::path::{Path, PathBuf};
use std::io::Read;
use std::path::Path;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use crate::constraints::Constrainable;
use crate::query_tracker::QueryMatchEntry;
use fff_query_parser::{FFFQuery, FuzzyQuery, Location};
use neo_frizbee::Matchable;
/// Cached file contents — mmap on Unix, heap buffer on Windows.
///
/// On Windows, memory-mapped files hold the file handle open and prevent
/// editors from saving (writing/replacing) those files. Reading into a
/// `Vec<u8>` releases the handle immediately after the read completes.
///
/// The `Buffer` variant is also used on Unix for temporary (uncached) reads
/// where the mmap/munmap syscall overhead exceeds the cost of a heap copy.
#[derive(Debug)]
#[allow(dead_code)] // variants are conditionally used per platform
enum FileContent {
pub enum FileContent {
#[cfg(not(target_os = "windows"))]
Mmap(memmap2::Mmap),
#[cfg(target_os = "windows")]
Buffer(Vec<u8>),
}
@@ -25,13 +30,22 @@ impl std::ops::Deref for FileContent {
match self {
#[cfg(not(target_os = "windows"))]
FileContent::Mmap(m) => m,
#[cfg(target_os = "windows")]
FileContent::Buffer(b) => b,
}
}
}
pub struct FileItemFlags;
impl FileItemFlags {
pub const BINARY: u8 = 1 << 0;
/// Tombstone — file was deleted but index slot is preserved so
/// bigram indices for other files stay valid.
pub const DELETED: u8 = 1 << 1;
}
/// A single indexed file with metadata, frecency scores, and lazy content cache.
/// Occupies ~100 bytes + file path per file
///
/// File contents are initialized lazily on the first grep access and cached for
/// subsequent searches. On Unix, uses mmap backed by the kernel page cache. On
@@ -41,18 +55,26 @@ impl std::ops::Deref for FileContent {
/// Each file is only searched by one rayon worker at a time via `par_iter`.
#[derive(Debug)]
pub struct FileItem {
pub path: PathBuf,
pub relative_path: String,
pub relative_path_lower: String,
pub file_name: String,
pub file_name_lower: String,
/// File size in bytes
pub size: u64,
/// Modification time in UNIX timestamp
pub modified: u64,
pub access_frecency_score: i64,
pub modification_frecency_score: i64,
pub total_frecency_score: i64,
/// Frecency access score
pub access_frecency_score: i16,
/// Frecency modification score
pub modification_frecency_score: i16,
/// The file's git status
pub git_status: Option<git2::Status>,
pub is_binary: bool,
/// Absolute path stored as a plain String. We never use path components —
/// only slicing, comparison, and passing to fs/DB APIs via `as_path()`.
path: String,
/// Byte offset where the relative path begins (after base_path + separator).
relative_start: u16,
/// Byte offset where the filename begins (after last separator).
filename_start: u16,
/// Packed boolean flags — see `FileItemFlags`.
flags: u8,
/// Lazily-initialized file contents for grep.
/// Initialized on first grep access via `OnceLock`; lock-free on subsequent reads.
content: OnceLock<FileContent>,
@@ -62,125 +84,263 @@ impl Clone for FileItem {
fn clone(&self) -> Self {
Self {
path: self.path.clone(),
relative_path: self.relative_path.clone(),
relative_path_lower: self.relative_path_lower.clone(),
file_name: self.file_name.clone(),
file_name_lower: self.file_name_lower.clone(),
relative_start: self.relative_start,
filename_start: self.filename_start,
size: self.size,
modified: self.modified,
access_frecency_score: self.access_frecency_score,
modification_frecency_score: self.modification_frecency_score,
total_frecency_score: self.total_frecency_score,
git_status: self.git_status,
is_binary: self.is_binary,
flags: self.flags,
// Don't clone the content — the clone lazily re-creates it on demand
content: OnceLock::new(),
}
}
}
const MAX_MMAP_FILE_SIZE: u64 = 10 * 1024 * 1024;
impl FileItem {
/// Create a new `FileItem` with all fields specified and an empty (not yet loaded) mmap.
pub fn new_raw(
path: PathBuf,
relative_path: String,
file_name: String,
path: String,
relative_start: u16,
filename_start: u16,
size: u64,
modified: u64,
git_status: Option<git2::Status>,
is_binary: bool,
) -> Self {
let mut flags = 0u8;
if is_binary {
flags |= FileItemFlags::BINARY;
}
Self {
relative_path_lower: relative_path.to_lowercase(),
file_name_lower: file_name.to_lowercase(),
path,
relative_path,
file_name,
relative_start,
filename_start,
size,
modified,
access_frecency_score: 0,
modification_frecency_score: 0,
total_frecency_score: 0,
git_status,
is_binary,
flags,
content: OnceLock::new(),
}
}
/// The full absolute path as a string slice.
#[inline]
pub fn path_str(&self) -> &str {
&self.path
}
/// The full absolute path as a `&Path` (zero-cost on Unix).
#[inline]
pub fn as_path(&self) -> &Path {
Path::new(&self.path)
}
/// The relative path (from the base directory).
#[inline]
pub fn relative_path(&self) -> &str {
&self.path[self.relative_start as usize..]
}
/// Just the filename component.
#[inline]
pub fn file_name(&self) -> &str {
&self.path[self.filename_start as usize..]
}
/// Byte offset of the filename within the relative path.
/// Equivalent to `relative_path().len() - file_name().len()`.
#[inline]
pub fn filename_offset_in_relative(&self) -> usize {
(self.filename_start - self.relative_start) as usize
}
#[inline]
pub fn total_frecency_score(&self) -> i32 {
self.access_frecency_score as i32 + self.modification_frecency_score as i32
}
#[inline]
pub fn is_binary(&self) -> bool {
self.flags & FileItemFlags::BINARY != 0
}
#[inline]
pub fn set_binary(&mut self, val: bool) {
if val {
self.flags |= FileItemFlags::BINARY;
} else {
self.flags &= !FileItemFlags::BINARY;
}
}
#[inline]
pub fn is_deleted(&self) -> bool {
self.flags & FileItemFlags::DELETED != 0
}
#[inline]
pub fn set_deleted(&mut self, val: bool) {
if val {
self.flags |= FileItemFlags::DELETED;
} else {
self.flags &= !FileItemFlags::DELETED;
}
}
}
impl Matchable for FileItem {
#[inline]
fn match_str(&self) -> Option<&str> {
(!self.is_deleted()).then(|| self.relative_path())
}
}
impl Matchable for &FileItem {
#[inline]
fn match_str(&self) -> Option<&str> {
(!self.is_deleted()).then(|| self.relative_path())
}
}
impl FileItem {
/// Invalidate the cached content so the next `get_content()` call creates a fresh one.
///
/// Call this when the background watcher detects that the file has been modified.
/// On Unix, a file that is truncated while mapped can cause SIGBUS. On Windows,
/// the stale buffer simply won't reflect the new contents. In both cases,
/// invalidating ensures a fresh read on the next access.
pub fn invalidate_mmap(&mut self) {
pub fn invalidate_mmap(&mut self, budget: &ContentCacheBudget) {
if self.content.get().is_some() {
budget.cached_count.fetch_sub(1, Ordering::Relaxed);
budget.cached_bytes.fetch_sub(self.size, Ordering::Relaxed);
}
self.content = OnceLock::new();
}
/// Get the cached file contents or lazily load them. Returns `None` if the
/// file is too large, empty, or can't be opened.
/// Get the cached file contents or lazily load and cache them.
///
/// Returns `None` if the file is too large, empty, can't be opened, **or
/// the cache budget is exhausted**. Callers that need content regardless
/// of the budget should use [`get_content_for_search`].
///
/// After the first call, this is lock-free (just an atomic load + pointer deref).
/// On Unix, uses mmap backed by the kernel page cache. On Windows, reads into
/// a heap buffer so the file handle is released immediately.
#[inline]
pub fn get_content(&self) -> Option<&[u8]> {
pub fn get_content(&self, budget: &ContentCacheBudget) -> Option<&[u8]> {
if let Some(content) = self.content.get() {
return Some(content);
}
if self.size == 0 || self.size > MAX_MMAP_FILE_SIZE {
let max_file_size = budget.max_file_size;
if self.size == 0 || self.size > max_file_size {
return None;
}
let content = load_file_content(&self.path)?;
// Check cache budget before creating a new persistent cache entry.
let count = budget.cached_count.load(Ordering::Relaxed);
let bytes = budget.cached_bytes.load(Ordering::Relaxed);
let max_files = budget.max_files;
let max_bytes = budget.max_bytes;
if count >= max_files || bytes + self.size > max_bytes {
return None;
}
// If another thread raced us, OnceLock discards ours and returns theirs.
Some(self.content.get_or_init(|| content))
let content = load_file_content(self.as_path(), self.size)?;
let result = self.content.get_or_init(|| content);
// Bump counters. Slight over-count under races is fine — the budget
// is a soft limit and the overshoot is bounded by rayon thread count.
budget.cached_count.fetch_add(1, Ordering::Relaxed);
budget.cached_bytes.fetch_add(self.size, Ordering::Relaxed);
Some(result)
}
/// Backward-compatible alias for `get_content`.
/// Get file content for searching — **always returns content** for eligible
/// files, even when the persistent cache budget is exhausted.
#[inline]
pub fn get_mmap(&self) -> Option<&[u8]> {
self.get_content()
pub fn get_content_for_search<'a>(
&'a self,
buf: &'a mut Vec<u8>,
budget: &ContentCacheBudget,
) -> Option<&'a [u8]> {
// Fast path: persistent cache hit (zero-copy).
if let Some(cached) = self.get_content(budget) {
return Some(cached);
}
let max_file_size = budget.max_file_size;
if self.is_binary() || self.size == 0 || self.size > max_file_size {
return None;
}
// Slow path: read into the reusable buffer — open() + read_exact() + close().
// No mmap()/munmap() syscalls, no page table setup/teardown.
// We know the exact size so we use read_exact (1 read syscall) instead of
// read_to_end (2 read syscalls — one for data, one for EOF confirmation).
let len = self.size as usize;
buf.resize(len, 0);
let mut file = std::fs::File::open(self.as_path()).ok()?;
file.read_exact(buf).ok()?;
Some(buf.as_slice())
}
}
/// Load file contents: mmap on Unix, heap buffer on Windows.
fn load_file_content(path: &Path) -> Option<FileContent> {
/// Page size on Apple Silicon is 16KB; on x86-64 it's 4KB.
/// Files smaller than one page waste the remainder when mmapped.
/// Reading them into a heap buffer avoids this overhead.
#[cfg(target_arch = "aarch64")]
const MMAP_THRESHOLD: u64 = 16 * 1024;
#[cfg(not(target_arch = "aarch64"))]
const MMAP_THRESHOLD: u64 = 4 * 1024;
/// Load file contents: small files are read into a heap buffer to avoid
/// mmap page alignment waste; large files use mmap for zero-copy access.
/// On Windows, always uses heap buffer (mmap holds the file handle open).
fn load_file_content(path: &Path, size: u64) -> Option<FileContent> {
#[cfg(not(target_os = "windows"))]
{
let file = std::fs::File::open(path).ok()?;
// SAFETY: The mmap is backed by the kernel page cache and automatically
// reflects file modifications. The only risk is SIGBUS if the file is
// truncated while mapped.
let mmap = unsafe { memmap2::Mmap::map(&file) }.ok()?;
Some(FileContent::Mmap(mmap))
if size < MMAP_THRESHOLD {
let data = std::fs::read(path).ok()?;
Some(FileContent::Buffer(data))
} else {
let file = std::fs::File::open(path).ok()?;
// SAFETY: The mmap is backed by the kernel page cache and automatically
// reflects file modifications. The only risk is SIGBUS if the file is
// truncated while mapped.
let mmap = unsafe { memmap2::Mmap::map(&file) }.ok()?;
Some(FileContent::Mmap(mmap))
}
}
#[cfg(target_os = "windows")]
{
let _ = size;
let data = std::fs::read(path).ok()?;
Some(FileContent::Buffer(data))
}
}
impl AsRef<Path> for FileItem {
#[inline]
fn as_ref(&self) -> &Path {
Path::new(&self.path)
}
}
impl Constrainable for FileItem {
#[inline]
fn relative_path(&self) -> &str {
&self.relative_path
}
#[inline]
fn relative_path_lower(&self) -> &str {
&self.relative_path_lower
FileItem::relative_path(self)
}
#[inline]
fn file_name(&self) -> &str {
&self.file_name
FileItem::file_name(self)
}
#[inline]
@@ -200,6 +360,7 @@ pub struct Score {
pub distance_penalty: i32,
pub current_file_penalty: i32,
pub combo_match_boost: i32,
pub path_alignment_bonus: i32,
pub exact_match: bool,
pub match_type: &'static str,
}
@@ -210,6 +371,15 @@ pub struct PaginationArgs {
pub limit: usize,
}
impl Default for PaginationArgs {
fn default() -> Self {
Self {
offset: 0,
limit: 100,
}
}
}
/// Context for scoring files during search.
///
/// The `query` field contains the pre-parsed query with constraints,
@@ -223,7 +393,7 @@ pub struct ScoringContext<'a> {
pub current_file: Option<&'a str>,
pub max_typos: u16,
pub max_threads: usize,
pub last_same_query_match: Option<&'a QueryMatchEntry>,
pub last_same_query_match: Option<QueryMatchEntry>,
pub combo_boost_score_multiplier: i32,
pub min_combo_count: u32,
pub pagination: PaginationArgs,
@@ -249,3 +419,91 @@ pub struct SearchResult<'a> {
pub total_files: usize,
pub location: Option<Location>,
}
const MAX_MMAP_FILE_SIZE: u64 = 10 * 1024 * 1024;
// Limits the total number of files (and bytes) whose content is kept in
// memory via the `OnceLock<FileContent>` cache. On Unix every cached file
// holds a live `mmap`, which consumes a kernel `vm_map_entry`. On a 500k-file
// monorepo, caching everything exhausts macOS/Linux kernel resources and
// crashes the machine (see issue #294).
//
// Each `FilePicker` owns its own `ContentCacheBudget`. The budget is passed
// to `grep_search` and `warmup_mmaps` so that multiple pickers can coexist
// without interfering with each other's counters.
const MAX_CACHED_CONTENT_BYTES: u64 = 512 * 1024 * 1024;
/// Per-picker budget controlling how many files may have their content
/// persistently cached (mmap on Unix, heap buffer on Windows).
#[derive(Debug)]
pub struct ContentCacheBudget {
pub max_files: usize,
pub max_bytes: u64,
pub max_file_size: u64,
pub cached_count: AtomicUsize,
pub cached_bytes: AtomicU64,
}
impl ContentCacheBudget {
/// No limits — every eligible file is cached. Useful for tests and
/// short-lived tools that don't need resource protection.
pub fn unlimited() -> Self {
Self {
max_files: usize::MAX,
max_bytes: u64::MAX,
max_file_size: MAX_MMAP_FILE_SIZE,
cached_count: AtomicUsize::new(0),
cached_bytes: AtomicU64::new(0),
}
}
pub fn zero() -> Self {
Self {
max_files: 0,
max_bytes: 0,
max_file_size: 0,
cached_count: AtomicUsize::new(0),
cached_bytes: AtomicU64::new(0),
}
}
pub fn new_for_repo(file_count: usize) -> Self {
let max_files = if file_count > 50_000 {
5_000
} else if file_count > 10_000 {
10_000
} else {
30_000 // effectively unlimited for small repos
};
let max_bytes = if file_count > 50_000 {
128 * 1024 * 1024 // 128 MB
} else if file_count > 10_000 {
256 * 1024 * 1024 // 256 MB
} else {
MAX_CACHED_CONTENT_BYTES // 512 MB
};
Self {
max_files,
max_bytes,
max_file_size: MAX_MMAP_FILE_SIZE,
cached_count: AtomicUsize::new(0),
cached_bytes: AtomicU64::new(0),
}
}
/// Reset the counters. Called when the file index is rebuilt (rescan /
/// directory change) and all old `FileItem`s are dropped.
pub fn reset(&self) {
self.cached_count.store(0, Ordering::Relaxed);
self.cached_bytes.store(0, Ordering::Relaxed);
}
}
impl Default for ContentCacheBudget {
fn default() -> Self {
Self::new_for_repo(30_000)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,416 @@
//! Integration test: verify that modifying a file after the bigram index is built
//! still makes the new content findable via grep (through the overlay layer).
use std::fs;
use std::time::Duration;
use tempfile::TempDir;
use fff_search::file_picker::{FFFMode, FilePicker};
use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query};
use fff_search::{FilePickerOptions, SharedFrecency, SharedPicker};
/// Create a temp directory with some initial files, run the full picker lifecycle,
/// then modify a file and verify grep finds the new content.
#[test]
fn modified_file_findable_via_overlay() {
let tmp = TempDir::new().unwrap();
let base = tmp.path();
// Create initial files with known content.
fs::write(base.join("alpha.txt"), "hello world\nfoo bar\n").unwrap();
fs::write(
base.join("beta.txt"),
"some other content\nnothing special\n",
)
.unwrap();
fs::write(base.join("gamma.txt"), "yet another file\nmore lines\n").unwrap();
let shared_picker = SharedPicker::default();
let shared_frecency = SharedFrecency::default();
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().to_string(),
warmup_mmap_cache: true,
mode: FFFMode::Neovim,
..Default::default()
},
)
.expect("Failed to create FilePicker");
// Wait for scan + bigram build to complete.
let deadline = std::time::Instant::now() + Duration::from_secs(30);
loop {
std::thread::sleep(Duration::from_millis(50));
let ready = shared_picker
.read()
.ok()
.map(|guard| {
guard
.as_ref()
.map_or(false, |p| !p.is_scan_active() && p.bigram_index().is_some())
})
.unwrap_or(false);
if ready {
break;
}
assert!(
std::time::Instant::now() < deadline,
"Timed out waiting for scan + bigram build"
);
}
// Sanity check: the 3 files are indexed.
{
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
assert_eq!(picker.get_files().len(), 3, "Expected 3 files after scan");
assert!(
picker.bigram_index().is_some(),
"Bigram index should be built"
);
assert!(
picker.bigram_overlay().is_some(),
"Overlay should be initialized"
);
}
// "UNIQUE_NEEDLE" should NOT exist in any file yet.
{
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let parsed = parse_grep_query("UNIQUE_NEEDLE");
let opts = grep_opts();
let result = picker.grep(&parsed, &opts);
assert_eq!(
result.matches.len(),
0,
"UNIQUE_NEEDLE should not exist before modification"
);
}
// Sleep so the filesystem mtime (seconds granularity) advances past the
// value recorded during scan. Without this, on_create_or_modify skips
// mmap invalidation and grep reads stale cached content.
std::thread::sleep(Duration::from_millis(1100));
// Write new content containing the needle.
let modified_path = base.join("beta.txt");
fs::write(
&modified_path,
"some other content\nUNIQUE_NEEDLE is here\nnothing special\n",
)
.unwrap();
// Simulate watcher event: call on_create_or_modify.
// This updates the overlay's bigrams and invalidates the mmap cache.
{
let mut guard = shared_picker.write().unwrap();
let picker = guard.as_mut().unwrap();
let result = picker.on_create_or_modify(&modified_path);
assert!(
result.is_some(),
"on_create_or_modify should return the file"
);
}
// The bigram index was built BEFORE the modification, so without the
// overlay, beta.txt would be filtered out (its old bigrams don't contain
// "UNIQUE_NEEDLE"). The overlay should fix that.
{
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let parsed = parse_grep_query("UNIQUE_NEEDLE");
let opts = grep_opts();
let result = picker.grep(&parsed, &opts);
assert!(
!result.matches.is_empty(),
"UNIQUE_NEEDLE should be findable after modification (overlay adds the candidate back)"
);
// May find 1 or 2 matches depending on mmap cache state — the important
// thing is that the modified content IS found.
assert!(
result
.matches
.iter()
.any(|m| m.line_content.contains("UNIQUE_NEEDLE")),
"At least one match should contain UNIQUE_NEEDLE"
);
}
// Prove the overlay is actually doing something: without it, the bigram
// index would filter out beta.txt and the search would miss the needle.
{
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let parsed = parse_grep_query("UNIQUE_NEEDLE");
let opts = grep_opts();
let result = picker.grep_without_overlay(&parsed, &opts);
assert_eq!(
result.matches.len(),
0,
"Without overlay, bigram prefiltering should exclude the modified file"
);
}
// Cleanup: stop background watcher.
if let Ok(mut guard) = shared_picker.write() {
if let Some(ref mut picker) = *guard {
picker.stop_background_monitor();
}
}
}
/// Verify that deleting a file makes its content un-findable via grep.
#[test]
fn deleted_file_excluded_via_overlay() {
let tmp = TempDir::new().unwrap();
let base = tmp.path();
fs::write(base.join("keep.txt"), "keep this content\n").unwrap();
fs::write(base.join("remove.txt"), "DELETEME_TOKEN is here\n").unwrap();
let shared_picker = SharedPicker::default();
let shared_frecency = SharedFrecency::default();
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().to_string(),
warmup_mmap_cache: true,
mode: FFFMode::Neovim,
..Default::default()
},
)
.unwrap();
wait_for_bigram(&shared_picker);
// Sanity: DELETEME_TOKEN is findable.
{
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let result = grep_for(picker, "DELETEME_TOKEN");
assert_eq!(
result.matches.len(),
1,
"Token should be found before delete"
);
}
// Delete the file on disk and via picker.
let remove_path = base.join("remove.txt");
fs::remove_file(&remove_path).unwrap();
{
let mut guard = shared_picker.write().unwrap();
let picker = guard.as_mut().unwrap();
assert!(
picker.remove_file_by_path(&remove_path),
"remove should succeed"
);
}
// Token should no longer be found (tombstone in overlay clears the candidate).
{
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let result = grep_for(picker, "DELETEME_TOKEN");
assert_eq!(
result.matches.len(),
0,
"DELETEME_TOKEN should not be found after deletion (tombstone in overlay)"
);
}
if let Ok(mut guard) = shared_picker.write() {
if let Some(ref mut picker) = *guard {
picker.stop_background_monitor();
}
}
}
/// Verify that a newly added file (in overflow) is findable via grep.
#[test]
fn new_file_findable_after_add() {
let tmp = TempDir::new().unwrap();
let base = tmp.path();
fs::write(base.join("existing.txt"), "original content\n").unwrap();
let shared_picker = SharedPicker::default();
let shared_frecency = SharedFrecency::default();
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().to_string(),
warmup_mmap_cache: true,
mode: FFFMode::Neovim,
..Default::default()
},
)
.unwrap();
wait_for_bigram(&shared_picker);
// Create a new file on disk after the index was built.
let new_path = base.join("newcomer.txt");
fs::write(&new_path, "BRAND_NEW_TOKEN lives here\n").unwrap();
// Simulate watcher detecting the new file.
{
let mut guard = shared_picker.write().unwrap();
let picker = guard.as_mut().unwrap();
let result = picker.on_create_or_modify(&new_path);
assert!(
result.is_some(),
"on_create_or_modify should return the new file"
);
}
// The new file is in overflow, not in the base files slice.
// grep_search currently only searches base files, so we need to verify
// the overflow file is accessible.
{
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let overflow = picker.get_overflow_files();
assert_eq!(overflow.len(), 1, "Should have 1 overflow file");
assert!(
overflow[0].as_path().ends_with("newcomer.txt"),
"Overflow file should be newcomer.txt"
);
}
if let Ok(mut guard) = shared_picker.write() {
if let Some(ref mut picker) = *guard {
picker.stop_background_monitor();
}
}
}
/// Verify that a file modified after index build is findable via regex grep
/// through the overlay. This catches a regression where `extract_bigrams` on
/// the raw regex string (e.g. "NEEDLE.*HERE") produces bogus bigrams containing
/// `.` and `*`, causing `query_modified` to miss the file.
#[test]
fn modified_file_findable_via_regex_overlay() {
let tmp = TempDir::new().unwrap();
let base = tmp.path();
fs::write(base.join("alpha.txt"), "hello world\nfoo bar\n").unwrap();
fs::write(
base.join("beta.txt"),
"some other content\nnothing special\n",
)
.unwrap();
let shared_picker = SharedPicker::default();
let shared_frecency = SharedFrecency::default();
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().to_string(),
warmup_mmap_cache: true,
mode: FFFMode::Neovim,
..Default::default()
},
)
.unwrap();
wait_for_bigram(&shared_picker);
// Advance mtime past the scan timestamp so the cache is invalidated.
std::thread::sleep(Duration::from_millis(1100));
// Write content that matches the regex "NEEDLE.*HERE" into beta.txt.
let modified_path = base.join("beta.txt");
fs::write(
&modified_path,
"some other content\nNEEDLE is right HERE\nnothing special\n",
)
.unwrap();
{
let mut guard = shared_picker.write().unwrap();
let picker = guard.as_mut().unwrap();
assert!(picker.on_create_or_modify(&modified_path).is_some());
}
// Regex grep should find the modified file through the overlay.
{
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let parsed = parse_grep_query("NEEDLE.*HERE");
let opts = GrepSearchOptions {
mode: GrepMode::Regex,
..grep_opts()
};
let result = picker.grep(&parsed, &opts);
assert!(
!result.matches.is_empty(),
"Regex grep should find NEEDLE.*HERE in modified file via overlay"
);
assert!(result.matches[0].line_content.contains("NEEDLE"));
}
if let Ok(mut guard) = shared_picker.write() {
if let Some(ref mut picker) = *guard {
picker.stop_background_monitor();
}
}
}
// ── Helpers ─────────────────────────────────────────────────────────────
fn grep_opts() -> GrepSearchOptions {
GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 200,
smart_case: true,
file_offset: 0,
page_limit: 200,
mode: GrepMode::PlainText,
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
}
}
fn grep_for<'a>(picker: &'a FilePicker, query: &str) -> fff_search::grep::GrepResult<'a> {
let parsed = parse_grep_query(query);
picker.grep(&parsed, &grep_opts())
}
fn wait_for_bigram(shared_picker: &SharedPicker) {
let deadline = std::time::Instant::now() + Duration::from_secs(30);
loop {
std::thread::sleep(Duration::from_millis(50));
let ready = shared_picker
.read()
.ok()
.map(|guard| {
guard
.as_ref()
.map_or(false, |p| !p.is_scan_active() && p.bigram_index().is_some())
})
.unwrap_or(false);
if ready {
break;
}
assert!(
std::time::Instant::now() < deadline,
"Timed out waiting for bigram build"
);
}
}
@@ -0,0 +1,765 @@
//! Randomized file-system mutation stress test.
//!
//! Seeds a directory with ~40 files across diverse content domains, builds the
//! picker + bigram index, then runs 20 rounds of randomized create / edit /
//! delete / rename / read-only operations. After every round the test verifies
//! that plain-text grep, regex grep, and fuzzy file search all return correct
//! results for every live and dead file.
//!
//! Uses a seeded RNG (`SmallRng::seed_from_u64`) for deterministic
//! reproduction.
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tempfile::TempDir;
use rand::rngs::SmallRng;
use rand::{RngCore, SeedableRng};
use fff_search::file_picker::{FFFMode, FilePicker, FuzzySearchOptions};
use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query};
use fff_search::{FilePickerOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
const DOMAINS: &[&str] = &[
r#"
use std::net::{TcpStream, SocketAddr};
fn establish_connection(addr: SocketAddr) -> Result<TcpStream, std::io::Error> {
let stream = TcpStream::connect(addr)?;
stream.set_nodelay(true)?;
Ok(stream)
}
fn parse_http_header(raw: &[u8]) -> Option<(&str, &str)> {
let line = std::str::from_utf8(raw).ok()?;
let (key, val) = line.split_once(':')?;
Some((key.trim(), val.trim()))
}
"#,
r#"
use sqlx::{PgPool, Row};
async fn query_users(pool: &PgPool, limit: i64) -> Vec<String> {
sqlx::query("SELECT name FROM users ORDER BY created_at DESC LIMIT $1")
.bind(limit)
.fetch_all(pool)
.await
.unwrap()
.iter()
.map(|row| row.get("name"))
.collect()
}
async fn insert_record(pool: &PgPool, name: &str) -> i64 {
sqlx::query_scalar("INSERT INTO records (name) VALUES ($1) RETURNING id")
.bind(name)
.fetch_one(pool)
.await
.unwrap()
}
"#,
r#"
fn verify_jwt_token(token: &str, secret: &[u8]) -> Result<Claims, AuthError> {
let parts: Vec<&str> = token.splitn(3, '.').collect();
if parts.len() != 3 { return Err(AuthError::MalformedToken); }
let payload = base64_decode(parts[1])?;
let signature = hmac_sha256(secret, &format!("{}.{}", parts[0], parts[1]));
if signature != base64_decode(parts[2])? { return Err(AuthError::InvalidSignature); }
serde_json::from_slice(&payload).map_err(AuthError::Deserialize)
}
fn hash_password(password: &str, salt: &[u8]) -> Vec<u8> {
argon2::hash_encoded(password.as_bytes(), salt, &argon2::Config::default())
.unwrap().into_bytes()
}
"#,
r#"
struct Renderer { framebuffer: Vec<u32>, width: usize, height: usize }
impl Renderer {
fn clear(&mut self, color: u32) { self.framebuffer.fill(color); }
fn draw_pixel(&mut self, x: usize, y: usize, color: u32) {
if x < self.width && y < self.height {
self.framebuffer[y * self.width + x] = color;
}
}
fn draw_line(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, color: u32) {
let dx = (x1 - x0).abs(); let dy = -(y1 - y0).abs();
let mut err = dx + dy;
let (mut cx, mut cy) = (x0, y0);
loop {
self.draw_pixel(cx as usize, cy as usize, color);
if cx == x1 && cy == y1 { break; }
let e2 = 2 * err;
if e2 >= dy { err += dy; cx += if x0 < x1 { 1 } else { -1 }; }
if e2 <= dx { err += dx; cy += if y0 < y1 { 1 } else { -1 }; }
}
}
}
"#,
r#"
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
struct ConfigFile { log_level: String, max_retries: u32, timeout_ms: u64 }
fn load_config(path: &std::path::Path) -> Result<ConfigFile, Box<dyn std::error::Error>> {
let contents = std::fs::read_to_string(path)?;
let config: ConfigFile = toml::from_str(&contents)?;
Ok(config)
}
fn merge_configs(base: ConfigFile, overlay: ConfigFile) -> ConfigFile {
ConfigFile {
log_level: if overlay.log_level.is_empty() { base.log_level } else { overlay.log_level },
max_retries: overlay.max_retries.max(base.max_retries),
timeout_ms: overlay.timeout_ms.max(base.timeout_ms),
}
}
"#,
r#"
struct PhysicsBody { position: [f64; 3], velocity: [f64; 3], mass: f64 }
fn apply_gravity(bodies: &mut [PhysicsBody], dt: f64) {
let gravity_constant = 6.674e-11;
let len = bodies.len();
let mut forces = vec![[0.0f64; 3]; len];
for i in 0..len {
for j in (i+1)..len {
let dx = bodies[j].position[0] - bodies[i].position[0];
let dy = bodies[j].position[1] - bodies[i].position[1];
let dz = bodies[j].position[2] - bodies[i].position[2];
let dist_sq = dx*dx + dy*dy + dz*dz;
let force_mag = gravity_constant * bodies[i].mass * bodies[j].mass / dist_sq;
let dist = dist_sq.sqrt();
for k in 0..3 {
let f = force_mag * [dx, dy, dz][k] / dist;
forces[i][k] += f; forces[j][k] -= f;
}
}
}
for (body, force) in bodies.iter_mut().zip(forces.iter()) {
for k in 0..3 {
body.velocity[k] += force[k] / body.mass * dt;
body.position[k] += body.velocity[k] * dt;
}
}
}
"#,
r#"
use std::collections::BTreeMap;
struct CacheEntry<V> { value: V, frequency: u64, last_access: u64 }
struct LFUCache<K: Ord, V> { map: BTreeMap<K, CacheEntry<V>>, capacity: usize, clock: u64 }
impl<K: Ord, V> LFUCache<K, V> {
fn new(capacity: usize) -> Self { Self { map: BTreeMap::new(), capacity, clock: 0 } }
fn get(&mut self, key: &K) -> Option<&V> {
self.clock += 1;
let entry = self.map.get_mut(key)?;
entry.frequency += 1;
entry.last_access = self.clock;
Some(&entry.value)
}
fn insert(&mut self, key: K, value: V) {
self.clock += 1;
if self.map.len() >= self.capacity { self.evict(); }
self.map.insert(key, CacheEntry { value, frequency: 1, last_access: self.clock });
}
fn evict(&mut self) {
if let Some(victim) = self.map.keys().min_by_key(|k| {
let e = &self.map[*k]; (e.frequency, e.last_access)
}).cloned() { self.map.remove(&victim); }
}
}
"#,
r#"
fn tokenize_expression(input: &str) -> Vec<Token> {
let mut tokens = Vec::new();
let mut chars = input.chars().peekable();
while let Some(&ch) = chars.peek() {
match ch {
'0'..='9' => {
let mut num = String::new();
while let Some(&d) = chars.peek() {
if d.is_ascii_digit() || d == '.' { num.push(d); chars.next(); }
else { break; }
}
tokens.push(Token::Number(num.parse().unwrap()));
}
'+' => { tokens.push(Token::Plus); chars.next(); }
'-' => { tokens.push(Token::Minus); chars.next(); }
'*' => { tokens.push(Token::Star); chars.next(); }
'/' => { tokens.push(Token::Slash); chars.next(); }
'(' => { tokens.push(Token::LParen); chars.next(); }
')' => { tokens.push(Token::RParen); chars.next(); }
_ if ch.is_whitespace() => { chars.next(); }
_ => { chars.next(); }
}
}
tokens
}
"#,
r#"
use std::sync::mpsc;
use std::thread;
fn parallel_map<T: Send + 'static, R: Send + 'static>(
items: Vec<T>, num_threads: usize, f: fn(T) -> R
) -> Vec<R> {
let chunk_size = (items.len() + num_threads - 1) / num_threads;
let (tx, rx) = mpsc::channel();
let mut handles = Vec::new();
for (chunk_idx, chunk) in items.into_iter().collect::<Vec<_>>()
.chunks(chunk_size).enumerate()
{
let tx = tx.clone();
let chunk = chunk.to_vec();
handles.push(thread::spawn(move || {
for (i, item) in chunk.into_iter().enumerate() {
tx.send((chunk_idx * chunk_size + i, f(item))).unwrap();
}
}));
}
drop(tx);
let mut results: Vec<Option<R>> = vec![None; handles.len() * chunk_size];
for (idx, result) in rx { if idx < results.len() { results[idx] = Some(result); } }
for h in handles { h.join().unwrap(); }
results.into_iter().flatten().collect()
}
"#,
r#"
struct Compressor { window: Vec<u8>, window_size: usize }
impl Compressor {
fn new(window_size: usize) -> Self {
Self { window: Vec::with_capacity(window_size), window_size }
}
fn find_longest_match(&self, data: &[u8], pos: usize) -> (usize, usize) {
let mut best_offset = 0; let mut best_length = 0;
let start = pos.saturating_sub(self.window_size);
for offset in start..pos {
let mut length = 0;
while pos + length < data.len()
&& data[offset + length] == data[pos + length]
&& length < 258
{ length += 1; }
if length > best_length { best_offset = pos - offset; best_length = length; }
}
(best_offset, best_length)
}
fn compress(&mut self, data: &[u8]) -> Vec<u8> {
let mut output = Vec::new();
let mut pos = 0;
while pos < data.len() {
let (offset, length) = self.find_longest_match(data, pos);
if length >= 3 {
output.push(1); output.extend_from_slice(&(offset as u16).to_le_bytes());
output.push(length as u8); pos += length;
} else { output.push(0); output.push(data[pos]); pos += 1; }
}
output
}
}
"#,
];
struct FileState {
name: String,
token: String,
#[allow(dead_code)]
is_base: bool,
/// Epoch second when this file was last written (used to detect same-second
/// re-edits that wouldn't bump mtime and thus wouldn't invalidate the mmap).
last_write_sec: u64,
}
#[test]
fn fuzz_file_operations_stress() {
const SEED: u64 = 0xDEAD_BEEF_CAFE_1234;
const INITIAL_FILE_COUNT: usize = 40;
const NUM_ROUNDS: usize = 20;
let mut rng = SmallRng::seed_from_u64(SEED);
let tmp = TempDir::new().unwrap();
let base = tmp.path();
// Timing accumulators.
let mut t_sleep = Duration::ZERO;
let mut t_git = Duration::ZERO;
let mut t_bigram_wait = Duration::ZERO;
let mut t_grep_plain = Duration::ZERO;
let mut t_grep_regex = Duration::ZERO;
let mut t_fuzzy = Duration::ZERO;
let mut t_dead_check = Duration::ZERO;
let grep_plain_calls = std::sync::atomic::AtomicUsize::new(0);
let grep_regex_calls = std::sync::atomic::AtomicUsize::new(0);
let fuzzy_calls = std::sync::atomic::AtomicUsize::new(0);
let dead_calls = std::sync::atomic::AtomicUsize::new(0);
let test_start = std::time::Instant::now();
let mut live_files: Vec<FileState> = Vec::with_capacity(INITIAL_FILE_COUNT + NUM_ROUNDS);
let mut dead_tokens: Vec<String> = Vec::new();
let mut next_file_id: usize = 0;
for i in 0..INITIAL_FILE_COUNT {
let name = format!("seed_{i:04}.rs");
let token = format!("FUZZ_SEED_{i:04}");
write_diverse_file(base, &name, &token, i);
live_files.push(FileState {
name,
token,
is_base: true,
last_write_sec: 0, // set before index build, doesn't matter
});
next_file_id += 1;
}
let t0 = std::time::Instant::now();
git_init_and_commit(base);
t_git += t0.elapsed();
let shared_picker = SharedPicker::default();
FilePicker::new_with_shared_state(
shared_picker.clone(),
SharedFrecency::noop(),
FilePickerOptions {
watch: false, // we do not need the backgrodun monitor
base_path: base.to_string_lossy().to_string(),
warmup_mmap_cache: true,
mode: FFFMode::Neovim,
..Default::default()
},
)
.expect("Failed to create FilePicker");
let t0 = std::time::Instant::now();
wait_for_bigram(&shared_picker);
t_bigram_wait += t0.elapsed();
// Sanity: all initial tokens findable via plain grep.
{
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
for fs in &live_files {
assert!(
grep_plain_count(picker, &fs.token) >= 1,
"initial sanity: plain grep should find token {} in {}",
fs.token,
fs.name
);
}
}
// Sleep so mtime advances past the scan snapshot timestamp.
let t0 = std::time::Instant::now();
std::thread::sleep(Duration::from_millis(1100));
t_sleep += t0.elapsed();
let mut op_counter: usize = 0;
for round in 0..NUM_ROUNDS {
let roll: u32 = rng.next_u32() % 100;
if roll < 40 && !live_files.is_empty() {
// ── EDIT existing file (40%) ──
let idx = rng.next_u32() as usize % live_files.len();
// on_create_or_modify uses mtime (seconds granularity) to decide
// whether to invalidate the mmap cache. If we re-edit a file in
// the same second it was last written, the mtime won't change and
// the stale cached content will be returned. Sleep to advance mtime.
let now_sec = epoch_secs();
if live_files[idx].last_write_sec >= now_sec {
let t0 = std::time::Instant::now();
std::thread::sleep(Duration::from_millis(1100));
t_sleep += t0.elapsed();
}
let old_token = live_files[idx].token.clone();
let new_token = format!("FUZZ_{round:02}_{op_counter:04}");
let name = &live_files[idx].name;
let domain_idx = rng.next_u32() as usize % DOMAINS.len();
write_diverse_file_with_domain(base, name, &new_token, domain_idx);
{
let mut guard = shared_picker.write().unwrap();
let picker = guard.as_mut().unwrap();
assert!(
picker.on_create_or_modify(base.join(name)).is_some(),
"round {round}: on_create_or_modify({name}) should succeed for edit"
);
}
dead_tokens.push(old_token);
live_files[idx].token = new_token;
live_files[idx].last_write_sec = epoch_secs();
op_counter += 1;
} else if roll < 60 {
// ── CREATE new file (20%) ──
let name = format!("created_{next_file_id:04}.rs");
let token = format!("FUZZ_{round:02}_{op_counter:04}");
let domain_idx = rng.next_u32() as usize % DOMAINS.len();
write_diverse_file_with_domain(base, &name, &token, domain_idx);
{
let mut guard = shared_picker.write().unwrap();
let picker = guard.as_mut().unwrap();
assert!(
picker.on_create_or_modify(base.join(&name)).is_some(),
"round {round}: on_create_or_modify({name}) should succeed for create"
);
}
live_files.push(FileState {
name,
token,
is_base: false,
last_write_sec: epoch_secs(),
});
next_file_id += 1;
op_counter += 1;
} else if roll < 75 && !live_files.is_empty() {
// ── DELETE existing file (15%) ──
let idx = rng.next_u32() as usize % live_files.len();
let removed = live_files.swap_remove(idx);
let path = base.join(&removed.name);
fs::remove_file(&path).unwrap();
{
let mut guard = shared_picker.write().unwrap();
let picker = guard.as_mut().unwrap();
assert!(
picker.remove_file_by_path(&path),
"round {round}: remove_file_by_path({}) should succeed",
removed.name
);
}
dead_tokens.push(removed.token);
op_counter += 1;
} else if roll < 85 && !live_files.is_empty() {
// ── RENAME file (10%) ──
let idx = rng.next_u32() as usize % live_files.len();
let old_name = live_files[idx].name.clone();
let old_path = base.join(&old_name);
let content = fs::read_to_string(&old_path).unwrap();
// Remove old file from disk + picker.
fs::remove_file(&old_path).unwrap();
{
let mut guard = shared_picker.write().unwrap();
let picker = guard.as_mut().unwrap();
picker.remove_file_by_path(&old_path);
}
// Create new file with same content but different name.
let new_name = format!("renamed_{next_file_id:04}.rs");
fs::write(base.join(&new_name), &content).unwrap();
{
let mut guard = shared_picker.write().unwrap();
let picker = guard.as_mut().unwrap();
assert!(
picker.on_create_or_modify(base.join(&new_name)).is_some(),
"round {round}: on_create_or_modify({new_name}) should succeed for rename"
);
}
live_files[idx].name = new_name;
live_files[idx].is_base = false;
live_files[idx].last_write_sec = epoch_secs();
next_file_id += 1;
op_counter += 1;
}
// else: no-op / read-only (15%) — just run verification below.
// ── VERIFY after every round ──
{
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
for fs in &live_files {
// Plain text grep: every live token must be found.
let t0 = std::time::Instant::now();
let plain_count = grep_plain_count(picker, &fs.token);
t_grep_plain += t0.elapsed();
grep_plain_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
assert!(
plain_count >= 1,
"round {round}: plain grep should find live token {} in {} (got {plain_count})",
fs.token,
fs.name
);
// Regex grep: search with `{first5}.*{last5}` pattern.
let regex_pattern = build_regex_pattern(&fs.token);
let t0 = std::time::Instant::now();
let regex_count = grep_regex_count(picker, &regex_pattern);
t_grep_regex += t0.elapsed();
grep_regex_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
assert!(
regex_count >= 1,
"round {round}: regex grep '{}' should find live token {} in {} (got {regex_count})",
regex_pattern,
fs.token,
fs.name
);
// Fuzzy file search: every live file must be findable by name.
let stem = extract_stem(&fs.name);
let t0 = std::time::Instant::now();
let fuzzy_results = fuzzy_search_paths(picker, &stem);
t_fuzzy += t0.elapsed();
fuzzy_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
assert!(
fuzzy_results.iter().any(|p| p.contains(&fs.name)),
"round {round}: fuzzy search '{}' should find file {} in results: {:?}",
stem,
fs.name,
fuzzy_results
);
}
// Dead tokens must return 0 grep results.
for dead in &dead_tokens {
let t0 = std::time::Instant::now();
let count = grep_plain_count(picker, dead);
t_dead_check += t0.elapsed();
dead_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
assert_eq!(
count, 0,
"round {round}: dead token {dead} should NOT be findable (got {count})"
);
}
}
}
let total = test_start.elapsed();
let t_overhead = t_sleep + t_bigram_wait + t_git;
let t_search = t_grep_plain + t_grep_regex + t_fuzzy + t_dead_check;
let t_mutations = total.saturating_sub(t_overhead + t_search);
let n_grep_plain = grep_plain_calls.load(std::sync::atomic::Ordering::Relaxed);
let n_grep_regex = grep_regex_calls.load(std::sync::atomic::Ordering::Relaxed);
let n_fuzzy = fuzzy_calls.load(std::sync::atomic::Ordering::Relaxed);
let n_dead = dead_calls.load(std::sync::atomic::Ordering::Relaxed);
eprintln!("\n╔══════════════════════════════════════════════════════╗");
eprintln!("║ Fuzz Test Performance Breakdown ║");
eprintln!("╠══════════════════════════════════════════════════════╣");
eprintln!(
"║ Total wall time: {:>8.1}ms ║",
total.as_secs_f64() * 1000.0
);
eprintln!("║ ── Overhead ─────────────────────────────────────── ║");
eprintln!(
"║ Sleep (mtime waits): {:>8.1}ms ║",
t_sleep.as_secs_f64() * 1000.0
);
eprintln!(
"║ Git init+commit: {:>8.1}ms ║",
t_git.as_secs_f64() * 1000.0
);
eprintln!(
"║ Bigram index build+scan: {:>8.1}ms ║",
t_bigram_wait.as_secs_f64() * 1000.0
);
eprintln!(
"║ ── Search ({:>3} live files, {:>3} dead tokens) ────── ║",
live_files.len(),
dead_tokens.len()
);
eprintln!(
"║ Plain grep: {:>4} calls {:>8.1}ms ({:>6.1}µs/call) ║",
n_grep_plain,
t_grep_plain.as_secs_f64() * 1000.0,
t_grep_plain.as_secs_f64() * 1_000_000.0 / n_grep_plain.max(1) as f64
);
eprintln!(
"║ Regex grep: {:>4} calls {:>8.1}ms ({:>6.1}µs/call) ║",
n_grep_regex,
t_grep_regex.as_secs_f64() * 1000.0,
t_grep_regex.as_secs_f64() * 1_000_000.0 / n_grep_regex.max(1) as f64
);
eprintln!(
"║ Fuzzy find: {:>4} calls {:>8.1}ms ({:>6.1}µs/call) ║",
n_fuzzy,
t_fuzzy.as_secs_f64() * 1000.0,
t_fuzzy.as_secs_f64() * 1_000_000.0 / n_fuzzy.max(1) as f64
);
eprintln!(
"║ Dead checks: {:>4} calls {:>8.1}ms ({:>6.1}µs/call) ║",
n_dead,
t_dead_check.as_secs_f64() * 1000.0,
t_dead_check.as_secs_f64() * 1_000_000.0 / n_dead.max(1) as f64
);
eprintln!("║ ── Other ────────────────────────────────────────── ║");
eprintln!(
"║ Mutations + FS I/O: {:>8.1}ms ║",
t_mutations.as_secs_f64() * 1000.0
);
eprintln!("╚══════════════════════════════════════════════════════╝");
}
fn write_diverse_file(dir: &Path, name: &str, token: &str, index: usize) {
let domain_idx = index % DOMAINS.len();
write_diverse_file_with_domain(dir, name, token, domain_idx);
}
fn write_diverse_file_with_domain(dir: &Path, name: &str, token: &str, domain_idx: usize) {
let domain = DOMAINS[domain_idx % DOMAINS.len()];
let content = format!(
"// File: {name}\n\
// Domain content for bigram diversity\n\
{domain}\n\
// === Unique searchable token below ===\n\
const MARKER: &str = \"{token}\";\n\
fn marker_function_{token}() {{ println!(\"{token}\"); }}\n"
);
if let Some(parent) = PathBuf::from(name).parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(dir.join(parent)).unwrap();
}
}
fs::write(dir.join(name), content).unwrap();
}
// ═══════════════════════════════════════════════════════════════════════
// Search helpers
// ═══════════════════════════════════════════════════════════════════════
fn grep_plain_opts() -> GrepSearchOptions {
GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 200,
smart_case: true,
file_offset: 0,
page_limit: 500,
mode: GrepMode::PlainText,
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
}
}
fn grep_regex_opts() -> GrepSearchOptions {
GrepSearchOptions {
mode: GrepMode::Regex,
..grep_plain_opts()
}
}
fn grep_plain_count(picker: &FilePicker, query: &str) -> usize {
let parsed = parse_grep_query(query);
picker.grep(&parsed, &grep_plain_opts()).matches.len()
}
fn grep_regex_count(picker: &FilePicker, regex_query: &str) -> usize {
let parsed = parse_grep_query(regex_query);
picker.grep(&parsed, &grep_regex_opts()).matches.len()
}
/// Build a regex pattern from a token: `{first5}.*{last5}`.
/// For tokens shorter than 10 chars, just use the literal (escaped).
fn build_regex_pattern(token: &str) -> String {
if token.len() >= 10 {
let first5 = &token[..5];
let last5 = &token[token.len() - 5..];
format!("{}.*{}", regex_escape(first5), regex_escape(last5))
} else {
regex_escape(token)
}
}
/// Escape regex metacharacters in a string.
fn regex_escape(s: &str) -> String {
let mut escaped = String::with_capacity(s.len() + 4);
for ch in s.chars() {
match ch {
'.' | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '\\' | '^' | '$' | '|' => {
escaped.push('\\');
escaped.push(ch);
}
_ => escaped.push(ch),
}
}
escaped
}
/// Extract a fuzzy-searchable stem from a filename.
/// Strips the extension and any leading path components, keeping the bare name.
fn extract_stem(name: &str) -> String {
let p = PathBuf::from(name);
p.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string()
}
fn fuzzy_search_paths(picker: &FilePicker, query: &str) -> Vec<String> {
let parser = QueryParser::default();
let parsed = parser.parse(query);
let result = FilePicker::fuzzy_search(
picker.get_files(),
&parsed,
None,
FuzzySearchOptions {
max_threads: 1,
pagination: PaginationArgs {
offset: 0,
limit: 200,
},
..Default::default()
},
);
result
.items
.iter()
.map(|f| f.path_str().to_string())
.collect()
}
fn wait_for_bigram(shared_picker: &SharedPicker) {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
std::thread::sleep(Duration::from_millis(50));
let ready = shared_picker
.read()
.ok()
.map(|guard| {
guard
.as_ref()
.map_or(false, |p| !p.is_scan_active() && p.bigram_index().is_some())
})
.unwrap_or(false);
if ready {
break;
}
assert!(
std::time::Instant::now() < deadline,
"Timed out waiting for bigram build"
);
}
}
fn git_run(dir: &Path, args: &[&str]) {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.output()
.unwrap_or_else(|e| panic!("git {:?} failed: {}", args, e));
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
}
fn epoch_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
fn git_init_and_commit(dir: &Path) {
git_run(dir, &["init"]);
git_run(dir, &["add", "-A"]);
git_run(dir, &["commit", "-m", "initial"]);
}
File diff suppressed because it is too large Load Diff
+11
View File
@@ -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.5.2"
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.
///
+175
View File
@@ -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()
}
}
+139
View File
@@ -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
}
}
+5 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "fff-mcp"
version = "0.1.0"
version = "0.5.2"
edition = "2024"
description = "MCP server for FFF file finder - drop-in replacement for AI code assistant search tools"
license = "MIT"
@@ -11,11 +11,11 @@ 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.5.1" }
fff-query-parser = { path = "../fff-query-parser", default-features = false , version = "0.5.2" }
mimalloc = { workspace = true }
rmcp = { version = "1.1.0", features = ["server", "transport-io"] }
schemars = "1.0"
@@ -24,4 +24,4 @@ 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"] }
+117
View File
@@ -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 file
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());
all_ok &= check(
"Log file",
parent_ok,
if parent_ok {
log_path
} else {
"parent directory does not exist"
},
);
} else {
check("Log file", false, "path not resolved");
}
if all_ok {
println!("All checks passed.");
Ok(())
} else {
Err("Some checks failed — review the items marked [x] above.".into())
}
}
+63 -23
View File
@@ -7,16 +7,15 @@
//! 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, SharedFrecency, SharedPicker};
use git2::Repository;
use mimalloc::MiMalloc;
use rmcp::{ServiceExt, transport::stdio};
@@ -104,7 +103,7 @@ pub const MCP_INSTRUCTIONS: &str = concat!(
/// FFF MCP Server — high-performance 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>,
@@ -129,6 +128,24 @@ 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,
/// 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.
@@ -197,8 +214,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()) {
eprintln!("Warning: Failed to init tracing: {}", e);
}
@@ -209,21 +230,34 @@ 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 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
}
}
Err(_) => {
tracing::info!(
"No git repository found, indexing from base path: {}",
base_path
);
base_path
}
};
let frecency_db_path = args.frecency_db_path.unwrap_or_default();
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
let shared_picker = SharedPicker::default();
let shared_frecency = SharedFrecency::default();
match FrecencyTracker::new(&frecency_db_path, false) {
Ok(tracker) => {
if let Ok(mut guard) = shared_frecency.write() {
*guard = Some(tracker);
}
FrecencyTracker::spawn_gc(Arc::clone(&shared_frecency), frecency_db_path, false);
let _ = shared_frecency.init(tracker);
let _ = shared_frecency.spawn_gc(frecency_db_path, false);
}
Err(e) => {
eprintln!("Warning: Failed to init frecency db: {}", e);
@@ -232,11 +266,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 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,
warmup_mmap_cache: !args.no_warmup,
mode: FFFMode::Ai,
cache_budget: args
.max_cached_files
.map(fff::ContentCacheBudget::new_for_repo),
..Default::default()
},
)
.map_err(|e| format!("Failed to init file picker: {}", e))?;
@@ -248,7 +288,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let server = FffServer::new(shared_picker.clone(), shared_frecency.clone());
// Wait for initial scan in background — don't block server startup
let picker_clone_for_scan = Arc::clone(&shared_picker);
let picker_clone_for_scan = shared_picker.clone();
tokio::task::spawn_blocking(move || {
let start = std::time::Instant::now();
loop {
+34 -46
View File
@@ -3,15 +3,15 @@
//! 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::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 {
@@ -24,7 +24,7 @@ fn frecency_word(score: i64) -> Option<&'static str> {
}
/// Build " - hot git:modified" style suffix. Empty when nothing to report.
pub fn file_suffix(git_status: Option<git2::Status>, frecency_score: i64) -> String {
pub fn file_suffix(git_status: Option<git2::Status>, frecency_score: i32) -> String {
match (
frecency_word(frecency_score),
format_git_status_opt(git_status),
@@ -81,34 +81,19 @@ fn trauncate_line_for_ai(
match_ranges: Option<&[(u32, u32)]>,
max_len: usize,
) -> String {
// Strip leading/trailing whitespace to save tokens — the LLM has file:line for location.
let trimmed = line.trim();
// Leading whitespace is already stripped by core (trim_whitespace option).
// Only strip trailing whitespace here.
let trimmed = line.trim_end();
if trimmed.is_empty() {
return String::new();
}
let strip_offset = line.len() - line.trim_start().len();
if trimmed.len() <= max_len {
return trimmed.to_string();
}
// Adjust match ranges for the stripped leading whitespace
let adjusted: Vec<(u32, u32)>;
let ranges = match match_ranges {
Some(r) if strip_offset > 0 => {
let off = strip_offset as u32;
adjusted = r
.iter()
.map(|&(s, e)| (s.saturating_sub(off), e.saturating_sub(off)))
.collect();
Some(adjusted.as_slice())
}
other => other,
};
// Use first match range to center the window
if let Some(ranges) = ranges
if let Some(ranges) = match_ranges
&& let Some(&(match_start, match_end)) = ranges.first()
{
let match_start = match_start as usize;
@@ -252,10 +237,10 @@ impl GrepFormatter<'_> {
let mut content_first_file = "";
for fm in &file_preview {
if content_first_file.is_empty() {
content_first_file = &fm.file.relative_path;
content_first_file = fm.file.relative_path();
}
if content_def_file.is_empty() && fm.is_definition {
content_def_file = &fm.file.relative_path;
content_def_file = fm.file.relative_path();
}
}
@@ -310,8 +295,8 @@ impl GrepFormatter<'_> {
let file = files[m.file_index];
let mut match_lines: Vec<String> = Vec::new();
if file.relative_path.as_str() != current_file {
current_file = &file.relative_path;
if file.relative_path() != current_file {
current_file = file.relative_path();
match_lines.push(current_file.to_string());
}
@@ -362,14 +347,14 @@ impl GrepFormatter<'_> {
&& !show_context
&& m.is_definition
&& !m.context_after.is_empty()
&& !def_expanded_files.contains(file.relative_path.as_str())
&& !def_expanded_files.contains(file.relative_path())
{
let expand_limit = if def_expanded_files.is_empty() {
MAX_DEF_EXPAND_FIRST
} else {
MAX_DEF_EXPAND
};
def_expanded_files.insert(file.relative_path.as_str());
def_expanded_files.insert(file.relative_path());
let start_line = m.line_number + 1;
for (i, ctx) in m.context_after.iter().take(expand_limit).enumerate() {
if ctx.trim().is_empty() {
@@ -419,10 +404,10 @@ fn format_files_with_matches(
let mut first_file = "";
for fm in &file_map {
if first_file.is_empty() {
first_file = &fm.file.relative_path;
first_file = fm.file.relative_path();
}
if first_def_file.is_empty() && fm.is_definition {
first_def_file = &fm.file.relative_path;
first_def_file = fm.file.relative_path();
}
}
let suggest_path = if !first_def_file.is_empty() {
@@ -456,7 +441,7 @@ fn format_files_with_matches(
let def_tag = if is_def { " [def]" } else { "" };
lines.push(format!(
"{}{}{}",
fm.file.relative_path,
fm.file.relative_path(),
def_tag,
size_tag(fm.file.size)
));
@@ -526,7 +511,7 @@ fn format_count(
let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
let mut order: Vec<&str> = Vec::new();
for m in items {
let path = files[m.file_index].relative_path.as_str();
let path = files[m.file_index].relative_path();
let count = counts.entry(path).or_insert_with(|| {
order.push(path);
0
@@ -550,7 +535,7 @@ fn collect_file_preview<'a>(items: &[GrepMatch], files: &[&'a FileItem]) -> Vec<
let mut seen = std::collections::HashSet::new();
for m in items {
let file = files[m.file_index];
if seen.insert(&file.relative_path) {
if seen.insert(file.relative_path()) {
file_preview.push(FileMeta {
file,
line_number: m.line_number,
@@ -569,26 +554,29 @@ mod tests {
use super::*;
#[test]
fn trunc_strips_whitespace() {
assert_eq!(trauncate_line_for_ai(" foo()", None, 180), "foo()");
assert_eq!(trauncate_line_for_ai(" bar ", None, 180), "bar");
fn trunc_strips_trailing_whitespace() {
// Leading whitespace is now stripped by core's trim_whitespace option.
// This function only strips trailing whitespace.
assert_eq!(trauncate_line_for_ai("foo()", None, 180), "foo()");
assert_eq!(trauncate_line_for_ai("bar ", None, 180), "bar");
assert_eq!(trauncate_line_for_ai(" ", None, 180), "");
}
#[test]
fn trunc_adjusts_match_ranges_after_strip() {
// " hello" — match on "hello" at bytes 4..9
let line = " hello";
let ranges = [(4, 9)];
fn trunc_preserves_pre_trimmed_match_ranges() {
// Core already stripped leading whitespace and adjusted offsets,
// so "hello" arrives with match at bytes 0..5.
let line = "hello";
let ranges = [(0, 5)];
let result = trauncate_line_for_ai(line, Some(&ranges), 180);
// After stripping 4 leading spaces, the trimmed line is "hello"
assert_eq!(result, "hello");
}
#[test]
fn trunc_long_line_centered() {
let line = format!("{}match_here{}", " ".repeat(8), "x".repeat(200));
let ranges = [(8u32, 18u32)];
// Core already stripped leading whitespace; offsets are pre-adjusted.
let line = format!("match_here{}", "x".repeat(200));
let ranges = [(0u32, 10u32)];
let result = trauncate_line_for_ai(&line, Some(&ranges), 50);
assert!(result.contains("match_here"));
assert!(result.len() <= 55); // budget + ellipsis chars
+55 -40
View File
@@ -8,19 +8,18 @@ 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::file_picker::FilePicker;
use fff::grep::{self, GrepMode, GrepSearchOptions, has_regex_metacharacters};
use fff::types::{FileItem, PaginationArgs};
use fff::{FuzzySearchOptions, QueryParser, SharedFrecency, SharedPicker};
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};
/// Strip common delimiters and lowercase for fuzzy fallback queries.
fn cleanup_fuzzy_query(s: &str) -> String {
let mut out = String::with_capacity(s.len());
@@ -65,6 +64,7 @@ fn make_grep_options(
before_context: ctx_lines,
after_context: after_ctx,
classify_definitions: true,
trim_whitespace: true,
},
auto_expand,
)
@@ -76,7 +76,8 @@ pub struct FindFilesParams {
pub query: String,
/// Max results (default 20).
#[serde(rename = "maxResults")]
pub max_results: Option<usize>,
// this has to be float because llms are stupid
pub max_results: Option<f64>,
/// Cursor from previous result. Only use if previous results weren't sufficient.
pub cursor: Option<String>,
}
@@ -88,7 +89,7 @@ pub struct GrepParams {
pub query: String,
/// Max matching lines (default 20).
#[serde(rename = "maxResults")]
pub max_results: Option<usize>,
pub max_results: Option<f64>, // this has to be float because llms are stupid
/// Cursor from previous result. Only use if previous results weren't sufficient.
pub cursor: Option<String>,
/// Output format (default 'content').
@@ -150,13 +151,13 @@ pub struct MultiGrepParams {
pub constraints: Option<String>,
/// Max matching lines (default 20).
#[serde(rename = "maxResults")]
pub max_results: Option<usize>,
pub max_results: Option<f64>,
/// Cursor from previous result.
pub cursor: Option<String>,
/// Output format (default 'content').
pub output_mode: Option<String>,
/// Context lines before/after each match.
pub context: Option<usize>,
pub context: Option<f64>,
}
#[derive(Clone)]
@@ -249,11 +250,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 +275,7 @@ impl FffServer {
};
let (retry_options, _) = make_grep_options(output_mode, retry_mode, 0, context);
let retry_result = grep::grep_search(files, &rest_parsed, &retry_options);
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()?;
@@ -304,7 +303,7 @@ impl FffServer {
let fuzzy_query = cleanup_fuzzy_query(query);
let (fuzzy_options, _) = make_grep_options(output_mode, GrepMode::Fuzzy, 0, Some(0));
let fuzzy_parsed = parser.parse(&fuzzy_query);
let fuzzy_result = grep::grep_search(files, &fuzzy_parsed, &fuzzy_options);
let fuzzy_result = picker.grep(&fuzzy_parsed, &fuzzy_options);
if !fuzzy_result.matches.is_empty() {
let mut lines: Vec<String> = Vec::new();
@@ -315,8 +314,8 @@ impl FffServer {
let mut current_file = "";
for m in fuzzy_result.matches.iter().take(3) {
let file = fuzzy_result.files[m.file_index];
if file.relative_path.as_str() != current_file {
current_file = &file.relative_path;
if file.relative_path() != current_file {
current_file = file.relative_path();
lines.push(current_file.to_string());
}
lines.push(format!(" {}: {}", m.line_number, m.line_content));
@@ -334,7 +333,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 +340,8 @@ impl FffServer {
limit: 1,
},
};
let file_result = FilePicker::fuzzy_search(files, &file_query, file_opts);
let file_result =
FilePicker::fuzzy_search(picker.get_files(), &file_query, None, file_opts);
if let (Some(top), Some(score)) =
(file_result.items.first(), file_result.scores.first())
{
@@ -351,7 +350,7 @@ impl FffServer {
if score.base_score > query_len * 10 {
return Ok(CallToolResult::success(vec![Content::text(format!(
"0 content matches. But there is a relevant file path: {}",
top.relative_path
top.relative_path()
))]));
}
}
@@ -401,7 +400,7 @@ impl FffServer {
&self,
Parameters(params): Parameters<FindFilesParams>,
) -> Result<CallToolResult, ErrorData> {
let max_results = params.max_results.unwrap_or(20);
let max_results = params.max_results.unwrap_or(20.0).round() as usize; // safe
let query = &params.query;
let page_offset = params
@@ -423,7 +422,6 @@ impl FffServer {
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 +432,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 = FilePicker::fuzzy_search(files, &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 +443,12 @@ 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 = FilePicker::fuzzy_search(
files,
&shorter_query,
/*query_tracker=*/ 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()
));
} 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()
));
}
}
@@ -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(),
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 = params.max_results.unwrap_or(20.0) as usize;
let output_mode = OutputMode::new(params.output_mode.as_deref());
let parsed = QueryParser::new(AiGrepConfig).parse(&params.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 = params.max_results.unwrap_or(20.0).round() as usize;
let context = params.context.map(|v| v.round() as usize);
let output_mode = OutputMode::new(params.output_mode.as_deref());
let file_offset = params
@@ -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("");
@@ -587,13 +590,24 @@ impl FffServer {
let constraints = parsed_constraints.constraints.as_slice();
let files = picker.get_files();
let result = grep::multi_grep_search(files, &patterns_refs, constraints, &options);
let budget = picker.cache_budget();
let overlay_guard = picker.bigram_overlay().map(|o| o.read());
let result = grep::multi_grep_search(
files,
&patterns_refs,
constraints,
&options,
budget,
picker.bigram_index(),
overlay_guard.as_deref(),
None,
);
let file_refs: Vec<&FileItem> = result.files.to_vec();
if result.matches.is_empty() && file_offset == 0 {
// Fallback: try individual patterns with plain grep
let (fallback_options, _) =
make_grep_options(output_mode, GrepMode::PlainText, 0, params.context);
make_grep_options(output_mode, GrepMode::PlainText, 0, context);
let fallback_options = GrepSearchOptions {
time_budget_ms: 3000,
@@ -609,7 +623,8 @@ impl FffServer {
};
let parsed = parser.parse(&full_query);
let fb_result = grep::grep_search(files, &parsed, &fallback_options);
let fb_result =
grep::grep_search(files, &parsed, &fallback_options, budget, None, None, None);
if !fb_result.matches.is_empty() {
let fb_file_refs: Vec<&FileItem> = fb_result.files.to_vec();
-40
View File
@@ -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"
);
}
}
}
+26 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "fff-nvim"
version = "0.1.0"
version = "0.5.2"
edition = "2024"
[lib]
@@ -9,7 +9,7 @@ crate-type = ["cdylib", "rlib"]
[features]
default = []
zlob = ["fff-core/zlob"]
zlob = ["fff/zlob"]
[[bin]]
name = "test_watcher"
@@ -35,6 +35,22 @@ path = "src/bin/grep_profiler.rs"
name = "grep_vs_rg"
path = "src/bin/grep_vs_rg.rs"
[[bin]]
name = "bench_grep_query"
path = "src/bin/bench_grep_query.rs"
[[bin]]
name = "fuzzy_grep_test"
path = "src/bin/fuzzy_grep_test.rs"
[[bin]]
name = "test_memory_leak"
path = "src/bin/test_memory_leak.rs"
[[bin]]
name = "bench_ci_memmem"
path = "src/bin/bench_ci_memmem.rs"
[dependencies]
# Workspace dependencies
ahash = { workspace = true }
@@ -44,8 +60,10 @@ thiserror = { workspace = true }
tracing = { workspace = true }
# Local crates
fff-core = { path = "../fff-core" }
fff-query-parser = { path = "../fff-query-parser" }
fff = { package = "fff-search", path = "../fff-core", version = "0.5.1", features = [
"mimalloc-collect",
] }
fff-query-parser = { path = "../fff-query-parser", version = "0.5.2" }
# External dependencies
blake3 = "1.8.2"
@@ -76,6 +94,9 @@ name = "indexing_and_search"
harness = false
[[bench]]
name = "query_tracker_bench"
name = "grep_bench"
harness = false
[[bench]]
name = "query_tracker_bench"
harness = false
+369
View File
@@ -0,0 +1,369 @@
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use fff::types::{ContentCacheBudget, FileItem};
use fff::{BigramFilter, GrepMode, GrepSearchOptions, build_bigram_index, grep};
use std::io::Read;
use std::path::Path;
use std::sync::OnceLock;
use std::time::Duration;
struct TestData {
files: Vec<FileItem>,
bigram: BigramFilter,
budget: ContentCacheBudget,
}
static SETUP: OnceLock<TestData> = OnceLock::new();
fn big_repo_path() -> std::path::PathBuf {
if let Some(path) = std::env::var_os("BIG_REPO_PATH") {
return std::path::PathBuf::from(path);
}
let candidates = [
std::path::PathBuf::from("./big-repo"),
std::path::PathBuf::from("../../big-repo"),
];
for p in &candidates {
if p.exists() {
return p.clone();
}
}
panic!(
"./big-repo not found. Run from workspace root:\n \
git clone --depth 1 https://github.com/torvalds/linux.git big-repo"
);
}
fn setup() -> &'static TestData {
SETUP.get_or_init(|| {
let repo = big_repo_path();
let canonical = fff::path_utils::canonicalize(&repo).expect("canonicalize");
eprintln!("Loading files from {:?}...", canonical);
let mut files = load_files(&canonical);
let budget = ContentCacheBudget::new_for_repo(files.len());
// Warm the content cache so warm benchmarks hit OnceLock.
// Use unlimited budget for warmup — we want ALL files cached.
// The repo budget (5k cap for 93k files) would leave most uncached.
eprintln!("Warming content cache for {} files...", files.len());
{
let warmup_budget = ContentCacheBudget::unlimited();
let mut buf = Vec::with_capacity(64 * 1024);
for f in files.iter() {
let _ = f.get_content_for_search(&mut buf, &warmup_budget);
}
}
eprintln!("Building bigram index...");
let (bigram, binary_indices) = build_bigram_index(&files, &budget);
for &i in &binary_indices {
files[i].set_binary(true);
}
let non_binary = files.iter().filter(|f| !f.is_binary()).count();
eprintln!(
"Ready: {} files ({} non-binary), bigram {:.1} MB",
files.len(),
non_binary,
bigram.heap_bytes() as f64 / (1024.0 * 1024.0),
);
TestData {
files,
bigram,
budget,
}
})
}
fn load_files(base_path: &Path) -> Vec<FileItem> {
use ignore::WalkBuilder;
let mut files = Vec::new();
WalkBuilder::new(base_path)
.hidden(false)
.git_ignore(true)
.git_exclude(true)
.git_global(true)
.ignore(true)
.follow_links(false)
.build()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
.for_each(|entry| {
let path = entry.path().to_path_buf();
let relative = pathdiff::diff_paths(&path, base_path).unwrap_or_else(|| path.clone());
let relative_path = relative.to_string_lossy().into_owned();
let size = entry.metadata().ok().map_or(0, |m| m.len());
let is_binary = detect_binary(&path, size);
let path_string = path.to_string_lossy().into_owned();
let relative_start = (path_string.len() - relative_path.len()) as u16;
let filename_start = path_string
.rfind('/')
.map(|i| i + 1)
.unwrap_or(relative_start as usize) as u16;
files.push(FileItem::new_raw(
path_string,
relative_start,
filename_start,
size,
0,
None,
is_binary,
));
});
files
}
fn detect_binary(path: &Path, size: u64) -> bool {
if size == 0 {
return false;
}
let Ok(file) = std::fs::File::open(path) else {
return false;
};
let mut reader = std::io::BufReader::with_capacity(1024, file);
let mut buf = [0u8; 512];
let n = reader.read(&mut buf).unwrap_or(0);
buf[..n].contains(&0)
}
fn plain_options() -> GrepSearchOptions {
GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 200,
smart_case: true,
file_offset: 0,
page_limit: 50,
mode: GrepMode::PlainText,
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
}
}
fn fuzzy_options() -> GrepSearchOptions {
GrepSearchOptions {
mode: GrepMode::Fuzzy,
..plain_options()
}
}
fn do_grep(
files: &[FileItem],
query: &str,
options: &GrepSearchOptions,
budget: &ContentCacheBudget,
bigram: Option<&BigramFilter>,
) -> usize {
let parsed = grep::parse_grep_query(query);
let result = grep::grep_search(
black_box(files),
black_box(&parsed),
black_box(options),
budget,
bigram,
None,
None,
);
result.matches.len()
}
fn bench_plain_warm(c: &mut Criterion) {
let test_picker = setup();
let opts = plain_options();
let queries: &[(&str, &str)] = &[
("2char_if", "if"),
("common_return", "return"),
("func_mutex_lock", "mutex_lock"),
("struct_inode_ops", "inode_operations"),
("define_MODULE_LICENSE", "MODULE_LICENSE"),
("rare_phylink_ethtool", "phylink_ethtool"),
("include", "#include"),
("comment_TODO", "TODO"),
("type_struct_file", "struct file"),
("error_EINVAL", "err = -EINVAL"),
("long_static_int_init", "static int __init"),
("very_common_int", "int"),
("single_char_x", "x"),
("path_printk_c", "printk *.c"),
("dir_mutex_kernel", "mutex /kernel/"),
];
let mut group = c.benchmark_group("plain_warm");
group.sample_size(30);
group.warm_up_time(Duration::from_secs(2));
group.measurement_time(Duration::from_secs(5));
for (name, query) in queries {
group.bench_with_input(BenchmarkId::from_parameter(name), query, |b, q| {
b.iter(|| do_grep(&test_picker.files, q, &opts, &test_picker.budget, None))
});
}
group.finish();
}
fn bench_bigram_warm(c: &mut Criterion) {
let test_picker = setup();
let opts = plain_options();
let queries: &[(&str, &str)] = &[
("2char_if", "if"),
("common_return", "return"),
("func_mutex_lock", "mutex_lock"),
("struct_inode_ops", "inode_operations"),
("define_MODULE_LICENSE", "MODULE_LICENSE"),
("rare_phylink_ethtool", "phylink_ethtool"),
("include", "#include"),
("comment_TODO", "TODO"),
("type_struct_file", "struct file"),
("error_EINVAL", "err = -EINVAL"),
("long_static_int_init", "static int __init"),
("very_common_int", "int"),
("single_char_x", "x"),
("path_printk_c", "printk *.c"),
("dir_mutex_kernel", "mutex /kernel/"),
];
let mut group = c.benchmark_group("bigram_warm");
group.sample_size(30);
group.warm_up_time(Duration::from_secs(2));
group.measurement_time(Duration::from_secs(5));
for (name, query) in queries {
group.bench_with_input(BenchmarkId::from_parameter(name), query, |b, q| {
b.iter(|| {
do_grep(
&test_picker.files,
q,
&opts,
&test_picker.budget,
Some(&test_picker.bigram),
)
})
});
}
group.finish();
}
fn bench_fuzzy_warm(c: &mut Criterion) {
let test_picker = setup();
let opts = fuzzy_options();
let queries: &[(&str, &str)] = &[
("exact_mutex_lock", "mutex_lock"),
("typo_mutx_lock", "mutx_lock"),
("camel_InodeOps", "InodeOps"),
("abbrev_sched_rt", "sched_rt"),
("short_kfr", "kfr"),
("common_return", "return"),
("define_MODULE_LICENSE", "MODULE_LICENSE"),
("struct_file_ops", "file_operations"),
("long_static_int_init", "static_int_init"),
("path_printk_c", "printk *.c"),
];
let mut group = c.benchmark_group("fuzzy_warm");
group.sample_size(10);
group.warm_up_time(Duration::from_secs(2));
group.measurement_time(Duration::from_secs(8));
for (name, query) in queries {
group.bench_with_input(BenchmarkId::from_parameter(name), query, |b, q| {
b.iter(|| do_grep(&test_picker.files, q, &opts, &test_picker.budget, None))
});
}
group.finish();
}
fn bench_fuzzy_bigram_warm(c: &mut Criterion) {
let test_picker = setup();
let opts = fuzzy_options();
let queries: &[(&str, &str)] = &[
("exact_mutex_lock", "mutex_lock"),
("typo_mutx_lock", "mutx_lock"),
("camel_InodeOps", "InodeOps"),
("abbrev_sched_rt", "sched_rt"),
("short_kfr", "kfr"),
("common_return", "return"),
("define_MODULE_LICENSE", "MODULE_LICENSE"),
("struct_file_ops", "file_operations"),
("long_static_int_init", "static_int_init"),
("path_printk_c", "printk *.c"),
];
let mut group = c.benchmark_group("fuzzy_bigram_warm");
group.sample_size(10);
group.warm_up_time(Duration::from_secs(2));
group.measurement_time(Duration::from_secs(8));
for (name, query) in queries {
group.bench_with_input(BenchmarkId::from_parameter(name), query, |b, q| {
b.iter(|| {
do_grep(
&test_picker.files,
q,
&opts,
&test_picker.budget,
Some(&test_picker.bigram),
)
})
});
}
group.finish();
}
fn bench_plain_cold(c: &mut Criterion) {
let test_picker = setup();
let opts = plain_options();
let queries: &[(&str, &str)] = &[
("2char_if", "if"),
("common_return", "return"),
("func_mutex_lock", "mutex_lock"),
("struct_inode_ops", "inode_operations"),
("define_MODULE_LICENSE", "MODULE_LICENSE"),
("rare_phylink_ethtool", "phylink_ethtool"),
("long_static_int_init", "static int __init"),
];
let mut group = c.benchmark_group("plain_cold");
group.sample_size(10);
group.warm_up_time(Duration::from_millis(500));
group.measurement_time(Duration::from_secs(10));
let canonical = fff::path_utils::canonicalize(&big_repo_path()).expect("canonicalize");
for (name, query) in queries {
group.bench_with_input(BenchmarkId::from_parameter(name), query, |b, q| {
b.iter_with_setup(
|| load_files(&canonical),
|fresh_files| do_grep(&fresh_files, q, &opts, &test_picker.budget, None),
);
});
}
group.finish();
}
criterion_group!(
benches,
bench_plain_warm,
bench_bigram_warm,
bench_fuzzy_warm,
bench_fuzzy_bigram_warm,
bench_plain_cold,
);
criterion_main!(benches);
+132 -28
View File
@@ -1,9 +1,11 @@
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use fff_core::file_picker::{FFFMode, FilePicker};
use fff_core::types::{FileItem, PaginationArgs};
use fff_core::{FuzzySearchOptions, QueryParser, SharedFrecency, SharedPicker};
use fff::file_picker::{FFFMode, FilePicker};
use fff::types::{ContentCacheBudget, FileItem, PaginationArgs};
use fff::{
FilePickerOptions, FuzzySearchOptions, GrepMode, GrepSearchOptions, QueryParser,
SharedFrecency, SharedPicker, build_bigram_index, grep,
};
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::time::Duration;
/// Initialize tracing to output to console
@@ -27,11 +29,14 @@ fn init_file_picker_internal(
shared_frecency: &SharedFrecency,
) -> Result<(), String> {
FilePicker::new_with_shared_state(
path.to_string(),
false,
FFFMode::Neovim,
Arc::clone(shared_picker),
Arc::clone(shared_frecency),
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: path.to_string(),
warmup_mmap_cache: false,
mode: FFFMode::Neovim,
..Default::default()
},
)
.map_err(|e| format!("Failed to create FilePicker: {:?}", e))
}
@@ -129,12 +134,12 @@ fn setup_once() -> Result<(Vec<FileItem>, SharedPicker, SharedFrecency), String>
return Err("./big-repo directory does not exist. Run git clone https://github.com/torvalds/linux.git big-repo".to_string());
}
let canonical_path = fff_core::path_utils::canonicalize(&big_repo_path)
let canonical_path = fff::path_utils::canonicalize(&big_repo_path)
.map_err(|e| format!("Failed to canonicalize path: {}", e))?;
eprintln!(" Path: {:?}", canonical_path);
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
let shared_picker = SharedPicker::default();
let shared_frecency = SharedFrecency::default();
init_file_picker_internal(
&canonical_path.to_string_lossy(),
@@ -165,7 +170,7 @@ fn bench_indexing(c: &mut Criterion) {
return;
}
let canonical_path = match fff_core::path_utils::canonicalize(&big_repo_path) {
let canonical_path = match fff::path_utils::canonicalize(&big_repo_path) {
Ok(p) => p,
Err(e) => {
eprintln!("⚠ Failed to canonicalize path: {}", e);
@@ -179,8 +184,8 @@ fn bench_indexing(c: &mut Criterion) {
group.bench_function("index_big_repo", |b| {
b.iter(|| {
let sp: SharedPicker = Arc::new(RwLock::new(None));
let sf: SharedFrecency = Arc::new(RwLock::new(None));
let sp = SharedPicker::default();
let sf = SharedFrecency::default();
let start = std::time::Instant::now();
init_file_picker_internal(black_box(&canonical_path.to_string_lossy()), &sp, &sf)
@@ -235,11 +240,12 @@ fn bench_search_queries(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(&parsed),
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
@@ -283,11 +289,12 @@ fn bench_search_thread_scaling(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(&parsed),
None,
FuzzySearchOptions {
max_threads: threads,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
@@ -329,11 +336,12 @@ fn bench_search_result_limits(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(&parsed),
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
@@ -387,11 +395,12 @@ fn bench_search_scalability(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(subset),
black_box(&parsed),
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
@@ -431,11 +440,12 @@ fn bench_search_ordering(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(&parsed_controller),
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
@@ -454,11 +464,12 @@ fn bench_search_ordering(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(&parsed_controller),
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
@@ -477,11 +488,12 @@ fn bench_search_ordering(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(&parsed_mod),
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
@@ -499,11 +511,12 @@ fn bench_search_ordering(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(&parsed_mod),
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
@@ -522,11 +535,12 @@ fn bench_search_ordering(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(&parsed_controller),
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
@@ -544,11 +558,12 @@ fn bench_search_ordering(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(&parsed_controller),
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
@@ -588,11 +603,12 @@ fn bench_pagination_performance(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(&parsed),
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
@@ -611,11 +627,12 @@ fn bench_pagination_performance(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(&parsed),
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
@@ -634,11 +651,12 @@ fn bench_pagination_performance(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(&parsed),
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
@@ -654,6 +672,91 @@ fn bench_pagination_performance(c: &mut Criterion) {
group.finish();
}
/// Benchmark grep search with bigram index prefiltering
fn bench_grep_search(c: &mut Criterion) {
let (files, _sp, _sf) = match setup_once() {
Ok(result) => result,
Err(e) => {
eprintln!("Skipping grep benchmarks: {}", e);
return;
}
};
let budget = ContentCacheBudget::new_for_repo(files.len());
eprintln!(" Building bigram index for {} files...", files.len());
let start = std::time::Instant::now();
let (bigram_filter, _overflow_indices) = build_bigram_index(&files, &budget);
eprintln!(
" Bigram index built in {:.2}s ({} columns)",
start.elapsed().as_secs_f64(),
bigram_filter.columns_used(),
);
let mut group = c.benchmark_group("grep");
group.sample_size(50);
let options = GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 0,
smart_case: true,
file_offset: 0,
page_limit: 100,
mode: GrepMode::PlainText,
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
};
let test_queries = vec![
("common", "struct"),
("specific", "DEFINE_MUTEX"),
("path_filter", "*.h mutex"),
];
let grep_parser = fff::QueryParser::new(fff::GrepConfig);
for (name, query) in &test_queries {
let parsed = grep_parser.parse(query);
// With bigram index
group.bench_with_input(BenchmarkId::new("with_bigram", name), query, |b, _| {
b.iter(|| {
let result = grep::grep_search(
black_box(&files),
black_box(&parsed),
black_box(&options),
&budget,
Some(&bigram_filter),
None,
None,
);
result.matches.len()
});
});
// Without bigram index
group.bench_with_input(BenchmarkId::new("without_bigram", name), query, |b, _| {
b.iter(|| {
let result = grep::grep_search(
black_box(&files),
black_box(&parsed),
black_box(&options),
&budget,
None,
None,
None,
);
result.matches.len()
});
});
}
group.finish();
}
criterion_group!(
benches,
bench_indexing,
@@ -663,6 +766,7 @@ criterion_group!(
bench_search_scalability,
bench_search_ordering,
bench_pagination_performance,
bench_grep_search,
);
criterion_main!(benches);
@@ -1,5 +1,5 @@
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use fff_core::query_tracker::QueryTracker;
use fff::query_tracker::QueryTracker;
use rand::distributions::Alphanumeric;
use rand::prelude::*;
use std::path::PathBuf;
+191
View File
@@ -0,0 +1,191 @@
/// Benchmark: AVX2 vs scalar case-insensitive memmem prefilter.
///
/// Loads all non-binary file contents from a repo, then times both
/// implementations scanning every file for the query.
///
/// Usage:
/// cargo build --release --bin bench_ci_memmem
/// ./target/release/bench_ci_memmem --path ./big-repo --query "nomore" --iters 5
use fff::case_insensitive_memmem;
use std::io::Read;
use std::path::Path;
use std::time::Instant;
fn fmt_dur(us: u128) -> String {
if us > 1_000_000 {
format!("{:.2}s", us as f64 / 1_000_000.0)
} else if us > 1000 {
format!("{:.2}ms", us as f64 / 1000.0)
} else {
format!("{}µs", us)
}
}
fn stats(times_us: &mut [u128]) -> (u128, u128, u128, u128) {
times_us.sort();
let sum: u128 = times_us.iter().sum();
let mean = sum / times_us.len() as u128;
let median = times_us[times_us.len() / 2];
(mean, median, times_us[0], times_us[times_us.len() - 1])
}
fn detect_binary(path: &Path, size: u64) -> bool {
if size == 0 {
return false;
}
let Ok(file) = std::fs::File::open(path) else {
return false;
};
let mut reader = std::io::BufReader::with_capacity(1024, file);
let mut buf = [0u8; 512];
let n = reader.read(&mut buf).unwrap_or(0);
buf[..n].contains(&0)
}
fn load_file_contents(base_path: &Path) -> Vec<Vec<u8>> {
use ignore::WalkBuilder;
let mut contents = Vec::new();
let max_size = 10 * 1024 * 1024u64;
WalkBuilder::new(base_path)
.hidden(false)
.git_ignore(true)
.git_exclude(true)
.git_global(true)
.ignore(true)
.follow_links(false)
.build()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
.for_each(|entry| {
let path = entry.path();
let size = entry.metadata().ok().map_or(0, |m| m.len());
if size == 0 || size > max_size || detect_binary(path, size) {
return;
}
if let Ok(data) = std::fs::read(path) {
contents.push(data);
}
});
contents
}
fn bench_impl(
label: &str,
contents: &[Vec<u8>],
needle_lower: &[u8],
total_bytes: u64,
iters: usize,
search_fn: fn(&[u8], &[u8]) -> bool,
) {
eprintln!("\n [{}]", label);
let mut times = Vec::with_capacity(iters);
let mut hit_count = 0u32;
for i in 0..iters {
let t = Instant::now();
let mut hits = 0u32;
for content in contents {
if search_fn(content, needle_lower) {
hits += 1;
}
}
let us = t.elapsed().as_micros();
times.push(us);
hit_count = hits;
let tp = total_bytes as f64 / (us as f64 / 1_000_000.0) / (1024.0 * 1024.0 * 1024.0);
eprintln!(
" iter {}: {} ({} hits, {:.2} GB/s)",
i + 1,
fmt_dur(us),
hits,
tp
);
}
let (mean, median, min, max) = stats(&mut times);
let med_tp = total_bytes as f64 / (median as f64 / 1_000_000.0) / (1024.0 * 1024.0 * 1024.0);
eprintln!(
" mean: {} median: {} ({:.2} GB/s) min: {} max: {} hits: {}",
fmt_dur(mean),
fmt_dur(median),
med_tp,
fmt_dur(min),
fmt_dur(max),
hit_count
);
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let path = args
.iter()
.position(|a| a == "--path")
.and_then(|i| args.get(i + 1))
.map(|s| s.as_str())
.unwrap_or(".");
let query = args
.iter()
.position(|a| a == "--query")
.and_then(|i| args.get(i + 1))
.map(|s| s.as_str())
.unwrap_or("TODO");
let iters: usize = args
.iter()
.position(|a| a == "--iters")
.and_then(|i| args.get(i + 1))
.and_then(|s| s.parse().ok())
.unwrap_or(5);
let repo = std::path::PathBuf::from(path);
if !repo.exists() {
eprintln!("Path not found: {}", path);
eprintln!("Usage: bench_ci_memmem --path <dir> --query <text> [--iters N]");
std::process::exit(1);
}
let canonical = fff::path_utils::canonicalize(&repo).expect("Failed to canonicalize path");
let needle_lower: Vec<u8> = query.bytes().map(|b| b.to_ascii_lowercase()).collect();
eprintln!("=== bench_ci_memmem: AVX2 vs Scalar ===");
eprintln!("Path: {}", canonical.display());
eprintln!("Query: \"{}\"", query);
eprintln!("Needle: {:?}", std::str::from_utf8(&needle_lower).unwrap());
eprintln!("Iters: {}", iters);
eprint!("\n[1/2] Loading files into memory... ");
let t = Instant::now();
let contents = load_file_contents(&canonical);
let total_bytes: u64 = contents.iter().map(|c| c.len() as u64).sum();
eprintln!(
"{} files, {:.1} MB in {:.2}s",
contents.len(),
total_bytes as f64 / (1024.0 * 1024.0),
t.elapsed().as_secs_f64()
);
eprintln!("\n[2/2] Benchmarking memmem prefilter (scanning ALL files)");
bench_impl(
"Packed pair: (AVX2 two-byte scan)",
&contents,
&needle_lower,
total_bytes,
iters,
case_insensitive_memmem::search_packed_pair,
);
bench_impl(
"scalar: memchr2 first-byte + AVX2 verify",
&contents,
&needle_lower,
total_bytes,
iters,
case_insensitive_memmem::search,
);
}
+164
View File
@@ -0,0 +1,164 @@
/// Single-query grep benchmark with bigram index profiling.
///
/// Usage:
/// cargo build --release --bin bench_grep_query
/// ./target/release/bench_grep_query --path ~/dev/chromium --query "MAX_FILE_SIZE" --iters 3
/// ./target/release/bench_grep_query --path ~/dev/chromium --query "TODO" --no-bigram
use fff::grep::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query};
use fff::types::ContentCacheBudget;
use std::time::Instant;
fn fmt_dur(us: u128) -> String {
if us > 1_000_000 {
format!("{:.2}s", us as f64 / 1_000_000.0)
} else if us > 1000 {
format!("{:.2}ms", us as f64 / 1000.0)
} else {
format!("{}µs", us)
}
}
fn run_grep(files: &[fff::FileItem], index: Option<&fff::BigramFilter>, query: &str, iters: usize) {
let options = GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 200,
smart_case: true,
file_offset: 0,
page_limit: usize::MAX,
mode: GrepMode::PlainText,
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
};
let parsed = parse_grep_query(query);
let budget = ContentCacheBudget::default();
let mut times_us = Vec::with_capacity(iters);
for i in 0..iters {
let t = Instant::now();
let result = grep_search(files, &parsed, &options, &budget, index, None, None);
let us = t.elapsed().as_micros();
times_us.push(us);
eprintln!(
" iter {}: {} ({} matches in {} files, {}/{} searched)",
i + 1,
fmt_dur(us),
result.matches.len(),
result.files_with_matches,
result.total_files_searched,
result.total_files,
);
}
if times_us.len() > 1 {
times_us.sort();
let sum: u128 = times_us.iter().sum();
let mean = sum / times_us.len() as u128;
let median = times_us[times_us.len() / 2];
let min = times_us[0];
let max = times_us[times_us.len() - 1];
eprintln!(
" mean: {} median: {} min: {} max: {}",
fmt_dur(mean),
fmt_dur(median),
fmt_dur(min),
fmt_dur(max)
);
}
}
fn build_bigram(files: &mut [fff::FileItem]) -> fff::BigramFilter {
let budget = ContentCacheBudget::default();
let (index, binary_indices) = fff::build_bigram_index(files, &budget);
for &i in &binary_indices {
files[i].set_binary(true);
}
index
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let path = args
.iter()
.position(|a| a == "--path")
.and_then(|i| args.get(i + 1))
.map(|s| s.as_str())
.unwrap_or(".");
let query = args
.iter()
.position(|a| a == "--query")
.and_then(|i| args.get(i + 1))
.map(|s| s.as_str())
.unwrap_or("TODO");
let iters: usize = args
.iter()
.position(|a| a == "--iters")
.and_then(|i| args.get(i + 1))
.and_then(|s| s.parse().ok())
.unwrap_or(5);
let no_bigram = args.iter().any(|a| a == "--no-bigram");
let repo = std::path::PathBuf::from(path);
if !repo.exists() {
eprintln!("Path not found: {}", path);
eprintln!("Usage: bench_grep_query --path <dir> --query <text> [--iters N] [--no-bigram]");
std::process::exit(1);
}
let canonical = fff::path_utils::canonicalize(&repo).expect("Failed to canonicalize path");
eprintln!("=== bench_grep_query ===");
eprintln!("Path: {}", canonical.display());
eprintln!("Query: \"{}\"", query);
eprintln!("Iters: {}", iters);
eprintln!();
// ── 1. Scan files ──────────────────────────────────────────────────
eprint!("[1/3] Scanning files... ");
let t = Instant::now();
let mut files = fff::scan_files(&canonical);
let non_binary = files.iter().filter(|f| !f.is_binary()).count();
eprintln!(
"{} files in {:.2}s ({} non-binary)",
files.len(),
t.elapsed().as_secs_f64(),
non_binary,
);
if no_bigram {
eprintln!("[2/3] Bigram index skipped (--no-bigram)");
eprintln!(
"\n[3/3] Running grep \"{}\" x {} iterations\n",
query, iters
);
run_grep(&files, None, query, iters);
return;
}
// ── 2. Build bigram index ──────────────────────────────────────────
eprint!("[2/3] Bigram index... ");
let t = Instant::now();
let index = build_bigram(&mut files);
eprintln!(
"done in {:.2}s ({} cols, {:.1} MB)",
t.elapsed().as_secs_f64(),
index.columns_used(),
index.heap_bytes() as f64 / (1024.0 * 1024.0),
);
// ── 3. Grep ───────────────────────────────────────────────────────
eprintln!(
"\n[3/3] Running grep \"{}\" x {} iterations\n",
query, iters
);
run_grep(&files, Some(&index), query, iters);
}
+13 -9
View File
@@ -1,6 +1,6 @@
/// Simple search profiler that directly uses scan_filesystem without background thread overhead
use fff_core::file_picker::FilePicker;
use fff_core::{FileItem, FuzzySearchOptions, PaginationArgs, QueryParser};
use fff::file_picker::FilePicker;
use fff::{FileItem, FuzzySearchOptions, PaginationArgs, QueryParser};
use std::time::Instant;
fn main() {
@@ -14,7 +14,7 @@ fn main() {
}
let canonical_path =
fff_core::path_utils::canonicalize(&big_repo_path).expect("Failed to canonicalize path");
fff::path_utils::canonicalize(&big_repo_path).expect("Failed to canonicalize path");
eprintln!("Loading files from: {:?}", canonical_path);
@@ -35,12 +35,17 @@ fn main() {
pathdiff::diff_paths(&path, &canonical_path).unwrap_or_else(|| path.clone());
let relative_path = relative.to_string_lossy().into_owned();
let file_name = entry.file_name().to_string_lossy().into_owned();
let path_string = path.to_string_lossy().into_owned();
let relative_start = (path_string.len() - relative_path.len()) as u16;
let filename_start = path_string
.rfind('/')
.map(|i| i + 1)
.unwrap_or(relative_start as usize) as u16;
files.push(FileItem::new_raw(
path,
relative_path,
file_name,
path_string,
relative_start,
filename_start,
entry.metadata().ok().map_or(0, |m| m.len()),
0,
None,
@@ -64,7 +69,6 @@ fn main() {
("long_rare", "user_authentication", 100),
("typo_resistant", "contrlr", 200),
("path_like", "src/lib", 150),
("single_char", "a", 300),
("two_char", "st", 300),
("partial_word", "test", 200),
("deep_path", "drivers/net", 100),
@@ -88,11 +92,11 @@ fn main() {
let results = FilePicker::fuzzy_search(
&files,
&parsed,
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
+48 -17
View File
@@ -1,4 +1,4 @@
use fff_core::FileItem;
use fff::FileItem;
/// Fuzzy grep quality test against ~/dev/lightsource
///
/// Runs queries through the fuzzy grep pipeline and prints results
@@ -7,7 +7,7 @@ use fff_core::FileItem;
/// Usage:
/// cargo run --release --bin fuzzy_grep_test # runs default test queries
/// cargo run --release --bin fuzzy_grep_test -- "query" # runs a single user query
use fff_core::grep::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query};
use fff::grep::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query};
use std::io::Read;
use std::path::Path;
use std::time::Instant;
@@ -31,14 +31,19 @@ fn load_files(base_path: &Path) -> Vec<FileItem> {
let path = entry.path().to_path_buf();
let relative = pathdiff::diff_paths(&path, base_path).unwrap_or_else(|| path.clone());
let relative_path = relative.to_string_lossy().into_owned();
let file_name = entry.file_name().to_string_lossy().into_owned();
let size = entry.metadata().ok().map_or(0, |m| m.len());
let is_binary = detect_binary(&path, size);
let path_string = path.to_string_lossy().into_owned();
let relative_start = (path_string.len() - relative_path.len()) as u16;
let filename_start = path_string
.rfind('/')
.map(|i| i + 1)
.unwrap_or(relative_start as usize) as u16;
files.push(FileItem::new_raw(
path,
relative_path,
file_name,
path_string,
relative_start,
filename_start,
size,
0,
None,
@@ -74,11 +79,20 @@ fn run_fuzzy_query(files: &[FileItem], query: &str, label: &str) {
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
};
let parsed = parse_grep_query(query);
let start = Instant::now();
let result = grep_search(files, &parsed, &options);
let result = grep_search(
files,
&parsed,
&options,
&fff::ContentCacheBudget::default(),
None,
None,
None,
);
let elapsed = start.elapsed();
eprintln!("══════════════════════════════════════════════════════════════");
@@ -102,7 +116,7 @@ fn run_fuzzy_query(files: &[FileItem], query: &str, label: &str) {
if m.file_index != current_file_idx {
current_file_idx = m.file_index;
let file = &result.files[m.file_index];
eprintln!("\n ┌─ {}", file.relative_path);
eprintln!("\n ┌─ {}", file.relative_path());
}
// Truncate long lines for display
@@ -156,25 +170,42 @@ fn run_fuzzy_query(files: &[FileItem], query: &str, label: &str) {
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let repo_path = std::path::PathBuf::from(
std::env::var("HOME").unwrap_or_else(|_| "/Users/neogoose".to_string()),
)
.join("dev/lightsource");
let (repo_path, queries) = if let Some(idx) = args.iter().position(|a| a == "--path") {
let path = args
.get(idx + 1)
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
eprintln!("--path requires an argument");
std::process::exit(1);
});
let queries: Vec<String> = args
.iter()
.enumerate()
.filter(|(i, _)| *i != idx && *i != idx + 1)
.map(|(_, s)| s.clone())
.collect();
(path, queries)
} else {
let path = std::path::PathBuf::from(
std::env::var("HOME").unwrap_or_else(|_| "/Users/neogoose".to_string()),
)
.join("dev/lightsource");
(path, args)
};
if !repo_path.exists() {
eprintln!("Repository not found at: {:?}", repo_path);
std::process::exit(1);
}
let canonical =
fff_core::path_utils::canonicalize(&repo_path).expect("Failed to canonicalize path");
let canonical = fff::path_utils::canonicalize(&repo_path).expect("Failed to canonicalize path");
eprintln!("=== Fuzzy Grep Quality Test ===");
eprintln!("Repository: {:?}\n", canonical);
eprintln!("Loading files...");
let load_start = Instant::now();
let files = load_files(&canonical);
let non_binary = files.iter().filter(|f| !f.is_binary).count();
let non_binary = files.iter().filter(|f| !f.is_binary()).count();
eprintln!(
"Loaded {} files ({} non-binary) in {:.2}s\n",
files.len(),
@@ -182,7 +213,7 @@ fn main() {
load_start.elapsed().as_secs_f64()
);
if args.is_empty() {
if queries.is_empty() {
// Run default test queries
run_fuzzy_query(&files, "shcema", "transposition of 'schema'");
run_fuzzy_query(&files, "SortedMap", "should match SortedArrayMap");
@@ -193,7 +224,7 @@ fn main() {
);
} else {
// Run user-provided queries
for query in &args {
for query in &queries {
run_fuzzy_query(&files, query, "user query");
}
}
+97 -12
View File
@@ -1,4 +1,3 @@
use fff_core::FileItem;
/// Live grep benchmark profiler for fff.nvim
///
/// Benchmarks the full grep pipeline against a large repository (Linux kernel).
@@ -10,7 +9,11 @@ use fff_core::FileItem;
/// Usage:
/// cargo build --release --bin grep_profiler
/// ./target/release/grep_profiler [--path /path/to/repo]
use fff_core::grep::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query};
use fff::{
BigramFilter, FileItem,
grep::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query},
types::ContentCacheBudget,
};
use std::io::Read;
use std::path::Path;
use std::time::{Duration, Instant};
@@ -34,14 +37,19 @@ fn load_files(base_path: &Path) -> Vec<FileItem> {
let path = entry.path().to_path_buf();
let relative = pathdiff::diff_paths(&path, base_path).unwrap_or_else(|| path.clone());
let relative_path = relative.to_string_lossy().into_owned();
let file_name = entry.file_name().to_string_lossy().into_owned();
let size = entry.metadata().ok().map_or(0, |m| m.len());
let is_binary = detect_binary(&path, size);
let path_string = path.to_string_lossy().into_owned();
let relative_start = (path_string.len() - relative_path.len()) as u16;
let filename_start = path_string
.rfind('/')
.map(|i| i + 1)
.unwrap_or(relative_start as usize) as u16;
files.push(FileItem::new_raw(
path,
relative_path,
file_name,
path_string,
relative_start,
filename_start,
size,
0,
None,
@@ -115,6 +123,7 @@ impl BenchStats {
struct GrepBench<'a> {
files: &'a [FileItem],
options: GrepSearchOptions,
bigram_index: Option<&'a BigramFilter>,
}
impl<'a> GrepBench<'a> {
@@ -125,6 +134,7 @@ impl<'a> GrepBench<'a> {
fn with_mode(files: &'a [FileItem], mode: GrepMode) -> Self {
Self {
files,
bigram_index: None,
options: GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 200,
@@ -136,15 +146,29 @@ impl<'a> GrepBench<'a> {
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
},
}
}
fn with_bigram(mut self, index: &'a BigramFilter) -> Self {
self.bigram_index = Some(index);
self
}
/// Run a single grep search, return (duration, match_count, files_searched)
fn run_once(&self, query: &str) -> (Duration, usize, usize) {
let parsed = parse_grep_query(query);
let start = Instant::now();
let result = grep_search(self.files, &parsed, &self.options);
let result = grep_search(
self.files,
&parsed,
&self.options,
&ContentCacheBudget::default(),
self.bigram_index,
None,
None,
);
let elapsed = start.elapsed();
(elapsed, result.matches.len(), result.total_files_searched)
}
@@ -166,6 +190,17 @@ impl<'a> GrepBench<'a> {
}
}
fn build_bigram(files: &mut [FileItem]) -> BigramFilter {
let budget = ContentCacheBudget::default();
let (index, binary_indices) = fff::build_bigram_index(files, &budget);
for &i in &binary_indices {
files[i].set_binary(true);
}
index
}
fn fmt_dur(d: Duration) -> String {
let us = d.as_micros();
if us > 1_000_000 {
@@ -222,16 +257,16 @@ fn main() {
std::process::exit(1);
}
let canonical = fff_core::path_utils::canonicalize(&repo).expect("Failed to canonicalize path");
let canonical = fff::path_utils::canonicalize(&repo).expect("Failed to canonicalize path");
eprintln!("=== FFF Live Grep Profiler ===");
eprintln!("Repository: {:?}", canonical);
// Direct file loading (no background thread)
eprintln!("\n[1/7] Loading files...");
let load_start = Instant::now();
let files = load_files(&canonical);
let mut files = load_files(&canonical);
let load_time = load_start.elapsed();
let non_binary = files.iter().filter(|f| !f.is_binary).count();
let non_binary = files.iter().filter(|f| !f.is_binary()).count();
let large_files = files.iter().filter(|f| f.size > 10 * 1024 * 1024).count();
eprintln!(
" Loaded {} files in {:.2}s ({} non-binary, {} >10MB skipped)\n",
@@ -300,6 +335,26 @@ fn main() {
print_row(name, &stats, matches, files_searched, *iters);
}
eprintln!("\n[3b/7] Building bigram index...");
let bigram_start = Instant::now();
let bigram_index = build_bigram(&mut files);
eprintln!(
" Built in {:.2}s ({} columns, {:.1} MB)\n",
bigram_start.elapsed().as_secs_f64(),
bigram_index.file_count(),
bigram_index.heap_bytes() as f64 / (1024.0 * 1024.0),
);
eprintln!("[3c/7] Bigram-accelerated warm benchmarks (same queries, with bigram prefilter)");
print_header();
let bigram_bench = GrepBench::new(&files).with_bigram(&bigram_index);
for (name, query, iters) in &warm_queries {
let bigram_name = format!("bg_{}", name.strip_prefix("warm_").unwrap_or(name));
let (stats, matches, files_searched) = bigram_bench.bench_query(query, *iters);
print_row(&bigram_name, &stats, matches, files_searched, *iters);
}
// ── Fuzzy grep benchmarks ─────────────────────────────────────────────
eprintln!("\n[4/7] Fuzzy grep warm benchmarks");
eprintln!(" Running 3 warmup iterations, then measuring.\n");
@@ -332,6 +387,19 @@ fn main() {
print_row(name, &stats, matches, files_searched, *iters);
}
// ── Fuzzy + bigram prefilter benchmarks ─────────────────────────────
eprintln!("\n[4b/7] Fuzzy grep with bigram prefilter");
print_header();
let fuzzy_bigram_bench =
GrepBench::with_mode(&files, GrepMode::Fuzzy).with_bigram(&bigram_index);
for (name, query, iters) in &fuzzy_queries {
let bg_name = format!("bg_{}", name);
let (stats, matches, files_searched) = fuzzy_bigram_bench.bench_query(query, *iters);
print_row(&bg_name, &stats, matches, files_searched, *iters);
}
// ── Fuzzy incremental typing ────────────────────────────────────────
eprintln!("\n[5/7] Fuzzy incremental typing simulation");
eprintln!(" Simulates user typing character by character (fuzzy mode).\n");
@@ -380,6 +448,7 @@ fn main() {
eprintln!("[6/7] Incremental typing simulation (plain text)");
eprintln!(" Simulates user typing character by character.\n");
let bench = GrepBench::new(&files);
let typing_sequences: Vec<(&str, Vec<&str>)> = vec![
(
"mutex_lock",
@@ -450,9 +519,18 @@ fn main() {
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
};
let start = Instant::now();
let result = grep_search(&files, &parsed, &opts);
let result = grep_search(
&files,
&parsed,
&opts,
&fff::ContentCacheBudget::unlimited(),
None,
None,
None,
);
let elapsed = start.elapsed();
eprintln!(
" {:>6} | {:>12} | {:>8} | {:>6} | {:>12}",
@@ -471,7 +549,14 @@ fn main() {
}
eprintln!("\n=== Summary ===");
let mmap_count = files.iter().filter(|f| f.get_mmap().is_some()).count();
let mmap_count = files
.iter()
.filter(|f| {
let mut buf = Vec::new();
f.get_content_for_search(&mut buf, &fff::ContentCacheBudget::unlimited())
.is_some()
})
.count();
eprintln!(" Files with cached mmap: {}", mmap_count);
eprintln!(" Total indexed files: {}", files.len());
eprintln!(" Non-binary files: {}", non_binary);
+44 -12
View File
@@ -1,5 +1,5 @@
use fff_core::FFFQuery;
use fff_core::FileItem;
use fff::FFFQuery;
use fff::FileItem;
/// FFF vs ripgrep comparison benchmark
///
/// Demonstrates why a persistent in-process search engine (fff) is fundamentally
@@ -21,7 +21,7 @@ use fff_core::FileItem;
/// Usage:
/// cargo build --release --bin grep_vs_rg
/// ./target/release/grep_vs_rg [--path /path/to/repo] [--iters 5]
use fff_core::grep::{GrepSearchOptions, grep_search, parse_grep_query};
use fff::grep::{GrepSearchOptions, grep_search, parse_grep_query};
use std::io::Read;
use std::path::Path;
use std::process::Command;
@@ -48,14 +48,19 @@ fn load_files(base_path: &Path) -> Vec<FileItem> {
let path = entry.path().to_path_buf();
let relative = pathdiff::diff_paths(&path, base_path).unwrap_or_else(|| path.clone());
let relative_path = relative.to_string_lossy().into_owned();
let file_name = entry.file_name().to_string_lossy().into_owned();
let size = entry.metadata().ok().map_or(0, |m| m.len());
let is_binary = detect_binary(&path, size);
let path_string = path.to_string_lossy().into_owned();
let relative_start = (path_string.len() - relative_path.len()) as u16;
let filename_start = path_string
.rfind('/')
.map(|i| i + 1)
.unwrap_or(relative_start as usize) as u16;
files.push(FileItem::new_raw(
path,
relative_path,
file_name,
path_string,
relative_start,
filename_start,
size,
0,
None,
@@ -208,9 +213,18 @@ fn run_fff_full(files: &[FileItem], query: &str) -> (usize, Duration) {
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
};
let start = Instant::now();
let result = grep_search(files, &parsed, &options);
let result = grep_search(
files,
&parsed,
&options,
&fff::ContentCacheBudget::zero(),
None,
None,
None,
);
let elapsed = start.elapsed();
(result.matches.len(), elapsed)
}
@@ -228,9 +242,18 @@ fn benchmark_fff_smart_case(files: &[FileItem], parsed: &FFFQuery<'_>) -> (usize
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
};
let start = Instant::now();
let result = grep_search(files, parsed, &options);
let result = grep_search(
files,
parsed,
&options,
&fff::ContentCacheBudget::unlimited(),
None,
None,
None,
);
let elapsed = start.elapsed();
(result.matches.len(), elapsed)
}
@@ -249,9 +272,18 @@ fn run_fff_page(files: &[FileItem], query: &str) -> (usize, Duration) {
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
};
let start = Instant::now();
let result = grep_search(files, &parsed, &options);
let result = grep_search(
files,
&parsed,
&options,
&fff::ContentCacheBudget::unlimited(),
None,
None,
None,
);
let elapsed = start.elapsed();
(result.matches.len(), elapsed)
}
@@ -319,7 +351,7 @@ fn main() {
std::process::exit(1);
}
let canonical = fff_core::path_utils::canonicalize(&repo).expect("Failed to canonicalize path");
let canonical = fff::path_utils::canonicalize(&repo).expect("Failed to canonicalize path");
let rg_version = Command::new("rg")
.arg("--version")
@@ -341,7 +373,7 @@ fn main() {
eprintln!("[1/5] Indexing files...");
let files = load_files(&canonical);
let non_binary = files.iter().filter(|f| !f.is_binary).count();
let non_binary = files.iter().filter(|f| !f.is_binary()).count();
eprintln!(" {} files ({} searchable)\n", files.len(), non_binary);
eprintln!("[2/5] Warming caches (fff mmap + OS page cache)...");
+13 -11
View File
@@ -1,7 +1,6 @@
use fff_core::file_picker::{FFFMode, FilePicker};
use fff_core::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
use fff::file_picker::{FFFMode, FilePicker};
use fff::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
use std::env;
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::Duration;
@@ -91,11 +90,11 @@ fn test_search_memory_pattern(
let search_result = FilePicker::fuzzy_search(
picker.get_files(),
&parsed,
None,
FuzzySearchOptions {
max_threads: 1 + (i % 4),
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
@@ -179,17 +178,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!();
// Create shared state
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
let shared_picker = SharedPicker::default();
let shared_frecency = SharedFrecency::default();
// Initialize FilePicker
println!("Initializing FilePicker...");
FilePicker::new_with_shared_state(
base_path.clone(),
false,
FFFMode::Neovim,
Arc::clone(&shared_picker),
Arc::clone(&shared_frecency),
shared_picker.clone(),
shared_frecency.clone(),
fff::FilePickerOptions {
base_path: base_path.clone(),
warmup_mmap_cache: false,
mode: FFFMode::Neovim,
..Default::default()
},
)?;
// Wait for initial scan
+36 -60
View File
@@ -1,50 +1,23 @@
use fff_core::file_picker::{FFFMode, FilePicker};
use fff_core::{
use fff::file_picker::{FFFMode, FilePicker};
use fff::{
FileItem, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker,
};
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
/// Wait for background scan to complete
fn wait_for_scan(shared_picker: &SharedPicker, timeout_secs: u64) -> Result<usize, String> {
let start = Instant::now();
let timeout = Duration::from_secs(timeout_secs);
let mut iteration = 0;
if !shared_picker.wait_for_scan(timeout) {
return Err(format!("Scan timed out after {} seconds", timeout_secs));
}
loop {
iteration += 1;
let picker_guard = shared_picker
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
let is_scanning = picker.is_scan_active();
let file_count = picker.get_files().len();
if iteration % 20 == 0 {
eprintln!(
" [{:.1}s] Scanning: {}, Files: {}",
start.elapsed().as_secs_f64(),
is_scanning,
file_count
);
}
if !is_scanning && file_count > 0 {
return Ok(file_count);
}
} else if iteration % 20 == 0 {
eprintln!(
" [{:.1}s] FilePicker is None",
start.elapsed().as_secs_f64()
);
}
if start.elapsed() > timeout {
return Err(format!("Scan timed out after {} seconds", timeout_secs));
}
std::thread::sleep(Duration::from_millis(100));
let picker_guard = shared_picker
.read()
.map_err(|e| format!("Failed to acquire read lock: {}", e))?;
if let Some(ref picker) = *picker_guard {
Ok(picker.get_files().len())
} else {
Err("FilePicker not initialized".to_string())
}
}
@@ -52,7 +25,7 @@ fn wait_for_scan(shared_picker: &SharedPicker, timeout_secs: u64) -> Result<usiz
fn get_files(shared_picker: &SharedPicker) -> Result<Vec<FileItem>, String> {
let picker_guard = shared_picker
.read()
.map_err(|_| "Failed to acquire read lock")?;
.map_err(|e| format!("Failed to acquire read lock: {}", e))?;
if let Some(ref picker) = *picker_guard {
Ok(picker.get_files().to_vec())
} else {
@@ -71,21 +44,24 @@ fn main() {
}
let canonical_path =
fff_core::path_utils::canonicalize(&big_repo_path).expect("Failed to canonicalize path");
fff::path_utils::canonicalize(&big_repo_path).expect("Failed to canonicalize path");
// Create shared state
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
let shared_picker = SharedPicker::default();
let shared_frecency = SharedFrecency::default();
eprintln!("Initializing FilePicker for: {:?}", canonical_path);
FilePicker::new_with_shared_state(
canonical_path.to_string_lossy().to_string(),
false,
FFFMode::Neovim,
Arc::clone(&shared_picker),
Arc::clone(&shared_frecency),
shared_picker.clone(),
shared_frecency.clone(),
fff::FilePickerOptions {
base_path: canonical_path.to_string_lossy().to_string(),
warmup_mmap_cache: false,
mode: FFFMode::Neovim,
..Default::default()
},
)
.expect("Failed to init FilePicker");
.expect("Failed to init FilePicker with shared state");
// Give background thread time to start
std::thread::sleep(Duration::from_millis(200));
@@ -98,16 +74,16 @@ fn main() {
// Test queries representing different search patterns
let test_queries = vec![
("short_common", "mod", 5000),
("medium_specific", "controller", 2000),
("long_rare", "user_authentication", 1000),
("typo_resistant", "contrlr", 2000),
("path_like", "src/lib", 1500),
("single_char", "a", 3000),
("two_char", "st", 3000),
("partial_word", "test", 2000),
("deep_path", "drivers/net", 1000),
("extension", ".rs", 2000),
("short_common", "mod", 100),
("medium_specific", "controller", 100),
("long_rare", "user_authentication", 100),
("typo_resistant", "contrlr", 100),
("path_like", "src/lib", 100),
("single_char", "a", 100),
("two_char", "st", 100),
("partial_word", "test", 100),
("deep_path", "drivers/net", 100),
("extension", ".rs", 100),
];
eprintln!("Running search profiler...");
@@ -127,11 +103,11 @@ fn main() {
let results = FilePicker::fuzzy_search(
&files,
&parsed,
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
+14 -12
View File
@@ -1,8 +1,7 @@
use fff_core::file_picker::{FFFMode, FilePicker};
use fff_core::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
use fff::file_picker::{FFFMode, FilePicker};
use fff::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
use std::env;
use std::io::{self, Write};
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::{Duration, Instant};
@@ -79,17 +78,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!();
// Create shared state
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
let shared_picker = SharedPicker::default();
let shared_frecency = SharedFrecency::default();
// Initialize the file picker
println!("📁 Initializing FilePicker...");
FilePicker::new_with_shared_state(
base_path.clone(),
false,
FFFMode::Neovim,
Arc::clone(&shared_picker),
Arc::clone(&shared_frecency),
shared_picker.clone(),
shared_frecency.clone(),
fff::FilePickerOptions {
base_path: base_path.clone(),
warmup_mmap_cache: false,
mode: FFFMode::Neovim,
..Default::default()
},
)?;
// Wait for initial scan to complete
@@ -142,7 +144,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if !files.is_empty() {
println!("Sample files:");
for (i, file) in files.iter().take(5).enumerate() {
println!(" {}. {}", i + 1, file.relative_path);
println!(" {}. {}", i + 1, file.relative_path());
}
}
files.len()
@@ -201,11 +203,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let search_result = FilePicker::fuzzy_search(
picker.get_files(),
&parsed,
None,
FuzzySearchOptions {
max_threads,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
+20 -18
View File
@@ -2,15 +2,13 @@
#![allow(dead_code)]
#![allow(clippy::enum_variant_names)]
use fff_core::file_picker::FilePicker;
use fff_core::git::format_git_status;
use fff_core::{
FFFMode, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker,
};
use fff::file_picker::FilePicker;
use fff::git::format_git_status;
use fff::{FFFMode, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
use std::env;
use std::io::{self, Write};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::Duration;
@@ -27,11 +25,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let r = running.clone();
// Create shared state
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
let shared_picker = SharedPicker::default();
let shared_frecency = SharedFrecency::default();
// Clone for signal handler
let picker_for_cleanup = Arc::clone(&shared_picker);
let picker_for_cleanup = shared_picker.clone();
ctrlc::set_handler(move || {
println!("\n🛑 Received interrupt signal, shutting down...");
if let Ok(mut guard) = picker_for_cleanup.write() {
@@ -48,11 +46,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize the file picker using shared state
FilePicker::new_with_shared_state(
base_path.clone(),
false,
FFFMode::default(),
Arc::clone(&shared_picker),
Arc::clone(&shared_frecency),
shared_picker.clone(),
shared_frecency.clone(),
fff::FilePickerOptions {
base_path: base_path.clone(),
warmup_mmap_cache: false,
mode: FFFMode::default(),
..Default::default()
},
)?;
// Get initial file count from shared state
@@ -67,7 +68,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!(
" {}. {} ({})",
i + 1,
file.relative_path,
file.relative_path(),
format_git_status(file.git_status)
);
}
@@ -109,7 +110,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let files = guard.as_ref().unwrap().get_files();
let newest_files = files.iter().rev().take(added.min(3));
for file in newest_files {
println!(" {}", file.relative_path);
println!(" {}", file.relative_path());
}
} else {
let removed = last_count - current_count;
@@ -156,11 +157,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let search_results = FilePicker::fuzzy_search(
files,
&parsed,
None,
FuzzySearchOptions {
max_threads: 2,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
@@ -185,7 +186,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!(
" {}. {} (score: {})",
i + 1,
file.relative_path,
file.relative_path(),
score.total
);
}
@@ -200,5 +201,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
picker.stop_background_monitor();
}
}
Ok(())
}
+4 -15
View File
@@ -1,10 +1,10 @@
//! Error handling for fff-nvim
//!
//! This module provides utilities for converting fff_core errors to mlua errors.
//! This module provides utilities for converting fff errors to mlua errors.
use fff_core::Error as CoreError;
use fff::Error as CoreError;
/// Convert a fff_core::Error to mlua::Error
/// Convert a fff::Error to mlua::Error
///
/// This function is used because we can't implement From<CoreError> for mlua::Error
/// due to Rust's orphan rules (both types are foreign to this crate).
@@ -14,7 +14,7 @@ pub fn to_lua_error(err: CoreError) -> mlua::Error {
mlua::Error::RuntimeError(string_value)
}
/// Extension trait for Result<T, fff_core::Error> to convert to LuaResult<T>
/// Extension trait for Result<T, fff::Error> to convert to LuaResult<T>
pub trait IntoLuaResult<T> {
fn into_lua_result(self) -> mlua::Result<T>;
}
@@ -24,14 +24,3 @@ impl<T> IntoLuaResult<T> for Result<T, CoreError> {
self.map_err(to_lua_error)
}
}
/// Extension trait for Result<T, PoisonError> to convert to Result<T, CoreError>
pub trait IntoCoreError<T> {
fn with_lock_error(self, err: CoreError) -> Result<T, CoreError>;
}
impl<T, G> IntoCoreError<T> for Result<T, std::sync::PoisonError<G>> {
fn with_lock_error(self, err: CoreError) -> Result<T, CoreError> {
self.map_err(|_| err)
}
}
+99 -172
View File
@@ -1,19 +1,19 @@
use crate::path_shortening::shorten_path_with_cache;
use error::{IntoCoreError, IntoLuaResult};
use fff_core::file_picker::FilePicker;
use fff_core::frecency::FrecencyTracker;
use fff_core::path_utils::expand_tilde;
use fff_core::query_tracker::QueryTracker;
use fff_core::{
DbHealthChecker, Error, FFFMode, FuzzySearchOptions, PaginationArgs, QueryParser, Score,
SearchResult, SharedFrecency, SharedPicker, SharedQueryTracker,
use error::IntoLuaResult;
use fff::file_picker::FilePicker;
use fff::frecency::FrecencyTracker;
use fff::path_utils::expand_tilde;
use fff::query_tracker::QueryTracker;
use fff::{
DbHealthChecker, Error, FFFMode, FileSearchConfig, FuzzySearchOptions, GrepConfig,
PaginationArgs, QueryParser, Score, SearchResult, SharedFrecency, SharedPicker,
SharedQueryTracker,
};
use mimalloc::MiMalloc;
use mlua::prelude::*;
use once_cell::sync::Lazy;
use path_shortening::PathShortenStrategy;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use std::time::Duration;
mod error;
@@ -27,18 +27,15 @@ static GLOBAL: MiMalloc = MiMalloc;
// the global state for neovim lives here for efficiency
// lua ffi is pretty bad with the overhead of converting raw pointer into tables
pub static FILE_PICKER: Lazy<SharedPicker> = Lazy::new(|| Arc::new(RwLock::new(None)));
pub static FRECENCY: Lazy<SharedFrecency> = Lazy::new(|| Arc::new(RwLock::new(None)));
pub static QUERY_TRACKER: Lazy<SharedQueryTracker> = Lazy::new(|| Arc::new(RwLock::new(None)));
pub static FILE_PICKER: Lazy<SharedPicker> = Lazy::new(SharedPicker::default);
pub static FRECENCY: Lazy<SharedFrecency> = Lazy::new(SharedFrecency::default);
pub static QUERY_TRACKER: Lazy<SharedQueryTracker> = Lazy::new(SharedQueryTracker::default);
pub fn init_db(
_: &Lua,
(frecency_db_path, history_db_path, use_unsafe_no_lock): (String, String, bool),
) -> LuaResult<bool> {
let mut frecency = FRECENCY
.write()
.with_lock_error(Error::AcquireFrecencyLock)
.into_lua_result()?;
let mut frecency = FRECENCY.write().into_lua_result()?;
if frecency.is_some() {
*frecency = None;
}
@@ -48,58 +45,45 @@ pub fn init_db(
drop(frecency);
// Spawn background GC to purge stale entries without blocking startup
FrecencyTracker::spawn_gc(Arc::clone(&FRECENCY), frecency_db_path, use_unsafe_no_lock);
let _ = FRECENCY.spawn_gc(frecency_db_path, use_unsafe_no_lock);
let mut query_tracker = QUERY_TRACKER
.write()
.with_lock_error(Error::AcquireFrecencyLock)
.into_lua_result()?;
let mut query_tracker = QUERY_TRACKER.write().into_lua_result()?;
if query_tracker.is_some() {
*query_tracker = None;
}
let tracker = QueryTracker::new(&history_db_path, use_unsafe_no_lock).into_lua_result()?;
*query_tracker = Some(tracker);
tracing::info!("Query tracker database initialized at {}", history_db_path);
*query_tracker =
Some(QueryTracker::new(&history_db_path, use_unsafe_no_lock).into_lua_result()?);
tracing::info!("Query tracker database initialized at {}", history_db_path);
Ok(true)
}
pub fn destroy_frecency_db(_: &Lua, _: ()) -> LuaResult<bool> {
let mut frecency = FRECENCY
.write()
.with_lock_error(Error::AcquireFrecencyLock)
.into_lua_result()?;
*frecency = None;
Ok(true)
Ok(FRECENCY.destroy().into_lua_result()?.is_some())
}
pub fn destroy_query_db(_: &Lua, _: ()) -> LuaResult<bool> {
let mut query_tracker = QUERY_TRACKER
.write()
.with_lock_error(Error::AcquireFrecencyLock)
.into_lua_result()?;
*query_tracker = None;
Ok(true)
Ok(QUERY_TRACKER.destroy().into_lua_result()?.is_some())
}
pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult<bool> {
{
let guard = FILE_PICKER
.read()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let guard = FILE_PICKER.read().into_lua_result()?;
if guard.is_some() {
return Ok(false);
}
}
FilePicker::new_with_shared_state(
base_path,
false,
FFFMode::Neovim,
Arc::clone(&FILE_PICKER),
Arc::clone(&FRECENCY),
FILE_PICKER.clone(),
FRECENCY.clone(),
fff::FilePickerOptions {
base_path,
warmup_mmap_cache: true,
mode: FFFMode::Neovim,
..Default::default()
},
)
.into_lua_result()?;
@@ -111,9 +95,7 @@ fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> {
// a window where FILE_PICKER is None (which causes FilePickerMissing
// errors if the UI is searching concurrently).
{
let mut guard = FILE_PICKER
.write()
.with_lock_error(Error::AcquireItemLock)?;
let mut guard = FILE_PICKER.write()?;
if let Some(ref mut picker) = *guard {
// Signal cancellation BEFORE stopping — this tells any orphaned
// scan threads from this picker to discard their results.
@@ -126,11 +108,14 @@ fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> {
// Create new picker — this atomically replaces the old one via write lock
FilePicker::new_with_shared_state(
path.to_string_lossy().to_string(),
false,
FFFMode::Neovim,
Arc::clone(&FILE_PICKER),
Arc::clone(&FRECENCY),
FILE_PICKER.clone(),
FRECENCY.clone(),
fff::FilePickerOptions {
base_path: path.to_string_lossy().to_string(),
warmup_mmap_cache: true,
mode: FFFMode::Neovim,
..Default::default()
},
)?;
Ok(())
@@ -145,7 +130,7 @@ pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> {
)));
}
let canonical_path = fff_core::path_utils::canonicalize(&path).map_err(|e| {
let canonical_path = fff::path_utils::canonicalize(&path).map_err(|e| {
LuaError::RuntimeError(format!("Failed to canonicalize path '{}': {}", new_path, e))
})?;
@@ -172,10 +157,7 @@ pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> {
}
pub fn scan_files(_: &Lua, _: ()) -> LuaResult<()> {
let mut file_picker = FILE_PICKER
.write()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let mut file_picker = FILE_PICKER.write().into_lua_result()?;
let picker = file_picker
.as_mut()
.ok_or(Error::FilePickerMissing)
@@ -207,10 +189,7 @@ pub fn fuzzy_search_files(
Option<usize>,
),
) -> LuaResult<LuaValue> {
let file_picker_guard = FILE_PICKER
.read()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let file_picker_guard = FILE_PICKER.read().into_lua_result()?;
let Some(ref picker) = *file_picker_guard else {
return Err(error::to_lua_error(Error::FilePickerMissing));
};
@@ -218,26 +197,13 @@ pub fn fuzzy_search_files(
let base_path = picker.base_path();
let min_combo_count = min_combo_count.unwrap_or(3);
let last_same_query_entry = {
let query_tracker = QUERY_TRACKER
.read()
.with_lock_error(Error::AcquireFrecencyLock)
.into_lua_result()?;
let query_tracker_guard = QUERY_TRACKER.read().into_lua_result()?;
if query_tracker.as_ref().is_none() {
tracing::warn!("Query tracker not initialized");
}
query_tracker
.as_ref()
.map(|tracker| tracker.get_last_query_entry(&query, base_path, min_combo_count))
.transpose()
.into_lua_result()?
.flatten()
};
if query_tracker_guard.as_ref().is_none() {
tracing::warn!("Query tracker not initialized");
}
tracing::debug!(
?last_same_query_entry,
?base_path,
?query,
?min_combo_count,
@@ -246,19 +212,18 @@ pub fn fuzzy_search_files(
"Fuzzy search parameters"
);
// Parse the query once at the API boundary
let parser = QueryParser::default();
let parser = QueryParser::new(FileSearchConfig);
let parsed = parser.parse(&query);
let files = picker.get_files();
let results = FilePicker::fuzzy_search(
files,
&parsed,
query_tracker_guard.as_ref(),
FuzzySearchOptions {
max_threads,
current_file: current_file.as_deref(),
project_path: Some(picker.base_path()),
last_same_query_match: last_same_query_entry.as_ref(),
combo_boost_score_multiplier,
min_combo_count,
pagination: PaginationArgs {
@@ -276,7 +241,7 @@ pub fn fuzzy_search_files(
let path = expand_tilde(pure_query);
if path.is_absolute() && path.is_file() {
if let Ok(idx) = files.binary_search_by(|f| f.path.as_path().cmp(&path)) {
if let Ok(idx) = files.binary_search_by(|f| f.as_path().cmp(&path)) {
let found = SearchResult {
items: vec![&files[idx]],
scores: vec![Score {
@@ -311,6 +276,7 @@ pub fn live_grep(
smart_case,
grep_mode,
time_budget_ms,
trim_whitespace,
): (
String,
Option<usize>,
@@ -320,25 +286,22 @@ pub fn live_grep(
Option<bool>,
Option<String>,
Option<u64>,
Option<bool>,
),
) -> LuaResult<LuaValue> {
let file_picker_guard = FILE_PICKER
.read()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let file_picker_guard = FILE_PICKER.read().into_lua_result()?;
let Some(ref picker) = *file_picker_guard else {
return Err(error::to_lua_error(Error::FilePickerMissing));
};
let parsed = fff_core::grep::parse_grep_query(&query);
let parsed = fff::grep::parse_grep_query(&query);
let mode = match grep_mode.as_deref() {
Some("regex") => fff_core::GrepMode::Regex,
Some("fuzzy") => fff_core::GrepMode::Fuzzy,
_ => fff_core::GrepMode::PlainText, // "plain" or nil or unknown
Some("regex") => fff::GrepMode::Regex,
Some("fuzzy") => fff::GrepMode::Fuzzy,
_ => fff::GrepMode::PlainText, // "plain" or nil or unknown
};
let options = fff_core::GrepSearchOptions {
let options = fff::GrepSearchOptions {
max_file_size: max_file_size.unwrap_or(10 * 1024 * 1024),
max_matches_per_file: max_matches_per_file.unwrap_or(200),
smart_case: smart_case.unwrap_or(true),
@@ -349,10 +312,10 @@ pub fn live_grep(
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: trim_whitespace.unwrap_or(false),
};
let result = fff_core::grep::grep_search(picker.get_files(), &parsed, &options);
let result = picker.grep(&parsed, &options);
lua_types::GrepResultLua::from(result).into_lua(lua)
}
@@ -373,9 +336,9 @@ fn build_file_path_fallback(lua: &Lua, path: &Path, total_files: usize) -> LuaRe
item.set("name", name.as_str())?;
item.set("size", path.metadata().map(|m| m.len()).unwrap_or(0))?;
item.set("modified", 0u64)?;
item.set("access_frecency_score", 0i64)?;
item.set("modification_frecency_score", 0i64)?;
item.set("total_frecency_score", 0i64)?;
item.set("access_frecency_score", 0i32)?;
item.set("modification_frecency_score", 0i32)?;
item.set("total_frecency_score", 0i32)?;
item.set("git_status", "")?;
item.set("is_binary", false)?;
@@ -411,10 +374,7 @@ pub fn track_access(_: &Lua, file_path: String) -> LuaResult<bool> {
// Track access in frecency DB (expensive LMDB write, ~100-200ms)
// Do this WITHOUT holding FILE_PICKER lock to avoid blocking searches
let frecency_guard = FRECENCY
.read()
.with_lock_error(Error::AcquireFrecencyLock)
.into_lua_result()?;
let frecency_guard = FRECENCY.read().into_lua_result()?;
let Some(ref frecency) = *frecency_guard else {
return Ok(false);
};
@@ -424,18 +384,12 @@ pub fn track_access(_: &Lua, file_path: String) -> LuaResult<bool> {
drop(frecency_guard);
// Quick lock to update single file's frecency score in picker
let mut file_picker = FILE_PICKER
.write()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let mut file_picker = FILE_PICKER.write().into_lua_result()?;
let Some(ref mut picker) = *file_picker else {
return Err(error::to_lua_error(Error::FilePickerMissing));
};
let frecency_guard = FRECENCY
.read()
.with_lock_error(Error::AcquireFrecencyLock)
.into_lua_result()?;
let frecency_guard = FRECENCY.read().into_lua_result()?;
let Some(ref frecency) = *frecency_guard else {
return Ok(false);
};
@@ -447,10 +401,7 @@ pub fn track_access(_: &Lua, file_path: String) -> LuaResult<bool> {
}
pub fn get_scan_progress(lua: &Lua, _: ()) -> LuaResult<LuaValue> {
let file_picker = FILE_PICKER
.read()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let file_picker = FILE_PICKER.read().into_lua_result()?;
let picker = file_picker
.as_ref()
.ok_or(Error::FilePickerMissing)
@@ -464,10 +415,7 @@ pub fn get_scan_progress(lua: &Lua, _: ()) -> LuaResult<LuaValue> {
}
pub fn is_scanning(_: &Lua, _: ()) -> LuaResult<bool> {
let file_picker = FILE_PICKER
.read()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let file_picker = FILE_PICKER.read().into_lua_result()?;
let picker = file_picker
.as_ref()
.ok_or(Error::FilePickerMissing)
@@ -476,10 +424,7 @@ pub fn is_scanning(_: &Lua, _: ()) -> LuaResult<bool> {
}
pub fn get_git_root(_: &Lua, _: ()) -> LuaResult<Option<String>> {
let file_picker = FILE_PICKER
.read()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let file_picker = FILE_PICKER.read().into_lua_result()?;
let Some(ref picker) = *file_picker else {
return Ok(None);
};
@@ -488,22 +433,16 @@ pub fn get_git_root(_: &Lua, _: ()) -> LuaResult<Option<String>> {
}
pub fn refresh_git_status(_: &Lua, _: ()) -> LuaResult<usize> {
FilePicker::refresh_git_status(&FILE_PICKER, &FRECENCY).into_lua_result()
FILE_PICKER.refresh_git_status(&FRECENCY).into_lua_result()
}
pub fn update_single_file_frecency(_: &Lua, file_path: String) -> LuaResult<bool> {
let frecency_guard = FRECENCY
.read()
.with_lock_error(Error::AcquireFrecencyLock)
.into_lua_result()?;
let frecency_guard = FRECENCY.read().into_lua_result()?;
let Some(ref frecency) = *frecency_guard else {
return Ok(false);
};
let mut file_picker = FILE_PICKER
.write()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let mut file_picker = FILE_PICKER.write().into_lua_result()?;
let Some(ref mut picker) = *file_picker else {
return Err(error::to_lua_error(Error::FilePickerMissing));
};
@@ -515,10 +454,7 @@ pub fn update_single_file_frecency(_: &Lua, file_path: String) -> LuaResult<bool
}
pub fn stop_background_monitor(_: &Lua, _: ()) -> LuaResult<bool> {
let mut file_picker = FILE_PICKER
.write()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let mut file_picker = FILE_PICKER.write().into_lua_result()?;
let Some(ref mut picker) = *file_picker else {
return Err(error::to_lua_error(Error::FilePickerMissing));
};
@@ -529,10 +465,7 @@ pub fn stop_background_monitor(_: &Lua, _: ()) -> LuaResult<bool> {
}
pub fn cleanup_file_picker(_: &Lua, _: ()) -> LuaResult<bool> {
let mut file_picker = FILE_PICKER
.write()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let mut file_picker = FILE_PICKER.write().into_lua_result()?;
if let Some(picker) = file_picker.take() {
drop(picker);
::tracing::info!("FilePicker cleanup completed");
@@ -550,10 +483,7 @@ pub fn cancel_scan(_: &Lua, _: ()) -> LuaResult<bool> {
pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) -> LuaResult<bool> {
// Get the project path before spawning thread
let project_path = {
let file_picker = FILE_PICKER
.read()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let file_picker = FILE_PICKER.read().into_lua_result()?;
let Some(ref picker) = *file_picker else {
return Ok(false);
};
@@ -561,7 +491,7 @@ pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) ->
};
// Canonicalize the file path before spawning thread
let file_path = match fff_core::path_utils::canonicalize(&file_path) {
let file_path = match fff::path_utils::canonicalize(&file_path) {
Ok(path) => path,
Err(e) => {
tracing::warn!(?file_path, error = ?e, "Failed to canonicalize file path for tracking");
@@ -570,9 +500,10 @@ pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) ->
};
// Spawn background thread to do the actual tracking (expensive DB write)
let query_tracker = Arc::clone(&QUERY_TRACKER);
let query_tracker = QUERY_TRACKER.clone();
std::thread::spawn(move || {
if let Ok(Some(tracker)) = query_tracker.write().as_deref_mut()
if let Ok(mut guard) = query_tracker.write()
&& let Some(tracker) = guard.as_mut()
&& let Err(e) = tracker.track_query_completion(&query, &project_path, &file_path)
{
tracing::error!(
@@ -589,20 +520,14 @@ pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) ->
pub fn get_historical_query(_: &Lua, offset: usize) -> LuaResult<Option<String>> {
let project_path = {
let file_picker = FILE_PICKER
.read()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let file_picker = FILE_PICKER.read().into_lua_result()?;
let Some(ref picker) = *file_picker else {
return Ok(None);
};
picker.base_path().to_path_buf()
};
let query_tracker = QUERY_TRACKER
.read()
.with_lock_error(Error::AcquireFrecencyLock)
.into_lua_result()?;
let query_tracker = QUERY_TRACKER.read().into_lua_result()?;
let Some(ref tracker) = *query_tracker else {
return Ok(None);
};
@@ -614,19 +539,17 @@ pub fn get_historical_query(_: &Lua, offset: usize) -> LuaResult<Option<String>>
pub fn track_grep_query(_: &Lua, query: String) -> LuaResult<bool> {
let project_path = {
let file_picker = FILE_PICKER
.read()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let file_picker = FILE_PICKER.read().into_lua_result()?;
let Some(ref picker) = *file_picker else {
return Ok(false);
};
picker.base_path().to_path_buf()
};
let query_tracker = Arc::clone(&QUERY_TRACKER);
let query_tracker = QUERY_TRACKER.clone();
std::thread::spawn(move || {
if let Ok(Some(tracker)) = query_tracker.write().as_deref_mut()
if let Ok(mut guard) = query_tracker.write()
&& let Some(ref mut tracker) = *guard
&& let Err(e) = tracker.track_grep_query(&query, &project_path)
{
tracing::error!(
@@ -642,20 +565,14 @@ pub fn track_grep_query(_: &Lua, query: String) -> LuaResult<bool> {
pub fn get_historical_grep_query(_: &Lua, offset: usize) -> LuaResult<Option<String>> {
let project_path = {
let file_picker = FILE_PICKER
.read()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let file_picker = FILE_PICKER.read().into_lua_result()?;
let Some(ref picker) = *file_picker else {
return Ok(None);
};
picker.base_path().to_path_buf()
};
let query_tracker = QUERY_TRACKER
.read()
.with_lock_error(Error::AcquireFrecencyLock)
.into_lua_result()?;
let query_tracker = QUERY_TRACKER.read().into_lua_result()?;
let Some(ref tracker) = *query_tracker else {
return Ok(None);
};
@@ -665,16 +582,25 @@ pub fn get_historical_grep_query(_: &Lua, offset: usize) -> LuaResult<Option<Str
.into_lua_result()
}
/// Parse a grep query string and return its text portion (with constraints stripped).
///
/// Uses the Rust `GrepConfig` parser as the single source of truth, so Lua
/// code never needs to re-implement constraint detection.
pub fn parse_grep_query(lua: &Lua, query: String) -> LuaResult<LuaTable> {
let parser = QueryParser::new(GrepConfig);
let parsed = parser.parse(&query);
let table = lua.create_table()?;
table.set("grep_text", parsed.grep_text())?;
Ok(table)
}
pub fn wait_for_initial_scan(_: &Lua, timeout_ms: Option<u64>) -> LuaResult<bool> {
// Extract the scan signal Arc WITHOUT holding the read lock, so the
// scan thread can acquire the write lock to store its results.
// Holding a read lock while polling would deadlock: the scan thread
// needs a write lock to finish, but can't acquire it while we hold the read lock.
let scan_signal = {
let file_picker = FILE_PICKER
.read()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let file_picker = FILE_PICKER.read().into_lua_result()?;
let picker = file_picker
.as_ref()
.ok_or(Error::FilePickerMissing)
@@ -908,6 +834,7 @@ fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
exports.set("health_check", lua.create_function(health_check)?)?;
exports.set("shorten_path", lua.create_function(shorten_path)?)?;
exports.set("hex_dump", lua.create_function(hex_dump::hex_dump)?)?;
exports.set("parse_grep_query", lua.create_function(parse_grep_query)?)?;
Ok(exports)
}
+1 -1
View File
@@ -1,3 +1,3 @@
//! Logging setup for fff-nvim — delegates to the shared fff-core::log utilities.
pub use fff_core::log::{init_tracing, install_panic_hook};
pub use fff::log::{init_tracing, install_panic_hook};
+13 -12
View File
@@ -1,5 +1,5 @@
use fff_core::git::format_git_status;
use fff_core::{FileItem, GrepResult, Location, Score, SearchResult};
use fff::git::format_git_status;
use fff::{FileItem, GrepResult, Location, Score, SearchResult};
use mlua::prelude::*;
pub struct SearchResultLua<'a> {
@@ -35,9 +35,9 @@ impl IntoLua for LuaPosition {
fn file_item_into_lua(item: &FileItem, lua: &Lua) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
table.set("path", item.path.to_string_lossy().to_string())?;
table.set("relative_path", item.relative_path.clone())?;
table.set("name", item.file_name.clone())?;
table.set("path", item.path_str())?;
table.set("relative_path", item.relative_path())?;
table.set("name", item.file_name())?;
table.set("size", item.size)?;
table.set("modified", item.modified)?;
table.set("access_frecency_score", item.access_frecency_score)?;
@@ -45,9 +45,9 @@ fn file_item_into_lua(item: &FileItem, lua: &Lua) -> LuaResult<LuaValue> {
"modification_frecency_score",
item.modification_frecency_score,
)?;
table.set("total_frecency_score", item.total_frecency_score)?;
table.set("total_frecency_score", item.total_frecency_score())?;
table.set("git_status", format_git_status(item.git_status))?;
table.set("is_binary", item.is_binary)?;
table.set("is_binary", item.is_binary())?;
Ok(LuaValue::Table(table))
}
@@ -61,6 +61,7 @@ fn score_into_lua(score: &Score, lua: &Lua) -> LuaResult<LuaValue> {
table.set("distance_penalty", score.distance_penalty)?;
table.set("current_file_penalty", score.current_file_penalty)?;
table.set("combo_match_boost", score.combo_match_boost)?;
table.set("path_alignment_bonus", score.path_alignment_bonus)?;
table.set("match_type", score.match_type)?;
table.set("exact_match", score.exact_match)?;
Ok(LuaValue::Table(table))
@@ -122,14 +123,14 @@ impl IntoLua for GrepResultLua<'_> {
// File metadata from the deduplicated files vec
let file = self.inner.files[m.file_index];
item.set("path", file.path.to_string_lossy().to_string())?;
item.set("relative_path", file.relative_path.as_str())?;
item.set("name", file.file_name.as_str())?;
item.set("is_binary", file.is_binary)?;
item.set("path", file.path_str())?;
item.set("relative_path", file.relative_path())?;
item.set("name", file.file_name())?;
item.set("is_binary", file.is_binary())?;
item.set("git_status", format_git_status(file.git_status))?;
item.set("size", file.size)?;
item.set("modified", file.modified)?;
item.set("total_frecency_score", file.total_frecency_score)?;
item.set("total_frecency_score", file.total_frecency_score())?;
item.set("access_frecency_score", file.access_frecency_score)?;
item.set(
"modification_frecency_score",
+4 -2
View File
@@ -1,7 +1,10 @@
[package]
name = "fff-query-parser"
version = "0.1.0"
version = "0.5.2"
edition = "2024"
description = "Query parser for fff file finder - includes specific syntax for various constraints like globs, extensions, regex etc"
license = "MIT"
authors = ["Dmitriy Kovalenko <dmtr.kovalenko@outlok.com>"]
[lib]
path = "src/lib.rs"
@@ -11,7 +14,6 @@ default = []
zlob = ["dep:zlob"]
[dependencies]
smallvec = { workspace = true }
zlob = { workspace = true, optional = true }
[dev-dependencies]
@@ -98,7 +98,7 @@ fn bench_parse_various_lengths(c: &mut Criterion) {
}
fn bench_config_comparison(c: &mut Criterion) {
let file_picker = QueryParser::new(FilePickerConfig);
let file_picker = QueryParser::new(FileSearchConfig);
let grep = QueryParser::new(GrepConfig);
let query = "src name *.rs !test";
+2 -2
View File
@@ -99,9 +99,9 @@ pub trait ParserConfig {
/// Default configuration for file picker - all features enabled
#[derive(Debug, Clone, Copy, Default)]
pub struct FilePickerConfig;
pub struct FileSearchConfig;
impl ParserConfig for FilePickerConfig {
impl ParserConfig for FileSearchConfig {
/// Detect bare filenames (`score.rs`) and path-prefixed filenames (`src/main.rs`)
/// as `FilePath` constraints so that multi-token queries like `score.rs file_picker`
/// filter by filename first, then fuzzy-match the remaining text against the path.
+2 -4
View File
@@ -1,5 +1,3 @@
use smallvec::SmallVec;
/// Constraint types that can be extracted from a query
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Constraint<'a> {
@@ -45,5 +43,5 @@ pub enum GitStatusFilter {
Unmodified,
}
/// Stack-allocated buffer for text parts (up to 16 parts without heap allocation)
pub(crate) type TextPartsBuffer<'a> = SmallVec<[&'a str; 16]>;
/// Buffer for text parts during query parsing.
pub(crate) type TextPartsBuffer<'a> = Vec<&'a str>;
+5 -11
View File
@@ -1,8 +1,7 @@
//! Fast, zero-allocation query parser for file search
//! Fast query parser for file search
//!
//! This parser takes a search query and extracts structured constraints
//! while preserving text for fuzzy matching. Designed for maximum performance:
//! - Zero allocations for queries with ≤8 constraints (SmallVec)
//! - Single-pass parsing with minimal branching
//! - Stack-allocated string buffers
//!
@@ -46,16 +45,12 @@ pub mod glob_detect;
pub mod location;
mod parser;
pub use config::{AiGrepConfig, FilePickerConfig, GrepConfig, ParserConfig};
pub use config::{AiGrepConfig, FileSearchConfig, GrepConfig, ParserConfig};
pub use constraints::{Constraint, GitStatusFilter};
pub use location::Location;
pub use parser::{FFFQuery, FuzzyQuery, QueryParser};
// Re-export SmallVec for convenience
pub use smallvec::SmallVec;
/// Type alias for constraint vector - stack-allocated for ≤8 constraints
pub type ConstraintVec<'a> = SmallVec<[Constraint<'a>; 8]>;
pub type ConstraintVec<'a> = Vec<Constraint<'a>>;
#[cfg(test)]
mod tests {
@@ -213,11 +208,10 @@ mod tests {
}
#[test]
fn test_no_heap_allocation_for_small_queries() {
fn test_small_constraint_count() {
let parser = QueryParser::default();
let result = parser.parse("*.rs *.toml !test");
// SmallVec should not have spilled to heap
assert!(!result.constraints.spilled());
assert_eq!(result.constraints.len(), 3);
}
#[test]
+23 -27
View File
@@ -208,9 +208,9 @@ fn strip_leading_backslash(token: &str) -> &str {
token
}
impl Default for QueryParser<crate::FilePickerConfig> {
impl Default for QueryParser<crate::FileSearchConfig> {
fn default() -> Self {
Self::new(crate::FilePickerConfig)
Self::new(crate::FileSearchConfig)
}
}
@@ -437,7 +437,7 @@ fn parse_git_status(value: &str) -> Option<Constraint<'_>> {
#[cfg(test)]
mod tests {
use super::*;
use crate::{FilePickerConfig, GrepConfig};
use crate::{FileSearchConfig, GrepConfig};
#[test]
fn test_parse_extension() {
@@ -452,7 +452,7 @@ mod tests {
#[test]
fn test_incomplete_patterns_ignored() {
let config = FilePickerConfig;
let config = FileSearchConfig;
// Incomplete patterns should return None and be treated as noise
assert_eq!(parse_token("*", &config), None);
assert_eq!(parse_token("*.", &config), None);
@@ -496,7 +496,7 @@ mod tests {
#[test]
fn test_trailing_slash_in_query() {
let parser = QueryParser::new(FilePickerConfig);
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("www/ test");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
@@ -532,7 +532,7 @@ mod tests {
#[test]
fn test_negation_text() {
let parser = QueryParser::new(FilePickerConfig);
let parser = QueryParser::new(FileSearchConfig);
// Need two tokens for parsing to return Some
let result = parser.parse("!test foo");
assert_eq!(result.constraints.len(), 1);
@@ -546,7 +546,7 @@ mod tests {
#[test]
fn test_negation_extension() {
let parser = QueryParser::new(FilePickerConfig);
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("!*.rs foo");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
@@ -559,7 +559,7 @@ mod tests {
#[test]
fn test_negation_path_segment() {
let parser = QueryParser::new(FilePickerConfig);
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("!/src/ foo");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
@@ -572,7 +572,7 @@ mod tests {
#[test]
fn test_negation_git_status() {
let parser = QueryParser::new(FilePickerConfig);
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("!status:modified foo");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
@@ -588,7 +588,7 @@ mod tests {
#[test]
fn test_backslash_escape_extension() {
let parser = QueryParser::new(FilePickerConfig);
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("\\*.rs foo");
// \*.rs should NOT be parsed as an Extension constraint
assert_eq!(result.constraints.len(), 0);
@@ -605,7 +605,7 @@ mod tests {
#[test]
fn test_backslash_escape_path_segment() {
let parser = QueryParser::new(FilePickerConfig);
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("\\/src/ foo");
assert_eq!(result.constraints.len(), 0);
match result.fuzzy_query {
@@ -619,7 +619,7 @@ mod tests {
#[test]
fn test_backslash_escape_negation() {
let parser = QueryParser::new(FilePickerConfig);
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("\\!test foo");
assert_eq!(result.constraints.len(), 0);
}
@@ -834,8 +834,6 @@ mod tests {
}
}
// ── AI grep config tests ──────────────────────────────────────────
#[test]
fn test_ai_grep_detects_file_path() {
use crate::AiGrepConfig;
@@ -1035,11 +1033,9 @@ mod tests {
);
}
// ── File picker filename constraint tests ─────────────────────────
#[test]
fn test_file_picker_bare_filename_constraint() {
let parser = QueryParser::new(FilePickerConfig);
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("score.rs file_picker");
assert_eq!(result.constraints.len(), 1);
assert!(
@@ -1052,7 +1048,7 @@ mod tests {
#[test]
fn test_file_picker_path_prefixed_filename_constraint() {
let parser = QueryParser::new(FilePickerConfig);
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("libswscale/slice.c lum_convert");
assert_eq!(result.constraints.len(), 1);
assert!(
@@ -1068,7 +1064,7 @@ mod tests {
#[test]
fn test_file_picker_single_token_filename_stays_fuzzy() {
let parser = QueryParser::new(FilePickerConfig);
let parser = QueryParser::new(FileSearchConfig);
// Single-token filename should NOT become a constraint -- it should
// return FFFQuery with Text fuzzy query so the caller uses it for fuzzy matching.
let result = parser.parse("score.rs");
@@ -1078,7 +1074,7 @@ mod tests {
#[test]
fn test_absolute_path_with_location_not_path_segment() {
let parser = QueryParser::new(FilePickerConfig);
let parser = QueryParser::new(FileSearchConfig);
// Absolute file path with :line should parse as text + location,
// NOT as a PathSegment constraint (which would eat the whole token).
let result = parser.parse("/Users/neogoose/dev/fframes/src/renderer/concatenator.rs:12");
@@ -1096,7 +1092,7 @@ mod tests {
#[test]
fn test_file_picker_filename_with_multiple_fuzzy_parts() {
let parser = QueryParser::new(FilePickerConfig);
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("main.rs src components");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
@@ -1105,13 +1101,13 @@ mod tests {
));
assert_eq!(
result.fuzzy_query,
FuzzyQuery::Parts(smallvec::smallvec!["src", "components"])
FuzzyQuery::Parts(vec!["src", "components"])
);
}
#[test]
fn test_file_picker_version_number_not_filename() {
let parser = QueryParser::new(FilePickerConfig);
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("v2.0 release");
// v2.0 extension starts with digit → not a filename constraint
assert!(
@@ -1123,7 +1119,7 @@ mod tests {
#[test]
fn test_file_picker_only_one_filepath_constraint() {
let parser = QueryParser::new(FilePickerConfig);
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("main.rs score.rs");
// Only first filename becomes a constraint; second is text
assert_eq!(result.constraints.len(), 1);
@@ -1136,7 +1132,7 @@ mod tests {
#[test]
fn test_file_picker_filename_with_extension_constraint() {
let parser = QueryParser::new(FilePickerConfig);
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("main.rs *.lua");
// main.rs → FilePath, *.lua → Extension
assert_eq!(result.constraints.len(), 2);
@@ -1152,7 +1148,7 @@ mod tests {
#[test]
fn test_file_picker_dotfile_is_filename() {
let parser = QueryParser::new(FilePickerConfig);
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse(".gitignore src");
assert_eq!(result.constraints.len(), 1);
assert!(
@@ -1165,7 +1161,7 @@ mod tests {
#[test]
fn test_file_picker_no_extension_not_filename() {
let parser = QueryParser::new(FilePickerConfig);
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("Makefile src");
// No dot → not a filename constraint
assert!(
-9
View File
@@ -1,9 +0,0 @@
[package]
name = "grep-searcher"
version = "0.1.16"
edition = "2024"
[dependencies]
bstr = { version = "1.6.2", default-features = false, features = ["std"] }
grep-matcher = { workspace = true }
memchr = "2.6.3"
-212
View File
@@ -1,212 +0,0 @@
use grep_matcher::{LineMatchKind, Matcher};
use crate::{
lines::{self, LineStep},
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> {
match self.matcher.find(slice) {
Err(err) => Err(S::Error::error_message(err)),
Ok(m) => Ok(m),
}
}
fn shortest_match(&mut self, slice: &[u8]) -> Result<Option<usize>, S::Error> {
match self.matcher.shortest_match(slice) {
Err(err) => Err(S::Error::error_message(err)),
Ok(m) => Ok(m),
}
}
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> {
if self.is_line_by_line_fast() {
self.match_by_line_fast(buf)
} else {
self.match_by_line_slow(buf)
}
}
fn match_by_line_slow(&mut self, buf: &[u8]) -> Result<bool, S::Error> {
debug_assert!(!self.searcher.multi_line_with_matcher(&self.matcher));
let range = Range::new(self.pos(), buf.len());
let mut stepper =
LineStep::new(self.config.line_term.as_byte(), range.start(), range.end());
while let Some(line) = stepper.next_match(buf) {
let matched = {
let slice = lines::without_terminator(&buf[line], self.config.line_term);
self.shortest_match(slice)?.is_some()
};
self.set_pos(line.end());
if matched && !self.sink_matched(buf, &line)? {
return Ok(false);
}
}
Ok(true)
}
fn match_by_line_fast(&mut self, buf: &[u8]) -> Result<bool, S::Error> {
while !buf[self.pos()..].is_empty() {
if let Some(line) = self.find_by_line_fast(buf)? {
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_fast(&mut self, buf: &[u8]) -> Result<Option<Range>, S::Error> {
debug_assert!(!self.searcher.multi_line_with_matcher(&self.matcher));
debug_assert!(self.is_line_by_line_fast());
let mut pos = self.pos();
while !buf[pos..].is_empty() {
match self.matcher.find_candidate_line(&buf[pos..]) {
Err(err) => return Err(S::Error::error_message(err)),
Ok(None) => return Ok(None),
Ok(Some(LineMatchKind::Confirmed(i))) => {
let line = lines::locate(
buf,
self.config.line_term.as_byte(),
Range::zero(i).offset(pos),
);
if line.start() == buf.len() {
pos = buf.len();
continue;
}
return Ok(Some(line));
}
Ok(Some(LineMatchKind::Candidate(i))) => {
let line = lines::locate(
buf,
self.config.line_term.as_byte(),
Range::zero(i).offset(pos),
);
let slice = lines::without_terminator(&buf[line], self.config.line_term);
if self
.matcher
.is_match(slice)
.map_err(S::Error::error_message)?
{
return Ok(Some(line));
}
pos = line.end();
}
}
}
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;
}
}
fn is_line_by_line_fast(&self) -> bool {
debug_assert!(!self.searcher.multi_line_with_matcher(&self.matcher));
if let Some(line_term) = self.matcher.line_terminator() {
if line_term.as_byte() == b'\x00' {
return false;
}
if line_term == self.config.line_term {
return true;
}
}
if let Some(non_matching) = self.matcher.non_matching_bytes()
&& non_matching.contains(self.config.line_term.as_byte())
{
return true;
}
false
}
}
+10 -4
View File
@@ -1,4 +1,5 @@
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 March 17
*fff.nvim.txt*
For Neovim >= 0.10.0 Last change: 2026 April 15
==============================================================================
Table of Contents *fff.nvim-table-of-contents*
@@ -6,7 +7,7 @@ Table of Contents *fff.nvim-table-of-contents*
- MCP |fff.nvim-mcp|
- Neovim guide |fff.nvim-neovim-guide|
1. Links |fff.nvim-links|
FFFAI agents (MCP)   |   Neovim usersA fast file search for your AI and neovim, with memory built-in
FFFAI agents (MCP) | Neovim usersA fast file search for your AI and neovim, with memory built-in
@@ -121,8 +122,12 @@ VIM.PACK
vim.pack.add({ 'https://github.com/dmtrKovalenko/fff.nvim' })
vim.api.nvim_create_autocmd('PackChanged', {
callback = function(event)
if event.data.updated then
callback = function(ev)
local name, kind = ev.data.spec.name, ev.data.kind
if name == 'fff.nvim' and (kind == 'install' or kind == 'update') then
if not ev.data.active then
vim.cmd.packadd('fff.nvim')
end
require('fff.download').download_or_build_binary()
end
end,
@@ -296,6 +301,7 @@ all available options:
smart_case = true, -- Case-insensitive unless query has uppercase
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
trim_whitespace = false, -- Strip leading whitespace from matched lines
},
})
<
Generated
+9 -9
View File
@@ -2,11 +2,11 @@
"nodes": {
"crane": {
"locked": {
"lastModified": 1773189535,
"narHash": "sha256-E1G/Or6MWeP+L6mpQ0iTFLpzSzlpGrITfU2220Gq47g=",
"lastModified": 1773857772,
"narHash": "sha256-5xsK26KRHf0WytBtsBnQYC/lTWDhQuT57HJ7SzuqZcM=",
"owner": "ipetkov",
"repo": "crane",
"rev": "6fa2fb4cf4a89ba49fc9dd5a3eb6cde99d388269",
"rev": "b556d7bbae5ff86e378451511873dfd07e4504cd",
"type": "github"
},
"original": {
@@ -35,11 +35,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1773597492,
"narHash": "sha256-hQ284SkIeNaeyud+LS0WVLX+WL2rxcVZLFEaK0e03zg=",
"lastModified": 1773628058,
"narHash": "sha256-hpXH0z3K9xv0fHaje136KY872VT2T5uwxtezlAskQgY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a07d4ce6bee67d7c838a8a5796e75dff9caa21ef",
"rev": "f8573b9c935cfaa162dd62cc9e75ae2db86f85df",
"type": "github"
},
"original": {
@@ -64,11 +64,11 @@
]
},
"locked": {
"lastModified": 1773716879,
"narHash": "sha256-vXCTasEzzTTd0ZGEuyle20H2hjRom66JeNr7i2ktHD0=",
"lastModified": 1773803479,
"narHash": "sha256-GD6i1F2vrSxbsmbS92+8+x3DbHOJ+yrS78Pm4xigW4M=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "1a9ddeb45c5751b800331363703641b84d1f41f0",
"rev": "f17186f52e82ec5cf40920b58eac63b78692ac7c",
"type": "github"
},
"original": {
+2
View File
@@ -54,6 +54,7 @@ local M = {}
--- @field smart_case boolean
--- @field time_budget_ms number
--- @field modes string[]
--- @field trim_whitespace boolean
--- @class FffConfig
--- @field base_path string
@@ -332,6 +333,7 @@ local function init()
smart_case = true, -- Case-insensitive unless query has uppercase
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
trim_whitespace = false, -- Strip leading whitespace from matched lines (useful for cleaner display)
},
}
+2 -2
View File
@@ -88,11 +88,11 @@ M.ensure_initialized = function()
local history_db_path = config.history.db_path or (vim.fn.stdpath('data') .. '/fff_history')
local ok, result = pcall(fuzzy.init_db, frecency_db_path, history_db_path, true)
if not ok then vim.notify('Failed to databases: ' .. result, vim.log.levels.WARN) end
if not ok then vim.notify('Failed to databases: ' .. tostring(result), vim.log.levels.WARN) end
ok, result = pcall(fuzzy.init_file_picker, config.base_path)
if not ok then
vim.notify('Failed to initialize file picker: ' .. result, vim.log.levels.ERROR)
vim.notify('Failed to initialize file picker: ' .. tostring(result), vim.log.levels.ERROR)
return fuzzy
end
+24 -15
View File
@@ -1,19 +1,10 @@
local M = {}
local system = require('fff.utils.system')
local fs_utils = require('fff.utils.fs')
local fff_version = require('fff.utils.version')
local GITHUB_REPO = 'dmtrKovalenko/fff.nvim'
local function get_current_version(plugin_dir, callback)
vim.system({ 'git', 'rev-parse', '--short', 'HEAD' }, { cwd = plugin_dir }, function(result)
if result.code ~= 0 or not result.stdout or result.stdout == '' then
callback(nil)
return
end
callback(result.stdout:gsub('%s+', ''))
end)
end
local function get_binary_dir(plugin_dir) return plugin_dir .. '/../target/release' end
local function get_binary_path(plugin_dir)
@@ -168,20 +159,38 @@ function M.ensure_downloaded(opts, callback)
return
end
local function on_version(target_version)
if not target_version then
local function on_release_tag(release_tag)
if not release_tag then
callback(false, 'Could not determine target version')
return
end
local binary_path = get_binary_path(plugin_dir)
download_from_github(target_version, binary_path, opts, callback)
download_from_github(release_tag, binary_path, opts, callback)
end
if opts.version then
on_version(opts.version)
on_release_tag(opts.version)
else
get_current_version(plugin_dir, on_version)
-- plugin_dir is <repo>/lua; parent is the repo root
local repo_root = vim.fn.fnamemodify(plugin_dir, ':h')
-- 1. Try reading the CI-created tag on HEAD (no version computation)
local tag = fff_version.current_release_tag(repo_root)
if tag then
on_release_tag(tag)
return
end
-- 2. No local tag — construct the nightly version (bumps patch so
-- the prerelease is higher than Cargo.toml base in semver)
local info, err = fff_version.resolve(repo_root)
if info then
on_release_tag(info.release_tag)
return
end
callback(false, err or 'Could not determine target version')
end
end
+6 -6
View File
@@ -24,7 +24,7 @@ function M.scan_files()
local ok, result = pcall(fuzzy.scan_files)
if not ok then
vim.notify('Failed to trigger file scan: ' .. result, vim.log.levels.ERROR)
vim.notify('Failed to trigger file scan: ' .. tostring(result), vim.log.levels.ERROR)
return
end
@@ -121,7 +121,7 @@ function M.track_access(file_path)
if not M.state.initialized then return end
local ok, result = pcall(fuzzy.track_access, file_path)
if not ok then vim.notify('Failed to record file access: ' .. result, vim.log.levels.WARN) end
if not ok then vim.notify('Failed to record file access: ' .. tostring(result), vim.log.levels.WARN) end
end
--- Get file content for preview
@@ -156,7 +156,7 @@ function M.get_scan_progress()
local ok, result = pcall(fuzzy.get_scan_progress)
if not ok then
vim.notify('Failed to get scan progress: ' .. result, vim.log.levels.WARN)
vim.notify('Failed to get scan progress: ' .. tostring(result), vim.log.levels.WARN)
return { scanned_files_count = 0, is_scanning = false }
end
@@ -170,7 +170,7 @@ function M.refresh_git_status()
local ok, result = pcall(fuzzy.refresh_git_status)
if not ok then
vim.notify('Failed to refresh git status: ' .. result, vim.log.levels.WARN)
vim.notify('Failed to refresh git status: ' .. tostring(result), vim.log.levels.WARN)
return {}
end
@@ -185,7 +185,7 @@ function M.stop_background_monitor()
local ok, result = pcall(fuzzy.stop_background_monitor)
if not ok then
vim.notify('Failed to stop background monitor: ' .. result, vim.log.levels.WARN)
vim.notify('Failed to stop background monitor: ' .. tostring(result), vim.log.levels.WARN)
return false
end
return result
@@ -199,7 +199,7 @@ function M.wait_for_initial_scan(timeout_ms)
local ok, result = pcall(fuzzy.wait_for_initial_scan, timeout_ms)
if not ok then
vim.notify('Failed to wait for initial scan: ' .. result, vim.log.levels.WARN)
vim.notify('Failed to wait for initial scan: ' .. tostring(result), vim.log.levels.WARN)
return false
end
return result
+26 -7
View File
@@ -6,6 +6,28 @@ local rust = require('fff.rust')
local M = {}
-- Preview buffers are scratch buffers. Detect the file's language and attach
-- highlighting directly, but keep buffer filetype empty to avoid ftplugin and
-- LSP side effects that are meant for real editing buffers.
local function attach_preview_highlighter(bufnr, filetype)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
pcall(vim.treesitter.stop, bufnr)
vim.api.nvim_set_option_value('filetype', '', { buf = bufnr })
vim.api.nvim_set_option_value('syntax', '', { buf = bufnr })
if not filetype or filetype == '' then return end
local lang_ok, lang = pcall(vim.treesitter.language.get_lang, filetype)
if not lang_ok or not lang then lang = filetype end
if pcall(vim.treesitter.language.add, lang) then
pcall(vim.treesitter.start, bufnr, lang)
else
vim.api.nvim_set_option_value('syntax', filetype, { buf = bufnr })
end
end
local function set_buffer_lines(bufnr, lines)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
@@ -274,7 +296,7 @@ local function link_buffer_content(source_bufnr, target_bufnr)
set_buffer_lines(target_bufnr, lines)
local source_ft = vim.api.nvim_get_option_value('filetype', { buf = source_bufnr })
if source_ft ~= '' then vim.api.nvim_set_option_value('filetype', source_ft, { buf = target_bufnr }) end
if source_ft ~= '' then attach_preview_highlighter(target_bufnr, source_ft) end
M.state.has_more_content = false
M.state.total_file_lines = #lines
@@ -532,7 +554,7 @@ function M.preview_file(file_path, bufnr)
set_buffer_lines(bufnr, content)
local file_config = M.get_file_config(file_path)
vim.api.nvim_set_option_value('filetype', info.filetype, { buf = bufnr })
attach_preview_highlighter(bufnr, info.filetype)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
@@ -651,7 +673,7 @@ function M.preview_binary_file(file_path, bufnr)
end
set_buffer_lines(bufnr, lines)
vim.api.nvim_set_option_value('filetype', 'text', { buf = bufnr })
attach_preview_highlighter(bufnr, 'text')
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
@@ -838,11 +860,8 @@ function M.clear_buffer(bufnr)
cleanup_file_operation()
M.clear_preview_visual_state(bufnr)
pcall(vim.treesitter.stop, bufnr)
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_set_option_value('filetype', '', { buf = bufnr })
vim.api.nvim_set_option_value('syntax', '', { buf = bufnr })
attach_preview_highlighter(bufnr, '')
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
set_buffer_lines(bufnr, {})
+22 -6
View File
@@ -182,11 +182,27 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
if is_cursor then
local base_hl = git_utils.get_border_highlight_selected(item.git_status)
if base_hl and base_hl ~= '' then
local border_fg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(base_hl)), 'fg')
local cursor_bg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(ctx.config.hl.cursor)), 'bg')
local temp_hl_name = 'FFFGitBorderSelected_' .. item_idx
if border_fg ~= '' and cursor_bg ~= '' then
vim.api.nvim_set_hl(0, temp_hl_name, { fg = border_fg, bg = cursor_bg })
local base_id = vim.fn.synIDtrans(vim.fn.hlID(base_hl))
local cursor_id = vim.fn.synIDtrans(vim.fn.hlID(ctx.config.hl.cursor))
local border_fg_gui = vim.fn.synIDattr(base_id, 'fg', 'gui')
local border_fg_cterm = vim.fn.synIDattr(base_id, 'fg', 'cterm')
local cursor_bg_gui = vim.fn.synIDattr(cursor_id, 'bg', 'gui')
local cursor_bg_cterm = vim.fn.synIDattr(cursor_id, 'bg', 'cterm')
local has_gui = border_fg_gui ~= '' and cursor_bg_gui ~= ''
local has_cterm = border_fg_cterm ~= '' and cursor_bg_cterm ~= ''
if has_gui or has_cterm then
local temp_hl_name = 'FFFGitBorderSelected_' .. item_idx
local hl_opts = {}
if has_gui then
hl_opts.fg = border_fg_gui
hl_opts.bg = cursor_bg_gui
end
if has_cterm then
hl_opts.ctermfg = tonumber(border_fg_cterm)
hl_opts.ctermbg = tonumber(cursor_bg_cterm)
end
vim.api.nvim_set_hl(0, temp_hl_name, hl_opts)
border_hl = temp_hl_name
else
border_hl = git_utils.get_border_highlight_selected(item.git_status)
@@ -225,7 +241,7 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
-- 9. Query match
if ctx.query and ctx.query ~= '' then
local match_start, match_end = string.find(line_content, ctx.query, 1)
local match_start, match_end = string.find(line_content, ctx.query, 1, true)
if match_start and match_end then
vim.api.nvim_buf_set_extmark(
buf,
+2 -1
View File
@@ -7,7 +7,7 @@ if not ok then error('Failed to load fff.rust module: ' .. rust_module) end
-- export all functions from the Rust module
M.init_db = rust_module.init_db
M.destroy_db = rust_module.destroy_db
M.destroy_frecency_db = rust_module.destroy_frecency_db
M.access = rust_module.access
M.set_provider_items = rust_module.set_provider_items
M.fuzzy = rust_module.fuzzy
@@ -46,6 +46,7 @@ M.get_git_root = rust_module.get_git_root
-- Grep functions
M.live_grep = rust_module.live_grep
M.parse_grep_query = rust_module.parse_grep_query
-- Utility functions
M.health_check = rust_module.health_check
+2 -1
View File
@@ -33,7 +33,8 @@ function M.search(query, file_offset, page_size, config, grep_mode)
conf.max_matches_per_file,
conf.smart_case,
grep_mode or 'plain',
conf.time_budget_ms
conf.time_budget_ms,
conf.trim_whitespace
)
return last_result
end
+7 -10
View File
@@ -178,16 +178,13 @@ function M.highlight_grep_matches(bufnr, location, namespace)
local query = location.grep_query
-- Extract the actual search text from the grep query (strip file constraints like *.rs /src/)
-- The query parser uses space-separated tokens; the first non-constraint token is the pattern.
-- Simple heuristic: strip tokens that look like constraints (start with *, /, or !)
local search_text = query
local parts = vim.split(query, '%s+')
local text_parts = {}
for _, part in ipairs(parts) do
if part ~= '' and not part:match('^[%*!/]') and not part:match('^%.') then table.insert(text_parts, part) end
end
if #text_parts > 0 then search_text = text_parts[1] end
-- Use the Rust GrepConfig parser as the single source of truth for
-- stripping constraint tokens. This avoids duplicating constraint
-- detection in Lua, which would break whenever a new token type is added.
local fuzzy = require('fff.fuzzy')
local parsed = fuzzy.parse_grep_query(query)
local search_text = parsed.grep_text
if search_text == '' then search_text = query end
if not search_text or search_text == '' then return nil end
+30 -1
View File
@@ -36,7 +36,6 @@ function M.live_grep(opts)
mode = 'grep',
renderer = grep_renderer,
grep_config = grep_config,
query = '',
}, opts or {})
picker_ui.open(picker_opts)
@@ -56,6 +55,36 @@ function M.find_in_git_root()
M.find_files_in_dir(git_root)
end
--- Clear FFF caches (both in-memory state and on-disk database files)
--- @param scope? string Cache scope: all|frecency|files
function M.clear_cache(scope)
local fuzzy = require('fff.fuzzy')
if not scope or scope == '' then scope = 'all' end
local errors = {}
if scope == 'all' or scope == 'files' then
local ok, err = pcall(fuzzy.cleanup_file_picker)
if not ok then table.insert(errors, 'cleanup file picker: ' .. tostring(err)) end
end
if scope == 'all' or scope == 'frecency' then
local ok, err = pcall(fuzzy.destroy_frecency_db)
if not ok then table.insert(errors, 'destroy frecency db: ' .. tostring(err)) end
ok, err = pcall(fuzzy.destroy_query_db)
if not ok then table.insert(errors, 'destroy query db: ' .. tostring(err)) end
end
if #errors > 0 then
vim.notify('FFF: errors clearing cache: ' .. table.concat(errors, '; '), vim.log.levels.ERROR)
return false
end
vim.notify('Cleared FFF cache: ' .. scope, vim.log.levels.INFO)
return true
end
--- Trigger rescan of files in the current directory
function M.scan_files()
local fuzzy = require('fff.core').ensure_initialized()
+21 -1
View File
@@ -442,6 +442,16 @@ end
local preview_config = conf.get().preview
if preview_config then preview.setup(preview_config) end
local function suspend_paste()
if not vim.o.paste then return false end
vim.o.paste = false
return true
end
local function restore_paste(should_restore)
if should_restore then vim.o.paste = true end
end
M.state = {
active = false,
layout = nil,
@@ -491,6 +501,7 @@ M.state = {
ns_id = nil,
last_status_info = nil,
restore_paste = false,
last_preview_file = nil,
last_preview_location = nil, -- Track last preview location to detect changes
@@ -528,6 +539,9 @@ function M.create_ui()
local config = M.state.config
if not config then return false end
-- Prompt editing should behave consistently even if the user has :set paste.
M.state.restore_paste = suspend_paste()
if not M.state.ns_id then
M.state.ns_id = vim.api.nvim_create_namespace('fff_picker_status')
combo_renderer.init(M.state.ns_id)
@@ -1338,8 +1352,11 @@ local function shrink_path(path, max_width)
end
local function format_file_display(item, max_width)
-- vim.json.decode may return Blobs for strings with NUL bytes; coerce to string.
local filename = item.name
if type(filename) ~= 'string' then filename = filename and tostring(filename) or '' end
local dir_path = item.directory or ''
if type(dir_path) ~= 'string' then dir_path = dir_path and tostring(dir_path) or '' end
if dir_path == '' and item.relative_path then
local parent_dir = vim.fn.fnamemodify(item.relative_path, ':h')
@@ -2268,7 +2285,7 @@ function M.send_to_quickfix()
local is_grep = M.state.mode == 'grep'
M.close()
vim.fn.setqflist(qf_list, 'r')
vim.fn.setqflist(qf_list)
vim.cmd('copen')
local count = #qf_list
@@ -2408,6 +2425,8 @@ function M.close()
vim.cmd('stopinsert')
M.state.active = false
restore_paste(M.state.restore_paste)
combo_renderer.cleanup()
scrollbar.cleanup()
@@ -2475,6 +2494,7 @@ function M.close()
M.state.grep_regex_fallback_error = nil
M.state.suggestion_items = nil
M.state.suggestion_source = nil
M.state.restore_paste = false
M.state.combo_visible = true
M.state.combo_initial_cursor = nil
M.reset_history_state()
+146
View File
@@ -0,0 +1,146 @@
local M = {}
local is_windows = (package.config:sub(1, 1) == '\\')
--- Shell-quote a string for safe interpolation into a command.
---@param s string
---@return string
local function shell_quote(s)
if is_windows then return '"' .. s:gsub('"', '\\"') .. '"' end
return "'" .. s:gsub("'", "'\\''") .. "'"
end
--- Run a git command in the given repository and return trimmed stdout.
---@param repo_root string
---@return string|nil output trimmed stdout, or nil on failure
local function git(repo_root, ...)
local parts = { 'git', '-C', shell_quote(repo_root) }
for i = 1, select('#', ...) do
parts[#parts + 1] = shell_quote(select(i, ...))
end
local redirect = is_windows and ' 2>NUL' or ' 2>/dev/null'
local handle = io.popen(table.concat(parts, ' ') .. redirect)
if not handle then return nil end
local output = handle:read('*a')
handle:close()
if not output or output:match('^%s*$') then return nil end
return output:gsub('%s+$', '')
end
function M.current_release_tag(repo_root)
local raw = git(repo_root, 'tag', '--points-at', 'HEAD')
if not raw then return nil end
local stable, nightly, dev, other
for tag in raw:gmatch('[^\n]+') do
if tag:match('^v%d') then
stable = tag
elseif tag:match('%-nightly%.') then
nightly = tag
elseif tag:match('%-dev%.') then
dev = tag
else
other = tag
end
end
return stable or nightly or dev or other
end
function M.read_base_version(repo_root)
local cargo_path = repo_root .. '/crates/fff-core/Cargo.toml'
local f = io.open(cargo_path, 'r')
if not f then return nil end
for line in f:lines() do
local ver = line:match('^version%s*=%s*"([^"]+)"')
if ver then
f:close()
return ver
end
end
f:close()
return nil
end
---@class FFFVersionInfo
---@field version string semver version (e.g. "0.4.0" or "0.4.1-nightly.abc1234")
---@field release_tag string GitHub release tag for download URLs
---@field is_release boolean true for tagged stable releases
---@field npm_tag string "latest"|"nightly"|"dev"
--- Bump the patch component of a semver string.
--- "1.2.3" → "1.2.4"
---@param version string
---@return string|nil bumped version, or nil if parsing fails
local function bump_patch(version)
local major, minor, patch = version:match('^(%d+)%.(%d+)%.(%d+)')
if not major then return nil end
return string.format('%s.%s.%d', major, minor, tonumber(patch) + 1)
end
--- Compute the version for a new release based on git state.
--- Used by CI to determine what tag to create — NOT for downloads.
---
--- For prerelease versions the patch is bumped so that the result is
--- higher than the current Cargo.toml version in semver ordering.
--- This is required for `cargo set-version` / crates.io publishing
--- (0.4.1-nightly.x > 0.4.0, whereas 0.4.0-nightly.x < 0.4.0).
---
--- tagged release (v*) → version from tag, npm_tag = "latest"
--- main branch → {base+1}-nightly.{sha}, npm_tag = "nightly"
--- detached HEAD → {base+1}-nightly.{sha}, npm_tag = "nightly"
--- other branch (PR / feature) → {base+1}-dev.{sha}, npm_tag = "dev"
---
---@param repo_root string absolute path to the repository root
---@return FFFVersionInfo|nil info
---@return string|nil err
function M.resolve(repo_root)
local tag = git(repo_root, 'describe', '--exact-match', '--tags', '--match', 'v*', 'HEAD')
if tag and tag:match('^v%d') then
return {
version = tag:sub(2),
release_tag = tag,
is_release = true,
npm_tag = 'latest',
}
end
local short_sha = git(repo_root, 'rev-parse', '--short', 'HEAD')
if not short_sha then return nil, 'Failed to determine git SHA' end
local base_version = M.read_base_version(repo_root)
if not base_version then return nil, 'Could not read base version from crates/fff-core/Cargo.toml' end
local next_version = bump_patch(base_version)
if not next_version then return nil, 'Could not parse base version: ' .. base_version end
local branch = git(repo_root, 'symbolic-ref', '--short', 'HEAD')
local prerelease_label, npm_tag
if not branch then
prerelease_label = 'nightly'
npm_tag = 'nightly'
elseif branch == 'main' or branch == 'fix/download-version' then
prerelease_label = 'nightly'
npm_tag = 'nightly'
else
prerelease_label = 'dev'
npm_tag = 'dev'
end
local version = string.format('%s-%s.%s', next_version, prerelease_label, short_sha)
return {
version = version,
release_tag = version,
is_release = false,
npm_tag = npm_tag,
}
end
return M
+709
View File
@@ -0,0 +1,709 @@
{
"name": "fff.nvim",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"workspaces": [
"packages/fff-bun",
"packages/fff-node"
],
"devDependencies": {
"@biomejs/biome": "^2.4.4"
}
},
"node_modules/@biomejs/biome": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.7.tgz",
"integrity": "sha512-vXrgcmNGZ4lpdwZSpMf1hWw1aWS6B+SyeSYKTLrNsiUsAdSRN0J4d/7mF3ogJFbIwFFSOL3wT92Zzxia/d5/ng==",
"dev": true,
"license": "MIT OR Apache-2.0",
"bin": {
"biome": "bin/biome"
},
"engines": {
"node": ">=14.21.3"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
"@biomejs/cli-darwin-arm64": "2.4.7",
"@biomejs/cli-darwin-x64": "2.4.7",
"@biomejs/cli-linux-arm64": "2.4.7",
"@biomejs/cli-linux-arm64-musl": "2.4.7",
"@biomejs/cli-linux-x64": "2.4.7",
"@biomejs/cli-linux-x64-musl": "2.4.7",
"@biomejs/cli-win32-arm64": "2.4.7",
"@biomejs/cli-win32-x64": "2.4.7"
}
},
"node_modules/@biomejs/cli-darwin-arm64": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.7.tgz",
"integrity": "sha512-Oo0cF5mHzmvDmTXw8XSjhCia8K6YrZnk7aCS54+/HxyMdZMruMO3nfpDsrlar/EQWe41r1qrwKiCa2QDYHDzWA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-darwin-x64": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.7.tgz",
"integrity": "sha512-I+cOG3sd/7HdFtvDSnF9QQPrWguUH7zrkIMMykM3PtfWU9soTcS2yRb9Myq6MHmzbeCT08D1UmY+BaiMl5CcoQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-arm64": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.7.tgz",
"integrity": "sha512-om6FugwmibzfP/6ALj5WRDVSND4H2G9X0nkI1HZpp2ySf9lW2j0X68oQSaHEnls6666oy4KDsc5RFjT4m0kV0w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.7.tgz",
"integrity": "sha512-I2NvM9KPb09jWml93O2/5WMfNR7Lee5Latag1JThDRMURVhPX74p9UDnyTw3Ae6cE1DgXfw7sqQgX7rkvpc0vw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-x64": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.7.tgz",
"integrity": "sha512-bV8/uo2Tj+gumnk4sUdkerWyCPRabaZdv88IpbmDWARQQoA/Q0YaqPz1a+LSEDIL7OfrnPi9Hq1Llz4ZIGyIQQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.7.tgz",
"integrity": "sha512-00kx4YrBMU8374zd2wHuRV5wseh0rom5HqRND+vDldJPrWwQw+mzd/d8byI9hPx926CG+vWzq6AeiT7Yi5y59g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-win32-arm64": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.7.tgz",
"integrity": "sha512-hOUHBMlFCvDhu3WCq6vaBoG0dp0LkWxSEnEEsxxXvOa9TfT6ZBnbh72A/xBM7CBYB7WgwqboetzFEVDnMxelyw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-win32-x64": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.7.tgz",
"integrity": "sha512-qEpGjSkPC3qX4ycbMUthXvi9CkRq7kZpkqMY1OyhmYlYLnANnooDQ7hDerM8+0NJ+DZKVnsIc07h30XOpt7LtQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@ff-labs/fff-bun": {
"resolved": "packages/fff-bun",
"link": true
},
"node_modules/@ff-labs/fff-node": {
"resolved": "packages/fff-node",
"link": true
},
"node_modules/@oven/bun-darwin-aarch64": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.10.tgz",
"integrity": "sha512-PXgg5gqcS/rHwa1hF0JdM1y5TiyejVrMHoBmWY/DjtfYZoFTXie1RCFOkoG0b5diOOmUcuYarMpH7CSNTqwj+w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true
},
"node_modules/@oven/bun-darwin-x64": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.10.tgz",
"integrity": "sha512-Nhssuh7GBpP5PiDSOl3+qnoIG7PJo+ec2oomDevnl9pRY6x6aD2gRt0JE+uf+A8Om2D6gjeHCxjEdrw5ZHE8mA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true
},
"node_modules/@oven/bun-darwin-x64-baseline": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.10.tgz",
"integrity": "sha512-w1gaTlqU0IJCmJ1X+PGHkdNU1n8Gemx5YKkjhkJIguvFINXEBB5U1KG82QsT65Tk4KyNMfbLTlmy4giAvUoKfA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true
},
"node_modules/@oven/bun-linux-aarch64": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.10.tgz",
"integrity": "sha512-OUgPHfL6+PM2Q+tFZjcaycN3D7gdQdYlWnwMI31DXZKY1r4HINWk9aEz9t/rNaHg65edwNrt7dsv9TF7xK8xIA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@oven/bun-linux-aarch64-musl": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.10.tgz",
"integrity": "sha512-Ui5pAgM7JE9MzHokF0VglRMkbak3lTisY4Mf1AZutPACXWgKJC5aGrgnHBfkl7QS6fEeYb0juy1q4eRznRHOsw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@oven/bun-linux-x64": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.10.tgz",
"integrity": "sha512-bzUgYj/PIZziB/ZesIP9HUyfvh6Vlf3od+TrbTTyVEuCSMKzDPQVW/yEbRp0tcHO3alwiEXwJDrWrHAguXlgiQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@oven/bun-linux-x64-baseline": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.10.tgz",
"integrity": "sha512-oqvMDYpX6dGJO03HgO5bXuccEsH3qbdO3MaAiAlO4CfkBPLUXz3N0DDElg5hz0L6ktdDVKbQVE5lfe+LAUISQg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@oven/bun-linux-x64-musl": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.10.tgz",
"integrity": "sha512-poVXvOShekbexHq45b4MH/mRjQKwACAC8lHp3Tz/hEDuz0/20oncqScnmKwzhBPEpqJvydXficXfBYuSim8opw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@oven/bun-linux-x64-musl-baseline": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.10.tgz",
"integrity": "sha512-/hOZ6S1VsTX6vtbhWVL9aAnOrdpuO54mAGUWpTdMz7dFG5UBZ/VUEiK0pBkq9A1rlBk0GeD/6Y4NBFl8Ha7cRA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@oven/bun-windows-aarch64": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.3.10.tgz",
"integrity": "sha512-GXbz2swvN2DLw2dXZFeedMxSJtI64xQ9xp9Eg7Hjejg6mS2E4dP1xoQ2yAo2aZPi/2OBPAVaGzppI2q20XumHA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true
},
"node_modules/@oven/bun-windows-x64": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.10.tgz",
"integrity": "sha512-qaS1In3yfC/Z/IGQriVmF8GWwKuNqiw7feTSJWaQhH5IbL6ENR+4wGNPniZSJFaM/SKUO0e/YCRdoVBvgU4C1g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true
},
"node_modules/@oven/bun-windows-x64-baseline": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.10.tgz",
"integrity": "sha512-gh3UAHbUdDUG6fhLc1Csa4IGdtghue6U8oAIXWnUqawp6lwb3gOCRvp25IUnLF5vUHtgfMxuEUYV7YA2WxVutw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true
},
"node_modules/@types/bun": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.10.tgz",
"integrity": "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"bun-types": "1.3.10"
}
},
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@yuuang/ffi-rs-android-arm64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-android-arm64/-/ffi-rs-android-arm64-1.3.1.tgz",
"integrity": "sha512-V4nmlXdOYZEa7GOxSExVG95SLp8FE0iTq2yKeN54UlfNMr3Sik+1Ff57LcCv7qYcn4TBqnBAt5rT3FAM6T6caQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-darwin-arm64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-darwin-arm64/-/ffi-rs-darwin-arm64-1.3.1.tgz",
"integrity": "sha512-YlnTMIyzfW3mAULC5ZA774nzQfFlYXM0rrfq/8ZzWt+IMbYk55a++jrI+6JeKV+1EqlDS3TFBEFtjdBNG94KzQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-darwin-x64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-darwin-x64/-/ffi-rs-darwin-x64-1.3.1.tgz",
"integrity": "sha512-sI3LpQQ34SX4nyOHc5yxA7FSqs9qPEUMqW/y/wWo9cuyPpaHMFsi/BeOVYsnC0syp3FrY7gzn6RnD6PlXCktXg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-arm-gnueabihf": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm-gnueabihf/-/ffi-rs-linux-arm-gnueabihf-1.3.1.tgz",
"integrity": "sha512-1WkcGkJTlwh4ZA59htKI+RXhiL3oKiYwLv7PO8LUf6FuADK73s5GcXp67iakKu243uYu+qGYr4RHco4ySddYhQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-arm64-gnu": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm64-gnu/-/ffi-rs-linux-arm64-gnu-1.3.1.tgz",
"integrity": "sha512-J2PwqviycZxaEVA0Bwv38LqGDGSB9A1DPN4iYginYJZSvTvKW8kh7Tis0HbZrX1YDKnY8hi3lt0N0tCTNPDH5Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-arm64-musl": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm64-musl/-/ffi-rs-linux-arm64-musl-1.3.1.tgz",
"integrity": "sha512-Hn1W1hBPssTaqikU1Bqp1XUdDdOgbnYVIOtR++LVx66hhrtjf/xrIUQOhTm+NmOFDG16JUKXe1skfM4gpaqYwg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-x64-gnu": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-x64-gnu/-/ffi-rs-linux-x64-gnu-1.3.1.tgz",
"integrity": "sha512-kW6e+oCYZPvpH2ppPsffA18e1aLowtmWTRjVlyHtY04g/nQDepQvDUkkcvInh9fW5jLna7PjHvktW1tVgYIj2A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-x64-musl": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-x64-musl/-/ffi-rs-linux-x64-musl-1.3.1.tgz",
"integrity": "sha512-HTwblAzruUS16nQPrez3ozvEHm1Xxh8J8w7rZYrpmAcNl1hzyOT8z/hY70M9Rt9fOqQ4Ovgor9qVy/U3ZJo0ZA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-win32-arm64-msvc": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-arm64-msvc/-/ffi-rs-win32-arm64-msvc-1.3.1.tgz",
"integrity": "sha512-WeZkGl2BP1U4tRhEQH+FXLQS52N8obp74smK5AAGOfzPAT1pHkq6+dVkC1QCSIt7dHJs7SPtlnQw+5DkdZYlWA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-win32-ia32-msvc": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-ia32-msvc/-/ffi-rs-win32-ia32-msvc-1.3.1.tgz",
"integrity": "sha512-rNGgMeCH5mdeHiMiJgt7wWXovZ+FHEfXhU9p4zZBH4n8M1/QnEsRUwlapISPLpILSGpoYS6iBuq9/fUlZY8Mhg==",
"cpu": [
"x64",
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-win32-x64-msvc": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-x64-msvc/-/ffi-rs-win32-x64-msvc-1.3.1.tgz",
"integrity": "sha512-dr2LcLD2CXo2a7BktlOpV68QhayqiI112KxIJC9tBgQO/Dkdg4CPsdqmvzzLhFo64iC5RLl2BT7M5lJImrfUWw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/bun": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/bun/-/bun-1.3.10.tgz",
"integrity": "sha512-S/CXaXXIyA4CMjdMkYQ4T2YMqnAn4s0ysD3mlsY4bUiOCqGlv28zck4Wd4H4kpvbekx15S9mUeLQ7Uxd0tYTLA==",
"cpu": [
"arm64",
"x64"
],
"hasInstallScript": true,
"license": "MIT",
"os": [
"darwin",
"linux",
"win32"
],
"peer": true,
"bin": {
"bun": "bin/bun.exe",
"bunx": "bin/bunx.exe"
},
"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"
}
},
"node_modules/bun-types": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.10.tgz",
"integrity": "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/ffi-rs": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/ffi-rs/-/ffi-rs-1.3.1.tgz",
"integrity": "sha512-ZyNXL9fnclnZV+waQmWB9JrfbIEyxQa1OWtMrHOrAgcC04PgP5hBMG5TdhVN8N4uT/eul8zCFMVnJUukAFFlXA==",
"license": "MIT",
"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"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"packages/fff-bun": {
"name": "@ff-labs/fff-bun",
"version": "0.1.37",
"cpu": [
"x64",
"arm64"
],
"license": "MIT",
"os": [
"darwin",
"linux",
"win32"
],
"bin": {
"fff-demo": "examples/search.ts",
"fff-grep": "examples/grep.ts"
},
"devDependencies": {
"@types/bun": "^1.3.8",
"typescript": "^5.0.0"
},
"engines": {
"bun": ">=1.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"
},
"peerDependencies": {
"bun": ">=1.0.0"
}
},
"packages/fff-node": {
"name": "@ff-labs/fff-node",
"version": "0.1.37",
"cpu": [
"x64",
"arm64"
],
"license": "MIT",
"os": [
"darwin",
"linux",
"win32"
],
"dependencies": {
"ffi-rs": "^1.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.0.0"
},
"engines": {
"node": ">=18.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"
}
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"private": true,
"workspaces": ["packages/fff-bun", "packages/fff-mcp"],
"packageManager": "bun@1.3.9",
"workspaces": ["packages/fff-bun", "packages/fff-node"],
"scripts": {
"format": "biome format --write",
"format:check": "biome format",
@@ -1,5 +1,5 @@
{
"name": "@ff-labs/fff-bun-darwin-arm64",
"name": "@ff-labs/fff-bin-darwin-arm64",
"version": "0.0.0",
"description": "fff native binary for macOS ARM64 (Apple Silicon)",
"os": ["darwin"],
@@ -13,6 +13,6 @@
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"directory": "packages/fff-bun-darwin-arm64"
"directory": "packages/fff-bin-darwin-arm64"
}
}

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