Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b72d024016 | |||
| 5bdc727e6d | |||
| 394a4dcb68 | |||
| 3e20e93d91 | |||
| 3bf2eea002 | |||
| ccb1b9d0c8 |
@@ -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
@@ -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
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
// }
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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.
@@ -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)
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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, {})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user