Compare commits

...

6 Commits

Author SHA1 Message Date
Dmitriy Kovalenko b72d024016 breaking!: Consolidate npm packages API
Finally make sure that we have one SINGLE api for both of the SDKs
(there were minor differences) and expose a single interface for both of
them + make better readme
2026-06-02 15:49:36 -07:00
Dmitriy Kovalenko 5bdc727e6d feat(sdk): Expose glob public api (#541)
* feat(sdk): Expose `glob` public api

also allow to configure the abillity to run fff in the home dir or file
sytsem root

* fix(ci): clippy lints + missing test-c-api Make target

- constraints.rs: drop redundant deref, fix GlobPattern type alias for non-zlob build
- file_picker.rs: use vec! macro for single-element ConstraintVec init (clippy)
- Makefile: add test-c-api alias to test-c-smoke for external-tests workflow
- glob-bench.ts: pageSize=100 (realistic, avoids JS marshaling overhead)
2026-06-02 13:32:47 -07:00
Gustav the Bot 394a4dcb68 fix(preview): keep cursor on match when paging with <C-d>/<C-u> (#556)
* fix(preview): keep cursor on match when paging with <C-d>/<C-u>

preview.scroll moved the cursor to the new topline, which dragged the
location highlight / cursorline away from the matched line. Page the
viewport with winrestview({topline=...}) instead so cursor stays parked
on the match.

Refs #555

* fix: Properly address the issue

---------

Co-authored-by: gustav-fff <286169375+gustav-fff@users.noreply.github.com>
Co-authored-by: Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>
2026-06-01 21:27:16 -07:00
Gustav the Bot 3e20e93d91 chore: regenerate Neovim vimdoc (#558)
Co-authored-by: Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>
2026-06-01 21:00:26 -07:00
Dmitriy Kovalenko 3bf2eea002 fix: Correctly classify all the binaries files (#557)
closes https://github.com/dmtrKovalenko/fff/issues/546

Removes all the heuristics across all the binary size detection, now we
scan every single byte up to content searchable cap of fff to detct if
the file is not a text

+ some fff lua size guard
2026-06-01 20:20:08 -07:00
Gustav the Bot ccb1b9d0c8 fix: restore user control over treesitter in preview (#367) (#508) 2026-06-01 16:45:04 -07:00
52 changed files with 4594 additions and 1298 deletions
+4 -2
View File
@@ -111,10 +111,12 @@ jobs:
shell: bash
run: make test-version
- name: Run bun tests
- name: Run non windows tests
shell: bash
if: ${{ matrix.os != 'windows-latest' }}
run: make test-bun
run: |
make test-bun
make test-c-api
- name: Install Node.js
if: ${{ matrix.os != 'ubuntu-latest' }}
Generated
+7 -13
View File
@@ -141,9 +141,9 @@ dependencies = [
[[package]]
name = "bindgen"
version = "0.70.1"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"bitflags 2.11.0",
"cexpr",
@@ -154,7 +154,7 @@ dependencies = [
"proc-macro2",
"quote",
"regex",
"rustc-hash 1.1.0",
"rustc-hash",
"shlex",
"syn",
]
@@ -676,7 +676,7 @@ dependencies = [
"log",
"notify",
"notify-types",
"rustc-hash 2.1.2",
"rustc-hash",
"walkdir",
]
@@ -1474,7 +1474,7 @@ dependencies = [
"mlua_derive",
"num-traits",
"parking_lot",
"rustc-hash 2.1.2",
"rustc-hash",
"rustversion",
]
@@ -2065,12 +2065,6 @@ dependencies = [
"syn",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -3152,9 +3146,9 @@ dependencies = [
[[package]]
name = "zlob"
version = "1.3.3"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f3522fa9701b74ec72758aedb96da278f6e0533bc16b6a79090bfb465d4661"
checksum = "466e82062db3527af78a7627a0e066f2420f8d2e573d530956fb9192956dc7b6"
dependencies = [
"bindgen",
"bitflags 2.11.0",
+1 -1
View File
@@ -30,7 +30,7 @@ heed = "0.22.0"
ignore = "0.4.22"
memmap2 = "0.9"
mimalloc = "0.1.47"
zlob = "1.3.3"
zlob = "1.4.1"
mlua = { version = "0.11.1", features = ["module", "luajit"] }
neo_frizbee = { version = "0.10.2", features = ["match_end_col"] }
+50 -3
View File
@@ -14,10 +14,38 @@ SHELL := bash
# string rather than the literal `-o` / `pipefail` tokens.
.SHELLFLAGS := -o pipefail -ec
.PHONY: build build-c-lib install uninstall test test-rust test-lua test-lua-snap test-version test-bun test-node prepare-bun prepare-node set-npm-version header test-stress test-stress-seeded test-stress-random test-stress-repos test-node-stress
.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-node set-npm-version header test-stress test-stress-seeded test-stress-random test-stress-repos test-node-stress sync-js-api sync-js-api-check
all: format test lint
# Single source of truth for the shared FileFinder TS interface lives in
# packages/shared/fff-api.ts. tsc cannot import across a package's
# rootDir and the bun package publishes its raw src/, so the file is copied
# into each package instead of symlinked.
SYNC_API_SRC := packages/shared/fff-api.ts
SYNC_API_TARGETS := packages/fff-node/src/fff-api.ts packages/fff-bun/src/fff-api.ts
SYNC_API_BANNER := // ----------------------------------------------------------------------------\n// GENERATED FILE - DO NOT EDIT.\n// Source of truth: packages/shared/fff-api.ts\n// Run make sync-js-api from the repo root to regenerate.\n// ----------------------------------------------------------------------------\n\n
sync-js-api:
@for target in $(SYNC_API_TARGETS); do \
printf '$(SYNC_API_BANNER)' > "$$target"; \
cat $(SYNC_API_SRC) >> "$$target"; \
echo "synced: $$target"; \
done
sync-js-api-check:
@status=0; \
for target in $(SYNC_API_TARGETS); do \
tmp=$$(mktemp); \
printf '$(SYNC_API_BANNER)' > "$$tmp"; \
cat $(SYNC_API_SRC) >> "$$tmp"; \
if ! cmp -s "$$tmp" "$$target"; then \
echo "out of date: $$target (run make sync-js-api)"; status=1; \
fi; \
rm -f "$$tmp"; \
done; \
exit $$status
build:
cargo build --release --features zlob
@@ -68,6 +96,23 @@ test-setup:
test-rust:
cargo test --workspace --features zlob --exclude fff-nvim
CC ?= cc
CFLAGS ?= -O0 -g -Wall -Wextra -std=c99
TARGET_DIR ?= target/release
SMOKE_BIN := $(TARGET_DIR)/fff_c_smoke
SMOKE_SRC := crates/fff-c/tests/smoke.c
SMOKE_INCLUDE := crates/fff-c/include
test-c-smoke: build-c-lib
$(CC) $(CFLAGS) -I $(SMOKE_INCLUDE) -L $(TARGET_DIR) \
-Wl,-rpath,@loader_path/../target/release \
-Wl,-rpath,$$(pwd)/$(TARGET_DIR) \
$(SMOKE_SRC) -lfff_c -o $(SMOKE_BIN)
$(SMOKE_BIN) .
# Alias kept for the `external-tests.yml` workflow naming.
test-c-api: test-c-smoke
# neovim instance swallows internal crashes and doesn't rise the the error exiting silently
# so check the stdout in case the sigsegv coming out of fff was printed (actual regression).
# Output is streamed live via `tee`; pipefail (set above) propagates nvim's exit.
@@ -106,13 +151,13 @@ test-version: test-setup
nvim --headless -u tests/minimal_init.lua \
-c "PlenaryBustedFile tests/version_spec.lua" 2>&1
prepare-bun: build
prepare-bun: build sync-js-api
mkdir -p packages/fff-bun/bin
cp target/release/libfff_c.dylib packages/fff-bun/bin/ 2>/dev/null || true; \
cp target/release/libfff_c.so packages/fff-bun/bin/ 2>/dev/null || true; \
cp target/release/fff_c.dll packages/fff-bun/bin/ 2>/dev/null || true
prepare-node: build
prepare-node: build sync-js-api
mkdir -p packages/fff-node/bin
cp target/release/libfff_c.dylib packages/fff-node/bin/ 2>/dev/null || true; \
cp target/release/libfff_c.so packages/fff-node/bin/ 2>/dev/null || true; \
@@ -125,6 +170,8 @@ test-bun: prepare-bun
test-node: prepare-node
cd packages/fff-node && npm run build && node test/e2e.mjs
test-js: test-bun test-node
# Bug pinning stress test script over fff-node for issue #515
# Just keep it untouched because it's good enough + some stress for SDK
FFF_STRESS_ITERS ?= 50
+32
View File
@@ -475,6 +475,9 @@ const hits = finder.value.grep("GetOffTheRecordProfile", {
classifyDefinitions: true,
});
// Run extremely fast glob matching which is significantly (10-100 times) faster than Bun's and Node implementation
const rustFiles = finder.value.glob("**/*.rs", { pageSize: 100 });
finder.value.destroy();
```
@@ -580,6 +583,35 @@ int main(void) {
}
```
### Versioned options struct (preferred)
For instance creation use [`FffCreateOptions`](./crates/fff-c/include/fff.h) — a
versioned struct that evolves without ABI breaks. C99 designated
initializers keep call sites readable and zero-init unspecified fields:
```c
FffResult *res = fff_create_instance_with(&(FffCreateOptions){
.version = FFF_CREATE_OPTIONS_VERSION,
.base_path = "/path/to/repo",
.ai_mode = true,
.watch = true,
.enable_fs_root_scanning = false, // off by default
.enable_home_dir_scanning = false, // off by default
});
```
### Glob-only search
`fff_glob` filters indexed files by a single glob pattern, ranks by frecency,
paginates — bypasses the regular query parser entirely. Use this when you
already have a literal glob (`*.rs`, `**/*.test.ts`, `src/**`) and don't want
fuzzy matching layered on top.
```c
FffResult *res = fff_glob(handle, "**/*.rs", "", 0, 0, 100);
// FffSearchResult in res->handle, free with fff_free_search_result.
```
### Notes
- Every function returning `FffResult*` allocates with Rust's `Box`. Free with `fff_free_result`, do not use malloc's free
+5
View File
@@ -26,3 +26,8 @@ include = [
[fn]
sort_by = "None"
# Translate `#[deprecated]` on extern "C" fns into a real C compiler
# attribute so callers get a warning when they use a removed/legacy entry.
# `{}` is substituted with the Rust deprecation note as a C string literal
# (already quoted) — do not wrap in extra quotes.
deprecated_with_note = "__attribute__((deprecated({})))"
+176 -42
View File
@@ -9,10 +9,17 @@
#include <stdbool.h>
#include <stddef.h>
/**
* Current used version of [`FffCreateOptions`].
*/
#define FFF_CREATE_OPTIONS_VERSION 1
/**
* Result envelope returned by all `fff_*` functions.
*
* Heap-allocated — the caller must free it with `fff_free_result`.
* Heap-allocated. The caller must free it with `fff_free_result`. Calling `fff_free_result`
* **does not** deallocate the underlying `handle` pointer. It needs to be cleaned separately.
* see (`fff_destroy`, `fff_free_search_result`, `fff_free_grep_result`, `fff_free_string`, etc.).
*
* Depending on the function, the payload is delivered through different fields:
*
@@ -32,11 +39,6 @@
* | `fff_restart_index` | (none) | success flag only |
*
* On failure, `success` is false and `error` contains the message.
*
* **Important:** `fff_free_result` frees `error` but does **not** free `handle`.
* The caller must free the handle with the appropriate function
* (`fff_destroy`, `fff_free_search_result`, `fff_free_grep_result`,
* `fff_free_string`, etc.).
*/
typedef struct FffResult {
/**
@@ -48,7 +50,7 @@ typedef struct FffResult {
*/
char *error;
/**
* Opaque pointer payload (instance handle, typed result struct, or string). May be null.
* Opaque pointer payload. May be null.
*/
void *handle;
/**
@@ -57,6 +59,79 @@ typedef struct FffResult {
int64_t int_value;
} FffResult;
/**
* 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!
*/
typedef struct FffCreateOptions {
/**
* Set to [`FFF_CREATE_OPTIONS_VERSION`] when allocating. Used by the
* library to determine which trailing fields are populated.
*/
uint32_t version;
/**
* Directory to index (required, non-NULL).
*/
const char *base_path;
/**
* Frecency LMDB database path. NULL/empty to skip frecency tracking.
*/
const char *frecency_db_path;
/**
* Query history LMDB database path. NULL/empty to skip query tracking.
*/
const char *history_db_path;
/**
* Pre-populate mmap caches for top-frecency files after the initial scan.
*/
bool enable_mmap_cache;
/**
* Build content index after the initial scan for faster grep.
*/
bool enable_content_indexing;
/**
* Start a background file-system watcher for live updates.
*/
bool watch;
/**
* Enable AI-agent optimizations.
*/
bool ai_mode;
/**
* Tracing log file path. NULL/empty to skip log init.
*/
const char *log_file_path;
/**
* Log level: `"trace" | "debug" | "info" | "warn" | "error"`.
* NULL/empty defaults to `"info"`. Ignored when `log_file_path` is unset.
*/
const char *log_level;
/**
* Content cache file-count cap. 0 = auto.
*/
uint64_t cache_budget_max_files;
/**
* Content cache byte cap. 0 = auto.
*/
uint64_t cache_budget_max_bytes;
/**
* Per-file byte cap inside the content cache. 0 = auto.
*/
uint64_t cache_budget_max_file_size;
/**
* Allow indexing the filesystem root (`/`). Off by default — root is
* rarely the intended target and floods the watcher with churn.
*/
bool enable_fs_root_scanning;
/**
* Allow indexing the user's home directory. Same trade-off as
* `enable_fs_root_scanning`.
*/
bool enable_home_dir_scanning;
} FffCreateOptions;
/**
* A file item returned by `fff_search`.
*
@@ -346,18 +421,18 @@ typedef struct FffMixedSearchResult {
} FffMixedSearchResult;
/**
* Create a new file finder instance (legacy signature).
* Create a new file finder instance (legacy 8-arg positional signature).
*
* @deprecated prefer `fff_create_instance2`, which also exposes log file and
* cache-budget configuration. This function delegates to `fff_create_instance2`
* with NULL log paths and auto cache budget, so behaviour is unchanged.
*
* The `use_unsafe_no_lock` parameter is deprecated and ignored; see
* [`fff_create_instance2`] for details.
* @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.
*
* ## Safety
* See `fff_create_instance2`.
* See `fff_create_instance_with`.
*/
__attribute__((deprecated("Use fff_create_instance_with (by pointer) or fff_create_instance_with_value (by value) with FffCreateOptions instead. The struct evolves without ABI breaks.")))
struct FffResult *fff_create_instance(const char *base_path,
const char *frecency_db_path,
const char *history_db_path,
@@ -368,37 +443,17 @@ struct FffResult *fff_create_instance(const char *base_path,
bool ai_mode);
/**
* Create a new file finder instance (v2, with full options).
* Create a new file finder instance (legacy 13-arg positional signature).
*
* Returns an opaque pointer that must be passed to all other `fff_*` calls
* and eventually freed with `fff_destroy`.
*
* # Parameters
*
* * `base_path` directory to index (required)
* * `frecency_db_path` frecency LMDB database path (NULL/empty to skip)
* * `history_db_path` query history LMDB database path (NULL/empty to skip)
* * `use_unsafe_no_lock` **deprecated, ignored.** Previously enabled
* * `enable_mmap_cache` pre-populate mmap caches after the initial scan
* * `enable_content_indexing` build content index after the initial scan
* * `watch` start a background file-system watcher for live updates
* * `ai_mode` enable AI-agent optimizations
* * `log_file_path` tracing log file path (NULL/empty to skip).
* Only the first successful call in a process installs the subscriber;
* subsequent calls are no-ops at the log layer.
* * `log_level` `"trace"`, `"debug"`, `"info"`, `"warn"`, `"error"`
* (NULL/empty defaults to `"info"`). Ignored when `log_file_path` is not set.
* * `cache_budget_max_files` content cache file-count cap (0 = auto)
* * `cache_budget_max_bytes` content cache byte cap (0 = auto)
* * `cache_budget_max_file_size` per-file byte cap (0 = auto)
*
* 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.
* @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.
*
* ## Safety
* String parameters must be valid null-terminated UTF-8 or NULL.
* See `fff_create_instance_with`.
*/
__attribute__((deprecated("Use fff_create_instance_with (by pointer) or fff_create_instance_with_value (by value) with FffCreateOptions instead. The struct evolves without ABI breaks.")))
struct FffResult *fff_create_instance2(const char *base_path,
const char *frecency_db_path,
const char *history_db_path,
@@ -413,6 +468,50 @@ struct FffResult *fff_create_instance2(const char *base_path,
uint64_t cache_budget_max_bytes,
uint64_t cache_budget_max_file_size);
/**
* Create a new file finder instance from an [`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.
*
* **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.
*
* ## Safety
* * `opts` must be a valid pointer to an `FffCreateOptions` whose `version`
* is in the range `1..=FFF_CREATE_OPTIONS_VERSION`.
* * All string pointers inside `opts` must be valid null-terminated UTF-8
* or NULL.
*/
struct FffResult *fff_create_instance_with(const struct FffCreateOptions *opts);
/**
* 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.
*
* ## Safety
* All `*const c_char` fields inside `opts` must be valid null-terminated
* UTF-8 or NULL. The struct itself is consumed by value.
*/
struct FffResult *fff_create_instance_with_value(struct FffCreateOptions opts);
/**
* Destroy a file finder instance and free all its resources.
*
@@ -448,6 +547,35 @@ struct FffResult *fff_search(void *fff_handle,
int32_t combo_boost_multiplier,
uint32_t min_combo_count);
/**
* Glob-only search: filter indexed files by a single glob pattern, rank by
* frecency, and paginate. Bypasses the regular query parser entirely.
*
* 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)
*
* ## Safety
* * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
* * `pattern` and `current_file` must be valid null-terminated UTF-8 strings or NULL.
*/
struct FffResult *fff_glob(void *fff_handle,
const char *pattern,
const char *current_file,
uint32_t max_threads,
uint32_t page_index,
uint32_t page_size);
/**
* Perform fuzzy search on indexed directories.
*
@@ -752,6 +880,12 @@ const void *fff_ptr_offset(const void *base, uintptr_t byte_offset);
/**
* 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.
*
* ## Safety
* `result_ptr` must be a valid pointer returned by a `fff_*` function.
+104 -7
View File
@@ -14,6 +14,75 @@ use fff::{
MixedSearchResult, Score, SearchResult,
};
/// Current used version of [`FffCreateOptions`].
pub const FFF_CREATE_OPTIONS_VERSION: u32 = 1;
/// Options for `fff_create_instance_with`.
///
/// Versioned struct: you populate the struct at your call level, we guarantee that
/// the version is stable across the version changes, new fields only appended!
#[repr(C)]
pub struct FffCreateOptions {
/// Set to [`FFF_CREATE_OPTIONS_VERSION`] when allocating. Used by the
/// library to determine which trailing fields are populated.
pub version: u32,
/// Directory to index (required, non-NULL).
pub base_path: *const c_char,
/// Frecency LMDB database path. NULL/empty to skip frecency tracking.
pub frecency_db_path: *const c_char,
/// Query history LMDB database path. NULL/empty to skip query tracking.
pub history_db_path: *const c_char,
/// Pre-populate mmap caches for top-frecency files after the initial scan.
pub enable_mmap_cache: bool,
/// Build content index after the initial scan for faster grep.
pub enable_content_indexing: bool,
/// Start a background file-system watcher for live updates.
pub watch: bool,
/// Enable AI-agent optimizations.
pub ai_mode: bool,
/// Tracing log file path. NULL/empty to skip log init.
pub log_file_path: *const c_char,
/// Log level: `"trace" | "debug" | "info" | "warn" | "error"`.
/// NULL/empty defaults to `"info"`. Ignored when `log_file_path` is unset.
pub log_level: *const c_char,
/// Content cache file-count cap. 0 = auto.
pub cache_budget_max_files: u64,
/// Content cache byte cap. 0 = auto.
pub cache_budget_max_bytes: u64,
/// Per-file byte cap inside the content cache. 0 = auto.
pub cache_budget_max_file_size: u64,
/// Allow indexing the filesystem root (`/`). Off by default — root is
/// rarely the intended target and floods the watcher with churn.
pub enable_fs_root_scanning: bool,
/// Allow indexing the user's home directory. Same trade-off as
/// `enable_fs_root_scanning`.
pub enable_home_dir_scanning: bool,
// ----- new version 2+ fields go here, ALWAYS appended -----
}
impl FffCreateOptions {
/// Default values for a v1 options struct.
pub fn defaults() -> Self {
Self {
version: FFF_CREATE_OPTIONS_VERSION,
base_path: ptr::null(),
frecency_db_path: ptr::null(),
history_db_path: ptr::null(),
enable_mmap_cache: true,
enable_content_indexing: true,
watch: true,
ai_mode: false,
log_file_path: ptr::null(),
log_level: ptr::null(),
cache_budget_max_files: 0,
cache_budget_max_bytes: 0,
cache_budget_max_file_size: 0,
enable_fs_root_scanning: false,
enable_home_dir_scanning: false,
}
}
}
/// Allocate a heap CString from a `&str`, returning a raw pointer.
fn cstring_new(s: &str) -> *mut c_char {
CString::new(s).unwrap_or_default().into_raw()
@@ -420,7 +489,9 @@ impl FffGrepResult {
/// Result envelope returned by all `fff_*` functions.
///
/// Heap-allocated — the caller must free it with `fff_free_result`.
/// Heap-allocated. The caller must free it with `fff_free_result`. Calling `fff_free_result`
/// **does not** deallocate the underlying `handle` pointer. It needs to be cleaned separately.
/// see (`fff_destroy`, `fff_free_search_result`, `fff_free_grep_result`, `fff_free_string`, etc.).
///
/// Depending on the function, the payload is delivered through different fields:
///
@@ -440,18 +511,13 @@ impl FffGrepResult {
/// | `fff_restart_index` | (none) | success flag only |
///
/// On failure, `success` is false and `error` contains the message.
///
/// **Important:** `fff_free_result` frees `error` but does **not** free `handle`.
/// The caller must free the handle with the appropriate function
/// (`fff_destroy`, `fff_free_search_result`, `fff_free_grep_result`,
/// `fff_free_string`, etc.).
#[repr(C)]
pub struct FffResult {
/// Whether the operation succeeded.
pub success: bool,
/// Error message on failure. Null on success.
pub error: *mut c_char,
/// Opaque pointer payload (instance handle, typed result struct, or string). May be null.
/// Opaque pointer payload. May be null.
pub handle: *mut c_void,
/// Integer payload for simple return values (bool as 0/1, counts, etc.).
pub int_value: i64,
@@ -725,3 +791,34 @@ impl From<fff::file_picker::ScanProgress> for FffScanProgress {
}
}
}
#[cfg(test)]
mod options_layout_tests {
use super::FffCreateOptions;
use std::mem::{align_of, offset_of, size_of};
// THIS TEST HAVE TO BE NEVER UPDATED ONLY ADDED NEW FIELDS
// this is needed to ensure ABI backward compatibility
#[test]
#[cfg(target_pointer_width = "64")]
fn fff_create_options_layout_is_stable_64bit() {
assert_eq!(size_of::<FffCreateOptions>(), 88);
assert_eq!(align_of::<FffCreateOptions>(), 8);
assert_eq!(offset_of!(FffCreateOptions, version), 0);
assert_eq!(offset_of!(FffCreateOptions, base_path), 8);
assert_eq!(offset_of!(FffCreateOptions, frecency_db_path), 16);
assert_eq!(offset_of!(FffCreateOptions, history_db_path), 24);
assert_eq!(offset_of!(FffCreateOptions, enable_mmap_cache), 32);
assert_eq!(offset_of!(FffCreateOptions, enable_content_indexing), 33);
assert_eq!(offset_of!(FffCreateOptions, watch), 34);
assert_eq!(offset_of!(FffCreateOptions, ai_mode), 35);
assert_eq!(offset_of!(FffCreateOptions, log_file_path), 40);
assert_eq!(offset_of!(FffCreateOptions, log_level), 48);
assert_eq!(offset_of!(FffCreateOptions, cache_budget_max_files), 56);
assert_eq!(offset_of!(FffCreateOptions, cache_budget_max_bytes), 64);
assert_eq!(offset_of!(FffCreateOptions, cache_budget_max_file_size), 72);
assert_eq!(offset_of!(FffCreateOptions, enable_fs_root_scanning), 80);
assert_eq!(offset_of!(FffCreateOptions, enable_home_dir_scanning), 81);
}
}
+212 -85
View File
@@ -37,8 +37,9 @@ use fff::query_tracker::QueryTracker;
use fff::{DbHealthChecker, FFFMode, FuzzySearchOptions, PaginationArgs, QueryParser};
use fff::{SharedFilePicker, SharedFrecency};
use ffi_types::{
FffDirItem, FffDirSearchResult, FffFileItem, FffGrepMatch, FffGrepResult, FffMixedItem,
FffMixedSearchResult, FffResult, FffScanProgress, FffScore, FffSearchResult,
FFF_CREATE_OPTIONS_VERSION, FffCreateOptions, FffDirItem, FffDirSearchResult, FffFileItem,
FffGrepMatch, FffGrepResult, FffMixedItem, FffMixedSearchResult, FffResult, FffScanProgress,
FffScore, FffSearchResult,
};
/// Opaque fff_handle holding all per-instance state.
@@ -104,17 +105,20 @@ fn default_i32(val: i32, default: i32) -> i32 {
if val == 0 { default } else { val }
}
/// Create a new file finder instance (legacy signature).
/// Create a new file finder instance (legacy 8-arg positional signature).
///
/// @deprecated prefer `fff_create_instance2`, which also exposes log file and
/// cache-budget configuration. This function delegates to `fff_create_instance2`
/// with NULL log paths and auto cache budget, so behaviour is unchanged.
///
/// The `use_unsafe_no_lock` parameter is deprecated and ignored; see
/// [`fff_create_instance2`] for details.
/// @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.
///
/// ## Safety
/// See `fff_create_instance2`.
/// See `fff_create_instance_with`.
#[deprecated(
since = "0.8.5",
note = "Use fff_create_instance_with (by pointer) or fff_create_instance_with_value (by value) with FffCreateOptions instead. The struct evolves without ABI breaks."
)]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_create_instance(
base_path: *const c_char,
@@ -126,59 +130,30 @@ pub unsafe extern "C" fn fff_create_instance(
watch: bool,
ai_mode: bool,
) -> *mut FffResult {
unsafe {
fff_create_instance2(
base_path,
frecency_db_path,
history_db_path,
false,
enable_mmap_cache,
enable_content_indexing,
watch,
ai_mode,
std::ptr::null(),
std::ptr::null(),
0,
0,
0,
)
}
let mut opts = FffCreateOptions::defaults();
opts.base_path = base_path;
opts.frecency_db_path = frecency_db_path;
opts.history_db_path = history_db_path;
opts.enable_mmap_cache = enable_mmap_cache;
opts.enable_content_indexing = enable_content_indexing;
opts.watch = watch;
opts.ai_mode = ai_mode;
unsafe { fff_create_instance_with(&opts as *const FffCreateOptions) }
}
/// Create a new file finder instance (v2, with full options).
/// Create a new file finder instance (legacy 13-arg positional signature).
///
/// Returns an opaque pointer that must be passed to all other `fff_*` calls
/// and eventually freed with `fff_destroy`.
///
/// # Parameters
///
/// * `base_path` directory to index (required)
/// * `frecency_db_path` frecency LMDB database path (NULL/empty to skip)
/// * `history_db_path` query history LMDB database path (NULL/empty to skip)
/// * `use_unsafe_no_lock` **deprecated, ignored.** Previously enabled
/// `MDB_NOLOCK|MDB_NOSYNC|MDB_NOMETASYNC` for LMDB; benchmarks showed no
/// measurable win under realistic contention, so the flag is now a no-op.
/// The parameter remains in the signature for ABI compatibility and will be
/// removed in a future release.
/// * `enable_mmap_cache` pre-populate mmap caches after the initial scan
/// * `enable_content_indexing` build content index after the initial scan
/// * `watch` start a background file-system watcher for live updates
/// * `ai_mode` enable AI-agent optimizations
/// * `log_file_path` tracing log file path (NULL/empty to skip).
/// Only the first successful call in a process installs the subscriber;
/// subsequent calls are no-ops at the log layer.
/// * `log_level` `"trace"`, `"debug"`, `"info"`, `"warn"`, `"error"`
/// (NULL/empty defaults to `"info"`). Ignored when `log_file_path` is not set.
/// * `cache_budget_max_files` content cache file-count cap (0 = auto)
/// * `cache_budget_max_bytes` content cache byte cap (0 = auto)
/// * `cache_budget_max_file_size` per-file byte cap (0 = auto)
///
/// 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.
/// @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.
///
/// ## Safety
/// String parameters must be valid null-terminated UTF-8 or NULL.
/// See `fff_create_instance_with`.
#[deprecated(
since = "0.8.5",
note = "Use fff_create_instance_with (by pointer) or fff_create_instance_with_value (by value) with FffCreateOptions instead. The struct evolves without ABI breaks."
)]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_create_instance2(
base_path: *const c_char,
@@ -195,27 +170,76 @@ pub unsafe extern "C" fn fff_create_instance2(
cache_budget_max_bytes: u64,
cache_budget_max_file_size: u64,
) -> *mut FffResult {
let base_path_str = match unsafe { cstr_to_str(base_path) } {
let mut opts = FffCreateOptions::defaults();
opts.base_path = base_path;
opts.frecency_db_path = frecency_db_path;
opts.history_db_path = history_db_path;
opts.enable_mmap_cache = enable_mmap_cache;
opts.enable_content_indexing = enable_content_indexing;
opts.watch = watch;
opts.ai_mode = ai_mode;
opts.log_file_path = log_file_path;
opts.log_level = log_level;
opts.cache_budget_max_files = cache_budget_max_files;
opts.cache_budget_max_bytes = cache_budget_max_bytes;
opts.cache_budget_max_file_size = cache_budget_max_file_size;
unsafe { fff_create_instance_with(&opts as *const FffCreateOptions) }
}
/// Create a new file finder instance from an [`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.
///
/// **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.
///
/// ## Safety
/// * `opts` must be a valid pointer to an `FffCreateOptions` whose `version`
/// is in the range `1..=FFF_CREATE_OPTIONS_VERSION`.
/// * All string pointers inside `opts` must be valid null-terminated UTF-8
/// or NULL.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_create_instance_with(opts: *const FffCreateOptions) -> *mut FffResult {
if opts.is_null() {
return FffResult::err("opts is null");
}
let opts = unsafe { &*opts };
if opts.version == 0 || opts.version > FFF_CREATE_OPTIONS_VERSION {
return FffResult::err(&format!(
"Unsupported FffCreateOptions version {} (library understands up to {})",
opts.version, FFF_CREATE_OPTIONS_VERSION
));
}
let base_path_str = match unsafe { cstr_to_str(opts.base_path) } {
Some(s) if !s.is_empty() => s.to_string(),
_ => return FffResult::err("base_path is null or empty"),
_ => return FffResult::err("opts.base_path is null or empty"),
};
if let Some(log_path) = unsafe { optional_cstr(log_file_path) } {
let level = unsafe { optional_cstr(log_level) };
if let Some(log_path) = unsafe { optional_cstr(opts.log_file_path) } {
let level = unsafe { optional_cstr(opts.log_level) };
if let Err(e) = fff::log::init_tracing(log_path, level) {
return FffResult::err(&format!("Failed to init tracing: {}", e));
}
}
let frecency_path = unsafe { optional_cstr(frecency_db_path) }.map(|s| s.to_string());
let history_path = unsafe { optional_cstr(history_db_path) }.map(|s| s.to_string());
let frecency_path = unsafe { optional_cstr(opts.frecency_db_path) }.map(|s| s.to_string());
let history_path = unsafe { optional_cstr(opts.history_db_path) }.map(|s| s.to_string());
// Create shared state that background threads will write into.
let shared_picker = SharedFilePicker::default();
let shared_frecency = SharedFrecency::default();
let query_tracker = SharedQueryTracker::default();
// Initialize frecency tracker if path is provided
if let Some(ref frecency_path) = frecency_path {
if let Some(parent) = PathBuf::from(frecency_path).parent() {
let _ = std::fs::create_dir_all(parent);
@@ -231,7 +255,6 @@ pub unsafe extern "C" fn fff_create_instance2(
}
}
// Initialize query tracker if path is provided
if let Some(ref history_path) = history_path {
if let Some(parent) = PathBuf::from(history_path).parent() {
let _ = std::fs::create_dir_all(parent);
@@ -247,30 +270,31 @@ pub unsafe extern "C" fn fff_create_instance2(
}
}
let mode = if ai_mode {
let mode = if opts.ai_mode {
FFFMode::Ai
} else {
FFFMode::Neovim
};
let cache_budget = fff::ContentCacheBudget::from_overrides(
cache_budget_max_files as usize,
cache_budget_max_bytes,
cache_budget_max_file_size,
opts.cache_budget_max_files as usize,
opts.cache_budget_max_bytes,
opts.cache_budget_max_file_size,
);
// Initialize file picker (writes directly into shared_picker)
if let Err(e) = FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
fff::FilePickerOptions {
base_path: base_path_str,
enable_mmap_cache,
enable_content_indexing,
watch,
enable_mmap_cache: opts.enable_mmap_cache,
enable_content_indexing: opts.enable_content_indexing,
watch: opts.watch,
mode,
cache_budget,
follow_symlinks: false,
enable_fs_root_scanning: opts.enable_fs_root_scanning,
enable_home_dir_scanning: opts.enable_home_dir_scanning,
},
) {
return FffResult::err(&format!("Failed to init file picker: {}", e));
@@ -286,6 +310,25 @@ pub unsafe extern "C" fn fff_create_instance2(
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.
///
/// ## Safety
/// All `*const c_char` fields inside `opts` must be valid null-terminated
/// UTF-8 or NULL. The struct itself is consumed by value.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_create_instance_with_value(opts: FffCreateOptions) -> *mut FffResult {
unsafe { fff_create_instance_with(&opts as *const FffCreateOptions) }
}
/// Destroy a file finder instance and free all its resources.
///
/// ## Safety
@@ -396,6 +439,79 @@ 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.
///
/// 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)
///
/// ## Safety
/// * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
/// * `pattern` and `current_file` must be valid null-terminated UTF-8 strings or NULL.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_glob(
fff_handle: *mut c_void,
pattern: *const c_char,
current_file: *const c_char,
max_threads: u32,
page_index: u32,
page_size: u32,
) -> *mut FffResult {
let inst = match unsafe { instance_ref(fff_handle) } {
Ok(i) => i,
Err(e) => return e,
};
let pattern_str = match unsafe { cstr_to_str(pattern) } {
Some(s) if !s.is_empty() => s,
_ => return FffResult::err("Pattern is null, empty, or invalid UTF-8"),
};
let current_file_str = unsafe { optional_cstr(current_file) };
let page_size = default_u32(page_size, 100) as usize;
let picker_guard = match inst.picker.read() {
Ok(g) => g,
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
};
let picker = match picker_guard.as_ref() {
Some(p) => p,
None => {
return FffResult::err("File picker not initialized. Call fff_create_instance first.");
}
};
let results = picker.glob(
pattern_str,
FuzzySearchOptions {
max_threads: max_threads as usize,
current_file: current_file_str,
project_path: Some(picker.base_path()),
combo_boost_score_multiplier: 0,
min_combo_count: 0,
pagination: PaginationArgs {
offset: page_index as usize,
limit: page_size,
},
},
);
let search_result = FffSearchResult::from_core(&results, picker);
FffResult::ok_handle(search_result as *mut c_void)
}
/// Perform fuzzy search on indexed directories.
///
/// # Parameters
@@ -902,16 +1018,19 @@ pub unsafe extern "C" fn fff_restart_index(
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
};
let (warmup_caches, content_indexing, watch, mode) = if let Some(ref picker) = *guard {
(
picker.has_mmap_cache(),
picker.has_content_indexing(),
picker.has_watcher(),
picker.mode(),
)
} else {
(false, true, true, FFFMode::default())
};
let (warmup_caches, content_indexing, watch, mode, fs_root, home_dir) =
if let Some(ref picker) = *guard {
(
picker.has_mmap_cache(),
picker.has_content_indexing(),
picker.has_watcher(),
picker.mode(),
picker.fs_root_scanning_enabled(),
picker.home_dir_scanning_enabled(),
)
} else {
(false, true, true, FFFMode::default(), false, false)
};
drop(guard);
@@ -926,6 +1045,8 @@ pub unsafe extern "C" fn fff_restart_index(
mode,
cache_budget: None,
follow_symlinks: false,
enable_fs_root_scanning: fs_root,
enable_home_dir_scanning: home_dir,
},
) {
Ok(()) => FffResult::ok_empty(),
@@ -1393,6 +1514,12 @@ pub unsafe extern "C" fn fff_ptr_offset(base: *const c_void, byte_offset: usize)
}
/// 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.
///
/// ## Safety
/// `result_ptr` must be a valid pointer returned by a `fff_*` function.
+89
View File
@@ -0,0 +1,89 @@
/*
* Smoke test for libfff_c — the smallest possible end-to-end exercise of
* the public C API. We:
*
* 1. Create a picker with an `FffCreateOptions` populated via C99
* designated initializers (the recommended idiom for direct C use).
* 2. Wait for the initial scan to complete.
* 3. Search for "smoke.c".
* 4. Fail unless this very file appears in the results.
*
* Build + run via `make test-c-smoke`. Override $(CC) to test other
* compilers.
*/
#include <fff.h>
#include <stdio.h>
#include <string.h>
int main(int argc, char **argv) {
const char *base_path = argc > 1 ? argv[1] : ".";
// make sure that FFF C api is designed more for FFI rather than for direct C usage (I'm sorry)
struct FffResult *create_result = fff_create_instance_with(&(struct FffCreateOptions){
.version = FFF_CREATE_OPTIONS_VERSION,
.base_path = base_path,
.enable_mmap_cache = false,
.enable_content_indexing = false,
.watch = false,
});
if (!create_result->success) {
fprintf(stderr, "fff couldn't create instance: %s\n",
create_result->error ? create_result->error : "?");
fff_free_result(create_result);
return 1;
}
void *file_picker = create_result->handle;
fff_free_result(create_result); // safe to drop now: handle outlives the envelope
struct FffResult *scan_result = fff_wait_for_scan(file_picker, 5000);
if (!scan_result->success) {
fprintf(stderr, "wait_for_scan failed: %s\n",
scan_result->error ? scan_result->error : "?");
fff_free_result(scan_result);
fff_destroy(file_picker);
return 1;
}
// int_value: 1 = scan completed in time, 0 = timed out.
if (scan_result->int_value == 0) {
fprintf(stderr, "wait_for_scan: timed out before initial scan finished\n");
fff_free_result(scan_result);
fff_destroy(file_picker);
return 1;
}
fff_free_result(scan_result);
struct FffResult *res = fff_search(file_picker, "smkoe.c", "", 0, 0, 50, 0, 0);
if (!res->success) {
fprintf(stderr, "search failed: %s\n", res->error ? res->error : "?");
fff_free_result(res);
fff_destroy(file_picker);
return 1;
}
struct FffSearchResult *sr = (struct FffSearchResult *)res->handle;
uint32_t total = sr->count;
int found = 0;
for (uint32_t i = 0; i < sr->count; i++) {
const char *path = sr->items[i].relative_path;
if (path && strstr(path, "smoke.c")) {
found = 1;
fprintf(stderr, "found self: %s\n", path);
break;
}
}
fff_free_search_result(sr);
fff_free_result(res);
fff_destroy(file_picker);
if (!found) {
fprintf(stderr, "FAIL: smoke.c not in search results (count=%u)\n", total);
return 1;
}
fprintf(stderr, "PASS\n");
return 0;
}
+5
View File
@@ -22,6 +22,11 @@ harness = false
name = "memmem_bench"
harness = false
[[bench]]
name = "glob_bench"
harness = false
required-features = ["zlob"]
[features]
default = []
# Enable C FFI exports
+386
View File
@@ -0,0 +1,386 @@
//! Compare three glob-matching strategies for `match_glob_pattern` in constraints.rs:
//!
//! 1. Current: `zlob_match_paths` -> collect `as_ptr()` into AHashSet, filter paths
//! by pointer to recover indices.
//! 2. Free fn: `zlob_match_paths_indices` (added in zlob 1.4) — indices direct from C.
//! 3. Compiled: `ZlobPattern::compile` + `match_indices` — same indices path, but with
//! a precompiled pattern (reusable). For one-shot it should match (2); the win
//! appears if the pattern is reused (chunked / repeated calls).
use ahash::AHashSet;
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use zlob::{ZlobFlags, ZlobPattern, zlob_match_paths, zlob_match_paths_indices};
fn make_paths(n: usize) -> Vec<String> {
let exts = ["rs", "ts", "lua", "md", "toml", "go", "py", "c", "h", "txt"];
let dirs = [
"src/core",
"src/ui",
"crates/fff-core/src",
"lua/fff",
"tests/integration",
"vendor/lib",
"node_modules/foo/bar",
"docs/internal",
];
let mut out = Vec::with_capacity(n);
for i in 0..n {
let dir = dirs[i % dirs.len()];
let ext = exts[i % exts.len()];
out.push(format!("{dir}/file_{i}.{ext}"));
}
out
}
fn current_impl(pattern: &str, paths: &[&str]) -> AHashSet<usize> {
let Ok(Some(matches)) = zlob_match_paths(pattern, paths, ZlobFlags::RECOMMENDED) else {
return AHashSet::new();
};
let matched_set: AHashSet<usize> = matches.iter().map(|s| s.as_ptr() as usize).collect();
paths
.iter()
.enumerate()
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
.map(|(i, _)| i)
.collect()
}
fn indices_free_fn(pattern: &str, paths: &[&str]) -> AHashSet<usize> {
let Ok(hits) = zlob_match_paths_indices(pattern, paths, ZlobFlags::RECOMMENDED) else {
return AHashSet::new();
};
hits.to_iter().collect()
}
fn compiled_pattern(pattern: &str, paths: &[&str]) -> AHashSet<usize> {
let Ok(p) = ZlobPattern::compile(pattern, ZlobFlags::RECOMMENDED) else {
return AHashSet::new();
};
let Ok(hits) = p.match_indices(paths, ZlobFlags::RECOMMENDED) else {
return AHashSet::new();
};
hits.to_iter().collect()
}
fn bench_glob_strategies(c: &mut Criterion) {
let path_counts = [1_000usize, 10_000, 100_000];
let patterns: &[(&str, &str)] = &[
("ext_rs", "**/*.rs"),
("dir_glob", "src/**/*.{ts,lua}"),
("literal_seg", "**/node_modules/**"),
("brace_multi", "**/*.{rs,ts,lua,md}"),
];
for &count in &path_counts {
let owned = make_paths(count);
let paths: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
let mut group = c.benchmark_group(format!("glob_{count}"));
group.sample_size(50);
for &(name, pat) in patterns {
let id_curr = BenchmarkId::new("current_ptr_trick", name);
group.bench_with_input(id_curr, &pat, |b, &pat| {
b.iter(|| {
let r = current_impl(black_box(pat), black_box(&paths));
black_box(r);
});
});
let id_idx = BenchmarkId::new("match_indices_fn", name);
group.bench_with_input(id_idx, &pat, |b, &pat| {
b.iter(|| {
let r = indices_free_fn(black_box(pat), black_box(&paths));
black_box(r);
});
});
let id_comp = BenchmarkId::new("compiled_pattern", name);
group.bench_with_input(id_comp, &pat, |b, &pat| {
b.iter(|| {
let r = compiled_pattern(black_box(pat), black_box(&paths));
black_box(r);
});
});
}
group.finish();
}
}
/// Hot-loop: pattern compiled ONCE, matched many times against fresh path slices.
/// Models a hypothetical change where we cache compiled patterns across calls.
fn bench_compiled_reuse(c: &mut Criterion) {
let owned = make_paths(10_000);
let paths: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
let pat = "**/*.{rs,ts,lua,md}";
let mut group = c.benchmark_group("glob_reuse_10k");
group.sample_size(100);
group.bench_function("recompile_each_time", |b| {
b.iter(|| {
let p = ZlobPattern::compile(black_box(pat), ZlobFlags::RECOMMENDED).unwrap();
let hits = p
.match_indices(black_box(&paths), ZlobFlags::RECOMMENDED)
.unwrap();
black_box(hits.len());
});
});
let compiled = ZlobPattern::compile(pat, ZlobFlags::RECOMMENDED).unwrap();
group.bench_function("reuse_compiled", |b| {
b.iter(|| {
let hits = compiled
.match_indices(black_box(&paths), ZlobFlags::RECOMMENDED)
.unwrap();
black_box(hits.len());
});
});
group.finish();
}
/// End-to-end: build the lookup AND iterate items checking membership, modeling the
/// real call shape in `apply_constraints` (filter loop reads the result for every item).
fn bench_full_pipeline(c: &mut Criterion) {
bench_full_pipeline_size(c, 100_000);
bench_full_pipeline_size(c, 500_000);
}
fn bench_full_pipeline_size(c: &mut Criterion, count: usize) {
let owned = make_paths(count);
let paths: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
let pat = "**/*.{rs,ts,lua,md}";
let mut group = c.benchmark_group(format!("glob_full_pipeline_{count}"));
group.sample_size(50);
// (A) current: indices -> AHashSet -> per-item set.contains
group.bench_function("indices_to_ahashset_then_filter", |b| {
b.iter(|| {
let hits =
zlob_match_paths_indices(black_box(pat), &paths, ZlobFlags::RECOMMENDED).unwrap();
let set: AHashSet<usize> = hits.to_iter().collect();
let count = (0..paths.len()).filter(|i| set.contains(i)).count();
black_box(count);
});
});
// (B) indices -> Vec<bool> bitmap -> per-item array lookup
group.bench_function("indices_to_bitmap_then_filter", |b| {
b.iter(|| {
let hits =
zlob_match_paths_indices(black_box(pat), &paths, ZlobFlags::RECOMMENDED).unwrap();
let mut mask = vec![false; paths.len()];
for i in hits.to_iter() {
mask[i] = true;
}
let count = (0..paths.len()).filter(|&i| mask[i]).count();
black_box(count);
});
});
// (C) compiled pattern + per-item matches() inside the filter loop. No batch.
group.bench_function("compiled_per_item_matches", |b| {
b.iter(|| {
let p = ZlobPattern::compile(black_box(pat), ZlobFlags::RECOMMENDED).unwrap();
let count = paths.iter().filter(|path| p.matches_default(path)).count();
black_box(count);
});
});
// (D) compiled pattern + chunked batch -> Vec<bool> bitmap. Best of both:
// SIMD batch wins inside chunks, no global allocation pressure, O(1) lookup.
group.bench_function("compiled_chunked_to_bitmap", |b| {
b.iter(|| {
let p = ZlobPattern::compile(black_box(pat), ZlobFlags::RECOMMENDED).unwrap();
let mut mask = vec![false; paths.len()];
for (chunk_idx, chunk) in paths.chunks(512).enumerate() {
let base = chunk_idx * 512;
let hits = p.match_indices(chunk, ZlobFlags::RECOMMENDED).unwrap();
for i in hits.to_iter() {
mask[base + i] = true;
}
}
let count = (0..paths.len()).filter(|&i| mask[i]).count();
black_box(count);
});
});
// (E') indices -> bit-packed Vec<u64> -> per-item bit test
group.bench_function("indices_to_bitset_then_filter", |b| {
b.iter(|| {
let hits =
zlob_match_paths_indices(black_box(pat), &paths, ZlobFlags::RECOMMENDED).unwrap();
let words = paths.len().div_ceil(64);
let mut bits = vec![0u64; words];
for i in hits.to_iter() {
bits[i >> 6] |= 1u64 << (i & 63);
}
let count = (0..paths.len())
.filter(|&i| (bits[i >> 6] >> (i & 63)) & 1 == 1)
.count();
black_box(count);
});
});
// (E) (D) but larger chunk
group.bench_function("compiled_chunked_4096_to_bitmap", |b| {
b.iter(|| {
let p = ZlobPattern::compile(black_box(pat), ZlobFlags::RECOMMENDED).unwrap();
let mut mask = vec![false; paths.len()];
for (chunk_idx, chunk) in paths.chunks(4096).enumerate() {
let base = chunk_idx * 4096;
let hits = p.match_indices(chunk, ZlobFlags::RECOMMENDED).unwrap();
for i in hits.to_iter() {
mask[base + i] = true;
}
}
let count = (0..paths.len()).filter(|&i| mask[i]).count();
black_box(count);
});
});
group.finish();
}
/// Mixed-constraint pipeline: glob + ext. Compare pre-pass batch (current) vs
/// inline `ZlobPattern::matches` after the cheap ext check rejects items.
///
/// Variables: ext rejection rate. Extreme cases reveal where each strategy wins.
fn bench_mixed_pipeline(c: &mut Criterion) {
let count = 100_000;
let owned = make_paths(count);
let paths: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
let glob_pat = "**/*.{rs,ts,lua,md}";
// 4 ext sets: from very selective (1/10 paths kept) to permissive (kept all).
let scenarios: &[(&str, &[&str])] = &[
("ext_1of10", &["rs"]),
("ext_4of10", &["rs", "ts", "lua", "md"]),
(
"ext_8of10",
&["rs", "ts", "lua", "md", "toml", "go", "py", "c"],
),
(
"ext_all",
&["rs", "ts", "lua", "md", "toml", "go", "py", "c", "h", "txt"],
),
];
fn ext_match(name: &str, exts: &[&str]) -> bool {
exts.iter().any(|e| {
let bytes = name.as_bytes();
let elen = e.len();
bytes.len() > elen + 1
&& bytes[bytes.len() - elen - 1] == b'.'
&& bytes[bytes.len() - elen..].eq_ignore_ascii_case(e.as_bytes())
})
}
let mut group = c.benchmark_group("glob_mixed_100k");
group.sample_size(50);
for &(name, exts) in scenarios {
// (A) PRE-PASS: build bitmap for ALL paths, then per-item ext-then-bitmap.
let id_pre = BenchmarkId::new("prepass_bitmap", name);
group.bench_with_input(id_pre, &exts, |b, &exts| {
b.iter(|| {
let hits =
zlob_match_paths_indices(black_box(glob_pat), &paths, ZlobFlags::RECOMMENDED)
.unwrap();
let mut mask = vec![false; paths.len()];
for i in hits.to_iter() {
mask[i] = true;
}
let count = paths
.iter()
.enumerate()
.filter(|&(_, p)| ext_match(p, exts))
.filter(|&(i, _)| mask[i])
.count();
black_box(count);
});
});
// (B) INLINE: compile once, per-item ext check first, then matches() only on survivors.
let id_inline = BenchmarkId::new("inline_compiled", name);
group.bench_with_input(id_inline, &exts, |b, &exts| {
b.iter(|| {
let p = ZlobPattern::compile(black_box(glob_pat), ZlobFlags::RECOMMENDED).unwrap();
let count = paths
.iter()
.filter(|path| ext_match(path, exts))
.filter(|path| p.matches_default(path))
.count();
black_box(count);
});
});
}
group.finish();
}
/// Compare hand-rolled `file_has_extension` byte compare vs compiling extensions
/// into a single brace glob `**/*.{rs,ts,lua,md}` and dispatching through zlob.
/// Both share the same per-item "filter then count" shape.
fn bench_extensions_vs_glob(c: &mut Criterion) {
let owned = make_paths(100_000);
let paths: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
let exts = ["rs", "ts", "lua", "md"];
let glob_pat = "**/*.{rs,ts,lua,md}";
fn ext_match(name: &str, exts: &[&str]) -> bool {
let bytes = name.as_bytes();
exts.iter().any(|e| {
let elen = e.len();
bytes.len() > elen + 1
&& bytes[bytes.len() - elen - 1] == b'.'
&& bytes[bytes.len() - elen..].eq_ignore_ascii_case(e.as_bytes())
})
}
let mut group = c.benchmark_group("ext_vs_glob_100k");
group.sample_size(50);
group.bench_function("file_has_extension_loop", |b| {
b.iter(|| {
let count = paths.iter().filter(|p| ext_match(p, &exts)).count();
black_box(count);
});
});
group.bench_function("compiled_brace_glob_inline", |b| {
b.iter(|| {
let p = ZlobPattern::compile(black_box(glob_pat), ZlobFlags::RECOMMENDED).unwrap();
let count = paths.iter().filter(|path| p.matches_default(path)).count();
black_box(count);
});
});
group.bench_function("brace_glob_prepass_bitmap", |b| {
b.iter(|| {
let hits =
zlob_match_paths_indices(black_box(glob_pat), &paths, ZlobFlags::RECOMMENDED)
.unwrap();
let mut mask = vec![false; paths.len()];
for i in hits.to_iter() {
mask[i] = true;
}
let count = (0..paths.len()).filter(|&i| mask[i]).count();
black_box(count);
});
});
group.finish();
}
criterion_group!(
benches,
bench_glob_strategies,
bench_compiled_reuse,
bench_full_pipeline,
bench_mixed_pipeline,
bench_extensions_vs_glob
);
criterion_main!(benches);
+21 -21
View File
@@ -1,5 +1,6 @@
use crate::constants::MAX_OVERFLOW_FILES;
use crate::error::Error;
use crate::file_picker::{FFFMode, MAX_OVERFLOW_FILES};
use crate::file_picker::FFFMode;
use crate::git::GitStatusCache;
use crate::shared::{SharedFilePicker, SharedFrecency};
use crate::sort_buffer::sort_with_buffer;
@@ -25,7 +26,6 @@ pub struct BackgroundWatcher {
}
const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(50);
const MAX_PATHS_THRESHOLD: usize = 1024;
/// On macOS, each `watch()` call creates a separate FSEventStream. When the
/// number of directories exceeds this threshold we fall back to a single
/// recursive watch to avoid exhausting the per-process stream limit.
@@ -41,6 +41,8 @@ impl BackgroundWatcher {
shared_picker: SharedFilePicker,
shared_frecency: SharedFrecency,
mode: FFFMode,
enable_fs_root_scanning: bool,
enable_home_dir_scanning: bool,
) -> Result<Self, Error> {
info!(
"Initializing background watcher for path: {}, mode: {:?}",
@@ -48,34 +50,31 @@ impl BackgroundWatcher {
mode,
);
// Refuse to watch the filesystem root or the user's home directory.
// These are prone to high-volume event churn (editor temp files,
// browser caches, log rotations) which inflates the overflow arena
// and, on macOS, can exhaust the per-process FSEvents stream limit.
if base_path.parent().is_none()
|| Some(base_path.as_os_str()) == dirs::home_dir().as_ref().map(|p| p.as_os_str())
{
// by default we do not want to allow users to search their FS root, this is very error prone
// though some consumers would specifically allow that e.g. unikernels, windows disc
// partition or sub file systems. By default - fail, unless user permits
let is_fs_root = base_path.parent().is_none();
// use rust's path api for maximum reliability of the comparison
let is_home_dir = Some(&base_path) == dirs::home_dir().as_ref();
if (is_fs_root && !enable_fs_root_scanning) || (is_home_dir && !enable_home_dir_scanning) {
return Err(Error::FilesystemRoot(base_path));
}
// macOS: always use a single recursive FSEvent stream.
//
// Per-dir NonRecursive watches create one FSEvent stream per dir.
// The per-process FSEvent cap is lower than expected in practice
// (4096 per process, but FFF usually is running within code editors),
// and each failed `watch()` after the cap blocks ~40 ms on kernel retry.
// Yes we pay for filtering events on handler phase but it is usable
//
// macOS and Windows use a single recursive watch. FSEvents and
// ReadDirectoryChangesW both support true kernel-level recursion
// on one handle — per-dir NonRecursive watches burn streams/handles
// for no benefit and, on Windows, have been observed to silently
// drop Modify events for nested paths.
// Windows doesn't seem to have a hard cap, but in practice non recursive watching
// does a way worse job and often looses events which is not an option for us.
//
// Linux keeps the per-dir NonRecursive strategy: inotify has no
// kernel-level recursion, so Recursive here would still register
// one watch per subdir but without the ignored-dir filtering we
// get by iterating `picker.for_each_dir` ourselves.
// kernel-level watcher recursion, so we have to manually watch every single interested
// directory for watch events which is in practice stable and fast if system has enough
// spare watcher (configurable by the user, usually 100k - 1m)
let use_recursive = cfg!(any(target_os = "macos", target_os = "windows"));
let (watch_tx, watch_rx) = mpsc::channel::<PathBuf>();
@@ -497,10 +496,11 @@ fn handle_debounced_events(
}
affected_paths_count += debounced_event.event.paths.len();
if affected_paths_count > MAX_PATHS_THRESHOLD {
if affected_paths_count > MAX_OVERFLOW_FILES {
warn!(
"Too many affected paths ({}) in a single batch, triggering full rescan",
affected_paths_count
?affected_paths_count,
max = MAX_OVERFLOW_FILES,
"Too many affected paths in a single batch, triggering full rescan",
);
need_full_rescan = true;
+26 -13
View File
@@ -1,3 +1,4 @@
use crate::constants::MAX_INDEXABLE_FILE_SIZE;
use ahash::AHashMap;
use rayon::iter::{IndexedParallelIterator, ParallelIterator};
use rayon::slice::ParallelSlice;
@@ -5,7 +6,7 @@ use std::cell::UnsafeCell;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU16, AtomicUsize, Ordering};
use crate::FileItem;
use crate::{FileItem, constants};
/// Maximum number of distinct bigrams tracked in the inverted index.
/// 95 printable ASCII chars (32..=126) after lowercasing → ~70 distinct → 4900 possible.
@@ -109,9 +110,7 @@ impl BigramIndexBuilder {
&slab[start..start + self.words]
}
// `pub` (via `#[doc(hidden)]`) only for benchmarking
// External consumers should use `build_bigram_index` instead.
#[doc(hidden)]
#[doc(hidden)] // `pub` (via `#[doc(hidden)]`) only for benchmarking
pub fn add_file_content(&self, skip_builder: &Self, file_idx: usize, content: &[u8]) {
if content.len() < 2 {
return;
@@ -126,13 +125,6 @@ impl BigramIndexBuilder {
let mut seen_consec = [0u64; 1024];
let mut seen_skip = [0u64; 1024];
// Normalise each byte as we stream and carry a 2-byte history
// across iterations so each input byte is normalised exactly once
// even though it participates in up to three bigrams (as `cur`,
// then `prev`, then `skip_prev`). Benchmarked against a NEON
// pre-pass variant — the pre-pass needs a heap scratch per call,
// which kills throughput unless content is gigantic. Inline
// normalisation is the faster choice for realistic file sizes.
let bytes = content;
let len = bytes.len();
@@ -592,7 +584,6 @@ impl BigramOverlay {
}
}
pub(crate) const MAX_INDEXABLE_FILE_SIZE: usize = 2 * 1024 * 1024;
const BIGRAM_CHUNK_FILES: usize = 4 * 64;
/// Sparse-column cutoff for the skip-1 sub-index. Rare skip columns add
@@ -680,7 +671,7 @@ pub(crate) fn build_bigram_index(
// an invalid text sequence if this is not a binary file.
//
// Need to find a better way to do this.
file.set_binary(crate::file_picker::detect_binary_content(content));
file.set_binary(crate::types::detect_binary_content(content));
builder.add_file_content(&skip_builder, file_idx, content);
}
@@ -706,6 +697,28 @@ pub(crate) fn build_bigram_index(
index
}
#[tracing::instrument(skip_all, name = "Sniffing Large Files Binary", level = tracing::Level::DEBUG)]
pub(crate) fn sniff_binary_for_non_indexable(
files: &[FileItem],
base_path: &std::path::Path,
arena: crate::simd_path::ArenaPtr,
) {
// Non-indexable files are few in a typical repo, so a serial pass with a
// single reused chunk buffer beats spinning up the thread pool.
let mut path_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
let mut chunk = vec![0u8; crate::types::BINARY_CLASSIFICATION_CHUNK_SIZE];
for file in files {
// check only the files that we are able to grep
if file.size == 0 || file.size > constants::MAX_FFFILE_SIZE {
continue;
}
let abs = file.write_absolute_path(arena, base_path, &mut path_buf);
file.detect_binary_per_byte(abs, &mut chunk);
}
}
/// Open the base directory for the `openat` fast path. Returns `-1` on
/// failure — callers interpret a negative fd as "fall back to absolute
/// paths".
+39
View File
@@ -0,0 +1,39 @@
/// Largest file whose full content fff will touch: the default grep read cap
/// (`GrepSearchOptions::max_file_size`) and the content-cache mmap cap
/// (`ContentCacheBudget::max_file_size`). Binary detection also streams up to
/// this far so nothing grep would read is left unclassified.
pub const MAX_FFFILE_SIZE: u64 = 10 * 1024 * 1024;
/// Upper bound on a file the bigram builder will build, if the file is very large there is a
/// big probability it will only bloat the available bigrams and will anyway pop ut from the prefilter
pub const MAX_INDEXABLE_FILE_SIZE: usize = 2 * 1024 * 1024;
/// Total bytes the persistent content mmap cache may hold for a small repo.
pub const MAX_CACHED_CONTENT_BYTES: u64 = 512 * 1024 * 1024;
/// Files below one page waste the remainder when mmapped, so the cache skips
/// them and falls back to chunked reads. Unused on Windows (no content cache).
#[cfg(all(not(target_os = "windows"), target_arch = "aarch64"))]
pub const MMAP_THRESHOLD: u64 = 16 * 1024;
#[cfg(all(not(target_os = "windows"), not(target_arch = "aarch64")))]
pub const MMAP_THRESHOLD: u64 = 4 * 1024;
/// Capacity reserved for files the watcher discovers after the initial scan;
/// exceeding it forces a full rescan.
pub const MAX_OVERFLOW_FILES: usize = 1024;
/// Fresh-mmap threshold: files at or above this size get mmapped directly on
/// cache miss instead of chunked reads into Vec. Empirically tuned per-platform.
/// Only referenced on Unix; Windows uses the `std::fs::read` fallback so this
/// constant is gated to non-Windows targets to keep `-D unused-imports` happy.
#[cfg(target_os = "macos")]
pub const FRESH_MMAP_THRESHOLD: u64 = 1024 * 1024;
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
pub const FRESH_MMAP_THRESHOLD: u64 = 256 * 1024;
// we do not support 32kb path limit on windows
#[cfg(target_os = "windows")]
pub const PATH_BUF_SIZE: usize = 4096;
#[cfg(not(target_os = "windows"))]
pub const PATH_BUF_SIZE: usize = libc::PATH_MAX as usize;
+464 -266
View File
@@ -1,6 +1,5 @@
//! Constraint-based prefiltering for search queries.
use ahash::AHashSet;
use fff_query_parser::{Constraint, GitStatusFilter};
use smallvec::SmallVec;
@@ -152,87 +151,14 @@ pub fn path_contains_segment(path: &str, segment: &str) -> bool {
false
}
#[inline]
#[allow(clippy::too_many_arguments)]
fn item_matches_constraint_at_index<T: Constrainable>(
item: &T,
item_index: usize,
constraint: &Constraint<'_>,
glob_results: &[(bool, AHashSet<usize>)],
glob_idx: &mut usize,
negate: bool,
arena: ArenaPtr,
fname_buf: &mut String,
path_buf: &mut String,
) -> bool {
let matches = match constraint {
Constraint::Extension(ext) => {
item.write_file_name(arena, fname_buf);
file_has_extension(fname_buf, ext)
}
Constraint::Glob(_) => {
let result = glob_results
.get(*glob_idx)
.map(|(is_neg, set)| {
let matched = set.contains(&item_index);
if *is_neg { !matched } else { matched }
})
.unwrap_or(true);
*glob_idx += 1;
return if negate { !result } else { result };
}
Constraint::PathSegment(segment) => {
item.write_relative_path(arena, path_buf);
path_contains_segment(path_buf, segment)
}
Constraint::FilePath(suffix) => {
item.write_relative_path(arena, path_buf);
path_ends_with_suffix(path_buf, suffix)
}
Constraint::GitStatus(status_filter) => match (item.git_status(), status_filter) {
(Some(status), GitStatusFilter::Modified) => is_modified_status(status),
(Some(status), GitStatusFilter::Untracked) => status.contains(git2::Status::WT_NEW),
(Some(status), GitStatusFilter::Staged) => status.intersects(
git2::Status::INDEX_NEW
| git2::Status::INDEX_MODIFIED
| git2::Status::INDEX_DELETED
| git2::Status::INDEX_RENAMED
| git2::Status::INDEX_TYPECHANGE,
),
(Some(status), GitStatusFilter::Unmodified) => status.is_empty(),
(None, GitStatusFilter::Unmodified) => true,
(None, _) => false,
},
Constraint::Not(inner) => {
return item_matches_constraint_at_index(
item,
item_index,
inner,
glob_results,
glob_idx,
!negate,
arena,
fname_buf,
path_buf,
);
}
// only works with negation
Constraint::Text(text) => {
item.write_relative_path(arena, path_buf);
contains_ascii_ci(path_buf, text)
}
// Parts and Exclude are handled at a higher level
Constraint::Parts(_) | Constraint::Exclude(_) | Constraint::FileType(_) => true,
};
if negate { !matches } else { matches }
}
/// Returns `None` if no constraints are present, `Some(filtered)` otherwise.
/// Extension constraints use OR logic; all others use AND.
///
/// Constraint semantics:
/// - All `Extension` constraints OR together (file matches if ANY extension hits).
/// They're split out up front so the per-item loop reads the OR predicate as a
/// single short-circuit check, not as N AND-merged sub-constraints.
/// - Every other constraint kind ANDs (file matches only if ALL hold). They're
/// evaluated in order with short-circuit on first failure.
pub(crate) fn apply_constraints<'a, T: Constrainable + Sync>(
items: &'a [T],
constraints: &[Constraint<'_>],
@@ -241,223 +167,420 @@ pub(crate) fn apply_constraints<'a, T: Constrainable + Sync>(
if constraints.is_empty() {
return None;
}
let plan = ConstraintPlan::build(constraints, items, arena);
Some(plan.run(items, arena))
}
// Separate extension constraints from other constraints — they use OR logic
let mut extensions: SmallVec<[&str; 8]> = SmallVec::new();
let mut other_constraints: SmallVec<[&Constraint<'_>; 8]> = SmallVec::new();
#[cfg(feature = "zlob")]
type GlobPattern = zlob::ZlobPattern;
#[cfg(not(feature = "zlob"))]
type GlobPattern = globset::GlobMatcher;
for constraint in constraints {
match constraint {
Constraint::Extension(ext) => extensions.push(ext),
_ => other_constraints.push(constraint),
/// How `Constraint::Glob` is evaluated for each item.
enum GlobStrategy {
/// No Glob constraint present.
None,
/// Pure-glob workload (no Extension filter to reject items first).
/// Batch all paths through zlob/globset once; per-item check is a Vec<bool> lookup.
Prepass(Vec<Vec<bool>>),
/// Mixed workload (Extension filter present). Compile patterns up front, then
/// only run them on items that survive the cheap Extension OR check.
/// `None` slot = compile failure -> never matches; preserves index alignment.
Inline(Vec<Option<GlobPattern>>),
}
/// Bundles preprocessed constraints for the per-item evaluator.
pub(crate) struct ConstraintPlan<'q, 'c> {
/// OR semantics — file passes if ANY extension matches. Empty = no ext filter.
extensions: SmallVec<[&'q str; 8]>,
/// AND semantics — file passes only if ALL match.
rest: SmallVec<[&'c Constraint<'q>; 8]>,
glob: GlobStrategy,
}
pub(crate) struct ConstraintsBuffers {
fname: String,
path: String,
}
impl ConstraintsBuffers {
pub(crate) fn new() -> Self {
Self {
fname: String::with_capacity(64),
path: String::with_capacity(64),
}
}
}
impl<'q, 'c> ConstraintPlan<'q, 'c> {
pub(crate) fn build<T: Constrainable>(
constraints: &'c [Constraint<'q>],
items: &[T],
arena: ArenaPtr,
) -> Self {
let mut extensions = SmallVec::new();
let mut rest = SmallVec::new();
for c in constraints {
match c {
Constraint::Extension(ext) => extensions.push(*ext),
_ => rest.push(c),
}
}
let has_pre_filter = !extensions.is_empty() || rest.iter().any(|&c| !is_glob_node(c));
let glob = build_glob_strategy(&rest, has_pre_filter, items, arena);
Self {
extensions,
rest,
glob,
}
}
// Only collect paths if we have glob constraints (expensive)
let has_globs = other_constraints
.iter()
.any(|c| matches!(c, Constraint::Glob(_) | Constraint::Not(_)));
fn run<'a, T: Constrainable + Sync>(&self, items: &'a [T], arena: ArenaPtr) -> Vec<&'a T> {
if items.len() >= PAR_THRESHOLD {
use rayon::prelude::*;
items
.par_iter()
.enumerate()
.map_init(ConstraintsBuffers::new, |scratch, (i, item)| {
self.matches(item, i, arena, scratch).then_some(item)
})
.flatten()
.collect()
} else {
let mut scratch = ConstraintsBuffers::new();
items
.iter()
.enumerate()
.filter_map(|(i, item)| self.matches(item, i, arena, &mut scratch).then_some(item))
.collect()
}
}
let glob_results = if has_globs {
// Build a single contiguous buffer of all relative paths + offset table.
// One allocation for the buffer, one for offsets — NOT one String per file.
// On Windows we fold `\\` into `/` while copying so globset/zlob see a
// canonical separator. The rewrite is in place on bytes we just wrote.
let mut path_buf = Vec::<u8>::new();
let mut offsets = Vec::<(usize, usize)>::with_capacity(items.len());
#[inline]
pub(crate) fn matches<T: Constrainable>(
&self,
item: &T,
index: usize,
arena: ArenaPtr,
scratch: &mut ConstraintsBuffers,
) -> bool {
if !self.passes_extensions(item, arena, scratch) {
return false;
}
let mut glob_idx = 0;
self.rest.iter().all(|c| {
let glob: &GlobStrategy = &self.glob;
let glob_idx: &mut usize = &mut glob_idx;
let negate = false;
let raw = match c {
Constraint::Glob(_) => {
let m = match glob {
GlobStrategy::None => true,
GlobStrategy::Prepass(masks) => masks
.get(*glob_idx)
.and_then(|mask| mask.get(index).copied())
.unwrap_or(false),
GlobStrategy::Inline(patterns) => {
item.write_relative_path(arena, &mut scratch.path);
patterns
.get(*glob_idx)
.and_then(|p| p.as_ref())
.map(|p| compiled_matches(p, &scratch.path))
.unwrap_or(false)
}
};
*glob_idx += 1;
m
}
// Reachable only via `Not(Extension(_))` — bare extensions are split out
// up front and handled in `passes_extensions`.
Constraint::Extension(ext) => {
item.write_file_name(arena, &mut scratch.fname);
file_has_extension(&scratch.fname, ext)
}
Constraint::PathSegment(segment) => {
item.write_relative_path(arena, &mut scratch.path);
path_contains_segment(&scratch.path, segment)
}
Constraint::FilePath(suffix) => {
item.write_relative_path(arena, &mut scratch.path);
path_ends_with_suffix(&scratch.path, suffix)
}
Constraint::Text(text) => {
// Only meaningful under negation (used as exclude filter).
item.write_relative_path(arena, &mut scratch.path);
contains_ascii_ci(&scratch.path, text)
}
Constraint::GitStatus(filter) => matches_git_status(item.git_status(), filter),
Constraint::Not(inner) => {
return evaluate(item, index, inner, glob, glob_idx, !negate, arena, scratch);
}
// Pass-throughs — handled at higher levels.
Constraint::Parts(_) | Constraint::Exclude(_) | Constraint::FileType(_) => true,
};
if negate { !raw } else { raw }
})
}
#[inline]
fn passes_extensions<T: Constrainable>(
&self,
item: &T,
arena: ArenaPtr,
scratch: &mut ConstraintsBuffers,
) -> bool {
if self.extensions.is_empty() {
return true;
}
item.write_file_name(arena, &mut scratch.fname);
self.extensions
.iter()
.any(|ext| file_has_extension(&scratch.fname, ext))
}
}
#[inline]
#[allow(clippy::too_many_arguments)]
fn evaluate<T: Constrainable>(
item: &T,
index: usize,
constraint: &Constraint<'_>,
glob: &GlobStrategy,
glob_idx: &mut usize,
negate: bool,
arena: ArenaPtr,
scratch: &mut ConstraintsBuffers,
) -> bool {
let raw = match constraint {
Constraint::Glob(_) => {
let m = match glob {
GlobStrategy::None => true,
GlobStrategy::Prepass(masks) => masks
.get(*glob_idx)
.and_then(|mask| mask.get(index).copied())
.unwrap_or(false),
GlobStrategy::Inline(patterns) => {
item.write_relative_path(arena, &mut scratch.path);
patterns
.get(*glob_idx)
.and_then(|p| p.as_ref())
.map(|p| compiled_matches(p, &scratch.path))
.unwrap_or(false)
}
};
*glob_idx += 1;
m
}
// Reachable only via `Not(Extension(_))` — bare extensions are split out
// up front and handled in `passes_extensions`.
Constraint::Extension(ext) => {
item.write_file_name(arena, &mut scratch.fname);
file_has_extension(&scratch.fname, ext)
}
Constraint::PathSegment(segment) => {
item.write_relative_path(arena, &mut scratch.path);
path_contains_segment(&scratch.path, segment)
}
Constraint::FilePath(suffix) => {
item.write_relative_path(arena, &mut scratch.path);
path_ends_with_suffix(&scratch.path, suffix)
}
Constraint::Text(text) => {
// Only meaningful under negation (used as exclude filter).
item.write_relative_path(arena, &mut scratch.path);
contains_ascii_ci(&scratch.path, text)
}
Constraint::GitStatus(filter) => matches_git_status(item.git_status(), filter),
Constraint::Not(inner) => {
return evaluate(item, index, inner, glob, glob_idx, !negate, arena, scratch);
}
// Pass-throughs — handled at higher levels.
Constraint::Parts(_) | Constraint::Exclude(_) | Constraint::FileType(_) => true,
};
if negate { !raw } else { raw }
}
#[inline]
fn matches_git_status(status: Option<git2::Status>, filter: &GitStatusFilter) -> bool {
match (status, filter) {
(Some(s), GitStatusFilter::Modified) => is_modified_status(s),
(Some(s), GitStatusFilter::Untracked) => s.contains(git2::Status::WT_NEW),
(Some(s), GitStatusFilter::Staged) => s.intersects(
git2::Status::INDEX_NEW
| git2::Status::INDEX_MODIFIED
| git2::Status::INDEX_DELETED
| git2::Status::INDEX_RENAMED
| git2::Status::INDEX_TYPECHANGE,
),
(Some(s), GitStatusFilter::Unmodified) => s.is_empty(),
(None, GitStatusFilter::Unmodified) => true,
(None, _) => false,
}
}
#[inline]
#[cfg(feature = "zlob")]
fn compiled_matches(p: &GlobPattern, path: &str) -> bool {
p.matches_default(path)
}
#[inline]
#[cfg(not(feature = "zlob"))]
fn compiled_matches(p: &GlobPattern, path: &str) -> bool {
p.is_match(path)
}
/// Decide between batch prepass and inline compiled patterns.
///
/// `has_pre_filter` = true when something cheaper than glob can reject items first
/// (extensions OR non-glob constraints in `rest`). In that case inline pays glob
/// cost only on survivors and beats prepass on every workload we benched. Pure-glob
/// (no pre-filter) takes prepass — single batched zlob call beats N inline matches.
fn build_glob_strategy<T: Constrainable>(
rest: &[&Constraint<'_>],
has_pre_filter: bool,
items: &[T],
arena: ArenaPtr,
) -> GlobStrategy {
if !contains_glob(rest) {
return GlobStrategy::None;
}
if has_pre_filter {
return GlobStrategy::Inline(compile_globs(rest));
}
let buf = PathBuffer::collect(items, arena);
let path_refs = buf.as_strs();
GlobStrategy::Prepass(precompute_masks(rest, &path_refs))
}
/// `Glob` or `Not(Glob)` — the constraint kinds whose evaluation goes through
/// the GlobStrategy. Everything else can pre-reject items before glob runs.
fn is_glob_node(c: &Constraint<'_>) -> bool {
match c {
Constraint::Glob(_) => true,
Constraint::Not(inner) => is_glob_node(inner),
_ => false,
}
}
fn contains_glob(rest: &[&Constraint<'_>]) -> bool {
rest.iter().any(|c| is_glob_node(c))
}
/// Contiguous byte buffer holding every item's `relative_path`. Single allocation
/// instead of N `String`s. On Windows the in-place pass folds `\\` -> `/` so the
/// glob library sees a canonical separator.
struct PathBuffer {
bytes: Vec<u8>,
offsets: Vec<(usize, usize)>,
}
impl PathBuffer {
fn collect<T: Constrainable>(items: &[T], arena: ArenaPtr) -> Self {
let mut bytes = Vec::<u8>::new();
let mut offsets = Vec::with_capacity(items.len());
let mut tmp = String::with_capacity(64);
for item in items.iter() {
let start = path_buf.len();
for item in items {
let start = bytes.len();
item.write_relative_path(arena, &mut tmp);
path_buf.extend_from_slice(tmp.as_bytes());
bytes.extend_from_slice(tmp.as_bytes());
#[cfg(windows)]
for b in &mut path_buf[start..] {
for b in &mut bytes[start..] {
if *b == b'\\' {
*b = b'/';
}
}
offsets.push((start, path_buf.len() - start));
offsets.push((start, bytes.len() - start));
}
let path_refs: Vec<&str> = offsets
.iter()
.map(|&(off, len)| unsafe { std::str::from_utf8_unchecked(&path_buf[off..off + len]) })
.collect();
precompute_glob_matches(&other_constraints, &path_refs)
} else {
Vec::new()
};
let filtered: Vec<&T> = if items.len() >= PAR_THRESHOLD {
use rayon::prelude::*;
items
.par_iter()
.enumerate()
.map_init(
|| (String::with_capacity(64), String::with_capacity(64)),
|(fname_buf, path_buf), (i, item)| {
if !extensions.is_empty() {
item.write_file_name(arena, fname_buf);
if !extensions
.iter()
.any(|ext| file_has_extension(fname_buf, ext))
{
return None;
}
}
let mut glob_idx = 0;
if other_constraints.iter().all(|constraint| {
item_matches_constraint_at_index(
item,
i,
constraint,
&glob_results,
&mut glob_idx,
false,
arena,
fname_buf,
path_buf,
)
}) {
Some(item)
} else {
None
}
},
)
.flatten()
.collect()
} else {
let mut fname_buf = String::with_capacity(64);
let mut path_buf = String::with_capacity(64);
items
.iter()
.enumerate()
.filter(|&(i, item)| {
if !extensions.is_empty() {
item.write_file_name(arena, &mut fname_buf);
if !extensions
.iter()
.any(|ext| file_has_extension(&fname_buf, ext))
{
return false;
}
}
let mut glob_idx = 0;
other_constraints.iter().all(|constraint| {
item_matches_constraint_at_index(
item,
i,
constraint,
&glob_results,
&mut glob_idx,
false,
arena,
&mut fname_buf,
&mut path_buf,
)
})
})
.map(|(_, item)| item)
.collect()
};
Some(filtered)
}
fn precompute_glob_matches<'a>(
constraints: &[&Constraint<'a>],
paths: &[&str],
) -> Vec<(bool, AHashSet<usize>)> {
let mut results = Vec::new();
for constraint in constraints {
collect_glob_indices(constraint, paths, &mut results, false);
Self { bytes, offsets }
}
fn as_strs(&self) -> Vec<&str> {
self.offsets
.iter()
.map(|&(off, len)| unsafe {
std::str::from_utf8_unchecked(&self.bytes[off..off + len])
})
.collect()
}
results
}
fn collect_glob_indices<'a>(
constraint: &Constraint<'a>,
paths: &[&str],
results: &mut Vec<(bool, AHashSet<usize>)>,
_is_negated: bool,
) {
match constraint {
Constraint::Glob(pattern) => {
let indices = match_glob_pattern(pattern, paths);
// Negation is handled by the `negate` parameter in
// `item_matches_constraint_at_index`, NOT here. Storing
// `is_negated=true` caused a double-negation bug when the
// Glob arm also applied `negate`.
results.push((false, indices));
}
Constraint::Not(inner) => {
collect_glob_indices(inner, paths, results, true);
}
fn precompute_masks(rest: &[&Constraint<'_>], paths: &[&str]) -> Vec<Vec<bool>> {
let mut out = Vec::new();
for c in rest {
walk_globs(c, &mut |pattern| {
out.push(match_glob_pattern(pattern, paths))
});
}
out
}
fn compile_globs(rest: &[&Constraint<'_>]) -> Vec<Option<GlobPattern>> {
let mut out = Vec::new();
for c in rest {
walk_globs(c, &mut |pattern| out.push(compile_one(pattern)));
}
out
}
/// Visit every Glob (including ones nested under Not) in constraint walk order.
/// Order matters: `glob_idx` in the per-item evaluator increments by one per Glob node.
fn walk_globs<F: FnMut(&str)>(c: &Constraint<'_>, f: &mut F) {
match c {
Constraint::Glob(p) => f(p),
Constraint::Not(inner) => walk_globs(inner, f),
_ => {}
}
}
/// Match a glob pattern against a list of paths, returning the set of matching indices.
///
/// When the `zlob` feature is enabled, delegates to `zlob::zlob_match_paths` (Zig-compiled
/// C library, fastest). Otherwise falls back to `globset::Glob` (pure Rust).
#[cfg(feature = "zlob")]
fn match_glob_pattern(pattern: &str, paths: &[&str]) -> AHashSet<usize> {
let Ok(Some(matches)) = zlob::zlob_match_paths(pattern, paths, zlob::ZlobFlags::RECOMMENDED)
else {
return AHashSet::new();
};
let matched_set: AHashSet<usize> = matches.iter().map(|s| s.as_ptr() as usize).collect();
if paths.len() >= PAR_THRESHOLD {
use rayon::prelude::*;
paths
.par_iter()
.enumerate()
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
.map(|(i, _)| i)
.collect::<Vec<_>>()
.into_iter()
.collect()
} else {
paths
.iter()
.enumerate()
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
.map(|(i, _)| i)
.collect()
}
fn compile_one(pattern: &str) -> Option<GlobPattern> {
zlob::ZlobPattern::compile(pattern, zlob::ZlobFlags::RECOMMENDED).ok()
}
#[cfg(not(feature = "zlob"))]
fn match_glob_pattern(pattern: &str, paths: &[&str]) -> AHashSet<usize> {
fn compile_one(pattern: &str) -> Option<GlobPattern> {
globset::Glob::new(pattern)
.ok()
.map(|g| g.compile_matcher())
}
/// Build a `paths.len()`-sized bitmap. Vec<bool> beats AHashSet ~2× in the per-item
/// filter loop — no hashing, plain array indexing, sequential prefetcher-friendly.
#[cfg(feature = "zlob")]
fn match_glob_pattern(pattern: &str, paths: &[&str]) -> Vec<bool> {
let mut mask = vec![false; paths.len()];
let Ok(hits) = zlob::zlob_match_paths_indices(pattern, paths, zlob::ZlobFlags::RECOMMENDED)
else {
return mask;
};
for i in hits.to_iter() {
if i < mask.len() {
mask[i] = true;
}
}
mask
}
#[cfg(not(feature = "zlob"))]
fn match_glob_pattern(pattern: &str, paths: &[&str]) -> Vec<bool> {
let mut mask = vec![false; paths.len()];
let Ok(glob) = globset::Glob::new(pattern) else {
return AHashSet::new();
return mask;
};
let matcher = glob.compile_matcher();
if paths.len() >= PAR_THRESHOLD {
use rayon::prelude::*;
paths
.par_iter()
.enumerate()
.filter(|(_, p)| matcher.is_match(p))
.map(|(i, _)| i)
.collect::<Vec<_>>()
.into_iter()
.collect()
mask.par_iter_mut()
.zip(paths.par_iter())
.for_each(|(slot, p)| *slot = matcher.is_match(p));
} else {
paths
.iter()
.enumerate()
.filter(|(_, p)| matcher.is_match(p))
.map(|(i, _)| i)
.collect()
for (slot, p) in mask.iter_mut().zip(paths.iter()) {
*slot = matcher.is_match(p);
}
}
mask
}
#[cfg(test)]
@@ -777,4 +900,79 @@ mod tests {
"h file should be included"
);
}
#[test]
fn test_inline_glob_path_matches_prepass() {
// Mixed (extensions + glob) takes the inline-compiled path.
// Pure glob takes the prepass bitmap path. Both must give identical results.
let arena_ptr = ArenaPtr(std::ptr::null());
let items = vec![
TestItem {
relative_path: "src/main.rs",
file_name: "main.rs",
},
TestItem {
relative_path: "src/lib.ts",
file_name: "lib.ts",
},
TestItem {
relative_path: "tests/foo.rs",
file_name: "foo.rs",
},
TestItem {
relative_path: "docs/readme.md",
file_name: "readme.md",
},
];
let mixed = vec![Constraint::Extension("rs"), Constraint::Glob("src/**")];
let mixed_paths: Vec<&str> = apply_constraints(&items, &mixed, arena_ptr)
.unwrap()
.iter()
.map(|i| i.relative_path)
.collect();
assert_eq!(mixed_paths, vec!["src/main.rs"]);
let pure_glob = vec![Constraint::Glob("src/**")];
let glob_paths: Vec<&str> = apply_constraints(&items, &pure_glob, arena_ptr)
.unwrap()
.iter()
.map(|i| i.relative_path)
.collect();
assert!(glob_paths.contains(&"src/main.rs"));
assert!(glob_paths.contains(&"src/lib.ts"));
assert_eq!(glob_paths.len(), 2);
}
#[test]
fn test_inline_negated_glob_with_extension() {
// Mixed Not(Glob) on inline path — exercise the negate=true branch in
// glob_matches_inline through the Not->Glob recursion.
let arena_ptr = ArenaPtr(std::ptr::null());
let items = vec![
TestItem {
relative_path: "src/main.rs",
file_name: "main.rs",
},
TestItem {
relative_path: "vendor/foo.rs",
file_name: "foo.rs",
},
TestItem {
relative_path: "vendor/foo.ts",
file_name: "foo.ts",
},
];
let constraints = vec![
Constraint::Extension("rs"),
Constraint::Not(Box::new(Constraint::Glob("vendor/**"))),
];
let paths: Vec<&str> = apply_constraints(&items, &constraints, arena_ptr)
.unwrap()
.iter()
.map(|i| i.relative_path)
.collect();
assert_eq!(paths, vec!["src/main.rs"]);
}
}
+93 -28
View File
@@ -33,6 +33,7 @@
use crate::FFFStringStorage;
use crate::background_watcher::{BackgroundWatcher, is_git_file};
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;
@@ -42,7 +43,7 @@ use crate::query_tracker::QueryTracker;
use crate::scan::{ScanConfig, ScanJob, ScanSignals};
use crate::score::fuzzy_match_and_score_files;
use crate::shared::{SharedFilePicker, SharedFrecency};
use crate::simd_path::{ArenaPtr, PATH_BUF_SIZE};
use crate::simd_path::ArenaPtr;
use crate::stable_vec::StableVec;
use crate::types::{
ContentCacheBudget, DirItem, DirSearchResult, FileItem, MixedItemRef, MixedSearchResult,
@@ -62,11 +63,6 @@ use std::thread::JoinHandle;
use std::time::SystemTime;
use tracing::{Level, debug, error, info, warn};
/// Max overflow files before the watcher triggers a full rescan.
/// `walk_filesystem` reserves this much extra capacity so the Vec never
/// reallocates while raw pointers are held during post-scan.
pub(crate) const MAX_OVERFLOW_FILES: usize = 1024;
/// Dedicated thread pool for background work (scan, warmup, bigram build).
/// Uses fewer threads than the global rayon pool so Neovim's event loop
/// and search queries can still get CPU time.
@@ -446,6 +442,12 @@ pub struct FilePickerOptions {
pub watch: bool,
/// 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.
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`.
pub enable_home_dir_scanning: bool,
}
impl Default for FilePickerOptions {
@@ -458,6 +460,8 @@ impl Default for FilePickerOptions {
cache_budget: None,
watch: true,
follow_symlinks: false,
enable_fs_root_scanning: false,
enable_home_dir_scanning: false,
}
}
}
@@ -475,6 +479,8 @@ pub struct FilePicker {
enable_content_indexing: bool,
watch: bool,
follow_symlinks: bool,
enable_fs_root_scanning: bool,
enable_home_dir_scanning: bool,
}
impl std::fmt::Debug for FilePicker {
@@ -532,6 +538,14 @@ impl FilePicker {
self.follow_symlinks
}
pub fn fs_root_scanning_enabled(&self) -> bool {
self.enable_fs_root_scanning
}
pub fn home_dir_scanning_enabled(&self) -> bool {
self.enable_home_dir_scanning
}
pub fn mode(&self) -> FFFMode {
self.mode
}
@@ -699,10 +713,16 @@ impl FilePicker {
error!("Base path does not exist: {}", options.base_path);
return Err(Error::InvalidPath(path));
}
if path.parent().is_none() {
if path.parent().is_none() && !options.enable_fs_root_scanning {
error!("Refusing to index filesystem root: {}", path.display());
return Err(Error::FilesystemRoot(path));
}
if !options.enable_home_dir_scanning
&& Some(path.as_os_str()) == dirs::home_dir().as_ref().map(|p| p.as_os_str())
{
error!("Refusing to index home directory: {}", path.display());
return Err(Error::FilesystemRoot(path));
}
// Windows-only: canonicalize with so the base path does NOT
// have the `\\?\` UNC prefix that `std::fs::canonicalize` adds.
@@ -726,6 +746,8 @@ impl FilePicker {
enable_content_indexing: options.enable_content_indexing,
watch: options.watch,
follow_symlinks: options.follow_symlinks,
enable_fs_root_scanning: options.enable_fs_root_scanning,
enable_home_dir_scanning: options.enable_home_dir_scanning,
})
}
@@ -751,6 +773,8 @@ impl FilePicker {
let watch = picker.watch;
let mode = picker.mode;
let follow_symlinks = picker.follow_symlinks;
let enable_fs_root_scanning = picker.enable_fs_root_scanning;
let enable_home_dir_scanning = picker.enable_home_dir_scanning;
let signals = picker.scan_signals();
let scanned_files_counter = picker.scanned_files_counter();
@@ -778,6 +802,8 @@ impl FilePicker {
auto_cache_budget: true,
install_watcher: true,
follow_symlinks,
enable_fs_root_scanning,
enable_home_dir_scanning,
},
)
.spawn();
@@ -812,8 +838,6 @@ impl FilePicker {
self.sync_data = sync;
// Recalculate cache budget based on actual file count (unless
// the caller provided an explicit budget via FilePickerOptions).
if !self.has_explicit_cache_budget {
let file_count = self.sync_data.files().len();
self.cache_budget = Arc::new(ContentCacheBudget::new_for_repo(file_count));
@@ -821,14 +845,18 @@ impl FilePicker {
self.cache_budget.reset();
}
// Apply git status synchronously.
if let Some(handle) = git_handle
&& let Ok(Some(git_cache)) = handle.join()
{
let mut path_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
let arena = self.arena_base_ptr();
for file in self.sync_data.files.iter_mut() {
file.git_status =
git_cache.lookup_status(&file.absolute_path(arena, &self.base_path));
file.git_status = git_cache.lookup_status(file.write_absolute_path(
arena,
&self.base_path,
&mut path_buf,
));
}
}
@@ -853,6 +881,8 @@ impl FilePicker {
shared_picker.clone(),
shared_frecency.clone(),
self.mode,
self.enable_fs_root_scanning,
self.enable_home_dir_scanning,
)?;
self.background_watcher = Some(watcher);
self.signals.watcher_ready.store(true, Ordering::Release);
@@ -864,6 +894,7 @@ impl FilePicker {
/// The query should be parsed using [`FFFQuery`]::parse() before calling
/// this function. If a [`QueryTracker`] is provided, the search will
/// automatically look up the last selected file for this query and boost it
#[tracing::instrument(skip_all, name = "Fuzzy file search", fields(query = query.raw_query))]
pub fn fuzzy_search<'q>(
&self,
query: &'q FFFQuery<'q>,
@@ -1149,6 +1180,32 @@ impl FilePicker {
}
}
/// Glob search: filter indexed files by a single glob pattern, rank by
/// frecency, and paginate. Bypasses the regular query parser entirely —
/// useful when callers already have a literal glob (`*.rs`, `**/*.test.ts`)
/// and want neither fuzzy matching nor multi-token constraint parsing.
///
/// Pipeline: `apply_constraints(Glob) → score_filtered_by_frecency → sort_and_paginate`.
/// Same ranking semantics as `fuzzy_search` when the fuzzy query is empty.
pub fn glob<'p>(
&'p self,
pattern: &'p str,
options: FuzzySearchOptions<'p>,
) -> SearchResult<'p> {
let query = FFFQuery {
raw_query: pattern,
constraints: vec![fff_query_parser::Constraint::Glob(pattern)],
fuzzy_query: fff_query_parser::FuzzyQuery::Empty,
location: None,
};
// `fuzzy_search` short-circuits to `score_filtered_by_frecency` when
// `fuzzy_query` is `Empty`, then runs the same `sort_and_paginate`
// path. Reusing it keeps the ranking guarantees identical without
// exposing the private scoring helpers.
self.fuzzy_search(&query, None, options)
}
/// Perform a live grep search across indexed files.
///
/// If `options.abort_signal` is set it overrides the picker's internal
@@ -1273,9 +1330,9 @@ impl FilePicker {
base_count: self.sync_data.base_count,
indexable_count: self.sync_data.indexable_count,
base_path: self.base_path.clone(),
budget: Arc::clone(&self.cache_budget),
cancelled: Arc::clone(&self.signals.cancelled),
post_scan_flag: Arc::clone(&self.signals.post_scan_indexing_active),
_budget: Arc::clone(&self.cache_budget),
})
}
@@ -1421,7 +1478,14 @@ impl FilePicker {
file.update_metadata(&self.cache_budget, modified_time, Some(size));
// only base-region entries participate in the bigram overlay
// Re-classify binary status from current content (chunked, fixed
// buffer). Already-binary files are left alone.
if !file.is_binary() {
let mut chunk = [0u8; crate::types::BINARY_CLASSIFICATION_CHUNK_SIZE];
file.detect_binary_per_byte(path, &mut chunk);
}
// Indexable base-region files feed fresh content to the bigram overlay.
if matches!(slot, FileSlot::Base(_))
&& let Some(ref overlay) = overlay
{
@@ -1451,12 +1515,10 @@ impl FilePicker {
} else if let Ok(c) = crate::path_utils::canonicalize(path) {
Some(c)
} else {
let parent = path.parent()?;
let file_name = path.file_name()?;
let mut p = crate::path_utils::canonicalize(parent).ok()?;
p.push(file_name);
Some(p)
tracing::error!(path = ?path.display(), "Failed to canonicalize file path to add");
return None;
};
#[cfg(windows)]
let path_for_index: &Path = canonical_buf.as_deref().unwrap_or(path);
#[cfg(not(windows))]
@@ -1465,13 +1527,20 @@ impl FilePicker {
let (mut file_item, rel_path) =
FileItem::new(path_for_index.to_path_buf(), &self.base_path, None);
// we have to perform manual classification for every new file this will be
// batched during the scan, this is the path when the file is ad-hoc added to the sync
file_item.detect_binary_per_byte(
path_for_index,
// inline chunk buf
&mut [0u8; crate::types::BINARY_CLASSIFICATION_CHUNK_SIZE],
);
let builder = self.sync_data.overflow_builder.get_or_insert_with(|| {
// we know that overflow would never create more files during the file
crate::simd_path::ChunkedPathStoreBuilder::new(MAX_OVERFLOW_FILES)
});
let chunked_path = builder.add_file_immediate(&rel_path, file_item.path.filename_offset);
file_item.set_path(chunked_path);
file_item.set_path(builder.add_file_immediate(&rel_path, file_item.path.filename_offset));
file_item.set_overflow(true);
if !self.sync_data.files.push(file_item) {
@@ -1658,7 +1727,8 @@ pub(crate) struct PostScanUnsafeSnapshot {
pub files: StableVec<FileItem>,
pub dirs: StableVec<crate::types::DirItem>,
pub arena: Option<Arc<crate::simd_path::ChunkedPathStore>>,
pub budget: Arc<crate::types::ContentCacheBudget>,
// TODO figure this out
pub _budget: Arc<crate::types::ContentCacheBudget>,
pub base_count: usize,
pub indexable_count: usize,
pub base_path: PathBuf,
@@ -1847,7 +1917,7 @@ impl FileSync {
let is_indexable = |f: &FileItem| {
!f.is_binary()
&& f.size > 0
&& f.size <= crate::bigram_filter::MAX_INDEXABLE_FILE_SIZE as u64
&& f.size <= crate::constants::MAX_INDEXABLE_FILE_SIZE as u64
};
BACKGROUND_THREAD_POOL.install(|| {
@@ -2034,11 +2104,6 @@ pub fn is_known_binary_extension(path: &Path) -> bool {
)
}
#[inline]
pub(crate) fn detect_binary_content(content: &[u8]) -> bool {
memchr::memchr(0, content).is_some()
}
/// Length of the longest shared directory prefix of two relative dir
/// paths (without a trailing separator), measured as the number of bytes
/// up to and including the last shared separator — plus the full shorter
+223 -276
View File
@@ -1,14 +1,8 @@
//! High-performance grep engine for live content search.
//!
//! Searches file contents using the `grep-searcher` crate with mmap-backed
//! file access. Files are searched in frecency order for optimal pagination
//! performance — the most relevant files are searched first, enabling early
//! termination once enough results are collected.
use crate::{
BigramFilter, BigramOverlay,
bigram_query::{fuzzy_to_bigram_query, regex_to_bigram_query},
constraints::apply_constraints,
case_insensitive_memmem,
constraints::{ConstraintPlan, ConstraintsBuffers},
extract_bigrams,
sort_buffer::sort_with_buffer,
types::{ContentCacheBudget, FileItem, FileSliceExt, MmapSlot},
@@ -333,6 +327,8 @@ pub struct GrepResult<'a> {
pub regex_fallback_error: Option<String>,
}
pub use crate::constants::MAX_FFFILE_SIZE;
/// Options for grep search.
#[derive(Debug, Clone)]
pub struct GrepSearchOptions {
@@ -371,7 +367,7 @@ pub struct GrepSearchOptions {
impl Default for GrepSearchOptions {
fn default() -> Self {
Self {
max_file_size: 10 * 1024 * 1024,
max_file_size: MAX_FFFILE_SIZE,
max_matches_per_file: 200,
smart_case: true,
file_offset: 0,
@@ -1010,41 +1006,37 @@ pub(crate) fn multi_grep_search<'a>(
None
};
let (mut files_to_search, mut filtered_file_count) =
prepare_files_to_search(files, constraints, options, arena);
let base_file_count = match bigram_overlay {
Some(bigram_overlay) => bigram_overlay.base_file_count(),
None => files.len(),
};
let (mut files_to_search, mut filtered_file_count) = prefilter_files(
files,
constraints,
bigram_candidates.as_deref(),
base_file_count,
options,
arena,
);
// If constraints yielded 0 files and we had FilePath constraints,
// retry without them (the path token was likely part of the search text).
if files_to_search.is_empty()
&& let Some(stripped) = strip_file_path_constraints(constraints)
&& let Some(stripped) = strip_file_path_constraint_if_present(constraints)
{
let (retry_files, retry_count) = prepare_files_to_search(files, &stripped, options, arena);
let (retry_files, retry_count) = prefilter_files(
files,
&stripped,
bigram_candidates.as_deref(),
base_file_count,
options,
arena,
);
files_to_search = retry_files;
filtered_file_count = retry_count;
}
// Apply bigram prefilter to the file list. Bigram columns only cover
// the indexable region — `bigram_overlay.base_file_count()` is the
// authoritative boundary. Files past it (unindexable base, overflow)
// are always retained and searched directly.
if let Some(ref candidates) = bigram_candidates {
let base_ptr = files.as_ptr();
let bigram_boundary = bigram_overlay
.map(|o| o.base_file_count())
.unwrap_or(files.len());
files_to_search.retain(|f| {
if f.is_overflow() {
return true;
}
let file_idx = unsafe { (*f as *const FileItem).offset_from(base_ptr) as usize };
if file_idx >= bigram_boundary {
return true;
}
BigramFilter::is_candidate(candidates, file_idx)
});
}
if files_to_search.is_empty() {
return GrepResult {
total_files,
@@ -1192,14 +1184,11 @@ fn char_indices_to_byte_offsets(line: &str, char_indices: &[usize]) -> SmallVec<
result
}
use crate::case_insensitive_memmem;
/// Minimum chunk size for paginated search. Must be large enough for good
/// thread utilization across rayon's pool (~28 threads on modern hardware)
/// but small enough to allow early termination after few chunks.
const PAGINATED_CHUNK_SIZE: usize = 512;
#[tracing::instrument(skip_all, level = Level::DEBUG, fields(prefiltered_count = files_to_search.len()))]
#[tracing::instrument(
skip_all,
level = Level::DEBUG,
fields(prefiltered_count = files_to_search.len())
)]
fn perform_grep<'a, F>(
files_to_search: &[&'a FileItem],
options: &GrepSearchOptions,
@@ -1219,39 +1208,22 @@ where
let page_limit = options.page_limit;
let budget_exceeded = AtomicBool::new(false);
// For paginated searches, process files in chunks to enable early
// termination. Each chunk is searched in parallel with rayon; between
// chunks we check whether enough matches have been collected.
//
// For full searches (page_limit = MAX), one chunk = all files — same
// throughput as before, no overhead from the chunking loop.
//
// For common queries ("x", "if") with ~99% hit rate: the first 512-file
// chunk yields ~500 matches, far exceeding page_limit=50. We stop after
// one chunk (~1ms) instead of searching all 93K files (~175ms).
let chunk_size = if page_limit < usize::MAX {
PAGINATED_CHUNK_SIZE
} else {
files_to_search.len().max(1)
};
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;
let chunk_size = rayon::current_num_threads() * 4;
for chunk in files_to_search.chunks(chunk_size) {
let chunk_offset = files_consumed;
// Parallel phase: search all files in this chunk concurrently.
// Within a chunk every file is visited (no gaps), so pagination
// offsets remain correct across chunk boundaries.
let chunk_results: Vec<(usize, &'a FileItem, Vec<GrepMatch>)> = chunk
.par_iter()
.enumerate()
.map_init(
// Per-thread scratch: a reusable read buffer for small files
// and an mmap slot for cache-miss large files (≥ FRESH_MMAP_THRESHOLD).
// 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)| {
if ctx.abort_signal.load(Ordering::Relaxed) {
@@ -1434,73 +1406,134 @@ fn collect_grep_results<'a>(
}
}
/// Filter files by constraints and size/binary checks, sort by frecency,
/// and apply file-based pagination.
///
/// Returns `(paginated_files, filtered_file_count)`. The paginated slice
/// is empty if the offset is past the end of available files.
fn prepare_files_to_search<'a>(
/// 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
fn prefilter_files<'a>(
files: &'a [FileItem],
constraints: &[fff_query_parser::Constraint<'_>],
bigram_candidates: Option<&[u64]>,
base_count: usize,
options: &GrepSearchOptions,
arena: crate::simd_path::ArenaPtr,
) -> (Vec<&'a FileItem>, usize) {
let prefiltered: Vec<&FileItem> = if constraints.is_empty() {
files
.iter()
.filter(|f| {
!f.is_deleted() && !f.is_binary() && f.size > 0 && f.size <= options.max_file_size
})
.collect()
let max_file_size = options.max_file_size;
let plan = if constraints.is_empty() {
None
} else {
match apply_constraints(files, constraints, arena) {
Some(constrained) => constrained
.into_iter()
.filter(|f| {
!f.is_deleted()
&& !f.is_binary()
&& f.size > 0
&& f.size <= options.max_file_size
})
.collect(),
None => files
.iter()
.filter(|f| {
!f.is_deleted()
&& !f.is_binary()
&& f.size > 0
&& f.size <= options.max_file_size
})
.collect(),
Some(ConstraintPlan::build(constraints, files, 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, &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 guaratee 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, &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, &mut scratch)
{
continue;
}
out.push(f);
}
out
}
};
let total_count = prefiltered.len();
let mut sorted_files = prefiltered;
// Only sort when there is meaningful frecency or modification data to rank by.
// On large repos (500k+ files) with no frecency data (fresh session, benchmark),
// skipping the O(n log n) sort saves ~200ms per query.
let needs_sort = sorted_files
.iter()
.any(|f| f.total_frecency_score() != 0 || f.modified != 0);
if needs_sort {
sort_with_buffer(&mut sorted_files, |a, b| {
b.total_frecency_score()
.cmp(&a.total_frecency_score())
.then(b.modified.cmp(&a.modified))
});
}
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 = sorted_files.split_off(options.file_offset);
let paginated = prefiltered.split_off(options.file_offset);
(paginated, total_count)
} else if options.file_offset >= total_count {
(Vec::new(), total_count)
} else {
// offset == 0: no split needed, return as-is
(sorted_files, total_count)
(prefiltered, total_count)
}
}
@@ -1519,19 +1552,6 @@ fn prepare_files_to_search<'a>(
/// the *reference* (scalar) smith-waterman, which is O(needle × line_len)
/// per line. For a 10k-line file that's 10k sequential reference calls.
///
/// `neo_frizbee::match_list` solves this by batching lines into
/// fixed-width SIMD buckets (4, 8, 12 … 512 bytes) and scoring 16+
/// haystacks per SIMD invocation. A single `match_list` call over the
/// entire file replaces 10k individual `match_indices` calls. We then
/// call `match_indices` *only* on the ~5-20 lines that pass `min_score`
/// to extract character highlight positions.
///
/// Line splitting uses `memchr::memchr` (the same SIMD-accelerated byte
/// search that `grep-searcher` and `bstr::ByteSlice::find_byte` use
/// internally) to locate `\n` terminators. This gives us the same
/// performance as the searcher's `LineStep` iterator without pulling in
/// the full searcher machinery.
///
/// For each file:
/// 1. mmap the file, split lines via memchr '\n' (tracking line numbers + byte offsets)
/// 2. Batch all lines through `match_list` (SIMD smith-waterman)
@@ -1669,6 +1689,7 @@ fn fuzzy_grep_search<'a>(
} else {
arena
};
let file_bytes =
file.get_content_for_search(buf, mmap_slot, file_arena, base_path, budget)?;
@@ -1922,31 +1943,10 @@ pub(crate) fn grep_search<'a>(
let regex = match options.mode {
GrepMode::PlainText => None,
GrepMode::Fuzzy => {
let (mut files_to_search, mut filtered_file_count) =
prepare_files_to_search(files, constraints_from_query, options, arena);
if files_to_search.is_empty()
&& let Some(stripped) = strip_file_path_constraints(constraints_from_query)
{
let (retry_files, retry_count) =
prepare_files_to_search(files, &stripped, options, arena);
files_to_search = retry_files;
filtered_file_count = retry_count;
}
if files_to_search.is_empty() {
return GrepResult {
total_files,
filtered_file_count,
next_file_offset: 0,
..Default::default()
};
}
// Bigram prefilter: pick 5 evenly-spaced probe bigrams, require
// (5 - max_typos) of them to appear. Widely-spaced probes are
// far more selective than sliding windows of adjacent bigrams.
if let Some(idx) = bigram_index
let bigram_candidates = if let Some(idx) = bigram_index
&& idx.is_ready()
{
let bq = fuzzy_to_bigram_query(&grep_text, 7);
@@ -1965,33 +1965,52 @@ pub(crate) fn grep_search<'a>(
}
}
}
// Bigram columns only cover the indexable region at
// `files[..overlay.base_file_count()]` — which equals
// `indexable_count` today. Files past that boundary
// (unindexable base files, overflow) are not tracked by
// the bigram filter, so we always retain them and let
// the full text search decide.
let base_ptr = files.as_ptr();
let bigram_boundary = bigram_overlay
.map(|o| o.base_file_count())
.unwrap_or(files.len());
files_to_search.retain(|f| {
if f.is_overflow() {
return true;
}
let file_idx =
unsafe { (*f as *const FileItem).offset_from(base_ptr) as usize };
if file_idx >= bigram_boundary {
return true;
}
BigramFilter::is_candidate(&candidates, file_idx)
});
Some(candidates)
} else {
None
}
} else {
None
};
let base_count = match bigram_overlay {
Some(bigram_overlay) => bigram_overlay.base_file_count(),
None => files.len(),
};
let (mut files_to_search, mut filtered_file_count) = prefilter_files(
files,
constraints_from_query,
bigram_candidates.as_deref(),
base_count,
options,
arena,
);
if files_to_search.is_empty()
&& let Some(stripped) =
strip_file_path_constraint_if_present(constraints_from_query)
{
let (retry_files, retry_count) = prefilter_files(
files,
&stripped,
bigram_candidates.as_deref(),
base_count,
options,
arena,
);
files_to_search = retry_files;
filtered_file_count = retry_count;
}
if files_to_search.is_empty() {
return GrepResult {
total_files,
filtered_file_count,
next_file_offset: 0,
..Default::default()
};
}
return fuzzy_grep_search(
@@ -2084,108 +2103,36 @@ pub(crate) fn grep_search<'a>(
None
};
// Overflow files (added after the bigram index was built) are not in
// the candidate bitset. They're few by definition, so just search all
// of them directly via memchr — no bigram tracking needed.
let overflow_start = bigram_overlay
// Bigram bitset only covers `files[..bigram_boundary]`. Overflow + unindexable
// tail files past the boundary are always retained — `prefilter_files` walks them
// via the linear sweep after the bitset walk.
let bigram_boundary = bigram_overlay
.map(|o| o.base_file_count())
.unwrap_or(files.len());
// it is important that this step is coming as early as possible
let (files_to_search, filtered_file_count) = match bigram_candidates {
Some(ref candidates) if constraints_from_query.is_empty() => {
// this call is essentially free and much more efficient than allowing a recollection
let overflow_count = files.len().saturating_sub(overflow_start);
let cap = BigramFilter::count_candidates(candidates) + overflow_count;
let mut result: Vec<&FileItem> = Vec::with_capacity(cap);
let (mut files_to_search, mut filtered_file_count) = prefilter_files(
files,
constraints_from_query,
bigram_candidates.as_deref(),
bigram_boundary,
options,
arena,
);
for (word_idx, &word) in candidates.iter().enumerate() {
if word == 0 {
continue;
}
let base = word_idx * 64;
let mut bits = word;
while bits != 0 {
let bit = bits.trailing_zeros() as usize;
let file_idx = base + bit;
// Stop at the overflow boundary: the loop below walks
// every overflow file, so counting them here too would duplicate.
if file_idx < overflow_start {
let f = unsafe { files.get_unchecked(file_idx) };
if !f.is_binary() && f.size <= options.max_file_size {
result.push(f);
}
}
bits &= bits - 1;
}
}
// Append all overflow files — they're not in the bigram index
// so we search them unconditionally (typically few files).
for f in &files[overflow_start..] {
if !f.is_binary() && !f.is_deleted() && f.size <= options.max_file_size {
result.push(f);
}
}
let total_searchable = files.len();
let needs_sort = result
.iter()
.any(|f| f.total_frecency_score() != 0 || f.modified != 0);
if needs_sort {
sort_with_buffer(&mut result, |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 < result.len() {
let paginated = result.split_off(options.file_offset);
(paginated, total_searchable)
} else if options.file_offset >= result.len() {
(Vec::new(), total_searchable)
} else {
(result, total_searchable)
}
}
_ => {
let (mut fts, mut fc) =
prepare_files_to_search(files, constraints_from_query, options, arena);
if fts.is_empty()
&& let Some(stripped) = strip_file_path_constraints(constraints_from_query)
{
let (retry_files, retry_count) =
prepare_files_to_search(files, &stripped, options, arena);
fts = retry_files;
fc = retry_count;
}
if let Some(ref candidates) = bigram_candidates {
let base_ptr = files.as_ptr();
fts.retain(|f| {
if f.is_overflow() {
return true;
}
let file_idx =
unsafe { (*f as *const FileItem).offset_from(base_ptr) as usize };
// Files past the bigram boundary (unindexable base files)
// are not tracked by the bigram filter — always search them.
if file_idx >= overflow_start {
return true;
}
BigramFilter::is_candidate(candidates, file_idx)
});
}
(fts, fc)
}
};
if files_to_search.is_empty()
&& let Some(stripped) = strip_file_path_constraint_if_present(constraints_from_query)
{
let (retry_files, retry_count) = prefilter_files(
files,
&stripped,
bigram_candidates.as_deref(),
bigram_boundary,
options,
arena,
);
files_to_search = retry_files;
filtered_file_count = retry_count;
}
if files_to_search.is_empty() {
return GrepResult {
@@ -2271,7 +2218,7 @@ pub fn parse_grep_query(query: &str) -> FFFQuery<'_> {
parser.parse(query)
}
fn strip_file_path_constraints<'a>(
fn strip_file_path_constraint_if_present<'a>(
constraints: &[Constraint<'a>],
) -> Option<fff_query_parser::ConstraintVec<'a>> {
if !constraints
@@ -2433,7 +2380,7 @@ mod tests {
let arena = picker.arena_base_ptr();
let options = super::GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_file_size: MAX_FFFILE_SIZE,
max_matches_per_file: 0,
smart_case: true,
file_offset: 0,
@@ -2617,7 +2564,7 @@ mod tests {
// (a, b, c in base + f, g, h in overflow).
let query = super::parse_grep_query("unicorn");
let options = super::GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_file_size: MAX_FFFILE_SIZE,
max_matches_per_file: 0,
smart_case: true,
file_offset: 0,
+1
View File
@@ -98,6 +98,7 @@ mod scan;
#[doc(hidden)]
pub mod bigram_filter;
pub mod bigram_query;
pub mod constants;
mod constraints;
mod error;
mod score;
+34 -10
View File
@@ -7,7 +7,7 @@ use tracing::{error, info};
use crate::FileSync;
use crate::background_watcher::BackgroundWatcher;
use crate::bigram_filter::build_bigram_index;
use crate::bigram_filter::{build_bigram_index, sniff_binary_for_non_indexable};
use crate::error::Error;
use crate::file_picker::{BACKGROUND_THREAD_POOL, FFFMode};
use crate::git::GitStatusCache;
@@ -40,6 +40,8 @@ pub(crate) struct ScanConfig {
pub(crate) auto_cache_budget: bool,
pub(crate) install_watcher: bool,
pub(crate) follow_symlinks: bool,
pub(crate) enable_fs_root_scanning: bool,
pub(crate) enable_home_dir_scanning: bool,
}
/// A fully-configured scan job ready to run on a background thread.
@@ -89,6 +91,8 @@ impl ScanJob {
auto_cache_budget: !picker.has_explicit_cache_budget(),
install_watcher: false, // the watcher is independent of rescan, it is not restarting EVER
follow_symlinks: picker.follows_symlinks(),
enable_fs_root_scanning: picker.fs_root_scanning_enabled(),
enable_home_dir_scanning: picker.home_dir_scanning_enabled(),
};
drop(guard); // just a sanity check
@@ -211,8 +215,9 @@ impl ScanJob {
// 3. Post-scan warmup + bigram build — runs in parallel with the
// git-status thread to overlap the two expensive phases.
if (config.warmup || config.content_indexing)
&& !signals.cancelled.load(Ordering::Acquire)
// Always runs (even with both flags off) so binary-content files
// with unknown extensions get reclassified before user search hits.
if !signals.cancelled.load(Ordering::Acquire)
&& let Some(snap) = snapshot.as_ref()
{
Self::run_post_scan(&shared_picker, &signals, &config, snap);
@@ -242,6 +247,8 @@ impl ScanJob {
shared_picker.clone(),
shared_frecency.clone(),
mode,
config.enable_fs_root_scanning,
config.enable_home_dir_scanning,
) {
Ok(watcher) => {
if let Ok(mut guard) = shared_picker.write()
@@ -289,20 +296,23 @@ impl ScanJob {
config: &ScanConfig,
unsafe_snapshot: &crate::file_picker::PostScanUnsafeSnapshot,
) {
let arena = unsafe_snapshot
.arena
let Some(arena) = unsafe_snapshot
.arena // we are never touching overlays so this arena is always correct
.as_ref()
.map(|s| s.as_arena_ptr())
.unwrap_or(ArenaPtr::null());
let _budget: &ContentCacheBudget = &unsafe_snapshot.budget;
let files: &[crate::types::FileItem] = &unsafe_snapshot.files[..unsafe_snapshot.base_count];
else {
tracing::error!("Failed to run post scan: arena is invalid");
return;
};
let files: &[crate::types::FileItem] = &unsafe_snapshot.files[..unsafe_snapshot.base_count];
if signals.cancelled.load(Ordering::Acquire) {
return;
}
if config.content_indexing {
let indexable_files = &files[..unsafe_snapshot.indexable_count.min(files.len())];
let indexable_count = unsafe_snapshot.indexable_count.min(files.len());
let (indexable_files, non_indexable_files) = files.split_at(indexable_count);
let index = build_bigram_index(indexable_files, &unsafe_snapshot.base_path, arena);
if let Ok(mut guard) = shared_picker.write()
@@ -310,9 +320,23 @@ impl ScanJob {
{
picker.set_bigram_index(index);
}
// Bigram only sniffs files <= MAX_INDEXABLE_FILE_SIZE; large
// unknown-extension binaries slip past it and would otherwise be
// grep-able as text. Cheap header sniff catches those.
if !signals.cancelled.load(Ordering::Acquire) {
sniff_binary_for_non_indexable(
non_indexable_files,
&unsafe_snapshot.base_path,
arena,
);
}
} else {
// this potentially a long running as we are not parallelizing it but it's okay
sniff_binary_for_non_indexable(files, &unsafe_snapshot.base_path, arena);
}
// Skipped as potentially unsafe - figure this out later
// TODO Skipped as potentially unsafe - figure this out later
// if config.warmup && !signals.cancelled.load(Ordering::Acquire) {
// warmup_mmaps(files, budget, &unsafe_snapshot.base_path, arena);
// }
+1 -1
View File
@@ -60,7 +60,7 @@ impl std::fmt::Debug for SimdChunk {
}
}
pub const PATH_BUF_SIZE: usize = 4096;
pub use crate::constants::PATH_BUF_SIZE;
/// Indices into a shared `SimdChunk` arena representing a file path.
///
+50 -23
View File
@@ -4,9 +4,12 @@ use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::sync::atomic::{AtomicI32, AtomicU8, AtomicU64, AtomicUsize, Ordering};
#[cfg(not(target_os = "windows"))]
use crate::constants::{FRESH_MMAP_THRESHOLD, MMAP_THRESHOLD};
use crate::constants::{MAX_CACHED_CONTENT_BYTES, MAX_FFFILE_SIZE, PATH_BUF_SIZE};
use crate::constraints::Constrainable;
use crate::query_tracker::QueryMatchEntry;
use crate::simd_path::{ArenaPtr, PATH_BUF_SIZE};
use crate::simd_path::ArenaPtr;
use fff_query_parser::{FFFQuery, FuzzyQuery, Location};
/// Different sources of the string storage used by FFF
@@ -237,6 +240,18 @@ impl Clone for FileItem {
}
}
/// Single-block read used by the binary classifier. Most binaries reveal a
/// NUL byte within the first filesystem block, so 16 KB lets one read settle
/// the classification for typical files while keeping the scratch buffer
/// small enough to live on the stack.
pub const BINARY_CLASSIFICATION_CHUNK_SIZE: usize = 16 * 1024;
/// A file is treated as binary if any NUL byte appears in the scanned prefix.
#[inline]
pub(crate) fn detect_binary_content(content: &[u8]) -> bool {
memchr::memchr(0, content).is_some()
}
impl FileItem {
pub fn new_raw(
filename_start: u16,
@@ -499,6 +514,38 @@ impl FileItem {
}
}
/// Chunked classifier of the binary content of the file chunk by chunk
/// accepts path which to reuse the allocated buffer for absolute path read
pub(crate) fn detect_binary_per_byte(&self, path: &Path, chunk: &mut [u8]) {
if self.size == 0 {
return;
}
let Ok(mut file) = std::fs::OpenOptions::new()
.write(false)
.read(true)
.open(path)
else {
tracing::error!(path = ?path.display(), "Failed to open indexed file");
return;
};
loop {
match file.read(chunk) {
Ok(0) => break,
Err(e) => {
tracing::error!(?e, "Failed to read file chunk");
break;
}
Ok(n) => {
if detect_binary_content(&chunk[..n]) {
self.set_binary(true);
}
}
}
}
}
#[inline]
pub fn is_deleted(&self) -> bool {
self.flags.load(Ordering::Relaxed) & FileItemFlags::DELETED != 0
@@ -686,22 +733,6 @@ impl FileItem {
}
}
#[cfg(all(not(target_os = "windows"), target_arch = "aarch64"))]
const MMAP_THRESHOLD: u64 = 16 * 1024;
#[cfg(all(not(target_os = "windows"), not(target_arch = "aarch64")))]
const MMAP_THRESHOLD: u64 = 4 * 1024;
// these are empirically set values for the benchmarks. Theory is simple:
// the larger the file is - the more syscalls needed to read the file, so at some
// point it becomes better strategy to mmap file and process instead of doing chunking
#[cfg(target_os = "macos")]
pub(crate) const FRESH_MMAP_THRESHOLD: u64 = 1024 * 1024;
#[cfg(target_os = "windows")]
#[allow(dead_code)]
pub(crate) const FRESH_MMAP_THRESHOLD: u64 = 0;
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub(crate) const FRESH_MMAP_THRESHOLD: u64 = 256 * 1024;
/// Per-thread scratch slot owning a transient mmap returned from
/// [`FileItem::get_content_for_search`]. `Option<Mmap>` on Unix,
/// unit on Windows where mmap is unused.
@@ -825,10 +856,6 @@ impl Default for MixedItemRef<'_> {
}
}
const MAX_MMAP_FILE_SIZE: u64 = 10 * 1024 * 1024;
const MAX_CACHED_CONTENT_BYTES: u64 = 512 * 1024 * 1024;
#[derive(Debug)]
pub struct ContentCacheBudget {
pub max_files: usize,
@@ -843,7 +870,7 @@ impl ContentCacheBudget {
Self {
max_files: usize::MAX,
max_bytes: u64::MAX,
max_file_size: MAX_MMAP_FILE_SIZE,
max_file_size: MAX_FFFILE_SIZE,
cached_count: AtomicUsize::new(0),
cached_bytes: AtomicU64::new(0),
}
@@ -885,7 +912,7 @@ impl ContentCacheBudget {
Self {
max_files,
max_bytes,
max_file_size: MAX_MMAP_FILE_SIZE,
max_file_size: MAX_FFFILE_SIZE,
cached_count: AtomicUsize::new(0),
cached_bytes: AtomicU64::new(0),
}
Binary file not shown.
Binary file not shown.
+351
View File
@@ -390,6 +390,357 @@ fn binary_payload_after_long_ascii_header_is_detected() {
);
}
#[test]
fn unknown_extension_binary_added_after_scan_is_reclassified() {
// The initial-scan path runs detect_binary_content as part of bigram build,
// but the watcher path used to fall back to extension-only triage and
// missed binary files with unknown extensions like `.codex`.
use fff_search::file_picker::FFFMode;
use fff_search::{SharedFilePicker, SharedFrecency};
use std::time::Duration;
let tmp = TempDir::new().unwrap();
let base = tmp.path();
// Seed one tracked text file so the initial scan has something to work with.
fs::write(base.join("seed.txt"), b"seed\n").unwrap();
let shared_picker = SharedFilePicker::default();
let shared_frecency = SharedFrecency::default();
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().to_string(),
enable_mmap_cache: false,
enable_content_indexing: true,
mode: FFFMode::Neovim,
watch: false,
..Default::default()
},
)
.expect("Failed to create FilePicker");
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
std::thread::sleep(Duration::from_millis(25));
let ready = shared_picker
.read()
.ok()
.and_then(|g| {
g.as_ref()
.map(|p| !p.is_scan_active() && p.bigram_index().is_some())
})
.unwrap_or(false);
if ready {
break;
}
assert!(
std::time::Instant::now() < deadline,
"Timed out waiting for bigram build"
);
}
// Drop the file on disk after indexing finished, then announce it through
// the watcher entry point. `.codex` is intentionally not in the extension
// allow-list — only a content sniff can flag it.
let mut payload = vec![0x03u8, 0x00, 0x04, 0x05];
payload.extend(std::iter::repeat_n(0u8, 256));
let new_path = base.join("snapshot.codex");
fs::write(&new_path, &payload).unwrap();
{
let mut guard = shared_picker.write().unwrap();
let picker = guard.as_mut().unwrap();
assert!(
picker.handle_create_or_modify(&new_path).is_some(),
"handle_create_or_modify must accept the new file"
);
}
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let was_flagged = picker
.get_files()
.iter()
.any(|f| f.relative_path(picker).contains("snapshot.codex") && f.is_binary());
assert!(
was_flagged,
"snapshot.codex must be flagged binary when added via the watcher path"
);
}
#[test]
fn text_file_modified_to_binary_is_reclassified() {
// A file that started life as text and later got rewritten with NUL bytes
// (e.g. a generator overwrote a .log) must lose its text classification.
use fff_search::file_picker::FFFMode;
use fff_search::{SharedFilePicker, SharedFrecency};
use std::time::Duration;
let tmp = TempDir::new().unwrap();
let base = tmp.path();
// Start as plain text with a known extension.
fs::write(base.join("notes.txt"), b"hello world\n").unwrap();
let shared_picker = SharedFilePicker::default();
let shared_frecency = SharedFrecency::default();
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().to_string(),
enable_mmap_cache: false,
enable_content_indexing: true,
mode: FFFMode::Neovim,
watch: false,
..Default::default()
},
)
.expect("Failed to create FilePicker");
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
std::thread::sleep(Duration::from_millis(25));
let ready = shared_picker
.read()
.ok()
.and_then(|g| {
g.as_ref()
.map(|p| !p.is_scan_active() && p.bigram_index().is_some())
})
.unwrap_or(false);
if ready {
break;
}
assert!(
std::time::Instant::now() < deadline,
"Timed out waiting for bigram build"
);
}
// Sanity: it's text right now.
{
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let is_text = picker
.get_files()
.iter()
.any(|f| f.relative_path(picker).contains("notes.txt") && !f.is_binary());
assert!(is_text, "notes.txt should start as text");
}
// Overwrite with binary content and replay through the watcher entry point.
// Bump mtime so update_metadata records it as a real change.
std::thread::sleep(Duration::from_secs(1));
let mut payload = b"header text\n".to_vec();
payload.extend(std::iter::repeat_n(0u8, 256));
fs::write(base.join("notes.txt"), &payload).unwrap();
{
let mut guard = shared_picker.write().unwrap();
let picker = guard.as_mut().unwrap();
assert!(
picker
.handle_create_or_modify(base.join("notes.txt"))
.is_some(),
"handle_create_or_modify must succeed for the modify case"
);
}
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let now_binary = picker
.get_files()
.iter()
.any(|f| f.relative_path(picker).contains("notes.txt") && f.is_binary());
assert!(
now_binary,
"notes.txt must flip to binary after being overwritten with NULs"
);
}
#[test]
fn large_unknown_extension_binary_is_classified_at_scan_time() {
// Files larger than MAX_INDEXABLE_FILE_SIZE never enter build_bigram_index,
// so without a separate header sniff they default to is_binary=false and
// pollute grep results with NUL-laden lines (e.g. a committed ELF blob
// named `codex_view` with no extension).
use fff_search::file_picker::FFFMode;
use fff_search::grep::{GrepSearchOptions, parse_grep_query};
use fff_search::{SharedFilePicker, SharedFrecency};
use std::time::Duration;
let tmp = TempDir::new().unwrap();
let base = tmp.path();
// 3 MiB: above the 2 MiB bigram cap and below the 10 MiB grep cap.
// ELF-like header with NULs at the very start, then ASCII filler so a
// grep for "match this text" would otherwise return polluted lines.
let mut blob = Vec::new();
blob.extend_from_slice(b"\x7fELF\x02\x01\x01\x00");
blob.extend(std::iter::repeat_n(0u8, 256));
blob.extend_from_slice(b"\nmatch this text\n");
blob.extend(std::iter::repeat_n(b'A', 3 * 1024 * 1024));
blob.extend_from_slice(b"\nmatch this text\n");
fs::write(base.join("codex_view"), &blob).unwrap();
fs::write(base.join("plain.txt"), b"match this text\n").unwrap();
let shared_picker = SharedFilePicker::default();
let shared_frecency = SharedFrecency::default();
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().to_string(),
enable_mmap_cache: false,
enable_content_indexing: true,
mode: FFFMode::Neovim,
watch: false,
..Default::default()
},
)
.expect("Failed to create FilePicker");
let deadline = std::time::Instant::now() + Duration::from_secs(15);
loop {
std::thread::sleep(Duration::from_millis(25));
let ready = shared_picker
.read()
.ok()
.and_then(|g| {
g.as_ref()
.map(|p| !p.is_scan_active() && p.bigram_index().is_some())
})
.unwrap_or(false);
if ready {
break;
}
assert!(
std::time::Instant::now() < deadline,
"Timed out waiting for bigram build"
);
}
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let was_flagged = picker
.get_files()
.iter()
.any(|f| f.relative_path(picker).contains("codex_view") && f.is_binary());
assert!(
was_flagged,
"large no-extension binary must be flagged via the header sniff"
);
let parsed = parse_grep_query("match this text");
let opts = GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
..plain_opts()
};
let result = picker.grep(&parsed, &opts);
assert_eq!(
result.files.len(),
1,
"only plain.txt should be searched; codex_view must be skipped as binary"
);
assert!(
result.files[0].relative_path(picker).contains("plain.txt"),
"the only match should come from plain.txt"
);
}
#[test]
fn large_binary_with_nuls_past_header_is_classified() {
// Guards the streaming sniff: a >2 MB file that is pure ASCII well past any
// fixed header window (the old code only checked the first 8 KB) but has
// NULs deeper in. Grep reads the whole file up to max_file_size, so the
// detector must scan the same range or the binary tail leaks as "text".
use fff_search::file_picker::FFFMode;
use fff_search::grep::{GrepSearchOptions, parse_grep_query};
use fff_search::{SharedFilePicker, SharedFrecency};
use std::time::Duration;
let tmp = TempDir::new().unwrap();
let base = tmp.path();
// 1 MiB of clean ASCII (with a grep marker) — dwarfs any header sniff —
// then NUL bytes, keeping the total above the 2 MiB non-indexable cap.
let mut blob = Vec::new();
blob.extend_from_slice(b"match this text\n");
blob.extend(std::iter::repeat_n(b'A', 1024 * 1024));
blob.extend_from_slice(b"match this text\n");
blob.extend(std::iter::repeat_n(0u8, 1024 * 1024 + 4096)); // NULs start ~1 MiB in
blob.extend_from_slice(b"match this text\n");
assert!(blob.len() > 2 * 1024 * 1024);
fs::write(base.join("late_nul.dat"), &blob).unwrap();
fs::write(base.join("plain.txt"), b"match this text\n").unwrap();
let shared_picker = SharedFilePicker::default();
let shared_frecency = SharedFrecency::default();
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().to_string(),
enable_mmap_cache: false,
enable_content_indexing: true,
mode: FFFMode::Neovim,
watch: false,
..Default::default()
},
)
.expect("Failed to create FilePicker");
let deadline = std::time::Instant::now() + Duration::from_secs(15);
loop {
std::thread::sleep(Duration::from_millis(25));
let ready = shared_picker
.read()
.ok()
.and_then(|g| {
g.as_ref()
.map(|p| !p.is_scan_active() && p.bigram_index().is_some())
})
.unwrap_or(false);
if ready {
break;
}
assert!(
std::time::Instant::now() < deadline,
"Timed out waiting for bigram build"
);
}
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let flagged = picker
.get_files()
.iter()
.any(|f| f.relative_path(picker).contains("late_nul.dat") && f.is_binary());
assert!(
flagged,
"NULs past the 8 KB header window must still be detected by the streaming scan"
);
let parsed = parse_grep_query("match this text");
let opts = GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
..plain_opts()
};
let result = picker.grep(&parsed, &opts);
assert_eq!(
result.files.len(),
1,
"only plain.txt should match; late_nul.dat must be skipped as binary"
);
assert!(result.files[0].relative_path(picker).contains("plain.txt"));
}
#[test]
fn plain_text_max_matches_per_file() {
let tmp = TempDir::new().unwrap();
@@ -0,0 +1,153 @@
//! Real-world binary fixture regression.
//!
//! Reproduces the exact bug chain we hit with `codex_view` (4.5 MB ELF, no
//! extension) and `codex_view.codex` (127 KB, unknown extension): both are
//! binary by content but slip past extension-only triage, so a plain grep
//! used to surface their NUL-laden bytes as "text" matches.
//!
//! The fixtures live in `tests/fixtures/binaries/`. `MARKER` is a string that
//! is present (as raw bytes) in BOTH binaries — the test first asserts that,
//! then drops the two binaries plus a single plain-text file containing the
//! same marker into a closed temp dir and greps for it. Only the text file may
//! come back; if binary detection ever regresses, a binary file re-enters the
//! results and this test fails.
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
use fff_search::file_picker::{FFFMode, FilePicker};
use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query};
use fff_search::{FilePickerOptions, SharedFilePicker, SharedFrecency};
const MARKER: &str = "__jai_runtime_init";
fn fixtures_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/binaries")
}
fn plain_opts() -> GrepSearchOptions {
GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 200,
smart_case: true,
file_offset: 0,
page_limit: 200,
mode: GrepMode::PlainText,
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
abort_signal: None,
}
}
#[test]
fn real_binary_fixtures_are_detected_and_excluded_from_grep() {
let fixtures = fixtures_dir();
let large = fixtures.join("codex_view"); // 4.5 MB ELF, no extension (> 2 MB)
let small = fixtures.join("codex_view.codex"); // 127 KB, unknown extension (< 2 MB)
assert!(
large.exists() && small.exists(),
"missing binary fixtures in {}",
fixtures.display()
);
// Both fixtures must really contain the marker bytes, otherwise the grep
// exclusion assertion below would be vacuous.
let large_bytes = fs::read(&large).unwrap();
let small_bytes = fs::read(&small).unwrap();
assert!(
contains_subslice(&large_bytes, MARKER.as_bytes()),
"fixture codex_view no longer contains the marker {MARKER:?}"
);
assert!(
contains_subslice(&small_bytes, MARKER.as_bytes()),
"fixture codex_view.codex no longer contains the marker {MARKER:?}"
);
// Sanity on the size split that drives the two distinct code paths.
assert!(
large_bytes.len() > 2 * 1024 * 1024,
"codex_view must exceed the 2 MB non-indexable threshold"
);
assert!(
small_bytes.len() < 2 * 1024 * 1024,
"codex_view.codex must stay under the 2 MB bigram cap"
);
// Closed environment: the two real binaries + one plain-text file that
// legitimately contains the marker.
let tmp = tempfile::TempDir::new().unwrap();
let base = tmp.path();
fs::copy(&large, base.join("codex_view")).unwrap();
fs::copy(&small, base.join("codex_view.codex")).unwrap();
fs::write(
base.join("marker.txt"),
format!("the only legitimate hit lives here: {MARKER}\n"),
)
.unwrap();
let shared_picker = SharedFilePicker::default();
let shared_frecency = SharedFrecency::default();
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().to_string(),
enable_mmap_cache: false,
enable_content_indexing: true,
mode: FFFMode::Neovim,
watch: false,
..Default::default()
},
)
.expect("failed to create FilePicker");
shared_picker.wait_for_indexing_complete(Duration::from_secs(5));
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
// Both binaries must be classified binary.
for name in ["codex_view", "codex_view.codex"] {
let flagged = picker
.get_files()
.iter()
.any(|f| f.relative_path(picker).ends_with(name) && f.is_binary());
assert!(flagged, "{name} must be flagged is_binary");
}
// we need to make sure that marker.txt ONLY can match as we have to match
// grep as binaries are excluded from the matching process
let parsed = parse_grep_query(MARKER);
let result = picker.grep(&parsed, &plain_opts());
let matched: Vec<String> = result
.files
.iter()
.map(|f| f.relative_path(picker))
.collect();
assert_eq!(
result.files.len(),
1,
"exactly one file should match {MARKER:?}, got: {matched:?}"
);
assert!(
matched[0].ends_with("marker.txt"),
"the only match must be marker.txt, got {:?}",
matched[0]
);
}
/// Tiny substring search over raw bytes (the marker may be surrounded by NULs).
fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || haystack.len() < needle.len() {
return false;
}
haystack
.windows(needle.len())
.any(|window| window == needle)
}
+1 -2
View File
@@ -68,8 +68,7 @@ pub fn count(bytes: &[u8], line_term: u8) -> u64 {
memchr::memchr_iter(line_term, bytes).count() as u64
}
/// Given a line that possibly ends with a terminator, return that line without
/// the terminator.
/// Given a line that possibly ends with a terminator, return that line without the terminator.
#[inline(always)]
pub fn without_terminator(bytes: &[u8], line_term: LineTerminator) -> &[u8] {
let line_term = line_term.as_bytes();
+1
View File
@@ -270,6 +270,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.max_cached_files
.map(fff::ContentCacheBudget::new_for_repo),
follow_symlinks: false,
..Default::default()
},
)
.map_err(|e| format!("Failed to init file picker: {}", e))?;
+5
View File
@@ -239,6 +239,11 @@ impl IntoLua for GrepResultLua<'_> {
item.set("line_number", m.line_number)?;
item.set("col", m.col)?;
item.set("byte_offset", m.byte_offset)?;
// There is a little race window when fff can return matches inside of a non-binary
// classified entities, the window is minimal but it errors out neovim so guard it
let is_binary_content = m.line_content.as_bytes().contains(&0u8);
item.set("is_binary_content", is_binary_content)?;
item.set("line_content", m.line_content.as_str())?;
// Match byte ranges within line_content
+148 -3
View File
@@ -1,5 +1,5 @@
*fff.nvim.txt*
For Neovim >= 0.10.0 Last change: 2026 May 20
For Neovim >= 0.10.0 Last change: 2026 June 02
==============================================================================
Table of Contents *fff.nvim-table-of-contents*
@@ -88,9 +88,77 @@ PUBLIC API ~
require('fff').refresh_git_status() -- refresh git status
require('fff').find_files_in_dir(path) -- find in a specific dir
require('fff').change_indexing_directory(new_path) -- change root
-- Programmatic search (no UI). Useful for plugin integrations.
require('fff').file_search(query, opts) -- fuzzy search files / dirs / mixed
require('fff').content_search(query, opts) -- programmatic grep
<
FILE_SEARCH(QUERY, OPTS)
Returns a structured result `{ items, scores, total_matched, total_files?,
total_dirs?, location? }`. Each item has a `type` field (`"file"` or
`"directory"`) and `name` / `relative_path`. File items also expose `size`,
`modified`, `git_status`, `is_binary`, and frecency scores.
>lua
local r = require('fff').file_search('button', {
mode = 'mixed', -- 'files' (default) | 'directories' | 'mixed'
max_results = 50,
page = 0, -- 0-based pagination
current_file = nil, -- path to deprioritize for distance scoring
max_threads = 4,
cwd = nil, -- switch indexed root if different (see below)
wait_for_index_ms = nil, -- override the default scan wait timeout
})
for _, item in ipairs(r.items) do
print(item.type, item.relative_path)
end
<
CONTENT_SEARCH(QUERY, OPTS)
Returns a `GrepResult` `{ items, total_matched, total_files_searched,
total_files, filtered_file_count, next_file_offset, regex_fallback_error? }`.
Each match item has `relative_path`, `name`, `line_number`, `col`,
`line_content`, `match_ranges`, plus the same file metadata as `file_search`.
>lua
local r = require('fff').content_search('TODO', {
mode = 'plain', -- 'plain' (default) | 'regex' | 'fuzzy'
max_file_size = 10 * 1024 * 1024,
max_matches_per_file = 100,
smart_case = true,
page_size = 50,
file_offset = 0,
time_budget_ms = 0,
trim_whitespace = false,
cwd = nil, -- switch indexed root if different
wait_for_index_ms = nil, -- override the default scan wait timeout
})
for _, m in ipairs(r.items) do
print(string.format('%s:%d %s', m.relative_path, m.line_number, m.line_content))
end
<
Both functions accept the same constraint syntax as the UI pickers (e.g.
`git:modified`, `*.rs`, `!test/`, glob patterns).
CWD AND INDEXING
Both `file_search` and `content_search` honour an optional `cwd` field. The
first call to either function lazily initialises the picker at
`config.base_path` (your Neovim cwd by default).
- If `cwd` matches the currently indexed root, the call returns immediately against the existing index.
- If `cwd` differs, the picker is re-indexed at the new root and the call **blocks** (default up to 10 s) until the new picker is installed and its initial scan completes — so callers always get results from the right tree.
- If the index is still warming up after a `change_indexing_directory`, you can pass `wait_for_index_ms = N` to block for up to `N` ms regardless of whether `cwd` triggered the swap. Pass `0` to skip waiting entirely (useful for fire-and-forget calls where partial results are acceptable).
- Invalid or non-existent `cwd` paths return an empty result and emit an error via `vim.notify`.
COMMANDS ~
- `:FFFScan`. Rescan files.
@@ -121,6 +189,7 @@ Defaults are sensible. Override only what you care about.
preview_position = 'right', -- 'left' | 'right' | 'top' | 'bottom'
preview_size = 0.5,
flex = { size = 130, wrap = 'top' },
min_list_height = 10, -- do not display anything except the list below this threshold
show_scrollbar = true,
path_shorten_strategy = 'middle_number', -- 'middle_number' | 'middle' | 'end' | 'start'
anchor = 'center',
@@ -178,15 +247,22 @@ Defaults are sensible. Override only what you care about.
time_budget_ms = 150,
modes = { 'plain', 'regex', 'fuzzy' },
trim_whitespace = false,
location_format = ':%d:%d', -- printf format for line:col prefix in grep results, e.g. ':%d' for line-only
},
debug = {
enabled = false, -- show the file info panel next to the preview
show_scores = false, -- inline scores in the file list
-- Per-section toggles for the file info panel. Accepts a boolean shorthand
-- (`show_file_info = true|false`) to flip everything at once. The panel
-- adapts to width: narrow renders sections vertically, wide renders them
-- as a two-column grid. Disable a section to also shrink the panel.
show_file_info = {
file_info = true, -- size, type, git status, frecency
score_breakdown = true, -- total + match type, bonuses, modifiers, penalty
timings = true, -- modified + accessed timestamps
full_path = true, -- absolute path at the bottom (wraps if too long)
-- modified + accessed timestamps; pass a table to hide individual rows:
-- timings = { modified = false, accessed = true }
timings = true,
full_path = true, -- relative path at the bottom (wraps if too long)
},
},
logging = {
@@ -242,6 +318,75 @@ set `git.status_text_color = true` and adjust the `hl.git_*` groups. See `:help
fff.nvim` for the full list.
FLOAT COLORS ~
The picker maps its float content to `NormalFloat` (via `hl.normal`) and the
border to `FloatBorder`. Default `FloatBorder` links to `NormalFloat`, so
border and content share a background out of the box and the picker reads as a
single popup. Override `hl.normal = 'Normal'` to make the picker blend with the
editor instead.
For finer control, set `hl.winhl` to override the per-window `winhighlight`. It
accepts either a single string applied to every picker window, or a table with
optional `prompt`, `list`, `preview`, and `file_info` keys. Missing keys fall
back to the default built from `hl.normal`, `hl.border`, and `hl.title`.
>lua
-- Apply the same winhighlight to all picker windows
hl = { winhl = 'Normal:NormalFloat,FloatBorder:FloatBorder,FloatTitle:Title' }
-- Or override specific windows only
hl = {
winhl = {
prompt = 'Normal:Pmenu,FloatBorder:FloatBorder',
list = 'Normal:NormalFloat,FloatBorder:FloatBorder',
preview = 'Normal:NormalFloat,FloatBorder:FloatBorder',
},
}
<
FILE INFO PANEL ~
Enable with `debug.enabled = true`. The panel sits above the preview and shows
file metadata, score breakdown, timestamps and the full absolute path. It
adapts to the panel width: at narrow widths sections stack vertically (B2), at
wide widths sections render as a two-column grid (H2). Each section can be
disabled individually via `debug.show_file_info`.
Customise the panel via `hl`:
-----------------------------------------------------------------------
key default used for
----------------------- ----------------- -----------------------------
file_info_section Title section header label
file_info_separator FloatBorder dashes that act as section
borders
file_info_label Comment row labels (Size, Type, Git,
…)
file_info_value Normal fg plain values
file_info_value_dim NonText dim values, separators inside
rows
file_info_size Number file size value
file_info_type Type filetype value
file_info_path Directory full path
file_info_total_score bold + Number total score (bold)
file_info_match_type bold + Special match type (bold)
file_info_score_pos DiagnosticOk positive score components
file_info_score_neg DiagnosticError negative score components
-----------------------------------------------------------------------
FILE FILTERING ~
FFF honours `.gitignore`. For picker-only ignores that do not touch git, add a
+7 -26
View File
@@ -10,28 +10,6 @@ local M = {}
-- Namespace dedicated to the file info panel highlights.
M.file_info_ns = vim.api.nvim_create_namespace('fff_file_info')
-- Preview buffers are scratch buffers. Detect the file's language and attach
-- highlighting directly, but keep buffer filetype empty to avoid ftplugin and
-- LSP side effects that are meant for real editing buffers.
local function attach_preview_highlighter(bufnr, filetype)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
pcall(vim.treesitter.stop, bufnr)
vim.api.nvim_set_option_value('filetype', '', { buf = bufnr })
vim.api.nvim_set_option_value('syntax', '', { buf = bufnr })
if not filetype or filetype == '' then return end
local lang_ok, lang = pcall(vim.treesitter.language.get_lang, filetype)
if not lang_ok or not lang then lang = filetype end
if pcall(vim.treesitter.language.add, lang) then
pcall(vim.treesitter.start, bufnr, lang)
else
vim.api.nvim_set_option_value('syntax', filetype, { buf = bufnr })
end
end
local function set_buffer_lines(bufnr, lines)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
@@ -300,7 +278,7 @@ local function link_buffer_content(source_bufnr, target_bufnr)
set_buffer_lines(target_bufnr, lines)
local source_ft = vim.api.nvim_get_option_value('filetype', { buf = source_bufnr })
if source_ft ~= '' then attach_preview_highlighter(target_bufnr, source_ft) end
if source_ft ~= '' then vim.api.nvim_set_option_value('filetype', source_ft, { buf = target_bufnr }) end
M.state.has_more_content = false
M.state.total_file_lines = #lines
@@ -456,7 +434,7 @@ function M.preview_file(file_path, bufnr)
set_buffer_lines(bufnr, content)
local file_config = M.get_file_config(file_path)
attach_preview_highlighter(bufnr, info.filetype)
vim.api.nvim_set_option_value('filetype', info.filetype, { buf = bufnr })
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
@@ -575,7 +553,7 @@ function M.preview_binary_file(file_path, bufnr)
end
set_buffer_lines(bufnr, lines)
attach_preview_highlighter(bufnr, 'text')
vim.api.nvim_set_option_value('filetype', 'text', { buf = bufnr })
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
@@ -858,8 +836,11 @@ function M.clear_buffer(bufnr)
cleanup_file_operation()
M.clear_preview_visual_state(bufnr)
pcall(vim.treesitter.stop, bufnr)
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
attach_preview_highlighter(bufnr, '')
vim.api.nvim_set_option_value('filetype', '', { buf = bufnr })
vim.api.nvim_set_option_value('syntax', '', { buf = bufnr })
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
set_buffer_lines(bufnr, {})
+15 -2
View File
@@ -48,6 +48,8 @@ local function format_location(item, ctx)
return str
end
local BINARY_PLACEHOLDER = '<binary content>'
local function render_match_line(item, ctx)
local location = format_location(item, ctx)
local separator = ' '
@@ -55,6 +57,7 @@ local function render_match_line(item, ctx)
local raw_content = item.line_content
if type(raw_content) ~= 'string' then raw_content = raw_content and tostring(raw_content) or '' end
local content = raw_content
if item.is_binary_content then content = BINARY_PLACEHOLDER end
-- Indent + location + separator + content
local indent = ' '
@@ -138,7 +141,17 @@ local function apply_match_highlights(item, ctx, item_idx, buf, ns_id, row, line
-- Priority 120: above CursorLine (100) so syntax is visible on cursor line,
-- below IncSearch match ranges (200) so search matches take precedence.
local content_start = sep_end
if item._trimmed_content and item.name then
if item.is_binary_content then
local content_end = content_start + #BINARY_PLACEHOLDER
if content_end <= #line_content then
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, content_start, {
end_col = content_end,
hl_group = 'Comment',
priority = 150,
})
end
elseif item._trimmed_content and item.name then
-- Resolve language once per file group (cache on the render context)
ctx._ts_lang_cache = ctx._ts_lang_cache or {}
local lang = ctx._ts_lang_cache[item.name]
@@ -166,7 +179,7 @@ local function apply_match_highlights(item, ctx, item_idx, buf, ns_id, row, line
-- 5. Match ranges highlighted with IncSearch
-- Use extmarks with priority > cursor line (100) so IncSearch renders
-- properly on the selected line instead of being overridden by CursorLine.
if item.match_ranges then
if item.match_ranges and not item.is_binary_content then
for _, range in ipairs(item.match_ranges) do
local raw_start = range[1] or 0
local raw_end = range[2] or 0
+19 -5
View File
@@ -54,6 +54,8 @@ function M.highlight_location(bufnr, location, namespace)
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, target_line - 1, target_col, {
end_col = end_col,
hl_group = 'IncSearch', -- inc search are better visible for a single chars
line_hl_group = 'CursorLine',
number_hl_group = 'CursorLineNr',
priority = 1000,
})
@@ -61,6 +63,7 @@ function M.highlight_location(bufnr, location, namespace)
else
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, target_line - 1, 0, {
line_hl_group = 'Visual',
number_hl_group = 'CursorLineNr',
priority = 1000,
})
@@ -82,6 +85,8 @@ function M.highlight_location(bufnr, location, namespace)
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, start_line - 1, start_col, {
end_col = end_col,
hl_group = 'IncSearch',
line_hl_group = 'CursorLine',
number_hl_group = 'CursorLineNr',
priority = 1000,
})
@@ -150,11 +155,19 @@ function M.highlight_grep_matches(bufnr, location, namespace)
local line_count = vim.api.nvim_buf_line_count(bufnr)
local extmarks = {}
-- Target line highlighting is handled by the native `cursorline` window
-- option, which is enabled on the preview window in grep mode (picker_ui.lua).
-- The cursor is positioned on the target line by preview.scroll_to_line(),
-- giving standard CursorLine background + CursorLineNr line number styling
-- without conflicting with IncSearch match highlights.
-- Pin CursorLine + CursorLineNr to the target match line via extmark, so
-- the highlight stays anchored when the user pages the preview viewport
-- with <C-d>/<C-u>. The cursor itself moves with paging, but the match
-- line stays styled until it scrolls out of view.
if location.line then
local target_line = math.max(1, math.min(location.line, line_count))
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, target_line - 1, 0, {
line_hl_group = 'CursorLine',
number_hl_group = 'CursorLineNr',
priority = 999,
})
if ok then table.insert(extmarks, { id = mark_id, line = target_line - 1 }) end
end
-- Fuzzy mode: use pre-computed byte offsets from Rust's match_indices.
-- These are the exact matched character positions within the line, already
@@ -173,6 +186,7 @@ function M.highlight_grep_matches(bufnr, location, namespace)
})
if ok then table.insert(extmarks, { id = mark_id, line = target_line - 1 }) end
end
-- Fuzzy mode: only target line has matches; skip the plain-text scan below.
return #extmarks > 0 and extmarks or nil
end
+6 -23
View File
@@ -140,31 +140,14 @@ local function open_preview(win_cfg)
M.state.preview_win = vim.api.nvim_open_win(M.state.preview_buf, false, win_cfg)
local win_hl = M.resolve_winhl('preview')
local cursorlineopt = utils.resolve_config_value(
preview_config.cursorlineopt,
vim.o.columns,
vim.o.lines,
function(value)
if type(value) ~= 'string' or #value == 0 then return false end
local has_line, has_screenline = false, false
for opt in value:gmatch('[^,]+') do
if not utils.is_one_of(opt:gsub('%s+', ''), { 'line', 'screenline', 'number', 'both' }) then return false end
if opt == 'line' or opt == 'both' then has_line = true end
if opt == 'screenline' then has_screenline = true end
end
return not (has_line and has_screenline)
end,
'both',
'preview.cursorlineopt'
)
vim.api.nvim_set_option_value('wrap', false, { win = M.state.preview_win })
vim.api.nvim_set_option_value('cursorline', M.state.mode == 'grep', { win = M.state.preview_win })
vim.api.nvim_set_option_value(
'cursorlineopt',
M.state.mode == 'grep' and cursorlineopt or vim.o.cursorlineopt,
{ win = M.state.preview_win }
)
-- Match line is highlighted via extmark (line_hl_group + number_hl_group)
-- in location_utils, so the preview window itself keeps cursorline off.
-- This way paging with <C-d>/<C-u> moves the cursor freely without dragging
-- the highlight off the actual match line.
vim.api.nvim_set_option_value('cursorline', false, { win = M.state.preview_win })
vim.api.nvim_set_option_value('cursorlineopt', vim.o.cursorlineopt, { win = M.state.preview_win })
vim.api.nvim_set_option_value(
'number',
M.state.mode == 'grep' or (preview_config and preview_config.line_numbers or false),
+60 -215
View File
@@ -1,257 +1,102 @@
# fff - Fast File Finder
High-performance fuzzy file finder for Bun, powered by Rust. Perfect for LLM agent tools that need to search through codebases.
High-performance fuzzy file finder for Bun, powered by Rust. Extremely fast live file, content, and directory search with a typo-resistant algorithm. As well as regex, plain-text, multi-occurrence and typo-resistant content search.
## Features
Comes with built-in git status support, frecency access tracking, and a real-time file watcher, content indexing and many more! Designed for LLM agent tools that search through codebases or agentic RAG document search.
- **Blazing fast** - Rust-powered fuzzy search with parallel processing
- **Smart ranking** - Frecency-based scoring (frequency + recency)
- **Git-aware** - Shows file git status in results
- **Query history** - Learns from your search patterns
- **Type-safe** - Full TypeScript support with Result types
Faster than ripgrep & fzf on any workflow that runs more than once per process.
## Installation
```bash
bun add @ff-labs/bun
bun add @ff-labs/fff-bun
```
The correct native binary for your platform is installed automatically via platform-specific packages (e.g. `@ff-labs/fff-bin-darwin-arm64`, `@ff-labs/fff-bin-linux-x64-gnu`). No GitHub downloads are needed.
The correct native binary for your platform is installed automatically via platform-specific packages (e.g. `@ff-labs/fff-bin-darwin-arm64`, `@ff-labs/fff-bin-linux-x64-gnu`)
### Supported Platforms
| Platform | Architecture | Package |
|----------|-------------|---------|
| macOS | ARM64 (Apple Silicon) | `@ff-labs/fff-bin-darwin-arm64` |
| macOS | x64 (Intel) | `@ff-labs/fff-bin-darwin-x64` |
| Linux | x64 (glibc) | `@ff-labs/fff-bin-linux-x64-gnu` |
| Linux | ARM64 (glibc) | `@ff-labs/fff-bin-linux-arm64-gnu` |
| Linux | x64 (musl) | `@ff-labs/fff-bin-linux-x64-musl` |
| Linux | ARM64 (musl) | `@ff-labs/fff-bin-linux-arm64-musl` |
| Windows | x64 | `@ff-labs/fff-bin-win32-x64` |
| Windows | ARM64 | `@ff-labs/fff-bin-win32-arm64` |
| Platform | Architecture | Package |
| -------- | --------------------- | ----------------------------------- |
| macOS | ARM64 (Apple Silicon) | `@ff-labs/fff-bin-darwin-arm64` |
| macOS | x64 (Intel) | `@ff-labs/fff-bin-darwin-x64` |
| Linux | x64 (glibc) | `@ff-labs/fff-bin-linux-x64-gnu` |
| Linux | ARM64 (glibc) | `@ff-labs/fff-bin-linux-arm64-gnu` |
| Linux | x64 (musl) | `@ff-labs/fff-bin-linux-x64-musl` |
| Linux | ARM64 (musl) | `@ff-labs/fff-bin-linux-arm64-musl` |
| Windows | x64 | `@ff-labs/fff-bin-win32-x64` |
| Windows | ARM64 | `@ff-labs/fff-bin-win32-arm64` |
If the platform package isn't available, the postinstall script will attempt to download from GitHub releases as a fallback.
## Quick Start
Each `FileFinder` instance owns an independent native index. Create one, wait
for the initial scan, then run as many searches as you like.
```typescript
import { FileFinder } from "fff";
import { FileFinder } from "@ff-labs/fff-bun";
// Initialize with a directory
const result = FileFinder.init({ basePath: "/path/to/project" });
if (!result.ok) {
console.error(result.error);
process.exit(1);
}
// Create an instance bound to a directory
const created = FileFinder.create({ basePath: "/path/to/project" });
if (!created.ok) throw new Error(created.error);
// Wait for initial scan
FileFinder.waitForScan(5000);
const finder = created.value;
// Search for files
const search = FileFinder.search("main.ts");
if (search.ok) {
for (const item of search.value.items) {
console.log(item.relativePath);
// Wait for the initial scan (non-blocking)
await finder.waitForScan(5000);
// 1. Fuzzy file search (typo resistant)
const files = finder.fileSearch("typescropt.ts", { pageSize: 10 });
if (files.ok) {
for (const item of files.value.items) {
console.log(item.relativePath, item.gitStatus);
}
}
// Cleanup when done
FileFinder.destroy();
// 2. Glob filter — no fuzzy matching, 100% compatible with npm `glob`
const globbed = finder.glob("src/**/*.ts");
if (globbed.ok) console.log(`${globbed.value.totalMatched} TypeScript files`);
// 3. Content search (live grep) with pagination
const grep = finder.grep("TODO", { mode: "plain", pageSize: 20 });
if (grep.ok) {
for (const m of grep.value.items) {
console.log(`${m.relativePath}:${m.lineNumber}: ${m.lineContent}`);
}
}
// 4. Directory search based on the query (typo resistant)
const dirs = finder.directorySearch("components");
if (dirs.ok) console.log(dirs.value.items.map((d) => d.relativePath));
// Free the resources when you don't need a file picker anymore
finder.destroy();
```
## API Reference
### `FileFinder.init(options)`
Verify the latest API in the local interface at [`./src/fff-api.ts`](./src/fff-api.ts). Every field and type is documented.
Initialize the file finder.
```typescript
interface InitOptions {
basePath: string; // Directory to index (required)
frecencyDbPath?: string; // Frecency DB path (omit to skip frecency)
historyDbPath?: string; // History DB path (omit to skip query tracking)
useUnsafeNoLock?: boolean; // Faster but less safe DB mode
}
const result = FileFinder.init({ basePath: "/my/project" });
```
### `FileFinder.search(query, options?)`
Search for files.
```typescript
interface SearchOptions {
maxThreads?: number; // Parallel threads (0 = auto)
currentFile?: string; // Deprioritize this file
comboBoostMultiplier?: number; // Query history boost
minComboCount?: number; // Min history matches
pageIndex?: number; // Pagination offset
pageSize?: number; // Results per page
}
const result = FileFinder.search("main.ts", { pageSize: 10 });
if (result.ok) {
console.log(`Found ${result.value.totalMatched} files`);
}
```
### Query Syntax
- `foo bar` - Match files containing "foo" and "bar"
- `src/` - Match files in src directory
- `file.ts:42` - Match file.ts with line 42
- `file.ts:42:10` - Match with line and column
### `FileFinder.trackAccess(filePath)`
Track file access for frecency scoring.
```typescript
// Call when user opens a file
FileFinder.trackAccess("/path/to/file.ts");
```
### `FileFinder.grep(query, options?)`
Search file contents with SIMD-accelerated matching.
```typescript
interface GrepOptions {
maxFileSize?: number; // Max file size in bytes (default: 10MB)
maxMatchesPerFile?: number; // Max matches per file (default: 200, set 0 to unlimited)
smartCase?: boolean; // Case-insensitive if all lowercase (default: true)
fileOffset?: number; // Pagination offset (default: 0)
pageLimit?: number; // Max matches to return (default: 50)
mode?: "plain" | "regex" | "fuzzy"; // Search mode (default: "plain")
timeBudgetMs?: number; // Time limit in ms, 0 = unlimited (default: 0)
}
// Plain text search
const result = FileFinder.grep("TODO", { pageLimit: 20 });
if (result.ok) {
for (const match of result.value.items) {
console.log(`${match.relativePath}:${match.lineNumber}: ${match.lineContent}`);
}
}
// Regex search
const regexResult = FileFinder.grep("fn\\s+\\w+", { mode: "regex" });
// Fuzzy search
const fuzzyResult = FileFinder.grep("imprt recat", { mode: "fuzzy" });
// Pagination
const page1 = FileFinder.grep("error");
if (page1.ok && page1.value.nextCursor) {
const page2 = FileFinder.grep("error", {
cursor: page1.value.nextCursor,
});
}
// With file constraints
const tsOnly = FileFinder.grep("*.ts useState");
const srcOnly = FileFinder.grep("src/ handleClick");
```
### `FileFinder.trackQuery(query, selectedFile)`
Track query completion for smart suggestions.
```typescript
// Call when user selects a file from search
FileFinder.trackQuery("main", "/path/to/main.ts");
```
### `FileFinder.healthCheck(testPath?)`
Get diagnostic information.
```typescript
const health = FileFinder.healthCheck();
if (health.ok) {
console.log(`Version: ${health.value.version}`);
console.log(`Indexed: ${health.value.filePicker.indexedFiles} files`);
}
```
### Other Methods
- `FileFinder.grep(query, options?)` - Search file contents
- `FileFinder.scanFiles()` - Trigger rescan
- `FileFinder.isScanning()` - Check scan status
- `FileFinder.getScanProgress()` - Get scan progress
- `FileFinder.waitForScan(timeoutMs)` - Wait for scan
- `FileFinder.reindex(newPath)` - Change indexed directory
- `FileFinder.refreshGitStatus()` - Refresh git cache
- `FileFinder.getHistoricalQuery(offset)` - Get past queries
- `FileFinder.destroy()` - Cleanup resources
## Result Types
### Result Types
All methods return a `Result<T>` type for explicit error handling:
```typescript
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
const result = FileFinder.search("foo");
```typescript
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
const result = finder.fileSearch("foo");
if (result.ok) {
// result.value is SearchResult
} else {
// result.error is string
// result.error is string error message
}
```
## Search Result Types
```typescript
interface SearchResult {
items: FileItem[];
scores: Score[];
totalMatched: number;
totalFiles: number;
location?: Location;
}
interface FileItem {
path: string;
relativePath: string;
fileName: string;
size: number;
modified: number;
gitStatus: string; // 'clean', 'modified', 'untracked', etc.
}
```
## Grep Result Types
```typescript
interface GrepResult {
items: GrepMatch[];
totalMatched: number;
totalFilesSearched: number;
totalFiles: number;
filteredFileCount: number;
nextCursor: GrepCursor | null; // Pass to options.cursor for next page
regexFallbackError?: string; // Set if regex was invalid
}
interface GrepMatch {
path: string;
relativePath: string;
fileName: string;
gitStatus: string;
lineNumber: number; // 1-based
col: number; // 0-based byte column
byteOffset: number; // Absolute byte offset in file
lineContent: string; // The matched line text
matchRanges: [number, number][]; // Byte offsets for highlighting
fuzzyScore?: number; // Only in fuzzy mode
}
```
This SDK calls a native compiled library for your platform at runtime. This is generally safe — fff is battle-tested and stable, and written in a memory-safe language — but there is a class of errors that can't be caught at the Bun/Node level. If you hit one, please report an issue!
## Building from Source
@@ -268,7 +113,7 @@ cargo build --release -p fff-c
# The binary will be at target/release/libfff_c.{so,dylib,dll}
```
## CLI Tools
## CLI examples
```bash
# Download binary manually (fallback if npm package unavailable)
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env bun
/**
* Glob benchmark: fff.glob vs Bun.Glob vs npm `glob`.
*
* Each engine is asked to enumerate files in a directory matching the same
* pattern. We measure wall-clock time + result count. fff scans + indexes
* once on init; the subsequent glob call is a filter over the in-memory
* index that's what we time.
*
* Usage:
* bun examples/glob-bench.ts [dir] [pattern] [iterations]
*
* dir default: cwd
* pattern default: "**\/*.ts"
* iterations default: 5 (each engine runs N times, best+median reported)
*
* Install npm glob first:
* bun add glob
*/
import { performance } from "node:perf_hooks";
import { resolve } from "node:path";
import { Glob as BunGlob } from "bun";
import { FileFinder } from "../src/index";
// npm glob — optional. Skip silently if not installed.
let npmGlob:
| ((pattern: string, opts: { cwd: string }) => Promise<string[]>)
| null = null;
try {
const mod: {
glob: (pattern: string, opts: { cwd: string }) => Promise<string[]>;
} =
// @ts-ignore - optional peer; resolved at runtime, may be absent
await import("glob");
npmGlob = mod.glob;
} catch {
console.warn("npm `glob` not installed — skipping. Run: bun add glob");
}
const dir = resolve(process.argv[2] ?? process.cwd());
const pattern = process.argv[3] ?? "**/lua/**/*.lua";
const iterations = Number(process.argv[4] ?? 5);
console.log(`dir: ${dir}`);
console.log(`pattern: ${pattern}`);
console.log(`iterations: ${iterations}\n`);
interface Sample {
ms: number;
count: number;
}
function summarize(label: string, samples: Sample[]): void {
if (samples.length === 0) {
console.log(`${label.padEnd(16)} skipped`);
return;
}
const sorted = [...samples].sort((a, b) => a.ms - b.ms);
const best = sorted[0]!;
const median = sorted[Math.floor(sorted.length / 2)]!;
const worst = sorted[sorted.length - 1]!;
const counts = new Set(samples.map((s) => s.count));
const countStr =
counts.size === 1 ? `${best.count}` : `[${[...counts].join(", ")}]`;
console.log(
`${label.padEnd(16)} best=${best.ms.toFixed(2)}ms median=${median.ms.toFixed(2)}ms worst=${worst.ms.toFixed(2)}ms count=${countStr}`,
);
}
async function bench<T>(
fn: () => Promise<T> | T,
): Promise<{ ms: number; result: T }> {
const start = performance.now();
const result = await fn();
return { ms: performance.now() - start, result };
}
// ---------------------------------------------------------------------------
// fff: init + warm scan, then time only the .glob() call. Init cost is
// reported separately because it's amortized across many subsequent calls.
// ---------------------------------------------------------------------------
const fffInit = await bench(() => {
const result = FileFinder.create({
basePath: dir,
disableMmapCache: true,
disableContentIndexing: true,
disableWatch: true,
});
if (!result.ok) throw new Error(result.error);
return result.value;
});
const finder = fffInit.result;
// Wait until initial scan done so the first .glob() doesn't see a partial
// index. Returns true = completed, false = timed out.
const scanReady = finder.waitForScanBlocking(30_000);
if (!scanReady.ok || !scanReady.value) {
console.error("fff: initial scan did not finish in 30s — exiting");
process.exit(1);
}
console.log(`fff init+scan: ${fffInit.ms.toFixed(2)}ms\n`);
const fffSamples: Sample[] = [];
for (let i = 0; i < iterations; i++) {
const r = await bench(() => {
const out = finder.glob(pattern, { pageSize: 100 });
if (!out.ok) throw new Error(out.error);
return out.value;
});
fffSamples.push({ ms: r.ms, count: r.result.items.length });
}
// ---------------------------------------------------------------------------
// Bun.Glob — sync iterator, returns relative paths.
// ---------------------------------------------------------------------------
const bunSamples: Sample[] = [];
for (let i = 0; i < iterations; i++) {
const r = await bench(() => {
const g = new BunGlob(pattern);
let count = 0;
for (const _ of g.scanSync({ cwd: dir })) count++;
return count;
});
bunSamples.push({ ms: r.ms, count: r.result });
}
// ---------------------------------------------------------------------------
// npm glob — async, returns absolute or relative paths depending on opts.
// ---------------------------------------------------------------------------
const npmSamples: Sample[] = [];
if (npmGlob) {
for (let i = 0; i < iterations; i++) {
const r = await bench(() => npmGlob!(pattern, { cwd: dir }));
npmSamples.push({ ms: r.ms, count: r.result.length });
}
}
console.log("results:");
summarize("fff.glob", fffSamples);
summarize("Bun.Glob", bunSamples);
summarize("npm glob", npmSamples);
// Sanity: counts should be in the same ballpark. They won't match exactly
// because indexing rules differ (fff respects gitignore + skips binaries by
// default; Bun.Glob and npm glob do not).
const counts = {
fff: fffSamples[0]?.count ?? 0,
bun: bunSamples[0]?.count ?? 0,
npm: npmSamples[0]?.count ?? 0,
};
console.log(
`\nNote: fff respects gitignore + skips binaries; Bun.Glob and npm glob walk the raw filesystem. Count differences are expected.`,
);
console.log(
`raw counts: fff=${counts.fff} bun=${counts.bun} npm=${counts.npm}`,
);
finder.destroy();
@@ -1,3 +1,25 @@
// ----------------------------------------------------------------------------
// GENERATED FILE - DO NOT EDIT.
// Source of truth: packages/shared/fff-api.ts
// Run make sync-js-api from the repo root to regenerate.
// ----------------------------------------------------------------------------
/**
* The shared public API surface for the fff file finder, implemented identically
* by `@ff-labs/fff-node` and `@ff-labs/fff-bun`.
*
* This file is the single source of truth for every type, helper, and the
* `FileFinderApi` interface that crosses the package boundary. It is copied
* verbatim into each package's `src/fff-api.ts` by `make sync-api`.
*
* Anything that is not part of the public API (FFI struct layouts, binary
* loading, platform detection, etc.) stays as per-package internal
* implementation and must NOT live here.
*
* Keep this file self-contained: it must not import from any package-local
* module, since each package compiles its own copy.
*/
/**
* Result type for all operations - follows the Result pattern
*/
@@ -27,12 +49,16 @@ export interface InitOptions {
frecencyDbPath?: string;
/** Path to query history database (optional, omit to skip query tracker initialization) */
historyDbPath?: string;
/** Use unsafe no-lock mode for databases (optional, defaults to false) */
/**
* @deprecated No-op. The no-lock LMDB flags showed no measurable win under
* realistic contention and are now ignored. Kept for source-compat.
*/
useUnsafeNoLock?: boolean;
/**
* Disable mmap cache warmup after the initial scan. When mmap cache is
* enabled (the default), the first grep search is as fast as subsequent
* ones at the cost of background resources spent on awarming up the cache
* ones at the cost of a longer scan time and higher initial memory usage.
* (default: false)
*/
disableMmapCache?: boolean;
/**
@@ -70,6 +96,18 @@ export interface InitOptions {
cacheBudgetMaxBytes?: number;
/** Override for the per-file byte cap in the content cache. */
cacheBudgetMaxFileSize?: number;
/**
* Allow indexing the filesystem root (`/`). Off by default root is
* rarely the intended target and floods the watcher with churn-prone
* events. Setting this true is opt-in and the caller is responsible for
* the resulting fs-event volume.
*/
enableFsRootScanning?: boolean;
/**
* Allow indexing the user's home directory. Same trade-off as
* `enableFsRootScanning`.
*/
enableHomeDirScanning?: boolean;
}
/**
@@ -90,6 +128,23 @@ export interface SearchOptions {
pageSize?: number;
}
/**
* Options for `glob`, the constraint-only search.
*
* The pattern is applied as a single pass SIMD optimized prefiltering
* without any fuzzy matching involved. Faster and 100% compatible to npm `glob`.
*/
export interface GlobOptions {
/** Maximum threads for parallel filtering (0 = auto). */
maxThreads?: number;
/** Current file path (for deprioritization in results). */
currentFile?: string;
/** Page index for pagination (default: 0). */
pageIndex?: number;
/** Page size for pagination (default: 100). */
pageSize?: number;
}
/**
* A file item in search results
*/
@@ -363,6 +418,12 @@ export interface GrepOptions {
afterContext?: number;
/** Maximum matches to return in this page across all files (default: 50) */
pageSize?: number;
/**
* When true, classify each match line as a code definition (struct/fn/class/...)
* and expose it via `GrepMatch.isDefinition`. Let callers re-rank defs first
* without a TS-side regex port. (default: false)
*/
classifyDefinitions?: boolean;
}
/**
@@ -403,6 +464,8 @@ export interface GrepMatch {
contextBefore?: string[];
/** Lines after the match (context). Empty array when context is 0. */
contextAfter?: string[];
/** Whether this line is a code definition (only populated when `classifyDefinitions: true`). */
isDefinition?: boolean;
}
/**
@@ -461,4 +524,94 @@ export interface MultiGrepOptions {
afterContext?: number;
/** Maximum matches to return in this page across all files (default: 50) */
pageSize?: number;
/**
* When true, classify each match line as a code definition (struct/fn/class/...)
* and expose it via `GrepMatch.isDefinition`. (default: false)
*/
classifyDefinitions?: boolean;
}
/**
* The shared instance surface implemented by `FileFinder` in both
* `@ff-labs/fff-node` and `@ff-labs/fff-bun`.
*
* Both packages must implement this identically. Only instance members belong
* here. Static helpers (`create`, `isAvailable`, `ensureLoaded`,
* `healthCheckStatic`) are package-specific and intentionally excluded.
*/
export interface FileFinderApi {
/** Whether the instance has been destroyed. */
readonly isDestroyed: boolean;
/** Destroy and free all native resources. */
destroy(): void;
/** Fuzzy file search. */
fileSearch(query: string, options?: SearchOptions): Result<SearchResult>;
/** Glob-only filtering (no fuzzy matching). */
glob(pattern: string, options?: GlobOptions): Result<SearchResult>;
/** Fuzzy directory search. */
directorySearch(query: string, options?: DirSearchOptions): Result<DirSearchResult>;
/** Fuzzy search over files and directories interleaved by score. */
mixedSearch(query: string, options?: SearchOptions): Result<MixedSearchResult>;
/** Content search (live grep). */
grep(query: string, options?: GrepOptions): Result<GrepResult>;
/** Multi-pattern OR content search (Aho-Corasick). */
multiGrep(options: MultiGrepOptions): Result<GrepResult>;
/** Trigger an async rescan of the indexed directory. */
scanFiles(): Result<void>;
/** Whether a scan is currently in progress. */
isScanning(): boolean;
/** The root directory being indexed. */
getBasePath(): Result<string | null>;
/** Current scan progress snapshot. */
getScanProgress(): Result<ScanProgress>;
/**
* Wait for the initial file scan to complete.
*
* Non-blocking: polls `isScanning` and yields to the event loop between
* checks, so other async work keeps running while waiting.
*/
waitForScan(timeoutMs?: number): Promise<Result<boolean>>;
/**
* Wait for the initial file scan to complete, blocking the calling thread.
*
* Backed by the native `fff_wait_for_scan` call. Prefer `waitForScan` unless
* you specifically need synchronous blocking behaviour.
*/
waitForScanBlocking(timeoutMs?: number): Result<boolean>;
/**
* Wait until the index is fully ready: the scan has finished and the warmup
* (content indexing / bigram) phase has completed.
*
* Non-blocking: polls `getScanProgress` and yields to the event loop.
*/
waitForIndexReady(timeoutMs?: number): Promise<Result<boolean>>;
/** Restart indexing in a new directory. */
reindex(newPath: string): Result<void>;
/** Refresh the git status cache. Returns the number of updated files. */
refreshGitStatus(): Result<number>;
/** Record that `selectedFilePath` was chosen for `query`. */
trackQuery(query: string, selectedFilePath: string): Result<boolean>;
/** Get a historical query by offset (0 = most recent). */
getHistoricalQuery(offset: number): Result<string | null>;
/** Health/diagnostics information for this instance. */
healthCheck(testPath?: string): Result<HealthCheck>;
}
+125 -42
View File
@@ -23,8 +23,8 @@ import type {
ScanProgress,
Score,
SearchResult,
} from "./types";
import { createGrepCursor, err } from "./types";
} from "./fff-api";
import { createGrepCursor, err } from "./fff-api";
/** Grep mode constants matching the C API (u8). */
const GREP_MODE_PLAIN = 0;
@@ -62,6 +62,10 @@ const ffiDefinition = {
],
returns: FFIType.ptr,
},
fff_create_instance_with: {
args: [FFIType.ptr], // *const FffCreateOptions
returns: FFIType.ptr,
},
fff_destroy: {
args: [FFIType.ptr],
returns: FFIType.void,
@@ -82,6 +86,19 @@ const ffiDefinition = {
returns: FFIType.ptr,
},
// Glob-only search (bypasses query parser)
fff_glob: {
args: [
FFIType.ptr, // handle
FFIType.cstring, // pattern
FFIType.cstring, // current_file
FFIType.u32, // max_threads
FFIType.u32, // page_index
FFIType.u32, // page_size
],
returns: FFIType.ptr,
},
// Directory search
fff_search_directories: {
args: [
@@ -169,10 +186,6 @@ const ffiDefinition = {
args: [FFIType.ptr, FFIType.u64],
returns: FFIType.ptr,
},
fff_wait_for_watcher: {
args: [FFIType.ptr, FFIType.u64],
returns: FFIType.ptr,
},
fff_restart_index: {
args: [FFIType.ptr, FFIType.cstring],
returns: FFIType.ptr,
@@ -269,7 +282,6 @@ const ffiDefinition = {
type FFFLibrary = ReturnType<typeof dlopen<typeof ffiDefinition>>;
// Library instance (lazy loaded)
let lib: FFFLibrary | null = null;
/**
@@ -332,10 +344,25 @@ const RES_ERROR = 8; // *mut c_char (8)
const RES_HANDLE = 16; // *mut c_void (8)
const RES_INT_VALUE = 24; // i64 (8)
/**
* Read the FffResult envelope: check success, extract payload, free envelope.
* On error returns a Result<never>. On success returns the raw handle pointer and int_value.
*/
// MUST match `crates/fff-c/src/ffi_types.rs::FffCreateOptions`
const FFF_CREATE_OPTIONS_VERSION = 1;
const FFF_CREATE_OPTIONS_SIZE = 88;
const FCO_VERSION = 0;
const FCO_BASE_PATH = 8;
const FCO_FRECENCY_DB_PATH = 16;
const FCO_HISTORY_DB_PATH = 24;
const FCO_ENABLE_MMAP_CACHE = 32;
const FCO_ENABLE_CONTENT_INDEXING = 33;
const FCO_WATCH = 34;
const FCO_AI_MODE = 35;
const FCO_LOG_FILE_PATH = 40;
const FCO_LOG_LEVEL = 48;
const FCO_CACHE_BUDGET_MAX_FILES = 56;
const FCO_CACHE_BUDGET_MAX_BYTES = 64;
const FCO_CACHE_BUDGET_MAX_FILE_SIZE = 72;
const FCO_ENABLE_FS_ROOT_SCANNING = 80;
const FCO_ENABLE_HOME_DIR_SCANNING = 81;
function readResultEnvelope(
resultPtr: Pointer | null,
): { success: true; handlePtr: number; intValue: number } | Result<never> {
@@ -420,12 +447,23 @@ export type NativeHandle = Pointer;
/**
* Create a new file finder instance.
*
* Hand-encodes a [`FffCreateOptions`] struct (88 bytes, locked offsets see
* `crates/fff-c/src/ffi_types.rs::options_layout_tests`) into a Buffer and
* passes its pointer to `fff_create_instance_with`. Inner cstring addresses
* come from Bun's native `ptr(buffer)` primitive no round-trip helpers,
* no struct support gaps.
*
* Adding new options later means: (1) appending the field to
* `FffCreateOptions` in Rust, (2) bumping `FFF_CREATE_OPTIONS_VERSION`,
* (3) extending `FFF_CREATE_OPTIONS_SIZE` + offsets here. The C entry point
* never changes.
*/
export function ffiCreate(
basePath: string,
frecencyDbPath: string,
historyDbPath: string,
useUnsafeNoLock: boolean,
_useUnsafeNoLock: boolean,
enableMmapCache: boolean,
enableContentIndexing: boolean,
watch: boolean,
@@ -435,48 +473,77 @@ export function ffiCreate(
cacheBudgetMaxFiles: bigint,
cacheBudgetMaxBytes: bigint,
cacheBudgetMaxFileSize: bigint,
enableFsRootScanning: boolean,
enableHomeDirScanning: boolean,
): Result<NativeHandle> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_create_instance2(
ptr(encodeString(basePath)),
ptr(encodeString(frecencyDbPath)),
ptr(encodeString(historyDbPath)),
useUnsafeNoLock,
enableMmapCache,
enableContentIndexing,
watch,
aiMode,
ptr(encodeString(logFilePath)),
ptr(encodeString(logLevel)),
cacheBudgetMaxFiles,
cacheBudgetMaxBytes,
cacheBudgetMaxFileSize,
);
// Keep cstring buffers alive across the FFI call. Bun's `ptr()` returns
// the underlying memory address of each Buffer — no round-trips.
const basePathCStr = encodeCStringBuf(basePath);
const frecencyCStr = encodeCStringBuf(frecencyDbPath);
const historyCStr = encodeCStringBuf(historyDbPath);
const logFileCStr = encodeCStringBuf(logFilePath);
const logLevelCStr = encodeCStringBuf(logLevel);
const opts = Buffer.alloc(FFF_CREATE_OPTIONS_SIZE);
opts.writeUInt32LE(FFF_CREATE_OPTIONS_VERSION, FCO_VERSION);
writePtrLE(opts, FCO_BASE_PATH, basePathCStr);
writePtrLE(opts, FCO_FRECENCY_DB_PATH, frecencyCStr);
writePtrLE(opts, FCO_HISTORY_DB_PATH, historyCStr);
opts.writeUInt8(enableMmapCache ? 1 : 0, FCO_ENABLE_MMAP_CACHE);
opts.writeUInt8(enableContentIndexing ? 1 : 0, FCO_ENABLE_CONTENT_INDEXING);
opts.writeUInt8(watch ? 1 : 0, FCO_WATCH);
opts.writeUInt8(aiMode ? 1 : 0, FCO_AI_MODE);
writePtrLE(opts, FCO_LOG_FILE_PATH, logFileCStr);
writePtrLE(opts, FCO_LOG_LEVEL, logLevelCStr);
opts.writeBigUInt64LE(cacheBudgetMaxFiles, FCO_CACHE_BUDGET_MAX_FILES);
opts.writeBigUInt64LE(cacheBudgetMaxBytes, FCO_CACHE_BUDGET_MAX_BYTES);
opts.writeBigUInt64LE(cacheBudgetMaxFileSize, FCO_CACHE_BUDGET_MAX_FILE_SIZE);
opts.writeUInt8(enableFsRootScanning ? 1 : 0, FCO_ENABLE_FS_ROOT_SCANNING);
opts.writeUInt8(enableHomeDirScanning ? 1 : 0, FCO_ENABLE_HOME_DIR_SCANNING);
const resultPtr = library.symbols.fff_create_instance_with(ptr(opts));
if (resultPtr === null) {
return err("FFI returned null pointer");
}
const success = read.u8(resultPtr, RES_SUCCESS) !== 0;
const errorPtr = read.ptr(resultPtr, RES_ERROR);
const handlePtr = read.ptr(resultPtr, RES_HANDLE);
if (success) {
const handlePtr = read.ptr(resultPtr, RES_HANDLE);
const handle = handlePtr as unknown as Pointer;
library.symbols.fff_free_result(resultPtr);
if (!handle || handle === (0 as unknown as Pointer)) {
return err("fff_create_instance returned null handle");
return err("fff_create_instance_with returned null handle");
}
return { ok: true, value: handle };
} else {
const errorPtr = read.ptr(resultPtr, RES_ERROR);
const errorMsg = readCString(errorPtr) || "Unknown error";
library.symbols.fff_free_result(resultPtr);
return err(errorMsg);
}
}
/** NUL-terminated UTF-8 buffer for `s`, or `null` for empty input. */
function encodeCStringBuf(s: string | null | undefined): Buffer | null {
if (!s) return null;
return Buffer.from(s + "\0", "utf-8");
}
/** Write Bun's native pointer-to-buffer address into the options buffer. */
function writePtrLE(buf: Buffer, offset: number, target: Buffer | null): void {
if (target == null) {
buf.writeBigUInt64LE(0n, offset);
return;
}
buf.writeBigUInt64LE(BigInt(ptr(target) as unknown as number), offset);
}
/**
* Destroy and clean up an instance.
*/
@@ -866,6 +933,7 @@ const GM_FUZZY_SCORE = 128;
// 1-byte
const GM_HAS_FUZZY = 130;
const GM_IS_BINARY = 131;
const GM_IS_DEFINITION = 132;
// struct size: pad to 8-byte alignment → 136
const GM_SIZE_OF = 136;
@@ -943,6 +1011,9 @@ function readGrepMatchStruct(p: number): GrepMatch {
if (ctxAfterCount > 0) {
match.contextAfter = readCStringArray(read.ptr(pp, GM_CTX_AFTER), ctxAfterCount);
}
if (read.u8(pp, GM_IS_DEFINITION) !== 0) {
match.isDefinition = true;
}
return match;
}
@@ -1017,6 +1088,30 @@ export function ffiSearch(
return parseSearchResult(resultPtr);
}
/**
* Glob-only search. Bypasses the regular query parser, applies the pattern
* as a single `Constraint::Glob`, ranks by frecency, paginates.
*/
export function ffiGlob(
handle: NativeHandle,
pattern: string,
currentFile: string,
maxThreads: number,
pageIndex: number,
pageSize: number,
): Result<SearchResult> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_glob(
handle,
ptr(encodeString(pattern)),
ptr(encodeString(currentFile)),
maxThreads,
pageIndex,
pageSize,
);
return parseSearchResult(resultPtr);
}
/**
* Perform fuzzy directory search.
*/
@@ -1202,18 +1297,6 @@ export function ffiWaitForScan(handle: NativeHandle, timeoutMs: number): Result<
return parseBoolResult(resultPtr);
}
/**
* Wait for the background file watcher to be ready.
*/
export function ffiWaitForWatcher(
handle: NativeHandle,
timeoutMs: number,
): Result<boolean> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_wait_for_watcher(handle, BigInt(timeoutMs));
return parseBoolResult(resultPtr);
}
/**
* Restart index in new path.
*/
+83 -18
View File
@@ -15,6 +15,7 @@ import {
ffiGetBasePath,
ffiGetHistoricalQuery,
ffiGetScanProgress,
ffiGlob,
ffiHealthCheck,
ffiIsScanning,
ffiLiveGrep,
@@ -27,7 +28,6 @@ import {
ffiSearchMixed,
ffiTrackQuery,
ffiWaitForScan,
ffiWaitForWatcher,
isAvailable,
type NativeHandle,
} from "./ffi";
@@ -35,19 +35,21 @@ import {
import type {
DirSearchOptions,
DirSearchResult,
InitOptions as FFFInitOptions,
FileFinderApi,
GlobOptions,
GrepOptions,
GrepResult,
HealthCheck,
InitOptions as FFFInitOptions,
MixedSearchResult,
MultiGrepOptions,
Result,
ScanProgress,
SearchOptions,
SearchResult,
} from "./types";
} from "./fff-api";
import { err } from "./types";
import { err } from "./fff-api";
/**
* FileFinder - Fast file finder with fuzzy search
@@ -67,7 +69,7 @@ import { err } from "./types";
* }
*
* // Wait for initial scan
* finder.value.waitForScan(5000);
* await finder.value.waitForScan(5000);
*
* // Search for files
* const search = finder.value.search("main.ts");
@@ -81,7 +83,7 @@ import { err } from "./types";
* finder.value.destroy();
* ```
*/
export class FileFinder {
export class FileFinder implements FileFinderApi {
private handle: NativeHandle | null;
private constructor(handle: NativeHandle) {
@@ -122,6 +124,8 @@ export class FileFinder {
BigInt(options.cacheBudgetMaxFiles ?? 0),
BigInt(options.cacheBudgetMaxBytes ?? 0),
BigInt(options.cacheBudgetMaxFileSize ?? 0),
options.enableFsRootScanning ?? false,
options.enableHomeDirScanning ?? false,
);
if (!result.ok) {
@@ -202,6 +206,30 @@ export class FileFinder {
);
}
/**
* Glob-only search.
*
* The pattern is applied as a single pass SIMD optimized prefiltering
* without any fuzzy matching involved. Faster and 100% compatible to npm `glob`.
*
* @param pattern - Glob pattern (required, non-empty)
* @param options - Glob search options (pagination, max threads, current file)
* @returns Search results with files matching the glob
*/
glob(pattern: string, options?: GlobOptions): Result<SearchResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiGlob(
guard.value,
pattern,
options?.currentFile ?? "",
options?.maxThreads ?? 0,
options?.pageIndex ?? 0,
options?.pageSize ?? 0,
);
}
/**
* Search for directories matching the query.
*
@@ -325,7 +353,7 @@ export class FileFinder {
options?.timeBudgetMs ?? 0,
options?.beforeContext ?? 0,
options?.afterContext ?? 0,
false,
options?.classifyDefinitions ?? false,
);
}
@@ -374,7 +402,7 @@ export class FileFinder {
options.timeBudgetMs ?? 0,
options.beforeContext ?? 0,
options.afterContext ?? 0,
false,
options.classifyDefinitions ?? false,
);
}
@@ -419,6 +447,9 @@ export class FileFinder {
/**
* Wait for the initial file scan to complete.
*
* Non-blocking: polls `isScanning` and yields to the event loop between
* checks, so other async work keeps running while waiting.
*
* @param timeoutMs - Maximum time to wait in milliseconds (default: 5000)
* @returns true if scan completed, false if timed out
*
@@ -426,33 +457,67 @@ export class FileFinder {
* ```typescript
* const finder = FileFinder.create({ basePath: "/path/to/project" });
* if (finder.ok) {
* const completed = finder.value.waitForScan(10000);
* const completed = await finder.value.waitForScan(10000);
* if (!completed.ok || !completed.value) {
* console.warn("Scan did not complete in time");
* }
* }
* ```
*/
waitForScan(timeoutMs: number = 5000): Result<boolean> {
async waitForScan(timeoutMs: number = 5000): Promise<Result<boolean>> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
const deadline = Date.now() + timeoutMs;
while (this.isScanning()) {
if (Date.now() >= deadline) {
return { ok: true, value: false };
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
return { ok: true, value: true };
}
/**
* Wait for the initial file scan to complete, blocking the calling thread.
*
* Backed by the native `fff_wait_for_scan` call. Prefer {@link waitForScan}
* unless you specifically need synchronous blocking behaviour.
*
* @param timeoutMs - Maximum time to wait in milliseconds (default: 5000)
* @returns true if scan completed, false if timed out
*/
waitForScanBlocking(timeoutMs: number = 5000): Result<boolean> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiWaitForScan(guard.value, timeoutMs);
}
/**
* Wait for the background file watcher to be ready.
* Wait until the index is fully ready: the scan has finished and the warmup
* (content indexing / bigram) phase has completed.
*
* The watcher is created after the initial scan, git status, and optional
* warmup phases complete. Useful for tests that need to ensure filesystem
* events will be detected.
* Non-blocking polls `getScanProgress` and yields to the event loop.
*
* @param timeoutMs - Maximum time to wait in milliseconds (default: 10000)
* @returns true if watcher is ready, false if timed out
* @param timeoutMs - Maximum time to wait in milliseconds (default: 5000)
* @returns true if the index became ready, false if timed out
*/
waitForWatcher(timeoutMs: number = 10000): Result<boolean> {
async waitForIndexReady(timeoutMs: number = 5000): Promise<Result<boolean>> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiWaitForWatcher(guard.value, timeoutMs);
const deadline = Date.now() + timeoutMs;
while(true) {
const progress = this.getScanProgress();
if (!progress.ok) return progress;
if (!progress.value.isScanning && progress.value.isWarmupComplete) {
return { ok: true, value: true };
}
if (Date.now() >= deadline) {
return { ok: true, value: false };
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
/**
+2 -2
View File
@@ -10,8 +10,8 @@ import {
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { FileItem } from "./fff-api";
import { FileFinder } from "./index";
import type { FileItem } from "./types";
/**
* Integration test: full git lifecycle with a real repository.
@@ -149,7 +149,7 @@ describe.skipIf(process.platform === "win32")("Git lifecycle integration", () =>
finder = result.value;
// Wait for the initial scan to finish
const scanResult = finder.waitForScan(10_000);
const scanResult = finder.waitForScanBlocking(10_000);
expect(scanResult.ok).toBe(true);
// Poll getScanProgress until the watcher is ready so that
+89 -5
View File
@@ -102,9 +102,9 @@ describe("FileFinder - Full Lifecycle", () => {
}
});
test("waitForScan completes", () => {
test("waitForScanBlocking completes", () => {
// Small timeout - scan should be fast or already done
const result = finder.waitForScan(500);
const result = finder.waitForScanBlocking(500);
expect(result.ok).toBe(true);
});
@@ -164,6 +164,90 @@ describe("FileFinder - Full Lifecycle", () => {
}
});
test("glob filters by extension via raw pattern", () => {
const result = finder.glob("**/*.ts", { pageSize: 50 });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.items.length).toBeGreaterThan(0);
for (const item of result.value.items) {
expect(item.relativePath.endsWith(".ts")).toBe(true);
}
}
});
test("glob returns empty result for non-matching pattern", () => {
const result = finder.glob("**/this-extension-does-not-exist-anywhere.zzz");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.items.length).toBe(0);
}
});
test("glob rejects empty pattern", () => {
const result = finder.glob("");
expect(result.ok).toBe(false);
});
test("glob respects pageSize", () => {
const result = finder.glob("**/*.ts", { pageSize: 2 });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.items.length).toBeLessThanOrEqual(2);
}
});
test("glob pageIndex offsets results", () => {
// pageIndex is a raw item offset (not a page-count multiplier). Verify
// by skipping the first item and checking the second result begins
// where page0[1] left off.
const page0 = finder.glob("**/*.ts", { pageSize: 5, pageIndex: 0 });
const page1 = finder.glob("**/*.ts", { pageSize: 5, pageIndex: 1 });
expect(page0.ok).toBe(true);
expect(page1.ok).toBe(true);
if (
page0.ok &&
page1.ok &&
page0.value.items.length > 1 &&
page1.value.items.length > 0
) {
expect(page1.value.items[0]!.relativePath).toBe(page0.value.items[1]!.relativePath);
}
});
test("glob directory-prefix pattern matches only that subtree", () => {
const result = finder.glob("src/**/*.ts", { pageSize: 100 });
expect(result.ok).toBe(true);
if (result.ok) {
for (const item of result.value.items) {
expect(item.relativePath.startsWith("src/")).toBe(true);
expect(item.relativePath.endsWith(".ts")).toBe(true);
}
}
});
test("glob result items carry expected fields", () => {
const result = finder.glob("**/*.ts", { pageSize: 1 });
expect(result.ok).toBe(true);
if (result.ok && result.value.items.length > 0) {
const item = result.value.items[0];
expect(typeof item.relativePath).toBe("string");
expect(typeof item.fileName).toBe("string");
expect(item.relativePath.length).toBeGreaterThan(0);
}
});
test("glob literal extension pattern (no leading **) still filters", () => {
const result = finder.glob("*.ts", { pageSize: 100 });
expect(result.ok).toBe(true);
// Don't assert non-zero — depends on whether top-level .ts files exist.
// Just assert all returned items match.
if (result.ok) {
for (const item of result.value.items) {
expect(item.relativePath.endsWith(".ts")).toBe(true);
}
}
});
test("grep plain text returns matching lines", () => {
const result = finder.grep("fff-core", {
mode: "plain",
@@ -321,7 +405,7 @@ describe("FileFinder - Directory Search", () => {
if (result.ok) {
finder = result.value;
}
finder.waitForScan(5000);
finder.waitForScanBlocking(5000);
});
afterAll(() => {
@@ -400,7 +484,7 @@ describe("FileFinder - Error Handling", () => {
describe("Result Type Helpers", () => {
test("ok helper creates success result", async () => {
const { ok } = await import("./types");
const { ok } = await import("./fff-api");
const result = ok(42);
expect(result.ok).toBe(true);
if (result.ok) {
@@ -409,7 +493,7 @@ describe("Result Type Helpers", () => {
});
test("err helper creates error result", async () => {
const { err } = await import("./types");
const { err } = await import("./fff-api");
const result = err<number>("something went wrong");
expect(result.ok).toBe(false);
if (!result.ok) {
+13 -55
View File
@@ -1,60 +1,11 @@
/**
* fff - Fast File Finder
*
* High-performance fuzzy file finder for Bun, powered by Rust.
* Perfect for LLM agent tools that need to search through codebases.
*
* Each `FileFinder` instance is backed by an independent native file picker.
* Create as many as you need and destroy them when done.
*
* @example
* ```typescript
* import { FileFinder } from "fff";
*
* // Create a file finder instance
* const result = FileFinder.create({ basePath: "/path/to/project" });
* if (!result.ok) {
* console.error(result.error);
* process.exit(1);
* }
* const finder = result.value;
*
* // Wait for initial scan
* finder.waitForScan(5000);
*
* // Search for files
* const search = finder.fileSearch("main.ts");
* if (search.ok) {
* for (const item of search.value.items) {
* console.log(item.relativePath);
* }
* }
*
* // Cleanup when done
* finder.destroy();
* ```
*
* @packageDocumentation
*/
export {
binaryExists,
findBinary,
} from "./download";
export { FileFinder } from "./finder";
export {
getLibExtension,
getLibFilename,
getNpmPackageName,
getTriple,
} from "./platform";
export { binaryExists, findBinary } from "./download";
export type {
DbHealth,
DirItem,
DirSearchOptions,
DirSearchResult,
err,
FileFinderApi,
FileItem,
GrepCursor,
GrepMatch,
@@ -67,11 +18,18 @@ export type {
MixedItem,
MixedSearchResult,
MultiGrepOptions,
ok,
Result,
ScanProgress,
Score,
SearchOptions,
SearchResult,
} from "./types";
// Result helpers
export { err, ok } from "./types";
} from "./fff-api";
export { FileFinder } from "./finder";
export {
getLibExtension,
getLibFilename,
getNpmPackageName,
getTriple,
} from "./platform";
+1 -1
View File
@@ -126,7 +126,7 @@ async function main() {
const finder2 = finder2Result.value;
console.log(" Second instance created successfully");
finder2.waitForScan(5000);
finder2.waitForScanBlocking(5000);
const search2 = finder2.fileSearch("Cargo.toml");
if (search2.ok) {
console.log(
+126
View File
@@ -0,0 +1,126 @@
# fff - Fast File Finder
High-performance fuzzy file finder for Node.js, powered by Rust. Extremely fast live file, content, and directory search with a typo-resistant algorithm. As well as regex, plain-text, multi-occurrence and typo-resistant content search.
Comes with built-in git status support, frecency access tracking, and a real-time file watcher, content indexing and many more! Designed for LLM agent tools that search through codebases or agentic RAG document search.
Faster than ripgrep & fzf on any workflow that runs more than once per process.
## Installation
```bash
npm install @ff-labs/fff-node
```
The correct native binary for your platform is installed automatically via platform-specific packages (e.g. `@ff-labs/fff-bin-darwin-arm64`, `@ff-labs/fff-bin-linux-x64-gnu`)
### Supported Platforms
| Platform | Architecture | Package |
| -------- | --------------------- | ----------------------------------- |
| macOS | ARM64 (Apple Silicon) | `@ff-labs/fff-bin-darwin-arm64` |
| macOS | x64 (Intel) | `@ff-labs/fff-bin-darwin-x64` |
| Linux | x64 (glibc) | `@ff-labs/fff-bin-linux-x64-gnu` |
| Linux | ARM64 (glibc) | `@ff-labs/fff-bin-linux-arm64-gnu` |
| Linux | x64 (musl) | `@ff-labs/fff-bin-linux-x64-musl` |
| Linux | ARM64 (musl) | `@ff-labs/fff-bin-linux-arm64-musl` |
| Windows | x64 | `@ff-labs/fff-bin-win32-x64` |
| Windows | ARM64 | `@ff-labs/fff-bin-win32-arm64` |
If the platform package isn't available, the postinstall script will attempt to download from GitHub releases as a fallback.
## Quick Start
Each `FileFinder` instance owns an independent native index. Create one, wait
for the initial scan, then run as many searches as you like.
```typescript
import { FileFinder } from "@ff-labs/fff-node";
// Create an instance bound to a directory
const created = FileFinder.create({ basePath: "/path/to/project" });
if (!created.ok) throw new Error(created.error);
const finder = created.value;
// Wait for the initial scan (async, non-blocking)
await finder.waitForScan(5000);
// 1. Fuzzy file search (typo resistant)
const files = finder.fileSearch("typescropt.ts", { pageSize: 10 });
if (files.ok) {
for (const item of files.value.items) {
console.log(item.relativePath, item.gitStatus);
}
}
// 2. Glob filter — no fuzzy matching, 100% compatible with npm `glob`
const globbed = finder.glob("src/**/*.ts");
if (globbed.ok) console.log(`${globbed.value.totalMatched} TypeScript files`);
// 3. Content search (live grep) with pagination
const grep = finder.grep("TODO", { mode: "plain", pageSize: 20 });
if (grep.ok) {
for (const m of grep.value.items) {
console.log(`${m.relativePath}:${m.lineNumber}: ${m.lineContent}`);
}
}
// 4. Directory search based on the query (typo resistant)
const dirs = finder.directorySearch("components");
if (dirs.ok) console.log(dirs.value.items.map((d) => d.relativePath));
// Free the resources when you don't need a file picker anymore
finder.destroy();
```
## API Reference
Verify the latest API in the local interface at [`./src/fff-api.ts`](./src/fff-api.ts). Every field and type is documented.
### Result Types
All methods return a `Result<T>` type for explicit error handling:
```typescript
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
const result = finder.fileSearch("foo");
if (result.ok) {
// result.value is SearchResult
} else {
// result.error is string error message
}
```
This SDK calls a native compiled library for your platform at runtime. This is generally safe — fff is battle-tested and stable, and written in a memory-safe language — but there is a class of errors that can't be caught at the Node.js level. If you hit one, please report an issue!
## Building from Source
If prebuilt binaries aren't available for your platform:
```bash
# Clone the repository
git clone https://github.com/dmtrKovalenko/fff.nvim
cd fff.nvim
# Build the C library
cargo build --release -p fff-c
# The binary will be at target/release/libfff_c.{so,dylib,dll}
```
## CLI examples
```bash
# Download binary manually (fallback if npm package unavailable)
npx @ff-labs/fff-node download [tag]
# Show platform info and binary location
npx @ff-labs/fff-node info
```
## License
MIT
+617
View File
@@ -0,0 +1,617 @@
// ----------------------------------------------------------------------------
// GENERATED FILE - DO NOT EDIT.
// Source of truth: packages/shared/fff-api.ts
// Run make sync-js-api from the repo root to regenerate.
// ----------------------------------------------------------------------------
/**
* The shared public API surface for the fff file finder, implemented identically
* by `@ff-labs/fff-node` and `@ff-labs/fff-bun`.
*
* This file is the single source of truth for every type, helper, and the
* `FileFinderApi` interface that crosses the package boundary. It is copied
* verbatim into each package's `src/fff-api.ts` by `make sync-api`.
*
* Anything that is not part of the public API (FFI struct layouts, binary
* loading, platform detection, etc.) stays as per-package internal
* implementation and must NOT live here.
*
* Keep this file self-contained: it must not import from any package-local
* module, since each package compiles its own copy.
*/
/**
* Result type for all operations - follows the Result pattern
*/
export type Result<T> = { ok: true; value: T } | { ok: false; error: string };
/**
* Helper to create a successful result
*/
export function ok<T>(value: T): Result<T> {
return { ok: true, value };
}
/**
* Helper to create an error result
*/
export function err<T>(error: string): Result<T> {
return { ok: false, error };
}
/**
* Initialization options for the file finder
*/
export interface InitOptions {
/** Base directory to index (required) */
basePath: string;
/** Path to frecency database (optional, omit to skip frecency initialization) */
frecencyDbPath?: string;
/** Path to query history database (optional, omit to skip query tracker initialization) */
historyDbPath?: string;
/**
* @deprecated No-op. The no-lock LMDB flags showed no measurable win under
* realistic contention and are now ignored. Kept for source-compat.
*/
useUnsafeNoLock?: boolean;
/**
* Disable mmap cache warmup after the initial scan. When mmap cache is
* enabled (the default), the first grep search is as fast as subsequent
* ones at the cost of a longer scan time and higher initial memory usage.
* (default: false)
*/
disableMmapCache?: boolean;
/**
* Disable the content index built after the initial scan.
* Content indexing enables faster content-aware filtering during grep.
* When omitted, follows `disableMmapCache` for backward compatibility.
* (default: follows `disableMmapCache`)
*/
disableContentIndexing?: boolean;
/**
* Disable the background file-system watcher. When the watcher is
* disabled, files are scanned once but not monitored for changes.
* (default: false)
*/
disableWatch?: boolean;
/** enables optimizations for AI agent assistants. Provide as true if running via mcp/agent */
aiMode?: boolean;
/**
* Path to the tracing log file. When set, the shared FFF tracing subscriber
* is installed on first init and file output is written here. Omit to leave
* logging uninitialized.
*/
logFilePath?: string;
/**
* Log level for the tracing subscriber: "trace", "debug", "info", "warn",
* or "error". Defaults to "info". Ignored when `logFilePath` is not set.
*/
logLevel?: "trace" | "debug" | "info" | "warn" | "error";
/**
* Override for the content cache file-count cap. When omitted, the picker
* auto-sizes the budget from the final scanned file count.
*/
cacheBudgetMaxFiles?: number;
/** Override for the content cache byte cap. See `cacheBudgetMaxFiles`. */
cacheBudgetMaxBytes?: number;
/** Override for the per-file byte cap in the content cache. */
cacheBudgetMaxFileSize?: number;
/**
* Allow indexing the filesystem root (`/`). Off by default root is
* rarely the intended target and floods the watcher with churn-prone
* events. Setting this true is opt-in and the caller is responsible for
* the resulting fs-event volume.
*/
enableFsRootScanning?: boolean;
/**
* Allow indexing the user's home directory. Same trade-off as
* `enableFsRootScanning`.
*/
enableHomeDirScanning?: boolean;
}
/**
* Search options for fuzzy file search
*/
export interface SearchOptions {
/** Maximum threads for parallel search (0 = auto) */
maxThreads?: number;
/** Current file path (for deprioritization in results) */
currentFile?: string;
/** Combo boost score multiplier (default: 100) */
comboBoostMultiplier?: number;
/** Minimum combo count for boost (default: 3) */
minComboCount?: number;
/** Page index for pagination (default: 0) */
pageIndex?: number;
/** Page size for pagination (default: 100) */
pageSize?: number;
}
/**
* Options for `glob`, the constraint-only search.
*
* The pattern is applied as a single pass SIMD optimized prefiltering
* without any fuzzy matching involved. Faster and 100% compatible to npm `glob`.
*/
export interface GlobOptions {
/** Maximum threads for parallel filtering (0 = auto). */
maxThreads?: number;
/** Current file path (for deprioritization in results). */
currentFile?: string;
/** Page index for pagination (default: 0). */
pageIndex?: number;
/** Page size for pagination (default: 100). */
pageSize?: number;
}
/**
* A file item in search results
*/
export interface FileItem {
/** Path relative to the indexed directory */
relativePath: string;
/** File name only */
fileName: string;
/** File size in bytes */
size: number;
/** Last modified timestamp (Unix seconds) */
modified: number;
/** Frecency score based on access patterns */
accessFrecencyScore: number;
/** Frecency score based on modification time */
modificationFrecencyScore: number;
/** Combined frecency score */
totalFrecencyScore: number;
/** Git status: 'clean', 'modified', 'untracked', 'staged_new', etc. */
gitStatus: string;
}
/**
* Score breakdown for a search result
*/
export interface Score {
/** Total combined score */
total: number;
/** Base fuzzy match score */
baseScore: number;
/** Bonus for filename match */
filenameBonus: number;
/** Bonus for special filenames (index.ts, main.rs, etc.) */
specialFilenameBonus: number;
/** Boost from frecency */
frecencyBoost: number;
/** Penalty for distance in path */
distancePenalty: number;
/** Penalty if this is the current file */
currentFilePenalty: number;
/** Boost from query history combo matching */
comboMatchBoost: number;
/** Whether this was an exact match */
exactMatch: boolean;
/** Type of match: 'fuzzy', 'exact', 'prefix', etc. */
matchType: string;
}
/**
* Location in file (from query like "file.ts:42")
*/
export type Location =
| { type: "line"; line: number }
| { type: "position"; line: number; col: number }
| {
type: "range";
start: { line: number; col: number };
end: { line: number; col: number };
};
/**
* Search result from fuzzy file search
*/
export interface SearchResult {
/** Matched file items */
items: FileItem[];
/** Corresponding scores for each item */
scores: Score[];
/** Total number of files that matched */
totalMatched: number;
/** Total number of indexed files */
totalFiles: number;
/** Location parsed from query (e.g., "file.ts:42:10") */
location?: Location;
}
/**
* A directory item in search results
*/
export interface DirItem {
/** Path relative to the indexed directory (e.g., "src/components/") */
relativePath: string;
/** Last path segment (e.g., "components/" for "src/components/") */
dirName: string;
/** Maximum access frecency score among direct child files */
maxAccessFrecency: number;
}
/**
* Search options for directory search (subset of SearchOptions)
*/
export interface DirSearchOptions {
/** Maximum threads for parallel search (0 = auto) */
maxThreads?: number;
/** Current file path (for distance scoring) */
currentFile?: string;
/** Page index for pagination (default: 0) */
pageIndex?: number;
/** Page size for pagination (default: 100) */
pageSize?: number;
}
/**
* Search result from fuzzy directory search
*/
export interface DirSearchResult {
/** Matched directory items */
items: DirItem[];
/** Corresponding scores for each item */
scores: Score[];
/** Total number of directories that matched */
totalMatched: number;
/** Total number of indexed directories */
totalDirs: number;
}
/**
* A single item in a mixed (files + directories) search result
*/
export type MixedItem =
| { type: "file"; item: FileItem }
| { type: "directory"; item: DirItem };
/**
* Search result from mixed (files + directories) fuzzy search.
* Items are interleaved by total score in descending order.
*/
export interface MixedSearchResult {
/** Matched items (files and directories interleaved by score) */
items: MixedItem[];
/** Corresponding scores for each item */
scores: Score[];
/** Total number of items (files + dirs) that matched */
totalMatched: number;
/** Total number of indexed files */
totalFiles: number;
/** Total number of indexed directories */
totalDirs: number;
/** Location parsed from query */
location?: Location;
}
/**
* Scan progress information
*/
export interface ScanProgress {
/** Number of files scanned so far */
scannedFilesCount: number;
/** Whether a scan is currently in progress */
isScanning: boolean;
/** Whether the background file watcher is ready */
isWatcherReady: boolean;
/** Whether the warmup/bigram phase has completed */
isWarmupComplete: boolean;
}
/**
* Database health information
*/
export interface DbHealth {
/** Path to the database */
path: string;
/** Size of the database on disk in bytes */
diskSize: number;
}
/**
* Health check result
*/
export interface HealthCheck {
/** Library version */
version: string;
/** Git integration status */
git: {
/** Whether git2 library is available */
available: boolean;
/** Whether a git repository was found */
repositoryFound: boolean;
/** Git working directory path */
workdir?: string;
/** libgit2 version string */
libgit2Version: string;
/** Error message if git detection failed */
error?: string;
};
/** File picker status */
filePicker: {
/** Whether the file picker is initialized */
initialized: boolean;
/** Base path being indexed */
basePath?: string;
/** Whether a scan is in progress */
isScanning?: boolean;
/** Number of indexed files */
indexedFiles?: number;
/** Error message if there's an issue */
error?: string;
};
/** Frecency database status */
frecency: {
/** Whether frecency tracking is initialized */
initialized: boolean;
/** Database health information */
dbHealthcheck?: DbHealth;
/** Error message if there's an issue */
error?: string;
};
/** Query tracker status */
queryTracker: {
/** Whether query tracking is initialized */
initialized: boolean;
/** Database health information */
dbHealthcheck?: DbHealth;
/** Error message if there's an issue */
error?: string;
};
}
/**
* Grep search mode
*/
export type GrepMode = "plain" | "regex" | "fuzzy";
/**
* Opaque pagination cursor for grep results.
* Pass this to `GrepOptions.cursor` to fetch the next page.
* Do not construct or modify this use the `nextCursor` from a previous `GrepResult`.
*/
export interface GrepCursor {
/** @internal */
readonly __brand: "GrepCursor";
/** @internal */
readonly _offset: number;
}
/**
* @internal Create a GrepCursor from a raw file offset.
*/
export function createGrepCursor(offset: number): GrepCursor {
return { __brand: "GrepCursor" as const, _offset: offset };
}
/**
* Options for live grep (content search)
*
* Files are searched sequentially in frecency order (most recently/frequently
* accessed first). The engine returns a `nextCursor` for fetching the next page.
*/
export interface GrepOptions {
/** Maximum file size to search in bytes. Files larger than this are skipped. (default: 10MB) */
maxFileSize?: number;
/** Maximum matching lines to collect from a single file (default: 200) */
maxMatchesPerFile?: number;
/** Smart case: case-insensitive when the query is all lowercase, case-sensitive otherwise (default: true) */
smartCase?: boolean;
/**
* Pagination cursor from a previous `GrepResult.nextCursor`.
* Omit (or pass `null`) for the first page.
*/
cursor?: GrepCursor | null;
/** Search mode (default: "plain") */
mode?: GrepMode;
/**
* Maximum wall-clock time in milliseconds to spend searching before returning
* partial results. 0 = unlimited. (default: 0)
*/
timeBudgetMs?: number;
/** Number of context lines to include before each match (default: 0) */
beforeContext?: number;
/** Number of context lines to include after each match (default: 0) */
afterContext?: number;
/** Maximum matches to return in this page across all files (default: 50) */
pageSize?: number;
/**
* When true, classify each match line as a code definition (struct/fn/class/...)
* and expose it via `GrepMatch.isDefinition`. Let callers re-rank defs first
* without a TS-side regex port. (default: false)
*/
classifyDefinitions?: boolean;
}
/**
* A single grep match with file and line information
*/
export interface GrepMatch {
/** Path relative to the indexed directory */
relativePath: string;
/** File name only */
fileName: string;
/** Git status */
gitStatus: string;
/** File size in bytes */
size: number;
/** Last modified timestamp (Unix seconds) */
modified: number;
/** Whether the file is binary */
isBinary: boolean;
/** Combined frecency score */
totalFrecencyScore: number;
/** Access-based frecency score */
accessFrecencyScore: number;
/** Modification-based frecency score */
modificationFrecencyScore: number;
/** 1-based line number of the match */
lineNumber: number;
/** 0-based byte column of first match start */
col: number;
/** Absolute byte offset of the matched line from file start */
byteOffset: number;
/** The matched line text (may be truncated) */
lineContent: string;
/** Byte offset pairs [start, end] within lineContent for highlighting */
matchRanges: [number, number][];
/** Fuzzy match score (only in fuzzy mode) */
fuzzyScore?: number;
/** Lines before the match (context). Empty array when context is 0. */
contextBefore?: string[];
/** Lines after the match (context). Empty array when context is 0. */
contextAfter?: string[];
/** Whether this line is a code definition (only populated when `classifyDefinitions: true`). */
isDefinition?: boolean;
}
/**
* Result from a grep search
*/
export interface GrepResult {
/** Matched items with file and line information. At most `max_matches_per_file`. */
items: GrepMatch[];
/** Total number of matches collected (always equal to items.length). */
totalMatched: number;
/** Number of files actually opened and searched in this call */
totalFilesSearched: number;
/** Total number of indexed files (before any filtering) */
totalFiles: number;
/** Number of files eligible for search after filtering out binary files, oversized files, and constraint mismatches */
filteredFileCount: number;
/**
* Cursor for the next page, or `null` if all eligible files have been searched.
* Pass this as `GrepOptions.cursor` to continue from where this call left off.
*/
nextCursor: GrepCursor | null;
/** When regex mode fails to compile the pattern, the engine falls back to literal matching and this field contains the compilation error */
regexFallbackError?: string;
}
/**
* Options for multi-pattern grep (Aho-Corasick multi-needle search)
*
* Searches for lines matching ANY of the provided patterns using
* SIMD-accelerated Aho-Corasick multi-pattern matching.
*/
export interface MultiGrepOptions {
/** Patterns to search for (OR logic — matches lines containing any pattern) */
patterns: string[];
/** File constraints like "*.rs" or "/src/" */
constraints?: string;
/** Maximum file size to search in bytes (default: 10MB) */
maxFileSize?: number;
/** Maximum matching lines to collect from a single file (default: 0 = unlimited) */
maxMatchesPerFile?: number;
/** Smart case: case-insensitive when all patterns are lowercase (default: true) */
smartCase?: boolean;
/**
* Pagination cursor from a previous `GrepResult.nextCursor`.
* Omit (or pass `null`) for the first page.
*/
cursor?: GrepCursor | null;
/**
* Maximum wall-clock time in milliseconds to spend searching before returning
* partial results. 0 = unlimited. (default: 0)
*/
timeBudgetMs?: number;
/** Number of context lines to include before each match (default: 0) */
beforeContext?: number;
/** Number of context lines to include after each match (default: 0) */
afterContext?: number;
/** Maximum matches to return in this page across all files (default: 50) */
pageSize?: number;
/**
* When true, classify each match line as a code definition (struct/fn/class/...)
* and expose it via `GrepMatch.isDefinition`. (default: false)
*/
classifyDefinitions?: boolean;
}
/**
* The shared instance surface implemented by `FileFinder` in both
* `@ff-labs/fff-node` and `@ff-labs/fff-bun`.
*
* Both packages must implement this identically. Only instance members belong
* here. Static helpers (`create`, `isAvailable`, `ensureLoaded`,
* `healthCheckStatic`) are package-specific and intentionally excluded.
*/
export interface FileFinderApi {
/** Whether the instance has been destroyed. */
readonly isDestroyed: boolean;
/** Destroy and free all native resources. */
destroy(): void;
/** Fuzzy file search. */
fileSearch(query: string, options?: SearchOptions): Result<SearchResult>;
/** Glob-only filtering (no fuzzy matching). */
glob(pattern: string, options?: GlobOptions): Result<SearchResult>;
/** Fuzzy directory search. */
directorySearch(query: string, options?: DirSearchOptions): Result<DirSearchResult>;
/** Fuzzy search over files and directories interleaved by score. */
mixedSearch(query: string, options?: SearchOptions): Result<MixedSearchResult>;
/** Content search (live grep). */
grep(query: string, options?: GrepOptions): Result<GrepResult>;
/** Multi-pattern OR content search (Aho-Corasick). */
multiGrep(options: MultiGrepOptions): Result<GrepResult>;
/** Trigger an async rescan of the indexed directory. */
scanFiles(): Result<void>;
/** Whether a scan is currently in progress. */
isScanning(): boolean;
/** The root directory being indexed. */
getBasePath(): Result<string | null>;
/** Current scan progress snapshot. */
getScanProgress(): Result<ScanProgress>;
/**
* Wait for the initial file scan to complete.
*
* Non-blocking: polls `isScanning` and yields to the event loop between
* checks, so other async work keeps running while waiting.
*/
waitForScan(timeoutMs?: number): Promise<Result<boolean>>;
/**
* Wait for the initial file scan to complete, blocking the calling thread.
*
* Backed by the native `fff_wait_for_scan` call. Prefer `waitForScan` unless
* you specifically need synchronous blocking behaviour.
*/
waitForScanBlocking(timeoutMs?: number): Result<boolean>;
/**
* Wait until the index is fully ready: the scan has finished and the warmup
* (content indexing / bigram) phase has completed.
*
* Non-blocking: polls `getScanProgress` and yields to the event loop.
*/
waitForIndexReady(timeoutMs?: number): Promise<Result<boolean>>;
/** Restart indexing in a new directory. */
reindex(newPath: string): Result<void>;
/** Refresh the git status cache. Returns the number of updated files. */
refreshGitStatus(): Result<number>;
/** Record that `selectedFilePath` was chosen for `query`. */
trackQuery(query: string, selectedFilePath: string): Result<boolean>;
/** Get a historical query by offset (0 = most recent). */
getHistoricalQuery(offset: number): Result<string | null>;
/** Health/diagnostics information for this instance. */
healthCheck(testPath?: string): Result<HealthCheck>;
}
+114 -47
View File
@@ -57,11 +57,32 @@ import type {
Result,
Score,
SearchResult,
} from "./types.js";
import { createGrepCursor, err } from "./types.js";
} from "./fff-api.js";
import { createGrepCursor, err } from "./fff-api.js";
const LIBRARY_KEY = "fff_c";
const FFF_CREATE_OPTIONS_STRUCT = {
version: DataType.U32,
base_path: DataType.String,
frecency_db_path: DataType.String,
history_db_path: DataType.String,
enable_mmap_cache: DataType.U8,
enable_content_indexing: DataType.U8,
watch: DataType.U8,
ai_mode: DataType.U8,
log_file_path: DataType.String,
log_level: DataType.String,
cache_budget_max_files: DataType.U64,
cache_budget_max_bytes: DataType.U64,
cache_budget_max_file_size: DataType.U64,
enable_fs_root_scanning: DataType.U8,
enable_home_dir_scanning: DataType.U8,
};
// ALWAYS KEEP IN SYNC WITH fff.h
const FFF_CREATE_OPTIONS_VERSION = 1;
/** Grep mode constants matching the C API (u8). */
const GREP_MODE_PLAIN = 0;
const GREP_MODE_REGEX = 1;
@@ -322,14 +343,11 @@ function freeString(ptr: JsExternal): void {
*/
export type NativeHandle = JsExternal;
/**
* Create a new file finder instance.
*/
export function ffiCreate(
basePath: string,
frecencyDbPath: string,
historyDbPath: string,
useUnsafeNoLock: boolean,
_useUnsafeNoLock: boolean,
enableMmapCache: boolean,
enableContentIndexing: boolean,
watch: boolean,
@@ -339,42 +357,42 @@ export function ffiCreate(
cacheBudgetMaxFiles: number,
cacheBudgetMaxBytes: number,
cacheBudgetMaxFileSize: number,
enableFsRootScanning: boolean,
enableHomeDirScanning: boolean,
): Result<NativeHandle> {
loadLibrary();
const { rawPtr, struct: structData } = callRaw(
"fff_create_instance2",
[
DataType.String, // base_path
DataType.String, // frecency_db_path
DataType.String, // history_db_path
DataType.Boolean, // use_unsafe_no_lock
DataType.Boolean, // enable_mmap_cache
DataType.Boolean, // enable_content_indexing
DataType.Boolean, // watch
DataType.Boolean, // ai_mode
DataType.String, // log_file_path
DataType.String, // log_level
DataType.U64, // cache_budget_max_files
DataType.U64, // cache_budget_max_bytes
DataType.U64, // cache_budget_max_file_size
],
[
basePath,
frecencyDbPath,
historyDbPath,
useUnsafeNoLock,
enableMmapCache,
enableContentIndexing,
watch,
aiMode,
logFilePath,
logLevel,
cacheBudgetMaxFiles,
cacheBudgetMaxBytes,
cacheBudgetMaxFileSize,
],
);
const optsValue = {
version: FFF_CREATE_OPTIONS_VERSION,
base_path: basePath,
frecency_db_path: frecencyDbPath,
history_db_path: historyDbPath,
enable_mmap_cache: enableMmapCache ? 1 : 0,
enable_content_indexing: enableContentIndexing ? 1 : 0,
watch: watch ? 1 : 0,
ai_mode: aiMode ? 1 : 0,
log_file_path: logFilePath,
log_level: logLevel,
cache_budget_max_files: cacheBudgetMaxFiles,
cache_budget_max_bytes: cacheBudgetMaxBytes,
cache_budget_max_file_size: cacheBudgetMaxFileSize,
enable_fs_root_scanning: enableFsRootScanning ? 1 : 0,
enable_home_dir_scanning: enableHomeDirScanning ? 1 : 0,
};
const rawPtr = load({
library: LIBRARY_KEY,
funcName: "fff_create_instance_with",
retType: DataType.External,
paramsType: [FFF_CREATE_OPTIONS_STRUCT],
paramsValue: [optsValue],
freeResultMemory: false,
}) as JsExternal;
const [structData] = restorePointer({
retType: [FFF_RESULT_STRUCT],
paramsValue: wrapPointer([rawPtr]),
}) as unknown as [FffResultRaw];
const success = structData.success !== 0;
@@ -382,7 +400,7 @@ export function ffiCreate(
if (success) {
const handle = structData.handle;
if (isNullPointer(handle)) {
return err("fff_create_instance2 returned null handle");
return err("fff_create_instance_with returned null handle");
}
return { ok: true, value: handle };
} else {
@@ -575,7 +593,6 @@ interface FffMixedSearchResultRaw {
location_end_col: number;
}
// FffGrepMatch (144 bytes) — ordered by alignment: ptrs, u64s, u32s, u16, bools
const FFF_GREP_MATCH_STRUCT = {
relative_path: DataType.External,
file_name: DataType.External,
@@ -595,7 +612,7 @@ const FFF_GREP_MATCH_STRUCT = {
match_ranges_count: DataType.U32,
context_before_count: DataType.U32,
context_after_count: DataType.U32,
fuzzy_score: DataType.U32, // actually u16 in C, but ffi-rs doesn't have U16 — reads as u32 with padding
fuzzy_score: DataType.U32, // actually u16 in C, but ffi-rs doesn't so we read it as u32 with padding
has_fuzzy_score: DataType.U8,
is_binary: DataType.U8,
is_definition: DataType.U8,
@@ -937,7 +954,11 @@ function parseSearchResult(rawPtr: JsExternal): Result<SearchResult> {
if (sr.location_tag === 1) {
location = { type: "line", line: sr.location_line };
} else if (sr.location_tag === 2) {
location = { type: "position", line: sr.location_line, col: sr.location_col };
location = {
type: "position",
line: sr.location_line,
col: sr.location_col,
};
} else if (sr.location_tag === 3) {
location = {
type: "range",
@@ -1108,7 +1129,11 @@ function parseMixedSearchResult(rawPtr: JsExternal): Result<MixedSearchResult> {
if (sr.location_tag === 1) {
location = { type: "line", line: sr.location_line };
} else if (sr.location_tag === 2) {
location = { type: "position", line: sr.location_line, col: sr.location_col };
location = {
type: "position",
line: sr.location_line,
col: sr.location_col,
};
} else if (sr.location_tag === 3) {
location = {
type: "range",
@@ -1206,6 +1231,39 @@ export function ffiSearch(
return parseSearchResult(rawPtr);
}
/**
* Glob-only search. Bypasses the regular query parser, applies the pattern
* as a single `Constraint::Glob`, ranks by frecency, paginates.
*/
export function ffiGlob(
handle: NativeHandle,
pattern: string,
currentFile: string,
maxThreads: number,
pageIndex: number,
pageSize: number,
): Result<SearchResult> {
loadLibrary();
const rawPtr = load({
library: LIBRARY_KEY,
funcName: "fff_glob",
retType: DataType.External,
paramsType: [
DataType.External, // handle
DataType.String, // pattern
DataType.String, // current_file
DataType.U32, // max_threads
DataType.U32, // page_index
DataType.U32, // page_size
],
paramsValue: [handle, pattern, currentFile, maxThreads, pageIndex, pageSize],
freeResultMemory: false,
}) as JsExternal;
return parseSearchResult(rawPtr);
}
/**
* Perform fuzzy directory search.
*/
@@ -1429,19 +1487,26 @@ export function ffiGetBasePath(handle: NativeHandle): Result<string | null> {
const FFF_SCAN_PROGRESS_STRUCT = {
scanned_files_count: DataType.U64,
is_scanning: DataType.U8,
is_watcher_ready: DataType.U8,
is_warmup_complete: DataType.U8,
};
interface FffScanProgressRaw {
scanned_files_count: number;
is_scanning: number;
is_watcher_ready: number;
is_warmup_complete: number;
}
/**
* Get scan progress.
*/
export function ffiGetScanProgress(
handle: NativeHandle,
): Result<{ scannedFilesCount: number; isScanning: boolean }> {
export function ffiGetScanProgress(handle: NativeHandle): Result<{
scannedFilesCount: number;
isScanning: boolean;
isWatcherReady: boolean;
isWarmupComplete: boolean;
}> {
loadLibrary();
const res = readResultEnvelope("fff_get_scan_progress", [DataType.External], [handle]);
if ("ok" in res) return res;
@@ -1459,6 +1524,8 @@ export function ffiGetScanProgress(
const result = {
scannedFilesCount: Number(sp.scanned_files_count),
isScanning: sp.is_scanning !== 0,
isWatcherReady: sp.is_watcher_ready !== 0,
isWarmupComplete: sp.is_warmup_complete !== 0,
};
// Free native scan progress
+87 -6
View File
@@ -15,6 +15,7 @@ import {
ffiGetBasePath,
ffiGetHistoricalQuery,
ffiGetScanProgress,
ffiGlob,
ffiHealthCheck,
ffiIsScanning,
ffiLiveGrep,
@@ -26,6 +27,7 @@ import {
ffiSearchDirectories,
ffiSearchMixed,
ffiTrackQuery,
ffiWaitForScan,
isAvailable,
type NativeHandle,
} from "./ffi.js";
@@ -33,6 +35,8 @@ import {
import type {
DirSearchOptions,
DirSearchResult,
FileFinderApi,
GlobOptions,
GrepOptions,
GrepResult,
HealthCheck,
@@ -43,9 +47,9 @@ import type {
ScanProgress,
SearchOptions,
SearchResult,
} from "./types.js";
} from "./fff-api.js";
import { err } from "./types.js";
import { err } from "./fff-api.js";
/**
* FileFinder - Fast file finder with fuzzy search
@@ -66,7 +70,7 @@ import { err } from "./types.js";
* }
*
* // Wait for initial scan
* finder.value.waitForScan(5000);
* await finder.value.waitForScan(5000);
*
* // Search for files
* const search = finder.value.search("main.ts");
@@ -80,7 +84,7 @@ import { err } from "./types.js";
* finder.value.destroy();
* ```
*/
export class FileFinder {
export class FileFinder implements FileFinderApi {
private handle: NativeHandle | null;
private constructor(handle: NativeHandle) {
@@ -121,6 +125,8 @@ export class FileFinder {
options.cacheBudgetMaxFiles ?? 0,
options.cacheBudgetMaxBytes ?? 0,
options.cacheBudgetMaxFileSize ?? 0,
options.enableFsRootScanning ?? false,
options.enableHomeDirScanning ?? false,
);
if (!result.ok) {
@@ -201,6 +207,40 @@ export class FileFinder {
);
}
/**
* Filters files using glob wildcard expression.
*
* The pattern is applied as a single pass SIMD optimized prefiltering
* without any fuzzy matching involved. Faster and 100% compatible to npm `glob`.
*
* @param pattern - Glob pattern (required, non-empty)
* @param options - Glob search options (pagination, max threads, current file)
* @returns Search results with files matching the glob
*
* @example
* ```typescript
* const result = finder.glob("**\/*.rs", { pageSize: 100 });
* if (result.ok) {
* for (const item of result.value.items) {
* console.log(item.relativePath);
* }
* }
* ```
*/
glob(pattern: string, options?: GlobOptions): Result<SearchResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiGlob(
guard.value,
pattern,
options?.currentFile ?? "",
options?.maxThreads ?? 0,
options?.pageIndex ?? 0,
options?.pageSize ?? 0,
);
}
/**
* Search for directories matching the query.
*
@@ -418,8 +458,7 @@ export class FileFinder {
/**
* Wait for the initial file scan to complete.
*
* Non-blocking polls `isScanning` and yields to the event loop between checks.
* Non-blocking: polls `isScanning` and yields to the event loop between checks.
*
* @param timeoutMs - Maximum time to wait in milliseconds (default: 5000)
* @returns true if scan completed, false if timed out
@@ -449,6 +488,48 @@ export class FileFinder {
return { ok: true, value: true };
}
/**
* Wait for the initial file scan to complete, blocking the calling thread.
*
* Backed by the native `fff_wait_for_scan` call. Prefer {@link waitForScan}
* unless you specifically need synchronous blocking behaviour.
*
* @param timeoutMs - Maximum time to wait in milliseconds (default: 5000)
* @returns true if scan completed, false if timed out
*/
waitForScanBlocking(timeoutMs: number = 5000): Result<boolean> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiWaitForScan(guard.value, timeoutMs);
}
/**
* Wait until the index is fully ready: the scan has finished and the warmup
* (content indexing / bigram) phase has completed.
*
* Non-blocking: polls `getScanProgress` and yields to the event loop.
*
* @param timeoutMs - Maximum time to wait in milliseconds (default: 5000)
* @returns true if the index became ready, false if timed out
*/
async waitForIndexReady(timeoutMs: number = 5000): Promise<Result<boolean>> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
const deadline = Date.now() + timeoutMs;
while(true) {
const progress = this.getScanProgress();
if (!progress.ok) return progress;
if (!progress.value.isScanning && progress.value.isWarmupComplete) {
return { ok: true, value: true };
}
if (Date.now() >= deadline) {
return { ok: true, value: false };
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
/**
* Change the indexed directory to a new path.
*
+11 -11
View File
@@ -22,7 +22,7 @@
* const finder = result.value;
*
* // Wait for initial scan
* finder.waitForScan(5000);
* await finder.waitForScan(5000);
*
* // Search for files
* const search = finder.fileSearch("main.ts");
@@ -44,19 +44,12 @@ export {
findBinary,
} from "./binary.js";
export { closeLibrary } from "./ffi.js";
export { FileFinder } from "./finder.js";
export {
getLibExtension,
getLibFilename,
getNpmPackageName,
getTriple,
} from "./platform.js";
export type {
DbHealth,
DirItem,
DirSearchOptions,
DirSearchResult,
FileFinderApi,
FileItem,
GrepCursor,
GrepMatch,
@@ -74,6 +67,13 @@ export type {
Score,
SearchOptions,
SearchResult,
} from "./types.js";
} from "./fff-api.js";
// Result helpers
export { err, ok } from "./types.js";
export { err, ok } from "./fff-api.js";
export { FileFinder } from "./finder.js";
export {
getLibExtension,
getLibFilename,
getNpmPackageName,
getTriple,
} from "./platform.js";
+2 -2
View File
@@ -37,11 +37,11 @@ function detectLinuxLibc(): string {
timeout: 5000,
});
} catch (e: unknown) {
// Alpine/musl: `ldd --version` exits with code 1 but still prints
// "musl libc ..." — execSync surfaces that on the error object.
const err = e as { stdout?: string | Buffer; stderr?: string | Buffer };
output = String(err?.stdout ?? "") + String(err?.stderr ?? "");
}
// ldd on musl can produce stdout with musl either with exit code 1 or 0
if (output.toLowerCase().includes("musl")) {
return "unknown-linux-musl";
}
+74 -35
View File
@@ -1,22 +1,8 @@
/**
* End-to-end tests for the fff-node package.
*
* Indexes the fff.nvim repository itself so the test suite is fully
* self-contained no external projects required.
*
* Requires:
* - A built Rust library (cargo build --release -p fff-c)
* - A compiled TS dist (cd packages/fff-node && npx tsc)
*
* Run:
* node --test test/e2e.mjs
*/
import { after, before, describe, it } from "node:test";
import { strict as assert } from "node:assert";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { FileFinder, closeLibrary } from "../dist/src/index.js";
import { FileFinder } from "../dist/src/index.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(__dirname, "..", "..", "..");
@@ -40,10 +26,9 @@ describe("fff-node", { concurrency: 1 }, () => {
});
after(() => {
if (finder && !finder.isDestroyed) finder.destroy();
// Skip closeLibrary() — the OS handles cleanup on process exit.
// Calling ffi-rs close() during test teardown can cause native crashes
// on some platforms (e.g. Windows DLL unload, Linux dlclose).
if (finder && !finder.isDestroyed) {
finder.destroy();
}
});
it("isAvailable returns true when the native library is loadable", () => {
@@ -130,19 +115,68 @@ describe("fff-node", { concurrency: 1 }, () => {
});
});
describe("glob", { concurrency: 1 }, () => {
it("filters by extension via raw glob pattern", () => {
const r = finder.glob("**/*.rs", { pageSize: 50 });
assert.ok(r.ok, `glob failed: ${!r.ok ? r.error : ""}`);
assert.ok(r.value.items.length > 0, "expected at least one .rs file");
for (const item of r.value.items) {
assert.ok(
item.relativePath.endsWith(".rs"),
`unexpected file: ${item.relativePath}`,
);
}
});
it("returns empty result for non-matching pattern", () => {
const r = finder.glob("**/this-extension-does-not-exist-anywhere.zzz");
assert.ok(r.ok);
assert.equal(r.value.items.length, 0);
});
it("rejects empty pattern", () => {
const r = finder.glob("");
assert.equal(r.ok, false);
});
it("respects pageSize", () => {
const r = finder.glob("**/*.rs", { pageSize: 3 });
assert.ok(r.ok);
assert.ok(r.value.items.length <= 3);
});
});
describe("grep", { concurrency: 1 }, () => {
it("finds FffResult in Rust sources", () => {
// Constrain to .rs files so the assertion doesn't depend on result ordering
// or content-indexing timing for other file types.
const rustResults = finder.grep("*.rs FffResult", { mode: "plain" });
assert.ok(rustResults.ok, `grep failed: ${!rustResults.ok ? rustResults.error : ""}`);
assert.ok(rustResults.value.items.length > 0, "expected at least one .rs match");
assert.ok(rustResults.value.items.some((m) => m.relativePath.endsWith(".rs")));
assert.ok(
rustResults.ok,
`grep failed: ${!rustResults.ok ? rustResults.error : ""}`,
);
assert.ok(
rustResults.value.items.length > 0,
"expected at least one .rs match",
);
assert.ok(
rustResults.value.items.some((m) => m.relativePath.endsWith(".rs")),
);
const cResults = finder.grep("!**/*.{js,ts,rs} FffResult", { mode: "plain" });
assert.ok(cResults.ok, `grep failed: ${!cResults.ok ? cResults.error : ""}`);
assert.ok(cResults.value.items.length > 0, "expected at least one non-js/ts/rs match");
assert.ok(cResults.value.items.some((m) => m.relativePath.endsWith(".h")));
const cResults = finder.grep("!**/*.{js,ts,rs} FffResult", {
mode: "plain",
});
assert.ok(
cResults.ok,
`grep failed: ${!cResults.ok ? cResults.error : ""}`,
);
assert.ok(
cResults.value.items.length > 0,
"expected at least one non-js/ts/rs match",
);
assert.ok(
cResults.value.items.some((m) => m.relativePath.endsWith(".h")),
);
});
it("match items contain all required fields", () => {
@@ -172,8 +206,14 @@ describe("fff-node", { concurrency: 1 }, () => {
it("respects pageSize", () => {
// Cap to one match per file so pageSize bounds the total deterministically.
const unbounded = finder.grep("fn", { mode: "plain", maxMatchesPerFile: 1 });
assert.ok(unbounded.ok, `grep failed: ${!unbounded.ok ? unbounded.error : ""}`);
const unbounded = finder.grep("fn", {
mode: "plain",
maxMatchesPerFile: 1,
});
assert.ok(
unbounded.ok,
`grep failed: ${!unbounded.ok ? unbounded.error : ""}`,
);
assert.ok(unbounded.value.items.length > 2);
const limited = finder.grep("fn", {
@@ -201,7 +241,7 @@ describe("fff-node", { concurrency: 1 }, () => {
it("decodes before/after context lines", () => {
const r = finder.grep(
"match.contextBefore = readCStringArray(raw.context_before, raw.context_before_count);",
"LOLLOWOIEJIWOIUOIWUIWUIOUWE", // the random text visible here
{
mode: "plain",
beforeContext: 1,
@@ -212,18 +252,17 @@ describe("fff-node", { concurrency: 1 }, () => {
assert.ok(r.ok, `grep with context failed: ${!r.ok ? r.error : ""}`);
const match = r.value.items.find(
(m) => normalizePath(m.relativePath) === "packages/fff-node/src/ffi.ts",
(m) =>
normalizePath(m.relativePath) === "packages/fff-node/test/e2e.mjs",
);
assert.ok(
match,
`expected a match in packages/fff-node/src/ffi.ts, got: ${r.value.items
`expected a single match in the codebase, got: ${r.value.items
.map((m) => normalizePath(m.relativePath))
.join(", ")}`,
);
assert.deepEqual(match.contextBefore, [
" if (raw.context_before_count > 0) {",
]);
assert.deepEqual(match.contextAfter, [" }"]);
assert.deepEqual(match.contextBefore, [" const r = finder.grep("]);
assert.deepEqual(match.contextAfter, [" {"]);
});
});
@@ -1,3 +1,19 @@
/**
* The shared public API surface for the fff file finder, implemented identically
* by `@ff-labs/fff-node` and `@ff-labs/fff-bun`.
*
* This file is the single source of truth for every type, helper, and the
* `FileFinderApi` interface that crosses the package boundary. It is copied
* verbatim into each package's `src/fff-api.ts` by `make sync-api`.
*
* Anything that is not part of the public API (FFI struct layouts, binary
* loading, platform detection, etc.) stays as per-package internal
* implementation and must NOT live here.
*
* Keep this file self-contained: it must not import from any package-local
* module, since each package compiles its own copy.
*/
/**
* Result type for all operations - follows the Result pattern
*/
@@ -27,7 +43,10 @@ export interface InitOptions {
frecencyDbPath?: string;
/** Path to query history database (optional, omit to skip query tracker initialization) */
historyDbPath?: string;
/** Use unsafe no-lock mode for databases (optional, defaults to false) */
/**
* @deprecated No-op. The no-lock LMDB flags showed no measurable win under
* realistic contention and are now ignored. Kept for source-compat.
*/
useUnsafeNoLock?: boolean;
/**
* Disable mmap cache warmup after the initial scan. When mmap cache is
@@ -71,6 +90,18 @@ export interface InitOptions {
cacheBudgetMaxBytes?: number;
/** Override for the per-file byte cap in the content cache. */
cacheBudgetMaxFileSize?: number;
/**
* Allow indexing the filesystem root (`/`). Off by default root is
* rarely the intended target and floods the watcher with churn-prone
* events. Setting this true is opt-in and the caller is responsible for
* the resulting fs-event volume.
*/
enableFsRootScanning?: boolean;
/**
* Allow indexing the user's home directory. Same trade-off as
* `enableFsRootScanning`.
*/
enableHomeDirScanning?: boolean;
}
/**
@@ -91,6 +122,23 @@ export interface SearchOptions {
pageSize?: number;
}
/**
* Options for `glob`, the constraint-only search.
*
* The pattern is applied as a single pass SIMD optimized prefiltering
* without any fuzzy matching involved. Faster and 100% compatible to npm `glob`.
*/
export interface GlobOptions {
/** Maximum threads for parallel filtering (0 = auto). */
maxThreads?: number;
/** Current file path (for deprioritization in results). */
currentFile?: string;
/** Page index for pagination (default: 0). */
pageIndex?: number;
/** Page size for pagination (default: 100). */
pageSize?: number;
}
/**
* A file item in search results
*/
@@ -241,6 +289,10 @@ export interface ScanProgress {
scannedFilesCount: number;
/** Whether a scan is currently in progress */
isScanning: boolean;
/** Whether the background file watcher is ready */
isWatcherReady: boolean;
/** Whether the warmup/bigram phase has completed */
isWarmupComplete: boolean;
}
/**
@@ -358,14 +410,14 @@ export interface GrepOptions {
beforeContext?: number;
/** Number of context lines to include after each match (default: 0) */
afterContext?: number;
/** Maximum matches to return in this page across all files (default: 50) */
pageSize?: number;
/**
* When true, classify each match line as a code definition (struct/fn/class/...)
* and expose it via `GrepMatch.isDefinition`. Let callers re-rank defs first
* without a TS-side regex port. (default: false)
*/
classifyDefinitions?: boolean;
/** Maximum matches to return in this page across all files (default: 50) */
pageSize?: number;
}
/**
@@ -464,11 +516,96 @@ export interface MultiGrepOptions {
beforeContext?: number;
/** Number of context lines to include after each match (default: 0) */
afterContext?: number;
/** Maximum matches to return in this page across all files (default: 50) */
pageSize?: number;
/**
* When true, classify each match line as a code definition (struct/fn/class/...)
* and expose it via `GrepMatch.isDefinition`. (default: false)
*/
classifyDefinitions?: boolean;
/** Maximum matches to return in this page across all files (default: 50) */
pageSize?: number;
}
/**
* The shared instance surface implemented by `FileFinder` in both
* `@ff-labs/fff-node` and `@ff-labs/fff-bun`.
*
* Both packages must implement this identically. Only instance members belong
* here. Static helpers (`create`, `isAvailable`, `ensureLoaded`,
* `healthCheckStatic`) are package-specific and intentionally excluded.
*/
export interface FileFinderApi {
/** Whether the instance has been destroyed. */
readonly isDestroyed: boolean;
/** Destroy and free all native resources. */
destroy(): void;
/** Fuzzy file search. */
fileSearch(query: string, options?: SearchOptions): Result<SearchResult>;
/** Glob-only filtering (no fuzzy matching). */
glob(pattern: string, options?: GlobOptions): Result<SearchResult>;
/** Fuzzy directory search. */
directorySearch(query: string, options?: DirSearchOptions): Result<DirSearchResult>;
/** Fuzzy search over files and directories interleaved by score. */
mixedSearch(query: string, options?: SearchOptions): Result<MixedSearchResult>;
/** Content search (live grep). */
grep(query: string, options?: GrepOptions): Result<GrepResult>;
/** Multi-pattern OR content search (Aho-Corasick). */
multiGrep(options: MultiGrepOptions): Result<GrepResult>;
/** Trigger an async rescan of the indexed directory. */
scanFiles(): Result<void>;
/** Whether a scan is currently in progress. */
isScanning(): boolean;
/** The root directory being indexed. */
getBasePath(): Result<string | null>;
/** Current scan progress snapshot. */
getScanProgress(): Result<ScanProgress>;
/**
* Wait for the initial file scan to complete.
*
* Non-blocking: polls `isScanning` and yields to the event loop between
* checks, so other async work keeps running while waiting.
*/
waitForScan(timeoutMs?: number): Promise<Result<boolean>>;
/**
* Wait for the initial file scan to complete, blocking the calling thread.
*
* Backed by the native `fff_wait_for_scan` call. Prefer `waitForScan` unless
* you specifically need synchronous blocking behaviour.
*/
waitForScanBlocking(timeoutMs?: number): Result<boolean>;
/**
* Wait until the index is fully ready: the scan has finished and the warmup
* (content indexing / bigram) phase has completed.
*
* Non-blocking: polls `getScanProgress` and yields to the event loop.
*/
waitForIndexReady(timeoutMs?: number): Promise<Result<boolean>>;
/** Restart indexing in a new directory. */
reindex(newPath: string): Result<void>;
/** Refresh the git status cache. Returns the number of updated files. */
refreshGitStatus(): Result<number>;
/** Record that `selectedFilePath` was chosen for `query`. */
trackQuery(query: string, selectedFilePath: string): Result<boolean>;
/** Get a historical query by offset (0 = most recent). */
getHistoricalQuery(offset: number): Result<string | null>;
/** Health/diagnostics information for this instance. */
healthCheck(testPath?: string): Result<HealthCheck>;
}
+5
View File
@@ -204,6 +204,11 @@ describe('programmatic search APIs', function()
fd:write('-- ' .. marker .. '\n')
fd:close()
-- on windows metadata cache is updated lazily if we programmatically start searching
-- the new directory right after we update it we can sometimes see a race window especially on CI
-- this is fine in practice cause this is not a real use case for fff to search RIGHT AFTER mkdir
if vim.fn.has('win32') == 1 then vim.wait(250, function() return false end) end
-- Marker must not exist anywhere in the primary fff.nvim tree.
local before = fff.content_search(marker)
assert.are.equal(0, #before.items, 'marker leaked into primary fff.nvim tree')