Compare commits

...

59 Commits

Author SHA1 Message Date
Dmitriy Kovalenko 7b2864e4b1 chore(ci): Attempt to optimize CI build time 2026-08-15 17:33:05 -07:00
dmtrKovalenko 2c9cae2e0f chore: bump fff-mcp release artifacts to v0.10.4 2026-08-15 22:16:40 +00:00
Dmitriy Kovalenko a166255d8f chore: release 0.10.4
Build & Publish / Build Neovim aarch64-apple-darwin (push) Has been cancelled
Build & Publish / Build Neovim aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build Neovim aarch64-unknown-linux-gnu (push) Has been cancelled
Build & Publish / Build C FFI aarch64-apple-darwin (push) Has been cancelled
Build & Publish / Build C FFI aarch64-linux-android (push) Has been cancelled
Build & Publish / Build C FFI aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build C FFI aarch64-unknown-linux-gnu (push) Has been cancelled
Build & Publish / Build C FFI aarch64-unknown-linux-musl (push) Has been cancelled
Build & Publish / Build Neovim aarch64-linux-android (push) Has been cancelled
Build & Publish / Build Neovim aarch64-unknown-linux-musl (push) Has been cancelled
Build & Publish / Build Neovim x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build Neovim x86_64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build Neovim x86_64-unknown-linux-gnu (push) Has been cancelled
Build & Publish / Build Neovim x86_64-unknown-linux-musl (push) Has been cancelled
Build & Publish / Build C FFI x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build C FFI x86_64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build C FFI x86_64-unknown-linux-gnu (push) Has been cancelled
Build & Publish / Build C FFI x86_64-unknown-linux-musl (push) Has been cancelled
Build & Publish / Build MCP aarch64-apple-darwin (push) Has been cancelled
Build & Publish / Build MCP aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build MCP aarch64-unknown-linux-gnu (push) Has been cancelled
Build & Publish / Build MCP aarch64-unknown-linux-musl (push) Has been cancelled
Build & Publish / Build MCP x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build MCP x86_64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build MCP x86_64-unknown-linux-gnu (push) Has been cancelled
Build & Publish / Build MCP x86_64-unknown-linux-musl (push) Has been cancelled
Build & Publish / Build Python wheels aarch64 (macos-latest) (push) Has been cancelled
Build & Publish / Build Python wheels x86_64 (macos-latest) (push) Has been cancelled
Build & Publish / Build Python wheels aarch64 (ubuntu-latest) (push) Has been cancelled
Build & Publish / Build Python wheels x86_64 (ubuntu-latest) (push) Has been cancelled
Build & Publish / Build Python wheels x86_64 (windows-latest) (push) Has been cancelled
Build & Publish / Build Python sdist (push) Has been cancelled
Build & Publish / Release (push) Has been cancelled
Build & Publish / Publish Python wheels to PyPI (push) Has been cancelled
Build & Publish / Publish Rust crates (push) Has been cancelled
Build & Publish / Publish npm packages (push) Has been cancelled
2026-08-15 14:35:20 -07:00
Kh05ifr4nD ffd8eb9c08 fix(mcp): standalone constraints in multi_grep (#753)
* fix: apply standalone constraints in multi_grep

* chore: use pub(crate)

---------

Co-authored-by: Dmitriy Kovalenko <dmitriy@iusevimbtw.com>
2026-08-14 22:11:24 -07:00
Dmitriy Kovalenko ed6b40a591 chore(deps): Update zlob to 1.6.3 (#777) 2026-08-14 21:47:10 -07:00
Dmitriy Kovalenko c4f6c600d6 fix(core): share one LMDB env per path within a process (#775)
because LMDB usee posix lock one process can not hold many open
databases
2026-08-14 21:46:46 -07:00
Gustav the Bot 787a9b1cb7 fix: rebuild file picker after FFFClearCache files (#772) (#773)
clear_cache('files') drops the Rust picker but core.lua kept
state.file_picker_initialized set, so ensure_initialized short-circuited
on state.initialized and never rebuilt it. Every later FFFScan/search then
ran against a dropped picker (watcher logs "File picker not initialized"),
which SIGSEGVs on Linux.

Split ensure_initialized: one-time setup stays gated on state.initialized,
picker creation is gated separately on state.file_picker_initialized.
clear_cache now clears that flag via mark_file_picker_uninitialized so the
next ensure_initialized rebuilds the picker.

Closes #772

Co-authored-by: gustav-fff <286169375+gustav-fff@users.noreply.github.com>
2026-08-14 09:10:03 -07:00
Dmitriy Kovalenko 6398d32c0c fix: Do not exit MCP if the parent process is alive (#770)
closes #703

Bumped inactivity timeout to an hour and make it actually check every
minute if parent is alive and working
2026-08-13 17:39:14 -07:00
Gustav the Bot c6194b848d fix(mcp): declare readOnlyHint on all tools (#771) (#774)
find_files, grep, and multi_grep only read the filesystem but declared
no MCP tool annotations, so plan-mode / read-only clients blocked them.
Add annotations(read_only_hint = true, destructive_hint = false,
open_world_hint = false) to each #[tool].

Closes #771

Co-authored-by: gustav-fff <286169375+gustav-fff@users.noreply.github.com>
2026-08-13 08:42:55 -07:00
Gustav the Bot b71b7cf4ac fix(pi-fff): cap grep total results with pageSize, clamp context (#768) (#769)
ffgrep/fff-multi-grep passed the documented `limit` only as
`maxMatchesPerFile`, so matches spread across files could return a full
SDK page (default 50) instead of `limit`. Pass `limit` as `pageSize`
too, on both grep tools and the fuzzy fallback, so the cap applies to
total matches per page; excess stays retrievable via the existing
cursor. Also clamp `context` to a bounded non-negative integer so a
large value cannot multiply output size past the model window.

Closes #768

Co-authored-by: gustav-fff <286169375+gustav-fff@users.noreply.github.com>
2026-08-13 07:45:48 -07:00
Joe Taber cc289f0f93 chore: rename old fff.nvim repo refs (#762)
* chore: point residual repo refs at dmtrKovalenko/fff

Update install URLs, download targets, and docs after the GitHub
rename from fff.nvim to fff. Preserve the existing Neovim package
name (fff.nvim) in lazy/vim.pack snippets so upgraders keep the same
install dir and lockfile identity.

* chore(docs): align Neovim package name with repo basename

Drop the explicit fff.nvim package name so lazy/vim.pack use fff from
the repository URL. Mild migration cost for existing installs (new
plugin dir + clean of the old one). Drop this commit to keep the
lowest-pain name=fff.nvim install snippets from the previous commit.
2026-08-11 21:40:21 -07:00
Gustav the Bot 2107f038dc fix(grep): keep FilePath scope in regex/literal fallback (#756) (#764)
* fix(grep): keep FilePath scope in regex/literal fallback (#756)

The literal/regex fallback rebuilt the query with empty constraints,
dropping an explicit inline FilePath scope. In regex mode a top-level
alternation then leaked matches into files outside the pinned path.
Preserve FilePath constraints in the fallback query.

Closes #756

* chore: cargo fmt (#756)

---------

Co-authored-by: gustav-fff <286169375+gustav-fff@users.noreply.github.com>
2026-08-11 20:54:36 -07:00
Dmitriy Kovalenko 9441cbc5ae fix(bun): Add a proper build step for fff-bun (#766)
We never build it and this seems to be not a good idea
2026-08-11 20:45:40 -07:00
Gustav the Bot dd87489d6b fix: add missing clap attribute to no_content_indexing (#754) (#765)
The no_content_indexing field lacked its #[arg(long = ...)] attribute,
so clap treated it as a positional with a SetTrue action. Debug builds
panicked on any invocation; release builds exposed [NO_CONTENT_INDEXING]
as a positional and rejected --no-content-indexing.

Closes #754

Co-authored-by: gustav-fff <286169375+gustav-fff@users.noreply.github.com>
2026-08-11 11:28:58 -07:00
chenydev d4c416c2fc fix(pi-fff): cache sdk import across reloads to avoid hang (#758)
Pi reloads extension modules with jiti moduleCache:false, so loadSdk()
re-executes a dynamic import of the fff-bun module graph on every /reload.
The fff-bun graph top-level awaits a type:file import of the native .so,
which never resolves when re-imported inside the Bun-compiled pi binary,
leaving the reload screen stuck forever (pi awaits session_start handlers
without a timeout).

Cache the first import on globalThis so reloads reuse it.

Closes #757

Co-authored-by: chenydev <chenydev@users.noreply.github.com>
2026-08-08 19:28:16 -07:00
dmtrKovalenko b6f351d729 chore: bump fff-mcp release artifacts to v0.10.3 2026-08-07 04:55:45 +00:00
Dmitriy Kovalenko e2cad2f09e chore: release 0.10.3
Build & Publish / Build Neovim aarch64-apple-darwin (push) Has been cancelled
Build & Publish / Build Neovim aarch64-linux-android (push) Has been cancelled
Build & Publish / Build Neovim aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build Neovim aarch64-unknown-linux-gnu (push) Has been cancelled
Build & Publish / Build Neovim aarch64-unknown-linux-musl (push) Has been cancelled
Build & Publish / Build Neovim x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build Neovim x86_64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build Neovim x86_64-unknown-linux-gnu (push) Has been cancelled
Build & Publish / Build Neovim x86_64-unknown-linux-musl (push) Has been cancelled
Build & Publish / Build C FFI aarch64-apple-darwin (push) Has been cancelled
Build & Publish / Build C FFI aarch64-linux-android (push) Has been cancelled
Build & Publish / Build C FFI aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build C FFI aarch64-unknown-linux-gnu (push) Has been cancelled
Build & Publish / Build C FFI aarch64-unknown-linux-musl (push) Has been cancelled
Build & Publish / Build C FFI x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build C FFI x86_64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build C FFI x86_64-unknown-linux-gnu (push) Has been cancelled
Build & Publish / Build C FFI x86_64-unknown-linux-musl (push) Has been cancelled
Build & Publish / Build MCP aarch64-apple-darwin (push) Has been cancelled
Build & Publish / Build MCP aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build MCP aarch64-unknown-linux-gnu (push) Has been cancelled
Build & Publish / Build MCP aarch64-unknown-linux-musl (push) Has been cancelled
Build & Publish / Build MCP x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build MCP x86_64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build MCP x86_64-unknown-linux-gnu (push) Has been cancelled
Build & Publish / Build Python wheels aarch64 (macos-latest) (push) Has been cancelled
Build & Publish / Build Python wheels x86_64 (macos-latest) (push) Has been cancelled
Build & Publish / Build Python wheels x86_64 (windows-latest) (push) Has been cancelled
Build & Publish / Build Python sdist (push) Has been cancelled
Build & Publish / Build MCP x86_64-unknown-linux-musl (push) Has been cancelled
Build & Publish / Build Python wheels aarch64 (ubuntu-latest) (push) Has been cancelled
Build & Publish / Build Python wheels x86_64 (ubuntu-latest) (push) Has been cancelled
Build & Publish / Release (push) Has been cancelled
Build & Publish / Publish Python wheels to PyPI (push) Has been cancelled
Build & Publish / Publish Rust crates (push) Has been cancelled
Build & Publish / Publish npm packages (push) Has been cancelled
2026-08-06 20:26:51 -07:00
Gustav the Bot 4afad820b4 fix(nvim): refuse fs-root/home index at Lua level before FFI (#745) (#748)
* fix(nvim): refuse fs-root/home index at Lua level before FFI (#745)

Opening nvim at `/` with lazy=false crashed the whole neovim process on
CI-cross-compiled aarch64 .so binaries: the init_file_picker FFI call
SIGSEGVs instead of returning Error::FilesystemRoot cleanly. A SIGSEGV
is a hardware signal that the pcall around init_file_picker cannot catch.

Mirror the Rust refusal (file_picker.rs:862) in Lua and bail before
crossing the FFI boundary, so the crashing path is never reached. Same
guard added to change_indexing_directory for the :cd-into-root case.
Honors enable_fs_root_scanning / enable_home_dir_scanning overrides.

* chore(nvim): trim comments in fs-root/home refusal guard

---------

Co-authored-by: gustav-fff <286169375+gustav-fff@users.noreply.github.com>
2026-08-06 20:25:43 -07:00
Gustav the Bot 7af3a9dab6 fix(pi-fff): make home-dir scanning configurable, warn when indexing $HOME (#743) (#749)
Expose home-dir scanning as --fff-enable-home-scan flag + FFF_ENABLE_HOME_SCAN
env, default true. resolveBoolOpt takes a fallback and accepts 0/false. Threaded
through the main finder and AuxFinderPool.

Cache os.homedir() once per process in src/paths.ts. AuxOpts.onHomeDirScan fires
whenever the agent spawns an aux picker rooted at or above $HOME. On session
start from $HOME, notify the user and track scan progress with a 1s poller that
clears the footer once the scan settles; the interval is unref'd and cleared on
shutdown and in destroyFinder().

Co-authored-by: gustav-fff <286169375+gustav-fff@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: dmtrKovalenko <16926049+dmtrKovalenko@users.noreply.github.com>
2026-08-06 20:25:33 -07:00
Dmitriy Kovalenko 031005e227 fix: Reduce amount of rescans in giant /Users/neogoose like folders (#751) 2026-08-06 19:47:41 -07:00
Dmitriy Kovalenko 3a0ce85c54 fix: Correctly handle empty directories during the scan (#735)
* fix: Correctly handle empty directories during the scan

Closes #725

Before we have completely ignored empty directories partially as a
feature cause usually they do not contain anything useful but there is a
bug #725 that we need to fix and it definetely makes sense to show empty
directories in the dir search

* fix: Gitignore incompatbility

Closes https://github.com/dmtrKovalenko/fff/issues/723 fixed in zlob

* more efficient way to track subdirs
2026-08-05 20:09:48 -07:00
Gustav the Bot 695724eb4a fix(pi-fff): dedup concurrent aux finders and bound grep time (#746) (#750)
* fix(pi-fff): dedup concurrent aux finders and bound grep time (#746)

Concurrent AuxFinderPool.acquire() calls for the same root each started a
full scan because entries was populated only after waitForScan() resolved.
Coalesce in-flight creations by root via a pending map. Also pass a finite
timeBudgetMs to native grep (sync call, uninterruptible by AbortSignal) and
skip the fuzzy fallback when the exact pass left a nextCursor.

Refs #746

* Apply suggestions from code review

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

* fix typo

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

---------

Co-authored-by: gustav-fff <286169375+gustav-fff@users.noreply.github.com>
Co-authored-by: Dmitriy Kovalenko <dmitriy@iusevimbtw.com>
2026-08-05 18:41:02 -07:00
Dmitriy Kovalenko 086044f5f7 fix: Gitignore incompatbility (#744)
Closes https://github.com/dmtrKovalenko/fff/issues/723 fixed in zlob
2026-08-04 21:19:14 -07:00
Gustav the Bot 3298ed9f39 fix(pi-fff): prefix promptGuidelines with tool name (#737) (#741)
Per pi extension docs, each guideline in promptGuidelines is appended
to the flat Guidelines block without tool grouping, so the LLM cannot
tell which tool owns which bullet. Prefix each entry with the dynamic
tool name (toolNames.grep / toolNames.find / toolNames.multiGrep) so
guidelines stay correct in override mode too.

Co-authored-by: gustav-fff <286169375+gustav-fff@users.noreply.github.com>
2026-08-04 11:28:43 -07:00
Gustav the Bot 1eb913e509 chore(deps): bump git2 to 0.21.0 to clear RUSTSEC-2026-0183/0184 (#733) (#734)
git2 0.20.4 carries two informational=unsound advisories that surface in
downstream cargo-audit/cargo-deny runs. fff does not call the affected
APIs (Remote::list, Blame::blame_buffer), but bumping clears the noise
for consumers.

0.21.0 changes StatusEntry::path() to return Result<&str, git2::Error>
instead of Option<&str>; adjust the two callers in fff-core.

Closes #733

Co-authored-by: gustav-fff <286169375+gustav-fff@users.noreply.github.com>
2026-07-30 19:17:29 -07:00
Gustav the Bot e453d007d5 chore: regenerate Neovim vimdoc (#732)
Co-authored-by: Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>
2026-07-30 17:55:57 -07:00
Gustav the Bot 9033efb60f fix(fff-mcp): compare CARGO_PKG_VERSION to stable release tag (#721) (#729)
* fix(fff-mcp): compare CARGO_PKG_VERSION to stable release tag (#721)

The update check compared the embedded 40-char git SHA prefix against
the release tag string, which never matched a semver tag. It also hit
/releases?per_page=1 (prerelease-inclusive) while install-mcp.sh is
pinned to a stable tag, so the suggested installer could not silence
the notice when the API returned a nightly.

Compare CARGO_PKG_VERSION against /releases/latest (stable-only) with
a leading `v` stripped from the tag.

Closes #721

* test(fff-node): retry watch subscribe in exit test

---------

Co-authored-by: gustav-fff <286169375+gustav-fff@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-29 18:29:52 -07:00
Mikhail Wijanarko ea2ee6b341 fix(pi-fff): pin SDK dependencies during publish (#712)gg 2026-07-29 17:14:13 -07:00
Vinicios Lugli 84a9cd556f feat(mcp): expose home and fs root scanning options (#720)
fff-core gates indexing of $HOME and / behind enable_home_dir_scanning and
enable_fs_root_scanning, and every other surface (C, python, nvim, node/bun)
exposes them. fff-mcp did not, so it aborts at startup whenever an editor or
agent launches it from a home directory.

Add --enable-home-scan and --enable-root-scan, also settable via
FFF_ENABLE_HOME_SCAN and FFF_ENABLE_ROOT_SCAN. Both default off, so the guard
is unchanged unless opted into.
2026-07-29 11:09:19 -07:00
jadonwb 9ec35bd101 chore(readme): fix neovim borders instructions (#727)
I apologize, but in my last pr I missed this typo, it is actually 5 junction border characters and not 6.
2026-07-29 11:09:00 -07:00
Timothy John Perisho Eccleston edda2cbef8 chore(readme): fix typo in README.md (#731)
* typo in README.md

* typo in README.md
2026-07-29 11:08:29 -07:00
Dmitriy Kovalenko 2cf871210b chore(readme): Add #1 badge 2026-07-29 08:24:12 -07:00
Gustav the Bot fbee146c44 chore: regenerate Neovim vimdoc (#718)
Co-authored-by: Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>
2026-07-28 12:41:01 -07:00
jadonwb 6a239e9875 feat: accept custom border character arrays (#715)
Allow users to pass a custom border table instead of just preset
strings. Supports a table { border_chars, junction_chars } and falls
back to existing preset system when a string is passed
2026-07-27 14:32:12 -07:00
Gustav the Bot fde8c52a29 chore: regenerate Neovim vimdoc (#709)
Co-authored-by: Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>
2026-07-23 22:09:16 -07:00
Dmitriy Kovalenko 84a9b50dd7 fix(nix): Prevent illegal instructions sneaking into nix builds (#707) 2026-07-23 16:54:14 -07:00
Dmitriy Kovalenko 63b126e7b0 fix: Not finding if needle contains != (#706) 2026-07-23 13:01:29 -07:00
Dmitriy Kovalenko 167f7d67cb chore: Fix CI (#704)
closes https://github.com/dmtrKovalenko/fff/issues/702
2026-07-23 11:49:29 -07:00
Gustav the Bot 829bfa9570 fix(pi-fff): preserve path/exclude in fuzzy grep fallback (#697) (#699)
* fix(pi-fff): preserve path and exclude constraints in fuzzy grep fallback (#697)

The automatic fuzzy fallback in the pi-fff `grep` tool passed the raw
`pattern` to `picker.grep`, discarding the constrained `query` built via
`buildQuery`. As a result, the fallback ignored the caller's `path` and
`exclude` constraints and could return matches from explicitly excluded
directories or files outside the requested path.

Pass the constrained `query` to the fallback instead, so it only broadens
matching (fuzzy vs. plain) without broadening scope.

Closes #697

* fix(pi-fff): drop path constraint in fuzzy fallback only for file paths

When the caller pinned a specific file (path has an extension), the
fuzzy fallback broadens across the whole picker so a mistyped filename
can still surface matches. For directory constraints (or no path), keep
the constrained query so the fallback does not leak matches from
excluded / out-of-scope directories.

---------

Co-authored-by: gustav-fff <286169375+gustav-fff@users.noreply.github.com>
2026-07-23 11:38:42 -07:00
Gustav the Bot 9bab609ddf fix(pi-fff): temporarily remove aux pickers frecency db access (#700) (#701)
LMDB envs may only be opened once per process. AuxFinderPool was forwarding
the main finder's frecencyDbPath / historyDbPath into every aux FileFinder,
so the first out-of-workspace search failed with
"environment already open in this program".

Aux finders are transient and per-search; they run without persistent
frecency/history scoring.

Closes #700

Co-authored-by: gustav-fff <286169375+gustav-fff@users.noreply.github.com>
2026-07-23 11:05:33 -07:00
dmtrKovalenko 05a35c6d48 chore: bump fff-mcp release artifacts to v0.10.1 2026-07-20 23:48:58 +00:00
Dmitriy Kovalenko 686a84959d chore: release 0.10.1
Build & Publish / Build Neovim aarch64-apple-darwin (push) Has been cancelled
Build & Publish / Build Neovim aarch64-linux-android (push) Has been cancelled
Build & Publish / Build Neovim aarch64-unknown-linux-gnu (push) Has been cancelled
Build & Publish / Build Neovim aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build Neovim aarch64-unknown-linux-musl (push) Has been cancelled
Build & Publish / Build Neovim x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build Neovim x86_64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build Neovim x86_64-unknown-linux-gnu (push) Has been cancelled
Build & Publish / Build Neovim x86_64-unknown-linux-musl (push) Has been cancelled
Build & Publish / Build C FFI aarch64-apple-darwin (push) Has been cancelled
Build & Publish / Build C FFI aarch64-linux-android (push) Has been cancelled
Build & Publish / Build C FFI aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build C FFI aarch64-unknown-linux-gnu (push) Has been cancelled
Build & Publish / Build C FFI aarch64-unknown-linux-musl (push) Has been cancelled
Build & Publish / Build C FFI x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build C FFI x86_64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build C FFI x86_64-unknown-linux-gnu (push) Has been cancelled
Build & Publish / Build C FFI x86_64-unknown-linux-musl (push) Has been cancelled
Build & Publish / Build MCP aarch64-apple-darwin (push) Has been cancelled
Build & Publish / Build MCP aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build MCP aarch64-unknown-linux-gnu (push) Has been cancelled
Build & Publish / Build MCP aarch64-unknown-linux-musl (push) Has been cancelled
Build & Publish / Build MCP x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build MCP x86_64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build MCP x86_64-unknown-linux-gnu (push) Has been cancelled
Build & Publish / Build MCP x86_64-unknown-linux-musl (push) Has been cancelled
Build & Publish / Build Python wheels aarch64 (macos-latest) (push) Has been cancelled
Build & Publish / Build Python wheels x86_64 (macos-latest) (push) Has been cancelled
Build & Publish / Build Python wheels aarch64 (ubuntu-latest) (push) Has been cancelled
Build & Publish / Build Python wheels x86_64 (ubuntu-latest) (push) Has been cancelled
Build & Publish / Build Python wheels x86_64 (windows-latest) (push) Has been cancelled
Build & Publish / Build Python sdist (push) Has been cancelled
Build & Publish / Release (push) Has been cancelled
Build & Publish / Publish Python wheels to PyPI (push) Has been cancelled
Build & Publish / Publish Rust crates (push) Has been cancelled
Build & Publish / Publish npm packages (push) Has been cancelled
2026-07-20 16:05:07 -07:00
Gustav the Bot 11a0d447a6 chore(node & bun): add Android arm64 (Termux) support (#695)
Adds @ff-labs/fff-bin-android-arm64 platform package and wires the
existing aarch64-linux-android CI build into the npm publishing matrix.
Extends getTriple() in fff-node and fff-bun to map process.platform
'android' to linux-android, and adds android to os arrays with the new
optionalDependency. Closes #692.

Co-authored-by: gustav-fff <286169375+gustav-fff@users.noreply.github.com>
2026-07-20 15:17:17 -07:00
ziqian li 9dee049973 fix(pi-fff): handle Windows cross-volume external paths (#684)
On Windows, `path.relative()` returns an absolute path when the source
and target are on different drives (e.g. `D:\` → `C:\`). The existing
check in `routePathConstraint()` only recognizes `".."` and `"..\..."`
as indicators that a path is outside the workspace — it misses the
cross-volume case entirely. The path is then treated as workspace-local,
and downstream code rejects it with:

    Path constraint must be relative to the workspace

Extract the workspace-outside check into
`isOutsideWorkspaceRelativePath()` so the logic is testable in
isolation, and add `path.isAbsolute()` to the condition. A cross-volume
relative result is by definition outside the workspace.

Add a Windows-specific regression test that verifies the helper
recognizes a cross-volume `path.win32.relative()` result as outside the
workspace. The test is gated on `process.platform === "win32"` and has
no effect on Linux or macOS CI runs.
2026-07-20 14:45:10 -07:00
Gustav the Bot 16a666b095 fix(pi-fff): install fff-bun so bun runtimes can load the SDK (#689) (#694)
PR #669 made sdk.ts dynamically import @ff-labs/fff-bun when running under
bun, but only added it as an optional peer dependency. Package managers do
not install optional peers by default, so `pi install npm:@ff-labs/pi-fff`
on a bun-only host produced a node_modules tree without @ff-labs/fff-bun
and pi crashed at session_start with "Cannot find module '@ff-labs/fff-bun'".

Promote both SDKs to regular deps. Bundle size cost is minor (SDKs are TS
shims over the same optional native bins) and this guarantees the dynamic
import in sdk.ts always resolves regardless of runtime or installer.

Closes #689

Co-authored-by: gustav-fff <286169375+gustav-fff@users.noreply.github.com>
2026-07-20 11:44:34 -07:00
Anton Panasenko 073698c8e7 fix(mcp): use absolute path for Codex setup (#685) 2026-07-17 13:27:45 -07:00
dmtrKovalenko b14c31d137 chore: bump fff-mcp release artifacts to v0.10.0 2026-07-16 20:28:10 +00:00
Dmitriy Kovalenko 31be224223 chore: release 0.10.0
Build & Publish / Build Neovim aarch64-apple-darwin (push) Has been cancelled
Build & Publish / Build Neovim aarch64-linux-android (push) Has been cancelled
Build & Publish / Build Neovim aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build Neovim aarch64-unknown-linux-gnu (push) Has been cancelled
Build & Publish / Build Neovim x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build Neovim x86_64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build Neovim x86_64-unknown-linux-gnu (push) Has been cancelled
Build & Publish / Build Neovim aarch64-unknown-linux-musl (push) Has been cancelled
Build & Publish / Build Neovim x86_64-unknown-linux-musl (push) Has been cancelled
Build & Publish / Build C FFI aarch64-apple-darwin (push) Has been cancelled
Build & Publish / Build C FFI aarch64-linux-android (push) Has been cancelled
Build & Publish / Build C FFI aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build C FFI aarch64-unknown-linux-gnu (push) Has been cancelled
Build & Publish / Build C FFI aarch64-unknown-linux-musl (push) Has been cancelled
Build & Publish / Build C FFI x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build C FFI x86_64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build C FFI x86_64-unknown-linux-gnu (push) Has been cancelled
Build & Publish / Build C FFI x86_64-unknown-linux-musl (push) Has been cancelled
Build & Publish / Build MCP aarch64-apple-darwin (push) Has been cancelled
Build & Publish / Build MCP aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build MCP aarch64-unknown-linux-gnu (push) Has been cancelled
Build & Publish / Build MCP aarch64-unknown-linux-musl (push) Has been cancelled
Build & Publish / Build MCP x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build MCP x86_64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build MCP x86_64-unknown-linux-gnu (push) Has been cancelled
Build & Publish / Build MCP x86_64-unknown-linux-musl (push) Has been cancelled
Build & Publish / Build Python wheels aarch64 (macos-latest) (push) Has been cancelled
Build & Publish / Build Python wheels x86_64 (macos-latest) (push) Has been cancelled
Build & Publish / Build Python wheels aarch64 (ubuntu-latest) (push) Has been cancelled
Build & Publish / Build Python wheels x86_64 (ubuntu-latest) (push) Has been cancelled
Build & Publish / Build Python wheels x86_64 (windows-latest) (push) Has been cancelled
Build & Publish / Build Python sdist (push) Has been cancelled
Build & Publish / Release (push) Has been cancelled
Build & Publish / Publish Python wheels to PyPI (push) Has been cancelled
Build & Publish / Publish Rust crates (push) Has been cancelled
Build & Publish / Publish npm packages (push) Has been cancelled
2026-07-16 12:15:31 -07:00
Dmitriy Kovalenko a9df55d18e feat(sdk): Expose file watcher (#674)
* feat: Expose fff watcher

* fix: Snapshots

* fix: Snapshots

* fix: CI cache
2026-07-16 12:14:10 -07:00
Gustav the Bot 42f38ff66e chore: regenerate Neovim vimdoc (#683)
Co-authored-by: Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>
2026-07-16 09:35:47 -07:00
Ionut Ciolan 57b6773f4c feat(sdk): add stable FffResult C accessors (#681)
Co-authored-by: Ionut Adrian Ciolan <iadrian@ciolan.net>
2026-07-15 17:44:46 -07:00
Gustav the Bot 9a637ccd73 fix(pi-fff): Allow pi to search in non cwd folders using fff(#622)
* fix(pi-fff): route out-of-workspace path constraints to a rotating aux finder pool

Hotfix prototype for #463. When the agent passes an absolute `path`
outside the workspace cwd to ffgrep/fffind, spin up (or reuse) a
FileFinder rooted at that path instead of throwing
"Path constraint must be relative to the workspace".

Pool keeps at most 3 aux finders, LRU-evicted, dropped after 5 minutes
of inactivity. Find pagination cursors carry the aux root so resumes
hit the same finder.

* fix: Redesign the aux finder

---------

Co-authored-by: gustav-fff <286169375+gustav-fff@users.noreply.github.com>
Co-authored-by: Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>
2026-07-15 17:42:25 -07:00
Karavellas 93b063b9d2 fix(picker_ui): highlight fuzzy query matches on file picker (#673)
* fix(picker_ui): highlight fuzzy query matches on file picker

* fix(file-picker): gate fuzzy query highlights behind config

* fix(file-picker): use rust fuzzy match ranges for highlights

Compute fuzzy match byte ranges on the Rust side for paginated file results and pass them through the nvim Lua binding.
2026-07-15 14:53:52 -07:00
Gustav the Bot a97adaccd8 docs(readme): add FAQ answering what FFF stands for (#680)
* docs(readme): add FAQ answering what FFF stands for

Closes #679

* docs(readme): move FFF expansions into branding subheader with logo links

* docs(readme): move FFF expansions from branding subheader to FAQ section

---------

Co-authored-by: dmtrKovalenko <dmitriy@iusevimbtw.com>
2026-07-15 14:10:14 -07:00
Gustav the Bot d1dac82ca7 fix(pi-fff): isomorphic bun/node SDK lazy-load (#669)
* fix(pi-fff): isomorphic runtime detection with lazy SDK load

Detect bun vs node at runtime and dynamically import the matching SDK
(@ff-labs/fff-bun on bun, @ff-labs/fff-node on node) via a variable
package name so oh-my-pi's static extension validator does not chase
ffi-rs' optional native binaries through the module graph.

Closes #668

* fix(pi-fff): fail loud on wrong-runtime SDK

Drop the cross-runtime SDK fallback in loadSdk. Falling back to the
node SDK on a bun host re-introduces the ffi-rs cost this PR is meant
to avoid, and hides packaging bugs where the correct SDK is missing.

---------

Co-authored-by: gustav-fff <286169375+gustav-fff@users.noreply.github.com>
2026-07-15 14:09:25 -07:00
Gustav the Bot 63fac0b455 chore: regenerate Neovim vimdoc (#677)
Co-authored-by: Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>
2026-07-14 22:06:20 -07:00
Dmitriy Kovalenko a487120c32 feat: Mulitline search (#676) 2026-07-14 20:28:04 -07:00
Gustav the Bot 44a5b25957 chore(deps): bump crossbeam-epoch, anyhow, memmap2 for RustSec advisories (#670) (#671) 2026-07-11 09:15:14 -07:00
Dmitriy Kovalenko b4590ca108 refactor: Split grep.rs into a few modules (#667)
* refactor: Split grep.rs into a few modules

* fix(git regression): Watch reflog for consistent git events burst handling
2026-07-10 02:35:44 -07:00
165 changed files with 14186 additions and 4576 deletions
+11 -15
View File
@@ -18,6 +18,10 @@ env:
# Force Node 24 for all JS-based actions to avoid the libuv
# process_title assertion crash on Windows (known Node 20 bug).
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
# e2e only needs a working binary, so skip fat LTO (same settings as the `ci`
# profile releases ship). Overriding release keeps artifacts in target/release.
CARGO_PROFILE_RELEASE_LTO: thin
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 16
jobs:
lua-tests:
@@ -32,7 +36,6 @@ jobs:
- os: ubuntu-latest
- os: macos-latest
- os: windows-latest
target: x86_64-pc-windows-msvc
steps:
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
@@ -49,20 +52,13 @@ jobs:
cache-on-failure: false
cache-key: "v2-lua-e2e"
rustflags: ""
target: ${{ matrix.target || '' }}
- name: Build Rust binary (Windows)
if: matrix.target
run: cargo build --release --target ${{ matrix.target }} -p fff-nvim --no-default-features --features zlob
- name: Copy binary to target/release (Windows)
if: matrix.target
- name: Build Rust binary
shell: bash
run: |
cp target/${{ matrix.target }}/release/fff_nvim.dll target/release/fff_nvim.dll
run: make build-e2e
- name: Verify Windows DLL has no unexpected dependencies
if: matrix.target
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
# Find dumpbin via vswhere (always available on GitHub Actions Windows runners)
@@ -78,10 +74,6 @@ jobs:
exit 1
}
- name: Build Rust binary
if: ${{ !matrix.target }}
run: cargo build --release -p fff-nvim --no-default-features --features zlob
- name: Install Neovim
uses: rhysd/action-setup-vim@v1
with:
@@ -208,6 +200,10 @@ jobs:
{ "name": "@ff-labs/fff-bin-linux-x64-musl", "version": "0.0.0" }
JSON
# fff-node builds with bun; the npm `bun` package ships musl binaries
- name: Install bun
run: npm install -g bun
- name: Build fff-node
working-directory: packages/fff-node
run: npm run build
+10 -2
View File
@@ -15,6 +15,8 @@ on:
env:
CARGO_TERM_COLOR: always
MACOSX_DEPLOYMENT_TARGET: "13.0"
CARGO_PROFILE_RELEASE_LTO: thin
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 16
jobs:
test:
@@ -31,7 +33,11 @@ jobs:
persist-credentials: false
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
uses: actions-rust-lang/setup-rust-toolchain@v1.15.4
with:
cache: true
cache-on-failure: true
cache-key: "v1-rust-python"
- name: Setup uv
uses: astral-sh/setup-uv@v5
@@ -43,6 +49,8 @@ jobs:
working-directory: packages/fff-python
shell: bash
run: |
uv sync --all-extras
# --no-install-project: uv would otherwise build the wheel via
# maturin's PEP 517 backend, then `maturin develop` builds it again.
uv sync --all-extras --no-install-project
uv run maturin develop --release
uv run pytest -v
+26 -5
View File
@@ -84,6 +84,13 @@ jobs:
- name: Install Rust
run: rustup target add ${{ matrix.target }}
# Cache the per-target build dir and cargo-zigbuild binary. Keyed by
# target so matrix legs don't collide. See issue on slow release CI.
- name: Rust cache
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
with:
key: nvim-${{ matrix.target }}
- name: Install Zig
uses: mlugg/setup-zig@v2
with:
@@ -176,6 +183,7 @@ jobs:
- os: ubuntu-latest
target: aarch64-linux-android
artifact_name: target/aarch64-linux-android/ci/libfff_c.so
npm_package: fff-bin-android-arm64
lib_filename: libfff_c.so
ext: so
@@ -215,6 +223,11 @@ jobs:
- name: Install Rust
run: rustup target add ${{ matrix.target }}
- name: Rust cache
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
with:
key: c-${{ matrix.target }}
- name: Install Zig
uses: mlugg/setup-zig@v2
with:
@@ -261,7 +274,6 @@ jobs:
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
- name: Prepare npm package
if: "!contains(matrix.target, 'android')"
shell: bash
run: |
# Copy the built binary into the platform npm package directory
@@ -274,7 +286,6 @@ jobs:
path: c-lib-${{ matrix.target }}.*
- name: Upload npm package artifact
if: "!contains(matrix.target, 'android')"
uses: actions/upload-artifact@v4
with:
name: npm-${{ matrix.npm_package }}
@@ -328,6 +339,11 @@ jobs:
- name: Install Rust
run: rustup target add ${{ matrix.target }}
- name: Rust cache
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
with:
key: mcp-${{ matrix.target }}
- name: Install Zig
uses: mlugg/setup-zig@v2
with:
@@ -466,7 +482,10 @@ jobs:
permissions:
contents: write
steps:
# we have to make sure that pushing a commit on this workflow triggers the CI for nightly neovim
- uses: actions/checkout@v5
with:
token: ${{ secrets.GUSTAV_PAT || github.token }}
- name: Install Lua
uses: leafo/gh-actions-lua@v12
@@ -593,7 +612,7 @@ jobs:
Update mcp via:
```sh
curl -fsSL https://raw.githubusercontent.com/dmtrKovalenko/fff.nvim/main/install-mcp.sh | bash
curl -fsSL https://raw.githubusercontent.com/dmtrKovalenko/fff/main/install-mcp.sh | bash
```
- name: Bump Homebrew formula (uses local checksums)
@@ -605,8 +624,6 @@ jobs:
run: make bump-install-mcp-sh VERSION="${{ steps.version.outputs.version }}" BINARIES_DIR=./binaries
- name: Commit formula + installer bump to main
# Uses the default GITHUB_TOKEN configured by actions/checkout above.
# Requires github-actions[bot] in the main branch-protection bypass list.
if: steps.version.outputs.is_release == 'true' && github.repository_owner == 'dmtrKovalenko'
uses: stefanzweifel/git-auto-commit-action@v5
with:
@@ -693,6 +710,8 @@ jobs:
node-version: "25"
registry-url: "https://registry.npmjs.org"
- uses: oven-sh/setup-bun@v2
- name: Determine version
id: version
run: lua scripts/determine-version.lua
@@ -730,6 +749,8 @@ jobs:
make set-npm-version PKG=packages/fff-bun VERSION="$VERSION"
cd packages/fff-bun
bun install --frozen-lockfile
bun run build
npm publish --tag "$TAG" --access public --provenance
- name: Publish Node.js package
+2 -1
View File
@@ -47,7 +47,8 @@ jobs:
components: rustfmt, clippy
- name: Run tests
run: cargo test --no-default-features --features zlob --workspace --exclude fff-nvim
# fff-python requires full python o3 machinery which is very slow
run: cargo test --no-default-features --features zlob --workspace --exclude fff-nvim --exclude fff-python
stress-test:
name: Fuzz Tests
+1 -1
View File
@@ -1,4 +1,4 @@
-- luacheck configuration for fff.nvim
-- luacheck configuration for fff
-- https://luacheck.readthedocs.io/en/stable/config.html
-- Neovim globals
+7
View File
@@ -31,6 +31,13 @@ When doing code make sure to REDUCE SIZE OF COMMENTS. This is very important. Ev
- Do not make public structs if something can be private
## Style guide
- NO MODULES COMMENTS
- NO TOP FILE COMMENTS
- NO COMMENT LONGER THAN 2 LINES UNLESS ASKED EXPLICITLY
- UTILITY FUNCTIONS GO INTO THE END OF FILE
## Architecture
Everything that is performance critical happens in rust world, everything that is neovim specific happens in the lua code.
Generated
+21 -20
View File
@@ -91,9 +91,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.102"
version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "arrayref"
@@ -440,9 +440,9 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
@@ -633,7 +633,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fff-c"
version = "0.9.6"
version = "0.10.4"
dependencies = [
"fff-query-parser",
"fff-search",
@@ -643,7 +643,7 @@ dependencies = [
[[package]]
name = "fff-grep"
version = "0.9.6"
version = "0.10.4"
dependencies = [
"bstr",
"memchr",
@@ -651,7 +651,7 @@ dependencies = [
[[package]]
name = "fff-mcp"
version = "0.9.6"
version = "0.10.4"
dependencies = [
"clap",
"fff-query-parser",
@@ -662,8 +662,10 @@ dependencies = [
"schemars",
"serde",
"serde_json",
"tempfile",
"tokio",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
@@ -682,7 +684,7 @@ dependencies = [
[[package]]
name = "fff-nvim"
version = "0.9.6"
version = "0.10.4"
dependencies = [
"ahash",
"chrono",
@@ -703,7 +705,7 @@ dependencies = [
[[package]]
name = "fff-python"
version = "0.9.6"
version = "0.10.4"
dependencies = [
"fff-query-parser",
"fff-search",
@@ -713,7 +715,7 @@ dependencies = [
[[package]]
name = "fff-query-parser"
version = "0.9.6"
version = "0.10.4"
dependencies = [
"criterion",
"zlob",
@@ -721,7 +723,7 @@ dependencies = [
[[package]]
name = "fff-search"
version = "0.9.6"
version = "0.10.4"
dependencies = [
"ahash",
"aho-corasick",
@@ -925,15 +927,14 @@ dependencies = [
[[package]]
name = "git2"
version = "0.20.4"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b"
checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e"
dependencies = [
"bitflags 2.11.0",
"libc",
"libgit2-sys",
"log",
"url",
]
[[package]]
@@ -1333,9 +1334,9 @@ checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]]
name = "libgit2-sys"
version = "0.18.3+1.9.2"
version = "0.18.7+1.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487"
checksum = "23c7391e4b9f4ffab1a624223cc1d7385ff9a678f490768add717de7ea2f4d89"
dependencies = [
"cc",
"libc",
@@ -1440,9 +1441,9 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memmap2"
version = "0.9.10"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
dependencies = [
"libc",
]
@@ -3236,9 +3237,9 @@ dependencies = [
[[package]]
name = "zlob"
version = "1.6.1"
version = "1.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e41cb327ac1b7e7e0d4514658500cb5734cd655edbe4e5ffeda69955da9028ee"
checksum = "f0f010d9b10495cc0037cb47cedc143c39c7fa2d59f752dca19036fd0234a210"
dependencies = [
"bindgen",
"bitflags 2.11.0",
+7 -4
View File
@@ -11,9 +11,12 @@ members = [
resolver = "2"
[workspace.lints.clippy]
module_inception = "allow"
[workspace.dependencies]
fff-grep = { version = "0.9.6", path = "crates/fff-grep" }
fff-query-parser = { version = "0.9.6", path = "crates/fff-query-parser", default-features = false }
fff-grep = { version = "0.10.4", path = "crates/fff-grep" }
fff-query-parser = { version = "0.10.4", path = "crates/fff-query-parser", default-features = false }
# Shared dependencies
ahash = "0.8"
@@ -23,7 +26,7 @@ ctrlc = "3.4.2"
dirs = "5.0"
dunce = "1.0"
# git2 - base config without TLS (each crate adds platform-specific TLS)
git2 = { version = "0.20.2", default-features = false, features = [
git2 = { version = "0.21.0", default-features = false, features = [
"vendored-libgit2",
] }
glidesort = "0.1"
@@ -33,7 +36,7 @@ ignore = "0.4.22"
memmap2 = "0.9"
mimalloc = "0.1.47"
signal-hook-registry = "1.4"
zlob = { version = "=1.6.1" }
zlob = { version = "=1.6.3" }
mlua = { version = "0.11.1", features = ["module", "luajit"] }
neo_frizbee = { version = "0.11.0", features = ["match_end_col"] }
+8 -8
View File
@@ -2,34 +2,34 @@
# Maintained in-repo; auto-bumped by .github/workflows/release.yaml on stable releases.
class FffMcp < Formula
desc "Fast file search toolkit for AI agents (MCP server)"
homepage "https://github.com/dmtrKovalenko/fff.nvim"
homepage "https://github.com/dmtrKovalenko/fff"
license "MIT"
version "0.9.6"
version "0.10.4"
LIVECHECK_REPO = "dmtrKovalenko/fff.nvim".freeze
RELEASE_BASE = "https://github.com/dmtrKovalenko/fff.nvim/releases/download".freeze
LIVECHECK_REPO = "dmtrKovalenko/fff".freeze
RELEASE_BASE = "https://github.com/dmtrKovalenko/fff/releases/download".freeze
on_macos do
on_arm do
url "#{RELEASE_BASE}/v#{version}/fff-mcp-aarch64-apple-darwin"
sha256 "29a7fadeafb062f3e5954b1ab8c69e14dca24f5e061cd8d3b1ea1bab385a3754"
sha256 "f55ce2945cd30fd252a4b1e7d8674ff7ce41b104cc009a8d3d52d08bfccca2c0"
end
on_intel do
url "#{RELEASE_BASE}/v#{version}/fff-mcp-x86_64-apple-darwin"
sha256 "58259324c2c13a1b6f24f13138c2cd3eae9ff20e05201a539beb8f2044a651aa"
sha256 "821aedd5873292841f32d7584eb68fea085b248e5c970ff5d4f6dca790e9b18c"
end
end
on_linux do
on_arm do
url "#{RELEASE_BASE}/v#{version}/fff-mcp-aarch64-unknown-linux-gnu"
sha256 "91e6fa14e040588dc92de854e35020536f1e2458ce3386b2b727b2e7a88f6684"
sha256 "3f266c0eded51a106badf1d47448a7f963762ccf3a12c5a4f06ee2b7adaf82d1"
end
on_intel do
url "#{RELEASE_BASE}/v#{version}/fff-mcp-x86_64-unknown-linux-gnu"
sha256 "d1bd2b89a79e8eda71b1754260499cec1feaafd2adf372e92371c8d6b68509a3"
sha256 "b5dd3182028c8a7bc34ee964cf2d30694f8121d2837a8516cf4a99bcfa822127"
end
end
+32 -5
View File
@@ -14,7 +14,7 @@ SHELL := bash
# string rather than the literal `-o` / `pipefail` tokens.
.SHELLFLAGS := -o pipefail -euc
.PHONY: build build-c-lib install uninstall test test-rust test-c-smoke test-c-api test-lua test-lua-snap test-version test-bun test-node prepare-bun prepare-bun-packaged prepare-node set-npm-version header test-stress test-stress-seeded test-stress-random test-stress-regressions test-stress-repos test-node-stress sync-js-api sync-js-api-check bump-homebrew-formula bump-install-mcp-sh test-bun-compile
.PHONY: build build-c-lib install uninstall test test-rust test-rescan test-rescan-known-defects rescan-probe test-c-smoke test-c-api test-lua test-lua-snap test-version test-bun test-node prepare-bun prepare-bun-packaged prepare-node set-npm-version header test-stress test-stress-seeded test-stress-random test-stress-regressions test-stress-repos test-node-stress sync-js-api sync-js-api-check bump-homebrew-formula bump-install-mcp-sh test-bun-compile
all: format test lint
@@ -45,6 +45,11 @@ sync-js-api-check:
build:
cargo build --release --no-default-features --features zlob
# Only the crates the e2e suites load (nvim lua tests + C/bun/node FFI tests),
# skipping fff-python (pyo3) and fff-mcp (tokio/rmcp) which e2e never touches.
build-e2e:
cargo build --release -p fff-nvim -p fff-c --no-default-features --features zlob
build-c-lib:
cargo build --release -p fff-c --no-default-features --features zlob
@@ -90,7 +95,26 @@ test-setup:
fi
test-rust:
cargo test --workspace --no-default-features --features zlob --exclude fff-nvim
cargo test --workspace --no-default-features --features zlob --exclude fff-nvim --exclude fff-python
# Watcher rescan harness: asserts that editing, build output, git activity and
# preview reads all stay on the incremental path instead of re-walking the tree.
test-rescan:
cargo test -p fff-search --no-default-features --features zlob \
--lib --test rescan_regression -- rescan
# Live probe for watcher rescan requests and their causes.
# Usage: make rescan-probe DIR=~/some/repo [SECONDS=120]
rescan-probe:
cargo run --release -p fff-nvim --bin rescan_probe \
--no-default-features --features zlob,rescan-stats -- \
$(or $(DIR),.) $(if $(SECONDS),--seconds $(SECONDS),)
# The same harness, restricted to cases that currently fail on purpose. Each
# `#[ignore]` reason names the code that causes the unnecessary rescan.
test-rescan-known-defects:
cargo test --no-fail-fast -p fff-search --no-default-features --features zlob \
--lib --test rescan_regression -- --ignored --nocapture
CC ?= cc
CFLAGS ?= -O0 -g -Wall -Wextra -std=c99
@@ -99,7 +123,7 @@ SMOKE_BIN := $(TARGET_DIR)/fff_c_smoke
SMOKE_SRC := crates/fff-c/tests/smoke.c
SMOKE_INCLUDE := crates/fff-c/include
test-c-smoke: build-c-lib
test-c-smoke: build
$(CC) $(CFLAGS) -I $(SMOKE_INCLUDE) -L $(TARGET_DIR) \
-Wl,-rpath,@loader_path/../target/release \
-Wl,-rpath,$$(pwd)/$(TARGET_DIR) \
@@ -197,7 +221,7 @@ test-bun-compile: prepare-bun-packaged
rm -f packages/fff-bun/glob-bench-bin packages/fff-bun/glob-bench-bin.exe
test-node: prepare-node
cd packages/fff-node && npm run build && node test/e2e.mjs
cd packages/fff-node && npm run build && node test/e2e.mjs && node test/watch.mjs
test-js: test-bun test-node
@@ -259,6 +283,9 @@ set-npm-version:
pkg.optionalDependencies[dep] = '$(VERSION)'; \
} \
} \
for (const dep of ['@ff-labs/fff-bun', '@ff-labs/fff-node']) { \
if (pkg.dependencies?.[dep]) pkg.dependencies[dep] = '$(VERSION)'; \
} \
fs.writeFileSync('$(PKG)/package.json', JSON.stringify(pkg, null, 2) + '\n'); \
"
@echo "Set $(PKG) to $(VERSION)"
@@ -283,7 +310,7 @@ lint: lint-rust lint-lua lint-ts
check: format lint
FFF_RELEASE_REPO ?= dmtrKovalenko/fff.nvim
FFF_RELEASE_REPO ?= dmtrKovalenko/fff
FFF_FORMULA_PATH ?= Formula/fff-mcp.rb
FFF_INSTALL_SCRIPT_PATH ?= install-mcp.sh
+62 -11
View File
@@ -1,14 +1,17 @@
<img alt="FFF" src="./assets/logo-orange.png" width="300">
<a href="./assets/logo-orange.png"><img alt="FFF" src="./assets/logo-orange.png" width="300"></a>
<p>
<i>A file search toolkit for humans and AI agents. Really fast.</i>
</p>
Typo-resistant path and content search, frecency-ranked file access, a background watcher, and a lightweight in-memory content index. Way faster than CLIs like ripgrep and fzf in any long-running process that searches more than once.
Typo-resistant path and content search, frequency-ranked file access, a background watcher, and a lightweight in-memory content index. Way faster than CLIs like ripgrep and fzf in any long-running process that searches more than once.
Powers file search in [opencode](http://github.com/anomalyco/opencode/), [nushell](https://github.com/nushell/nushell), and many more amazing projects!
Originally started as [Neovim plugin](#neovim-plugin) people loved, but it turned out that plenty of AI harnesses and code editors need the same thing: accurate, fast file search as a library. That is what fff is.
<p>
<a href="https://trendshift.io/repositories/26711?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-26711" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/26711" alt="dmtrKovalenko%2Ffff | Trendshift" width="250" height="55"/></a>
</p>
---
@@ -34,7 +37,7 @@ curl -L https://dmtrkovalenko.dev/install-fff-mcp.sh | bash
Windows (PowerShell):
```powershell
irm https://raw.githubusercontent.com/dmtrKovalenko/fff.nvim/main/install-mcp.ps1 | iex
irm https://raw.githubusercontent.com/dmtrKovalenko/fff/main/install-mcp.ps1 | iex
```
The scripts live at [`install-mcp.sh`](./install-mcp.sh) and [`install-mcp.ps1`](./install-mcp.ps1) if you want to read them first. They print the exact wiring instructions for your client.
@@ -46,7 +49,32 @@ brew install dmtrKovalenko/fff/fff-mcp
brew upgrade fff-mcp # after new stable releases
```
Formula lives in [`Formula/fff-mcp.rb`](./Formula/fff-mcp.rb) in this repo and is **auto-bumped on every stable release** (see `bump-homebrew-formula` in [`.github/workflows/release.yaml`](./.github/workflows/release.yaml)). Installs the prebuilt `fff-mcp` binary from [GitHub releases](https://github.com/dmtrKovalenko/fff.nvim/releases).
Formula lives in [`Formula/fff-mcp.rb`](./Formula/fff-mcp.rb) in this repo and is **auto-bumped on every stable release** (see `bump-homebrew-formula` in [`.github/workflows/release.yaml`](./.github/workflows/release.yaml)). Installs the prebuilt `fff-mcp` binary from [GitHub releases](https://github.com/dmtrKovalenko/fff/releases).
### Codex setup
Register the installed binary using its absolute path, since Codex desktop sessions may not inherit your interactive shell's `PATH`.
Homebrew:
```bash
codex mcp add fff -- "$(brew --prefix)/bin/fff-mcp"
```
One-line installer:
```bash
codex mcp add fff -- "$HOME/.local/bin/fff-mcp"
```
This creates an entry in `~/.codex/config.toml` similar to:
```toml
[mcp_servers.fff]
command = "/opt/homebrew/bin/fff-mcp"
```
Use the actual installed path for your system, then restart Codex or start a new task so it loads the server.
Once the server is connected, ask the agent to "use fff" and it picks up the `ffgrep`, `fffind`, and `fff-multi-grep` tools.
@@ -125,8 +153,9 @@ https://github.com/user-attachments/assets/5d0e1ce9-642c-4c44-aa88-01b05bb86abb
#### lazy.nvim
```lua
-- Package name changed from `fff.nvim` to `fff`. If you installed fff.nvim before, clean with `:Lazy clean`
{
'dmtrKovalenko/fff.nvim',
'dmtrKovalenko/fff',
build = function()
-- downloads a prebuilt binary or falls back to cargo build
require("fff.download").download_or_build_binary()
@@ -159,13 +188,14 @@ https://github.com/user-attachments/assets/5d0e1ce9-642c-4c44-aa88-01b05bb86abb
#### vim.pack
```lua
vim.pack.add({ 'https://github.com/dmtrKovalenko/fff.nvim' })
-- Package name changed from `fff.nvim` to `fff`. If you installed fff.nvim before, clean with `:packdel fff.nvim`
vim.pack.add({ 'https://github.com/dmtrKovalenko/fff' })
vim.api.nvim_create_autocmd('PackChanged', {
callback = function(ev)
local name, kind = ev.data.spec.name, ev.data.kind
if name == 'fff.nvim' and (kind == 'install' or kind == 'update') then
if not ev.data.active then vim.cmd.packadd('fff.nvim') end
if name == 'fff' and (kind == 'install' or kind == 'update') then
if not ev.data.active then vim.cmd.packadd('fff') end
require('fff.download').download_or_build_binary()
end
end,
@@ -284,6 +314,11 @@ require('fff').setup({
-- Border style for the picker windows. Leave unset (nil) to follow the
-- global `vim.o.winborder`; set it to override fff's borders independently.
border = nil, -- 'single' | 'double' | 'rounded' | 'solid' | 'shadow' | 'none'
-- border = {
-- { ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ' },
-- { ' ', ' ', ' ', ' ', ' ' },
-- },
flex = { size = 130, wrap = 'top' },
min_list_height = 10, -- do not display anything except the list below this threshold
show_scrollbar = true,
@@ -317,6 +352,7 @@ require('fff').setup({
preview_scroll_down = '<C-d>',
toggle_debug = '<F2>',
cycle_grep_modes = '<S-Tab>',
insert_newline_escape = '<C-CR>',
-- grep mode only: jump cursor to first match of next/prev file group
grep_jump_to_next_file = { '<C-A-n>', '<A-Down>' },
grep_jump_to_prev_file = { '<C-A-p>', '<A-Up>' },
@@ -339,6 +375,9 @@ require('fff').setup({
git = {
status_text_color = false, -- true to color filenames by git status
},
file_picker = {
fuzzy_query_highlighting = false, -- true to highlight fuzzy query matches in file picker results
},
select = {
-- Return winid to open the chosen file in, or nil to open in the original window
select_window = function(current_buf, action) --[[ default impl ]] end,
@@ -396,7 +435,7 @@ Both find and grep accept these tokens to refine a query:
- `git:modified`. One of `modified`, `staged`, `deleted`, `renamed`, `untracked`, `ignored`.
- `test/`. Any deeply nested children of `test/`.
- `!something`, `!test/`, `!git:modified`. Exclusion.
- `!something`, `!test/`, `!git:modified`. Exclusion. Text exclusions need at least 3 alphanumeric-containing characters, so operators like `!=` or `!==` work.
- `./**/*.{rs,lua}`. Any valid glob, powered by [zlob](https://github.com/dmtrKovalenko/zlob).
Grep-only:
@@ -577,7 +616,7 @@ cargo build --release -p fff-c --features zlob
The output is a `cdylib` (`libfff_c.so` / `libfff_c.dylib` / `fff_c.dll`). The header lives at [`crates/fff-c/include/fff.h`](./crates/fff-c/include/fff.h).
Prebuilt binaries for every version, including every commit on main, are on the [releases page](https://github.com/dmtrKovalenko/fff.nvim/releases). The same binaries also ship inside the `@ff-labs/fff-bin-*` npm packages.
Prebuilt binaries for every version, including every commit on main, are on the [releases page](https://github.com/dmtrKovalenko/fff/releases). The same binaries also ship inside the `@ff-labs/fff-bin-*` npm packages.
### Install
@@ -754,7 +793,7 @@ FFF is a file search library, not a CLI. Ripgrep and fzf are great tools, but th
FFF keeps the index and the file cache resident in one long-lived process and exposes the same Rust core through four thin layers: a native crate (`fff-search`), a C library (`libfff_c`), a Node/Bun SDK (`@ff-labs/fff-node`), and an MCP server. You call `FileFinder.create()` once, then every subsequent search hits warm memory. On a 500k-file Chromium checkout, that is the difference between 3-9 **SECONDS** per ripgrep spawn and sub-10 ms per FFF query.
Algorithm for fuzzy matching is much more comprehensive than fzf's algorithm it is **typo-resistant** and we provide a query language with additional constraint parsing for prefiltering e.g. "\*.rs !test/ shcema" is a perfectly valid query for fff, but fzf wouldn't find anything even for a single typo in "shcema".
Algorithm for fuzzy matching is much more comprehensive than fzf's algorithm. It is **typo-resistant** and we provide a query language with additional constraint parsing for prefiltering e.g. "\*.rs !test/ shcema" is a perfectly valid query for fff, but fzf wouldn't find anything even for a single typo in "shcema".
### Why a programmatic API matters
@@ -823,3 +862,15 @@ Bug reports and pull requests welcome. Agentic coding tools are welcome to be us
## License
[MIT](./LICENSE) & open source forever.
## FAQ
### What does FFF stand for?
There is intentionally no single canonical definition. Pick your favourite:
- **F**ast **F**ile **F**inder
- **F**uzzy **F**ile **F**inder
- will search **F**iles **F**or **F**ood
The brand hex is `#F87216`, not `#FFF`. Logo variants: [orange](./assets/logo-orange.png) · [dark](./assets/logo-dark.png) · [light](./assets/logo-light.png).
+1 -4
View File
@@ -10,10 +10,6 @@
"packages/fff-bun": {
"name": "@ff-labs/fff-bun",
"version": "0.1.37",
"bin": {
"fff-demo": "./examples/search.ts",
"fff-grep": "./examples/grep.ts",
},
"devDependencies": {
"@types/bun": "^1.3.8",
"typescript": "^5.0.0",
@@ -54,6 +50,7 @@
"name": "@ff-labs/pi-fff",
"version": "0.6.0",
"dependencies": {
"@ff-labs/fff-bun": "*",
"@ff-labs/fff-node": "*",
},
"devDependencies": {
+6 -3
View File
@@ -1,10 +1,13 @@
[package]
name = "fff-c"
version = "0.9.6"
version = "0.10.4"
edition = "2024"
description = "Raw C api of FFF file finder"
license = "MIT"
[lints]
workspace = true
[lib]
crate-type = ["cdylib"]
@@ -16,6 +19,6 @@ zlob = ["fff/zlob", "fff-query-parser/zlob"]
[dependencies]
git2.workspace = true
fff = { package = "fff-search", path = "../fff-core", version = "0.9.6", default-features = false }
fff-query-parser = { path = "../fff-query-parser", version = "0.9.6", default-features = false }
fff = { package = "fff-search", path = "../fff-core", version = "0.10.4", default-features = false }
fff-query-parser = { path = "../fff-query-parser", version = "0.10.4", default-features = false }
serde_json = "1.0"
+304 -299
View File
File diff suppressed because it is too large Load Diff
+141 -127
View File
@@ -1,42 +1,70 @@
//! Stable accessor functions for `fff-c` FFI struct fields.
//!
//! # Why this exists
//!
//! `fff-c` exposes its result types as plain `#[repr(C)]` structs. External
//! consumers (Emacs Lisp via `emacs-ffi`, Python `ctypes`, etc.) that access
//! fields by hardcoding byte offsets break silently whenever the struct layout
//! changes — a new field shifts every subsequent offset with no compile-time
//! warning.
//!
//! These functions turn field access into a **stable named API**: callers bind
//! to a symbol name once and are fully insulated from layout changes.
//!
//! # Usage from Emacs Lisp (example)
//!
//! ```elisp
//! (define-ffi-function fff--grep-match-line-content
//! "fff_grep_match_get_line_content" :pointer [:pointer] fff--library)
//!
//! (ffi-get-c-string (fff--grep-match-line-content match-ptr))
//! ```
//!
//! # Array iteration
//!
//! To walk result arrays use `fff_search_result_get_item`,
//! `fff_grep_result_get_match`, and `fff_search_result_get_score` — these are
//! defined in the main `lib.rs` FFI surface alongside the search functions.
//! Stable accessor functions for `fff-c` FFI struct fields: a named API so
//! FFI callers (Emacs Lisp, Python `ctypes`, etc.) don't hardcode struct byte
//! offsets that break silently on layout changes. For array iteration use
//! `fff_search_result_get_item` / `fff_grep_result_get_match` in `lib.rs`.
use std::ffi::c_char;
use std::ptr;
use crate::ffi_types::{FffFileItem, FffGrepMatch, FffGrepResult, FffMatchRange, FffSearchResult};
use crate::ffi_types::{
FffFileItem, FffGrepMatch, FffGrepResult, FffMatchRange, FffResult, FffSearchResult,
};
// ── FffResult ────────────────────────────────────────────────────────────────
/// Returns whether the operation completed successfully. Returns `false` if `result` is null.
///
/// ## Safety
/// `result` must be a valid `FffResult` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_result_get_success(result: *const FffResult) -> bool {
if result.is_null() {
return false;
}
unsafe { (*result).success }
}
/// Returns the operation error message, or null when there is no error or `result` is null.
///
/// Do not free the returned pointer. It remains valid until `fff_free_result` is called.
///
/// ## Safety
/// `result` must be a valid `FffResult` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_result_get_error(result: *const FffResult) -> *const c_char {
if result.is_null() {
return ptr::null();
}
unsafe { (*result).error }
}
/// Returns the result payload handle, or null if `result` is null.
///
/// ## Safety
/// `result` must be a valid `FffResult` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_result_get_handle(result: *const FffResult) -> *mut std::ffi::c_void {
if result.is_null() {
return ptr::null_mut();
}
unsafe { (*result).handle }
}
/// Returns the result integer payload. Returns `0` if `result` is null.
///
/// ## Safety
/// `result` must be a valid `FffResult` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_result_get_int_value(result: *const FffResult) -> i64 {
if result.is_null() {
return 0;
}
unsafe { (*result).int_value }
}
// ── FffFileItem ──────────────────────────────────────────────────────────────
/// Returns the relative path of a file item (e.g. `"src/main.rs"`).
///
/// Returns null if `item` is null. The returned pointer is valid for the
/// lifetime of the owning `FffSearchResult`; do not free it directly.
/// Relative path of a file item (e.g. `"src/main.rs"`); null if `item` is null. Do not free.
///
/// ## Safety
/// `item` must be a valid `FffFileItem` pointer or null.
@@ -50,9 +78,7 @@ pub unsafe extern "C" fn fff_file_item_get_relative_path(
unsafe { (*item).relative_path }
}
/// Returns the file-name component of a file item (e.g. `"main.rs"`).
///
/// Returns null if `item` is null. Do not free the returned pointer.
/// File-name component of a file item (e.g. `"main.rs"`); null if `item` is null. Do not free.
///
/// ## Safety
/// `item` must be a valid `FffFileItem` pointer or null.
@@ -64,10 +90,8 @@ pub unsafe extern "C" fn fff_file_item_get_file_name(item: *const FffFileItem) -
unsafe { (*item).file_name }
}
/// Returns the git status string for a file item (e.g. `"M "`, `"??"`)
/// or null if git is unavailable, the file is untracked, or `item` is null.
///
/// Do not free the returned pointer.
/// Git status string of a file item (e.g. `"M "`, `"??"`); null if git is unavailable,
/// the file is untracked, or `item` is null. Do not free.
///
/// ## Safety
/// `item` must be a valid `FffFileItem` pointer or null.
@@ -79,7 +103,7 @@ pub unsafe extern "C" fn fff_file_item_get_git_status(item: *const FffFileItem)
unsafe { (*item).git_status }
}
/// Returns the file size in bytes. Returns `0` if `item` is null.
/// File size in bytes; `0` if `item` is null.
///
/// ## Safety
/// `item` must be a valid `FffFileItem` pointer or null.
@@ -91,8 +115,7 @@ pub unsafe extern "C" fn fff_file_item_get_size(item: *const FffFileItem) -> u64
unsafe { (*item).size }
}
/// Returns the last-modified time as seconds since the UNIX epoch.
/// Returns `0` if `item` is null.
/// Last-modified time as seconds since the UNIX epoch; `0` if `item` is null.
///
/// ## Safety
/// `item` must be a valid `FffFileItem` pointer or null.
@@ -104,7 +127,7 @@ pub unsafe extern "C" fn fff_file_item_get_modified(item: *const FffFileItem) ->
unsafe { (*item).modified }
}
/// Returns the combined frecency score. Returns `0` if `item` is null.
/// Combined frecency score; `0` if `item` is null.
///
/// ## Safety
/// `item` must be a valid `FffFileItem` pointer or null.
@@ -116,7 +139,7 @@ pub unsafe extern "C" fn fff_file_item_get_total_frecency_score(item: *const Fff
unsafe { (*item).total_frecency_score }
}
/// Returns the access-based frecency score. Returns `0` if `item` is null.
/// Access-based frecency score; `0` if `item` is null.
///
/// ## Safety
/// `item` must be a valid `FffFileItem` pointer or null.
@@ -128,7 +151,7 @@ pub unsafe extern "C" fn fff_file_item_get_access_frecency_score(item: *const Ff
unsafe { (*item).access_frecency_score }
}
/// Returns the modification-based frecency score. Returns `0` if `item` is null.
/// Modification-based frecency score; `0` if `item` is null.
///
/// ## Safety
/// `item` must be a valid `FffFileItem` pointer or null.
@@ -142,7 +165,7 @@ pub unsafe extern "C" fn fff_file_item_get_modification_frecency_score(
unsafe { (*item).modification_frecency_score }
}
/// Returns `true` if the file was detected as binary. Returns `false` if `item` is null.
/// `true` if the file was detected as binary; `false` if `item` is null.
///
/// ## Safety
/// `item` must be a valid `FffFileItem` pointer or null.
@@ -156,9 +179,7 @@ pub unsafe extern "C" fn fff_file_item_get_is_binary(item: *const FffFileItem) -
// ── FffGrepMatch ─────────────────────────────────────────────────────────────
/// Returns the relative path of the file containing this grep match.
///
/// Returns null if `m` is null. Do not free the returned pointer.
/// Relative path of the file containing this grep match; null if `m` is null. Do not free.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
@@ -170,9 +191,7 @@ pub unsafe extern "C" fn fff_grep_match_get_relative_path(m: *const FffGrepMatch
unsafe { (*m).relative_path }
}
/// Returns the file-name component of the file containing this grep match.
///
/// Returns null if `m` is null. Do not free the returned pointer.
/// File-name component of the file containing this grep match; null if `m` is null. Do not free.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
@@ -184,10 +203,8 @@ pub unsafe extern "C" fn fff_grep_match_get_file_name(m: *const FffGrepMatch) ->
unsafe { (*m).file_name }
}
/// Returns the git status string for the matched file (e.g. `"M "`, `"??"`)
/// or null if git is unavailable, the file is untracked, or `m` is null.
///
/// Do not free the returned pointer.
/// Git status string of the matched file (e.g. `"M "`, `"??"`); null if git is unavailable,
/// the file is untracked, or `m` is null. Do not free.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
@@ -199,9 +216,7 @@ pub unsafe extern "C" fn fff_grep_match_get_git_status(m: *const FffGrepMatch) -
unsafe { (*m).git_status }
}
/// Returns the full text content of the matched line.
///
/// Returns null if `m` is null. Do not free the returned pointer.
/// Full text content of the matched line; null if `m` is null. Do not free.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
@@ -213,8 +228,7 @@ pub unsafe extern "C" fn fff_grep_match_get_line_content(m: *const FffGrepMatch)
unsafe { (*m).line_content }
}
/// Returns the 1-based line number of the match within its file.
/// Returns `0` if `m` is null.
/// 1-based line number of the match within its file; `0` if `m` is null.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
@@ -226,8 +240,7 @@ pub unsafe extern "C" fn fff_grep_match_get_line_number(m: *const FffGrepMatch)
unsafe { (*m).line_number }
}
/// Returns the 0-based column of the match start within its line.
/// Returns `0` if `m` is null.
/// 0-based column of the match start within its line; `0` if `m` is null.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
@@ -239,8 +252,7 @@ pub unsafe extern "C" fn fff_grep_match_get_col(m: *const FffGrepMatch) -> u32 {
unsafe { (*m).col }
}
/// Returns the byte offset of the match start from the beginning of the file.
/// Returns `0` if `m` is null.
/// Byte offset of the match start from the beginning of the file; `0` if `m` is null.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
@@ -252,7 +264,7 @@ pub unsafe extern "C" fn fff_grep_match_get_byte_offset(m: *const FffGrepMatch)
unsafe { (*m).byte_offset }
}
/// Returns the file size in bytes for the matched file. Returns `0` if `m` is null.
/// File size in bytes of the matched file; `0` if `m` is null.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
@@ -264,8 +276,7 @@ pub unsafe extern "C" fn fff_grep_match_get_size(m: *const FffGrepMatch) -> u64
unsafe { (*m).size }
}
/// Returns the combined frecency score for the matched file.
/// Returns `0` if `m` is null.
/// Combined frecency score of the matched file; `0` if `m` is null.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
@@ -277,8 +288,7 @@ pub unsafe extern "C" fn fff_grep_match_get_total_frecency_score(m: *const FffGr
unsafe { (*m).total_frecency_score }
}
/// Returns the access-based frecency score for the matched file.
/// Returns `0` if `m` is null.
/// Access-based frecency score of the matched file; `0` if `m` is null.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
@@ -290,8 +300,7 @@ pub unsafe extern "C" fn fff_grep_match_get_access_frecency_score(m: *const FffG
unsafe { (*m).access_frecency_score }
}
/// Returns the modification-based frecency score for the matched file.
/// Returns `0` if `m` is null.
/// Modification-based frecency score of the matched file; `0` if `m` is null.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
@@ -305,8 +314,7 @@ pub unsafe extern "C" fn fff_grep_match_get_modification_frecency_score(
unsafe { (*m).modification_frecency_score }
}
/// Returns the last-modified time as seconds since the UNIX epoch for the matched file.
/// Returns `0` if `m` is null.
/// Last-modified time of the matched file as seconds since the UNIX epoch; `0` if `m` is null.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
@@ -318,8 +326,7 @@ pub unsafe extern "C" fn fff_grep_match_get_modified(m: *const FffGrepMatch) ->
unsafe { (*m).modified }
}
/// Returns the number of highlight ranges in this match. Returns `0` if `m` is null.
///
/// Number of highlight ranges in this match; `0` if `m` is null.
/// Use with [`fff_grep_match_get_match_range`] to iterate the highlight spans.
///
/// ## Safety
@@ -332,11 +339,8 @@ pub unsafe extern "C" fn fff_grep_match_get_match_ranges_count(m: *const FffGrep
unsafe { (*m).match_ranges_count }
}
/// Returns a pointer to the `index`-th [`FffMatchRange`] highlight span.
///
/// Returns null if `m` is null, `index >= match_ranges_count`, or the
/// ranges array is null. The returned pointer is valid until the owning
/// `FffGrepResult` is freed; do not free it directly.
/// Pointer to the `index`-th [`FffMatchRange`] highlight span; null if `m` is null,
/// `index >= match_ranges_count`, or the ranges array is null. Valid until the owning `FffGrepResult` is freed; do not free.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
@@ -355,9 +359,7 @@ pub unsafe extern "C" fn fff_grep_match_get_match_range(
unsafe { m.match_ranges.add(index as usize) }
}
/// Returns the number of context lines captured before the match.
/// Returns `0` if `m` is null.
///
/// Number of context lines captured before the match; `0` if `m` is null.
/// Use with [`fff_grep_match_get_context_before`] to read each line.
///
/// ## Safety
@@ -370,10 +372,8 @@ pub unsafe extern "C" fn fff_grep_match_get_context_before_count(m: *const FffGr
unsafe { (*m).context_before_count }
}
/// Returns the `index`-th context line before the match.
///
/// Returns null if `m` is null, `index >= context_before_count`, or the
/// context array is null. Do not free the returned pointer.
/// The `index`-th context line before the match; null if `m` is null,
/// `index >= context_before_count`, or the context array is null. Do not free.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
@@ -392,9 +392,7 @@ pub unsafe extern "C" fn fff_grep_match_get_context_before(
unsafe { *m.context_before.add(index as usize) }
}
/// Returns the number of context lines captured after the match.
/// Returns `0` if `m` is null.
///
/// Number of context lines captured after the match; `0` if `m` is null.
/// Use with [`fff_grep_match_get_context_after`] to read each line.
///
/// ## Safety
@@ -407,10 +405,8 @@ pub unsafe extern "C" fn fff_grep_match_get_context_after_count(m: *const FffGre
unsafe { (*m).context_after_count }
}
/// Returns the `index`-th context line after the match.
///
/// Returns null if `m` is null, `index >= context_after_count`, or the
/// context array is null. Do not free the returned pointer.
/// The `index`-th context line after the match; null if `m` is null,
/// `index >= context_after_count`, or the context array is null. Do not free.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
@@ -429,11 +425,8 @@ pub unsafe extern "C" fn fff_grep_match_get_context_after(
unsafe { *m.context_after.add(index as usize) }
}
/// Returns the fuzzy match score. Returns `0` if `m` is null or no fuzzy
/// score is present.
///
/// Always check [`fff_grep_match_get_has_fuzzy_score`] first; `0` is
/// ambiguous without that flag.
/// Fuzzy match score; `0` if `m` is null or no fuzzy score is present.
/// Always check [`fff_grep_match_get_has_fuzzy_score`] first; `0` is ambiguous without that flag.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
@@ -445,8 +438,7 @@ pub unsafe extern "C" fn fff_grep_match_get_fuzzy_score(m: *const FffGrepMatch)
unsafe { (*m).fuzzy_score }
}
/// Returns `true` if this match carries a valid fuzzy score.
/// Returns `false` if `m` is null.
/// `true` if this match carries a valid fuzzy score; `false` if `m` is null.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
@@ -458,8 +450,7 @@ pub unsafe extern "C" fn fff_grep_match_get_has_fuzzy_score(m: *const FffGrepMat
unsafe { (*m).has_fuzzy_score }
}
/// Returns `true` if the match was identified as a symbol definition.
/// Returns `false` if `m` is null.
/// `true` if the match was identified as a symbol definition; `false` if `m` is null.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
@@ -471,8 +462,7 @@ pub unsafe extern "C" fn fff_grep_match_get_is_definition(m: *const FffGrepMatch
unsafe { (*m).is_definition }
}
/// Returns `true` if the matched file was detected as binary.
/// Returns `false` if `m` is null.
/// `true` if the matched file was detected as binary; `false` if `m` is null.
///
/// ## Safety
/// `m` must be a valid `FffGrepMatch` pointer or null.
@@ -486,7 +476,7 @@ pub unsafe extern "C" fn fff_grep_match_get_is_binary(m: *const FffGrepMatch) ->
// ── FffSearchResult ──────────────────────────────────────────────────────────
/// Returns the number of items in the result. Returns `0` if `r` is null.
/// Number of items in the result; `0` if `r` is null.
///
/// ## Safety
/// `r` must be a valid `FffSearchResult` pointer or null.
@@ -498,8 +488,7 @@ pub unsafe extern "C" fn fff_search_result_get_count(r: *const FffSearchResult)
unsafe { (*r).count }
}
/// Returns the total number of files that matched before the result was
/// truncated to the page size. Returns `0` if `r` is null.
/// Total number of files that matched before truncation to the page size; `0` if `r` is null.
///
/// ## Safety
/// `r` must be a valid `FffSearchResult` pointer or null.
@@ -511,8 +500,7 @@ pub unsafe extern "C" fn fff_search_result_get_total_matched(r: *const FffSearch
unsafe { (*r).total_matched }
}
/// Returns the total number of indexed files considered during search.
/// Returns `0` if `r` is null.
/// Total number of indexed files considered during search; `0` if `r` is null.
///
/// ## Safety
/// `r` must be a valid `FffSearchResult` pointer or null.
@@ -526,7 +514,7 @@ pub unsafe extern "C" fn fff_search_result_get_total_files(r: *const FffSearchRe
// ── FffGrepResult ─────────────────────────────────────────────────────────────
/// Returns the number of matches in the result. Returns `0` if `r` is null.
/// Number of matches in the result; `0` if `r` is null.
///
/// ## Safety
/// `r` must be a valid `FffGrepResult` pointer or null.
@@ -538,8 +526,7 @@ pub unsafe extern "C" fn fff_grep_result_get_count(r: *const FffGrepResult) -> u
unsafe { (*r).count }
}
/// Returns the total number of matches found across all pages.
/// Returns `0` if `r` is null.
/// Total number of matches found across all pages; `0` if `r` is null.
///
/// ## Safety
/// `r` must be a valid `FffGrepResult` pointer or null.
@@ -551,8 +538,7 @@ pub unsafe extern "C" fn fff_grep_result_get_total_matched(r: *const FffGrepResu
unsafe { (*r).total_matched }
}
/// Returns the number of files actually opened and searched in this call.
/// Returns `0` if `r` is null.
/// Number of files actually opened and searched in this call; `0` if `r` is null.
///
/// ## Safety
/// `r` must be a valid `FffGrepResult` pointer or null.
@@ -564,8 +550,7 @@ pub unsafe extern "C" fn fff_grep_result_get_total_files_searched(r: *const FffG
unsafe { (*r).total_files_searched }
}
/// Returns the total number of indexed files before any filtering.
/// Returns `0` if `r` is null.
/// Total number of indexed files before any filtering; `0` if `r` is null.
///
/// ## Safety
/// `r` must be a valid `FffGrepResult` pointer or null.
@@ -577,8 +562,7 @@ pub unsafe extern "C" fn fff_grep_result_get_total_files(r: *const FffGrepResult
unsafe { (*r).total_files }
}
/// Returns the number of files eligible for search after path/type filtering.
/// Returns `0` if `r` is null.
/// Number of files eligible for search after path/type filtering; `0` if `r` is null.
///
/// ## Safety
/// `r` must be a valid `FffGrepResult` pointer or null.
@@ -590,9 +574,8 @@ pub unsafe extern "C" fn fff_grep_result_get_filtered_file_count(r: *const FffGr
unsafe { (*r).filtered_file_count }
}
/// Returns the file offset for the next page, or `0` if all files have been
/// searched or `r` is null. Pass this value as `file_offset` to a subsequent
/// `fff_live_grep` or `fff_multi_grep` call to continue pagination.
/// File offset for the next page; `0` if all files have been searched or `r` is null.
/// Pass as `file_offset` to a subsequent `fff_live_grep`/`fff_multi_grep` call to continue pagination.
///
/// ## Safety
/// `r` must be a valid `FffGrepResult` pointer or null.
@@ -604,10 +587,8 @@ pub unsafe extern "C" fn fff_grep_result_get_next_file_offset(r: *const FffGrepR
unsafe { (*r).next_file_offset }
}
/// Returns the regex compilation error string if the engine fell back to
/// literal matching, or null if there was no error or `r` is null.
///
/// Do not free the returned pointer.
/// Regex compilation error string if the engine fell back to literal matching;
/// null if there was no error or `r` is null. Do not free.
///
/// ## Safety
/// `r` must be a valid `FffGrepResult` pointer or null.
@@ -801,8 +782,41 @@ mod tests {
}
}
#[test]
fn null_result_returns_zero_or_null() {
let null: *const FffResult = ptr::null();
unsafe {
assert!(!fff_result_get_success(null));
assert!(fff_result_get_error(null).is_null());
assert!(fff_result_get_handle(null).is_null());
assert_eq!(fff_result_get_int_value(null), 0);
}
}
// ── data correctness tests ────────────────────────────────────────────────
#[test]
fn result_getters_return_correct_values() {
let error = CString::new("failed").unwrap();
let handle = 0x1234usize as *mut std::ffi::c_void;
let result = FffResult {
success: false,
error: error.as_ptr() as *mut std::ffi::c_char,
handle,
int_value: -7,
};
let p = &result as *const FffResult;
unsafe {
assert!(!fff_result_get_success(p));
assert_eq!(
std::ffi::CStr::from_ptr(fff_result_get_error(p)),
error.as_c_str()
);
assert_eq!(fff_result_get_handle(p), handle);
assert_eq!(fff_result_get_int_value(p), -7);
}
}
#[test]
fn file_item_getters_return_correct_values() {
let mut item = make_file_item("src/main.rs", "main.rs");
+37 -59
View File
@@ -1,8 +1,5 @@
//! FFI-compatible type definitions
//!
//! All result types use `#[repr(C)]` structs for direct memory access from any
//! language with C FFI support. No JSON serialization is used for search or grep
//! results — callers read struct fields directly.
//! FFI-compatible type definitions: all result types are `#[repr(C)]` structs
//! read directly from any language with C FFI — no JSON serialization.
use std::ffi::{CString, c_char, c_void};
use std::ptr;
@@ -19,12 +16,12 @@ pub const FFF_CREATE_OPTIONS_VERSION: u32 = 2;
/// Options for `fff_create_instance_with`.
///
/// Versioned struct: you populate the struct at your call level, we guarantee that
/// the version is stable across the version changes, new fields only appended!
/// Versioned struct: the layout is stable across releases, new fields are
/// only appended.
#[repr(C)]
pub struct FffCreateOptions {
/// Set to [`FFF_CREATE_OPTIONS_VERSION`] when allocating. Used by the
/// library to determine which trailing fields are populated.
/// Set to [`FFF_CREATE_OPTIONS_VERSION`] when allocating; tells the
/// library which trailing fields are populated.
pub version: u32,
/// Directory to index (required, non-NULL).
pub base_path: *const c_char,
@@ -51,16 +48,14 @@ pub struct FffCreateOptions {
pub cache_budget_max_bytes: u64,
/// Per-file byte cap inside the content cache. 0 = auto.
pub cache_budget_max_file_size: u64,
/// Allow indexing the filesystem root (`/`). Off by default root is
/// rarely the intended target and floods the watcher with churn.
/// Allow indexing the filesystem root (`/`). Off by default: root is rarely
/// intended and floods the watcher with churn.
pub enable_fs_root_scanning: bool,
/// Allow indexing the user's home directory. Same trade-off as
/// `enable_fs_root_scanning`.
/// Allow indexing the user's home directory. Same trade-off as `enable_fs_root_scanning`.
pub enable_home_dir_scanning: bool,
// ----- v2 fields -----
/// Follow symlinks during scan and watcher walks. Off by default
/// enabling this without external loop protection can wedge the watcher
/// on cyclic symlink graphs. Caller is responsible for the trade-off.
/// Follow symlinks during scan and watcher walks. Off by default: without
/// external loop protection cyclic symlinks can wedge the watcher.
pub follow_symlinks: bool,
// ----- new version 3+ fields go here, ALWAYS appended -----
}
@@ -133,10 +128,8 @@ unsafe fn free_cstring_array(arr: *mut *mut c_char, count: u32) {
}
}
/// A file item returned by `fff_search`.
///
/// All string fields are heap-allocated and owned by the parent `FffSearchResult`.
/// Free the entire result with `fff_free_search_result`.
/// A file item returned by `fff_search`. Strings are owned by the parent
/// `FffSearchResult`; free everything with `fff_free_search_result`.
#[repr(C)]
pub struct FffFileItem {
pub relative_path: *mut c_char,
@@ -230,13 +223,9 @@ impl FffScore {
}
}
/// Location parsed from a query string (e.g. `"file.ts:42:10"`).
///
/// `tag` encodes the variant:
/// 0 = no location,
/// 1 = line only (`line` is set),
/// 2 = position (`line` + `col`),
/// 3 = range (`line`/`col` = start, `end_line`/`end_col` = end).
/// Location parsed from a query string (e.g. `"file.ts:42:10"`). `tag`:
/// 0 = none, 1 = line, 2 = position (`line` + `col`),
/// 3 = range (`line`/`col` = start, `end_line`/`end_col` = end).
#[repr(C)]
pub struct FffLocation {
pub tag: u8,
@@ -281,14 +270,12 @@ impl From<Option<&Location>> for FffLocation {
}
}
/// Search result returned by `fff_search`.
///
/// The caller must free this with `fff_free_search_result`.
/// Search result returned by `fff_search`; free with `fff_free_search_result`.
#[repr(C)]
pub struct FffSearchResult {
/// Pointer to a heap-allocated array of `FffFileItem` (length = `count`).
/// Heap array of `FffFileItem` (length = `count`).
pub items: *mut FffFileItem,
/// Pointer to a heap-allocated array of `FffScore` (length = `count`).
/// Heap array of `FffScore` (length = `count`).
pub scores: *mut FffScore,
/// Number of items/scores in the arrays.
pub count: u32,
@@ -336,10 +323,8 @@ pub struct FffMatchRange {
pub end: u32,
}
/// A single grep match with file and line information.
///
/// All string fields and arrays are heap-allocated. Free the parent
/// `FffGrepResult` with `fff_free_grep_result` to release everything.
/// A single grep match with file and line information. Strings and arrays are
/// owned by the parent `FffGrepResult`; free everything with `fff_free_grep_result`.
#[repr(C)]
pub struct FffGrepMatch {
// -- pointers (8 bytes each) --
@@ -441,12 +426,11 @@ impl FffGrepMatch {
}
}
/// Grep result returned by `fff_live_grep` and `fff_multi_grep`.
///
/// The caller must free this with `fff_free_grep_result`.
/// Grep result returned by `fff_live_grep` and `fff_multi_grep`;
/// free with `fff_free_grep_result`.
#[repr(C)]
pub struct FffGrepResult {
/// Pointer to a heap-allocated array of `FffGrepMatch` (length = `count`).
/// Heap array of `FffGrepMatch` (length = `count`).
pub items: *mut FffGrepMatch,
/// Number of matches in the `items` array.
pub count: u32,
@@ -583,10 +567,8 @@ impl FffResult {
}
}
/// A directory item returned by `fff_search_directories`.
///
/// All string fields are heap-allocated and owned by the parent `FffDirSearchResult`.
/// Free the entire result with `fff_free_dir_search_result`.
/// A directory item returned by `fff_search_directories`. Strings are owned by
/// the parent `FffDirSearchResult`; free everything with `fff_free_dir_search_result`.
#[repr(C)]
pub struct FffDirItem {
pub relative_path: *mut c_char,
@@ -617,14 +599,13 @@ impl FffDirItem {
}
}
/// Directory search result returned by `fff_search_directories`.
///
/// The caller must free this with `fff_free_dir_search_result`.
/// Directory search result returned by `fff_search_directories`;
/// free with `fff_free_dir_search_result`.
#[repr(C)]
pub struct FffDirSearchResult {
/// Pointer to a heap-allocated array of `FffDirItem` (length = `count`).
/// Heap array of `FffDirItem` (length = `count`).
pub items: *mut FffDirItem,
/// Pointer to a heap-allocated array of `FffScore` (length = `count`).
/// Heap array of `FffScore` (length = `count`).
pub scores: *mut FffScore,
/// Number of items/scores in the arrays.
pub count: u32,
@@ -659,9 +640,8 @@ impl FffDirSearchResult {
}
/// A single item in a mixed (files + directories) search result.
///
/// `item_type`: 0 = file, 1 = directory.
/// All string fields are heap-allocated and owned by the parent `FffMixedSearchResult`.
/// `item_type`: 0 = file, 1 = directory. Strings are owned by the parent
/// `FffMixedSearchResult`.
#[repr(C)]
pub struct FffMixedItem {
/// 0 = file, 1 = directory.
@@ -672,8 +652,7 @@ pub struct FffMixedItem {
pub git_status: *mut c_char,
pub size: u64,
pub modified: u64,
/// The access frecency score for files, or max access frecency among all the immediate
/// children for directories.
/// Access frecency for files; max among immediate children for directories.
pub access_frecency_score: i64,
/// Always 0 for directories
pub modification_frecency_score: i64,
@@ -730,14 +709,13 @@ impl FffMixedItem {
}
}
/// Mixed search result returned by `fff_search_mixed`.
///
/// The caller must free this with `fff_free_mixed_search_result`.
/// Mixed search result returned by `fff_search_mixed`
/// free with `fff_free_mixed_search_result`.
#[repr(C)]
pub struct FffMixedSearchResult {
/// Pointer to a heap-allocated array of `FffMixedItem` (length = `count`).
/// Heap array of `FffMixedItem` (length = `count`).
pub items: *mut FffMixedItem,
/// Pointer to a heap-allocated array of `FffScore` (length = `count`).
/// Heap array of `FffScore` (length = `count`).
pub scores: *mut FffScore,
/// Number of items/scores in the arrays.
pub count: u32,
+79 -196
View File
@@ -1,26 +1,14 @@
//! C FFI bindings for fff-core
//! C FFI bindings for fff-core, usable from any language with C FFI
//! (Bun, Node.js, Python, Ruby, etc.).
//!
//! This crate provides C-compatible FFI exports that can be used from any language
//! with C FFI support (Bun, Node.js, Python, Ruby, etc.).
//! All state is owned by an opaque instance handle: create with
//! `fff_create_instance*`, pass to every call, free with `fff_destroy`.
//! Multiple instances can coexist in one process.
//!
//! # Instance-based API
//!
//! All state is owned by an opaque `FffInstance` fff_handle. Callers create an instance
//! with `fff_create_instance`, pass the fff_handle to every subsequent call, and free it with
//! `fff_destroy`. Multiple independent instances can coexist in the same process.
//!
//! # Memory management
//!
//! * Every `fff_*` function that returns `*mut FffResult` requires the caller to
//! free the result with `fff_free_result`.
//! * The instance itself must be freed with `fff_destroy`.
//!
//! # Parameter conventions
//!
//! * Optional `*const c_char` parameters: pass NULL or an empty string to omit.
//! * Numeric parameters: 0 means "use default" unless documented otherwise.
//! * Grep mode (`u8`): 0 = plain text, 1 = regex, 2 = fuzzy.
//! * Multi-grep patterns are passed as a single newline-separated (`\n`) string.
//! Conventions: every returned `*mut FffResult` is freed with
//! `fff_free_result`; optional string params take NULL/empty; numeric 0 means
//! "use default" unless documented otherwise; grep mode `u8` is 0 = plain
//! text, 1 = regex, 2 = fuzzy; multi-grep patterns are `\n`-separated.
use std::ffi::{CStr, CString, c_char, c_void};
use std::path::PathBuf;
@@ -30,6 +18,7 @@ use fff::shared::SharedQueryTracker;
mod accessors;
mod ffi_types;
mod watch;
use fff::file_picker::FilePicker;
use fff::frecency::FrecencyTracker;
@@ -42,20 +31,17 @@ use ffi_types::{
FffScore, FffSearchResult,
};
/// Opaque fff_handle holding all per-instance state.
///
/// The caller receives this as `*mut c_void` and must pass it to every FFI call.
/// The fff_handle is freed by `fff_destroy`.
/// Opaque handle holding all per-instance state; freed by `fff_destroy`.
struct FffInstance {
picker: SharedFilePicker,
frecency: SharedFrecency,
query_tracker: SharedQueryTracker,
// we keep a single callback type
watch_callback: std::sync::Arc<watch::WatchCallbackSlot>,
}
/// Helper to convert C string to Rust &str.
///
/// Returns `None` if the pointer is null or the string is not valid UTF-8.
unsafe fn cstr_to_str<'a>(s: *const c_char) -> Option<&'a str> {
/// Convert a C string to `&str`; `None` if null or invalid UTF-8.
pub(crate) unsafe fn cstr_to_str<'a>(s: *const c_char) -> Option<&'a str> {
if s.is_null() {
None
} else {
@@ -63,17 +49,15 @@ unsafe fn cstr_to_str<'a>(s: *const c_char) -> Option<&'a str> {
}
}
/// Helper to convert an optional C string parameter.
///
/// Returns `None` if the pointer is null, empty, or not valid UTF-8.
/// Optional C string param: `None` if null, empty, or invalid UTF-8.
unsafe fn optional_cstr<'a>(s: *const c_char) -> Option<&'a str> {
unsafe { cstr_to_str(s) }.filter(|s| !s.is_empty())
}
/// Recover a `&FffInstance` from the opaque pointer.
///
/// Returns an error `FffResult` if the pointer is null.
unsafe fn instance_ref<'a>(fff_handle: *mut c_void) -> Result<&'a FffInstance, *mut FffResult> {
/// Recover a `&FffInstance` from the opaque pointer; error `FffResult` if null.
pub(crate) unsafe fn instance_ref<'a>(
fff_handle: *mut c_void,
) -> Result<&'a FffInstance, *mut FffResult> {
if fff_handle.is_null() {
Err(FffResult::err(
"Instance handle is null. Create one with fff_create_instance first.",
@@ -107,11 +91,8 @@ fn default_i32(val: i32, default: i32) -> i32 {
/// Create a new file finder instance (legacy 8-arg positional signature).
///
/// @deprecated Use [`fff_create_instance_with`] (or
/// [`fff_create_instance_with_value`] for FFI bindings) — both take the
/// versioned [`FffCreateOptions`] struct that evolves without ABI breaks.
/// This function delegates to `fff_create_instance_with` internally; the
/// `use_unsafe_no_lock` parameter is deprecated and ignored.
/// @deprecated Use [`fff_create_instance_with`] (or [`fff_create_instance_with_value`]
/// for FFI bindings). The `use_unsafe_no_lock` parameter is ignored.
///
/// ## Safety
/// See `fff_create_instance_with`.
@@ -143,10 +124,8 @@ pub unsafe extern "C" fn fff_create_instance(
/// Create a new file finder instance (legacy 13-arg positional signature).
///
/// @deprecated Use [`fff_create_instance_with`] (or
/// [`fff_create_instance_with_value`] for FFI bindings) — both take the
/// versioned [`FffCreateOptions`] struct that evolves without ABI breaks.
/// The `use_unsafe_no_lock` parameter is deprecated and ignored.
/// @deprecated Use [`fff_create_instance_with`] (or [`fff_create_instance_with_value`]
/// for FFI bindings). The `use_unsafe_no_lock` parameter is ignored.
///
/// ## Safety
/// See `fff_create_instance_with`.
@@ -186,22 +165,14 @@ pub unsafe extern "C" fn fff_create_instance2(
unsafe { fff_create_instance_with(&opts as *const FffCreateOptions) }
}
/// Create a new file finder instance from an [`FffCreateOptions`] struct.
/// Create a new file finder instance from a versioned [`FffCreateOptions`] struct.
///
/// **Direct C consumers** populate the struct (designated initializers
/// recommended), set `version` to [`FFF_CREATE_OPTIONS_VERSION`], and pass
/// it by pointer. New fields are appended in future versions; old callers
/// passing `version = 1` keep working forever.
/// Populate the struct, set `version` to [`FFF_CREATE_OPTIONS_VERSION`], pass by
/// pointer. New fields are only appended; older `version` values keep working.
/// FFI bindings needing struct-by-value should use [`fff_create_instance_with_value`].
///
/// **FFI consumers** that prefer struct-by-value semantics (e.g. ffi-rs's
/// `paramsType: [structDef]`) should use [`fff_create_instance_with_value`]
/// instead — it's a thin calling-convention adapter that delegates here.
///
/// Required: `opts.base_path` must be non-NULL and non-empty.
///
/// When all three `cache_budget_*` values are 0 the budget is auto-computed
/// from repo size after the initial scan. Otherwise an explicit budget is
/// used: any field left at 0 falls back to its `unlimited()` default.
/// `opts.base_path` is required (non-NULL, non-empty). Zero `cache_budget_*`
/// values are auto-computed from repo size after the initial scan.
///
/// ## Safety
/// * `opts` must be a valid pointer to an `FffCreateOptions` whose `version`
@@ -304,22 +275,15 @@ pub unsafe extern "C" fn fff_create_instance_with(opts: *const FffCreateOptions)
picker: shared_picker,
frecency: shared_frecency,
query_tracker,
watch_callback: std::sync::Arc::new(watch::WatchCallbackSlot::default()),
});
let fff_handle = Box::into_raw(instance) as *mut c_void;
FffResult::ok_handle(fff_handle)
}
/// Calling-convention adapter for [`fff_create_instance_with`].
///
/// Same logic, but takes the [`FffCreateOptions`] struct **by value**. This
/// makes the function callable from FFI libraries whose native struct
/// support passes structs by value on the wire (e.g. Node's `ffi-rs` with
/// `paramsType: [structDef]`).
///
/// This is **not** a versioned wrapper — when new fields are appended to
/// `FffCreateOptions`, both this function and `fff_create_instance_with`
/// pick them up automatically with no signature change.
/// [`fff_create_instance_with`] adapter taking [`FffCreateOptions`] **by value**,
/// for FFI libraries that pass native structs by value (e.g. Node's `ffi-rs`).
///
/// ## Safety
/// All `*const c_char` fields inside `opts` must be valid null-terminated
@@ -341,6 +305,10 @@ pub unsafe extern "C" fn fff_destroy(fff_handle: *mut c_void) {
let instance = unsafe { Box::from_raw(fff_handle as *mut FffInstance) };
// The C callback and user_data may be freed as soon as this returns.
instance.picker.shutdown_watches_and_wait();
instance.watch_callback.clear();
if let Ok(mut guard) = instance.picker.write()
&& let Some(picker) = guard.take()
{
@@ -357,16 +325,9 @@ pub unsafe extern "C" fn fff_destroy(fff_handle: *mut c_void) {
/// Perform fuzzy search on indexed files.
///
/// # Parameters
///
/// * `fff_handle` instance from `fff_create_instance`
/// * `query` search query string
/// * `current_file` path of the currently open file for deprioritization (NULL/empty to skip)
/// * `max_threads` maximum worker threads (0 = auto-detect)
/// * `page_index` pagination offset (0 = first page)
/// * `page_size` results per page (0 = default 100)
/// * `combo_boost_multiplier` score multiplier for combo matches (0 = default 100)
/// * `min_combo_count` minimum combo count before boost applies (0 = default 3)
/// `current_file` deprioritizes the currently open file (NULL/empty to skip).
/// Zero picks the default: `max_threads` auto, `page_size` 100,
/// `combo_boost_multiplier` 100, `min_combo_count` 3.
///
/// ## Safety
/// * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
@@ -439,22 +400,11 @@ pub unsafe extern "C" fn fff_search(
FffResult::ok_handle(search_result as *mut c_void)
}
/// Glob-only search: filter indexed files by a single glob pattern, rank by
/// frecency, and paginate. Bypasses the regular query parser entirely.
/// Glob-only search: filter indexed files by a single glob pattern (passed
/// through verbatim, no query parsing), rank by frecency, and paginate.
///
/// Use this when you already have a literal glob pattern (e.g. `*.rs`, a
/// recursive `**` match, or `src/components` prefix) and want neither fuzzy
/// matching nor multi-token constraint parsing. Ranking falls back to
/// frecency because there is no fuzzy score to combine with.
///
/// # Parameters
///
/// * `fff_handle` - instance from `fff_create_instance`
/// * `pattern` - glob pattern (required, no parsing - passed through verbatim)
/// * `current_file` - path of the currently open file for deprioritization (NULL/empty to skip)
/// * `max_threads` - maximum worker threads (0 = auto-detect)
/// * `page_index` - pagination offset (0 = first page)
/// * `page_size` - results per page (0 = default 100)
/// `current_file` deprioritizes the currently open file (NULL/empty to skip).
/// Zero picks the default: `max_threads` auto, `page_size` 100.
///
/// ## Safety
/// * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
@@ -514,14 +464,8 @@ pub unsafe extern "C" fn fff_glob(
/// Perform fuzzy search on indexed directories.
///
/// # Parameters
///
/// * `fff_handle` instance from `fff_create_instance`
/// * `query` search query string
/// * `current_file` path of the currently open file for distance scoring (NULL/empty to skip)
/// * `max_threads` maximum worker threads (0 = auto-detect)
/// * `page_index` pagination offset (0 = first page)
/// * `page_size` results per page (0 = default 100)
/// `current_file` is used for distance scoring (NULL/empty to skip).
/// Zero picks the default: `max_threads` auto, `page_size` 100.
///
/// ## Safety
/// * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
@@ -584,20 +528,8 @@ pub unsafe extern "C" fn fff_search_directories(
/// Perform a mixed fuzzy search across both files and directories.
///
/// Returns a single flat list where files and directories are interleaved
/// by total score in descending order. Each item has an `item_type` field
/// (0 = file, 1 = directory).
///
/// # Parameters
///
/// * `fff_handle` instance from `fff_create_instance`
/// * `query` search query string
/// * `current_file` path of the currently open file (NULL/empty to skip)
/// * `max_threads` maximum worker threads (0 = auto-detect)
/// * `page_index` pagination offset (0 = first page)
/// * `page_size` results per page (0 = default 100)
/// * `combo_boost_multiplier` score multiplier for combo matches (0 = default 100)
/// * `min_combo_count` minimum combo count before boost applies (0 = default 3)
/// Returns one flat list interleaved by descending total score; each item's
/// `item_type` is 0 = file, 1 = directory. Parameters as in [`fff_search`].
///
/// ## Safety
/// * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
@@ -671,20 +603,11 @@ pub unsafe extern "C" fn fff_search_mixed(
/// Perform content search (grep) across indexed files.
///
/// # Parameters
///
/// * `fff_handle` instance from `fff_create_instance`
/// * `query` search query (supports constraint syntax like `*.rs pattern`)
/// * `mode` 0 = plain text (SIMD), 1 = regex, 2 = fuzzy
/// * `max_file_size` skip files larger than this in bytes (0 = default 10 MB)
/// * `max_matches_per_file` max matches per file (0 = unlimited)
/// * `smart_case` case-insensitive when query is all lowercase
/// * `file_offset` file-based pagination offset (0 = start)
/// * `page_limit` max matches to return (0 = default 50)
/// * `time_budget_ms` wall-clock budget in ms (0 = unlimited)
/// * `before_context` context lines before each match
/// * `after_context` context lines after each match
/// * `classify_definitions` tag matches that are code definitions
/// `query` supports constraint syntax like `*.rs pattern`; `mode` is
/// 0 = plain text (SIMD), 1 = regex, 2 = fuzzy. Zero picks the default:
/// `max_file_size` 10 MB, `page_limit` 50, `max_matches_per_file` and
/// `time_budget_ms` unlimited. `smart_case` is case-insensitive for
/// all-lowercase queries; `classify_definitions` tags code definitions.
///
/// ## Safety
/// * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
@@ -753,25 +676,11 @@ pub unsafe extern "C" fn fff_live_grep(
FffResult::ok_handle(grep_result as *mut c_void)
}
/// Perform multi-pattern OR search (Aho-Corasick) across indexed files.
/// Multi-pattern OR search (SIMD Aho-Corasick): lines matching ANY pattern.
///
/// Searches for lines matching ANY of the provided patterns using
/// SIMD-accelerated multi-needle matching.
///
/// # Parameters
///
/// * `fff_handle` instance from `fff_create_instance`
/// * `patterns_joined` patterns separated by `\n` (e.g. `"foo\nbar\nbaz"`)
/// * `constraints` file filter like `"*.rs"` or `"/src/"` (NULL/empty to skip)
/// * `max_file_size` skip files larger than this in bytes (0 = default 10 MB)
/// * `max_matches_per_file` max matches per file (0 = unlimited)
/// * `smart_case` case-insensitive when all patterns are lowercase
/// * `file_offset` file-based pagination offset (0 = start)
/// * `page_limit` max matches to return (0 = default 50)
/// * `time_budget_ms` wall-clock budget in ms (0 = unlimited)
/// * `before_context` context lines before each match
/// * `after_context` context lines after each match
/// * `classify_definitions` tag matches that are code definitions
/// `patterns_joined` is `\n`-separated (e.g. `"foo\nbar"`); `constraints` is an
/// optional file filter like `"*.rs"` or `"/src/"` (NULL/empty to skip).
/// Remaining parameters as in [`fff_live_grep`].
///
/// ## Safety
/// * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
@@ -820,19 +729,12 @@ pub unsafe extern "C" fn fff_multi_grep(
}
};
let is_ai = picker.mode().is_ai();
// Parse constraints from the optional string (e.g. "*.rs /src/")
let parsed_constraints = constraints_str.map(|c| {
if is_ai {
fff::QueryParser::new(fff_query_parser::AiGrepConfig).parse(c)
} else {
fff::grep::parse_grep_query(c)
}
});
let parsed_constraints = constraints_str
.map(|c| fff::QueryParser::new(fff_query_parser::AiGrepConfig).parse_constraints(c));
let constraint_refs: &[fff::Constraint<'_>] = match &parsed_constraints {
Some(q) => &q.constraints,
Some(constraints) => constraints,
None => &[],
};
@@ -893,10 +795,8 @@ pub unsafe extern "C" fn fff_is_scanning(fff_handle: *mut c_void) -> bool {
.unwrap_or(false)
}
/// Get the base path of the file picker.
///
/// Returns an `FffResult` with a heap-allocated C string in the `handle`
/// field. Free the string with `fff_free_string` after reading it.
/// Get the picker's base path as a heap C string in `handle`;
/// free it with `fff_free_string`.
///
/// ## Safety
/// `fff_handle` must be a valid instance pointer from `fff_create_instance`.
@@ -1358,10 +1258,8 @@ pub unsafe extern "C" fn fff_health_check(
}
}
/// Free a search result returned by `fff_search`.
///
/// This frees the `FffSearchResult` struct, its `items` and `scores` arrays,
/// and all heap-allocated strings within each item and score.
/// Free a search result returned by `fff_search`: the struct, its `items`
/// and `scores` arrays, and every string within.
///
/// ## Safety
/// `result` must be a valid pointer previously returned via `FffResult.handle`
@@ -1391,10 +1289,8 @@ pub unsafe extern "C" fn fff_free_search_result(result: *mut FffSearchResult) {
}
}
/// Get a pointer to the `index`-th `FffFileItem` in a search result.
///
/// Returns null if `result` is null or `index >= result->count`.
/// The returned pointer is valid until the search result is freed.
/// Pointer to the `index`-th `FffFileItem`; null if `result` is null or
/// `index >= count`. Valid until the search result is freed.
///
/// ## Safety
/// `result` must be a valid `FffSearchResult` pointer from `fff_search`.
@@ -1413,10 +1309,8 @@ pub unsafe extern "C" fn fff_search_result_get_item(
unsafe { result.items.add(index as usize) }
}
/// Get a pointer to the `index`-th `FffScore` in a search result.
///
/// Returns null if `result` is null or `index >= result->count`.
/// The returned pointer is valid until the search result is freed.
/// Pointer to the `index`-th `FffScore`; null if `result` is null or
/// `index >= count`. Valid until the search result is freed.
///
/// ## Safety
/// `result` must be a valid `FffSearchResult` pointer from `fff_search`.
@@ -1435,10 +1329,8 @@ pub unsafe extern "C" fn fff_search_result_get_score(
unsafe { result.scores.add(index as usize) }
}
/// Free a grep result returned by `fff_live_grep` or `fff_multi_grep`.
///
/// This frees the `FffGrepResult` struct, its `items` array, and all
/// heap-allocated strings, match ranges, and context arrays within each match.
/// Free a grep result returned by `fff_live_grep` or `fff_multi_grep`:
/// the struct, its `items` array, and all strings/ranges/context within.
///
/// ## Safety
/// `result` must be a valid pointer previously returned via `FffResult.handle`
@@ -1465,10 +1357,8 @@ pub unsafe extern "C" fn fff_free_grep_result(result: *mut FffGrepResult) {
}
}
/// Get a pointer to the `index`-th `FffGrepMatch` in a grep result.
///
/// Returns null if `result` is null or `index >= result->count`.
/// The returned pointer is valid until the grep result is freed.
/// Pointer to the `index`-th `FffGrepMatch`; null if `result` is null or
/// `index >= count`. Valid until the grep result is freed.
///
/// ## Safety
/// `result` must be a valid `FffGrepResult` pointer from `fff_live_grep` or `fff_multi_grep`.
@@ -1499,10 +1389,8 @@ pub unsafe extern "C" fn fff_free_scan_progress(result: *mut FffScanProgress) {
}
}
/// Offset a pointer by `byte_offset` bytes.
///
/// General-purpose utility for FFI consumers that need pointer arithmetic
/// (e.g. iterating over arrays). Returns null if `base` is null.
/// Offset a pointer by `byte_offset` bytes (FFI array iteration helper).
/// Returns null if `base` is null.
///
/// ## Safety
/// The resulting pointer must be within the bounds of the original allocation.
@@ -1514,13 +1402,9 @@ pub unsafe extern "C" fn fff_ptr_offset(base: *const c_void, byte_offset: usize)
unsafe { (base as *const u8).add(byte_offset) as *const c_void }
}
/// Free a result returned by any `fff_*` function.
/// **IMPORTANT:** this doesn't clean the the internal handle, so it is safe to call right after
/// you handle the error case.
///
/// Note: Many non-libffi implementations are not supporting struct-by-value returns, so it's more
/// convenient to have pointer returned at most of the time, though allocating result for every call
/// is annoying, so we just rely on the fact that our allocator is good enough.
/// Free a result envelope returned by any `fff_*` function.
/// **IMPORTANT:** the `handle` payload is NOT freed release it separately
/// using handle specific cleaning methods (`fff_destroy`, `fff_free_search_result`, etc.).
///
/// ## Safety
/// `result_ptr` must be a valid pointer returned by a `fff_*` function.
@@ -1535,9 +1419,8 @@ pub unsafe extern "C" fn fff_free_result(result_ptr: *mut FffResult) {
if !result.error.is_null() {
drop(CString::from_raw(result.error));
}
// Note: `handle` is NOT freed here — the caller must free it
// with the appropriate function (fff_destroy, fff_free_search_result,
// fff_free_grep_result, fff_free_string, fff_free_scan_progress, etc.).
// note: handle is not freed by design
}
}
+351
View File
@@ -0,0 +1,351 @@
use std::ffi::{CString, c_char, c_void};
use std::ptr;
use std::sync::Arc;
use std::sync::Mutex;
use fff::{WatchEvent, WatchId, WatchOptions};
use crate::ffi_types::FffResult;
use crate::instance_ref;
/// Current version of [`FffWatchOptions`].
pub const FFF_WATCH_OPTIONS_VERSION: u32 = 1;
/// Options for `fff_watch`. Versioned: new fields are only appended.
#[repr(C)]
pub struct FffWatchOptions {
/// Set to [`FFF_WATCH_OPTIONS_VERSION`] when allocating.
pub version: u32,
/// Per-subscription excludes (parcel-watcher style): entries with wildcards
/// are base-relative globs, entries without are path prefixes. NULL when
/// `ignore_count` is 0.
pub ignore: *const *const c_char,
pub ignore_count: u32,
// ----- new version 2+ fields go here, ALWAYS appended -----
}
/// A single watch event. `kind`: 0 = created, 1 = modified, 2 = removed,
/// 3 = rescan (events were lost; re-stat what you care about).
#[repr(C)]
pub struct FffWatchEvent {
/// Absolute path (heap C string owned by the parent batch).
pub path: *mut c_char,
pub kind: u8,
}
/// A batch of watch events. Free with `fff_free_watch_events`.
#[repr(C)]
pub struct FffWatchEventBatch {
pub events: *mut FffWatchEvent,
pub count: u32,
}
/// Instance-wide callback invoked with `(watch_id, batch)` for every `fff_watch`
/// subscription. The callee owns and frees `batch` via `fff_free_watch_events`.
pub type FffWatchCallback =
unsafe extern "C" fn(watch_id: u64, batch: *mut FffWatchEventBatch, user_data: *mut c_void);
fn batch_into_raw(events: &[WatchEvent]) -> *mut FffWatchEventBatch {
let items: Vec<FffWatchEvent> = events
.iter()
.map(|ev| FffWatchEvent {
path: CString::new(ev.path.to_string_lossy().as_bytes())
.unwrap_or_default()
.into_raw(),
kind: ev.kind as u8,
})
.collect();
let count = items.len() as u32;
let events_ptr = if items.is_empty() {
ptr::null_mut()
} else {
let mut boxed = items.into_boxed_slice();
let p = boxed.as_mut_ptr();
std::mem::forget(boxed);
p
};
Box::into_raw(Box::new(FffWatchEventBatch {
events: events_ptr,
count,
}))
}
unsafe fn watch_options_from_ffi(
opts: *const FffWatchOptions,
) -> Result<WatchOptions, *mut FffResult> {
if opts.is_null() {
return Ok(WatchOptions::default());
}
let opts = unsafe { &*opts };
if opts.version == 0 || opts.version > FFF_WATCH_OPTIONS_VERSION {
return Err(FffResult::err(&format!(
"Unsupported FffWatchOptions version {} (library understands up to {})",
opts.version, FFF_WATCH_OPTIONS_VERSION
)));
}
let mut ignore = Vec::with_capacity(opts.ignore_count as usize);
if opts.ignore_count > 0 {
if opts.ignore.is_null() {
return Err(FffResult::err("ignore_count > 0 but ignore is NULL"));
}
for i in 0..opts.ignore_count as usize {
let entry = unsafe { *opts.ignore.add(i) };
match unsafe { crate::cstr_to_str(entry) } {
Some(s) if !s.is_empty() => ignore.push(s.to_string()),
Some(_) => {}
None => return Err(FffResult::err("ignore entry is NULL or invalid UTF-8")),
}
}
}
Ok(WatchOptions { ignore })
}
// The caller guarantees user_data is safe on the callback thread.
struct UserData(*mut c_void);
unsafe impl Send for UserData {}
unsafe impl Sync for UserData {}
// Shared so a closure surviving an unwatch race never dangles.
#[derive(Default)]
pub(crate) struct WatchCallbackSlot(Mutex<Option<(FffWatchCallback, UserData)>>);
impl WatchCallbackSlot {
fn get(&self) -> Option<(FffWatchCallback, *mut c_void)> {
self.0
.lock()
.ok()
.and_then(|guard| guard.as_ref().map(|(cb, ud)| (*cb, ud.0)))
}
fn set(&self, callback: FffWatchCallback, user_data: *mut c_void) {
if let Ok(mut guard) = self.0.lock() {
*guard = Some((callback, UserData(user_data)));
}
}
pub(crate) fn clear(&self) {
if let Ok(mut guard) = self.0.lock() {
*guard = None;
}
}
}
/// Register the instance-wide watch callback used by all `fff_watch`
/// subscriptions; call before the first `fff_watch`, calling again replaces it.
///
/// ## Safety
/// * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
/// * `callback` must remain callable until fff_unwatch called
/// `fff_destroy(fff_handle)` returns.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_set_watch_callback(
fff_handle: *mut c_void,
callback: FffWatchCallback,
user_data: *mut c_void,
) -> *mut FffResult {
let inst = match unsafe { instance_ref(fff_handle) } {
Ok(i) => i,
Err(e) => return e,
};
inst.watch_callback.set(callback, user_data);
FffResult::ok_empty()
}
/// Subscribe to filesystem changes, delivered through the instance callback
/// registered by `fff_set_watch_callback`.
///
/// Returns the watch id, pass it to `fff_unwatch` to stop.
///
/// `pattern` if non `NULL` can be wildcard pattern, absolute, or relative path
/// that will be used to filter the events triggering exact subscription.
///
/// ## Safety
/// * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
/// * `pattern` must be NULL or valid null-terminated UTF-8.
/// * `opts` must be NULL or a valid `FffWatchOptions` pointer.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_watch(
fff_handle: *mut c_void,
pattern: *const c_char,
opts: *const FffWatchOptions,
) -> *mut FffResult {
let inst = match unsafe { instance_ref(fff_handle) } {
Ok(i) => i,
Err(e) => return e,
};
// NULL pattern = watch the entire indexed tree ("" in core).
let pattern_str = if pattern.is_null() {
""
} else {
match unsafe { crate::cstr_to_str(pattern) } {
Some(s) => s,
None => return FffResult::err("Pattern is not valid UTF-8"),
}
};
let options = match unsafe { watch_options_from_ffi(opts) } {
Ok(o) => o,
Err(e) => return e,
};
if inst.watch_callback.get().is_none() {
return FffResult::err("No watch callback registered. Call fff_set_watch_callback first.");
}
let slot = Arc::clone(&inst.watch_callback);
let result = inst.picker.watch(pattern_str, options, move |id, events| {
if let Some((cb, user_data)) = slot.get() {
let batch = batch_into_raw(events);
unsafe { cb(id.0, batch, user_data) };
}
});
match result {
Ok(id) => FffResult::ok_int(id.0 as i64),
Err(e) => FffResult::err(&format!("Failed to subscribe: {}", e)),
}
}
/// [`fff_watch`] adapter with flattened options, for FFI libraries that cannot
/// marshal pointer arrays inside structs (e.g. Node's `ffi-rs`).
///
/// ## Safety
/// * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
/// * `pattern` must be NULL (watch everything) or valid null-terminated UTF-8.
/// * `ignore` must be NULL or point to `ignore_count` valid C strings.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_watch_args(
fff_handle: *mut c_void,
pattern: *const c_char,
ignore: *const *const c_char,
ignore_count: u32,
) -> *mut FffResult {
let opts = FffWatchOptions {
version: FFF_WATCH_OPTIONS_VERSION,
ignore,
ignore_count,
};
unsafe { fff_watch(fff_handle, pattern, &opts) }
}
/// Remove a watch subscription. `int_value` = 1 if the id existed, 0 otherwise.
///
/// ## Safety
/// `fff_handle` must be a valid instance pointer from `fff_create_instance`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_unwatch(fff_handle: *mut c_void, watch_id: u64) -> *mut FffResult {
let inst = match unsafe { instance_ref(fff_handle) } {
Ok(i) => i,
Err(e) => return e,
};
FffResult::ok_int(inst.picker.unwatch(WatchId(watch_id)) as i64)
}
/// Number of events in a batch, 0 if `batch` is null.
///
/// ## Safety
/// `batch` must be a valid `FffWatchEventBatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_watch_events_count(batch: *const FffWatchEventBatch) -> u32 {
if batch.is_null() {
return 0;
}
unsafe { (*batch).count }
}
/// Absolute path of event `index`, will be null when out of bounds
///
/// ## Safety
/// `batch` must be a valid `FffWatchEventBatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_watch_events_get_path(
batch: *const FffWatchEventBatch,
index: u32,
) -> *const c_char {
match unsafe { watch_event_at(batch, index) } {
Some(ev) => ev.path,
None => ptr::null(),
}
}
/// Kind of event `index` (0 = created, 1 = modified, 2 = removed, 3 = rescan)
/// 3 (rescan aka "re-stat something" kind) returned when OS based buffer
/// has been overflown and some events might be loss. Paths will contain a list of
/// directories that needs to be rescanned to ensure consistency.
///
/// ## Safety
/// `batch` must be a valid `FffWatchEventBatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_watch_events_get_kind(
batch: *const FffWatchEventBatch,
index: u32,
) -> u8 {
match unsafe { watch_event_at(batch, index) } {
Some(ev) => ev.kind,
None => 3,
}
}
unsafe fn watch_event_at<'a>(
batch: *const FffWatchEventBatch,
index: u32,
) -> Option<&'a FffWatchEvent> {
if batch.is_null() {
return None;
}
let batch = unsafe { &*batch };
if batch.events.is_null() || index >= batch.count {
return None;
}
Some(unsafe { &*batch.events.add(index as usize) })
}
/// Free a watch event batch delivered to the instance callback.
///
/// ## Safety
/// `batch` must be a pointer produced by this library, or null (no-op).
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_free_watch_events(batch: *mut FffWatchEventBatch) {
if batch.is_null() {
return;
}
unsafe {
let batch = Box::from_raw(batch);
if !batch.events.is_null() {
let events =
Vec::from_raw_parts(batch.events, batch.count as usize, batch.count as usize);
for ev in events {
if !ev.path.is_null() {
drop(CString::from_raw(ev.path));
}
}
}
}
}
// THESE TESTS MUST NEVER BE UPDATED, ONLY EXTENDED WITH NEW FIELDS —
// bindings hardcode these offsets (ABI stability).
#[cfg(test)]
mod layout_tests {
use super::*;
use std::mem::{offset_of, size_of};
#[test]
#[cfg(target_pointer_width = "64")]
fn watch_ffi_layouts_are_stable_64bit() {
assert_eq!(size_of::<FffWatchOptions>(), 24);
assert_eq!(offset_of!(FffWatchOptions, version), 0);
assert_eq!(offset_of!(FffWatchOptions, ignore), 8);
assert_eq!(offset_of!(FffWatchOptions, ignore_count), 16);
assert_eq!(size_of::<FffWatchEvent>(), 16);
assert_eq!(offset_of!(FffWatchEvent, path), 0);
assert_eq!(offset_of!(FffWatchEvent, kind), 8);
assert_eq!(size_of::<FffWatchEventBatch>(), 16);
assert_eq!(offset_of!(FffWatchEventBatch, events), 0);
assert_eq!(offset_of!(FffWatchEventBatch, count), 8);
}
}
+186
View File
@@ -12,9 +12,190 @@
* compilers.
*/
/* expose mkdtemp/usleep under -std=c99 on glibc; harmless on musl/darwin */
#define _DEFAULT_SOURCE
#define _BSD_SOURCE
#include <fff.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
// simple mock function to make sure that both globbing patterns and dir based pattern work
static int watch_glob_hits = 0;
static int watch_dir_hits = 0;
static int watch_all_hits = 0;
static int watch_ignored_leaks = 0;
static uint64_t watch_glob_id = 0;
static uint64_t watch_dir_id = 0;
static uint64_t watch_all_id = 0;
static void on_watch_batch(uint64_t watch_id, struct FffWatchEventBatch *batch, void *user_data) {
(void)user_data;
/* route by id like real SDKs do; unknown ids are benign no-ops */
for (uint32_t i = 0; i < batch->count; i++) {
const char *path = batch->events[i].path;
if (!path) continue;
if (watch_id == watch_glob_id && strstr(path, "hello.txt")) {
watch_glob_hits++;
}
if (watch_id == watch_dir_id) {
if (strstr(path, "hello.txt")) watch_dir_hits++;
if (strstr(path, "noise.log")) watch_ignored_leaks++;
}
if (watch_id == watch_all_id && strstr(path, "hello.txt")) {
watch_all_hits++;
}
}
fff_free_watch_events(batch); // need to clean dynamic array of events
}
static int watch_smoke(void) {
char tmpl[] = "/tmp/fff-c-watch-XXXXXX";
char *dir = mkdtemp(tmpl);
if (!dir) {
fprintf(stderr, "watch_smoke: mkdtemp failed\n");
return 1;
}
struct FffResult *create_result = fff_create_instance_with(&(struct FffCreateOptions){
.version = FFF_CREATE_OPTIONS_VERSION,
.base_path = dir,
.enable_mmap_cache = false,
.enable_content_indexing = false,
.watch = true,
});
if (!create_result->success) {
fprintf(stderr, "watch_smoke: create failed: %s\n",
create_result->error ? create_result->error : "?");
fff_free_result(create_result);
return 1;
}
void *picker = create_result->handle;
fff_free_result(create_result);
struct FffResult *r = fff_wait_for_scan(picker, 10000);
fff_free_result(r);
r = fff_wait_for_watcher(picker, 10000);
fff_free_result(r);
usleep(300 * 1000); /* let the FSEvents stream settle */
/* instance-wide callback, then two subscriptions routed by id */
r = fff_set_watch_callback(picker, on_watch_batch, NULL);
if (!r->success) {
fprintf(stderr, "watch_smoke: fff_set_watch_callback failed: %s\n",
r->error ? r->error : "?");
fff_free_result(r);
fff_destroy(picker);
return 1;
}
fff_free_result(r);
r = fff_watch(picker, "**/*.txt", NULL);
if (!r->success) {
fprintf(stderr, "watch_smoke: fff_watch failed: %s\n", r->error ? r->error : "?");
fff_free_result(r);
fff_destroy(picker);
return 1;
}
watch_glob_id = (uint64_t)r->int_value;
fff_free_result(r);
/* whole-tree dir subscription with an ignore glob */
const char *ignores[] = {"*.log"};
r = fff_watch(picker, dir,
&(struct FffWatchOptions){.version = FFF_WATCH_OPTIONS_VERSION,
.ignore = ignores,
.ignore_count = 1});
if (!r->success) {
fprintf(stderr, "watch_smoke: dir fff_watch failed: %s\n", r->error ? r->error : "?");
fff_free_result(r);
fff_destroy(picker);
return 1;
}
watch_dir_id = (uint64_t)r->int_value;
fff_free_result(r);
/* NULL pattern subscribes to the entire indexed tree */
r = fff_watch(picker, NULL, NULL);
if (!r->success) {
fprintf(stderr, "watch_smoke: NULL-pattern fff_watch failed: %s\n",
r->error ? r->error : "?");
fff_free_result(r);
fff_destroy(picker);
return 1;
}
watch_all_id = (uint64_t)r->int_value;
fff_free_result(r);
char file_path[512];
snprintf(file_path, sizeof(file_path), "%s/hello.txt", dir);
FILE *f = fopen(file_path, "w");
if (!f) {
fprintf(stderr, "watch_smoke: fopen failed\n");
fff_destroy(picker);
return 1;
}
fputs("hello watch\n", f);
fclose(f);
/* must be filtered out by the dir subscription's ignore glob */
char log_path[512];
snprintf(log_path, sizeof(log_path), "%s/noise.log", dir);
FILE *lf = fopen(log_path, "w");
if (lf) {
fputs("noise\n", lf);
fclose(lf);
}
for (int attempt = 0;
attempt < 100 && (watch_glob_hits == 0 || watch_dir_hits == 0 || watch_all_hits == 0);
attempt++) {
usleep(100 * 1000);
}
r = fff_unwatch(picker, watch_glob_id);
fff_free_result(r);
r = fff_unwatch(picker, watch_dir_id);
fff_free_result(r);
r = fff_unwatch(picker, watch_all_id);
fff_free_result(r);
/* unwatch of an unknown id reports 0, not an error */
r = fff_unwatch(picker, watch_dir_id);
int unwatch_idempotent = r->success && r->int_value == 0;
fff_free_result(r);
/* fff_destroy is the quiescence barrier: after it returns the callback
* will never run again and could be freed (ours is static). */
fff_destroy(picker);
if (watch_glob_hits == 0) {
fprintf(stderr, "watch_smoke FAIL: glob subscriber saw no events\n");
return 1;
}
if (watch_dir_hits == 0) {
fprintf(stderr, "watch_smoke FAIL: dir subscriber saw no events\n");
return 1;
}
if (watch_all_hits == 0) {
fprintf(stderr, "watch_smoke FAIL: NULL-pattern subscriber saw no events\n");
return 1;
}
if (watch_ignored_leaks > 0) {
fprintf(stderr, "watch_smoke FAIL: ignore glob leaked %d events\n", watch_ignored_leaks);
return 1;
}
if (!unwatch_idempotent) {
fprintf(stderr, "watch_smoke FAIL: repeated unwatch was not a no-op\n");
return 1;
}
fprintf(stderr, "watch_smoke PASS (glob=%d dir=%d all=%d)\n", watch_glob_hits, watch_dir_hits,
watch_all_hits);
return 0;
}
int main(int argc, char **argv) {
const char *base_path = argc > 1 ? argv[1] : ".";
@@ -84,6 +265,11 @@ int main(int argc, char **argv) {
return 1;
}
if (watch_smoke() != 0) {
fprintf(stderr, "FAIL: watch test failed\n");
return 1;
}
fprintf(stderr, "PASS\n");
return 0;
}
+13 -1
View File
@@ -1,11 +1,14 @@
[package]
name = "fff-search"
version = "0.9.6"
version = "0.10.4"
edition = "2024"
license = "MIT"
authors = ["Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>"]
description = "Faboulous & Fast File Finder - a fast and extremely correct file finder SDK with typo resistance, SIMD, prefiltering, and more"
[lints]
workspace = true
[lib]
path = "src/lib.rs"
crate-type = ["rlib", "staticlib", "cdylib"]
@@ -27,6 +30,10 @@ name = "glob_bench"
harness = false
required-features = ["zlob"]
[[bench]]
name = "grep_bench"
harness = false
[features]
# `ripgrep` is the pure-Rust walker/glob backend and is on by default so
# consumers build without a Zig toolchain. CI/release opt into zlob via
@@ -34,6 +41,11 @@ required-features = ["zlob"]
default = ["ripgrep"]
# Enable C FFI exports
ffi = []
# Count full rescans and their causes. Always on in debug builds; enable this
# to keep the accounting in a release build (used by the rescan_probe binary).
rescan-stats = []
# Enables POC definition classification for grep result matched lines
definitions = []
# Pure-Rust filesystem walker + glob matcher (ignore + globset crates).
ripgrep = ["dep:ignore", "dep:globset", "fff-query-parser/ripgrep"]
# Call mi_collect(true) after large allocator churn (bigram build).
+2 -8
View File
@@ -1,11 +1,5 @@
//! Compare three glob-matching strategies for `match_glob_pattern` in constraints.rs:
//!
//! 1. Current: `zlob_match_paths` -> collect `as_ptr()` into AHashSet, filter paths
//! by pointer to recover indices.
//! 2. Free fn: `zlob_match_paths_indices` (added in zlob 1.4) — indices direct from C.
//! 3. Compiled: `ZlobPattern::compile` + `match_indices` — same indices path, but with
//! a precompiled pattern (reusable). For one-shot it should match (2); the win
//! appears if the pattern is reused (chunked / repeated calls).
//! Compare three glob-matching strategies for `match_glob_pattern` in constraints.rs
//! need to make sure that my zlob changes are not affecting fff's internal globbing
use ahash::AHashSet;
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use zlob::{ZlobFlags, ZlobPattern, zlob_match_paths, zlob_match_paths_indices};
+105
View File
@@ -0,0 +1,105 @@
use criterion::{Criterion, criterion_group, criterion_main};
use fff_search::file_picker::{FilePicker, FilePickerOptions};
use fff_search::{GrepMode, GrepSearchOptions, parse_grep_query};
use std::io::Write;
/// Synthetic repo: half the files contain the needle on every line (stresses
/// the per-match find/highlight path), half are pure noise (stresses the
/// whole-file prefilter path).
fn setup_repo(dir: &std::path::Path) {
for i in 0..400 {
let mut f = std::fs::File::create(dir.join(format!("match_{i}.rs"))).unwrap();
for j in 0..100 {
writeln!(
f,
"fn handle_{j}() {{ let controller = Controller::new({j}); controller.run(); }}"
)
.unwrap();
}
}
for i in 0..400 {
let mut f = std::fs::File::create(dir.join(format!("noise_{i}.rs"))).unwrap();
for j in 0..100 {
writeln!(
f,
"fn compute_{j}() {{ let value = {j} * 42; process(value); }}"
)
.unwrap();
}
}
}
fn options(mode: GrepMode) -> GrepSearchOptions {
GrepSearchOptions {
// Force a full scan of every file so we measure matcher/sink work,
// not pagination early-exit.
page_limit: usize::MAX,
max_matches_per_file: 0,
mode,
..Default::default()
}
}
fn bench_grep(c: &mut Criterion) {
let dir = tempfile::tempdir().unwrap();
setup_repo(dir.path());
let mut picker = FilePicker::new(FilePickerOptions {
base_path: dir.path().to_str().unwrap().into(),
watch: false,
..Default::default()
})
.unwrap();
picker.collect_files().unwrap();
assert_eq!(picker.get_files().len(), 800);
let mut group = c.benchmark_group("grep_e2e");
group.sample_size(30);
// Case-sensitive, 40k matched lines: hottest find_at/highlight path
let query = parse_grep_query("Controller");
let opts = options(GrepMode::PlainText);
group.bench_function("plain_case_sensitive_many_matches", |b| {
b.iter(|| {
let r = picker.grep(&query, &opts);
assert_eq!(r.files_with_matches, 400);
std::hint::black_box(r.matches.len())
});
});
// Case-insensitive (SIMD folding path), 120k matched spans
let query = parse_grep_query("controller");
group.bench_function("plain_case_insensitive_many_matches", |b| {
b.iter(|| {
let r = picker.grep(&query, &opts);
assert_eq!(r.files_with_matches, 400);
std::hint::black_box(r.matches.len())
});
});
// No matches anywhere: whole-file prefilter dominates
let query = parse_grep_query("Qqzyx");
group.bench_function("plain_no_matches", |b| {
b.iter(|| {
let r = picker.grep(&query, &opts);
assert_eq!(r.files_with_matches, 0);
std::hint::black_box(r.total_files_searched)
});
});
// Regex mode: must be unaffected by NeedleFinder changes
let query = parse_grep_query("Contr[a-z]+ller");
let regex_opts = options(GrepMode::Regex);
group.bench_function("regex_many_matches", |b| {
b.iter(|| {
let r = picker.grep(&query, &regex_opts);
assert_eq!(r.files_with_matches, 400);
std::hint::black_box(r.matches.len())
});
});
group.finish();
}
criterion_group!(benches, bench_grep);
criterion_main!(benches);
+9
View File
@@ -3,6 +3,15 @@ fn main() {
// used by tests/fuzz_git_watcher_stress.rs
println!("cargo::rustc-check-cfg=cfg(stress)");
// Full-rescan accounting. Debug builds get it for free; a release build has
// to opt in with `--features rescan-stats` (what the rescan_probe needs).
println!("cargo::rustc-check-cfg=cfg(rescan_stats)");
if std::env::var("DEBUG").is_ok_and(|debug| debug != "false")
|| std::env::var("CARGO_FEATURE_RESCAN_STATS").is_ok()
{
println!("cargo::rustc-cfg=rescan_stats");
}
// When the `zlob` feature is enabled (Zig-compiled C library):
// On Windows MSVC, explicitly link the C runtime libraries.
// Zig-compiled static libraries don't emit /DEFAULTLIB directives for the
+12 -3
View File
@@ -12,16 +12,25 @@ pub const MAX_INDEXABLE_FILE_SIZE: usize = 2 * 1024 * 1024;
pub const MAX_CACHED_CONTENT_BYTES: u64 = 512 * 1024 * 1024;
/// Files below one page waste the remainder when mmapped, so the cache skips
/// them and falls back to chunked reads. Unused on Windows (no content cache).
/// them and falls back to chunked reads. Unused on Windows (no content cache)
#[cfg(all(not(target_os = "windows"), target_arch = "aarch64"))]
pub const MMAP_THRESHOLD: u64 = 16 * 1024;
#[cfg(all(not(target_os = "windows"), not(target_arch = "aarch64")))]
pub const MMAP_THRESHOLD: u64 = 4 * 1024;
/// Capacity reserved for files the watcher discovers after the initial scan;
/// exceeding it forces a full rescan.
/// Watcher overflow capacity reserved after the initial scan
pub const MAX_OVERFLOW_FILES: usize = 1024;
/// Minimum delay between watcher-initiated rescans.
pub const RESCAN_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30);
/// Rescan delay for large indexes.
pub const RESCAN_MIN_INTERVAL_LARGE_INDEX: std::time::Duration =
std::time::Duration::from_secs(5 * 60);
/// Live-file count at which [`RESCAN_MIN_INTERVAL_LARGE_INDEX`] takes over.
pub const LARGE_INDEX_FILE_COUNT: usize = 1_000_000;
/// Fresh-mmap threshold: files at or above this size get mmapped directly on
/// cache miss instead of chunked reads into Vec. Empirically tuned per-platform.
/// Only referenced on Unix; Windows uses the `std::fs::read` fallback so this
+250
View File
@@ -0,0 +1,250 @@
use heed::{Env, EnvOpenOptions};
use std::collections::HashMap;
use std::fs;
use std::ops::Deref;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock, Mutex, MutexGuard, PoisonError, Weak};
use std::thread;
use std::time::Duration;
use crate::error::{Error, Result};
use crate::lmdb::DbHealth;
pub(crate) struct EnvSpec {
pub label: &'static str,
pub map_size: usize,
pub max_dbs: u32,
pub size_cap_bytes: u64,
}
pub(crate) struct PooledEnv {
env: Env,
key: PathBuf,
/// lmdb's env spec label
label: &'static str,
map_size: usize,
max_dbs: u32,
health: DbHealth,
gc_started: AtomicBool,
dbi_lock: Mutex<()>,
}
impl Drop for PooledEnv {
fn drop(&mut self) {
let mut pool = POOL.lock().unwrap_or_else(PoisonError::into_inner);
// Only remove a dead entry: begin_exclusive_destroy may have removed ours.
if pool.get(&self.key).is_some_and(|w| w.strong_count() == 0) {
pool.remove(&self.key);
}
// heed closes the env right after this body; a concurrent reopen of the
// same path rides out that gap via env_closing_event in get_or_open.
}
}
// Cloneable handle to a process-shared LMDB env, derefs to `heed::Env`.
#[derive(Clone)]
pub(crate) struct SharedEnv(Arc<PooledEnv>);
impl Deref for SharedEnv {
type Target = Env;
fn deref(&self) -> &Env {
&self.0.env
}
}
impl std::fmt::Debug for SharedEnv {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("SharedEnv").field(&self.0.env).finish()
}
}
impl SharedEnv {
pub(crate) fn get_or_open(db_path: &Path, spec: &EnvSpec) -> Result<Self> {
fs::create_dir_all(db_path).map_err(Error::CreateDir)?;
let path = fs::canonicalize(db_path).map_err(|e| Error::EnvOpen {
db: spec.label,
source: heed::Error::Io(e),
})?;
let mut close_waits = 0u32;
let mut transient_retries = 0u32;
loop {
let mut open_failed = false;
{
let mut pool = POOL.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(existing) = pool.get(&path).and_then(Weak::upgrade) {
drop(pool);
if existing.label != spec.label
|| existing.map_size != spec.map_size
|| existing.max_dbs != spec.max_dbs
{
return Err(Error::EnvSpecMismatch {
path,
open_as: existing.label,
requested_as: spec.label,
});
}
return Ok(Self(existing));
}
erase_if_oversized(&path, spec);
let result = unsafe {
let mut opts = EnvOpenOptions::new();
opts.map_size(spec.map_size);
if spec.max_dbs > 0 {
opts.max_dbs(spec.max_dbs);
}
opts.open(&path)
};
match result {
Ok(env) => {
let entry = Arc::new(PooledEnv {
env,
key: path.clone(),
label: spec.label,
map_size: spec.map_size,
max_dbs: spec.max_dbs,
health: DbHealth::new(),
gc_started: AtomicBool::new(false),
dbi_lock: Mutex::new(()),
});
pool.insert(path.clone(), Arc::downgrade(&entry));
drop(pool);
let shared = Self(entry);
match shared.clear_stale_readers() {
Ok(cleared_count) if cleared_count > 0 => {
tracing::info!(
cleared_count,
db = spec.label,
"reclaimed stale LMDB reader slots at open"
);
}
Ok(_) => {}
Err(e) => {
tracing::debug!("clear_stale_readers at open failed: {e}")
}
}
return Ok(shared);
}
Err(heed::Error::EnvAlreadyOpened) => open_failed = true,
// special handling cause we know this happens randomly
Err(e)
if is_transient_env_open_error(&e)
&& transient_retries < MAX_TRANSIENT_RETRIES =>
{
transient_retries += 1;
tracing::debug!(
path = %path.display(),
transient_retries,
error = ?e,
"transient LMDB env open error, retrying"
);
}
Err(e) => {
return Err(Error::EnvOpen {
db: spec.label,
source: e,
});
}
}
}
if open_failed {
close_waits += 1;
if close_waits > MAX_CLOSE_WAITS {
return Err(Error::EnvOpen {
db: spec.label,
source: heed::Error::EnvAlreadyOpened,
});
}
match heed::env_closing_event(&path) {
Some(event) => {
event.wait_timeout(CLOSE_WAIT);
}
None => thread::sleep(Duration::from_millis(2)),
}
} else {
thread::sleep(TRANSIENT_RETRY_SLEEP);
}
}
}
pub(crate) fn health(&self) -> &DbHealth {
&self.0.health
}
// First caller wins: GC runs once per opened env, not once per tracker.
pub(crate) fn try_start_gc(&self) -> bool {
!self.0.gc_started.swap(true, Ordering::AcqRel)
}
// LMDB forbids mdb_dbi_open from concurrent txns in the same process.
pub(crate) fn lock_dbi_open(&self) -> MutexGuard<'_, ()> {
self.0
.dbi_lock
.lock()
.unwrap_or_else(PoisonError::into_inner)
}
pub(crate) fn destroy(&self) -> Result<Option<heed::EnvClosingEvent>> {
let mut pool = POOL.lock().unwrap_or_else(PoisonError::into_inner);
let holders = Arc::strong_count(&self.0);
if holders > 1 {
return Err(Error::DbInUse {
db: self.0.label,
path: self.0.key.clone(),
holders: holders - 1,
});
}
pool.remove(&self.0.key);
Ok(heed::env_closing_event(&self.0.key))
}
}
static POOL: LazyLock<Mutex<HashMap<PathBuf, Weak<PooledEnv>>>> = LazyLock::new(Mutex::default);
const CLOSE_WAIT: Duration = Duration::from_millis(100);
const MAX_CLOSE_WAITS: u32 = 100;
const TRANSIENT_RETRY_SLEEP: Duration = Duration::from_millis(50);
const MAX_TRANSIENT_RETRIES: u32 = 8;
// Concurrent mdb_env_open calls on the same path can race on macOS
// this is for some reason fixable by simple retry of the open
fn is_transient_env_open_error(err: &heed::Error) -> bool {
match err {
heed::Error::Io(io) => matches!(
io.kind(),
std::io::ErrorKind::InvalidInput | std::io::ErrorKind::NotFound
),
_ => false,
}
}
fn erase_if_oversized(db_path: &Path, spec: &EnvSpec) {
let data = db_path.join("data.mdb");
let Ok(meta) = fs::metadata(&data) else {
return;
};
if meta.len() <= spec.size_cap_bytes {
return;
}
tracing::error!(
path = %db_path.display(),
size = meta.len(),
cap = spec.size_cap_bytes,
"LMDB db exceeds size cap, erasing"
);
let _ = fs::remove_file(&data);
let _ = fs::remove_file(db_path.join("lock.mdb"));
}
+7 -6
View File
@@ -1,10 +1,11 @@
use super::db_healthcheck::DbHealthChecker;
use super::lmdb::{DbHealth, LmdbStore, is_map_full};
use super::env_pool::SharedEnv;
use crate::error::{Error, Result};
use crate::file_picker::FFFMode;
use crate::git::is_modified_status;
use crate::lmdb::{DbHealth, LmdbStore, is_map_full};
use heed::Database;
use heed::types::{Bytes, SerdeBincode};
use heed::{Database, Env};
use std::time::{SystemTime, UNIX_EPOCH};
use std::{collections::VecDeque, path::Path};
@@ -19,7 +20,7 @@ const AI_MAX_HISTORY_DAYS: f64 = 7.0; // Only consider accesses within 7 days
#[derive(Debug)]
pub struct FrecencyTracker {
env: Env,
env: SharedEnv,
db: Database<Bytes, SerdeBincode<VecDeque<u64>>>,
health: DbHealth,
}
@@ -77,7 +78,7 @@ impl LmdbStore for FrecencyTracker {
// MAP_SIZE so we don't hit MDB_MAP_FULL before the open-time erase fires.
const SIZE_CAP_BYTES: u64 = 12 * 1024 * 1024;
fn env(&self) -> &Env {
fn shared_env(&self) -> &SharedEnv {
&self.env
}
@@ -85,7 +86,7 @@ impl LmdbStore for FrecencyTracker {
&self.health
}
fn purge_stale_data(env: &Env) -> Result<()> {
fn purge_stale_data(env: &SharedEnv) -> Result<()> {
let (deleted, pruned) = Self::purge_stale_entries(env)?;
if deleted > 0 || pruned > 0 {
tracing::info!(deleted, pruned, "Frecency GC purged entries");
@@ -121,7 +122,7 @@ impl FrecencyTracker {
/// Removes entries where all timestamps are older than MAX_HISTORY_DAYS,
/// and prunes stale timestamps from entries that still have recent ones.
/// Returns (deleted_count, pruned_count).
fn purge_stale_entries(env: &Env) -> Result<(usize, usize)> {
fn purge_stale_entries(env: &SharedEnv) -> Result<(usize, usize)> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
+34 -96
View File
@@ -1,12 +1,11 @@
use heed::{Database, Env, EnvOpenOptions};
use std::fs;
use heed::{Database, Env};
use std::path::Path;
use std::sync::Arc;
use std::sync::RwLock;
use std::sync::atomic::{AtomicU8, Ordering};
use std::thread;
use std::time::Duration;
use super::env_pool::{EnvSpec, SharedEnv};
use crate::error::{Error, Result};
pub(crate) fn is_map_full(err: &heed::Error) -> bool {
@@ -85,9 +84,13 @@ pub(crate) fn spawn_lmdb_gc<T: LmdbStore>(shared: Arc<RwLock<Option<T>>>) {
let Some(ref tracker) = *guard else {
return; // destroyed before we started
};
let env = tracker.env();
// Trackers attaching to an already-pooled env must not repeat the
// GC; the first opener's run flips the shared health flag.
if !tracker.shared_env().try_start_gc() {
return;
}
if let Err(e) = T::purge_stale_data(env) {
if let Err(e) = T::purge_stale_data(tracker.shared_env()) {
tracing::debug!("purge_stale_data failed: {e}");
}
@@ -105,18 +108,6 @@ pub(crate) fn spawn_lmdb_gc<T: LmdbStore>(shared: Arc<RwLock<Option<T>>>) {
}
}
// Concurrent `mdb_env_open` calls on the same path can race on macOS
// this is for some reason fixabtly by simple retry of the open
fn is_transient_env_open_error(err: &heed::Error) -> bool {
match err {
heed::Error::Io(io) => matches!(
io.kind(),
std::io::ErrorKind::InvalidInput | std::io::ErrorKind::NotFound
),
_ => false,
}
}
pub(crate) trait LmdbStore: Sized + Send + Sync + 'static {
/// Short label used to defferintiate different instances of this trait
const LABEL: &'static str;
@@ -127,85 +118,51 @@ pub(crate) trait LmdbStore: Sized + Send + Sync + 'static {
/// Hard cap on `data.mdb` size.
const SIZE_CAP_BYTES: u64;
/// Borrow the env in the read lock
fn env(&self) -> &Env;
/// Borrow the pooled env handle shared by every tracker of this path.
fn shared_env(&self) -> &SharedEnv;
/// Borrow the health flag from the tracker.
fn health(&self) -> &DbHealth;
/// Borrow the raw heed env.
fn env(&self) -> &Env {
self.shared_env()
}
/// Override to purge stale rows, compact, etc. Default no-op. Runs on
/// the GC thread while a read lock is held against the shared handle,
/// so destroy / re-init naturally wait for it.
fn purge_stale_data(_env: &Env) -> Result<()> {
fn purge_stale_data(_env: &SharedEnv) -> Result<()> {
Ok(())
}
/// Open the LMDB env. Returns env + a `DbHealth` starting in Pending;
/// the GC thread spawned by `spawn_gc` flips it to Healthy. Write
/// paths flip it to Degraded on MDB_MAP_FULL.
/// Open (or join) the process-shared LMDB env for `db_path`. The health
/// flag is per-env: the GC of the first opener flips it for everyone.
#[tracing::instrument]
fn open_env(db_path: &Path) -> Result<(Env, DbHealth)> {
Self::erase_if_oversized(db_path);
fs::create_dir_all(db_path).map_err(Error::CreateDir)?;
let db = Self::LABEL;
const MAX_ATTEMPTS: u32 = 8;
let mut attempt = 0u32;
let env = loop {
let result = unsafe {
let mut opts = EnvOpenOptions::new();
opts.map_size(Self::MAP_SIZE);
if Self::MAX_DBS > 0 {
opts.max_dbs(Self::MAX_DBS);
}
opts.open(db_path)
};
match result {
Ok(env) => break env,
Err(e) if is_transient_env_open_error(&e) && attempt + 1 < MAX_ATTEMPTS => {
attempt += 1;
tracing::debug!(
path = %db_path.display(),
attempt,
error = ?e,
"transient LMDB env open error, retrying"
);
thread::sleep(Duration::from_millis(50));
}
Err(e) => return Err(Error::EnvOpen { db, source: e }),
}
};
// Reclaim reader slots left behind by prior processes that died
// without cleanup. Must run before we start any read txns (which
// open_database_safe does) — otherwise we may hit MDB_READERS_FULL
// on a fresh env just because lock.mdb still has stale entries
// from a previous crash.
//
// This is the one LMDB maintenance call we run on the caller's
// thread. If the lock file is genuinely wedged this will block
// forever, but the alternative — never getting past init — is
// worse and the bg-thread trick doesn't solve it anyway.
match env.clear_stale_readers() {
Ok(cleared) if cleared > 0 => {
tracing::warn!(cleared, "reclaimed stale LMDB reader slots at open");
}
Ok(_) => {}
Err(e) => tracing::debug!("clear_stale_readers at open failed: {e}"),
}
Ok((env, DbHealth::new()))
fn open_env(db_path: &Path) -> Result<(SharedEnv, DbHealth)> {
let shared = SharedEnv::get_or_open(
db_path,
&EnvSpec {
label: Self::LABEL,
map_size: Self::MAP_SIZE,
max_dbs: Self::MAX_DBS,
size_cap_bytes: Self::SIZE_CAP_BYTES,
},
)?;
let health = shared.health().clone();
Ok((shared, health))
}
/// Open or create a database without blocking on the LMDB writer mutex
/// when the database already exists.
fn open_database_safe<KC, DC>(env: &Env, name: Option<&str>) -> Result<Database<KC, DC>>
fn open_database_safe<KC, DC>(env: &SharedEnv, name: Option<&str>) -> Result<Database<KC, DC>>
where
KC: 'static,
DC: 'static,
{
let db = Self::LABEL;
// mdb_dbi_open must not run from concurrent txns in this process.
let _dbi_guard = env.lock_dbi_open();
let rtxn = env
.read_txn()
.map_err(|source| Error::DbStartReadTxn { db, source })?;
@@ -237,23 +194,4 @@ pub(crate) trait LmdbStore: Sized + Send + Sync + 'static {
}
}
}
fn erase_if_oversized(db_path: &Path) {
let data = db_path.join("data.mdb");
let Ok(meta) = fs::metadata(&data) else {
return;
};
if meta.len() <= Self::SIZE_CAP_BYTES {
return;
}
tracing::error!(
path = %db_path.display(),
size = meta.len(),
cap = Self::SIZE_CAP_BYTES,
"LMDB db exceeds size cap, erasing"
);
let _ = fs::remove_file(&data);
let _ = fs::remove_file(db_path.join("lock.mdb"));
}
}
+1
View File
@@ -1,3 +1,4 @@
pub(crate) mod env_pool;
pub(crate) mod lmdb;
pub mod db_healthcheck;
+4 -3
View File
@@ -1,6 +1,7 @@
use super::db_healthcheck::DbHealthChecker;
use super::lmdb::{DbHealth, LmdbStore, is_map_full};
use super::env_pool::SharedEnv;
use crate::error::Error;
use crate::lmdb::{DbHealth, LmdbStore, is_map_full};
use heed::types::{Bytes, SerdeBincode};
use heed::{Database, Env};
use serde::{Deserialize, Serialize};
@@ -27,7 +28,7 @@ struct HistoryEntry {
#[derive(Debug)]
pub struct QueryTracker {
env: Env,
env: SharedEnv,
// Database for (project_path, query) -> QueryMatchEntry mappings
query_file_db: Database<Bytes, SerdeBincode<QueryMatchEntry>>,
// Database for project_path -> VecDeque<HistoryEntry> mappings (file picker)
@@ -92,7 +93,7 @@ impl LmdbStore for QueryTracker {
const MAX_DBS: u32 = 16;
const SIZE_CAP_BYTES: u64 = 8 * 1024 * 1024;
fn env(&self) -> &Env {
fn shared_env(&self) -> &SharedEnv {
&self.env
}
+31
View File
@@ -34,6 +34,22 @@ pub enum Error {
#[source]
source: heed::Error,
},
#[error(
"LMDB env at {path} is already open as the '{open_as}' database with different options; requested by '{requested_as}'. Use a distinct path per database."
)]
EnvSpecMismatch {
path: std::path::PathBuf,
open_as: &'static str,
requested_as: &'static str,
},
#[error(
"The {db} database at {path} is still used by {holders} other tracker(s) in this process"
)]
DbInUse {
db: &'static str,
path: std::path::PathBuf,
holders: usize,
},
#[error("Failed to create {db} database: {source}")]
DbCreate {
db: &'static str,
@@ -94,6 +110,21 @@ pub enum Error {
#[error("Filesystem walk failed: {0}")]
WalkFailed(String),
#[error("Invalid glob pattern '{pattern}': {reason}")]
InvalidGlobPattern { pattern: String, reason: String },
#[error("File system watching is disabled for this picker")]
WatcherDisabled,
#[error("File system watcher is not ready")]
WatcherNotReady,
#[error("Indexed base path changed while creating the watch subscription")]
WatchBaseChanged,
#[error("Failed to start watch callback dispatcher: {0}")]
WatchDispatcherStart(#[source] std::io::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
+454 -90
View File
@@ -26,21 +26,18 @@
//! # Thread Safety
//!
//! `FilePicker` itself is **not** `Sync`!
//! all concurrent access goes through [`SharedPicker`](crate::SharedPicker) .
//! The background scanner and watcher acquire write locks only when mutating
//! the file index, so read-heavy search workloads rarely contend.
//! all concurrent access goes through [`crate::SharedFilePicker`]
use crate::FFFStringStorage;
use crate::background_watcher::BackgroundWatcher;
use crate::bigram_filter::{BigramFilter, BigramOverlay};
use crate::constants::{MAX_OVERFLOW_FILES, PATH_BUF_SIZE};
use crate::error::Error;
use crate::frecency::FrecencyTracker;
use crate::git::GitStatusCache;
use crate::grep::{GrepResult, GrepSearchOptions, grep_search, multi_grep_search};
use crate::index::{BigramFilter, BigramOverlay};
use crate::query_tracker::QueryTracker;
use crate::scan::{ScanConfig, ScanJob, ScanSignals};
use crate::score::fuzzy_match_and_score_files;
use crate::score::{fuzzy_match_and_score_files, fuzzy_match_byte_offsets_for_page};
use crate::shared::{SharedFilePicker, SharedFrecency};
use crate::simd_path::ArenaPtr;
use crate::stable_vec::StableVec;
@@ -48,6 +45,8 @@ use crate::types::{
ContentCacheBudget, DirItem, DirSearchResult, FileItem, MixedItemRef, MixedSearchResult,
PaginationArgs, Score, ScoringContext, SearchResult,
};
use crate::walk::WalkOutput;
use crate::watch::BackgroundWatcher;
use fff_query_parser::FFFQuery;
use git2::{Repository, Status};
use rayon::prelude::*;
@@ -98,7 +97,7 @@ pub(crate) struct FileSync {
/// (parent_dir, filename):
/// `files[..indexable_count]` - indexable
/// `files[indexable_count..base_count]` - original-unindexable
/// `files[base_count..]` overflow (created on demand)
/// `files[base_count..]` - overflow
files: StableVec<FileItem>,
indexable_count: usize,
base_count: usize,
@@ -109,7 +108,12 @@ pub(crate) struct FileSync {
/// concurrent readers observe a consistent view via the same shared
/// allocation. Dir frecency is updated through the per-entry atomic
/// (`DirItem::max_access_frecency`) without `&mut` aliasing.
/// Layout mirrors `files`: `dirs[..base_dirs_count]` is the sorted
/// scan-built region, `dirs[base_dirs_count..]` holds watcher-appended dirs.
dirs: StableVec<DirItem>,
base_dirs_count: usize,
/// Number of dirs with at least one live file (mirrors `live_count`).
live_dirs_count: usize,
/// Shared builder for overflow file paths. Each overflow file's ChunkedString
/// uses `arena_override` pointing into this builder's arena.
overflow_builder: Option<crate::simd_path::ChunkedPathStoreBuilder>,
@@ -130,7 +134,9 @@ impl FileSync {
indexable_count: 0,
base_count: 0,
live_count: 0,
dirs: StableVec::from_vec_with_reserve(Vec::new(), 0),
dirs: StableVec::from_vec_with_reserve(Vec::new(), MAX_OVERFLOW_FILES),
base_dirs_count: 0,
live_dirs_count: 0,
overflow_builder: None,
git_workdir: None,
bigram_index: None,
@@ -223,15 +229,12 @@ impl FileSync {
// Binary search dirs to find the parent directory index.
// Dir items store the relative path including trailing '/' (e.g. "src/components/").
// Only the scan-built region is sorted; watcher-appended dirs are not.
let mut dir_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
let dir_idx = self
.dirs
let dir_idx = self.dirs[..self.base_dirs_count]
.binary_search_by(|d| d.read_relative_path(arena, &mut dir_buf).cmp(dir_rel))
.ok();
// Binary search base files by (parent_dir, filename). Base files live in
// two internally-sorted partitions — indexable first, then unindexable —
// so we try each half in turn. Two O(log n) searches with short-circuit.
if let Some(dir_idx) = dir_idx {
let dir_idx = dir_idx as u32;
let cmp_key = |f: &FileItem| {
@@ -271,10 +274,11 @@ impl FileSync {
// TODO remove this function and make a better way to remove all files
// from the directory without looping over the whole sync data list
/// Tombstones every file in the arena that matches certain predicate
fn tombstone_files_with_arena<F>(&mut self, mut predicate: F) -> usize
// Tombstones every matching arena file.
fn tombstone_files_with_arena<F, T>(&mut self, mut predicate: F, mut on_tombstone: T) -> usize
where
F: FnMut(&FileItem, ArenaPtr) -> bool,
T: FnMut(&FileItem, ArenaPtr),
{
let base_arena = self.arena_base_ptr();
let overflow_arena = self.arena_overflow_ptr();
@@ -291,6 +295,7 @@ impl FileSync {
overflow_arena
};
if predicate(file, arena) {
on_tombstone(file, arena);
file.set_deleted(true);
tombstoned += 1;
}
@@ -298,6 +303,91 @@ impl FileSync {
self.live_count -= tombstoned;
tombstoned
}
/// Marks every dir matching `predicate` as deleted. Mirrors how dir-level
/// FS events (remove/move-out) invalidate whole subtrees.
fn tombstone_dirs_with_arena<F>(&mut self, mut predicate: F)
where
F: FnMut(&DirItem, ArenaPtr) -> bool,
{
let base_arena = self.arena_base_ptr();
let overflow_arena = self.arena_overflow_ptr();
let base_dirs_count = self.base_dirs_count;
let mut removed = 0usize;
for (idx, dir) in self.dirs.iter_mut().enumerate() {
if dir.is_deleted() {
continue;
}
let arena = if idx < base_dirs_count {
base_arena
} else {
overflow_arena
};
if predicate(dir, arena) && dir.set_deleted(true) {
removed += 1;
}
}
self.live_dirs_count -= removed;
}
/// Restores a dir to the live state (file appeared under it again).
fn revive_dir(&mut self, dir_idx: u32) {
if let Some(dir) = self.dirs.get_mut(dir_idx as usize)
&& dir.set_deleted(false)
{
self.live_dirs_count += 1;
}
}
/// Finds the dir index for a '/'-canonical relative dir path
/// (with trailing '/', empty string for the base dir itself).
fn find_dir_index(&self, dir_rel: &str) -> Option<usize> {
let arena = self.arena_base_ptr();
let mut dir_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
if let Ok(idx) = self.dirs[..self.base_dirs_count]
.binary_search_by(|d| d.read_relative_path(arena, &mut dir_buf).cmp(dir_rel))
{
return Some(idx);
}
// Watcher-appended region: unsorted, small (bounded by overflow cap).
let overflow_arena = self.arena_overflow_ptr();
self.dirs[self.base_dirs_count..]
.iter()
.position(|d| d.read_relative_path(overflow_arena, &mut dir_buf) == dir_rel)
.map(|pos| self.base_dirs_count + pos)
}
/// Finds or appends the DirItem for `dir_rel`, returning its index.
/// `None` when the dir table's overflow capacity is exhausted.
fn find_or_add_dir(&mut self, dir_rel: &str) -> Option<u32> {
if let Some(idx) = self.find_dir_index(dir_rel) {
return Some(idx as u32);
}
let builder = self.overflow_builder.get_or_insert_with(|| {
crate::simd_path::ChunkedPathStoreBuilder::new(MAX_OVERFLOW_FILES)
});
let chunked = builder.add_dir_immediate(dir_rel);
let last_seg = if dir_rel.is_empty() {
0
} else {
let trimmed = dir_rel.trim_end_matches(std::path::is_separator);
trimmed
.rfind(std::path::is_separator)
.map(|i| i + 1)
.unwrap_or(0) as u16
};
let idx = self.dirs.len();
if !self.dirs.push(DirItem::new_overflow(chunked, last_seg)) {
return None;
}
self.live_dirs_count += 1;
Some(idx as u32)
}
}
impl FileItem {
@@ -441,24 +531,23 @@ impl FileItem {
/// Options for creating a [`FilePicker`].
pub struct FilePickerOptions {
pub base_path: String,
/// Pre-populate mmap caches for top-frecency files after the initial scan.
/// Pre-populate mmap caches for top-frecency files after the initial scan
pub enable_mmap_cache: bool,
/// Build content index after the initial scan for faster content-aware filtering.
/// Build content index after the initial scan for faster content-aware filtering
pub enable_content_indexing: bool,
/// Mode of the picker impact the way file watcher events are handled and the scoring logic
pub mode: FFFMode,
/// Explicit cache budget. When `None`, the budget is auto-computed from
/// the repo size after the initial scan completes.
pub cache_budget: Option<ContentCacheBudget>,
/// When `false`, `new_with_shared_state` skips the background file watcher.
/// When `false` no background watcher will be created
pub watch: bool,
/// Follow symbolic links during file indexing.
/// Follow symbolic links during file indexing
pub follow_symlinks: bool,
/// Allow indexing the filesystem root (`/`). Off by default — these dirs
/// generate enormous fs-event traffic and are rarely the intended target.
/// Allow indexing the filesystem root (`/`)
pub enable_fs_root_scanning: bool,
/// Allow indexing the user's home directory. Off by default for the same
/// reason as `enable_fs_root_scanning`.
/// reason as `enable_fs_root_scanning`
pub enable_home_dir_scanning: bool,
}
@@ -540,6 +629,10 @@ impl FilePicker {
&self.base_path
}
pub fn has_git_repo(&self) -> bool {
self.sync_data.git_workdir.is_some()
}
/// Ignore rules the walker assembled during the last scan (zlob backend
/// only). The background watcher uses these to filter events without
/// libgit2. `None` when the backend doesn't surface rules or no ignore
@@ -560,6 +653,10 @@ impl FilePicker {
self.watch
}
pub fn is_watcher_ready(&self) -> bool {
self.background_watcher.is_some() && self.signals.watcher_ready.load(Ordering::Acquire)
}
pub fn follows_symlinks(&self) -> bool {
self.follow_symlinks
}
@@ -655,12 +752,21 @@ impl FilePicker {
if !dir_table.is_empty() {
let arena = self.arena_base_ptr();
let overflow_arena = self.sync_data.arena_overflow_ptr();
let mut path_buf = PathBuf::with_capacity(crate::simd_path::PATH_BUF_SIZE);
let mut prev_relative_path = String::new();
let mut scratch_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
for dir_item in dir_table.iter() {
let full_relative_path = dir_item.read_relative_path(arena, &mut scratch_buf);
if dir_item.is_deleted() {
continue;
}
let item_arena = if dir_item.is_overflow() {
overflow_arena
} else {
arena
};
let full_relative_path = dir_item.read_relative_path(item_arena, &mut scratch_buf);
let relative_path = full_relative_path.trim_end_matches(std::path::is_separator);
if relative_path.is_empty() {
@@ -747,6 +853,17 @@ impl FilePicker {
error!("Base path does not exist: {}", options.base_path);
return Err(Error::InvalidPath(path));
}
// Relative bases (".", "sub/dir") are resolved against the cwd so
// they can be compared with the absolute paths reported by the OS
// watcher. Purely lexical: no symlinks are resolved. The
// `components()` pass drops interior `.` segments ("/cwd/.").
let path = if path.is_relative() {
std::env::current_dir()
.map(|cwd| cwd.join(&path).components().collect())
.unwrap_or(path)
} else {
path
};
if path.parent().is_none() && !options.enable_fs_root_scanning {
error!("Refusing to index filesystem root: {}", path.display());
return Err(Error::FilesystemRoot(path));
@@ -758,7 +875,7 @@ impl FilePicker {
return Err(Error::FilesystemRoot(path));
}
// Windows-only: canonicalize with so the base path does NOT
// Windows-only: canonicalize with dunce so the base path does NOT
// have the `\\?\` UNC prefix that `std::fs::canonicalize` adds.
// libgit2's `repo.workdir()`
#[cfg(windows)]
@@ -831,6 +948,9 @@ impl FilePicker {
.scanning
.store(true, std::sync::atomic::Ordering::Release);
// Update the watch base before publishing the new picker.
shared_picker.rebase_watches(&path);
{
let mut guard = shared_picker.write()?;
*guard = Some(picker);
@@ -994,6 +1114,8 @@ impl FilePicker {
base_arena,
overflow_arena,
);
let match_byte_offsets =
fuzzy_match_byte_offsets_for_page(query, &items, max_typos, base_arena, overflow_arena);
info!(
?query,
@@ -1007,6 +1129,7 @@ impl FilePicker {
SearchResult {
items,
scores,
match_byte_offsets,
total_matched,
total_files,
location,
@@ -1030,7 +1153,7 @@ impl FilePicker {
options.max_threads
};
let total_dirs = dirs.len();
let total_dirs = self.sync_data.live_dirs_count;
let effective_query = match &query.fuzzy_query {
fff_query_parser::FuzzyQuery::Text(t) => *t,
@@ -1053,10 +1176,11 @@ impl FilePicker {
};
let arena = self.sync_data.arena_base_ptr();
let overflow_arena = self.sync_data.arena_overflow_ptr();
let time = std::time::Instant::now();
let (items, scores, total_matched) =
crate::score::fuzzy_match_and_score_dirs(dirs, &context, arena);
crate::score::fuzzy_match_and_score_dirs(dirs, &context, arena, overflow_arena);
info!(
?query,
@@ -1566,11 +1690,25 @@ impl FilePicker {
file_item.set_path(builder.add_file_immediate(&rel_path, file_item.path.filename_offset));
file_item.set_overflow(true);
// Keep the dir table consistent: register (or revive) the parent dir
// so directory search reflects watcher-added files immediately.
let dir_rel = crate::path_utils::to_canonical_slashes(
&rel_path[..file_item.path.filename_offset as usize],
);
if let Some(dir_idx) = self.sync_data.find_or_add_dir(&dir_rel) {
file_item.parent_dir_index = dir_idx;
}
let parent_dir = file_item.parent_dir_index;
if !self.sync_data.files.push(file_item) {
return None;
}
self.sync_data.live_count += 1;
// Dir may have been tombstoned by an earlier removal; a new file
// under it proves it exists again.
self.sync_data.revive_dir(parent_dir);
self.sync_data.files.last()
}
@@ -1600,8 +1738,11 @@ impl FilePicker {
return;
}
file.set_deleted(false);
let parent_dir = file.parent_dir_index;
self.sync_data.live_count += 1;
// The path exists on disk again, so its parent dir does too.
self.sync_data.revive_dir(parent_dir);
}
/// Marks file as deleted, make sure that if you call this yourself these changes can be reverted
@@ -1619,22 +1760,75 @@ impl FilePicker {
// TODO make this O(n)
pub fn remove_all_files_in_dir(&mut self, dir: impl AsRef<Path>) -> usize {
let dir_path = dir.as_ref();
let relative_dir = self
.to_relative_path(dir_path)
.map(|c| c.into_owned())
.unwrap_or_default();
self.remove_all_files_in_dirs_inner(std::iter::once(dir.as_ref()), None)
}
let dir_prefix = if relative_dir.is_empty() {
String::new()
} else {
// Stored relative paths are '/'-canonical on every platform.
format!("{relative_dir}/")
};
/// Tombstones files under any of `dirs` in a single index scan.
pub(crate) fn remove_all_files_in_dirs_with_callback<'a>(
&mut self,
dirs: impl IntoIterator<Item = &'a Path>,
mut callback: impl FnMut(&Path),
) -> usize {
self.remove_all_files_in_dirs_inner(dirs, Some(&mut callback))
}
self.sync_data.tombstone_files_with_arena(|file, arena| {
file.relative_path_starts_with(arena, &dir_prefix)
})
pub(crate) fn remove_all_files_in_dirs<'a>(
&mut self,
dirs: impl IntoIterator<Item = &'a Path>,
) -> usize {
self.remove_all_files_in_dirs_inner(dirs, None)
}
fn remove_all_files_in_dirs_inner<'a>(
&mut self,
dirs: impl IntoIterator<Item = &'a Path>,
mut callback: Option<&mut dyn FnMut(&Path)>,
) -> usize {
let mut dir_prefixes = Vec::new();
for dir_path in dirs {
let Some(relative_dir) = self
.to_relative_path(dir_path)
.map(|path| path.into_owned())
else {
continue;
};
if relative_dir.is_empty() {
dir_prefixes.push(String::new());
} else {
// Stored relative paths are '/'-canonical on every platform.
dir_prefixes.push(format!("{relative_dir}/"));
}
}
if dir_prefixes.is_empty() {
return 0;
}
let base_path = self.base_path.clone();
let mut path_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
let tombstoned = self.sync_data.tombstone_files_with_arena(
|file, arena| {
dir_prefixes
.iter()
.any(|prefix| file.relative_path_starts_with(arena, prefix))
},
|file, arena| {
if let Some(callback) = callback.as_mut() {
callback(file.write_absolute_path(arena, &base_path, &mut path_buf));
}
},
);
// The whole subtree is gone: tombstone the dirs too so directory
// search stops surfacing them.
let mut dir_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
self.sync_data.tombstone_dirs_with_arena(|dir, arena| {
let rel = dir.read_relative_path(arena, &mut dir_buf);
dir_prefixes.iter().any(|prefix| rel.starts_with(prefix))
});
tombstoned
}
/// Use this to prevent any substantial background threads from acquiring the locks
@@ -1647,6 +1841,7 @@ impl FilePicker {
if let Some(mut watcher) = self.background_watcher.take() {
watcher.stop();
}
self.signals.watcher_ready.store(false, Ordering::Release);
}
/// Quick way to check if scan is going without acquiring a lock for [Self::get_scan_progress]
@@ -1830,30 +2025,38 @@ impl FileSync {
let is_git_repo = git_workdir.is_some();
let bg_threads = BACKGROUND_THREAD_POOL.current_num_threads();
let mut walk_output = crate::walk::walk_collect_files(
let WalkOutput {
dirs: mut walked_dirs,
mut pairs,
ignore_rules,
} = crate::walk::walk_collect_files(
base_path,
is_git_repo,
follow_symlinks,
bg_threads,
synced_files_count,
)?;
let ignore_rules = walk_output.ignore_rules.take().map(Arc::new);
let mut pairs = walk_output.pairs;
let ignore_rules = ignore_rules.map(Arc::new);
// Sort by (dir_part, filename). This groups files by their directory
// into contiguous runs so the linear dir-extraction pass below can
// dedupe by comparing only against the previous dir.
// group walked dirs and files with a dir part to the same order
BACKGROUND_THREAD_POOL.install(|| {
pairs.par_sort_unstable_by(|(a, path_a), (b, path_b)| {
// SAFETY: `filename_offset` is always at a character boundary
let (a_dir, a_file) = path_a.split_at(a.path.filename_offset as usize);
let (b_dir, b_file) = path_b.split_at(b.path.filename_offset as usize);
a_dir.cmp(b_dir).then_with(|| a_file.cmp(b_file))
});
rayon::join(
|| {
pairs.par_sort_unstable_by(|(a, path_a), (b, path_b)| {
// SAFETY: `filename_offset` is always at a character boundary
let (a_dir, a_file) = path_a.split_at(a.path.filename_offset as usize);
let (b_dir, b_file) = path_b.split_at(b.path.filename_offset as usize);
a_dir.cmp(b_dir).then_with(|| a_file.cmp(b_file))
});
},
|| walked_dirs.par_sort_unstable(),
);
});
walked_dirs.dedup();
let mut builder = crate::simd_path::ChunkedPathStoreBuilder::new(pairs.len());
let dirs = populates_dirs_files_chunked_storage(&mut pairs, &mut builder);
let dirs = populates_dirs_files_chunked_storage(&mut pairs, &walked_dirs, &mut builder);
drop(walked_dirs);
let mut files: Vec<FileItem> = pairs.into_iter().map(|(file, _)| file).collect();
let chunked_paths = builder.finish();
@@ -1925,13 +2128,16 @@ impl FileSync {
);
let base_count = files.len();
let base_dirs_count = dirs.len();
Ok(FileSync {
files: StableVec::from_vec_with_reserve(files, MAX_OVERFLOW_FILES),
indexable_count,
base_count,
live_count: base_count,
dirs: StableVec::from_vec_with_reserve(dirs, 0),
dirs: StableVec::from_vec_with_reserve(dirs, MAX_OVERFLOW_FILES),
base_dirs_count,
live_dirs_count: base_dirs_count,
overflow_builder: None,
git_workdir,
bigram_index: None,
@@ -1971,49 +2177,91 @@ pub(crate) fn warmup_mmaps(
}
/// This does both thing (yes sorry all the OOP morons)
/// in one go: populates files chunked storage and creates new directories
/// in one go: populates files chunked storage and builds the dir table from
/// `walked_dirs` (every dir the walker visited: sorted, '/'-terminated,
/// deduped), merging file parents in a single lockstep sweep so dirs with no
/// files (empty subtrees, pure ancestors) are indexed and searchable too.
fn populates_dirs_files_chunked_storage<'a>(
pairs: &'a mut [(FileItem, String)],
walked_dirs: &[String],
chunk_storage: &mut crate::simd_path::ChunkedPathStoreBuilder,
) -> Vec<DirItem> {
let mut dirs: Vec<DirItem> = Vec::new();
let mut dirs: Vec<DirItem> = Vec::with_capacity(walked_dirs.len() + 1);
let mut dir_iter = walked_dirs.iter().peekable();
// Root-level files sort first and their "" parent is never a walker dir.
if pairs
.first()
.is_some_and(|(f, _)| f.path.filename_offset == 0)
{
push_dir_item(&mut dirs, chunk_storage, "");
}
// Detects contiguous same-dir runs (pairs are sorted by dir) so the
// merge below runs once per directory, not once per file.
let mut prev_dir: &'a str = "";
let mut prev_dir_valid = false;
let mut current_dir_idx: u32 = 0;
for (file, rel) in pairs.iter_mut() {
let rel: &'a str = rel;
let dir_part: &'a str = &rel[..file.path.filename_offset as usize];
if !prev_dir_valid || prev_dir != dir_part {
let dir_string = chunk_storage.add_dir_immediate(dir_part);
if prev_dir != dir_part {
// Flush walked dirs up to and including this file's parent,
// keeping the table sorted for the find_dir_index binary search.
while let Some(dir) = dir_iter.peek()
&& dir.as_str() < dir_part
{
push_dir_item(&mut dirs, chunk_storage, dir);
dir_iter.next();
}
// Compute last-segment offset: for "src/components/" -> 4 (points to "components/")
let last_seg = if dir_part.is_empty() {
0
} else {
let trimmed = dir_part.trim_end_matches(std::path::is_separator);
trimmed
.rfind(std::path::is_separator)
.map(|i| i + 1)
.unwrap_or(0) as u16
};
match dir_iter.peek() {
Some(dir) if dir.as_str() == dir_part => {
push_dir_item(&mut dirs, chunk_storage, dir);
dir_iter.next();
}
// Parents the walker reported with a non-dir kind
// (e.g. followed symlinks) aren't in the list.
_ => push_dir_item(&mut dirs, chunk_storage, dir_part),
}
dirs.push(DirItem::new(dir_string, last_seg));
current_dir_idx = (dirs.len() - 1) as u32;
prev_dir = dir_part;
prev_dir_valid = true;
}
file.path = chunk_storage.add_file_immediate(rel, file.path.filename_offset);
file.parent_dir_index = current_dir_idx;
}
for dir in dir_iter {
push_dir_item(&mut dirs, chunk_storage, dir);
}
dirs
}
fn push_dir_item(
dirs: &mut Vec<DirItem>,
chunk_storage: &mut crate::simd_path::ChunkedPathStoreBuilder,
dir_part: &str,
) {
let dir_string = chunk_storage.add_dir_immediate(dir_part);
// Compute last-segment offset: for "src/components/" -> 4 (points to "components/")
let last_seg = if dir_part.is_empty() {
0
} else {
let trimmed = dir_part.trim_end_matches(std::path::is_separator);
trimmed
.rfind(std::path::is_separator)
.map(|i| i + 1)
.unwrap_or(0) as u16
};
dirs.push(DirItem::new(dir_string, last_seg));
}
/// Fast extension-based binary detection. Avoids opening files during scan.
/// Covers the vast majority of binary files in typical repositories.
#[inline]
@@ -2148,13 +2396,9 @@ mod tests {
use super::*;
/// The watcher must watch every ancestor directory up to `base_path`,
/// not just the immediate parents of indexed files. Intermediate dirs
/// that contain only subdirectories (no direct files) are NOT in
/// `sync_data.dirs` — yet they must still appear in `extract_watch_dirs`
/// so Create events on new subdirectories below them fire.
///
/// Correctness regression guard for any refactor that replaces the
/// ancestor walk with a direct `sync_data.dirs` iteration.
/// not just the immediate parents of indexed files. The dir table is
/// built from the walker's visited dirs, so pure ancestors (dirs that
/// contain only subdirectories) must be present and emitted exactly once.
#[test]
fn extract_watch_dirs_includes_pure_ancestor_dirs() {
let dir = tempfile::tempdir().unwrap();
@@ -2168,17 +2412,6 @@ mod tests {
// base/src/components/button.txt (src/components has a file)
// base/src/routes/home.txt (src/routes has a file)
// base/lib/deep/nested/util.txt (lib and lib/deep have no files)
//
// `sync_data.dirs` will only contain:
// src/components/
// src/routes/
// lib/deep/nested/
//
// But the watcher also needs:
// src/ (pure ancestor — no direct files)
// lib/ (pure ancestor)
// lib/deep/ (pure ancestor)
// otherwise new siblings like `src/NewDir/x.txt` are missed.
for rel in [
"src/components/button.txt",
"src/routes/home.txt",
@@ -2236,6 +2469,97 @@ mod tests {
);
}
/// Regression guard for #725: dirs that are EMPTY at scan time are merged
/// into `sync_data.dirs` so they are searchable and get an inotify watch;
/// files created in them later must be detected.
#[test]
fn for_each_dir_includes_empty_directories() {
let dir = tempfile::tempdir().unwrap();
let base_buf = crate::path_utils::canonicalize(dir.path()).unwrap();
let base = base_buf.as_path();
// Tree:
// base/init.lua (file directly under base)
// base/commands/ (empty at scan — the #725 repro)
// base/src/main.rs (src is indexed)
// base/src/plugins/extra/ (empty chain under an indexed dir)
std::fs::create_dir_all(base.join("commands")).unwrap();
std::fs::create_dir_all(base.join("src/plugins/extra")).unwrap();
std::fs::write(base.join("init.lua"), b"x").unwrap();
std::fs::write(base.join("src/main.rs"), b"x").unwrap();
let mut picker = FilePicker::new(FilePickerOptions {
base_path: base.to_str().unwrap().into(),
watch: false,
..Default::default()
})
.unwrap();
picker.collect_files().unwrap();
let mut watch_dirs: Vec<PathBuf> = Vec::new();
picker.for_each_dir(|p| {
watch_dirs.push(p.to_path_buf());
std::ops::ControlFlow::Continue(())
});
let watch_set: std::collections::HashSet<PathBuf> = watch_dirs.iter().cloned().collect();
for rel in ["commands", "src/plugins", "src/plugins/extra", "src"] {
assert!(
watch_set.contains(&base.join(rel)),
"expected {rel} in watch dirs, got {watch_set:?}",
);
}
// Dirs covered by indexed files must not be duplicated.
assert_eq!(
watch_dirs.len(),
watch_set.len(),
"duplicate watch dir emitted: {watch_dirs:?}",
);
}
#[test]
fn dir_table_merges_walked_dirs_with_file_parents() {
let mut pairs: Vec<(FileItem, String)> = ["src/main.rs", "src/deep/lib.rs", "root.txt"]
.iter()
.map(|p| {
let (item, rel) = FileItem::new(PathBuf::from(p), Path::new(""), None);
(item, rel)
})
.collect();
pairs.sort_by(|(a, pa), (b, pb)| {
pa[..a.path.filename_offset as usize]
.cmp(&pb[..b.path.filename_offset as usize])
.then_with(|| pa.cmp(pb))
});
// Sorted '/'-terminated walker output: file parents + an empty dir +
// a sibling sharing a prefix with a file parent.
let walked: Vec<String> = ["empty/", "src/", "src/deep/", "src/deeper/"]
.iter()
.map(|s| s.to_string())
.collect();
let mut builder = crate::simd_path::ChunkedPathStoreBuilder::new(pairs.len());
let dirs = populates_dirs_files_chunked_storage(&mut pairs, &walked, &mut builder);
let store = builder.finish();
let arena = store.as_arena_ptr();
let table: Vec<String> = dirs.iter().map(|d| d.relative_path(arena)).collect();
// Sorted: "" (root files) first, all walked dirs present exactly once.
assert_eq!(table, ["", "empty/", "src/", "src/deep/", "src/deeper/"]);
// Every file's parent_dir_index points at its own dir entry.
for (file, _) in &pairs {
let dir = &dirs[file.parent_dir_index as usize];
let rel = file.relative_path(arena);
assert!(
rel.starts_with(&dir.relative_path(arena)),
"file {rel} must live under its parent dir",
);
}
}
#[test]
fn common_dir_prefix_len_cases() {
assert_eq!(common_dir_prefix_len("", ""), 0);
@@ -2249,4 +2573,44 @@ mod tests {
// "src" is emitted-as-dir; "src/x" extends it — full "src" is shared.
assert_eq!(common_dir_prefix_len("src", "src/x"), 3);
}
#[test]
fn directory_removal_collects_each_tombstoned_path() {
let dir = tempfile::tempdir().unwrap();
let base = crate::path_utils::canonicalize(dir.path()).unwrap();
let removed_dir = base.join("removed");
let kept = base.join("kept.txt");
let first = removed_dir.join("a.txt");
let second = removed_dir.join("nested/b.txt");
std::fs::create_dir_all(second.parent().unwrap()).unwrap();
std::fs::write(&first, b"a").unwrap();
std::fs::write(&second, b"b").unwrap();
std::fs::write(&kept, b"kept").unwrap();
let mut picker = FilePicker::new(FilePickerOptions {
base_path: base.to_string_lossy().into_owned(),
watch: false,
..Default::default()
})
.unwrap();
picker.collect_files().unwrap();
let mut removed = Vec::new();
assert_eq!(
picker.remove_all_files_in_dirs_with_callback(
std::iter::once(removed_dir.as_path()),
|path| {
removed.push(path.to_path_buf());
}
),
2
);
removed.sort_unstable();
assert_eq!(removed, vec![first, second]);
assert!(picker.get_file_by_path(&kept).is_some());
let outside = base.parent().unwrap().join("outside");
assert_eq!(picker.remove_all_files_in_dir(&outside), 0);
assert!(picker.get_file_by_path(&kept).is_some());
}
}
+1 -1
View File
@@ -62,7 +62,7 @@ impl GitStatusCache {
let mut entries = AHashMap::with_capacity(statuses.len());
for entry in &statuses {
if let Some(entry_path) = entry.path() {
if let Ok(entry_path) = entry.path() {
// libgit2 returns entry paths with forward slashes on every platform
// fff stores native paths - meaning we have forward slash issue on windows
let full_path = crate::path_utils::normalize(repo_path.join(entry_path));
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
//! Definition and import line classification (vibe coded POC)
//!
//! Byte-level heuristics that tag a matched line as a code definition
//! (`struct`, `fn`, `class`, …) or an import/use statement. Used to
//! rank/annotate grep results for AI/MCP consumers. Gated behind the
//! `definitions` feature since only such consumers need it.
/// Detect if a line looks like a code definition (struct, fn, class, etc.)
pub fn is_definition_line(line: &str) -> bool {
let s = line.trim_start().as_bytes();
let s = skip_modifiers(s);
is_definition_keyword(s)
}
/// Modifier keywords that can precede a definition keyword.
/// Each must be followed by whitespace to be consumed.
const MODIFIERS: &[&[u8]] = &[
b"pub",
b"export",
b"default",
b"async",
b"abstract",
b"unsafe",
b"static",
b"protected",
b"private",
b"public",
];
/// Definition keywords to detect.
const DEF_KEYWORDS: &[&[u8]] = &[
b"struct",
b"fn",
b"enum",
b"trait",
b"impl",
b"class",
b"interface",
b"function",
b"def",
b"func",
b"type",
b"module",
b"object",
];
/// Skip zero or more modifier keywords (including `pub(crate)` style visibility).
fn skip_modifiers(mut s: &[u8]) -> &[u8] {
loop {
// Handle `pub(...)` — e.g. `pub(crate)`, `pub(super)`
if s.starts_with(b"pub(")
&& let Some(end) = s.iter().position(|&b| b == b')')
{
s = skip_ws(&s[end + 1..]);
continue;
}
let mut matched = false;
for &kw in MODIFIERS {
if s.starts_with(kw) {
let rest = &s[kw.len()..];
if rest.first().is_some_and(|b| b.is_ascii_whitespace()) {
s = skip_ws(rest);
matched = true;
break;
}
}
}
if !matched {
return s;
}
}
}
/// Check if `s` starts with a definition keyword followed by a word boundary.
fn is_definition_keyword(s: &[u8]) -> bool {
for &kw in DEF_KEYWORDS {
if s.starts_with(kw) {
let after = s.get(kw.len());
// Word boundary: end of input, or next byte is not alphanumeric/underscore
if after.is_none_or(|b| !b.is_ascii_alphanumeric() && *b != b'_') {
return true;
}
}
}
false
}
/// Skip ASCII whitespace.
#[inline]
fn skip_ws(s: &[u8]) -> &[u8] {
let n = s
.iter()
.position(|b| !b.is_ascii_whitespace())
.unwrap_or(s.len());
&s[n..]
}
/// Detect import/use lines — lower value than definitions or usages.
///
/// Checks if the line (after leading whitespace) starts with a common
/// import statement prefix. Pure byte-level checks, no regex.
pub fn is_import_line(line: &str) -> bool {
let s = line.trim_start().as_bytes();
s.starts_with(b"import ")
|| s.starts_with(b"import\t")
|| (s.starts_with(b"from ") && s.get(5).is_some_and(|&b| b == b'\'' || b == b'"'))
|| s.starts_with(b"use ")
|| s.starts_with(b"use\t")
|| starts_with_require(s)
|| starts_with_include(s)
}
/// Match `require(` or `require (`.
#[inline]
fn starts_with_require(s: &[u8]) -> bool {
if !s.starts_with(b"require") {
return false;
}
let rest = &s[b"require".len()..];
rest.first() == Some(&b'(') || (rest.first() == Some(&b' ') && rest.get(1) == Some(&b'('))
}
/// Match `# include ` (with optional spaces after `#`).
#[inline]
fn starts_with_include(s: &[u8]) -> bool {
if s.first() != Some(&b'#') {
return false;
}
let rest = skip_ws(&s[1..]);
rest.starts_with(b"include ") || rest.starts_with(b"include\t")
}
+358
View File
@@ -0,0 +1,358 @@
use crate::simd_path::ArenaPtr;
use crate::types::{ContentCacheBudget, FileItem, MmapSlot};
use fff_grep::lines::LineStep;
use rayon::prelude::*;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use super::sink::{
char_indices_to_byte_offsets, classify_definition, strip_line_terminators,
truncate_display_bytes,
};
use super::types::{GrepMatch, GrepResult, GrepSearchOptions};
#[allow(clippy::too_many_arguments)]
pub(super) fn fuzzy_grep_search<'a>(
grep_text: &str,
files_to_search: &[&'a FileItem],
options: &GrepSearchOptions,
total_files: usize,
filtered_file_count: usize,
case_insensitive: bool,
budget: &ContentCacheBudget,
abort_signal: &AtomicBool,
base_path: &Path,
arena: ArenaPtr,
overflow_arena: ArenaPtr,
) -> GrepResult<'a> {
// max_typos controls how many *needle* characters can be unmatched.
// A transposition (e.g. "shcema" -> "schema") costs ~1 typo with
// default gap penalties. We scale max_typos by needle length:
// 1-2 chars -> 0 typos (exact subsequence only)
// 3-5 chars -> 1 typo
// 6+ chars -> 2 typos
// Cap at 2: higher values (3+) let the SIMD prefilter pass lines
// missing key characters entirely (e.g. query "flvencodeX" matching
// lines without 'l' or 'v'). Quality comes from the post-match filters.
let max_typos = (grep_text.len() / 3).min(2);
let scoring = neo_frizbee::Scoring {
// Use default gap penalties. Higher values (e.g. 20) cause
// smith-waterman to prefer *dropping needle chars* over paying
// gap costs, which inflates the typo count and breaks
// transposition matching ("shcema" -> "schema" becomes 3 typos instead of 1)
exact_match_bonus: 100,
// gap_open_penalty: 4,
// gap_extend_penalty: 2,
prefix_bonus: 0,
capitalization_bonus: if case_insensitive { 0 } else { 4 },
..neo_frizbee::Scoring::default()
};
let matcher = neo_frizbee::Matcher::new(
grep_text,
&neo_frizbee::Config {
// Use the real max_typos so frizbee's SIMD prefilter actually rejects non-matching lines (~2 SIMD instructions per line vs full SW scoring).
max_typos: Some(max_typos as u16),
sort: false,
scoring,
..Default::default()
},
);
// Minimum score threshold: 50% of a perfect contiguous match.
// With default scoring (match_score=12, matching_case_bonus=4 = 16/char),
// a transposition costs ~5 from a gap, keeping the score well above 50%
let perfect_score = (grep_text.len() as u16) * 16;
let min_score = (perfect_score * 50) / 100;
// Target identifiers are often longer than the query due to delimiters
// (e.g. query "flvencodepicture" -> "ff_flv_encode_picture_header" from ffmpeg)
// Allow 3x needle length to accommodate underscore/dot-separated names
let max_match_span = grep_text.len() * 3;
let needle_len = grep_text.len();
// Each delimiter (_, .) in the target creates a gap. A typical C/Rust
// identifier like "ff_flv_encode_picture_header" has 4-5 underscores.
// Scale generously so delimiter gaps don't reject valid matches.
let max_gaps = (needle_len / 3).max(2);
// If a file doesn't contain enough distinct needle characters just skip it
let needle_bytes = grep_text.as_bytes();
let mut unique_needle_chars: Vec<u8> = Vec::new();
for &b in needle_bytes {
let lo = b.to_ascii_lowercase();
let hi = b.to_ascii_uppercase();
if !unique_needle_chars.contains(&lo) {
unique_needle_chars.push(lo);
}
if lo != hi && !unique_needle_chars.contains(&hi) {
unique_needle_chars.push(hi);
}
}
// How many distinct needle chars must appear in the file.
// With max_typos allowed, we need at least (unique_count - max_typos)
let unique_count = {
let mut seen = [false; 256];
for &b in needle_bytes {
seen[b.to_ascii_lowercase() as usize] = true;
}
seen.iter().filter(|&&v| v).count()
};
let min_chars_required = unique_count.saturating_sub(max_typos);
let time_budget = if options.time_budget_ms > 0 {
Some(std::time::Duration::from_millis(options.time_budget_ms))
} else {
None
};
let search_start = std::time::Instant::now();
let budget_exceeded = AtomicBool::new(false);
let max_matches_per_file = options.max_matches_per_file;
// for fuzzy match we need a bit smarter chunking as the amount of work we have to perform is
// exponentially larger than the original grep (and the nature of work is heavier), so in short we have to
// understand if the approximate index prefilter got us a lot of candidates or not
//
// if we have a few candidates -> likely we have a lot of matches, so verify the check faster
// if we have a lot of candidates -> rely on a larger chunk pipelining more parallel lines at once
let page_limit = options.page_limit;
let base_chunk = rayon::current_num_threads() * 4;
let prefilter_strong = total_files > 0 && files_to_search.len() * 2 < total_files;
let max_chunk = if prefilter_strong {
base_chunk
} else {
(base_chunk * 256).max(8 * 1024)
};
let growth = if prefilter_strong { 1 } else { 2 };
let mut chunk_size = base_chunk;
let mut chunk_start = 0;
let mut running_matches = 0usize;
let mut per_file_results: Vec<(usize, &'a FileItem, Vec<GrepMatch>)> = Vec::new();
while chunk_start < files_to_search.len() {
let chunk_end = (chunk_start + chunk_size).min(files_to_search.len());
let chunk = &files_to_search[chunk_start..chunk_end];
let chunk_offset = chunk_start;
chunk_start = chunk_end;
chunk_size = (chunk_size * growth).min(max_chunk);
// Parallel phase with `map_init`: each rayon worker thread clones the
// matcher once and gets a reusable read buffer + mmap slot. Buffer holds
// small files, slot holds fresh mmap for cache-miss files ≥ FRESH_MMAP_THRESHOLD.
let chunk_results: Vec<(usize, &'a FileItem, Vec<GrepMatch>)> = chunk
.par_iter()
.enumerate()
.map_init(
|| {
(
matcher.clone(),
Vec::with_capacity(64 * 1024),
MmapSlot::default(),
)
},
|(matcher, buf, mmap_slot), (local_idx, file)| {
if abort_signal.load(Ordering::Relaxed) {
budget_exceeded.store(true, Ordering::Relaxed);
return None;
}
if let Some(budget) = time_budget
&& search_start.elapsed() > budget
{
budget_exceeded.store(true, Ordering::Relaxed);
return None;
}
let file_arena = if file.is_overflow() {
overflow_arena
} else {
arena
};
let file_bytes =
file.get_content_for_search(buf, mmap_slot, file_arena, base_path, budget)?;
if min_chars_required > 0 {
let mut chars_found = 0usize;
for &ch in &unique_needle_chars {
if memchr::memchr(ch, file_bytes).is_some() {
chars_found += 1;
if chars_found >= min_chars_required {
break;
}
}
}
if chars_found < min_chars_required {
return None;
}
}
// Validate the whole file as UTF-8 once upfront. Source code
// files are virtually always valid UTF-8; this single check
// replaces per-line from_utf8 calls (~8% of fuzzy grep time)
let file_is_utf8 = std::str::from_utf8(file_bytes).is_ok();
let mut stepper = LineStep::new(b'\n', 0, file_bytes.len());
let estimated_lines = (file_bytes.len() / 40).max(64);
let mut file_lines: Vec<&str> = Vec::with_capacity(estimated_lines);
let mut line_meta: Vec<(u64, u64)> = Vec::with_capacity(estimated_lines);
let mut line_number: u64 = 1;
while let Some(line_match) = stepper.next_match(file_bytes) {
let byte_offset = line_match.start() as u64;
let trimmed = strip_line_terminators(&file_bytes[line_match]);
if !trimmed.is_empty() {
// we know for sure that the file is UTF-8 at this point
let line_str = if file_is_utf8 {
unsafe { std::str::from_utf8_unchecked(trimmed) }
} else if let Ok(s) = std::str::from_utf8(trimmed) {
s
} else {
line_number += 1;
continue;
};
file_lines.push(line_str);
line_meta.push((line_number, byte_offset));
}
line_number += 1;
}
if file_lines.is_empty() {
return None;
}
// Single-pass: score + indices in one Smith-Waterman run per line (not parallel)
let matches_with_indices = matcher.match_list_indices(&file_lines);
let mut file_matches: Vec<GrepMatch> = Vec::new();
for mut match_indices in matches_with_indices {
if match_indices.score < min_score {
continue;
}
let idx = match_indices.index as usize;
let raw_line = file_lines[idx];
let truncated = truncate_display_bytes(raw_line.as_bytes());
let display_line = if truncated.len() < raw_line.len() {
// SAFETY: truncate_display_bytes preserves UTF-8 char boundaries
&raw_line[..truncated.len()]
} else {
raw_line
};
// If the line was truncated, re-compute indices on the shorter string.
if display_line.len() < raw_line.len() {
let Some(re_indices) = matcher
.match_list_indices(&[display_line])
.into_iter()
.next()
else {
continue;
};
match_indices = re_indices;
}
match_indices.indices.sort_unstable();
// Minimum matched chars: at least (needle_len - max_typos)
// characters must appear. This is consistent with the typo
// budget: each typo can drop one needle char from the alignment.
let min_matched = needle_len.saturating_sub(max_typos).max(1);
if match_indices.indices.len() < min_matched {
continue;
}
let indices = &match_indices.indices;
if let (Some(&first), Some(&last)) = (indices.first(), indices.last()) {
// reject widely scattered matches
let span = last - first + 1;
if span > max_match_span {
continue;
}
// Density check: matched chars / span must be dense enough.
// Relaxed for perfect subsequence matches (all needle chars
// present), slightly relaxed for typo matches to handle
// delimiter-heavy targets
// (e.g. "ff_flv_encode_picture_header" has span inflated by underscores w/ density ~68%)
let density = (indices.len() * 100) / span;
let min_density = if indices.len() >= needle_len {
45 // Perfect subsequence relaxed (delimiters inflate span)
} else {
65 // Has typos filter out a long string
};
if density < min_density {
continue;
}
// Gap count check: count discontinuities in the indices
let gap_count = indices.windows(2).filter(|w| w[1] != w[0] + 1).count();
if gap_count > max_gaps {
continue;
}
}
let (ln, bo) = line_meta[idx];
let match_byte_offsets =
char_indices_to_byte_offsets(display_line, &match_indices.indices);
let col = match_byte_offsets
.first()
.map(|r| r.0 as usize)
.unwrap_or(0);
file_matches.push(GrepMatch {
file_index: 0,
line_number: ln,
col,
byte_offset: bo,
is_definition: classify_definition(
options.classify_definitions,
display_line,
),
line_content: display_line.to_string(),
match_byte_offsets,
fuzzy_score: Some(match_indices.score),
context_before: Vec::new(),
context_after: Vec::new(),
});
if max_matches_per_file != 0 && file_matches.len() >= max_matches_per_file {
break;
}
}
if file_matches.is_empty() {
return None;
}
Some((chunk_offset + local_idx, *file, file_matches))
},
)
.flatten()
.collect();
for result in chunk_results {
running_matches += result.2.len();
per_file_results.push(result);
}
if running_matches >= page_limit || budget_exceeded.load(Ordering::Relaxed) {
break;
}
}
GrepResult::collect(
per_file_results,
files_to_search.len(),
options,
total_files,
filtered_file_count,
budget_exceeded.load(Ordering::Relaxed),
)
}
+706
View File
@@ -0,0 +1,706 @@
use super::prefilter::prefilter_with_filepath_retry;
use super::regex::{RegexMatcher, RegexSink, build_regex};
use super::sink::{SinkState, debug_assert_newline_terminator};
use super::types::{GrepMatch, GrepMode, GrepResult, GrepSearchOptions};
use crate::index::{
BigramFilter, BigramOverlay, bigram_boundary, fuzzy_candidates, literal_candidates,
regex_candidates,
};
use crate::simd_string_utils::memmem;
use crate::types::{ContentCacheBudget, FileItem, FileSliceExt, MmapSlot};
use fff_grep::{
Searcher, SearcherBuilder, Sink, SinkMatch,
matcher::{Match, Matcher, NoError},
};
use fff_query_parser::{FFFQuery, GrepConfig, QueryParser};
use rayon::prelude::*;
use smallvec::SmallVec;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use tracing::Level;
#[allow(clippy::large_enum_variant)]
pub(super) enum NeedleFinder<'a> {
CaseSensitive(memchr::memmem::Finder<'a>),
/// Pre-lowered needle bytes for the SIMD case-insensitive search.
CaseInsensitive(&'a [u8]),
}
impl<'a> NeedleFinder<'a> {
fn new(needle: &'a [u8], case_insensitive: bool) -> Self {
if case_insensitive {
Self::CaseInsensitive(needle)
} else {
Self::CaseSensitive(memchr::memmem::Finder::new(needle))
}
}
#[inline]
fn find(&self, haystack: &[u8]) -> Option<usize> {
match self {
Self::CaseSensitive(finder) => finder.find(haystack),
Self::CaseInsensitive(needle_lower) => memmem::find(haystack, needle_lower),
}
}
#[inline]
fn needle(&self) -> &[u8] {
match self {
Self::CaseSensitive(finder) => finder.needle(),
Self::CaseInsensitive(needle_lower) => needle_lower,
}
}
/// Compare `haystack` against a slice of the needle with the same case
/// semantics as `find`.
#[inline]
fn eq_fold(&self, haystack: &[u8], needle_seg: &[u8]) -> bool {
match self {
Self::CaseSensitive(_) => haystack == needle_seg,
Self::CaseInsensitive(_) => {
haystack.len() == needle_seg.len() && memmem::find(haystack, needle_seg) == Some(0)
}
}
}
/// Collect highlight spans for every needle occurrence within a line.
/// The case branch is resolved once per line, not once per occurrence.
#[inline]
fn for_each_occurrence(&self, haystack: &[u8], mut on_match: impl FnMut(usize)) {
match self {
Self::CaseSensitive(finder) => {
let mut start_pos = 0usize;
while let Some(pos) = finder.find(&haystack[start_pos..]) {
on_match(start_pos + pos);
start_pos += pos + 1;
}
}
Self::CaseInsensitive(needle_lower) => {
let mut start_pos = 0usize;
while let Some(pos) = memmem::find(&haystack[start_pos..], needle_lower) {
on_match(start_pos + pos);
start_pos += pos + 1;
}
}
}
}
}
struct PlainTextMatcher<'a> {
finder: &'a NeedleFinder<'a>,
}
impl Matcher for PlainTextMatcher<'_> {
type Error = NoError;
#[inline]
fn find_at(&self, haystack: &[u8], at: usize) -> Result<Option<Match>, NoError> {
let hay = &haystack[at..];
let needle_len = self.finder.needle().len();
Ok(self
.finder
.find(hay)
.map(|pos| Match::new(at + pos, at + pos + needle_len)))
}
#[inline]
fn line_terminator(&self) -> Option<fff_grep::LineTerminator> {
Some(fff_grep::LineTerminator::byte(b'\n'))
}
}
struct PlainTextSink<'r> {
state: SinkState,
finder: &'r NeedleFinder<'r>,
pattern_len: u32,
multiline_segment_len: Option<usize>,
}
impl Sink for PlainTextSink<'_> {
type Error = std::io::Error;
fn matched(
&mut self,
searcher: &Searcher,
sink_match: &SinkMatch<'_>,
) -> Result<bool, Self::Error> {
debug_assert_newline_terminator(searcher);
if self.state.max_matches != 0 && self.state.matches.len() >= self.state.max_matches {
return Ok(false);
}
let line_bytes = sink_match.bytes();
let (display_bytes, display_len, line_number, byte_offset) =
SinkState::prepare_line(line_bytes, sink_match);
let line_content = String::from_utf8_lossy(display_bytes).into_owned();
let mut match_byte_offsets: SmallVec<[(u32, u32); 4]> = SmallVec::new();
let mut col = 0usize;
let mut first = true;
if let Some(seg_len) = self.multiline_segment_len {
// Multiline needle: the match starts on this line, so the needle's
// first segment must be a suffix of the line. Highlight that suffix.
let seg = &self.finder.needle()[..seg_len];
if !seg.is_empty()
&& display_bytes.len() >= seg.len()
&& self
.finder
.eq_fold(&display_bytes[display_bytes.len() - seg.len()..], seg)
{
col = display_bytes.len() - seg.len();
match_byte_offsets.push((col as u32, display_len));
}
} else {
let pattern_len = self.pattern_len;
self.finder.for_each_occurrence(display_bytes, |pos| {
let abs_start = pos as u32;
let abs_end = (abs_start + pattern_len).min(display_len);
if first {
col = pos;
first = false;
}
match_byte_offsets.push((abs_start, abs_end));
});
}
let (context_before, context_after) = self.state.extract_context(sink_match);
self.state.push_match(
line_number,
col,
byte_offset,
line_content,
match_byte_offsets,
context_before,
context_after,
);
Ok(true)
}
fn finish(&mut self, _: &Searcher, _: &fff_grep::SinkFinish) -> Result<(), Self::Error> {
Ok(())
}
}
/// Perform a grep search across all indexed files.
///
/// When `query` is empty, returns git-modified/untracked files sorted by
/// frecency for the "welcome state" UI.
#[tracing::instrument(skip_all, fields(file_count = files.len()))]
#[allow(clippy::too_many_arguments)]
pub(crate) fn grep_search<'a>(
files: &'a [FileItem],
query: &FFFQuery<'_>,
options: &GrepSearchOptions,
budget: &ContentCacheBudget,
bigram_index: Option<&BigramFilter>,
bigram_overlay: Option<&BigramOverlay>,
abort_signal: &AtomicBool,
base_path: &Path,
arena: crate::simd_path::ArenaPtr,
overflow_arena: crate::simd_path::ArenaPtr,
) -> GrepResult<'a> {
let result = grep_search_parsed(
files,
query,
options,
budget,
bigram_index,
bigram_overlay,
abort_signal,
base_path,
arena,
overflow_arena,
);
// Constraint parsing can swallow tokens the user meant literally (e.g. `!=`
// becoming an exclusion). If the constrained search scanned everything and
// found nothing, retry the whole raw query as literal text. This also holds
// for later pages: an empty full scan at offset 0 stays empty at any offset,
// so paging offsets consistently index the literal search's file list.
let full_scan_empty = result.matches.is_empty() && result.next_file_offset == 0;
if !full_scan_empty || query.constraints.is_empty() || abort_signal.load(Ordering::Relaxed) {
return result;
}
let raw = query.raw_query.trim();
if raw.is_empty() {
return result;
}
// Keep any explicit FilePath scope (AI mode `path/to/file.ext` prefix) so the
// fallback can't leak matches outside the file the user pinned. Only the
// swallowed operator/glob tokens are dropped. See issue #756.
let scoped_constraints: fff_query_parser::ConstraintVec<'_> = query
.constraints
.iter()
.filter(|c| matches!(c, fff_query_parser::Constraint::FilePath(_)))
.cloned()
.collect();
let literal_query = FFFQuery {
raw_query: query.raw_query,
constraints: scoped_constraints,
fuzzy_query: fff_query_parser::FuzzyQuery::Text(raw),
location: None,
};
let mut fallback = grep_search_parsed(
files,
&literal_query,
options,
budget,
bigram_index,
bigram_overlay,
abort_signal,
base_path,
arena,
overflow_arena,
);
if fallback.matches.is_empty() {
result
} else {
fallback.literal_fallback = true;
fallback
}
}
#[allow(clippy::too_many_arguments)]
fn grep_search_parsed<'a>(
files: &'a [FileItem],
query: &FFFQuery<'_>,
options: &GrepSearchOptions,
budget: &ContentCacheBudget,
bigram_index: Option<&BigramFilter>,
bigram_overlay: Option<&BigramOverlay>,
abort_signal: &AtomicBool,
base_path: &Path,
arena: crate::simd_path::ArenaPtr,
overflow_arena: crate::simd_path::ArenaPtr,
) -> GrepResult<'a> {
let total_files = files.live_count();
let constraints_from_query = &query.constraints[..];
let grep_text = extract_grep_text(query);
if grep_text.is_empty() {
return GrepResult::empty(total_files, total_files);
}
let case_insensitive = if options.smart_case {
!grep_text.chars().any(|c| c.is_uppercase())
} else {
false
};
let base_count = bigram_boundary(bigram_overlay, files.len());
let mut regex_fallback_error: Option<String> = None;
let regex = match options.mode {
GrepMode::PlainText => None,
GrepMode::Fuzzy => {
let bigram_candidates = fuzzy_candidates(bigram_index, bigram_overlay, &grep_text);
let (files_to_search, filtered_file_count) = prefilter_with_filepath_retry(
files,
constraints_from_query,
bigram_candidates.as_deref(),
base_count,
options,
arena,
overflow_arena,
);
if files_to_search.is_empty() {
return GrepResult::empty(total_files, filtered_file_count);
}
return super::fuzzy_grep::fuzzy_grep_search(
&grep_text,
&files_to_search,
options,
total_files,
filtered_file_count,
case_insensitive,
budget,
abort_signal,
base_path,
arena,
overflow_arena,
);
}
GrepMode::Regex => build_regex(&grep_text, options.smart_case)
.inspect_err(|err| {
tracing::warn!("Regex compilation failed for {}. Error {}", grep_text, err);
regex_fallback_error = Some(err.to_string());
})
.ok(),
};
let (multiline_segment_len, effective_pattern) = match replace_newline_escapes(&grep_text) {
Some((replaced, first_newline_pos)) => (Some(first_newline_pos), replaced),
None => (None, grep_text),
};
let is_multiline = multiline_segment_len.is_some();
// when there is multiple line requested automatically expand the context to include all the lines
let after_context = if is_multiline && regex.is_none() && options.after_context == 0 {
effective_pattern.bytes().filter(|&b| b == b'\n').count()
} else {
options.after_context
};
let finder_pattern: Vec<u8> = if case_insensitive {
effective_pattern.as_bytes().to_ascii_lowercase()
} else {
effective_pattern.as_bytes().to_vec()
};
let finder = NeedleFinder::new(&finder_pattern, case_insensitive);
let pattern_len = finder_pattern.len() as u32;
// PlainText (or regex-fallback-to-plain): literal bigram query.
// Regex: decompose the pattern HIR into an AND/OR bigram query tree.
let bigram_candidates = if regex.is_none() {
literal_candidates(bigram_index, bigram_overlay, &[&effective_pattern])
} else {
regex_candidates(bigram_index, bigram_overlay, &effective_pattern)
};
let (files_to_search, filtered_file_count) = prefilter_with_filepath_retry(
files,
constraints_from_query,
bigram_candidates.as_deref(),
base_count,
options,
arena,
overflow_arena,
);
if files_to_search.is_empty() {
return GrepResult::empty(total_files, filtered_file_count);
}
// `PlainTextMatcher` is used by the grep-searcher engine for line detection.
// `PlainTextSink` / `RegexSink` handle highlight extraction independently via ripgrep create
let plain_matcher = PlainTextMatcher { finder: &finder };
let searcher = {
let mut b = SearcherBuilder::new();
b.line_number(true).multi_line(is_multiline);
b
}
.build();
let should_prefilter = regex.is_none();
let mut result = perform_grep(
&files_to_search,
options,
&GrepContext {
total_files,
filtered_file_count,
budget,
base_path,
arena,
overflow_arena,
prefilter: should_prefilter.then_some(&finder),
abort_signal,
},
// The single sink-selection point: every mode's matcher/sink pairing
// is decided here based on the compiled pattern.
|file_bytes: &[u8], max_matches: usize| {
let state = SinkState {
file_index: 0,
matches: Vec::with_capacity(4),
max_matches,
before_context: options.before_context,
after_context,
classify_definitions: options.classify_definitions,
};
match regex {
Some(ref re) => {
let regex_matcher = RegexMatcher {
regex: re,
is_multiline,
};
let mut sink = RegexSink { state, re };
if let Err(e) = searcher.search_slice(&regex_matcher, file_bytes, &mut sink) {
tracing::error!(error = %e, "Grep (regex) search failed");
}
sink.state.matches
}
None => {
let mut sink = PlainTextSink {
state,
finder: &finder,
pattern_len,
multiline_segment_len,
};
if let Err(e) = searcher.search_slice(&plain_matcher, file_bytes, &mut sink) {
tracing::error!(error = %e, "Grep (plain text) search failed");
}
sink.state.matches
}
}
},
);
result.regex_fallback_error = regex_fallback_error;
result
}
/// Replace unescaped `\n` escapes with real newlines in a single pass.
///
/// Returns `Some((replaced, first_newline_pos))` when the pattern contained at
/// least one real `\n` escape (the user wants multiline search), where
/// `first_newline_pos` is the byte offset of the first inserted newline in the
/// replaced string. Returns `None` when nothing had to be replaced: `\\n` is
/// preserved as-is (escaped backslash + literal `n`, e.g. `\\nvim-data`).
pub(super) fn replace_newline_escapes(text: &str) -> Option<(String, usize)> {
let bytes = text.as_bytes();
let mut result = Vec::with_capacity(bytes.len());
let mut first_newline_pos: Option<usize> = None;
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\' && i + 1 < bytes.len() {
if bytes[i + 1] == b'n' {
// Odd number of consecutive backslashes before 'n' -> real \n escape
let mut backslash_count = 1;
while backslash_count <= i && bytes[i - backslash_count] == b'\\' {
backslash_count += 1;
}
if backslash_count % 2 == 1 {
first_newline_pos.get_or_insert(result.len());
result.push(b'\n');
i += 2;
continue;
}
}
result.push(bytes[i]);
i += 1;
} else {
result.push(bytes[i]);
i += 1;
}
}
let first_newline_pos = first_newline_pos?;
let replaced = String::from_utf8(result).unwrap_or_else(|_| text.to_string());
Some((replaced, first_newline_pos))
}
pub fn parse_grep_query(query: &str) -> FFFQuery<'_> {
let parser = QueryParser::new(GrepConfig);
parser.parse(query)
}
/// Extract the grep pattern text from the parsed query: all non-constraint
/// tokens joined with spaces, e.g. `"name = *.rs someth"` -> `"name = someth"`
/// with constraint `Extension("rs")`.
fn extract_grep_text(query: &FFFQuery<'_>) -> String {
if !matches!(query.fuzzy_query, fff_query_parser::FuzzyQuery::Empty) {
return query.grep_text();
}
// if constraint-only or empty query we use raw_query for backslash-escape handling
let t = query.raw_query.trim();
if t.starts_with('\\') && t.len() > 1 {
let suffix = &t[1..];
let parser = QueryParser::new(GrepConfig);
if !parser.parse(suffix).constraints.is_empty() {
return suffix.to_string();
}
}
t.to_string()
}
#[derive(Clone, Copy)]
pub(super) struct GrepContext<'a, 'b> {
pub(super) total_files: usize,
pub(super) filtered_file_count: usize,
pub(super) budget: &'a ContentCacheBudget,
pub(super) base_path: &'a Path,
pub(super) arena: crate::simd_path::ArenaPtr,
pub(super) overflow_arena: crate::simd_path::ArenaPtr,
pub(super) prefilter: Option<&'a NeedleFinder<'b>>,
pub(super) abort_signal: &'a AtomicBool,
}
impl GrepContext<'_, '_> {
#[inline]
fn arena_for_file(&self, file: &FileItem) -> crate::simd_path::ArenaPtr {
if file.is_overflow() {
self.overflow_arena
} else {
self.arena
}
}
}
#[tracing::instrument(
skip_all,
level = Level::DEBUG,
fields(prefiltered_count = files_to_search.len())
)]
pub(super) fn perform_grep<'a, F>(
files_to_search: &[&'a FileItem],
options: &GrepSearchOptions,
ctx: &GrepContext<'_, '_>,
search_file: F,
) -> GrepResult<'a>
where
F: Fn(&[u8], usize) -> Vec<GrepMatch> + Sync,
{
let time_budget = if options.time_budget_ms > 0 {
Some(std::time::Duration::from_millis(options.time_budget_ms))
} else {
None
};
let search_start = std::time::Instant::now();
let page_limit = options.page_limit;
let budget_exceeded = AtomicBool::new(false);
let mut result_files: Vec<&'a FileItem> = Vec::new();
let mut all_matches: Vec<GrepMatch> = Vec::new();
let mut files_consumed: usize = 0;
let mut page_filled = false;
// Each chunk is a rayon barrier. A flat small chunk over 500k files = ~7800
// barriers; x2 growth makes it logarithmic. But a too-aggressive growth
// over-scans: when a page fills mid-chunk, the whole submitted chunk still
// runs.
//
// So only grow when the prefilter is weak (large candidate set);
// when bigram cut the set in half, keep fixed small chunks for cheap page-fill termination.
let base_chunk = rayon::current_num_threads() * 4;
let prefilter_strong = ctx.total_files > 0 && files_to_search.len() * 2 < ctx.total_files;
let max_chunk = if prefilter_strong {
base_chunk
} else {
(base_chunk * 256).max(8 * 1024)
};
let growth = if prefilter_strong { 1 } else { 2 };
let mut chunk_size = base_chunk;
let mut chunk_start = 0;
while chunk_start < files_to_search.len() {
let chunk_end = (chunk_start + chunk_size).min(files_to_search.len());
let chunk = &files_to_search[chunk_start..chunk_end];
chunk_start = chunk_end;
chunk_size = (chunk_size * growth).min(max_chunk);
let chunk_offset = files_consumed;
let chunk_results: Vec<(usize, &'a FileItem, Vec<GrepMatch>)> = chunk
.par_iter()
.enumerate()
.map_init(
// tested it out a few times, this is just fine for rayon worker in this specific
// case it doesn't reallocate this many times and it is actually faster than using
// scoped threads with a predefined local scratch buffers because of spawn cost
|| (Vec::with_capacity(64 * 1024), MmapSlot::default()),
|(buf, mmap_slot), (local_idx, file)| {
// perform all the atomic machinery on every 8th
if local_idx % 8 == 0 {
let mut need_abort = ctx.abort_signal.load(Ordering::Relaxed);
if !need_abort
&& let Some(budget) = time_budget
&& all_matches.len() > 1
&& search_start.elapsed() > budget
{
need_abort = true;
}
if need_abort {
budget_exceeded.store(true, Ordering::Relaxed);
return None;
}
}
let content = file.get_content_for_search(
buf,
mmap_slot,
ctx.arena_for_file(file),
ctx.base_path,
ctx.budget,
)?;
// Fast whole-file memmem check before entering the
// grep-searcher machinery. Skips Vec alloc, Searcher
// setup, and line-splitting for files that can't match.
if let Some(pf) = ctx.prefilter
&& pf.find(content).is_none()
{
return None;
}
let file_matches = search_file(content, options.max_matches_per_file);
if file_matches.is_empty() {
return None;
}
Some((chunk_offset + local_idx, *file, file_matches))
},
)
.flatten()
.collect();
// Every file in the chunk was visited by rayon (matched or not).
files_consumed = chunk_offset + chunk.len();
// Flatten this chunk's results into the accumulator.
for (batch_idx, file, file_matches) in chunk_results {
let file_result_idx = result_files.len();
result_files.push(file);
for mut m in file_matches {
m.file_index = file_result_idx;
if options.trim_whitespace {
m.trim_leading_whitespace();
}
all_matches.push(m);
}
if all_matches.len() >= page_limit {
// Tighten files_consumed to the file that tipped us over so
// the next page resumes right after it.
files_consumed = batch_idx + 1;
page_filled = true;
break;
}
}
if page_filled || budget_exceeded.load(Ordering::Relaxed) {
break;
}
}
// If no file had any match, we searched the entire slice.
if result_files.is_empty() {
files_consumed = files_to_search.len();
}
let has_more = budget_exceeded.load(Ordering::Relaxed)
|| (page_filled && files_consumed < files_to_search.len());
let next_file_offset = if has_more {
options.file_offset + files_consumed
} else {
0
};
GrepResult {
matches: all_matches,
files_with_matches: result_files.len(),
files: result_files,
total_files_searched: files_consumed,
total_files: ctx.total_files,
filtered_file_count: ctx.filtered_file_count,
next_file_offset,
regex_fallback_error: None,
literal_fallback: false,
}
}
+518
View File
@@ -0,0 +1,518 @@
use super::grep::replace_newline_escapes;
use super::*;
use crate::file_picker::{FilePicker, FilePickerOptions};
use crate::index::BigramIndexBuilder;
use std::io::Write;
use std::sync::atomic::AtomicBool;
#[test]
fn test_replace_newline_escapes() {
// Single \n → multiline: replaced with a real newline at byte 3
assert_eq!(
replace_newline_escapes("foo\\nbar"),
Some(("foo\nbar".to_string(), 3))
);
// \\n → escaped backslash + literal n, NOT multiline
// (this is what the user types when grepping Rust source with `\\nvim`)
assert_eq!(replace_newline_escapes("foo\\\\nvim-data"), None);
// Real-world: source file has literal \\AppData\\Local\\nvim-data
// (double backslash in the file, so user types double backslash)
assert_eq!(
replace_newline_escapes(r#"format!("{}\\AppData\\Local\\nvim-data","#),
None
);
// No \n at all
assert_eq!(replace_newline_escapes("hello world"), None);
// \\\\n → even number of backslashes before n → NOT multiline
assert_eq!(replace_newline_escapes("foo\\\\\\\\nbar"), None);
// \\\n → 3 backslashes: first two pair up, third + n = \n → multiline,
// newline lands after "foo" + 2 kept backslashes = byte 5
assert_eq!(
replace_newline_escapes("foo\\\\\\nbar"),
Some(("foo\\\\\nbar".to_string(), 5))
);
// Position is for the FIRST newline when there are several
assert_eq!(
replace_newline_escapes("a\\nb\\nc"),
Some(("a\nb\nc".to_string(), 1))
);
}
#[test]
fn test_fuzzy_typo_scoring() {
// Mirror the config from fuzzy_grep_search
let needle = "schema";
let max_typos = (needle.len() / 3).min(2); // 2
let config = neo_frizbee::Config {
max_typos: Some(max_typos as u16),
sort: false,
scoring: neo_frizbee::Scoring {
exact_match_bonus: 100,
..neo_frizbee::Scoring::default()
},
..Default::default()
};
let min_matched = needle.len().saturating_sub(1).max(1); // 5
let max_match_span = needle.len() + 4; // 10
// Helper: check if a match would pass our post-filters
let passes = |n: &str, h: &str| -> bool {
let Some(mut mi) = neo_frizbee::match_list_indices(n, &[h], &config)
.into_iter()
.next()
else {
return false;
};
// upstream returns indices in reverse order, sort ascending
mi.indices.sort_unstable();
if mi.indices.len() < min_matched {
return false;
}
if let (Some(&first), Some(&last)) = (mi.indices.first(), mi.indices.last()) {
let span = last - first + 1;
if span > max_match_span {
return false;
}
let density = (mi.indices.len() * 100) / span;
if density < 70 {
return false;
}
}
true
};
// Exact match: must pass
assert!(passes("schema", "schema"));
// Exact in longer line: must pass
assert!(passes("schema", " schema: String,"));
// In identifier: must pass
assert!(passes("schema", "pub fn validate_schema() {}"));
// Transposition: must pass
assert!(passes("shcema", "schema"));
// Partial "ema" only line: must NOT pass
assert!(!passes("schema", "it has ema in it"));
// Completely unrelated: must NOT pass
assert!(!passes("schema", "hello world foo bar"));
}
#[test]
fn test_multi_grep_search() {
use crate::file_picker::{FilePicker, FilePickerOptions};
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
// File 1: has "GrepMode" and "GrepMatch"
{
let mut f = std::fs::File::create(dir.path().join("grep.rs")).unwrap();
writeln!(f, "pub enum GrepMode {{").unwrap();
writeln!(f, " PlainText,").unwrap();
writeln!(f, " Regex,").unwrap();
writeln!(f, "}}").unwrap();
writeln!(f, "pub struct GrepMatch {{").unwrap();
writeln!(f, " pub line_number: u64,").unwrap();
writeln!(f, "}}").unwrap();
}
// File 2: has "PlainTextMatcher" only
{
let mut f = std::fs::File::create(dir.path().join("matcher.rs")).unwrap();
writeln!(f, "struct PlainTextMatcher {{").unwrap();
writeln!(f, " needle: Vec<u8>,").unwrap();
writeln!(f, "}}").unwrap();
}
// File 3: no matches
{
let mut f = std::fs::File::create(dir.path().join("other.rs")).unwrap();
writeln!(f, "fn main() {{").unwrap();
writeln!(f, " println!(\"hello\");").unwrap();
writeln!(f, "}}").unwrap();
}
let mut picker = FilePicker::new(FilePickerOptions {
base_path: dir.path().to_str().unwrap().into(),
watch: false,
..Default::default()
})
.unwrap();
picker.collect_files().unwrap();
let files = picker.get_files();
let arena = picker.arena_base_ptr();
let options = super::GrepSearchOptions {
max_file_size: MAX_FFFILE_SIZE,
max_matches_per_file: 0,
smart_case: true,
file_offset: 0,
page_limit: 100,
mode: super::GrepMode::PlainText,
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
abort_signal: None,
};
let no_cancel = AtomicBool::new(false);
// Test with 3 patterns
let result = super::multi_grep_search(
files,
&["GrepMode", "GrepMatch", "PlainTextMatcher"],
&[],
&options,
picker.cache_budget(),
None,
None,
&no_cancel,
dir.path(),
arena,
arena,
);
assert!(
result.matches.len() >= 3,
"Expected at least 3 matches, got {}",
result.matches.len()
);
let has_grep_mode = result
.matches
.iter()
.any(|m| m.line_content.contains("GrepMode"));
let has_grep_match = result
.matches
.iter()
.any(|m| m.line_content.contains("GrepMatch"));
let has_plain_text_matcher = result
.matches
.iter()
.any(|m| m.line_content.contains("PlainTextMatcher"));
assert!(has_grep_mode, "Should find GrepMode");
assert!(has_grep_match, "Should find GrepMatch");
assert!(has_plain_text_matcher, "Should find PlainTextMatcher");
assert_eq!(result.files.len(), 2, "Should match exactly 2 files");
// Test with single pattern
let result2 = super::multi_grep_search(
files,
&["PlainTextMatcher"],
&[],
&options,
picker.cache_budget(),
None,
None,
&no_cancel,
dir.path(),
arena,
arena,
);
assert_eq!(
result2.matches.len(),
1,
"Single pattern should find 1 match"
);
// Test with empty patterns
let result3 = super::multi_grep_search(
files,
&[],
&[],
&options,
picker.cache_budget(),
None,
None,
&no_cancel,
dir.path(),
arena,
arena,
);
assert_eq!(
result3.matches.len(),
0,
"Empty patterns should find nothing"
);
}
/// E2E: multiline grep (`\n` in query) and escaped-backslash literals (`\\n`)
/// through the full picker.grep pipeline, in both PlainText and Regex modes.
#[test]
fn test_grep_multiline_and_escaped_newline_e2e() {
let dir = tempfile::tempdir().unwrap();
let base = crate::path_utils::canonicalize(dir.path()).unwrap();
// Content spanning two lines: "hello unicorn\nrainbow world"
{
let mut f = std::fs::File::create(base.join("multi.txt")).unwrap();
writeln!(f, "hello unicorn").unwrap();
writeln!(f, "rainbow world").unwrap();
}
// Content with a literal double backslash before "nvim": `C:\\Users\\nvim-data`
{
let mut f = std::fs::File::create(base.join("winpath.rs")).unwrap();
writeln!(f, "let p = \"C:\\\\Users\\\\nvim-data\";").unwrap();
}
{
let mut f = std::fs::File::create(base.join("noise.txt")).unwrap();
writeln!(f, "nothing interesting here").unwrap();
}
let mut picker = FilePicker::new(FilePickerOptions {
base_path: base.to_str().unwrap().into(),
watch: false,
..Default::default()
})
.unwrap();
picker.collect_files().unwrap();
let options = crate::GrepSearchOptions {
page_limit: 100,
max_matches_per_file: 0,
..Default::default()
};
// 1. PlainText + `\n`: needle becomes a real newline, match spans two lines
let query = super::parse_grep_query("unicorn\\nrainbow");
let result = picker.grep(&query, &options);
assert_eq!(
result.files.len(),
1,
"multiline plaintext should match multi.txt"
);
assert_eq!(result.files[0].relative_path(&picker), "multi.txt");
let m = &result.matches[0];
assert_eq!(m.line_content, "hello unicorn");
// Auto after-context: the rest of the matched span is returned
assert_eq!(m.context_after, vec!["rainbow world"]);
// First needle segment highlighted as the line suffix
assert_eq!(m.match_byte_offsets.as_slice(), &[(6, 13)]);
assert_eq!(m.col, 6);
// 2. PlainText + `\\n`: literal backslash + n, must NOT be mangled to newline
let query = super::parse_grep_query("\\\\nvim-data");
let result = picker.grep(&query, &options);
assert_eq!(
result.files.len(),
1,
"escaped backslash should match winpath.rs literally"
);
assert_eq!(result.files[0].relative_path(&picker), "winpath.rs");
assert!(result.matches[0].line_content.contains("\\\\nvim-data"));
assert!(result.matches[0].context_after.is_empty());
// 3. Regex + `\n`: goes through the MultiLine searcher strategy
let regex_options = super::GrepSearchOptions {
mode: super::GrepMode::Regex,
..options.clone()
};
let query = super::parse_grep_query("unicorn\\nrainbow");
let result = picker.grep(&query, &regex_options);
assert!(result.regex_fallback_error.is_none());
assert_eq!(
result.files.len(),
1,
"multiline regex should match multi.txt"
);
assert_eq!(result.files[0].relative_path(&picker), "multi.txt");
let m = &result.matches[0];
// Blob is normalized: single-line content + remaining lines as context
assert_eq!(m.line_content, "hello unicorn");
assert_eq!(m.context_after, vec!["rainbow world"]);
// Highlight clamped to the visible first line
assert_eq!(m.match_byte_offsets.as_slice(), &[(6, 13)]);
}
/// Regression test for issue #407: Live grep returns duplicate results
/// when the bigram candidate bitset has trailing bits set beyond
/// `base_file_count`. The bitset is rounded up to a multiple of 64 bits
/// so any trailing bit that happens to be set (e.g. from overlay data)
/// would previously map to an overflow file index, which was then also
/// unconditionally appended by the overflow loop, producing duplicates.
#[test]
fn test_grep_no_duplicates_with_overflow_trailing_bits() {
let dir = tempfile::tempdir().unwrap();
// Match the picker's internal dunce-canonicalize so paths passed to
// on_create_or_modify resolve back to the same base_path on Windows.
let base = crate::path_utils::canonicalize(dir.path()).unwrap();
// Five base files: only three contain the pattern "unicorn".
// We need some files WITHOUT the pattern so the bigrams for
// "unicorn" aren't treated as ubiquitous (≥90% of files) and
// dropped from the index during compress().
let base_contents: &[(&str, &str)] = &[
("a.txt", "hello unicorn world"),
("b.txt", "another unicorn line"),
("c.txt", "one more unicorn here"),
("d.txt", "nothing special in here"),
("e.txt", "just some random content"),
];
for (name, content) in base_contents {
let mut f = std::fs::File::create(base.join(name)).unwrap();
writeln!(f, "{}", content).unwrap();
}
let mut picker = FilePicker::new(FilePickerOptions {
base_path: base.to_str().unwrap().into(),
watch: false,
..Default::default()
})
.unwrap();
picker.collect_files().unwrap();
assert_eq!(picker.get_files().len(), 5);
// Manually build a bigram index over the 5 base files.
let base_count = 5usize;
let consec_builder = BigramIndexBuilder::new(base_count);
let skip_builder = BigramIndexBuilder::new(base_count);
for (i, (_, content)) in base_contents.iter().enumerate() {
consec_builder.add_file_content(&skip_builder, i, content.as_bytes());
}
let mut index = consec_builder.compress(Some(0));
index.set_skip_index(skip_builder.compress(Some(0)));
picker.set_bigram_index(index);
// Add three overflow files (new after the bigram index was built),
// all containing "unicorn".
for name in ["f.txt", "g.txt", "h.txt"] {
let path = base.join(name);
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "overflow unicorn entry").unwrap();
drop(f);
picker.handle_create_or_modify(&path);
}
assert_eq!(picker.get_files().len(), 8);
// Inject a trailing bit into the overlay at a file index that
// corresponds to an overflow file (i.e. >= base_file_count=5 but
// < bitset_word_size=64). Without the fix, the bigram-candidate
// merge would set this bit in the bitset, and the bitset loop would
// push files[6] while the overflow loop also appends files[5..]
// which includes files[6], producing a duplicate.
let overflow_rel = "g.txt"; // middle overflow file
let overflow_abs = picker
.get_files()
.iter()
.position(|f| f.relative_path(&picker) == overflow_rel)
.expect("overflow file should be present");
assert!(overflow_abs >= base_count);
assert!(
overflow_abs < 64,
"index must fit in the single bitset word"
);
if let Some(overlay) = picker.bigram_overlay() {
overlay
.write()
.modify_file(overflow_abs, b"overflow unicorn entry");
}
// Run a grep for "unicorn": six files match
// (a, b, c in base + f, g, h in overflow).
let query = super::parse_grep_query("unicorn");
let options = super::GrepSearchOptions {
max_file_size: MAX_FFFILE_SIZE,
max_matches_per_file: 0,
smart_case: true,
file_offset: 0,
page_limit: 100,
mode: super::GrepMode::PlainText,
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
abort_signal: Some(std::sync::Arc::new(AtomicBool::new(false))),
};
let result = picker.grep(&query, &options);
// Collect the matched relative paths via the returned files list.
let mut paths: Vec<String> = result
.files
.iter()
.map(|f| f.relative_path(&picker))
.collect();
paths.sort();
// Every file (base + overflow) should match exactly once.
let mut dedup = paths.clone();
dedup.dedup();
assert_eq!(
dedup, paths,
"grep must not return duplicate results (issue #407): {:?}",
paths
);
assert_eq!(
paths,
vec!["a.txt", "b.txt", "c.txt", "f.txt", "g.txt", "h.txt"],
);
// And the match count must equal the number of files (one line per
// file). A duplicate entry in files_to_search would double-count
// matches for the duplicated file.
assert_eq!(
result.matches.len(),
6,
"expected exactly one match per file, got {}",
result.matches.len()
);
}
/// Issue #756: an AI-mode regex query with an inline FilePath scope and
/// top-level alternation. The regex fragments are swallowed as bogus Glob
/// constraints, the constrained search finds nothing, and the literal/regex
/// fallback must NOT drop the FilePath scope — otherwise the `|` branch leaks
/// matches into files outside the pinned path.
#[test]
fn regex_fallback_keeps_file_path_scope_issue_756() {
use fff_query_parser::{AiGrepConfig, QueryParser};
let dir = tempfile::tempdir().unwrap();
let base = crate::path_utils::canonicalize(dir.path()).unwrap();
std::fs::create_dir(base.join("scope")).unwrap();
std::fs::write(
base.join("scope").join("target.css"),
"/* ---------- target ---------- */\n",
)
.unwrap();
std::fs::write(
base.join("outside.css"),
"/* ---------- outside ---------- */\n",
)
.unwrap();
let mut picker = FilePicker::new(FilePickerOptions {
base_path: base.to_str().unwrap().into(),
watch: false,
..Default::default()
})
.unwrap();
picker.collect_files().unwrap();
let options = crate::GrepSearchOptions {
mode: super::GrepMode::Regex,
smart_case: true,
max_matches_per_file: 80,
page_limit: 100,
..Default::default()
};
let raw = r"scope/target.css ^/\* |^\s*/\* ----------";
let query = QueryParser::new(AiGrepConfig).parse(raw);
let result = picker.grep(&query, &options);
let mut paths: Vec<String> = result
.files
.iter()
.map(|f| f.relative_path(&picker))
.collect();
paths.sort();
assert_eq!(
paths,
vec!["scope/target.css"],
"regex fallback must not leak outside the FilePath scope"
);
}
+27
View File
@@ -0,0 +1,27 @@
//! Live grep. `grep.rs` implements the main plain-text path, the parallel
//! scan engine, and the `grep_search` entry point that picks the matcher/sink
//! for every mode in one place; `regex`, `multi_pattern`, and `fuzzy_grep`
//! hold the mode-specific machinery on top of the shared `prefilter`/`sink`.
#[allow(clippy::module_inception)]
mod grep;
pub use grep::*;
mod fuzzy_grep;
mod multi_pattern;
mod prefilter;
mod regex;
mod sink;
mod types;
#[cfg(feature = "definitions")]
mod classify;
#[cfg(feature = "definitions")]
pub use classify::*;
pub(crate) use multi_pattern::multi_grep_search;
pub use regex::has_regex_metacharacters;
pub use types::*;
#[cfg(test)]
mod grep_tests;
+191
View File
@@ -0,0 +1,191 @@
use super::grep::{GrepContext, perform_grep};
use super::prefilter::prefilter_files;
use super::sink::{SinkState, debug_assert_newline_terminator};
use super::types::{GrepResult, GrepSearchOptions};
use crate::index::{BigramFilter, BigramOverlay, bigram_boundary, literal_candidates};
use crate::types::{ContentCacheBudget, FileItem, FileSliceExt};
use aho_corasick::AhoCorasick;
use fff_grep::{
Searcher, SearcherBuilder, Sink, SinkMatch,
matcher::{Match, Matcher, NoError},
};
use smallvec::SmallVec;
use std::path::Path;
use std::sync::atomic::AtomicBool;
/// A `grep_matcher::Matcher` backed by Aho-Corasick for multi-pattern search.
///
/// Finds the first occurrence of any pattern starting at the given offset.
/// Always reports `\n` as the line terminator for the fast candidate-line path.
struct AhoCorasickMatcher<'a> {
ac: &'a AhoCorasick,
}
impl Matcher for AhoCorasickMatcher<'_> {
type Error = NoError;
#[inline]
fn find_at(&self, haystack: &[u8], at: usize) -> std::result::Result<Option<Match>, NoError> {
let hay = &haystack[at..];
let found: Option<aho_corasick::Match> = self.ac.find(hay);
Ok(found.map(|m| Match::new(at + m.start(), at + m.end())))
}
#[inline]
fn line_terminator(&self) -> Option<fff_grep::LineTerminator> {
Some(fff_grep::LineTerminator::byte(b'\n'))
}
}
/// Sink for Aho-Corasick multi-pattern mode.
///
/// Collects all pattern match positions on each matched line for highlighting.
struct AhoCorasickSink<'a> {
state: SinkState,
ac: &'a AhoCorasick,
}
impl Sink for AhoCorasickSink<'_> {
type Error = std::io::Error;
fn matched(&mut self, searcher: &Searcher, mat: &SinkMatch<'_>) -> Result<bool, Self::Error> {
debug_assert_newline_terminator(searcher);
if self.state.max_matches != 0 && self.state.matches.len() >= self.state.max_matches {
return Ok(false);
}
let line_bytes = mat.bytes();
let (display_bytes, display_len, line_number, byte_offset) =
SinkState::prepare_line(line_bytes, mat);
let line_content = String::from_utf8_lossy(display_bytes).into_owned();
let mut match_byte_offsets: SmallVec<[(u32, u32); 4]> = SmallVec::new();
let mut col = 0usize;
let mut first = true;
for m in self.ac.find_iter(display_bytes as &[u8]) {
let abs_start = m.start() as u32;
let abs_end = (m.end() as u32).min(display_len);
if first {
col = abs_start as usize;
first = false;
}
match_byte_offsets.push((abs_start, abs_end));
}
let (context_before, context_after) = self.state.extract_context(mat);
self.state.push_match(
line_number,
col,
byte_offset,
line_content,
match_byte_offsets,
context_before,
context_after,
);
Ok(true)
}
fn finish(&mut self, _: &Searcher, _: &fff_grep::SinkFinish) -> Result<(), Self::Error> {
Ok(())
}
}
/// Multi-pattern OR search using Aho-Corasick.
///
/// Builds a single automaton from all patterns and searches each file in one
/// pass. This is significantly faster than regex alternation for literal text
/// searches because Aho-Corasick uses SIMD-accelerated multi-needle matching.
///
/// Returns the same `GrepResult` type as `grep_search`.
#[allow(clippy::too_many_arguments)]
pub(crate) fn multi_grep_search<'a>(
files: &'a [FileItem],
patterns: &[&str],
constraints: &[fff_query_parser::Constraint<'_>],
options: &GrepSearchOptions,
budget: &ContentCacheBudget,
bigram_index: Option<&BigramFilter>,
bigram_overlay: Option<&BigramOverlay>,
abort_signal: &AtomicBool,
base_path: &Path,
arena: crate::simd_path::ArenaPtr,
overflow_arena: crate::simd_path::ArenaPtr,
) -> GrepResult<'a> {
let total_files = files.live_count();
if patterns.is_empty() || patterns.iter().all(|p| p.is_empty()) {
return GrepResult::empty(total_files, total_files);
}
let bigram_candidates = literal_candidates(bigram_index, bigram_overlay, patterns);
let base_file_count = bigram_boundary(bigram_overlay, files.len());
// Constraints are separate from patterns, so a miss must not broaden the search.
let (files_to_search, filtered_file_count) = prefilter_files(
files,
constraints,
bigram_candidates.as_deref(),
base_file_count,
options,
arena,
overflow_arena,
);
if files_to_search.is_empty() {
return GrepResult::empty(total_files, filtered_file_count);
}
// Smart case: case-insensitive when all patterns are lowercase
let case_insensitive = if options.smart_case {
!patterns.iter().any(|p| p.chars().any(|c| c.is_uppercase()))
} else {
false
};
let ac = aho_corasick::AhoCorasickBuilder::new()
.ascii_case_insensitive(case_insensitive)
.build(patterns)
.expect("Aho-Corasick build should not fail for literal patterns");
let searcher = {
let mut b = SearcherBuilder::new();
b.line_number(true);
b
}
.build();
let ac_matcher = AhoCorasickMatcher { ac: &ac };
perform_grep(
&files_to_search,
options,
&GrepContext {
total_files,
filtered_file_count,
budget,
base_path,
arena,
overflow_arena,
prefilter: None, // no memmem prefilter for multi-pattern search
abort_signal,
},
|file_bytes: &[u8], max_matches: usize| {
let state = SinkState {
file_index: 0,
matches: Vec::with_capacity(4),
max_matches,
before_context: options.before_context,
after_context: options.after_context,
classify_definitions: options.classify_definitions,
};
let mut sink = AhoCorasickSink { state, ac: &ac };
if let Err(e) = searcher.search_slice(&ac_matcher, file_bytes, &mut sink) {
tracing::error!(error = %e, "Grep (aho-corasick multi) search failed");
}
sink.state.matches
},
)
}
+204
View File
@@ -0,0 +1,204 @@
use super::types::GrepSearchOptions;
use crate::index::BigramFilter;
use crate::index::constraints::{ConstraintPlan, ConstraintsBuffers};
use crate::sort_buffer::sort_with_buffer;
use crate::types::FileItem;
use fff_query_parser::Constraint;
/// Prefilter with a FilePath-constraint fallback: when constraints yield 0
/// files and the query had FilePath constraints, retry without them (the path
/// token was likely part of the search text).
#[allow(clippy::too_many_arguments)]
pub(super) fn prefilter_with_filepath_retry<'a>(
files: &'a [FileItem],
constraints: &[Constraint<'_>],
bigram_candidates: Option<&[u64]>,
base_count: usize,
options: &GrepSearchOptions,
arena: crate::simd_path::ArenaPtr,
overflow_arena: crate::simd_path::ArenaPtr,
) -> (Vec<&'a FileItem>, usize) {
let (files_to_search, filtered_file_count) = prefilter_files(
files,
constraints,
bigram_candidates,
base_count,
options,
arena,
overflow_arena,
);
if !files_to_search.is_empty() {
return (files_to_search, filtered_file_count);
}
let Some(stripped) = strip_file_path_constraint_if_present(constraints) else {
return (files_to_search, filtered_file_count);
};
prefilter_files(
files,
&stripped,
bigram_candidates,
base_count,
options,
arena,
overflow_arena,
)
}
/// Single pass prefilter that doesn't involve file reading
/// allocates only amount of memory required for storing references of the FileItems have to be
/// opened for grepping unaviodably, in the worst case allocates N * <word> memory if no prefilter needed
pub(crate) fn prefilter_files<'a>(
files: &'a [FileItem],
constraints: &[Constraint<'_>],
bigram_candidates: Option<&[u64]>,
base_count: usize,
options: &GrepSearchOptions,
arena: crate::simd_path::ArenaPtr,
overflow_arena: crate::simd_path::ArenaPtr,
) -> (Vec<&'a FileItem>, usize) {
let max_file_size = options.max_file_size;
let plan = if constraints.is_empty() {
None
} else {
Some(ConstraintPlan::build(
constraints,
files,
arena,
overflow_arena,
))
};
let mut scratch = ConstraintsBuffers::new();
#[inline(always)]
fn basic_prefilter(file: &FileItem, max: u64) -> bool {
!file.is_deleted() && !file.is_binary() && file.size > 0 && file.size <= max
}
// squeeze as much prefilters into a single loop as possible
let mut prefiltered: Vec<&FileItem> = match bigram_candidates {
Some(candidates) => {
let boundary = base_count.min(files.len());
let (indexed, tail) = files.split_at(boundary);
let cap = BigramFilter::count_candidates(candidates) + tail.len();
let mut out: Vec<&FileItem> = Vec::with_capacity(cap);
let full_words = boundary / 64;
let last_word_bits = boundary % 64;
// we need this because we already had a regression of the wrong bit
// has been set for the very last word based on the overlay, it's pretty cheap
macro_rules! evaluate_bigram_match_word {
($word:expr, $base:expr) => {{
let mut bits: u64 = $word;
while bits != 0 {
let bit = bits.trailing_zeros() as usize;
let file_idx = $base + bit;
bits &= bits - 1;
let f = unsafe { indexed.get_unchecked(file_idx) };
if !basic_prefilter(f, max_file_size) {
continue;
}
if let Some(plan) = plan.as_ref()
&& !plan.matches(f, file_idx, arena, overflow_arena, &mut scratch)
{
continue;
}
out.push(f);
}
}};
}
// Full words: every set bit guaranteed `< boundary`.
for (word_idx, &word) in candidates.iter().take(full_words).enumerate() {
if word != 0 {
evaluate_bigram_match_word!(word, word_idx * 64);
}
}
// Last partial word: mask bits past `boundary` once at word load.
if last_word_bits != 0 {
// this will get only (mod 64) bits from the last word guarantee that it's 0 padded
let last_mask: u64 = (1u64 << last_word_bits) - 1;
let word = candidates[full_words] & last_mask;
if word != 0 {
evaluate_bigram_match_word!(word, full_words * 64);
}
}
// Sequential processing for non-bigrammable files: they are always in the end
for (offset, f) in tail.iter().enumerate() {
if !basic_prefilter(f, max_file_size) {
continue;
}
if let Some(ref p) = plan
&& !p.matches(f, boundary + offset, arena, overflow_arena, &mut scratch)
{
continue;
}
out.push(f);
}
out
}
// this will be executed if there is no bigram, in the worst case it will allocate
// whole array of files but probability in the real repo of NO preflter working is so
// low that we just ignore that, usually there would be at least a few files excluded
None => {
let mut out: Vec<&FileItem> = Vec::new();
for (idx, f) in files.iter().enumerate() {
if !basic_prefilter(f, max_file_size) {
continue;
}
if let Some(ref p) = plan
&& !p.matches(f, idx, arena, overflow_arena, &mut scratch)
{
continue;
}
out.push(f);
}
out
}
};
let total_count = prefiltered.len();
sort_with_buffer(&mut prefiltered, |a, b| {
b.total_frecency_score()
.cmp(&a.total_frecency_score())
.then(b.modified.cmp(&a.modified))
});
if options.file_offset > 0 && options.file_offset < total_count {
let paginated = prefiltered.split_off(options.file_offset);
(paginated, total_count)
} else if options.file_offset >= total_count {
(Vec::new(), total_count)
} else {
(prefiltered, total_count)
}
}
fn strip_file_path_constraint_if_present<'a>(
constraints: &[Constraint<'a>],
) -> Option<fff_query_parser::ConstraintVec<'a>> {
if !constraints
.iter()
.any(|c| matches!(c, Constraint::FilePath(_)))
{
return None;
}
let filtered: fff_query_parser::ConstraintVec<'a> = constraints
.iter()
.filter(|c| !matches!(c, Constraint::FilePath(_)))
.cloned()
.collect();
Some(filtered)
}
+130
View File
@@ -0,0 +1,130 @@
use super::sink::{SinkState, debug_assert_newline_terminator, split_multiline_blob};
use fff_grep::{
Searcher, Sink, SinkMatch,
matcher::{Match, Matcher, NoError},
};
use smallvec::SmallVec;
pub fn has_regex_metacharacters(text: &str) -> bool {
regex::escape(text) != text
}
pub(super) fn build_regex(pattern: &str, smart_case: bool) -> Result<regex::bytes::Regex, String> {
if pattern.is_empty() {
return Err("empty pattern".to_string());
}
let regex_pattern = if pattern.contains("\\n") {
pattern.replace("\\n", "\n")
} else {
pattern.to_string()
};
let case_insensitive = if smart_case {
!pattern.chars().any(|c| c.is_uppercase())
} else {
false
};
regex::bytes::RegexBuilder::new(&regex_pattern)
.case_insensitive(case_insensitive)
.multi_line(true)
.unicode(false)
.build()
.map_err(|e| e.to_string())
}
pub(super) struct RegexMatcher<'r> {
pub(super) regex: &'r regex::bytes::Regex,
pub(super) is_multiline: bool,
}
impl Matcher for RegexMatcher<'_> {
type Error = NoError;
#[inline]
fn find_at(&self, haystack: &[u8], at: usize) -> Result<Option<Match>, NoError> {
Ok(self
.regex
.find_at(haystack, at)
.map(|m| Match::new(m.start(), m.end())))
}
#[inline]
fn line_terminator(&self) -> Option<fff_grep::LineTerminator> {
if self.is_multiline {
None
} else {
Some(fff_grep::LineTerminator::byte(b'\n'))
}
}
}
pub(super) struct RegexSink<'r> {
pub(super) state: SinkState,
pub(super) re: &'r regex::bytes::Regex,
}
impl Sink for RegexSink<'_> {
type Error = std::io::Error;
fn matched(
&mut self,
searcher: &Searcher,
sink_match: &SinkMatch<'_>,
) -> Result<bool, Self::Error> {
debug_assert_newline_terminator(searcher);
if self.state.max_matches != 0 && self.state.matches.len() >= self.state.max_matches {
return Ok(false);
}
let line_bytes = sink_match.bytes();
let (display_bytes, _, line_number, byte_offset) =
SinkState::prepare_line(line_bytes, sink_match);
// MultiLine strategy hands over all matched lines as one blob: keep
// `line_content` single-line, the remaining lines become after-context.
let (first_line, extra_after) = split_multiline_blob(display_bytes);
let first_len = first_line.len() as u32;
let line_content = String::from_utf8_lossy(first_line).into_owned();
let mut match_byte_offsets: SmallVec<[(u32, u32); 4]> = SmallVec::new();
let mut col = 0usize;
let mut first = true;
for m in self.re.find_iter(display_bytes) {
let abs_start = m.start() as u32;
if abs_start >= first_len {
continue; // highlight only spans visible in the first line
}
let abs_end = (m.end() as u32).min(first_len);
if first {
col = abs_start as usize;
first = false;
}
match_byte_offsets.push((abs_start, abs_end));
}
let (context_before, context_after) = self.state.extract_context(sink_match);
let context_after = if extra_after.is_empty() {
context_after
} else {
let mut combined = extra_after;
combined.extend(context_after);
combined
};
self.state.push_match(
line_number,
col,
byte_offset,
line_content,
match_byte_offsets,
context_before,
context_after,
);
Ok(true)
}
fn finish(&mut self, _: &Searcher, _: &fff_grep::SinkFinish) -> Result<(), Self::Error> {
Ok(())
}
}
+242
View File
@@ -0,0 +1,242 @@
use super::types::GrepMatch;
use fff_grep::{Searcher, SinkMatch};
use smallvec::SmallVec;
/// Maximum bytes of a matched line to keep for display. Prevents minified
/// JS or huge single-line files from blowing up memory.
pub(super) const MAX_LINE_DISPLAY_LEN: usize = 512;
#[cfg(feature = "definitions")]
#[inline]
pub(super) fn classify_definition(enabled: bool, line: &str) -> bool {
enabled && super::classify::is_definition_line(line)
}
#[cfg(not(feature = "definitions"))]
#[inline]
pub(super) fn classify_definition(_enabled: bool, _line: &str) -> bool {
false
}
#[inline]
pub(super) fn debug_assert_newline_terminator(searcher: &Searcher) {
debug_assert_eq!(
searcher.line_terminator(),
fff_grep::LineTerminator::byte(b'\n'),
"sink helpers assume \\n line terminators (see module invariant)"
);
}
#[inline]
pub(super) fn strip_line_terminators(bytes: &[u8]) -> &[u8] {
let mut len = bytes.len();
while len > 0 && matches!(bytes[len - 1], b'\n' | b'\r') {
len -= 1;
}
&bytes[..len]
}
pub(super) struct SinkState {
pub(super) file_index: usize,
pub(super) matches: Vec<GrepMatch>,
pub(super) max_matches: usize,
pub(super) before_context: usize,
pub(super) after_context: usize,
pub(super) classify_definitions: bool,
}
impl SinkState {
#[inline]
pub(super) fn prepare_line<'a>(
line_bytes: &'a [u8],
mat: &SinkMatch<'_>,
) -> (&'a [u8], u32, u64, u64) {
let line_number = mat.line_number().unwrap_or(0);
let byte_offset = mat.absolute_byte_offset();
// Trim trailing newline/CR directly on bytes to avoid UTF-8 conversion.
let trimmed_bytes = strip_line_terminators(line_bytes);
// Truncate for display (floor to a char boundary).
let display_bytes = truncate_display_bytes(trimmed_bytes);
let display_len = display_bytes.len() as u32;
(display_bytes, display_len, line_number, byte_offset)
}
#[inline]
#[allow(clippy::too_many_arguments)]
pub(super) fn push_match(
&mut self,
line_number: u64,
col: usize,
byte_offset: u64,
line_content: String,
match_byte_offsets: SmallVec<[(u32, u32); 4]>,
context_before: Vec<String>,
context_after: Vec<String>,
) {
let is_definition = classify_definition(self.classify_definitions, &line_content);
self.matches.push(GrepMatch {
file_index: self.file_index,
line_number,
col,
byte_offset,
line_content,
match_byte_offsets,
fuzzy_score: None,
is_definition,
context_before,
context_after,
});
}
/// Extract context lines from the full buffer around a matched region.
pub(super) fn extract_context(&self, mat: &SinkMatch<'_>) -> (Vec<String>, Vec<String>) {
if self.before_context == 0 && self.after_context == 0 {
return (Vec::new(), Vec::new());
}
let buffer = mat.buffer();
let range = mat.bytes_range_in_buffer();
let mut before = Vec::new();
if self.before_context > 0 && range.start > 0 {
// Walk backward from the start of the match line to find preceding lines
let mut pos = range.start;
let mut lines_found = 0;
while lines_found < self.before_context && pos > 0 {
// Skip the newline just before our current position
pos -= 1;
// Find the previous newline
let line_start = match memchr::memrchr(b'\n', &buffer[..pos]) {
Some(nl) => nl + 1,
None => 0,
};
let line = &buffer[line_start..pos];
// Trim trailing \r
let line = if line.last() == Some(&b'\r') {
&line[..line.len() - 1]
} else {
line
};
let truncated = truncate_display_bytes(line);
before.push(String::from_utf8_lossy(truncated).into_owned());
pos = line_start;
lines_found += 1;
}
before.reverse();
}
let mut after = Vec::new();
if self.after_context > 0 && range.end < buffer.len() {
let mut pos = range.end;
let mut lines_found = 0;
while lines_found < self.after_context && pos < buffer.len() {
// Find the next newline
let line_end = match memchr::memchr(b'\n', &buffer[pos..]) {
Some(nl) => pos + nl,
None => buffer.len(),
};
let line = &buffer[pos..line_end];
// Trim trailing \r
let line = if line.last() == Some(&b'\r') {
&line[..line.len() - 1]
} else {
line
};
let truncated = truncate_display_bytes(line);
after.push(String::from_utf8_lossy(truncated).into_owned());
pos = if line_end < buffer.len() {
line_end + 1 // skip past \n
} else {
buffer.len()
};
lines_found += 1;
}
}
(before, after)
}
}
/// Truncate a byte slice for display, respecting UTF-8 char boundaries.
#[inline]
pub(super) fn truncate_display_bytes(bytes: &[u8]) -> &[u8] {
if bytes.len() <= MAX_LINE_DISPLAY_LEN {
bytes
} else {
let mut end = MAX_LINE_DISPLAY_LEN;
while end > 0 && !is_utf8_char_boundary(bytes[end]) {
end -= 1;
}
&bytes[..end]
}
}
/// Split a multiline match blob (from the MultiLine searcher strategy) into
/// the first line and the remaining lines so `line_content` stays single-line.
pub(super) fn split_multiline_blob(display_bytes: &[u8]) -> (&[u8], Vec<String>) {
match memchr::memchr(b'\n', display_bytes) {
None => (display_bytes, Vec::new()),
Some(pos) => {
let first = strip_line_terminators(&display_bytes[..pos + 1]);
let extra = display_bytes[pos + 1..]
.split(|&b| b == b'\n')
.map(|l| String::from_utf8_lossy(strip_line_terminators(l)).into_owned())
.collect();
(first, extra)
}
}
}
/// Convert character-position indices from neo_frizbee into byte-offset
/// pairs (start, end) suitable for `match_byte_offsets`.
///
/// frizbee returns character positions (0-based index into the char
/// iterator). We need byte ranges because the UI renderer and Lua layer
/// use byte offsets for extmark highlights.
///
/// Each matched character becomes its own (byte_start, byte_end) pair.
/// Adjacent characters are merged into a single contiguous range.
pub(super) fn char_indices_to_byte_offsets(
line: &str,
char_indices: &[usize],
) -> SmallVec<[(u32, u32); 4]> {
if char_indices.is_empty() {
return SmallVec::new();
}
// Build a map: char_index -> (byte_start, byte_end) for all chars.
// Iterating all chars is O(n) in the line length which is bounded by MAX_LINE_DISPLAY_LEN (512).
let char_byte_ranges: Vec<(usize, usize)> = line
.char_indices()
.map(|(byte_pos, ch)| (byte_pos, byte_pos + ch.len_utf8()))
.collect();
// Convert char indices to byte ranges, merging adjacent ranges
let mut result: SmallVec<[(u32, u32); 4]> = SmallVec::with_capacity(char_indices.len());
for &ci in char_indices {
if ci >= char_byte_ranges.len() {
continue; // out of bounds (shouldn't happen with valid data)
}
let (start, end) = char_byte_ranges[ci];
// Merge with previous range if adjacent
if let Some(last) = result.last_mut()
&& last.1 == start as u32
{
last.1 = end as u32;
continue;
}
result.push((start as u32, end as u32));
}
result
}
// copied from the rust u8 private method
#[inline]
const fn is_utf8_char_boundary(b: u8) -> bool {
(b as i8) >= -0x40
}
+243
View File
@@ -0,0 +1,243 @@
use crate::types::FileItem;
use smallvec::SmallVec;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
pub use crate::constants::MAX_FFFILE_SIZE;
/// Controls how the grep pattern is interpreted.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GrepMode {
/// Literal plain text match: default path that doesn't require any regex machinery
#[default]
PlainText,
/// Regex mode: uses the same exact matching engine as ripgrep
Regex,
/// Smart fuzzy mode, allows user to make either a couple of single char typos or long gaps
/// e.g. shcema -> shcema, or UserController -> UserAuthController
///
/// Significatnly slower than plain text, especially on unindexed FilePicker
Fuzzy,
}
/// A single content match within a file
#[derive(Debug, Clone)]
pub struct GrepMatch {
/// Index into the deduplicated `files` vec of the GrepResult.
pub file_index: usize,
/// 1-based line number.
pub line_number: u64,
/// 0-based byte column of first match start within the line.
pub col: usize,
/// Absolute byte offset of the matched line from the start of the file.
/// Can be used by the preview to seek directly without scanning from the top.
pub byte_offset: u64,
/// The matched line text, truncated to `MAX_LINE_DISPLAY_LEN`.
pub line_content: String,
/// Byte offsets `(start, end)` within `line_content` for each match.
/// Stack-allocated for the common case of ≤4 spans per line.
pub match_byte_offsets: SmallVec<[(u32, u32); 4]>,
/// Fuzzy match score from neo_frizbee (only set in Fuzzy grep mode).
pub fuzzy_score: Option<u16>,
/// Whether the matched line looks like a definition (struct, fn, class, etc.).
/// Computed at match time so output formatters don't need to re-scan.
pub is_definition: bool,
/// Lines before the match (for context display). Empty when context is 0.
pub context_before: Vec<String>,
/// Lines after the match (for context display). Empty when context is 0.
pub context_after: Vec<String>,
}
impl GrepMatch {
/// Strip leading whitespace from `line_content` and all context lines,
/// adjusting `col` and `match_byte_offsets` so highlights remain correct.
pub fn trim_leading_whitespace(&mut self) {
let strip_len = self.line_content.len() - self.line_content.trim_start().len();
if strip_len > 0 {
self.line_content.drain(..strip_len);
let off = strip_len as u32;
self.col = self.col.saturating_sub(strip_len);
for range in &mut self.match_byte_offsets {
range.0 = range.0.saturating_sub(off);
range.1 = range.1.saturating_sub(off);
}
}
for line in &mut self.context_before {
let n = line.len() - line.trim_start().len();
if n > 0 {
line.drain(..n);
}
}
for line in &mut self.context_after {
let n = line.len() - line.trim_start().len();
if n > 0 {
line.drain(..n);
}
}
}
}
/// Options for grep search.
#[derive(Debug, Clone)]
pub struct GrepSearchOptions {
pub max_file_size: u64,
pub max_matches_per_file: usize,
pub smart_case: bool,
/// File-based pagination offset: index into the sorted/filtered file list
/// to start searching from. Pass 0 for the first page, then use
/// `GrepResult::next_file_offset` for subsequent pages.
pub file_offset: usize,
/// Maximum number of matches to collect before stopping.
pub page_limit: usize,
/// How to interpret the search pattern. Defaults to `PlainText`.
pub mode: GrepMode,
/// Maximum time in milliseconds to spend searching before returning partial
/// results. Prevents UI freezes on pathological queries. 0 = no limit.
pub time_budget_ms: u64,
/// Number of context lines to include before each match. 0 = disabled.
pub before_context: usize,
/// Number of context lines to include after each match. 0 = disabled.
pub after_context: usize,
/// Whether to classify each match as a definition line. Adds ~2% overhead
/// on large repos; disable for interactive grep where it is not needed.
pub classify_definitions: bool,
/// Strip leading whitespace from matched lines and context lines, adjusting
/// highlight byte offsets accordingly. Useful for AI/MCP consumers and UIs
/// that don't need indentation. Default: false.
pub trim_whitespace: bool,
/// External abort signal. When provided, overrides the picker's internal
/// cancellation flag. Set to `true` to stop the search early and return
/// partial results. Omit (or use `..Default::default()`) to let the
/// picker manage cancellation.
pub abort_signal: Option<Arc<AtomicBool>>,
}
impl Default for GrepSearchOptions {
fn default() -> Self {
Self {
max_file_size: MAX_FFFILE_SIZE,
max_matches_per_file: 200,
smart_case: true,
file_offset: 0,
page_limit: 50,
mode: GrepMode::default(),
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
abort_signal: None,
}
}
}
/// Result of a grep search with a list of matches, list of matched files, and metadata.
#[derive(Debug, Clone, Default)]
pub struct GrepResult<'a> {
pub matches: Vec<GrepMatch>,
/// Deduplicated file references for the returned matches.
pub files: Vec<&'a FileItem>,
/// Number of files actually searched in this call.
pub total_files_searched: usize,
/// Total number of indexed files (before filtering).
pub total_files: usize,
/// Total number of searchable files (after filtering out binary, too-large, etc.).
pub filtered_file_count: usize,
/// Number of files that contained at least one match.
pub files_with_matches: usize,
/// The file offset to pass for the next page. `0` if there are no more files.
/// Callers should store this and pass it as `file_offset` in the next call.
pub next_file_offset: usize,
/// When regex mode fails to compile the pattern, the search falls back to
/// literal matching and this field contains the compilation error message.
/// The UI can display this to inform the user their regex was invalid.
pub regex_fallback_error: Option<String>,
/// Set to `true` if the constrained query found nothing and the results come from
/// retrying the whole raw query as literal text (ignored all the inferred constraints)
pub literal_fallback: bool,
}
impl<'a> GrepResult<'a> {
/// Empty result carrying only the file counts (empty query / prefilter miss)
pub(crate) fn empty(total_files: usize, filtered_file_count: usize) -> Self {
Self {
total_files,
filtered_file_count,
..Default::default()
}
}
pub(crate) fn collect(
per_file_results: Vec<(usize, &'a FileItem, Vec<GrepMatch>)>,
files_to_search_len: usize,
options: &GrepSearchOptions,
total_files: usize,
filtered_file_count: usize,
budget_exceeded: bool,
) -> Self {
let page_limit = options.page_limit;
// Each match stores a `file_index` pointing into `result_files` so that
// consumers (FFI JSON, Lua) can look up file metadata without duplicating
// it across every match from the same file
let mut result_files: Vec<&'a FileItem> = Vec::new();
let mut all_matches: Vec<GrepMatch> = Vec::new();
// files_consumed tracks how far into files_to_search we have advanced,
// counting every file whose results were emitted (with or without matches).
// We use the batch_idx of the last consumed file + 1, which is correct
// because per_file_results only contains files that had matches, and
// files between them that had no matches were still searched and can be
// safely skipped on the next page
let mut files_consumed: usize = 0;
for (batch_idx, file, file_matches) in per_file_results {
// batch_idx is the 0-based position in files_to_search.
// Advance files_consumed to include this file and all no-match files before it.
files_consumed = batch_idx + 1;
let file_result_idx = result_files.len();
result_files.push(file);
for mut m in file_matches {
m.file_index = file_result_idx;
if options.trim_whitespace {
m.trim_leading_whitespace();
}
all_matches.push(m);
}
// page_limit is a soft cap: we always finish the current file before
// stopping, so no matches are dropped. A page may return up to
// page_limit + max_matches_per_file - 1 matches in the worst case
if all_matches.len() >= page_limit {
break;
}
}
// If no file had any match, we searched the entire slice.
if result_files.is_empty() {
files_consumed = files_to_search_len;
}
let has_more = budget_exceeded
|| (all_matches.len() >= page_limit && files_consumed < files_to_search_len);
let next_file_offset = if has_more {
options.file_offset + files_consumed
} else {
0
};
Self {
matches: all_matches,
files_with_matches: result_files.len(),
files: result_files,
total_files_searched: files_consumed,
total_files,
filtered_file_count,
next_file_offset,
regex_fallback_error: None,
literal_fallback: false,
}
}
}
+76 -8
View File
@@ -3,28 +3,53 @@ use std::path::Path;
/// Directories excluded when walking a non-git root. Entries are `cfg`-gated
/// so a single iteration covers standard + platform-specific overrides.
pub(crate) const IGNORED_DIRS: &[&str] = &[
// various dev tools that can be meet in the developer app
"node_modules",
"__pycache__",
"venv",
".venv",
// Rust (glob-only patterns for non_git_repo_overrides; is_non_code_directory
// matches the "target" component separately).
"target/debug",
"target/release",
"target/rust-analyzer",
"target/criterion",
// Language package caches in non-git roots.
"go/pkg/mod",
".cargo/registry",
".rustup/toolchains",
".gradle/caches",
".m2/repository",
".npm/_cacache",
".pub-cache",
#[cfg(not(target_os = "windows"))]
".local/state", // this contains tons of logs which generate too much watcher noise
#[cfg(target_os = "macos")]
"Library/Application Support",
#[cfg(target_os = "macos")]
"Library/Caches",
// App-group sandbox storage — used by iMessage, Photos, Notes, Calendar,
// Electron apps, etc. for SQLite-WAL, LevelDB, protobuf files. These are
// almost entirely extension-less binary files (~80k on a typical $HOME)
// that never need to appear in a fuzzy or grep search.
#[cfg(target_os = "macos")]
"Library/Group Containers",
"Library/Containers", // sandboxed apps data
#[cfg(target_os = "macos")]
"Library/Containers",
"Library/Group Containers", // random application data and networking
#[cfg(target_os = "macos")]
"Library/pnpm",
#[cfg(target_os = "macos")]
"Library/Metadata",
#[cfg(target_os = "macos")]
"Library/Developer/CoreSimulator",
#[cfg(target_os = "macos")]
"Library/Android",
#[cfg(target_os = "macos")]
"Library/Logs",
#[cfg(target_os = "macos")]
"Library/Daemon Containers",
#[cfg(target_os = "macos")]
"Library/Trial",
#[cfg(target_os = "macos")]
"Library/Preferences",
#[cfg(target_os = "macos")]
"Library/Messages",
#[cfg(target_os = "macos")]
"Library/IdentityServices",
#[cfg(target_os = "windows")]
"bin/Debug",
#[cfg(target_os = "windows")]
@@ -57,6 +82,10 @@ pub(crate) fn non_git_repo_overrides(base_path: &Path) -> Option<ignore::overrid
pub(crate) fn is_non_code_directory(path: &Path) -> bool {
let path_str = path.as_os_str().to_str().unwrap_or("");
IGNORED_DIRS.iter().any(|&dir| {
// Entries are gitignore patterns for the walkers; here they are matched
// as substrings, so a leading `*` wildcard has to come off first.
let dir = dir.strip_prefix('*').unwrap_or(dir);
#[cfg(target_os = "windows")]
let dir = dir.replace('/', std::path::MAIN_SEPARATOR_STR);
#[cfg(target_os = "windows")]
@@ -66,3 +95,42 @@ pub(crate) fn is_non_code_directory(path: &Path) -> bool {
path_str.contains(dir)
})
}
#[cfg(all(test, target_os = "macos"))]
mod tests {
use super::*;
#[test]
fn home_machine_state_is_excluded_but_source_trees_are_not() {
// Representative machine state from a home index.
for rel in [
"Library/pnpm/store/v3/files/00/abcdef",
"Library/Preferences/com.apple.finder.plist",
"Library/Messages/prewarm.db-shm",
"Library/IdentityServices/TetraDB-identityservicesd.db-wal",
"Library/Developer/CoreSimulator/Devices/X/data/f",
"go/pkg/mod/github.com/x/y@v1/main.go",
".cargo/registry/src/index.crates.io-1/serde-1.0/src/lib.rs",
"Library/Android/sdk/platforms/android-34/data/x",
".local/state/nvim/fff+123+456.log",
] {
assert!(
is_non_code_directory(Path::new(rel)),
"{rel} must not reach the index"
);
}
// Source trees under $HOME stay searchable.
for rel in [
"dev/chromium/third_party/blink/renderer/core/dom/node.cc",
"dev/fff/crates/fff-core/src/lib.rs",
"Documents/notes/todo.md",
"dev/myproj/pkg/mod/thing.go",
] {
assert!(
!is_non_code_directory(Path::new(rel)),
"{rel} must stay searchable"
);
}
}
}
@@ -1,4 +1,4 @@
use crate::bigram_filter::BigramFilter;
use crate::index::bigram_filter::BigramFilter;
use regex_syntax::hir::{Class, Hir, HirKind};
use smallvec::SmallVec;
use std::borrow::Cow;
@@ -199,7 +199,7 @@ pub(crate) fn fuzzy_to_bigram_query(query: &str, num_probes: usize) -> BigramQue
return BigramQuery::Any;
}
// the simpliest case, just check that every bigram is present either consec or not
// the simplest case, just check that every bigram is present either consec or not
if max_typos == 0 {
return simplify_and(
bigram_keys
@@ -677,7 +677,7 @@ fn simplify_or(children: Vec<BigramQuery>) -> BigramQuery {
#[cfg(test)]
mod tests {
use super::*;
use crate::bigram_filter::BigramIndexBuilder;
use crate::index::bigram_filter::BigramIndexBuilder;
/// Build a tiny index from the given file contents for testing.
fn build_test_index(files: &[&[u8]]) -> BigramFilter {
+118
View File
@@ -0,0 +1,118 @@
use super::{BigramFilter, BigramOverlay, extract_bigrams};
use super::{fuzzy_to_bigram_query, regex_to_bigram_query};
/// Number of evenly-spaced probe bigrams used by the fuzzy candidate query.
const FUZZY_PROBE_COUNT: usize = 7;
#[inline]
fn set_bit(candidates: &mut [u64], file_idx: usize) {
let word = file_idx / 64;
if word < candidates.len() {
candidates[word] |= 1u64 << (file_idx % 64);
}
}
#[inline]
fn clear_tombstones(candidates: &mut [u64], overlay: &BigramOverlay) {
for (r, t) in candidates.iter_mut().zip(overlay.tombstones().iter()) {
*r &= !t;
}
}
/// Number of base files covered by the bigram bitset; files past this
/// boundary (overflow, max 1024) are always scanned.
#[inline]
pub(crate) fn bigram_boundary(overlay: Option<&BigramOverlay>, files_len: usize) -> usize {
overlay.map(|o| o.base_file_count()).unwrap_or(files_len)
}
/// Candidate bitset for literal patterns, OR-ed across all of them: a file is
/// a candidate when it contains the bigrams of ANY pattern. Overlay-modified
/// files are re-checked against each pattern's bigrams.
pub(crate) fn literal_candidates(
index: Option<&BigramFilter>,
overlay: Option<&BigramOverlay>,
patterns: &[&str],
) -> Option<Vec<u64>> {
let index = ready_index(index)?;
let mut combined: Option<Vec<u64>> = None;
for pattern in patterns {
if let Some(candidates) = index.query(pattern.as_bytes()) {
combined = Some(match combined {
None => candidates,
Some(mut acc) => {
acc.iter_mut()
.zip(candidates.iter())
.for_each(|(a, b)| *a |= *b);
acc
}
});
}
}
let mut candidates = combined?;
if let Some(overlay) = overlay {
clear_tombstones(&mut candidates, overlay);
for pattern in patterns {
let pattern_bigrams = extract_bigrams(pattern.as_bytes());
for file_idx in overlay.query_modified(&pattern_bigrams) {
set_bit(&mut candidates, file_idx);
}
}
}
Some(candidates)
}
/// Candidate bitset for a regex pattern: the regex HIR is decomposed into an
/// AND/OR bigram query tree (supports alternation, optional groups, character
/// classes, and sparse-1 bigrams across single-byte wildcards). Since modified
/// file contents can't be re-checked against a regex cheaply, all
/// overlay-modified files are conservatively added.
pub(crate) fn regex_candidates(
index: Option<&BigramFilter>,
overlay: Option<&BigramOverlay>,
pattern: &str,
) -> Option<Vec<u64>> {
let index = ready_index(index)?;
let bq = regex_to_bigram_query(pattern);
if bq.is_any() {
return None;
}
let candidates = bq.evaluate(index)?;
Some(add_all_modified(candidates, overlay))
}
/// Candidate bitset for a fuzzy pattern: evenly-spaced probe bigrams with a
/// typo allowance (widely-spaced probes are far more selective than sliding
/// windows of adjacent bigrams). All overlay-modified files are added.
pub(crate) fn fuzzy_candidates(
index: Option<&BigramFilter>,
overlay: Option<&BigramOverlay>,
pattern: &str,
) -> Option<Vec<u64>> {
let index = ready_index(index)?;
let bq = fuzzy_to_bigram_query(pattern, FUZZY_PROBE_COUNT);
if bq.is_any() {
return None;
}
let candidates = bq.evaluate(index)?;
Some(add_all_modified(candidates, overlay))
}
#[inline]
fn ready_index(index: Option<&BigramFilter>) -> Option<&BigramFilter> {
index.filter(|idx| idx.is_ready())
}
fn add_all_modified(mut candidates: Vec<u64>, overlay: Option<&BigramOverlay>) -> Vec<u64> {
if let Some(overlay) = overlay {
clear_tombstones(&mut candidates, overlay);
for file_idx in overlay.modified_indices() {
set_bit(&mut candidates, file_idx);
}
}
candidates
}
@@ -144,9 +144,9 @@ pub(crate) fn apply_constraints<'a, T: Constrainable + Sync>(
}
#[cfg(feature = "zlob")]
type GlobPattern = zlob::ZlobPattern;
pub(crate) type GlobPattern = zlob::ZlobPattern;
#[cfg(all(not(feature = "zlob"), feature = "ripgrep"))]
type GlobPattern = globset::GlobMatcher;
pub(crate) type GlobPattern = globset::GlobMatcher;
/// How `Constraint::Glob` is evaluated for each item.
enum GlobStrategy {
@@ -371,16 +371,34 @@ fn matches_git_status(status: Option<git2::Status>, filter: &GitStatusFilter) ->
#[inline]
#[cfg(feature = "zlob")]
fn compiled_matches(p: &GlobPattern, path: &str) -> bool {
pub(crate) fn compiled_matches(p: &GlobPattern, path: &str) -> bool {
p.matches_default(path)
}
#[inline]
#[cfg(all(not(feature = "zlob"), feature = "ripgrep"))]
fn compiled_matches(p: &GlobPattern, path: &str) -> bool {
pub(crate) fn compiled_matches(p: &GlobPattern, path: &str) -> bool {
p.is_match(path)
}
/// Append indices (into `rels`) of paths matching `p`, in input order.
/// zlob backend: ONE FFI call for the whole batch.
#[cfg(feature = "zlob")]
pub(crate) fn glob_matches_into(p: &GlobPattern, rels: &[&str], out: &mut Vec<usize>) {
match p.match_indices(rels, p.flags()) {
Ok(ix) => out.extend_from_slice(ix.as_slice()),
Err(e) => {
tracing::warn!(?e, "zlob batch match failed, falling back to per-path");
out.extend((0..rels.len()).filter(|&i| p.matches_default(rels[i])));
}
}
}
#[cfg(all(not(feature = "zlob"), feature = "ripgrep"))]
pub(crate) fn glob_matches_into(p: &GlobPattern, rels: &[&str], out: &mut Vec<usize>) {
out.extend((0..rels.len()).filter(|&i| p.is_match(rels[i])));
}
/// Decide between batch prepass and inline compiled patterns.
///
/// `has_pre_filter` = true when something cheaper than glob can reject items first
@@ -485,12 +503,12 @@ fn walk_globs<F: FnMut(&str)>(c: &Constraint<'_>, f: &mut F) {
}
#[cfg(feature = "zlob")]
fn compile_one(pattern: &str) -> Option<GlobPattern> {
pub(crate) fn compile_one(pattern: &str) -> Option<GlobPattern> {
zlob::ZlobPattern::compile(pattern, zlob::ZlobFlags::RECOMMENDED).ok()
}
#[cfg(all(not(feature = "zlob"), feature = "ripgrep"))]
fn compile_one(pattern: &str) -> Option<GlobPattern> {
pub(crate) fn compile_one(pattern: &str) -> Option<GlobPattern> {
globset::Glob::new(pattern)
.ok()
.map(|g| g.compile_matcher())
+11
View File
@@ -0,0 +1,11 @@
#[doc(hidden)] // for bench
pub mod bigram_filter;
pub(crate) use bigram_filter::*;
mod bigram_query;
pub use bigram_query::*;
mod candidates;
pub(crate) use candidates::*;
pub mod constraints;
+19 -6
View File
@@ -1,6 +1,6 @@
//! # FFF Search — High-performance file finder core
//!
//! This crate provides the core search engine for [FFF (Fast File Finder)](https://github.com/dmtrKovalenko/fff.nvim).
//! This crate provides the core search engine for [FFF (Fast File Finder)](https://github.com/dmtrKovalenko/fff).
//! It includes filesystem indexing with real-time watching, fuzzy matching powered
//! by [frizbee](https://docs.rs/neo_frizbee), frecency scoring backed by LMDB,
//! and multi-mode grep search.
@@ -20,6 +20,9 @@
//! - [`grep`] — Live grep search supporting regex, plain-text, and fuzzy modes
//! with optional constraint filtering.
//! - [`git`] — Git status caching and repository detection.
//! - [`watch`] — Client-facing filesystem watch subscriptions: glob, exact
//! path, or directory subtree with normalized batch delivery
//! (see [`SharedFilePicker::watch`]).
//!
//! ## Shared State
//!
@@ -120,7 +123,7 @@ pub mod git;
pub mod grep;
pub use grep::*;
/// Tracing/logging initialization and panic hook setup.
/// Tracing/logging initialization
pub mod log;
/// Various path utils might be handy for you to work with fff paths
@@ -132,16 +135,20 @@ pub use types::*;
pub mod constants;
/// Watcher rescan request accounting.
pub mod rescan_stats;
pub use rescan_stats::{RESCAN_STATS_ENABLED, RescanReason, RescanStats};
mod rescan_throttle;
// ==================================
// these are public only for benchmarks, no backward compatibility guaranteed
#[doc(hidden)]
pub mod bigram_filter;
pub use index::bigram_filter;
#[doc(hidden)]
pub mod simd_string_utils;
// ==================================
mod background_watcher;
mod constraints;
mod error;
mod git_status_worker;
mod ignore;
@@ -149,12 +156,18 @@ mod scan;
mod score;
mod sort_buffer;
pub(crate) mod bigram_query;
pub(crate) mod index;
pub(crate) mod parallelism;
pub(crate) mod simd_path;
pub(crate) mod stable_vec;
pub(crate) mod walk;
/// Filesystem watch subscriptions with glob filtering and batched delivery,
/// plus the background OS watcher.
#[path = "watcher/mod.rs"]
pub mod watch;
pub use watch::{WatchEvent, WatchEventKind, WatchId, WatchOptions};
// fff error
pub use error::{Error, Result};
+215
View File
@@ -0,0 +1,215 @@
#[cfg(rescan_stats)]
use std::sync::atomic::{AtomicUsize, Ordering};
/// Whether rescan accounting is compiled in.
pub const RESCAN_STATS_ENABLED: bool = cfg!(rescan_stats);
/// Cause recorded for a filesystem rescan request.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum RescanReason {
/// Requested through the public API (refresh, directory change).
Explicit,
/// The kernel dropped events and asked us to re-read the subtree.
KernelEventLoss,
/// A `.gitignore`/`.ignore` changed, so the cached ignore rules are stale.
IgnoreFileChanged,
/// A single debounce batch touched more paths than we apply incrementally.
EventBatchOverflow,
/// The picker refused an incremental insert/update.
IndexUpdateRejected,
/// The post-scan overflow region ran out of slots.
OverflowCapacity,
}
impl RescanReason {
pub const ALL: [RescanReason; 6] = [
RescanReason::Explicit,
RescanReason::KernelEventLoss,
RescanReason::IgnoreFileChanged,
RescanReason::EventBatchOverflow,
RescanReason::IndexUpdateRejected,
RescanReason::OverflowCapacity,
];
pub const fn as_str(self) -> &'static str {
match self {
RescanReason::Explicit => "explicit",
RescanReason::KernelEventLoss => "kernel_event_loss",
RescanReason::IgnoreFileChanged => "ignore_file_changed",
RescanReason::EventBatchOverflow => "event_batch_overflow",
RescanReason::IndexUpdateRejected => "index_update_rejected",
RescanReason::OverflowCapacity => "overflow_capacity",
}
}
const fn slot(self) -> usize {
match self {
RescanReason::Explicit => 0,
RescanReason::KernelEventLoss => 1,
RescanReason::IgnoreFileChanged => 2,
RescanReason::EventBatchOverflow => 3,
RescanReason::IndexUpdateRejected => 4,
RescanReason::OverflowCapacity => 5,
}
}
}
impl std::fmt::Display for RescanReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// Snapshot of rescan requests grouped by reason.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct RescanStats {
pub total: usize,
/// Requests suppressed during the cooldown.
pub throttled: usize,
counts: [usize; RescanReason::ALL.len()],
throttled_counts: [usize; RescanReason::ALL.len()],
}
impl RescanStats {
pub fn count(&self, reason: RescanReason) -> usize {
self.counts[reason.slot()]
}
pub fn count_throttled(&self, reason: RescanReason) -> usize {
self.throttled_counts[reason.slot()]
}
/// Admitted requests originating from watcher fallbacks.
pub fn watcher_triggered(&self) -> usize {
self.total - self.count(RescanReason::Explicit)
}
/// Per-reason delta against an earlier snapshot.
pub fn since(&self, earlier: &RescanStats) -> RescanStats {
let mut counts = [0usize; RescanReason::ALL.len()];
let mut throttled_counts = [0usize; RescanReason::ALL.len()];
for slot in 0..RescanReason::ALL.len() {
counts[slot] = self.counts[slot].saturating_sub(earlier.counts[slot]);
throttled_counts[slot] =
self.throttled_counts[slot].saturating_sub(earlier.throttled_counts[slot]);
}
RescanStats {
total: self.total.saturating_sub(earlier.total),
throttled: self.throttled.saturating_sub(earlier.throttled),
counts,
throttled_counts,
}
}
}
impl std::fmt::Display for RescanStats {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} rescan(s)", self.total)?;
let mut first = true;
for reason in RescanReason::ALL {
let count = self.count(reason);
if count == 0 {
continue;
}
f.write_str(if first { " [" } else { ", " })?;
write!(f, "{reason}={count}")?;
first = false;
}
if !first {
f.write_str("]")?;
}
if self.throttled > 0 {
write!(f, ", {} throttled", self.throttled)?;
}
Ok(())
}
}
#[cfg(rescan_stats)]
#[derive(Default)]
pub(crate) struct RescanCounters {
counters: [AtomicUsize; RescanReason::ALL.len()],
throttled: [AtomicUsize; RescanReason::ALL.len()],
}
#[cfg(rescan_stats)]
impl RescanCounters {
pub(crate) fn record(&self, reason: RescanReason) {
self.counters[reason.slot()].fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn record_throttled(&self, reason: RescanReason) {
self.throttled[reason.slot()].fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn snapshot(&self) -> RescanStats {
let mut stats = RescanStats::default();
for reason in RescanReason::ALL {
let count = self.counters[reason.slot()].load(Ordering::Relaxed);
stats.counts[reason.slot()] = count;
stats.total += count;
let throttled = self.throttled[reason.slot()].load(Ordering::Relaxed);
stats.throttled_counts[reason.slot()] = throttled;
stats.throttled += throttled;
}
stats
}
pub(crate) fn reset(&self) {
for counter in self.counters.iter().chain(self.throttled.iter()) {
counter.store(0, Ordering::Relaxed);
}
}
}
// Release builds retain the API without counter storage.
#[cfg(not(rescan_stats))]
#[derive(Default)]
pub(crate) struct RescanCounters;
#[cfg(not(rescan_stats))]
impl RescanCounters {
pub(crate) fn record(&self, _reason: RescanReason) {}
pub(crate) fn record_throttled(&self, _reason: RescanReason) {}
pub(crate) fn snapshot(&self) -> RescanStats {
RescanStats::default()
}
pub(crate) fn reset(&self) {}
}
#[cfg(all(test, rescan_stats))]
mod tests {
use super::*;
#[test]
fn counters_attribute_and_diff_per_reason() {
let counters = RescanCounters::default();
counters.record(RescanReason::Explicit);
let baseline = counters.snapshot();
counters.record(RescanReason::IgnoreFileChanged);
counters.record(RescanReason::IgnoreFileChanged);
counters.record(RescanReason::OverflowCapacity);
let stats = counters.snapshot();
assert_eq!(stats.total, 4);
assert_eq!(stats.watcher_triggered(), 3);
let delta = stats.since(&baseline);
assert_eq!(delta.total, 3);
assert_eq!(delta.count(RescanReason::Explicit), 0);
assert_eq!(delta.count(RescanReason::IgnoreFileChanged), 2);
assert_eq!(
delta.to_string(),
"3 rescan(s) [ignore_file_changed=2, overflow_capacity=1]"
);
counters.reset();
assert_eq!(counters.snapshot(), RescanStats::default());
}
}
+124
View File
@@ -0,0 +1,124 @@
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
use crate::constants::{
LARGE_INDEX_FILE_COUNT, RESCAN_MIN_INTERVAL, RESCAN_MIN_INTERVAL_LARGE_INDEX,
};
const NEVER: u64 = u64::MAX;
// Drops watcher rescan requests inside the cooldown after the last scan.
// A slightly stale index is fine: the next admitted event rescans everything.
pub(crate) struct RescanThrottle {
epoch: Instant,
last_admitted: AtomicU64,
}
impl Default for RescanThrottle {
fn default() -> Self {
Self {
epoch: Instant::now(),
last_admitted: AtomicU64::new(NEVER),
}
}
}
impl RescanThrottle {
/// Returns `true` if a rescan may start now and records it as the last scan
pub(crate) fn admit(&self, live_files: usize, has_git_repo: bool) -> bool {
let min_interval = if !has_git_repo && live_files >= LARGE_INDEX_FILE_COUNT {
RESCAN_MIN_INTERVAL_LARGE_INDEX
} else {
RESCAN_MIN_INTERVAL
};
let min_ms = min_interval.as_millis() as u64;
let now = self.elapsed_ms();
loop {
let last = self.last_admitted.load(Ordering::Acquire);
if last != NEVER && now.saturating_sub(last) < min_ms {
return false;
}
// CAS so two concurrent requests cannot both start a walk.
if self
.last_admitted
.compare_exchange(last, now, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
return true;
}
}
}
/// Records an explicit (unthrottled) scan so watcher requests right after
/// it are dropped: the index is already fresh.
pub(crate) fn note_explicit_scan(&self) {
self.last_admitted
.store(self.elapsed_ms(), Ordering::Release);
}
fn elapsed_ms(&self) -> u64 {
self.epoch.elapsed().as_millis() as u64
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn throttle_at(ms_ago: u64) -> RescanThrottle {
let now = Instant::now();
RescanThrottle {
epoch: now
.checked_sub(Duration::from_millis(ms_ago))
.expect("monotonic clock older than the rewind"),
last_admitted: AtomicU64::new(0),
}
}
#[test]
fn first_request_is_always_admitted() {
let throttle = RescanThrottle::default();
assert!(throttle.admit(100, true));
}
#[test]
fn requests_inside_the_cooldown_are_dropped() {
let throttle = throttle_at(1_000);
assert!(!throttle.admit(100, false));
assert!(!throttle.admit(100, false));
}
#[test]
fn a_large_index_outside_a_git_repo_uses_the_slower_cadence() {
// A minute is past the normal cooldown but not the large-index one.
let throttle = throttle_at(60_000);
assert!(throttle.admit(100, false));
let throttle = throttle_at(60_000);
assert!(!throttle.admit(LARGE_INDEX_FILE_COUNT, false));
}
#[test]
fn a_git_repo_keeps_the_normal_cadence_at_any_size() {
let throttle = throttle_at(60_000);
assert!(throttle.admit(LARGE_INDEX_FILE_COUNT, true));
}
#[test]
fn cooldown_expiry_admits_again() {
let throttle = throttle_at(RESCAN_MIN_INTERVAL.as_millis() as u64 + 1);
assert!(throttle.admit(100, false));
// Admission rearms the cooldown.
assert!(!throttle.admit(100, false));
}
#[test]
fn explicit_scan_rearms_the_cooldown() {
let throttle = RescanThrottle::default();
throttle.note_explicit_scan();
assert!(!throttle.admit(100, false));
}
}
+9 -15
View File
@@ -5,13 +5,13 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use tracing::{error, info};
use crate::FileSync;
use crate::background_watcher::BackgroundWatcher;
use crate::bigram_filter::{build_bigram_index, sniff_binary_for_non_indexable};
use crate::error::Error;
use crate::file_picker::FFFMode;
use crate::index::{build_bigram_index, sniff_binary_for_non_indexable};
use crate::parallelism::BACKGROUND_THREAD_POOL;
use crate::shared::{SharedFilePicker, SharedFrecency};
use crate::types::ContentCacheBudget;
use crate::watch::BackgroundWatcher;
#[derive(Clone, Default)]
pub(crate) struct ScanSignals {
@@ -161,7 +161,7 @@ impl ScanJob {
trace_span: _,
} = self;
let _scanning = ScanningGuard::new(&signals, config.install_watcher);
let _scanning = ScanningGuard::new(&signals);
scanned_files_counter.store(0, Ordering::Relaxed);
// 1. Walk the file system and collect the list of files
@@ -259,8 +259,11 @@ impl ScanJob {
Ok(watcher) => {
if let Ok(mut guard) = shared_picker.write()
&& let Some(picker) = guard.as_mut()
&& picker.base_path() == base_path
&& !signals.cancelled.load(Ordering::Acquire)
{
picker.background_watcher = Some(watcher);
signals.watcher_ready.store(true, Ordering::Release);
}
}
Err(e) => error!(?e, "failed to initialize background watcher"),
@@ -355,30 +358,21 @@ impl ScanJob {
}
}
/// RAII helper that flips the `scanning` signal on construction and
/// resets it on drop (so early-returns can't leave it stuck on `true`).
/// Also drives the `watcher_ready` signal on the initial-scan path.
// Ensures early returns clear the scanning signal.
struct ScanningGuard<'a> {
signals: &'a ScanSignals,
release_watcher_ready_on_drop: bool,
}
impl<'a> ScanningGuard<'a> {
fn new(signals: &'a ScanSignals, release_watcher_ready_on_drop: bool) -> Self {
fn new(signals: &'a ScanSignals) -> Self {
signals.scanning.store(true, Ordering::Relaxed);
Self {
signals,
release_watcher_ready_on_drop,
}
Self { signals }
}
}
impl Drop for ScanningGuard<'_> {
fn drop(&mut self) {
self.signals.scanning.store(false, Ordering::Relaxed);
if self.release_watcher_ready_on_drop {
self.signals.watcher_ready.store(true, Ordering::Release);
}
}
}
+149 -9
View File
@@ -1,14 +1,15 @@
use crate::{
constraints::apply_constraints,
git::is_modified_status,
index::constraints::apply_constraints,
path_utils::calculate_distance_penalty,
simd_path::{ArenaPtr, MAX_PATH_CHUNKS},
sort_buffer::{sort_by_key_with_buffer, sort_with_buffer},
types::{DirItem, FileItem, Score, ScoringContext},
};
use fff_query_parser::FuzzyQuery;
use fff_query_parser::{FFFQuery, FuzzyQuery};
use neo_frizbee::Scoring;
use rayon::prelude::*;
use smallvec::SmallVec;
use std::{borrow::Cow, path::MAIN_SEPARATOR};
enum FileItems<'a> {
@@ -168,13 +169,141 @@ pub(crate) fn fuzzy_match_and_score_files<'a>(
sort_and_paginate(results, context)
}
pub(crate) fn fuzzy_match_byte_offsets_for_page<'q>(
query: &'q FFFQuery<'q>,
items: &[&FileItem],
max_typos: u16,
base_arena: ArenaPtr,
overflow_arena: ArenaPtr,
) -> Vec<SmallVec<[(u32, u32); 4]>> {
let parts: Vec<&str> = match &query.fuzzy_query {
FuzzyQuery::Text(text) if text.len() >= 2 => vec![*text],
FuzzyQuery::Parts(parts) => parts.iter().copied().filter(|p| p.len() >= 2).collect(),
_ => Vec::new(),
};
let mut ranges_by_item = vec![SmallVec::new(); items.len()];
if parts.is_empty() || items.is_empty() {
return ranges_by_item;
}
let paths: Vec<String> = items
.iter()
.map(|item| {
let arena = if item.is_overflow() {
overflow_arena
} else {
base_arena
};
let mut path = String::with_capacity(item.relative_path_len());
item.write_relative_path_from_arena(arena, &mut path);
path
})
.collect();
let has_uppercase = parts
.iter()
.any(|part| part.chars().any(|ch| ch.is_uppercase()));
let config = neo_frizbee::Config {
max_typos: Some(max_typos),
sort: false,
scoring: Scoring {
capitalization_bonus: if has_uppercase { 8 } else { 0 },
matching_case_bonus: if has_uppercase { 4 } else { 0 },
..Default::default()
},
..Default::default()
};
for (idx, part) in parts.iter().copied().enumerate() {
let mut part_config = config;
if idx > 0 {
part_config.max_typos = config.max_typos.map(|t| t.min(part.len() as u16));
}
let mut matcher = neo_frizbee::Matcher::new(part, &part_config);
for mut matched in matcher.match_list_indices(&paths) {
let item_idx = matched.index as usize;
let Some(path) = paths.get(item_idx) else {
continue;
};
matched.indices.sort_unstable();
ranges_by_item[item_idx].extend(char_indices_to_byte_offsets(path, &matched.indices));
}
}
for ranges in &mut ranges_by_item {
*ranges = merge_byte_offsets(std::mem::take(ranges));
}
ranges_by_item
}
fn char_indices_to_byte_offsets(line: &str, char_indices: &[usize]) -> SmallVec<[(u32, u32); 4]> {
let char_byte_ranges: Vec<(usize, usize)> = line
.char_indices()
.map(|(byte_pos, ch)| (byte_pos, byte_pos + ch.len_utf8()))
.collect();
let mut result: SmallVec<[(u32, u32); 4]> = SmallVec::with_capacity(char_indices.len());
for &char_idx in char_indices {
let Some(&(start, end)) = char_byte_ranges.get(char_idx) else {
continue;
};
if let Some(last) = result.last_mut()
&& last.1 == start as u32
{
last.1 = end as u32;
continue;
}
result.push((start as u32, end as u32));
}
result
}
fn merge_byte_offsets(mut ranges: SmallVec<[(u32, u32); 4]>) -> SmallVec<[(u32, u32); 4]> {
if ranges.len() <= 1 {
return ranges;
}
ranges.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
let mut merged: SmallVec<[(u32, u32); 4]> = SmallVec::with_capacity(ranges.len());
for (start, end) in ranges {
if end <= start {
continue;
}
if let Some(last) = merged.last_mut()
&& start <= last.1
{
last.1 = last.1.max(end);
continue;
}
merged.push((start, end));
}
merged
}
/// Resolve a DirItem's chunked path into frizbee's pointer buffer.
#[inline]
fn resolve_dir_chunks(
dir: &DirItem,
arena: ArenaPtr,
overflow_arena: ArenaPtr,
buf: &mut [*const u8; MAX_PATH_CHUNKS],
) -> Option<(usize, u16)> {
let arena = if dir.is_overflow() {
overflow_arena
} else {
arena
};
let ptrs = dir.path.resolve_ptrs(arena, buf);
Some((ptrs.len(), dir.path.byte_len))
}
@@ -187,6 +316,7 @@ fn match_fuzzy_parts_dirs(
options: &neo_frizbee::Config,
max_threads: usize,
arena: ArenaPtr,
overflow_arena: ArenaPtr,
) -> Vec<neo_frizbee::Match> {
let valid_parts: Vec<&str> = fuzzy_parts
.iter()
@@ -200,7 +330,7 @@ fn match_fuzzy_parts_dirs(
let resolve_chunks_for_frizbee =
|dir: &&DirItem, buf: &mut [*const u8; MAX_PATH_CHUNKS]| -> Option<(usize, u16)> {
resolve_dir_chunks(dir, arena, buf)
resolve_dir_chunks(dir, arena, overflow_arena, buf)
};
let first_part_matches = neo_frizbee::match_list_parallel_resolved(
@@ -267,19 +397,23 @@ pub(crate) fn fuzzy_match_and_score_dirs<'a>(
dirs: &'a [DirItem],
context: &ScoringContext,
arena: ArenaPtr,
overflow_arena: ArenaPtr,
) -> (Vec<&'a DirItem>, Vec<Score>, usize) {
if dirs.is_empty() {
return (vec![], vec![], 0);
}
let parsed_query = context.query;
// Ghost dirs (all files tombstoned) never surface in search results.
let working_dirs: Vec<&DirItem> = if parsed_query.constraints.is_empty() {
dirs.iter().collect()
dirs.iter().filter(|d| !d.is_deleted()).collect()
} else {
match apply_constraints(dirs, &parsed_query.constraints, arena, arena) {
Some(filtered) if !filtered.is_empty() => filtered,
match apply_constraints(dirs, &parsed_query.constraints, arena, overflow_arena) {
Some(filtered) if !filtered.is_empty() => {
filtered.into_iter().filter(|d| !d.is_deleted()).collect()
}
Some(_) => return (vec![], vec![], 0),
None => dirs.iter().collect(),
None => dirs.iter().filter(|d| !d.is_deleted()).collect(),
}
};
@@ -322,6 +456,7 @@ pub(crate) fn fuzzy_match_and_score_dirs<'a>(
&options,
context.max_threads,
arena,
overflow_arena,
);
let main_needle = valid_parts[0].as_bytes();
@@ -334,12 +469,17 @@ pub(crate) fn fuzzy_match_and_score_dirs<'a>(
.into_iter()
.map(|path_match| {
let dir = working_dirs[path_match.index as usize];
let dir_arena = if dir.is_overflow() {
overflow_arena
} else {
arena
};
let base_score = path_match.score as i32;
let frecency_boost = base_score.saturating_mul(dir.max_access_frecency()) / 100;
// Distance penalty from current file's directory.
let distance_penalty = if context.current_file.is_some() {
dir.path.write_to_string(arena, &mut dir_buf);
dir.path.write_to_string(dir_arena, &mut dir_buf);
calculate_distance_penalty(context.current_file, &dir_buf)
} else {
0
@@ -350,7 +490,7 @@ pub(crate) fn fuzzy_match_and_score_dirs<'a>(
let match_start_approx = path_match.end_col.saturating_sub(main_needle_len - 1);
let is_dirname_match = match_start_approx >= last_seg_offset;
dir.write_dir_name(arena, &mut dirname_buf);
dir.write_dir_name(dir_arena, &mut dirname_buf);
let dirname_len = dirname_buf.len();
let is_exact_dirname = is_dirname_match
&& main_needle_len as usize == dirname_len
+145 -5
View File
@@ -8,7 +8,10 @@ use crate::file_picker::FilePicker;
use crate::frecency::FrecencyTracker;
use crate::git::GitStatusCache;
use crate::query_tracker::QueryTracker;
use crate::rescan_stats::{RescanCounters, RescanReason, RescanStats};
use crate::rescan_throttle::RescanThrottle;
use crate::scan::ScanJob;
use crate::watch::{WatchEvent, WatchId, WatchOptions, WatchRegistry};
use git2::Repository;
/// Poll `.git/index.lock` until it disappears (git write completed), giving up
@@ -73,12 +76,20 @@ pub struct SharedFilePicker(pub(crate) Arc<SharedPickerInner>);
pub struct SharedPickerInner {
picker: parking_lot::RwLock<Option<FilePicker>>,
/// Watch subscriptions live outside the picker lock so delivery and
/// (un)subscribing never contend with searches.
watchers: Arc<WatchRegistry>,
rescans: RescanCounters,
rescan_throttle: RescanThrottle,
}
impl Default for SharedPickerInner {
fn default() -> Self {
Self {
picker: parking_lot::RwLock::new(None),
watchers: Arc::new(WatchRegistry::default()),
rescans: RescanCounters::default(),
rescan_throttle: RescanThrottle::default(),
}
}
}
@@ -194,6 +205,39 @@ impl SharedFilePicker {
/// Performs a safe async rescan. Guarantees only single active rescan per picker.
/// If many rescans requested the last one guaranteed to be finished.
pub fn trigger_full_rescan_async(&self, shared_frecency: &SharedFrecency) -> Result<(), Error> {
self.trigger_full_rescan_with_reason(shared_frecency, RescanReason::Explicit)
.map(|_| ())
}
/// Returns admitted and throttled rescan requests by reason.
/// Counters start at picker creation or the last reset.
pub fn rescan_stats(&self) -> RescanStats {
self.0.rescans.snapshot()
}
pub fn reset_rescan_stats(&self) {
self.0.rescans.reset();
}
/// Returns `Ok(true)` when a rescan was started (or queued behind an
/// active scan) and `Ok(false)` when the request was throttled — the
/// caller must then fall back to incremental event processing.
pub(crate) fn trigger_full_rescan_with_reason(
&self,
shared_frecency: &SharedFrecency,
reason: RescanReason,
) -> Result<bool, Error> {
// for giant folders we have no other choice other than throttling rescans
// if user is running application in millions of files with a ton of rescan events
// we drop / throttle some of requests to avoid constant burst of IO
if reason == RescanReason::Explicit {
self.0.rescan_throttle.note_explicit_scan();
} else if !self.check_rescan_throttle(reason) {
return Ok(false);
}
self.0.rescans.record(reason);
match ScanJob::new_rescan(self, shared_frecency)? {
Some(job) => {
job.spawn();
@@ -214,7 +258,92 @@ impl SharedFilePicker {
}
}
}
Ok(())
Ok(true)
}
fn check_rescan_throttle(&self, reason: RescanReason) -> bool {
let (live_files, has_git) = self
.read()
.ok()
.and_then(|guard| {
guard
.as_ref()
.map(|picker| (picker.live_file_count(), picker.has_git_repo()))
})
.unwrap_or((0, false));
if self.0.rescan_throttle.admit(live_files, has_git) {
return true;
}
self.0.rescans.record_throttled(reason);
tracing::debug!(%reason, live_files, "Rescan throttled, skipping");
false
}
/// Subscribe to filesystem changes matching `pattern`.
///
/// Patterns may be base-relative globs (./ works), exact paths inside the indexed
/// tree, or existing directories. An empty pattern watches the whole tree.
///
/// Events are debounced and submitted in batches per 100-ms window at most 128 events.
/// Gitignored and other ignored files are never triggering watcher.
pub fn watch(
&self,
pattern: &str,
options: WatchOptions,
callback: impl Fn(WatchId, &[WatchEvent]) + Send + Sync + 'static,
) -> Result<WatchId, Error> {
let (base_path, has_watcher, watcher_ready) = {
let guard = self.read()?;
let picker = guard.as_ref().ok_or(Error::FilePickerMissing)?;
(
picker.base_path().to_path_buf(),
picker.has_watcher(),
picker.is_watcher_ready(),
)
};
if !has_watcher {
return Err(Error::WatcherDisabled);
}
if !watcher_ready {
return Err(Error::WatcherNotReady);
}
self.0
.watchers
.subscribe(&base_path, pattern, options, Box::new(callback))
}
/// Remove a watch subscription. Returns `true` if the id was active.
pub fn unwatch(&self, id: WatchId) -> bool {
self.0.watchers.unsubscribe(id)
}
/// Return whether a watch subscription is active.
pub fn is_watch_active(&self, id: WatchId) -> bool {
self.0.watchers.contains(id)
}
/// Remove every subscription without waiting for an executing callback.
pub fn shutdown_watches(&self) {
self.0.watchers.shutdown();
}
/// Remove every subscription and wait for an executing callback.
/// When called by that callback, it does not wait on itself.
pub fn shutdown_watches_and_wait(&self) {
self.0.watchers.shutdown_and_wait();
}
pub(crate) fn rebase_watches(&self, base_path: &Path) {
self.0.watchers.rebase(base_path);
}
pub(crate) fn watch_registry(&self) -> &Arc<WatchRegistry> {
&self.0.watchers
}
/// Refresh git statuses for all indexed files
@@ -362,20 +491,31 @@ impl<T: LmdbStore> SharedDb<T> {
/// 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 closing_event = match tracker.shared_env().destroy() {
Ok(closing) => closing,
Err(e) => {
*guard = Some(tracker);
return Err(e);
}
};
let db_path = tracker.env().path().to_path_buf();
// Drop closes the LMDB env and unmaps the files
drop(tracker);
drop(guard);
// Deleting before mdb_env_close finishes would race the unmap.
if let Some(event) = closing_event {
event.wait_timeout(Duration::from_secs(5));
}
std::fs::remove_dir_all(&db_path).map_err(|source| Error::RemoveDbDir {
path: db_path.clone(),
source,
+13 -1
View File
@@ -415,7 +415,10 @@ mod tests {
#[test]
fn test_chunked_string_full_path() {
let (store, strings, _files) = build_test_store(&["src/components/Button.tsx"]);
let (store, strings, _files) = build_test_store(&[
"src/components/Button.tsx",
"src/components/Button.test.tsx",
]);
let arena = store.as_arena_ptr();
let cs = &strings[0];
@@ -423,6 +426,15 @@ mod tests {
assert_eq!(cs.read_to_buf(arena, &mut buf), "src/components/Button.tsx");
assert_eq!(cs.byte_len, 25);
assert_eq!(cs.filename_offset, 15);
let cs = &strings[1];
let mut buf = [0u8; 512];
assert_eq!(
cs.read_to_buf(arena, &mut buf),
"src/components/Button.test.tsx"
);
assert_eq!(cs.byte_len, 30);
assert_eq!(cs.filename_offset, 15);
}
#[test]
+34 -1
View File
@@ -7,7 +7,7 @@ use std::sync::atomic::{AtomicI32, AtomicU8, AtomicU64, AtomicUsize, Ordering};
#[cfg(not(target_os = "windows"))]
use crate::constants::{FRESH_MMAP_THRESHOLD, MMAP_THRESHOLD};
use crate::constants::{MAX_CACHED_CONTENT_BYTES, MAX_FFFILE_SIZE, PATH_BUF_SIZE};
use crate::constraints::Constrainable;
use crate::index::constraints::Constrainable;
use crate::query_tracker::QueryMatchEntry;
use crate::simd_path::ArenaPtr;
use fff_query_parser::{FFFQuery, FuzzyQuery, Location};
@@ -69,6 +69,7 @@ pub struct DirFlags;
impl DirFlags {
pub const OVERFLOW: u8 = 1 << 0;
pub const DELETED: u8 = 1 << 1;
}
/// A directory in the file index. Shares chunk arena with file paths.
@@ -101,6 +102,24 @@ impl DirItem {
self.flags & DirFlags::OVERFLOW != 0
}
#[inline(always)]
pub fn is_deleted(&self) -> bool {
self.flags & DirFlags::DELETED != 0
}
/// Marks the dir deleted/restored. Returns `true` when the state changed.
pub(crate) fn set_deleted(&mut self, deleted: bool) -> bool {
if self.is_deleted() == deleted {
return false;
}
if deleted {
self.flags |= DirFlags::DELETED;
} else {
self.flags &= !DirFlags::DELETED;
}
true
}
pub(crate) fn new(path: crate::simd_path::ChunkedString, last_segment_offset: u16) -> Self {
Self {
path,
@@ -110,6 +129,19 @@ impl DirItem {
}
}
/// A dir appended after the initial scan; its path lives in the overflow arena.
pub(crate) fn new_overflow(
path: crate::simd_path::ChunkedString,
last_segment_offset: u16,
) -> Self {
Self {
path,
flags: DirFlags::OVERFLOW,
last_segment_offset,
max_access_frecency: AtomicI32::new(0),
}
}
/// Byte offset of the last path segment within the directory path.
#[inline]
pub fn last_segment_offset(&self) -> u16 {
@@ -831,6 +863,7 @@ impl ScoringContext<'_> {
pub struct SearchResult<'a> {
pub items: Vec<&'a FileItem>,
pub scores: Vec<Score>,
pub match_byte_offsets: Vec<smallvec::SmallVec<[(u32, u32); 4]>>,
pub total_matched: usize,
pub total_files: usize,
pub location: Option<Location>,
+2
View File
@@ -20,6 +20,8 @@ pub(crate) use ripgrep::walk_collect_files;
pub(crate) struct WalkOutput {
pub(crate) pairs: Vec<(FileItem, String)>,
/// Every non-ignored directory the walk visited, relative, ending with /
pub(crate) dirs: Vec<String>,
pub(crate) ignore_rules: Option<WalkIgnoreRules>,
}
+20 -5
View File
@@ -1,7 +1,7 @@
use crate::background_watcher::is_git_file;
use crate::ignore::non_git_repo_overrides;
use crate::types::FileItem;
use crate::walk::WalkOutput;
use crate::watch::is_git_file;
use ignore::WalkBuilder;
use std::path::Path;
use std::sync::{
@@ -34,9 +34,12 @@ pub(crate) fn walk_collect_files(
let walker = walk_builder.build_parallel();
let pairs = parking_lot::Mutex::new(Vec::<(FileItem, String)>::new());
// Single lock for both collections: every entry is either a file or a
// dir, so this keeps one mutex acquisition per entry.
let collected =
parking_lot::Mutex::new((Vec::<(FileItem, String)>::new(), Vec::<String>::new()));
walker.run(|| {
let pairs = &pairs;
let collected = &collected;
let counter = Arc::clone(synced_files_count);
let base_path = base_path.to_path_buf();
@@ -58,15 +61,27 @@ pub(crate) fn walk_collect_files(
let (file_item, rel_path) =
FileItem::new_from_walk(path, &base_path, None, metadata.as_ref());
pairs.lock().push((file_item, rel_path));
collected.lock().0.push((file_item, rel_path));
counter.fetch_add(1, Ordering::Relaxed);
} else if entry.depth() > 0 && entry.file_type().is_some_and(|ft| ft.is_dir()) {
let path = entry.path();
if !is_git_file(path)
&& let Ok(rel) = path.strip_prefix(&base_path)
{
let mut rel = crate::path_utils::to_canonical_slashes(&rel.to_string_lossy())
.into_owned();
rel.push('/');
collected.lock().1.push(rel);
}
}
ignore::WalkState::Continue
})
});
let (pairs, dirs) = collected.into_inner();
Ok(WalkOutput {
pairs: pairs.into_inner(),
pairs,
dirs,
ignore_rules: None,
})
}
+20 -8
View File
@@ -1,10 +1,8 @@
//! Filesystem traversal backed by zlob's native parallel walker.
//! Active when the `zlob` feature is enabled (requires the Zig toolchain).
use crate::file_picker::is_known_binary_extension_basename;
use crate::ignore::IGNORED_DIRS;
use crate::types::FileItem;
use crate::walk::{WalkIgnoreRules, WalkOutput};
use parking_lot::Mutex;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
@@ -48,12 +46,25 @@ pub(crate) fn walk_collect_files(
tracing::warn!(?e, "zlob extra_ignore rejected; walking without it");
}
let pairs = parking_lot::Mutex::new(Vec::<(FileItem, String)>::new());
// Single lock for both collections: every entry is either a file or a
// dir, so this keeps one mutex acquisition per entry.
let collected = Mutex::new((Vec::new(), Vec::new()));
let outcome = match builder.run(|entry| {
if !entry.is_file() {
// unlike ripgrep walker zlob doesnt show .git files
if entry.is_dir() {
let rel_bytes = entry.relative_path_bytes();
if !rel_bytes.is_empty() {
let mut rel = String::from_utf8_lossy(rel_bytes).into_owned();
rel.push('/');
collected.lock().1.push(rel);
}
}
return WalkState::Continue;
}
let rel_bytes = entry.relative_path_bytes();
// `basename()` returns `&str` for files only.
@@ -73,9 +84,9 @@ pub(crate) fn walk_collect_files(
let rel_str = String::from_utf8_lossy(rel_bytes).into_owned();
let item = FileItem::new_raw(basename_offset, size, modified, None, is_binary);
let mut guard = pairs.lock();
guard.push((item, rel_str));
let n = guard.len();
let mut guard = collected.lock();
guard.0.push((item, rel_str));
let n = guard.0.len();
drop(guard);
if n % PROGRESS_STEP == 0 {
@@ -93,7 +104,7 @@ pub(crate) fn walk_collect_files(
}
};
let pairs = pairs.into_inner();
let (pairs, dirs) = collected.into_inner();
// Always report the exact final total regardless of the last step.
synced_files_count.store(pairs.len(), Ordering::Relaxed);
@@ -106,6 +117,7 @@ pub(crate) fn walk_collect_files(
Ok(WalkOutput {
pairs,
dirs,
ignore_rules,
})
}
@@ -2,8 +2,10 @@ use crate::constants::MAX_OVERFLOW_FILES;
use crate::error::Error;
use crate::file_picker::FFFMode;
use crate::git_status_worker::GitStatusWorker;
use crate::rescan_stats::RescanReason;
use crate::shared::{SharedFilePicker, SharedFrecency};
use crate::sort_buffer::sort_with_buffer;
use crate::watch::{RawWatchEvent, WatchEventKind};
use git2::Repository;
use notify::event::{AccessKind, AccessMode};
use notify::{Config, EventKind, EventKindMask, RecursiveMode};
@@ -21,22 +23,27 @@ type Debouncer = notify_debouncer_full::Debouncer<notify::RecommendedWatcher, No
/// are fully joined before `stop()` / `Drop` returns.
pub struct BackgroundWatcher {
debouncer: Arc<Mutex<Option<Debouncer>>>,
watch_tx: Option<mpsc::Sender<PathBuf>>,
watch_tx: Option<mpsc::Sender<WatchTask>>,
owner_thread: Option<std::thread::JoinHandle<()>>,
}
enum WatchTask {
/// Only subscribe to a specific path, this is happening when we did rescun and have to update
/// the watcher only
Subscribe(PathBuf),
/// This is requires a separate walk of the new directory copies or created within a scan
/// window because it might contain subdirectories we have to walk, prune, and add to index
IndexNewDir(PathBuf),
}
const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(50);
/// On macOS, each `watch()` call creates a separate FSEventStream. When the
/// number of directories exceeds this threshold we fall back to a single
/// recursive watch to avoid exhausting the per-process stream limit.
const MAX_MACOS_NONRECURSIVE_WATCHES: usize = 4096;
/// Minimum seconds between frecency tracks of the same file in AI mode.
/// Prevents score inflation from rapid burst edits by AI agents.
const AI_MODE_COOLDOWN_SECS: u64 = 5 * 60;
impl BackgroundWatcher {
#[allow(clippy::too_many_arguments)]
pub fn new(
pub(crate) fn new(
base_path: PathBuf,
git_workdir: Option<PathBuf>,
shared_picker: SharedFilePicker,
@@ -80,7 +87,7 @@ impl BackgroundWatcher {
// spare watcher (configurable by the user, usually 100k - 1m)
let use_recursive = cfg!(any(target_os = "macos", target_os = "windows"));
let (watch_tx, watch_rx) = mpsc::channel::<PathBuf>();
let (watch_tx, watch_rx) = mpsc::channel::<WatchTask>();
let watch_tx_for_debouncer = watch_tx.clone();
let owner_weak_picker = shared_picker.weaken();
@@ -100,7 +107,6 @@ impl BackgroundWatcher {
info!("Background file watcher initialized successfully");
// debouncer is shared with the owner thread, once it's dropped the thread is closed
let debouncer = Arc::new(Mutex::new(Some(debouncer)));
// Only the Linux per-dir-watch branch needs this clone; on other
// platforms the owner thread never touches the debouncer.
@@ -112,48 +118,46 @@ impl BackgroundWatcher {
.name("fff-watcher-own".into())
.spawn(move || {
let _g = owner_span.enter();
while let Ok(dir) = watch_rx.recv() {
while let Ok(task) = watch_rx.recv() {
// if the picker is dropped we do need to exit the loop
let Some(strong_picker) = owner_weak_picker.upgrade() else {
break;
};
// Only inotify (Linux) has no kernel-level recursion, so
// it's the only platform that needs a per-subdir watch to
// be registered at runtime. macOS FSEvents and Windows
// ReadDirectoryChangesW are already watching recursively
// from the base path (see `create_debouncer`), and
// registering a second overlapping stream there produces
// duplicate/out-of-order events.
#[cfg(target_os = "linux")]
{
// Register the new directory with the debouncer, then
// drop the mutex BEFORE doing picker-side work — see
// the comment on `BackgroundWatcher::stop` for the
// lock-ordering rationale.
let mut guard = owner_debouncer.lock();
let Some(debouncer) = guard.as_mut() else {
break;
};
let (dir, is_new_dir) = match task {
WatchTask::Subscribe(dir) => (dir, false),
WatchTask::IndexNewDir(dir) => (dir, true),
};
if let Err(e) = debouncer.watch(&dir, RecursiveMode::NonRecursive) {
warn!(
?e,
dir = %dir.display(),
"Failed to init watcher for new directory"
);
}
// Register the watch BEFORE walking so files created mid-walk still handled
#[cfg(target_os = "linux")]
if !watch_dirs_nonrecursive(&owner_debouncer, std::iter::once(dir.as_path())) {
break;
}
track_files_from_new_directories(
&dir,
&strong_picker,
&owner_git_workdir,
&owner_git_worker,
);
if is_new_dir {
// need to call this on every platform to add subdirectories from the
// new folders to the picker, but on linux we have to handle the subdirs
let subdirs = index_new_directory(
&dir,
&strong_picker,
&owner_git_workdir,
&owner_git_worker,
);
// Transient strong ref drops here, back
// to weak-only before the next `recv()`.
// on linux we manually resubscribe for new inodes
#[cfg(target_os = "linux")]
if !watch_dirs_nonrecursive(
&owner_debouncer,
subdirs.iter().map(|p| p.as_path()),
) {
break;
}
drop(subdirs); // need it cause subdirs is unused on non-linux target'
}
drop(strong_picker);
}
tracing::info!("Background watcher is stopped");
@@ -175,13 +179,10 @@ impl BackgroundWatcher {
shared_frecency: SharedFrecency,
mode: FFFMode,
use_recursive: bool,
watch_tx: mpsc::Sender<PathBuf>,
watch_tx: mpsc::Sender<WatchTask>,
git_status_worker: Arc<GitStatusWorker>,
) -> Result<Debouncer, Error> {
let config = Config::default()
// do not follow symlinks as then notifiers spawns a bunch of events for symlinked
// files that could be git ignored, we have to property differentiate those and if
// the file was edited through a
.with_follow_symlinks(false)
// only the actual modification events, ignore the open syscals that we can generate by
// our own grep calls and preview window rendering
@@ -213,7 +214,7 @@ impl BackgroundWatcher {
// every new directory created has to be reflected in the picker state
for dir in new_dirs {
if let Err(e) = watch_tx.send(dir) {
if let Err(e) = watch_tx.send(WatchTask::IndexNewDir(dir)) {
error!(?e, "Failed to send directory update error");
}
}
@@ -232,13 +233,10 @@ impl BackgroundWatcher {
)?;
if use_recursive {
// if the platform supports native watcher recursion
debouncer.watch(base_path.as_path(), RecursiveMode::Recursive)?;
info!(
"File watcher initialized with single recursive watch on {} \
(exceeded threshold of {})",
"File watcher initialized with single recursive watch on {}",
base_path.display(),
MAX_MACOS_NONRECURSIVE_WATCHES,
);
} else {
debouncer.watch(base_path.as_path(), RecursiveMode::NonRecursive)?;
@@ -298,7 +296,9 @@ impl BackgroundWatcher {
Ok(debouncer)
}
/// Signals the background watcher threads to shut down, doesn't guarantee to deallocate immediately
/// Signal the watcher to shut down without blocking on its worker
/// threads. Safe to call from any context, including while holding
/// the [`SharedFilePicker`] write lock.
pub fn stop(&mut self) {
self.watch_tx.take();
if let Some(debouncer) = self.debouncer.lock().take() {
@@ -312,7 +312,7 @@ impl BackgroundWatcher {
pub(crate) fn request_watch_dir(&self, dir: PathBuf) -> bool {
match self.watch_tx.as_ref() {
Some(tx) => tx.send(dir).is_ok(),
Some(tx) => tx.send(WatchTask::Subscribe(dir)).is_ok(),
None => false,
}
}
@@ -325,7 +325,7 @@ impl Drop for BackgroundWatcher {
}
#[tracing::instrument(name = "fs_events", skip(events, shared_picker, shared_frecency, git_status_worker), level = Level::DEBUG)]
fn handle_debounced_events(
pub(crate) fn handle_debounced_events(
mode: FFFMode,
events: Vec<DebouncedEvent>,
base_path: &Path,
@@ -343,14 +343,32 @@ fn handle_debounced_events(
.ok()
.and_then(|g| g.as_ref().and_then(|p| p.ignore_rules()));
let filter = IgnoreFilter::new(base_path, walker_rules, repo.as_ref());
let mut need_full_rescan = false;
let mut need_full_git_rescan = false;
let mut batch_overflow_attempted = false;
let mut paths_to_remove = Vec::new();
let mut dirs_to_remove: Vec<PathBuf> = Vec::new();
let mut paths_to_add_or_modify = Vec::new();
let mut new_dirs_to_watch = Vec::new();
let mut affected_paths_count = 0usize;
let watch_registry = shared_picker.watch_registry();
let need_events_propagation = watch_registry.is_active();
let try_trigger_full_rescan = |reason: RescanReason| -> bool {
match shared_picker.trigger_full_rescan_with_reason(shared_frecency, reason) {
Ok(true) => {
warn!(%reason, "Triggering full rescan");
watch_registry.dispatch_rescan(base_path);
true
}
Ok(false) => false,
Err(e) => {
error!(%reason, "Failed to trigger full rescan: {:?}", e);
false
}
}
};
for debounced_event in &events {
// It is very important to not react to the access errors because we inevitably
// gonna trigger the sync by our own preview or other unnecessary noise
@@ -368,22 +386,19 @@ fn handle_debounced_events(
// When macOS FSEvents (or other backends) overflow their event buffer, the kernel
// drops individual events and emits a rescan flag telling us to re-scan the subtree
if debounced_event.event.need_rescan() {
if debounced_event.event.paths.len() < 16 // this should be usually one event
let small_and_known = debounced_event.event.paths.len() < 16 // this should be usually one event
&& debounced_event
.paths
.iter()
// but we are smart enough and not falling into the paths
.all(|p| should_include_file(p, &filter))
{
break;
.all(|p| !p.is_dir() && !filter.is_ignored(p));
if !small_and_known && try_trigger_full_rescan(RescanReason::KernelEventLoss) {
return Vec::new();
}
warn!(
"Received rescan event for paths {:?}, triggering full rescan",
debounced_event.event.paths
);
need_full_rescan = true;
break;
// Small batches and throttled rescans fall through: the listed
// paths are still applied incrementally below.
}
tracing::debug!(event = ?debounced_event.event, "Processing FS event");
@@ -392,13 +407,24 @@ fn handle_debounced_events(
path.file_name().and_then(|f| f.to_str()),
Some(".ignore") | Some(".gitignore")
) {
if path
.parent()
.is_some_and(|parent| filter.is_ignored(parent))
{
continue;
}
info!(
"Detected change in ignore definition file: {}",
path.display()
);
need_full_rescan = true;
break;
if try_trigger_full_rescan(RescanReason::IgnoreFileChanged) {
return Vec::new();
}
// Throttled: fall through so the ignore file itself stays
// indexed; the stale rules heal on the next admitted rescan.
}
if is_dotgit_change_affecting_status(path, &repo) {
@@ -431,45 +457,47 @@ fn handle_debounced_events(
EventKind::Remove(notify::event::RemoveKind::Folder)
);
let is_removed = is_folder_removal || is_removal || !path.exists();
let (is_dir, is_ignored) = if is_removed {
(false, true)
} else {
(path.is_dir(), filter.is_ignored(path))
};
if is_folder_removal {
dirs_to_remove.push(path.to_path_buf());
} else if is_removal || !path.exists() {
paths_to_remove.push(path.as_path());
} else if path.is_dir() {
if !is_path_ignored(path, &filter) {
} else if is_removed {
// best effort but doesn't require a stat and generally correct
let maybe_directory = !matches!(
debounced_event.event.kind,
EventKind::Remove(notify::event::RemoveKind::File)
);
paths_to_remove.push((path.as_path(), maybe_directory));
} else if is_dir {
if !is_ignored {
new_dirs_to_watch.push(path.to_path_buf());
}
} else {
} else if !is_ignored {
// For additions/modifications, still filter gitignored files.
if should_include_file(path, &filter) {
paths_to_add_or_modify.push(path.as_path());
}
paths_to_add_or_modify.push(path.as_path());
}
}
affected_paths_count += debounced_event.event.paths.len();
if affected_paths_count > MAX_OVERFLOW_FILES {
if !batch_overflow_attempted && affected_paths_count > MAX_OVERFLOW_FILES * 4 {
batch_overflow_attempted = true;
warn!(
?affected_paths_count,
max = MAX_OVERFLOW_FILES,
max = MAX_OVERFLOW_FILES * 4,
"Too many affected paths in a single batch, triggering full rescan",
);
need_full_rescan = true;
break;
if try_trigger_full_rescan(RescanReason::EventBatchOverflow) {
return Vec::new();
}
}
if need_full_rescan {
break;
}
}
if need_full_rescan {
info!(?affected_paths_count, "Triggering full rescan");
if let Err(e) = shared_picker.trigger_full_rescan_async(shared_frecency) {
error!("Failed to trigger full rescan: {:?}", e);
}
return Vec::new();
}
// It's important to get the allocated sort
@@ -496,8 +524,10 @@ fn handle_debounced_events(
}
let mut files_to_update_git_status = Vec::new();
let mut need_full_rescan = false;
let mut index_update_rejected = false;
let mut overflow_count = 0;
let mut removed_from_dirs = Vec::new();
let mut watch_events = ahash::AHashMap::new();
if !paths_to_remove.is_empty()
|| !dirs_to_remove.is_empty()
@@ -519,22 +549,64 @@ fn handle_debounced_events(
return new_dirs_to_watch;
};
for dir in &dirs_to_remove {
let count = picker.remove_all_files_in_dir(dir);
debug!("remove_all_files_in_dir({:?}) -> {} files", dir, count);
for (path, may_be_dir) in &paths_to_remove {
let removed = picker.remove_file_by_path(path);
if removed {
if need_events_propagation {
watch_events.insert(path.to_path_buf(), WatchEventKind::Removed);
}
} else if *may_be_dir {
// Not an indexed file: likely a dir renamed out of the tree
// (no Remove(Folder) is emitted), expand it per indexed file.
dirs_to_remove.push(path.to_path_buf());
}
}
for path in &paths_to_remove {
let removed = picker.remove_file_by_path(path);
debug!("remove_file_by_path({:?}) -> {}", path, removed);
// Single index scan for all dirs; misses (never-indexed paths) are free.
dirs_to_remove.sort_unstable();
dirs_to_remove.dedup();
if !dirs_to_remove.is_empty() {
let dirs = dirs_to_remove.iter().map(PathBuf::as_path);
if need_events_propagation {
picker.remove_all_files_in_dirs_with_callback(dirs, |path| {
removed_from_dirs.push(path.to_path_buf());
})
} else {
picker.remove_all_files_in_dirs(dirs)
};
}
if need_events_propagation {
for path in removed_from_dirs.drain(..) {
watch_events.insert(path, WatchEventKind::Removed);
}
}
files_to_update_git_status.reserve(paths_to_add_or_modify.len());
for path in &paths_to_add_or_modify {
if picker.get_overflow_files().len() >= MAX_OVERFLOW_FILES
&& picker.get_file_by_path(path).is_none()
{
index_update_rejected = true;
break;
}
let existed = need_events_propagation && picker.get_file_by_path(path).is_some();
if picker.handle_create_or_modify(path).is_some() {
files_to_update_git_status.push(path.to_path_buf());
if need_events_propagation {
let kind = if existed {
WatchEventKind::Modified
} else {
WatchEventKind::Created
};
watch_events.insert(path.to_path_buf(), kind);
}
} else {
need_full_rescan = true;
index_update_rejected = true;
}
}
@@ -545,11 +617,34 @@ fn handle_debounced_events(
files_updated = files_to_update_git_status.len(),
overflow_count, "File index changes applied",
);
if need_full_rescan || overflow_count > MAX_OVERFLOW_FILES {
info!("Watcher faced limit of index overflow. Triggering rescan");
if let Err(e) = shared_picker.trigger_full_rescan_async(shared_frecency) {
error!("Failed to trigger full rescan: {:?}", e);
}
let rescan_started = if index_update_rejected || overflow_count > MAX_OVERFLOW_FILES {
let reason = if index_update_rejected {
RescanReason::IndexUpdateRejected
} else {
RescanReason::OverflowCapacity
};
info!(%reason, "Watcher faced limit of index overflow. Triggering rescan");
try_trigger_full_rescan(reason)
} else {
false
};
// When the rescan is throttled the incrementally applied changes are
// still the freshest state we have — propagate them to subscribers.
if !rescan_started && need_events_propagation {
watch_registry.dispatch(
base_path,
watch_events
.into_iter()
.map(|(path, kind)| RawWatchEvent {
path,
kind,
is_ignored: false,
})
.collect(),
);
}
// AI mode: auto-track frecency for all modified/created files.
@@ -598,7 +693,7 @@ fn handle_debounced_events(
// do not try to update the paths if we anyway going to rescan everything from scratch
// no repo => no consumer thread, so don't accumulate paths nobody will drain
if !need_full_rescan && repo.is_some() {
if !index_update_rejected && repo.is_some() {
if need_full_git_rescan {
// A full git rescan re-reads every tracked path (including ones that just
// went clean after a commit), so it already subsumes the per-path update.
@@ -611,82 +706,131 @@ fn handle_debounced_events(
new_dirs_to_watch
}
/// After registering a watch on a newly created directory, list its
/// immediate children and add any files to the picker.
fn track_files_from_new_directories(
fn index_new_directory(
dir: &Path,
shared_picker: &SharedFilePicker,
git_workdir: &Option<PathBuf>,
git_status_worker: &Arc<GitStatusWorker>,
) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
) -> Vec<PathBuf> {
let repo = git_workdir.as_ref().and_then(|p| Repository::open(p).ok());
// Prefer the walker's ignore rules; read base_path + rules from the picker.
let (base_path, walker_rules) = match shared_picker.read().ok().and_then(|g| {
g.as_ref()
.map(|p| (p.base_path().to_path_buf(), p.ignore_rules()))
let (base_path, walker_rules, follow_symlinks) = match shared_picker.read().ok().and_then(|g| {
g.as_ref().map(|p| {
(
p.base_path().to_path_buf(),
p.ignore_rules(),
p.follows_symlinks(),
)
})
}) {
Some(pair) => pair,
None => return,
Some(triple) => triple,
None => return Vec::new(),
};
let filter = IgnoreFilter::new(&base_path, walker_rules, repo.as_ref());
let mut files_to_add = Vec::new();
for entry in entries.flatten() {
if entry.file_type().is_ok_and(|ft| ft.is_file()) {
let path = entry.path();
if should_include_file(&path, &filter) {
files_to_add.push(path);
}
let walk = match crate::walk::walk_collect_files(
dir,
repo.is_some(),
follow_symlinks,
1,
&Arc::new(std::sync::atomic::AtomicUsize::new(0)),
) {
Ok(walk) => walk,
Err(e) => {
warn!(?e, dir = %dir.display(), "Failed to walk new directory");
return Vec::new();
}
}
};
// TODO: figure out a better optimized way for zlob to rerun the directory walk using existing
// ignore rules, but currently we have to filter out ignored files on our own
let filter = IgnoreFilter::new(&base_path, walker_rules, repo.as_ref());
let join_unless_ignored = |relative_path: &str| -> Option<PathBuf> {
let path = dir.join(relative_path);
(!filter.is_ignored(&path)).then_some(path)
};
let files_to_add: Vec<PathBuf> = walk
.pairs
.iter()
.filter_map(|(_, path)| join_unless_ignored(path))
.collect();
let subdirs: Vec<PathBuf> = walk
.dirs
.iter()
.filter_map(|path| join_unless_ignored(path.trim_end_matches('/')))
.collect();
if files_to_add.is_empty() {
return;
return subdirs;
}
let added = files_to_add.len();
let mut indexed_files = Vec::with_capacity(files_to_add.len());
{
let Ok(mut guard) = shared_picker.write() else {
return;
return subdirs;
};
let Some(ref mut picker) = *guard else {
return;
return subdirs;
};
for path in &files_to_add {
picker.handle_create_or_modify(path);
for path in files_to_add {
if picker.handle_create_or_modify(&path).is_some() {
indexed_files.push(path);
}
}
}
let added = indexed_files.len();
let watch_registry = shared_picker.watch_registry();
if watch_registry.is_active() {
let events = indexed_files
.iter()
.map(|path| RawWatchEvent {
path: path.clone(),
kind: WatchEventKind::Created,
is_ignored: false,
})
.collect();
watch_registry.dispatch(&base_path, events);
}
if repo.is_some() {
git_status_worker.enqueue_paths(files_to_add);
git_status_worker.enqueue_paths(indexed_files);
}
debug!(
"Injected {} existing files from new directory {}",
"Indexed new {} files from new directory {}",
added,
dir.display(),
);
subdirs
}
fn should_include_file(path: &Path, filter: &IgnoreFilter) -> bool {
// Directories are not indexed — only regular files (and symlinks to files).
if path.is_dir() {
#[cfg(target_os = "linux")]
fn watch_dirs_nonrecursive<'a>(
debouncer: &Mutex<Option<Debouncer>>,
dirs: impl Iterator<Item = &'a Path>,
) -> bool {
let mut guard = debouncer.lock();
let Some(debouncer) = guard.as_mut() else {
return false;
}
!filter.is_ignored(path)
}
};
#[inline]
fn is_path_ignored(path: &Path, filter: &IgnoreFilter) -> bool {
filter.is_ignored(path)
for dir in dirs {
if let Err(e) = debouncer.watch(dir, RecursiveMode::NonRecursive) {
warn!(
?e,
dir = %dir.display(),
"Failed to init watcher for new directory"
);
}
}
true
}
struct IgnoreFilter<'a> {
@@ -714,18 +858,22 @@ impl<'a> IgnoreFilter<'a> {
/// Whether `path` (absolute) is ignored.
fn is_ignored(&self, path: &Path) -> bool {
if let Some(rules) = self.rules.as_ref() {
let Ok(rel) = path.strip_prefix(self.base_path) else {
let Ok(relative) = path.strip_prefix(self.base_path) else {
return false;
};
// `IgnoreRules::is_ignored` enumerates every ancestor .gitignore
// layer internally, so a leaf under an ignored directory (rule
// `build/`, path `build/out.rs`) is caught in one call.
return rules.is_ignored(rel);
return rules.is_ignored(relative);
}
match self.repo {
Some(repo) => repo.is_path_ignored(path) == Ok(true),
// No repo and no rules: fall back to the non-code-dir heuristic.
None => crate::ignore::is_non_code_directory(path),
// No repo and no rules: the non-code-dir heuristic, applied to the
// base-relative path so ancestors of the base (e.g. a temp dir
// under AppData/Local on Windows) never match.
None => crate::ignore::is_non_code_directory(
path.strip_prefix(self.base_path).unwrap_or(path),
),
}
}
}
@@ -759,6 +907,13 @@ fn is_dotgit_change_affecting_status(changed: &Path, repo: &Option<Repository>)
return true;
}
// some of the git ops are not involving nethier index nor HEAD change, or sometimes
// index updates can arrive too late after the change - that's why we track the log
// the actual user action, once user
if path_in_git_dir == Path::new("logs/HEAD") {
return true;
}
if let Some(fname) = path_in_git_dir.file_name().and_then(|f| f.to_str())
&& matches!(fname, "MERGE_HEAD" | "CHERRY_PICK_HEAD" | "REVERT_HEAD")
{
@@ -785,4 +940,132 @@ fn watch_git_status_paths(debouncer: &mut Debouncer, git_workdir: Option<&PathBu
if let Err(e) = debouncer.watch(&git_dir, RecursiveMode::NonRecursive) {
warn!("Failed to watch .git directory: {}", e);
}
// `.git` above is non-recursive, so on Linux (per-dir inotify watches)
// events for `logs/HEAD` — the commit-finished signal used by
// `is_dotgit_change_affecting_status` — would never be delivered without
// watching `.git/logs` itself. On macOS/Windows the recursive base watch
// already covers it; an extra watch is harmless there.
let logs_dir = git_dir.join("logs");
if logs_dir.is_dir()
&& let Err(e) = debouncer.watch(&logs_dir, RecursiveMode::NonRecursive)
{
warn!("Failed to watch .git/logs directory: {}", e);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::file_picker::{FilePicker, FilePickerOptions};
use crate::watch::{WatchEvent, WatchOptions};
use notify::Event;
use notify::event::{CreateKind, DataChange, ModifyKind, RemoveKind};
use std::sync::mpsc;
use std::time::{Duration, Instant};
#[test]
fn replacement_batch_emits_one_modified_event() {
let tmp = tempfile::tempdir().unwrap();
let base = crate::path_utils::canonicalize(tmp.path()).unwrap();
let path = base.join("file.txt");
std::fs::write(&path, "before").unwrap();
let shared_picker = SharedFilePicker::default();
let shared_frecency = SharedFrecency::noop();
let mut picker = FilePicker::new(FilePickerOptions {
base_path: base.to_string_lossy().into_owned(),
watch: false,
..Default::default()
})
.unwrap();
picker.collect_files().unwrap();
shared_picker.rebase_watches(&base);
*shared_picker.write().unwrap() = Some(picker);
let (sender, receiver) = mpsc::channel::<Vec<WatchEvent>>();
shared_picker
.watch_registry()
.subscribe(
&base,
"**",
WatchOptions::default(),
Box::new(move |_, events| sender.send(events.to_vec()).unwrap()),
)
.unwrap();
std::fs::write(&path, "after").unwrap();
let now = Instant::now();
let events = vec![
DebouncedEvent::new(
Event::new(EventKind::Remove(RemoveKind::File)).add_path(path.clone()),
now,
),
DebouncedEvent::new(
Event::new(EventKind::Create(CreateKind::File)).add_path(path.clone()),
now,
),
DebouncedEvent::new(
Event::new(EventKind::Modify(ModifyKind::Data(DataChange::Content)))
.add_path(path.clone()),
now,
),
];
handle_debounced_events(
FFFMode::Neovim,
events,
&base,
&None,
&shared_picker,
&shared_frecency,
&GitStatusWorker::new(),
);
let received = receiver.recv_timeout(Duration::from_secs(1)).unwrap();
assert_eq!(received.len(), 1);
assert_eq!(received[0].path, path);
assert_eq!(received[0].kind, WatchEventKind::Modified);
}
#[test]
fn dotgit_status_filter_matches_worktree_state_changes() {
let tmp = tempfile::tempdir().unwrap();
let repo = git2::Repository::init(tmp.path()).unwrap();
let git_dir = repo.path().to_path_buf();
let repo = Some(repo);
let affecting = ["index", "index.lock", "HEAD", "logs/HEAD", "MERGE_HEAD"];
for p in affecting {
assert!(
is_dotgit_change_affecting_status(&git_dir.join(p), &repo),
"{p} must trigger a git status rescan"
);
}
// Ref-only updates (fetch/push/tags) and commit scratch files must not.
let non_affecting = [
"refs/heads/main",
"refs/heads/main.lock",
"logs/refs/remotes/origin/main",
"COMMIT_EDITMSG",
"packed-refs",
];
for p in non_affecting {
assert!(
!is_dotgit_change_affecting_status(&git_dir.join(p), &repo),
"{p} must NOT trigger a git status rescan"
);
}
// Worktree paths outside .git never match.
assert!(!is_dotgit_change_affecting_status(
&tmp.path().join("src/main.rs"),
&repo
));
assert!(!is_dotgit_change_affecting_status(
&git_dir.join("index"),
&None
));
}
}
+9
View File
@@ -0,0 +1,9 @@
mod background_watcher;
pub use background_watcher::*;
mod watch;
pub use watch::*;
// The harness reads rescan counters, which release builds compile out.
#[cfg(all(test, rescan_stats))]
mod rescan_tests;
+621
View File
@@ -0,0 +1,621 @@
use std::path::PathBuf;
use std::process::Command;
use std::sync::Arc;
use std::time::{Duration, Instant};
use notify::Event;
use notify::EventKind;
use notify::event::{
AccessKind, AccessMode, CreateKind, DataChange, Flag, ModifyKind, RemoveKind, RenameMode,
};
use notify_debouncer_full::DebouncedEvent;
use tempfile::TempDir;
use super::handle_debounced_events;
use crate::constants::MAX_OVERFLOW_FILES;
use crate::file_picker::{FFFMode, FilePicker, FilePickerOptions};
use crate::git_status_worker::GitStatusWorker;
use crate::rescan_stats::{RescanReason, RescanStats};
use crate::shared::{SharedFilePicker, SharedFrecency};
#[test]
fn saving_an_indexed_file_stays_incremental() {
let f = Fixture::new();
f.write("src/main.rs", "fn main() {}");
f.index();
f.write("src/main.rs", "fn main() { println!(); }");
let delta = f.feed([modify(f.path("src/main.rs"))]);
f.assert_no_rescan(&delta, "saving a tracked file");
}
#[test]
fn editor_atomic_save_stays_incremental() {
let f = Fixture::new();
f.write("src/main.rs", "fn main() {}");
f.index();
// write-to-temp + rename-over-target, the way vim/VSCode/IntelliJ save.
f.write("src/main.rs", "fn main() { println!(); }");
let target = f.path("src/main.rs");
let temp = f.path("src/.main.rs.swp");
let delta = f.feed([
DebouncedEvent::new(
Event::new(EventKind::Create(CreateKind::File)).add_path(temp.clone()),
Instant::now(),
),
DebouncedEvent::new(
Event::new(EventKind::Modify(ModifyKind::Name(RenameMode::From)))
.add_path(temp.clone()),
Instant::now(),
),
DebouncedEvent::new(
Event::new(EventKind::Modify(ModifyKind::Name(RenameMode::To)))
.add_path(target.clone()),
Instant::now(),
),
DebouncedEvent::new(
Event::new(EventKind::Remove(RemoveKind::File)).add_path(temp),
Instant::now(),
),
]);
f.assert_no_rescan(&delta, "an atomic editor save");
assert!(f.is_indexed("src/main.rs"), "target must stay indexed");
}
#[test]
fn creating_and_deleting_files_stays_incremental() {
let f = Fixture::new();
f.write("src/main.rs", "fn main() {}");
f.index();
f.write("src/added.rs", "pub fn added() {}");
let created = f.feed([create(f.path("src/added.rs"))]);
f.assert_no_rescan(&created, "creating a file");
assert!(f.is_indexed("src/added.rs"));
f.remove("src/added.rs");
let removed = f.feed([remove_file(f.path("src/added.rs"))]);
f.assert_no_rescan(&removed, "deleting a file");
assert!(!f.is_indexed("src/added.rs"));
}
#[test]
fn deleting_a_directory_stays_incremental() {
let f = Fixture::new();
f.write("src/main.rs", "fn main() {}");
f.write("src/nested/a.rs", "");
f.write("src/nested/b.rs", "");
f.index();
std::fs::remove_dir_all(f.path("src/nested")).unwrap();
let delta = f.feed([DebouncedEvent::new(
Event::new(EventKind::Remove(RemoveKind::Folder)).add_path(f.path("src/nested")),
Instant::now(),
)]);
f.assert_no_rescan(&delta, "deleting a directory");
assert!(!f.is_indexed("src/nested/a.rs"));
assert!(f.is_indexed("src/main.rs"));
}
#[test]
fn read_only_access_events_are_ignored() {
let f = Fixture::new();
f.write("src/main.rs", "fn main() {}");
f.index();
// fff's own preview + grep reads generate these; reacting to them would
// make the picker rescan whenever the user scrolls the result list.
let path = f.path("src/main.rs");
let delta = f.feed([
DebouncedEvent::new(
Event::new(EventKind::Access(AccessKind::Read)).add_path(path.clone()),
Instant::now(),
),
DebouncedEvent::new(
Event::new(EventKind::Access(AccessKind::Open(AccessMode::Read)))
.add_path(path.clone()),
Instant::now(),
),
DebouncedEvent::new(
Event::new(EventKind::Access(AccessKind::Close(AccessMode::Read))).add_path(path),
Instant::now(),
),
]);
f.assert_no_rescan(&delta, "read-only access events");
}
#[test]
fn recreating_the_same_paths_does_not_consume_overflow_capacity() {
let f = Fixture::new();
f.write("src/main.rs", "fn main() {}");
f.index();
// Recreated paths must reuse their overflow slots.
for _ in 0..8 {
for i in 0..200 {
let rel = format!("gen/out{i}.rs");
f.write(&rel, "generated");
f.feed([create(f.path(&rel))]);
}
for i in 0..200 {
let rel = format!("gen/out{i}.rs");
f.remove(&rel);
f.feed([remove_file(f.path(&rel))]);
}
}
let delta = f.all_rescans();
f.assert_no_rescan(&delta, "1600 create/delete cycles over 200 stable paths");
assert!(
f.overflow_len() <= 200,
"each path must claim one overflow slot at most, got {}",
f.overflow_len()
);
}
#[test]
fn writes_inside_a_gitignored_directory_stay_incremental() {
let f = Fixture::with_git();
f.write(".gitignore", "target/\nnode_modules/\n");
f.write("src/main.rs", "fn main() {}");
f.index();
let mut events = Vec::new();
for i in 0..64 {
let rel = format!("target/debug/artifact{i}.o");
f.write(&rel, "binary");
events.push(create(f.path(&rel)));
}
let delta = f.feed(events);
f.assert_no_rescan(&delta, "build output written into an ignored directory");
}
#[test]
fn ignored_event_batch_above_index_capacity_stays_incremental() {
let f = Fixture::with_git();
f.write(".gitignore", "node_modules/\n");
f.write("src/main.rs", "fn main() {}");
f.index();
let events = (0..MAX_OVERFLOW_FILES + 1)
.map(|i| {
let rel = format!("node_modules/pkg/file{i}.js");
f.write(&rel, "");
create(f.path(&rel))
})
.collect::<Vec<_>>();
let delta = f.feed(events);
f.assert_no_rescan(&delta, "ignored events above the index capacity");
assert_eq!(f.overflow_len(), 0);
}
#[test]
fn repeated_edits_above_index_capacity_stay_incremental() {
let f = Fixture::new();
f.write("src/main.rs", "fn main() {}");
f.index();
let path = f.path("src/main.rs");
let events = (0..MAX_OVERFLOW_FILES + 1)
.map(|_| modify(path.clone()))
.collect::<Vec<_>>();
let delta = f.feed(events);
f.assert_no_rescan(&delta, "repeated edits above the index capacity");
assert_eq!(f.overflow_len(), 0);
}
#[test]
fn ignore_file_inside_an_ignored_directory_stays_incremental() {
let f = Fixture::with_git();
f.write(".gitignore", "node_modules/\n");
f.write("src/main.rs", "fn main() {}");
f.index();
let ignore_files =
["left-pad", "lodash", "typescript"].map(|pkg| format!("node_modules/{pkg}/.gitignore"));
for rel in &ignore_files {
f.write(rel, "dist\n");
}
let delta = f.feed(ignore_files.iter().map(|rel| create(f.path(rel))));
f.assert_no_rescan(&delta, "creating ignored .gitignore files");
for rel in &ignore_files {
f.write(rel, "build\n");
}
let delta = f.feed(ignore_files.iter().map(|rel| modify(f.path(rel))));
f.assert_no_rescan(&delta, "modifying ignored .gitignore files");
for rel in &ignore_files {
f.remove(rel);
}
let delta = f.feed(ignore_files.iter().map(|rel| remove_file(f.path(rel))));
f.assert_no_rescan(&delta, "removing ignored .gitignore files");
}
#[test]
fn ignore_file_inside_an_indexed_directory_triggers_a_rescan() {
let f = Fixture::with_git();
f.write("src/.gitignore", ".gitignore\ngenerated/\n");
f.write("src/main.rs", "fn main() {}");
f.index();
f.write("src/.gitignore", ".gitignore\ngenerated/\nbuild/\n");
let delta = f.feed([modify(f.path("src/.gitignore"))]);
assert_eq!(delta.count(RescanReason::IgnoreFileChanged), 1);
}
#[test]
fn git_internal_churn_stays_incremental() {
let f = Fixture::with_git();
f.write("src/main.rs", "fn main() {}");
f.index();
let git_dir = f.path(".git");
let delta = f.feed([
create(git_dir.join("index.lock")),
modify(git_dir.join("index")),
remove_file(git_dir.join("index.lock")),
modify(git_dir.join("HEAD")),
modify(git_dir.join("logs/HEAD")),
modify(git_dir.join("COMMIT_EDITMSG")),
modify(git_dir.join("refs/heads/main")),
]);
f.assert_no_rescan(&delta, "git writing its own metadata");
}
#[test]
fn changing_the_root_ignore_file_triggers_a_rescan() {
let f = Fixture::with_git();
f.write(".gitignore", "target/\n");
f.write("src/main.rs", "fn main() {}");
f.index();
f.write(".gitignore", "target/\nsrc/\n");
let delta = f.feed([modify(f.path(".gitignore"))]);
assert_eq!(
delta.count(RescanReason::IgnoreFileChanged),
1,
"the indexed set depends on the root ignore rules, got {delta}"
);
}
#[test]
fn kernel_event_loss_on_a_directory_triggers_a_rescan() {
let f = Fixture::new();
f.write("src/main.rs", "fn main() {}");
f.index();
let delta = f.feed([DebouncedEvent::new(
Event::new(EventKind::Modify(ModifyKind::Any))
.add_path(f.path("src"))
.set_flag(Flag::Rescan),
Instant::now(),
)]);
assert_eq!(
delta.count(RescanReason::KernelEventLoss),
1,
"a dropped-events flag over a directory means unknown subtree state, got {delta}"
);
}
#[test]
fn new_files_above_index_capacity_trigger_a_rescan() {
let f = Fixture::new();
f.write("src/main.rs", "fn main() {}");
f.index();
let events = (0..MAX_OVERFLOW_FILES + 1)
.map(|i| {
let rel = format!("src/bulk{i}.rs");
f.write(&rel, "");
create(f.path(&rel))
})
.collect::<Vec<_>>();
let delta = f.feed(events);
assert_eq!(
delta.count(RescanReason::IndexUpdateRejected),
1,
"new files above the overflow region cannot be applied incrementally, got {delta}"
);
}
#[test]
fn batch_at_the_overflow_boundary_stays_incremental() {
let f = Fixture::new();
f.write("src/main.rs", "fn main() {}");
f.index();
let events = (0..MAX_OVERFLOW_FILES)
.map(|i| {
let rel = format!("src/bulk{i}.rs");
f.write(&rel, "");
create(f.path(&rel))
})
.collect::<Vec<_>>();
let delta = f.feed(events);
f.assert_no_rescan(&delta, "a batch exactly at the overflow limit");
}
#[test]
fn event_batch_at_four_times_index_capacity_stays_incremental() {
let f = Fixture::new();
f.write("src/main.rs", "fn main() {}");
f.index();
let path = f.path("src/main.rs");
let events = (0..MAX_OVERFLOW_FILES * 4)
.map(|_| modify(path.clone()))
.collect::<Vec<_>>();
let delta = f.feed(events);
f.assert_no_rescan(&delta, "an event batch exactly at the event limit");
}
#[test]
fn event_batch_above_four_times_index_capacity_triggers_a_rescan() {
let f = Fixture::new();
f.write("src/main.rs", "fn main() {}");
f.index();
let path = f.path("src/main.rs");
let events = (0..MAX_OVERFLOW_FILES * 4 + 1)
.map(|_| modify(path.clone()))
.collect::<Vec<_>>();
let delta = f.feed(events);
assert_eq!(
delta.count(RescanReason::EventBatchOverflow),
1,
"an event batch above four times the index capacity must rescan, got {delta}"
);
}
#[test]
fn repeated_triggers_inside_the_cooldown_collapse_to_one_rescan() {
let f = Fixture::with_git();
f.write(".gitignore", "target/\n");
f.write("src/main.rs", "fn main() {}");
f.index();
// Repeated batches during the cooldown must share one walk.
for round in 0..50 {
f.write(".gitignore", &format!("target/\n# round {round}\n"));
f.feed([modify(f.path(".gitignore"))]);
}
let stats = f.all_rescans();
assert_eq!(
stats.total, 1,
"50 triggers inside the cooldown must collapse to a single walk, got {stats}"
);
assert_eq!(
stats.throttled, 49,
"every suppressed request must be accounted for, got {stats}"
);
}
#[test]
fn an_explicit_request_is_never_throttled() {
let f = Fixture::with_git();
f.write(".gitignore", "target/\n");
f.write("src/main.rs", "fn main() {}");
f.index();
// Burn the cooldown with a watcher trigger, then confirm a user-initiated
// refresh still goes through.
f.write(".gitignore", "target/\nsrc/\n");
f.feed([modify(f.path(".gitignore"))]);
for _ in 0..3 {
f.picker.trigger_full_rescan_async(&f.frecency).unwrap();
}
let stats = f.all_rescans();
assert_eq!(
stats.count(RescanReason::Explicit),
3,
"explicit refreshes must bypass the throttle, got {stats}"
);
assert_eq!(stats.count_throttled(RescanReason::Explicit), 0);
}
#[test]
fn events_after_a_suppressed_kernel_rescan_are_still_applied() {
let f = Fixture::new();
f.write("src/main.rs", "fn main() {}");
f.index();
f.write("src/added.rs", "pub fn added() {}");
let delta = f.feed([
DebouncedEvent::new(
Event::new(EventKind::Modify(ModifyKind::Data(DataChange::Content)))
.add_path(f.path("src/main.rs"))
.set_flag(Flag::Rescan),
Instant::now(),
),
create(f.path("src/added.rs")),
]);
f.assert_no_rescan(&delta, "a dropped-events flag over a single tracked file");
assert!(
f.is_indexed("src/added.rs"),
"suppressing the rescan must not drop the rest of the batch"
);
}
#[test]
fn a_throttled_ignore_file_event_is_still_applied_incrementally() {
let f = Fixture::with_git();
f.write(".gitignore", "target/\n");
f.write("src/main.rs", "fn main() {}");
f.index();
// Burn the cooldown: deleting .gitignore admits a full rescan.
f.remove(".gitignore");
let delta = f.feed([remove_file(f.path(".gitignore"))]);
assert_eq!(delta.count(RescanReason::IgnoreFileChanged), 1);
f.picker.wait_for_indexing_complete(Duration::from_secs(10));
// Recreating it inside the cooldown throttles the rescan, but the file
// itself must re-enter the index via the incremental fallback.
f.write(".gitignore", "target/\n__ignored_x/\n");
let delta = f.feed([create(f.path(".gitignore"))]);
assert_eq!(delta.total, 0, "the rescan must be throttled, got {delta}");
assert_eq!(delta.count_throttled(RescanReason::IgnoreFileChanged), 1);
assert!(
f.is_indexed(".gitignore"),
"a throttled ignore-file event must still index the file itself"
);
}
struct Fixture {
base: PathBuf,
picker: SharedFilePicker,
frecency: SharedFrecency,
git_workdir: Option<PathBuf>,
git_worker: Arc<GitStatusWorker>,
// Dropped last so background work started by a triggered rescan still
// sees the tree it was asked to walk.
_tmp: TempDir,
}
impl Fixture {
fn new() -> Self {
Self::build(false)
}
fn with_git() -> Self {
Self::build(true)
}
fn build(git: bool) -> Self {
let tmp = tempfile::tempdir().unwrap();
let base = crate::path_utils::canonicalize(tmp.path()).unwrap();
let git_workdir = git.then(|| {
let status = Command::new("git")
.args(["init", "-b", "main"])
.current_dir(&base)
.output()
.expect("git init");
assert!(status.status.success(), "git init failed");
base.clone()
});
Self {
base,
picker: SharedFilePicker::default(),
frecency: SharedFrecency::noop(),
git_workdir,
git_worker: GitStatusWorker::new(),
_tmp: tmp,
}
}
fn index(&self) {
let mut picker = FilePicker::new(FilePickerOptions {
base_path: self.base.to_string_lossy().into_owned(),
watch: false,
..Default::default()
})
.unwrap();
picker.collect_files().unwrap();
self.picker.rebase_watches(&self.base);
*self.picker.write().unwrap() = Some(picker);
}
fn feed(&self, events: impl IntoIterator<Item = DebouncedEvent>) -> RescanStats {
let before = self.picker.rescan_stats();
handle_debounced_events(
FFFMode::Neovim,
events.into_iter().collect(),
&self.base,
&self.git_workdir,
&self.picker,
&self.frecency,
&self.git_worker,
);
self.picker.rescan_stats().since(&before)
}
fn assert_no_rescan(&self, delta: &RescanStats, what: &str) {
assert_eq!(delta.total, 0, "{what} must not trigger a rescan: {delta}");
}
fn path(&self, rel: &str) -> PathBuf {
self.base.join(rel)
}
fn write(&self, rel: &str, contents: &str) {
let path = self.path(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, contents).unwrap();
}
fn remove(&self, rel: &str) {
std::fs::remove_file(self.path(rel)).unwrap();
}
fn is_indexed(&self, rel: &str) -> bool {
let guard = self.picker.read().unwrap();
guard
.as_ref()
.and_then(|p| p.get_file_by_path(self.path(rel)))
.is_some_and(|file| !file.is_deleted())
}
fn all_rescans(&self) -> RescanStats {
self.picker.rescan_stats()
}
fn overflow_len(&self) -> usize {
let guard = self.picker.read().unwrap();
guard
.as_ref()
.map(|p| p.get_overflow_files().len())
.unwrap_or(0)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
// A test that intentionally triggers a rescan leaves a walk running on
// the background pool; let it finish before the tree disappears.
self.picker
.wait_for_indexing_complete(Duration::from_secs(10));
}
}
fn event(kind: EventKind, path: PathBuf) -> DebouncedEvent {
DebouncedEvent::new(Event::new(kind).add_path(path), Instant::now())
}
fn create(path: PathBuf) -> DebouncedEvent {
event(EventKind::Create(CreateKind::File), path)
}
fn modify(path: PathBuf) -> DebouncedEvent {
event(
EventKind::Modify(ModifyKind::Data(DataChange::Content)),
path,
)
}
fn remove_file(path: PathBuf) -> DebouncedEvent {
event(EventKind::Remove(RemoveKind::File), path)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,301 @@
use fff_search::file_picker::{FFFMode, FilePicker};
use fff_search::{
DirSearchConfig, FilePickerOptions, FuzzySearchOptions, PaginationArgs, QueryParser,
SharedFilePicker, SharedFrecency,
};
use std::fs;
use std::path::Path;
use std::time::{Duration, Instant};
use tempfile::TempDir;
fn make_watched_picker(base: &Path) -> (SharedFilePicker, SharedFrecency) {
let shared_picker = SharedFilePicker::default();
let shared_frecency = SharedFrecency::noop();
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().into_owned(),
enable_mmap_cache: false,
enable_content_indexing: false,
mode: FFFMode::Neovim,
watch: true,
..Default::default()
},
)
.expect("FilePicker::new_with_shared_state");
assert!(
shared_picker.wait_for_scan(Duration::from_secs(30)),
"initial scan did not complete"
);
assert!(
shared_picker.wait_for_watcher(Duration::from_secs(30)),
"watcher did not install"
);
// macOS FSEvents streams need a beat before they deliver reliably
std::thread::sleep(Duration::from_millis(300));
(shared_picker, shared_frecency)
}
fn search_dirs(picker: &SharedFilePicker, query: &str) -> Vec<String> {
let guard = picker.read().expect("picker read lock");
let p = guard.as_ref().expect("picker initialized");
let parser = QueryParser::new(DirSearchConfig);
let parsed = parser.parse(query);
let results = p.fuzzy_search_directories(
&parsed,
FuzzySearchOptions {
pagination: PaginationArgs {
offset: 0,
limit: 100,
},
..Default::default()
},
);
results.items.iter().map(|d| d.relative_path(p)).collect()
}
fn wait_until<F: Fn() -> bool>(cond: F, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if cond() {
return true;
}
std::thread::sleep(Duration::from_millis(50));
}
cond()
}
#[test]
fn removed_directory_disappears_from_dir_search() {
let tmp = TempDir::new().unwrap();
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
fs::create_dir_all(base.join("doomed/nested")).unwrap();
fs::write(base.join("doomed/a.rs"), "x").unwrap();
fs::write(base.join("doomed/nested/b.rs"), "x").unwrap();
fs::write(base.join("keep.rs"), "x").unwrap();
let (picker, _frecency) = make_watched_picker(&base);
assert!(
search_dirs(&picker, "doomed")
.iter()
.any(|d| d.starts_with("doomed")),
"sanity: dir indexed after scan"
);
fs::remove_dir_all(base.join("doomed")).unwrap();
assert!(
wait_until(
|| !search_dirs(&picker, "doomed")
.iter()
.any(|d| d.starts_with("doomed")),
Duration::from_secs(10)
),
"removed dir must disappear from dir search, got: {:?}",
search_dirs(&picker, "doomed")
);
}
#[test]
fn moved_out_directory_disappears_from_dir_search() {
let tmp = TempDir::new().unwrap();
let trash = TempDir::new().unwrap();
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
fs::create_dir_all(base.join("doomed/nested")).unwrap();
fs::write(base.join("doomed/a.rs"), "x").unwrap();
fs::write(base.join("doomed/nested/b.rs"), "x").unwrap();
fs::write(base.join("keep.rs"), "x").unwrap();
let (picker, _frecency) = make_watched_picker(&base);
assert!(
search_dirs(&picker, "doomed")
.iter()
.any(|d| d.starts_with("doomed")),
"sanity: dir indexed after scan"
);
fs::rename(base.join("doomed"), trash.path().join("doomed")).unwrap();
assert!(
wait_until(
|| !search_dirs(&picker, "doomed")
.iter()
.any(|d| d.starts_with("doomed")),
Duration::from_secs(10)
),
"moved-out dir must disappear from dir search, got: {:?}",
search_dirs(&picker, "doomed")
);
}
#[test]
fn moved_in_directory_appears_in_dir_search() {
let tmp = TempDir::new().unwrap();
let staging = TempDir::new().unwrap();
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
fs::write(base.join("keep.rs"), "x").unwrap();
let incoming = staging.path().join("arrived");
fs::create_dir_all(incoming.join("nested")).unwrap();
fs::write(incoming.join("a.rs"), "x").unwrap();
fs::write(incoming.join("nested/b.rs"), "x").unwrap();
let (picker, _frecency) = make_watched_picker(&base);
assert!(search_dirs(&picker, "arrived").is_empty(), "sanity");
fs::rename(&incoming, base.join("arrived")).unwrap();
assert!(
wait_until(
|| {
let dirs = search_dirs(&picker, "arrived");
dirs.iter().any(|d| d.starts_with("arrived"))
},
Duration::from_secs(10)
),
"moved-in dir must appear in dir search, got: {:?}",
search_dirs(&picker, "arrived")
);
}
#[test]
fn new_file_in_new_directory_surfaces_the_dir() {
let tmp = TempDir::new().unwrap();
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
fs::write(base.join("keep.rs"), "x").unwrap();
let (picker, _frecency) = make_watched_picker(&base);
assert!(search_dirs(&picker, "brandnew").is_empty(), "sanity");
fs::create_dir_all(base.join("brandnew")).unwrap();
fs::write(base.join("brandnew/file.rs"), "x").unwrap();
assert!(
wait_until(
|| search_dirs(&picker, "brandnew")
.iter()
.any(|d| d.starts_with("brandnew")),
Duration::from_secs(10)
),
"new dir must appear in dir search, got: {:?}",
search_dirs(&picker, "brandnew")
);
}
#[test]
fn deleting_last_file_keeps_directory_visible() {
let tmp = TempDir::new().unwrap();
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
fs::create_dir_all(base.join("lonely")).unwrap();
fs::write(base.join("lonely/only.rs"), "x").unwrap();
fs::write(base.join("keep.rs"), "x").unwrap();
let (picker, _frecency) = make_watched_picker(&base);
// the file goes away but the directory itself still exists on disk
fs::remove_file(base.join("lonely/only.rs")).unwrap();
assert!(
wait_until(
|| {
let guard = picker.read().unwrap();
let p = guard.as_ref().unwrap();
p.get_file_by_path(base.join("lonely/only.rs"))
.is_none_or(|f| f.is_deleted())
},
Duration::from_secs(10)
),
"file removal must be applied"
);
assert!(
search_dirs(&picker, "lonely")
.iter()
.any(|d| d.starts_with("lonely")),
"dir still exists on disk and must stay searchable"
);
}
#[test]
fn recreated_directory_reappears_in_dir_search() {
let tmp = TempDir::new().unwrap();
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
fs::create_dir_all(base.join("phoenix")).unwrap();
fs::write(base.join("phoenix/a.rs"), "x").unwrap();
fs::write(base.join("keep.rs"), "x").unwrap();
let (picker, _frecency) = make_watched_picker(&base);
fs::remove_dir_all(base.join("phoenix")).unwrap();
assert!(
wait_until(
|| !search_dirs(&picker, "phoenix")
.iter()
.any(|d| d.starts_with("phoenix")),
Duration::from_secs(10)
),
"dir must disappear after removal"
);
fs::create_dir_all(base.join("phoenix")).unwrap();
fs::write(base.join("phoenix/a.rs"), "x").unwrap();
assert!(
wait_until(
|| search_dirs(&picker, "phoenix")
.iter()
.any(|d| d.starts_with("phoenix")),
Duration::from_secs(10)
),
"recreated dir must reappear in dir search, got: {:?}",
search_dirs(&picker, "phoenix")
);
}
/// Regression for #725: a dir that is EMPTY at scan time must be indexed —
/// searchable in dir search and watched so later file creations are seen.
#[test]
fn empty_directory_at_scan_is_searchable_and_watched() {
let tmp = TempDir::new().unwrap();
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
fs::create_dir_all(base.join("commands")).unwrap();
fs::write(base.join("keep.rs"), "x").unwrap();
let (picker, _frecency) = make_watched_picker(&base);
assert!(
search_dirs(&picker, "commands")
.iter()
.any(|d| d.starts_with("commands")),
"empty dir must be searchable right after the scan, got: {:?}",
search_dirs(&picker, "commands")
);
// The empty dir must reuse its scan-built DirItem when a file lands in it
// and the watcher must have registered a watch on it (the #725 repro).
fs::write(base.join("commands/review.md"), "# review").unwrap();
assert!(
wait_until(
|| {
let guard = picker.read().unwrap();
let p = guard.as_ref().unwrap();
p.get_file_by_path(base.join("commands/review.md"))
.is_some()
},
Duration::from_secs(10)
),
"file created in a scan-time-empty dir must be indexed"
);
let guard = picker.read().unwrap();
let p = guard.as_ref().unwrap();
let commands_dirs = p
.get_dirs()
.iter()
.filter(|d| d.relative_path(p).starts_with("commands"))
.count();
assert_eq!(commands_dirs, 1, "no duplicate DirItem for the empty dir");
}
@@ -773,7 +773,7 @@ fn read_truth_status(base: &Path) -> BTreeMap<String, Status> {
let mut out = BTreeMap::new();
for entry in statuses.iter() {
if let Some(p) = entry.path() {
if let Ok(p) = entry.path() {
// git2 returns forward-slash paths; accept as-is.
out.insert(p.to_string(), entry.status());
}
@@ -1258,7 +1258,7 @@ fn get_baseline_status_from_git(base: &Path) -> Vec<Live> {
Err(_) => return out,
};
for entry in statuses.iter() {
if let Some(p) = entry.path() {
if let Ok(p) = entry.path() {
let abs = base.join(p);
// Must be a real file *right now* — ignore stale WT_DELETED rows.
if abs.is_file() {
+61
View File
@@ -1835,3 +1835,64 @@ fn plain_text_smart_case_finds_uppercase_content_with_lowercase_query() {
"lowercase query should case-insensitively match 'VFIO-KVM'"
);
}
/// Bug pinning: `!=` was parsed as a Not("=") exclusion constraint, dropping it
/// from the needle. Operator tokens must stay literal search text.
#[test]
fn plain_text_not_equals_operator_is_literal() {
let tmp = TempDir::new().unwrap();
let picker = create_picker(
tmp.path(),
&[(
"watch.rs",
"if delivery.sub.epoch.load(Ordering::Acquire) != delivery.epoch {\n",
)],
);
let parsed = parse_grep_query("Ordering::Acquire) != delivery.epoch");
let result = picker.grep(&parsed, &plain_opts());
assert_eq!(
result.matches.len(),
1,
"operator `!=` must match literally"
);
assert!(!result.literal_fallback, "no fallback should be needed");
assert!(result.matches[0].line_content.contains("!= delivery.epoch"));
}
#[test]
fn literal_fallback_when_constraints_find_nothing() {
let tmp = TempDir::new().unwrap();
let picker = create_picker(tmp.path(), &[("a.txt", "foo !bar_baz qux\n")]);
// `!bar_baz` becomes Not(Text) so the constrained needle is "foo qux" → no
// match; the search must retry the raw query as literal text.
let parsed = parse_grep_query("foo !bar_baz qux");
let result = picker.grep(&parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
assert!(
result.literal_fallback,
"literal fallback should be flagged"
);
assert!(result.matches[0].line_content.contains("!bar_baz"));
}
#[test]
fn literal_fallback_not_triggered_when_constraints_match() {
let tmp = TempDir::new().unwrap();
let picker = create_picker(
tmp.path(),
&[
("src/lib.rs", "needle here\n"),
("test/lib.rs", "needle here\n"),
],
);
let parsed = parse_grep_query("needle !test");
let result = picker.grep(&parsed, &plain_opts());
assert_eq!(result.matches.len(), 1, "exclusion should still apply");
assert!(!result.literal_fallback);
}
+123
View File
@@ -0,0 +1,123 @@
//! One process must be able to hold many trackers over the same LMDB path
//! (issues #700/#760): they share a single pooled env instead of failing
//! with `EnvAlreadyOpened`.
use std::path::{Path, PathBuf};
use fff_search::frecency::FrecencyTracker;
use fff_search::query_tracker::QueryTracker;
use fff_search::shared::SharedFrecency;
fn unique_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("fff-env-pool-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
#[test]
fn same_path_trackers_share_one_env() {
let dir = unique_dir("share");
let file = Path::new("/virtual/env-pool/shared.rs");
let a = FrecencyTracker::open(&dir).expect("first open");
let b = FrecencyTracker::open(&dir).expect("second open in the same process (#700/#760)");
a.track_access(file).expect("write via a");
assert_eq!(b.access_count(file).expect("read via b"), 1);
drop(a);
b.track_access(file)
.expect("b must stay usable after a drops");
assert_eq!(b.access_count(file).unwrap(), 2);
drop(b);
let c = FrecencyTracker::open(&dir).expect("reopen after all handles dropped");
assert_eq!(
c.access_count(file).unwrap(),
2,
"data persisted across reopen"
);
drop(c);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn concurrent_open_and_drop_never_collide() {
let dir = unique_dir("hammer");
let file = Path::new("/virtual/env-pool/hammer.rs");
let mut handles = Vec::new();
for t in 0..8 {
let dir = dir.clone();
handles.push(std::thread::spawn(move || {
for i in 0..100 {
let tracker = FrecencyTracker::open(&dir)
.unwrap_or_else(|e| panic!("thread {t} iteration {i}: {e}"));
if i % 20 == 0 {
tracker.track_access(file).expect("track access");
}
}
}));
}
for handle in handles {
handle.join().expect("no thread may panic");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn different_store_on_same_path_is_rejected_with_clear_error() {
let dir = unique_dir("mismatch");
let _frecency = FrecencyTracker::open(&dir).expect("frecency open");
let err = QueryTracker::open(&dir).expect_err("env options differ, must be rejected");
let msg = err.to_string();
assert!(
msg.contains("frecency") && msg.contains("query"),
"error must name both stores so the user can fix their config, got: {msg}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn destroy_refuses_while_shared_then_succeeds_when_sole() {
let dir = unique_dir("destroy");
let file = Path::new("/virtual/env-pool/destroy.rs");
let shared = SharedFrecency::default();
shared
.init(FrecencyTracker::open(&dir).expect("init open"))
.expect("init");
let other = FrecencyTracker::open(&dir).expect("second handle over the same db");
shared
.destroy()
.expect_err("destroy must refuse while another tracker uses the env");
// Refusal must keep both the files and the shared handle intact.
assert!(
dir.join("data.mdb").exists(),
"db files survive a refused destroy"
);
shared
.read()
.expect("read lock")
.as_ref()
.expect("tracker restored after refused destroy")
.track_access(file)
.expect("shared handle still works");
drop(other);
let removed = shared
.destroy()
.expect("sole-owner destroy succeeds")
.expect("a path was removed");
assert!(
!removed.exists(),
"db dir deleted once nobody shares the env"
);
}
@@ -8,8 +8,9 @@
//! 3. The watcher's event handler detects the directory Create event,
//! collects it, and sends it to the owner thread via `watch_tx`.
//! 4. The owner thread adds a NonRecursive watch on the new directory and
//! does a flat (non-recursive) read_dir to inject files that already
//! exist (race-window coverage).
//! walks its subtree (`index_new_directory`) to inject files that
//! already exist (race-window + burst/mv-in coverage) and to watch
//! nested subdirectories.
//! 5. Files created *after* the watch is established are picked up via
//! normal event delivery.
//!
@@ -79,11 +80,11 @@ fn make_watched_picker(base: &Path) -> (SharedFilePicker, SharedFrecency) {
/// Wait for the initial scan + watcher to be fully ready.
fn wait_ready(shared_picker: &SharedFilePicker) {
assert!(
shared_picker.wait_for_scan(Duration::from_secs(10)),
shared_picker.wait_for_scan(Duration::from_secs(30)),
"Timed out waiting for initial scan"
);
assert!(
shared_picker.wait_for_watcher(Duration::from_secs(10)),
shared_picker.wait_for_watcher(Duration::from_secs(30)),
"Timed out waiting for watcher"
);
}
@@ -469,6 +470,160 @@ fn burst_file_creation_in_new_directory() {
}
}
/// bug pinning #725: a directory that already exists but is EMPTY at
/// initial scan time is absent from `sync_data.dirs` and missing watch events
#[test]
fn file_created_in_preexisting_empty_directory() {
let tmp = TempDir::new().unwrap();
let base = tmp.path().canonicalize().unwrap();
// `commands/` is empty during the initial scan — only `init.lua` is indexed.
fs::create_dir_all(base.join("commands")).unwrap();
fs::write(base.join("init.lua"), "-- init\n").unwrap();
let (shared_picker, _frecency) = make_watched_picker(&base);
wait_ready(&shared_picker);
// Now write a file into the directory that was empty at scan time.
fs::write(
base.join("commands/review.md"),
"# Review\nEMPTY_DIR_REVIEW_TOKEN\n",
)
.unwrap();
let elapsed = poll_until(
&shared_picker,
WATCHER_TIMEOUT,
"file commands/review.md created in a pre-existing empty directory",
|picker| {
picker
.get_files()
.iter()
.any(|f| f.relative_path(picker).contains("review.md"))
},
);
eprintln!(
" File in pre-existing empty directory detected in {:.0}ms",
elapsed.as_secs_f64() * 1000.0
);
}
/// Same as above but with a nested chain of empty directories under an
/// indexed one: every level of the empty subtree must be watched.
#[test]
fn file_created_in_nested_preexisting_empty_directories() {
let tmp = TempDir::new().unwrap();
let base = tmp.path().canonicalize().unwrap();
// `src/` is indexed (has a file); `src/plugins/extra/` is an empty chain.
fs::create_dir_all(base.join("src/plugins/extra")).unwrap();
fs::write(base.join("src/main.rs"), "fn main() {}\n").unwrap();
git_init_and_commit(&base);
let (shared_picker, _frecency) = make_watched_picker(&base);
wait_ready(&shared_picker);
fs::write(
base.join("src/plugins/extra/loader.rs"),
"pub fn load() {}\nconst TOKEN: &str = \"NESTED_EMPTY_DIR_TOKEN\";\n",
)
.unwrap();
let elapsed = poll_until(
&shared_picker,
WATCHER_TIMEOUT,
"file src/plugins/extra/loader.rs created in nested empty directories",
|picker| {
picker
.get_files()
.iter()
.any(|f| f.relative_path(picker).contains("loader.rs"))
},
);
eprintln!(
" File in nested empty directories detected in {:.0}ms",
elapsed.as_secs_f64() * 1000.0
);
poll_until(
&shared_picker,
WATCHER_TIMEOUT,
"grep finds NESTED_EMPTY_DIR_TOKEN",
|picker| grep_plain_count(picker, "NESTED_EMPTY_DIR_TOKEN") >= 1,
);
}
#[test]
fn nested_tree_created_in_one_burst_detected() {
let tmp = TempDir::new().unwrap();
let base = tmp.path().canonicalize().unwrap();
fs::write(base.join("root.txt"), "root file\n").unwrap();
git_init_and_commit(&base);
let (shared_picker, _frecency) = make_watched_picker(&base);
wait_ready(&shared_picker);
// No sleeps between levels: the watcher sees one Create for `pkg` and
// must index the whole subtree from it.
fs::create_dir_all(base.join("pkg/src/nested")).unwrap();
fs::write(base.join("pkg/Cargo.toml"), "[package]\n").unwrap();
fs::write(
base.join("pkg/src/lib.rs"),
"const TOKEN: &str = \"BURST_TREE_LIB_TOKEN\";\n",
)
.unwrap();
fs::write(
base.join("pkg/src/nested/deep.rs"),
"const TOKEN: &str = \"BURST_TREE_DEEP_TOKEN\";\n",
)
.unwrap();
for rel in ["pkg/Cargo.toml", "pkg/src/lib.rs", "pkg/src/nested/deep.rs"] {
let elapsed = poll_until(
&shared_picker,
WATCHER_TIMEOUT,
&format!("burst-created file {rel}"),
|picker| {
picker
.get_files()
.iter()
.any(|f| f.relative_path(picker) == rel)
},
);
eprintln!(
" Burst file {rel} detected in {:.0}ms",
elapsed.as_secs_f64() * 1000.0
);
}
// Files created later at the deepest level need the nested watches too.
fs::write(
base.join("pkg/src/nested/late.rs"),
"const TOKEN: &str = \"BURST_TREE_LATE_TOKEN\";\n",
)
.unwrap();
poll_until(
&shared_picker,
WATCHER_TIMEOUT,
"late file in burst-created nested dir",
|picker| {
picker
.get_files()
.iter()
.any(|f| f.relative_path(picker).ends_with("late.rs"))
},
);
poll_until(
&shared_picker,
WATCHER_TIMEOUT,
"grep finds BURST_TREE_DEEP_TOKEN",
|picker| grep_plain_count(picker, "BURST_TREE_DEEP_TOKEN") >= 1,
);
}
/// Verify that gitignored directories created at runtime are NOT watched
/// and their files do NOT appear in the index.
#[test]
@@ -222,6 +222,17 @@ fn multi_grep_with_file_path_suffix_constraint() {
}
}
#[test]
fn multi_grep_with_missing_file_path_constraint_returns_no_matches() {
let tmp = TempDir::new().unwrap();
let picker = create_picker(tmp.path(), &[("other.lua", "handleRequest\n")]);
let constraints = [Constraint::FilePath("missing.lua")];
let result = picker.multi_grep(&["handleRequest"], &constraints, &plain_opts());
assert!(result.matches.is_empty());
}
/// Glob constraints must match native Windows paths — the picker normalises
/// separators when handing paths to the glob matcher.
#[test]
+343
View File
@@ -0,0 +1,343 @@
#![cfg(rescan_stats)]
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use fff_search::file_picker::{FFFMode, FilePicker};
use fff_search::{FilePickerOptions, RescanStats, SharedFilePicker, SharedFrecency};
use tempfile::TempDir;
const SETTLE: Duration = Duration::from_millis(600);
#[test]
fn saving_source_files_does_not_rescan() {
let repo = WatchedRepo::new(|base| {
write(base, ".gitignore", "target/\n");
for i in 0..20 {
write(base, &format!("src/mod{i}.rs"), "pub fn f() {}");
}
});
for round in 0..10 {
for i in 0..20 {
repo.write(
&format!("src/mod{i}.rs"),
&format!("pub fn f() {{ let _ = {round}; }}"),
);
}
repo.settle();
}
repo.assert_quiet("200 file saves");
}
#[test]
fn build_output_in_ignored_directories_does_not_rescan() {
let repo = WatchedRepo::new(|base| {
write(base, ".gitignore", "target/\nnode_modules/\ndist/\n");
write(base, "src/main.rs", "fn main() {}");
});
for round in 0..4 {
for i in 0..150 {
repo.write(&format!("target/debug/deps/unit-{round}-{i}.o"), "binary");
repo.write(&format!("dist/chunk-{round}-{i}.js"), "bundled");
}
repo.settle();
}
repo.assert_quiet("1200 build artifacts written into ignored directories");
}
#[test]
fn adding_source_files_and_directories_does_not_rescan() {
let repo = WatchedRepo::new(|base| {
write(base, ".gitignore", "target/\n");
write(base, "src/main.rs", "fn main() {}");
});
for i in 0..40 {
repo.write(&format!("src/feature{i}/mod.rs"), "pub mod inner;");
repo.write(&format!("src/feature{i}/inner.rs"), "pub fn go() {}");
}
repo.settle();
assert!(
repo.wait_indexed("src/feature39/inner.rs"),
"watcher must index files in newly created directories"
);
repo.assert_quiet("40 new directories with 80 files");
}
#[test]
fn recreating_generated_files_does_not_rescan() {
let repo = WatchedRepo::new(|base| {
write(base, ".gitignore", "target/\n");
write(base, "src/main.rs", "fn main() {}");
});
// Recreated paths must reuse their overflow slots.
for round in 0..12 {
for i in 0..40 {
repo.write(&format!("src/generated/api{i}.rs"), "pub struct A;");
}
repo.settle();
for i in 0..40 {
repo.remove(&format!("src/generated/api{i}.rs"));
}
repo.settle();
assert!(
repo.overflow_len() <= 64,
"round {round}: regenerating the same paths grew the overflow region to {}",
repo.overflow_len()
);
}
repo.assert_quiet("12 codegen cycles over 40 stable paths");
}
#[test]
fn git_workflow_does_not_rescan() {
let repo = WatchedRepo::new(|base| {
write(base, ".gitignore", "target/\n");
write(base, "src/main.rs", "fn main() {}");
write(base, "src/lib.rs", "pub mod thing;");
git(base, &["init", "-b", "main"]);
git(base, &["add", "-A"]);
git(base, &["commit", "-m", "initial"]);
});
repo.write("src/main.rs", "fn main() { println!(\"hi\"); }");
repo.settle();
repo.git(&["add", "-A"]);
repo.settle();
repo.git(&["commit", "-m", "second"]);
repo.settle();
repo.git(&["checkout", "-b", "feature"]);
repo.settle();
repo.write("src/feature.rs", "pub fn feature() {}");
repo.git(&["add", "-A"]);
repo.git(&["commit", "-m", "feature"]);
repo.settle();
repo.git(&["checkout", "main"]);
repo.settle();
repo.git(&["merge", "feature"]);
repo.settle();
repo.assert_quiet("a commit / branch / merge cycle");
}
#[test]
fn reading_files_does_not_rescan() {
let repo = WatchedRepo::new(|base| {
write(base, ".gitignore", "target/\n");
for i in 0..50 {
write(base, &format!("src/mod{i}.rs"), "pub fn f() {}");
}
});
// Preview rendering and grep open every file in the result list. Reacting
// to those reads would make the picker rescan while the user scrolls.
for _ in 0..5 {
for i in 0..50 {
let _ = std::fs::read(repo.path(&format!("src/mod{i}.rs"))).unwrap();
}
}
repo.settle();
repo.assert_quiet("reading every indexed file");
}
#[test]
fn npm_install_style_churn_does_not_rescan() {
let repo = WatchedRepo::new(|base| {
write(base, ".gitignore", "node_modules/\n");
write(base, "src/index.ts", "export const a = 1;");
});
for pkg in 0..100 {
repo.write(&format!("node_modules/pkg{pkg}/package.json"), "{}");
repo.write(
&format!("node_modules/pkg{pkg}/index.js"),
"module.exports={}",
);
repo.write(&format!("node_modules/pkg{pkg}/.gitignore"), "dist\n");
}
repo.settle();
repo.settle();
repo.assert_quiet("an npm install into an ignored node_modules");
}
#[test]
fn a_churning_root_is_capped_at_one_rescan_per_cooldown() {
let repo = WatchedRepo::new(|base| {
write(base, "src/main.rs", "fn main() {}");
});
// Root ignore changes force watcher rescan requests.
for round in 0..25 {
repo.write(".gitignore", &format!("target/\n# round {round}\n"));
std::thread::sleep(Duration::from_millis(120));
}
repo.settle();
let stats = repo.rescans();
assert!(
stats.total <= 1,
"a churning root must not exceed one walk per cooldown, got {stats}"
);
assert!(
stats.throttled > 0,
"the suppressed triggers must be recorded, got {stats}"
);
}
struct WatchedRepo {
base: PathBuf,
picker: SharedFilePicker,
_frecency: SharedFrecency,
_tmp: TempDir,
}
impl WatchedRepo {
fn new(setup: impl FnOnce(&Path)) -> Self {
let tmp = tempfile::tempdir().unwrap();
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
setup(&base);
let picker = SharedFilePicker::default();
let frecency = SharedFrecency::noop();
FilePicker::new_with_shared_state(
picker.clone(),
frecency.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().into_owned(),
enable_mmap_cache: false,
mode: FFFMode::Neovim,
watch: true,
..Default::default()
},
)
.expect("failed to create file picker");
assert!(
picker.wait_for_scan(Duration::from_secs(60)),
"timed out waiting for the initial scan"
);
assert!(
picker.wait_for_watcher(Duration::from_secs(60)),
"timed out waiting for the watcher"
);
let repo = Self {
base,
picker,
_frecency: frecency,
_tmp: tmp,
};
repo.settle();
repo.picker.reset_rescan_stats();
repo
}
fn settle(&self) {
std::thread::sleep(SETTLE);
assert!(
self.picker
.wait_for_indexing_complete(Duration::from_secs(60)),
"timed out waiting for background indexing to finish"
);
}
fn assert_quiet(&self, workload: &str) {
let stats = self.rescans();
assert_eq!(
stats.watcher_triggered(),
0,
"{workload} must be absorbed incrementally, but the watcher fell back to {stats}"
);
}
fn rescans(&self) -> RescanStats {
self.picker.rescan_stats()
}
fn path(&self, rel: &str) -> PathBuf {
self.base.join(rel)
}
fn write(&self, rel: &str, contents: &str) {
write(&self.base, rel, contents);
}
fn remove(&self, rel: &str) {
std::fs::remove_file(self.path(rel)).unwrap();
}
fn git(&self, args: &[&str]) {
git(&self.base, args);
}
fn wait_indexed(&self, rel: &str) -> bool {
let deadline = std::time::Instant::now() + Duration::from_secs(30);
while std::time::Instant::now() < deadline {
if self.is_indexed(rel) {
return true;
}
std::thread::sleep(Duration::from_millis(50));
}
false
}
fn is_indexed(&self, rel: &str) -> bool {
let guard = self.picker.read().unwrap();
guard
.as_ref()
.and_then(|p| p.get_file_by_path(self.path(rel)))
.is_some_and(|file| !file.is_deleted())
}
fn overflow_len(&self) -> usize {
let guard = self.picker.read().unwrap();
guard
.as_ref()
.map(|p| p.get_overflow_files().len())
.unwrap_or(0)
}
}
impl Drop for WatchedRepo {
fn drop(&mut self) {
// Stop the watcher before the tree disappears, otherwise a late batch
// races the tempdir removal.
if let Ok(mut guard) = self.picker.write() {
guard.take();
}
}
}
fn write(base: &Path, rel: &str, contents: &str) {
let path = base.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, contents).unwrap();
}
fn git(dir: &Path, args: &[&str]) {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.output()
.unwrap_or_else(|e| panic!("git {args:?} failed to spawn: {e}"));
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
@@ -0,0 +1,531 @@
use fff_search::file_picker::{FFFMode, FilePicker};
use fff_search::{
FilePickerOptions, SharedFilePicker, SharedFrecency, WatchEvent, WatchEventKind, WatchOptions,
};
use parking_lot::Mutex;
use std::fs;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use tempfile::TempDir;
fn make_watched_picker(base: &Path) -> (SharedFilePicker, SharedFrecency) {
let shared_picker = SharedFilePicker::default();
let shared_frecency = SharedFrecency::noop();
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().into_owned(),
enable_mmap_cache: false,
enable_content_indexing: false,
mode: FFFMode::Neovim,
watch: true,
..Default::default()
},
)
.expect("FilePicker::new_with_shared_state");
assert!(
shared_picker.wait_for_scan(Duration::from_secs(30)),
"initial scan did not complete"
);
assert!(
shared_picker.wait_for_watcher(Duration::from_secs(30)),
"watcher did not install"
);
// macOS FSEvents streams need a beat before they deliver reliably
std::thread::sleep(Duration::from_millis(300));
(shared_picker, shared_frecency)
}
fn wait_for<F: Fn() -> bool>(cond: F, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if cond() {
return true;
}
std::thread::sleep(Duration::from_millis(25));
}
cond()
}
fn seed(base: &Path) {
fs::create_dir_all(base.join("src")).unwrap();
fs::write(base.join("src/main.rs"), "fn main() {}\n").unwrap();
fs::write(base.join("README.md"), "# seed\n").unwrap();
}
type Collected = Arc<Mutex<Vec<WatchEvent>>>;
/// Subscribe with a collector callback; returns the shared event sink.
fn watch_collect(picker: &SharedFilePicker, pattern: &str, options: WatchOptions) -> Collected {
let collected: Collected = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&collected);
picker
.watch(pattern, options, move |_id, events| {
sink.lock().extend_from_slice(events)
})
.expect("watch subscription failed");
collected
}
#[test]
fn glob_subscription_receives_created_and_removed_events() {
let tmp = TempDir::new().unwrap();
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
seed(&base);
let (picker, _frecency) = make_watched_picker(&base);
let events: Arc<Mutex<Vec<WatchEvent>>> = Arc::new(Mutex::new(Vec::new()));
let calls = Arc::new(AtomicUsize::new(0));
let events_cb = Arc::clone(&events);
let calls_cb = Arc::clone(&calls);
let id = picker
.watch("**/*.rs", WatchOptions::default(), move |_id, batch| {
calls_cb.fetch_add(1, Ordering::SeqCst);
events_cb.lock().extend_from_slice(batch);
})
.expect("subscribe glob");
let rs_file = base.join("src/new_module.rs");
let ts_file = base.join("src/ignored_by_glob.ts");
fs::write(&rs_file, "pub fn hi() {}\n").unwrap();
fs::write(&ts_file, "export {};\n").unwrap();
assert!(
wait_for(
|| events.lock().iter().any(|e| e.path == rs_file),
Duration::from_secs(10)
),
"did not receive event for created .rs file, got: {:?}",
events.lock()
);
assert!(
!events.lock().iter().any(|e| e.path == ts_file),
".ts file must not match the *.rs glob"
);
fs::remove_file(&rs_file).unwrap();
assert!(
wait_for(
|| events
.lock()
.iter()
.any(|e| e.path == rs_file && e.kind == WatchEventKind::Removed),
Duration::from_secs(10)
),
"did not receive Removed event, got: {:?}",
events.lock()
);
// batching: each debounce window is one callback invocation, so the call
// count must be well below the delivered event count + noise ceiling
assert!(calls.load(Ordering::SeqCst) <= events.lock().len() + 2);
assert!(picker.unwatch(id));
let count_after = events.lock().len();
fs::write(base.join("src/after_unsub.rs"), "\n").unwrap();
std::thread::sleep(Duration::from_millis(500));
assert_eq!(
events.lock().len(),
count_after,
"no events after unsubscribe"
);
}
#[test]
fn watch_events_reflect_applied_file_transitions() {
let tmp = TempDir::new().unwrap();
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
let removed_path = base.join("removed.txt");
let created_path = base.join("created.txt");
let replaced_path = base.join("replaced.txt");
fs::write(&removed_path, "remove me").unwrap();
fs::write(&replaced_path, "before").unwrap();
let (picker, _frecency) = make_watched_picker(&base);
let removed = watch_collect(
&picker,
removed_path.to_str().unwrap(),
WatchOptions::default(),
);
let created = watch_collect(
&picker,
created_path.to_str().unwrap(),
WatchOptions::default(),
);
let replaced = watch_collect(
&picker,
replaced_path.to_str().unwrap(),
WatchOptions::default(),
);
fs::remove_file(&removed_path).unwrap();
assert!(
wait_for(|| !removed.lock().is_empty(), Duration::from_secs(10)),
"remove event was not delivered"
);
fs::write(&created_path, "created").unwrap();
assert!(
wait_for(|| !created.lock().is_empty(), Duration::from_secs(10)),
"create event was not delivered"
);
fs::remove_file(&replaced_path).unwrap();
fs::write(&replaced_path, "after").unwrap();
assert!(
wait_for(|| !replaced.lock().is_empty(), Duration::from_secs(10)),
"replacement event was not delivered"
);
std::thread::sleep(Duration::from_millis(300));
let removed = removed.lock();
assert_eq!(removed.len(), 1, "unexpected remove events: {removed:?}");
assert_eq!(removed[0].path, removed_path);
assert_eq!(removed[0].kind, WatchEventKind::Removed);
let created = created.lock();
assert_eq!(created.len(), 1, "unexpected create events: {created:?}");
assert_eq!(created[0].path, created_path);
assert_eq!(created[0].kind, WatchEventKind::Created);
let replaced = replaced.lock();
assert_eq!(
replaced.len(),
1,
"replacement must be one event: {replaced:?}"
);
assert_eq!(replaced[0].path, replaced_path);
assert_eq!(replaced[0].kind, WatchEventKind::Modified);
}
#[test]
fn removed_directory_delivers_removed_event_per_file() {
let tmp = TempDir::new().unwrap();
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
seed(&base);
let dir = base.join("doomed");
fs::create_dir_all(dir.join("nested")).unwrap();
let files = [dir.join("a.rs"), dir.join("b.txt"), dir.join("nested/c.rs")];
for f in &files {
fs::write(f, "content\n").unwrap();
}
let (picker, _frecency) = make_watched_picker(&base);
let events = watch_collect(&picker, "", WatchOptions::default());
fs::remove_dir_all(&dir).unwrap();
assert!(
wait_for(
|| {
let got = events.lock();
files.iter().all(|f| {
got.iter()
.any(|e| e.path == *f && e.kind == WatchEventKind::Removed)
})
},
Duration::from_secs(10)
),
"expected Removed for every file in the removed dir, got: {:?}",
events.lock()
);
}
#[test]
fn moved_out_directory_delivers_removed_event_per_file() {
let tmp = TempDir::new().unwrap();
let trash = TempDir::new().unwrap();
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
seed(&base);
let dir = base.join("doomed");
fs::create_dir_all(dir.join("nested")).unwrap();
let files = [dir.join("a.rs"), dir.join("b.txt"), dir.join("nested/c.rs")];
for f in &files {
fs::write(f, "content\n").unwrap();
}
let (picker, _frecency) = make_watched_picker(&base);
let events = watch_collect(&picker, "", WatchOptions::default());
// mimics `mv dir elsewhere` / Finder trash: one rename event on the dir,
// no per-file remove events from the OS
fs::rename(&dir, trash.path().join("doomed")).unwrap();
assert!(
wait_for(
|| {
let got = events.lock();
files.iter().all(|f| {
got.iter()
.any(|e| e.path == *f && e.kind == WatchEventKind::Removed)
})
},
Duration::from_secs(10)
),
"expected Removed for every file in the moved-out dir, got: {:?}",
events.lock()
);
}
#[test]
fn empty_pattern_watches_the_whole_tree() {
let tmp = TempDir::new().unwrap();
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
seed(&base);
let (picker, _frecency) = make_watched_picker(&base);
let events = watch_collect(
&picker,
"",
WatchOptions {
ignore: vec!["*.log".to_string()],
..Default::default()
},
);
let rs_file = base.join("src/anywhere.rs");
let txt_file = base.join("notes.txt");
let log_file = base.join("noise.log");
fs::write(&rs_file, "\n").unwrap();
fs::write(&txt_file, "\n").unwrap();
fs::write(&log_file, "\n").unwrap();
assert!(
wait_for(
|| {
let got = events.lock();
got.iter().any(|e| e.path == rs_file) && got.iter().any(|e| e.path == txt_file)
},
Duration::from_secs(10)
),
"watch-all did not receive events for both files, got: {:?}",
events.lock()
);
// the ignore option still filters within a watch-all subscription
std::thread::sleep(Duration::from_millis(300));
assert!(
!events.lock().iter().any(|e| e.path == log_file),
"*.log must be filtered by the ignore option"
);
}
#[test]
fn exact_out_of_tree_paths_are_rejected() {
let tmp = TempDir::new().unwrap();
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
seed(&base);
let outside = TempDir::new().unwrap();
let outside_file = fff_search::path_utils::canonicalize(outside.path())
.unwrap()
.join("config.txt");
fs::write(&outside_file, "v1").unwrap();
let (picker, _frecency) = make_watched_picker(&base);
assert!(
picker
.watch(
outside_file.to_str().unwrap(),
WatchOptions::default(),
|_, _| {}
)
.is_err(),
"exact paths outside the indexed tree must be rejected"
);
}
#[test]
fn gitignored_files_are_never_delivered() {
let tmp = TempDir::new().unwrap();
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
seed(&base);
fs::create_dir_all(base.join("dist")).unwrap();
fs::write(base.join(".gitignore"), "dist/\n*.log\n").unwrap();
git2::Repository::init(&base).unwrap();
let (picker, _frecency) = make_watched_picker(&base);
let events = watch_collect(&picker, "", WatchOptions::default());
fs::write(base.join("dist/bundle.js"), "js").unwrap();
fs::write(base.join("noise.log"), "log").unwrap();
fs::write(base.join("visible.txt"), "txt").unwrap();
assert!(
wait_for(
|| events
.lock()
.iter()
.any(|e| e.path == base.join("visible.txt")),
Duration::from_secs(10)
),
"non-ignored file must be delivered, got {:?}",
events.lock()
);
std::thread::sleep(Duration::from_millis(500));
let collected = events.lock();
assert!(
!collected
.iter()
.any(|e| e.path == base.join("dist/bundle.js")),
"gitignored directory content must not be delivered: {:?}",
collected
);
assert!(
!collected.iter().any(|e| e.path == base.join("noise.log")),
"gitignored file must not be delivered: {:?}",
collected
);
}
#[test]
fn dir_subscription_with_ignore_option() {
let tmp = TempDir::new().unwrap();
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
seed(&base);
fs::create_dir_all(base.join("src/vendor")).unwrap();
let (picker, _frecency) = make_watched_picker(&base);
// parcel-style: subscribe to a directory subtree with excludes
let got = watch_collect(
&picker,
"src",
WatchOptions {
ignore: vec!["*.map".to_string(), "src/vendor".to_string()],
..Default::default()
},
);
fs::write(base.join("src/feature.rs"), "pub fn f() {}\n").unwrap();
fs::write(base.join("src/feature.js.map"), "{}\n").unwrap();
fs::write(base.join("src/vendor/lib.js"), "x\n").unwrap();
fs::write(base.join("outside_dir.txt"), "not in src\n").unwrap();
assert!(
wait_for(
|| got
.lock()
.iter()
.any(|e| e.path == base.join("src/feature.rs")),
Duration::from_secs(10)
),
"dir subscriber must see files in its subtree, got {:?}",
got.lock()
);
let got = got.lock();
assert!(
!got.iter()
.any(|e| e.path == base.join("src/feature.js.map")),
"ignore glob leaked: {got:?}"
);
assert!(
!got.iter().any(|e| e.path == base.join("src/vendor/lib.js")),
"ignore prefix leaked: {got:?}"
);
assert!(
!got.iter().any(|e| e.path == base.join("outside_dir.txt")),
"event outside the subscribed dir leaked: {got:?}"
);
}
#[test]
fn shutdown_watches_stops_future_deliveries() {
let tmp = TempDir::new().unwrap();
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
seed(&base);
let (picker, _frecency) = make_watched_picker(&base);
let calls = Arc::new(AtomicUsize::new(0));
let calls_cb = Arc::clone(&calls);
picker
.watch("**/*.txt", WatchOptions::default(), move |_, _| {
calls_cb.fetch_add(1, Ordering::SeqCst);
})
.unwrap();
fs::write(base.join("one.txt"), "1\n").unwrap();
assert!(
wait_for(|| calls.load(Ordering::SeqCst) > 0, Duration::from_secs(10)),
"callback never fired before shutdown"
);
picker.shutdown_watches();
let after = calls.load(Ordering::SeqCst);
fs::write(base.join("two.txt"), "2\n").unwrap();
std::thread::sleep(Duration::from_millis(500));
assert_eq!(
calls.load(Ordering::SeqCst),
after,
"callback fired after shutdown_watches returned"
);
}
#[test]
fn non_canonical_dir_pattern_resolves_into_the_tree() {
let tmp = TempDir::new().unwrap();
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
seed(&base);
let (picker, _) = make_watched_picker(&base);
// tmp.path() is the non-canonical spelling (e.g. /var/... symlinked to
// /private/var/... on macOS, 8.3 short names on Windows); the watch must
// canonicalize instead of rejecting it
let events = watch_collect(
&picker,
tmp.path().to_str().unwrap(),
WatchOptions::default(),
);
fs::write(base.join("via-alias.txt"), "x\n").unwrap();
assert!(
wait_for(
|| events
.lock()
.iter()
.any(|e| e.path == base.join("via-alias.txt")),
Duration::from_secs(10)
),
"non-canonical base-dir pattern must receive events, got {:?}",
events.lock()
);
}
#[test]
fn invalid_patterns_are_rejected() {
let tmp = TempDir::new().unwrap();
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
seed(&base);
let (picker, _frecency) = make_watched_picker(&base);
assert!(
picker
.watch(
"/somewhere/else/**/*.rs",
WatchOptions::default(),
|_, _| {}
)
.is_err(),
"absolute glob outside base must be rejected"
);
// relative exact path resolves against base
let got = watch_collect(&picker, "README.md", WatchOptions::default());
fs::write(base.join("README.md"), "# updated\n").unwrap();
assert!(
wait_for(
|| got.lock().iter().any(|e| e.path == base.join("README.md")),
Duration::from_secs(10)
),
"got {:?}",
got.lock()
);
}
+4 -1
View File
@@ -3,9 +3,12 @@ name = "fff-grep"
description = "File grepping logic for fff"
license = "MIT"
authors = ["Dmitriy Kovalenko <dmtr.kovalenko@outlok.com>"]
version = "0.9.6"
version = "0.10.4"
edition = "2024"
[lints]
workspace = true
[dependencies]
bstr = { version = "1.6.2", default-features = false, features = ["std"] }
memchr = "2.6.3"
+16 -3
View File
@@ -1,10 +1,13 @@
[package]
name = "fff-mcp"
version = "0.9.6"
version = "0.10.4"
edition = "2024"
description = "MCP server for FFF file finder - drop-in replacement for AI code assistant search tools"
license = "MIT"
[lints]
workspace = true
[[bin]]
name = "fff-mcp"
path = "src/main.rs"
@@ -16,8 +19,8 @@ ripgrep = ["fff/ripgrep", "fff-query-parser/ripgrep"]
zlob = ["fff/zlob", "fff-query-parser/zlob"]
[dependencies]
fff = { package = "fff-search", path = "../fff-core", default-features = false , version = "0.9.6" }
fff-query-parser = { path = "../fff-query-parser", default-features = false , version = "0.9.6" }
fff = { package = "fff-search", path = "../fff-core", default-features = false , version = "0.10.4", features = ["definitions"] }
fff-query-parser = { path = "../fff-query-parser", default-features = false , version = "0.10.4" }
mimalloc = { workspace = true }
rmcp = { version = "1.7.0", features = ["server", "transport-io"] }
schemars = "1.0"
@@ -27,3 +30,13 @@ tokio = { version = "1", features = ["full"] }
tracing = { workspace = true }
git2 = { workspace = true }
clap = { version = "4", features = ["derive", "env"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.60", features = [
"Win32_Foundation",
"Win32_System_Threading",
"Win32_System_Diagnostics_ToolHelp",
] }
[dev-dependencies]
tempfile = "3.8"
+85 -13
View File
@@ -1,9 +1,12 @@
mod cursor;
mod healthcheck;
mod output;
mod parent;
mod server;
mod update_check;
use std::time::{Duration, SystemTime};
use clap::Parser;
use fff::file_picker::FilePicker;
use fff::frecency::FrecencyTracker;
@@ -92,7 +95,7 @@ pub const MCP_INSTRUCTIONS: &str = concat!(
" !generated/ - exclude generated code",
);
/// FFF MCP Server -- a high performance & accuracy file finder for AI code assistants.
/// FFF MCP Server - a high performance & accuracy file finder for AI code assistants.
#[derive(Parser)]
#[command(name = "fff-mcp", version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("FFF_GIT_HASH"), ")"))]
pub(crate) struct Args {
@@ -131,6 +134,7 @@ pub(crate) struct Args {
/// Disable the content index built after the initial scan.
/// This makes grep calls slower but consumes less RAM (recommended to not turn off)
#[arg(long = "no-content-indexing")]
no_content_indexing: bool,
/// Explicitly enable content indexing even when `--no-warmup` is set.
@@ -154,15 +158,40 @@ pub(crate) struct Args {
#[arg(long = "follow-symlinks")]
follow_symlinks: bool,
/// Allow indexing the user's home directory. FFF refuses to init in `~`
/// unless this is set. Also settable via FFF_ENABLE_HOME_SCAN=1.
#[arg(
long = "enable-home-scan",
env = "FFF_ENABLE_HOME_SCAN",
num_args = 0..=1,
default_missing_value = "true",
default_value_t = false,
value_parser = clap::builder::BoolishValueParser::new()
)]
enable_home_scan: bool,
/// Allow indexing the filesystem root, off by default for the same reason.
/// Also settable via FFF_ENABLE_ROOT_SCAN=1.
#[arg(
long = "enable-root-scan",
env = "FFF_ENABLE_ROOT_SCAN",
num_args = 0..=1,
default_missing_value = "true",
default_value_t = false,
value_parser = clap::builder::BoolishValueParser::new()
)]
enable_root_scan: bool,
/// Run a health check and print diagnostic information, then exit.
#[arg(long = "healthcheck")]
pub(crate) healthcheck: bool,
/// Exit after this many seconds of inactivity. 0 = never exit.
/// Timeout of inactivity after which fff mcp will be exited. Even if the parent process
/// is alive we don't want to occupy resources on index and file watches if fff is unused
#[arg(
long = "idle-timeout-secs",
env = "FFF_MCP_IDLE_TIMEOUT_SECS",
default_value_t = 900
default_value_t = 60 * 60
)]
idle_timeout_secs: u64,
}
@@ -276,7 +305,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.max_cached_files
.map(fff::ContentCacheBudget::new_for_repo),
follow_symlinks: args.follow_symlinks,
..Default::default()
enable_home_dir_scanning: args.enable_home_scan,
enable_fs_root_scanning: args.enable_root_scan,
},
)
.map_err(|e| format!("Failed to init file picker: {}", e))?;
@@ -317,9 +347,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
};
if idle_timeout_secs > 0 {
let parent_watcher = parent::ParentWatcher::new();
match &parent_watcher {
Some(watcher) => tracing::info!(
"Watching parent process (pid {}); will exit when it dies",
watcher.parent_pid()
),
None => tracing::warn!(
"Parent process liveness detection unavailable; idle timeout will exit unconditionally"
),
}
if idle_timeout_secs > 0 || parent_watcher.is_some() {
last_activity.store(
std::time::SystemTime::now()
SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
@@ -328,9 +369,27 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let last_activity_for_watchdog = last_activity.clone();
tokio::spawn(async move {
let tick = std::time::Duration::from_secs(60);
let tick = watchdog_interval();
loop {
tokio::time::sleep(tick).await;
if let Some(ref watcher) = parent_watcher {
if !watcher.parent_alive() {
tracing::info!(
"Parent process (pid {}) exited, shutting down",
watcher.parent_pid()
);
flush_logs_and_exit().await;
}
// Parent is alive: it owns our lifecycle, never exit on idle
// Clients like Codex do not restart MCP servers @see #703
continue;
}
if idle_timeout_secs == 0 {
continue;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
@@ -338,12 +397,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let last = last_activity_for_watchdog.load(std::sync::atomic::Ordering::Relaxed);
if now.saturating_sub(last) >= idle_timeout_secs {
tracing::info!(
"Exiting after {}s of inactivity (idle_timeout_secs={})",
now.saturating_sub(last),
idle_timeout_secs
);
std::process::exit(0);
tracing::info!(?idle_timeout_secs, "Exiting due to inactivity",);
flush_logs_and_exit().await;
}
}
});
@@ -370,3 +425,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
// Tracing appender is non blocking, to get full log give it some time before hard exit
async fn flush_logs_and_exit() -> ! {
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
std::process::exit(0);
}
fn watchdog_interval() -> Duration {
if cfg!(debug_assertions)
&& let Some(milliseconds) = std::env::var("FFF_MCP_TEST_WATCHDOG_INTERVAL_MS")
.ok()
.and_then(|value| value.parse().ok())
{
return Duration::from_millis(milliseconds);
}
Duration::from_secs(60)
}
+99
View File
@@ -0,0 +1,99 @@
#[cfg(unix)]
mod imp {
pub struct ParentWatcher {
ppid: u32,
}
impl ParentWatcher {
pub fn new() -> Option<Self> {
let ppid = std::os::unix::process::parent_id();
// ppid <= 1 means we were spawned by init and can't detect death
(ppid > 1).then_some(Self { ppid })
}
pub fn parent_pid(&self) -> u32 {
self.ppid
}
// When the parent dies the kernel reparents us, so getppid() changes.
// Race-free and immune to PID reuse, unlike kill(ppid, 0).
pub fn parent_alive(&self) -> bool {
std::os::unix::process::parent_id() == self.ppid
}
}
}
#[cfg(windows)]
mod imp {
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE, WAIT_TIMEOUT};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, PROCESSENTRY32, Process32First, Process32Next, TH32CS_SNAPPROCESS,
};
use windows_sys::Win32::System::Threading::{
GetCurrentProcessId, OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject,
};
pub struct ParentWatcher {
handle: HANDLE,
ppid: u32,
}
// HANDLE is a raw pointer; it is only ever used via WaitForSingleObject
// which is thread-safe, so moving/sharing the watcher across threads is fine.
unsafe impl Send for ParentWatcher {}
unsafe impl Sync for ParentWatcher {}
impl ParentWatcher {
pub fn new() -> Option<Self> {
let ppid = parent_pid_of_current()?;
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, ppid) };
if handle.is_null() {
return None;
}
// Holding the handle pins the PID, preventing reuse for the process lifetime
Some(Self { handle, ppid })
}
pub fn parent_pid(&self) -> u32 {
self.ppid
}
pub fn parent_alive(&self) -> bool {
unsafe { WaitForSingleObject(self.handle, 0) == WAIT_TIMEOUT }
}
}
impl Drop for ParentWatcher {
fn drop(&mut self) {
unsafe { CloseHandle(self.handle) };
}
}
fn parent_pid_of_current() -> Option<u32> {
unsafe {
let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if snapshot == INVALID_HANDLE_VALUE {
return None;
}
let mut entry: PROCESSENTRY32 = std::mem::zeroed();
entry.dwSize = std::mem::size_of::<PROCESSENTRY32>() as u32;
let current = GetCurrentProcessId();
let mut found = None;
if Process32First(snapshot, &mut entry) != 0 {
loop {
if entry.th32ProcessID == current {
found = Some(entry.th32ParentProcessID);
break;
}
if Process32Next(snapshot, &mut entry) == 0 {
break;
}
}
}
CloseHandle(snapshot);
found
}
}
}
pub use imp::ParentWatcher;
+21 -56
View File
@@ -7,7 +7,6 @@ use fff_query_parser::AiGrepConfig;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::*;
use rmcp::{ServerHandler, schemars, tool, tool_handler, tool_router};
use std::borrow::Cow;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -421,7 +420,12 @@ impl FffServer {
/// IMPORTANT: Keep queries SHORT — prefer 1-2 terms max.
#[tool(
name = "find_files",
description = "Fuzzy file search by name. Searches FILE NAMES, not file contents. Use it when you need to find a file, not a definition. Use grep instead for searching code content (definitions, usage patterns). Supports fuzzy matching, path prefixes ('src/'), and glob constraints ('name **/src/*.{ts,tsx} !test/'). IMPORTANT: Keep queries SHORT — prefer 1-2 terms max. Multiple words are a waterfall (each narrows results), NOT OR. If unsure, start broad with 1 term and refine."
description = "Fuzzy file search by name. Searches FILE NAMES, not file contents. Use it when you need to find a file, not a definition. Use grep instead for searching code content (definitions, usage patterns). Supports fuzzy matching, path prefixes ('src/'), and glob constraints ('name **/src/*.{ts,tsx} !test/'). IMPORTANT: Keep queries SHORT — prefer 1-2 terms max. Multiple words are a waterfall (each narrows results), NOT OR. If unsure, start broad with 1 term and refine.",
annotations(
read_only_hint = true,
destructive_hint = false,
open_world_hint = false
)
)]
fn find_files(
&self,
@@ -536,7 +540,12 @@ impl FffServer {
/// Prefer plain text over regex. Filter files with constraints.
#[tool(
name = "grep",
description = "Search file contents. Search for bare identifiers (e.g. 'InProgressQuote', 'ActorAuth'), NOT code syntax or regex. Filter files with constraints (e.g. '*.rs query', 'src/ query'). Use filename, directory (ending with /) or glob expressions to prefilter. See server instructions for constraint syntax and core rules."
description = "Search file contents. Search for bare identifiers (e.g. 'InProgressQuote', 'ActorAuth'), NOT code syntax or regex. Filter files with constraints (e.g. '*.rs query', 'src/ query'). Use filename, directory (ending with /) or glob expressions to prefilter. See server instructions for constraint syntax and core rules.",
annotations(
read_only_hint = true,
destructive_hint = false,
open_world_hint = false
)
)]
fn grep(
&self,
@@ -573,7 +582,12 @@ impl FffServer {
/// Patterns are literal text — NEVER escape special characters.
#[tool(
name = "multi_grep",
description = "Search file contents for lines matching ANY of multiple patterns (OR logic). IMPORTANT: This returns files where ANY query matches, NOT all patterns. Patterns are literal text — NEVER escape special characters (no \\( \\) \\. etc). Faster than regex alternation for literal text. See server instructions for constraint syntax."
description = "Search file contents for lines matching ANY of multiple patterns (OR logic). IMPORTANT: This returns files where ANY query matches, NOT all patterns. Patterns are literal text — NEVER escape special characters (no \\( \\) \\. etc). Faster than regex alternation for literal text. See server instructions for constraint syntax.",
annotations(
read_only_hint = true,
destructive_hint = false,
open_world_hint = false
)
)]
fn multi_grep(
&self,
@@ -613,61 +627,12 @@ impl FffServer {
.ok_or_else(|| ErrorData::internal_error("File picker not initialized", None))?;
let patterns_refs: Vec<&str> = params.patterns.iter().map(|s| s.as_str()).collect();
let parser = fff_query_parser::QueryParser::new(fff_query_parser::AiGrepConfig);
let parsed_constraints = parser.parse(constraint_query);
let constraints = parsed_constraints.constraints.as_slice();
let parser = QueryParser::new(AiGrepConfig);
let constraints = parser.parse_constraints(constraint_query);
let result = picker.multi_grep(&patterns_refs, constraints, &options);
let result = picker.multi_grep(&patterns_refs, &constraints, &options);
let file_refs: Vec<&FileItem> = result.files.to_vec();
if result.matches.is_empty() && file_offset == 0 {
// Fallback: try individual patterns with plain grep
let (fallback_options, _) =
make_grep_options(output_mode, GrepMode::PlainText, 0, context);
let fallback_options = GrepSearchOptions {
time_budget_ms: 3000,
before_context: 0,
..fallback_options
};
for pat in &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,
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(),
+50 -28
View File
@@ -1,20 +1,14 @@
//! Background update checker — compares the embedded build hash against
//! the latest GitHub release tag to surface upgrade notices in MCP instructions.
use std::sync::OnceLock;
const REPO: &str = "dmtrKovalenko/fff.nvim";
const BUILD_HASH: &str = env!("FFF_GIT_HASH");
const REPO: &str = "dmtrKovalenko/fff";
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Holds the result of the update check (empty string = up to date or check failed).
static UPDATE_NOTICE: OnceLock<String> = OnceLock::new();
/// Returns the update notice if the check has completed, empty string otherwise.
pub fn get_update_notice() -> &'static str {
UPDATE_NOTICE.get().map(|s| s.as_str()).unwrap_or("")
}
/// Kick off the update check in a background thread so it never blocks the server.
pub fn spawn_update_check() {
std::thread::spawn(|| {
let notice = check_latest_release();
@@ -22,34 +16,28 @@ pub fn spawn_update_check() {
});
}
/// Fetch the latest release tag from GitHub and compare against the build hash.
fn check_latest_release() -> String {
match fetch_latest_tag() {
Ok(tag) => compare_versions(BUILD_HASH, &tag),
match fetch_latest_stable_tag() {
Ok(tag) => compare_versions(CURRENT_VERSION, &tag),
Err(_) => String::new(),
}
}
/// Compare a build hash against a release tag.
/// Returns an update notice string, or empty if up-to-date.
fn compare_versions(build_hash: &str, release_tag: &str) -> String {
fn compare_versions(current_version: &str, release_tag: &str) -> String {
let tag = release_tag.trim();
if tag.is_empty() || build_hash == "unknown" {
return String::new();
}
let our_short = &build_hash[..build_hash.len().min(tag.len())];
if our_short == tag {
let tag_version = tag.strip_prefix('v').unwrap_or(tag);
if tag.is_empty() || tag_version == current_version {
return String::new();
}
format!(
"\n[fff update available: `curl -fsSL https://raw.githubusercontent.com/{REPO}/main/install-mcp.sh | bash`]\n"
"\n[fff update available ({current_version} -> {tag_version}): `curl -fsSL https://raw.githubusercontent.com/{REPO}/main/install-mcp.sh | bash`]\n"
)
}
/// Shell out to curl to fetch the latest release tag name from GitHub API.
fn fetch_latest_tag() -> Result<String, Box<dyn std::error::Error>> {
// Uses /releases/latest — GitHub excludes prereleases here, matching the
// stable channel that install-mcp.sh installs from.
fn fetch_latest_stable_tag() -> Result<String, Box<dyn std::error::Error>> {
let output = std::process::Command::new("curl")
.args([
"-fsSL",
@@ -57,7 +45,7 @@ fn fetch_latest_tag() -> Result<String, Box<dyn std::error::Error>> {
"5",
"-H",
"Accept: application/vnd.github.v3+json",
&format!("https://api.github.com/repos/{REPO}/releases?per_page=1"),
&format!("https://api.github.com/repos/{REPO}/releases/latest"),
])
.output()?;
@@ -66,13 +54,47 @@ fn fetch_latest_tag() -> Result<String, Box<dyn std::error::Error>> {
}
let body = String::from_utf8(output.stdout)?;
let releases: Vec<serde_json::Value> = serde_json::from_str(&body)?;
let tag = releases
.first()
.and_then(|r| r.get("tag_name"))
let release: serde_json::Value = serde_json::from_str(&body)?;
let tag = release
.get("tag_name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Ok(tag)
}
#[cfg(test)]
mod tests {
use super::compare_versions;
#[test]
fn same_version_with_v_prefix_is_silent() {
assert_eq!(compare_versions("0.10.1", "v0.10.1"), "");
}
#[test]
fn same_version_without_v_prefix_is_silent() {
assert_eq!(compare_versions("0.10.1", "0.10.1"), "");
}
#[test]
fn empty_tag_is_silent() {
assert_eq!(compare_versions("0.10.1", ""), "");
assert_eq!(compare_versions("0.10.1", " "), "");
}
#[test]
fn older_current_reports_update() {
let notice = compare_versions("0.10.0", "v0.10.1");
assert!(notice.contains("0.10.0 -> 0.10.1"), "got: {notice}");
assert!(notice.contains("install-mcp.sh"));
}
#[test]
fn nightly_tag_never_equals_stable_current() {
let notice = compare_versions("0.10.1", "0.10.2-nightly.6a239e9");
assert!(!notice.is_empty());
assert!(notice.contains("0.10.1 -> 0.10.2-nightly.6a239e9"));
}
}
+181
View File
@@ -0,0 +1,181 @@
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
const BIN: &str = env!("CARGO_BIN_EXE_fff-mcp");
#[test]
fn stays_alive_while_parent_alive_despite_idle_timeout() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.txt"), "hello").unwrap();
let mut child = Command::new(BIN)
.arg(dir.path())
.args([
"--no-update-check",
"--no-warmup",
"--no-watch",
"--idle-timeout-secs",
"1",
])
.arg("--log-file")
.arg(dir.path().join("test.log"))
.env("FFF_MCP_TEST_WATCHDOG_INTERVAL_MS", "100")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut stdin = child.stdin.take().unwrap();
let stdout_lines = spawn_line_reader(child.stdout.take().unwrap());
do_handshake(&mut stdin, &stdout_lines);
// Wait past the idle timeout and several watchdog ticks.
std::thread::sleep(Duration::from_secs(2));
assert!(
child.try_wait().unwrap().is_none(),
"fff-mcp exited on idle timeout even though its parent is alive"
);
// Closing stdin ends the transport; the server must still shut down cleanly.
drop(stdin);
wait_for_exit(&mut child, Duration::from_secs(15));
}
#[cfg(unix)]
#[test]
fn exits_when_parent_dies_even_without_idle_timeout() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.txt"), "hello").unwrap();
let log_path = dir.path().join("test.log");
let exit_signal = dir.path().join("exit-parent");
// Intermediary parent: sh backgrounds fff-mcp and waits until the handshake
// completes before dying and orphaning it.
let mut sh = Command::new("sh")
.arg("-c")
.arg(
// Preserve stdin before POSIX shells assign /dev/null to background jobs.
r#"exec 3<&0
"$1" "$2" --no-update-check --no-warmup --no-watch \
--idle-timeout-secs 0 --log-file "$3" <&3 &
while [ ! -e "$4" ]; do sleep 0.1; done"#,
)
.arg("sh")
.arg(BIN)
.arg(dir.path())
.arg(&log_path)
.arg(&exit_signal)
.env("FFF_MCP_TEST_WATCHDOG_INTERVAL_MS", "100")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut stdin = sh.stdin.take().unwrap();
let stdout_lines = spawn_line_reader(sh.stdout.take().unwrap());
do_handshake(&mut stdin, &stdout_lines);
std::fs::write(exit_signal, "").unwrap();
sh.wait().unwrap();
// We still hold the stdin write end, so the only exit path is the parent
// liveness check. EOF on stdout means fff-mcp closed it by exiting.
let deadline = Instant::now() + Duration::from_secs(5);
loop {
match stdout_lines.recv_timeout(deadline.saturating_duration_since(Instant::now())) {
Ok(_) => continue,
Err(mpsc::RecvTimeoutError::Disconnected) => break,
Err(mpsc::RecvTimeoutError::Timeout) => {
panic!("fff-mcp did not exit within 5s of its parent dying")
}
}
}
drop(stdin);
let logs = read_session_logs(dir.path());
assert!(
logs.contains("Parent process") && logs.contains("exited, shutting down"),
"expected parent-death exit reason in logs, got:\n{}",
logs
);
}
fn do_handshake(stdin: &mut ChildStdin, stdout_lines: &mpsc::Receiver<String>) {
let initialize = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": { "name": "parent-liveness-test", "version": "0.0.0" }
}
});
writeln!(stdin, "{}", initialize).unwrap();
stdin.flush().unwrap();
let response = stdout_lines
.recv_timeout(Duration::from_secs(30))
.expect("no initialize response within 30s");
assert!(
response.contains("\"serverInfo\""),
"unexpected initialize response: {}",
response
);
writeln!(
stdin,
"{}",
serde_json::json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })
)
.unwrap();
stdin.flush().unwrap();
}
fn spawn_line_reader(stdout: std::process::ChildStdout) -> mpsc::Receiver<String> {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
for line in BufReader::new(stdout).lines() {
match line {
Ok(line) => {
if tx.send(line).is_err() {
break;
}
}
Err(_) => break,
}
}
});
rx
}
fn wait_for_exit(child: &mut Child, timeout: Duration) {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if child.try_wait().unwrap().is_some() {
return;
}
std::thread::sleep(Duration::from_millis(100));
}
child.kill().ok();
panic!(
"fff-mcp did not exit within {:?} after stdin closed",
timeout
);
}
#[cfg(unix)]
fn read_session_logs(dir: &std::path::Path) -> String {
let mut combined = String::new();
for entry in std::fs::read_dir(dir).unwrap().flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("test") && name.ends_with(".log") {
combined.push_str(&std::fs::read_to_string(entry.path()).unwrap_or_default());
}
}
combined
}
+8 -3
View File
@@ -1,8 +1,11 @@
[package]
name = "fff-nvim"
version = "0.9.6"
version = "0.10.4"
edition = "2024"
[lints]
workspace = true
[lib]
path = "src/lib.rs"
crate-type = ["cdylib", "rlib"]
@@ -12,6 +15,8 @@ crate-type = ["cdylib", "rlib"]
default = ["ripgrep"]
ripgrep = ["fff/ripgrep", "fff-query-parser/ripgrep", "dep:ignore"]
zlob = ["fff/zlob", "fff-query-parser/zlob", "dep:zlob"]
# Keep full-rescan accounting in a release build; required by rescan_probe.
rescan-stats = ["fff/rescan-stats"]
[dependencies]
# Workspace dependencies
@@ -19,10 +24,10 @@ ahash = { workspace = true }
tracing = { workspace = true }
# Local crates
fff = { package = "fff-search", path = "../fff-core", version = "0.9.6", default-features = false, features = [
fff = { package = "fff-search", path = "../fff-core", version = "0.10.4", default-features = false, features = [
"mimalloc-collect",
] }
fff-query-parser = { path = "../fff-query-parser", version = "0.9.6", default-features = false }
fff-query-parser = { path = "../fff-query-parser", version = "0.10.4", default-features = false }
chrono = { version = "0.4", features = ["serde"] }
ctrlc = "3.4.2"
git2 = { workspace = true }
+158
View File
@@ -0,0 +1,158 @@
use fff::file_picker::FilePicker;
use fff::{
FFFMode, FilePickerOptions, RESCAN_STATS_ENABLED, RescanReason, RescanStats, SharedFilePicker,
SharedFrecency,
};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
const POLL: Duration = Duration::from_millis(250);
fn main() -> Result<(), Box<dyn std::error::Error>> {
let (base_path, run_for) = parse_args()?;
if !RESCAN_STATS_ENABLED {
return Err(
"this build has rescan accounting compiled out; rebuild with \
`--features rescan-stats` (or drop `--release`)"
.into(),
);
}
let picker = SharedFilePicker::default();
let frecency = SharedFrecency::noop();
println!("indexing {base_path} ...");
let started = Instant::now();
FilePicker::new_with_shared_state(
picker.clone(),
frecency.clone(),
FilePickerOptions {
base_path: base_path.clone(),
enable_mmap_cache: false,
mode: FFFMode::default(),
watch: true,
..Default::default()
},
)?;
if !picker.wait_for_scan(Duration::from_secs(600)) {
return Err("timed out waiting for the initial scan".into());
}
if !picker.wait_for_watcher(Duration::from_secs(600)) {
return Err("timed out waiting for the watcher".into());
}
println!(
"indexed {} files in {:.2}s; watching for rescan requests.\n",
live_files(&picker),
started.elapsed().as_secs_f64()
);
picker.reset_rescan_stats();
let running = Arc::new(AtomicBool::new(true));
let stop = Arc::clone(&running);
ctrlc::set_handler(move || stop.store(false, Ordering::SeqCst))?;
let watching_since = Instant::now();
let mut last = RescanStats::default();
while running.load(Ordering::SeqCst) {
std::thread::sleep(POLL);
let stats = picker.rescan_stats();
let delta = stats.since(&last);
if delta.total > 0 || delta.throttled > 0 {
let now = watching_since.elapsed().as_secs_f64();
let files = live_files(&picker);
let overflow = overflow_files(&picker);
for reason in RescanReason::ALL {
for _ in 0..delta.count(reason) {
println!(
"[{now:>8.2}s] request {reason:<21} files={files} overflow={overflow}"
);
}
let suppressed = delta.count_throttled(reason);
if suppressed > 0 {
println!("[{now:>8.2}s] throttled {reason:<21} x{suppressed}");
}
}
last = stats;
}
if run_for.is_some_and(|limit| watching_since.elapsed() >= limit) {
break;
}
}
let elapsed = watching_since.elapsed();
let stats = picker.rescan_stats();
println!("\n{:.1}s watched", elapsed.as_secs_f64());
println!("{stats}");
if stats.watcher_triggered() > 0 {
println!(
"{:.1} watcher rescan requests/minute",
stats.watcher_triggered() as f64 / elapsed.as_secs_f64().max(1.0) * 60.0
);
} else {
println!("no full rescans: every change was applied incrementally");
}
if stats.throttled > 0 {
println!(
"{} additional request(s) were throttled; {} total requests observed",
stats.throttled,
stats.total + stats.throttled
);
}
if let Ok(mut guard) = picker.write() {
guard.take();
}
Ok(())
}
fn parse_args() -> Result<(String, Option<Duration>), Box<dyn std::error::Error>> {
let mut base_path = None;
let mut run_for = None;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--seconds" | "-s" => {
let value = args.next().ok_or("--seconds needs a value")?;
run_for = Some(Duration::from_secs(value.parse()?));
}
"--help" | "-h" => {
println!("usage: rescan_probe [path] [--seconds N]");
std::process::exit(0);
}
other => base_path = Some(other.to_string()),
}
}
let base_path = match base_path {
Some(path) => path,
None => std::env::current_dir()?.to_string_lossy().into_owned(),
};
Ok((base_path, run_for))
}
fn live_files(picker: &SharedFilePicker) -> usize {
picker
.read()
.ok()
.and_then(|g| g.as_ref().map(|p| p.live_file_count()))
.unwrap_or(0)
}
fn overflow_files(picker: &SharedFilePicker) -> usize {
picker
.read()
.ok()
.and_then(|g| g.as_ref().map(|p| p.get_overflow_files().len()))
.unwrap_or(0)
}
+1
View File
@@ -322,6 +322,7 @@ pub fn fuzzy_search_files(
match_type: "path",
..Default::default()
}],
match_byte_offsets: vec![Default::default()],
total_matched: 1,
total_files: results.total_files,
location: parsed_query.location,
+21 -10
View File
@@ -109,6 +109,19 @@ fn score_into_lua(score: &Score, lua: &Lua) -> LuaResult<LuaValue> {
Ok(LuaValue::Table(table))
}
fn set_match_ranges(lua: &Lua, item: &LuaTable, ranges: &[(u32, u32)]) -> LuaResult<()> {
let ranges_table = lua.create_table()?;
for (i, &(start, end)) in ranges.iter().enumerate() {
let range = lua.create_table()?;
range.set(1, start)?;
range.set(2, end)?;
ranges_table.set(i + 1, range)?;
}
item.set("match_ranges", ranges_table)
}
fn location_into_lua(location: &Location, lua: &Lua) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
match location {
@@ -134,7 +147,13 @@ 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, self.picker)?)?;
let lua_item = file_item_into_lua(item, lua, self.picker)?;
if let LuaValue::Table(item_table) = &lua_item
&& let Some(ranges) = self.inner.match_byte_offsets.get(i)
{
set_match_ranges(lua, item_table, ranges.as_slice())?;
}
items_table.set(i + 1, lua_item)?;
}
table.set("items", items_table)?;
@@ -246,15 +265,7 @@ impl IntoLua for GrepResultLua<'_> {
item.set("is_binary_content", is_binary_content)?;
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)?;
set_match_ranges(lua, &item, m.match_byte_offsets.as_slice())?;
// Fuzzy match score (only set in fuzzy grep mode, nil otherwise)
if let Some(score) = m.fuzzy_score {
+6 -3
View File
@@ -1,8 +1,11 @@
[package]
name = "fff-python"
version = "0.9.6"
version = "0.10.4"
edition = "2024"
[lints]
workspace = true
[lib]
name = "fff_python"
crate-type = ["cdylib"]
@@ -14,7 +17,7 @@ ripgrep = ["fff/ripgrep", "fff-query-parser/ripgrep"]
zlob = ["fff/zlob", "fff-query-parser/zlob"]
[dependencies]
fff = { package = "fff-search", path = "../fff-core", version = "0.9.6", default-features = false }
fff-query-parser = { path = "../fff-query-parser", version = "0.9.6", default-features = false }
fff = { package = "fff-search", path = "../fff-core", version = "0.10.4", default-features = false }
fff-query-parser = { path = "../fff-query-parser", version = "0.10.4", default-features = false }
git2 = { workspace = true }
pyo3 = { version = "0.24.0", features = ["extension-module", "abi3-py310"] }
+77 -12
View File
@@ -1,4 +1,6 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use fff::file_picker::FilePicker;
@@ -8,14 +10,16 @@ use fff::{
FFFMode, FilePickerOptions, FuzzySearchOptions, GrepSearchOptions, PaginationArgs, QueryParser,
SharedFilePicker, SharedFrecency, SharedQueryTracker,
};
use pyo3::exceptions::PyTypeError;
use pyo3::prelude::*;
use pyo3::types::PyDict;
use crate::conversions::MixedItem;
use crate::types::{
DirItem, DirSearchResult, FileItem, GrepCursor, GrepMatch, GrepResult, MixedDirItem,
MixedFileItem, MixedSearchResult, ScanProgress, Score, SearchResult,
MixedFileItem, MixedSearchResult, ScanProgress, Score, SearchResult, WatchEvent,
};
use crate::watch::WatchSubscription;
use crate::{parse_grep_mode, py_err};
const DEFAULT_SEARCH_PAGE_SIZE: usize = 100;
@@ -267,11 +271,20 @@ impl FileFinder {
slf
}
fn __exit__(&mut self, _exc_type: PyObject, _exc_value: PyObject, _traceback: PyObject) {
let _ = self.close();
fn __exit__(
&mut self,
py: Python<'_>,
_exc_type: PyObject,
_exc_value: PyObject,
_traceback: PyObject,
) {
let _ = self.close(py);
}
fn close(&mut self) -> PyResult<()> {
fn close(&mut self, py: Python<'_>) -> PyResult<()> {
// Release the GIL while an in-flight callback finishes.
let picker = self.picker.clone();
py.allow_threads(move || picker.shutdown_watches_and_wait());
clear_shared_state(&self.picker, &self.frecency, &self.query_tracker);
Ok(())
}
@@ -680,15 +693,11 @@ impl FileFinder {
}
let pattern_refs: Vec<&str> = patterns.iter().map(|s| s.as_str()).collect();
let parsed_constraints = constraints.as_ref().map(|c| {
if picker.mode().is_ai() {
QueryParser::new(fff_query_parser::AiGrepConfig).parse(c)
} else {
fff::grep::parse_grep_query(c)
}
});
let parsed_constraints = constraints
.as_ref()
.map(|c| QueryParser::new(fff_query_parser::AiGrepConfig).parse_constraints(c));
let constraint_refs: &[fff::Constraint<'_>] = match &parsed_constraints {
Some(q) => &q.constraints,
Some(constraints) => constraints,
None => &[],
};
let options = grep_options(
@@ -728,6 +737,62 @@ impl FileFinder {
py.allow_threads(move || Ok(picker.wait_for_scan(Duration::from_millis(timeout_ms))))
}
/// Subscribe to filesystem changes matching `pattern`.
///
/// Patterns may be base-relative globs (./ works), exact paths inside the indexed
/// tree, or existing directories. An empty pattern watches the whole tree.
///
/// Events are debounced and submitted in batches per 100-ms window at most 128 events.
/// Gitignored and other ignored files are never triggering watcher.
#[pyo3(signature = (pattern, callback, *, ignore = None))]
fn watch(
&self,
py: Python<'_>,
pattern: Option<&str>,
callback: Py<PyAny>,
ignore: Option<Vec<String>>,
) -> PyResult<WatchSubscription> {
if !callback.bind(py).is_callable() {
return Err(PyTypeError::new_err("callback must be callable"));
}
let pattern = pattern.unwrap_or_default().to_string();
let options = fff::WatchOptions {
ignore: ignore.unwrap_or_default(),
};
// Suppresses invocations racing an unsubscribe: the flag flips before
// core unwatch, so user code never runs after unsubscribe() returns.
let active = Arc::new(AtomicBool::new(true));
let active_cb = Arc::clone(&active);
let id = {
let picker = self.picker.clone();
py.allow_threads(move || {
picker.watch(&pattern, options, move |_id, events| {
Python::with_gil(|py| {
if !active_cb.load(Ordering::Acquire) {
return;
}
let batch: Vec<WatchEvent> = events
.iter()
.map(|ev| WatchEvent {
path: ev.path.to_string_lossy().to_string(),
kind: ev.kind.as_str().to_string(),
})
.collect();
if let Err(e) = callback.call1(py, (batch,)) {
e.write_unraisable(py, None);
}
})
})
})
.map_err(py_err)?
};
Ok(WatchSubscription::new(self.picker.clone(), id.0, active))
}
fn reindex(&self, py: Python<'_>, new_path: PathBuf) -> PyResult<()> {
let picker = self.picker.clone();
let frecency = self.frecency.clone();
+3
View File
@@ -4,6 +4,7 @@ use pyo3::prelude::*;
mod conversions;
mod finder;
mod types;
mod watch;
create_exception!(fff_python, FFFException, pyo3::exceptions::PyException);
@@ -39,6 +40,8 @@ fn _fff_python(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<types::GrepResult>()?;
m.add_class::<types::ScanProgress>()?;
m.add_class::<types::GrepCursor>()?;
m.add_class::<types::WatchEvent>()?;
m.add_class::<watch::WatchSubscription>()?;
m.add("FFFException", m.py().get_type::<FFFException>())?;
Ok(())
}
+16
View File
@@ -388,6 +388,22 @@ impl ScanProgress {
}
}
#[pyclass]
#[derive(Clone)]
pub struct WatchEvent {
#[pyo3(get)]
pub path: String,
#[pyo3(get)]
pub kind: String,
}
#[pymethods]
impl WatchEvent {
fn __repr__(&self) -> String {
format!("WatchEvent(path={:?}, kind={:?})", self.path, self.kind)
}
}
#[pyclass]
#[derive(Clone)]
pub struct GrepCursor {
+58
View File
@@ -0,0 +1,58 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use fff::{SharedFilePicker, WatchId};
use pyo3::prelude::*;
/// Handle for an active watch subscription returned by [crate::FileFinder::watch]
///
/// Usable as a context manager: exiting the `with` block unsubscribes
#[pyclass]
pub struct WatchSubscription {
picker: SharedFilePicker,
id: u64,
/// Shared with the delivery closure: flipped before core unwatch so the
/// user callback never runs for events racing the unsubscribe.
active: Arc<AtomicBool>,
}
impl WatchSubscription {
pub(crate) fn new(picker: SharedFilePicker, id: u64, active: Arc<AtomicBool>) -> Self {
Self { picker, id, active }
}
}
#[pymethods]
impl WatchSubscription {
#[getter]
fn id(&self) -> u64 {
self.id
}
#[getter]
fn active(&self) -> bool {
self.active.load(Ordering::Acquire) && self.picker.is_watch_active(WatchId(self.id))
}
/// Stop delivering events. Idempotent: returns True when the subscription
/// was removed by this call, False if it was already inactive.
fn unsubscribe(&self) -> bool {
if !self.active.swap(false, Ordering::AcqRel) {
return false;
}
self.picker.unwatch(WatchId(self.id))
}
fn __enter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
slf
}
fn __exit__(&self, _exc_type: PyObject, _exc_value: PyObject, _traceback: PyObject) {
self.unsubscribe();
}
fn __repr__(&self) -> String {
let active = if self.active() { "True" } else { "False" };
format!("WatchSubscription(id={}, active={})", self.id, active)
}
}
+4 -1
View File
@@ -1,11 +1,14 @@
[package]
name = "fff-query-parser"
version = "0.9.6"
version = "0.10.4"
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>"]
[lints]
workspace = true
[lib]
path = "src/lib.rs"
+82 -3
View File
@@ -49,6 +49,14 @@ impl<C: ParserConfig> QueryParser<C> {
Self { config }
}
/// Parse a field containing only constraints.
pub fn parse_constraints<'a>(&self, query: &'a str) -> ConstraintVec<'a> {
query
.split_whitespace()
.filter_map(|token| parse_token(token, &self.config))
.collect()
}
pub fn parse<'a>(&self, query: &'a str) -> FFFQuery<'a> {
let raw_query = query;
let config: &C = &self.config;
@@ -364,8 +372,11 @@ fn parse_negation<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option<Con
return Some(Constraint::Not(Box::new(inner_constraint)));
}
// If it's not a special constraint, treat it as negated text
// For backward compatibility with !test syntax
// Negated text (!test) requires ≥3 inner chars with at least one alphanumeric,
// so operators like `!=`, `!==`, `!!` stay literal search text.
if inner_token.len() < 3 || !inner_token.chars().any(|c| c.is_alphanumeric()) {
return None;
}
Some(Constraint::Not(Box::new(Constraint::Text(inner_token))))
}
@@ -495,7 +506,7 @@ fn parse_git_status(value: &str) -> Option<Constraint<'_>> {
#[cfg(test)]
mod tests {
use super::*;
use crate::{FileSearchConfig, GrepConfig};
use crate::{AiGrepConfig, FileSearchConfig, GrepConfig};
/// File-picker-like config with filename-constraint detection enabled,
/// mirroring the Neovim layer's opt-in behavior.
@@ -612,6 +623,38 @@ mod tests {
}
}
#[test]
fn test_negation_operators_stay_literal() {
let parser = QueryParser::new(GrepConfig);
// Operator-like tokens must not become exclusion constraints
for query in [
"Ordering::Acquire) != delivery.epoch",
"a !== b",
"x !! y",
"foo !~ bar",
] {
let result = parser.parse(query);
assert!(
result.constraints.is_empty(),
"{query:?} produced constraints {:?}",
result.constraints
);
assert_eq!(result.grep_text(), query, "grep text must equal raw query");
}
}
#[test]
fn test_negation_short_text_stays_literal() {
let parser = QueryParser::new(FileSearchConfig);
// Inner text < 3 chars is not a Not constraint
let result = parser.parse("!ab foo");
assert!(result.constraints.is_empty());
// Inner text >= 3 chars still is
let result = parser.parse("!abc foo");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(&result.constraints[0], Constraint::Not(_)));
}
#[test]
fn test_negation_extension() {
let parser = QueryParser::new(FileSearchConfig);
@@ -984,6 +1027,42 @@ mod tests {
assert_eq!(result.grep_text(), "pattern");
}
#[test]
fn test_standalone_constraints_preserve_directory() {
let result = QueryParser::new(AiGrepConfig).parse_constraints("scope-a/");
assert_eq!(result.as_slice(), &[Constraint::PathSegment("scope-a")]);
}
#[test]
fn test_plain_constraints_preserve_directory_only() {
let directory = QueryParser::new(GrepConfig).parse_constraints("scope-a/");
assert_eq!(directory.as_slice(), &[Constraint::PathSegment("scope-a")]);
let file = QueryParser::new(GrepConfig).parse_constraints("scope-a/one.txt");
assert!(file.is_empty());
}
#[test]
fn test_standalone_constraints_preserve_file() {
let result = QueryParser::new(AiGrepConfig).parse_constraints("scope-a/one.txt");
assert_eq!(
result.as_slice(),
&[Constraint::FilePath("scope-a/one.txt")]
);
}
#[test]
fn test_standalone_constraints_preserve_file_without_search_text() {
let result = QueryParser::new(AiGrepConfig).parse_constraints("scope-a/ scope-a/one.txt");
assert_eq!(
result.as_slice(),
&[
Constraint::PathSegment("scope-a"),
Constraint::FilePath("scope-a/one.txt")
]
);
}
#[test]
fn test_ai_grep_filename_with_pathsegment_only_promotes_to_text() {
// When the ONLY non-text constraints are path-scoping (PathSegment,
+17 -6
View File
@@ -1,5 +1,5 @@
*fff.nvim.txt*
For Neovim >= 0.10.0 Last change: 2026 June 30
For Neovim >= 0.10.0 Last change: 2026 July 30
==============================================================================
Table of Contents *fff.nvim-table-of-contents*
@@ -24,8 +24,9 @@ INSTALLATION ~
LAZY.NVIM
>lua
-- Package name changed from `fff.nvim` to `fff`. If you installed fff.nvim before, clean with `:Lazy clean`
{
'dmtrKovalenko/fff.nvim',
'dmtrKovalenko/fff',
build = function()
-- downloads a prebuilt binary or falls back to cargo build
require("fff.download").download_or_build_binary()
@@ -59,13 +60,14 @@ LAZY.NVIM
VIM.PACK
>lua
vim.pack.add({ 'https://github.com/dmtrKovalenko/fff.nvim' })
-- Package name changed from `fff.nvim` to `fff`. If you installed fff.nvim before, clean with `:packdel fff.nvim`
vim.pack.add({ 'https://github.com/dmtrKovalenko/fff' })
vim.api.nvim_create_autocmd('PackChanged', {
callback = function(ev)
local name, kind = ev.data.spec.name, ev.data.kind
if name == 'fff.nvim' and (kind == 'install' or kind == 'update') then
if not ev.data.active then vim.cmd.packadd('fff.nvim') end
if name == 'fff' and (kind == 'install' or kind == 'update') then
if not ev.data.active then vim.cmd.packadd('fff') end
require('fff.download').download_or_build_binary()
end
end,
@@ -199,6 +201,11 @@ Defaults are sensible. Override only what you care about.
-- Border style for the picker windows. Leave unset (nil) to follow the
-- global `vim.o.winborder`; set it to override fff's borders independently.
border = nil, -- 'single' | 'double' | 'rounded' | 'solid' | 'shadow' | 'none'
-- border = {
-- { ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ' },
-- { ' ', ' ', ' ', ' ', ' ' },
-- },
flex = { size = 130, wrap = 'top' },
min_list_height = 10, -- do not display anything except the list below this threshold
show_scrollbar = true,
@@ -232,6 +239,7 @@ Defaults are sensible. Override only what you care about.
preview_scroll_down = '<C-d>',
toggle_debug = '<F2>',
cycle_grep_modes = '<S-Tab>',
insert_newline_escape = '<C-CR>',
-- grep mode only: jump cursor to first match of next/prev file group
grep_jump_to_next_file = { '<C-A-n>', '<A-Down>' },
grep_jump_to_prev_file = { '<C-A-p>', '<A-Up>' },
@@ -254,6 +262,9 @@ Defaults are sensible. Override only what you care about.
git = {
status_text_color = false, -- true to color filenames by git status
},
file_picker = {
fuzzy_query_highlighting = false, -- true to highlight fuzzy query matches in file picker results
},
select = {
-- Return winid to open the chosen file in, or nil to open in the original window
select_window = function(current_buf, action) --[[ default impl ]] end,
@@ -315,7 +326,7 @@ Both find and grep accept these tokens to refine a query:
- `git:modified`. One of `modified`, `staged`, `deleted`, `renamed`, `untracked`, `ignored`.
- `test/`. Any deeply nested children of `test/`.
- `!something`, `!test/`, `!git:modified`. Exclusion.
- `!something`, `!test/`, `!git:modified`. Exclusion. Text exclusions need at least 3 alphanumeric-containing characters, so operators like `!=` or `!==` work.
- `./**/*.{rs,lua}`. Any valid glob, powered by zlob <https://github.com/dmtrKovalenko/zlob>.
Grep-only:
+6 -6
View File
@@ -1,5 +1,5 @@
-- Single file Neovim config for testing fff.nvim locally
-- Usage: nvim -u /Users/neogoose/dev/fff.nvim/init.lua
-- Single file Neovim config for testing fff locally
-- Usage: nvim -u /Users/neogoose/dev/fff/init.lua
-- Set up lazy.nvim plugin manager
local lazypath = vim.fn.stdpath('data') .. '/lazy/lazy.nvim'
@@ -17,8 +17,8 @@ vim.opt.rtp:prepend(lazypath)
require('lazy').setup({
{
dir = '~/dev/fff.nvim',
'https://github.com/dmtrKovalenko/fff.nvim',
dir = '~/dev/fff',
'https://github.com/dmtrKovalenko/fff',
build = function()
-- this will download prebuild binary or try to use existing rustup toolchain to build from source
-- (if you are using lazy you can use gb for rebuilding a plugin if needed)
@@ -34,7 +34,7 @@ require('lazy').setup({
},
config = function()
require('fff').setup({
-- Configure fff.nvim here
-- Configure fff here
ui = {
width = 0.8,
height = 0.8,
@@ -59,4 +59,4 @@ vim.keymap.set('n', 'fg', function() require('fff').find_in_git_root() end, { de
vim.keymap.set('n', 'fr', function() require('fff').scan_files() end, { desc = 'Rescan files' })
vim.keymap.set('n', 'fs', function() require('fff').refresh_git_status() end, { desc = 'Refresh git status' })
vim.notify('FFF.nvim local config loaded! Press ff', vim.log.levels.INFO)
vim.notify('FFF local config loaded! Press ff', vim.log.levels.INFO)
Generated
+12 -12
View File
@@ -2,11 +2,11 @@
"nodes": {
"crane": {
"locked": {
"lastModified": 1773857772,
"narHash": "sha256-5xsK26KRHf0WytBtsBnQYC/lTWDhQuT57HJ7SzuqZcM=",
"lastModified": 1785782307,
"narHash": "sha256-MPaRdVkf6zZP5fCPxYCi8Dr4pZzgmXzg8T9nVEbp3Mw=",
"owner": "ipetkov",
"repo": "crane",
"rev": "b556d7bbae5ff86e378451511873dfd07e4504cd",
"rev": "2c71e194474d13de031d729b729c968ddbe3507f",
"type": "github"
},
"original": {
@@ -51,11 +51,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1776329215,
"narHash": "sha256-a8BYi3mzoJ/AcJP8UldOx8emoPRLeWqALZWu4ZvjPXw=",
"lastModified": 1786593342,
"narHash": "sha256-smTKQXMLLStzc8zJevMCckbk3My7SvbbLmPYZUJJKW4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b86751bc4085f48661017fa226dee99fab6c651b",
"rev": "6b5e5b7a6631f065bf6908986990b37d845f847f",
"type": "github"
},
"original": {
@@ -81,11 +81,11 @@
]
},
"locked": {
"lastModified": 1773803479,
"narHash": "sha256-GD6i1F2vrSxbsmbS92+8+x3DbHOJ+yrS78Pm4xigW4M=",
"lastModified": 1786762605,
"narHash": "sha256-iQpYIhInh8gRx+cSnPtX+Yp2Gg9kr2h+MehETCqRgDo=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "f17186f52e82ec5cf40920b58eac63b78692ac7c",
"rev": "ad8ebb59d84bcf3780c46f107e1d99eb4ca2fe7f",
"type": "github"
},
"original": {
@@ -134,11 +134,11 @@
"systems": "systems_2"
},
"locked": {
"lastModified": 1776789209,
"narHash": "sha256-G6B7Q4TXn7MZ1mB+f9rymjsYF5PLWoSvmbxijb/99bw=",
"lastModified": 1786753251,
"narHash": "sha256-7UGNbG2MwgYCdsknKBL6wMXQQeiTmCiz+JS9zvcmqv4=",
"owner": "mitchellh",
"repo": "zig-overlay",
"rev": "14fe971844e841297ddd2ce9783d6892b467af39",
"rev": "a07cc99cee149044f27d95500717161fca2afdf6",
"type": "github"
},
"original": {
+5 -1
View File
@@ -1,5 +1,5 @@
{
description = "fff.nvim";
description = "fff";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
@@ -63,6 +63,10 @@
];
LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib";
# in zlob we compile by default with -target=native unless CI is detected, because
# nix is different we have to make sure that the build target is a baseline cpu
CI = "1";
# Zig 0.16 insists on writing to its global cache even when the
# zlob build.rs passes --global-cache-dir. In the nix sandbox $HOME
# is /homeless-shelter (unwritable), so redirect to $TMPDIR before
+3 -3
View File
@@ -4,7 +4,7 @@
FFF MCP Server installer for Windows.
.DESCRIPTION
Pipe usage:
irm https://raw.githubusercontent.com/dmtrKovalenko/fff.nvim/main/install-mcp.ps1 | iex
irm https://raw.githubusercontent.com/dmtrKovalenko/fff/main/install-mcp.ps1 | iex
Direct usage (supports params):
iwr https://.../install-mcp.ps1 -OutFile install-mcp.ps1; .\install-mcp.ps1 -Version v0.1.2
Env-var fallbacks (for the piped form):
@@ -29,7 +29,7 @@ $ErrorActionPreference = 'Stop'
# Force TLS 1.2 — PS 5.1 on older Win10 may default to SSL3/TLS1.0 which GitHub rejects.
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$Repo = 'dmtrKovalenko/fff.nvim'
$Repo = 'dmtrKovalenko/fff'
$BinaryName = 'fff-mcp'
if (-not $InstallDir) { $InstallDir = Join-Path $env:LOCALAPPDATA 'fff-mcp\bin' }
@@ -209,7 +209,7 @@ function Show-SetupInstructions {
if (Get-Command codex -ErrorAction SilentlyContinue) {
$foundAny = $true
Write-Success "[Codex] detected"
Write-Host "codex mcp add fff -- fff-mcp"
Write-Host "codex mcp add fff -- `"$BinaryPath`""
Write-Host ""
}
+10 -10
View File
@@ -2,20 +2,20 @@
set -eo pipefail
# FFF MCP Server installer
# Usage: curl -fsSL https://raw.githubusercontent.com/dmtrKovalenko/fff.nvim/main/install-mcp.sh | bash
# Usage: curl -fsSL https://raw.githubusercontent.com/dmtrKovalenko/fff/main/install-mcp.sh | bash
REPO="dmtrKovalenko/fff.nvim"
REPO="dmtrKovalenko/fff"
BINARY_NAME="fff-mcp"
INSTALL_DIR="${FFF_MCP_INSTALL_DIR:-$HOME/.local/bin}"
PINNED_RELEASE_TAG="v0.9.6"
PINNED_RELEASE_TAG="v0.10.4"
SHA256_X86_64_UNKNOWN_LINUX_MUSL="102ceaf173ef776becb3322216e9f6b5caef997c400c5d15f112ce4de40a1f5a"
SHA256_AARCH64_UNKNOWN_LINUX_MUSL="a9810c9056afa6d9e8ac1e7a3f1f15f8ecbfdc16a592e9d26e4b2434ef97a675"
SHA256_X86_64_APPLE_DARWIN="58259324c2c13a1b6f24f13138c2cd3eae9ff20e05201a539beb8f2044a651aa"
SHA256_AARCH64_APPLE_DARWIN="29a7fadeafb062f3e5954b1ab8c69e14dca24f5e061cd8d3b1ea1bab385a3754"
SHA256_X86_64_PC_WINDOWS_MSVC="7ff688d034aa42ff779a61ad12689794bdc253c895152796046f374390fb9cad"
SHA256_AARCH64_PC_WINDOWS_MSVC="94ce316a38775d8ed3b32882d8ef45560740319de8d7ac7bb80c31887b3afb6d"
SHA256_X86_64_UNKNOWN_LINUX_MUSL="68384121a9173b214190c594ea938cb5a1c52ca3069a3f3b1a0eecc28fd8ea82"
SHA256_AARCH64_UNKNOWN_LINUX_MUSL="334ff6f28d5ee47b9652a6c590cf47b908541ff25cb1bd3c7c3303d5f5342c40"
SHA256_X86_64_APPLE_DARWIN="821aedd5873292841f32d7584eb68fea085b248e5c970ff5d4f6dca790e9b18c"
SHA256_AARCH64_APPLE_DARWIN="f55ce2945cd30fd252a4b1e7d8674ff7ce41b104cc009a8d3d52d08bfccca2c0"
SHA256_X86_64_PC_WINDOWS_MSVC="2ff065dc7636c3d50d93a6ea55209760ed382bb6c096c01a3ba7357d6fa6b59e"
SHA256_AARCH64_PC_WINDOWS_MSVC="42244ce562fec2ff13d97d819430e028f1f2e5a0d08418f9bd24f97d060741eb"
expected_sha_for() {
case "$1" in
@@ -262,7 +262,7 @@ print_setup_instructions() {
found_any=true
success "[Codex] detected"
echo ""
echo "codex mcp add fff -- fff-mcp"
echo "codex mcp add fff -- \"${binary_path}\""
echo ""
fi
+6 -1
View File
@@ -9,7 +9,7 @@ local M = {}
--- @field min_list_height number
--- @field show_scrollbar boolean
--- @field path_shorten_strategy string
--- @field border? 'single'|'double'|'rounded'|'solid'|'shadow'|'none' Border preset; falls back to `vim.o.winborder` when nil
--- @field border? 'single'|'double'|'rounded'|'solid'|'shadow'|'none'|table<string[],string[]> Border preset; falls back to `vim.o.winborder` when nil
--- @class FffPreviewConfig
--- @field enabled boolean
@@ -34,6 +34,7 @@ local M = {}
--- @field preview_scroll_down string
--- @field toggle_debug string
--- @field cycle_grep_modes string
--- @field insert_newline_escape string
--- @field cycle_previous_query string
--- @field cycle_forward_query string
--- @field grep_jump_to_next_file string|string[]
@@ -276,6 +277,9 @@ local function init()
toggle_debug = '<F2>',
-- grep mode: cycle between plain text, regex, and fuzzy search
cycle_grep_modes = '<S-Tab>',
-- grep mode only: insert a literal `\n` to search across lines
-- (requires a terminal with extended-key support to distinguish from <CR>)
insert_newline_escape = '<C-CR>',
-- grep mode only: jump cursor to first item of next/prev file group
grep_jump_to_next_file = { '<C-A-n>', '<A-Down>' },
grep_jump_to_prev_file = { '<C-A-p>', '<A-Up>' },
@@ -414,6 +418,7 @@ local function init()
-- find_files settings
file_picker = {
current_file_label = '(current)',
fuzzy_query_highlighting = false,
},
-- grep settings
grep = {
+84 -35
View File
@@ -3,6 +3,8 @@ if not fuzzy then error('Failed to load fff.fuzzy module. Ensure the Rust backen
local M = {}
local fs_scanning_refusal
---@class fff.core.State
local state = {
---@type boolean
@@ -111,6 +113,13 @@ M.change_indexing_directory = function(new_path)
local fff_rust = M.ensure_initialized()
local config = require('fff.conf').get()
local refusal = fs_scanning_refusal(vim.tbl_extend('force', config, { base_path = expanded_path }))
if refusal then
vim.notify('FFF: ' .. refusal, vim.log.levels.WARN)
return false
end
local ok, err = pcall(fff_rust.restart_index_in_path, expanded_path, {
follow_symlinks = config.follow_symlinks,
enable_fs_root_scanning = config.enable_fs_root_scanning,
@@ -126,51 +135,91 @@ M.change_indexing_directory = function(new_path)
return true
end
--- Reset the file-picker flag so the next `ensure_initialized` recreates the
--- Rust picker. Call after `cleanup_file_picker` drops it (`FFFClearCache`);
--- otherwise the flag stays set and every later call operates on a dropped
--- picker (see #772).
M.mark_file_picker_uninitialized = function() state.file_picker_initialized = false end
M.ensure_initialized = function()
if state.initialized then return fuzzy end
state.initialized = true
local config = require('fff.conf').get()
if config.logging.enabled then
local log_success, log_error =
pcall(fuzzy.init_tracing, config.logging.log_file, config.logging.log_level, config.logging.retain_runs)
if log_success then
M.log_file_path = log_error
else
vim.notify('Failed to initialize logging: ' .. (tostring(log_error) or 'unknown error'), vim.log.levels.WARN)
end
end
local frecency_db_path = config.frecency.db_path or (vim.fn.stdpath('cache') .. '/fff_frecency')
local history_db_path = config.history.db_path or (vim.fn.stdpath('data') .. '/fff_history')
local ok, result = pcall(fuzzy.init_db, frecency_db_path, history_db_path, true)
if not ok then vim.notify('Failed to databases: ' .. tostring(result), vim.log.levels.WARN) end
ok, result = pcall(fuzzy.init_file_picker, config.base_path, {
follow_symlinks = config.follow_symlinks,
enable_fs_root_scanning = config.enable_fs_root_scanning,
enable_home_dir_scanning = config.enable_home_dir_scanning,
enable_filename_constraint = config.grep and config.grep.enable_filename_constraint,
})
if not ok then
vim.notify('Failed to initialize file picker: ' .. tostring(result), vim.log.levels.ERROR)
-- Refusal gates both one-time setup and (re)creating the picker so we never
-- index fs-root / home, even after a cache clear.
-- Some folks are complaining that neovim instance is closing if ffi returns error on startup (via lazy=false)
-- I can't repro so just precheck on lua side to prevent crashing neovim instance
local refusal = fs_scanning_refusal(config)
if refusal then
state.initialized = true
vim.notify('FFF: ' .. refusal, vim.log.levels.WARN)
return fuzzy
end
state.file_picker_initialized = true
setup_global_autocmds(config)
if not state.initialized then
state.initialized = true
if config.logging.enabled then
local log_success, log_error =
pcall(fuzzy.init_tracing, config.logging.log_file, config.logging.log_level, config.logging.retain_runs)
if log_success then
M.log_file_path = log_error
else
vim.notify('Failed to initialize logging: ' .. (tostring(log_error) or 'unknown error'), vim.log.levels.WARN)
end
end
local highlights = require('fff.highlights')
highlights.setup()
local frecency_db_path = config.frecency.db_path or (vim.fn.stdpath('cache') .. '/fff_frecency')
local history_db_path = config.history.db_path or (vim.fn.stdpath('data') .. '/fff_history')
vim.api.nvim_create_autocmd('ColorScheme', {
group = vim.api.nvim_create_augroup('fff_highlights', { clear = true }),
callback = function() highlights.setup() end,
desc = 'Re-apply FFF highlights on colorscheme change',
})
local ok, result = pcall(fuzzy.init_db, frecency_db_path, history_db_path, true)
if not ok then vim.notify('Failed to databases: ' .. tostring(result), vim.log.levels.WARN) end
setup_global_autocmds(config)
local highlights = require('fff.highlights')
highlights.setup()
vim.api.nvim_create_autocmd('ColorScheme', {
group = vim.api.nvim_create_augroup('fff_highlights', { clear = true }),
callback = function() highlights.setup() end,
desc = 'Re-apply FFF highlights on colorscheme change',
})
end
-- Recreated whenever the picker was torn down (e.g. `FFFClearCache files`).
-- Guarded separately from one-time setup so a cache clear rebuilds the
-- picker instead of leaving a dropped one behind (#772).
if not state.file_picker_initialized then
local ok, result = pcall(fuzzy.init_file_picker, config.base_path, {
follow_symlinks = config.follow_symlinks,
enable_fs_root_scanning = config.enable_fs_root_scanning,
enable_home_dir_scanning = config.enable_home_dir_scanning,
enable_filename_constraint = config.grep and config.grep.enable_filename_constraint,
})
if not ok then
vim.notify('Failed to initialize file picker: ' .. tostring(result), vim.log.levels.ERROR)
return fuzzy
end
state.file_picker_initialized = true
end
return fuzzy
end
function fs_scanning_refusal(config)
local path = vim.fn.fnamemodify(vim.fn.expand(config.base_path), ':p'):gsub('/+$', '')
if not config.enable_fs_root_scanning and (path == '' or path:match('^%a:$')) then
return 'Refusing to index filesystem root. Set enable_fs_root_scanning = true to override.'
end
if not config.enable_home_dir_scanning then
local home = (vim.fn.expand('$HOME') or ''):gsub('/+$', '')
if home ~= '' and path == home then
return 'Refusing to index home directory. Set enable_home_dir_scanning = true to override.'
end
end
return nil
end
return M
+1 -1
View File
@@ -3,7 +3,7 @@ local system = require('fff.utils.system')
local fs_utils = require('fff.utils.fs')
local fff_version = require('fff.utils.version')
local GITHUB_REPO = 'dmtrKovalenko/fff.nvim'
local GITHUB_REPO = 'dmtrKovalenko/fff'
local function get_binary_dir(plugin_dir) return plugin_dir .. '/../target/release' end
+3
View File
@@ -29,6 +29,9 @@ local function get_border_chars(config)
if border == nil or border == '' then border = vim.o.winborder end
if border == nil or border == '' then border = 'single' end
if type(border) == 'table' then
if #border == 2 and type(border[1]) == 'table' and type(border[2]) == 'table' then return border[1], border[2] end
end
if BORDER_PRESETS[border] then return BORDER_PRESETS[border], T_JUNCTION_PRESETS[border] end
return BORDER_PRESETS.single, T_JUNCTION_PRESETS.single
end

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