Compare commits

...

96 Commits

Author SHA1 Message Date
Dmitriy Kovalenko 6451f85070 chore: Update docs for - breaking(sdk): Rename several options & add more flags to control
docs / docs (push) Has been cancelled
2026-04-19 00:00:31 +00:00
Dmitriy Kovalenko 4725e96c9a breaking(sdk): Rename several options & add more flags to control 2026-04-18 17:00:13 -07:00
Dmitriy Kovalenko e2ce56ae51 fix(inotify): Watch files in new created directories (#391)
There are 2 problems:

1. There is a fixed limit of how many fsevents streams macos can spawn per process
2. New created directories are not getting watched

This fixes both
2026-04-18 16:52:16 -07:00
Dmitriy Kovalenko ed4c60833b feat: Directories and mixed files & directories search (#390) 2026-04-18 16:32:06 -07:00
Dmitriy Kovalenko cebacb3c35 feat!: Revamp & optimize the way strings are stored in RAM (#387)
* wip: Paths and dirs

* feat: att 2

* final changes

* feat!: Revamp & optimize the way paths are stored

This is a very crucial redesign that actually required a lot of
consideration to move forward but now it allows us to save a lot of RAM,
acutally improves query time and gives an ability to query directories
for free

* fix: Wrong pointer

* chore: Update docs for - fix: Wrong pointer

* fix: notify debouncer on macos

* get rid of flienma_ptrs juggling

* consolidate arena/picker via pub trait
2026-04-17 22:51:31 -07:00
Dmitriy Kovalenko 689e31284a chore: Improve chunking when reading files for grep (#379) 2026-04-17 20:43:45 -07:00
Timo Clasen c450a8d346 fix: do close picker immediately on <C-c> in vim mode (#385)
* fix: close picker immediately on <C-c> in vim mode

When prompt_vim_mode is enabled, <C-c> in insert mode was just leaving
insert (Neovim default) instead of closing the picker. Map it explicitly
to close.

* chore: Update docs for - fix: close picker immediately on <C-c> in vim mode
2026-04-17 11:17:35 -07:00
Farrel Darian 77881a1c92 feat: optional vim-mode in prompt input (#384)
* feat: enable vim-mode

* feat: handle cursor move to move up until prefix

* feat: make it configurable

* docs: vim_mode

* docs: update to prompt_vim_mode

Co-authored-by: Dmitriy Kovalenko <dmitriy@iusevimbtw.com>

* refactor: rename vim_mode to prompt_vim_mode

Matches the maintainer's preferred naming from PR review: since this is a
neovim plugin, the flag scope is clarified by the prompt_ prefix.

* style: collapse single-statement if for stylua

---------

Co-authored-by: Dmitriy Kovalenko <dmitriy@iusevimbtw.com>
2026-04-16 20:22:30 -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
Dmitriy Kovalenko 4b83987ea1 feat: Colorized hex dump for binary file previews (#291)
* feat: Colorized hex dump for binary file previews

* chore: Update docs for - feat: Colorized hex dump for binary file previews
2026-03-17 12:27:48 -07:00
Dmitriy Kovalenko c6cb66b597 fix: Windows dir locking (#293)
* fix: Windows dir locking

* chore: Update docs for - fix: Windows dir locking
2026-03-17 10:40:09 -07:00
George 64861f8142 chore(readme) Fix install-fff.sh link to install-mcp.sh (#283)
* install-fff.sh -> install.mcp.sh

* install-fff.sh -> install.mcp.sh
2026-03-15 08:00:18 -07:00
Mohamed Ibraheem eb5f2b3648 refactor: move change_indexing_directory to picker_ui module (#286)
* refactor: move change_indexing_directory to picker_ui module

closes #237

* chore: format

* chore: format
2026-03-15 07:59:38 -07:00
Dmitriy Kovalenko fcdf4a9172 feat(mcp): Make install script understand upgrade use case 2026-03-13 16:45:21 -07:00
Dmitriy Kovalenko 1001eb8b5e feat(mcp): Add file path fallback for grep (#281)
* feat(mcp): Add file path fallback for grep

* chore: Update docs for - feat(mcp): Add file path fallback for grep
2026-03-13 16:29:24 -07:00
Dmitriy Kovalenko f0ce2dd50d chore: fix format 2026-03-13 09:58:27 -07:00
Dmitriy Kovalenko 1c2a1c1204 fix(grep): file path single file constraint parsing (#280) 2026-03-12 17:10:28 -07:00
Salomon Popp 66bdfff454 fix: correct typo in vim.uv.fs_unlink() (#279) 2026-03-12 16:51:20 -07:00
Dmitriy Kovalenko 1e50f8df80 fix: Prevent mcp from running root large folders (#278) 2026-03-12 08:35:27 -07:00
Dmitriy Kovalenko 736c41ecd6 feat: MCP (#272)
* feat: MCP

* chore: Update docs for - feat: MCP

* feat: Perf optimizations

* chore: Update docs for - feat: Perf optimizations
2026-03-11 21:27:11 -07:00
Dmitriy Kovalenko ac8df4c9e4 chore: Update docs for - feat: Show the actual matches for typo resistant fuzzy search 2026-03-06 13:31:12 -08:00
Dmitriy Kovalenko e3e534f4ad feat: Show the actual matches for typo resistant fuzzy search 2026-03-06 13:31:12 -08:00
Dmitriy Kovalenko 764e3ecf18 fix: Race when openning many sequenital dirs
closes https://github.com/dmtrKovalenko/fff.nvim/issues/269
closes https://github.com/dmtrKovalenko/fff.nvim/issues/256
2026-03-06 13:31:12 -08:00
Nick Kadutskyi 10a27f9678 fix: proper git hl group for sign on selected file (#268) 2026-03-03 20:27:03 -08:00
Nico Domino 66e712e066 fix: prompt_position=bottom fix scrolling of viewport to top (#267) 2026-03-03 09:18:56 -08:00
Dmitriy Kovalenko c5a3f89c5e feat: Make zig optional for building (#266)
* feat: Make zig optional for building

* chore: Update docs for - feat: Make zig optional for building
2026-02-28 14:13:07 -08:00
bansheerubber 29f81a141f feat: add cursorlineopt config option for styling livegrep file preview (#262)
* feat: add cursorlineopt config option for styling livegrep file preview

* fix: better validation for junk `cursorlineopt` values
2026-02-27 21:00:30 -08:00
Dmitriy Kovalenko d2e3993398 feat: cross-compile for android (! for termux users) (#264)
* feat: cross-compile for android (! for termux users)

* chore: Update docs for - feat: cross-compile for android (! for termux users)
2026-02-27 20:57:35 -08:00
bansheerubber a411100fa4 chore(docs): replace flex = nil with flex = false in README.md (#263) 2026-02-27 17:57:26 -08:00
Italo Silva 0a993d7a30 feat!: Make layout flex on small screens & responsive (#255)
* feat: Add `flex` option for preview

* chore: Update docs for - feat: Add `flex` option for preview

* feat: Recalculate layout on resize

---------

Co-authored-by: Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>
2026-02-25 18:36:20 -08:00
Dmitriy Kovalenko 7c2d46f1e2 chore: Upgrade every used neovim deprecated lua apis (#261)
* chore: Do not use any neovim deprecated apis

* chore: Update docs for - chore: Do not use any neovim deprecated apis
2026-02-25 17:44:13 -08:00
Dmitriy Kovalenko c3ed9fb17b fix: Binary download integrity issues (#260)
* feat: cleanup hl groups resolution (#259)

* feat: cleanup hl groups resolution

* chore: Update docs for - feat: cleanup hl groups resolution

* chore: Update docs for - feat: cleanup hl groups resolution (#259)

* feat: cleanup hl groups resolution (#259)

* feat: cleanup hl groups resolution

* chore: Update docs for - feat: cleanup hl groups resolution
2026-02-24 17:32:10 -08:00
Dmitriy Kovalenko 0f40c66eb7 feat: cleanup hl groups resolution (#259)
* feat: cleanup hl groups resolution

* chore: Update docs for - feat: cleanup hl groups resolution
2026-02-24 09:54:24 -08:00
Nicolò Francesco Maria Spingola 7298978bcb fix(download): correctly parse certutil output on Windows (#258) 2026-02-23 06:57:07 -08:00
Federico 8f69f987a4 docs: update readme example keybindings (#253)
* docs: update readme example keybindings

* update indenntation

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-20 07:41:24 -08:00
Dmitriy Kovalenko 3e9b8655b7 fix: Atomic download checks for binaries + codesign for macos (#252) 2026-02-19 22:16:22 -08:00
Dmitriy Kovalenko 727935ede6 fix: Macos background watcher memory usage (#251)
* fix: Macos background watcher memory usage

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

Fixes a memory leak of the backgorund watcher on macos when the internal
fsevents buffer is droping certain elements

* chore: Update docs for - fix: Macos background watcher memory usage
2026-02-19 18:10:59 -08:00
Dmitriy Kovalenko 9a2612b1b5 feat: Grep perfromance and stability improvements (#235)
* feat: Allow many fff consumers over ffi

* parallize grep

* chore: Update docs for - parallize grep
2026-02-19 15:59:23 -08:00
Koichi Nakayamada 7c0d999144 fix: Use consistent emoji for frecency debug star indicator (#248) 2026-02-19 14:10:59 -08:00
Salomon Popp 81d98f6b9a fix: ignore window-scoped DirChanged events (#240) 2026-02-19 13:56:15 -08:00
Aashish Sharma 46e87e5928 fix(nix): warnings on flake (#246) 2026-02-18 10:19:51 -08:00
Timo Clasen 9bc928db65 feat: Support initial query for live_grep and find_files (#243)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 08:31:35 -08:00
Dmitriy Kovalenko 434344f6e9 feat: Prebuilt binaries for bun (#234)
* feat: Prebuilt binaries for bun

* chore: Update docs for - feat: Prebuilt binaries for bun
2026-02-16 20:10:48 -08:00
Dmitriy Kovalenko 6a3e481175 fix: Only use static linking libraries on windows (#232)
closes https://github.com/dmtrKovalenko/fff.nvim/issues/228
2026-02-16 12:12:51 -08:00
Dmitriy Kovalenko 29e13ac3d4 feat: Live grep (#231)
* feat: Live grep

* chore: Update docs for - feat: Live grep

* chore: Update docs for - chore: Update docs for - feat: Live grep

* fix formatting

* chore: Update docs for - fix formatting
2026-02-15 21:42:42 -08:00
Dmitriy Kovalenko c9137b19b6 fix: Windows long path prefix (#226)
closes https://github.com/dmtrKovalenko/fff.nvim/issues/221
2026-02-12 00:39:07 -08:00
186 changed files with 41905 additions and 6512 deletions
+3 -8
View File
@@ -10,12 +10,7 @@ rustflags = ["-C", "target-feature=-crt-static"]
[target.aarch64-unknown-linux-musl]
rustflags = ["-C", "target-feature=-crt-static"]
# Android/Termux: no hardcoded linker so native Termux builds use the system cc.
# For CI cross-compilation the linker is set via CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER env var.
[target.aarch64-linux-android]
rustflags = [
"-C",
"linker=aarch64-linux-android-clang",
"-C",
"link-args=-rdynamic",
"-C",
"default-linker-libraries",
]
rustflags = ["-C", "link-args=-rdynamic"]
+116
View File
@@ -0,0 +1,116 @@
name: e2e Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
MACOSX_DEPLOYMENT_TARGET: "13"
# Force Node 24 for all JS-based actions to avoid the libuv
# process_title assertion crash on Windows (known Node 20 bug).
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
lua-tests:
name: e2e (${{ matrix.os }})
runs-on: ${{ matrix.os }}
# e2e tests could be flaky on CI so we do not block release creation if they failed
continue-on-error: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
- os: macos-latest
- os: windows-latest
target: x86_64-pc-windows-msvc
steps:
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
- name: Install Zig
uses: mlugg/setup-zig@v2
with:
version: 0.15.2
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@v1.15.4
with:
cache: true
cache-on-failure: false
cache-key: "v2-lua-e2e"
rustflags: ""
target: ${{ matrix.target || '' }}
- name: Build Rust binary (Windows)
if: matrix.target
run: cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
- name: Copy binary to target/release (Windows)
if: matrix.target
shell: bash
run: |
cp target/${{ matrix.target }}/release/fff_nvim.dll target/release/fff_nvim.dll
- name: Verify Windows DLL has no unexpected dependencies
if: matrix.target
shell: pwsh
run: |
# Find dumpbin via vswhere (always available on GitHub Actions Windows runners)
$vsPath = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -property installationPath
$dumpbin = Get-ChildItem "$vsPath" -Recurse -Filter "dumpbin.exe" | Select-Object -First 1
if (-not $dumpbin) { Write-Error "dumpbin.exe not found"; exit 1 }
$deps = & $dumpbin.FullName /DEPENDENTS target\release\fff_nvim.dll | Out-String
Write-Host $deps
# zlob must be statically linked - fail if zlob.dll appears as a dependency
if ($deps -match 'zlob\.dll') {
Write-Error "fff_nvim.dll has unexpected dynamic dependency on zlob.dll - zlob should be statically linked"
exit 1
}
- name: Build Rust binary
if: ${{ !matrix.target }}
run: cargo build --release -p fff-nvim --features zlob
- name: Install Neovim
uses: rhysd/action-setup-vim@v1
with:
neovim: true
version: v0.10.4
- name: Clone plenary.nvim
shell: bash
run: git clone --depth 1 https://github.com/nvim-lua/plenary.nvim ../plenary.nvim
- name: Run Lua tests
shell: bash
run: make test-lua
- name: 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
-70
View File
@@ -1,70 +0,0 @@
name: Lua E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
MACOSX_DEPLOYMENT_TARGET: "13"
jobs:
lua-tests:
name: Lua E2E (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
- os: macos-latest
- os: windows-latest
target: x86_64-pc-windows-msvc
steps:
- uses: actions/checkout@v4
- name: Install Zig
uses: mlugg/setup-zig@v2
with:
version: 0.15.2
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache: true
cache-on-failure: true
cache-key: "v1-lua-e2e"
rustflags: ""
target: ${{ matrix.target || '' }}
- name: Build Rust binary (Windows)
if: matrix.target
run: cargo build --release --target ${{ matrix.target }} -p fff-nvim
- name: Copy binary to target/release (Windows)
if: matrix.target
shell: bash
run: |
cp target/${{ matrix.target }}/release/fff_nvim.dll target/release/fff_nvim.dll
- name: Build Rust binary
if: ${{ !matrix.target }}
run: cargo build --release -p fff-nvim
- name: Install Neovim
uses: rhysd/action-setup-vim@v1
with:
neovim: true
version: v0.10.4
- name: Clone plenary.nvim
shell: bash
run: git clone --depth 1 https://github.com/nvim-lua/plenary.nvim ../plenary.nvim
- name: Run Lua tests
shell: bash
run: |
nvim --headless -u tests/minimal_init.lua \
-c "PlenaryBustedFile tests/fff_core_spec.lua" 2>&1
+50
View File
@@ -0,0 +1,50 @@
name: Lua CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lua-ls:
name: lua-language-server type check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Neovim
run: |
curl -L https://github.com/neovim/neovim/releases/download/v0.11.5/nvim-linux-x86_64.tar.gz -o /opt/nvim.tar.gz
mkdir /opt/nvim
tar xzf /opt/nvim.tar.gz -C /opt/nvim
mv /opt/nvim/nvim-linux-x86_64/* /opt/nvim
echo "/opt/nvim/bin" >> $GITHUB_PATH
- name: Install lua-language-server
run: |
curl -L "https://github.com/LuaLS/lua-language-server/releases/download/3.17.1/lua-language-server-3.17.1-linux-x64.tar.gz" -o /opt/lls.tar.gz
mkdir /opt/lls
tar -xzf /opt/lls.tar.gz -C /opt/lls
echo "/opt/lls/bin" >> $GITHUB_PATH
- name: Clone snacks.nvim
run: git clone --depth=1 https://github.com/folke/snacks.nvim /opt/snacks.nvim
- name: Run lua-language-server
run: lua-language-server --configpath .luarc.ci.json --check=.
luacheck:
name: luacheck lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install luacheck
run: |
sudo apt-get update -qq
sudo apt-get install -y luarocks
sudo luarocks install luacheck
- name: Run luacheck
run: luacheck lua/
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
id-token: "write"
contents: "read"
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- uses: DeterminateSystems/flake-checker-action@main
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
# fetch last 2 commits required for auto force push back
fetch-depth: 2
+345 -85
View File
@@ -2,9 +2,14 @@ name: Prebuild
on:
push:
branches: [main]
branches: [main, fix/download-version]
tags:
- "v*"
pull_request:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build-nvim:
name: Build Neovim ${{ matrix.target }}
@@ -19,44 +24,50 @@ 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/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:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
@@ -73,22 +84,40 @@ jobs:
run: cargo install cargo-zigbuild
- name: Build for Linux
if: contains(matrix.os, 'ubuntu')
if: contains(matrix.os, 'ubuntu') && !contains(matrix.target, 'android')
run: |
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-nvim
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)
if: contains(matrix.target, 'android')
run: |
NDK_BIN="$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
# NDK clang for C deps (libgit2, lmdb, blake3) that need Bionic sysroot headers
export CC_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang"
export CXX_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang++"
export AR_aarch64_linux_android="$NDK_BIN/llvm-ar"
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$NDK_BIN/aarch64-linux-android24-clang"
cargo build --profile ci --target ${{ matrix.target }} -p fff-nvim --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
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
if: contains(matrix.os, 'macos')
run: codesign --force --sign - "${{ matrix.target }}.${{ matrix.ext }}"
- name: Build for Windows
if: contains(matrix.os, 'windows')
shell: bash
run: |
cargo build --release --target ${{ matrix.target }} -p fff-nvim
cargo build --profile ci --target ${{ matrix.target }} -p fff-nvim --features zlob
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
- name: Upload artifacts
@@ -109,44 +138,180 @@ jobs:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
zigbuild_target: x86_64-unknown-linux-gnu.2.17
artifact_name: target/x86_64-unknown-linux-gnu/release/libfff_c.so
artifact_name: target/x86_64-unknown-linux-gnu/ci/libfff_c.so
npm_package: fff-bin-linux-x64-gnu
lib_filename: libfff_c.so
ext: so
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
zigbuild_target: aarch64-unknown-linux-gnu.2.17
artifact_name: target/aarch64-unknown-linux-gnu/release/libfff_c.so
artifact_name: target/aarch64-unknown-linux-gnu/ci/libfff_c.so
npm_package: fff-bin-linux-arm64-gnu
lib_filename: libfff_c.so
ext: so
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
artifact_name: target/x86_64-unknown-linux-musl/release/libfff_c.so
artifact_name: target/x86_64-unknown-linux-musl/ci/libfff_c.so
npm_package: fff-bin-linux-x64-musl
lib_filename: libfff_c.so
ext: so
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
artifact_name: target/aarch64-unknown-linux-musl/release/libfff_c.so
artifact_name: target/aarch64-unknown-linux-musl/ci/libfff_c.so
npm_package: fff-bin-linux-arm64-musl
lib_filename: libfff_c.so
ext: so
## Android (Termux)
- os: ubuntu-latest
target: aarch64-linux-android
artifact_name: target/aarch64-linux-android/ci/libfff_c.so
lib_filename: libfff_c.so
ext: so
## macOS builds
- os: macos-latest
target: x86_64-apple-darwin
artifact_name: target/x86_64-apple-darwin/release/libfff_c.dylib
artifact_name: target/x86_64-apple-darwin/ci/libfff_c.dylib
npm_package: fff-bin-darwin-x64
lib_filename: libfff_c.dylib
ext: dylib
- os: macos-latest
target: aarch64-apple-darwin
artifact_name: target/aarch64-apple-darwin/release/libfff_c.dylib
artifact_name: target/aarch64-apple-darwin/ci/libfff_c.dylib
npm_package: fff-bin-darwin-arm64
lib_filename: libfff_c.dylib
ext: dylib
## Windows builds
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact_name: target/x86_64-pc-windows-msvc/release/fff_c.dll
artifact_name: target/x86_64-pc-windows-msvc/ci/fff_c.dll
npm_package: fff-bin-win32-x64
lib_filename: fff_c.dll
ext: dll
- os: windows-latest
target: aarch64-pc-windows-msvc
artifact_name: target/aarch64-pc-windows-msvc/release/fff_c.dll
artifact_name: target/aarch64-pc-windows-msvc/ci/fff_c.dll
npm_package: fff-bin-win32-arm64
lib_filename: fff_c.dll
ext: dll
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Install Rust
run: rustup target add ${{ matrix.target }}
- name: Install Zig
uses: mlugg/setup-zig@v2
with:
version: 0.15.2
- name: Install cargo-zigbuild
if: contains(matrix.os, 'ubuntu')
run: cargo install cargo-zigbuild
- name: Build for Linux
if: contains(matrix.os, 'ubuntu') && !contains(matrix.target, 'android')
run: |
cargo zigbuild --profile ci --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-c --features zlob
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
- name: Build for Android (Termux)
if: contains(matrix.target, 'android')
run: |
NDK_BIN="$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
export CC_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang"
export CXX_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang++"
export AR_aarch64_linux_android="$NDK_BIN/llvm-ar"
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$NDK_BIN/aarch64-linux-android24-clang"
cargo build --profile ci --target ${{ matrix.target }} -p fff-c --features zlob
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
- name: Build for macOS
if: contains(matrix.os, 'macos')
run: |
MACOSX_DEPLOYMENT_TARGET="13" cargo build --profile ci --target ${{ matrix.target }} -p fff-c --features zlob
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
- name: Ad-hoc sign macOS binary
if: contains(matrix.os, 'macos')
run: codesign --force --sign - "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
- name: Build for Windows
if: contains(matrix.os, 'windows')
shell: bash
run: |
cargo build --profile ci --target ${{ matrix.target }} -p fff-c --features zlob
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
- name: Prepare npm package
if: "!contains(matrix.target, 'android')"
shell: bash
run: |
# Copy the built binary into the platform npm package directory
cp "c-lib-${{ matrix.target }}.${{ matrix.ext }}" "packages/${{ matrix.npm_package }}/${{ matrix.lib_filename }}"
- name: Upload C library artifact
uses: actions/upload-artifact@v4
with:
name: c-lib-${{ matrix.target }}
path: c-lib-${{ matrix.target }}.*
- name: Upload npm package artifact
if: "!contains(matrix.target, 'android')"
uses: actions/upload-artifact@v4
with:
name: npm-${{ matrix.npm_package }}
path: packages/${{ matrix.npm_package }}/
build-mcp:
name: Build MCP ${{ matrix.target }}
runs-on: ${{ matrix.os }}
permissions:
contents: read
strategy:
matrix:
include:
## Linux builds (using cargo-zigbuild)
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
zigbuild_target: x86_64-unknown-linux-gnu.2.17
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/ci/fff-mcp
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
artifact_name: target/x86_64-unknown-linux-musl/ci/fff-mcp
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
artifact_name: target/aarch64-unknown-linux-musl/ci/fff-mcp
## macOS builds
- os: macos-latest
target: x86_64-apple-darwin
artifact_name: target/x86_64-apple-darwin/ci/fff-mcp
- os: macos-latest
target: aarch64-apple-darwin
artifact_name: target/aarch64-apple-darwin/ci/fff-mcp
## Windows builds
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact_name: target/x86_64-pc-windows-msvc/ci/fff-mcp.exe
- os: windows-latest
target: aarch64-pc-windows-msvc
artifact_name: target/aarch64-pc-windows-msvc/ci/fff-mcp.exe
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
@@ -165,36 +330,45 @@ jobs:
- name: Build for Linux
if: contains(matrix.os, 'ubuntu')
run: |
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-c
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
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-c
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
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
if: contains(matrix.os, 'macos')
run: codesign --force --sign - "fff-mcp-${{ matrix.target }}"
- name: Build for Windows
if: contains(matrix.os, 'windows')
shell: bash
run: |
cargo build --release --target ${{ matrix.target }} -p fff-c
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
cargo build --profile ci --target ${{ matrix.target }} -p fff-mcp --features zlob
cp "${{ matrix.artifact_name }}" "fff-mcp-${{ matrix.target }}.exe"
- name: Upload artifacts
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: c-lib-${{ matrix.target }}
path: c-lib-${{ matrix.target }}.*
name: mcp-${{ matrix.target }}
path: fff-mcp-${{ matrix.target }}*
release:
name: Release
needs: [build-nvim, build-c]
needs: [build-nvim, build-c, build-mcp]
runs-on: ubuntu-latest
# do not create releases on the forks (no permissions)
if: github.event_name != 'pull_request' || github.event.pull_request.user.login == 'dmtrKovalenko'
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Lua
uses: leafo/gh-actions-lua@v12
- name: Download artifacts
uses: actions/download-artifact@v4
@@ -231,6 +405,24 @@ jobs:
rmdir "$dir" 2>/dev/null || true
done
- name: Flatten MCP artifacts
working-directory: ./binaries
run: |
for dir in mcp-*/; do
for file in "$dir"*; do
if [ -f "$file" ]; then
filename=$(basename "$file")
mv "$file" "./$filename"
fi
done
rmdir "$dir" 2>/dev/null || true
done
- name: Remove npm package artifacts from release binaries
working-directory: ./binaries
run: |
rm -rf npm-*
- name: Generate checksums
working-directory: ./binaries
run: |
@@ -241,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
@@ -267,47 +456,118 @@ jobs:
## C FFI Library (for Bun/Node/Python)
- `c-lib-{target}.so` / `.dylib` / `.dll` - C FFI library
comment-on-pr:
name: Comment on PR
needs: [build-nvim, build-c]
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
permissions:
pull-requests: write
steps:
- name: Get short SHA
id: vars
run: echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
## MCP Server
- `fff-mcp-{target}` - MCP server binary
- 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 -->"
- 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 }}",
}
Install with:
```sh
curl -fsSL https://raw.githubusercontent.com/dmtrKovalenko/fff.nvim/main/install-mcp.sh | bash
```
### Bun/TypeScript Package
The `fff` npm package will download binaries from this release automatically.
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')
---
*Built from ${{ github.sha }}*
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/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: "25"
registry-url: "https://registry.npmjs.org"
- name: Determine version
id: version
run: lua scripts/determine-version.lua
- name: Download npm package artifacts
uses: actions/download-artifact@v4
with:
pattern: npm-*
path: ./npm-packages
- name: Publish platform packages
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="${{ steps.version.outputs.npm_tag }}"
for pkg_dir in ./npm-packages/npm-*/; do
if [ -d "$pkg_dir" ]; then
pkg_name=$(node -p "require('${pkg_dir}package.json').name")
echo "Publishing ${pkg_name}@${VERSION} with tag ${TAG}..."
make set-npm-version PKG="$pkg_dir" VERSION="$VERSION"
cd "$pkg_dir"
npm publish --tag "$TAG" --access public || echo "Failed to publish ${pkg_name} (may already exist)"
cd -
fi
done
- name: Publish bun package
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="${{ steps.version.outputs.npm_tag }}"
echo "Publishing @ff-labs/fff-bun@${VERSION} with tag ${TAG}..."
make set-npm-version PKG=packages/fff-bun VERSION="$VERSION"
cd packages/fff-bun
npm publish --tag "$TAG" --access public || echo "Failed to publish @ff-labs/fff-bun (may already exist)"
- name: Publish Node.js package
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="${{ steps.version.outputs.npm_tag }}"
echo "Publishing @ff-labs/fff-node@${VERSION} with tag ${TAG}..."
make set-npm-version PKG=packages/fff-node VERSION="$VERSION"
cd packages/fff-node
npm install
npm run build
npm publish --tag "$TAG" --access public || echo "Failed to publish @ff-labs/fff-node (may already exist)"
+7 -7
View File
@@ -20,16 +20,16 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
# Zig is required to compile zlob
- name: Install Zig
uses: mlugg/setup-zig@v2
uses: goto-bus-stop/setup-zig@v2
with:
version: 0.15.2
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
uses: actions-rust-lang/setup-rust-toolchain@v1.15.4
with:
cache: true
cache-on-failure: true
@@ -37,13 +37,13 @@ jobs:
components: rustfmt, clippy
- name: Run tests
run: cargo test --verbose --workspace --exclude fff-nvim
run: cargo test --features zlob --workspace --exclude fff-nvim
fmt:
name: cargo fmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Rust
uses: dtolnay/rust-toolchain@master
with:
@@ -56,11 +56,11 @@ jobs:
name: cargo clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
# Zig is required to compile zlob
- name: Install Zig
uses: mlugg/setup-zig@v2
uses: goto-bus-stop/setup-zig@v2
with:
version: 0.15.2
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
name: Spell Check with Typos
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
name: Check lua files using Stylua
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
+12 -1
View File
@@ -1,4 +1,5 @@
doc/tags
big-repo
target/
.archive.lua
_*.lua
@@ -10,6 +11,16 @@ result
.repro/
.wrangler/
*.so
big-repo/
*.dylib
# all the perf like utility files
*.data
node_modules/
crates/fff-notify-debouncer-full/
dist/
scripts/benchmark-results/
# Native binaries (downloaded at install)
*.dylib
*.so
*.dll
+25
View File
@@ -0,0 +1,25 @@
-- luacheck configuration for fff.nvim
-- https://luacheck.readthedocs.io/en/stable/config.html
-- Neovim globals
globals = { "vim" }
-- Standard library
std = "luajit"
-- Ignore line length (handled by stylua)
max_line_length = false
-- Ignore unused self argument in methods
self = false
-- Files/directories to ignore
exclude_files = {
".luarocks/",
}
-- Warn about unused variables, but allow _ prefix convention
unused_args = true
ignore = {
"212", -- unused argument (too noisy for callback-heavy code)
}
+44
View File
@@ -0,0 +1,44 @@
{
"$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
"runtime": {
"version": "LuaJIT",
"pathStrict": true
},
"workspace": {
"library": [
"/opt/nvim/share/nvim/runtime/lua/vim/_meta",
"/opt/nvim/share/nvim/runtime/lua/vim/shared.lua",
"${3rd}/luv/library",
"${3rd}/busted/library",
"/opt/snacks.nvim/lua"
],
"checkThirdParty": false
},
"diagnostics": {
"severity": {
"undefined-global": "Error",
"undefined-field": "Warning",
"missing-return": "Warning",
"redundant-parameter": "Warning",
"param-type-mismatch": "Warning",
"assign-type-mismatch": "Warning",
"cast-type-mismatch": "Warning",
"deprecated": "Warning",
"undefined-doc-param": "Warning"
},
"neededFileStatus": {
"undefined-global": "Any",
"undefined-field": "Any",
"missing-return": "Any",
"redundant-parameter": "Any",
"param-type-mismatch": "Any",
"assign-type-mismatch": "Any",
"cast-type-mismatch": "Any",
"deprecated": "Any",
"undefined-doc-param": "Any"
}
},
"type": {
"checkTableShape": true
}
}
+42
View File
@@ -0,0 +1,42 @@
{
"$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
"runtime": {
"version": "LuaJIT"
},
"workspace": {
"library": [
"/opt/homebrew/share/nvim/runtime/lua/vim/_meta",
"/opt/homebrew/share/nvim/runtime/lua/vim/shared.lua",
"${3rd}/luv/library",
"${3rd}/busted/library"
],
"checkThirdParty": false
},
"diagnostics": {
"severity": {
"undefined-global": "Error",
"undefined-field": "Warning",
"missing-return": "Warning",
"redundant-parameter": "Warning",
"param-type-mismatch": "Warning",
"assign-type-mismatch": "Warning",
"cast-type-mismatch": "Warning",
"deprecated": "Warning",
"undefined-doc-param": "Warning"
},
"neededFileStatus": {
"undefined-global": "Any",
"undefined-field": "Any",
"missing-return": "Any",
"redundant-parameter": "Any",
"param-type-mismatch": "Any",
"assign-type-mismatch": "Any",
"cast-type-mismatch": "Any",
"deprecated": "Any",
"undefined-doc-param": "Any"
}
},
"type": {
"checkTableShape": true
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"mcpServers": {
"fff": {
"type": "stdio",
"command": "./target/release/fff-mcp",
"args": []
}
}
}
Generated
+1278 -600
View File
File diff suppressed because it is too large Load Diff
+22 -3
View File
@@ -2,35 +2,48 @@
members = [
"crates/fff-c",
"crates/fff-core",
"crates/fff-mcp",
"crates/fff-nvim",
"crates/fff-query-parser",
"crates/fff-grep",
]
exclude = ["crates/fff-notify-debouncer-full"]
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"
blake3 = "1.8.2"
chrono = { version = "0.4", features = ["serde"] }
ctrlc = "3.4.2"
dirs = "5.0"
dunce = "1.0"
# git2 - base config without TLS (each crate adds platform-specific TLS)
git2 = { version = "0.20.2", default-features = false, features = [
"vendored-libgit2",
] }
glidesort = "0.1"
globset = "0.4"
heed = "0.22.0"
ignore = "0.4.22"
memmap2 = "0.9"
mimalloc = "0.1.47"
zlob = "1.3.0"
mlua = { version = "0.11.1", features = ["module", "luajit"] }
neo_frizbee = { version = "0.7.2" }
notify = "8.1.0"
notify-debouncer-full = "0.7"
neo_frizbee = { version = "0.10.1", features = ["match_end_col"] }
notify = { version = "9.0.0-rc.3" }
notify-debouncer-full = { package = "fff-notify-debouncer-full", version = "0.9.1" }
once_cell = "1.20.2"
parking_lot = "0.12"
pathdiff = "0.2.1"
rayon = "1.8.0"
regex = "1.11"
regex-syntax = "0.8"
smallvec = { version = "1.13", features = ["const_generics", "union"] }
thiserror = "2.0.10"
tracing = "0.1"
@@ -41,5 +54,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"
+75 -5
View File
@@ -1,9 +1,14 @@
PLENARY_DIR ?= ../plenary.nvim
.PHONY: build test test-rust test-lua test-setup
.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
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 \
@@ -12,17 +17,82 @@ test-setup:
fi
test-rust:
cargo test --verbose --workspace --exclude fff-nvim
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: test-rust test-lua
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
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-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
format-lua:
stylua .
format-ts:
bun format
format: format-rust format-lua
format: format-rust format-lua format-ts
lint-rust:
cargo clippy --workspace --features zlob -- -D warnings
lint-lua:
~/.luarocks/bin/luacheck .
lint-ts:
bun lint
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
+188 -76
View File
@@ -1,9 +1,13 @@
<p align="center">
<h2 align="center">FFF.nvim</h2>
<h1 align="center">FFF</h1>
</p>
<p align="center">
Finally a smart fuzzy file picker for neovim.
<a href="#mcp"><strong>AI agents (MCP)</strong></a> &nbsp;&nbsp;|&nbsp;&nbsp; <a href="#neovim-guide"><strong>Neovim users</strong></a>
</p>
<p align="center">
<i>A fast file search for your AI and neovim, with memory built-in</i>
</p>
<p align="center" style="text-decoration: none; border: none;">
@@ -14,35 +18,44 @@
<a href="https://github.com/dmtrKovalenko/fff.nvim/contributors" style="text-decoration: none"> <img alt="Contributors" src="https://img.shields.io/github/contributors/dmtrKovalenko/fff.nvim?color=%23DDB6F2&label=CONTRIBUTORS&logo=git&style=for-the-badge&logoColor=D9E0EE&labelColor=302D41"/></a>
</p>
**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an opinionated fuzzy file picker for neovim. Just for files, but we'll try to solve file picking completely.
---
It comes with a dedicated rust backend runtime that keep tracks of the file index, your file access and modifications, git status, and provides a comprehensive typo-resistant fuzzy search experience.
**FFF** stands for ~~freakin fast fuzzy file finder~~ (pick 3) and it is an opinionated fuzzy file picker for your AI agent and Neovim. Just for file search, but we do the file search really fff well.
## Features
FFF is a tool for grepping, fuzzy file matching, globbing, and multigrepping with a strong focus on performance and useful search results. For humans - provides an unbelievable typo-resistant experience, for AI agents - implements the fastest file search with additional free memory suggesting the best search results based on various factors like frecency, git status, file size, definition matches, and more.
- Works out of the box with no additional configuration
- [Typo resistant fuzzy search](https://github.com/saghen/frizbee)
- Git status integration allowing to take advantage of last modified times within a worktree
- Separate file index maintained by a dedicated backend allows <10 milliseconds search time for 50k files codebase
- Display images in previews (for now requires snacks.nvim)
- Smart in a plenty of different ways hopefully helpful for your workflow
- This plugin initializes itself lazily by default
## MCP
## Installation
FFF is an amazing way to reduce the time and tokens by giving your AI agent a bit of memory built-in to their file search tools. It makes your AI harness to find the code faster and spend less tokens by doing less roundtrips and reading less useless files.
> [!NOTE]
> Although we'll try to make sure to keep 100% backward compatibility, by using you should understand that silly bugs and breaking changes may happen.
> And also we hope for your contributions and feedback to make this plugin ideal for everyone.
![Chart showing the superiority of fff.nvim over builtin claude code tools](./chart.png)
### Prerequisites
You can install FFF as a dependency for your AI agent using a simple bash script:
FFF.nvim requires:
```bash
curl -L https://dmtrkovalenko.dev/install-fff-mcp.sh | bash
```
- Neovim 0.10.0+
- [Rustup](https://rustup.rs/) (we require nightly for building the native backend rustup will handle toolchain automatically)
> The installation script is here [./install-mcp.sh](./install-mcp.sh) if you want to review it before running.
It will print out the instructions on how to connect it to your `Claude Code`, `Codex`, `OpenCode`, etc. Once you have it connected just ask your agent to "use fff".
Here is an example addition to `CLAUDE.md` that works perfectly:
```sh
# CLAUDE.md
For any file search or grep in the current git indexed directory use fff tools
```
## Neovim guide
Here is some demo on the linux repository (100k files, 8GB) but you better fill it yourself and see the magic
https://github.com/user-attachments/assets/5d0e1ce9-642c-4c44-aa88-01b05bb86abb
### Installation
FFF.nvim requires neovim 0.10.0 or higher
#### lazy.nvim
```lua
@@ -69,7 +82,26 @@ FFF.nvim requires:
"ff", -- try it if you didn't it is a banger keybinding for a picker
function() require('fff').find_files() end,
desc = 'FFFind files',
}
},
{
"fg",
function() require('fff').live_grep() end,
desc = 'LiFFFe grep',
},
{
"fz",
function() require('fff').live_grep({
grep = {
modes = { 'fuzzy', 'plain' }
}
}) end,
desc = 'Live fffuzy grep',
},
{
"fc",
function() require('fff').live_grep({ query = vim.fn.expand("<cword>") }) end,
desc = 'Search current word',
},
}
}
```
@@ -80,8 +112,12 @@ FFF.nvim requires:
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,
@@ -116,12 +152,17 @@ require('fff').setup({
max_results = 100,
max_threads = 4,
lazy_sync = true, -- set to false if you want file indexing to start on open
prompt_vim_mode = false, -- set to true to enable vim-mode in the prompt: <Esc> leaves insert for normal mode bindings (also allows <leader>p or <leader>l to jump around) the second <Esc> closes the picker
layout = {
height = 0.8,
width = 0.8,
prompt_position = 'bottom', -- or 'top'
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
preview_size = 0.5,
flex = { -- set to false to disable flex layout
size = 130, -- column threshold: if screen width >= size, use preview_position; otherwise use wrap
wrap = 'top', -- position to use when screen is narrower than size
},
show_scrollbar = true, -- Show scrollbar for pagination
-- How to shorten long directory paths in the file list:
-- 'middle_number' (default): uses dots for 1-3 hidden (a/./b, a/../b, a/.../b)
@@ -137,6 +178,7 @@ require('fff').setup({
binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable)
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
line_numbers = false,
cursorlineopt = 'both', -- the cursorlineopt used for lines in grep file previews, see :h cursorlineopt
wrap_lines = false,
filetypes = {
svg = { wrap_lines = true },
@@ -156,25 +198,29 @@ require('fff').setup({
preview_scroll_up = '<C-u>',
preview_scroll_down = '<C-d>',
toggle_debug = '<F2>',
-- grep mode: cycle between plain text, regex, and fuzzy search
cycle_grep_modes = '<S-Tab>',
-- goes to the previous query in history
cycle_previous_query = '<C-Up>',
-- multi-select keymaps for quickfix
toggle_select = '<Tab>',
send_to_quickfix = '<C-q>',
-- this are specific for the normal mode (you can exit it using any other keybind like jj)
focus_list = '<leader>l',
focus_preview = '<leader>p',
},
hl = {
border = 'FloatBorder',
normal = 'Normal',
cursor = 'CursorLine',
cursor = 'CursorLine', -- Falls back to 'Visual' if CursorLine is not defined
matched = 'IncSearch',
title = 'Title',
prompt = 'Question',
active_file = 'Visual',
frecency = 'Number',
debug = 'Comment',
combo_header = 'Number',
scrollbar = 'Comment', -- Highlight for scrollbar thumb (track uses border)
directory_path = 'Comment', -- Highlight for directory path in file list
scrollbar = 'Comment',
directory_path = 'Comment',
-- Multi-select highlights
selected = 'FFFSelected',
selected_active = 'FFFSelectedActive',
@@ -199,6 +245,14 @@ require('fff').setup({
git_sign_renamed_selected = 'FFFGitSignRenamedSelected',
git_sign_untracked_selected = 'FFFGitSignUntrackedSelected',
git_sign_ignored_selected = 'FFFGitSignIgnoredSelected',
-- Grep highlights
grep_match = 'IncSearch', -- Highlight for matched text in grep results
grep_line_number = 'LineNr', -- Highlight for :line:col location
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
grep_plain_active = 'Comment', -- Highlight for keybind + label when regex is off
grep_fuzzy_active = 'DiagnosticHint', -- Highlight for keybind + label when fuzzy is on
-- Cross-mode suggestion highlights
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
},
-- Store file open frecency
frecency = {
@@ -209,24 +263,36 @@ require('fff').setup({
history = {
enabled = true,
db_path = vim.fn.stdpath('data') .. '/fff_queries',
min_combo_count = 3, -- file will get a boost if it was selected 3 in a row times per specific query
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches
min_combo_count = 3, -- Minimum selections before combo boost applies (3 = boost starts on 3rd selection)
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches (files repeatedly opened with same query)
},
-- Git integration
git = {
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
},
debug = {
enabled = false, -- Set to true to show scores in the UI
show_scores = false,
show_file_info = false, -- Show file info panel in preview
enabled = false, -- Show file info panel in preview
show_scores = false, -- Show scores inline in the UI
},
logging = {
enabled = true,
log_file = vim.fn.stdpath('log') .. '/fff.log',
log_level = 'info',
}
})
},
-- find_files settings
file_picker = {
current_file_label = '(current)',
},
-- grep settings
grep = {
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
max_matches_per_file = 100, -- Maximum matches per file (set 0 to unlimited)
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
},
})
```
### Key Features
@@ -234,19 +300,19 @@ require('fff').setup({
#### Available Methods
```lua
require('fff').find_files() -- Find files in current directory
require('fff').find_in_git_root() -- Find files in the current git repository
require('fff').find_files() -- Find files in current repository
require('fff').scan_files() -- Trigger rescan of files in the current directory
require('fff').refresh_git_status() -- Refresh git status for the active file lock
require('fff').refresh_git_status() -- Refresh git status for the active file list
require('fff').find_files_in_dir(path) -- Find files in a specific directory
require('fff').change_indexing_directory(new_path) -- Change the base directory for the file picker
```
just jump to the definition and see what other APIs are exposed we have a plenty
#### Commands
FFF.nvim provides several commands for interacting with the file picker:
- `:FFFFind [path|query]` - Open file picker. Optional: provide directory path or search query
- `:FFFScan` - Manually trigger a rescan of files in the current directory
- `:FFFRefreshGit` - Manually refresh git status for all files
- `:FFFClearCache [all|frecency|files]` - Clear various caches
@@ -254,10 +320,6 @@ FFF.nvim provides several commands for interacting with the file picker:
- `:FFFDebug [on|off|toggle]` - Toggle debug scores display
- `:FFFOpenLog` - Open the FFF log file in a new tab
#### Multiline Paste Support
The input field automatically handles multiline clipboard content by joining all lines into a single search query. This is particularly useful when copying file paths from terminal output.
#### Debug Mode
Toggle scoring information display:
@@ -273,11 +335,92 @@ Select multiple files and send them to Neovim's quickfix list (keymaps are confi
- `<Tab>` - Toggle selection for the current file (shows thick border `▊` in signcolumn)
- `<C-q>` - Send selected files to quickfix list and close picker
#### Live Grep Search Modes
Live grep supports three search modes, cycled with `<S-Tab>`:
- **Plain text** (default) - The query is matched literally. Special regex characters like `.`, `*`, `(`, `)`, `$` have no special meaning. This is the safest mode for searching code containing regex metacharacters.
- **Regex** - The query is interpreted as a regular expression. Supports character classes (`[a-z]`), quantifiers (`+`, `*`, `{n}`), alternation (`foo|bar`), anchors (`^`, `$`), word boundaries (`\b`), and more.
- **Fuzzy** - The query is fuzzy matched using Smith-Waterman scoring. Accommodates typos and scattered characters (e.g., "mtxlk" matches "mutex_lock"). Results are filtered by a quality threshold to avoid overly fuzzy matches.
The current mode is shown on the right side of the input field (e.g., `plain`, `regex`, `fuzzy`) with color-coded highlighting.
You can customize which modes are available and their cycling order globally in your configuration, or per-call when invoking `live_grep()`.
**Global configuration:**
```lua
require('fff').setup({
grep = {
modes = { 'plain', 'regex' }, -- Only plain and regex, no fuzzy
}
})
```
**Per-call configuration:**
```lua
-- Only fuzzy and plain modes for this specific grep
require('fff').live_grep({
grep = {
modes = { 'fuzzy', 'plain' },
}
})
-- Single mode (hides mode indicator completely)
require('fff').live_grep({
grep = {
modes = { 'fuzzy' },
}
})
-- Pre-fill the search with an initial query
require('fff').live_grep({ query = 'search term' })
```
When only one mode is configured, the mode indicator is hidden completely and the cycle keybind does nothing.
#### Constraints
There are a number of constraints you can use to refine your search in both grep and file search mode:
- `git:modified` - show only modified files (one of `modified`, `staged`, `deleted`, `renamed`, `untracked`, `ignored`)
- `test/` - any deeply nested children of any test/ dir
- `!something` - exclude results matching something
- `!test/`, `!git:modified` - combining with any other constraint works as negation
- `./**/*.{rs,lua}` - any valid glob expression via [the fastest globbing library](https://github.com/dmtrKovalenko/zlob)
For grep only:
- `*.md`, `*.{c,h}` - extension filtering
- `src/main.rs` - grep in a single file
In addition to that, all constraints can be combined together like:
```
git:modified src/**/*.rs !src/**/mod.rs user controller
```
This will find all the files that qualify the constraints and:
- match **both** user and controller (for file mode)
- match "user controller" (for grep mode)
#### Cross-Mode Suggestions
When a search returns no results, FFF automatically queries the opposite search mode and displays the results as suggestions:
- **File search with no matches** → shows suggested **content matches** (grep results) for the same query
- **Grep search with no matches** → shows suggested **file name matches** for the same query
Suggestions are clearly labeled with a "No results found. Suggested ..." banner (highlighted with `hl.suggestion_header`). You can navigate and select suggestion items just like normal results — selecting a grep suggestion will open the file at the matching line.
#### Git Status Highlighting
FFF integrates with git to show file status through sign column indicators (enabled by default) and optional filename text coloring.
**Sign Column Indicators** (enabled by default) - Border characters shown in the sign column:
```lua
hl = {
git_sign_staged = 'FFFGitSignStaged',
@@ -292,6 +435,7 @@ hl = {
**Text Highlights** (opt-in) - Apply colors to filenames based on git status:
To enable git status text coloring, set `git.status_text_color = true`:
```lua
require('fff').setup({
git = {
@@ -311,6 +455,7 @@ require('fff').setup({
The plugin provides sensible default highlight groups that link to common git highlight groups (e.g., GitSignsAdd, GitSignsChange). You can override these with your own custom highlight groups to match your colorscheme.
**Example - Custom Bright Colors for Text:**
```lua
vim.api.nvim_set_hl(0, 'CustomGitModified', { fg = '#FFA500' })
vim.api.nvim_set_hl(0, 'CustomGitUntracked', { fg = '#00FF00' })
@@ -354,41 +499,8 @@ Run `:FFFHealth` to check the status of FFF.nvim and its dependencies. This will
If you encounter issues, check the log file:
```vim
```
:FFFOpenLog
```
Or manually open the log file at `~/.local/state/nvim/log/fff.log` (default location).
#### Common Issues
**File picker not initializing:**
- Ensure the Rust backend is compiled: `cargo build --release` in the plugin directory
- Check that your Neovim version is 0.10.0 or higher
**Image previews not working:**
- Verify your terminal supports images (kitty, iTerm2, WezTerm, etc.)
- For terminals without native image support, install one of: `chafa`, `viu`, or `img2txt`
- If using snacks.nvim, ensure it's properly configured
**Performance issues:**
- Adjust `max_threads` in configuration based on your system
- Reduce `preview.max_lines` and `preview.max_size` for large files
- Clear cache if it becomes too large: `:FFFClearCache all`
**Files not being indexed:**
- Run `:FFFScan` to manually trigger a file scan
- Check that the `base_path` is correctly set
- Verify you have read permissions for the directory
#### Debug Mode
Enable debug mode to see scoring information and troubleshoot search results:
- Press `F2` while in the picker
- Run `:FFFDebug on` to enable permanently
- Set `debug.show_scores = true` in configuration
+5
View File
@@ -4,6 +4,11 @@ extend-exclude = ["/CHANGELOG.md", "data/filetypes/base.lua"]
[default.extend-words]
noice = "noice"
fo = "fo"
ba = "ba"
ue = "ue"
# some typos we use for tests
comparsion = "comparsion"
modfiers = "modfiers"
[default]
extend-ignore-re = [
+35
View File
@@ -0,0 +1,35 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
"files": {
"includes": ["packages/**/*.ts", "!packages/*/dist"],
"ignoreUnknown": true
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 90
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"trailingCommas": "all",
"semicolons": "always"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noNonNullAssertion": "off"
},
"suspicious": {
"noExplicitAny": "off"
},
"complexity": {
"noForEach": "off"
}
}
}
}
+148
View File
@@ -0,0 +1,148 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"devDependencies": {
"@biomejs/biome": "^2.4.4",
},
},
"packages/fff-bun": {
"name": "@ff-labs/fff-bun",
"version": "0.1.37",
"bin": {
"fff": "./scripts/cli.ts",
"fff-demo": "./examples/search.ts",
"fff-grep": "./examples/grep.ts",
},
"devDependencies": {
"@types/bun": "^1.3.8",
"typescript": "^5.0.0",
},
"optionalDependencies": {
"@ff-labs/fff-bun-darwin-arm64": "0.0.0",
"@ff-labs/fff-bun-darwin-x64": "0.0.0",
"@ff-labs/fff-bun-linux-arm64-gnu": "0.0.0",
"@ff-labs/fff-bun-linux-arm64-musl": "0.0.0",
"@ff-labs/fff-bun-linux-x64-gnu": "0.0.0",
"@ff-labs/fff-bun-linux-x64-musl": "0.0.0",
"@ff-labs/fff-bun-win32-arm64": "0.0.0",
"@ff-labs/fff-bun-win32-x64": "0.0.0",
},
"peerDependencies": {
"bun": ">=1.0.0",
},
},
"packages/fff-node": {
"name": "@ff-labs/fff-node",
"version": "0.1.37",
"bin": {
"fff-node": "./dist/scripts/cli.js",
},
"dependencies": {
"ffi-rs": "^1.0.0",
},
"devDependencies": {
"@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",
},
},
},
"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=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A=="],
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@workspace:packages/fff-bun"],
"@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=="],
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-Nhssuh7GBpP5PiDSOl3+qnoIG7PJo+ec2oomDevnl9pRY6x6aD2gRt0JE+uf+A8Om2D6gjeHCxjEdrw5ZHE8mA=="],
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-w1gaTlqU0IJCmJ1X+PGHkdNU1n8Gemx5YKkjhkJIguvFINXEBB5U1KG82QsT65Tk4KyNMfbLTlmy4giAvUoKfA=="],
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-OUgPHfL6+PM2Q+tFZjcaycN3D7gdQdYlWnwMI31DXZKY1r4HINWk9aEz9t/rNaHg65edwNrt7dsv9TF7xK8xIA=="],
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Ui5pAgM7JE9MzHokF0VglRMkbak3lTisY4Mf1AZutPACXWgKJC5aGrgnHBfkl7QS6fEeYb0juy1q4eRznRHOsw=="],
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-bzUgYj/PIZziB/ZesIP9HUyfvh6Vlf3od+TrbTTyVEuCSMKzDPQVW/yEbRp0tcHO3alwiEXwJDrWrHAguXlgiQ=="],
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-oqvMDYpX6dGJO03HgO5bXuccEsH3qbdO3MaAiAlO4CfkBPLUXz3N0DDElg5hz0L6ktdDVKbQVE5lfe+LAUISQg=="],
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-poVXvOShekbexHq45b4MH/mRjQKwACAC8lHp3Tz/hEDuz0/20oncqScnmKwzhBPEpqJvydXficXfBYuSim8opw=="],
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-/hOZ6S1VsTX6vtbhWVL9aAnOrdpuO54mAGUWpTdMz7dFG5UBZ/VUEiK0pBkq9A1rlBk0GeD/6Y4NBFl8Ha7cRA=="],
"@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.3.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-GXbz2swvN2DLw2dXZFeedMxSJtI64xQ9xp9Eg7Hjejg6mS2E4dP1xoQ2yAo2aZPi/2OBPAVaGzppI2q20XumHA=="],
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.10", "", { "os": "win32", "cpu": "x64" }, "sha512-qaS1In3yfC/Z/IGQriVmF8GWwKuNqiw7feTSJWaQhH5IbL6ENR+4wGNPniZSJFaM/SKUO0e/YCRdoVBvgU4C1g=="],
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.10", "", { "os": "win32", "cpu": "x64" }, "sha512-gh3UAHbUdDUG6fhLc1Csa4IGdtghue6U8oAIXWnUqawp6lwb3gOCRvp25IUnLF5vUHtgfMxuEUYV7YA2WxVutw=="],
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
"@yuuang/ffi-rs-android-arm64": ["@yuuang/ffi-rs-android-arm64@1.3.1", "", { "os": "android", "cpu": "arm64" }, "sha512-V4nmlXdOYZEa7GOxSExVG95SLp8FE0iTq2yKeN54UlfNMr3Sik+1Ff57LcCv7qYcn4TBqnBAt5rT3FAM6T6caQ=="],
"@yuuang/ffi-rs-darwin-arm64": ["@yuuang/ffi-rs-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YlnTMIyzfW3mAULC5ZA774nzQfFlYXM0rrfq/8ZzWt+IMbYk55a++jrI+6JeKV+1EqlDS3TFBEFtjdBNG94KzQ=="],
"@yuuang/ffi-rs-darwin-x64": ["@yuuang/ffi-rs-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-sI3LpQQ34SX4nyOHc5yxA7FSqs9qPEUMqW/y/wWo9cuyPpaHMFsi/BeOVYsnC0syp3FrY7gzn6RnD6PlXCktXg=="],
"@yuuang/ffi-rs-linux-arm-gnueabihf": ["@yuuang/ffi-rs-linux-arm-gnueabihf@1.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-1WkcGkJTlwh4ZA59htKI+RXhiL3oKiYwLv7PO8LUf6FuADK73s5GcXp67iakKu243uYu+qGYr4RHco4ySddYhQ=="],
"@yuuang/ffi-rs-linux-arm64-gnu": ["@yuuang/ffi-rs-linux-arm64-gnu@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-J2PwqviycZxaEVA0Bwv38LqGDGSB9A1DPN4iYginYJZSvTvKW8kh7Tis0HbZrX1YDKnY8hi3lt0N0tCTNPDH5Q=="],
"@yuuang/ffi-rs-linux-arm64-musl": ["@yuuang/ffi-rs-linux-arm64-musl@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Hn1W1hBPssTaqikU1Bqp1XUdDdOgbnYVIOtR++LVx66hhrtjf/xrIUQOhTm+NmOFDG16JUKXe1skfM4gpaqYwg=="],
"@yuuang/ffi-rs-linux-x64-gnu": ["@yuuang/ffi-rs-linux-x64-gnu@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-kW6e+oCYZPvpH2ppPsffA18e1aLowtmWTRjVlyHtY04g/nQDepQvDUkkcvInh9fW5jLna7PjHvktW1tVgYIj2A=="],
"@yuuang/ffi-rs-linux-x64-musl": ["@yuuang/ffi-rs-linux-x64-musl@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HTwblAzruUS16nQPrez3ozvEHm1Xxh8J8w7rZYrpmAcNl1hzyOT8z/hY70M9Rt9fOqQ4Ovgor9qVy/U3ZJo0ZA=="],
"@yuuang/ffi-rs-win32-arm64-msvc": ["@yuuang/ffi-rs-win32-arm64-msvc@1.3.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-WeZkGl2BP1U4tRhEQH+FXLQS52N8obp74smK5AAGOfzPAT1pHkq6+dVkC1QCSIt7dHJs7SPtlnQw+5DkdZYlWA=="],
"@yuuang/ffi-rs-win32-ia32-msvc": ["@yuuang/ffi-rs-win32-ia32-msvc@1.3.1", "", { "os": "win32", "cpu": [ "x64", "ia32", ] }, "sha512-rNGgMeCH5mdeHiMiJgt7wWXovZ+FHEfXhU9p4zZBH4n8M1/QnEsRUwlapISPLpILSGpoYS6iBuq9/fUlZY8Mhg=="],
"@yuuang/ffi-rs-win32-x64-msvc": ["@yuuang/ffi-rs-win32-x64-msvc@1.3.1", "", { "os": "win32", "cpu": "x64" }, "sha512-dr2LcLD2CXo2a7BktlOpV68QhayqiI112KxIJC9tBgQO/Dkdg4CPsdqmvzzLhFo64iC5RLl2BT7M5lJImrfUWw=="],
"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=="],
"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@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"bun-types/@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
"bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

+8 -5
View File
@@ -1,20 +1,23 @@
[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]
crate-type = ["cdylib"]
[features]
default = []
zlob = ["fff/zlob"]
[dependencies]
mimalloc.workspace = true
once_cell.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"
+771
View File
@@ -0,0 +1,771 @@
/* 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 *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;
int32_t path_alignment_bonus;
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 *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;
bool is_watcher_ready;
bool is_warmup_complete;
} FffScanProgress;
/**
* A directory item returned by `fff_search_directories`.
*
* All string fields are heap-allocated and owned by the parent `FffDirSearchResult`.
* Free the entire result with `fff_free_dir_search_result`.
*/
typedef struct FffDirItem {
char *relative_path;
char *dir_name;
int32_t max_access_frecency;
} FffDirItem;
/**
* Directory search result returned by `fff_search_directories`.
*
* The caller must free this with `fff_free_dir_search_result`.
*/
typedef struct FffDirSearchResult {
/**
* Pointer to a heap-allocated array of `FffDirItem` (length = `count`).
*/
struct FffDirItem *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 directories that matched the query.
*/
uint32_t total_matched;
/**
* Total number of indexed directories.
*/
uint32_t total_dirs;
} FffDirSearchResult;
/**
* A single item in a mixed (files + directories) search result.
*
* `item_type`: 0 = file, 1 = directory.
* All string fields are heap-allocated and owned by the parent `FffMixedSearchResult`.
*/
typedef struct FffMixedItem {
/**
* 0 = file, 1 = directory.
*/
uint8_t item_type;
char *relative_path;
/**
* Filename for files, last directory segment for directories.
*/
char *display_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;
} FffMixedItem;
/**
* Mixed search result returned by `fff_search_mixed`.
*
* The caller must free this with `fff_free_mixed_search_result`.
*/
typedef struct FffMixedSearchResult {
/**
* Pointer to a heap-allocated array of `FffMixedItem` (length = `count`).
*/
struct FffMixedItem *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 items (files + dirs) that matched the query.
*/
uint32_t total_matched;
/**
* Total number of indexed files.
*/
uint32_t total_files;
/**
* Total number of indexed directories.
*/
uint32_t total_dirs;
/**
* Location parsed from the query string.
*/
struct FffLocation location;
} FffMixedSearchResult;
/**
* 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)
* * `enable_mmap_cache` pre-populate mmap caches after the initial scan
* * `enable_content_indexing` build content index after the initial scan
* * `watch` start a background file-system watcher for live updates
* * `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 enable_mmap_cache,
bool enable_content_indexing,
bool watch,
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 fuzzy search on indexed directories.
*
* # Parameters
*
* * `fff_handle` instance from `fff_create_instance`
* * `query` search query string
* * `current_file` path of the currently open file for distance scoring (NULL/empty to skip)
* * `max_threads` maximum worker threads (0 = auto-detect)
* * `page_index` pagination offset (0 = first page)
* * `page_size` results per page (0 = default 100)
*
* ## 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_directories(void *fff_handle,
const char *query,
const char *current_file,
uint32_t max_threads,
uint32_t page_index,
uint32_t page_size);
/**
* Perform a mixed fuzzy search across both files and directories.
*
* Returns a single flat list where files and directories are interleaved
* by total score in descending order. Each item has an `item_type` field
* (0 = file, 1 = directory).
*
* # Parameters
*
* * `fff_handle` instance from `fff_create_instance`
* * `query` search query string
* * `current_file` path of the currently open file (NULL/empty to skip)
* * `max_threads` maximum worker threads (0 = auto-detect)
* * `page_index` pagination offset (0 = first page)
* * `page_size` results per page (0 = default 100)
* * `combo_boost_multiplier` score multiplier for combo matches (0 = default 100)
* * `min_combo_count` minimum combo count before boost applies (0 = default 3)
*
* ## 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_mixed(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 the base path of the file picker.
*
* Returns an `FffResult` with a heap-allocated C string in the `handle`
* field. Free the string with `fff_free_string` after reading it.
*
* ## Safety
* `fff_handle` must be a valid instance pointer from `fff_create_instance`.
*/
struct FffResult *fff_get_base_path(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);
/**
* Wait for the background file watcher to be ready.
*
* ## Safety
* `fff_handle` must be a valid instance pointer from `fff_create_instance`.
*/
struct FffResult *fff_wait_for_watcher(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);
/**
* Free a directory search result returned by `fff_search_directories`.
*
* ## Safety
* `result` must be a valid pointer previously returned via `FffResult.handle`
* from `fff_search_directories`, or null (no-op).
*/
void fff_free_dir_search_result(struct FffDirSearchResult *result);
/**
* Get a pointer to the `index`-th `FffDirItem` in a directory search result.
*
* ## Safety
* `result` must be a valid `FffDirSearchResult` pointer from `fff_search_directories`.
*/
const struct FffDirItem *fff_dir_search_result_get_item(const struct FffDirSearchResult *result,
uint32_t index);
/**
* Get a pointer to the `index`-th `FffScore` in a directory search result.
*
* ## Safety
* `result` must be a valid `FffDirSearchResult` pointer from `fff_search_directories`.
*/
const struct FffScore *fff_dir_search_result_get_score(const struct FffDirSearchResult *result,
uint32_t index);
/**
* Free a mixed search result returned by `fff_search_mixed`.
*
* ## Safety
* `result` must be a valid pointer previously returned via `FffResult.handle`
* from `fff_search_mixed`, or null (no-op).
*/
void fff_free_mixed_search_result(struct FffMixedSearchResult *result);
/**
* Get a pointer to the `index`-th `FffMixedItem` in a mixed search result.
*
* ## Safety
* `result` must be a valid `FffMixedSearchResult` pointer from `fff_search_mixed`.
*/
const struct FffMixedItem *fff_mixed_search_result_get_item(const struct FffMixedSearchResult *result,
uint32_t index);
/**
* Get a pointer to the `index`-th `FffScore` in a mixed search result.
*
* ## Safety
* `result` must be a valid `FffMixedSearchResult` pointer from `fff_search_mixed`.
*/
const struct FffScore *fff_mixed_search_result_get_score(const struct FffMixedSearchResult *result,
uint32_t index);
#endif /* FFF_C_H */
+663 -157
View File
@@ -1,127 +1,117 @@
//! 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};
use std::ffi::{CString, c_char, c_void};
use std::ptr;
use fff_core::git::format_git_status;
use fff_core::{FileItem, Location, Score, SearchResult};
use serde::{Deserialize, Serialize};
use fff::file_picker::FilePicker;
use fff::git::format_git_status;
use fff::{
DirItem, DirSearchResult, FileItem, GrepMatch, GrepResult, Location, MixedItemRef,
MixedSearchResult, Score, SearchResult,
};
/// Result type returned by all FFI functions
/// Returned as a heap-allocated pointer that must be freed with fff_free_result
/// 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,
}
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(),
}))
}
/// 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(),
}))
}
/// 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(),
}))
}
}
/// 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,
}
/// 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 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 FffFileItem {
pub fn from_item(item: &FileItem, picker: &FilePicker) -> Self {
FffFileItem {
relative_path: cstring_new(&item.relative_path(picker)),
file_name: cstring_new(&item.file_name(picker)),
git_status: cstring_new(format_git_status(item.git_status)),
size: item.size,
modified: item.modified,
access_frecency_score: item.access_frecency_score,
modification_frecency_score: item.modification_frecency_score,
total_frecency_score: item.total_frecency_score,
git_status: format_git_status(item.git_status).to_string(),
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.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,
@@ -130,13 +120,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,
@@ -145,77 +136,592 @@ 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, picker: &FilePicker) -> *mut Self {
let items: Vec<FffFileItem> = result
.items
.iter()
.map(|i| FffFileItem::from_item(i, picker))
.collect();
let scores: Vec<FffScore> = result.scores.iter().map(FffScore::from).collect();
let count = items.len() as u32;
let (items_ptr, _) = vec_to_raw(items);
let (scores_ptr, _) = vec_to_raw(scores);
Box::into_raw(Box::new(FffSearchResult {
items: items_ptr,
scores: scores_ptr,
count,
total_matched: result.total_matched as u32,
total_files: result.total_files as u32,
location: FffLocation::from(result.location.as_ref()),
}))
}
}
// ---------------------------------------------------------------------------
// Grep result types
// ---------------------------------------------------------------------------
/// A byte range within a matched line, used for highlighting.
#[repr(C)]
pub struct FffMatchRange {
pub start: u32,
pub end: u32,
}
/// A single grep match with file and line information.
///
/// All string fields and arrays are heap-allocated. Free the parent
/// `FffGrepResult` with `fff_free_grep_result` to release everything.
#[repr(C)]
pub struct FffGrepMatch {
// -- pointers (8 bytes each) --
pub relative_path: *mut c_char,
pub file_name: *mut c_char,
pub git_status: *mut c_char,
pub line_content: *mut c_char,
pub match_ranges: *mut FffMatchRange,
pub context_before: *mut *mut c_char,
pub context_after: *mut *mut c_char,
// -- 8-byte numeric fields --
pub size: u64,
pub modified: u64,
pub total_frecency_score: i64,
pub access_frecency_score: i64,
pub modification_frecency_score: i64,
pub line_number: u64,
pub byte_offset: u64,
// -- 4-byte fields --
pub col: u32,
pub match_ranges_count: u32,
pub context_before_count: u32,
pub context_after_count: u32,
// -- 2-byte fields --
pub fuzzy_score: u16,
// -- 1-byte fields --
pub has_fuzzy_score: bool,
pub is_binary: bool,
pub is_definition: bool,
}
impl FffGrepMatch {
fn from_core_with_file(m: &GrepMatch, file: &FileItem, picker: &FilePicker) -> Self {
let ranges: Vec<FffMatchRange> = m
.match_byte_offsets
.iter()
.map(|&(start, end)| FffMatchRange { start, end })
.collect();
let (match_ranges, match_ranges_count) = vec_to_raw(ranges);
let (context_before, context_before_count) = strings_to_raw(&m.context_before);
let (context_after, context_after_count) = strings_to_raw(&m.context_after);
let (has_fuzzy_score, fuzzy_score) = match m.fuzzy_score {
Some(s) => (true, s),
None => (false, 0),
};
FffGrepMatch {
relative_path: cstring_new(&file.relative_path(picker)),
file_name: cstring_new(&file.file_name(picker)),
git_status: cstring_new(format_git_status(file.git_status)),
line_content: cstring_new(&m.line_content),
match_ranges,
context_before,
context_after,
size: file.size,
modified: file.modified,
total_frecency_score: file.total_frecency_score() as i64,
access_frecency_score: file.access_frecency_score as i64,
modification_frecency_score: file.modification_frecency_score as i64,
line_number: m.line_number,
byte_offset: m.byte_offset,
col: m.col as u32,
match_ranges_count,
context_before_count,
context_after_count,
fuzzy_score,
has_fuzzy_score,
is_binary: file.is_binary(),
is_definition: m.is_definition,
}
}
/// ## Safety
/// All pointers must have been allocated by the corresponding `from_core`.
pub unsafe fn free_fields(&mut self) {
unsafe {
if !self.relative_path.is_null() {
drop(CString::from_raw(self.relative_path));
}
if !self.file_name.is_null() {
drop(CString::from_raw(self.file_name));
}
if !self.git_status.is_null() {
drop(CString::from_raw(self.git_status));
}
if !self.line_content.is_null() {
drop(CString::from_raw(self.line_content));
}
if !self.match_ranges.is_null() {
drop(Vec::from_raw_parts(
self.match_ranges,
self.match_ranges_count as usize,
self.match_ranges_count as usize,
));
}
free_cstring_array(self.context_before, self.context_before_count);
free_cstring_array(self.context_after, self.context_after_count);
}
}
}
/// Grep result returned by `fff_live_grep` and `fff_multi_grep`.
///
/// The caller must free this with `fff_free_grep_result`.
#[repr(C)]
pub struct FffGrepResult {
/// Pointer to a heap-allocated array of `FffGrepMatch` (length = `count`).
pub items: *mut FffGrepMatch,
/// Number of matches in the `items` array.
pub count: u32,
/// Total number of matches (always equal to `count`).
pub total_matched: u32,
/// Number of files actually opened and searched in this call.
pub total_files_searched: u32,
/// Total number of indexed files (before any filtering).
pub total_files: u32,
/// Number of files eligible for search after filtering.
pub filtered_file_count: u32,
/// File offset for the next page. 0 if all files have been searched.
pub next_file_offset: u32,
/// Regex compilation error when falling back to literal matching. Null if none.
pub regex_fallback_error: *mut c_char,
}
impl FffGrepResult {
/// Convert a core `GrepResult` into a heap-allocated `FffGrepResult`.
pub fn from_core(result: &GrepResult, picker: &FilePicker) -> *mut Self {
let items: Vec<FffGrepMatch> = result
.matches
.iter()
.map(|m| {
let file = result.files[m.file_index];
FffGrepMatch::from_core_with_file(m, file, picker)
})
.collect();
let (items_ptr, count) = vec_to_raw(items);
Box::into_raw(Box::new(FffGrepResult {
items: items_ptr,
count,
total_matched: result.matches.len() as u32,
total_files_searched: result.total_files_searched as u32,
total_files: result.total_files as u32,
filtered_file_count: result.filtered_file_count as u32,
next_file_offset: result.next_file_offset as u32,
regex_fallback_error: match &result.regex_fallback_error {
Some(e) => cstring_new(e),
None => ptr::null_mut(),
},
}))
}
}
/// Result envelope returned by all `fff_*` functions.
///
/// Heap-allocated — the caller must free it with `fff_free_result`.
///
/// 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,
}))
}
}
/// A directory item returned by `fff_search_directories`.
///
/// All string fields are heap-allocated and owned by the parent `FffDirSearchResult`.
/// Free the entire result with `fff_free_dir_search_result`.
#[repr(C)]
pub struct FffDirItem {
pub relative_path: *mut c_char,
pub dir_name: *mut c_char,
pub max_access_frecency: i32,
}
impl FffDirItem {
pub fn from_item(item: &DirItem, picker: &FilePicker) -> Self {
FffDirItem {
relative_path: cstring_new(&item.relative_path(picker)),
dir_name: cstring_new(&item.dir_name(picker)),
max_access_frecency: item.max_access_frecency(),
}
}
/// ## Safety
/// All string pointers must have been allocated by the rust side
pub unsafe fn free_strings(&mut self) {
unsafe {
if !self.relative_path.is_null() {
drop(CString::from_raw(self.relative_path));
}
if !self.dir_name.is_null() {
drop(CString::from_raw(self.dir_name));
}
}
}
}
/// Directory search result returned by `fff_search_directories`.
///
/// The caller must free this with `fff_free_dir_search_result`.
#[repr(C)]
pub struct FffDirSearchResult {
/// Pointer to a heap-allocated array of `FffDirItem` (length = `count`).
pub items: *mut FffDirItem,
/// Pointer to a heap-allocated array of `FffScore` (length = `count`).
pub scores: *mut FffScore,
/// Number of items/scores in the arrays.
pub count: u32,
/// Total number of directories that matched the query.
pub total_matched: u32,
/// Total number of indexed directories.
pub total_dirs: u32,
}
impl FffDirSearchResult {
/// Convert a core `DirSearchResult` into a heap-allocated `FffDirSearchResult`.
pub fn from_core(result: &DirSearchResult, picker: &FilePicker) -> *mut Self {
let items: Vec<FffDirItem> = result
.items
.iter()
.map(|i| FffDirItem::from_item(i, picker))
.collect();
let scores: Vec<FffScore> = result.scores.iter().map(FffScore::from).collect();
let count = items.len() as u32;
let (items_ptr, _) = vec_to_raw(items);
let (scores_ptr, _) = vec_to_raw(scores);
Box::into_raw(Box::new(FffDirSearchResult {
items: items_ptr,
scores: scores_ptr,
count,
total_matched: result.total_matched as u32,
total_dirs: result.total_dirs as u32,
}))
}
}
/// A single item in a mixed (files + directories) search result.
///
/// `item_type`: 0 = file, 1 = directory.
/// All string fields are heap-allocated and owned by the parent `FffMixedSearchResult`.
#[repr(C)]
pub struct FffMixedItem {
/// 0 = file, 1 = directory.
pub item_type: u8,
pub relative_path: *mut c_char,
/// Filename for files, last directory segment for directories.
pub display_name: *mut c_char,
pub git_status: *mut c_char,
pub size: u64,
pub modified: u64,
/// The access frecency score for files, or max access frecency among all the immediate
/// children for directories.
pub access_frecency_score: i64,
/// Always 0 for directories
pub modification_frecency_score: i64,
/// Always 0 for directories
pub total_frecency_score: i64,
/// Always 0 for directories
pub is_binary: bool,
}
impl FffMixedItem {
pub fn from_mixed_ref(item: &MixedItemRef<'_>, picker: &FilePicker) -> Self {
match item {
MixedItemRef::File(file) => FffMixedItem {
item_type: 0,
relative_path: cstring_new(&file.relative_path(picker)),
display_name: cstring_new(&file.file_name(picker)),
git_status: cstring_new(format_git_status(file.git_status)),
size: file.size,
modified: file.modified,
access_frecency_score: file.access_frecency_score as i64,
modification_frecency_score: file.modification_frecency_score as i64,
total_frecency_score: file.total_frecency_score() as i64,
is_binary: file.is_binary(),
},
MixedItemRef::Dir(dir) => FffMixedItem {
item_type: 1,
relative_path: cstring_new(&dir.relative_path(picker)),
display_name: cstring_new(&dir.dir_name(picker)),
git_status: cstring_new(""),
size: 0,
modified: 0,
access_frecency_score: dir.max_access_frecency() as i64,
modification_frecency_score: 0,
total_frecency_score: dir.max_access_frecency() as i64,
is_binary: false,
},
}
}
/// ## Safety
/// All string pointers must have been allocated by rust side
pub unsafe fn free_strings(&mut self) {
unsafe {
if !self.relative_path.is_null() {
drop(CString::from_raw(self.relative_path));
}
if !self.display_name.is_null() {
drop(CString::from_raw(self.display_name));
}
if !self.git_status.is_null() {
drop(CString::from_raw(self.git_status));
}
}
}
}
/// Mixed search result returned by `fff_search_mixed`.
///
/// The caller must free this with `fff_free_mixed_search_result`.
#[repr(C)]
pub struct FffMixedSearchResult {
/// Pointer to a heap-allocated array of `FffMixedItem` (length = `count`).
pub items: *mut FffMixedItem,
/// Pointer to a heap-allocated array of `FffScore` (length = `count`).
pub scores: *mut FffScore,
/// Number of items/scores in the arrays.
pub count: u32,
/// Total number of items (files + dirs) that matched the query.
pub total_matched: u32,
/// Total number of indexed files.
pub total_files: u32,
/// Total number of indexed directories.
pub total_dirs: u32,
/// Location parsed from the query string.
pub location: FffLocation,
}
impl FffMixedSearchResult {
/// Convert a core `MixedSearchResult` into a heap-allocated `FffMixedSearchResult`.
pub fn from_core(result: &MixedSearchResult, picker: &FilePicker) -> *mut Self {
let items: Vec<FffMixedItem> = result
.items
.iter()
.map(|i| FffMixedItem::from_mixed_ref(i, picker))
.collect();
let scores: Vec<FffScore> = result.scores.iter().map(FffScore::from).collect();
let count = items.len() as u32;
let (items_ptr, _) = vec_to_raw(items);
let (scores_ptr, _) = vec_to_raw(scores);
Box::into_raw(Box::new(FffMixedSearchResult {
items: items_ptr,
scores: scores_ptr,
count,
total_matched: result.total_matched as u32,
total_files: result.total_files as u32,
total_dirs: result.total_dirs as u32,
location: FffLocation::from(result.location.as_ref()),
}))
}
}
/// Scan progress returned by `fff_get_scan_progress`.
/// The caller must free this with `fff_free_scan_progress`.
#[repr(C)]
pub struct FffScanProgress {
pub scanned_files_count: u64,
pub is_scanning: bool,
pub is_watcher_ready: bool,
pub is_warmup_complete: bool,
}
impl From<fff::file_picker::ScanProgress> for FffScanProgress {
fn from(p: fff::file_picker::ScanProgress) -> Self {
Self {
scanned_files_count: p.scanned_files_count as u64,
is_scanning: p.is_scanning,
is_watcher_ready: p.is_watcher_ready,
is_warmup_complete: p.is_warmup_complete,
}
}
}
+1213 -419
View File
File diff suppressed because it is too large Load Diff
+40 -12
View File
@@ -1,53 +1,81 @@
[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"
crate-type = ["rlib", "staticlib", "cdylib"]
[[bench]]
name = "parse_bench"
harness = false
[[bench]]
name = "bigram_bench"
harness = false
[[bench]]
name = "memmem_bench"
harness = false
[features]
default = []
# Enable C FFI exports
ffi = []
# Call mi_collect(true) after large allocator churn (bigram build).
# Requires mimalloc to be the global allocator (linked by fff-nvim).
mimalloc-collect = ["dep:libmimalloc-sys"]
# Use zlob (Zig-compiled C globbing library) for glob matching.
# Requires Zig to be installed. When disabled, falls back to globset (pure Rust).
zlob = ["dep:zlob", "fff-query-parser/zlob"]
[dependencies]
# Workspace dependencies
ahash = { workspace = true }
rayon = { workspace = true }
smallvec = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
# Local crates
fff-query-parser = { path = "../fff-query-parser" }
fff-query-parser = { workspace = true }
# 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 }
fff-grep = { workspace = true }
aho-corasick = "1"
memchr = "2"
heed = { workspace = true }
ignore = { workspace = true }
memmap2 = { workspace = true }
neo_frizbee = { workspace = true }
notify = { workspace = true }
notify-debouncer-full = { workspace = true }
once_cell = { workspace = true }
parking_lot = { workspace = true }
pathdiff = { workspace = true }
regex = { workspace = true }
regex-syntax = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
smartstring = { version = "1.0.1", features = ["serde"] }
toml = "0.8"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
zlob = { version = "1.2.8" }
# Platform-specific: Use vendored OpenSSL on non-Windows (Linux, macOS)
[target.'cfg(not(windows))'.dependencies]
openssl = { version = "0.10", features = ["vendored"] }
zlob = { workspace = true, optional = true }
libmimalloc-sys = { version = "0.1", optional = true, features = ["extended"] }
mimalloc = { version = "0.1", optional = true }
# Platform-specific: dunce for Windows to avoid \\?\ extended path prefix
[target.'cfg(windows)'.dependencies]
dunce = { workspace = true }
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
+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);
+42 -12
View File
@@ -1,16 +1,46 @@
fn main() {
// When the `zlob` feature is enabled (Zig-compiled C library):
// On Windows MSVC, explicitly link the C runtime libraries.
// This is needed because Zig-compiled static libraries (zlob) don't emit
// /DEFAULTLIB directives for the MSVC CRT. Without this, symbols like
// strcmp, memcpy, memchr etc. from vendored C libraries (libgit2, lmdb)
// are unresolved when linking the cdylib.
//
// We link both msvcrt (classic CRT) and ucrt (Universal CRT where memchr,
// strcmp etc. live on newer MSVC/ARM64 targets).
let target = std::env::var("TARGET").unwrap_or_default();
if target.contains("windows") && target.contains("msvc") {
println!("cargo:rustc-link-lib=msvcrt");
println!("cargo:rustc-link-lib=ucrt");
println!("cargo:rustc-link-lib=vcruntime");
// Zig-compiled static libraries don't emit /DEFAULTLIB directives for the
// MSVC CRT, so symbols like strcmp, memcpy etc. would be unresolved.
if std::env::var("CARGO_FEATURE_ZLOB").is_ok() {
let target = std::env::var("TARGET").unwrap_or_default();
if target.contains("windows") && target.contains("msvc") {
println!("cargo:rustc-link-lib=msvcrt");
println!("cargo:rustc-link-lib=ucrt");
println!("cargo:rustc-link-lib=vcruntime");
}
} else if std::env::var("CI").is_ok() {
// CI must always build with zlob for production-quality binaries.
if !zig_available() {
panic!(
"CI detected but Zig is not installed. \
Please install Zig and build with `--features zlob`."
);
}
panic!(
"CI detected but `zlob` feature is not enabled. \
Build with `--features zlob`."
);
} else {
// Hint: if Zig is available but the zlob feature wasn't enabled,
// let the developer know they can get faster glob matching.
if zig_available() {
println!(
"cargo:warning=Zig detected but `zlob` feature is not enabled. \
Build with `--features zlob` for faster glob matching."
);
}
}
}
/// Probe the system for a working Zig installation.
fn zig_available() -> bool {
std::process::Command::new("zig")
.arg("version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
+503 -112
View File
@@ -1,105 +1,292 @@
use crate::FILE_PICKER;
use crate::error::Error;
use crate::file_picker::FilePicker;
use crate::file_picker::{FFFMode, FilePicker};
use crate::git::GitStatusCache;
use crate::shared::{SharedFrecency, SharedPicker};
use crate::sort_buffer::sort_with_buffer;
use git2::Repository;
use notify::event::{AccessKind, AccessMode};
use notify::{Config, EventKind, RecursiveMode};
use notify_debouncer_full::{
DebounceEventResult, DebouncedEvent, RecommendedCache, new_debouncer_opt,
};
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, Mutex};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::time::Duration;
use tracing::{Level, error, info, warn};
use tracing::{Level, debug, error, info, warn};
type Debouncer = notify_debouncer_full::Debouncer<notify::RecommendedWatcher, RecommendedCache>;
type Debouncer = notify_debouncer_full::Debouncer<notify::RecommendedWatcher, NoCache>;
/// Owns the file-system watcher and guarantees that all background threads
/// are fully joined before `stop()` / `Drop` returns.
///
/// Architecture:
/// - The debouncer (and its internal watcher) live inside an **owner thread**
/// that we spawn and hold the `JoinHandle` for.
/// - `stop()` sets a flag, unparks the owner thread, and **joins** it.
/// - Inside the owner thread, `Debouncer::stop()` is called which joins the
/// debouncer's event-processing thread.
/// - On Windows an additional short sleep is added after `Debouncer::stop()`
/// because `notify`'s `ReadDirectoryChangesWatcher` discards its thread
/// `JoinHandle`, so we cannot join it directly. The watcher's `Drop` does
/// signal the thread via semaphore so it exits almost immediately, but we
/// need to give the OS a moment to reclaim it.
pub struct BackgroundWatcher {
debouncer: Arc<Mutex<Option<Debouncer>>>,
stop_signal: Arc<AtomicBool>,
owner_thread: Option<std::thread::JoinHandle<()>>,
}
const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(250);
const MAX_PATHS_THRESHOLD: usize = 1024;
/// On macOS, each `watch()` call creates a separate FSEventStream. When the
/// number of directories exceeds this threshold we fall back to a single
/// recursive watch to avoid exhausting the per-process stream limit.
const MAX_MACOS_NONRECURSIVE_WATCHES: usize = 4096;
/// Minimum seconds between frecency tracks of the same file in AI mode.
/// Prevents score inflation from rapid burst edits by AI agents.
const AI_MODE_COOLDOWN_SECS: u64 = 5 * 60;
impl BackgroundWatcher {
pub fn new(base_path: PathBuf, git_workdir: Option<PathBuf>) -> Result<Self, Error> {
pub fn new(
base_path: PathBuf,
git_workdir: Option<PathBuf>,
shared_picker: SharedPicker,
shared_frecency: SharedFrecency,
mode: FFFMode,
watch_dirs: Vec<PathBuf>,
) -> Result<Self, Error> {
info!(
"Initializing background watcher for path: {}",
base_path.display()
"Initializing background watcher for path: {}, mode: {:?}",
base_path.display(),
mode,
);
let debouncer = Self::create_debouncer(base_path, git_workdir)?;
let (watch_tx, watch_rx) = mpsc::channel::<PathBuf>();
// Clone shared state for the owner thread (needed for injecting
// files that existed before a watch was registered on their directory).
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));
let stop_clone = Arc::clone(&stop_signal);
// The owner thread keeps the debouncer alive and ensures proper
// cleanup: `Debouncer::stop()` joins its internal thread, then the
// watcher `Drop` signals its I/O thread to exit.
let owner_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 created before the watch was registered don't
// generate events. Do a flat (non-recursive) read_dir
// to inject any files that already exist. Subdirectories
// are not descended — they get their own watches via
// future Create events from this directory's watch.
inject_existing_files(&dir, &owner_picker, &owner_git_workdir);
}
std::thread::park_timeout(Duration::from_secs(1));
}
// Debouncer::stop() joins the debouncer's event thread, then
// drops the watcher (whose Drop signals the I/O thread).
debouncer.stop();
// On Windows the notify crate discards the ReadDirectoryChangesW
// thread's JoinHandle — we cannot join it. Its Drop signals the
// thread via semaphore so it exits almost immediately; give the
// OS a moment to fully reclaim it.
#[cfg(windows)]
std::thread::sleep(Duration::from_millis(250));
})
.expect("failed to spawn fff-watcher-owner thread");
Ok(Self {
debouncer: Arc::new(Mutex::new(Some(debouncer))),
stop_signal,
owner_thread: Some(owner_thread),
})
}
fn create_debouncer(
base_path: PathBuf,
git_workdir: Option<PathBuf>,
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);
// Decide the watching strategy up-front so the event handler closure
// knows whether it needs to request dynamic directory watches.
let use_recursive =
cfg!(target_os = "macos") && watch_dirs.len() > MAX_MACOS_NONRECURSIVE_WATCHES;
let git_workdir_for_handler = git_workdir.clone();
let mut debouncer = new_debouncer_opt(
DEBOUNCE_TIMEOUT,
Some(DEBOUNCE_TIMEOUT / 2), // tick rate for the event span
{
move |result: DebounceEventResult| match result {
Ok(events) => {
handle_debounced_events(events, &git_workdir);
let new_dirs = handle_debounced_events(
events,
&git_workdir_for_handler,
&shared_picker,
&shared_frecency,
mode,
);
// In NonRecursive mode, register watches for newly
// discovered directories so future file events in them
// are captured. In Recursive mode the single stream
// already covers new subdirectories.
if !use_recursive {
for dir in new_dirs {
let _ = watch_tx.send(dir);
}
}
}
Err(errors) => {
error!("File watcher errors: {:?}", errors);
}
}
},
RecommendedCache::new(),
// There is an issue with recommended cache implementation on macos
// it keeps track of all the files added to the watcher which is not a problem
// for us because any rename to the file will anyway require the removing from the
// ordedred index and adding it back with the new name
NoCache::new(),
config,
)?;
debouncer.watch(base_path.as_path(), RecursiveMode::Recursive)?;
info!("File watcher initizlieed for path: {}", base_path.display());
// Watching strategy:
//
// For small-to-medium repos we watch each indexed directory individually
// (NonRecursive). This avoids receiving events for gitignored paths like
// node_modules/ and keeps the event volume low.
//
// On macOS, each `watch()` call creates a separate FSEventStream. Large
// repos (e.g. Chromium with 487K+ files) can have tens of thousands of
// directories, which exhausts the per-process FSEvents stream limit and
// causes "unable to start FSEvent stream" errors. When the directory
// count exceeds the threshold we fall back to a single Recursive watch
// on the base path. FSEvents handles this efficiently with one kernel
// stream for the entire subtree. Gitignored paths are already filtered
// in the event handler via `should_include_file()`.
//
// On Linux (inotify), RecursiveMode::Recursive creates one kernel watch
// per subdirectory *including* gitignored ones, wasting file descriptors.
// The per-directory NonRecursive approach is always used on Linux.
//
// New directories created at runtime are detected via Create events on
// the parent and dynamically added by the owner thread via watch_tx.
if use_recursive {
debouncer.watch(base_path.as_path(), RecursiveMode::Recursive)?;
info!(
"File watcher initialized with single recursive watch on {} \
({} directories exceeded threshold of {})",
base_path.display(),
watch_dirs.len(),
MAX_MACOS_NONRECURSIVE_WATCHES,
);
} else {
debouncer.watch(base_path.as_path(), RecursiveMode::NonRecursive)?;
for dir in &watch_dirs {
match debouncer.watch(dir.as_path(), RecursiveMode::NonRecursive) {
Ok(()) => {}
Err(e) => {
// Non-fatal: directory may have been removed between discovery and watch
warn!("Failed to watch directory {}: {}", dir.display(), e);
}
}
}
info!(
"File watcher initialized for {} directories (NonRecursive) under {}",
watch_dirs.len(),
base_path.display()
);
}
// The .git directory is excluded from the file list but we still need
// to observe changes that affect git status (staging, unstaging,
// committing, branch switches, merges, etc.).
// When using recursive mode the base watch already covers .git/,
// but these targeted watches are cheap (at most 3 extra streams)
// and ensure we catch status changes even if the recursive backend
// coalesces or delays .git events.
watch_git_status_paths(&mut debouncer, git_workdir.as_ref());
Ok(debouncer)
}
pub fn stop(&self) {
if let Ok(Some(debouncer)) = self.debouncer.lock().map(|mut debouncer| debouncer.take()) {
drop(debouncer);
info!("Background file watcher stopped successfully");
} else {
error!("Failed to stop background watcher");
pub fn stop(&mut self) {
self.stop_signal.store(true, Ordering::Release);
if let Some(handle) = self.owner_thread.take() {
handle.thread().unpark();
if let Err(e) = handle.join() {
error!("Watcher owner thread panicked: {:?}", e);
}
}
info!("Background file watcher stopped successfully");
}
}
impl Drop for BackgroundWatcher {
fn drop(&mut self) {
if let Ok(mut debouncer_guard) = self.debouncer.lock() {
if let Some(debouncer) = debouncer_guard.take() {
drop(debouncer);
}
} else {
error!("Failed to acquire debouncer lock to drop");
}
self.stop();
}
}
#[tracing::instrument(name = "fs_events", skip(events), level = Level::DEBUG)]
fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<PathBuf>) {
#[tracing::instrument(name = "fs_events", skip(events, shared_picker, shared_frecency), level = Level::DEBUG)]
fn handle_debounced_events(
events: Vec<DebouncedEvent>,
git_workdir: &Option<PathBuf>,
shared_picker: &SharedPicker,
shared_frecency: &SharedFrecency,
mode: FFFMode,
) -> Vec<PathBuf> {
// this will be called very often, we have to minimiy the lock time for file picker
let repo = git_workdir.as_ref().and_then(|p| Repository::open(p).ok());
let mut need_full_rescan = false;
let mut need_full_git_rescan = false;
let mut paths_to_remove = Vec::new();
let mut paths_to_add_or_modify = Vec::new();
let mut new_dirs_to_watch = Vec::new();
let mut affected_paths_count = 0usize;
for debounced_event in &events {
@@ -116,6 +303,18 @@ fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<Pat
continue;
}
// When macOS FSEvents (or other backends) overflow their event buffer, the kernel
// drops individual events and emits a Rescan flag telling us to re-scan the subtree.
// Without handling this, modified source files can be silently missed.
if debounced_event.event.need_rescan() {
warn!(
"Received rescan event for paths {:?}, triggering full rescan",
debounced_event.event.paths
);
need_full_rescan = true;
break;
}
tracing::debug!(event = ?debounced_event.event, "Processing FS event");
for path in &debounced_event.event.paths {
if is_ignore_definition_path(path) {
@@ -131,14 +330,37 @@ fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<Pat
need_full_git_rescan = true;
}
if !should_include_file(path, &repo) {
if is_git_file(path) {
continue;
}
if !path.exists() {
// Use a combination of event kind and filesystem state to decide
// whether a path is an addition/modification or a removal.
//
// We cannot rely on `path.exists()` alone because:
// - A freshly created file might not be visible yet (race).
// - macOS FSEvents uses Modify(Name(Any)) for both rename-in
// and rename-out, so we must stat the path to disambiguate.
//
// We cannot rely on event kind alone because:
// - Remove events are not always emitted (macOS often sends
// Modify(Name(Any)) instead of Remove).
let is_removal = matches!(debounced_event.event.kind, EventKind::Remove(_));
if is_removal || !path.exists() {
paths_to_remove.push(path.as_path());
} else if path.is_dir() {
// New directory — collect it so the caller can register a
// watcher. No filesystem scanning: files that arrive later
// will be handled by the newly registered watch.
if !is_path_ignored(path, &repo) {
new_dirs_to_watch.push(path.to_path_buf());
}
} else {
paths_to_add_or_modify.push(path.as_path());
// For additions/modifications, still filter gitignored files.
if should_include_file(path, &repo) {
paths_to_add_or_modify.push(path.as_path());
}
}
}
@@ -160,8 +382,8 @@ fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<Pat
if need_full_rescan {
info!(?affected_paths_count, "Triggering full rescan");
trigger_full_rescan();
return;
trigger_full_rescan(shared_picker, shared_frecency);
return Vec::new();
}
// It's important to get the allocated sort
@@ -171,111 +393,246 @@ fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<Pat
paths_to_add_or_modify.dedup_by(|a, b| a.as_os_str().eq(b.as_os_str()));
info!(
"Event processing summary: {} to remove, {} to add/modify",
"Event processing summary: {} to remove, {} to add/modify, {} new dirs",
paths_to_remove.len(),
paths_to_add_or_modify.len()
paths_to_add_or_modify.len(),
new_dirs_to_watch.len()
);
// Apply file index updates (add/remove) unconditionally — these must
// happen even when there is no git repository.
let files_to_update_git_status =
if !paths_to_remove.is_empty() || !paths_to_add_or_modify.is_empty() {
debug!(
"Applying file index changes: {} to remove, {} to add/modify",
paths_to_remove.len(),
paths_to_add_or_modify.len(),
);
let apply_changes = |picker: &mut FilePicker| -> Vec<PathBuf> {
for path in &paths_to_remove {
let removed = picker.remove_file_by_path(path);
debug!("remove_file_by_path({:?}) -> {}", path, removed);
}
let mut files_to_update = Vec::with_capacity(paths_to_add_or_modify.len());
for path in &paths_to_add_or_modify {
let added = picker.on_create_or_modify(path).is_some();
if added {
debug!("on_create_or_modify({:?}) -> Some", path);
files_to_update.push(path.to_path_buf());
} else {
error!("on_create_or_modify({:?}) -> None (file not added!)", path);
}
}
info!(
"apply_changes complete: {} files to update git status",
files_to_update.len()
);
files_to_update
};
let Ok(mut guard) = shared_picker.write() else {
error!("Failed to acquire file picker write lock");
return new_dirs_to_watch;
};
let Some(ref mut picker) = *guard else {
error!("File picker not initialized");
return new_dirs_to_watch;
};
apply_changes(picker)
} else {
debug!("No file index changes to apply");
Vec::new()
};
// AI mode: auto-track frecency for all modified/created files.
// Uses a 5-minute cooldown per file to prevent score inflation from rapid
// burst edits (AI agents often edit the same file many times in minutes).
// This runs after apply_changes so the picker write lock is released.
if mode.is_ai() && !paths_to_add_or_modify.is_empty() {
let mut tracked_count = 0usize;
if let Ok(frecency_guard) = shared_frecency.read()
&& let Some(ref frecency) = *frecency_guard
{
for path in &paths_to_add_or_modify {
// Skip if this file was tracked less than 5 minutes ago
let should_track = match frecency.seconds_since_last_access(path) {
Ok(Some(secs)) => secs >= AI_MODE_COOLDOWN_SECS,
Ok(None) => true, // Never tracked before
Err(_) => true, // DB error, track anyway
};
if !should_track {
continue;
}
if let Err(e) = frecency.track_access(path) {
error!("Failed to track frecency for {:?}: {:?}", path, e);
} else {
tracked_count += 1;
}
}
if tracked_count > 0 {
info!("AI mode: tracked frecency for {} files", tracked_count);
}
}
// Update in-memory frecency scores for tracked files
if tracked_count > 0
&& let Ok(mut picker_guard) = shared_picker.write()
&& let Some(ref mut picker) = *picker_guard
&& let Ok(frecency_guard) = shared_frecency.read()
&& let Some(ref frecency) = *frecency_guard
{
for path in &paths_to_add_or_modify {
let _ = picker.update_single_file_frecency(path, frecency);
}
}
}
// Git status updates require a repository.
let Some(repo) = repo.as_ref() else {
info!("No git repo, skipping git status updates");
return;
debug!("No git repo available, skipping git status updates");
return new_dirs_to_watch;
};
if need_full_git_rescan {
info!("Triggering full git rescan");
if let Err(e) = FilePicker::refresh_git_status_global() {
let result = shared_picker.refresh_git_status(shared_frecency);
if let Err(e) = result {
error!("Failed to refresh git status: {:?}", e);
}
return;
return new_dirs_to_watch;
}
if paths_to_remove.is_empty() && paths_to_add_or_modify.is_empty() {
return;
}
if !files_to_update_git_status.is_empty() {
info!(
"Fetching git status for {} files",
files_to_update_git_status.len()
);
let files_to_update_git_status = {
let Ok(mut file_picker_guard) = FILE_PICKER.write() else {
error!("Failed to acquire file picker write lock");
return;
};
let Some(ref mut picker) = *file_picker_guard else {
error!("File picker not initialized");
return;
};
// Apply file removals
for path in paths_to_remove {
picker.remove_file_by_path(path);
}
// Apply file additions/modifications and collect paths for git status update
let mut files_to_update_git_status = Vec::with_capacity(paths_to_add_or_modify.len());
for path in paths_to_add_or_modify {
if let Some(file) = picker.on_create_or_modify(path) {
files_to_update_git_status.push(file.path.clone());
let status = match GitStatusCache::git_status_for_paths(repo, &files_to_update_git_status) {
Ok(status) => status,
Err(e) => {
tracing::error!(?e, "Failed to query git status");
return new_dirs_to_watch;
}
}
};
files_to_update_git_status
};
info!(
"Fetching git status for {} files",
files_to_update_git_status.len()
);
let status = match GitStatusCache::git_status_for_paths(repo, &files_to_update_git_status) {
Ok(status) => status,
Err(e) => {
tracing::error!(?e, "Failed to query git statue");
return;
}
};
// only lock the picker for theshortest possitble time
if let Ok(mut file_picker_guard) = FILE_PICKER.write()
&& let Some(ref mut picker) = *file_picker_guard
{
if let Err(e) = picker.update_git_statuses(status) {
error!("Failed to update git statuses: {:?}", e);
if let Ok(mut guard) = shared_picker.write()
&& let Some(ref mut picker) = *guard
{
if let Err(e) = picker.update_git_statuses(status, shared_frecency) {
error!("Failed to update git statuses: {:?}", e);
} else {
info!("Successfully updated git statuses in picker");
}
} else {
info!("Successfully updated git statuses in picker");
error!("Failed to acquire picker lock for git status update");
}
} else {
error!("Failed to acquire picker lock for git status update");
}
new_dirs_to_watch
}
fn trigger_full_rescan() {
fn trigger_full_rescan(shared_picker: &SharedPicker, shared_frecency: &SharedFrecency) {
info!("Triggering full filesystem rescan");
let Ok(mut file_picker_guard) = FILE_PICKER.write() else {
// Note: no need to clear mmaps — they are backed by the kernel page cache
// and automatically reflect file changes. Old FileItems (and their mmaps)
// are dropped when the picker rebuilds its file list.
let Ok(mut guard) = shared_picker.write() else {
error!("Failed to acquire file picker write lock for full rescan");
return;
};
let Some(ref mut picker) = *file_picker_guard else {
let Some(ref mut picker) = *guard else {
error!("File picker not initialized, cannot trigger rescan");
return;
};
if let Err(e) = picker.trigger_rescan() {
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.
if shared_picker.need_complex_rebuild() {
picker.spawn_post_rescan_rebuild(shared_picker.clone());
}
}
/// After registering a watch on a newly created directory, list its
/// immediate children and add any files to the picker.
fn inject_existing_files(dir: &Path, shared_picker: &SharedPicker, git_workdir: &Option<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let repo = git_workdir.as_ref().and_then(|p| Repository::open(p).ok());
let mut files_to_add = Vec::new();
for entry in entries.flatten() {
if entry.file_type().is_ok_and(|ft| ft.is_file()) {
let path = entry.path();
if should_include_file(&path, &repo) {
files_to_add.push(path);
}
}
}
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);
}
debug!(
"Injected {} existing files from new directory {}",
files_to_add.len(),
dir.display(),
);
}
fn should_include_file(path: &Path, repo: &Option<Repository>) -> bool {
if !path.is_file() || is_git_file(path) {
// Directories are not indexed — only regular files (and symlinks to files).
if path.is_dir() {
return false;
}
repo.as_ref()
.is_some_and(|repo| repo.is_path_ignored(path) == Ok(false))
match repo.as_ref() {
Some(repo) => repo.is_path_ignored(path) != Ok(true),
None => {
// No git repo — apply basic sanity filters.
// Hidden directories are skipped by the watcher setup (hidden(true)),
// but events can still arrive for files in known non-code directories.
!is_non_code_directory(path)
}
}
}
fn is_non_code_directory(path: &Path) -> bool {
crate::ignore::is_non_code_directory(path)
}
#[inline]
fn is_path_ignored(path: &Path, repo: &Option<Repository>) -> bool {
match repo.as_ref() {
Some(repo) => repo.is_path_ignored(path) == Ok(true),
None => is_non_code_directory(path),
}
}
#[inline]
@@ -284,7 +641,7 @@ fn is_git_file(path: &Path) -> bool {
.any(|component| component.as_os_str() == ".git")
}
pub fn is_dotgit_change_affecting_status(changed: &Path, repo: &Option<Repository>) -> bool {
fn is_dotgit_change_affecting_status(changed: &Path, repo: &Option<Repository>) -> bool {
let Some(repo) = repo.as_ref() else {
return false;
};
@@ -324,3 +681,37 @@ fn is_ignore_definition_path(path: &Path) -> bool {
Some(".ignore") | Some(".gitignore")
)
}
fn watch_git_status_paths(debouncer: &mut Debouncer, git_workdir: Option<&PathBuf>) {
let Some(workdir) = git_workdir else {
return;
};
let git_dir = workdir.join(".git");
if !git_dir.is_dir() {
return;
}
// Watch .git/ non-recursively to catch top-level files:
// index, index.lock, HEAD, packed-refs, MERGE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD
if let Err(e) = debouncer.watch(&git_dir, RecursiveMode::NonRecursive) {
warn!("Failed to watch .git directory: {}", e);
return;
}
// Watch refs/ recursively to catch branch/tag changes
let refs_dir = git_dir.join("refs");
if refs_dir.is_dir()
&& let Err(e) = debouncer.watch(&refs_dir, RecursiveMode::Recursive)
{
warn!("Failed to watch .git/refs: {}", e);
}
// Watch info/ non-recursively for exclude and sparse-checkout
let info_dir = git_dir.join("info");
if info_dir.is_dir()
&& let Err(e) = debouncer.watch(&info_dir, RecursiveMode::NonRecursive)
{
warn!("Failed to watch .git/info: {}", e);
}
}
+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()
}
}
+998
View File
@@ -0,0 +1,998 @@
//! Regex → bigram decomposition for the inverted bigram index.
//!
//! Parses a regex pattern with `regex-syntax`, walks the HIR to extract
//! guaranteed bigram keys (u16), and evaluates them as an AND/OR query tree
//! against [`BigramFilter`]'s inverted posting lists.
//!
//! Two bigram types are extracted:
//! - **Consecutive** (gap=0): adjacent byte pairs `(pattern[i], pattern[i+1])`
//! - **Sparse-1** (gap=1): pairs across a single-byte wildcard, e.g. `a.b → (a,b)`
//!
//! The sparse-1 extraction is the key feature: regex patterns like `foo.bar`
//! yield the cross-boundary sparse-1 bigram `(o,b)` that provides strong
//! filtering even when the `.` prevents any consecutive cross-boundary bigram.
use crate::bigram_filter::BigramFilter;
use regex_syntax::hir::{Class, Hir, HirKind};
use smallvec::SmallVec;
use std::borrow::Cow;
/// Maximum byte values to enumerate from a character class.
/// Larger classes are treated as unknown (no bigram extractable).
const MAX_CLASS_EXPAND: usize = 16;
#[inline]
fn consec_key(a: u8, b: u8) -> Option<u16> {
let al = a.to_ascii_lowercase();
let bl = b.to_ascii_lowercase();
if (32..=126).contains(&al) && (32..=126).contains(&bl) {
Some((al as u16) << 8 | bl as u16)
} else {
None
}
}
#[derive(Debug, Clone)]
pub enum BigramQuery {
Any,
/// A consecutive bigram key to look up in the main index.
Consec(u16),
/// A skip-1 bigram key to look up in the skip sub-index.
Skip1(u16),
/// All children must match (intersect posting lists).
And(Vec<BigramQuery>),
/// At least one child must match (union posting lists).
Or(Vec<BigramQuery>),
}
/// SIMD-friendly bitwise OR of two equal-length bitsets.
#[inline]
fn bitset_or(a: &mut [u64], b: &[u64]) {
a.iter_mut().zip(b.iter()).for_each(|(x, y)| *x |= *y);
}
/// SIMD-friendly bitwise AND of two equal-length bitsets.
#[inline]
fn bitset_and(a: &mut [u64], b: &[u64]) {
a.iter_mut().zip(b.iter()).for_each(|(x, y)| *x &= *y);
}
impl BigramQuery {
pub fn is_any(&self) -> bool {
matches!(self, BigramQuery::Any)
}
pub(crate) fn evaluate(&self, index: &BigramFilter) -> Option<Vec<u64>> {
self.evaluate_cow(index).map(Cow::into_owned)
}
fn evaluate_cow<'a>(&self, index: &'a BigramFilter) -> Option<Cow<'a, [u64]>> {
match self {
BigramQuery::Any => None,
BigramQuery::Consec(key) => {
let col = index.lookup()[*key as usize];
if col == u16::MAX {
return None;
}
let words = index.words();
let offset = col as usize * words;
let data = index.dense_data();
if offset + words > data.len() {
return None;
}
Some(Cow::Borrowed(&data[offset..offset + words]))
}
BigramQuery::Skip1(key) => {
let skip = index.skip_index()?;
let col = skip.lookup()[*key as usize];
if col == u16::MAX {
return None;
}
let words = skip.words();
let offset = col as usize * words;
let data = skip.dense_data();
if offset + words > data.len() {
return None;
}
Some(Cow::Borrowed(&data[offset..offset + words]))
}
BigramQuery::And(children) => {
let mut result: Option<Vec<u64>> = None;
for child in children {
if let Some(child_bits) = child.evaluate_cow(index) {
result = Some(match result {
None => child_bits.into_owned(),
Some(mut r) => {
bitset_and(&mut r, &child_bits);
r
}
});
}
}
result.map(Cow::Owned)
}
BigramQuery::Or(children) => {
if children.is_empty() {
return None;
}
let mut result: Option<Vec<u64>> = None;
for child in children {
match child.evaluate_cow(index) {
// Any branch can't be filtered → whole OR can't be filtered
None => return None,
Some(child_bits) => {
result = Some(match result {
None => child_bits.into_owned(),
Some(mut r) => {
bitset_or(&mut r, &child_bits);
r
}
});
}
}
}
result.map(Cow::Owned)
}
}
}
}
/// Intermediate state tracked during HIR traversal for bigram extraction.
struct HirInfo {
query: BigramQuery,
/// Possible first bytes (lowercased, printable ASCII) when this node matches.
first: Option<SmallVec<[u8; MAX_CLASS_EXPAND]>>,
/// Possible last bytes.
last: Option<SmallVec<[u8; MAX_CLASS_EXPAND]>>,
/// Whether this node can match the empty string.
can_be_empty: bool,
}
impl HirInfo {
fn empty() -> Self {
Self {
query: BigramQuery::Any,
first: None,
last: None,
can_be_empty: true,
}
}
}
/// Prefilter fuzzy query. The algorithm is the following:
/// we allow max_typos = min(len/3,2) every typo destroys at most 2 consecutive bigrams
/// So out of N bigrams at least N - 2 * max_typos have to present in the matching fil
pub(crate) fn fuzzy_to_bigram_query(query: &str, num_probes: usize) -> BigramQuery {
let lower: Vec<u8> = query.bytes().map(|b| b.to_ascii_lowercase()).collect();
if lower.len() < 2 {
return BigramQuery::Any;
}
let max_typos = (lower.len() / 3).min(2);
// Extract all consecutive bigram keys.
let bigram_keys: Vec<u16> = lower
.windows(2)
.filter_map(|w| consec_key(w[0], w[1]))
.collect();
if bigram_keys.is_empty() {
return BigramQuery::Any;
}
// For very short queries (0 typos), AND all bigrams — exact subsequence.
if max_typos == 0 {
return simplify_and(
bigram_keys
.iter()
.map(|&k| BigramQuery::Consec(k))
.collect(),
);
}
// Pick evenly-spaced probe bigrams.
let n = num_probes.min(bigram_keys.len());
if n <= max_typos {
// Too few probes to require anything useful.
return simplify_or(
bigram_keys
.iter()
.map(|&k| BigramQuery::Consec(k))
.collect(),
);
}
let probes: Vec<u16> = if n == bigram_keys.len() {
bigram_keys
} else {
(0..n)
.map(|i| {
let idx = i * (bigram_keys.len() - 1) / (n - 1);
bigram_keys[idx]
})
.collect()
};
let required = n - max_typos;
// If required == n, just AND all probes.
if required >= n {
return simplify_and(probes.iter().map(|&k| BigramQuery::Consec(k)).collect());
}
// Generate all C(n, required) subsets → OR(AND(subset), ...)
let mut branches = Vec::new();
let mut combo = vec![0u16; required];
combine(&probes, required, 0, 0, &mut combo, &mut branches);
simplify_or(branches)
}
/// Build C(n, k) combination branches in-place on a fixed-size slice.
fn combine(
items: &[u16],
k: usize,
start: usize,
depth: usize,
combo: &mut [u16],
branches: &mut Vec<BigramQuery>,
) {
if depth == k {
branches.push(simplify_and(
combo.iter().map(|&key| BigramQuery::Consec(key)).collect(),
));
return;
}
let remaining = k - depth;
for i in start..=items.len() - remaining {
combo[depth] = items[i];
combine(items, k, i + 1, depth + 1, combo, branches);
}
}
pub(crate) fn regex_to_bigram_query(pattern: &str) -> BigramQuery {
let mut parser = regex_syntax::ParserBuilder::new()
.unicode(false)
.utf8(false)
.build();
let hir = match parser.parse(pattern) {
Ok(h) => h,
Err(_) => return BigramQuery::Any,
};
decompose(&hir).query
}
fn decompose(hir: &Hir) -> HirInfo {
let can_be_empty = hir.properties().minimum_len().is_none_or(|n| n == 0);
match hir.kind() {
HirKind::Empty => HirInfo::empty(),
HirKind::Literal(lit) => decompose_literal(lit.0.as_ref()),
HirKind::Class(class) => {
let bytes = expand_class(class);
match bytes {
Some(b) if !b.is_empty() => HirInfo {
query: BigramQuery::Any,
first: Some(b.clone()),
last: Some(b),
can_be_empty,
},
_ => HirInfo {
query: BigramQuery::Any,
first: None,
last: None,
can_be_empty,
},
}
}
HirKind::Look(_) => HirInfo::empty(),
HirKind::Repetition(rep) => {
let inner = decompose(&rep.sub);
if rep.min == 0 {
HirInfo {
query: BigramQuery::Any,
first: inner.first,
last: inner.last,
can_be_empty: true,
}
} else {
// min >= 1: inner bigrams guaranteed
let mut qs = Vec::new();
if !inner.query.is_any() {
qs.push(inner.query.clone());
}
// min >= 2: cross-boundary between consecutive occurrences
if rep.min >= 2 {
push_cross_consec(&mut qs, inner.last.as_deref(), inner.first.as_deref());
}
HirInfo {
query: simplify_and(qs),
first: inner.first,
last: inner.last,
can_be_empty,
}
}
}
HirKind::Capture(cap) => decompose(&cap.sub),
HirKind::Concat(parts) => decompose_concat(parts),
HirKind::Alternation(alts) => decompose_alternation(alts),
}
}
/// Extract bigrams from a literal byte sequence.
fn decompose_literal(bytes: &[u8]) -> HirInfo {
if bytes.is_empty() {
return HirInfo::empty();
}
let lower: SmallVec<[u8; 64]> = bytes.iter().map(|b| b.to_ascii_lowercase()).collect();
if lower.len() == 1 {
let b = lower[0];
let first = if (32..=126).contains(&b) {
Some(SmallVec::from_slice(&[b]))
} else {
None
};
return HirInfo {
query: BigramQuery::Any,
first: first.clone(),
last: first,
can_be_empty: false,
};
}
let mut qs: Vec<BigramQuery> = Vec::new();
// Consecutive bigrams
for w in lower.windows(2) {
if let Some(k) = consec_key(w[0], w[1]) {
qs.push(BigramQuery::Consec(k));
}
}
// Skip-1 bigrams from the literal itself
if lower.len() >= 3 {
for i in 0..lower.len() - 2 {
if let Some(k) = consec_key(lower[i], lower[i + 2]) {
qs.push(BigramQuery::Skip1(k));
}
}
}
let first_byte = lower[0];
let last_byte = *lower.last().unwrap();
HirInfo {
query: simplify_and(qs),
first: if (32..=126).contains(&first_byte) {
Some(SmallVec::from_slice(&[first_byte]))
} else {
None
},
last: if (32..=126).contains(&last_byte) {
Some(SmallVec::from_slice(&[last_byte]))
} else {
None
},
can_be_empty: false,
}
}
fn decompose_concat(parts: &[Hir]) -> HirInfo {
if parts.is_empty() {
return HirInfo::empty();
}
let infos: Vec<HirInfo> = parts.iter().map(decompose).collect();
let mut qs: Vec<BigramQuery> = Vec::new();
// 1. Collect child bigrams
for info in &infos {
if !info.query.is_any() {
qs.push(info.query.clone());
}
}
// 2. Dense cross-boundary between adjacent mandatory parts
for pair in infos.windows(2) {
if !pair[0].can_be_empty && !pair[1].can_be_empty {
push_cross_consec(&mut qs, pair[0].last.as_deref(), pair[1].first.as_deref());
}
}
// 3. Sparse-1 cross-boundary: across a single 1-byte-wide middle part.
// Catches `foo.bar` → sparse-1 `(o,b)` across the dot.
if parts.len() >= 3 {
for i in 0..parts.len() - 2 {
let left = &infos[i];
let mid = &parts[i + 1];
let right = &infos[i + 2];
let min_len = mid.properties().minimum_len();
let max_len = mid.properties().maximum_len();
let is_1byte = min_len == Some(1) && max_len == Some(1);
if is_1byte && !left.can_be_empty && !right.can_be_empty {
push_cross_skip1(&mut qs, left.last.as_deref(), right.first.as_deref());
}
}
}
let first = collect_first(&infos);
let last = collect_last(&infos);
let can_be_empty = infos.iter().all(|i| i.can_be_empty);
HirInfo {
query: simplify_and(qs),
first,
last,
can_be_empty,
}
}
fn decompose_alternation(alts: &[Hir]) -> HirInfo {
if alts.is_empty() {
return HirInfo::empty();
}
let infos: Vec<HirInfo> = alts.iter().map(decompose).collect();
let query = simplify_or(infos.iter().map(|i| i.query.clone()).collect());
let first = merge_byte_sets(infos.iter().map(|i| &i.first));
let last = merge_byte_sets(infos.iter().map(|i| &i.last));
let can_be_empty = infos.iter().any(|i| i.can_be_empty);
HirInfo {
query,
first,
last,
can_be_empty,
}
}
fn expand_class(class: &Class) -> Option<SmallVec<[u8; MAX_CLASS_EXPAND]>> {
let mut bytes: SmallVec<[u8; MAX_CLASS_EXPAND]> = SmallVec::new();
match class {
Class::Bytes(bc) => {
for range in bc.ranges() {
let count = (range.end() as usize) - (range.start() as usize) + 1;
if bytes.len() + count > MAX_CLASS_EXPAND {
return None;
}
for b in range.start()..=range.end() {
if (32..=126).contains(&b) {
let lower = b.to_ascii_lowercase();
if !bytes.contains(&lower) {
bytes.push(lower);
}
}
}
}
}
Class::Unicode(uc) => {
for range in uc.ranges() {
let start = range.start() as u32;
let end = range.end() as u32;
if start > 127 {
continue;
}
let ascii_end = end.min(126) as u8;
let ascii_start = start.max(32) as u8;
if ascii_start > ascii_end {
continue;
}
let count = (ascii_end - ascii_start) as usize + 1;
if bytes.len() + count > MAX_CLASS_EXPAND {
return None;
}
for b in ascii_start..=ascii_end {
let lower = b.to_ascii_lowercase();
if !bytes.contains(&lower) {
bytes.push(lower);
}
}
}
}
}
if bytes.is_empty() { None } else { Some(bytes) }
}
/// Push consecutive cross-product bigrams into `qs`.
fn push_cross_consec(qs: &mut Vec<BigramQuery>, last: Option<&[u8]>, first: Option<&[u8]>) {
if let Some(q) = cross_product(last, first, false) {
qs.push(q);
}
}
/// Push skip-1 cross-product bigrams into `qs`.
fn push_cross_skip1(qs: &mut Vec<BigramQuery>, last: Option<&[u8]>, first: Option<&[u8]>) {
if let Some(q) = cross_product(last, first, true) {
qs.push(q);
}
}
fn cross_product(last: Option<&[u8]>, first: Option<&[u8]>, skip: bool) -> Option<BigramQuery> {
let last = last?;
let first = first?;
let n = last.len() * first.len();
if n == 0 || n > MAX_CLASS_EXPAND * MAX_CLASS_EXPAND {
return None;
}
let mut bigrams: Vec<BigramQuery> = Vec::with_capacity(n);
for &l in last {
for &f in first {
if let Some(k) = consec_key(l, f) {
let node = if skip {
BigramQuery::Skip1(k)
} else {
BigramQuery::Consec(k)
};
bigrams.push(node);
}
}
}
match bigrams.len() {
0 => None,
1 => Some(bigrams.into_iter().next().unwrap()),
_ => Some(simplify_or(bigrams)),
}
}
fn collect_first(infos: &[HirInfo]) -> Option<SmallVec<[u8; MAX_CLASS_EXPAND]>> {
let mut result: SmallVec<[u8; MAX_CLASS_EXPAND]> = SmallVec::new();
for info in infos {
if let Some(ref bytes) = info.first {
for &b in bytes {
if !result.contains(&b) {
if result.len() >= MAX_CLASS_EXPAND {
return None;
}
result.push(b);
}
}
} else if !info.can_be_empty {
return None;
}
if !info.can_be_empty {
break;
}
}
if result.is_empty() {
None
} else {
Some(result)
}
}
fn collect_last(infos: &[HirInfo]) -> Option<SmallVec<[u8; MAX_CLASS_EXPAND]>> {
let mut result: SmallVec<[u8; MAX_CLASS_EXPAND]> = SmallVec::new();
for info in infos.iter().rev() {
if let Some(ref bytes) = info.last {
for &b in bytes {
if !result.contains(&b) {
if result.len() >= MAX_CLASS_EXPAND {
return None;
}
result.push(b);
}
}
} else if !info.can_be_empty {
return None;
}
if !info.can_be_empty {
break;
}
}
if result.is_empty() {
None
} else {
Some(result)
}
}
fn merge_byte_sets<'a>(
iter: impl Iterator<Item = &'a Option<SmallVec<[u8; MAX_CLASS_EXPAND]>>>,
) -> Option<SmallVec<[u8; MAX_CLASS_EXPAND]>> {
let mut result: SmallVec<[u8; MAX_CLASS_EXPAND]> = SmallVec::new();
for opt in iter {
match opt {
None => return None,
Some(bytes) => {
for &b in bytes {
if !result.contains(&b) {
if result.len() >= MAX_CLASS_EXPAND {
return None;
}
result.push(b);
}
}
}
}
}
if result.is_empty() {
None
} else {
Some(result)
}
}
fn simplify_and(children: Vec<BigramQuery>) -> BigramQuery {
let mut flat: Vec<BigramQuery> = Vec::new();
for child in children {
match child {
BigramQuery::Any => {}
BigramQuery::And(inner) => flat.extend(inner),
other => flat.push(other),
}
}
match flat.len() {
0 => BigramQuery::Any,
1 => flat.into_iter().next().unwrap(),
_ => BigramQuery::And(flat),
}
}
fn simplify_or(children: Vec<BigramQuery>) -> BigramQuery {
if children.iter().any(|c| c.is_any()) {
return BigramQuery::Any;
}
let mut flat: Vec<BigramQuery> = Vec::new();
for child in children {
match child {
BigramQuery::Or(inner) => flat.extend(inner),
other => flat.push(other),
}
}
match flat.len() {
0 => BigramQuery::Any,
1 => flat.into_iter().next().unwrap(),
_ => BigramQuery::Or(flat),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bigram_filter::BigramIndexBuilder;
/// Build a tiny index from the given file contents for testing.
fn build_test_index(files: &[&[u8]]) -> BigramFilter {
let n = files.len();
let consec_builder = BigramIndexBuilder::new(n);
let skip_builder = BigramIndexBuilder::new(n);
for (i, content) in files.iter().enumerate() {
consec_builder.add_file_content(&skip_builder, i, content);
}
let mut idx = consec_builder.compress(Some(0));
idx.set_skip_index(skip_builder.compress(Some(0)));
idx
}
#[test]
fn literal_pattern() {
let idx = build_test_index(&[
b"hello world", // 0: contains "hello"
b"goodbye world", // 1: no "hello"
b"say hello there", // 2: contains "hello"
]);
let q = regex_to_bigram_query("hello");
assert!(!q.is_any());
let candidates = q.evaluate(&idx).unwrap();
assert!(BigramFilter::is_candidate(&candidates, 0));
assert!(!BigramFilter::is_candidate(&candidates, 1));
assert!(BigramFilter::is_candidate(&candidates, 2));
}
#[test]
fn alternation() {
let idx = build_test_index(&[
b"has foo in it", // 0
b"has bar in it", // 1
b"has xyz in it", // 2
]);
let q = regex_to_bigram_query("foo|bar");
assert!(!q.is_any());
let candidates = q.evaluate(&idx).unwrap();
assert!(BigramFilter::is_candidate(&candidates, 0));
assert!(BigramFilter::is_candidate(&candidates, 1));
// xyz doesn't contain foo or bar bigrams
assert!(!BigramFilter::is_candidate(&candidates, 2));
}
#[test]
fn wildcard_concat() {
let idx = build_test_index(&[
b"foo something bar", // 0
b"foo only", // 1: has foo but not bar
b"only bar", // 2: has bar but not foo
]);
let q = regex_to_bigram_query("foo.*bar");
assert!(!q.is_any());
let candidates = q.evaluate(&idx).unwrap();
assert!(BigramFilter::is_candidate(&candidates, 0));
// file 1 and 2 should be filtered (missing bigrams from "bar" / "foo")
assert!(!BigramFilter::is_candidate(&candidates, 1));
assert!(!BigramFilter::is_candidate(&candidates, 2));
}
#[test]
fn sparse1_across_dot() {
// "a.b" should produce a skip-1 bigram (a,b)
let idx = build_test_index(&[
b"axb", // 0: has sparse-1 (a,b)
b"ayb", // 1: has sparse-1 (a,b)
b"xyz", // 2: no (a,b) at all
]);
let q = regex_to_bigram_query("a.b");
assert!(!q.is_any());
let candidates = q.evaluate(&idx).unwrap();
assert!(BigramFilter::is_candidate(&candidates, 0));
assert!(BigramFilter::is_candidate(&candidates, 1));
assert!(!BigramFilter::is_candidate(&candidates, 2));
}
#[test]
fn sparse1_across_digit() {
// "foo\dbar" → sparse-1 (o,b) across \d
let idx = build_test_index(&[
b"foo3bar baz", // 0: has all bigrams
b"foobar baz", // 1: has consecutive (o,b) but pattern needs sparse-1
b"xyz only", // 2: no relevant bigrams
]);
let q = regex_to_bigram_query(r"foo\dbar");
assert!(!q.is_any());
let candidates = q.evaluate(&idx).unwrap();
assert!(BigramFilter::is_candidate(&candidates, 0));
// file 1 may or may not match depending on what bigrams are in the index
// (it has all the literal bigrams and also o,b as both consec and skip-1)
// The important thing is file 2 is excluded:
assert!(!BigramFilter::is_candidate(&candidates, 2));
}
#[test]
fn pure_wildcard_is_any() {
let q = regex_to_bigram_query(".*");
assert!(q.is_any());
}
#[test]
fn single_char_is_any() {
let q = regex_to_bigram_query("a");
assert!(q.is_any());
}
#[test]
fn invalid_regex_is_any() {
let q = regex_to_bigram_query("[invalid");
assert!(q.is_any());
}
#[test]
fn optional_group_excluded() {
// (bar)? is optional — its bigrams are not required
let q = regex_to_bigram_query("foo(bar)?baz");
assert!(!q.is_any());
let idx = build_test_index(&[
b"foobaz content", // 0: has foo+baz bigrams (bar absent)
b"foobarbaz content", // 1: has everything
b"xyz only", // 2: nothing
]);
let candidates = q.evaluate(&idx).unwrap();
assert!(BigramFilter::is_candidate(&candidates, 0));
assert!(BigramFilter::is_candidate(&candidates, 1));
assert!(!BigramFilter::is_candidate(&candidates, 2));
}
#[test]
fn repetition_min2_cross_boundary() {
// (ab){2,} → bigram "ab" + cross-boundary "b","a"
let q = regex_to_bigram_query("(ab){2,}");
assert!(!q.is_any());
let idx = build_test_index(&[
b"ababab", // 0: has "ab" and "b"->"a"
b"abonly", // 1: has "ab" but not "b"->"a"
b"xyz", // 2: nothing
]);
let candidates = q.evaluate(&idx).unwrap();
assert!(BigramFilter::is_candidate(&candidates, 0));
assert!(!BigramFilter::is_candidate(&candidates, 2));
}
#[test]
fn two_dots_no_sparse1() {
// "a..b" — two 1-byte parts between a and b, not a single 1-byte part
// No sparse-1 (a,b) should be extracted
let q = regex_to_bigram_query("a..b");
// Single-char literals with 2 unknown bytes between → Any
assert!(q.is_any());
}
#[test]
fn character_class_cross_boundary() {
// [abc]de → cross-boundary OR(ad,bd,cd) + bigram de
// All three class variants must appear in the corpus so the OR
// branches are tracked in the index (untracked bigrams make the
// OR conservatively return None, which is correct but untestable).
let idx = build_test_index(&[
b"ade content", // 0: has ad
b"bde content", // 1: has bd
b"cde content", // 2: has cd
b"xde content", // 3: has de but not ad/bd/cd
]);
let q = regex_to_bigram_query("[abc]de");
assert!(!q.is_any());
let candidates = q.evaluate(&idx).unwrap();
assert!(BigramFilter::is_candidate(&candidates, 0));
assert!(BigramFilter::is_candidate(&candidates, 1));
assert!(BigramFilter::is_candidate(&candidates, 2));
// file 3 doesn't have ad/bd/cd so should be filtered
assert!(!BigramFilter::is_candidate(&candidates, 3));
}
// ── Helpers for inspecting query trees ──────────────────────────
fn has_consec(q: &BigramQuery, a: u8, b: u8) -> bool {
let Some(key) = consec_key(a, b) else {
return false;
};
match q {
BigramQuery::Consec(k) => *k == key,
BigramQuery::And(cs) | BigramQuery::Or(cs) => cs.iter().any(|c| has_consec(c, a, b)),
_ => false,
}
}
fn has_skip1(q: &BigramQuery, a: u8, b: u8) -> bool {
let Some(key) = consec_key(a, b) else {
return false;
};
match q {
BigramQuery::Skip1(k) => *k == key,
BigramQuery::And(cs) | BigramQuery::Or(cs) => cs.iter().any(|c| has_skip1(c, a, b)),
_ => false,
}
}
/// Bigram expectation: `("ab", is_skip1)`.
/// The 2-char str is the byte pair; C = consecutive, S = skip-1.
type Bg = (&'static str, bool);
const C: bool = false;
const S: bool = true;
/// Top 15+ commonly used regex patterns from
/// https://digitalfortress.tech/tips/top-15-commonly-used-regex/
/// plus typical grep patterns used by agentic tools.
///
/// Each entry: `(regex, Option<&[Bg]>)`.
/// - `None` → pure classes / unsupported syntax, Any is acceptable.
/// - `Some(&[..])` → must be non-Any, and every listed bigram must appear.
#[test]
fn common_regex_patterns() {
#[rustfmt::skip]
let cases: &[(&str, Option<&[Bg]>)] = &[
// ── Pure-class / anchor / unsupported → Any is fine ──────
(r"^\d+$", None), // 1. whole numbers
(r"^\d*\.\d+$", None), // 2. decimals
(r"^\d*(\.\d+)?$", None), // 3. whole + decimal
(r"^-?\d*(\.\d+)?$", None), // 4. neg/pos decimal
(r"[-]?[0-9]+[,.]?[0-9]*([/][0-9]+[,.]?[0-9]*)*", None), // 5. fractions
(r"^[a-zA-Z0-9]*$", None), // 6. alphanumeric
(r"^[a-zA-Z0-9 ]*$", None), // 7. alphanum + space
(r"^([a-zA-Z0-9._%-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6})*$", None), // 8. email
(r"^([a-z0-9_\.\+-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})$", None), // 9. email v2
(r"(?=(.*[0-9]))(?=.*[!@#$%^&*()\[\]{}\-_+=~`|:;<>,./?\x5c])(?=.*[a-z])(?=(.*[A-Z]))(?=(.*)).{8,}", None), // 10. complex pw
(r"(?=(.*[0-9]))((?=.*[A-Za-z0-9])(?=.*[A-Z])(?=.*[a-z]))^.{8,}$", None), // 11. moderate pw
(r"^[a-z0-9_-]{3,16}$", None), // 12. username
(r"(https?://)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)", None), // 14. URL optional
(r"^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$", None), // 15. IPv4
(r"(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))", None), // 16. IPv6
(r"[12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])", None), // 17. date
(r"^(0?[1-9]|1[0-2]):[0-5][0-9]$", None), // 18. time 12h
(r"((1[0-2]|0?[1-9]):([0-5][0-9]) ?([AaPp][Mm]))", None), // 19. time AM/PM
(r"^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$", None), // 20. time 24h
(r"^([0-9]|0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$", None), // 21. time 24h v2
(r"(?:[01]\d|2[0123]):(?:[012345]\d):(?:[012345]\d)", None), // 22. time+sec
(r"</?[\w\s]*>|<.+[\W]>", None), // 23. HTML tag
(r"\bon\w+=\S+(?=.*>)", None), // 24. inline JS
(r"^[a-z0-9]+(?:-[a-z0-9]+)*$", None), // 25. slug
(r"(\b\w+\b)(?=.*\b\1\b)", None), // 26. dup words
(r"^[\w,\s-]+\.[A-Za-z]{3}$", None), // 27. filename
(r"^[A-PR-WY][1-9]\d\s?\d{4}[1-9]$", None), // 28. HK ID
// ── Patterns with extractable literal bigrams ────────────
// 13. URL with required protocol
(r"https?://(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)", Some(&[
("ht", C), ("tt", C), ("tp", C), // from "http"
("ht", S), ("tp", S), // from "http" skip-1
(":/", C), ("//", C), // from "://"
])),
// 29. fn\s+\w+
(r"fn\s+\w+", Some(&[
("fn", C), // from "fn"
("n ", C), // cross-boundary: 'n' → \s starts ' '
])),
// 30. use\s+crate::
(r"use\s+crate::", Some(&[
("us", C), ("se", C), ("ue", S), // from "use"
("cr", C), ("ra", C), ("at", C), // from "crate"
("te", C), ("::", C),
("ca", S), ("rt", S), ("ae", S), // "crate" skip-1
])),
// 31. unwrap\(\)|expect\(
(r"unwrap\(\)|expect\(", Some(&[
("nw", C), ("wr", C), ("ra", C), // "unwrap("
("ap", C), ("p(", C),
("xp", C), ("pe", C), ("ec", C), // "expect("
("ct", C), ("t(", C),
])),
// 32. TODO|FIXME|HACK
(r"TODO|FIXME|HACK", Some(&[
("to", C), ("od", C), ("do", C), // "TODO"
("fi", C), ("ix", C), ("xm", C), // "FIXME"
("me", C),
("ha", C), ("ac", C), ("ck", C), // "HACK"
("hc", S), ("ak", S), // "HACK" skip-1
])),
];
for (i, &(pattern, expected)) in cases.iter().enumerate() {
let q = regex_to_bigram_query(pattern);
if let Some(bigrams) = expected {
assert!(
!q.is_any(),
"#{i} {pattern:?}: expected bigrams but got Any"
);
for &(pair, skip) in bigrams {
let b = pair.as_bytes();
debug_assert_eq!(b.len(), 2, "bigram must be 2 chars: {pair:?}");
let found = if skip {
has_skip1(&q, b[0], b[1])
} else {
has_consec(&q, b[0], b[1])
};
let kind = if skip { "skip-1" } else { "consec" };
assert!(found, "#{i} {pattern:?}: missing {kind} bigram {pair:?}");
}
}
}
}
}
@@ -0,0 +1,662 @@
//! SIMD-accelerated case-insensitive substring search.
//!
//! Implementations (fastest → simplest):
//! - `search_packed_pair`: AVX2 packed-pair scan (two rare bytes at known offsets)
//! - `search`: memchr2 first-byte scan + verify
//!
//! The packed-pair approach mirrors what `memchr::memmem` does internally for
//! case-sensitive search — pick two rare bytes from the needle, SIMD-scan for
//! both simultaneously, verify candidates. This gives quadratic selectivity
//! over the single-byte memchr2 approach.
// this is stolen from the memchr2 crate
const BYTE_FREQUENCIES: [u8; 256] = [
55, 52, 51, 50, 49, 48, 47, 46, 45, 103, 242, 66, 67, 229, 44, 43, // 0x00
42, 41, 40, 39, 38, 37, 36, 35, 34, 33, 56, 32, 31, 30, 29, 28, // 0x10
255, 148, 164, 149, 136, 160, 155, 173, 221, 222, 134, 122, 232, 202, 215, 224, // 0x20
208, 220, 204, 187, 183, 179, 177, 168, 178, 200, 226, 195, 154, 184, 174, 126, // 0x30
120, 191, 157, 194, 170, 189, 162, 161, 150, 193, 142, 137, 171, 176, 185,
167, // 0x40 A-O
186, 112, 175, 192, 188, 156, 140, 143, 123, 133, 128, 147, 138, 146, 114,
223, // 0x50 P-_
151, 249, 216, 238, 236, 253, 227, 218, 230, 247, 135, 180, 241, 233, 246,
244, // 0x60 a-o
231, 139, 245, 243, 251, 235, 201, 196, 240, 214, 152, 182, 205, 181, 127,
27, // 0x70 p-DEL
212, 211, 210, 213, 228, 197, 169, 159, 131, 172, 105, 80, 98, 96, 97, 81, // 0x80
207, 145, 116, 115, 144, 130, 153, 121, 107, 132, 109, 110, 124, 111, 82, 108, // 0x90
118, 141, 113, 129, 119, 125, 165, 117, 92, 106, 83, 72, 99, 93, 65, 79, // 0xa0
166, 237, 163, 199, 190, 225, 209, 203, 198, 217, 219, 206, 234, 248, 158, 239, // 0xb0
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, // 0xc0
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, // 0xd0
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, // 0xe0
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, // 0xf0
];
#[inline]
fn ascii_fold_byte(b: u8) -> u8 {
if b.is_ascii_uppercase() { b | 0x20 } else { b }
}
/// Toggle ASCII letter case by flipping bit 5.
/// `'n' → 'N'`, `'N' → 'n'`.
#[inline]
fn ascii_swap_case(b: u8) -> u8 {
b ^ 0x20
}
/// Effective frequency rank for a case-insensitive byte position.
/// Takes the max of lower/upper ranks because we must scan for both.
#[inline]
fn case_insensitive_rank(lower: u8) -> u8 {
if lower.is_ascii_lowercase() {
let upper = ascii_swap_case(lower);
BYTE_FREQUENCIES[lower as usize].max(BYTE_FREQUENCIES[upper as usize])
} else {
BYTE_FREQUENCIES[lower as usize]
}
}
/// Pick two needle positions with the rarest bytes (case-insensitive).
/// Returns (index1, index2) where index1 <= index2.
fn select_rare_pair(needle_lower: &[u8]) -> (usize, usize) {
debug_assert!(needle_lower.len() >= 2);
let mut best1 = (u8::MAX, 0usize); // (rank, position)
let mut best2 = (u8::MAX, 1usize);
for (i, &b) in needle_lower.iter().enumerate() {
let r = case_insensitive_rank(b);
if r < best1.0 {
best2 = best1;
best1 = (r, i);
} else if r < best2.0 && i != best1.1 {
best2 = (r, i);
}
}
let i1 = best1.1.min(best2.1);
let i2 = best1.1.max(best2.1);
(i1, i2)
}
#[inline]
fn verify_scalar(h: *const u8, needle_lower: &[u8]) -> bool {
for (i, _) in needle_lower.iter().enumerate() {
if ascii_fold_byte(unsafe { *h.add(i) }) != needle_lower[i] {
return false;
}
}
true
}
/// AVX2 case-insensitive verify: checks whether `needle_lower` matches
/// the haystack bytes starting at `h`, treating ASCII uppercase as lowercase.
///
/// Processes 32 bytes at a time using a SIMD trick: AVX2 only has a
/// **signed** byte compare (`cmpgt`), but we need an **unsigned** range
/// check (`'A' <= byte <= 'Z'`). The trick is to XOR every byte with
/// `0x80`, which maps the unsigned range `[0, 255]` into the signed range
/// `[-128, 127]` while preserving order. After the flip, signed `cmpgt`
/// gives correct unsigned comparisons.
///
/// Once we know which bytes are uppercase, we set bit 5 (`0x20`) on them
/// — this converts `'A'..'Z'` to `'a'..'z'` — then compare against the
/// pre-lowered needle.
#[cfg(target_arch = "x86_64")]
#[target_feature(enable = "avx2")]
unsafe fn verify_avx2(h: *const u8, needle_lower: &[u8]) -> bool {
use core::arch::x86_64::*;
let len = needle_lower.len();
let mut i = 0usize;
// Broadcast constants used every iteration:
//
// flip = 0x80 in every lane — XOR converts unsigned→signed domain
// a_minus_1 = ('A' - 1) ^ 0x80 — lower bound for the range check (signed)
// z_plus_1 = ('Z' + 1) ^ 0x80 — upper bound for the range check (signed)
// bit20 = 0x20 in every lane — OR this onto uppercase bytes to lowercase them
let flip = _mm256_set1_epi8(0x80u8 as i8);
let a_minus_1 = _mm256_set1_epi8((b'A' - 1) as i8 ^ 0x80u8 as i8);
let z_plus_1 = _mm256_set1_epi8((b'Z' + 1) as i8 ^ 0x80u8 as i8);
let bit20 = _mm256_set1_epi8(0x20u8 as i8);
while i + 32 <= len {
// Load 32 bytes from the haystack candidate position.
let hv = unsafe { _mm256_loadu_si256(h.add(i) as *const __m256i) };
// Load 32 bytes from the pre-lowercased needle.
let nv = unsafe { _mm256_loadu_si256(needle_lower.as_ptr().add(i) as *const __m256i) };
// Flip into signed domain: x = hv ^ 0x80.
// After this, unsigned ordering is preserved under signed compare.
let x = _mm256_xor_si256(hv, flip);
// ge_a[lane] = 0xFF if x[lane] > a_minus_1, i.e. hv[lane] >= 'A' (unsigned).
let ge_a = _mm256_cmpgt_epi8(x, a_minus_1);
// le_z[lane] = 0xFF if z_plus_1 > x[lane], i.e. hv[lane] <= 'Z' (unsigned).
let le_z = _mm256_cmpgt_epi8(z_plus_1, x);
// upper[lane] = 0xFF only for bytes in the range 'A'..='Z'.
let upper = _mm256_and_si256(ge_a, le_z);
// Case-fold: set bit 5 on uppercase bytes → converts 'A'..'Z' to 'a'..'z'.
// Non-letter bytes are untouched because their `upper` lane is 0x00.
let folded = _mm256_or_si256(hv, _mm256_and_si256(upper, bit20));
// Compare the folded haystack against the lowercase needle.
let eq = _mm256_cmpeq_epi8(folded, nv);
// movemask extracts the high bit of each lane into a 32-bit mask.
// All-equal → all high bits set → mask == 0xFFFFFFFF == -1i32.
if _mm256_movemask_epi8(eq) != -1i32 {
return false;
}
i += 32;
}
// Scalar tail: handle remaining bytes that don't fill a full 32-byte vector.
while i < len {
if ascii_fold_byte(unsafe { *h.add(i) }) != needle_lower[i] {
return false;
}
i += 1;
}
true
}
// ======== NEON + dotprod (aarch64) ===========================================
/// Extract a 16-bit bitmask from a NEON comparison result (each byte 0x00 or 0xFF).
/// Bit *i* of the result corresponds to byte *i* of the input vector.
#[cfg(target_arch = "aarch64")]
#[target_feature(enable = "neon")]
#[inline]
unsafe fn neon_movemask(v: core::arch::aarch64::uint8x16_t) -> u16 {
use core::arch::aarch64::*;
// AND each byte with its bit-position mask, then horizontally sum each half.
// Max possible sum per half = 1+2+4+8+16+32+64+128 = 255, fits in u8.
static BITS: [u8; 16] = [1, 2, 4, 8, 16, 32, 64, 128, 1, 2, 4, 8, 16, 32, 64, 128];
let bit_mask = unsafe { vld1q_u8(BITS.as_ptr()) };
let masked = vandq_u8(v, bit_mask);
let lo = vaddv_u8(vget_low_u8(masked));
let hi = vaddv_u8(vget_high_u8(masked));
(lo as u16) | ((hi as u16) << 8)
}
/// NEON + dotprod case-insensitive verify.
///
/// Uses unsigned range checks (NEON has `vcge`/`vcle` for unsigned bytes
/// no XOR-0x80 trick needed unlike AVX2) to detect uppercase ASCII, folds
/// to lowercase, then checks equality via UDOT: XOR the folded haystack
/// with the pre-lowered needle and dot-product the difference with itself.
/// Any non-zero byte produces a non-zero u32 lane.
///
/// The UDOT instruction is emitted via inline asm because the `vdotq_u32`
/// intrinsic is still behind an unstable feature gate on stable Rust.
#[cfg(target_arch = "aarch64")]
#[target_feature(enable = "neon,dotprod")]
unsafe fn verify_neon_dotprod(h: *const u8, needle_lower: &[u8]) -> bool {
use core::arch::aarch64::*;
let len = needle_lower.len();
let mut i = 0usize;
let a_val = vdupq_n_u8(b'A');
let z_val = vdupq_n_u8(b'Z');
let bit20 = vdupq_n_u8(0x20);
while i + 16 <= len {
let hv = unsafe { vld1q_u8(h.add(i)) };
let nv = unsafe { vld1q_u8(needle_lower.as_ptr().add(i)) };
// Unsigned range check: 'A' <= byte <= 'Z'
let upper = vandq_u8(vcgeq_u8(hv, a_val), vcleq_u8(hv, z_val));
// Case-fold: set bit 5 on uppercase bytes → 'A'..'Z' → 'a'..'z'
let folded = vorrq_u8(hv, vandq_u8(upper, bit20));
// XOR with needle — all-zero iff every byte matches.
let xored = veorq_u8(folded, nv);
// UDOT: dot(xored, xored) sums squares of 4 consecutive byte
// differences into each of the 4 u32 lanes (accumulates into zero).
// Any non-zero byte produces a positive u32 contribution.
let dots: uint32x4_t;
let zero = vdupq_n_u32(0);
unsafe {
core::arch::asm!(
"udot {d:v}.4s, {a:v}.16b, {b:v}.16b",
d = inlateout(vreg) zero => dots,
a = in(vreg) xored,
b = in(vreg) xored,
);
}
if vmaxvq_u32(dots) != 0 {
return false;
}
i += 16;
}
// Scalar tail
while i < len {
if ascii_fold_byte(unsafe { *h.add(i) }) != needle_lower[i] {
return false;
}
i += 1;
}
true
}
/// NEON packed-pair kernel: scan 16 haystack positions per iteration,
/// checking two rare bytes (case-insensitive) simultaneously.
/// Same algorithm as the AVX2 version but with 128-bit vectors.
#[cfg(target_arch = "aarch64")]
#[target_feature(enable = "neon")]
unsafe fn search_packed_pair_neon(
haystack: &[u8],
needle_lower: &[u8],
i1: usize,
i2: usize,
) -> bool {
use core::arch::aarch64::*;
let n = needle_lower.len();
let hlen = haystack.len();
let ptr = haystack.as_ptr();
let last_start = hlen - n;
let b1 = needle_lower[i1];
let b1_alt = if b1.is_ascii_lowercase() {
ascii_swap_case(b1)
} else {
b1
};
let b2 = needle_lower[i2];
let b2_alt = if b2.is_ascii_lowercase() {
ascii_swap_case(b2)
} else {
b2
};
let v1_lo = vdupq_n_u8(b1);
let v1_hi = vdupq_n_u8(b1_alt);
let v2_lo = vdupq_n_u8(b2);
let v2_hi = vdupq_n_u8(b2_alt);
let max_idx = i1.max(i2);
let max_offset = hlen.saturating_sub(max_idx + 16);
let mut offset = 0usize;
while offset <= max_offset {
let chunk1 = unsafe { vld1q_u8(ptr.add(offset + i1)) };
let chunk2 = unsafe { vld1q_u8(ptr.add(offset + i2)) };
// Case-insensitive match: OR both case variants, then AND the two positions.
let eq1 = vorrq_u8(vceqq_u8(chunk1, v1_lo), vceqq_u8(chunk1, v1_hi));
let eq2 = vorrq_u8(vceqq_u8(chunk2, v2_lo), vceqq_u8(chunk2, v2_hi));
let mut mask = unsafe { neon_movemask(vandq_u8(eq1, eq2)) };
while mask != 0 {
let bit = mask.trailing_zeros() as usize;
let candidate = offset + bit;
if candidate > last_start {
return false;
}
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
return true;
}
mask &= mask - 1;
}
offset += 16;
}
// Tail: remaining positions that couldn't fill a full vector.
if offset <= last_start {
let rare_pos =
if case_insensitive_rank(needle_lower[i1]) <= case_insensitive_rank(needle_lower[i2]) {
i1
} else {
i2
};
let rare_byte = needle_lower[rare_pos];
let tail_start = offset + rare_pos;
let tail_end = last_start + rare_pos + 1;
if tail_start < tail_end {
let tail_space = &haystack[tail_start..tail_end];
if rare_byte.is_ascii_lowercase() {
for pos in memchr::memchr2_iter(rare_byte, ascii_swap_case(rare_byte), tail_space) {
let candidate = offset + pos;
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
return true;
}
}
} else {
for pos in memchr::memchr_iter(rare_byte, tail_space) {
let candidate = offset + pos;
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
return true;
}
}
}
}
}
false
}
#[inline]
unsafe fn verify_dispatch(h: *const u8, needle_lower: &[u8]) -> bool {
#[cfg(target_arch = "x86_64")]
{
if needle_lower.len() >= 32 && std::is_x86_feature_detected!("avx2") {
return unsafe { verify_avx2(h, needle_lower) };
}
}
#[cfg(target_arch = "aarch64")]
{
if needle_lower.len() >= 16 && std::arch::is_aarch64_feature_detected!("dotprod") {
return unsafe { verify_neon_dotprod(h, needle_lower) };
}
}
verify_scalar(h, needle_lower)
}
// ── Packed-pair search (AVX2) ───────────────────────────────────────────
/// AVX2 packed-pair kernel: scan 32 haystack positions per iteration,
/// checking two rare bytes (case-insensitive) simultaneously.
/// 4 cmpeq + 2 or + 1 and + 1 movemask per 32 bytes — same memory
/// bandwidth as memchr2 but quadratic selectivity.
#[cfg(target_arch = "x86_64")]
#[target_feature(enable = "avx2")]
unsafe fn search_packed_pair_avx2(
haystack: &[u8],
needle_lower: &[u8],
i1: usize,
i2: usize,
) -> bool {
use core::arch::x86_64::*;
let n = needle_lower.len();
let hlen = haystack.len();
let ptr = haystack.as_ptr();
let last_start = hlen - n; // last valid match-start position
let b1 = needle_lower[i1];
let b1_alt = if b1.is_ascii_lowercase() {
ascii_swap_case(b1)
} else {
b1
};
let b2 = needle_lower[i2];
let b2_alt = if b2.is_ascii_lowercase() {
ascii_swap_case(b2)
} else {
b2
};
let v1_lo = _mm256_set1_epi8(b1 as i8);
let v1_hi = _mm256_set1_epi8(b1_alt as i8);
let v2_lo = _mm256_set1_epi8(b2 as i8);
let v2_hi = _mm256_set1_epi8(b2_alt as i8);
// Main loop: process 32 candidate positions per iteration.
// We load from ptr+offset+i1 and ptr+offset+i2, so we need
// offset + max(i1,i2) + 31 < hlen.
let max_idx = i1.max(i2);
let max_offset = hlen.saturating_sub(max_idx + 32);
let mut offset = 0usize;
while offset <= max_offset {
let chunk1 = unsafe { _mm256_loadu_si256(ptr.add(offset + i1) as *const __m256i) };
let chunk2 = unsafe { _mm256_loadu_si256(ptr.add(offset + i2) as *const __m256i) };
// Case-insensitive match: OR both case variants, then AND the two positions.
let eq1 = _mm256_or_si256(
_mm256_cmpeq_epi8(chunk1, v1_lo),
_mm256_cmpeq_epi8(chunk1, v1_hi),
);
let eq2 = _mm256_or_si256(
_mm256_cmpeq_epi8(chunk2, v2_lo),
_mm256_cmpeq_epi8(chunk2, v2_hi),
);
let mut mask = _mm256_movemask_epi8(_mm256_and_si256(eq1, eq2)) as u32;
while mask != 0 {
let bit = mask.trailing_zeros() as usize;
let candidate = offset + bit;
if candidate > last_start {
// Past the end — no more valid positions in this or future chunks.
return false;
}
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
return true;
}
mask &= mask - 1;
}
offset += 32;
}
// Tail: remaining positions that couldn't fill a full vector.
// Use memchr2 on the rarest byte for these last few positions.
if offset <= last_start {
let rare_pos =
if case_insensitive_rank(needle_lower[i1]) <= case_insensitive_rank(needle_lower[i2]) {
i1
} else {
i2
};
let rare_byte = needle_lower[rare_pos];
let tail_start = offset + rare_pos;
let tail_end = last_start + rare_pos + 1;
if tail_start < tail_end {
let tail_space = &haystack[tail_start..tail_end];
if rare_byte.is_ascii_lowercase() {
for pos in memchr::memchr2_iter(rare_byte, ascii_swap_case(rare_byte), tail_space) {
let candidate = offset + pos;
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
return true;
}
}
} else {
for pos in memchr::memchr_iter(rare_byte, tail_space) {
let candidate = offset + pos;
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
return true;
}
}
}
}
}
false
}
/// Packed-pair case-insensitive substring search.
///
/// Selects the two rarest bytes from the needle (using the memchr byte
/// frequency heuristic), then SIMD-scans for both at their known offsets
/// simultaneously. Falls back to `search` for needles shorter than 2 bytes.
pub fn search_packed_pair(haystack: &[u8], needle_lower: &[u8]) -> bool {
let n = needle_lower.len();
if n == 0 {
return true;
}
if n < 2 {
return search(haystack, needle_lower);
}
if n > haystack.len() {
return false;
}
let (i1, i2) = select_rare_pair(needle_lower);
#[cfg(target_arch = "x86_64")]
{
if std::is_x86_feature_detected!("avx2") {
// Need enough haystack for at least one vector load.
let max_idx = i1.max(i2);
if haystack.len() >= max_idx + 32 {
return unsafe { search_packed_pair_avx2(haystack, needle_lower, i1, i2) };
}
}
}
#[cfg(target_arch = "aarch64")]
{
// The NEON packed-pair scan checks 16 bytes/iteration with ~7 ops,
// while memchr's optimized loop processes more bytes with fewer ops.
// Packed-pair wins when the first byte is common (lots of false
// positives for memchr2 that we avoid). But when the first byte is
// rare (z, q, x, ...) memchr2 has no false positives and its raw
// throughput dominates. Threshold 200 on the frequency table splits
// common letters (s=243, e=253, f=227) from rare ones (z=152, q=139).
let first_byte_rank = case_insensitive_rank(needle_lower[0]);
let max_idx = i1.max(i2);
if first_byte_rank >= 200 && haystack.len() >= max_idx + 16 {
return unsafe { search_packed_pair_neon(haystack, needle_lower, i1, i2) };
}
}
// Fallback for short haystacks or non-SIMD platforms.
search(haystack, needle_lower)
}
// ── Original memchr2 first-byte search ──────────────────────────────────
/// Case-insensitive search using memchr2 on the first byte.
pub fn search(haystack: &[u8], needle_lower: &[u8]) -> bool {
let n = needle_lower.len();
if n == 0 {
return true;
}
if n > haystack.len() {
return false;
}
let search_space = &haystack[..=haystack.len() - n];
let first = needle_lower[0];
if first.is_ascii_lowercase() {
let alt = ascii_swap_case(first);
for pos in memchr::memchr2_iter(first, alt, search_space) {
if unsafe { verify_dispatch(haystack.as_ptr().add(pos), needle_lower) } {
return true;
}
}
} else {
for pos in memchr::memchr_iter(first, search_space) {
if unsafe { verify_dispatch(haystack.as_ptr().add(pos), needle_lower) } {
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_case_insensitive() {
assert!(search_packed_pair(b"Hello World", b"hello"));
assert!(search_packed_pair(b"Hello World", b"world"));
assert!(search_packed_pair(b"NOMORE bugs", b"nomore"));
assert!(!search_packed_pair(b"Hello World", b"xyz"));
}
#[test]
fn edge_cases() {
assert!(search_packed_pair(b"ab", b"ab"));
assert!(search_packed_pair(b"AB", b"ab"));
assert!(!search_packed_pair(b"a", b"ab"));
assert!(search_packed_pair(b"anything", b""));
assert!(!search_packed_pair(b"", b"x"));
}
#[test]
fn packed_pair_matches_search() {
let haystacks: &[&[u8]] = &[
b"The quick brown fox jumps over the lazy dog",
b"int mutex_lock(struct mutex *lock) { return 0; }",
b"#define NOMORE_RETRIES 5\nif (nomore) return;",
b"abcdefghijklmnopqrstuvwxyz",
b"short",
];
let needles: &[&[u8]] = &[b"fox", b"mutex", b"nomore", b"xyz", b"the", b"short", b"qr"];
for h in haystacks {
for n in needles {
let lower: Vec<u8> = n.iter().map(|b| b.to_ascii_lowercase()).collect();
assert_eq!(
search_packed_pair(h, &lower),
search(h, &lower),
"mismatch for haystack={:?} needle={:?}",
std::str::from_utf8(h),
std::str::from_utf8(n),
);
}
}
}
#[test]
fn long_haystack_neon_path() {
// Haystack > 16 bytes exercises NEON packed-pair search loop
let haystack =
b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaTHIS_IS_A_LONG_NEEDLE_TESTbbbbbbbbbbbbbbbbbb";
assert!(search_packed_pair(haystack, b"this_is_a_long_needle_test"));
assert!(!search_packed_pair(
haystack,
b"this_is_a_long_needle_testz"
));
// Needle >= 16 bytes exercises NEON dotprod verify
let long_needle = b"struct mutex *lock";
let haystack2 = b"int STRUCT MUTEX *LOCK(struct mutex *lock) { return 0; }";
assert!(search_packed_pair(haystack2, long_needle));
// All uppercase haystack, lowercase needle
let upper_hay = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
assert!(search_packed_pair(upper_hay, b"qrstuvwxyz0123456789a"));
assert!(!search_packed_pair(upper_hay, b"qrstuvwxyz01234567899"));
// Needle at very end
let end_hay = b"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxfind_me";
assert!(search_packed_pair(end_hay, b"find_me"));
// Needle at very start
assert!(search_packed_pair(end_hay, b"xx"));
// 1KB haystack with needle near the end
let mut big = vec![b'z'; 1024];
big[1000..1010].copy_from_slice(b"hElLo_WoRl");
assert!(search_packed_pair(&big, b"hello_wo"));
assert!(!search_packed_pair(&big, b"hello_world"));
}
#[test]
fn rare_pair_selection() {
// For "nomore": n=246, o=244, m=233, o=244, r=245, e=253
// Rarest positions should include 'm' (pos 2, rank 233)
let (i1, i2) = select_rare_pair(b"nomore");
let ranks: Vec<u8> = b"nomore"
.iter()
.map(|&b| case_insensitive_rank(b))
.collect();
let r1 = ranks[i1];
let r2 = ranks[i2];
// Both selected ranks should be <= all other ranks
for (i, &r) in ranks.iter().enumerate() {
if i != i1 && i != i2 {
assert!(r1 <= r || r2 <= r, "pair ({i1},{i2}) not optimal");
}
}
}
}
+342 -106
View File
@@ -1,75 +1,107 @@
//! Constraint filtering engine for fff.
//!
//! This module provides the core constraint application logic that filters items
//! based on parsed query constraints (extensions, path segments, globs, git status, etc.).
//!
//! The filtering is generic over the [`Constrainable`] trait, allowing reuse across
//! different search modes (file picker, live grep, etc.).
//! Constraint-based prefiltering for search queries.
use ahash::AHashSet;
use fff_query_parser::{Constraint, GitStatusFilter};
use smallvec::SmallVec;
use zlob::{ZlobFlags, zlob_match_paths};
use crate::git::is_modified_status;
use crate::simd_path::ArenaPtr;
/// 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;
/// Trait for items that can be filtered by constraints.
/// Implement this for any searchable item type (files, grep results, etc.).
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;
/// The git status of this item, if available
fn git_status(&self) -> Option<git2::Status>;
}
/// Check if file extension matches (without allocation)
/// `needle` must already be lowercase.
#[inline]
pub fn file_has_extension(file_name: &str, ext: &str) -> bool {
if file_name.len() <= ext.len() + 1 {
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;
}
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)
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
}
/// Check if path contains segment (without allocation)
const PAR_THRESHOLD: usize = 10_000;
pub(crate) trait Constrainable {
fn write_file_name(&self, arena: ArenaPtr, out: &mut String);
fn git_status(&self) -> Option<git2::Status>;
fn write_relative_path(&self, arena: ArenaPtr, out: &mut String);
}
/// Check if a relative path ends with the given suffix at a `/` boundary (case-insensitive).
///
/// Returns `true` when the path equals the suffix or the character before the suffix
/// in the path is `/`. This ensures partial directory-name matches are rejected.
///
/// Examples:
/// - `path_ends_with_suffix("libswscale/input.c", "libswscale/input.c")` → true (exact)
/// - `path_ends_with_suffix("foo/libswscale/input.c", "libswscale/input.c")` → true (suffix)
/// - `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 {
let path_bytes = path.as_bytes();
let suffix_bytes = suffix.as_bytes();
if path_bytes.len() < suffix_bytes.len() {
return false;
}
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_bytes[start - 1] == b'/'
}
#[inline]
pub fn file_has_extension(file_name: &str, ext: &str) -> bool {
let name_bytes = file_name.as_bytes();
let ext_bytes = ext.as_bytes();
if name_bytes.len() <= ext_bytes.len() + 1 {
return false;
}
let start = name_bytes.len() - ext_bytes.len() - 1;
name_bytes.get(start) == Some(&b'.') && name_bytes[start + 1..].eq_ignore_ascii_case(ext_bytes)
}
/// Supports multi-segment paths like "libswscale/aarch64" (consecutive components).
#[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
if path.len() > segment_len
// Check segment/ at start of path
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;
}
@@ -78,8 +110,8 @@ pub fn path_contains_segment(path: &str, segment: &str) -> bool {
false
}
/// Check if an item at given index matches a constraint (single-pass friendly, allocation-free)
#[inline]
#[allow(clippy::too_many_arguments)]
fn item_matches_constraint_at_index<T: Constrainable>(
item: &T,
item_index: usize,
@@ -87,21 +119,35 @@ fn item_matches_constraint_at_index<T: Constrainable>(
glob_results: &[(bool, AHashSet<usize>)],
glob_idx: &mut usize,
negate: bool,
arena: ArenaPtr,
fname_buf: &mut String,
path_buf: &mut String,
) -> bool {
let matches = match constraint {
Constraint::Extension(ext) => file_has_extension(item.file_name(), ext),
Constraint::Extension(ext) => {
item.write_file_name(arena, fname_buf);
file_has_extension(fname_buf, ext)
}
Constraint::Glob(_) => {
let result = glob_results
.get(*glob_idx)
.map(|(is_neg, set)| {
let matched = set.contains(&item_index);
if *is_neg { !matched } else { matched }
})
.unwrap_or(true);
*glob_idx += 1;
return if negate { !result } else { result };
}
Constraint::PathSegment(segment) => path_contains_segment(item.relative_path(), segment),
Constraint::PathSegment(segment) => {
item.write_relative_path(arena, path_buf);
path_contains_segment(path_buf, segment)
}
Constraint::FilePath(suffix) => {
item.write_relative_path(arena, path_buf);
path_ends_with_suffix(path_buf, suffix)
}
Constraint::GitStatus(status_filter) => match (item.git_status(), status_filter) {
(Some(status), GitStatusFilter::Modified) => is_modified_status(status),
(Some(status), GitStatusFilter::Untracked) => status.contains(git2::Status::WT_NEW),
@@ -124,11 +170,17 @@ fn item_matches_constraint_at_index<T: Constrainable>(
glob_results,
glob_idx,
!negate,
arena,
fname_buf,
path_buf,
);
}
// only works with negation
Constraint::Text(text) => item.relative_path_lower().contains(text),
Constraint::Text(text) => {
item.write_relative_path(arena, path_buf);
contains_ascii_ci(path_buf, text)
}
// Parts and Exclude are handled at a higher level
Constraint::Parts(_) | Constraint::Exclude(_) | Constraint::FileType(_) => true,
@@ -137,15 +189,12 @@ fn item_matches_constraint_at_index<T: Constrainable>(
if negate { !matches } else { matches }
}
/// Apply constraint-based prefiltering in a single pass over all items.
/// Returns `None` if no constraints are present, `Some(filtered)` otherwise.
/// Multiple extension constraints (*.rs *.ts) are combined with OR logic.
/// All other constraints are combined with AND logic.
///
/// Uses parallel iteration via rayon when the item count exceeds [`PAR_THRESHOLD`].
pub fn apply_constraints<'a, T: Constrainable + Sync>(
/// Extension constraints use OR logic; all others use AND.
pub(crate) fn apply_constraints<'a, T: Constrainable + Sync>(
items: &'a [T],
constraints: &[Constraint<'_>],
arena: ArenaPtr,
) -> Option<Vec<&'a T>> {
if constraints.is_empty() {
return None;
@@ -168,47 +217,99 @@ pub fn apply_constraints<'a, T: Constrainable + Sync>(
.any(|c| matches!(c, Constraint::Glob(_) | Constraint::Not(_)));
let glob_results = if has_globs {
let paths: Vec<&str> = items.iter().map(|f| f.relative_path()).collect();
precompute_glob_matches(&other_constraints, &paths)
// Build a single contiguous buffer of all relative paths + offset table.
// One allocation for the buffer, one for offsets — NOT one String per file.
let mut path_buf = Vec::<u8>::new();
let mut offsets = Vec::<(usize, usize)>::with_capacity(items.len());
let mut tmp = String::with_capacity(64);
for item in items.iter() {
let start = path_buf.len();
item.write_relative_path(arena, &mut tmp);
path_buf.extend_from_slice(tmp.as_bytes());
offsets.push((start, path_buf.len() - start));
}
let path_refs: Vec<&str> = offsets
.iter()
.map(|&(off, len)| unsafe { std::str::from_utf8_unchecked(&path_buf[off..off + len]) })
.collect();
precompute_glob_matches(&other_constraints, &path_refs)
} else {
Vec::new()
};
let matches_constraints = |i: usize, item: &T| -> bool {
if !extensions.is_empty()
&& !extensions
.iter()
.any(|ext| file_has_extension(item.file_name(), ext))
{
return false;
}
let mut glob_idx = 0;
other_constraints.iter().all(|constraint| {
item_matches_constraint_at_index(
item,
i,
constraint,
&glob_results,
&mut glob_idx,
false,
)
})
};
let filtered: Vec<&T> = if items.len() >= PAR_THRESHOLD {
use rayon::prelude::*;
items
.par_iter()
.enumerate()
.filter(|(i, item)| matches_constraints(*i, item))
.map(|(_, item)| item)
.map_init(
|| (String::with_capacity(64), String::with_capacity(64)),
|(fname_buf, path_buf), (i, item)| {
if !extensions.is_empty() {
item.write_file_name(arena, fname_buf);
if !extensions
.iter()
.any(|ext| file_has_extension(fname_buf, ext))
{
return None;
}
}
let mut glob_idx = 0;
if other_constraints.iter().all(|constraint| {
item_matches_constraint_at_index(
item,
i,
constraint,
&glob_results,
&mut glob_idx,
false,
arena,
fname_buf,
path_buf,
)
}) {
Some(item)
} else {
None
}
},
)
.flatten()
.collect()
} else {
let mut fname_buf = String::with_capacity(64);
let mut path_buf = String::with_capacity(64);
items
.iter()
.enumerate()
.filter(|(i, item)| matches_constraints(*i, item))
.filter(|&(i, item)| {
if !extensions.is_empty() {
item.write_file_name(arena, &mut fname_buf);
if !extensions
.iter()
.any(|ext| file_has_extension(&fname_buf, ext))
{
return false;
}
}
let mut glob_idx = 0;
other_constraints.iter().all(|constraint| {
item_matches_constraint_at_index(
item,
i,
constraint,
&glob_results,
&mut glob_idx,
false,
arena,
&mut fname_buf,
&mut path_buf,
)
})
})
.map(|(_, item)| item)
.collect()
};
@@ -235,32 +336,8 @@ fn collect_glob_indices<'a>(
) {
match constraint {
Constraint::Glob(pattern) => {
if let Ok(Some(matches)) = zlob_match_paths(pattern, paths, ZlobFlags::RECOMMENDED) {
let matched_set: AHashSet<usize> =
matches.iter().map(|s| s.as_ptr() as usize).collect();
let indices: AHashSet<usize> = if paths.len() >= PAR_THRESHOLD {
use rayon::prelude::*;
paths
.par_iter()
.enumerate()
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
.map(|(i, _)| i)
.collect::<Vec<_>>()
.into_iter()
.collect()
} else {
paths
.iter()
.enumerate()
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
.map(|(i, _)| i)
.collect()
};
results.push((is_negated, indices));
} else {
results.push((is_negated, AHashSet::new()));
}
let indices = match_glob_pattern(pattern, paths);
results.push((is_negated, indices));
}
Constraint::Not(inner) => {
collect_glob_indices(inner, paths, results, !is_negated);
@@ -269,6 +346,66 @@ fn collect_glob_indices<'a>(
}
}
/// Match a glob pattern against a list of paths, returning the set of matching indices.
///
/// When the `zlob` feature is enabled, delegates to `zlob::zlob_match_paths` (Zig-compiled
/// C library, fastest). Otherwise falls back to `globset::Glob` (pure Rust).
#[cfg(feature = "zlob")]
fn match_glob_pattern(pattern: &str, paths: &[&str]) -> AHashSet<usize> {
let Ok(Some(matches)) = zlob::zlob_match_paths(pattern, paths, zlob::ZlobFlags::RECOMMENDED)
else {
return AHashSet::new();
};
let matched_set: AHashSet<usize> = matches.iter().map(|s| s.as_ptr() as usize).collect();
if paths.len() >= PAR_THRESHOLD {
use rayon::prelude::*;
paths
.par_iter()
.enumerate()
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
.map(|(i, _)| i)
.collect::<Vec<_>>()
.into_iter()
.collect()
} else {
paths
.iter()
.enumerate()
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
.map(|(i, _)| i)
.collect()
}
}
#[cfg(not(feature = "zlob"))]
fn match_glob_pattern(pattern: &str, paths: &[&str]) -> AHashSet<usize> {
let Ok(glob) = globset::Glob::new(pattern) else {
return AHashSet::new();
};
let matcher = glob.compile_matcher();
if paths.len() >= PAR_THRESHOLD {
use rayon::prelude::*;
paths
.par_iter()
.enumerate()
.filter(|(_, p)| matcher.is_match(p))
.map(|(i, _)| i)
.collect::<Vec<_>>()
.into_iter()
.collect()
} else {
paths
.iter()
.enumerate()
.filter(|(_, p)| matcher.is_match(p))
.map(|(i, _)| i)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -312,8 +449,107 @@ mod tests {
// Should not match filename
assert!(!path_contains_segment("lib/src", "src"));
// Multi-segment constraints
assert!(path_contains_segment(
"libswscale/aarch64/input.S",
"libswscale/aarch64"
));
assert!(path_contains_segment(
"foo/libswscale/aarch64/input.S",
"libswscale/aarch64"
));
assert!(path_contains_segment(
"foo/LibSwscale/AArch64/input.S",
"libswscale/aarch64"
)); // case-insensitive
assert!(!path_contains_segment(
"xlibswscale/aarch64/input.S",
"libswscale/aarch64"
)); // partial match at start
assert!(!path_contains_segment(
"foo/libswscale/aarch64x/input.S",
"libswscale/aarch64"
)); // partial match at end
assert!(path_contains_segment(
"crates/fff-core/src/grep.rs",
"fff-core/src"
));
// Edge cases
assert!(!path_contains_segment("", "src"));
assert!(!path_contains_segment("src", "src")); // no trailing slash
}
#[test]
fn test_path_ends_with_suffix() {
// Exact match
assert!(path_ends_with_suffix(
"libswscale/input.c",
"libswscale/input.c"
));
// Suffix match at / boundary
assert!(path_ends_with_suffix(
"foo/libswscale/input.c",
"libswscale/input.c"
));
// Deep nesting
assert!(path_ends_with_suffix(
"a/b/c/libswscale/input.c",
"libswscale/input.c"
));
// No boundary — partial directory name
assert!(!path_ends_with_suffix(
"xlibswscale/input.c",
"libswscale/input.c"
));
// Case insensitive
assert!(path_ends_with_suffix(
"foo/LibSwscale/Input.C",
"libswscale/input.c"
));
// Single file name
assert!(path_ends_with_suffix("input.c", "input.c"));
assert!(!path_ends_with_suffix("xinput.c", "input.c"));
// Suffix longer than path
assert!(!path_ends_with_suffix("input.c", "foo/input.c"));
// Simple path
assert!(path_ends_with_suffix("src/main.rs", "src/main.rs"));
assert!(path_ends_with_suffix("crates/src/main.rs", "src/main.rs"));
}
#[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
+295 -23
View File
@@ -1,11 +1,15 @@
use crate::db_healthcheck::DbHealthChecker;
use crate::{error::Error, git::is_modified_status};
use crate::error::{Error, Result};
use crate::file_picker::FFFMode;
use crate::git::is_modified_status;
use crate::shared::SharedFrecency;
use heed::{Database, Env, EnvOpenOptions};
use heed::{
EnvFlags,
types::{Bytes, SerdeBincode},
};
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use std::{collections::VecDeque, path::Path};
@@ -13,6 +17,10 @@ const DECAY_CONSTANT: f64 = 0.0693; // ln(2)/10 for 10-day half-life
const SECONDS_PER_DAY: f64 = 86400.0;
const MAX_HISTORY_DAYS: f64 = 30.0; // Only consider accesses within 30 days
// AI mode: faster decay since AI sessions are shorter and more intense
const AI_DECAY_CONSTANT: f64 = 0.231; // ln(2)/3 for 3-day half-life
const AI_MAX_HISTORY_DAYS: f64 = 7.0; // Only consider accesses within 7 days
#[derive(Debug)]
pub struct FrecencyTracker {
env: Env,
@@ -27,12 +35,21 @@ const MODIFICATION_THRESHOLDS: [(i64, u64); 5] = [
(1, 60 * 60 * 24 * 7), // 1 week
];
// AI mode: compressed thresholds since AI edits happen in rapid bursts
const AI_MODIFICATION_THRESHOLDS: [(i64, u64); 5] = [
(16, 30), // 30 seconds
(8, 60 * 5), // 5 minutes
(4, 60 * 15), // 15 minutes
(2, 60 * 60), // 1 hour
(1, 60 * 60 * 4), // 4 hours
];
impl DbHealthChecker for FrecencyTracker {
fn get_env(&self) -> &heed::Env {
&self.env
}
fn 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)?;
@@ -41,10 +58,18 @@ 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
if use_unsafe_no_lock {
opts.flags(EnvFlags::NO_LOCK | EnvFlags::NO_SYNC | EnvFlags::NO_META_SYNC);
}
@@ -53,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,
@@ -65,7 +107,209 @@ impl FrecencyTracker {
})
}
fn get_accesses(&self, path: &Path) -> Result<Option<VecDeque<u64>>, Error> {
/// 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.
///
/// 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))?)
}
#[tracing::instrument(skip(shared), fields(db_path = %db_path))]
fn run_frecency_gc(shared: SharedFrecency, db_path: String, use_unsafe_no_lock: bool) {
let start = std::time::Instant::now();
let data_path = PathBuf::from(&db_path).join("data.mdb");
// Phase 1: Purge stale entries.
// The RwLock protects the Option<FrecencyTracker> (not the DB itself),
// so a read lock is sufficient — LMDB handles its own write serialization.
let (deleted, pruned) = {
let guard = match shared.read() {
Ok(g) => g,
Err(e) => {
tracing::debug!("Failed to acquire read lock: {e}");
return;
}
};
let Some(ref tracker) = *guard else {
return;
};
match tracker.purge_stale_entries() {
Ok(result) => result,
Err(e) => {
tracing::debug!("Purge failed: {e}");
return;
}
}
};
if deleted > 0 || pruned > 0 {
tracing::info!(deleted, pruned, elapsed = ?start.elapsed(), "Frecency GC purged entries");
}
// Compact if we purged entries OR the file has significant freelist bloat
let file_size = fs::metadata(&data_path).map(|m| m.len()).unwrap_or(0);
if deleted == 0 && pruned == 0 && file_size <= 512 * 1024 {
return;
}
// Phase 2: Manual compaction under a single write lock
let mut guard = match shared.write() {
Ok(g) => g,
Err(e) => {
tracing::debug!("Failed to acquire write lock: {e}");
return;
}
};
// Read all entries from current env
let entries: Vec<(Vec<u8>, VecDeque<u64>)> = match guard.as_ref() {
Some(tracker) => {
let rtxn = match tracker.env.read_txn() {
Ok(t) => t,
Err(e) => {
tracing::debug!("Compaction read_txn failed: {e}");
return;
}
};
let iter = match tracker.db.iter(&rtxn) {
Ok(i) => i,
Err(e) => {
tracing::debug!("Compaction iter failed: {e}");
return;
}
};
let mut entries = Vec::new();
let mut read_errors = 0u32;
for result in iter {
match result {
Ok((key, value)) => entries.push((key.to_vec(), value)),
Err(_) => read_errors += 1,
}
}
if read_errors > 0 {
tracing::warn!(
read_errors,
"Skipped corrupted entries during compaction read"
);
}
entries
}
None => return,
};
// Drop old tracker, delete files, create fresh env, write back
*guard = None;
let lock_path = PathBuf::from(&db_path).join("lock.mdb");
let _ = fs::remove_file(&data_path);
let _ = fs::remove_file(&lock_path);
let tracker = match FrecencyTracker::new(&db_path, use_unsafe_no_lock) {
Ok(t) => t,
Err(e) => {
tracing::error!("Compaction reopen failed, frecency disabled: {e}");
return;
}
};
let write_result = (|| -> std::result::Result<(), heed::Error> {
let mut wtxn = tracker.env.write_txn()?;
for (key, value) in &entries {
tracker.db.put(&mut wtxn, key.as_slice(), value)?;
}
wtxn.commit()?;
Ok(())
})();
match write_result {
Ok(()) => {
let new_size = fs::metadata(&data_path).map(|m| m.len()).unwrap_or(0);
*guard = Some(tracker);
tracing::debug!(
entries = entries.len(),
old_size = file_size,
new_size,
elapsed = ?start.elapsed(),
"Frecency DB compacted"
);
}
Err(e) => {
tracing::error!("Compaction write failed, frecency data may be incomplete: {e}");
*guard = Some(tracker);
}
}
}
/// 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)> {
let now = self.get_now();
let cutoff_time = now.saturating_sub((MAX_HISTORY_DAYS * SECONDS_PER_DAY) as u64);
// Collect entries to delete or update
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
let mut to_delete: Vec<Vec<u8>> = Vec::new();
let mut to_update: Vec<(Vec<u8>, VecDeque<u64>)> = Vec::new();
let iter = self.db.iter(&rtxn).map_err(Error::DbRead)?;
for result in iter {
let (key, accesses) = result.map_err(Error::DbRead)?;
// Timestamps are chronologically ordered (oldest at front).
// Find the first timestamp that is still within the retention window.
let fresh_start = accesses.iter().position(|&ts| ts >= cutoff_time);
match fresh_start {
None => {
// All timestamps are stale — delete the entire entry
to_delete.push(key.to_vec());
}
Some(0) => {
// All timestamps are fresh — nothing to do
}
Some(start) => {
// Some timestamps are stale — keep only the fresh ones
let pruned: VecDeque<u64> = accesses.iter().skip(start).copied().collect();
to_update.push((key.to_vec(), pruned));
}
}
}
drop(rtxn);
if to_delete.is_empty() && to_update.is_empty() {
return Ok((0, 0));
}
// Apply all changes in a single write transaction
let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?;
for key in &to_delete {
self.db.delete(&mut wtxn, key).map_err(Error::DbWrite)?;
}
for (key, accesses) in &to_update {
self.db
.put(&mut wtxn, key, accesses)
.map_err(Error::DbWrite)?;
}
wtxn.commit().map_err(Error::DbCommit)?;
Ok((to_delete.len(), to_update.len()))
}
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)?;
@@ -79,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()));
};
@@ -87,7 +331,15 @@ impl FrecencyTracker {
Ok(*blake3::hash(key.as_bytes()).as_bytes())
}
pub fn track_access(&self, path: &Path) -> Result<(), Error> {
/// Returns seconds since the most recent tracked access, or `None` if the
/// file has never been tracked.
pub fn seconds_since_last_access(&self, path: &Path) -> Result<Option<u64>> {
let accesses = self.get_accesses(path)?;
let last = accesses.and_then(|a| a.back().copied());
Ok(last.map(|ts| self.get_now().saturating_sub(ts)))
}
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)?;
@@ -115,7 +367,7 @@ impl FrecencyTracker {
Ok(())
}
pub fn get_access_score(&self, file_path: &Path) -> i64 {
pub fn get_access_score(&self, file_path: &Path, mode: FFFMode) -> i64 {
let accesses = self
.get_accesses(file_path)
.ok()
@@ -126,10 +378,21 @@ impl FrecencyTracker {
return 0;
}
let decay_constant = if mode.is_ai() {
AI_DECAY_CONSTANT
} else {
DECAY_CONSTANT
};
let max_history_days = if mode.is_ai() {
AI_MAX_HISTORY_DAYS
} else {
MAX_HISTORY_DAYS
};
let now = self.get_now();
let mut total_frecency = 0.0;
let cutoff_time = now.saturating_sub((MAX_HISTORY_DAYS * SECONDS_PER_DAY) as u64);
let cutoff_time = now.saturating_sub((max_history_days * SECONDS_PER_DAY) as u64);
for &access_time in accesses.iter().rev() {
if access_time < cutoff_time {
@@ -137,7 +400,7 @@ impl FrecencyTracker {
}
let days_ago = (now.saturating_sub(access_time) as f64) / SECONDS_PER_DAY;
let decay_factor = (-DECAY_CONSTANT * days_ago).exp();
let decay_factor = (-decay_constant * days_ago).exp();
total_frecency += decay_factor;
}
@@ -155,24 +418,31 @@ impl FrecencyTracker {
&self,
modified_time: u64,
git_status: Option<git2::Status>,
mode: FFFMode,
) -> i64 {
let is_modified_git_status = git_status.is_some_and(is_modified_status);
if !is_modified_git_status {
return 0;
}
let thresholds = if mode.is_ai() {
&AI_MODIFICATION_THRESHOLDS
} else {
&MODIFICATION_THRESHOLDS
};
let now = self.get_now();
let duration_since = now.saturating_sub(modified_time);
for i in 0..MODIFICATION_THRESHOLDS.len() {
let (current_points, current_threshold) = MODIFICATION_THRESHOLDS[i];
for i in 0..thresholds.len() {
let (current_points, current_threshold) = thresholds[i];
if duration_since <= current_threshold {
if i == 0 || duration_since == current_threshold {
return current_points;
}
let (prev_points, prev_threshold) = MODIFICATION_THRESHOLDS[i - 1];
let (prev_points, prev_threshold) = thresholds[i - 1];
let time_range = current_threshold - prev_threshold;
let time_offset = duration_since - prev_threshold;
@@ -192,6 +462,7 @@ impl FrecencyTracker {
#[cfg(test)]
mod tests {
use super::*;
use crate::file_picker::FFFMode;
fn calculate_test_frecency_score(access_timestamps: &[u64], current_time: u64) -> i64 {
let mut total_frecency = 0.0;
@@ -269,34 +540,35 @@ mod tests {
// At 5 minutes: should interpolate between 16 and 8 points
let five_minutes_ago = current_time - (5 * 60);
let score = tracker.get_modification_score(five_minutes_ago, git_status);
let score = tracker.get_modification_score(five_minutes_ago, git_status, FFFMode::Neovim);
// Expected: 16 - (8 * 3 / 13) = 16 - 1 = 15 points
// (time_offset = 5-2 = 3, time_range = 15-2 = 13, points_diff = 16-8 = 8)
assert_eq!(score, 15, "5 minutes should interpolate to 15 points");
let two_minutes_ago = current_time - (2 * 60);
let score = tracker.get_modification_score(two_minutes_ago, git_status);
let score = tracker.get_modification_score(two_minutes_ago, git_status, FFFMode::Neovim);
assert_eq!(score, 16, "2 minutes should be exactly 16 points");
let fifteen_minutes_ago = current_time - (15 * 60);
let score = tracker.get_modification_score(fifteen_minutes_ago, git_status);
let score =
tracker.get_modification_score(fifteen_minutes_ago, git_status, FFFMode::Neovim);
assert_eq!(score, 8, "15 minutes should be exactly 8 points");
// At 12 hours: should interpolate between 4 and 2 points
let twelve_hours_ago = current_time - (12 * 60 * 60);
let score = tracker.get_modification_score(twelve_hours_ago, git_status);
let score = tracker.get_modification_score(twelve_hours_ago, git_status, FFFMode::Neovim);
// Expected: 4 - (2 * 11 / 23) = 4 - 0 = 4 points (integer division)
// (time_offset = 12-1 = 11 hours, time_range = 24-1 = 23 hours, points_diff = 4-2 = 2)
assert_eq!(score, 4, "12 hours should interpolate to 4 points");
// at 18 hours for more significant interpolation
let eighteen_hours_ago = current_time - (18 * 60 * 60);
let score = tracker.get_modification_score(eighteen_hours_ago, git_status);
let score = tracker.get_modification_score(eighteen_hours_ago, git_status, FFFMode::Neovim);
// Expected: 4 - (2 * 17 / 23) = 4 - 1 = 3 points
assert_eq!(score, 3, "18 hours should interpolate to 3 points");
let score = tracker.get_modification_score(five_minutes_ago, None);
let score = tracker.get_modification_score(five_minutes_ago, None, FFFMode::Neovim);
assert_eq!(score, 0, "No git status should return 0");
let _ = std::fs::remove_dir_all(&temp_dir);
+16 -12
View File
@@ -125,31 +125,35 @@ pub fn is_modified_status(status: Status) -> bool {
)
}
pub fn format_git_status(status: Option<Status>) -> &'static str {
pub fn format_git_status_opt(status: Option<Status>) -> Option<&'static str> {
match status {
None => "clear",
None => Some("clean"),
Some(status) => {
if status.contains(Status::WT_NEW) {
"untracked"
Some("untracked")
} else if status.contains(Status::WT_MODIFIED) {
"modified"
Some("modified")
} else if status.contains(Status::WT_DELETED) {
"deleted"
Some("deleted")
} else if status.contains(Status::WT_RENAMED) {
"renamed"
Some("renamed")
} else if status.contains(Status::INDEX_NEW) {
"staged_new"
Some("staged_new")
} else if status.contains(Status::INDEX_MODIFIED) {
"staged_modified"
Some("staged_modified")
} else if status.contains(Status::INDEX_DELETED) {
"staged_deleted"
Some("staged_deleted")
} else if status.contains(Status::IGNORED) {
"ignored"
Some("ignored")
} else if status.contains(Status::CURRENT) || status.is_empty() {
"clean"
Some("clean")
} else {
"unknown"
None
}
}
}
}
pub fn format_git_status(status: Option<Status>) -> &'static str {
format_git_status_opt(status).unwrap_or("unknown")
}
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)
})
}
+145 -28
View File
@@ -1,39 +1,156 @@
//! 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.
//! It maintains global state for the file picker, frecency tracker, and query tracker.
//! 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.
//!
//! ## Architecture
//!
//! - [`file_picker::FilePicker`] — Main entry point. Indexes a directory tree in a
//! background thread, maintains a sorted file list, watches the filesystem for
//! changes, and performs fuzzy search with frecency-weighted scoring.
//! - [`frecency::FrecencyTracker`] — LMDB-backed database that tracks file access
//! and modification patterns for intelligent result ranking.
//! - [`query_tracker::QueryTracker`] — Tracks search query history and provides
//! "combo-boost" scoring for repeatedly matched files.
//! - [`grep`] — Live grep search supporting regex, plain-text, and fuzzy modes
//! with optional constraint filtering.
//! - [`git`] — Git status caching and repository detection.
//!
//! ## Shared State
//!
//! [`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 = picker.fuzzy_search(
//! &query,
//! qt_guard.as_ref(),
//! FuzzySearchOptions {
//! max_threads: 0,
//! current_file: None,
//! pagination: PaginationArgs { offset: 0, limit: 50 },
//! ..Default::default()
//! },
//! );
//!
//! assert!(results.total_matched > 0);
//! assert!(results.items.first().unwrap().relative_path(picker).ends_with("lib.rs"));
//!
//! let _ = std::fs::remove_dir_all(&tmp);
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```
mod background_watcher;
pub mod constraints;
mod 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 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;
pub(crate) mod simd_path;
/// Core file picker: filesystem indexing, background watching, and fuzzy search.
///
/// See [`FilePicker`](file_picker::FilePicker) for the main entry point.
pub mod file_picker;
/// 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 once_cell::sync::Lazy;
use query_tracker::QueryTracker;
use std::sync::RwLock;
mod ignore;
/// Thread-safe shared handles for [`FilePicker`], [`FrecencyTracker`],
/// and [`QueryTracker`].
pub mod shared;
// Global state - same pattern as fff-nvim
pub static FRECENCY: Lazy<RwLock<Option<FrecencyTracker>>> = Lazy::new(|| RwLock::new(None));
pub static FILE_PICKER: Lazy<RwLock<Option<FilePicker>>> = Lazy::new(|| RwLock::new(None));
pub static QUERY_TRACKER: Lazy<RwLock<Option<QueryTracker>>> = Lazy::new(|| RwLock::new(None));
// Re-export main types for convenience
pub use bigram_filter::*;
pub use db_healthcheck::{DbHealth, DbHealthChecker};
pub use error::{Error, Result};
pub use file_picker::{FuzzySearchOptions, ScanProgress};
pub use types::{FileItem, PaginationArgs, Score, ScoringContext, SearchResult};
// Re-export query parser types (including Location which moved there)
pub use fff_query_parser::{
Constraint, FFFQuery, FuzzyQuery, Location, QueryParser, location::parse_location,
};
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::*;
+150
View File
@@ -0,0 +1,150 @@
//! Shared logging utilities for FFF crates.
//!
//! 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, PathBuf};
use tracing_appender::non_blocking;
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
static TRACING_INITIALIZED: std::sync::OnceLock<tracing_appender::non_blocking::WorkerGuard> =
std::sync::OnceLock::new();
static CRASH_HANDLERS_INSTALLED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
/// 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() {
CRASH_HANDLERS_INSTALLED.get_or_init(|| {
let default_panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let message = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
s.clone()
} else {
"Unknown panic payload".to_string()
};
let location = panic_info
.location()
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
.unwrap_or_else(|| "unknown location".to_string());
tracing::error!(
panic.message = %message,
panic.location = %location,
"PANIC occurred in FFF"
);
write_crash_report(
"RUST PANIC",
&format!("Message: {}\nLocation: {}", message, location),
);
default_panic(panic_info);
}));
unsafe {
libc::signal(
libc::SIGSEGV,
sigsegv_handler as *const () as libc::sighandler_t,
);
}
});
}
/// Parse a log level string into a `tracing::Level`.
pub fn parse_log_level(level: Option<&str>) -> tracing::Level {
match level.as_ref().map(|s| s.trim().to_lowercase()).as_deref() {
Some("trace") => tracing::Level::TRACE,
Some("debug") => tracing::Level::DEBUG,
Some("info") => tracing::Level::INFO,
Some("warn") => tracing::Level::WARN,
Some("error") => tracing::Level::ERROR,
_ => tracing::Level::INFO,
}
}
/// Initialize tracing with a single log file.
pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<String, io::Error> {
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) // truncates a file on restart (instead of appending)
.open(log_path)?;
let level = parse_log_level(log_level);
TRACING_INITIALIZED.get_or_init(|| {
let (non_blocking_appender, guard) = non_blocking(file_appender);
let subscriber = tracing_subscriber::registry()
.with(
fmt::layer()
.with_writer(non_blocking_appender)
.with_target(true)
.with_thread_ids(false)
.with_thread_names(false)
.with_ansi(false)
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
)
.with(
EnvFilter::builder()
.with_default_directive(level.into())
.from_env_lossy(),
);
if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
eprintln!("Failed to set tracing subscriber: {}", e);
} else {
tracing::info!(
"FFF tracing initialized with log file: {}",
log_path.display()
);
}
guard
});
Ok(log_file_path.to_string())
}
+89 -52
View File
@@ -1,52 +1,94 @@
//! Path utility functions for file picker scoring
use std::path::{Path, PathBuf};
/// Calculate distance penalty based on directory proximity
/// Returns a negative penalty score based on how far the candidate is from the current file
pub fn calculate_distance_penalty(current_file: Option<&str>, candidate_path: &str) -> i32 {
let Some(ref current_path) = current_file else {
return 0; // No penalty if no current file
#[cfg(windows)]
pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
dunce::canonicalize(path)
}
#[cfg(not(windows))]
pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
std::fs::canonicalize(path)
}
#[cfg(windows)]
pub fn expand_tilde(path: &str) -> PathBuf {
return PathBuf::from(path);
}
#[cfg(not(windows))]
pub fn expand_tilde(path: &str) -> PathBuf {
if let Some(stripped) = path.strip_prefix("~/")
&& let Some(home_dir) = dirs::home_dir()
{
return home_dir.join(stripped);
}
PathBuf::from(path)
}
/// Calculate distance penalty based on directory proximity.
/// Returns a negative penalty score based on how far the candidate is from the current file.
///
/// `candidate_dir` is the directory portion of the candidate path (e.g. `"src/components/"`).
/// It may have a trailing `/` which is stripped internally.
///
/// Zero-allocation: walks both directory part iterators in lockstep.
pub fn calculate_distance_penalty(current_file: Option<&str>, candidate_dir: &str) -> i32 {
let Some(current_path) = current_file else {
return 0;
};
let current_dir = if let Some(parent) = std::path::Path::new(current_path).parent() {
parent.to_string_lossy().to_string()
} else {
String::new()
// Extract the directory from current_file (everything before the last '/')
let current_dir = match current_path.rfind('/') {
Some(pos) => &current_path[..pos],
None => "",
};
let candidate_dir = if let Some(parent) = std::path::Path::new(candidate_path).parent() {
parent.to_string_lossy().to_string()
} else {
String::new()
};
// Strip trailing separator from candidate_dir
let candidate_dir = candidate_dir.strip_suffix('/').unwrap_or(candidate_dir);
if current_dir == candidate_dir {
return 0; // Same directory, no penalty
return 0;
}
let current_parts: Vec<&str> = current_dir
.split(std::path::MAIN_SEPARATOR)
.filter(|s| !s.is_empty())
.collect();
let candidate_parts: Vec<&str> = candidate_dir
.split(std::path::MAIN_SEPARATOR)
.filter(|s| !s.is_empty())
.collect();
// Count components and common prefix length by walking iterators in lockstep.
// No Vec allocation — just iterate and count.
let mut current_parts = current_dir.split('/').filter(|s| !s.is_empty());
let mut candidate_parts = candidate_dir.split('/').filter(|s| !s.is_empty());
let common_len = current_parts
.iter()
.zip(candidate_parts.iter())
.take_while(|(a, b)| a == b)
.count();
let mut common_len = 0usize;
let mut current_total = 0usize;
let current_depth_from_common = current_parts.len() - common_len;
if current_depth_from_common == 0 {
return 0; // Current file is at the common ancestor level
loop {
match (current_parts.next(), candidate_parts.next()) {
(Some(a), Some(b)) => {
current_total += 1;
if a == b {
common_len += 1;
} else {
// Diverged — count remaining current parts
current_total += current_parts.count();
break;
}
}
(Some(_), None) => {
// current is deeper
current_total += 1 + current_parts.count();
break;
}
(None, _) => {
// current is at or above candidate
break;
}
}
}
let penalty = -(current_depth_from_common as i32);
let depth_from_common = current_total - common_len;
if depth_from_common == 0 {
return 0;
}
penalty.max(-20)
(-(depth_from_common as i32)).max(-20)
}
#[cfg(test)]
@@ -56,16 +98,11 @@ mod tests {
#[test]
#[cfg(not(target_family = "windows"))]
fn test_calculate_distance_penalty() {
assert_eq!(
calculate_distance_penalty(None, "examples/user/test/mod.rs"),
0
);
// candidate_dir is now just the directory portion (with or without trailing /)
assert_eq!(calculate_distance_penalty(None, "examples/user/test/"), 0);
// Same directory
assert_eq!(
calculate_distance_penalty(
Some("examples/user/test/main.rs"),
"examples/user/test/mod.rs"
),
calculate_distance_penalty(Some("examples/user/test/main.rs"), "examples/user/test/"),
0
);
//
@@ -73,7 +110,7 @@ mod tests {
assert_eq!(
calculate_distance_penalty(
Some("examples/user/test/subdir/file.rs"),
"examples/user/test/mod.rs"
"examples/user/test/"
),
-1
);
@@ -82,7 +119,7 @@ mod tests {
assert_eq!(
calculate_distance_penalty(
Some("examples/user/test/dir1/file.rs"),
"examples/user/test/dir2/mod.rs"
"examples/user/test/dir2/"
),
-1
);
@@ -90,7 +127,7 @@ mod tests {
assert_eq!(
calculate_distance_penalty(
Some("examples/audio-announce/src/lib/audio-announce.rs"),
"examples/audio-announce/src/main.rs"
"examples/audio-announce/src/"
),
-1
);
@@ -98,27 +135,27 @@ mod tests {
assert_eq!(
calculate_distance_penalty(
Some("examples/audio-announce/src/audio-announce.rs"),
"examples/pixel/src/main.rs"
"examples/pixel/src/"
),
-2
);
// Root level files
assert_eq!(calculate_distance_penalty(Some("main.rs"), "lib.rs"), 0);
// Root level files (empty dir)
assert_eq!(calculate_distance_penalty(Some("main.rs"), ""), 0);
}
#[test]
#[cfg(target_family = "windows")]
fn distance_penalty_works_on_windows() {
assert_eq!(
calculate_distance_penalty(None, "examples\\user\\test\\mod.rs"),
calculate_distance_penalty(None, "examples\\user\\test\\"),
0
);
// Same directory
assert_eq!(
calculate_distance_penalty(
Some("examples\\user\\test\\main.rs"),
"examples\\user\\test\\mod.rs"
"examples\\user\\test\\"
),
0
);
@@ -127,7 +164,7 @@ mod tests {
assert_eq!(
calculate_distance_penalty(
Some("examples\\user\\test\\subdir\\file.rs"),
"examples\\user\\test\\mod.rs"
"examples\\user\\test\\"
),
-1
);
+104 -37
View File
@@ -31,8 +31,10 @@ pub struct QueryTracker {
env: Env,
// Database for (project_path, query) -> QueryMatchEntry mappings
query_file_db: Database<Bytes, SerdeBincode<QueryMatchEntry>>,
// Database for project_path -> VecDeque<HistoryEntry> mappings
// Database for project_path -> VecDeque<HistoryEntry> mappings (file picker)
query_history_db: Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
// Database for project_path -> VecDeque<HistoryEntry> mappings (grep)
grep_query_history_db: Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
}
impl DbHealthChecker for QueryTracker {
@@ -45,19 +47,32 @@ impl DbHealthChecker for QueryTracker {
let count_queries = self.query_file_db.len(&rtxn).map_err(Error::DbRead)?;
let count_histories = self.query_history_db.len(&rtxn).map_err(Error::DbRead)?;
let count_grep_histories = self
.grep_query_history_db
.len(&rtxn)
.map_err(Error::DbRead)?;
Ok(vec![
("query_file_entries", count_queries),
("query_history_entries", count_histories),
("grep_query_history_entries", count_grep_histories),
])
}
}
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
opts.max_dbs(16); // Allow up to 16 databases per environment
if use_unsafe_no_lock {
opts.flags(EnvFlags::NO_LOCK | EnvFlags::NO_SYNC | EnvFlags::NO_META_SYNC);
@@ -77,6 +92,9 @@ impl QueryTracker {
let query_history_db = env
.create_database(&mut wtxn, Some("query_history"))
.map_err(Error::DbCreate)?;
let grep_query_history_db = env
.create_database(&mut wtxn, Some("grep_query_history"))
.map_err(Error::DbCreate)?;
wtxn.commit().map_err(Error::DbCommit)?;
@@ -84,6 +102,7 @@ impl QueryTracker {
env,
query_file_db,
query_history_db,
grep_query_history_db,
})
}
@@ -115,6 +134,57 @@ impl QueryTracker {
Ok(*blake3::hash(project_str.as_bytes()).as_bytes())
}
/// Append a query to a history database within an existing write transaction.
fn append_to_history(
db: &Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
wtxn: &mut heed::RwTxn,
project_key: &[u8; 32],
query: &str,
now: u64,
) -> Result<(), Error> {
let mut history = db
.get(wtxn, project_key)
.map_err(Error::DbRead)?
.unwrap_or_default();
history.push_back(HistoryEntry {
query: query.to_string(),
timestamp: now,
});
while history.len() > MAX_HISTORY_ENTRIES {
history.pop_front();
}
db.put(wtxn, project_key, &history)
.map_err(Error::DbWrite)?;
Ok(())
}
/// Read a query from a history database at a specific offset.
/// offset=0 returns most recent, offset=1 returns 2nd most recent, etc.
fn read_history_at_offset(
db: &Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
env: &Env,
project_key: &[u8; 32],
offset: usize,
) -> Result<Option<String>, Error> {
let rtxn = env.read_txn().map_err(Error::DbStartReadTxn)?;
let mut history = db
.get(&rtxn, project_key)
.map_err(Error::DbRead)?
.unwrap_or_default();
// history is FIFO, last element is most recent
if history.len() > offset {
let index = history.len() - 1 - offset;
let record = history.remove(index);
Ok(record.map(|r| r.query))
} else {
Ok(None)
}
}
pub fn track_query_completion(
&mut self,
query: &str,
@@ -166,24 +236,7 @@ impl QueryTracker {
// Update query history database
let project_key = Self::create_project_key(project_path)?;
let mut history = self
.query_history_db
.get(&wtxn, &project_key)
.map_err(Error::DbRead)?
.unwrap_or_default();
let history_entry = HistoryEntry {
query: query.to_string(),
timestamp: now,
};
history.push_back(history_entry);
while history.len() > MAX_HISTORY_ENTRIES {
history.pop_front();
}
self.query_history_db
.put(&mut wtxn, &project_key, &history)
.map_err(Error::DbWrite)?;
Self::append_to_history(&self.query_history_db, &mut wtxn, &project_key, query, now)?;
wtxn.commit().map_err(Error::DbCommit)?;
@@ -198,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
@@ -237,32 +289,47 @@ impl QueryTracker {
}
}
/// Get query from history at a specific offset
/// Get query from file picker history at a specific offset.
/// offset=0 returns most recent query, offset=1 returns 2nd most recent, etc.
/// Returns None if offset exceeds history length
pub fn get_historical_query(
&self,
project_path: &Path,
offset: usize,
) -> Result<Option<String>, Error> {
let project_key = Self::create_project_key(project_path)?;
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
Self::read_history_at_offset(&self.query_history_db, &self.env, &project_key, offset)
}
let mut history = self
.query_history_db
.get(&rtxn, &project_key)
.map_err(Error::DbRead)?
.unwrap_or_default();
/// Track a grep query in the grep-specific history.
/// Only records query history (no file association tracking needed for grep).
pub fn track_grep_query(&mut self, query: &str, project_path: &Path) -> Result<(), Error> {
let now = self.get_now();
let project_key = Self::create_project_key(project_path)?;
let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?;
// history is FIFO, last element is most recent
if history.len() > offset {
let index = history.len() - 1 - offset;
let record = history.remove(index);
Self::append_to_history(
&self.grep_query_history_db,
&mut wtxn,
&project_key,
query,
now,
)?;
Ok(record.map(|r| r.query))
} else {
Ok(None)
}
wtxn.commit().map_err(Error::DbCommit)?;
tracing::debug!(?query, "Tracked grep query");
Ok(())
}
/// Get grep query from history at a specific offset.
/// offset=0 returns most recent grep query, offset=1 returns 2nd most recent, etc.
pub fn get_historical_grep_query(
&self,
project_path: &Path,
offset: usize,
) -> Result<Option<String>, Error> {
let project_key = Self::create_project_key(project_path)?;
Self::read_history_at_offset(&self.grep_query_history_db, &self.env, &project_key, offset)
}
}
+1047 -308
View File
File diff suppressed because it is too large Load Diff
+283
View File
@@ -0,0 +1,283 @@
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())
}
/// Return `true` if this is an instance of the picker that requires a complicated post-scan
/// indexing/cache warmup job. The indexing is not crazy but it takes time.
pub fn need_complex_rebuild(&self) -> bool {
let guard = self.0.read();
guard
.as_ref()
.is_some_and(|p| p.need_enable_mmap_cache() || p.need_enable_content_indexing())
}
/// Block until the background filesystem scan finishes.
/// Returns `true` if scan completed, `false` on timeout.
pub fn wait_for_scan(&self, timeout: Duration) -> bool {
let signal = {
let guard = self.0.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))
}
}
+573
View File
@@ -0,0 +1,573 @@
use ahash::AHashMap;
use smallvec::SmallVec;
use std::borrow::Cow;
/// SIMD chunk size in bytes (matches NEON/SSE2 register width).
/// This must stay in sync with neo_frizbee's internal chunk size.
pub(crate) const SIMD_CHUNK_BYTES: usize = 16;
/// 4 chunks = 64 bytes inline, covers ~85% of paths without heap fallback.
const INLINE_CHUNKS: usize = 4;
pub(crate) type ChunkIndices = SmallVec<[u32; INLINE_CHUNKS]>;
#[derive(Clone, Copy)]
pub struct ArenaPtr(pub(crate) *const u8);
// SAFETY: The arena is a read-only immutable part of file sync
unsafe impl Send for ArenaPtr {}
unsafe impl Sync for ArenaPtr {}
impl ArenaPtr {
#[inline]
pub fn new(ptr: *const u8) -> Self {
Self(ptr)
}
#[inline]
pub fn null() -> Self {
Self(std::ptr::null())
}
#[inline]
pub fn as_ptr(self) -> *const u8 {
self.0
}
}
impl std::fmt::Debug for ArenaPtr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "--arena-raw-pointer-0x({:?})", self.0)
}
}
#[repr(C, align(16))]
#[derive(Clone, Copy)]
pub(crate) struct SimdChunk(pub(crate) [u8; SIMD_CHUNK_BYTES]);
impl Default for SimdChunk {
#[inline]
fn default() -> Self {
Self([0u8; SIMD_CHUNK_BYTES])
}
}
impl std::fmt::Debug for SimdChunk {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Show the actual bytes, trimming trailing zeros for readability
let end = self.0.iter().rposition(|&b| b != 0).map_or(0, |i| i + 1);
write!(f, "SimdChunk({:?})", &self.0[..end])
}
}
pub const PATH_BUF_SIZE: usize = 4096;
/// Indices into a shared `SimdChunk` arena representing a file path.
///
/// All read methods require an explicit `arena_base` pointer from the owning
/// `ChunkedPathStore`. The struct itself contains no raw pointers to the arena
#[derive(Clone)]
pub(crate) struct ChunkedString {
indices: ChunkIndices,
pub byte_len: u16,
/// Byte offset where the filename begins. 0 for root-level files.
pub filename_offset: u16,
}
impl ChunkedString {
pub fn empty() -> Self {
Self {
indices: SmallVec::new(),
byte_len: 0,
filename_offset: 0,
}
}
#[inline]
pub fn new(indices: ChunkIndices, byte_len: u16, filename_offset: u16) -> Self {
Self {
indices,
byte_len,
filename_offset,
}
}
#[cfg(test)]
pub fn chunk_count(&self) -> usize {
self.indices.len()
}
#[inline]
pub fn resolve_ptrs<'a>(
&self,
arena: ArenaPtr,
buf: &'a mut [*const u8; 32],
) -> &'a [*const u8] {
let count = self.indices.len();
let base = arena.as_ptr();
for (i, &idx) in self.indices.iter().enumerate() {
buf[i] = unsafe { base.add(idx as usize * SIMD_CHUNK_BYTES) };
}
&buf[..count]
}
#[inline]
fn write_slice_to_vec(
indices: &[u32],
base: *const u8,
offset_in_chunk: usize,
len: usize,
vec: &mut Vec<u8>,
) {
let mut written = 0usize;
for (i, &idx) in indices.iter().enumerate() {
let src = unsafe { base.add(idx as usize * SIMD_CHUNK_BYTES) };
let chunk_bytes = unsafe { core::slice::from_raw_parts(src, SIMD_CHUNK_BYTES) };
let start = if i == 0 { offset_in_chunk } else { 0 };
let end = SIMD_CHUNK_BYTES.min(start + (len - written));
vec.extend_from_slice(&chunk_bytes[start..end]);
written += end - start;
}
}
/// Return the filename portion as a `Cow<str>`.
///
/// When the filename starts at a chunk boundary and fits in one chunk we
/// borrow directly from the arena (zero-copy). Otherwise we allocate.
/// Filenames are almost always <=16 bytes so the fast path dominates.
#[inline]
pub fn filename_cow<'a>(&self, arena: ArenaPtr) -> Cow<'a, str> {
let fname_offset = self.filename_offset as usize;
let fname_len = self.byte_len as usize - fname_offset;
if fname_len == 0 {
return Cow::Borrowed("");
}
let base = arena.as_ptr();
let start_chunk = fname_offset / SIMD_CHUNK_BYTES;
let offset_in_chunk = fname_offset % SIMD_CHUNK_BYTES;
if offset_in_chunk == 0 && fname_len <= SIMD_CHUNK_BYTES {
let ptr = unsafe { base.add(self.indices[start_chunk] as usize * SIMD_CHUNK_BYTES) };
let slice = unsafe { core::slice::from_raw_parts(ptr, fname_len) };
return Cow::Borrowed(unsafe { core::str::from_utf8_unchecked(slice) });
}
let mut out = String::with_capacity(fname_len);
let needed_chunks = chunks_needed(offset_in_chunk + fname_len);
Self::write_slice_to_vec(
&self.indices[start_chunk..start_chunk + needed_chunks],
base,
offset_in_chunk,
fname_len,
unsafe { out.as_mut_vec() },
);
Cow::Owned(out)
}
/// Truncates at `buf.len()` if exceeded -- use `[u8; PATH_BUF_SIZE]` to avoid.
#[inline]
pub fn read_to_buf<'a>(&self, arena: ArenaPtr, buf: &'a mut [u8]) -> &'a str {
let total = (self.byte_len as usize).min(buf.len());
let usable_chunks = total.div_ceil(SIMD_CHUNK_BYTES);
let chunks_to_copy = usable_chunks.min(self.indices.len());
let base = arena.as_ptr();
for (i, &idx) in self.indices[..chunks_to_copy].iter().enumerate() {
let src = unsafe { base.add(idx as usize * SIMD_CHUNK_BYTES) };
let dst_offset = i * SIMD_CHUNK_BYTES;
let take = SIMD_CHUNK_BYTES.min(total - dst_offset);
unsafe {
core::ptr::copy_nonoverlapping(src, buf.as_mut_ptr().add(dst_offset), take);
}
}
unsafe { core::str::from_utf8_unchecked(&buf[..total]) }
}
#[inline]
pub fn write_dir_to(&self, arena: ArenaPtr, out: &mut String) {
out.clear();
let dir_len = self.filename_offset as usize;
out.reserve(dir_len);
let dir_chunks = chunks_needed(dir_len).min(self.indices.len());
let base = arena.as_ptr();
let vec = unsafe { out.as_mut_vec() };
for (i, &idx) in self.indices[..dir_chunks].iter().enumerate() {
let src = unsafe { base.add(idx as usize * SIMD_CHUNK_BYTES) };
let take = SIMD_CHUNK_BYTES.min(dir_len - i * SIMD_CHUNK_BYTES);
vec.extend_from_slice(unsafe { core::slice::from_raw_parts(src, take) });
}
}
#[inline]
pub fn write_filename_to(&self, arena: ArenaPtr, out: &mut String) {
out.clear();
let fname_offset = self.filename_offset as usize;
let fname_len = self.byte_len as usize - fname_offset;
out.reserve(fname_len);
let start_chunk = fname_offset / SIMD_CHUNK_BYTES;
let offset_in_chunk = fname_offset % SIMD_CHUNK_BYTES;
let needed_chunks = chunks_needed(offset_in_chunk + fname_len);
Self::write_slice_to_vec(
&self.indices[start_chunk..start_chunk + needed_chunks],
arena.as_ptr(),
offset_in_chunk,
fname_len,
unsafe { out.as_mut_vec() },
);
}
#[inline]
pub fn write_to_string(&self, arena: ArenaPtr, out: &mut String) {
out.clear();
let total = self.byte_len as usize;
if total == 0 {
return;
}
out.reserve(total);
let base = arena.as_ptr();
let vec = unsafe { out.as_mut_vec() };
for (i, &idx) in self.indices.iter().enumerate() {
let src = unsafe { base.add(idx as usize * SIMD_CHUNK_BYTES) };
let take = SIMD_CHUNK_BYTES.min(total - i * SIMD_CHUNK_BYTES);
vec.extend_from_slice(unsafe { core::slice::from_raw_parts(src, take) });
}
}
}
impl std::fmt::Debug for ChunkedString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ChunkedString")
.field("indices", &self.indices.as_slice())
.field("chunks", &self.indices.len())
.field("byte_len", &self.byte_len)
.field("filename_offset", &self.filename_offset)
.finish()
}
}
#[inline]
const fn chunks_needed(byte_len: usize) -> usize {
if byte_len == 0 {
0
} else {
byte_len.div_ceil(SIMD_CHUNK_BYTES)
}
}
#[derive(Clone, Debug)]
pub(crate) struct ChunkedPathStore {
arena: Vec<SimdChunk>,
}
// SAFETY: arena is immutable after construction. Pointers derived from it are
// only read during scoring (no mutation, no reallocation).
unsafe impl Send for ChunkedPathStore {}
unsafe impl Sync for ChunkedPathStore {}
impl ChunkedPathStore {
pub fn heap_bytes(&self) -> usize {
self.arena.len() * SIMD_CHUNK_BYTES
}
#[cfg(test)]
fn unique_chunks(&self) -> usize {
self.arena.len()
}
#[inline]
pub fn as_arena_ptr(&self) -> ArenaPtr {
ArenaPtr::new(self.arena.as_ptr() as *const u8)
}
}
/// At runtime the builder should be split out from the store after `finish()`.
#[derive(Clone, Debug)]
pub(crate) struct ChunkedPathStoreBuilder {
arena: Vec<SimdChunk>,
chunk_dedup: AHashMap<[u8; SIMD_CHUNK_BYTES], u32>,
}
impl ChunkedPathStoreBuilder {
pub fn new(estimated_files: usize) -> Self {
let est_chunks = estimated_files * 3;
Self {
arena: Vec::with_capacity(est_chunks / 2),
chunk_dedup: AHashMap::with_capacity(est_chunks / 2),
}
}
pub fn finish(self) -> ChunkedPathStore {
ChunkedPathStore { arena: self.arena }
}
pub fn as_arena_ptr(&self) -> ArenaPtr {
ArenaPtr::new(self.arena.as_ptr() as *const u8)
}
/// Like [`add_file_immediate`] but for directory paths where the entire
/// string is the "directory" portion (filename_offset == byte_len).
pub fn add_dir_immediate(&mut self, dir_rel_path: &str) -> ChunkedString {
self.add_file_immediate(dir_rel_path, dir_rel_path.len() as u16)
}
pub fn add_file_immediate(&mut self, rel_path: &str, filename_offset: u16) -> ChunkedString {
let path_bytes = rel_path.as_bytes();
let byte_len = rel_path.len();
let n_chunks = chunks_needed(byte_len);
let mut indices = ChunkIndices::with_capacity(n_chunks);
for i in 0..n_chunks {
let chunk_start = i * SIMD_CHUNK_BYTES;
let chunk_end = (chunk_start + SIMD_CHUNK_BYTES).min(byte_len);
let mut chunk_bytes = [0u8; SIMD_CHUNK_BYTES];
chunk_bytes[..chunk_end - chunk_start]
.copy_from_slice(&path_bytes[chunk_start..chunk_end]);
let arena_idx = match self.chunk_dedup.get(&chunk_bytes) {
Some(&idx) => idx,
None => {
let idx = self.arena.len() as u32;
self.arena.push(SimdChunk(chunk_bytes));
self.chunk_dedup.insert(chunk_bytes, idx);
idx
}
};
indices.push(arena_idx);
}
ChunkedString::new(indices, byte_len as u16, filename_offset)
}
}
#[cfg(test)]
pub(crate) fn build_chunked_path_store_from_strings(
rel_paths: &[String],
files: &[crate::types::FileItem],
) -> (ChunkedPathStore, Vec<ChunkedString>) {
assert_eq!(rel_paths.len(), files.len());
let mut builder = ChunkedPathStoreBuilder::new(rel_paths.len());
let strings: Vec<ChunkedString> = rel_paths
.iter()
.zip(files.iter())
.map(|(rel_path, file)| builder.add_file_immediate(rel_path, file.path.filename_offset))
.collect();
(builder.finish(), strings)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_file_item(path: &str) -> crate::types::FileItem {
let filename_start = path.rfind('/').map(|i| i + 1).unwrap_or(0) as u16;
crate::types::FileItem::new_raw(filename_start, 0, 0, None, false)
}
fn build_test_store(
paths: &[&str],
) -> (
ChunkedPathStore,
Vec<ChunkedString>,
Vec<crate::types::FileItem>,
) {
let mut files: Vec<crate::types::FileItem> =
paths.iter().map(|p| make_file_item(p)).collect();
let path_strings: Vec<String> = paths.iter().map(|p| p.to_string()).collect();
let (store, strings) = build_chunked_path_store_from_strings(&path_strings, &files);
for (i, file) in files.iter_mut().enumerate() {
file.set_path(strings[i].clone());
}
(store, strings, files)
}
#[test]
fn test_chunked_store_empty() {
let (store, strings, _files) = build_test_store(&[]);
assert_eq!(strings.len(), 0);
assert_eq!(store.unique_chunks(), 0);
}
#[test]
fn test_chunked_store_basic() {
let (store, strings, _files) =
build_test_store(&["src/lib.rs", "src/main.rs", "Cargo.toml"]);
let arena = store.as_arena_ptr();
assert_eq!(strings.len(), 3);
assert!(store.unique_chunks() >= 2);
let mut buf = [0u8; 512];
assert_eq!(
strings[0].read_to_buf(arena, &mut buf).len(),
"src/lib.rs".len()
);
assert_eq!(
strings[2].read_to_buf(arena, &mut buf).len(),
"Cargo.toml".len()
);
}
#[test]
fn test_chunked_string_full_path() {
let (store, strings, _files) = build_test_store(&["src/components/Button.tsx"]);
let arena = store.as_arena_ptr();
let cs = &strings[0];
let mut buf = [0u8; 512];
assert_eq!(cs.read_to_buf(arena, &mut buf), "src/components/Button.tsx");
assert_eq!(cs.byte_len, 25);
assert_eq!(cs.filename_offset, 15);
}
#[test]
fn test_chunked_string_dir_and_filename() {
let (store, strings, _files) = build_test_store(&["src/components/Button.tsx"]);
let arena = store.as_arena_ptr();
let cs = &strings[0];
let mut s = String::new();
cs.write_dir_to(arena, &mut s);
assert_eq!(s, "src/components/");
cs.write_filename_to(arena, &mut s);
assert_eq!(s, "Button.tsx");
}
#[test]
fn test_chunked_string_root_file() {
let (store, strings, _files) = build_test_store(&["Cargo.toml"]);
let arena = store.as_arena_ptr();
let cs = &strings[0];
let mut s = String::new();
cs.write_dir_to(arena, &mut s);
assert_eq!(s, "");
cs.write_filename_to(arena, &mut s);
assert_eq!(s, "Cargo.toml");
let mut buf = [0u8; 512];
assert_eq!(cs.read_to_buf(arena, &mut buf), "Cargo.toml");
}
#[test]
fn test_chunked_string_resolve_ptrs() {
let (store, strings, _files) = build_test_store(&["src/components/Button.tsx"]);
let arena = store.as_arena_ptr();
let cs = &strings[0];
let mut ptrs = [std::ptr::null::<u8>(); 32];
let resolved = cs.resolve_ptrs(arena, &mut ptrs);
assert_eq!(resolved.len(), 2); // 25 bytes = 2 chunks
// Verify we can read back the bytes
let mut reconstructed = Vec::new();
for (i, &ptr) in resolved.iter().enumerate() {
let chunk = unsafe { std::slice::from_raw_parts(ptr, SIMD_CHUNK_BYTES) };
let start = i * SIMD_CHUNK_BYTES;
let take = SIMD_CHUNK_BYTES.min(25 - start);
reconstructed.extend_from_slice(&chunk[..take]);
}
assert_eq!(
std::str::from_utf8(&reconstructed).unwrap(),
"src/components/Button.tsx"
);
}
#[test]
fn test_filename_cow_mid_chunk() {
let (store, strings, _files) = build_test_store(&["src/components/Button.tsx"]);
let arena = store.as_arena_ptr();
let cs = &strings[0];
assert_eq!(cs.filename_offset, 15);
assert_eq!(cs.byte_len, 25);
let fname = cs.filename_cow(arena);
assert_eq!(&*fname, "Button.tsx");
}
#[test]
fn test_filename_cow_chunk_aligned() {
let path = "0123456789abcdef/file.txt";
let (store, strings, _files) = build_test_store(&[path]);
let arena = store.as_arena_ptr();
let cs = &strings[0];
assert_eq!(cs.filename_offset, 17);
let fname = cs.filename_cow(arena);
assert_eq!(&*fname, "file.txt");
}
#[test]
fn test_filename_cow_root_file() {
let (store, strings, _files) = build_test_store(&["Cargo.toml"]);
let arena = store.as_arena_ptr();
let cs = &strings[0];
assert_eq!(cs.filename_offset, 0);
let fname = cs.filename_cow(arena);
assert_eq!(&*fname, "Cargo.toml");
}
#[test]
fn test_chunked_string_long_path() {
let path = "very/deeply/nested/directory/structure/with/many/levels/file.txt";
let (store, strings, _files) = build_test_store(&[path]);
let arena = store.as_arena_ptr();
let cs = &strings[0];
let mut buf = [0u8; 512];
assert_eq!(cs.read_to_buf(arena, &mut buf), path);
assert!(
cs.chunk_count() <= 6,
"should fit inline in ChunkIndices (INLINE_CHUNKS={})",
INLINE_CHUNKS
);
}
#[test]
fn test_chunked_string_clone() {
let (store, strings, _files) = build_test_store(&["src/main.rs"]);
let arena = store.as_arena_ptr();
let cs = &strings[0];
let cs2 = cs.clone();
let mut buf1 = [0u8; 512];
let mut buf2 = [0u8; 512];
assert_eq!(
cs.read_to_buf(arena, &mut buf1),
cs2.read_to_buf(arena, &mut buf2)
);
}
#[test]
fn test_chunked_string_full_path_roundtrip() {
let paths = [
"src/components/Button.tsx",
"src/components/ui/DatePicker.tsx",
"very/deeply/nested/directory/structure/file.txt",
"Cargo.toml",
"a.rs",
];
let (store, strings, _files) = build_test_store(&paths);
let arena = store.as_arena_ptr();
for (i, expected) in paths.iter().enumerate() {
let mut buf = [0u8; 512];
let got = strings[i].read_to_buf(arena, &mut buf);
assert_eq!(got, *expected, "full path roundtrip failed for file {i}");
let mut ds = String::new();
let mut fs = String::new();
strings[i].write_dir_to(arena, &mut ds);
strings[i].write_filename_to(arena, &mut fs);
assert_eq!(
format!("{ds}{fs}"),
*expected,
"dir+fname mismatch for file {i}"
);
}
}
}
+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]
+705 -40
View File
@@ -1,38 +1,561 @@
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::sync::atomic::{AtomicI32, AtomicU64, AtomicUsize, Ordering};
use crate::constraints::Constrainable;
use crate::query_tracker::QueryMatchEntry;
use crate::simd_path::{ArenaPtr, PATH_BUF_SIZE};
use fff_query_parser::{FFFQuery, FuzzyQuery, Location};
#[derive(Debug, Clone)]
/// Different sources of the string storage used by FFF
/// implements as a deduplicated 16-bytes alined heap
/// can be stored in RAM or on disk
pub trait FFFStringStorage {
/// Resolve the arena for a [`FileItem`] (handles base vs overflow split).
fn arena_for(&self, file: &FileItem) -> ArenaPtr;
/// The base arena (scan-time paths).
fn base_arena(&self) -> ArenaPtr;
/// The overflow arena (paths added after the last full scan).
fn overflow_arena(&self) -> ArenaPtr;
}
impl FFFStringStorage for ArenaPtr {
#[inline]
fn arena_for(&self, _file: &FileItem) -> ArenaPtr {
*self
}
#[inline]
fn base_arena(&self) -> ArenaPtr {
*self
}
#[inline]
fn overflow_arena(&self) -> ArenaPtr {
*self
}
}
/// Cached file contents — mmap on Unix, heap buffer on Windows.
///
/// On Windows, memory-mapped files hold the file handle open and prevent
/// editors from saving (writing/replacing) those files. Reading into a
/// `Vec<u8>` releases the handle immediately after the read completes.
///
/// The `Buffer` variant is also used on Unix for temporary (uncached) reads
/// where the mmap/munmap syscall overhead exceeds the cost of a heap copy.
#[derive(Debug)]
#[allow(dead_code)] // variants are conditionally used per platform
pub enum FileContent {
#[cfg(not(target_os = "windows"))]
Mmap(memmap2::Mmap),
Buffer(Vec<u8>),
}
impl std::ops::Deref for FileContent {
type Target = [u8];
fn deref(&self) -> &[u8] {
match self {
#[cfg(not(target_os = "windows"))]
FileContent::Mmap(m) => m,
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;
/// File was added after the last full reindex; its indices point
/// into the overflow builder arena, not the base arena.
pub const OVERFLOW: u8 = 1 << 2;
}
pub struct DirFlags;
impl DirFlags {
pub const OVERFLOW: u8 = 1 << 0;
}
/// A directory in the file index. Shares chunk arena with file paths.
#[derive(Debug)]
pub struct DirItem {
flags: u8,
pub(crate) path: crate::simd_path::ChunkedString,
/// Byte offset where the last path segment begins (e.g. for `src/components/`
/// this is 4, pointing to `components/`). Used for dirname-bonus scoring.
last_segment_offset: u16,
/// Maximum `access_frecency_score` among direct child files.
/// Atomic so parallel frecency updates can write directly without juggling.
max_access_frecency: AtomicI32,
}
impl Clone for DirItem {
fn clone(&self) -> Self {
Self {
flags: self.flags,
path: self.path.clone(),
last_segment_offset: self.last_segment_offset,
max_access_frecency: AtomicI32::new(self.max_access_frecency()),
}
}
}
impl DirItem {
#[inline(always)]
pub fn is_overflow(&self) -> bool {
self.flags & DirFlags::OVERFLOW == 0
}
pub(crate) fn new(path: crate::simd_path::ChunkedString, last_segment_offset: u16) -> Self {
Self {
path,
flags: 0,
last_segment_offset,
max_access_frecency: AtomicI32::new(0),
}
}
/// Byte offset of the last path segment within the directory path.
#[inline]
pub fn last_segment_offset(&self) -> u16 {
self.last_segment_offset
}
/// Current max access frecency score.
#[inline]
pub fn max_access_frecency(&self) -> i32 {
self.max_access_frecency.load(Ordering::Relaxed)
}
/// Atomically update the directory's frecency score if the given score is larger.
/// Safe to call from parallel threads.
#[inline]
pub fn update_frecency_if_larger(&self, score: i32) {
self.max_access_frecency.fetch_max(score, Ordering::Relaxed);
}
/// Reset frecency to zero (used before full recomputation).
#[inline]
pub fn reset_frecency(&self) {
self.max_access_frecency.store(0, Ordering::Relaxed);
}
pub(crate) fn read_relative_path<'a>(&self, arena: ArenaPtr, buf: &'a mut [u8]) -> &'a str {
self.path.read_to_buf(arena, buf)
}
/// Relative dir path as owned String (cold path).
pub fn relative_path(&self, arena: impl FFFStringStorage) -> String {
let mut out = String::new();
let ptr = if self.is_overflow() {
arena.overflow_arena()
} else {
arena.base_arena()
};
self.path.write_to_string(ptr, &mut out);
out
}
/// Write the last segment (dirname) of this directory path to `out`.
pub fn write_dir_name(&self, arena: ArenaPtr, out: &mut String) {
out.clear();
let total = self.path.byte_len as usize;
let offset = self.last_segment_offset as usize;
if offset >= total {
return;
}
// Read the full path, then slice from last_segment_offset
let mut buf = [0u8; PATH_BUF_SIZE];
let full = self.path.read_to_buf(arena, &mut buf);
out.push_str(&full[offset..]);
}
/// The dirname (last segment) as an owned String. Cold path.
pub fn dir_name(&self, arena: impl FFFStringStorage) -> String {
let mut out = String::new();
let ptr = if self.is_overflow() {
arena.overflow_arena()
} else {
arena.base_arena()
};
self.write_dir_name(ptr, &mut out);
out
}
/// A path = base_path + "/" + relative. Cold path, allocates.
pub fn absolute_path(&self, arena: impl FFFStringStorage, base_path: &Path) -> PathBuf {
let rel = self.relative_path(arena);
if rel.is_empty() {
base_path.to_path_buf()
} else {
base_path.join(&rel)
}
}
}
impl Constrainable for DirItem {
#[inline]
fn write_file_name(&self, arena: ArenaPtr, out: &mut String) {
// For dirs, the "file name" equivalent is the last path segment
self.write_dir_name(arena, out);
}
#[inline]
fn write_relative_path(&self, arena: ArenaPtr, out: &mut String) {
self.path.write_to_string(arena, out);
}
#[inline]
fn git_status(&self) -> Option<git2::Status> {
None
}
}
#[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,
pub size: u64,
pub modified: u64,
pub access_frecency_score: i64,
pub modification_frecency_score: i64,
pub total_frecency_score: i64,
pub access_frecency_score: i16,
pub modification_frecency_score: i16,
pub git_status: Option<git2::Status>,
pub(crate) path: crate::simd_path::ChunkedString,
parent_dir: u32,
flags: u8,
content: OnceLock<FileContent>,
}
impl Clone for FileItem {
fn clone(&self) -> Self {
Self {
path: self.path.clone(),
parent_dir: self.parent_dir,
size: self.size,
modified: self.modified,
access_frecency_score: self.access_frecency_score,
modification_frecency_score: self.modification_frecency_score,
git_status: self.git_status,
flags: self.flags,
// on clone we have to reset the content lock
content: OnceLock::new(),
}
}
}
impl FileItem {
pub fn new_raw(
filename_start: u16,
size: u64,
modified: u64,
git_status: Option<git2::Status>,
is_binary: bool,
) -> Self {
let mut flags = 0u8;
if is_binary {
flags |= FileItemFlags::BINARY;
}
let mut path = crate::simd_path::ChunkedString::empty();
path.filename_offset = filename_start;
Self {
path,
parent_dir: u32::MAX,
size,
modified,
access_frecency_score: 0,
modification_frecency_score: 0,
git_status,
flags,
content: OnceLock::new(),
}
}
/// Returns an absolute path of the file
pub fn absolute_path(&self, arena: impl FFFStringStorage, base_path: &Path) -> PathBuf {
let mut buf = [0u8; PATH_BUF_SIZE];
let rel = self.path.read_to_buf(arena.arena_for(self), &mut buf);
base_path.join(rel)
}
pub(crate) fn set_path(&mut self, path: crate::simd_path::ChunkedString) {
self.path = path;
}
pub(crate) fn parent_dir_index(&self) -> u32 {
self.parent_dir
}
pub(crate) fn set_parent_dir(&mut self, idx: u32) {
self.parent_dir = idx;
}
pub fn dir_str(&self, arena: impl FFFStringStorage) -> String {
let mut s = String::with_capacity(64);
self.path.write_dir_to(arena.arena_for(self), &mut s);
s
}
pub(crate) fn write_dir_str(&self, arena: ArenaPtr, out: &mut String) {
self.path.write_dir_to(arena, out);
}
pub fn file_name(&self, arena: impl FFFStringStorage) -> String {
let mut s = String::with_capacity(32);
self.path.write_filename_to(arena.arena_for(self), &mut s);
s
}
pub(crate) fn write_file_name_from_arena(&self, arena: ArenaPtr, out: &mut String) {
self.path.write_filename_to(arena, out);
}
pub fn relative_path(&self, arena: impl FFFStringStorage) -> String {
let mut s = String::with_capacity(64);
self.path.write_to_string(arena.arena_for(self), &mut s);
s
}
pub(crate) fn write_relative_path_from_arena(&self, arena: ArenaPtr, out: &mut String) {
self.path.write_to_string(arena, out);
}
pub fn relative_path_len(&self) -> usize {
self.path.byte_len as usize
}
pub fn filename_offset_in_relative_path(&self) -> usize {
self.path.filename_offset as usize
}
pub(crate) fn relative_path_eq(&self, arena: ArenaPtr, other: &str) -> bool {
if other.len() != self.path.byte_len as usize {
return false;
}
let mut buf = [0u8; 512];
let mine = self.path.read_to_buf(arena, &mut buf);
mine == other
}
pub(crate) fn relative_path_starts_with(&self, arena: ArenaPtr, prefix: &str) -> bool {
let mut buf = [0u8; PATH_BUF_SIZE];
let path = self.path.read_to_buf(arena, &mut buf);
path.starts_with(prefix)
}
pub(crate) fn write_absolute_path<'a>(
&self,
arena: ArenaPtr,
base_path: &Path,
buf: &'a mut [u8; PATH_BUF_SIZE],
) -> &'a Path {
let base = base_path.as_os_str().as_encoded_bytes();
let base_len = base.len();
buf[..base_len].copy_from_slice(base);
// Add separator if base doesn't end with one
let sep_len = if base_len > 0 && base[base_len - 1] != b'/' {
buf[base_len] = b'/';
1
} else {
0
};
let rel_start = base_len + sep_len;
let mut rel_buf = [0u8; PATH_BUF_SIZE];
let rel = self.path.read_to_buf(arena, &mut rel_buf);
let rel_bytes = rel.as_bytes();
buf[rel_start..rel_start + rel_bytes.len()].copy_from_slice(rel_bytes);
let total = rel_start + rel_bytes.len();
Path::new(unsafe { std::str::from_utf8_unchecked(&buf[..total]) })
}
#[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;
}
}
#[inline]
pub fn is_overflow(&self) -> bool {
self.flags & FileItemFlags::OVERFLOW != 0
}
#[inline]
pub fn set_overflow(&mut self, val: bool) {
if val {
self.flags |= FileItemFlags::OVERFLOW;
} else {
self.flags &= !FileItemFlags::OVERFLOW;
}
}
}
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, 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 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).
pub(crate) fn get_content(
&self,
arena: ArenaPtr,
base_path: &Path,
budget: &ContentCacheBudget,
) -> Option<&[u8]> {
if let Some(content) = self.content.get() {
return Some(content);
}
let max_file_size = budget.max_file_size;
if self.size == 0 || self.size > max_file_size {
return None;
}
// Check cache budget before creating a new persistent cache entry.
let count = budget.cached_count.load(Ordering::Relaxed);
let bytes = budget.cached_bytes.load(Ordering::Relaxed);
let max_files = budget.max_files;
let max_bytes = budget.max_bytes;
if count >= max_files || bytes + self.size > max_bytes {
return None;
}
let content = load_file_content(&self.absolute_path(arena, base_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)
}
/// Get file content for searching — **always returns content** for eligible
/// files, even when the persistent cache budget is exhausted.
///
/// The caller provides a reusable `path_buf` (pre-filled with `base_path/`)
/// and its `base_len` to avoid allocations when constructing the absolute path.
#[inline]
pub(crate) fn get_content_for_search<'a>(
&'a self,
buf: &'a mut Vec<u8>, // we allow it to grow
arena: ArenaPtr,
base_path: &Path,
budget: &ContentCacheBudget,
) -> Option<&'a [u8]> {
// Fast path: persistent cache hit (zero-copy).
if let Some(cached) = self.get_content(arena, base_path, budget) {
return Some(cached);
}
let max_file_size = budget.max_file_size;
if self.is_binary() || self.size == 0 || self.size > max_file_size {
return None;
}
// 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 abs = self.absolute_path(arena, base_path);
let len = self.size as usize;
buf.resize(len, 0);
let mut file = std::fs::File::open(&abs).ok()?;
file.read_exact(buf).ok()?;
Some(buf.as_slice())
}
}
/// Files smaller than one page waste the remainder when mmapped.
#[cfg(target_arch = "aarch64")]
const MMAP_THRESHOLD: u64 = 16 * 1024;
#[cfg(not(target_arch = "aarch64"))]
const MMAP_THRESHOLD: u64 = 4 * 1024;
fn load_file_content(path: &Path, size: u64) -> Option<FileContent> {
#[cfg(not(target_os = "windows"))]
{
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 Constrainable for FileItem {
#[inline]
fn relative_path(&self) -> &str {
&self.relative_path
fn write_file_name(&self, arena: ArenaPtr, out: &mut String) {
self.path.write_filename_to(arena, out);
}
#[inline]
fn relative_path_lower(&self) -> &str {
&self.relative_path_lower
}
#[inline]
fn file_name(&self) -> &str {
&self.file_name
fn write_relative_path(&self, arena: ArenaPtr, out: &mut String) {
self.path.write_to_string(arena, out);
}
#[inline]
@@ -41,16 +564,18 @@ impl Constrainable for FileItem {
}
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub struct Score {
pub total: i32,
pub base_score: i32,
pub filename_bonus: i32,
pub special_filename_bonus: i32,
pub frecency_boost: i32,
pub git_status_boost: i32,
pub distance_penalty: i32,
pub current_file_penalty: i32,
pub combo_match_boost: i32,
pub path_alignment_bonus: i32,
pub exact_match: bool,
pub match_type: &'static str,
}
@@ -61,38 +586,34 @@ pub struct PaginationArgs {
pub limit: usize,
}
/// Context for scoring files during search.
///
/// The `parsed_query` field contains the pre-parsed query with constraints,
/// fuzzy parts, and location information. Parsing is done once at the API
/// boundary and passed through.
impl Default for PaginationArgs {
fn default() -> Self {
Self {
offset: 0,
limit: 100,
}
}
}
#[derive(Debug, Clone)]
pub struct ScoringContext<'a> {
/// The original raw query string (for compatibility and debugging)
pub raw_query: &'a str,
/// Pre-parsed query containing constraints, fuzzy parts, and location
pub parsed_query: Option<FFFQuery<'a>>,
pub query: &'a FFFQuery<'a>,
pub project_path: Option<&'a Path>,
pub current_file: Option<&'a str>,
pub max_typos: u16,
pub max_threads: usize,
pub last_same_query_match: Option<&'a QueryMatchEntry>,
pub last_same_query_match: Option<QueryMatchEntry>,
pub combo_boost_score_multiplier: i32,
pub min_combo_count: u32,
pub pagination: PaginationArgs,
}
impl<'a> ScoringContext<'a> {
/// Get the effective fuzzy query string for matching.
/// Returns the first fuzzy part, or the raw query if no parsing was done.
pub fn effective_query(&self) -> &'a str {
match &self.parsed_query {
Some(p) => match &p.fuzzy_query {
FuzzyQuery::Text(t) => t,
FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
_ => self.raw_query.trim(),
},
None => self.raw_query.trim(),
impl ScoringContext<'_> {
pub fn effective_query(&self) -> &str {
match &self.query.fuzzy_query {
FuzzyQuery::Text(t) => t,
FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
_ => self.query.raw_query.trim(),
}
}
}
@@ -105,3 +626,147 @@ pub struct SearchResult<'a> {
pub total_files: usize,
pub location: Option<Location>,
}
/// Search result for directory-only fuzzy search.
#[derive(Debug, Clone, Default)]
pub struct DirSearchResult<'a> {
pub items: Vec<&'a DirItem>,
pub scores: Vec<Score>,
pub total_matched: usize,
pub total_dirs: usize,
}
/// A single item in a mixed (files + directories) search result.
#[derive(Debug, Clone)]
pub enum MixedItemRef<'a> {
File(&'a FileItem),
Dir(&'a DirItem),
}
/// Search result for mixed (files + directories) fuzzy search.
/// Items are interleaved by total score in descending order.
#[derive(Debug, Clone, Default)]
pub struct MixedSearchResult<'a> {
pub items: Vec<MixedItemRef<'a>>,
pub scores: Vec<Score>,
pub total_matched: usize,
pub total_files: usize,
pub total_dirs: usize,
pub location: Option<Location>,
}
impl Default for MixedItemRef<'_> {
fn default() -> Self {
// Should never be used, exists only for Default derive on MixedSearchResult
unreachable!("MixedItemRef::default should not be called")
}
}
const MAX_MMAP_FILE_SIZE: u64 = 10 * 1024 * 1024;
const MAX_CACHED_CONTENT_BYTES: u64 = 512 * 1024 * 1024;
#[derive(Debug)]
pub struct ContentCacheBudget {
pub max_files: usize,
pub max_bytes: u64,
pub max_file_size: u64,
pub cached_count: AtomicUsize,
pub cached_bytes: AtomicU64,
}
impl ContentCacheBudget {
pub fn unlimited() -> Self {
Self {
max_files: usize::MAX,
max_bytes: u64::MAX,
max_file_size: MAX_MMAP_FILE_SIZE,
cached_count: AtomicUsize::new(0),
cached_bytes: AtomicU64::new(0),
}
}
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),
}
}
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)
}
}
#[cfg(test)]
impl FileItem {
/// Leaks a single-file arena so the pointer stays valid forever.
pub fn new_for_test(
rel_path: &str,
size: u64,
modified: u64,
git_status: Option<git2::Status>,
is_binary: bool,
) -> Self {
let (item, _arena) =
Self::new_for_test_with_arena(rel_path, size, modified, git_status, is_binary);
item
}
pub(crate) fn new_for_test_with_arena(
rel_path: &str,
size: u64,
modified: u64,
git_status: Option<git2::Status>,
is_binary: bool,
) -> (Self, ArenaPtr) {
let filename_start = rel_path.rfind('/').map(|i| i + 1).unwrap_or(0) as u16;
let mut item = Self::new_raw(filename_start, size, modified, git_status, is_binary);
let paths = [rel_path.to_string()];
let (store, strings) = crate::simd_path::build_chunked_path_store_from_strings(
&paths,
std::slice::from_ref(&item),
);
let cs = strings.into_iter().next().unwrap();
let arena = store.as_arena_ptr();
item.set_path(cs);
std::mem::forget(store);
(item, arena)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,421 @@
//! 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(),
enable_mmap_cache: true,
enable_content_indexing: 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(),
enable_mmap_cache: true,
enable_content_indexing: 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(),
enable_mmap_cache: true,
enable_content_indexing: 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].relative_path(picker).ends_with("newcomer.txt"),
"Overflow file should be newcomer.txt"
);
}
if let Ok(mut guard) = shared_picker.write() {
if let Some(ref mut picker) = *guard {
picker.stop_background_monitor();
}
}
}
/// Verify that a file modified after index build is findable via regex grep
/// through the overlay. This catches a regression where `extract_bigrams` on
/// the raw regex string (e.g. "NEEDLE.*HERE") produces bogus bigrams containing
/// `.` and `*`, causing `query_modified` to miss the file.
#[test]
fn modified_file_findable_via_regex_overlay() {
let tmp = TempDir::new().unwrap();
let base = tmp.path();
fs::write(base.join("alpha.txt"), "hello world\nfoo bar\n").unwrap();
fs::write(
base.join("beta.txt"),
"some other content\nnothing special\n",
)
.unwrap();
let shared_picker = 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(),
enable_mmap_cache: true,
enable_content_indexing: 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,
abort_signal: None,
}
}
fn grep_for<'a>(picker: &'a FilePicker, query: &str) -> fff_search::grep::GrepResult<'a> {
let parsed = parse_grep_query(query);
picker.grep(&parsed, &grep_opts())
}
fn wait_for_bigram(shared_picker: &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,326 @@
//! Reproducer: macOS FSEvents does not deliver Remove events for files
//! deleted from NonRecursive-watched directories when multiple directories
//! are watched via stop/restart cycles.
//!
//! This test watches a temp directory NonRecursively, creates a file,
//! verifies the Create event, deletes the file, and checks whether a
//! Remove (or any) event is delivered.
use notify::event::*;
use notify::{Config, EventKindMask, RecommendedWatcher, RecursiveMode, Watcher};
use std::fs;
use std::path::PathBuf;
use std::sync::mpsc;
use std::time::Duration;
fn setup_temp_git_repo() -> (PathBuf, tempfile::TempDir) {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().canonicalize().unwrap();
// Create a git repo like the bun test does
std::process::Command::new("git")
.args(["init", "-b", "main"])
.current_dir(&dir)
.output()
.unwrap();
fs::write(dir.join("hello.txt"), "hello\n").unwrap();
fs::create_dir_all(dir.join("src")).unwrap();
fs::write(dir.join("src/main.rs"), "fn main() {}\n").unwrap();
std::process::Command::new("git")
.args(["add", "-A"])
.current_dir(&dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(&dir)
.output()
.unwrap();
(dir, tmp)
}
/// Raw notify watcher: single NonRecursive watch on a directory.
/// Create a file, delete it, check if Remove event is delivered.
#[test]
fn raw_notify_nonrecursive_detects_deletion() {
let (dir, _tmp) = setup_temp_git_repo();
let (tx, rx) = mpsc::channel();
let config = Config::default()
.with_follow_symlinks(false)
.with_event_kinds(EventKindMask::CORE);
let mut watcher = RecommendedWatcher::new(
move |res: notify::Result<Event>| {
if let Ok(ev) = res {
let _ = tx.send(ev);
}
},
config,
)
.unwrap();
// Watch ONLY the root dir NonRecursively (like fff does)
watcher
.watch(dir.as_path(), RecursiveMode::NonRecursive)
.unwrap();
// Let the watcher stabilize
std::thread::sleep(Duration::from_millis(500));
// Drain any startup events
while rx.try_recv().is_ok() {}
// Create a file
let file_path = dir.join("testfile.txt");
fs::write(&file_path, "content\n").unwrap();
// Wait for create event
let mut got_create = false;
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(ev) => {
eprintln!(" [create phase] event: {:?} paths={:?}", ev.kind, ev.paths);
if matches!(ev.kind, EventKind::Create(_)) && ev.paths.contains(&file_path) {
got_create = true;
break;
}
}
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(_) => break,
}
}
assert!(got_create, "Expected Create event for testfile.txt");
// Drain remaining events from the create
std::thread::sleep(Duration::from_millis(300));
while rx.try_recv().is_ok() {}
// Delete the file
fs::remove_file(&file_path).unwrap();
eprintln!(" File deleted: {}", file_path.display());
// Wait for any event related to the deletion
let mut got_removal_event = false;
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(ev) => {
eprintln!(" [delete phase] event: {:?} paths={:?}", ev.kind, ev.paths);
if ev.paths.contains(&file_path) {
got_removal_event = true;
break;
}
}
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(_) => break,
}
}
assert!(
got_removal_event,
"Expected some event for deleted testfile.txt but got none within 5s"
);
}
/// Same test but with MULTIPLE NonRecursive watches (base + src + .git + .git/refs)
/// to match what fff actually does. Each watch() call stops/restarts the FSEvents stream.
#[test]
fn raw_notify_multi_nonrecursive_detects_deletion() {
let (dir, _tmp) = setup_temp_git_repo();
let (tx, rx) = mpsc::channel();
let config = Config::default()
.with_follow_symlinks(false)
.with_event_kinds(EventKindMask::CORE);
let mut watcher = RecommendedWatcher::new(
move |res: notify::Result<Event>| {
if let Ok(ev) = res {
let _ = tx.send(ev);
}
},
config,
)
.unwrap();
// Watch multiple directories NonRecursively — EACH call restarts the FSEvents stream
watcher
.watch(dir.as_path(), RecursiveMode::NonRecursive)
.unwrap();
watcher
.watch(&dir.join("src"), RecursiveMode::NonRecursive)
.unwrap();
watcher
.watch(&dir.join(".git"), RecursiveMode::NonRecursive)
.unwrap();
watcher
.watch(&dir.join(".git/refs"), RecursiveMode::Recursive)
.unwrap();
// Let the watcher stabilize
std::thread::sleep(Duration::from_millis(500));
while rx.try_recv().is_ok() {}
// Create a file in root dir
let file_path = dir.join("testfile.txt");
fs::write(&file_path, "content\n").unwrap();
// Wait for create event
let mut got_create = false;
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(ev) => {
eprintln!(" [multi-create] event: {:?} paths={:?}", ev.kind, ev.paths);
if matches!(ev.kind, EventKind::Create(_)) && ev.paths.contains(&file_path) {
got_create = true;
break;
}
}
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(_) => break,
}
}
assert!(
got_create,
"Expected Create event for testfile.txt with multi-watch"
);
// Drain
std::thread::sleep(Duration::from_millis(300));
while rx.try_recv().is_ok() {}
// Delete the file
fs::remove_file(&file_path).unwrap();
eprintln!(" File deleted: {}", file_path.display());
// Wait for any event related to the deletion
let mut got_removal_event = false;
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(ev) => {
eprintln!(" [multi-delete] event: {:?} paths={:?}", ev.kind, ev.paths);
if ev.paths.contains(&file_path) {
got_removal_event = true;
break;
}
}
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(_) => break,
}
}
assert!(
got_removal_event,
"Expected some event for deleted testfile.txt with multi-watch but got none within 5s"
);
}
/// Test with debouncer (matching exactly what fff uses)
#[test]
fn debounced_nonrecursive_detects_deletion() {
use notify_debouncer_full::{DebounceEventResult, NoCache, new_debouncer_opt};
let (dir, _tmp) = setup_temp_git_repo();
let (tx, rx) = mpsc::channel();
let config = Config::default()
.with_follow_symlinks(false)
.with_event_kinds(EventKindMask::CORE);
let mut debouncer: notify_debouncer_full::Debouncer<RecommendedWatcher, NoCache> =
new_debouncer_opt(
Duration::from_millis(250),
Some(Duration::from_millis(125)),
move |result: DebounceEventResult| {
if let Ok(events) = result {
for ev in events {
eprintln!(
" [debounced-cb] kind={:?} paths={:?}",
ev.event.kind, ev.event.paths
);
let _ = tx.send(ev);
}
}
},
NoCache::new(),
config,
)
.unwrap();
// Watch like fff does
debouncer
.watch(dir.as_path(), RecursiveMode::NonRecursive)
.unwrap();
debouncer
.watch(&dir.join("src"), RecursiveMode::NonRecursive)
.unwrap();
debouncer
.watch(&dir.join(".git"), RecursiveMode::NonRecursive)
.unwrap();
debouncer
.watch(&dir.join(".git/refs"), RecursiveMode::Recursive)
.unwrap();
// Longer stabilization — each watch() restarts the FSEvents stream
std::thread::sleep(Duration::from_secs(1));
while rx.try_recv().is_ok() {}
// Create file
let file_path = dir.join("testfile.txt");
fs::write(&file_path, "content\n").unwrap();
let mut got_create = false;
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(ev) => {
if ev.event.paths.contains(&file_path) {
got_create = true;
break;
}
}
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(_) => break,
}
}
assert!(got_create, "Expected Create event via debouncer");
// Wait for debounce to fully flush
std::thread::sleep(Duration::from_millis(500));
while rx.try_recv().is_ok() {}
// Delete
fs::remove_file(&file_path).unwrap();
eprintln!(" File deleted: {}", file_path.display());
// Wait for ANY event for this path
let mut got_event = false;
let mut event_kind = String::new();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(ev) => {
if ev.event.paths.contains(&file_path) {
event_kind = format!("{:?}", ev.event.kind);
got_event = true;
break;
}
}
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(_) => break,
}
}
assert!(
got_event,
"Expected some event for deleted testfile.txt via debouncer but got none within 5s"
);
eprintln!(" Got event kind: {}", event_kind);
}
@@ -0,0 +1,766 @@
//! 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(),
enable_mmap_cache: true,
enable_content_indexing: true,
mode: FFFMode::Neovim,
..Default::default()
},
)
.expect("Failed to create FilePicker");
let t0 = std::time::Instant::now();
wait_for_bigram(&shared_picker);
t_bigram_wait += t0.elapsed();
// Sanity: all initial tokens findable via plain grep.
{
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
for fs in &live_files {
assert!(
grep_plain_count(picker, &fs.token) >= 1,
"initial sanity: plain grep should find token {} in {}",
fs.token,
fs.name
);
}
}
// Sleep so mtime advances past the scan snapshot timestamp.
let t0 = std::time::Instant::now();
std::thread::sleep(Duration::from_millis(1100));
t_sleep += t0.elapsed();
let mut op_counter: usize = 0;
for round in 0..NUM_ROUNDS {
let roll: u32 = rng.next_u32() % 100;
if roll < 40 && !live_files.is_empty() {
// ── EDIT existing file (40%) ──
let idx = rng.next_u32() as usize % live_files.len();
// on_create_or_modify uses mtime (seconds granularity) to decide
// whether to invalidate the mmap cache. If we re-edit a file in
// the same second it was last written, the mtime won't change and
// the stale cached content will be returned. Sleep to advance mtime.
let now_sec = epoch_secs();
if live_files[idx].last_write_sec >= now_sec {
let t0 = std::time::Instant::now();
std::thread::sleep(Duration::from_millis(1100));
t_sleep += t0.elapsed();
}
let old_token = live_files[idx].token.clone();
let new_token = format!("FUZZ_{round:02}_{op_counter:04}");
let name = &live_files[idx].name;
let domain_idx = rng.next_u32() as usize % DOMAINS.len();
write_diverse_file_with_domain(base, name, &new_token, domain_idx);
{
let mut guard = shared_picker.write().unwrap();
let picker = guard.as_mut().unwrap();
assert!(
picker.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,
abort_signal: None,
}
}
fn grep_regex_opts() -> GrepSearchOptions {
GrepSearchOptions {
mode: GrepMode::Regex,
..grep_plain_opts()
}
}
fn grep_plain_count(picker: &FilePicker, query: &str) -> usize {
let parsed = parse_grep_query(query);
picker.grep(&parsed, &grep_plain_opts()).matches.len()
}
fn grep_regex_count(picker: &FilePicker, regex_query: &str) -> usize {
let parsed = parse_grep_query(regex_query);
picker.grep(&parsed, &grep_regex_opts()).matches.len()
}
/// Build a regex pattern from a token: `{first5}.*{last5}`.
/// For tokens shorter than 10 chars, just use the literal (escaped).
fn build_regex_pattern(token: &str) -> String {
if token.len() >= 10 {
let first5 = &token[..5];
let last5 = &token[token.len() - 5..];
format!("{}.*{}", regex_escape(first5), regex_escape(last5))
} else {
regex_escape(token)
}
}
/// Escape regex metacharacters in a string.
fn regex_escape(s: &str) -> String {
let mut escaped = String::with_capacity(s.len() + 4);
for ch in s.chars() {
match ch {
'.' | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '\\' | '^' | '$' | '|' => {
escaped.push('\\');
escaped.push(ch);
}
_ => escaped.push(ch),
}
}
escaped
}
/// Extract a fuzzy-searchable stem from a filename.
/// Strips the extension and any leading path components, keeping the bare name.
fn extract_stem(name: &str) -> String {
let p = PathBuf::from(name);
p.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string()
}
fn fuzzy_search_paths(picker: &FilePicker, query: &str) -> Vec<String> {
let parser = QueryParser::default();
let parsed = parser.parse(query);
let result = picker.fuzzy_search(
&parsed,
None,
FuzzySearchOptions {
max_threads: 1,
pagination: PaginationArgs {
offset: 0,
limit: 200,
},
..Default::default()
},
);
result
.items
.iter()
.map(|f| f.relative_path(picker))
.collect()
}
fn wait_for_bigram(shared_picker: &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
@@ -0,0 +1,534 @@
//! Integration test: verifying that the background watcher dynamically detects
//! newly created directories and picks up files written inside them.
//!
//! This covers the NonRecursive watching behavior where:
//! 1. The watcher starts with watches on directories discovered during the
//! initial scan.
//! 2. A brand-new subdirectory is created at runtime (after the scan).
//! 3. The watcher's event handler detects the directory Create event,
//! collects it, and sends it to the owner thread via `watch_tx`.
//! 4. The owner thread adds a NonRecursive watch on the new directory and
//! does a flat (non-recursive) read_dir to inject files that already
//! exist (race-window coverage).
//! 5. Files created *after* the watch is established are picked up via
//! normal event delivery.
//!
//! The test uses the real `BackgroundWatcher` (via `watch: true`) and polls
//! the picker until the expected files appear or a timeout expires.
use std::fs;
use std::path::Path;
use std::process::Command;
use std::time::{Duration, Instant};
use tempfile::TempDir;
use fff_search::file_picker::{FFFMode, FilePicker};
use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query};
use fff_search::{FilePickerOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
// ═══════════════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════════════
fn git_run(dir: &Path, args: &[&str]) {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.output()
.unwrap_or_else(|e| panic!("git {:?} failed: {}", args, e));
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
}
fn git_init_and_commit(dir: &Path) {
git_run(dir, &["init", "-b", "main"]);
git_run(dir, &["add", "-A"]);
git_run(dir, &["commit", "-m", "initial"]);
}
fn make_watched_picker(base: &Path) -> (SharedPicker, SharedFrecency) {
let shared_picker = SharedPicker::default();
let shared_frecency = SharedFrecency::noop();
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().to_string(),
enable_mmap_cache: false,
mode: FFFMode::Neovim,
watch: true,
..Default::default()
},
)
.expect("Failed to create FilePicker");
(shared_picker, shared_frecency)
}
/// Wait for the initial scan + watcher to be fully ready.
fn wait_ready(shared_picker: &SharedPicker) {
assert!(
shared_picker.wait_for_scan(Duration::from_secs(10)),
"Timed out waiting for initial scan"
);
assert!(
shared_picker.wait_for_watcher(Duration::from_secs(10)),
"Timed out waiting for watcher"
);
}
/// Poll the picker until `predicate` returns true or timeout expires.
/// Returns the elapsed duration if successful, panics on timeout.
fn poll_until(
shared_picker: &SharedPicker,
timeout: Duration,
description: &str,
predicate: impl Fn(&FilePicker) -> bool,
) -> Duration {
let start = Instant::now();
loop {
{
let guard = shared_picker.read().unwrap();
if let Some(ref picker) = *guard {
if predicate(picker) {
return start.elapsed();
}
}
}
if start.elapsed() >= timeout {
// One final attempt to give a useful error message.
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let file_count = picker.get_files().len();
let paths: Vec<String> = picker
.get_files()
.iter()
.map(|f| f.relative_path(picker))
.collect();
panic!(
"Timed out after {:?} waiting for: {}\n\
Current file count: {}\n\
Current files: {:?}",
timeout, description, file_count, paths
);
}
std::thread::sleep(Duration::from_millis(50));
}
}
fn grep_plain_count(picker: &FilePicker, query: &str) -> usize {
let parsed = parse_grep_query(query);
let opts = GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 200,
smart_case: true,
file_offset: 0,
page_limit: 500,
mode: GrepMode::PlainText,
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
abort_signal: None,
};
picker.grep(&parsed, &opts).matches.len()
}
fn fuzzy_search_paths(picker: &FilePicker, query: &str) -> Vec<String> {
let parser = QueryParser::default();
let parsed = parser.parse(query);
let result = picker.fuzzy_search(
&parsed,
None,
fff_search::FuzzySearchOptions {
max_threads: 1,
pagination: PaginationArgs {
offset: 0,
limit: 200,
},
..Default::default()
},
);
result
.items
.iter()
.map(|f| f.relative_path(picker))
.collect()
}
/// Debounce timeout in the watcher is 250ms. Events need to propagate through
/// the debouncer, the owner thread park loop (1s), and the picker write lock.
/// We use a generous timeout for CI environments.
const WATCHER_TIMEOUT: Duration = Duration::from_secs(10);
// ═══════════════════════════════════════════════════════════════════════
// Tests
// ═══════════════════════════════════════════════════════════════════════
/// Create a new directory and immediately write a file inside it.
/// The file is written before the watch is registered, so the flat
/// inject_existing_files scan in the owner thread must catch it.
#[test]
fn new_directory_and_file_detected_by_watcher() {
let tmp = TempDir::new().unwrap();
let base = tmp.path().canonicalize().unwrap();
// Seed the repo with some initial files so the scan has something.
fs::create_dir_all(base.join("src")).unwrap();
fs::write(
base.join("src/main.rs"),
"fn main() { println!(\"INITIAL_MARKER\"); }\n",
)
.unwrap();
fs::write(base.join("README.md"), "# Test project\n").unwrap();
git_init_and_commit(&base);
let (shared_picker, _frecency) = make_watched_picker(&base);
wait_ready(&shared_picker);
// Sanity: initial file is indexed.
poll_until(
&shared_picker,
Duration::from_secs(5),
"initial file src/main.rs indexed",
|picker| {
picker
.get_files()
.iter()
.any(|f| f.relative_path(picker).contains("main.rs"))
},
);
// Create a new directory and write a file into it immediately.
// The file exists before the watch is registered — inject_existing_files
// in the owner thread catches it via a flat read_dir.
let new_dir = base.join("src/components");
fs::create_dir_all(&new_dir).unwrap();
fs::write(
new_dir.join("button.rs"),
"pub struct Button;\nconst TOKEN: &str = \"NEW_DIR_BUTTON_TOKEN\";\n",
)
.unwrap();
// Wait for the watcher to detect the new directory + file.
let elapsed = poll_until(
&shared_picker,
WATCHER_TIMEOUT,
"file src/components/button.rs in new directory",
|picker| {
picker
.get_files()
.iter()
.any(|f| f.relative_path(picker).contains("button.rs"))
},
);
eprintln!(
" New directory + file detected in {:.0}ms",
elapsed.as_secs_f64() * 1000.0
);
// Also verify via grep that the content is accessible.
poll_until(
&shared_picker,
WATCHER_TIMEOUT,
"grep finds NEW_DIR_BUTTON_TOKEN",
|picker| grep_plain_count(picker, "NEW_DIR_BUTTON_TOKEN") >= 1,
);
// And via fuzzy search.
poll_until(
&shared_picker,
WATCHER_TIMEOUT,
"fuzzy search finds button.rs",
|picker| {
let results = fuzzy_search_paths(picker, "button");
results.iter().any(|p| p.contains("button.rs"))
},
);
}
/// Create a new directory, then create files AFTER a delay to ensure the
/// watch was established on the directory.
#[test]
fn file_created_after_directory_watch_established() {
let tmp = TempDir::new().unwrap();
let base = tmp.path().canonicalize().unwrap();
fs::create_dir_all(base.join("lib")).unwrap();
fs::write(base.join("lib/utils.rs"), "pub fn helper() {}\n").unwrap();
git_init_and_commit(&base);
let (shared_picker, _frecency) = make_watched_picker(&base);
wait_ready(&shared_picker);
// Create the directory first, wait for the watcher to register it.
let new_dir = base.join("lib/models");
fs::create_dir(&new_dir).unwrap();
// Wait long enough for the debouncer to flush + owner thread to add watch.
std::thread::sleep(Duration::from_millis(2000));
// Now write a file into the already-watched directory.
fs::write(
new_dir.join("user.rs"),
"pub struct User { name: String }\nconst TOKEN: &str = \"POST_WATCH_USER_TOKEN\";\n",
)
.unwrap();
let elapsed = poll_until(
&shared_picker,
WATCHER_TIMEOUT,
"file lib/models/user.rs created after directory watch",
|picker| {
picker
.get_files()
.iter()
.any(|f| f.relative_path(picker).contains("user.rs"))
},
);
eprintln!(
" Post-watch file detected in {:.0}ms",
elapsed.as_secs_f64() * 1000.0
);
// Grep sanity.
poll_until(
&shared_picker,
WATCHER_TIMEOUT,
"grep finds POST_WATCH_USER_TOKEN",
|picker| grep_plain_count(picker, "POST_WATCH_USER_TOKEN") >= 1,
);
}
/// Create a deeply nested directory tree all at once with create_dir_all
/// and write a file at the leaf. The watcher must detect the top-level
/// directory via the parent's watch, inject_existing_files finds the file
/// at the leaf (and intermediate dirs get their own watches from Create
/// events on subsequent levels).
#[test]
fn deeply_nested_new_directories_detected() {
let tmp = TempDir::new().unwrap();
let base = tmp.path().canonicalize().unwrap();
fs::write(base.join("root.txt"), "root file\n").unwrap();
git_init_and_commit(&base);
let (shared_picker, _frecency) = make_watched_picker(&base);
wait_ready(&shared_picker);
// Create each level one at a time, waiting for each watch to register.
// inject_existing_files is flat (non-recursive), so deeply nested dirs
// need each parent to be watched before we can see files at the leaf.
fs::create_dir(base.join("app")).unwrap();
std::thread::sleep(Duration::from_millis(2000));
fs::create_dir(base.join("app/services")).unwrap();
std::thread::sleep(Duration::from_millis(2000));
fs::create_dir(base.join("app/services/auth")).unwrap();
// Write the file immediately — inject_existing_files catches it.
fs::write(
base.join("app/services/auth/jwt.rs"),
"pub fn verify_token() {}\nconst TOKEN: &str = \"DEEP_NESTED_JWT_TOKEN\";\n",
)
.unwrap();
let elapsed = poll_until(
&shared_picker,
WATCHER_TIMEOUT,
"deeply nested file app/services/auth/jwt.rs",
|picker| {
picker
.get_files()
.iter()
.any(|f| f.relative_path(picker).contains("jwt.rs"))
},
);
eprintln!(
" Deeply nested file detected in {:.0}ms",
elapsed.as_secs_f64() * 1000.0
);
// Verify content is grepable.
poll_until(
&shared_picker,
WATCHER_TIMEOUT,
"grep finds DEEP_NESTED_JWT_TOKEN",
|picker| grep_plain_count(picker, "DEEP_NESTED_JWT_TOKEN") >= 1,
);
// Now create a sibling at the same depth — the parent (app/services)
// is already watched, so this just needs the flat inject.
let sibling_dir = base.join("app/services/database");
fs::create_dir(&sibling_dir).unwrap();
fs::write(
sibling_dir.join("pool.rs"),
"pub struct ConnectionPool;\nconst TOKEN: &str = \"SIBLING_POOL_TOKEN\";\n",
)
.unwrap();
let elapsed = poll_until(
&shared_picker,
WATCHER_TIMEOUT,
"sibling nested file app/services/database/pool.rs",
|picker| {
picker
.get_files()
.iter()
.any(|f| f.relative_path(picker).contains("pool.rs"))
},
);
eprintln!(
" Sibling nested file detected in {:.0}ms",
elapsed.as_secs_f64() * 1000.0
);
poll_until(
&shared_picker,
WATCHER_TIMEOUT,
"grep finds SIBLING_POOL_TOKEN",
|picker| grep_plain_count(picker, "SIBLING_POOL_TOKEN") >= 1,
);
}
/// Create a new directory and immediately burst-write multiple files.
/// inject_existing_files catches all of them in one flat read_dir.
#[test]
fn burst_file_creation_in_new_directory() {
let tmp = TempDir::new().unwrap();
let base = tmp.path().canonicalize().unwrap();
fs::create_dir_all(base.join("src")).unwrap();
fs::write(base.join("src/lib.rs"), "// lib\n").unwrap();
git_init_and_commit(&base);
let (shared_picker, _frecency) = make_watched_picker(&base);
wait_ready(&shared_picker);
// Create a new directory and immediately write 5 files.
let batch_dir = base.join("src/batch");
fs::create_dir(&batch_dir).unwrap();
let file_count = 5;
for i in 0..file_count {
fs::write(
batch_dir.join(format!("item_{i}.rs")),
format!("pub struct Item{i};\nconst TOKEN: &str = \"BATCH_ITEM_{i}\";\n"),
)
.unwrap();
}
// Wait for ALL files to appear.
let elapsed = poll_until(
&shared_picker,
WATCHER_TIMEOUT,
&format!("all {file_count} batch files in src/batch/"),
|picker| {
let batch_count = picker
.get_files()
.iter()
.filter(|f| f.relative_path(picker).starts_with("src/batch/"))
.count();
batch_count >= file_count
},
);
eprintln!(
" All {} burst files detected in {:.0}ms",
file_count,
elapsed.as_secs_f64() * 1000.0
);
// Verify each file's content is grepable.
for i in 0..file_count {
let token = format!("BATCH_ITEM_{i}");
poll_until(
&shared_picker,
WATCHER_TIMEOUT,
&format!("grep finds {token}"),
|picker| grep_plain_count(picker, &token) >= 1,
);
}
}
/// Verify that gitignored directories created at runtime are NOT watched
/// and their files do NOT appear in the index.
#[test]
fn gitignored_new_directory_excluded() {
let tmp = TempDir::new().unwrap();
let base = tmp.path().canonicalize().unwrap();
fs::write(base.join("main.rs"), "fn main() {}\n").unwrap();
// Ignore the build/ directory.
fs::write(base.join(".gitignore"), "build/\n").unwrap();
git_init_and_commit(&base);
let (shared_picker, _frecency) = make_watched_picker(&base);
wait_ready(&shared_picker);
// Create a gitignored directory with files.
let ignored_dir = base.join("build");
fs::create_dir(&ignored_dir).unwrap();
fs::write(
ignored_dir.join("output.rs"),
"const TOKEN: &str = \"IGNORED_BUILD_TOKEN\";\n",
)
.unwrap();
// Also create a non-ignored directory to confirm the watcher works.
let good_dir = base.join("src");
fs::create_dir(&good_dir).unwrap();
fs::write(
good_dir.join("app.rs"),
"const TOKEN: &str = \"GOOD_SRC_TOKEN\";\n",
)
.unwrap();
// Wait for the non-ignored file to appear (proves watcher is working).
poll_until(
&shared_picker,
WATCHER_TIMEOUT,
"non-ignored file src/app.rs appears",
|picker| {
picker
.get_files()
.iter()
.any(|f| f.relative_path(picker).contains("app.rs"))
},
);
// Give extra time for any straggler events from the ignored dir.
std::thread::sleep(Duration::from_secs(2));
// The gitignored file must NOT be in the index.
{
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let has_ignored = picker
.get_files()
.iter()
.any(|f| f.relative_path(picker).contains("output.rs"));
assert!(
!has_ignored,
"Gitignored file build/output.rs should NOT be in the index"
);
let grep_count = grep_plain_count(picker, "IGNORED_BUILD_TOKEN");
assert_eq!(grep_count, 0, "Gitignored content should NOT be grepable");
}
}
+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"
+19
View File
@@ -0,0 +1,19 @@
/*!
Simplified grep-searcher for fff.nvim.
Provides line-oriented search over byte slices with optional multi-line support.
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;
+233
View File
@@ -0,0 +1,233 @@
/*!
A collection of routines for performing operations on lines.
*/
use bstr::ByteSlice;
use crate::matcher::{LineTerminator, Match};
/// An explicit iterator over lines in a particular slice of bytes.
///
/// This iterator avoids borrowing the bytes themselves, and instead requires
/// callers to explicitly provide the bytes when moving through the iterator.
///
/// Line terminators are considered part of the line they terminate. All lines
/// yielded by the iterator are guaranteed to be non-empty.
#[derive(Debug)]
pub struct LineStep {
line_term: u8,
pos: usize,
end: usize,
}
impl LineStep {
/// Create a new line iterator over the given range of bytes using the
/// given line terminator.
pub fn new(line_term: u8, start: usize, end: usize) -> LineStep {
LineStep {
line_term,
pos: start,
end,
}
}
/// Like next, but returns a `Match` instead of a tuple.
#[inline(always)]
pub fn next_match(&mut self, bytes: &[u8]) -> Option<Match> {
self.next_impl(bytes).map(|(s, e)| Match::new(s, e))
}
#[inline(always)]
fn next_impl(&mut self, mut bytes: &[u8]) -> Option<(usize, usize)> {
bytes = &bytes[..self.end];
match bytes[self.pos..].find_byte(self.line_term) {
None => {
if self.pos < bytes.len() {
let m = (self.pos, bytes.len());
assert!(m.0 <= m.1);
self.pos = m.1;
Some(m)
} else {
None
}
}
Some(line_end) => {
let m = (self.pos, self.pos + line_end + 1);
assert!(m.0 <= m.1);
self.pos = m.1;
Some(m)
}
}
}
}
/// Count the number of occurrences of `line_term` in `bytes`.
pub fn count(bytes: &[u8], line_term: u8) -> u64 {
memchr::memchr_iter(line_term, bytes).count() as u64
}
/// Given a line that possibly ends with a terminator, return that line without
/// the terminator.
#[inline(always)]
pub fn without_terminator(bytes: &[u8], line_term: LineTerminator) -> &[u8] {
let line_term = line_term.as_bytes();
let start = bytes.len().saturating_sub(line_term.len());
if bytes.get(start..) == Some(line_term) {
return &bytes[..bytes.len() - line_term.len()];
}
bytes
}
/// Return the start and end offsets of the lines containing the given range
/// of bytes.
///
/// Line terminators are considered part of the line they terminate.
#[inline(always)]
pub fn locate(bytes: &[u8], line_term: u8, range: Match) -> Match {
let line_start = bytes[..range.start()]
.rfind_byte(line_term)
.map_or(0, |i| i + 1);
let line_end = if range.end() > line_start && bytes[range.end() - 1] == line_term {
range.end()
} else {
bytes[range.end()..]
.find_byte(line_term)
.map_or(bytes.len(), |i| range.end() + i + 1)
};
Match::new(line_start, line_end)
}
#[cfg(test)]
mod tests {
use super::*;
const SHERLOCK: &'static str = "\
For the Doctor Watsons of this world, as opposed to the Sherlock
Holmeses, success in the province of detective work must always
be, to a very large extent, the result of luck. Sherlock Holmes
can extract a clew from a wisp of straw or a flake of cigar ash;
but Doctor Watson has to have it taken out for him and dusted,
and exhibited clearly, with a label attached.\
";
fn m(start: usize, end: usize) -> Match {
Match::new(start, end)
}
fn lines(text: &str) -> Vec<&str> {
let mut results = vec![];
let mut it = LineStep::new(b'\n', 0, text.len());
while let Some(m) = it.next_match(text.as_bytes()) {
results.push(&text[m]);
}
results
}
fn line_ranges(text: &str) -> Vec<std::ops::Range<usize>> {
let mut results = vec![];
let mut it = LineStep::new(b'\n', 0, text.len());
while let Some(m) = it.next_match(text.as_bytes()) {
results.push(m.start()..m.end());
}
results
}
fn loc(text: &str, start: usize, end: usize) -> Match {
locate(text.as_bytes(), b'\n', Match::new(start, end))
}
#[test]
fn line_count() {
assert_eq!(0, count(b"", b'\n'));
assert_eq!(1, count(b"\n", b'\n'));
assert_eq!(2, count(b"\n\n", b'\n'));
assert_eq!(2, count(b"a\nb\nc", b'\n'));
}
#[test]
fn line_locate() {
let t = SHERLOCK;
let lines = line_ranges(t);
assert_eq!(
loc(t, lines[0].start, lines[0].end),
m(lines[0].start, lines[0].end)
);
assert_eq!(
loc(t, lines[0].start + 1, lines[0].end),
m(lines[0].start, lines[0].end)
);
assert_eq!(
loc(t, lines[0].end - 1, lines[0].end),
m(lines[0].start, lines[0].end)
);
assert_eq!(
loc(t, lines[0].end, lines[0].end),
m(lines[1].start, lines[1].end)
);
assert_eq!(
loc(t, lines[5].start, lines[5].end),
m(lines[5].start, lines[5].end)
);
assert_eq!(
loc(t, lines[5].start + 1, lines[5].end),
m(lines[5].start, lines[5].end)
);
assert_eq!(
loc(t, lines[5].end - 1, lines[5].end),
m(lines[5].start, lines[5].end)
);
assert_eq!(
loc(t, lines[5].end, lines[5].end),
m(lines[5].start, lines[5].end)
);
}
#[test]
fn line_locate_weird() {
assert_eq!(loc("", 0, 0), m(0, 0));
assert_eq!(loc("\n", 0, 1), m(0, 1));
assert_eq!(loc("\n", 1, 1), m(1, 1));
assert_eq!(loc("\n\n", 0, 0), m(0, 1));
assert_eq!(loc("\n\n", 0, 1), m(0, 1));
assert_eq!(loc("\n\n", 1, 1), m(1, 2));
assert_eq!(loc("\n\n", 1, 2), m(1, 2));
assert_eq!(loc("\n\n", 2, 2), m(2, 2));
assert_eq!(loc("a\nb\nc", 0, 1), m(0, 2));
assert_eq!(loc("a\nb\nc", 1, 2), m(0, 2));
assert_eq!(loc("a\nb\nc", 2, 3), m(2, 4));
assert_eq!(loc("a\nb\nc", 3, 4), m(2, 4));
assert_eq!(loc("a\nb\nc", 4, 5), m(4, 5));
assert_eq!(loc("a\nb\nc", 5, 5), m(4, 5));
}
#[test]
fn line_iter() {
assert_eq!(lines("abc"), vec!["abc"]);
assert_eq!(lines("abc\n"), vec!["abc\n"]);
assert_eq!(lines("abc\nxyz"), vec!["abc\n", "xyz"]);
assert_eq!(lines("abc\nxyz\n"), vec!["abc\n", "xyz\n"]);
assert_eq!(lines("abc\n\n"), vec!["abc\n", "\n"]);
assert_eq!(lines("abc\n\n\n"), vec!["abc\n", "\n", "\n"]);
assert_eq!(lines("abc\n\nxyz"), vec!["abc\n", "\n", "xyz"]);
assert_eq!(lines("abc\n\nxyz\n"), vec!["abc\n", "\n", "xyz\n"]);
assert_eq!(lines("abc\nxyz\n\n"), vec!["abc\n", "xyz\n", "\n"]);
assert_eq!(lines("\n"), vec!["\n"]);
assert_eq!(lines(""), Vec::<&str>::new());
}
#[test]
fn line_iter_empty() {
let mut it = LineStep::new(b'\n', 0, 0);
assert_eq!(it.next_match(b"abc"), None);
}
}
+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;
}
}
}
+127
View File
@@ -0,0 +1,127 @@
use crate::{
lines,
matcher::Matcher,
searcher::{Config, Range, Searcher, core::Core},
sink::Sink,
};
#[derive(Debug)]
pub(crate) struct SliceByLine<'s, M, S> {
core: Core<'s, M, S>,
slice: &'s [u8],
}
impl<'s, M: Matcher, S: Sink> SliceByLine<'s, M, S> {
pub(crate) fn new(
searcher: &'s Searcher,
matcher: M,
slice: &'s [u8],
write_to: S,
) -> SliceByLine<'s, M, S> {
debug_assert!(!searcher.multi_line_with_matcher(&matcher));
SliceByLine {
core: Core::new(searcher, matcher, write_to),
slice,
}
}
pub(crate) fn run(mut self) -> Result<(), S::Error> {
if self.core.begin()? {
while !self.slice[self.core.pos()..].is_empty()
&& self.core.match_by_line(self.slice)?
{}
}
let byte_count = self.slice.len() as u64;
self.core.finish(byte_count)
}
}
#[derive(Debug)]
pub(crate) struct MultiLine<'s, M, S> {
config: &'s Config,
core: Core<'s, M, S>,
slice: &'s [u8],
last_match: Option<Range>,
}
impl<'s, M: Matcher, S: Sink> MultiLine<'s, M, S> {
pub(crate) fn new(
searcher: &'s Searcher,
matcher: M,
slice: &'s [u8],
write_to: S,
) -> MultiLine<'s, M, S> {
debug_assert!(searcher.multi_line_with_matcher(&matcher));
MultiLine {
config: &searcher.config,
core: Core::new(searcher, matcher, write_to),
slice,
last_match: None,
}
}
pub(crate) fn run(mut self) -> Result<(), S::Error> {
if self.core.begin()? {
let mut keepgoing = true;
while !self.slice[self.core.pos()..].is_empty() && keepgoing {
keepgoing = self.sink()?;
}
if keepgoing && let Some(last_match) = self.last_match.take() {
self.sink_matched(&last_match)?;
}
}
let byte_count = self.slice.len() as u64;
self.core.finish(byte_count)
}
fn sink(&mut self) -> Result<bool, S::Error> {
let mat = match self.find()? {
Some(range) => range,
None => {
self.core.set_pos(self.slice.len());
return Ok(true);
}
};
self.advance(&mat);
let line = lines::locate(self.slice, self.config.line_term.as_byte(), mat);
match self.last_match.take() {
None => {
self.last_match = Some(line);
Ok(true)
}
Some(last_match) => {
if last_match.end() >= line.start() {
self.last_match = Some(last_match.with_end(line.end()));
Ok(true)
} else {
self.last_match = Some(line);
self.sink_matched(&last_match)
}
}
}
}
fn sink_matched(&mut self, range: &Range) -> Result<bool, S::Error> {
if range.is_empty() {
return Ok(false);
}
self.core.matched(self.slice, range)
}
fn find(&mut self) -> Result<Option<Range>, S::Error> {
self.core
.find(&self.slice[self.core.pos()..])
.map(|m| m.map(|m| m.offset(self.core.pos())))
}
fn advance(&mut self, range: &Range) {
self.core.set_pos(range.end());
if range.is_empty() && self.core.pos() < self.slice.len() {
let newpos = self.core.pos() + 1;
self.core.set_pos(newpos);
}
}
}
+194
View File
@@ -0,0 +1,194 @@
use crate::{
matcher::{LineTerminator, Match, Matcher},
searcher::glue::{MultiLine, SliceByLine},
sink::{Sink, SinkError},
};
mod core;
mod glue;
/// We use this type alias since we want the ergonomics of a matcher's `Match`
/// type, but in practice, we use it for arbitrary ranges, so give it a more
/// accurate name. This is only used in the searcher's internals.
type Range = Match;
/// An error that can occur when building a searcher.
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub(crate) enum ConfigError {
/// Occurs when a matcher reports a line terminator that is different than
/// the one configured in the searcher.
MismatchedLineTerminators {
/// The matcher's line terminator.
matcher: LineTerminator,
/// The searcher's line terminator.
searcher: LineTerminator,
},
}
impl std::error::Error for ConfigError {}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match *self {
ConfigError::MismatchedLineTerminators { matcher, searcher } => {
write!(
f,
"grep config error: mismatched line terminators, \
matcher has {:?} but searcher has {:?}",
matcher, searcher
)
}
}
}
}
/// The internal configuration of a searcher.
#[derive(Clone, Debug)]
pub(crate) struct Config {
/// The line terminator to use.
pub(crate) line_term: LineTerminator,
/// Whether to count line numbers.
pub(crate) line_number: bool,
/// Whether to enable matching across multiple lines.
multi_line: bool,
}
impl Default for Config {
fn default() -> Config {
Config {
line_term: LineTerminator::default(),
line_number: true,
multi_line: false,
}
}
}
/// A builder for configuring a searcher.
#[derive(Clone, Debug)]
pub struct SearcherBuilder {
config: Config,
}
impl Default for SearcherBuilder {
fn default() -> SearcherBuilder {
SearcherBuilder::new()
}
}
impl SearcherBuilder {
/// Create a new searcher builder with a default configuration.
pub fn new() -> SearcherBuilder {
SearcherBuilder {
config: Config::default(),
}
}
/// Build a searcher.
pub fn build(&self) -> Searcher {
Searcher {
config: self.config.clone(),
}
}
/// Whether to count and include line numbers with matching lines.
pub fn line_number(&mut self, yes: bool) -> &mut SearcherBuilder {
self.config.line_number = yes;
self
}
/// Whether to enable multi line search or not.
pub fn multi_line(&mut self, yes: bool) -> &mut SearcherBuilder {
self.config.multi_line = yes;
self
}
}
/// A searcher executes searches over a haystack and writes results to a caller
/// provided sink.
#[derive(Clone, Debug)]
pub struct Searcher {
pub(crate) config: Config,
}
impl Searcher {
/// Create a new searcher with a default configuration.
pub fn new() -> Searcher {
SearcherBuilder::new().build()
}
/// Execute a search over the given slice and write the results to the
/// given sink.
pub fn search_slice<M, S>(&self, matcher: M, slice: &[u8], write_to: S) -> Result<(), S::Error>
where
M: Matcher,
S: Sink,
{
self.check_config(&matcher)
.map_err(S::Error::error_message)?;
if self.multi_line_with_matcher(&matcher) {
MultiLine::new(self, matcher, slice, write_to).run()
} else {
SliceByLine::new(self, matcher, slice, write_to).run()
}
}
/// Check that the searcher's configuration and the matcher are consistent.
fn check_config<M: Matcher>(&self, matcher: M) -> Result<(), ConfigError> {
let matcher_line_term = match matcher.line_terminator() {
None => return Ok(()),
Some(line_term) => line_term,
};
if matcher_line_term != self.config.line_term {
return Err(ConfigError::MismatchedLineTerminators {
matcher: matcher_line_term,
searcher: self.config.line_term,
});
}
Ok(())
}
}
impl Default for Searcher {
fn default() -> Self {
Self::new()
}
}
/// Configuration query methods used by the sink and internal search core.
impl Searcher {
/// Returns the line terminator used by this searcher.
#[inline]
pub fn line_terminator(&self) -> LineTerminator {
self.config.line_term
}
/// Returns true if and only if this searcher is configured to count line
/// numbers.
#[inline]
pub fn line_number(&self) -> bool {
self.config.line_number
}
/// Returns true if and only if this searcher is configured to perform
/// multi line search.
#[inline]
pub fn multi_line(&self) -> bool {
self.config.multi_line
}
/// Returns true if and only if this searcher will choose a multi-line
/// strategy given the provided matcher.
pub fn multi_line_with_matcher<M: Matcher>(&self, matcher: M) -> bool {
if !self.multi_line() {
return false;
}
if let Some(line_term) = matcher.line_terminator()
&& line_term == self.line_terminator()
{
return false;
}
true
}
}
+138
View File
@@ -0,0 +1,138 @@
use std::io;
use crate::searcher::Searcher;
/// A trait that describes errors that can be reported by searchers and
/// implementations of `Sink`.
pub trait SinkError: Sized {
/// A constructor for converting any value that satisfies the
/// `std::fmt::Display` trait into an error.
fn error_message<T: std::fmt::Display>(message: T) -> Self;
/// A constructor for converting I/O errors that occur while searching into
/// an error of this type.
fn error_io(err: io::Error) -> Self {
Self::error_message(err)
}
}
impl SinkError for io::Error {
fn error_message<T: std::fmt::Display>(message: T) -> io::Error {
io::Error::other(message.to_string())
}
fn error_io(err: io::Error) -> io::Error {
err
}
}
/// A trait that defines how results from searchers are handled.
///
/// The searcher follows the "push" model: the searcher drives execution and
/// pushes results back to the caller via this trait.
pub trait Sink {
/// The type of an error that should be reported by a searcher.
type Error: SinkError;
/// This method is called whenever a match is found.
///
/// If this returns `true`, then searching continues. If this returns
/// `false`, then searching is stopped immediately and `finish` is called.
fn matched(&mut self, _searcher: &Searcher, _mat: &SinkMatch<'_>) -> Result<bool, Self::Error>;
/// This method is called when a search has begun, before any search is
/// executed. By default, this does nothing.
#[inline]
fn begin(&mut self, _searcher: &Searcher) -> Result<bool, Self::Error> {
Ok(true)
}
/// This method is called when a search has completed. By default, this
/// does nothing.
#[inline]
fn finish(&mut self, _searcher: &Searcher, _: &SinkFinish) -> Result<(), Self::Error> {
Ok(())
}
}
impl<S: Sink> Sink for &mut S {
type Error = S::Error;
#[inline]
fn matched(&mut self, searcher: &Searcher, mat: &SinkMatch<'_>) -> Result<bool, S::Error> {
(**self).matched(searcher, mat)
}
#[inline]
fn begin(&mut self, searcher: &Searcher) -> Result<bool, S::Error> {
(**self).begin(searcher)
}
#[inline]
fn finish(&mut self, searcher: &Searcher, sink_finish: &SinkFinish) -> Result<(), S::Error> {
(**self).finish(searcher, sink_finish)
}
}
/// Summary data reported at the end of a search.
#[derive(Clone, Debug)]
pub struct SinkFinish {
pub(crate) byte_count: u64,
}
impl SinkFinish {
/// Return the total number of bytes searched.
#[inline]
pub fn byte_count(&self) -> u64 {
self.byte_count
}
}
/// A type that describes a match reported by a searcher.
#[derive(Clone, Debug)]
pub struct SinkMatch<'b> {
pub(crate) bytes: &'b [u8],
pub(crate) absolute_byte_offset: u64,
pub(crate) line_number: Option<u64>,
pub(crate) buffer: &'b [u8],
pub(crate) bytes_range_in_buffer: std::ops::Range<usize>,
}
impl<'b> SinkMatch<'b> {
/// Returns the bytes for all matching lines, including the line
/// terminators, if they exist.
#[inline]
pub fn bytes(&self) -> &'b [u8] {
self.bytes
}
/// Returns the absolute byte offset of the start of this match. This
/// offset is absolute in that it is relative to the very beginning of the
/// input in a search.
#[inline]
pub fn absolute_byte_offset(&self) -> u64 {
self.absolute_byte_offset
}
/// Returns the line number of the first line in this match, if available.
///
/// Line numbers are only available when the search builder is instructed
/// to compute them.
#[inline]
pub fn line_number(&self) -> Option<u64> {
self.line_number
}
/// Exposes as much of the underlying buffer that was searched as possible.
#[inline]
pub fn buffer(&self) -> &'b [u8] {
self.buffer
}
/// Returns a range that corresponds to where [`SinkMatch::bytes`] appears
/// in [`SinkMatch::buffer`].
#[inline]
pub fn bytes_range_in_buffer(&self) -> std::ops::Range<usize> {
self.bytes_range_in_buffer.clone()
}
}
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "fff-mcp"
version = "0.5.2"
edition = "2024"
description = "MCP server for FFF file finder - drop-in replacement for AI code assistant search tools"
license = "MIT"
[[bin]]
name = "fff-mcp"
path = "src/main.rs"
[features]
default = ["zlob"]
zlob = ["fff/zlob"]
[dependencies]
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"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["full"] }
tracing = { workspace = true }
git2 = { workspace = true }
clap = { version = "4", features = ["derive", "env"] }
+15
View File
@@ -0,0 +1,15 @@
fn main() {
// Embed the git commit hash at build time for update checking.
let hash = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
println!("cargo:rustc-env=FFF_GIT_HASH={}", hash);
println!("cargo:rerun-if-changed=../../.git/HEAD");
println!("cargo:rerun-if-changed=../../.git/refs/");
}
+52
View File
@@ -0,0 +1,52 @@
//! Cursor store for grep pagination.
//!
//! Maintains an in-memory map of opaque cursor IDs to file offsets.
//! Cursors are evicted LRU-style when the store exceeds capacity.
use std::collections::{HashMap, VecDeque};
const MAX_CURSORS: usize = 20;
/// Stores cursor state for paginated grep results.
pub struct CursorStore {
counter: u64,
/// Map from cursor ID string → file offset for next page.
cursors: HashMap<String, usize>,
/// Insertion order for LRU eviction.
insertion_order: VecDeque<String>,
}
impl CursorStore {
pub fn new() -> Self {
Self {
counter: 0,
cursors: HashMap::new(),
insertion_order: VecDeque::new(),
}
}
/// Store a cursor and return its opaque ID string.
pub fn store(&mut self, file_offset: usize) -> String {
self.counter = self.counter.wrapping_add(1);
let id = self.counter.to_string();
self.cursors.insert(id.clone(), file_offset);
self.insertion_order.push_back(id.clone());
// Evict oldest cursors
while self.cursors.len() > MAX_CURSORS {
if let Some(oldest) = self.insertion_order.pop_front() {
self.cursors.remove(&oldest);
} else {
break;
}
}
id
}
/// Retrieve the file offset for a cursor ID.
pub fn get(&self, id: &str) -> Option<usize> {
self.cursors.get(id).copied()
}
}
+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())
}
}
+359
View File
@@ -0,0 +1,359 @@
//! FFF MCP Server — high-performance file finder for AI code assistants.
//!
//! Drop-in replacement for AI code assistant file search tools (Glob/Grep).
//! Provides frecency-ranked, fuzzy-matched, git-aware file finding and
//! code search via the Model Context Protocol (MCP).
//!
//! Uses `fff-core` directly (zero FFI overhead) for all search operations.
mod cursor;
mod healthcheck;
mod output;
mod server;
mod update_check;
use clap::Parser;
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};
use server::FffServer;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
pub const MCP_INSTRUCTIONS: &str = concat!(
"FFF is a fast file finder with frecency-ranked results (frequent/recent files first, git-dirty files boosted).\n",
"\n",
"## Which Tool Should I Use?\n",
"\n",
"- **grep**: DEFAULT tool. Searches file CONTENTS -- definitions, usage, patterns. Use when you have a specific name or pattern.\n",
"- **find_files**: Explores which files/modules exist for a topic. Use when you DON'T have a specific identifier or LOOKING FOR A FILE.\n",
"- **multi_grep**: OR logic across multiple patterns. Use for case variants (e.g. ['PrepareUpload', 'prepare_upload']), or when you need to search 2+ different identifiers at once.\n",
"\n",
"## Core Rules\n",
"\n",
"### 1. Search BARE IDENTIFIERS only\n",
"Grep matches single lines. Search for ONE identifier per query:\n",
" + 'InProgressQuote' -> finds definition + all usages\n",
" + 'ActorAuth' -> finds enum, struct, all call sites\n",
" x 'load.*metadata.*InProgressQuote' -> regex spanning multiple tokens, 0 results\n",
" x 'ctx.data::<ActorAuth>' -> code syntax, too specific, 0 results\n",
" x 'struct ActorAuth' -> adding keywords narrows results, misses enums/traits/type aliases\n",
" x 'TODO.*#\\d+' -> complex regex, use simple 'TODO' then filter visually\n",
"\n",
"### 2. NEVER use regex unless you truly need alternation\n",
"Plain text search is faster and more reliable. Regex patterns like `.*`, `\\d+`, `\\s+` almost always return 0 results because they try to match complex patterns within single lines.\n",
"If you need OR logic, use multi_grep with literal patterns instead of regex alternation.\n",
"\n",
"### 3. Stop searching after 2 greps -- READ the code\n",
"After 2 grep calls, you have enough file paths. Read the top result to understand the code.\n",
"Do NOT keep grepping with variations. More greps != better understanding.\n",
"\n",
"### 4. Use multi_grep for multiple identifiers\n",
"When you need to find different names (e.g. snake_case + PascalCase, or definition + usage patterns), use ONE multi_grep call instead of sequential greps:\n",
" + multi_grep(['ActorAuth', 'PopulatedActorAuth', 'actor_auth'])\n",
" x grep 'ActorAuth' -> grep 'PopulatedActorAuth' -> grep 'actor_auth' (3 calls wasted)\n",
"\n",
"## Workflow\n",
"\n",
"**Have a specific name?** -> grep the bare identifier.\n",
"**Need multiple name variants?** -> multi_grep with all variants in one call.\n",
"**Exploring a topic / finding files?** -> find_files.\n",
"**Got results?** -> Read the top file. Don't grep again.\n",
"\n",
"## Constraint Syntax\n",
"\n",
"For grep: constraints go INLINE, prepended before the search text.\n",
"For multi_grep: constraints go in the separate 'constraints' parameter.\n",
"\n",
"Constraints MUST match one of these formats:\n",
" Extension: '*.rs', '*.{ts,tsx}'\n",
" Directory: 'src/', 'quotes/'\n",
" Filename: 'schema.rs', 'src/main.rs'\n",
" Exclude: '!test/', '!*.spec.ts'\n",
"\n",
"! Bare words without extensions are NOT constraints. 'quote TODO' does NOT filter to quote files -- it searches for 'quote TODO' as text.\n",
" + 'schema.rs TODO' -> searches for 'TODO' in files schema.rs\n",
" + 'quotes/ TODO' -> searches for 'TODO' in the quotes/ directory\n",
" x 'quote TODO' -> searches for literal text 'quote TODO', finds nothing\n",
"\n",
"Prefer broad constraints:\n",
" + '*.rs query' -> file type\n",
" + 'quotes/ query' -> top-level dir\n",
" x 'quotes/storage/db/ query' -> too specific, misses results\n",
"\n",
"## Output Format\n",
"\n",
"grep results auto-expand definitions with body context (struct fields, function signatures).\n",
"This often provides enough information WITHOUT a follow-up Read call.\n",
"Lines marked with | are definition body context. [def] marks definition files.\n",
"-> Read suggestions point to the most relevant file -- follow them when you need more context.\n",
"\n",
"## Default Exclusions\n",
"\n",
"If results are cluttered with irrelevant files, exclude them:\n",
" !tests/ - exclude tests directory\n",
" !*.spec.ts - exclude test files\n",
" !generated/ - exclude generated code",
);
/// 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"), ")"))]
pub(crate) struct Args {
/// Base directory to index. Defaults to the current working directory.
#[arg(value_name = "PATH")]
base_path: Option<String>,
/// Path to the frecency database.
#[arg(long = "frecency-db")]
frecency_db_path: Option<String>,
/// Path to the query history database.
#[arg(long = "history-db")]
#[allow(dead_code)]
history_db_path: Option<String>,
/// Path to the log file.
#[arg(long = "log-file")]
log_file: Option<String>,
/// Log level (e.g. trace, debug, info, warn, error).
#[arg(long = "log-level")]
log_level: Option<String>,
/// Disable automatic update checks on startup.
#[arg(long = "no-update-check")]
no_update_check: bool,
/// Disable eager mmap warmup after the initial scan. Grep results will
/// still work (files are mmap'd lazily on first access), but the first
/// search may be slightly slower. Useful on very large repos where the
/// warmup would consume too many kernel resources.
#[arg(long = "no-warmup")]
no_warmup: bool,
/// Disable the content index built after the initial scan.
/// This makes grep calls slower but consumes less RAM (recommended to not turn off)
no_content_indexing: bool,
/// Explicitly enable content indexing even when `--no-warmup` is set.
#[arg(long = "content-indexing")]
content_indexing: bool,
/// Disable the background file-system watcher. Files are scanned once
/// at startup but not monitored for changes.
#[arg(long = "no-watch")]
no_watch: bool,
/// Maximum number of files whose content is kept persistently in memory.
/// Files beyond this limit are still searchable via temporary mmaps that
/// are released after each grep. Defaults to 30 000.
/// Also settable via the FFF_MAX_CACHED_FILES environment variable.
#[arg(long = "max-cached-files", env = "FFF_MAX_CACHED_FILES")]
max_cached_files: Option<usize>,
/// Run a health check and print diagnostic information, then exit.
#[arg(long = "healthcheck")]
pub(crate) healthcheck: bool,
}
/// Resolve default paths for frecency db, history db, and log file.
/// Shares Neovim's standard data locations when they exist so the MCP
/// server and fff.nvim plugin use the same databases.
fn resolve_defaults(args: &mut Args) {
let home = dirs_home();
let is_windows = cfg!(target_os = "windows");
let nvim_cache_dir = if is_windows {
format!("{}\\AppData\\Local\\nvim-data", home)
} else {
format!("{}/.cache/nvim", home)
};
let nvim_data_dir = if is_windows {
format!("{}\\AppData\\Local\\nvim-data", home)
} else {
format!("{}/.local/share/nvim", home)
};
let use_nvim_paths = std::path::Path::new(&nvim_cache_dir).exists()
|| std::path::Path::new(&nvim_data_dir).exists();
if args.frecency_db_path.is_none() {
args.frecency_db_path = Some(if use_nvim_paths {
format!("{}/fff_nvim", nvim_cache_dir)
} else {
format!("{}/.fff/frecency.mdb", home)
});
}
if args.history_db_path.is_none() {
args.history_db_path = Some(if use_nvim_paths {
format!("{}/fff_queries", nvim_data_dir)
} else {
format!("{}/.fff/history.mdb", home)
});
}
// Ensure parent directories exist for database paths
for path in [&args.frecency_db_path, &args.history_db_path]
.into_iter()
.flatten()
{
if let Some(parent) = std::path::Path::new(path).parent() {
let _ = std::fs::create_dir_all(parent);
}
}
if args.log_file.is_none() {
args.log_file = Some(if is_windows {
format!("{}\\AppData\\Local\\fff_mcp.log", home)
} else {
format!("{}/.cache/fff_mcp.log", home)
});
}
}
fn dirs_home() -> String {
std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| "/tmp".to_string())
}
#[tokio::main]
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::log::init_tracing(log_file, args.log_level.as_deref()) {
eprintln!("Warning: Failed to init tracing: {}", e);
}
let base_path = args.base_path.unwrap_or_else(|| {
std::env::current_dir()
.unwrap_or_default()
.to_string_lossy()
.to_string()
});
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::default();
let shared_frecency = SharedFrecency::default();
match FrecencyTracker::new(&frecency_db_path, false) {
Ok(tracker) => {
let _ = shared_frecency.init(tracker);
let _ = shared_frecency.spawn_gc(frecency_db_path, false);
}
Err(e) => {
eprintln!("Warning: Failed to init frecency db: {}", e);
}
}
// Content indexing follows warmup by default (backward compat), unless
// the user explicitly opts in via --content-indexing or out via
// --no-content-indexing.
let enable_content_indexing = if args.content_indexing {
true
} else if args.no_content_indexing {
false
} else {
!args.no_warmup
};
// Initialize file picker (spawns background scan + watcher)
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
fff::FilePickerOptions {
base_path,
enable_mmap_cache: !args.no_warmup,
enable_content_indexing,
watch: !args.no_watch,
mode: FFFMode::Ai,
cache_budget: args
.max_cached_files
.map(fff::ContentCacheBudget::new_for_repo),
},
)
.map_err(|e| format!("Failed to init file picker: {}", e))?;
if !args.no_update_check {
update_check::spawn_update_check();
}
// Create and start the MCP server
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 = shared_picker.clone();
tokio::task::spawn_blocking(move || {
let start = std::time::Instant::now();
loop {
let is_scanning = picker_clone_for_scan
.read()
.ok()
.and_then(|g| g.as_ref().map(|p| p.is_scan_active()))
.unwrap_or(true);
if !is_scanning {
tracing::info!("Initial scan completed in {:?}", start.elapsed());
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
});
let service = server
.serve(stdio())
.await
.map_err(|e| format!("Failed to start MCP server: {}", e))?;
let picker_for_shutdown = shared_picker.clone();
tokio::spawn(async move {
tokio::signal::ctrl_c().await.ok();
if let Ok(mut guard) = picker_for_shutdown.write()
&& let Some(ref mut picker) = *guard
{
picker.stop_background_monitor();
}
std::process::exit(0);
});
service.waiting().await?;
if let Ok(mut guard) = shared_picker.write()
&& let Some(ref mut picker) = *guard
{
picker.stop_background_monitor();
}
Ok(())
}
+582
View File
@@ -0,0 +1,582 @@
//! Output formatting for MCP grep/search results.
use fff::GrepMatch;
use fff::file_picker::FilePicker;
use fff::git::format_git_status_opt;
use fff::grep::is_import_line;
use fff::types::FileItem;
use crate::cursor::CursorStore;
fn frecency_word(score: i32) -> Option<&'static str> {
if score >= 100 {
Some("hot")
} else if score >= 50 {
Some("warm")
} else if score >= 10 {
Some("frequent")
} else {
None
}
}
pub fn file_suffix(git_status: Option<git2::Status>, frecency_score: i32) -> String {
match (
frecency_word(frecency_score),
format_git_status_opt(git_status),
) {
(Some(f), Some(g)) => format!(" - {f} git:{g}"),
(Some(f), None) => format!(" - {f}"),
(None, Some(g)) => format!(" git:{g}"),
(None, None) => String::new(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputMode {
Content,
FilesWithMatches,
Count,
Usage,
}
impl OutputMode {
pub fn new(s: Option<&str>) -> Self {
match s {
Some("files_with_matches") => Self::FilesWithMatches,
Some("count") => Self::Count,
Some("usage") => Self::Usage,
_ => Self::Content,
}
}
}
const LARGE_FILE_BYTES: u64 = 20_000;
fn size_tag(bytes: u64) -> String {
if bytes < LARGE_FILE_BYTES {
String::new()
} else {
let kb = (bytes + 512) / 1024; // round
format!(" ({}KB - use offset to read relevant section)", kb)
}
}
const MAX_PREVIEW: usize = 120;
const MAX_LINE_LEN: usize = 180;
const MAX_DEF_EXPAND_FIRST: usize = 8;
const MAX_DEF_EXPAND: usize = 5;
const MAX_FIRST_MATCH_EXPAND: usize = 8;
fn trauncate_line_for_ai(
line: &str,
match_ranges: Option<&[(u32, u32)]>,
max_len: usize,
) -> String {
// 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();
}
if trimmed.len() <= max_len {
return trimmed.to_string();
}
// Use first match range to center the window
if let Some(ranges) = match_ranges
&& let Some(&(match_start, match_end)) = ranges.first()
{
let match_start = match_start as usize;
let match_end = match_end as usize;
let match_len = match_end.saturating_sub(match_start);
let budget = max_len.saturating_sub(match_len);
let before = budget / 3;
let after = budget - before;
let win_start = match_start.saturating_sub(before);
let win_end = (match_end + after).min(trimmed.len());
// Clamp to char boundaries
let win_start = floor_char_boundary(trimmed, win_start);
let win_end = ceil_char_boundary(trimmed, win_end);
let mut result = trimmed[win_start..win_end].to_string();
if win_start > 0 {
result.insert(0, '…');
}
if win_end < trimmed.len() {
result.push('…');
}
return result;
}
// No match ranges — truncate from start
let end = ceil_char_boundary(trimmed, max_len);
format!("{}", &trimmed[..end])
}
fn floor_char_boundary(s: &str, index: usize) -> usize {
if index >= s.len() {
return s.len();
}
let mut i = index;
while i > 0 && !s.is_char_boundary(i) {
i -= 1;
}
i
}
fn ceil_char_boundary(s: &str, index: usize) -> usize {
if index >= s.len() {
return s.len();
}
let mut i = index;
while i < s.len() && !s.is_char_boundary(i) {
i += 1;
}
i
}
struct FileMeta<'a> {
file: &'a FileItem,
line_number: u64,
line_content: String,
is_definition: bool,
match_ranges: Vec<(u32, u32)>,
context_after: Vec<String>,
}
pub struct GrepFormatter<'a> {
pub matches: &'a [GrepMatch],
pub files: &'a [&'a FileItem],
pub total_matched: usize,
pub next_file_offset: usize,
pub regex_fallback_error: Option<&'a str>,
pub output_mode: OutputMode,
pub max_results: usize,
pub show_context: bool,
pub auto_expand_defs: bool,
pub picker: &'a FilePicker,
}
impl GrepFormatter<'_> {
pub fn format(&self, cursor_store: &mut CursorStore) -> String {
let GrepFormatter {
matches,
files,
total_matched,
next_file_offset,
regex_fallback_error,
output_mode,
max_results,
show_context,
auto_expand_defs,
picker,
} = *self;
let items = if matches.len() > max_results {
&matches[..max_results]
} else {
matches
};
if output_mode == OutputMode::FilesWithMatches {
return format_files_with_matches(
items,
files,
next_file_offset,
auto_expand_defs,
cursor_store,
picker,
);
}
if output_mode == OutputMode::Count {
return format_count(items, files, next_file_offset, cursor_store, picker);
}
// output_mode == usage
let mut lines: Vec<String> = Vec::new();
let unique_files = {
let mut seen = std::collections::HashSet::new();
for m in items {
seen.insert(m.file_index);
}
seen.len()
};
let max_output_chars: usize = if output_mode == OutputMode::Usage || unique_files <= 3 {
5000
} else if unique_files <= 8 {
3500
} else {
2500
};
if let Some(err) = regex_fallback_error {
lines.push(format!("! regex failed: {}, using literal match", err));
}
// File overview: collect first match per file
let file_preview = collect_file_preview(items, files, picker);
let mut content_def_file = String::new();
let mut content_first_file = String::new();
for fm in &file_preview {
if content_first_file.is_empty() {
content_first_file = fm.file.relative_path(picker);
}
if content_def_file.is_empty() && fm.is_definition {
content_def_file = fm.file.relative_path(picker);
}
}
let content_suggest = if !content_def_file.is_empty() {
&content_def_file
} else {
&content_first_file
};
if !content_suggest.is_empty() {
let file_count = file_preview.len();
if file_count == 1 {
lines.push(format!("→ Read {} (only match)", content_suggest));
} else if !content_def_file.is_empty() {
lines.push(format!("→ Read {} [def]", content_suggest));
} else if file_count <= 3 {
lines.push(format!("→ Read {} (best match)", content_suggest));
}
}
if total_matched > items.len() {
lines.push(format!("{}/{} matches shown", items.len(), total_matched));
}
// Track which files already had a definition expanded
let mut def_expanded_files = std::collections::HashSet::new();
// Detailed content (subject to budget)
let mut char_count = 0usize;
let mut shown_count = 0usize;
let mut current_file = String::new();
// Reorder: definitions first, then usages, then imports (when auto-expanding)
let sorted_items: Vec<usize> = if auto_expand_defs {
let mut indices: Vec<usize> = (0..items.len()).collect();
indices.sort_unstable_by_key(|&i| {
if items[i].is_definition {
0
} else if is_import_line(&items[i].line_content) {
2
} else {
1
}
});
indices
} else {
(0..items.len()).collect()
};
for &idx in &sorted_items {
let m = &items[idx];
let file = files[m.file_index];
let mut match_lines: Vec<String> = Vec::new();
let file_rel_path = file.relative_path(picker);
if file_rel_path != current_file {
current_file = file_rel_path;
match_lines.push(current_file.to_string());
}
// Skip import-only lines when we already have definitions
if auto_expand_defs && is_import_line(&m.line_content) && !def_expanded_files.is_empty()
{
continue;
}
// Context before (only when explicitly requested)
if show_context && !m.context_before.is_empty() {
let start_line = m.line_number.saturating_sub(m.context_before.len() as u64);
for (i, ctx) in m.context_before.iter().enumerate() {
match_lines.push(format!(
" {}-{}",
start_line + i as u64,
trauncate_line_for_ai(ctx, None, MAX_LINE_LEN)
));
}
}
// Match line
match_lines.push(format!(
" {}: {}",
m.line_number,
trauncate_line_for_ai(
&m.line_content,
Some(m.match_byte_offsets.as_ref()),
MAX_LINE_LEN
)
));
// Context after (only when explicitly requested via context parameter)
if show_context && !m.context_after.is_empty() {
let start_line = m.line_number + 1;
for (i, ctx) in m.context_after.iter().enumerate() {
match_lines.push(format!(
" {}-{}",
start_line + i as u64,
trauncate_line_for_ai(ctx, None, MAX_LINE_LEN)
));
}
match_lines.push("--".to_string());
}
// Auto-expand definitions with body context
let file_rel_for_expand = file.relative_path(picker);
if auto_expand_defs
&& !show_context
&& m.is_definition
&& !m.context_after.is_empty()
&& !def_expanded_files.contains(&file_rel_for_expand)
{
let expand_limit = if def_expanded_files.is_empty() {
MAX_DEF_EXPAND_FIRST
} else {
MAX_DEF_EXPAND
};
def_expanded_files.insert(file_rel_for_expand);
let start_line = m.line_number + 1;
for (i, ctx) in m.context_after.iter().take(expand_limit).enumerate() {
if ctx.trim().is_empty() {
break;
}
match_lines.push(format!(
" {}| {}",
start_line + i as u64,
trauncate_line_for_ai(ctx, None, MAX_LINE_LEN)
));
}
}
let chunk = match_lines.join("\n");
if char_count + chunk.len() > max_output_chars && shown_count > 0 {
break;
}
char_count += chunk.len();
lines.push(chunk);
shown_count += 1;
}
if next_file_offset > 0 {
let cursor_id = cursor_store.store(next_file_offset);
lines.push(format!("\ncursor: {}", cursor_id));
}
lines.join("\n")
}
}
fn format_files_with_matches(
items: &[GrepMatch],
files: &[&FileItem],
next_file_offset: usize,
auto_expand_defs: bool,
cursor_store: &mut CursorStore,
picker: &FilePicker,
) -> String {
let file_map = collect_file_preview(items, files, picker);
let mut lines: Vec<String> = Vec::new();
let file_count = file_map.len();
// Find best Read target
let mut first_def_file = String::new();
let mut first_file = String::new();
for fm in &file_map {
if first_file.is_empty() {
first_file = fm.file.relative_path(picker);
}
if first_def_file.is_empty() && fm.is_definition {
first_def_file = fm.file.relative_path(picker);
}
}
let suggest_path = if !first_def_file.is_empty() {
&first_def_file
} else {
&first_file
};
if !suggest_path.is_empty() {
if file_count == 1 {
lines.push(format!(
"→ Read {} (only match — no need to search further)",
suggest_path
));
} else if !first_def_file.is_empty() && file_count <= 5 {
lines.push(format!("→ Read {} (definition found)", suggest_path));
} else if !first_def_file.is_empty() {
lines.push(format!("→ Read {} (definition)", suggest_path));
} else if file_count <= 3 {
lines.push(format!("→ Read {} (best match)", suggest_path));
} else {
lines.push(format!("→ Read {}", suggest_path));
}
}
let is_small_set = file_count <= 5;
let mut def_expanded_count = 0usize;
for (file_idx, fm) in file_map.iter().enumerate() {
let is_def = fm.is_definition;
let def_tag = if is_def { " [def]" } else { "" };
lines.push(format!(
"{}{}{}",
fm.file.relative_path(picker),
def_tag,
size_tag(fm.file.size)
));
// Show preview
if !fm.line_content.is_empty() && (is_def || file_idx == 0 || is_small_set) {
let ranges_ref: Option<&[(u32, u32)]> = if fm.match_ranges.is_empty() {
None
} else {
Some(&fm.match_ranges)
};
lines.push(format!(
" {}: {}",
fm.line_number,
trauncate_line_for_ai(&fm.line_content, ranges_ref, MAX_PREVIEW)
));
// Auto-expand body context
if auto_expand_defs && !fm.context_after.is_empty() {
let expand_limit = if is_def {
let limit = if def_expanded_count == 0 {
MAX_DEF_EXPAND_FIRST
} else {
MAX_DEF_EXPAND
};
def_expanded_count += 1;
limit
} else if is_small_set && file_idx == 0 {
MAX_FIRST_MATCH_EXPAND
} else if is_small_set {
MAX_DEF_EXPAND
} else {
0
};
if expand_limit > 0 {
let start_line = fm.line_number + 1;
for (i, ctx) in fm.context_after.iter().take(expand_limit).enumerate() {
if ctx.trim().is_empty() {
break;
}
lines.push(format!(
" {}| {}",
start_line + i as u64,
trauncate_line_for_ai(ctx, None, MAX_PREVIEW)
));
}
}
}
}
}
if next_file_offset > 0 {
let cursor_id = cursor_store.store(next_file_offset);
lines.push(format!("\ncursor: {}", cursor_id));
}
lines.join("\n")
}
fn format_count(
items: &[GrepMatch],
files: &[&FileItem],
next_file_offset: usize,
cursor_store: &mut CursorStore,
picker: &FilePicker,
) -> String {
let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut order: Vec<String> = Vec::new();
for m in items {
let file = files[m.file_index];
let path = file.relative_path(picker);
let count = counts.entry(path.to_string()).or_insert_with(|| {
order.push(path.to_string());
0
});
*count += 1;
}
let mut lines: Vec<String> = Vec::new();
for path in &order {
lines.push(format!("{}: {}", path, counts[path.as_str()]));
}
if next_file_offset > 0 {
let cursor_id = cursor_store.store(next_file_offset);
lines.push(format!("\ncursor: {}", cursor_id));
}
lines.join("\n")
}
fn collect_file_preview<'a>(
items: &[GrepMatch],
files: &[&'a FileItem],
picker: &FilePicker,
) -> Vec<FileMeta<'a>> {
let mut file_preview: Vec<FileMeta<'a>> = Vec::new();
let mut seen = std::collections::HashSet::new();
for m in items {
let file = files[m.file_index];
if seen.insert(file.relative_path(picker)) {
file_preview.push(FileMeta {
file,
line_number: m.line_number,
line_content: m.line_content.clone(),
is_definition: m.is_definition,
match_ranges: m.match_byte_offsets.iter().copied().collect(),
context_after: m.context_after.clone(),
});
}
}
file_preview
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
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_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);
assert_eq!(result, "hello");
}
#[test]
fn trunc_long_line_centered() {
// 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
}
}
+666
View File
@@ -0,0 +1,666 @@
//! FFF MCP server — tool definitions and handlers.
//!
//! Uses the `rmcp` crate's `#[tool_router]` / `#[tool_handler]` macros
//! for declarative tool registration. Each tool method directly calls
//! `fff-core` APIs (no C FFI overhead).
use std::borrow::Cow;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use crate::cursor::CursorStore;
use crate::output::{GrepFormatter, OutputMode, file_suffix};
use fff::grep::{GrepMode, GrepSearchOptions, has_regex_metacharacters};
use fff::types::{FileItem, PaginationArgs};
use fff::{FuzzySearchOptions, QueryParser, 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};
fn cleanup_fuzzy_query(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if !matches!(c, ':' | '-' | '_') {
out.extend(c.to_lowercase());
}
}
out
}
fn make_grep_options(
output_mode: OutputMode,
mode: GrepMode,
file_offset: usize,
context: Option<usize>,
) -> (GrepSearchOptions, bool) {
let is_usage = output_mode == OutputMode::Usage;
let matches_per_file = match output_mode {
OutputMode::FilesWithMatches => 1,
_ if is_usage => 8,
_ => 10,
};
let ctx_lines = if is_usage {
context.unwrap_or(1)
} else {
context.unwrap_or(0)
};
let auto_expand = !is_usage && ctx_lines == 0;
let after_ctx = if auto_expand { 8 } else { ctx_lines };
(
GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: matches_per_file,
smart_case: true,
file_offset,
page_limit: 50,
mode,
time_budget_ms: 0,
before_context: ctx_lines,
after_context: after_ctx,
classify_definitions: true,
trim_whitespace: true,
abort_signal: None,
},
auto_expand,
)
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct FindFilesParams {
/// Fuzzy search query. Supports path prefixes and glob constraints.
pub query: String,
/// Max results (default 20).
#[serde(rename = "maxResults")]
// 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>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct GrepParams {
/// Search text or regex query with optional constraint prefixes.
/// Matches within single lines only — use ONE specific term, not multiple words.
pub query: String,
/// Max matching lines (default 20).
#[serde(rename = "maxResults")]
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').
pub output_mode: Option<String>,
}
fn deserialize_patterns<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
struct PatternsVisitor;
impl<'de> de::Visitor<'de> for PatternsVisitor {
type Value = Vec<String>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string, an array of strings, or a stringified JSON array")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
// Try to parse as JSON array first
if v.starts_with('[')
&& let Ok(parsed) = serde_json::from_str::<Vec<String>>(v)
{
return Ok(parsed);
}
Ok(vec![v.to_string()])
}
fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
if v.starts_with('[')
&& let Ok(parsed) = serde_json::from_str::<Vec<String>>(&v)
{
return Ok(parsed);
}
Ok(vec![v])
}
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
let mut values = Vec::new();
while let Some(value) = seq.next_element::<String>()? {
values.push(value);
}
Ok(values)
}
}
deserializer.deserialize_any(PatternsVisitor)
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct MultiGrepParams {
/// Patterns to match (OR logic). Include all naming conventions: snake_case, PascalCase, camelCase.
#[serde(deserialize_with = "deserialize_patterns")]
pub patterns: Vec<String>,
/// File constraints (e.g. '*.{ts,tsx} !test/'). ALWAYS provide when possible.
pub constraints: Option<String>,
/// Max matching lines (default 20).
#[serde(rename = "maxResults")]
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<f64>,
}
#[derive(Clone)]
pub struct FffServer {
picker: SharedPicker,
#[allow(dead_code)]
frecency: SharedFrecency,
cursor_store: Arc<Mutex<CursorStore>>,
update_notice_sent: Arc<AtomicBool>,
tool_router: ToolRouter<Self>,
}
impl FffServer {
pub fn new(picker: SharedPicker, frecency: SharedFrecency) -> Self {
Self {
picker,
frecency,
cursor_store: Arc::new(Mutex::new(CursorStore::new())),
update_notice_sent: Arc::new(AtomicBool::new(false)),
tool_router: Self::tool_router(),
}
}
#[allow(dead_code)]
pub fn wait_for_scan(&self) {
loop {
let guard = self.picker.read().ok();
let is_scanning = guard
.as_ref()
.and_then(|g| g.as_ref())
.map(|p| p.is_scan_active())
.unwrap_or(true);
if !is_scanning {
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
}
fn lock_cursors(&self) -> Result<std::sync::MutexGuard<'_, CursorStore>, ErrorData> {
self.cursor_store.lock().map_err(|e| {
ErrorData::internal_error(format!("Failed to acquire cursor store lock: {e}"), None)
})
}
fn maybe_append_update_notice(&self, result: &mut CallToolResult) {
if self.update_notice_sent.swap(true, Ordering::Relaxed) {
return;
}
let notice = crate::update_check::get_update_notice();
if notice.is_empty() {
// Reset so the next call can try again (check may still be in flight)
self.update_notice_sent.store(false, Ordering::Relaxed);
return;
}
result.content.push(Content::text(notice));
}
fn perform_grep(
&self,
query: &str,
mode: GrepMode,
max_results: usize,
cursor_id: Option<&str>,
output_mode: OutputMode,
context: Option<usize>,
) -> Result<CallToolResult, ErrorData> {
let file_offset = cursor_id
.and_then(|id| self.cursor_store.lock().ok()?.get(id))
.unwrap_or(0);
let (options, auto_expand) = make_grep_options(output_mode, mode, file_offset, context);
let ctx_lines = options.before_context;
// Acquire picker lock once for the entire operation.
let guard = self.picker.read().map_err(|e| {
ErrorData::internal_error(format!("Failed to acquire picker lock: {e}"), None)
})?;
let picker = guard
.as_ref()
.ok_or_else(|| ErrorData::internal_error("File picker not initialized", None))?;
let parser = QueryParser::new(AiGrepConfig);
let parsed = parser.parse(query);
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
let parts: Vec<&str> = query.split_whitespace().collect();
if parts.len() >= 2 {
let first_word = parts[0];
let is_valid_constraint = first_word.starts_with('!')
|| first_word.starts_with('*')
|| first_word.ends_with('/');
if !is_valid_constraint {
let rest_query = parts[1..].join(" ");
let rest_parsed = parser.parse(&rest_query);
let rest_text = rest_parsed.grep_text();
let retry_mode = if has_regex_metacharacters(&rest_text) {
GrepMode::Regex
} else {
mode
};
let (retry_options, _) = make_grep_options(output_mode, retry_mode, 0, context);
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()?;
let text = &GrepFormatter {
matches: &retry_result.matches,
files: &retry_result.files,
total_matched: retry_result.matches.len(),
next_file_offset: retry_result.next_file_offset,
regex_fallback_error: retry_result.regex_fallback_error.as_deref(),
output_mode,
max_results,
show_context: ctx_lines > 0,
auto_expand_defs: auto_expand,
picker,
}
.format(&mut cs);
return Ok(CallToolResult::success(vec![Content::text(format!(
"0 matches for '{}'. Auto-broadened to '{}':\n{}",
query, rest_query, text
))]));
}
}
}
// Fuzzy fallback for typo tolerance
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 = picker.grep(&fuzzy_parsed, &fuzzy_options);
if !fuzzy_result.matches.is_empty() {
let mut lines: Vec<String> = Vec::new();
lines.push(format!(
"0 exact matches. {} approximate:",
fuzzy_result.matches.len()
));
let mut current_file = String::new();
for m in fuzzy_result.matches.iter().take(3) {
let file = fuzzy_result.files[m.file_index];
let file_rel = file.relative_path(picker);
if file_rel != current_file {
current_file = file_rel;
lines.push(current_file.to_string());
}
lines.push(format!(" {}: {}", m.line_number, m.line_content));
}
return Ok(CallToolResult::success(vec![Content::text(
lines.join("\n"),
)]));
}
// File path fallback: if query looks like a path, suggest the matching file
if query.contains('/') {
let file_parser = QueryParser::default();
let file_query = file_parser.parse(query);
let file_opts = FuzzySearchOptions {
max_threads: 0,
current_file: None,
project_path: Some(picker.base_path()),
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 1,
},
};
let file_result = picker.fuzzy_search(&file_query, None, file_opts);
if let (Some(top), Some(score)) =
(file_result.items.first(), file_result.scores.first())
{
// Only suggest when the match is strong enough.
let query_len = query.len() as i32;
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(picker)
))]));
}
}
}
return Ok(CallToolResult::success(vec![Content::text(
"0 matches.".to_string(),
)]));
}
if result.matches.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(
"0 matches.".to_string(),
)]));
}
let mut cs = self.lock_cursors()?;
let text = &GrepFormatter {
matches: &result.matches,
files: &result.files,
total_matched: result.matches.len(),
next_file_offset: result.next_file_offset,
regex_fallback_error: result.regex_fallback_error.as_deref(),
output_mode,
max_results,
show_context: ctx_lines > 0,
auto_expand_defs: auto_expand,
picker,
}
.format(&mut cs);
Ok(CallToolResult::success(vec![Content::text(text)]))
}
}
#[tool_router]
impl FffServer {
/// Fuzzy file search by name. Searches FILE NAMES, not file contents.
/// Use it when you need to find a file, not a definition.
/// Use grep instead for searching code content (definitions, usage patterns).
/// Supports fuzzy matching, path prefixes ('shc/'), and glob constraints.
/// IMPORTANT: Keep queries SHORT — prefer 1-2 terms max.
#[tool(
name = "find_files",
description = "Fuzzy file search by name. Searches FILE NAMES, not file contents. Use it when you need to find a file, not a definition. Use grep instead for searching code content (definitions, usage patterns). Supports fuzzy matching, path prefixes ('src/'), and glob constraints ('name **/src/*.{ts,tsx} !test/'). IMPORTANT: Keep queries SHORT — prefer 1-2 terms max. Multiple words are a waterfall (each narrows results), NOT OR. If unsure, start broad with 1 term and refine."
)]
fn find_files(
&self,
Parameters(params): Parameters<FindFilesParams>,
) -> Result<CallToolResult, ErrorData> {
let max_results = params.max_results.unwrap_or(20.0).round() as usize; // safe
let query = &params.query;
let page_offset = params
.cursor
.as_deref()
.and_then(|id| self.cursor_store.lock().ok()?.get(id))
.unwrap_or(0);
let guard = self.picker.read().map_err(|e| {
ErrorData::internal_error(format!("Failed to acquire picker lock: {e}"), None)
})?;
let picker = guard
.as_ref()
.ok_or_else(|| ErrorData::internal_error("File picker not initialized", None))?;
let base_path = picker.base_path();
let make_opts = |offset: usize| FuzzySearchOptions {
max_threads: 0,
current_file: None,
project_path: Some(base_path),
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset,
limit: max_results,
},
};
let parser = QueryParser::default();
let fff_query = parser.parse(query);
let result = picker.fuzzy_search(&fff_query, None, make_opts(page_offset));
let total_files = result.total_files;
// Auto-retry with fewer terms if 3+ words return 0 results
let words: Vec<&str> = query.split_whitespace().collect();
let shorter = words.get(..2).map(|w| w.join(" "));
let (items, scores, total_matched) =
if result.items.is_empty() && words.len() >= 3 && page_offset == 0 {
if let Some(shorter) = &shorter {
let shorter_query = parser.parse(shorter);
let retry = picker.fuzzy_search(&shorter_query, None, make_opts(0));
(retry.items, retry.scores, retry.total_matched)
} else {
(result.items, result.scores, result.total_matched)
}
} else {
(result.items, result.scores, result.total_matched)
};
if items.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(format!(
"0 results ({} indexed)",
total_files
))]));
}
let mut lines: Vec<String> = Vec::new();
let top_item = items[0];
let is_exact_match = scores[0].exact_match;
if page_offset == 0 {
if is_exact_match {
lines.push(format!(
"→ Read {} (exact match!)",
top_item.relative_path(picker)
));
} else if scores.len() < 2 || scores[0].total > scores[1].total.saturating_mul(2) {
lines.push(format!(
"→ Read {} (best match — Read this file directly)",
top_item.relative_path(picker)
));
}
}
let next_offset = page_offset + items.len();
let has_more = next_offset < total_matched;
if has_more {
lines.push(format!("{}/{} matches", items.len(), total_matched));
}
for item in &items {
lines.push(format!(
"{}{}",
item.relative_path(picker),
file_suffix(item.git_status, item.total_frecency_score())
));
}
if has_more {
let mut cs = self.lock_cursors()?;
let cursor_id = cs.store(next_offset);
lines.push(format!("cursor: {}", cursor_id));
}
let mut result = CallToolResult::success(vec![Content::text(lines.join("\n"))]);
self.maybe_append_update_notice(&mut result);
Ok(result)
}
/// Search file contents for text patterns. This is the DEFAULT search tool.
/// Prefer plain text over regex. Filter files with constraints.
#[tool(
name = "grep",
description = "Search file contents. Search for bare identifiers (e.g. 'InProgressQuote', 'ActorAuth'), NOT code syntax or regex. Filter files with constraints (e.g. '*.rs query', 'src/ query'). Use filename, directory (ending with /) or glob expressions to prefilter. See server instructions for constraint syntax and core rules."
)]
fn grep(
&self,
Parameters(params): Parameters<GrepParams>,
) -> Result<CallToolResult, ErrorData> {
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);
let grep_text = parsed.grep_text();
let mode = if has_regex_metacharacters(&grep_text) {
GrepMode::Regex
} else {
GrepMode::PlainText
};
let mut result = self.perform_grep(
&params.query,
mode,
max_results,
params.cursor.as_deref(),
output_mode,
None,
)?;
self.maybe_append_update_notice(&mut result);
Ok(result)
}
/// Search file contents for lines matching ANY of multiple patterns (OR logic).
/// Patterns are literal text — NEVER escape special characters.
#[tool(
name = "multi_grep",
description = "Search file contents for lines matching ANY of multiple patterns (OR logic). IMPORTANT: This returns files where ANY query matches, NOT all patterns. Patterns are literal text — NEVER escape special characters (no \\( \\) \\. etc). Faster than regex alternation for literal text. See server instructions for constraint syntax."
)]
fn multi_grep(
&self,
Parameters(params): Parameters<MultiGrepParams>,
) -> Result<CallToolResult, ErrorData> {
let mut result = self.multi_grep_inner(params)?;
self.maybe_append_update_notice(&mut result);
Ok(result)
}
}
impl FffServer {
fn multi_grep_inner(&self, params: MultiGrepParams) -> Result<CallToolResult, ErrorData> {
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
.cursor
.as_deref()
.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, context);
let ctx_lines = options.before_context;
let constraint_query = params.constraints.as_deref().unwrap_or("");
let guard = self.picker.read().map_err(|e| {
ErrorData::internal_error(format!("Failed to acquire picker lock: {e}"), None)
})?;
let picker = guard
.as_ref()
.ok_or_else(|| ErrorData::internal_error("File picker not initialized", None))?;
let patterns_refs: Vec<&str> = params.patterns.iter().map(|s| s.as_str()).collect();
let parser = fff_query_parser::QueryParser::new(fff_query_parser::AiGrepConfig);
let parsed_constraints = parser.parse(constraint_query);
let constraints = parsed_constraints.constraints.as_slice();
let result = picker.multi_grep(&patterns_refs, constraints, &options);
let file_refs: Vec<&FileItem> = result.files.to_vec();
if result.matches.is_empty() && file_offset == 0 {
// Fallback: try individual patterns with plain grep
let (fallback_options, _) =
make_grep_options(output_mode, GrepMode::PlainText, 0, context);
let fallback_options = GrepSearchOptions {
time_budget_ms: 3000,
before_context: 0,
..fallback_options
};
for pat in &params.patterns {
let full_query: Cow<str> = if !constraint_query.is_empty() {
Cow::Owned(format!("{} {}", constraint_query, pat))
} else {
Cow::Borrowed(pat)
};
let parsed = parser.parse(&full_query);
let fb_result = picker.grep(&parsed, &fallback_options);
if !fb_result.matches.is_empty() {
let fb_file_refs: Vec<&FileItem> = fb_result.files.to_vec();
let mut cs = self.lock_cursors()?;
let text = &GrepFormatter {
matches: &fb_result.matches,
files: &fb_file_refs,
total_matched: fb_result.matches.len(),
next_file_offset: fb_result.next_file_offset,
regex_fallback_error: None,
output_mode,
max_results,
show_context: false,
auto_expand_defs: auto_expand,
picker,
}
.format(&mut cs);
return Ok(CallToolResult::success(vec![Content::text(format!(
"0 multi-pattern matches. Plain grep fallback for \"{}\":\n{}",
pat, text
))]));
}
}
return Ok(CallToolResult::success(vec![Content::text(
"0 matches.".to_string(),
)]));
}
if result.matches.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(
"0 matches.".to_string(),
)]));
}
let mut cs = self.lock_cursors()?;
let text = &GrepFormatter {
matches: &result.matches,
files: &file_refs,
total_matched: result.matches.len(),
next_file_offset: result.next_file_offset,
regex_fallback_error: None,
output_mode,
max_results,
show_context: ctx_lines > 0,
auto_expand_defs: auto_expand,
picker,
}
.format(&mut cs);
Ok(CallToolResult::success(vec![Content::text(text)]))
}
}
#[tool_handler]
impl ServerHandler for FffServer {
fn get_info(&self) -> ServerInfo {
let notice = crate::update_check::get_update_notice();
let instructions = if notice.is_empty() {
crate::MCP_INSTRUCTIONS.to_string()
} else {
format!("{}{}", crate::MCP_INSTRUCTIONS, notice)
};
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new("fff", env!("CARGO_PKG_VERSION")))
.with_instructions(instructions)
}
}
+78
View File
@@ -0,0 +1,78 @@
//! Background update checker — compares the embedded build hash against
//! the latest GitHub release tag to surface upgrade notices in MCP instructions.
use std::sync::OnceLock;
const REPO: &str = "dmtrKovalenko/fff.nvim";
const BUILD_HASH: &str = env!("FFF_GIT_HASH");
/// Holds the result of the update check (empty string = up to date or check failed).
static UPDATE_NOTICE: OnceLock<String> = OnceLock::new();
/// Returns the update notice if the check has completed, empty string otherwise.
pub fn get_update_notice() -> &'static str {
UPDATE_NOTICE.get().map(|s| s.as_str()).unwrap_or("")
}
/// Kick off the update check in a background thread so it never blocks the server.
pub fn spawn_update_check() {
std::thread::spawn(|| {
let notice = check_latest_release();
let _ = UPDATE_NOTICE.set(notice);
});
}
/// Fetch the latest release tag from GitHub and compare against the build hash.
fn check_latest_release() -> String {
match fetch_latest_tag() {
Ok(tag) => compare_versions(BUILD_HASH, &tag),
Err(_) => String::new(),
}
}
/// Compare a build hash against a release tag.
/// Returns an update notice string, or empty if up-to-date.
fn compare_versions(build_hash: &str, release_tag: &str) -> String {
let tag = release_tag.trim();
if tag.is_empty() || build_hash == "unknown" {
return String::new();
}
let our_short = &build_hash[..build_hash.len().min(tag.len())];
if our_short == tag {
return String::new();
}
format!(
"\n[fff update available: `curl -fsSL https://raw.githubusercontent.com/{REPO}/main/install-mcp.sh | bash`]\n"
)
}
/// Shell out to curl to fetch the latest release tag name from GitHub API.
fn fetch_latest_tag() -> Result<String, Box<dyn std::error::Error>> {
let output = std::process::Command::new("curl")
.args([
"-fsSL",
"--max-time",
"5",
"-H",
"Accept: application/vnd.github.v3+json",
&format!("https://api.github.com/repos/{REPO}/releases?per_page=1"),
])
.output()?;
if !output.status.success() {
return Err("curl failed".into());
}
let body = String::from_utf8(output.stdout)?;
let releases: Vec<serde_json::Value> = serde_json::from_str(&body)?;
let tag = releases
.first()
.and_then(|r| r.get("tag_name"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Ok(tag)
}
+39 -13
View File
@@ -1,18 +1,22 @@
[package]
name = "fff-nvim"
version = "0.1.0"
version = "0.5.2"
edition = "2024"
[lib]
path = "src/lib.rs"
crate-type = ["cdylib", "rlib"]
[features]
default = []
zlob = ["fff/zlob"]
[[bin]]
name = "test_watcher"
path = "src/bin/test_watcher.rs"
[[bin]]
name = "jemalloc_profile"
name = "jemalloc_profile"
path = "src/bin/jemalloc_profile.rs"
[[bin]]
@@ -23,6 +27,30 @@ path = "src/bin/search_profiler.rs"
name = "bench_search_only"
path = "src/bin/bench_search_only.rs"
[[bin]]
name = "grep_profiler"
path = "src/bin/grep_profiler.rs"
[[bin]]
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 }
@@ -32,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"
@@ -46,16 +76,13 @@ heed = "0.22.0"
ignore = "0.4.22"
mimalloc = "0.1.47"
mlua = { version = "0.11.1", features = ["module", "luajit"] }
neo_frizbee = { version = "0.7.2" }
neo_frizbee = { workspace = true }
notify = "8.1.0"
notify-debouncer-full = "0.6"
once_cell = "1.20.2"
pathdiff = "0.2.1"
serde = { version = "1.0", features = ["derive"] }
smartstring = { version = "1.0.1", features = ["serde"] }
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
zlob = "1.2.8"
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
@@ -67,10 +94,9 @@ name = "indexing_and_search"
harness = false
[[bench]]
name = "query_tracker_bench"
name = "grep_bench"
harness = false
# Platform-specific: Use vendored OpenSSL on non-Windows (Linux, macOS)
# On Windows, git2 uses the native SChannel TLS backend
[target.'cfg(not(windows))'.dependencies]
openssl = { version = "0.10", features = ["vendored"] }
[[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);
+219 -158
View File
@@ -1,7 +1,10 @@
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use fff_nvim::FILE_PICKER;
use fff_nvim::file_picker::{FilePicker, FuzzySearchOptions};
use fff_nvim::types::PaginationArgs;
use fff::file_picker::{FFFMode, FilePicker};
use fff::types::PaginationArgs;
use fff::{
FilePickerOptions, FuzzySearchOptions, GrepMode, GrepSearchOptions, QueryParser,
SharedFrecency, SharedPicker,
};
use std::path::PathBuf;
use std::time::Duration;
@@ -19,20 +22,30 @@ fn init_tracing() {
// .try_init();
}
/// Initialize FilePicker and insert into global state
fn init_file_picker_internal(path: &str) -> Result<(), String> {
let picker = FilePicker::new(path.to_string())
.map_err(|e| format!("Failed to create FilePicker: {:?}", e))?;
let mut picker_guard = FILE_PICKER
.write()
.map_err(|_| "Failed to acquire write lock")?;
*picker_guard = Some(picker);
Ok(())
/// Initialize FilePicker using shared state
fn init_file_picker_internal(
path: &str,
shared_picker: &SharedPicker,
shared_frecency: &SharedFrecency,
) -> Result<(), String> {
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: path.to_string(),
enable_mmap_cache: false,
mode: FFFMode::Neovim,
..Default::default()
},
)
.map_err(|e| format!("Failed to create FilePicker: {:?}", e))
}
/// Helper function to wait for scanning to complete and get file count
fn wait_for_scan_completion(timeout_secs: u64) -> Result<usize, String> {
fn wait_for_scan_completion(
shared_picker: &SharedPicker,
timeout_secs: u64,
) -> Result<usize, String> {
let start = std::time::Instant::now();
let timeout = Duration::from_secs(timeout_secs);
let mut last_log = std::time::Instant::now();
@@ -42,7 +55,7 @@ fn wait_for_scan_completion(timeout_secs: u64) -> Result<usize, String> {
iteration += 1;
{
let picker_guard = FILE_PICKER
let picker_guard = shared_picker
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
@@ -91,29 +104,17 @@ fn wait_for_scan_completion(timeout_secs: u64) -> Result<usize, String> {
}
}
/// Get files from the global FILE_PICKER
fn get_files_snapshot() -> Result<Vec<fff_nvim::types::FileItem>, String> {
let picker_guard = FILE_PICKER
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
Ok(picker.get_files().to_vec())
} else {
Err("FilePicker not initialized".to_string())
}
}
/// Clean up global state
fn cleanup_global_state() {
if let Ok(mut picker_guard) = FILE_PICKER.write() {
/// Clean up shared state
fn cleanup_shared_state(shared_picker: &SharedPicker) {
if let Ok(mut picker_guard) = shared_picker.write() {
if let Some(mut picker) = picker_guard.take() {
picker.stop_background_monitor();
}
}
}
/// Initialize FilePicker once and return files snapshot
fn setup_once() -> Result<Vec<fff_nvim::types::FileItem>, String> {
/// Initialize FilePicker once and return shared state
fn setup_once() -> Result<(SharedPicker, SharedFrecency), String> {
init_tracing();
let big_repo_path = PathBuf::from("./big-repo");
@@ -121,37 +122,27 @@ fn setup_once() -> Result<Vec<fff_nvim::types::FileItem>, 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 = big_repo_path
.canonicalize()
let canonical_path = fff::path_utils::canonicalize(&big_repo_path)
.map_err(|e| format!("Failed to canonicalize path: {}", e))?;
eprintln!(" Path: {:?}", canonical_path);
{
let picker_guard = FILE_PICKER
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
let files = picker.get_files();
if !files.is_empty() {
eprintln!(" Reusing existing index with {} files", files.len());
return Ok(files.to_vec());
}
}
}
let shared_picker = SharedPicker::default();
let shared_frecency = SharedFrecency::default();
cleanup_global_state();
std::thread::sleep(Duration::from_millis(500));
init_file_picker_internal(&canonical_path.to_string_lossy())?;
init_file_picker_internal(
&canonical_path.to_string_lossy(),
&shared_picker,
&shared_frecency,
)?;
eprintln!(" Waiting for background scan to complete...");
let file_count = wait_for_scan_completion(120)?;
let file_count = wait_for_scan_completion(&shared_picker, 120)?;
eprintln!(
" ✓ Indexed {} files (will be reused for all benchmarks)\n",
file_count
);
get_files_snapshot()
Ok((shared_picker, shared_frecency))
}
/// Benchmark for indexing the big-repo directory
@@ -166,7 +157,7 @@ fn bench_indexing(c: &mut Criterion) {
return;
}
let canonical_path = match big_repo_path.canonicalize() {
let canonical_path = match fff::path_utils::canonicalize(&big_repo_path) {
Ok(p) => p,
Err(e) => {
eprintln!("⚠ Failed to canonicalize path: {}", e);
@@ -180,21 +171,23 @@ fn bench_indexing(c: &mut Criterion) {
group.bench_function("index_big_repo", |b| {
b.iter(|| {
cleanup_global_state();
std::thread::sleep(Duration::from_millis(500));
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()))
init_file_picker_internal(black_box(&canonical_path.to_string_lossy()), &sp, &sf)
.expect("Failed to init FilePicker");
match wait_for_scan_completion(120) {
match wait_for_scan_completion(&sp, 120) {
Ok(file_count) => {
let elapsed = start.elapsed();
eprintln!(" ✓ Indexed {} files in {:?}", file_count, elapsed);
cleanup_shared_state(&sp);
file_count
}
Err(e) => {
eprintln!(" ✗ Error: {}", e);
cleanup_shared_state(&sp);
0
}
}
@@ -206,14 +199,17 @@ fn bench_indexing(c: &mut Criterion) {
/// Benchmark for searching with various query patterns
fn bench_search_queries(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
let (sp, _sf) = match setup_once() {
Ok(result) => result,
Err(e) => {
eprint!("Failed to setup picker {e:?}");
return;
}
};
let guard = sp.read().unwrap();
let picker = guard.as_ref().unwrap();
let mut group = c.benchmark_group("search");
group.sample_size(100);
@@ -225,18 +221,20 @@ fn bench_search_queries(c: &mut Criterion) {
("partial", "src/lib"),
];
let parser = QueryParser::default();
for (name, query) in test_queries {
group.bench_with_input(BenchmarkId::new("query", name), &query, |b, &query| {
let parsed = parser.parse(query);
group.bench_with_input(BenchmarkId::new("query", name), &query, |b, &_query| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
let results = picker.fuzzy_search(
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 {
@@ -255,18 +253,23 @@ fn bench_search_queries(c: &mut Criterion) {
/// Benchmark search with different thread counts
fn bench_search_thread_scaling(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
let (sp, _sf) = match setup_once() {
Ok(result) => result,
Err(e) => {
eprintln!("Skipping thread scaling benchmarks: {}", e);
eprintln!("Skipping thread scaling benchmarks: {}", e);
return;
}
};
let guard = sp.read().unwrap();
let picker = guard.as_ref().unwrap();
let mut group = c.benchmark_group("thread_scaling");
group.sample_size(100);
let query = "controller";
let parser = QueryParser::default();
let parsed = parser.parse(query);
let thread_counts = vec![1, 2, 4, 8];
for threads in thread_counts {
@@ -275,15 +278,14 @@ fn bench_search_thread_scaling(c: &mut Criterion) {
&threads,
|b, &threads| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
let results = picker.fuzzy_search(
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 {
@@ -303,38 +305,39 @@ fn bench_search_thread_scaling(c: &mut Criterion) {
/// Benchmark search with different result limits
fn bench_search_result_limits(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
let (sp, _sf) = match setup_once() {
Ok(result) => result,
Err(e) => {
eprintln!("Skipping result limit benchmarks: {}", e);
eprintln!("Skipping result limit benchmarks: {}", e);
return;
}
};
let guard = sp.read().unwrap();
let picker = guard.as_ref().unwrap();
let mut group = c.benchmark_group("result_limits");
group.sample_size(100);
let query = "mod";
let parser = QueryParser::default();
let parsed = parser.parse(query);
let result_limits = vec![10, 50, 100, 500];
for limit in result_limits {
group.bench_with_input(BenchmarkId::from_parameter(limit), &limit, |b, &limit| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
let results = picker.fuzzy_search(
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 {
offset: 0,
limit: limit,
},
pagination: PaginationArgs { offset: 0, limit },
},
);
results.total_matched
@@ -345,20 +348,23 @@ fn bench_search_result_limits(c: &mut Criterion) {
group.finish();
}
/// Benchmark search algorithm performance scaling with file count
/// Benchmark search algorithm performance with queries of varying selectivity
fn bench_search_scalability(c: &mut Criterion) {
let all_files = match setup_once() {
Ok(files) => files,
let (sp, _sf) = match setup_once() {
Ok(result) => result,
Err(e) => {
eprintln!("Skipping scalability benchmarks: {}", e);
eprintln!("Skipping scalability benchmarks: {}", e);
return;
}
};
if all_files.len() < 1000 {
let guard = sp.read().unwrap();
let picker = guard.as_ref().unwrap();
if picker.get_files().len() < 1000 {
eprintln!(
"Skipping scalability benchmark: need at least 1000 files, got {}",
all_files.len()
"Skipping scalability benchmark: need at least 1000 files, got {}",
picker.get_files().len()
);
return;
}
@@ -366,26 +372,26 @@ fn bench_search_scalability(c: &mut Criterion) {
let mut group = c.benchmark_group("search_scalability");
group.sample_size(50);
let query = "controller";
let file_counts = vec![100, 1000, 5000, 10000, all_files.len().min(50000)];
let parser = QueryParser::default();
let selectivity_queries = vec![
("broad_a", "a"),
("medium_mod", "mod"),
("narrow_controller", "controller"),
("very_narrow_user_auth", "user_authentication"),
];
for count in file_counts {
if count > all_files.len() {
continue;
}
let subset = &all_files[..count];
group.bench_with_input(BenchmarkId::from_parameter(count), &count, |b, _| {
for (name, query) in selectivity_queries {
let parsed = parser.parse(query);
group.bench_with_input(BenchmarkId::from_parameter(name), &name, |b, _| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(subset),
black_box(query),
let results = picker.fuzzy_search(
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 {
@@ -404,31 +410,35 @@ fn bench_search_scalability(c: &mut Criterion) {
/// Benchmark search performance with different ordering modes
fn bench_search_ordering(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
let (sp, _sf) = match setup_once() {
Ok(result) => result,
Err(e) => {
eprintln!("Skipping ordering benchmarks: {}", e);
eprintln!("Skipping ordering benchmarks: {}", e);
return;
}
};
let guard = sp.read().unwrap();
let picker = guard.as_ref().unwrap();
let mut group = c.benchmark_group("ordering");
group.sample_size(100);
let query = "controller";
let parser = QueryParser::default();
let parsed_controller = parser.parse("controller");
let parsed_mod = parser.parse("mod");
// Benchmark normal order (descending)
group.bench_function("normal_order", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
let results = picker.fuzzy_search(
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 {
@@ -444,15 +454,14 @@ fn bench_search_ordering(c: &mut Criterion) {
// Benchmark reverse order (ascending)
group.bench_function("reverse_order", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
let results = picker.fuzzy_search(
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 {
@@ -468,15 +477,14 @@ fn bench_search_ordering(c: &mut Criterion) {
// Benchmark with large result set
group.bench_function("normal_order_large", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("mod"),
let results = picker.fuzzy_search(
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 {
@@ -491,15 +499,14 @@ fn bench_search_ordering(c: &mut Criterion) {
group.bench_function("reverse_order_large", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("mod"),
let results = picker.fuzzy_search(
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 {
@@ -515,15 +522,14 @@ fn bench_search_ordering(c: &mut Criterion) {
// Benchmark with small result set
group.bench_function("normal_order_small", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("controller"),
let results = picker.fuzzy_search(
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 {
@@ -538,15 +544,14 @@ fn bench_search_ordering(c: &mut Criterion) {
group.bench_function("reverse_order_small", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("controller"),
let results = picker.fuzzy_search(
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 {
@@ -564,32 +569,36 @@ fn bench_search_ordering(c: &mut Criterion) {
/// Benchmark pagination: first page vs deep page
fn bench_pagination_performance(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
let (sp, _sf) = match setup_once() {
Ok(result) => result,
Err(e) => {
eprintln!("Skipping pagination benchmarks: {}", e);
eprintln!("Skipping pagination benchmarks: {}", e);
return;
}
};
let guard = sp.read().unwrap();
let picker = guard.as_ref().unwrap();
let mut group = c.benchmark_group("pagination");
group.sample_size(100);
let query = "mod";
let parser = QueryParser::default();
let parsed = parser.parse(query);
let page_size = 40;
// Benchmark first page (uses partial sort optimization)
group.bench_function("page_0_size_40", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
let results = picker.fuzzy_search(
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 {
@@ -605,15 +614,14 @@ fn bench_pagination_performance(c: &mut Criterion) {
// Benchmark 10th page (requires full sort, no optimization)
group.bench_function("page_10_size_40", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
let results = picker.fuzzy_search(
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 {
@@ -629,15 +637,14 @@ fn bench_pagination_performance(c: &mut Criterion) {
// Benchmark 50th page (even deeper pagination)
group.bench_function("page_50_size_40", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
let results = picker.fuzzy_search(
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 {
@@ -653,6 +660,59 @@ fn bench_pagination_performance(c: &mut Criterion) {
group.finish();
}
/// Benchmark grep search via the FilePicker public API
fn bench_grep_search(c: &mut Criterion) {
let (sp, _sf) = match setup_once() {
Ok(result) => result,
Err(e) => {
eprintln!("Skipping grep benchmarks: {}", e);
return;
}
};
let guard = sp.read().unwrap();
let picker = guard.as_ref().unwrap();
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,
abort_signal: None,
};
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);
group.bench_with_input(BenchmarkId::new("grep", name), query, |b, _| {
b.iter(|| {
let result = picker.grep(black_box(&parsed), black_box(&options));
result.matches.len()
});
});
}
group.finish();
}
criterion_group!(
benches,
bench_indexing,
@@ -662,6 +722,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_nvim::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,
);
}
+138
View File
@@ -0,0 +1,138 @@
/// 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"
use fff::file_picker::FilePicker;
use fff::grep::{GrepMode, GrepSearchOptions, parse_grep_query};
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!("{}us", us)
}
}
fn run_grep(picker: &FilePicker, 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,
abort_signal: None,
};
let parsed = parse_grep_query(query);
let mut times_us = Vec::with_capacity(iters);
for i in 0..iters {
let t = Instant::now();
let result = picker.grep(&parsed, &options);
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 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_grep_query --path <dir> --query <text> [--iters N]");
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/2] Scanning files... ");
let t = Instant::now();
let mut picker = FilePicker::new(fff::FilePickerOptions {
base_path: canonical.to_string_lossy().to_string(),
enable_mmap_cache: false,
mode: fff::FFFMode::Neovim,
..Default::default()
})
.expect("Failed to create FilePicker");
picker.collect_files().expect("Failed to collect files");
let files = picker.get_files();
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,
);
// ── 2. Grep ───────────────────────────────────────────────────────
eprintln!(
"\n[2/2] Running grep \"{}\" x {} iterations\n",
query, iters
);
run_grep(&picker, query, iters);
}
+21 -48
View File
@@ -1,8 +1,20 @@
/// 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::{FuzzySearchOptions, PaginationArgs, QueryParser};
use std::time::Instant;
fn load_picker(path: &std::path::Path) -> FilePicker {
let mut picker = FilePicker::new(fff::FilePickerOptions {
base_path: path.to_string_lossy().to_string(),
enable_mmap_cache: false,
mode: fff::FFFMode::Neovim,
..Default::default()
})
.expect("Failed to create FilePicker");
picker.collect_files().expect("Failed to collect files");
picker
}
fn main() {
let big_repo_path = std::path::PathBuf::from("./big-repo");
@@ -13,52 +25,16 @@ fn main() {
return;
}
let canonical_path = big_repo_path
.canonicalize()
.expect("Failed to canonicalize path");
let canonical_path =
fff::path_utils::canonicalize(&big_repo_path).expect("Failed to canonicalize path");
eprintln!("Loading files from: {:?}", canonical_path);
// Directly scan without background thread
let start = Instant::now();
let files = {
use ignore::WalkBuilder;
let mut files = Vec::new();
WalkBuilder::new(&canonical_path)
.hidden(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, &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();
files.push(FileItem {
path,
relative_path_lower: relative_path.to_lowercase(),
relative_path,
file_name_lower: file_name.to_lowercase(),
file_name,
size: entry.metadata().ok().map_or(0, |m| m.len()),
modified: 0,
access_frecency_score: 0,
modification_frecency_score: 0,
total_frecency_score: 0,
git_status: None,
});
});
files
};
let picker = load_picker(&canonical_path);
eprintln!(
"✓ Loaded {} files in {:.2}s\n",
files.len(),
picker.get_files().len(),
start.elapsed().as_secs_f64()
);
@@ -69,7 +45,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),
@@ -90,15 +65,13 @@ fn main() {
for _ in 0..iterations {
let parser = QueryParser::default();
let parsed = parser.parse(query);
let results = FilePicker::fuzzy_search(
&files,
query,
parsed,
let results = picker.fuzzy_search(
&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 {
+183
View File
@@ -0,0 +1,183 @@
/// Fuzzy grep quality test against ~/dev/lightsource
///
/// Runs queries through the fuzzy grep pipeline and prints results
/// so we can verify match quality.
///
/// 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::file_picker::FilePicker;
use fff::grep::{GrepMode, GrepSearchOptions, parse_grep_query};
use std::path::Path;
use std::time::Instant;
fn create_picker(path: &Path) -> FilePicker {
let mut picker = FilePicker::new(fff::FilePickerOptions {
base_path: path.to_string_lossy().to_string(),
enable_mmap_cache: false,
mode: fff::FFFMode::Neovim,
..Default::default()
})
.expect("Failed to create FilePicker");
picker.collect_files().expect("Failed to collect files");
picker
}
fn run_fuzzy_query(picker: &FilePicker, query: &str, label: &str) {
let options = GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 200,
smart_case: true,
file_offset: 0,
page_limit: 100,
mode: GrepMode::Fuzzy,
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
abort_signal: None,
};
let parsed = parse_grep_query(query);
let start = Instant::now();
let result = picker.grep(&parsed, &options);
let elapsed = start.elapsed();
eprintln!("══════════════════════════════════════════════════════════════");
eprintln!(" Query: \"{}\" ({})", query, label);
eprintln!(
" Results: {} matches in {} files ({:.2}ms)",
result.matches.len(),
result.total_files_searched,
elapsed.as_secs_f64() * 1000.0,
);
eprintln!("══════════════════════════════════════════════════════════════");
if result.matches.is_empty() {
eprintln!(" (no matches)\n");
return;
}
// Group by file for readability
let mut current_file_idx = usize::MAX;
for (i, m) in result.matches.iter().enumerate() {
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(picker));
}
// Truncate long lines for display
let display_line = if m.line_content.len() > 100 {
format!("{}...", &m.line_content[..100])
} else {
m.line_content.clone()
};
let score_str = m
.fuzzy_score
.map(|s| format!("score={}", s))
.unwrap_or_else(|| "no-score".to_string());
let offsets_str = if m.match_byte_offsets.is_empty() {
String::new()
} else {
// Show what text fragments are highlighted
let fragments: Vec<String> = m
.match_byte_offsets
.iter()
.filter_map(|&(s, e)| {
m.line_content
.get(s as usize..e as usize)
.map(|frag| format!("\"{}\"", frag))
})
.collect();
format!(" hl=[{}]", fragments.join(","))
};
eprintln!(
" │ L{:<5} [{}{}] {}",
m.line_number,
score_str,
offsets_str,
display_line.trim(),
);
// Cap output at 50 lines
if i >= 49 {
let remaining = result.matches.len() - 50;
if remaining > 0 {
eprintln!(" │ ... and {} more matches", remaining);
}
break;
}
}
eprintln!();
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
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::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 picker = create_picker(&canonical);
let files = picker.get_files();
let non_binary = files.iter().filter(|f| !f.is_binary()).count();
eprintln!(
"Loaded {} files ({} non-binary) in {:.2}s\n",
files.len(),
non_binary,
load_start.elapsed().as_secs_f64()
);
if queries.is_empty() {
// Run default test queries
run_fuzzy_query(&picker, "shcema", "transposition of 'schema'");
run_fuzzy_query(&picker, "SortedMap", "should match SortedArrayMap");
run_fuzzy_query(
&picker,
"struct SortedMap",
"should NOT match SourcingProjectMetadataParts",
);
} else {
// Run user-provided queries
for query in &queries {
run_fuzzy_query(&picker, query, "user query");
}
}
eprintln!("=== Done ===");
}
+453
View File
@@ -0,0 +1,453 @@
/// Live grep benchmark profiler for fff.nvim
///
/// Benchmarks the full grep pipeline against a large repository (Linux kernel).
/// Measures cold-cache, warm-cache, and incremental typing latencies to simulate
/// real user interaction patterns.
///
/// Uses FilePicker::collect_files for synchronous scanning (no background thread).
///
/// Usage:
/// cargo build --release --bin grep_profiler
/// ./target/release/grep_profiler [--path /path/to/repo]
use fff::file_picker::FilePicker;
use fff::grep::{GrepMode, GrepSearchOptions, parse_grep_query};
use std::time::{Duration, Instant};
fn create_picker(path: &std::path::Path) -> FilePicker {
let mut picker = FilePicker::new(fff::FilePickerOptions {
base_path: path.to_string_lossy().to_string(),
enable_mmap_cache: false,
mode: fff::FFFMode::Neovim,
..Default::default()
})
.expect("Failed to create FilePicker");
picker.collect_files().expect("Failed to collect files");
picker
}
struct BenchStats {
times: Vec<Duration>,
}
impl BenchStats {
fn new() -> Self {
Self { times: Vec::new() }
}
fn push(&mut self, d: Duration) {
self.times.push(d);
}
fn mean(&self) -> Duration {
let total: Duration = self.times.iter().sum();
total / self.times.len() as u32
}
fn median(&self) -> Duration {
let mut sorted = self.times.clone();
sorted.sort();
sorted[sorted.len() / 2]
}
fn p95(&self) -> Duration {
let mut sorted = self.times.clone();
sorted.sort();
let idx = ((sorted.len() as f64) * 0.95) as usize;
sorted[idx.min(sorted.len() - 1)]
}
fn p99(&self) -> Duration {
let mut sorted = self.times.clone();
sorted.sort();
let idx = ((sorted.len() as f64) * 0.99) as usize;
sorted[idx.min(sorted.len() - 1)]
}
fn min(&self) -> Duration {
*self.times.iter().min().unwrap()
}
fn max(&self) -> Duration {
*self.times.iter().max().unwrap()
}
}
struct GrepBench<'a> {
picker: &'a FilePicker,
options: GrepSearchOptions,
}
impl<'a> GrepBench<'a> {
fn new(picker: &'a FilePicker) -> Self {
Self::with_mode(picker, GrepMode::PlainText)
}
fn with_mode(picker: &'a FilePicker, mode: GrepMode) -> Self {
Self {
picker,
options: GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 200,
smart_case: true,
file_offset: 0,
page_limit: 50,
mode,
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
abort_signal: None,
},
}
}
/// 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 = self.picker.grep(&parsed, &self.options);
let elapsed = start.elapsed();
(elapsed, result.matches.len(), result.total_files_searched)
}
/// Benchmark a query with multiple iterations
fn bench_query(&self, query: &str, iterations: usize) -> (BenchStats, usize, usize) {
let mut stats = BenchStats::new();
let mut last_matches = 0;
let mut last_files_searched = 0;
for _ in 0..iterations {
let (elapsed, matches, files_searched) = self.run_once(query);
stats.push(elapsed);
last_matches = matches;
last_files_searched = files_searched;
}
(stats, last_matches, last_files_searched)
}
}
fn fmt_dur(d: Duration) -> String {
let us = d.as_micros();
if us > 1_000_000 {
format!("{:.2}s", d.as_secs_f64())
} else if us > 1000 {
format!("{:.2}ms", us as f64 / 1000.0)
} else {
format!("{}us", us)
}
}
fn print_row(name: &str, stats: &BenchStats, matches: usize, files_searched: usize, iters: usize) {
eprintln!(
" {:<24} | {:>8} | {:>8} | {:>8} | {:>8} | {:>8} | {:>8} | {:>6} | {:>6} | {:>4}",
name,
fmt_dur(stats.mean()),
fmt_dur(stats.median()),
fmt_dur(stats.p95()),
fmt_dur(stats.p99()),
fmt_dur(stats.min()),
fmt_dur(stats.max()),
matches,
files_searched,
iters,
);
}
fn print_header() {
eprintln!(
" {:<24} | {:>8} | {:>8} | {:>8} | {:>8} | {:>8} | {:>8} | {:>6} | {:>6} | {:>4}",
"Name", "Mean", "Median", "P95", "P99", "Min", "Max", "Match", "Files", "Iter"
);
eprintln!(
" {:-<24}-+-{:-<8}-+-{:-<8}-+-{:-<8}-+-{:-<8}-+-{:-<8}-+-{:-<8}-+-{:-<6}-+-{:-<6}-+-{:-<4}",
"", "", "", "", "", "", "", "", "", ""
);
}
fn main() {
// Parse args
let args: Vec<String> = std::env::args().collect();
let repo_path = if let Some(idx) = args.iter().position(|a| a == "--path") {
args.get(idx + 1)
.map(|s| s.as_str())
.unwrap_or("./big-repo")
} else {
"./big-repo"
};
let repo = std::path::PathBuf::from(repo_path);
if !repo.exists() {
eprintln!("Repository not found at: {}", repo_path);
eprintln!("Usage: grep_profiler [--path /path/to/large/repo]");
std::process::exit(1);
}
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 picker = create_picker(&canonical);
let load_time = load_start.elapsed();
let files = picker.get_files();
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",
files.len(),
load_time.as_secs_f64(),
non_binary,
large_files,
);
let bench = GrepBench::new(&picker);
eprintln!("[2/7] Cold cache benchmarks (first search, mmap not yet loaded)");
eprintln!(" Each query runs once with fresh FileItem mmaps.\n");
print_header();
let cold_queries: Vec<(&str, &str)> = vec![
("cold_common_2char", "if"),
("cold_common_word", "return"),
("cold_specific_func", "mutex_lock"),
("cold_struct_name", "inode_operations"),
("cold_define", "MODULE_LICENSE"),
("cold_rare_string", "phylink_ethtool"),
("cold_path_filter", "printk *.c"),
("cold_long_query", "static int __init"),
];
for (name, query) in &cold_queries {
// Re-load files to get fresh FileItems with no cached mmaps
let fresh_picker = create_picker(&canonical);
let fresh_bench = GrepBench::new(&fresh_picker);
let (stats, matches, files_searched) = fresh_bench.bench_query(query, 1);
print_row(name, &stats, matches, files_searched, 1);
}
eprintln!("\n[3/7] Warm cache benchmarks (plain text, mmap cache populated)");
eprintln!(" Running 3 warmup iterations, then measuring.\n");
print_header();
let warm_queries: Vec<(&str, &str, usize)> = vec![
("warm_2char", "if", 10),
("warm_common_word", "return", 10),
("warm_function_call", "mutex_lock", 15),
("warm_struct_name", "inode_operations", 15),
("warm_define", "MODULE_LICENSE", 15),
("warm_rare_string", "phylink_ethtool", 20),
("warm_include", "#include", 10),
("warm_comment", "TODO", 15),
("warm_type_decl", "struct file", 15),
("warm_error_path", "err = -EINVAL", 15),
("warm_long_pattern", "static int __init", 15),
("warm_very_common", "int", 10),
("warm_single_char", "x", 10),
("warm_path_constraint", "printk *.c", 15),
("warm_dir_constraint", "mutex /kernel/", 15),
];
// Warmup pass - populate mmap cache
for (_, query, _) in &warm_queries {
for _ in 0..3 {
bench.run_once(query);
}
}
for (name, query, iters) in &warm_queries {
let (stats, matches, files_searched) = bench.bench_query(query, *iters);
print_row(name, &stats, matches, files_searched, *iters);
}
// Bigram-related benchmarks are omitted: the bigram index is built
// asynchronously by the picker and is exercised through picker.grep().
// ── Fuzzy grep benchmarks ─────────────────────────────────────────────
eprintln!("\n[4/7] Fuzzy grep warm benchmarks");
eprintln!(" Running 3 warmup iterations, then measuring.\n");
print_header();
let fuzzy_bench = GrepBench::with_mode(&picker, GrepMode::Fuzzy);
let fuzzy_queries: Vec<(&str, &str, usize)> = vec![
("fuzzy_exact", "mutex_lock", 15),
("fuzzy_typo", "mutx_lock", 15),
("fuzzy_camel", "InodeOps", 15),
("fuzzy_abbrev", "sched_rt", 15),
("fuzzy_short", "kfr", 15),
("fuzzy_common", "return", 10),
("fuzzy_define", "MODULE_LICENSE", 15),
("fuzzy_struct", "file_operations", 15),
("fuzzy_long", "static_int_init", 15),
("fuzzy_path", "printk *.c", 15),
];
// Warmup
for (_, query, _) in &fuzzy_queries {
for _ in 0..3 {
fuzzy_bench.run_once(query);
}
}
for (name, query, iters) in &fuzzy_queries {
let (stats, matches, files_searched) = fuzzy_bench.bench_query(query, *iters);
print_row(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");
let fuzzy_typing_sequences: Vec<(&str, Vec<&str>)> = vec![
(
"mutex_lock",
vec![
"m",
"mu",
"mut",
"mute",
"mutex",
"mutex_",
"mutex_l",
"mutex_lo",
"mutex_loc",
"mutex_lock",
],
),
("printk", vec!["p", "pr", "pri", "prin", "print", "printk"]),
("kfree", vec!["k", "kf", "kfr", "kfre", "kfree"]),
];
for (name, sequence) in &fuzzy_typing_sequences {
eprintln!(" Typing '{}' ({} keystrokes):", name, sequence.len());
eprintln!(
" {:>16} | {:>8} | {:>6} | {:>6}",
"Query", "Latency", "Match", "Files"
);
eprintln!(" {:-<16}-+-{:-<8}-+-{:-<6}-+-{:-<6}", "", "", "", "");
for prefix in sequence {
let (elapsed, matches, files_searched) = fuzzy_bench.run_once(prefix);
eprintln!(
" {:>16} | {:>8} | {:>6} | {:>6}",
format!("\"{}\"", prefix),
fmt_dur(elapsed),
matches,
files_searched,
);
}
eprintln!();
}
eprintln!("[6/7] Incremental typing simulation (plain text)");
eprintln!(" Simulates user typing character by character.\n");
let bench = GrepBench::new(&picker);
let typing_sequences: Vec<(&str, Vec<&str>)> = vec![
(
"mutex_lock",
vec![
"m",
"mu",
"mut",
"mute",
"mutex",
"mutex_",
"mutex_l",
"mutex_lo",
"mutex_loc",
"mutex_lock",
],
),
("printk", vec!["p", "pr", "pri", "prin", "print", "printk"]),
("inode", vec!["i", "in", "ino", "inod", "inode"]),
("kfree", vec!["k", "kf", "kfr", "kfre", "kfree"]),
];
for (name, sequence) in &typing_sequences {
eprintln!(" Typing '{}' ({} keystrokes):", name, sequence.len());
eprintln!(
" {:>16} | {:>8} | {:>6} | {:>6}",
"Query", "Latency", "Match", "Files"
);
eprintln!(" {:-<16}-+-{:-<8}-+-{:-<6}-+-{:-<6}", "", "", "", "");
for prefix in sequence {
let (elapsed, matches, files_searched) = bench.run_once(prefix);
eprintln!(
" {:>16} | {:>8} | {:>6} | {:>6}",
format!("\"{}\"", prefix),
fmt_dur(elapsed),
matches,
files_searched,
);
}
eprintln!();
}
eprintln!("[7/7] Pagination benchmark");
eprintln!(" Testing page_offset performance for common query.\n");
let pagination_query = "return";
eprintln!(" Query: \"{}\"", pagination_query);
eprintln!(
" {:>6} | {:>12} | {:>8} | {:>6} | {:>12}",
"Page", "File offset", "Latency", "Matches", "Next offset"
);
eprintln!(
" {:-<6}-+-{:-<12}-+-{:-<8}-+-{:-<6}-+-{:-<12}",
"", "", "", "", ""
);
let mut file_offset = 0usize;
for page in 0..10 {
let parsed = parse_grep_query(pagination_query);
let opts = GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 200,
smart_case: true,
file_offset,
page_limit: 50,
mode: Default::default(),
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
abort_signal: None,
};
let start = Instant::now();
let result = picker.grep(&parsed, &opts);
let elapsed = start.elapsed();
eprintln!(
" {:>6} | {:>12} | {:>8} | {:>6} | {:>12}",
page,
file_offset,
fmt_dur(elapsed),
result.matches.len(),
result.next_file_offset,
);
if result.next_file_offset == 0 || result.matches.is_empty() {
eprintln!(" (no more results)");
break;
}
file_offset = result.next_file_offset;
}
eprintln!("\n=== Summary ===");
eprintln!(" Total indexed files: {}", files.len());
eprintln!(" Non-binary files: {}", non_binary);
eprintln!(" Files > 10MB (skipped): {}", large_files);
std::thread::sleep(Duration::from_millis(100));
eprintln!("\nDone. For perf profiling:");
eprintln!(" perf record -g --call-graph dwarf -F 999 ./target/release/grep_profiler");
eprintln!(" perf report --no-children");
}
+443
View File
@@ -0,0 +1,443 @@
use fff::file_picker::FilePicker;
/// FFF vs ripgrep comparison benchmark
///
/// Demonstrates why a persistent in-process search engine (fff) is fundamentally
/// faster than shelling out to ripgrep on every keystroke (telescope/fzf-lua).
///
/// Each query is run N iterations to show the real-world advantage:
/// - fff: pre-indexed files + cached mmaps = near-zero overhead per search
/// - rg: fork/exec + directory traversal + gitignore parsing + file opens per invocation
///
/// Sections:
/// 1. Raw engine speed — fff count-only vs rg --count-matches (N iterations)
/// 2. Full results — fff collect-all vs rg full line output (N iterations)
/// 3. First-page — fff paginated (50 results) vs rg telescope-style
/// (spawn, stream 50 lines, kill) — the real UI scenario (N iterations)
///
/// The rg commands use telescope's default vimgrep_arguments:
/// rg --color=never --no-heading --with-filename --line-number --column --smart-case
///
/// Usage:
/// cargo build --release --bin grep_vs_rg
/// ./target/release/grep_vs_rg [--path /path/to/repo] [--iters 5]
use fff::grep::{GrepSearchOptions, parse_grep_query};
use std::path::Path;
use std::process::Command;
use std::time::{Duration, Instant};
/// Number of times each query is repeated (overridable with --iters).
const DEFAULT_ITERS: usize = 5;
fn create_picker(path: &Path) -> FilePicker {
let mut picker = FilePicker::new(fff::FilePickerOptions {
base_path: path.to_string_lossy().to_string(),
enable_mmap_cache: false,
mode: fff::FFFMode::Neovim,
..Default::default()
})
.expect("Failed to create FilePicker");
picker.collect_files().expect("Failed to collect files");
picker
}
/// Telescope's default vimgrep_arguments applied to any rg command.
/// Also limits rg's thread count to match rayon's pool (fair comparison).
fn apply_telescope_args(cmd: &mut Command, threads: usize) {
cmd.arg("--color=never")
.arg("--no-heading")
.arg("--with-filename")
.arg("--line-number")
.arg("--column")
.arg("--smart-case")
.arg("--fixed-strings")
.arg("--max-filesize")
.arg("10M")
.arg("--threads")
.arg(threads.to_string());
}
/// Run ripgrep counting matches via --count-matches.
fn run_rg_count(
repo_path: &Path,
pattern: &str,
case_insensitive: bool,
threads: usize,
) -> (usize, Duration) {
let start = Instant::now();
let mut cmd = Command::new("rg");
cmd.arg("--count-matches").arg("--no-filename");
apply_telescope_args(&mut cmd, threads);
if case_insensitive {
cmd.arg("--ignore-case");
}
cmd.arg(pattern).current_dir(repo_path);
let output = cmd.output().expect("Failed to run rg");
let elapsed = start.elapsed();
let stdout = String::from_utf8_lossy(&output.stdout);
let count: usize = stdout
.lines()
.filter_map(|l| l.trim().parse::<usize>().ok())
.sum();
(count, elapsed)
}
/// Run ripgrep collecting full line output.
fn run_rg_lines(
repo_path: &Path,
pattern: &str,
case_insensitive: bool,
threads: usize,
) -> (usize, Duration) {
let start = Instant::now();
let mut cmd = Command::new("rg");
apply_telescope_args(&mut cmd, threads);
if case_insensitive {
cmd.arg("--ignore-case");
}
cmd.arg(pattern).current_dir(repo_path);
let output = cmd.output().expect("Failed to run rg");
let elapsed = start.elapsed();
let count = bytecount(&output.stdout, b'\n');
(count, elapsed)
}
/// Run ripgrep the way telescope/fzf-lua actually do it: spawn rg as a
/// streaming subprocess, read stdout line-by-line, and kill the process
/// after `limit` lines. This is the realistic "first page" scenario.
fn run_rg_page(
repo_path: &Path,
pattern: &str,
case_insensitive: bool,
limit: usize,
threads: usize,
) -> (usize, Duration) {
use std::io::{BufRead, BufReader};
use std::process::Stdio;
let start = Instant::now();
let mut rg_cmd = Command::new("rg");
apply_telescope_args(&mut rg_cmd, threads);
if case_insensitive {
rg_cmd.arg("--ignore-case");
}
rg_cmd
.arg(pattern)
.current_dir(repo_path)
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = rg_cmd.spawn().expect("Failed to spawn rg");
let stdout = child.stdout.take().expect("Failed to get rg stdout");
let reader = BufReader::new(stdout);
let mut count = 0;
for _line in reader.lines() {
if _line.is_err() {
break;
}
count += 1;
if count >= limit {
break;
}
}
// Kill rg immediately --- this is what telescope does when the picker
// closes or the query changes (plenary.job:shutdown).
let _ = child.kill();
let _ = child.wait();
let elapsed = start.elapsed();
(count, elapsed)
}
fn bytecount(bytes: &[u8], needle: u8) -> usize {
bytes.iter().filter(|&&b| b == needle).count()
}
/// fff full: collects all GrepMatch structs (what the UI uses).
fn run_fff_full(picker: &FilePicker, query: &str) -> (usize, Duration) {
let parsed = parse_grep_query(query);
let options = GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: usize::MAX,
smart_case: true,
file_offset: 0,
page_limit: usize::MAX,
mode: Default::default(),
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
abort_signal: None,
};
let start = Instant::now();
let result = picker.grep(&parsed, &options);
let elapsed = start.elapsed();
(result.matches.len(), elapsed)
}
/// fff paginated: first 50 results only (real UI scenario).
fn run_fff_page(picker: &FilePicker, query: &str) -> (usize, Duration) {
let parsed = parse_grep_query(query);
let options = GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 200,
smart_case: true,
file_offset: 0,
page_limit: 50,
mode: Default::default(),
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
abort_signal: None,
};
let start = Instant::now();
let result = picker.grep(&parsed, &options);
let elapsed = start.elapsed();
(result.matches.len(), elapsed)
}
#[allow(dead_code)]
struct IterStats {
min: Duration,
avg: Duration,
count: usize,
}
fn run_n<F: Fn() -> (usize, Duration)>(f: F, n: usize) -> IterStats {
let mut times = Vec::with_capacity(n);
let mut count = 0;
for _ in 0..n {
let (c, d) = f();
count = c;
times.push(d);
}
times.sort();
let min = times[0];
let avg = times.iter().sum::<Duration>() / n as u32;
IterStats { min, avg, count }
}
fn fmt_dur(d: Duration) -> String {
let us = d.as_micros();
if us > 1_000_000 {
format!("{:.2}s", d.as_secs_f64())
} else if us > 1000 {
format!("{:.1}ms", us as f64 / 1000.0)
} else {
format!("{}us", us)
}
}
fn ratio_str(a: Duration, b: Duration) -> String {
if a.is_zero() || b.is_zero() {
return "-".to_string();
}
let r = b.as_secs_f64() / a.as_secs_f64();
format!("{:.1}x", r)
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let repo_path = if let Some(idx) = args.iter().position(|a| a == "--path") {
args.get(idx + 1)
.map(|s| s.as_str())
.unwrap_or("./big-repo")
} else {
"./big-repo"
};
let iters = if let Some(idx) = args.iter().position(|a| a == "--iters") {
args.get(idx + 1)
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_ITERS)
} else {
DEFAULT_ITERS
};
let repo = std::path::PathBuf::from(repo_path);
if !repo.exists() {
eprintln!("Repository not found at: {}", repo_path);
std::process::exit(1);
}
let canonical = fff::path_utils::canonicalize(&repo).expect("Failed to canonicalize path");
let rg_version = Command::new("rg")
.arg("--version")
.output()
.expect("ripgrep (rg) not found in PATH");
let rg_ver = String::from_utf8_lossy(&rg_version.stdout);
// Match rg's thread count to rayon's (both default to logical CPU count).
let threads = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4);
eprintln!("=== FFF vs ripgrep (telescope-style) ===");
eprintln!("Repo: {:?}", canonical);
eprintln!("rg: {}", rg_ver.lines().next().unwrap_or("?"));
eprintln!("Threads: {} (rg -j{} = rayon default)", threads, threads);
eprintln!("Iterations: {} per query", iters);
eprintln!();
eprintln!("[1/5] Indexing files...");
let picker = create_picker(&canonical);
let files = picker.get_files();
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)...");
for q in &["return", "mutex", "struct", "include", "if", "int"] {
let _ = run_fff_page(&picker, q);
let _ = run_rg_count(&canonical, q, true, threads);
}
eprintln!(" mmap cache: warmed\n");
// (name, query, case_insensitive_for_rg)
let queries: Vec<(&str, &str, bool)> = vec![
("single_char", "x", true),
("short_common", "if", true),
("very_common", "int", true),
("common_keyword", "return", true),
("preprocessor", "#include", true),
("function_call", "mutex_lock", true),
("multi_word", "static int __init", true),
("type_decl", "struct file", true),
("macro_define", "MODULE_LICENSE", false),
("kernel_api", "EXPORT_SYMBOL", false),
("error_path", "err = -EINVAL", false),
("comment_tag", "TODO", false),
("struct_name", "inode_operations", true),
("rare_symbol", "phylink_ethtool", true),
("long_literal", "This program is free software", true),
];
eprintln!(
"\n[4/5] Full results: fff (collect all) vs rg (full line output) ({} iters, showing min)\n",
iters
);
eprintln!(
" {:<22} | {:>9} {:>10} | {:>9} {:>10} | {:>7}",
"Query", "fff min", "count", "rg min", "count", "fff/rg"
);
eprintln!(
" {:-<22}-+-{:-<9}-{:-<10}-+-{:-<9}-{:-<10}-+-{:-<7}",
"", "", "", "", "", ""
);
let mut fff_full_total = Duration::ZERO;
let mut rg_full_total = Duration::ZERO;
for (name, query, ci) in &queries {
let q = *query;
let ci = *ci;
let fs = run_n(|| run_fff_full(&picker, q), iters);
let rs = run_n(|| run_rg_lines(&canonical, q, ci, threads), iters);
eprintln!(
" {:<22} | {:>9} {:>10} | {:>9} {:>10} | {:>7}",
name,
fmt_dur(fs.min),
fs.count,
fmt_dur(rs.min),
rs.count,
ratio_str(fs.min, rs.min),
);
fff_full_total += fs.min;
rg_full_total += rs.min;
}
eprintln!(
" {:<22} | {:>9} {:>10} | {:>9} {:>10} | {:>7}",
"TOTAL",
fmt_dur(fff_full_total),
"",
fmt_dur(rg_full_total),
"",
ratio_str(fff_full_total, rg_full_total),
);
eprintln!(
"\n[5/5] First-page latency --- the real UI scenario ({} iters, showing min)",
iters
);
eprintln!(" fff: paginated search (50 matches) from warm mmap cache");
eprintln!(" rg: telescope-style (spawn, stream 50 lines, kill) --- per-keystroke cost\n");
eprintln!(
" {:<22} | {:>9} {:>10} | {:>9} {:>10} | {:>7}",
"Query", "fff min", "matches", "rg min", "matches", "fff/rg"
);
eprintln!(
" {:-<22}-+-{:-<9}-{:-<10}-+-{:-<9}-{:-<10}-+-{:-<7}",
"", "", "", "", "", ""
);
let mut fff_page_total = Duration::ZERO;
let mut rg_page_total = Duration::ZERO;
for (name, query, ci) in &queries {
let q = *query;
let ci = *ci;
let fs = run_n(|| run_fff_page(&picker, q), iters);
let rs = run_n(|| run_rg_page(&canonical, q, ci, 50, threads), iters);
eprintln!(
" {:<22} | {:>9} {:>10} | {:>9} {:>10} | {:>7}",
name,
fmt_dur(fs.min),
fs.count,
fmt_dur(rs.min),
rs.count,
ratio_str(fs.min, rs.min),
);
fff_page_total += fs.min;
rg_page_total += rs.min;
}
eprintln!(
" {:<22} | {:>9} {:>10} | {:>9} {:>10} | {:>7}",
"TOTAL",
fmt_dur(fff_page_total),
"",
fmt_dur(rg_page_total),
"",
ratio_str(fff_page_total, rg_page_total),
);
eprintln!(
"\n=== Summary (total min across all queries, {} iterations) ===\n",
iters
);
eprintln!(
" {:>25} | {:>12} | {:>12} | {:>7}",
"", "fff", "rg", "speedup"
);
eprintln!(" {:->25}-+-{:->12}-+-{:->12}-+-{:->7}", "", "", "", "");
eprintln!(
" {:>25} | {:>12} | {:>12} | {:>7}",
"full results (collect)",
fmt_dur(fff_full_total),
fmt_dur(rg_full_total),
ratio_str(fff_full_total, rg_full_total),
);
eprintln!(
" {:>25} | {:>12} | {:>12} | {:>7}",
"first-page (UI latency)",
fmt_dur(fff_page_total),
fmt_dur(rg_page_total),
ratio_str(fff_page_total, rg_page_total),
);
eprintln!();
eprintln!(" Note: rg cost includes fork/exec + directory traversal + gitignore parsing");
eprintln!(" on EVERY invocation (= every keystroke in telescope/fzf-lua).");
eprintln!(" fff pays this cost once at startup, then searches from warm cached mmaps.");
eprintln!();
}
+35 -25
View File
@@ -1,5 +1,5 @@
use fff_core::file_picker::FilePicker;
use fff_core::{FILE_PICKER, FuzzySearchOptions, PaginationArgs, QueryParser};
use fff::file_picker::{FFFMode, FilePicker};
use fff::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
use std::env;
use std::thread;
use std::time::Duration;
@@ -60,6 +60,7 @@ fn format_bytes(bytes: usize) -> String {
}
fn test_search_memory_pattern(
shared_picker: &SharedPicker,
name: &str,
iterations: usize,
query_pattern: impl Fn(usize) -> String,
@@ -82,19 +83,17 @@ fn test_search_memory_pattern(
let query = query_pattern(i);
let (result_count, _total_matched) = {
let file_picker_guard = FILE_PICKER.read().unwrap();
if let Some(ref picker) = *file_picker_guard {
let guard = shared_picker.read().unwrap();
if let Some(ref picker) = *guard {
let parser = QueryParser::default();
let parsed = parser.parse(&query);
let search_result = FilePicker::fuzzy_search(
picker.get_files(),
&query,
parsed,
let search_result = picker.fuzzy_search(
&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 {
@@ -177,20 +176,28 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("Test directory: {}", base_path);
println!();
// Create shared state
let shared_picker = SharedPicker::default();
let shared_frecency = SharedFrecency::default();
// Initialize FilePicker
{
let mut file_picker_guard = FILE_PICKER.write().unwrap();
if file_picker_guard.is_none() {
println!("Initializing FilePicker...");
*file_picker_guard = Some(FilePicker::new(base_path.clone())?);
}
}
println!("Initializing FilePicker...");
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
fff::FilePickerOptions {
base_path: base_path.clone(),
enable_mmap_cache: false,
mode: FFFMode::Neovim,
..Default::default()
},
)?;
// Wait for initial scan
println!("Waiting for file scan...");
loop {
if let Ok(file_picker_guard) = FILE_PICKER.read()
&& let Some(ref picker) = *file_picker_guard
if let Ok(guard) = shared_picker.read()
&& let Some(ref picker) = *guard
&& !picker.is_scan_active()
&& !picker.get_files().is_empty()
{
@@ -200,8 +207,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
let file_count = {
let file_picker_guard = FILE_PICKER.read()?;
file_picker_guard.as_ref().unwrap().get_files().len()
let guard = shared_picker.read().unwrap();
guard.as_ref().unwrap().get_files().len()
};
println!("📊 Found {} files", file_count);
@@ -216,10 +223,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Test different memory patterns
// 1. Repeated same query - should have minimal growth if caching works
test_search_memory_pattern("Same Query Repeated (1000x)", 1000, |_| "test".to_string())?;
test_search_memory_pattern(&shared_picker, "Same Query Repeated (1000x)", 1000, |_| {
"test".to_string()
})?;
// 2. Cycling through different queries
test_search_memory_pattern("Cycling Queries (1000x)", 1000, |i| {
test_search_memory_pattern(&shared_picker, "Cycling Queries (1000x)", 1000, |i| {
let queries = [
"test", "main", "lib", "src", "mod", "file", "picker", "fuzzy", "search",
];
@@ -227,24 +236,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
})?;
// 3. Unique queries each time - worst case for any caching
test_search_memory_pattern("Unique Queries (500x)", 500, |i| {
test_search_memory_pattern(&shared_picker, "Unique Queries (500x)", 500, |i| {
format!("unique_query_{}", i)
})?;
// 4. Queries that return many results
test_search_memory_pattern(
&shared_picker,
"High Result Count (500x)",
500,
|_| "a".to_string(), // Single character likely to match many files
)?;
// 5. Queries with no results
test_search_memory_pattern("No Results (500x)", 500, |_| {
test_search_memory_pattern(&shared_picker, "No Results (500x)", 500, |_| {
"zzzz_no_match_expected".to_string()
})?;
// 6. Long intensive test
test_search_memory_pattern("Long Intensive Test (2000x)", 2000, |i| {
test_search_memory_pattern(&shared_picker, "Long Intensive Test (2000x)", 2000, |i| {
let patterns = [
"rs", "lua", "toml", "mod", "lib", "main", "test", "src", "file",
];
+41 -79
View File
@@ -1,69 +1,19 @@
use fff_core::file_picker::FilePicker;
use fff_core::{FILE_PICKER, FileItem, FuzzySearchOptions, PaginationArgs, QueryParser};
use fff::file_picker::{FFFMode, FilePicker};
use fff::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
use std::time::{Duration, Instant};
/// Wait for background scan to complete
fn wait_for_scan(timeout_secs: u64) -> Result<usize, String> {
let start = Instant::now();
fn wait_for_scan(shared_picker: &SharedPicker, timeout_secs: u64) -> Result<usize, String> {
let timeout = Duration::from_secs(timeout_secs);
let mut iteration = 0;
loop {
iteration += 1;
let picker_guard = FILE_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));
if !shared_picker.wait_for_scan(timeout) {
return Err(format!("Scan timed out after {} seconds", timeout_secs));
}
}
/// Initialize FilePicker and insert into global state
fn init_file_picker(path: &str) -> Result<(), String> {
let picker = FilePicker::new(path.to_string())
.map_err(|e| format!("Failed to create FilePicker: {:?}", e))?;
let mut picker_guard = FILE_PICKER
.write()
.map_err(|_| "Failed to acquire write lock")?;
*picker_guard = Some(picker);
Ok(())
}
/// Get files snapshot from global state
fn get_files() -> Result<Vec<FileItem>, String> {
let picker_guard = FILE_PICKER
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())
Ok(picker.get_files().len())
} else {
Err("FilePicker not initialized".to_string())
}
@@ -79,34 +29,48 @@ fn main() {
return;
}
let canonical_path = big_repo_path
.canonicalize()
.expect("Failed to canonicalize path");
let canonical_path =
fff::path_utils::canonicalize(&big_repo_path).expect("Failed to canonicalize path");
// Create shared state
let shared_picker = SharedPicker::default();
let shared_frecency = SharedFrecency::default();
eprintln!("Initializing FilePicker for: {:?}", canonical_path);
init_file_picker(&canonical_path.to_string_lossy()).expect("Failed to init FilePicker");
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
fff::FilePickerOptions {
base_path: canonical_path.to_string_lossy().to_string(),
enable_mmap_cache: false,
mode: FFFMode::Neovim,
..Default::default()
},
)
.expect("Failed to init FilePicker with shared state");
// Give background thread time to start
std::thread::sleep(Duration::from_millis(200));
eprintln!("Waiting for scan to complete...");
let file_count = wait_for_scan(120).expect("Failed to wait for scan");
let file_count = wait_for_scan(&shared_picker, 120).expect("Failed to wait for scan");
eprintln!("✓ Indexed {} files\n", file_count);
let files = get_files().expect("Failed to get files");
let picker_guard = shared_picker.read().expect("Failed to acquire read lock");
let picker = picker_guard.as_ref().expect("FilePicker not initialized");
// 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...");
@@ -123,15 +87,13 @@ fn main() {
for _ in 0..iterations {
let parsed = parser.parse(query);
let results = FilePicker::fuzzy_search(
&files,
query,
parsed,
let results = picker.fuzzy_search(
&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 {
+31 -35
View File
@@ -1,5 +1,5 @@
use fff_core::file_picker::FilePicker;
use fff_core::{FILE_PICKER, FuzzySearchOptions, PaginationArgs, QueryParser};
use fff::file_picker::{FFFMode, FilePicker};
use fff::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
use std::env;
use std::io::{self, Write};
use std::thread;
@@ -77,24 +77,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("Test directory: {}", base_path);
println!();
// Initialize the file picker directly
// Create shared state
let shared_picker = SharedPicker::default();
let shared_frecency = SharedFrecency::default();
// Initialize the file picker
println!("📁 Initializing FilePicker...");
{
let mut file_picker_guard = FILE_PICKER.write().unwrap();
if file_picker_guard.is_none() {
println!("Creating new FilePicker for path: {}", base_path);
match FilePicker::new(base_path.clone()) {
Ok(picker) => {
println!("FilePicker created successfully");
*file_picker_guard = Some(picker);
}
Err(e) => {
eprintln!("Failed to create FilePicker: {:?}", e);
std::process::exit(1);
}
}
}
}
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
fff::FilePickerOptions {
base_path: base_path.clone(),
enable_mmap_cache: false,
mode: FFFMode::Neovim,
..Default::default()
},
)?;
// Wait for initial scan to complete
println!("⏳ Waiting for initial file scan to complete...");
@@ -102,8 +100,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut scan_completed = false;
loop {
if let Ok(file_picker_guard) = FILE_PICKER.read()
&& let Some(ref picker) = *file_picker_guard
if let Ok(guard) = shared_picker.read()
&& let Some(ref picker) = *guard
{
if !picker.is_scan_active() {
println!("Scan inactive, checking file count...");
@@ -128,10 +126,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// If async scan didn't work, trigger a manual scan
if !scan_completed {
println!("Triggering manual rescan...");
if let Ok(mut file_picker_guard) = FILE_PICKER.write()
&& let Some(ref mut picker) = *file_picker_guard
if let Ok(mut guard) = shared_picker.write()
&& let Some(ref mut picker) = *guard
{
match picker.trigger_rescan() {
match picker.trigger_rescan(&shared_frecency) {
Ok(_) => println!("Manual rescan completed"),
Err(e) => println!("Manual rescan failed: {:?}", e),
}
@@ -139,19 +137,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
let initial_file_count = {
let file_picker_guard = FILE_PICKER.read()?;
if let Some(ref picker) = *file_picker_guard {
let guard = shared_picker.read().unwrap();
if let Some(ref picker) = *guard {
let files = picker.get_files();
println!("Found {} files in picker", files.len());
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(picker));
}
}
files.len()
} else {
println!("No picker found in FILE_PICKER static!");
println!("No picker found!");
0
}
};
@@ -199,18 +197,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let search_start = Instant::now();
let parser = QueryParser::default();
let (result_count, search_duration) = {
let file_picker_guard = FILE_PICKER.read().unwrap();
if let Some(ref picker) = *file_picker_guard {
let guard = shared_picker.read().unwrap();
if let Some(ref picker) = *guard {
let parsed = parser.parse(query);
let search_result = FilePicker::fuzzy_search(
picker.get_files(),
query,
parsed,
let search_result = picker.fuzzy_search(
&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 {
+52 -54
View File
@@ -2,9 +2,9 @@
#![allow(dead_code)]
#![allow(clippy::enum_variant_names)]
use fff_core::file_picker::FilePicker;
use fff_core::git::format_git_status;
use fff_core::{FILE_PICKER, FRECENCY, FuzzySearchOptions, PaginationArgs, QueryParser};
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;
@@ -12,25 +12,6 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;
fn cleanup_global_state() {
// Clean up file picker
{
let mut file_picker = FILE_PICKER.write().unwrap();
if let Some(mut picker) = file_picker.take() {
picker.stop_background_monitor();
drop(picker);
println!("🧹 FilePicker cleaned up");
}
}
// Clean up frecency tracker
{
let mut frecency = FRECENCY.write().unwrap();
*frecency = None;
println!("🧹 Frecency tracker cleaned up");
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().collect();
let base_path = if args.len() > 1 {
@@ -43,28 +24,43 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
// Create shared state
let shared_picker = SharedPicker::default();
let shared_frecency = SharedFrecency::default();
// Clone for signal handler
let picker_for_cleanup = shared_picker.clone();
ctrlc::set_handler(move || {
println!("\n🛑 Received interrupt signal, shutting down...");
cleanup_global_state();
if let Ok(mut guard) = picker_for_cleanup.write() {
if let Some(mut picker) = guard.take() {
picker.stop_background_monitor();
println!("🧹 FilePicker cleaned up");
}
}
r.store(false, Ordering::SeqCst);
std::process::exit(0);
})?;
let mut git_stats = std::collections::HashMap::new();
// Initialize the global file picker using lib.rs function
{
let mut file_picker = FILE_PICKER.write().unwrap();
if file_picker.is_some() {
eprintln!("❌ FilePicker already initialized");
std::process::exit(1);
}
*file_picker = Some(FilePicker::new(base_path.clone())?);
}
// Get initial file count from global state
// Initialize the file picker using shared state
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
fff::FilePickerOptions {
base_path: base_path.clone(),
enable_mmap_cache: false,
mode: FFFMode::default(),
..Default::default()
},
)?;
// Get initial file count from shared state
let initial_count = {
let file_picker = FILE_PICKER.read().unwrap();
let files = file_picker.as_ref().unwrap().get_files();
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let files = picker.get_files();
println!("Initial file count: {}", files.len());
if !files.is_empty() {
@@ -73,7 +69,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!(
" {}. {} ({})",
i + 1,
file.relative_path,
file.relative_path(picker),
format_git_status(file.git_status)
);
}
@@ -96,8 +92,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
iteration += 1;
let current_count = {
let file_picker = FILE_PICKER.read().unwrap();
file_picker.as_ref().unwrap().get_files().len()
let guard = shared_picker.read().unwrap();
guard.as_ref().unwrap().get_files().len()
};
if current_count != last_count {
@@ -111,13 +107,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
);
// Show some recently added files
let file_picker = FILE_PICKER.read().unwrap();
let files = file_picker.as_ref().unwrap().get_files();
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let files = picker.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(picker));
}
drop(file_picker);
} else {
let removed = last_count - current_count;
println!(
@@ -136,8 +132,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
timestamp, current_count
);
let file_picker = FILE_PICKER.read().unwrap();
let current_files = file_picker.as_ref().unwrap().get_files();
let guard = shared_picker.read().unwrap();
let current_files = guard.as_ref().unwrap().get_files();
git_stats.clear();
for file in current_files {
@@ -156,19 +152,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if iteration % 40 == 0 {
let timestamp = chrono::Local::now().format("%H:%M:%S");
let file_picker = FILE_PICKER.read().unwrap();
let files = file_picker.as_ref().unwrap().get_files();
let guard = shared_picker.read().unwrap();
let picker_ref = guard.as_ref().unwrap();
let parser = QueryParser::default();
let parsed = parser.parse("rs");
let search_results = FilePicker::fuzzy_search(
files,
"rs",
parsed,
let search_results = picker_ref.fuzzy_search(
&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 {
@@ -193,17 +187,21 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!(
" {}. {} (score: {})",
i + 1,
file.relative_path,
file.relative_path(picker_ref),
score.total
);
}
drop(file_picker);
}
io::stdout().flush().unwrap();
}
// Clean up before exit
cleanup_global_state();
if let Ok(mut guard) = shared_picker.write() {
if let Some(mut picker) = guard.take() {
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)
}
}
+190
View File
@@ -0,0 +1,190 @@
use mlua::prelude::*;
use std::fmt::Write as _;
use std::io::{Read, Seek, SeekFrom};
// Byte category colors (matching hexyl's default theme)
const COLOR_OFFSET: &str = "#888888";
const COLOR_NULL: &str = "#555753";
const COLOR_ASCII_PRINTABLE: &str = "#06989a";
const COLOR_ASCII_WHITESPACE: &str = "#4e9a06";
const COLOR_ASCII_OTHER: &str = "#4e9a06";
const COLOR_NON_ASCII: &str = "#c4a000";
fn byte_color(b: u8) -> &'static str {
match b {
0x00 => COLOR_NULL,
0x20 | 0x09 | 0x0a | 0x0d => COLOR_ASCII_WHITESPACE,
0x21..=0x7e => COLOR_ASCII_PRINTABLE,
0x01..=0x1f | 0x7f => COLOR_ASCII_OTHER,
_ => COLOR_NON_ASCII,
}
}
fn byte_char(b: u8) -> char {
match b {
0x20..=0x7e => b as char,
_ => '.',
}
}
const BYTES_PER_LINE: usize = 16;
struct Span {
line: usize,
col_start: usize,
col_end: usize,
color: &'static str,
}
/// Push a span, merging with the previous one if same line and color.
fn push_span(
spans: &mut Vec<Span>,
line: usize,
col_start: usize,
col_end: usize,
color: &'static str,
) {
if let Some(last) = spans.last_mut() {
// Merge if same line, same color, and adjacent (allow small gaps for spaces between hex pairs)
if last.line == line && std::ptr::eq(last.color, color) && col_start <= last.col_end + 1 {
last.col_end = col_end;
return;
}
}
spans.push(Span {
line,
col_start,
col_end,
color,
});
}
/// Format raw bytes into hex dump lines with coalesced highlight spans.
///
/// Layout per line:
/// ```text
/// XXXXXXXX HH HH HH HH HH HH HH HH HH HH HH HH HH HH HH HH CCCCCCCCCCCCCCCC
/// ```
fn format_hex_dump(raw_bytes: &[u8], base_offset: u64) -> (Vec<String>, Vec<Span>) {
let mut lines = Vec::new();
let mut spans = Vec::new();
for (chunk_idx, chunk) in raw_bytes.chunks(BYTES_PER_LINE).enumerate() {
let addr = base_offset + (chunk_idx * BYTES_PER_LINE) as u64;
let mut line = format!("{addr:08x} ");
// Offset label highlight
push_span(&mut spans, chunk_idx, 0, 8, COLOR_OFFSET);
// Hex pairs with a gap after 8 bytes
for (i, &b) in chunk.iter().enumerate() {
if i == 8 {
line.push(' ');
}
let col = line.len();
push_span(&mut spans, chunk_idx, col, col + 2, byte_color(b));
write!(line, "{b:02x} ").unwrap();
}
// Pad if the last line is short
if chunk.len() < BYTES_PER_LINE {
let missing = BYTES_PER_LINE - chunk.len();
let mut pad = missing * 3;
if chunk.len() <= 8 {
pad += 1;
}
for _ in 0..pad {
line.push(' ');
}
}
// Separator before char panel
line.push(' ');
// Character panel — consecutive same-color chars merge automatically
let char_start = line.len();
for (i, &b) in chunk.iter().enumerate() {
let col = char_start + i;
push_span(&mut spans, chunk_idx, col, col + 1, byte_color(b));
line.push(byte_char(b));
}
lines.push(line);
}
(lines, spans)
}
/// Generate a hex dump for a binary file with paging support and highlight data.
///
/// Returns a Lua table:
/// ```text
/// {
/// lines: string[],
/// highlights: {line_0idx, col_start, col_end, color}[],
/// has_more: bool,
/// next_offset: number,
/// }
/// ```
pub fn hex_dump(
lua: &Lua,
(file_path, offset, length): (String, Option<u64>, Option<u64>),
) -> LuaResult<LuaValue> {
let offset = offset.unwrap_or(0);
let length = length.unwrap_or(4096);
let file = std::fs::File::open(&file_path)
.map_err(|e| LuaError::RuntimeError(format!("Failed to open file: {e}")))?;
let file_size = file
.metadata()
.map_err(|e| LuaError::RuntimeError(format!("Failed to get metadata: {e}")))?
.len();
let table = lua.create_table()?;
if offset >= file_size {
table.set("lines", lua.create_table()?)?;
table.set("highlights", lua.create_table()?)?;
table.set("has_more", false)?;
table.set("next_offset", file_size)?;
return Ok(LuaValue::Table(table));
}
let mut reader = std::io::BufReader::new(file);
reader
.seek(SeekFrom::Start(offset))
.map_err(|e| LuaError::RuntimeError(format!("Failed to seek: {e}")))?;
let mut raw_bytes = Vec::with_capacity(length as usize);
reader
.by_ref()
.take(length)
.read_to_end(&mut raw_bytes)
.map_err(|e| LuaError::RuntimeError(format!("Failed to read: {e}")))?;
let (plain_lines, hl_spans) = format_hex_dump(&raw_bytes, offset);
let lines_table = lua.create_table()?;
for (i, line) in plain_lines.iter().enumerate() {
lines_table.set(i + 1, line.as_str())?;
}
table.set("lines", lines_table)?;
let highlights_table = lua.create_table()?;
for (i, span) in hl_spans.iter().enumerate() {
let hl = lua.create_table()?;
hl.raw_set(1, span.line)?;
hl.raw_set(2, span.col_start)?;
hl.raw_set(3, span.col_end)?;
hl.raw_set(4, span.color)?;
highlights_table.raw_set(i + 1, hl)?;
}
table.set("highlights", highlights_table)?;
let bytes_read = raw_bytes.len() as u64;
let next_offset = offset + bytes_read;
table.set("has_more", next_offset < file_size)?;
table.set("next_offset", next_offset)?;
Ok(LuaValue::Table(table))
}
+330 -145
View File
@@ -1,17 +1,23 @@
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::query_tracker::QueryTracker;
use fff_core::{DbHealthChecker, Error, FuzzySearchOptions, PaginationArgs, QueryParser};
use fff_core::{FILE_PICKER, FRECENCY, QUERY_TRACKER};
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::time::Duration;
mod error;
mod hex_dump;
mod log;
mod lua_types;
mod path_shortening;
@@ -19,80 +25,100 @@ mod path_shortening;
#[global_allocator]
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(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;
}
*frecency =
Some(FrecencyTracker::new(&frecency_db_path, use_unsafe_no_lock).into_lua_result()?);
tracing::info!("Frecency database initialized at {}", frecency_db_path);
drop(frecency);
let mut query_tracker = QUERY_TRACKER
.write()
.with_lock_error(Error::AcquireFrecencyLock)
.into_lua_result()?;
// Spawn background GC to purge stale entries without blocking startup
let _ = FRECENCY.spawn_gc(frecency_db_path, use_unsafe_no_lock);
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 mut file_picker = FILE_PICKER
.write()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
if file_picker.is_some() {
return Ok(false);
{
let guard = FILE_PICKER.read().into_lua_result()?;
if guard.is_some() {
return Ok(false);
}
}
let picker = FilePicker::new(base_path).into_lua_result()?;
*file_picker = Some(picker);
FilePicker::new_with_shared_state(
FILE_PICKER.clone(),
FRECENCY.clone(),
fff::FilePickerOptions {
base_path,
enable_mmap_cache: true,
enable_content_indexing: true,
mode: FFFMode::Neovim,
..Default::default()
},
)
.into_lua_result()?;
Ok(true)
}
fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> {
let mut file_picker = FILE_PICKER
.write()
.with_lock_error(Error::AcquireItemLock)?;
// drop should clean it anyway but just to be extra sure
if let Some(mut picker) = file_picker.take() {
picker.stop_background_monitor();
// Cancel and stop the old picker under a single write lock to avoid
// a window where FILE_PICKER is None (which causes FilePickerMissing
// errors if the UI is searching concurrently).
{
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.
picker.cancel();
picker.stop_background_monitor();
}
// Don't take() here — leave the old picker in place so searches
// still work until new_with_shared_state replaces it atomically.
}
let new_picker = FilePicker::new(path.to_string_lossy().to_string())?;
*file_picker = Some(new_picker);
// Create new picker — this atomically replaces the old one via write lock
FilePicker::new_with_shared_state(
FILE_PICKER.clone(),
FRECENCY.clone(),
fff::FilePickerOptions {
base_path: path.to_string_lossy().to_string(),
enable_mmap_cache: true,
enable_content_indexing: true,
mode: FFFMode::Neovim,
..Default::default()
},
)?;
Ok(())
}
@@ -106,10 +132,16 @@ pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> {
)));
}
let canonical_path = path.canonicalize().map_err(|e| {
let canonical_path = fff::path_utils::canonicalize(&path).map_err(|e| {
LuaError::RuntimeError(format!("Failed to canonicalize path '{}': {}", new_path, e))
})?;
if let Ok(Some(picker)) = FILE_PICKER.read().as_deref()
&& picker.base_path() == canonical_path
{
return Ok(()); // same dir
}
// Spawn a background thread to avoid blocking Lua/UI thread
std::thread::spawn(move || {
if let Err(e) = reinit_file_picker_internal(&canonical_path) {
@@ -127,16 +159,13 @@ 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)
.into_lua_result()?;
picker.trigger_rescan().into_lua_result()?;
picker.trigger_rescan(&FRECENCY).into_lua_result()?;
::tracing::info!("scan_files trigger_rescan completed");
Ok(())
}
@@ -162,10 +191,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));
};
@@ -173,26 +199,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,
@@ -201,19 +214,16 @@ 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 results = FilePicker::fuzzy_search(
picker.get_files(),
&query,
parsed,
let results = picker.fuzzy_search(
&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 {
@@ -223,7 +233,140 @@ pub fn fuzzy_search_files(
},
);
lua_types::SearchResultLua::from(results).into_lua(lua)
if results.items.is_empty() && query.contains(std::path::MAIN_SEPARATOR) {
let pure_query = match &parsed.fuzzy_query {
fff_query_parser::FuzzyQuery::Text(t) => t.trim(),
_ => query.trim(),
};
let path = expand_tilde(pure_query);
if path.is_absolute() && path.is_file() {
if let Some(found_file) = picker.get_file_by_path(&path) {
let found = SearchResult {
items: vec![found_file],
scores: vec![Score {
exact_match: true,
match_type: "path",
..Default::default()
}],
total_matched: 1,
total_files: results.total_files,
location: parsed.location,
};
return lua_types::SearchResultLua::new(found, picker).into_lua(lua);
}
return build_file_path_fallback(lua, &path, results.total_files);
}
}
lua_types::SearchResultLua::new(results, picker).into_lua(lua)
}
#[allow(clippy::type_complexity)]
pub fn live_grep(
lua: &Lua,
(
query,
file_offset,
page_size,
max_file_size,
max_matches_per_file,
smart_case,
grep_mode,
time_budget_ms,
trim_whitespace,
): (
String,
Option<usize>,
Option<usize>,
Option<u64>,
Option<usize>,
Option<bool>,
Option<String>,
Option<u64>,
Option<bool>,
),
) -> LuaResult<LuaValue> {
let file_picker_guard = FILE_PICKER.read().into_lua_result()?;
let Some(ref picker) = *file_picker_guard else {
return Err(error::to_lua_error(Error::FilePickerMissing));
};
let parsed = fff::grep::parse_grep_query(&query);
let mode = match grep_mode.as_deref() {
Some("regex") => fff::GrepMode::Regex,
Some("fuzzy") => fff::GrepMode::Fuzzy,
_ => fff::GrepMode::PlainText, // "plain" or nil or unknown
};
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),
file_offset: file_offset.unwrap_or(0),
page_limit: page_size.unwrap_or(50),
mode,
time_budget_ms: time_budget_ms.unwrap_or(0),
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: trim_whitespace.unwrap_or(false),
abort_signal: None,
};
let result = picker.grep(&parsed, &options);
lua_types::GrepResultLua::new(result, picker).into_lua(lua)
}
/// Build a file-picker result for an absolute path that exists on disk but
/// isn't in the picker index (e.g. file from a different project).
fn build_file_path_fallback(lua: &Lua, path: &Path, total_files: usize) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let path_str = path.to_string_lossy().to_string();
let item = lua.create_table()?;
item.set("relative_path", path_str.as_str())?;
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", 0i32)?;
item.set("modification_frecency_score", 0i32)?;
item.set("total_frecency_score", 0i32)?;
item.set("git_status", "")?;
item.set("is_binary", false)?;
let items_table = lua.create_table()?;
items_table.set(1, item)?;
table.set("items", items_table)?;
let score = lua.create_table()?;
score.set("total", 0)?;
score.set("base_score", 0)?;
score.set("filename_bonus", 0)?;
score.set("special_filename_bonus", 0)?;
score.set("frecency_boost", 0)?;
score.set("git_status_boost", 0)?;
score.set("distance_penalty", 0)?;
score.set("current_file_penalty", 0)?;
score.set("combo_match_boost", 0)?;
score.set("exact_match", true)?;
score.set("match_type", "path")?;
let scores_table = lua.create_table()?;
scores_table.set(1, score)?;
table.set("scores", scores_table)?;
table.set("total_matched", 1)?;
table.set("total_files", total_files)?;
Ok(LuaValue::Table(table))
}
pub fn track_access(_: &Lua, file_path: String) -> LuaResult<bool> {
@@ -231,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);
};
@@ -244,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);
};
@@ -267,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)
@@ -284,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)
@@ -296,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);
};
@@ -307,23 +432,26 @@ pub fn get_git_root(_: &Lua, _: ()) -> LuaResult<Option<String>> {
Ok(picker.git_root().map(|p| p.to_string_lossy().into_owned()))
}
pub fn get_base_path(_: &Lua, _: ()) -> LuaResult<Option<String>> {
let file_picker = FILE_PICKER.read().into_lua_result()?;
let Some(ref picker) = *file_picker else {
return Ok(None);
};
Ok(Some(picker.base_path().to_string_lossy().into_owned()))
}
pub fn refresh_git_status(_: &Lua, _: ()) -> LuaResult<usize> {
FilePicker::refresh_git_status_global().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));
};
@@ -335,10 +463,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));
};
@@ -349,10 +474,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");
@@ -370,10 +492,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);
};
@@ -381,7 +500,7 @@ pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) ->
};
// Canonicalize the file path before spawning thread
let file_path = match PathBuf::from(&file_path).canonicalize() {
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");
@@ -390,8 +509,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 = 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!(
@@ -408,20 +529,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);
};
@@ -431,22 +546,83 @@ pub fn get_historical_query(_: &Lua, offset: usize) -> LuaResult<Option<String>>
.into_lua_result()
}
pub fn track_grep_query(_: &Lua, query: String) -> LuaResult<bool> {
let project_path = {
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 = QUERY_TRACKER.clone();
std::thread::spawn(move || {
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!(
query = %query,
error = ?e,
"Failed to track grep query"
);
}
});
Ok(true)
}
pub fn get_historical_grep_query(_: &Lua, offset: usize) -> LuaResult<Option<String>> {
let project_path = {
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().into_lua_result()?;
let Some(ref tracker) = *query_tracker else {
return Ok(None);
};
tracker
.get_historical_grep_query(&project_path, offset)
.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> {
let file_picker = FILE_PICKER
.read()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let picker = file_picker
.as_ref()
.ok_or(Error::FilePickerMissing)
.into_lua_result()?;
// 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().into_lua_result()?;
let picker = file_picker
.as_ref()
.ok_or(Error::FilePickerMissing)
.into_lua_result()?;
picker.scan_signal()
}; // read lock released here
let timeout_ms = timeout_ms.unwrap_or(500);
let timeout_duration = Duration::from_millis(timeout_ms);
let start_time = std::time::Instant::now();
let mut sleep_duration = Duration::from_millis(1);
while picker.is_scan_active() {
while scan_signal.load(std::sync::atomic::Ordering::Relaxed) {
if start_time.elapsed() >= timeout_duration {
::tracing::warn!("wait_for_initial_scan timed out after {}ms", timeout_ms);
return Ok(false);
@@ -628,6 +804,7 @@ fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
"fuzzy_search_files",
lua.create_function(fuzzy_search_files)?,
)?;
exports.set("live_grep", lua.create_function(live_grep)?)?;
exports.set("track_access", lua.create_function(track_access)?)?;
exports.set("cancel_scan", lua.create_function(cancel_scan)?)?;
exports.set("get_scan_progress", lua.create_function(get_scan_progress)?)?;
@@ -636,6 +813,7 @@ fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
lua.create_function(refresh_git_status)?,
)?;
exports.set("get_git_root", lua.create_function(get_git_root)?)?;
exports.set("get_base_path", lua.create_function(get_base_path)?)?;
exports.set(
"stop_background_monitor",
lua.create_function(stop_background_monitor)?,
@@ -658,8 +836,15 @@ fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
"get_historical_query",
lua.create_function(get_historical_query)?,
)?;
exports.set("track_grep_query", lua.create_function(track_grep_query)?)?;
exports.set(
"get_historical_grep_query",
lua.create_function(get_historical_grep_query)?,
)?;
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)
}
+2 -151
View File
@@ -1,152 +1,3 @@
use std::io;
use std::path::Path;
use tracing_appender::non_blocking;
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
//! Logging setup for fff-nvim — delegates to the shared fff-core::log utilities.
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();
/// Install panic hook that writes to both stderr and a fallback file
/// This is called separately from init_tracing to ensure panics are always logged
pub fn install_panic_hook() {
PANIC_HOOK_INSTALLED.get_or_init(|| {
let default_panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let payload = panic_info.payload();
let message = if let Some(s) = payload.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"Unknown panic payload".to_string()
};
let location = if let Some(location) = panic_info.location() {
format!(
"{}:{}:{}",
location.file(),
location.line(),
location.column()
)
} else {
"unknown location".to_string()
};
// Always log to tracing (if initialized)
tracing::error!(
panic.message = %message,
panic.location = %location,
"PANIC occurred in FFF.nvim"
);
// Always print to stderr
eprintln!("=== FFF.nvim 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_nvim_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());
}
default_panic(panic_info);
}));
});
}
/// Initialize tracing with single log file
///
/// # 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 file_appender = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true) // creates a new file on every setup
.open(log_path)?;
let level = match log_level
.as_ref()
.map(|s| s.trim().to_lowercase())
.as_deref()
{
Some("trace") => tracing::Level::TRACE,
Some("debug") => tracing::Level::DEBUG,
Some("info") => tracing::Level::INFO,
Some("warn") => tracing::Level::WARN,
Some("error") => tracing::Level::ERROR,
_ => tracing::Level::INFO,
};
TRACING_INITIALIZED.get_or_init(|| {
let (non_blocking_appender, guard) = non_blocking(file_appender);
let subscriber = tracing_subscriber::registry()
.with(
fmt::layer()
.with_writer(non_blocking_appender)
.with_target(true)
.with_thread_ids(false)
.with_thread_names(false)
.with_file(true)
.with_line_number(true)
.with_ansi(false)
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
)
.with(
EnvFilter::builder()
.with_default_directive(level.into())
.from_env_lossy(),
);
if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
eprintln!("Failed to set tracing subscriber: {}", e);
} else {
tracing::info!(
"FFF.nvim tracing initialized with log file: {}",
log_path.display()
);
}
guard
});
Ok(log_file_path.to_string())
}
pub use fff::log::{init_tracing, install_panic_hook};
+89 -16
View File
@@ -1,19 +1,27 @@
//! Lua type conversions for fff-core types
//!
//! This module provides IntoLua implementations for core types.
use fff_core::git::format_git_status;
use fff_core::{FileItem, Location, Score, SearchResult};
use fff::file_picker::FilePicker;
use fff::git::format_git_status;
use fff::{FileItem, GrepResult, Location, Score, SearchResult};
use mlua::prelude::*;
/// Wrapper for SearchResult that implements IntoLua
pub struct SearchResultLua<'a> {
inner: SearchResult<'a>,
picker: &'a FilePicker,
}
impl<'a> From<SearchResult<'a>> for SearchResultLua<'a> {
fn from(inner: SearchResult<'a>) -> Self {
Self { inner }
impl<'a> SearchResultLua<'a> {
pub fn new(inner: SearchResult<'a>, picker: &'a FilePicker) -> Self {
Self { inner, picker }
}
}
pub struct GrepResultLua<'a> {
inner: GrepResult<'a>,
picker: &'a FilePicker,
}
impl<'a> GrepResultLua<'a> {
pub fn new(inner: GrepResult<'a>, picker: &'a FilePicker) -> Self {
Self { inner, picker }
}
}
@@ -28,11 +36,10 @@ impl IntoLua for LuaPosition {
}
}
fn file_item_into_lua(item: &FileItem, lua: &Lua) -> LuaResult<LuaValue> {
fn file_item_into_lua(item: &FileItem, lua: &Lua, picker: &FilePicker) -> 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("relative_path", item.relative_path(picker))?;
table.set("name", item.file_name(picker))?;
table.set("size", item.size)?;
table.set("modified", item.modified)?;
table.set("access_frecency_score", item.access_frecency_score)?;
@@ -40,8 +47,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())?;
Ok(LuaValue::Table(table))
}
@@ -55,6 +63,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))
@@ -67,7 +76,7 @@ impl IntoLua for SearchResultLua<'_> {
// Convert items
let items_table = lua.create_table()?;
for (i, item) in self.inner.items.iter().enumerate() {
items_table.set(i + 1, file_item_into_lua(item, lua)?)?;
items_table.set(i + 1, file_item_into_lua(item, lua, self.picker)?)?;
}
table.set("items", items_table)?;
@@ -104,3 +113,67 @@ impl IntoLua for SearchResultLua<'_> {
Ok(LuaValue::Table(table))
}
}
impl IntoLua for GrepResultLua<'_> {
fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
// Convert grep match items — each includes file metadata + match metadata
let items_table = lua.create_table()?;
for (i, m) in self.inner.matches.iter().enumerate() {
let item = lua.create_table()?;
// File metadata from the deduplicated files vec
let file = self.inner.files[m.file_index];
item.set("relative_path", file.relative_path(self.picker))?;
item.set("name", file.file_name(self.picker))?;
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("access_frecency_score", file.access_frecency_score)?;
item.set(
"modification_frecency_score",
file.modification_frecency_score,
)?;
// Match metadata
item.set("line_number", m.line_number)?;
item.set("col", m.col)?;
item.set("byte_offset", m.byte_offset)?;
item.set("line_content", m.line_content.as_str())?;
// Match byte ranges within line_content
let ranges = lua.create_table()?;
for (j, &(start, end)) in m.match_byte_offsets.iter().enumerate() {
let range = lua.create_table()?;
range.set(1, start)?;
range.set(2, end)?;
ranges.set(j + 1, range)?;
}
item.set("match_ranges", ranges)?;
// Fuzzy match score (only set in fuzzy grep mode, nil otherwise)
if let Some(score) = m.fuzzy_score {
item.set("fuzzy_score", score)?;
}
items_table.set(i + 1, item)?;
}
table.set("items", items_table)?;
table.set("total_matched", self.inner.matches.len())?;
table.set("total_files_searched", self.inner.total_files_searched)?;
table.set("total_files", self.inner.total_files)?;
table.set("filtered_file_count", self.inner.filtered_file_count)?;
table.set("next_file_offset", self.inner.next_file_offset)?;
// Pass regex fallback error to Lua (nil if no error)
if let Some(ref err) = self.inner.regex_fallback_error {
table.set("regex_fallback_error", err.as_str())?;
}
Ok(LuaValue::Table(table))
}
}
+2 -2
View File
@@ -34,7 +34,7 @@ impl PathCache {
}
}
#[tracing::instrument(skip(self), fields(path = %path.display(), max_size))]
#[tracing::instrument(skip(self), fields(path = %path.display(), max_size), level = tracing::Level::TRACE)]
fn get(&self, path: &Path, max_size: usize) -> Option<&str> {
self.map.get(path).and_then(|entry| {
// Only return cached value if max_size matches
@@ -85,7 +85,7 @@ pub fn shorten_path_with_cache(
.read()
.map_err(|_| "Failed to acquire path cache lock".to_string())?;
if let Some(cached) = cache.get(path, max_size) {
tracing::debug!("Cache hit for path '{}'", path.display());
tracing::trace!("Cache hit for path '{}'", path.display());
return Ok(cached.to_string());
}
}
+9 -3
View File
@@ -1,14 +1,20 @@
[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"
[features]
default = []
zlob = ["dep:zlob"]
[dependencies]
smallvec = { workspace = true }
zlob = { version = "1.2.8" }
zlob = { workspace = true, optional = true }
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
@@ -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";
+219 -12
View File
@@ -1,4 +1,46 @@
use crate::constraints::Constraint;
use crate::glob_detect::has_wildcards;
/// Check if a token looks like a filename or file path for use as a `FilePath` constraint.
///
/// A token is a filename/path if ALL of:
/// - Does NOT end with `/` (that's a directory/PathSegment)
/// - Does NOT contain wildcards (`*`, `?`, `{`, `[`) — those are globs
/// - Last component (after final `/`) contains `.` with a valid-looking extension
/// (110 alphanumeric chars starting with a letter, e.g. `rs`, `json`, `tsx`)
///
/// This covers both bare filenames (`score.rs`) and path-prefixed ones (`src/main.rs`).
#[inline]
fn is_filename_constraint_token(token: &str) -> bool {
let bytes = token.as_bytes();
// Must NOT end with / (that's a PathSegment)
if bytes.last() == Some(&b'/') {
return false;
}
// Must NOT contain wildcards (those are globs)
if has_wildcards(token) {
return false;
}
// Get the filename component (after last /)
let filename = token.rsplit('/').next().unwrap_or(token);
// Extension must exist and look like a real file extension:
// starts with an ASCII letter (rejects version numbers like "v2.0"),
// followed by alphanumeric chars, max 10 chars total.
match filename.rfind('.') {
Some(dot_pos) => {
let ext = &filename[dot_pos + 1..];
!ext.is_empty()
&& ext.len() <= 10
&& ext.as_bytes()[0].is_ascii_alphabetic()
&& ext.bytes().all(|b| b.is_ascii_alphanumeric())
}
None => false,
}
}
/// Parser configuration trait - allows different picker types to customize parsing
pub trait ParserConfig {
@@ -31,6 +73,24 @@ pub trait ParserConfig {
true
}
/// Should parse location suffixes (e.g., file:12, file:12:4)
/// Disabled for grep modes where colon-number patterns like localhost:8080
/// are search text, not file locations.
fn enable_location(&self) -> bool {
true
}
/// Determine whether a token should be treated as a glob constraint.
///
/// The default implementation delegates to `zlob::has_wildcards` with
/// `RECOMMENDED` flags, which recognises `*`, `?`, `[`, `{…}` etc.
///
/// Override this in configs where some wildcard characters are common
/// in search text (e.g. grep mode where `?` and `[` appear in code).
fn is_glob_pattern(&self, token: &str) -> bool {
has_wildcards(token)
}
/// Custom constraint parsers for picker-specific needs
fn parse_custom<'a>(&self, _input: &'a str) -> Option<Constraint<'a>> {
None
@@ -39,25 +99,32 @@ 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 {
// All defaults enabled
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.
fn parse_custom<'a>(&self, token: &'a str) -> Option<Constraint<'a>> {
if is_filename_constraint_token(token) {
Some(Constraint::FilePath(token))
} else {
None
}
}
}
/// Configuration for full-text search (grep) - limited constraints
/// Configuration for full-text search (grep) - file constraints enabled for
/// filtering which files to search, git status disabled since it's not useful
/// when searching file contents.
///
/// Glob detection is narrowed: only patterns containing a path separator (`/`)
/// or brace expansion (`{…}`) are treated as globs. Characters like `?` and
/// `[` are extremely common in source code and must remain literal search text.
#[derive(Debug, Clone, Copy, Default)]
pub struct GrepConfig;
impl ParserConfig for GrepConfig {
fn enable_extension(&self) -> bool {
false
}
fn enable_glob(&self) -> bool {
false
}
fn enable_path_segments(&self) -> bool {
true
}
@@ -65,4 +132,144 @@ impl ParserConfig for GrepConfig {
fn enable_git_status(&self) -> bool {
false
}
fn enable_location(&self) -> bool {
false
}
/// Only recognise globs that are clearly directory/path oriented.
///
/// Characters like `?`, `[`, and bare `*` (without `/`) are extremely
/// common in source code (`foo?`, `arr[0]`, `*ptr`) and must NOT be
/// consumed as glob constraints. We only treat a token as a glob when
/// it contains path-oriented patterns:
///
/// - Contains `/` → path glob (e.g. `src/**/*.rs`, `*/tests/*`)
/// - Contains `{…}` → brace expansion (e.g. `{src,lib}`)
fn is_glob_pattern(&self, token: &str) -> bool {
// Must contain at least one glob wildcard character
if !has_wildcards(token) {
return false;
}
let bytes = token.as_bytes();
// Contains path separator → clearly a path glob
if bytes.contains(&b'/') {
return true;
}
// Brace expansion → useful for directory alternatives.
// Require a comma between `{` and `}` AND at least one letter to
// distinguish real glob expansions like `{src,lib}` or `*.{ts,tsx}`
// from code patterns like `format!("{}")` and regex quantifiers `{2,3}`.
if let Some(open) = bytes.iter().position(|&b| b == b'{')
&& let Some(close) = bytes.iter().rposition(|&b| b == b'}')
{
let inner = &bytes[open + 1..close];
if inner.contains(&b',') && inner.iter().any(|b| b.is_ascii_alphabetic()) {
return true;
}
}
// Everything else (?, [, bare * without /) → treat as literal text
false
}
}
/// Configuration for AI-mode grep — extends `GrepConfig` behavior with
/// automatic file-path constraint detection.
///
/// Bare filenames with valid extensions (`schema.rs`) and path-prefixed
/// filenames (`libswscale/input.c`) are detected as `FilePath` constraints
/// so the search is scoped to matching files. The caller validates the
/// constraint against the index and drops it if no files match (fallback).
#[derive(Debug, Clone, Copy, Default)]
pub struct AiGrepConfig;
/// Configuration for directory and mixed search modes.
///
/// Disables path segment parsing so that trailing `/` is kept as fuzzy text
/// (e.g. `fff-core/` fuzzy-matches directory paths instead of becoming a
/// `PathSegment("fff-core")` constraint with an empty query). Extension and
/// filename constraints are also disabled since they don't apply to directories.
#[derive(Debug, Clone, Copy, Default)]
pub struct DirSearchConfig;
impl ParserConfig for AiGrepConfig {
fn enable_path_segments(&self) -> bool {
true
}
fn enable_git_status(&self) -> bool {
false
}
fn enable_location(&self) -> bool {
false
}
fn is_glob_pattern(&self, token: &str) -> bool {
// First check GrepConfig's strict rules (path globs, brace expansion)
if GrepConfig.is_glob_pattern(token) {
return true;
}
// AI agents use `*text*` to scope file searches (e.g. `*quote* TODO`).
// Recognise tokens that start AND end with `*` with non-empty text
// between them as glob constraints. Bare `*` or `**` are excluded.
if !has_wildcards(token) {
return false;
}
let bytes = token.as_bytes();
if bytes.len() >= 3
&& bytes[0] == b'*'
&& bytes[bytes.len() - 1] == b'*'
&& bytes[1..bytes.len() - 1].iter().all(|&b| b != b'*')
{
return true;
}
false
}
fn parse_custom<'a>(&self, token: &'a str) -> Option<Constraint<'a>> {
if is_filename_constraint_token(token) {
Some(Constraint::FilePath(token))
} else {
None
}
}
}
impl ParserConfig for DirSearchConfig {
fn enable_path_segments(&self) -> bool {
false
}
fn enable_extension(&self) -> bool {
false
}
fn enable_type_filter(&self) -> bool {
false
}
fn enable_git_status(&self) -> bool {
false
}
}
/// Configuration for mixed (files + directories) search.
///
/// Like `DirSearchConfig`, disables path segment parsing so trailing `/`
/// triggers dirs-only mode instead of becoming a constraint. Keeps git
/// status and extension filters enabled since files are part of the results.
#[derive(Debug, Clone, Copy, Default)]
pub struct MixedSearchConfig;
impl ParserConfig for MixedSearchConfig {
fn enable_path_segments(&self) -> bool {
false
}
}
+6 -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> {
@@ -22,6 +20,10 @@ pub enum Constraint<'a> {
/// Path constraint: /src/ -> PathSegment("src")
PathSegment(&'a str),
/// File path constraint (AI mode): "libswscale/input.c" → FilePath("libswscale/input.c")
/// Matches files whose relative path ends with this suffix at a `/` boundary.
FilePath(&'a str),
/// File type constraint: type:rust -> FileType("rust")
FileType(&'a str),
@@ -41,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>;

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