diff --git a/.github/workflows/external-tests.yml b/.github/workflows/external-tests.yml index 7c21232c7..8cf071f4d 100644 --- a/.github/workflows/external-tests.yml +++ b/.github/workflows/external-tests.yml @@ -80,7 +80,7 @@ jobs: - name: Build Rust binary if: ${{ !matrix.target }} - run: cargo build --release -p fff-nvim --no-default-features --features zlob + run: make build - name: Install Neovim uses: rhysd/action-setup-vim@v1 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 519f48bce..5a524a577 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -84,6 +84,13 @@ jobs: - name: Install Rust run: rustup target add ${{ matrix.target }} + # Cache the per-target build dir and cargo-zigbuild binary. Keyed by + # target so matrix legs don't collide. See issue on slow release CI. + - name: Rust cache + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + with: + key: nvim-${{ matrix.target }} + - name: Install Zig uses: mlugg/setup-zig@v2 with: @@ -215,6 +222,11 @@ jobs: - name: Install Rust run: rustup target add ${{ matrix.target }} + - name: Rust cache + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + with: + key: c-${{ matrix.target }} + - name: Install Zig uses: mlugg/setup-zig@v2 with: @@ -328,6 +340,11 @@ jobs: - name: Install Rust run: rustup target add ${{ matrix.target }} + - name: Rust cache + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + with: + key: mcp-${{ matrix.target }} + - name: Install Zig uses: mlugg/setup-zig@v2 with: diff --git a/Cargo.toml b/Cargo.toml index e333fd1e5..be9b35ec1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,9 @@ members = [ resolver = "2" +[workspace.lints.clippy] +module_inception = "allow" + [workspace.dependencies] fff-grep = { version = "0.9.6", path = "crates/fff-grep" } fff-query-parser = { version = "0.9.6", path = "crates/fff-query-parser", default-features = false } diff --git a/Makefile b/Makefile index a3004f483..b26783b07 100644 --- a/Makefile +++ b/Makefile @@ -99,7 +99,7 @@ SMOKE_BIN := $(TARGET_DIR)/fff_c_smoke SMOKE_SRC := crates/fff-c/tests/smoke.c SMOKE_INCLUDE := crates/fff-c/include -test-c-smoke: build-c-lib +test-c-smoke: build $(CC) $(CFLAGS) -I $(SMOKE_INCLUDE) -L $(TARGET_DIR) \ -Wl,-rpath,@loader_path/../target/release \ -Wl,-rpath,$$(pwd)/$(TARGET_DIR) \ @@ -197,7 +197,7 @@ test-bun-compile: prepare-bun-packaged rm -f packages/fff-bun/glob-bench-bin packages/fff-bun/glob-bench-bin.exe test-node: prepare-node - cd packages/fff-node && npm run build && node test/e2e.mjs + cd packages/fff-node && npm run build && node test/e2e.mjs && node test/watch.mjs test-js: test-bun test-node diff --git a/crates/fff-c/Cargo.toml b/crates/fff-c/Cargo.toml index 72aea2c4c..4add9d3fa 100644 --- a/crates/fff-c/Cargo.toml +++ b/crates/fff-c/Cargo.toml @@ -5,6 +5,9 @@ edition = "2024" description = "Raw C api of FFF file finder" license = "MIT" +[lints] +workspace = true + [lib] crate-type = ["cdylib"] diff --git a/crates/fff-c/include/fff.h b/crates/fff-c/include/fff.h index 8573cf908..83df6c38c 100644 --- a/crates/fff-c/include/fff.h +++ b/crates/fff-c/include/fff.h @@ -14,6 +14,11 @@ */ #define FFF_CREATE_OPTIONS_VERSION 2 +/** + * Current version of [`FffWatchOptions`]. + */ +#define FFF_WATCH_OPTIONS_VERSION 1 + /** * Result envelope returned by all `fff_*` functions. * @@ -62,13 +67,13 @@ typedef struct 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! + * Versioned struct: the layout is stable across releases, new fields are + * only appended. */ typedef struct FffCreateOptions { /** - * Set to [`FFF_CREATE_OPTIONS_VERSION`] when allocating. Used by the - * library to determine which trailing fields are populated. + * Set to [`FFF_CREATE_OPTIONS_VERSION`] when allocating; tells the + * library which trailing fields are populated. */ uint32_t version; /** @@ -121,28 +126,24 @@ typedef struct FffCreateOptions { */ 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. + * Allow indexing the filesystem root (`/`). Off by default: root is rarely + * intended 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`. + * Allow indexing the user's home directory. Same trade-off as `enable_fs_root_scanning`. */ bool enable_home_dir_scanning; /** - * Follow symlinks during scan and watcher walks. Off by default — - * enabling this without external loop protection can wedge the watcher - * on cyclic symlink graphs. Caller is responsible for the trade-off. + * Follow symlinks during scan and watcher walks. Off by default: without + * external loop protection cyclic symlinks can wedge the watcher. */ bool follow_symlinks; } FffCreateOptions; /** - * A file item returned by `fff_search`. - * - * All string fields are heap-allocated and owned by the parent `FffSearchResult`. - * Free the entire result with `fff_free_search_result`. + * A file item returned by `fff_search`. Strings are owned by the parent + * `FffSearchResult`; free everything with `fff_free_search_result`. */ typedef struct FffFileItem { char *relative_path; @@ -174,13 +175,9 @@ typedef struct FffScore { } FffScore; /** - * Location parsed from a query string (e.g. `"file.ts:42:10"`). - * - * `tag` encodes the variant: - * 0 = no location, - * 1 = line only (`line` is set), - * 2 = position (`line` + `col`), - * 3 = range (`line`/`col` = start, `end_line`/`end_col` = end). + * Location parsed from a query string (e.g. `"file.ts:42:10"`). `tag`: + * 0 = none, 1 = line, 2 = position (`line` + `col`), + * 3 = range (`line`/`col` = start, `end_line`/`end_col` = end). */ typedef struct FffLocation { uint8_t tag; @@ -191,17 +188,15 @@ typedef struct FffLocation { } FffLocation; /** - * Search result returned by `fff_search`. - * - * The caller must free this with `fff_free_search_result`. + * Search result returned by `fff_search`; free with `fff_free_search_result`. */ typedef struct FffSearchResult { /** - * Pointer to a heap-allocated array of `FffFileItem` (length = `count`). + * Heap array of `FffFileItem` (length = `count`). */ struct FffFileItem *items; /** - * Pointer to a heap-allocated array of `FffScore` (length = `count`). + * Heap array of `FffScore` (length = `count`). */ struct FffScore *scores; /** @@ -231,10 +226,8 @@ typedef struct FffMatchRange { } FffMatchRange; /** - * A single grep match with file and line information. - * - * All string fields and arrays are heap-allocated. Free the parent - * `FffGrepResult` with `fff_free_grep_result` to release everything. + * A single grep match with file and line information. Strings and arrays are + * owned by the parent `FffGrepResult`; free everything with `fff_free_grep_result`. */ typedef struct FffGrepMatch { char *relative_path; @@ -262,13 +255,12 @@ typedef struct FffGrepMatch { } FffGrepMatch; /** - * Grep result returned by `fff_live_grep` and `fff_multi_grep`. - * - * The caller must free this with `fff_free_grep_result`. + * Grep result returned by `fff_live_grep` and `fff_multi_grep`; + * free with `fff_free_grep_result`. */ typedef struct FffGrepResult { /** - * Pointer to a heap-allocated array of `FffGrepMatch` (length = `count`). + * Heap array of `FffGrepMatch` (length = `count`). */ struct FffGrepMatch *items; /** @@ -313,10 +305,8 @@ typedef struct FffScanProgress { } FffScanProgress; /** - * A directory item returned by `fff_search_directories`. - * - * All string fields are heap-allocated and owned by the parent `FffDirSearchResult`. - * Free the entire result with `fff_free_dir_search_result`. + * A directory item returned by `fff_search_directories`. Strings are owned by + * the parent `FffDirSearchResult`; free everything with `fff_free_dir_search_result`. */ typedef struct FffDirItem { char *relative_path; @@ -325,17 +315,16 @@ typedef struct FffDirItem { } FffDirItem; /** - * Directory search result returned by `fff_search_directories`. - * - * The caller must free this with `fff_free_dir_search_result`. + * Directory search result returned by `fff_search_directories`; + * free with `fff_free_dir_search_result`. */ typedef struct FffDirSearchResult { /** - * Pointer to a heap-allocated array of `FffDirItem` (length = `count`). + * Heap array of `FffDirItem` (length = `count`). */ struct FffDirItem *items; /** - * Pointer to a heap-allocated array of `FffScore` (length = `count`). + * Heap array of `FffScore` (length = `count`). */ struct FffScore *scores; /** @@ -354,9 +343,8 @@ typedef struct FffDirSearchResult { /** * A single item in a mixed (files + directories) search result. - * - * `item_type`: 0 = file, 1 = directory. - * All string fields are heap-allocated and owned by the parent `FffMixedSearchResult`. + * `item_type`: 0 = file, 1 = directory. Strings are owned by the parent + * `FffMixedSearchResult`. */ typedef struct FffMixedItem { /** @@ -372,8 +360,7 @@ typedef struct FffMixedItem { uint64_t size; uint64_t modified; /** - * The access frecency score for files, or max access frecency among all the immediate - * children for directories. + * Access frecency for files; max among immediate children for directories. */ int64_t access_frecency_score; /** @@ -391,17 +378,16 @@ typedef struct FffMixedItem { } FffMixedItem; /** - * Mixed search result returned by `fff_search_mixed`. - * - * The caller must free this with `fff_free_mixed_search_result`. + * Mixed search result returned by `fff_search_mixed` + * free with `fff_free_mixed_search_result`. */ typedef struct FffMixedSearchResult { /** - * Pointer to a heap-allocated array of `FffMixedItem` (length = `count`). + * Heap array of `FffMixedItem` (length = `count`). */ struct FffMixedItem *items; /** - * Pointer to a heap-allocated array of `FffScore` (length = `count`). + * Heap array of `FffScore` (length = `count`). */ struct FffScore *scores; /** @@ -426,14 +412,56 @@ typedef struct FffMixedSearchResult { struct FffLocation location; } FffMixedSearchResult; +/** + * A single watch event. `kind`: 0 = created, 1 = modified, 2 = removed, + * 3 = rescan (events were lost; re-stat what you care about). + */ +typedef struct FffWatchEvent { + /** + * Absolute path (heap C string owned by the parent batch). + */ + char *path; + uint8_t kind; +} FffWatchEvent; + +/** + * A batch of watch events. Free with `fff_free_watch_events`. + */ +typedef struct FffWatchEventBatch { + struct FffWatchEvent *events; + uint32_t count; +} FffWatchEventBatch; + +/** + * Instance-wide callback invoked with `(watch_id, batch)` for every `fff_watch` + * subscription. The callee owns and frees `batch` via `fff_free_watch_events`. + */ +typedef void (*FffWatchCallback)(uint64_t watch_id, + struct FffWatchEventBatch *batch, + void *user_data); + +/** + * Options for `fff_watch`. Versioned: new fields are only appended. + */ +typedef struct FffWatchOptions { + /** + * Set to [`FFF_WATCH_OPTIONS_VERSION`] when allocating. + */ + uint32_t version; + /** + * Per-subscription excludes (parcel-watcher style): entries with wildcards + * are base-relative globs, entries without are path prefixes. NULL when + * `ignore_count` is 0. + */ + const char *const *ignore; + uint32_t ignore_count; +} FffWatchOptions; + /** * Create a new file finder instance (legacy 8-arg positional signature). * - * @deprecated Use [`fff_create_instance_with`] (or - * [`fff_create_instance_with_value`] for FFI bindings) — both take the - * versioned [`FffCreateOptions`] struct that evolves without ABI breaks. - * This function delegates to `fff_create_instance_with` internally; the - * `use_unsafe_no_lock` parameter is deprecated and ignored. + * @deprecated Use [`fff_create_instance_with`] (or [`fff_create_instance_with_value`] + * for FFI bindings). The `use_unsafe_no_lock` parameter is ignored. * * ## Safety * See `fff_create_instance_with`. @@ -451,10 +479,8 @@ struct FffResult *fff_create_instance(const char *base_path, /** * Create a new file finder instance (legacy 13-arg positional signature). * - * @deprecated Use [`fff_create_instance_with`] (or - * [`fff_create_instance_with_value`] for FFI bindings) — both take the - * versioned [`FffCreateOptions`] struct that evolves without ABI breaks. - * The `use_unsafe_no_lock` parameter is deprecated and ignored. + * @deprecated Use [`fff_create_instance_with`] (or [`fff_create_instance_with_value`] + * for FFI bindings). The `use_unsafe_no_lock` parameter is ignored. * * ## Safety * See `fff_create_instance_with`. @@ -475,22 +501,14 @@ struct FffResult *fff_create_instance2(const char *base_path, 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. + * Create a new file finder instance from a versioned [`FffCreateOptions`] struct. * - * **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. + * Populate the struct, set `version` to [`FFF_CREATE_OPTIONS_VERSION`], pass by + * pointer. New fields are only appended; older `version` values keep working. + * FFI bindings needing struct-by-value should use [`fff_create_instance_with_value`]. * - * Required: `opts.base_path` must be non-NULL and non-empty. - * - * When all three `cache_budget_*` values are 0 the budget is auto-computed - * from repo size after the initial scan. Otherwise an explicit budget is - * used: any field left at 0 falls back to its `unlimited()` default. + * `opts.base_path` is required (non-NULL, non-empty). Zero `cache_budget_*` + * values are auto-computed from repo size after the initial scan. * * ## Safety * * `opts` must be a valid pointer to an `FffCreateOptions` whose `version` @@ -501,16 +519,8 @@ struct FffResult *fff_create_instance2(const char *base_path, 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. + * [`fff_create_instance_with`] adapter taking [`FffCreateOptions`] **by value**, + * for FFI libraries that pass native structs by value (e.g. Node's `ffi-rs`). * * ## Safety * All `*const c_char` fields inside `opts` must be valid null-terminated @@ -529,16 +539,9 @@ void fff_destroy(void *fff_handle); /** * Perform fuzzy search on indexed files. * - * # Parameters - * - * * `fff_handle` – instance from `fff_create_instance` - * * `query` – search query string - * * `current_file` – path of the currently open file for deprioritization (NULL/empty to skip) - * * `max_threads` – maximum worker threads (0 = auto-detect) - * * `page_index` – pagination offset (0 = first page) - * * `page_size` – results per page (0 = default 100) - * * `combo_boost_multiplier` – score multiplier for combo matches (0 = default 100) - * * `min_combo_count` – minimum combo count before boost applies (0 = default 3) + * `current_file` deprioritizes the currently open file (NULL/empty to skip). + * Zero picks the default: `max_threads` auto, `page_size` 100, + * `combo_boost_multiplier` 100, `min_combo_count` 3. * * ## Safety * * `fff_handle` must be a valid instance pointer from `fff_create_instance`. @@ -554,22 +557,11 @@ struct FffResult *fff_search(void *fff_handle, 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. + * Glob-only search: filter indexed files by a single glob pattern (passed + * through verbatim, no query parsing), rank by frecency, and paginate. * - * # Parameters - * - * * `fff_handle` - instance from `fff_create_instance` - * * `pattern` - glob pattern (required, no parsing - passed through verbatim) - * * `current_file` - path of the currently open file for deprioritization (NULL/empty to skip) - * * `max_threads` - maximum worker threads (0 = auto-detect) - * * `page_index` - pagination offset (0 = first page) - * * `page_size` - results per page (0 = default 100) + * `current_file` deprioritizes the currently open file (NULL/empty to skip). + * Zero picks the default: `max_threads` auto, `page_size` 100. * * ## Safety * * `fff_handle` must be a valid instance pointer from `fff_create_instance`. @@ -585,14 +577,8 @@ struct FffResult *fff_glob(void *fff_handle, /** * Perform fuzzy search on indexed directories. * - * # Parameters - * - * * `fff_handle` – instance from `fff_create_instance` - * * `query` – search query string - * * `current_file` – path of the currently open file for distance scoring (NULL/empty to skip) - * * `max_threads` – maximum worker threads (0 = auto-detect) - * * `page_index` – pagination offset (0 = first page) - * * `page_size` – results per page (0 = default 100) + * `current_file` is used for distance scoring (NULL/empty to skip). + * Zero picks the default: `max_threads` auto, `page_size` 100. * * ## Safety * * `fff_handle` must be a valid instance pointer from `fff_create_instance`. @@ -608,20 +594,8 @@ struct FffResult *fff_search_directories(void *fff_handle, /** * Perform a mixed fuzzy search across both files and directories. * - * Returns a single flat list where files and directories are interleaved - * by total score in descending order. Each item has an `item_type` field - * (0 = file, 1 = directory). - * - * # Parameters - * - * * `fff_handle` – instance from `fff_create_instance` - * * `query` – search query string - * * `current_file` – path of the currently open file (NULL/empty to skip) - * * `max_threads` – maximum worker threads (0 = auto-detect) - * * `page_index` – pagination offset (0 = first page) - * * `page_size` – results per page (0 = default 100) - * * `combo_boost_multiplier` – score multiplier for combo matches (0 = default 100) - * * `min_combo_count` – minimum combo count before boost applies (0 = default 3) + * Returns one flat list interleaved by descending total score; each item's + * `item_type` is 0 = file, 1 = directory. Parameters as in [`fff_search`]. * * ## Safety * * `fff_handle` must be a valid instance pointer from `fff_create_instance`. @@ -639,20 +613,11 @@ struct FffResult *fff_search_mixed(void *fff_handle, /** * Perform content search (grep) across indexed files. * - * # Parameters - * - * * `fff_handle` – instance from `fff_create_instance` - * * `query` – search query (supports constraint syntax like `*.rs pattern`) - * * `mode` – 0 = plain text (SIMD), 1 = regex, 2 = fuzzy - * * `max_file_size` – skip files larger than this in bytes (0 = default 10 MB) - * * `max_matches_per_file` – max matches per file (0 = unlimited) - * * `smart_case` – case-insensitive when query is all lowercase - * * `file_offset` – file-based pagination offset (0 = start) - * * `page_limit` – max matches to return (0 = default 50) - * * `time_budget_ms` – wall-clock budget in ms (0 = unlimited) - * * `before_context` – context lines before each match - * * `after_context` – context lines after each match - * * `classify_definitions` – tag matches that are code definitions + * `query` supports constraint syntax like `*.rs pattern`; `mode` is + * 0 = plain text (SIMD), 1 = regex, 2 = fuzzy. Zero picks the default: + * `max_file_size` 10 MB, `page_limit` 50, `max_matches_per_file` and + * `time_budget_ms` unlimited. `smart_case` is case-insensitive for + * all-lowercase queries; `classify_definitions` tags code definitions. * * ## Safety * * `fff_handle` must be a valid instance pointer from `fff_create_instance`. @@ -672,25 +637,11 @@ struct FffResult *fff_live_grep(void *fff_handle, bool classify_definitions); /** - * Perform multi-pattern OR search (Aho-Corasick) across indexed files. + * Multi-pattern OR search (SIMD Aho-Corasick): lines matching ANY pattern. * - * Searches for lines matching ANY of the provided patterns using - * SIMD-accelerated multi-needle matching. - * - * # Parameters - * - * * `fff_handle` – instance from `fff_create_instance` - * * `patterns_joined` – patterns separated by `\n` (e.g. `"foo\nbar\nbaz"`) - * * `constraints` – file filter like `"*.rs"` or `"/src/"` (NULL/empty to skip) - * * `max_file_size` – skip files larger than this in bytes (0 = default 10 MB) - * * `max_matches_per_file` – max matches per file (0 = unlimited) - * * `smart_case` – case-insensitive when all patterns are lowercase - * * `file_offset` – file-based pagination offset (0 = start) - * * `page_limit` – max matches to return (0 = default 50) - * * `time_budget_ms` – wall-clock budget in ms (0 = unlimited) - * * `before_context` – context lines before each match - * * `after_context` – context lines after each match - * * `classify_definitions` – tag matches that are code definitions + * `patterns_joined` is `\n`-separated (e.g. `"foo\nbar"`); `constraints` is an + * optional file filter like `"*.rs"` or `"/src/"` (NULL/empty to skip). + * Remaining parameters as in [`fff_live_grep`]. * * ## Safety * * `fff_handle` must be a valid instance pointer from `fff_create_instance`. @@ -726,10 +677,8 @@ struct FffResult *fff_scan_files(void *fff_handle); bool fff_is_scanning(void *fff_handle); /** - * Get the base path of the file picker. - * - * Returns an `FffResult` with a heap-allocated C string in the `handle` - * field. Free the string with `fff_free_string` after reading it. + * Get the picker's base path as a heap C string in `handle`; + * free it with `fff_free_string`. * * ## Safety * `fff_handle` must be a valid instance pointer from `fff_create_instance`. @@ -805,10 +754,8 @@ struct FffResult *fff_get_historical_query(void *fff_handle, uint64_t offset); struct FffResult *fff_health_check(void *fff_handle, const char *test_path); /** - * Free a search result returned by `fff_search`. - * - * This frees the `FffSearchResult` struct, its `items` and `scores` arrays, - * and all heap-allocated strings within each item and score. + * Free a search result returned by `fff_search`: the struct, its `items` + * and `scores` arrays, and every string within. * * ## Safety * `result` must be a valid pointer previously returned via `FffResult.handle` @@ -817,10 +764,8 @@ struct FffResult *fff_health_check(void *fff_handle, const char *test_path); void fff_free_search_result(struct FffSearchResult *result); /** - * Get a pointer to the `index`-th `FffFileItem` in a search result. - * - * Returns null if `result` is null or `index >= result->count`. - * The returned pointer is valid until the search result is freed. + * Pointer to the `index`-th `FffFileItem`; null if `result` is null or + * `index >= count`. Valid until the search result is freed. * * ## Safety * `result` must be a valid `FffSearchResult` pointer from `fff_search`. @@ -829,10 +774,8 @@ const struct FffFileItem *fff_search_result_get_item(const struct FffSearchResul uint32_t index); /** - * Get a pointer to the `index`-th `FffScore` in a search result. - * - * Returns null if `result` is null or `index >= result->count`. - * The returned pointer is valid until the search result is freed. + * Pointer to the `index`-th `FffScore`; null if `result` is null or + * `index >= count`. Valid until the search result is freed. * * ## Safety * `result` must be a valid `FffSearchResult` pointer from `fff_search`. @@ -841,10 +784,8 @@ const struct FffScore *fff_search_result_get_score(const struct FffSearchResult uint32_t index); /** - * Free a grep result returned by `fff_live_grep` or `fff_multi_grep`. - * - * This frees the `FffGrepResult` struct, its `items` array, and all - * heap-allocated strings, match ranges, and context arrays within each match. + * Free a grep result returned by `fff_live_grep` or `fff_multi_grep`: + * the struct, its `items` array, and all strings/ranges/context within. * * ## Safety * `result` must be a valid pointer previously returned via `FffResult.handle` @@ -853,10 +794,8 @@ const struct FffScore *fff_search_result_get_score(const struct FffSearchResult void fff_free_grep_result(struct FffGrepResult *result); /** - * Get a pointer to the `index`-th `FffGrepMatch` in a grep result. - * - * Returns null if `result` is null or `index >= result->count`. - * The returned pointer is valid until the grep result is freed. + * Pointer to the `index`-th `FffGrepMatch`; null if `result` is null or + * `index >= count`. Valid until the grep result is freed. * * ## Safety * `result` must be a valid `FffGrepResult` pointer from `fff_live_grep` or `fff_multi_grep`. @@ -874,10 +813,8 @@ const struct FffGrepMatch *fff_grep_result_get_match(const struct FffGrepResult void fff_free_scan_progress(struct FffScanProgress *result); /** - * Offset a pointer by `byte_offset` bytes. - * - * General-purpose utility for FFI consumers that need pointer arithmetic - * (e.g. iterating over arrays). Returns null if `base` is null. + * Offset a pointer by `byte_offset` bytes (FFI array iteration helper). + * Returns null if `base` is null. * * ## Safety * The resulting pointer must be within the bounds of the original allocation. @@ -885,13 +822,9 @@ void fff_free_scan_progress(struct FffScanProgress *result); 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. + * Free a result envelope returned by any `fff_*` function. + * **IMPORTANT:** the `handle` payload is NOT freed release it separately + * using handle specific cleaning methods (`fff_destroy`, `fff_free_search_result`, etc.). * * ## Safety * `result_ptr` must be a valid pointer returned by a `fff_*` function. @@ -995,10 +928,7 @@ void *fff_result_get_handle(const struct FffResult *result); int64_t fff_result_get_int_value(const struct FffResult *result); /** - * Returns the relative path of a file item (e.g. `"src/main.rs"`). - * - * Returns null if `item` is null. The returned pointer is valid for the - * lifetime of the owning `FffSearchResult`; do not free it directly. + * Relative path of a file item (e.g. `"src/main.rs"`); null if `item` is null. Do not free. * * ## Safety * `item` must be a valid `FffFileItem` pointer or null. @@ -1006,9 +936,7 @@ int64_t fff_result_get_int_value(const struct FffResult *result); const char *fff_file_item_get_relative_path(const struct FffFileItem *item); /** - * Returns the file-name component of a file item (e.g. `"main.rs"`). - * - * Returns null if `item` is null. Do not free the returned pointer. + * File-name component of a file item (e.g. `"main.rs"`); null if `item` is null. Do not free. * * ## Safety * `item` must be a valid `FffFileItem` pointer or null. @@ -1016,10 +944,8 @@ const char *fff_file_item_get_relative_path(const struct FffFileItem *item); const char *fff_file_item_get_file_name(const struct FffFileItem *item); /** - * Returns the git status string for a file item (e.g. `"M "`, `"??"`) - * or null if git is unavailable, the file is untracked, or `item` is null. - * - * Do not free the returned pointer. + * Git status string of a file item (e.g. `"M "`, `"??"`); null if git is unavailable, + * the file is untracked, or `item` is null. Do not free. * * ## Safety * `item` must be a valid `FffFileItem` pointer or null. @@ -1027,7 +953,7 @@ const char *fff_file_item_get_file_name(const struct FffFileItem *item); const char *fff_file_item_get_git_status(const struct FffFileItem *item); /** - * Returns the file size in bytes. Returns `0` if `item` is null. + * File size in bytes; `0` if `item` is null. * * ## Safety * `item` must be a valid `FffFileItem` pointer or null. @@ -1035,8 +961,7 @@ const char *fff_file_item_get_git_status(const struct FffFileItem *item); uint64_t fff_file_item_get_size(const struct FffFileItem *item); /** - * Returns the last-modified time as seconds since the UNIX epoch. - * Returns `0` if `item` is null. + * Last-modified time as seconds since the UNIX epoch; `0` if `item` is null. * * ## Safety * `item` must be a valid `FffFileItem` pointer or null. @@ -1044,7 +969,7 @@ uint64_t fff_file_item_get_size(const struct FffFileItem *item); uint64_t fff_file_item_get_modified(const struct FffFileItem *item); /** - * Returns the combined frecency score. Returns `0` if `item` is null. + * Combined frecency score; `0` if `item` is null. * * ## Safety * `item` must be a valid `FffFileItem` pointer or null. @@ -1052,7 +977,7 @@ uint64_t fff_file_item_get_modified(const struct FffFileItem *item); int64_t fff_file_item_get_total_frecency_score(const struct FffFileItem *item); /** - * Returns the access-based frecency score. Returns `0` if `item` is null. + * Access-based frecency score; `0` if `item` is null. * * ## Safety * `item` must be a valid `FffFileItem` pointer or null. @@ -1060,7 +985,7 @@ int64_t fff_file_item_get_total_frecency_score(const struct FffFileItem *item); int64_t fff_file_item_get_access_frecency_score(const struct FffFileItem *item); /** - * Returns the modification-based frecency score. Returns `0` if `item` is null. + * Modification-based frecency score; `0` if `item` is null. * * ## Safety * `item` must be a valid `FffFileItem` pointer or null. @@ -1068,7 +993,7 @@ int64_t fff_file_item_get_access_frecency_score(const struct FffFileItem *item); int64_t fff_file_item_get_modification_frecency_score(const struct FffFileItem *item); /** - * Returns `true` if the file was detected as binary. Returns `false` if `item` is null. + * `true` if the file was detected as binary; `false` if `item` is null. * * ## Safety * `item` must be a valid `FffFileItem` pointer or null. @@ -1076,9 +1001,7 @@ int64_t fff_file_item_get_modification_frecency_score(const struct FffFileItem * bool fff_file_item_get_is_binary(const struct FffFileItem *item); /** - * Returns the relative path of the file containing this grep match. - * - * Returns null if `m` is null. Do not free the returned pointer. + * Relative path of the file containing this grep match; null if `m` is null. Do not free. * * ## Safety * `m` must be a valid `FffGrepMatch` pointer or null. @@ -1086,9 +1009,7 @@ bool fff_file_item_get_is_binary(const struct FffFileItem *item); const char *fff_grep_match_get_relative_path(const struct FffGrepMatch *m); /** - * Returns the file-name component of the file containing this grep match. - * - * Returns null if `m` is null. Do not free the returned pointer. + * File-name component of the file containing this grep match; null if `m` is null. Do not free. * * ## Safety * `m` must be a valid `FffGrepMatch` pointer or null. @@ -1096,10 +1017,8 @@ const char *fff_grep_match_get_relative_path(const struct FffGrepMatch *m); const char *fff_grep_match_get_file_name(const struct FffGrepMatch *m); /** - * Returns the git status string for the matched file (e.g. `"M "`, `"??"`) - * or null if git is unavailable, the file is untracked, or `m` is null. - * - * Do not free the returned pointer. + * Git status string of the matched file (e.g. `"M "`, `"??"`); null if git is unavailable, + * the file is untracked, or `m` is null. Do not free. * * ## Safety * `m` must be a valid `FffGrepMatch` pointer or null. @@ -1107,9 +1026,7 @@ const char *fff_grep_match_get_file_name(const struct FffGrepMatch *m); const char *fff_grep_match_get_git_status(const struct FffGrepMatch *m); /** - * Returns the full text content of the matched line. - * - * Returns null if `m` is null. Do not free the returned pointer. + * Full text content of the matched line; null if `m` is null. Do not free. * * ## Safety * `m` must be a valid `FffGrepMatch` pointer or null. @@ -1117,8 +1034,7 @@ const char *fff_grep_match_get_git_status(const struct FffGrepMatch *m); const char *fff_grep_match_get_line_content(const struct FffGrepMatch *m); /** - * Returns the 1-based line number of the match within its file. - * Returns `0` if `m` is null. + * 1-based line number of the match within its file; `0` if `m` is null. * * ## Safety * `m` must be a valid `FffGrepMatch` pointer or null. @@ -1126,8 +1042,7 @@ const char *fff_grep_match_get_line_content(const struct FffGrepMatch *m); uint64_t fff_grep_match_get_line_number(const struct FffGrepMatch *m); /** - * Returns the 0-based column of the match start within its line. - * Returns `0` if `m` is null. + * 0-based column of the match start within its line; `0` if `m` is null. * * ## Safety * `m` must be a valid `FffGrepMatch` pointer or null. @@ -1135,8 +1050,7 @@ uint64_t fff_grep_match_get_line_number(const struct FffGrepMatch *m); uint32_t fff_grep_match_get_col(const struct FffGrepMatch *m); /** - * Returns the byte offset of the match start from the beginning of the file. - * Returns `0` if `m` is null. + * Byte offset of the match start from the beginning of the file; `0` if `m` is null. * * ## Safety * `m` must be a valid `FffGrepMatch` pointer or null. @@ -1144,7 +1058,7 @@ uint32_t fff_grep_match_get_col(const struct FffGrepMatch *m); uint64_t fff_grep_match_get_byte_offset(const struct FffGrepMatch *m); /** - * Returns the file size in bytes for the matched file. Returns `0` if `m` is null. + * File size in bytes of the matched file; `0` if `m` is null. * * ## Safety * `m` must be a valid `FffGrepMatch` pointer or null. @@ -1152,8 +1066,7 @@ uint64_t fff_grep_match_get_byte_offset(const struct FffGrepMatch *m); uint64_t fff_grep_match_get_size(const struct FffGrepMatch *m); /** - * Returns the combined frecency score for the matched file. - * Returns `0` if `m` is null. + * Combined frecency score of the matched file; `0` if `m` is null. * * ## Safety * `m` must be a valid `FffGrepMatch` pointer or null. @@ -1161,8 +1074,7 @@ uint64_t fff_grep_match_get_size(const struct FffGrepMatch *m); int64_t fff_grep_match_get_total_frecency_score(const struct FffGrepMatch *m); /** - * Returns the access-based frecency score for the matched file. - * Returns `0` if `m` is null. + * Access-based frecency score of the matched file; `0` if `m` is null. * * ## Safety * `m` must be a valid `FffGrepMatch` pointer or null. @@ -1170,8 +1082,7 @@ int64_t fff_grep_match_get_total_frecency_score(const struct FffGrepMatch *m); int64_t fff_grep_match_get_access_frecency_score(const struct FffGrepMatch *m); /** - * Returns the modification-based frecency score for the matched file. - * Returns `0` if `m` is null. + * Modification-based frecency score of the matched file; `0` if `m` is null. * * ## Safety * `m` must be a valid `FffGrepMatch` pointer or null. @@ -1179,8 +1090,7 @@ int64_t fff_grep_match_get_access_frecency_score(const struct FffGrepMatch *m); int64_t fff_grep_match_get_modification_frecency_score(const struct FffGrepMatch *m); /** - * Returns the last-modified time as seconds since the UNIX epoch for the matched file. - * Returns `0` if `m` is null. + * Last-modified time of the matched file as seconds since the UNIX epoch; `0` if `m` is null. * * ## Safety * `m` must be a valid `FffGrepMatch` pointer or null. @@ -1188,8 +1098,7 @@ int64_t fff_grep_match_get_modification_frecency_score(const struct FffGrepMatch uint64_t fff_grep_match_get_modified(const struct FffGrepMatch *m); /** - * Returns the number of highlight ranges in this match. Returns `0` if `m` is null. - * + * Number of highlight ranges in this match; `0` if `m` is null. * Use with [`fff_grep_match_get_match_range`] to iterate the highlight spans. * * ## Safety @@ -1198,11 +1107,8 @@ uint64_t fff_grep_match_get_modified(const struct FffGrepMatch *m); uint32_t fff_grep_match_get_match_ranges_count(const struct FffGrepMatch *m); /** - * Returns a pointer to the `index`-th [`FffMatchRange`] highlight span. - * - * Returns null if `m` is null, `index >= match_ranges_count`, or the - * ranges array is null. The returned pointer is valid until the owning - * `FffGrepResult` is freed; do not free it directly. + * Pointer to the `index`-th [`FffMatchRange`] highlight span; null if `m` is null, + * `index >= match_ranges_count`, or the ranges array is null. Valid until the owning `FffGrepResult` is freed; do not free. * * ## Safety * `m` must be a valid `FffGrepMatch` pointer or null. @@ -1211,9 +1117,7 @@ const struct FffMatchRange *fff_grep_match_get_match_range(const struct FffGrepM uint32_t index); /** - * Returns the number of context lines captured before the match. - * Returns `0` if `m` is null. - * + * Number of context lines captured before the match; `0` if `m` is null. * Use with [`fff_grep_match_get_context_before`] to read each line. * * ## Safety @@ -1222,10 +1126,8 @@ const struct FffMatchRange *fff_grep_match_get_match_range(const struct FffGrepM uint32_t fff_grep_match_get_context_before_count(const struct FffGrepMatch *m); /** - * Returns the `index`-th context line before the match. - * - * Returns null if `m` is null, `index >= context_before_count`, or the - * context array is null. Do not free the returned pointer. + * The `index`-th context line before the match; null if `m` is null, + * `index >= context_before_count`, or the context array is null. Do not free. * * ## Safety * `m` must be a valid `FffGrepMatch` pointer or null. @@ -1233,9 +1135,7 @@ uint32_t fff_grep_match_get_context_before_count(const struct FffGrepMatch *m); const char *fff_grep_match_get_context_before(const struct FffGrepMatch *m, uint32_t index); /** - * Returns the number of context lines captured after the match. - * Returns `0` if `m` is null. - * + * Number of context lines captured after the match; `0` if `m` is null. * Use with [`fff_grep_match_get_context_after`] to read each line. * * ## Safety @@ -1244,10 +1144,8 @@ const char *fff_grep_match_get_context_before(const struct FffGrepMatch *m, uint uint32_t fff_grep_match_get_context_after_count(const struct FffGrepMatch *m); /** - * Returns the `index`-th context line after the match. - * - * Returns null if `m` is null, `index >= context_after_count`, or the - * context array is null. Do not free the returned pointer. + * The `index`-th context line after the match; null if `m` is null, + * `index >= context_after_count`, or the context array is null. Do not free. * * ## Safety * `m` must be a valid `FffGrepMatch` pointer or null. @@ -1255,11 +1153,8 @@ uint32_t fff_grep_match_get_context_after_count(const struct FffGrepMatch *m); const char *fff_grep_match_get_context_after(const struct FffGrepMatch *m, uint32_t index); /** - * Returns the fuzzy match score. Returns `0` if `m` is null or no fuzzy - * score is present. - * - * Always check [`fff_grep_match_get_has_fuzzy_score`] first; `0` is - * ambiguous without that flag. + * Fuzzy match score; `0` if `m` is null or no fuzzy score is present. + * Always check [`fff_grep_match_get_has_fuzzy_score`] first; `0` is ambiguous without that flag. * * ## Safety * `m` must be a valid `FffGrepMatch` pointer or null. @@ -1267,8 +1162,7 @@ const char *fff_grep_match_get_context_after(const struct FffGrepMatch *m, uint3 uint16_t fff_grep_match_get_fuzzy_score(const struct FffGrepMatch *m); /** - * Returns `true` if this match carries a valid fuzzy score. - * Returns `false` if `m` is null. + * `true` if this match carries a valid fuzzy score; `false` if `m` is null. * * ## Safety * `m` must be a valid `FffGrepMatch` pointer or null. @@ -1276,8 +1170,7 @@ uint16_t fff_grep_match_get_fuzzy_score(const struct FffGrepMatch *m); bool fff_grep_match_get_has_fuzzy_score(const struct FffGrepMatch *m); /** - * Returns `true` if the match was identified as a symbol definition. - * Returns `false` if `m` is null. + * `true` if the match was identified as a symbol definition; `false` if `m` is null. * * ## Safety * `m` must be a valid `FffGrepMatch` pointer or null. @@ -1285,8 +1178,7 @@ bool fff_grep_match_get_has_fuzzy_score(const struct FffGrepMatch *m); bool fff_grep_match_get_is_definition(const struct FffGrepMatch *m); /** - * Returns `true` if the matched file was detected as binary. - * Returns `false` if `m` is null. + * `true` if the matched file was detected as binary; `false` if `m` is null. * * ## Safety * `m` must be a valid `FffGrepMatch` pointer or null. @@ -1294,7 +1186,7 @@ bool fff_grep_match_get_is_definition(const struct FffGrepMatch *m); bool fff_grep_match_get_is_binary(const struct FffGrepMatch *m); /** - * Returns the number of items in the result. Returns `0` if `r` is null. + * Number of items in the result; `0` if `r` is null. * * ## Safety * `r` must be a valid `FffSearchResult` pointer or null. @@ -1302,8 +1194,7 @@ bool fff_grep_match_get_is_binary(const struct FffGrepMatch *m); uint32_t fff_search_result_get_count(const struct FffSearchResult *r); /** - * Returns the total number of files that matched before the result was - * truncated to the page size. Returns `0` if `r` is null. + * Total number of files that matched before truncation to the page size; `0` if `r` is null. * * ## Safety * `r` must be a valid `FffSearchResult` pointer or null. @@ -1311,8 +1202,7 @@ uint32_t fff_search_result_get_count(const struct FffSearchResult *r); uint32_t fff_search_result_get_total_matched(const struct FffSearchResult *r); /** - * Returns the total number of indexed files considered during search. - * Returns `0` if `r` is null. + * Total number of indexed files considered during search; `0` if `r` is null. * * ## Safety * `r` must be a valid `FffSearchResult` pointer or null. @@ -1320,7 +1210,7 @@ uint32_t fff_search_result_get_total_matched(const struct FffSearchResult *r); uint32_t fff_search_result_get_total_files(const struct FffSearchResult *r); /** - * Returns the number of matches in the result. Returns `0` if `r` is null. + * Number of matches in the result; `0` if `r` is null. * * ## Safety * `r` must be a valid `FffGrepResult` pointer or null. @@ -1328,8 +1218,7 @@ uint32_t fff_search_result_get_total_files(const struct FffSearchResult *r); uint32_t fff_grep_result_get_count(const struct FffGrepResult *r); /** - * Returns the total number of matches found across all pages. - * Returns `0` if `r` is null. + * Total number of matches found across all pages; `0` if `r` is null. * * ## Safety * `r` must be a valid `FffGrepResult` pointer or null. @@ -1337,8 +1226,7 @@ uint32_t fff_grep_result_get_count(const struct FffGrepResult *r); uint32_t fff_grep_result_get_total_matched(const struct FffGrepResult *r); /** - * Returns the number of files actually opened and searched in this call. - * Returns `0` if `r` is null. + * Number of files actually opened and searched in this call; `0` if `r` is null. * * ## Safety * `r` must be a valid `FffGrepResult` pointer or null. @@ -1346,8 +1234,7 @@ uint32_t fff_grep_result_get_total_matched(const struct FffGrepResult *r); uint32_t fff_grep_result_get_total_files_searched(const struct FffGrepResult *r); /** - * Returns the total number of indexed files before any filtering. - * Returns `0` if `r` is null. + * Total number of indexed files before any filtering; `0` if `r` is null. * * ## Safety * `r` must be a valid `FffGrepResult` pointer or null. @@ -1355,8 +1242,7 @@ uint32_t fff_grep_result_get_total_files_searched(const struct FffGrepResult *r) uint32_t fff_grep_result_get_total_files(const struct FffGrepResult *r); /** - * Returns the number of files eligible for search after path/type filtering. - * Returns `0` if `r` is null. + * Number of files eligible for search after path/type filtering; `0` if `r` is null. * * ## Safety * `r` must be a valid `FffGrepResult` pointer or null. @@ -1364,9 +1250,8 @@ uint32_t fff_grep_result_get_total_files(const struct FffGrepResult *r); uint32_t fff_grep_result_get_filtered_file_count(const struct FffGrepResult *r); /** - * Returns the file offset for the next page, or `0` if all files have been - * searched or `r` is null. Pass this value as `file_offset` to a subsequent - * `fff_live_grep` or `fff_multi_grep` call to continue pagination. + * File offset for the next page; `0` if all files have been searched or `r` is null. + * Pass as `file_offset` to a subsequent `fff_live_grep`/`fff_multi_grep` call to continue pagination. * * ## Safety * `r` must be a valid `FffGrepResult` pointer or null. @@ -1374,14 +1259,100 @@ uint32_t fff_grep_result_get_filtered_file_count(const struct FffGrepResult *r); uint32_t fff_grep_result_get_next_file_offset(const struct FffGrepResult *r); /** - * Returns the regex compilation error string if the engine fell back to - * literal matching, or null if there was no error or `r` is null. - * - * Do not free the returned pointer. + * Regex compilation error string if the engine fell back to literal matching; + * null if there was no error or `r` is null. Do not free. * * ## Safety * `r` must be a valid `FffGrepResult` pointer or null. */ const char *fff_grep_result_get_regex_fallback_error(const struct FffGrepResult *r); +/** + * Register the instance-wide watch callback used by all `fff_watch` + * subscriptions; call before the first `fff_watch`, calling again replaces it. + * + * ## Safety + * * `fff_handle` must be a valid instance pointer from `fff_create_instance`. + * * `callback` must remain callable until fff_unwatch called + * `fff_destroy(fff_handle)` returns. + */ +struct FffResult *fff_set_watch_callback(void *fff_handle, + FffWatchCallback callback, + void *user_data); + +/** + * Subscribe to filesystem changes, delivered through the instance callback + * registered by `fff_set_watch_callback`. + * + * Returns the watch id, pass it to `fff_unwatch` to stop. + * + * `pattern` if non `NULL` can be wildcard pattern, absolute, or relative path + * that will be used to filter the events triggering exact subscription. + * + * ## Safety + * * `fff_handle` must be a valid instance pointer from `fff_create_instance`. + * * `pattern` must be NULL or valid null-terminated UTF-8. + * * `opts` must be NULL or a valid `FffWatchOptions` pointer. + */ +struct FffResult *fff_watch(void *fff_handle, + const char *pattern, + const struct FffWatchOptions *opts); + +/** + * [`fff_watch`] adapter with flattened options, for FFI libraries that cannot + * marshal pointer arrays inside structs (e.g. Node's `ffi-rs`). + * + * ## Safety + * * `fff_handle` must be a valid instance pointer from `fff_create_instance`. + * * `pattern` must be NULL (watch everything) or valid null-terminated UTF-8. + * * `ignore` must be NULL or point to `ignore_count` valid C strings. + */ +struct FffResult *fff_watch_args(void *fff_handle, + const char *pattern, + const char *const *ignore, + uint32_t ignore_count); + +/** + * Remove a watch subscription. `int_value` = 1 if the id existed, 0 otherwise. + * + * ## Safety + * `fff_handle` must be a valid instance pointer from `fff_create_instance`. + */ +struct FffResult *fff_unwatch(void *fff_handle, uint64_t watch_id); + +/** + * Number of events in a batch; 0 if `batch` is null. + * + * ## Safety + * `batch` must be a valid `FffWatchEventBatch` pointer or null. + */ +uint32_t fff_watch_events_count(const struct FffWatchEventBatch *batch); + +/** + * Absolute path of event `index`, will be null when out of bounds + * + * ## Safety + * `batch` must be a valid `FffWatchEventBatch` pointer or null. + */ +const char *fff_watch_events_get_path(const struct FffWatchEventBatch *batch, uint32_t index); + +/** + * Kind of event `index` (0 = created, 1 = modified, 2 = removed, 3 = rescan) + * 3 (rescan aka "re-stat something" kind) returned when OS based buffer + * has been overflown and some events might be loss. Paths will contain a list of + * directories that needs to be rescanned to ensure consistency. + * + * ## Safety + * `batch` must be a valid `FffWatchEventBatch` pointer or null. + */ +uint8_t fff_watch_events_get_kind(const struct FffWatchEventBatch *batch, uint32_t index); + +/** + * Free a watch event batch delivered to the instance callback. + * + * ## Safety + * `batch` must be a pointer produced by this library, or null (no-op). + */ +void fff_free_watch_events(struct FffWatchEventBatch *batch); + #endif /* FFF_C_H */ diff --git a/crates/fff-c/src/accessors.rs b/crates/fff-c/src/accessors.rs index 633df204c..84da47873 100644 --- a/crates/fff-c/src/accessors.rs +++ b/crates/fff-c/src/accessors.rs @@ -1,30 +1,7 @@ -//! Stable accessor functions for `fff-c` FFI struct fields. -//! -//! # Why this exists -//! -//! `fff-c` exposes its result types as plain `#[repr(C)]` structs. External -//! consumers (Emacs Lisp via `emacs-ffi`, Python `ctypes`, etc.) that access -//! fields by hardcoding byte offsets break silently whenever the struct layout -//! changes — a new field shifts every subsequent offset with no compile-time -//! warning. -//! -//! These functions turn field access into a **stable named API**: callers bind -//! to a symbol name once and are fully insulated from layout changes. -//! -//! # Usage from Emacs Lisp (example) -//! -//! ```elisp -//! (define-ffi-function fff--grep-match-line-content -//! "fff_grep_match_get_line_content" :pointer [:pointer] fff--library) -//! -//! (ffi-get-c-string (fff--grep-match-line-content match-ptr)) -//! ``` -//! -//! # Array iteration -//! -//! To walk result arrays use `fff_search_result_get_item`, -//! `fff_grep_result_get_match`, and `fff_search_result_get_score` — these are -//! defined in the main `lib.rs` FFI surface alongside the search functions. +//! Stable accessor functions for `fff-c` FFI struct fields: a named API so +//! FFI callers (Emacs Lisp, Python `ctypes`, etc.) don't hardcode struct byte +//! offsets that break silently on layout changes. For array iteration use +//! `fff_search_result_get_item` / `fff_grep_result_get_match` in `lib.rs`. use std::ffi::c_char; use std::ptr; @@ -87,10 +64,7 @@ pub unsafe extern "C" fn fff_result_get_int_value(result: *const FffResult) -> i // ── FffFileItem ────────────────────────────────────────────────────────────── -/// Returns the relative path of a file item (e.g. `"src/main.rs"`). -/// -/// Returns null if `item` is null. The returned pointer is valid for the -/// lifetime of the owning `FffSearchResult`; do not free it directly. +/// Relative path of a file item (e.g. `"src/main.rs"`); null if `item` is null. Do not free. /// /// ## Safety /// `item` must be a valid `FffFileItem` pointer or null. @@ -104,9 +78,7 @@ pub unsafe extern "C" fn fff_file_item_get_relative_path( unsafe { (*item).relative_path } } -/// Returns the file-name component of a file item (e.g. `"main.rs"`). -/// -/// Returns null if `item` is null. Do not free the returned pointer. +/// File-name component of a file item (e.g. `"main.rs"`); null if `item` is null. Do not free. /// /// ## Safety /// `item` must be a valid `FffFileItem` pointer or null. @@ -118,10 +90,8 @@ pub unsafe extern "C" fn fff_file_item_get_file_name(item: *const FffFileItem) - unsafe { (*item).file_name } } -/// Returns the git status string for a file item (e.g. `"M "`, `"??"`) -/// or null if git is unavailable, the file is untracked, or `item` is null. -/// -/// Do not free the returned pointer. +/// Git status string of a file item (e.g. `"M "`, `"??"`); null if git is unavailable, +/// the file is untracked, or `item` is null. Do not free. /// /// ## Safety /// `item` must be a valid `FffFileItem` pointer or null. @@ -133,7 +103,7 @@ pub unsafe extern "C" fn fff_file_item_get_git_status(item: *const FffFileItem) unsafe { (*item).git_status } } -/// Returns the file size in bytes. Returns `0` if `item` is null. +/// File size in bytes; `0` if `item` is null. /// /// ## Safety /// `item` must be a valid `FffFileItem` pointer or null. @@ -145,8 +115,7 @@ pub unsafe extern "C" fn fff_file_item_get_size(item: *const FffFileItem) -> u64 unsafe { (*item).size } } -/// Returns the last-modified time as seconds since the UNIX epoch. -/// Returns `0` if `item` is null. +/// Last-modified time as seconds since the UNIX epoch; `0` if `item` is null. /// /// ## Safety /// `item` must be a valid `FffFileItem` pointer or null. @@ -158,7 +127,7 @@ pub unsafe extern "C" fn fff_file_item_get_modified(item: *const FffFileItem) -> unsafe { (*item).modified } } -/// Returns the combined frecency score. Returns `0` if `item` is null. +/// Combined frecency score; `0` if `item` is null. /// /// ## Safety /// `item` must be a valid `FffFileItem` pointer or null. @@ -170,7 +139,7 @@ pub unsafe extern "C" fn fff_file_item_get_total_frecency_score(item: *const Fff unsafe { (*item).total_frecency_score } } -/// Returns the access-based frecency score. Returns `0` if `item` is null. +/// Access-based frecency score; `0` if `item` is null. /// /// ## Safety /// `item` must be a valid `FffFileItem` pointer or null. @@ -182,7 +151,7 @@ pub unsafe extern "C" fn fff_file_item_get_access_frecency_score(item: *const Ff unsafe { (*item).access_frecency_score } } -/// Returns the modification-based frecency score. Returns `0` if `item` is null. +/// Modification-based frecency score; `0` if `item` is null. /// /// ## Safety /// `item` must be a valid `FffFileItem` pointer or null. @@ -196,7 +165,7 @@ pub unsafe extern "C" fn fff_file_item_get_modification_frecency_score( unsafe { (*item).modification_frecency_score } } -/// Returns `true` if the file was detected as binary. Returns `false` if `item` is null. +/// `true` if the file was detected as binary; `false` if `item` is null. /// /// ## Safety /// `item` must be a valid `FffFileItem` pointer or null. @@ -210,9 +179,7 @@ pub unsafe extern "C" fn fff_file_item_get_is_binary(item: *const FffFileItem) - // ── FffGrepMatch ───────────────────────────────────────────────────────────── -/// Returns the relative path of the file containing this grep match. -/// -/// Returns null if `m` is null. Do not free the returned pointer. +/// Relative path of the file containing this grep match; null if `m` is null. Do not free. /// /// ## Safety /// `m` must be a valid `FffGrepMatch` pointer or null. @@ -224,9 +191,7 @@ pub unsafe extern "C" fn fff_grep_match_get_relative_path(m: *const FffGrepMatch unsafe { (*m).relative_path } } -/// Returns the file-name component of the file containing this grep match. -/// -/// Returns null if `m` is null. Do not free the returned pointer. +/// File-name component of the file containing this grep match; null if `m` is null. Do not free. /// /// ## Safety /// `m` must be a valid `FffGrepMatch` pointer or null. @@ -238,10 +203,8 @@ pub unsafe extern "C" fn fff_grep_match_get_file_name(m: *const FffGrepMatch) -> unsafe { (*m).file_name } } -/// Returns the git status string for the matched file (e.g. `"M "`, `"??"`) -/// or null if git is unavailable, the file is untracked, or `m` is null. -/// -/// Do not free the returned pointer. +/// Git status string of the matched file (e.g. `"M "`, `"??"`); null if git is unavailable, +/// the file is untracked, or `m` is null. Do not free. /// /// ## Safety /// `m` must be a valid `FffGrepMatch` pointer or null. @@ -253,9 +216,7 @@ pub unsafe extern "C" fn fff_grep_match_get_git_status(m: *const FffGrepMatch) - unsafe { (*m).git_status } } -/// Returns the full text content of the matched line. -/// -/// Returns null if `m` is null. Do not free the returned pointer. +/// Full text content of the matched line; null if `m` is null. Do not free. /// /// ## Safety /// `m` must be a valid `FffGrepMatch` pointer or null. @@ -267,8 +228,7 @@ pub unsafe extern "C" fn fff_grep_match_get_line_content(m: *const FffGrepMatch) unsafe { (*m).line_content } } -/// Returns the 1-based line number of the match within its file. -/// Returns `0` if `m` is null. +/// 1-based line number of the match within its file; `0` if `m` is null. /// /// ## Safety /// `m` must be a valid `FffGrepMatch` pointer or null. @@ -280,8 +240,7 @@ pub unsafe extern "C" fn fff_grep_match_get_line_number(m: *const FffGrepMatch) unsafe { (*m).line_number } } -/// Returns the 0-based column of the match start within its line. -/// Returns `0` if `m` is null. +/// 0-based column of the match start within its line; `0` if `m` is null. /// /// ## Safety /// `m` must be a valid `FffGrepMatch` pointer or null. @@ -293,8 +252,7 @@ pub unsafe extern "C" fn fff_grep_match_get_col(m: *const FffGrepMatch) -> u32 { unsafe { (*m).col } } -/// Returns the byte offset of the match start from the beginning of the file. -/// Returns `0` if `m` is null. +/// Byte offset of the match start from the beginning of the file; `0` if `m` is null. /// /// ## Safety /// `m` must be a valid `FffGrepMatch` pointer or null. @@ -306,7 +264,7 @@ pub unsafe extern "C" fn fff_grep_match_get_byte_offset(m: *const FffGrepMatch) unsafe { (*m).byte_offset } } -/// Returns the file size in bytes for the matched file. Returns `0` if `m` is null. +/// File size in bytes of the matched file; `0` if `m` is null. /// /// ## Safety /// `m` must be a valid `FffGrepMatch` pointer or null. @@ -318,8 +276,7 @@ pub unsafe extern "C" fn fff_grep_match_get_size(m: *const FffGrepMatch) -> u64 unsafe { (*m).size } } -/// Returns the combined frecency score for the matched file. -/// Returns `0` if `m` is null. +/// Combined frecency score of the matched file; `0` if `m` is null. /// /// ## Safety /// `m` must be a valid `FffGrepMatch` pointer or null. @@ -331,8 +288,7 @@ pub unsafe extern "C" fn fff_grep_match_get_total_frecency_score(m: *const FffGr unsafe { (*m).total_frecency_score } } -/// Returns the access-based frecency score for the matched file. -/// Returns `0` if `m` is null. +/// Access-based frecency score of the matched file; `0` if `m` is null. /// /// ## Safety /// `m` must be a valid `FffGrepMatch` pointer or null. @@ -344,8 +300,7 @@ pub unsafe extern "C" fn fff_grep_match_get_access_frecency_score(m: *const FffG unsafe { (*m).access_frecency_score } } -/// Returns the modification-based frecency score for the matched file. -/// Returns `0` if `m` is null. +/// Modification-based frecency score of the matched file; `0` if `m` is null. /// /// ## Safety /// `m` must be a valid `FffGrepMatch` pointer or null. @@ -359,8 +314,7 @@ pub unsafe extern "C" fn fff_grep_match_get_modification_frecency_score( unsafe { (*m).modification_frecency_score } } -/// Returns the last-modified time as seconds since the UNIX epoch for the matched file. -/// Returns `0` if `m` is null. +/// Last-modified time of the matched file as seconds since the UNIX epoch; `0` if `m` is null. /// /// ## Safety /// `m` must be a valid `FffGrepMatch` pointer or null. @@ -372,8 +326,7 @@ pub unsafe extern "C" fn fff_grep_match_get_modified(m: *const FffGrepMatch) -> unsafe { (*m).modified } } -/// Returns the number of highlight ranges in this match. Returns `0` if `m` is null. -/// +/// Number of highlight ranges in this match; `0` if `m` is null. /// Use with [`fff_grep_match_get_match_range`] to iterate the highlight spans. /// /// ## Safety @@ -386,11 +339,8 @@ pub unsafe extern "C" fn fff_grep_match_get_match_ranges_count(m: *const FffGrep unsafe { (*m).match_ranges_count } } -/// Returns a pointer to the `index`-th [`FffMatchRange`] highlight span. -/// -/// Returns null if `m` is null, `index >= match_ranges_count`, or the -/// ranges array is null. The returned pointer is valid until the owning -/// `FffGrepResult` is freed; do not free it directly. +/// Pointer to the `index`-th [`FffMatchRange`] highlight span; null if `m` is null, +/// `index >= match_ranges_count`, or the ranges array is null. Valid until the owning `FffGrepResult` is freed; do not free. /// /// ## Safety /// `m` must be a valid `FffGrepMatch` pointer or null. @@ -409,9 +359,7 @@ pub unsafe extern "C" fn fff_grep_match_get_match_range( unsafe { m.match_ranges.add(index as usize) } } -/// Returns the number of context lines captured before the match. -/// Returns `0` if `m` is null. -/// +/// Number of context lines captured before the match; `0` if `m` is null. /// Use with [`fff_grep_match_get_context_before`] to read each line. /// /// ## Safety @@ -424,10 +372,8 @@ pub unsafe extern "C" fn fff_grep_match_get_context_before_count(m: *const FffGr unsafe { (*m).context_before_count } } -/// Returns the `index`-th context line before the match. -/// -/// Returns null if `m` is null, `index >= context_before_count`, or the -/// context array is null. Do not free the returned pointer. +/// The `index`-th context line before the match; null if `m` is null, +/// `index >= context_before_count`, or the context array is null. Do not free. /// /// ## Safety /// `m` must be a valid `FffGrepMatch` pointer or null. @@ -446,9 +392,7 @@ pub unsafe extern "C" fn fff_grep_match_get_context_before( unsafe { *m.context_before.add(index as usize) } } -/// Returns the number of context lines captured after the match. -/// Returns `0` if `m` is null. -/// +/// Number of context lines captured after the match; `0` if `m` is null. /// Use with [`fff_grep_match_get_context_after`] to read each line. /// /// ## Safety @@ -461,10 +405,8 @@ pub unsafe extern "C" fn fff_grep_match_get_context_after_count(m: *const FffGre unsafe { (*m).context_after_count } } -/// Returns the `index`-th context line after the match. -/// -/// Returns null if `m` is null, `index >= context_after_count`, or the -/// context array is null. Do not free the returned pointer. +/// The `index`-th context line after the match; null if `m` is null, +/// `index >= context_after_count`, or the context array is null. Do not free. /// /// ## Safety /// `m` must be a valid `FffGrepMatch` pointer or null. @@ -483,11 +425,8 @@ pub unsafe extern "C" fn fff_grep_match_get_context_after( unsafe { *m.context_after.add(index as usize) } } -/// Returns the fuzzy match score. Returns `0` if `m` is null or no fuzzy -/// score is present. -/// -/// Always check [`fff_grep_match_get_has_fuzzy_score`] first; `0` is -/// ambiguous without that flag. +/// Fuzzy match score; `0` if `m` is null or no fuzzy score is present. +/// Always check [`fff_grep_match_get_has_fuzzy_score`] first; `0` is ambiguous without that flag. /// /// ## Safety /// `m` must be a valid `FffGrepMatch` pointer or null. @@ -499,8 +438,7 @@ pub unsafe extern "C" fn fff_grep_match_get_fuzzy_score(m: *const FffGrepMatch) unsafe { (*m).fuzzy_score } } -/// Returns `true` if this match carries a valid fuzzy score. -/// Returns `false` if `m` is null. +/// `true` if this match carries a valid fuzzy score; `false` if `m` is null. /// /// ## Safety /// `m` must be a valid `FffGrepMatch` pointer or null. @@ -512,8 +450,7 @@ pub unsafe extern "C" fn fff_grep_match_get_has_fuzzy_score(m: *const FffGrepMat unsafe { (*m).has_fuzzy_score } } -/// Returns `true` if the match was identified as a symbol definition. -/// Returns `false` if `m` is null. +/// `true` if the match was identified as a symbol definition; `false` if `m` is null. /// /// ## Safety /// `m` must be a valid `FffGrepMatch` pointer or null. @@ -525,8 +462,7 @@ pub unsafe extern "C" fn fff_grep_match_get_is_definition(m: *const FffGrepMatch unsafe { (*m).is_definition } } -/// Returns `true` if the matched file was detected as binary. -/// Returns `false` if `m` is null. +/// `true` if the matched file was detected as binary; `false` if `m` is null. /// /// ## Safety /// `m` must be a valid `FffGrepMatch` pointer or null. @@ -540,7 +476,7 @@ pub unsafe extern "C" fn fff_grep_match_get_is_binary(m: *const FffGrepMatch) -> // ── FffSearchResult ────────────────────────────────────────────────────────── -/// Returns the number of items in the result. Returns `0` if `r` is null. +/// Number of items in the result; `0` if `r` is null. /// /// ## Safety /// `r` must be a valid `FffSearchResult` pointer or null. @@ -552,8 +488,7 @@ pub unsafe extern "C" fn fff_search_result_get_count(r: *const FffSearchResult) unsafe { (*r).count } } -/// Returns the total number of files that matched before the result was -/// truncated to the page size. Returns `0` if `r` is null. +/// Total number of files that matched before truncation to the page size; `0` if `r` is null. /// /// ## Safety /// `r` must be a valid `FffSearchResult` pointer or null. @@ -565,8 +500,7 @@ pub unsafe extern "C" fn fff_search_result_get_total_matched(r: *const FffSearch unsafe { (*r).total_matched } } -/// Returns the total number of indexed files considered during search. -/// Returns `0` if `r` is null. +/// Total number of indexed files considered during search; `0` if `r` is null. /// /// ## Safety /// `r` must be a valid `FffSearchResult` pointer or null. @@ -580,7 +514,7 @@ pub unsafe extern "C" fn fff_search_result_get_total_files(r: *const FffSearchRe // ── FffGrepResult ───────────────────────────────────────────────────────────── -/// Returns the number of matches in the result. Returns `0` if `r` is null. +/// Number of matches in the result; `0` if `r` is null. /// /// ## Safety /// `r` must be a valid `FffGrepResult` pointer or null. @@ -592,8 +526,7 @@ pub unsafe extern "C" fn fff_grep_result_get_count(r: *const FffGrepResult) -> u unsafe { (*r).count } } -/// Returns the total number of matches found across all pages. -/// Returns `0` if `r` is null. +/// Total number of matches found across all pages; `0` if `r` is null. /// /// ## Safety /// `r` must be a valid `FffGrepResult` pointer or null. @@ -605,8 +538,7 @@ pub unsafe extern "C" fn fff_grep_result_get_total_matched(r: *const FffGrepResu unsafe { (*r).total_matched } } -/// Returns the number of files actually opened and searched in this call. -/// Returns `0` if `r` is null. +/// Number of files actually opened and searched in this call; `0` if `r` is null. /// /// ## Safety /// `r` must be a valid `FffGrepResult` pointer or null. @@ -618,8 +550,7 @@ pub unsafe extern "C" fn fff_grep_result_get_total_files_searched(r: *const FffG unsafe { (*r).total_files_searched } } -/// Returns the total number of indexed files before any filtering. -/// Returns `0` if `r` is null. +/// Total number of indexed files before any filtering; `0` if `r` is null. /// /// ## Safety /// `r` must be a valid `FffGrepResult` pointer or null. @@ -631,8 +562,7 @@ pub unsafe extern "C" fn fff_grep_result_get_total_files(r: *const FffGrepResult unsafe { (*r).total_files } } -/// Returns the number of files eligible for search after path/type filtering. -/// Returns `0` if `r` is null. +/// Number of files eligible for search after path/type filtering; `0` if `r` is null. /// /// ## Safety /// `r` must be a valid `FffGrepResult` pointer or null. @@ -644,9 +574,8 @@ pub unsafe extern "C" fn fff_grep_result_get_filtered_file_count(r: *const FffGr unsafe { (*r).filtered_file_count } } -/// Returns the file offset for the next page, or `0` if all files have been -/// searched or `r` is null. Pass this value as `file_offset` to a subsequent -/// `fff_live_grep` or `fff_multi_grep` call to continue pagination. +/// File offset for the next page; `0` if all files have been searched or `r` is null. +/// Pass as `file_offset` to a subsequent `fff_live_grep`/`fff_multi_grep` call to continue pagination. /// /// ## Safety /// `r` must be a valid `FffGrepResult` pointer or null. @@ -658,10 +587,8 @@ pub unsafe extern "C" fn fff_grep_result_get_next_file_offset(r: *const FffGrepR unsafe { (*r).next_file_offset } } -/// Returns the regex compilation error string if the engine fell back to -/// literal matching, or null if there was no error or `r` is null. -/// -/// Do not free the returned pointer. +/// Regex compilation error string if the engine fell back to literal matching; +/// null if there was no error or `r` is null. Do not free. /// /// ## Safety /// `r` must be a valid `FffGrepResult` pointer or null. diff --git a/crates/fff-c/src/ffi_types.rs b/crates/fff-c/src/ffi_types.rs index 6421de72a..3a007624f 100644 --- a/crates/fff-c/src/ffi_types.rs +++ b/crates/fff-c/src/ffi_types.rs @@ -1,8 +1,5 @@ -//! FFI-compatible type definitions -//! -//! All result types use `#[repr(C)]` structs for direct memory access from any -//! language with C FFI support. No JSON serialization is used for search or grep -//! results — callers read struct fields directly. +//! FFI-compatible type definitions: all result types are `#[repr(C)]` structs +//! read directly from any language with C FFI — no JSON serialization. use std::ffi::{CString, c_char, c_void}; use std::ptr; @@ -19,12 +16,12 @@ pub const FFF_CREATE_OPTIONS_VERSION: u32 = 2; /// Options for `fff_create_instance_with`. /// -/// Versioned struct: you populate the struct at your call level, we guarantee that -/// the version is stable across the version changes, new fields only appended! +/// Versioned struct: the layout is stable across releases, new fields are +/// only appended. #[repr(C)] pub struct FffCreateOptions { - /// Set to [`FFF_CREATE_OPTIONS_VERSION`] when allocating. Used by the - /// library to determine which trailing fields are populated. + /// Set to [`FFF_CREATE_OPTIONS_VERSION`] when allocating; tells the + /// library which trailing fields are populated. pub version: u32, /// Directory to index (required, non-NULL). pub base_path: *const c_char, @@ -51,16 +48,14 @@ pub struct FffCreateOptions { pub cache_budget_max_bytes: u64, /// Per-file byte cap inside the content cache. 0 = auto. pub cache_budget_max_file_size: u64, - /// Allow indexing the filesystem root (`/`). Off by default — root is - /// rarely the intended target and floods the watcher with churn. + /// Allow indexing the filesystem root (`/`). Off by default: root is rarely + /// intended and floods the watcher with churn. pub enable_fs_root_scanning: bool, - /// Allow indexing the user's home directory. Same trade-off as - /// `enable_fs_root_scanning`. + /// Allow indexing the user's home directory. Same trade-off as `enable_fs_root_scanning`. pub enable_home_dir_scanning: bool, // ----- v2 fields ----- - /// Follow symlinks during scan and watcher walks. Off by default — - /// enabling this without external loop protection can wedge the watcher - /// on cyclic symlink graphs. Caller is responsible for the trade-off. + /// Follow symlinks during scan and watcher walks. Off by default: without + /// external loop protection cyclic symlinks can wedge the watcher. pub follow_symlinks: bool, // ----- new version 3+ fields go here, ALWAYS appended ----- } @@ -133,10 +128,8 @@ unsafe fn free_cstring_array(arr: *mut *mut c_char, count: u32) { } } -/// A file item returned by `fff_search`. -/// -/// All string fields are heap-allocated and owned by the parent `FffSearchResult`. -/// Free the entire result with `fff_free_search_result`. +/// A file item returned by `fff_search`. Strings are owned by the parent +/// `FffSearchResult`; free everything with `fff_free_search_result`. #[repr(C)] pub struct FffFileItem { pub relative_path: *mut c_char, @@ -230,13 +223,9 @@ impl FffScore { } } -/// Location parsed from a query string (e.g. `"file.ts:42:10"`). -/// -/// `tag` encodes the variant: -/// 0 = no location, -/// 1 = line only (`line` is set), -/// 2 = position (`line` + `col`), -/// 3 = range (`line`/`col` = start, `end_line`/`end_col` = end). +/// Location parsed from a query string (e.g. `"file.ts:42:10"`). `tag`: +/// 0 = none, 1 = line, 2 = position (`line` + `col`), +/// 3 = range (`line`/`col` = start, `end_line`/`end_col` = end). #[repr(C)] pub struct FffLocation { pub tag: u8, @@ -281,14 +270,12 @@ impl From> for FffLocation { } } -/// Search result returned by `fff_search`. -/// -/// The caller must free this with `fff_free_search_result`. +/// Search result returned by `fff_search`; free with `fff_free_search_result`. #[repr(C)] pub struct FffSearchResult { - /// Pointer to a heap-allocated array of `FffFileItem` (length = `count`). + /// Heap array of `FffFileItem` (length = `count`). pub items: *mut FffFileItem, - /// Pointer to a heap-allocated array of `FffScore` (length = `count`). + /// Heap array of `FffScore` (length = `count`). pub scores: *mut FffScore, /// Number of items/scores in the arrays. pub count: u32, @@ -336,10 +323,8 @@ pub struct FffMatchRange { pub end: u32, } -/// A single grep match with file and line information. -/// -/// All string fields and arrays are heap-allocated. Free the parent -/// `FffGrepResult` with `fff_free_grep_result` to release everything. +/// A single grep match with file and line information. Strings and arrays are +/// owned by the parent `FffGrepResult`; free everything with `fff_free_grep_result`. #[repr(C)] pub struct FffGrepMatch { // -- pointers (8 bytes each) -- @@ -441,12 +426,11 @@ impl FffGrepMatch { } } -/// Grep result returned by `fff_live_grep` and `fff_multi_grep`. -/// -/// The caller must free this with `fff_free_grep_result`. +/// Grep result returned by `fff_live_grep` and `fff_multi_grep`; +/// free with `fff_free_grep_result`. #[repr(C)] pub struct FffGrepResult { - /// Pointer to a heap-allocated array of `FffGrepMatch` (length = `count`). + /// Heap array of `FffGrepMatch` (length = `count`). pub items: *mut FffGrepMatch, /// Number of matches in the `items` array. pub count: u32, @@ -583,10 +567,8 @@ impl FffResult { } } -/// A directory item returned by `fff_search_directories`. -/// -/// All string fields are heap-allocated and owned by the parent `FffDirSearchResult`. -/// Free the entire result with `fff_free_dir_search_result`. +/// A directory item returned by `fff_search_directories`. Strings are owned by +/// the parent `FffDirSearchResult`; free everything with `fff_free_dir_search_result`. #[repr(C)] pub struct FffDirItem { pub relative_path: *mut c_char, @@ -617,14 +599,13 @@ impl FffDirItem { } } -/// Directory search result returned by `fff_search_directories`. -/// -/// The caller must free this with `fff_free_dir_search_result`. +/// Directory search result returned by `fff_search_directories`; +/// free with `fff_free_dir_search_result`. #[repr(C)] pub struct FffDirSearchResult { - /// Pointer to a heap-allocated array of `FffDirItem` (length = `count`). + /// Heap array of `FffDirItem` (length = `count`). pub items: *mut FffDirItem, - /// Pointer to a heap-allocated array of `FffScore` (length = `count`). + /// Heap array of `FffScore` (length = `count`). pub scores: *mut FffScore, /// Number of items/scores in the arrays. pub count: u32, @@ -659,9 +640,8 @@ impl FffDirSearchResult { } /// A single item in a mixed (files + directories) search result. -/// -/// `item_type`: 0 = file, 1 = directory. -/// All string fields are heap-allocated and owned by the parent `FffMixedSearchResult`. +/// `item_type`: 0 = file, 1 = directory. Strings are owned by the parent +/// `FffMixedSearchResult`. #[repr(C)] pub struct FffMixedItem { /// 0 = file, 1 = directory. @@ -672,8 +652,7 @@ pub struct FffMixedItem { pub git_status: *mut c_char, pub size: u64, pub modified: u64, - /// The access frecency score for files, or max access frecency among all the immediate - /// children for directories. + /// Access frecency for files; max among immediate children for directories. pub access_frecency_score: i64, /// Always 0 for directories pub modification_frecency_score: i64, @@ -730,14 +709,13 @@ impl FffMixedItem { } } -/// Mixed search result returned by `fff_search_mixed`. -/// -/// The caller must free this with `fff_free_mixed_search_result`. +/// Mixed search result returned by `fff_search_mixed` +/// free with `fff_free_mixed_search_result`. #[repr(C)] pub struct FffMixedSearchResult { - /// Pointer to a heap-allocated array of `FffMixedItem` (length = `count`). + /// Heap array of `FffMixedItem` (length = `count`). pub items: *mut FffMixedItem, - /// Pointer to a heap-allocated array of `FffScore` (length = `count`). + /// Heap array of `FffScore` (length = `count`). pub scores: *mut FffScore, /// Number of items/scores in the arrays. pub count: u32, diff --git a/crates/fff-c/src/lib.rs b/crates/fff-c/src/lib.rs index 56283d7ef..e1713f31b 100644 --- a/crates/fff-c/src/lib.rs +++ b/crates/fff-c/src/lib.rs @@ -1,26 +1,14 @@ -//! C FFI bindings for fff-core +//! C FFI bindings for fff-core, usable from any language with C FFI +//! (Bun, Node.js, Python, Ruby, etc.). //! -//! This crate provides C-compatible FFI exports that can be used from any language -//! with C FFI support (Bun, Node.js, Python, Ruby, etc.). +//! All state is owned by an opaque instance handle: create with +//! `fff_create_instance*`, pass to every call, free with `fff_destroy`. +//! Multiple instances can coexist in one process. //! -//! # Instance-based API -//! -//! All state is owned by an opaque `FffInstance` fff_handle. Callers create an instance -//! with `fff_create_instance`, pass the fff_handle to every subsequent call, and free it with -//! `fff_destroy`. Multiple independent instances can coexist in the same process. -//! -//! # Memory management -//! -//! * Every `fff_*` function that returns `*mut FffResult` requires the caller to -//! free the result with `fff_free_result`. -//! * The instance itself must be freed with `fff_destroy`. -//! -//! # Parameter conventions -//! -//! * Optional `*const c_char` parameters: pass NULL or an empty string to omit. -//! * Numeric parameters: 0 means "use default" unless documented otherwise. -//! * Grep mode (`u8`): 0 = plain text, 1 = regex, 2 = fuzzy. -//! * Multi-grep patterns are passed as a single newline-separated (`\n`) string. +//! Conventions: every returned `*mut FffResult` is freed with +//! `fff_free_result`; optional string params take NULL/empty; numeric 0 means +//! "use default" unless documented otherwise; grep mode `u8` is 0 = plain +//! text, 1 = regex, 2 = fuzzy; multi-grep patterns are `\n`-separated. use std::ffi::{CStr, CString, c_char, c_void}; use std::path::PathBuf; @@ -30,6 +18,7 @@ use fff::shared::SharedQueryTracker; mod accessors; mod ffi_types; +mod watch; use fff::file_picker::FilePicker; use fff::frecency::FrecencyTracker; @@ -42,20 +31,17 @@ use ffi_types::{ FffScore, FffSearchResult, }; -/// Opaque fff_handle holding all per-instance state. -/// -/// The caller receives this as `*mut c_void` and must pass it to every FFI call. -/// The fff_handle is freed by `fff_destroy`. +/// Opaque handle holding all per-instance state; freed by `fff_destroy`. struct FffInstance { picker: SharedFilePicker, frecency: SharedFrecency, query_tracker: SharedQueryTracker, + // we keep a single callback type + watch_callback: std::sync::Arc, } -/// Helper to convert C string to Rust &str. -/// -/// Returns `None` if the pointer is null or the string is not valid UTF-8. -unsafe fn cstr_to_str<'a>(s: *const c_char) -> Option<&'a str> { +/// Convert a C string to `&str`; `None` if null or invalid UTF-8. +pub(crate) unsafe fn cstr_to_str<'a>(s: *const c_char) -> Option<&'a str> { if s.is_null() { None } else { @@ -63,17 +49,15 @@ unsafe fn cstr_to_str<'a>(s: *const c_char) -> Option<&'a str> { } } -/// Helper to convert an optional C string parameter. -/// -/// Returns `None` if the pointer is null, empty, or not valid UTF-8. +/// Optional C string param: `None` if null, empty, or invalid UTF-8. unsafe fn optional_cstr<'a>(s: *const c_char) -> Option<&'a str> { unsafe { cstr_to_str(s) }.filter(|s| !s.is_empty()) } -/// Recover a `&FffInstance` from the opaque pointer. -/// -/// Returns an error `FffResult` if the pointer is null. -unsafe fn instance_ref<'a>(fff_handle: *mut c_void) -> Result<&'a FffInstance, *mut FffResult> { +/// Recover a `&FffInstance` from the opaque pointer; error `FffResult` if null. +pub(crate) unsafe fn instance_ref<'a>( + fff_handle: *mut c_void, +) -> Result<&'a FffInstance, *mut FffResult> { if fff_handle.is_null() { Err(FffResult::err( "Instance handle is null. Create one with fff_create_instance first.", @@ -107,11 +91,8 @@ fn default_i32(val: i32, default: i32) -> i32 { /// Create a new file finder instance (legacy 8-arg positional signature). /// -/// @deprecated Use [`fff_create_instance_with`] (or -/// [`fff_create_instance_with_value`] for FFI bindings) — both take the -/// versioned [`FffCreateOptions`] struct that evolves without ABI breaks. -/// This function delegates to `fff_create_instance_with` internally; the -/// `use_unsafe_no_lock` parameter is deprecated and ignored. +/// @deprecated Use [`fff_create_instance_with`] (or [`fff_create_instance_with_value`] +/// for FFI bindings). The `use_unsafe_no_lock` parameter is ignored. /// /// ## Safety /// See `fff_create_instance_with`. @@ -143,10 +124,8 @@ pub unsafe extern "C" fn fff_create_instance( /// Create a new file finder instance (legacy 13-arg positional signature). /// -/// @deprecated Use [`fff_create_instance_with`] (or -/// [`fff_create_instance_with_value`] for FFI bindings) — both take the -/// versioned [`FffCreateOptions`] struct that evolves without ABI breaks. -/// The `use_unsafe_no_lock` parameter is deprecated and ignored. +/// @deprecated Use [`fff_create_instance_with`] (or [`fff_create_instance_with_value`] +/// for FFI bindings). The `use_unsafe_no_lock` parameter is ignored. /// /// ## Safety /// See `fff_create_instance_with`. @@ -186,22 +165,14 @@ pub unsafe extern "C" fn fff_create_instance2( unsafe { fff_create_instance_with(&opts as *const FffCreateOptions) } } -/// Create a new file finder instance from an [`FffCreateOptions`] struct. -/// -/// **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. +/// Create a new file finder instance from a versioned [`FffCreateOptions`] struct. /// -/// **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. +/// Populate the struct, set `version` to [`FFF_CREATE_OPTIONS_VERSION`], pass by +/// pointer. New fields are only appended; older `version` values keep working. +/// FFI bindings needing struct-by-value should use [`fff_create_instance_with_value`]. /// -/// Required: `opts.base_path` must be non-NULL and non-empty. -/// -/// When all three `cache_budget_*` values are 0 the budget is auto-computed -/// from repo size after the initial scan. Otherwise an explicit budget is -/// used: any field left at 0 falls back to its `unlimited()` default. +/// `opts.base_path` is required (non-NULL, non-empty). Zero `cache_budget_*` +/// values are auto-computed from repo size after the initial scan. /// /// ## Safety /// * `opts` must be a valid pointer to an `FffCreateOptions` whose `version` @@ -304,22 +275,15 @@ pub unsafe extern "C" fn fff_create_instance_with(opts: *const FffCreateOptions) picker: shared_picker, frecency: shared_frecency, query_tracker, + watch_callback: std::sync::Arc::new(watch::WatchCallbackSlot::default()), }); let fff_handle = Box::into_raw(instance) as *mut c_void; FffResult::ok_handle(fff_handle) } -/// Calling-convention adapter for [`fff_create_instance_with`]. -/// -/// Same logic, but takes the [`FffCreateOptions`] struct **by value**. This -/// makes the function callable from FFI libraries whose native struct -/// support passes structs by value on the wire (e.g. Node's `ffi-rs` with -/// `paramsType: [structDef]`). -/// -/// This is **not** a versioned wrapper — when new fields are appended to -/// `FffCreateOptions`, both this function and `fff_create_instance_with` -/// pick them up automatically with no signature change. +/// [`fff_create_instance_with`] adapter taking [`FffCreateOptions`] **by value**, +/// for FFI libraries that pass native structs by value (e.g. Node's `ffi-rs`). /// /// ## Safety /// All `*const c_char` fields inside `opts` must be valid null-terminated @@ -341,6 +305,10 @@ pub unsafe extern "C" fn fff_destroy(fff_handle: *mut c_void) { let instance = unsafe { Box::from_raw(fff_handle as *mut FffInstance) }; + // The C callback and user_data may be freed as soon as this returns. + instance.picker.shutdown_watches_and_wait(); + instance.watch_callback.clear(); + if let Ok(mut guard) = instance.picker.write() && let Some(picker) = guard.take() { @@ -357,16 +325,9 @@ pub unsafe extern "C" fn fff_destroy(fff_handle: *mut c_void) { /// Perform fuzzy search on indexed files. /// -/// # Parameters -/// -/// * `fff_handle` – instance from `fff_create_instance` -/// * `query` – search query string -/// * `current_file` – path of the currently open file for deprioritization (NULL/empty to skip) -/// * `max_threads` – maximum worker threads (0 = auto-detect) -/// * `page_index` – pagination offset (0 = first page) -/// * `page_size` – results per page (0 = default 100) -/// * `combo_boost_multiplier` – score multiplier for combo matches (0 = default 100) -/// * `min_combo_count` – minimum combo count before boost applies (0 = default 3) +/// `current_file` deprioritizes the currently open file (NULL/empty to skip). +/// Zero picks the default: `max_threads` auto, `page_size` 100, +/// `combo_boost_multiplier` 100, `min_combo_count` 3. /// /// ## Safety /// * `fff_handle` must be a valid instance pointer from `fff_create_instance`. @@ -439,22 +400,11 @@ pub unsafe extern "C" fn fff_search( FffResult::ok_handle(search_result as *mut c_void) } -/// Glob-only search: filter indexed files by a single glob pattern, rank by -/// frecency, and paginate. Bypasses the regular query parser entirely. -/// -/// 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. +/// Glob-only search: filter indexed files by a single glob pattern (passed +/// through verbatim, no query parsing), rank by frecency, and paginate. /// -/// # Parameters -/// -/// * `fff_handle` - instance from `fff_create_instance` -/// * `pattern` - glob pattern (required, no parsing - passed through verbatim) -/// * `current_file` - path of the currently open file for deprioritization (NULL/empty to skip) -/// * `max_threads` - maximum worker threads (0 = auto-detect) -/// * `page_index` - pagination offset (0 = first page) -/// * `page_size` - results per page (0 = default 100) +/// `current_file` deprioritizes the currently open file (NULL/empty to skip). +/// Zero picks the default: `max_threads` auto, `page_size` 100. /// /// ## Safety /// * `fff_handle` must be a valid instance pointer from `fff_create_instance`. @@ -514,14 +464,8 @@ pub unsafe extern "C" fn fff_glob( /// Perform fuzzy search on indexed directories. /// -/// # Parameters -/// -/// * `fff_handle` – instance from `fff_create_instance` -/// * `query` – search query string -/// * `current_file` – path of the currently open file for distance scoring (NULL/empty to skip) -/// * `max_threads` – maximum worker threads (0 = auto-detect) -/// * `page_index` – pagination offset (0 = first page) -/// * `page_size` – results per page (0 = default 100) +/// `current_file` is used for distance scoring (NULL/empty to skip). +/// Zero picks the default: `max_threads` auto, `page_size` 100. /// /// ## Safety /// * `fff_handle` must be a valid instance pointer from `fff_create_instance`. @@ -584,20 +528,8 @@ pub unsafe extern "C" fn fff_search_directories( /// Perform a mixed fuzzy search across both files and directories. /// -/// Returns a single flat list where files and directories are interleaved -/// by total score in descending order. Each item has an `item_type` field -/// (0 = file, 1 = directory). -/// -/// # Parameters -/// -/// * `fff_handle` – instance from `fff_create_instance` -/// * `query` – search query string -/// * `current_file` – path of the currently open file (NULL/empty to skip) -/// * `max_threads` – maximum worker threads (0 = auto-detect) -/// * `page_index` – pagination offset (0 = first page) -/// * `page_size` – results per page (0 = default 100) -/// * `combo_boost_multiplier` – score multiplier for combo matches (0 = default 100) -/// * `min_combo_count` – minimum combo count before boost applies (0 = default 3) +/// Returns one flat list interleaved by descending total score; each item's +/// `item_type` is 0 = file, 1 = directory. Parameters as in [`fff_search`]. /// /// ## Safety /// * `fff_handle` must be a valid instance pointer from `fff_create_instance`. @@ -671,20 +603,11 @@ pub unsafe extern "C" fn fff_search_mixed( /// Perform content search (grep) across indexed files. /// -/// # Parameters -/// -/// * `fff_handle` – instance from `fff_create_instance` -/// * `query` – search query (supports constraint syntax like `*.rs pattern`) -/// * `mode` – 0 = plain text (SIMD), 1 = regex, 2 = fuzzy -/// * `max_file_size` – skip files larger than this in bytes (0 = default 10 MB) -/// * `max_matches_per_file` – max matches per file (0 = unlimited) -/// * `smart_case` – case-insensitive when query is all lowercase -/// * `file_offset` – file-based pagination offset (0 = start) -/// * `page_limit` – max matches to return (0 = default 50) -/// * `time_budget_ms` – wall-clock budget in ms (0 = unlimited) -/// * `before_context` – context lines before each match -/// * `after_context` – context lines after each match -/// * `classify_definitions` – tag matches that are code definitions +/// `query` supports constraint syntax like `*.rs pattern`; `mode` is +/// 0 = plain text (SIMD), 1 = regex, 2 = fuzzy. Zero picks the default: +/// `max_file_size` 10 MB, `page_limit` 50, `max_matches_per_file` and +/// `time_budget_ms` unlimited. `smart_case` is case-insensitive for +/// all-lowercase queries; `classify_definitions` tags code definitions. /// /// ## Safety /// * `fff_handle` must be a valid instance pointer from `fff_create_instance`. @@ -753,25 +676,11 @@ pub unsafe extern "C" fn fff_live_grep( FffResult::ok_handle(grep_result as *mut c_void) } -/// Perform multi-pattern OR search (Aho-Corasick) across indexed files. -/// -/// Searches for lines matching ANY of the provided patterns using -/// SIMD-accelerated multi-needle matching. +/// Multi-pattern OR search (SIMD Aho-Corasick): lines matching ANY pattern. /// -/// # Parameters -/// -/// * `fff_handle` – instance from `fff_create_instance` -/// * `patterns_joined` – patterns separated by `\n` (e.g. `"foo\nbar\nbaz"`) -/// * `constraints` – file filter like `"*.rs"` or `"/src/"` (NULL/empty to skip) -/// * `max_file_size` – skip files larger than this in bytes (0 = default 10 MB) -/// * `max_matches_per_file` – max matches per file (0 = unlimited) -/// * `smart_case` – case-insensitive when all patterns are lowercase -/// * `file_offset` – file-based pagination offset (0 = start) -/// * `page_limit` – max matches to return (0 = default 50) -/// * `time_budget_ms` – wall-clock budget in ms (0 = unlimited) -/// * `before_context` – context lines before each match -/// * `after_context` – context lines after each match -/// * `classify_definitions` – tag matches that are code definitions +/// `patterns_joined` is `\n`-separated (e.g. `"foo\nbar"`); `constraints` is an +/// optional file filter like `"*.rs"` or `"/src/"` (NULL/empty to skip). +/// Remaining parameters as in [`fff_live_grep`]. /// /// ## Safety /// * `fff_handle` must be a valid instance pointer from `fff_create_instance`. @@ -893,10 +802,8 @@ pub unsafe extern "C" fn fff_is_scanning(fff_handle: *mut c_void) -> bool { .unwrap_or(false) } -/// Get the base path of the file picker. -/// -/// Returns an `FffResult` with a heap-allocated C string in the `handle` -/// field. Free the string with `fff_free_string` after reading it. +/// Get the picker's base path as a heap C string in `handle`; +/// free it with `fff_free_string`. /// /// ## Safety /// `fff_handle` must be a valid instance pointer from `fff_create_instance`. @@ -1358,10 +1265,8 @@ pub unsafe extern "C" fn fff_health_check( } } -/// Free a search result returned by `fff_search`. -/// -/// This frees the `FffSearchResult` struct, its `items` and `scores` arrays, -/// and all heap-allocated strings within each item and score. +/// Free a search result returned by `fff_search`: the struct, its `items` +/// and `scores` arrays, and every string within. /// /// ## Safety /// `result` must be a valid pointer previously returned via `FffResult.handle` @@ -1391,10 +1296,8 @@ pub unsafe extern "C" fn fff_free_search_result(result: *mut FffSearchResult) { } } -/// Get a pointer to the `index`-th `FffFileItem` in a search result. -/// -/// Returns null if `result` is null or `index >= result->count`. -/// The returned pointer is valid until the search result is freed. +/// Pointer to the `index`-th `FffFileItem`; null if `result` is null or +/// `index >= count`. Valid until the search result is freed. /// /// ## Safety /// `result` must be a valid `FffSearchResult` pointer from `fff_search`. @@ -1413,10 +1316,8 @@ pub unsafe extern "C" fn fff_search_result_get_item( unsafe { result.items.add(index as usize) } } -/// Get a pointer to the `index`-th `FffScore` in a search result. -/// -/// Returns null if `result` is null or `index >= result->count`. -/// The returned pointer is valid until the search result is freed. +/// Pointer to the `index`-th `FffScore`; null if `result` is null or +/// `index >= count`. Valid until the search result is freed. /// /// ## Safety /// `result` must be a valid `FffSearchResult` pointer from `fff_search`. @@ -1435,10 +1336,8 @@ pub unsafe extern "C" fn fff_search_result_get_score( unsafe { result.scores.add(index as usize) } } -/// Free a grep result returned by `fff_live_grep` or `fff_multi_grep`. -/// -/// This frees the `FffGrepResult` struct, its `items` array, and all -/// heap-allocated strings, match ranges, and context arrays within each match. +/// Free a grep result returned by `fff_live_grep` or `fff_multi_grep`: +/// the struct, its `items` array, and all strings/ranges/context within. /// /// ## Safety /// `result` must be a valid pointer previously returned via `FffResult.handle` @@ -1465,10 +1364,8 @@ pub unsafe extern "C" fn fff_free_grep_result(result: *mut FffGrepResult) { } } -/// Get a pointer to the `index`-th `FffGrepMatch` in a grep result. -/// -/// Returns null if `result` is null or `index >= result->count`. -/// The returned pointer is valid until the grep result is freed. +/// Pointer to the `index`-th `FffGrepMatch`; null if `result` is null or +/// `index >= count`. Valid until the grep result is freed. /// /// ## Safety /// `result` must be a valid `FffGrepResult` pointer from `fff_live_grep` or `fff_multi_grep`. @@ -1499,10 +1396,8 @@ pub unsafe extern "C" fn fff_free_scan_progress(result: *mut FffScanProgress) { } } -/// Offset a pointer by `byte_offset` bytes. -/// -/// General-purpose utility for FFI consumers that need pointer arithmetic -/// (e.g. iterating over arrays). Returns null if `base` is null. +/// Offset a pointer by `byte_offset` bytes (FFI array iteration helper). +/// Returns null if `base` is null. /// /// ## Safety /// The resulting pointer must be within the bounds of the original allocation. @@ -1514,13 +1409,9 @@ pub unsafe extern "C" fn fff_ptr_offset(base: *const c_void, byte_offset: usize) unsafe { (base as *const u8).add(byte_offset) as *const c_void } } -/// Free a result returned by any `fff_*` function. -/// **IMPORTANT:** this doesn't clean the the internal handle, so it is safe to call right after -/// you handle the error case. -/// -/// Note: Many non-libffi implementations are not supporting struct-by-value returns, so it's more -/// convenient to have pointer returned at most of the time, though allocating result for every call -/// is annoying, so we just rely on the fact that our allocator is good enough. +/// Free a result envelope returned by any `fff_*` function. +/// **IMPORTANT:** the `handle` payload is NOT freed release it separately +/// using handle specific cleaning methods (`fff_destroy`, `fff_free_search_result`, etc.). /// /// ## Safety /// `result_ptr` must be a valid pointer returned by a `fff_*` function. @@ -1535,9 +1426,8 @@ pub unsafe extern "C" fn fff_free_result(result_ptr: *mut FffResult) { if !result.error.is_null() { drop(CString::from_raw(result.error)); } - // Note: `handle` is NOT freed here — the caller must free it - // with the appropriate function (fff_destroy, fff_free_search_result, - // fff_free_grep_result, fff_free_string, fff_free_scan_progress, etc.). + + // note: handle is not freed by design } } diff --git a/crates/fff-c/src/watch.rs b/crates/fff-c/src/watch.rs new file mode 100644 index 000000000..e4ec23e4d --- /dev/null +++ b/crates/fff-c/src/watch.rs @@ -0,0 +1,351 @@ +use std::ffi::{CString, c_char, c_void}; +use std::ptr; +use std::sync::Arc; +use std::sync::Mutex; + +use fff::{WatchEvent, WatchId, WatchOptions}; + +use crate::ffi_types::FffResult; +use crate::instance_ref; + +/// Current version of [`FffWatchOptions`]. +pub const FFF_WATCH_OPTIONS_VERSION: u32 = 1; + +/// Options for `fff_watch`. Versioned: new fields are only appended. +#[repr(C)] +pub struct FffWatchOptions { + /// Set to [`FFF_WATCH_OPTIONS_VERSION`] when allocating. + pub version: u32, + /// Per-subscription excludes (parcel-watcher style): entries with wildcards + /// are base-relative globs, entries without are path prefixes. NULL when + /// `ignore_count` is 0. + pub ignore: *const *const c_char, + pub ignore_count: u32, + // ----- new version 2+ fields go here, ALWAYS appended ----- +} + +/// A single watch event. `kind`: 0 = created, 1 = modified, 2 = removed, +/// 3 = rescan (events were lost; re-stat what you care about). +#[repr(C)] +pub struct FffWatchEvent { + /// Absolute path (heap C string owned by the parent batch). + pub path: *mut c_char, + pub kind: u8, +} + +/// A batch of watch events. Free with `fff_free_watch_events`. +#[repr(C)] +pub struct FffWatchEventBatch { + pub events: *mut FffWatchEvent, + pub count: u32, +} + +/// Instance-wide callback invoked with `(watch_id, batch)` for every `fff_watch` +/// subscription. The callee owns and frees `batch` via `fff_free_watch_events`. +pub type FffWatchCallback = + unsafe extern "C" fn(watch_id: u64, batch: *mut FffWatchEventBatch, user_data: *mut c_void); + +fn batch_into_raw(events: &[WatchEvent]) -> *mut FffWatchEventBatch { + let items: Vec = events + .iter() + .map(|ev| FffWatchEvent { + path: CString::new(ev.path.to_string_lossy().as_bytes()) + .unwrap_or_default() + .into_raw(), + kind: ev.kind as u8, + }) + .collect(); + + let count = items.len() as u32; + let events_ptr = if items.is_empty() { + ptr::null_mut() + } else { + let mut boxed = items.into_boxed_slice(); + let p = boxed.as_mut_ptr(); + std::mem::forget(boxed); + p + }; + + Box::into_raw(Box::new(FffWatchEventBatch { + events: events_ptr, + count, + })) +} + +unsafe fn watch_options_from_ffi( + opts: *const FffWatchOptions, +) -> Result { + if opts.is_null() { + return Ok(WatchOptions::default()); + } + let opts = unsafe { &*opts }; + if opts.version == 0 || opts.version > FFF_WATCH_OPTIONS_VERSION { + return Err(FffResult::err(&format!( + "Unsupported FffWatchOptions version {} (library understands up to {})", + opts.version, FFF_WATCH_OPTIONS_VERSION + ))); + } + + let mut ignore = Vec::with_capacity(opts.ignore_count as usize); + if opts.ignore_count > 0 { + if opts.ignore.is_null() { + return Err(FffResult::err("ignore_count > 0 but ignore is NULL")); + } + for i in 0..opts.ignore_count as usize { + let entry = unsafe { *opts.ignore.add(i) }; + match unsafe { crate::cstr_to_str(entry) } { + Some(s) if !s.is_empty() => ignore.push(s.to_string()), + Some(_) => {} + None => return Err(FffResult::err("ignore entry is NULL or invalid UTF-8")), + } + } + } + + Ok(WatchOptions { ignore }) +} + +// The caller guarantees user_data is safe on the callback thread. +struct UserData(*mut c_void); +unsafe impl Send for UserData {} +unsafe impl Sync for UserData {} + +// Shared so a closure surviving an unwatch race never dangles. +#[derive(Default)] +pub(crate) struct WatchCallbackSlot(Mutex>); + +impl WatchCallbackSlot { + fn get(&self) -> Option<(FffWatchCallback, *mut c_void)> { + self.0 + .lock() + .ok() + .and_then(|guard| guard.as_ref().map(|(cb, ud)| (*cb, ud.0))) + } + + fn set(&self, callback: FffWatchCallback, user_data: *mut c_void) { + if let Ok(mut guard) = self.0.lock() { + *guard = Some((callback, UserData(user_data))); + } + } + + pub(crate) fn clear(&self) { + if let Ok(mut guard) = self.0.lock() { + *guard = None; + } + } +} + +/// Register the instance-wide watch callback used by all `fff_watch` +/// subscriptions; call before the first `fff_watch`, calling again replaces it. +/// +/// ## Safety +/// * `fff_handle` must be a valid instance pointer from `fff_create_instance`. +/// * `callback` must remain callable until fff_unwatch called +/// `fff_destroy(fff_handle)` returns. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn fff_set_watch_callback( + fff_handle: *mut c_void, + callback: FffWatchCallback, + user_data: *mut c_void, +) -> *mut FffResult { + let inst = match unsafe { instance_ref(fff_handle) } { + Ok(i) => i, + Err(e) => return e, + }; + inst.watch_callback.set(callback, user_data); + FffResult::ok_empty() +} + +/// Subscribe to filesystem changes, delivered through the instance callback +/// registered by `fff_set_watch_callback`. +/// +/// Returns the watch id, pass it to `fff_unwatch` to stop. +/// +/// `pattern` if non `NULL` can be wildcard pattern, absolute, or relative path +/// that will be used to filter the events triggering exact subscription. +/// +/// ## Safety +/// * `fff_handle` must be a valid instance pointer from `fff_create_instance`. +/// * `pattern` must be NULL or valid null-terminated UTF-8. +/// * `opts` must be NULL or a valid `FffWatchOptions` pointer. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn fff_watch( + fff_handle: *mut c_void, + pattern: *const c_char, + opts: *const FffWatchOptions, +) -> *mut FffResult { + let inst = match unsafe { instance_ref(fff_handle) } { + Ok(i) => i, + Err(e) => return e, + }; + // NULL pattern = watch the entire indexed tree ("" in core). + let pattern_str = if pattern.is_null() { + "" + } else { + match unsafe { crate::cstr_to_str(pattern) } { + Some(s) => s, + None => return FffResult::err("Pattern is not valid UTF-8"), + } + }; + let options = match unsafe { watch_options_from_ffi(opts) } { + Ok(o) => o, + Err(e) => return e, + }; + if inst.watch_callback.get().is_none() { + return FffResult::err("No watch callback registered. Call fff_set_watch_callback first."); + } + + let slot = Arc::clone(&inst.watch_callback); + let result = inst.picker.watch(pattern_str, options, move |id, events| { + if let Some((cb, user_data)) = slot.get() { + let batch = batch_into_raw(events); + unsafe { cb(id.0, batch, user_data) }; + } + }); + + match result { + Ok(id) => FffResult::ok_int(id.0 as i64), + Err(e) => FffResult::err(&format!("Failed to subscribe: {}", e)), + } +} + +/// [`fff_watch`] adapter with flattened options, for FFI libraries that cannot +/// marshal pointer arrays inside structs (e.g. Node's `ffi-rs`). +/// +/// ## Safety +/// * `fff_handle` must be a valid instance pointer from `fff_create_instance`. +/// * `pattern` must be NULL (watch everything) or valid null-terminated UTF-8. +/// * `ignore` must be NULL or point to `ignore_count` valid C strings. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn fff_watch_args( + fff_handle: *mut c_void, + pattern: *const c_char, + ignore: *const *const c_char, + ignore_count: u32, +) -> *mut FffResult { + let opts = FffWatchOptions { + version: FFF_WATCH_OPTIONS_VERSION, + ignore, + ignore_count, + }; + unsafe { fff_watch(fff_handle, pattern, &opts) } +} + +/// Remove a watch subscription. `int_value` = 1 if the id existed, 0 otherwise. +/// +/// ## Safety +/// `fff_handle` must be a valid instance pointer from `fff_create_instance`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn fff_unwatch(fff_handle: *mut c_void, watch_id: u64) -> *mut FffResult { + let inst = match unsafe { instance_ref(fff_handle) } { + Ok(i) => i, + Err(e) => return e, + }; + FffResult::ok_int(inst.picker.unwatch(WatchId(watch_id)) as i64) +} + +/// Number of events in a batch, 0 if `batch` is null. +/// +/// ## Safety +/// `batch` must be a valid `FffWatchEventBatch` pointer or null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn fff_watch_events_count(batch: *const FffWatchEventBatch) -> u32 { + if batch.is_null() { + return 0; + } + unsafe { (*batch).count } +} + +/// Absolute path of event `index`, will be null when out of bounds +/// +/// ## Safety +/// `batch` must be a valid `FffWatchEventBatch` pointer or null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn fff_watch_events_get_path( + batch: *const FffWatchEventBatch, + index: u32, +) -> *const c_char { + match unsafe { watch_event_at(batch, index) } { + Some(ev) => ev.path, + None => ptr::null(), + } +} + +/// Kind of event `index` (0 = created, 1 = modified, 2 = removed, 3 = rescan) +/// 3 (rescan aka "re-stat something" kind) returned when OS based buffer +/// has been overflown and some events might be loss. Paths will contain a list of +/// directories that needs to be rescanned to ensure consistency. +/// +/// ## Safety +/// `batch` must be a valid `FffWatchEventBatch` pointer or null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn fff_watch_events_get_kind( + batch: *const FffWatchEventBatch, + index: u32, +) -> u8 { + match unsafe { watch_event_at(batch, index) } { + Some(ev) => ev.kind, + None => 3, + } +} + +unsafe fn watch_event_at<'a>( + batch: *const FffWatchEventBatch, + index: u32, +) -> Option<&'a FffWatchEvent> { + if batch.is_null() { + return None; + } + let batch = unsafe { &*batch }; + if batch.events.is_null() || index >= batch.count { + return None; + } + Some(unsafe { &*batch.events.add(index as usize) }) +} + +/// Free a watch event batch delivered to the instance callback. +/// +/// ## Safety +/// `batch` must be a pointer produced by this library, or null (no-op). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn fff_free_watch_events(batch: *mut FffWatchEventBatch) { + if batch.is_null() { + return; + } + unsafe { + let batch = Box::from_raw(batch); + if !batch.events.is_null() { + let events = + Vec::from_raw_parts(batch.events, batch.count as usize, batch.count as usize); + for ev in events { + if !ev.path.is_null() { + drop(CString::from_raw(ev.path)); + } + } + } + } +} + +// THESE TESTS MUST NEVER BE UPDATED, ONLY EXTENDED WITH NEW FIELDS — +// bindings hardcode these offsets (ABI stability). +#[cfg(test)] +mod layout_tests { + use super::*; + use std::mem::{offset_of, size_of}; + + #[test] + #[cfg(target_pointer_width = "64")] + fn watch_ffi_layouts_are_stable_64bit() { + assert_eq!(size_of::(), 24); + assert_eq!(offset_of!(FffWatchOptions, version), 0); + assert_eq!(offset_of!(FffWatchOptions, ignore), 8); + assert_eq!(offset_of!(FffWatchOptions, ignore_count), 16); + + assert_eq!(size_of::(), 16); + assert_eq!(offset_of!(FffWatchEvent, path), 0); + assert_eq!(offset_of!(FffWatchEvent, kind), 8); + + assert_eq!(size_of::(), 16); + assert_eq!(offset_of!(FffWatchEventBatch, events), 0); + assert_eq!(offset_of!(FffWatchEventBatch, count), 8); + } +} diff --git a/crates/fff-c/tests/smoke.c b/crates/fff-c/tests/smoke.c index b02195307..68eaa9a21 100644 --- a/crates/fff-c/tests/smoke.c +++ b/crates/fff-c/tests/smoke.c @@ -12,9 +12,190 @@ * compilers. */ +/* expose mkdtemp/usleep under -std=c99 on glibc; harmless on musl/darwin */ +#define _DEFAULT_SOURCE +#define _BSD_SOURCE + #include #include +#include #include +#include + +// simple mock function to make sure that both globbing patterns and dir based pattern work +static int watch_glob_hits = 0; +static int watch_dir_hits = 0; +static int watch_all_hits = 0; +static int watch_ignored_leaks = 0; +static uint64_t watch_glob_id = 0; +static uint64_t watch_dir_id = 0; +static uint64_t watch_all_id = 0; + +static void on_watch_batch(uint64_t watch_id, struct FffWatchEventBatch *batch, void *user_data) { + (void)user_data; + /* route by id like real SDKs do; unknown ids are benign no-ops */ + for (uint32_t i = 0; i < batch->count; i++) { + const char *path = batch->events[i].path; + if (!path) continue; + if (watch_id == watch_glob_id && strstr(path, "hello.txt")) { + watch_glob_hits++; + } + if (watch_id == watch_dir_id) { + if (strstr(path, "hello.txt")) watch_dir_hits++; + if (strstr(path, "noise.log")) watch_ignored_leaks++; + } + if (watch_id == watch_all_id && strstr(path, "hello.txt")) { + watch_all_hits++; + } + } + + fff_free_watch_events(batch); // need to clean dynamic array of events +} + +static int watch_smoke(void) { + char tmpl[] = "/tmp/fff-c-watch-XXXXXX"; + char *dir = mkdtemp(tmpl); + if (!dir) { + fprintf(stderr, "watch_smoke: mkdtemp failed\n"); + return 1; + } + + struct FffResult *create_result = fff_create_instance_with(&(struct FffCreateOptions){ + .version = FFF_CREATE_OPTIONS_VERSION, + .base_path = dir, + .enable_mmap_cache = false, + .enable_content_indexing = false, + .watch = true, + }); + if (!create_result->success) { + fprintf(stderr, "watch_smoke: create failed: %s\n", + create_result->error ? create_result->error : "?"); + fff_free_result(create_result); + return 1; + } + void *picker = create_result->handle; + fff_free_result(create_result); + + struct FffResult *r = fff_wait_for_scan(picker, 10000); + fff_free_result(r); + r = fff_wait_for_watcher(picker, 10000); + fff_free_result(r); + usleep(300 * 1000); /* let the FSEvents stream settle */ + + /* instance-wide callback, then two subscriptions routed by id */ + r = fff_set_watch_callback(picker, on_watch_batch, NULL); + if (!r->success) { + fprintf(stderr, "watch_smoke: fff_set_watch_callback failed: %s\n", + r->error ? r->error : "?"); + fff_free_result(r); + fff_destroy(picker); + return 1; + } + fff_free_result(r); + + r = fff_watch(picker, "**/*.txt", NULL); + if (!r->success) { + fprintf(stderr, "watch_smoke: fff_watch failed: %s\n", r->error ? r->error : "?"); + fff_free_result(r); + fff_destroy(picker); + return 1; + } + watch_glob_id = (uint64_t)r->int_value; + fff_free_result(r); + + /* whole-tree dir subscription with an ignore glob */ + const char *ignores[] = {"*.log"}; + r = fff_watch(picker, dir, + &(struct FffWatchOptions){.version = FFF_WATCH_OPTIONS_VERSION, + .ignore = ignores, + .ignore_count = 1}); + if (!r->success) { + fprintf(stderr, "watch_smoke: dir fff_watch failed: %s\n", r->error ? r->error : "?"); + fff_free_result(r); + fff_destroy(picker); + return 1; + } + watch_dir_id = (uint64_t)r->int_value; + fff_free_result(r); + + /* NULL pattern subscribes to the entire indexed tree */ + r = fff_watch(picker, NULL, NULL); + if (!r->success) { + fprintf(stderr, "watch_smoke: NULL-pattern fff_watch failed: %s\n", + r->error ? r->error : "?"); + fff_free_result(r); + fff_destroy(picker); + return 1; + } + watch_all_id = (uint64_t)r->int_value; + fff_free_result(r); + + char file_path[512]; + snprintf(file_path, sizeof(file_path), "%s/hello.txt", dir); + FILE *f = fopen(file_path, "w"); + if (!f) { + fprintf(stderr, "watch_smoke: fopen failed\n"); + fff_destroy(picker); + return 1; + } + fputs("hello watch\n", f); + fclose(f); + + /* must be filtered out by the dir subscription's ignore glob */ + char log_path[512]; + snprintf(log_path, sizeof(log_path), "%s/noise.log", dir); + FILE *lf = fopen(log_path, "w"); + if (lf) { + fputs("noise\n", lf); + fclose(lf); + } + + for (int attempt = 0; + attempt < 100 && (watch_glob_hits == 0 || watch_dir_hits == 0 || watch_all_hits == 0); + attempt++) { + usleep(100 * 1000); + } + + r = fff_unwatch(picker, watch_glob_id); + fff_free_result(r); + r = fff_unwatch(picker, watch_dir_id); + fff_free_result(r); + r = fff_unwatch(picker, watch_all_id); + fff_free_result(r); + /* unwatch of an unknown id reports 0, not an error */ + r = fff_unwatch(picker, watch_dir_id); + int unwatch_idempotent = r->success && r->int_value == 0; + fff_free_result(r); + + /* fff_destroy is the quiescence barrier: after it returns the callback + * will never run again and could be freed (ours is static). */ + fff_destroy(picker); + + if (watch_glob_hits == 0) { + fprintf(stderr, "watch_smoke FAIL: glob subscriber saw no events\n"); + return 1; + } + if (watch_dir_hits == 0) { + fprintf(stderr, "watch_smoke FAIL: dir subscriber saw no events\n"); + return 1; + } + if (watch_all_hits == 0) { + fprintf(stderr, "watch_smoke FAIL: NULL-pattern subscriber saw no events\n"); + return 1; + } + if (watch_ignored_leaks > 0) { + fprintf(stderr, "watch_smoke FAIL: ignore glob leaked %d events\n", watch_ignored_leaks); + return 1; + } + if (!unwatch_idempotent) { + fprintf(stderr, "watch_smoke FAIL: repeated unwatch was not a no-op\n"); + return 1; + } + + fprintf(stderr, "watch_smoke PASS (glob=%d dir=%d all=%d)\n", watch_glob_hits, watch_dir_hits, + watch_all_hits); + return 0; +} int main(int argc, char **argv) { const char *base_path = argc > 1 ? argv[1] : "."; @@ -84,6 +265,11 @@ int main(int argc, char **argv) { return 1; } + if (watch_smoke() != 0) { + fprintf(stderr, "FAIL: watch test failed\n"); + return 1; + } + fprintf(stderr, "PASS\n"); return 0; } diff --git a/crates/fff-core/Cargo.toml b/crates/fff-core/Cargo.toml index fc07dd830..acd0ee53a 100644 --- a/crates/fff-core/Cargo.toml +++ b/crates/fff-core/Cargo.toml @@ -6,6 +6,9 @@ license = "MIT" authors = ["Dmitriy Kovalenko "] description = "Faboulous & Fast File Finder - a fast and extremely correct file finder SDK with typo resistance, SIMD, prefiltering, and more" +[lints] +workspace = true + [lib] path = "src/lib.rs" crate-type = ["rlib", "staticlib", "cdylib"] diff --git a/crates/fff-core/src/error.rs b/crates/fff-core/src/error.rs index 451af79cd..17a3ebd45 100644 --- a/crates/fff-core/src/error.rs +++ b/crates/fff-core/src/error.rs @@ -94,6 +94,21 @@ pub enum Error { #[error("Filesystem walk failed: {0}")] WalkFailed(String), + + #[error("Invalid glob pattern '{pattern}': {reason}")] + InvalidGlobPattern { pattern: String, reason: String }, + + #[error("File system watching is disabled for this picker")] + WatcherDisabled, + + #[error("File system watcher is not ready")] + WatcherNotReady, + + #[error("Indexed base path changed while creating the watch subscription")] + WatchBaseChanged, + + #[error("Failed to start watch callback dispatcher: {0}")] + WatchDispatcherStart(#[source] std::io::Error), } pub type Result = std::result::Result; diff --git a/crates/fff-core/src/file_picker.rs b/crates/fff-core/src/file_picker.rs index 2037bb435..6f9055fcb 100644 --- a/crates/fff-core/src/file_picker.rs +++ b/crates/fff-core/src/file_picker.rs @@ -26,12 +26,9 @@ //! # Thread Safety //! //! `FilePicker` itself is **not** `Sync`! -//! all concurrent access goes through [`SharedPicker`](crate::SharedPicker) . -//! The background scanner and watcher acquire write locks only when mutating -//! the file index, so read-heavy search workloads rarely contend. +//! all concurrent access goes through [`crate::SharedFilePicker`] use crate::FFFStringStorage; -use crate::background_watcher::BackgroundWatcher; use crate::constants::{MAX_OVERFLOW_FILES, PATH_BUF_SIZE}; use crate::error::Error; use crate::frecency::FrecencyTracker; @@ -48,6 +45,7 @@ use crate::types::{ ContentCacheBudget, DirItem, DirSearchResult, FileItem, MixedItemRef, MixedSearchResult, PaginationArgs, Score, ScoringContext, SearchResult, }; +use crate::watch::BackgroundWatcher; use fff_query_parser::FFFQuery; use git2::{Repository, Status}; use rayon::prelude::*; @@ -98,7 +96,7 @@ pub(crate) struct FileSync { /// (parent_dir, filename): /// `files[..indexable_count]` - indexable /// `files[indexable_count..base_count]` - original-unindexable - /// `files[base_count..]`— overflow (created on demand) + /// `files[base_count..]` - overflow files: StableVec, indexable_count: usize, base_count: usize, @@ -109,7 +107,12 @@ pub(crate) struct FileSync { /// concurrent readers observe a consistent view via the same shared /// allocation. Dir frecency is updated through the per-entry atomic /// (`DirItem::max_access_frecency`) without `&mut` aliasing. + /// Layout mirrors `files`: `dirs[..base_dirs_count]` is the sorted + /// scan-built region, `dirs[base_dirs_count..]` holds watcher-appended dirs. dirs: StableVec, + base_dirs_count: usize, + /// Number of dirs with at least one live file (mirrors `live_count`). + live_dirs_count: usize, /// Shared builder for overflow file paths. Each overflow file's ChunkedString /// uses `arena_override` pointing into this builder's arena. overflow_builder: Option, @@ -130,7 +133,9 @@ impl FileSync { indexable_count: 0, base_count: 0, live_count: 0, - dirs: StableVec::from_vec_with_reserve(Vec::new(), 0), + dirs: StableVec::from_vec_with_reserve(Vec::new(), MAX_OVERFLOW_FILES), + base_dirs_count: 0, + live_dirs_count: 0, overflow_builder: None, git_workdir: None, bigram_index: None, @@ -223,15 +228,12 @@ impl FileSync { // Binary search dirs to find the parent directory index. // Dir items store the relative path including trailing '/' (e.g. "src/components/"). + // Only the scan-built region is sorted; watcher-appended dirs are not. let mut dir_buf = [0u8; crate::simd_path::PATH_BUF_SIZE]; - let dir_idx = self - .dirs + let dir_idx = self.dirs[..self.base_dirs_count] .binary_search_by(|d| d.read_relative_path(arena, &mut dir_buf).cmp(dir_rel)) .ok(); - // Binary search base files by (parent_dir, filename). Base files live in - // two internally-sorted partitions — indexable first, then unindexable — - // so we try each half in turn. Two O(log n) searches with short-circuit. if let Some(dir_idx) = dir_idx { let dir_idx = dir_idx as u32; let cmp_key = |f: &FileItem| { @@ -271,10 +273,11 @@ impl FileSync { // TODO remove this function and make a better way to remove all files // from the directory without looping over the whole sync data list - /// Tombstones every file in the arena that matches certain predicate - fn tombstone_files_with_arena(&mut self, mut predicate: F) -> usize + // Tombstones every matching arena file. + fn tombstone_files_with_arena(&mut self, mut predicate: F, mut on_tombstone: T) -> usize where F: FnMut(&FileItem, ArenaPtr) -> bool, + T: FnMut(&FileItem, ArenaPtr), { let base_arena = self.arena_base_ptr(); let overflow_arena = self.arena_overflow_ptr(); @@ -291,6 +294,7 @@ impl FileSync { overflow_arena }; if predicate(file, arena) { + on_tombstone(file, arena); file.set_deleted(true); tombstoned += 1; } @@ -298,6 +302,91 @@ impl FileSync { self.live_count -= tombstoned; tombstoned } + + /// Marks every dir matching `predicate` as deleted. Mirrors how dir-level + /// FS events (remove/move-out) invalidate whole subtrees. + fn tombstone_dirs_with_arena(&mut self, mut predicate: F) + where + F: FnMut(&DirItem, ArenaPtr) -> bool, + { + let base_arena = self.arena_base_ptr(); + let overflow_arena = self.arena_overflow_ptr(); + let base_dirs_count = self.base_dirs_count; + + let mut removed = 0usize; + for (idx, dir) in self.dirs.iter_mut().enumerate() { + if dir.is_deleted() { + continue; + } + let arena = if idx < base_dirs_count { + base_arena + } else { + overflow_arena + }; + if predicate(dir, arena) && dir.set_deleted(true) { + removed += 1; + } + } + self.live_dirs_count -= removed; + } + + /// Restores a dir to the live state (file appeared under it again). + fn revive_dir(&mut self, dir_idx: u32) { + if let Some(dir) = self.dirs.get_mut(dir_idx as usize) + && dir.set_deleted(false) + { + self.live_dirs_count += 1; + } + } + + /// Finds the dir index for a '/'-canonical relative dir path + /// (with trailing '/', empty string for the base dir itself). + fn find_dir_index(&self, dir_rel: &str) -> Option { + let arena = self.arena_base_ptr(); + let mut dir_buf = [0u8; crate::simd_path::PATH_BUF_SIZE]; + if let Ok(idx) = self.dirs[..self.base_dirs_count] + .binary_search_by(|d| d.read_relative_path(arena, &mut dir_buf).cmp(dir_rel)) + { + return Some(idx); + } + + // Watcher-appended region: unsorted, small (bounded by overflow cap). + let overflow_arena = self.arena_overflow_ptr(); + self.dirs[self.base_dirs_count..] + .iter() + .position(|d| d.read_relative_path(overflow_arena, &mut dir_buf) == dir_rel) + .map(|pos| self.base_dirs_count + pos) + } + + /// Finds or appends the DirItem for `dir_rel`, returning its index. + /// `None` when the dir table's overflow capacity is exhausted. + fn find_or_add_dir(&mut self, dir_rel: &str) -> Option { + if let Some(idx) = self.find_dir_index(dir_rel) { + return Some(idx as u32); + } + + let builder = self.overflow_builder.get_or_insert_with(|| { + crate::simd_path::ChunkedPathStoreBuilder::new(MAX_OVERFLOW_FILES) + }); + let chunked = builder.add_dir_immediate(dir_rel); + + let last_seg = if dir_rel.is_empty() { + 0 + } else { + let trimmed = dir_rel.trim_end_matches(std::path::is_separator); + trimmed + .rfind(std::path::is_separator) + .map(|i| i + 1) + .unwrap_or(0) as u16 + }; + + let idx = self.dirs.len(); + if !self.dirs.push(DirItem::new_overflow(chunked, last_seg)) { + return None; + } + self.live_dirs_count += 1; + Some(idx as u32) + } } impl FileItem { @@ -441,24 +530,23 @@ impl FileItem { /// Options for creating a [`FilePicker`]. pub struct FilePickerOptions { pub base_path: String, - /// Pre-populate mmap caches for top-frecency files after the initial scan. + /// Pre-populate mmap caches for top-frecency files after the initial scan pub enable_mmap_cache: bool, - /// Build content index after the initial scan for faster content-aware filtering. + /// Build content index after the initial scan for faster content-aware filtering pub enable_content_indexing: bool, /// Mode of the picker impact the way file watcher events are handled and the scoring logic pub mode: FFFMode, /// Explicit cache budget. When `None`, the budget is auto-computed from /// the repo size after the initial scan completes. pub cache_budget: Option, - /// When `false`, `new_with_shared_state` skips the background file watcher. + /// When `false` no background watcher will be created pub watch: bool, - /// Follow symbolic links during file indexing. + /// Follow symbolic links during file indexing pub follow_symlinks: bool, - /// Allow indexing the filesystem root (`/`). Off by default — these dirs - /// generate enormous fs-event traffic and are rarely the intended target. + /// Allow indexing the filesystem root (`/`) pub enable_fs_root_scanning: bool, /// Allow indexing the user's home directory. Off by default for the same - /// reason as `enable_fs_root_scanning`. + /// reason as `enable_fs_root_scanning` pub enable_home_dir_scanning: bool, } @@ -560,6 +648,10 @@ impl FilePicker { self.watch } + pub fn is_watcher_ready(&self) -> bool { + self.background_watcher.is_some() && self.signals.watcher_ready.load(Ordering::Acquire) + } + pub fn follows_symlinks(&self) -> bool { self.follow_symlinks } @@ -655,12 +747,21 @@ impl FilePicker { if !dir_table.is_empty() { let arena = self.arena_base_ptr(); + let overflow_arena = self.sync_data.arena_overflow_ptr(); let mut path_buf = PathBuf::with_capacity(crate::simd_path::PATH_BUF_SIZE); let mut prev_relative_path = String::new(); let mut scratch_buf = [0u8; crate::simd_path::PATH_BUF_SIZE]; for dir_item in dir_table.iter() { - let full_relative_path = dir_item.read_relative_path(arena, &mut scratch_buf); + if dir_item.is_deleted() { + continue; + } + let item_arena = if dir_item.is_overflow() { + overflow_arena + } else { + arena + }; + let full_relative_path = dir_item.read_relative_path(item_arena, &mut scratch_buf); let relative_path = full_relative_path.trim_end_matches(std::path::is_separator); if relative_path.is_empty() { @@ -747,6 +848,17 @@ impl FilePicker { error!("Base path does not exist: {}", options.base_path); return Err(Error::InvalidPath(path)); } + // Relative bases (".", "sub/dir") are resolved against the cwd so + // they can be compared with the absolute paths reported by the OS + // watcher. Purely lexical: no symlinks are resolved. The + // `components()` pass drops interior `.` segments ("/cwd/."). + let path = if path.is_relative() { + std::env::current_dir() + .map(|cwd| cwd.join(&path).components().collect()) + .unwrap_or(path) + } else { + path + }; if path.parent().is_none() && !options.enable_fs_root_scanning { error!("Refusing to index filesystem root: {}", path.display()); return Err(Error::FilesystemRoot(path)); @@ -758,7 +870,7 @@ impl FilePicker { return Err(Error::FilesystemRoot(path)); } - // Windows-only: canonicalize with so the base path does NOT + // Windows-only: canonicalize with dunce so the base path does NOT // have the `\\?\` UNC prefix that `std::fs::canonicalize` adds. // libgit2's `repo.workdir()` #[cfg(windows)] @@ -831,6 +943,9 @@ impl FilePicker { .scanning .store(true, std::sync::atomic::Ordering::Release); + // Update the watch base before publishing the new picker. + shared_picker.rebase_watches(&path); + { let mut guard = shared_picker.write()?; *guard = Some(picker); @@ -1033,7 +1148,7 @@ impl FilePicker { options.max_threads }; - let total_dirs = dirs.len(); + let total_dirs = self.sync_data.live_dirs_count; let effective_query = match &query.fuzzy_query { fff_query_parser::FuzzyQuery::Text(t) => *t, @@ -1056,10 +1171,11 @@ impl FilePicker { }; let arena = self.sync_data.arena_base_ptr(); + let overflow_arena = self.sync_data.arena_overflow_ptr(); let time = std::time::Instant::now(); let (items, scores, total_matched) = - crate::score::fuzzy_match_and_score_dirs(dirs, &context, arena); + crate::score::fuzzy_match_and_score_dirs(dirs, &context, arena, overflow_arena); info!( ?query, @@ -1569,11 +1685,25 @@ impl FilePicker { file_item.set_path(builder.add_file_immediate(&rel_path, file_item.path.filename_offset)); file_item.set_overflow(true); + // Keep the dir table consistent: register (or revive) the parent dir + // so directory search reflects watcher-added files immediately. + let dir_rel = crate::path_utils::to_canonical_slashes( + &rel_path[..file_item.path.filename_offset as usize], + ); + + if let Some(dir_idx) = self.sync_data.find_or_add_dir(&dir_rel) { + file_item.parent_dir_index = dir_idx; + } + let parent_dir = file_item.parent_dir_index; + if !self.sync_data.files.push(file_item) { return None; } self.sync_data.live_count += 1; + // Dir may have been tombstoned by an earlier removal; a new file + // under it proves it exists again. + self.sync_data.revive_dir(parent_dir); self.sync_data.files.last() } @@ -1603,8 +1733,11 @@ impl FilePicker { return; } file.set_deleted(false); + let parent_dir = file.parent_dir_index; self.sync_data.live_count += 1; + // The path exists on disk again, so its parent dir does too. + self.sync_data.revive_dir(parent_dir); } /// Marks file as deleted, make sure that if you call this yourself these changes can be reverted @@ -1622,22 +1755,75 @@ impl FilePicker { // TODO make this O(n) pub fn remove_all_files_in_dir(&mut self, dir: impl AsRef) -> usize { - let dir_path = dir.as_ref(); - let relative_dir = self - .to_relative_path(dir_path) - .map(|c| c.into_owned()) - .unwrap_or_default(); - - let dir_prefix = if relative_dir.is_empty() { - String::new() - } else { - // Stored relative paths are '/'-canonical on every platform. - format!("{relative_dir}/") - }; + self.remove_all_files_in_dirs_inner(std::iter::once(dir.as_ref()), None) + } - self.sync_data.tombstone_files_with_arena(|file, arena| { - file.relative_path_starts_with(arena, &dir_prefix) - }) + /// Tombstones files under any of `dirs` in a single index scan. + pub(crate) fn remove_all_files_in_dirs_with_callback<'a>( + &mut self, + dirs: impl IntoIterator, + mut callback: impl FnMut(&Path), + ) -> usize { + self.remove_all_files_in_dirs_inner(dirs, Some(&mut callback)) + } + + pub(crate) fn remove_all_files_in_dirs<'a>( + &mut self, + dirs: impl IntoIterator, + ) -> usize { + self.remove_all_files_in_dirs_inner(dirs, None) + } + + fn remove_all_files_in_dirs_inner<'a>( + &mut self, + dirs: impl IntoIterator, + mut callback: Option<&mut dyn FnMut(&Path)>, + ) -> usize { + let mut dir_prefixes = Vec::new(); + for dir_path in dirs { + let Some(relative_dir) = self + .to_relative_path(dir_path) + .map(|path| path.into_owned()) + else { + continue; + }; + + if relative_dir.is_empty() { + dir_prefixes.push(String::new()); + } else { + // Stored relative paths are '/'-canonical on every platform. + dir_prefixes.push(format!("{relative_dir}/")); + } + } + + if dir_prefixes.is_empty() { + return 0; + } + + let base_path = self.base_path.clone(); + let mut path_buf = [0u8; crate::simd_path::PATH_BUF_SIZE]; + let tombstoned = self.sync_data.tombstone_files_with_arena( + |file, arena| { + dir_prefixes + .iter() + .any(|prefix| file.relative_path_starts_with(arena, prefix)) + }, + |file, arena| { + if let Some(callback) = callback.as_mut() { + callback(file.write_absolute_path(arena, &base_path, &mut path_buf)); + } + }, + ); + + // The whole subtree is gone: tombstone the dirs too so directory + // search stops surfacing them. + let mut dir_buf = [0u8; crate::simd_path::PATH_BUF_SIZE]; + self.sync_data.tombstone_dirs_with_arena(|dir, arena| { + let rel = dir.read_relative_path(arena, &mut dir_buf); + dir_prefixes.iter().any(|prefix| rel.starts_with(prefix)) + }); + + tombstoned } /// Use this to prevent any substantial background threads from acquiring the locks @@ -1650,6 +1836,7 @@ impl FilePicker { if let Some(mut watcher) = self.background_watcher.take() { watcher.stop(); } + self.signals.watcher_ready.store(false, Ordering::Release); } /// Quick way to check if scan is going without acquiring a lock for [Self::get_scan_progress] @@ -1928,13 +2115,16 @@ impl FileSync { ); let base_count = files.len(); + let base_dirs_count = dirs.len(); Ok(FileSync { files: StableVec::from_vec_with_reserve(files, MAX_OVERFLOW_FILES), indexable_count, base_count, live_count: base_count, - dirs: StableVec::from_vec_with_reserve(dirs, 0), + dirs: StableVec::from_vec_with_reserve(dirs, MAX_OVERFLOW_FILES), + base_dirs_count, + live_dirs_count: base_dirs_count, overflow_builder: None, git_workdir, bigram_index: None, @@ -2252,4 +2442,44 @@ mod tests { // "src" is emitted-as-dir; "src/x" extends it — full "src" is shared. assert_eq!(common_dir_prefix_len("src", "src/x"), 3); } + + #[test] + fn directory_removal_collects_each_tombstoned_path() { + let dir = tempfile::tempdir().unwrap(); + let base = crate::path_utils::canonicalize(dir.path()).unwrap(); + let removed_dir = base.join("removed"); + let kept = base.join("kept.txt"); + let first = removed_dir.join("a.txt"); + let second = removed_dir.join("nested/b.txt"); + std::fs::create_dir_all(second.parent().unwrap()).unwrap(); + std::fs::write(&first, b"a").unwrap(); + std::fs::write(&second, b"b").unwrap(); + std::fs::write(&kept, b"kept").unwrap(); + + let mut picker = FilePicker::new(FilePickerOptions { + base_path: base.to_string_lossy().into_owned(), + watch: false, + ..Default::default() + }) + .unwrap(); + picker.collect_files().unwrap(); + + let mut removed = Vec::new(); + assert_eq!( + picker.remove_all_files_in_dirs_with_callback( + std::iter::once(removed_dir.as_path()), + |path| { + removed.push(path.to_path_buf()); + } + ), + 2 + ); + removed.sort_unstable(); + assert_eq!(removed, vec![first, second]); + assert!(picker.get_file_by_path(&kept).is_some()); + + let outside = base.parent().unwrap().join("outside"); + assert_eq!(picker.remove_all_files_in_dir(&outside), 0); + assert!(picker.get_file_by_path(&kept).is_some()); + } } diff --git a/crates/fff-core/src/grep/prefilter.rs b/crates/fff-core/src/grep/prefilter.rs index 815b6c69c..8fd1589fb 100644 --- a/crates/fff-core/src/grep/prefilter.rs +++ b/crates/fff-core/src/grep/prefilter.rs @@ -123,7 +123,7 @@ fn prefilter_files<'a>( // 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 + // this will get only (mod 64) bits from the last word guarantee that it's 0 padded let last_mask: u64 = (1u64 << last_word_bits) - 1; let word = candidates[full_words] & last_mask; if word != 0 { diff --git a/crates/fff-core/src/index/constraints.rs b/crates/fff-core/src/index/constraints.rs index c70e9bbb7..00152d1ed 100644 --- a/crates/fff-core/src/index/constraints.rs +++ b/crates/fff-core/src/index/constraints.rs @@ -144,9 +144,9 @@ pub(crate) fn apply_constraints<'a, T: Constrainable + Sync>( } #[cfg(feature = "zlob")] -type GlobPattern = zlob::ZlobPattern; +pub(crate) type GlobPattern = zlob::ZlobPattern; #[cfg(all(not(feature = "zlob"), feature = "ripgrep"))] -type GlobPattern = globset::GlobMatcher; +pub(crate) type GlobPattern = globset::GlobMatcher; /// How `Constraint::Glob` is evaluated for each item. enum GlobStrategy { @@ -371,16 +371,34 @@ fn matches_git_status(status: Option, filter: &GitStatusFilter) -> #[inline] #[cfg(feature = "zlob")] -fn compiled_matches(p: &GlobPattern, path: &str) -> bool { +pub(crate) fn compiled_matches(p: &GlobPattern, path: &str) -> bool { p.matches_default(path) } #[inline] #[cfg(all(not(feature = "zlob"), feature = "ripgrep"))] -fn compiled_matches(p: &GlobPattern, path: &str) -> bool { +pub(crate) fn compiled_matches(p: &GlobPattern, path: &str) -> bool { p.is_match(path) } +/// Append indices (into `rels`) of paths matching `p`, in input order. +/// zlob backend: ONE FFI call for the whole batch. +#[cfg(feature = "zlob")] +pub(crate) fn glob_matches_into(p: &GlobPattern, rels: &[&str], out: &mut Vec) { + match p.match_indices(rels, p.flags()) { + Ok(ix) => out.extend_from_slice(ix.as_slice()), + Err(e) => { + tracing::warn!(?e, "zlob batch match failed, falling back to per-path"); + out.extend((0..rels.len()).filter(|&i| p.matches_default(rels[i]))); + } + } +} + +#[cfg(all(not(feature = "zlob"), feature = "ripgrep"))] +pub(crate) fn glob_matches_into(p: &GlobPattern, rels: &[&str], out: &mut Vec) { + out.extend((0..rels.len()).filter(|&i| p.is_match(rels[i]))); +} + /// Decide between batch prepass and inline compiled patterns. /// /// `has_pre_filter` = true when something cheaper than glob can reject items first @@ -485,12 +503,12 @@ fn walk_globs(c: &Constraint<'_>, f: &mut F) { } #[cfg(feature = "zlob")] -fn compile_one(pattern: &str) -> Option { +pub(crate) fn compile_one(pattern: &str) -> Option { zlob::ZlobPattern::compile(pattern, zlob::ZlobFlags::RECOMMENDED).ok() } #[cfg(all(not(feature = "zlob"), feature = "ripgrep"))] -fn compile_one(pattern: &str) -> Option { +pub(crate) fn compile_one(pattern: &str) -> Option { globset::Glob::new(pattern) .ok() .map(|g| g.compile_matcher()) diff --git a/crates/fff-core/src/lib.rs b/crates/fff-core/src/lib.rs index 3f8c427b4..ff4bdfc30 100644 --- a/crates/fff-core/src/lib.rs +++ b/crates/fff-core/src/lib.rs @@ -20,6 +20,9 @@ //! - [`grep`] — Live grep search supporting regex, plain-text, and fuzzy modes //! with optional constraint filtering. //! - [`git`] — Git status caching and repository detection. +//! - [`watch`] — Client-facing filesystem watch subscriptions: glob, exact +//! path, or directory subtree with normalized batch delivery +//! (see [`SharedFilePicker::watch`]). //! //! ## Shared State //! @@ -140,7 +143,6 @@ pub use index::bigram_filter; pub mod simd_string_utils; // ================================== -mod background_watcher; mod error; mod git_status_worker; mod ignore; @@ -154,6 +156,12 @@ pub(crate) mod simd_path; pub(crate) mod stable_vec; pub(crate) mod walk; +/// Filesystem watch subscriptions with glob filtering and batched delivery, +/// plus the background OS watcher. +#[path = "watcher/mod.rs"] +pub mod watch; +pub use watch::{WatchEvent, WatchEventKind, WatchId, WatchOptions}; + // fff error pub use error::{Error, Result}; diff --git a/crates/fff-core/src/scan.rs b/crates/fff-core/src/scan.rs index 1ef6c00a3..29f1bb32c 100644 --- a/crates/fff-core/src/scan.rs +++ b/crates/fff-core/src/scan.rs @@ -5,13 +5,13 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use tracing::{error, info}; use crate::FileSync; -use crate::background_watcher::BackgroundWatcher; use crate::error::Error; use crate::file_picker::FFFMode; use crate::index::{build_bigram_index, sniff_binary_for_non_indexable}; use crate::parallelism::BACKGROUND_THREAD_POOL; use crate::shared::{SharedFilePicker, SharedFrecency}; use crate::types::ContentCacheBudget; +use crate::watch::BackgroundWatcher; #[derive(Clone, Default)] pub(crate) struct ScanSignals { @@ -161,7 +161,7 @@ impl ScanJob { trace_span: _, } = self; - let _scanning = ScanningGuard::new(&signals, config.install_watcher); + let _scanning = ScanningGuard::new(&signals); scanned_files_counter.store(0, Ordering::Relaxed); // 1. Walk the file system and collect the list of files @@ -259,8 +259,11 @@ impl ScanJob { Ok(watcher) => { if let Ok(mut guard) = shared_picker.write() && let Some(picker) = guard.as_mut() + && picker.base_path() == base_path + && !signals.cancelled.load(Ordering::Acquire) { picker.background_watcher = Some(watcher); + signals.watcher_ready.store(true, Ordering::Release); } } Err(e) => error!(?e, "failed to initialize background watcher"), @@ -355,30 +358,21 @@ impl ScanJob { } } -/// RAII helper that flips the `scanning` signal on construction and -/// resets it on drop (so early-returns can't leave it stuck on `true`). -/// Also drives the `watcher_ready` signal on the initial-scan path. +// Ensures early returns clear the scanning signal. struct ScanningGuard<'a> { signals: &'a ScanSignals, - release_watcher_ready_on_drop: bool, } impl<'a> ScanningGuard<'a> { - fn new(signals: &'a ScanSignals, release_watcher_ready_on_drop: bool) -> Self { + fn new(signals: &'a ScanSignals) -> Self { signals.scanning.store(true, Ordering::Relaxed); - Self { - signals, - release_watcher_ready_on_drop, - } + Self { signals } } } impl Drop for ScanningGuard<'_> { fn drop(&mut self) { self.signals.scanning.store(false, Ordering::Relaxed); - if self.release_watcher_ready_on_drop { - self.signals.watcher_ready.store(true, Ordering::Release); - } } } diff --git a/crates/fff-core/src/score.rs b/crates/fff-core/src/score.rs index b74a4cf1b..8fe21ceb9 100644 --- a/crates/fff-core/src/score.rs +++ b/crates/fff-core/src/score.rs @@ -296,8 +296,14 @@ fn merge_byte_offsets(mut ranges: SmallVec<[(u32, u32); 4]>) -> SmallVec<[(u32, fn resolve_dir_chunks( dir: &DirItem, arena: ArenaPtr, + overflow_arena: ArenaPtr, buf: &mut [*const u8; MAX_PATH_CHUNKS], ) -> Option<(usize, u16)> { + let arena = if dir.is_overflow() { + overflow_arena + } else { + arena + }; let ptrs = dir.path.resolve_ptrs(arena, buf); Some((ptrs.len(), dir.path.byte_len)) } @@ -310,6 +316,7 @@ fn match_fuzzy_parts_dirs( options: &neo_frizbee::Config, max_threads: usize, arena: ArenaPtr, + overflow_arena: ArenaPtr, ) -> Vec { let valid_parts: Vec<&str> = fuzzy_parts .iter() @@ -323,7 +330,7 @@ fn match_fuzzy_parts_dirs( let resolve_chunks_for_frizbee = |dir: &&DirItem, buf: &mut [*const u8; MAX_PATH_CHUNKS]| -> Option<(usize, u16)> { - resolve_dir_chunks(dir, arena, buf) + resolve_dir_chunks(dir, arena, overflow_arena, buf) }; let first_part_matches = neo_frizbee::match_list_parallel_resolved( @@ -390,19 +397,23 @@ pub(crate) fn fuzzy_match_and_score_dirs<'a>( dirs: &'a [DirItem], context: &ScoringContext, arena: ArenaPtr, + overflow_arena: ArenaPtr, ) -> (Vec<&'a DirItem>, Vec, usize) { if dirs.is_empty() { return (vec![], vec![], 0); } let parsed_query = context.query; + // Ghost dirs (all files tombstoned) never surface in search results. let working_dirs: Vec<&DirItem> = if parsed_query.constraints.is_empty() { - dirs.iter().collect() + dirs.iter().filter(|d| !d.is_deleted()).collect() } else { - match apply_constraints(dirs, &parsed_query.constraints, arena, arena) { - Some(filtered) if !filtered.is_empty() => filtered, + match apply_constraints(dirs, &parsed_query.constraints, arena, overflow_arena) { + Some(filtered) if !filtered.is_empty() => { + filtered.into_iter().filter(|d| !d.is_deleted()).collect() + } Some(_) => return (vec![], vec![], 0), - None => dirs.iter().collect(), + None => dirs.iter().filter(|d| !d.is_deleted()).collect(), } }; @@ -445,6 +456,7 @@ pub(crate) fn fuzzy_match_and_score_dirs<'a>( &options, context.max_threads, arena, + overflow_arena, ); let main_needle = valid_parts[0].as_bytes(); @@ -457,12 +469,17 @@ pub(crate) fn fuzzy_match_and_score_dirs<'a>( .into_iter() .map(|path_match| { let dir = working_dirs[path_match.index as usize]; + let dir_arena = if dir.is_overflow() { + overflow_arena + } else { + arena + }; let base_score = path_match.score as i32; let frecency_boost = base_score.saturating_mul(dir.max_access_frecency()) / 100; // Distance penalty from current file's directory. let distance_penalty = if context.current_file.is_some() { - dir.path.write_to_string(arena, &mut dir_buf); + dir.path.write_to_string(dir_arena, &mut dir_buf); calculate_distance_penalty(context.current_file, &dir_buf) } else { 0 @@ -473,7 +490,7 @@ pub(crate) fn fuzzy_match_and_score_dirs<'a>( let match_start_approx = path_match.end_col.saturating_sub(main_needle_len - 1); let is_dirname_match = match_start_approx >= last_seg_offset; - dir.write_dir_name(arena, &mut dirname_buf); + dir.write_dir_name(dir_arena, &mut dirname_buf); let dirname_len = dirname_buf.len(); let is_exact_dirname = is_dirname_match && main_needle_len as usize == dirname_len diff --git a/crates/fff-core/src/shared.rs b/crates/fff-core/src/shared.rs index 10d6e6500..71cda38dd 100644 --- a/crates/fff-core/src/shared.rs +++ b/crates/fff-core/src/shared.rs @@ -9,6 +9,7 @@ use crate::frecency::FrecencyTracker; use crate::git::GitStatusCache; use crate::query_tracker::QueryTracker; use crate::scan::ScanJob; +use crate::watch::{WatchEvent, WatchId, WatchOptions, WatchRegistry}; use git2::Repository; /// Poll `.git/index.lock` until it disappears (git write completed), giving up @@ -73,12 +74,16 @@ pub struct SharedFilePicker(pub(crate) Arc); pub struct SharedPickerInner { picker: parking_lot::RwLock>, + /// Watch subscriptions live outside the picker lock so delivery and + /// (un)subscribing never contend with searches. + watchers: Arc, } impl Default for SharedPickerInner { fn default() -> Self { Self { picker: parking_lot::RwLock::new(None), + watchers: Arc::new(WatchRegistry::default()), } } } @@ -217,6 +222,71 @@ impl SharedFilePicker { Ok(()) } + /// Subscribe to filesystem changes matching `pattern`. + /// + /// Patterns may be base-relative globs (./ works), exact paths inside the indexed + /// tree, or existing directories. An empty pattern watches the whole tree. + /// + /// Events are debounced and submitted in batches per 100-ms window at most 128 events. + /// Gitignored and other ignored files are never triggering watcher. + pub fn watch( + &self, + pattern: &str, + options: WatchOptions, + callback: impl Fn(WatchId, &[WatchEvent]) + Send + Sync + 'static, + ) -> Result { + let (base_path, has_watcher, watcher_ready) = { + let guard = self.read()?; + let picker = guard.as_ref().ok_or(Error::FilePickerMissing)?; + + ( + picker.base_path().to_path_buf(), + picker.has_watcher(), + picker.is_watcher_ready(), + ) + }; + + if !has_watcher { + return Err(Error::WatcherDisabled); + } + if !watcher_ready { + return Err(Error::WatcherNotReady); + } + + self.0 + .watchers + .subscribe(&base_path, pattern, options, Box::new(callback)) + } + + /// Remove a watch subscription. Returns `true` if the id was active. + pub fn unwatch(&self, id: WatchId) -> bool { + self.0.watchers.unsubscribe(id) + } + + /// Return whether a watch subscription is active. + pub fn is_watch_active(&self, id: WatchId) -> bool { + self.0.watchers.contains(id) + } + + /// Remove every subscription without waiting for an executing callback. + pub fn shutdown_watches(&self) { + self.0.watchers.shutdown(); + } + + /// Remove every subscription and wait for an executing callback. + /// When called by that callback, it does not wait on itself. + pub fn shutdown_watches_and_wait(&self) { + self.0.watchers.shutdown_and_wait(); + } + + pub(crate) fn rebase_watches(&self, base_path: &Path) { + self.0.watchers.rebase(base_path); + } + + pub(crate) fn watch_registry(&self) -> &Arc { + &self.0.watchers + } + /// Refresh git statuses for all indexed files #[tracing::instrument(level = "info", skip_all)] pub fn refresh_git_status(&self, shared_frecency: &SharedFrecency) -> Result { diff --git a/crates/fff-core/src/types.rs b/crates/fff-core/src/types.rs index 76a24cec3..72661c967 100644 --- a/crates/fff-core/src/types.rs +++ b/crates/fff-core/src/types.rs @@ -69,6 +69,7 @@ pub struct DirFlags; impl DirFlags { pub const OVERFLOW: u8 = 1 << 0; + pub const DELETED: u8 = 1 << 1; } /// A directory in the file index. Shares chunk arena with file paths. @@ -101,6 +102,24 @@ impl DirItem { self.flags & DirFlags::OVERFLOW != 0 } + #[inline(always)] + pub fn is_deleted(&self) -> bool { + self.flags & DirFlags::DELETED != 0 + } + + /// Marks the dir deleted/restored. Returns `true` when the state changed. + pub(crate) fn set_deleted(&mut self, deleted: bool) -> bool { + if self.is_deleted() == deleted { + return false; + } + if deleted { + self.flags |= DirFlags::DELETED; + } else { + self.flags &= !DirFlags::DELETED; + } + true + } + pub(crate) fn new(path: crate::simd_path::ChunkedString, last_segment_offset: u16) -> Self { Self { path, @@ -110,6 +129,19 @@ impl DirItem { } } + /// A dir appended after the initial scan; its path lives in the overflow arena. + pub(crate) fn new_overflow( + path: crate::simd_path::ChunkedString, + last_segment_offset: u16, + ) -> Self { + Self { + path, + flags: DirFlags::OVERFLOW, + last_segment_offset, + max_access_frecency: AtomicI32::new(0), + } + } + /// Byte offset of the last path segment within the directory path. #[inline] pub fn last_segment_offset(&self) -> u16 { diff --git a/crates/fff-core/src/walk/ripgrep.rs b/crates/fff-core/src/walk/ripgrep.rs index 249de2dc5..def4599b2 100644 --- a/crates/fff-core/src/walk/ripgrep.rs +++ b/crates/fff-core/src/walk/ripgrep.rs @@ -1,7 +1,7 @@ -use crate::background_watcher::is_git_file; use crate::ignore::non_git_repo_overrides; use crate::types::FileItem; use crate::walk::WalkOutput; +use crate::watch::is_git_file; use ignore::WalkBuilder; use std::path::Path; use std::sync::{ diff --git a/crates/fff-core/src/background_watcher.rs b/crates/fff-core/src/watcher/background_watcher.rs similarity index 81% rename from crates/fff-core/src/background_watcher.rs rename to crates/fff-core/src/watcher/background_watcher.rs index 72e1b9f6d..36a9e124d 100644 --- a/crates/fff-core/src/background_watcher.rs +++ b/crates/fff-core/src/watcher/background_watcher.rs @@ -4,6 +4,7 @@ use crate::file_picker::FFFMode; use crate::git_status_worker::GitStatusWorker; use crate::shared::{SharedFilePicker, SharedFrecency}; use crate::sort_buffer::sort_with_buffer; +use crate::watch::{RawWatchEvent, WatchEventKind}; use git2::Repository; use notify::event::{AccessKind, AccessMode}; use notify::{Config, EventKind, EventKindMask, RecursiveMode}; @@ -26,17 +27,13 @@ pub struct BackgroundWatcher { } const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(50); -/// On macOS, each `watch()` call creates a separate FSEventStream. When the -/// number of directories exceeds this threshold we fall back to a single -/// recursive watch to avoid exhausting the per-process stream limit. -const MAX_MACOS_NONRECURSIVE_WATCHES: usize = 4096; /// Minimum seconds between frecency tracks of the same file in AI mode. /// Prevents score inflation from rapid burst edits by AI agents. const AI_MODE_COOLDOWN_SECS: u64 = 5 * 60; impl BackgroundWatcher { #[allow(clippy::too_many_arguments)] - pub fn new( + pub(crate) fn new( base_path: PathBuf, git_workdir: Option, shared_picker: SharedFilePicker, @@ -100,7 +97,6 @@ impl BackgroundWatcher { info!("Background file watcher initialized successfully"); - // debouncer is shared with the owner thread, once it's dropped the thread is closed let debouncer = Arc::new(Mutex::new(Some(debouncer))); // Only the Linux per-dir-watch branch needs this clone; on other // platforms the owner thread never touches the debouncer. @@ -179,9 +175,6 @@ impl BackgroundWatcher { git_status_worker: Arc, ) -> Result { let config = Config::default() - // do not follow symlinks as then notifiers spawns a bunch of events for symlinked - // files that could be git ignored, we have to property differentiate those and if - // the file was edited through a .with_follow_symlinks(false) // only the actual modification events, ignore the open syscals that we can generate by // our own grep calls and preview window rendering @@ -232,13 +225,10 @@ impl BackgroundWatcher { )?; if use_recursive { - // if the platform supports native watcher recursion debouncer.watch(base_path.as_path(), RecursiveMode::Recursive)?; info!( - "File watcher initialized with single recursive watch on {} \ - (exceeded threshold of {})", + "File watcher initialized with single recursive watch on {}", base_path.display(), - MAX_MACOS_NONRECURSIVE_WATCHES, ); } else { debouncer.watch(base_path.as_path(), RecursiveMode::NonRecursive)?; @@ -298,7 +288,9 @@ impl BackgroundWatcher { Ok(debouncer) } - /// Signals the background watcher threads to shut down, doesn't guarantee to deallocate immediately + /// Signal the watcher to shut down without blocking on its worker + /// threads. Safe to call from any context, including while holding + /// the [`SharedFilePicker`] write lock. pub fn stop(&mut self) { self.watch_tx.take(); if let Some(debouncer) = self.debouncer.lock().take() { @@ -351,6 +343,9 @@ fn handle_debounced_events( let mut new_dirs_to_watch = Vec::new(); let mut affected_paths_count = 0usize; + let watch_registry = shared_picker.watch_registry(); + let need_events_propagation = watch_registry.is_active(); + for debounced_event in &events { // It is very important to not react to the access errors because we inevitably // gonna trigger the sync by our own preview or other unnecessary noise @@ -373,7 +368,7 @@ fn handle_debounced_events( .paths .iter() // but we are smart enough and not falling into the paths - .all(|p| should_include_file(p, &filter)) + .all(|p| !p.is_dir() && !filter.is_ignored(p)) { break; } @@ -431,19 +426,31 @@ fn handle_debounced_events( EventKind::Remove(notify::event::RemoveKind::Folder) ); + let is_removed = is_folder_removal || is_removal || !path.exists(); + + let (is_dir, is_ignored) = if is_removed { + (false, true) + } else { + (path.is_dir(), filter.is_ignored(path)) + }; + if is_folder_removal { dirs_to_remove.push(path.to_path_buf()); - } else if is_removal || !path.exists() { - paths_to_remove.push(path.as_path()); - } else if path.is_dir() { - if !is_path_ignored(path, &filter) { + } else if is_removed { + // best effort but doesn't require a stat and generally correct + let maybe_directory = !matches!( + debounced_event.event.kind, + EventKind::Remove(notify::event::RemoveKind::File) + ); + + paths_to_remove.push((path.as_path(), maybe_directory)); + } else if is_dir { + if !is_ignored { new_dirs_to_watch.push(path.to_path_buf()); } - } else { + } else if !is_ignored { // For additions/modifications, still filter gitignored files. - if should_include_file(path, &filter) { - paths_to_add_or_modify.push(path.as_path()); - } + paths_to_add_or_modify.push(path.as_path()); } } @@ -466,6 +473,7 @@ fn handle_debounced_events( if need_full_rescan { info!(?affected_paths_count, "Triggering full rescan"); + watch_registry.dispatch_rescan(base_path); if let Err(e) = shared_picker.trigger_full_rescan_async(shared_frecency) { error!("Failed to trigger full rescan: {:?}", e); } @@ -498,6 +506,8 @@ fn handle_debounced_events( let mut files_to_update_git_status = Vec::new(); let mut need_full_rescan = false; let mut overflow_count = 0; + let mut removed_from_dirs = Vec::new(); + let mut watch_events = ahash::AHashMap::new(); if !paths_to_remove.is_empty() || !dirs_to_remove.is_empty() @@ -519,20 +529,55 @@ fn handle_debounced_events( return new_dirs_to_watch; }; - for dir in &dirs_to_remove { - let count = picker.remove_all_files_in_dir(dir); - debug!("remove_all_files_in_dir({:?}) -> {} files", dir, count); + for (path, may_be_dir) in &paths_to_remove { + let removed = picker.remove_file_by_path(path); + + if removed { + if need_events_propagation { + watch_events.insert(path.to_path_buf(), WatchEventKind::Removed); + } + } else if *may_be_dir { + // Not an indexed file: likely a dir renamed out of the tree + // (no Remove(Folder) is emitted), expand it per indexed file. + dirs_to_remove.push(path.to_path_buf()); + } } - for path in &paths_to_remove { - let removed = picker.remove_file_by_path(path); - debug!("remove_file_by_path({:?}) -> {}", path, removed); + // Single index scan for all dirs; misses (never-indexed paths) are free. + dirs_to_remove.sort_unstable(); + dirs_to_remove.dedup(); + if !dirs_to_remove.is_empty() { + let dirs = dirs_to_remove.iter().map(PathBuf::as_path); + if need_events_propagation { + picker.remove_all_files_in_dirs_with_callback(dirs, |path| { + removed_from_dirs.push(path.to_path_buf()); + }) + } else { + picker.remove_all_files_in_dirs(dirs) + }; + } + + if need_events_propagation { + for path in removed_from_dirs.drain(..) { + watch_events.insert(path, WatchEventKind::Removed); + } } files_to_update_git_status.reserve(paths_to_add_or_modify.len()); for path in &paths_to_add_or_modify { + let existed = need_events_propagation && picker.get_file_by_path(path).is_some(); + if picker.handle_create_or_modify(path).is_some() { files_to_update_git_status.push(path.to_path_buf()); + if need_events_propagation { + let kind = if existed { + WatchEventKind::Modified + } else { + WatchEventKind::Created + }; + + watch_events.insert(path.to_path_buf(), kind); + } } else { need_full_rescan = true; } @@ -545,11 +590,25 @@ fn handle_debounced_events( files_updated = files_to_update_git_status.len(), overflow_count, "File index changes applied", ); + if need_full_rescan || overflow_count > MAX_OVERFLOW_FILES { info!("Watcher faced limit of index overflow. Triggering rescan"); + watch_registry.dispatch_rescan(base_path); if let Err(e) = shared_picker.trigger_full_rescan_async(shared_frecency) { error!("Failed to trigger full rescan: {:?}", e); } + } else if need_events_propagation { + watch_registry.dispatch( + base_path, + watch_events + .into_iter() + .map(|(path, kind)| RawWatchEvent { + path, + kind, + is_ignored: false, + }) + .collect(), + ); } // AI mode: auto-track frecency for all modified/created files. @@ -639,7 +698,8 @@ fn track_files_from_new_directories( for entry in entries.flatten() { if entry.file_type().is_ok_and(|ft| ft.is_file()) { let path = entry.path(); - if should_include_file(&path, &filter) { + // file_type() already ruled out directories — only ignore rules left + if !filter.is_ignored(&path) { files_to_add.push(path); } } @@ -649,8 +709,7 @@ fn track_files_from_new_directories( return; } - let added = files_to_add.len(); - + let mut indexed_files = Vec::with_capacity(files_to_add.len()); { let Ok(mut guard) = shared_picker.write() else { return; @@ -661,12 +720,29 @@ fn track_files_from_new_directories( }; for path in &files_to_add { - picker.handle_create_or_modify(path); + if picker.handle_create_or_modify(path).is_some() { + indexed_files.push(path.clone()); + } } } + let added = indexed_files.len(); + + let watch_registry = shared_picker.watch_registry(); + if watch_registry.is_active() { + let events = indexed_files + .iter() + .map(|path| RawWatchEvent { + path: path.clone(), + kind: WatchEventKind::Created, + is_ignored: false, + }) + .collect(); + + watch_registry.dispatch(&base_path, events); + } if repo.is_some() { - git_status_worker.enqueue_paths(files_to_add); + git_status_worker.enqueue_paths(indexed_files); } debug!( @@ -676,19 +752,6 @@ fn track_files_from_new_directories( ); } -fn should_include_file(path: &Path, filter: &IgnoreFilter) -> bool { - // Directories are not indexed — only regular files (and symlinks to files). - if path.is_dir() { - return false; - } - !filter.is_ignored(path) -} - -#[inline] -fn is_path_ignored(path: &Path, filter: &IgnoreFilter) -> bool { - filter.is_ignored(path) -} - struct IgnoreFilter<'a> { base_path: &'a Path, /// Reusable ignore rules from the last walk (zlob backend only). @@ -724,8 +787,12 @@ impl<'a> IgnoreFilter<'a> { } match self.repo { Some(repo) => repo.is_path_ignored(path) == Ok(true), - // No repo and no rules: fall back to the non-code-dir heuristic. - None => crate::ignore::is_non_code_directory(path), + // No repo and no rules: the non-code-dir heuristic, applied to the + // base-relative path so ancestors of the base (e.g. a temp dir + // under AppData/Local on Windows) never match. + None => crate::ignore::is_non_code_directory( + path.strip_prefix(self.base_path).unwrap_or(path), + ), } } } @@ -809,6 +876,76 @@ fn watch_git_status_paths(debouncer: &mut Debouncer, git_workdir: Option<&PathBu #[cfg(test)] mod tests { use super::*; + use crate::file_picker::{FilePicker, FilePickerOptions}; + use crate::watch::{WatchEvent, WatchOptions}; + use notify::Event; + use notify::event::{CreateKind, DataChange, ModifyKind, RemoveKind}; + use std::sync::mpsc; + use std::time::{Duration, Instant}; + + #[test] + fn replacement_batch_emits_one_modified_event() { + let tmp = tempfile::tempdir().unwrap(); + let base = crate::path_utils::canonicalize(tmp.path()).unwrap(); + let path = base.join("file.txt"); + std::fs::write(&path, "before").unwrap(); + + let shared_picker = SharedFilePicker::default(); + let shared_frecency = SharedFrecency::noop(); + let mut picker = FilePicker::new(FilePickerOptions { + base_path: base.to_string_lossy().into_owned(), + watch: false, + ..Default::default() + }) + .unwrap(); + picker.collect_files().unwrap(); + shared_picker.rebase_watches(&base); + *shared_picker.write().unwrap() = Some(picker); + + let (sender, receiver) = mpsc::channel::>(); + shared_picker + .watch_registry() + .subscribe( + &base, + "**", + WatchOptions::default(), + Box::new(move |_, events| sender.send(events.to_vec()).unwrap()), + ) + .unwrap(); + + std::fs::write(&path, "after").unwrap(); + let now = Instant::now(); + let events = vec![ + DebouncedEvent::new( + Event::new(EventKind::Remove(RemoveKind::File)).add_path(path.clone()), + now, + ), + DebouncedEvent::new( + Event::new(EventKind::Create(CreateKind::File)).add_path(path.clone()), + now, + ), + DebouncedEvent::new( + Event::new(EventKind::Modify(ModifyKind::Data(DataChange::Content))) + .add_path(path.clone()), + now, + ), + ]; + + handle_debounced_events( + FFFMode::Neovim, + events, + &base, + &None, + &shared_picker, + &shared_frecency, + &GitStatusWorker::new(), + ); + + let received = receiver.recv_timeout(Duration::from_secs(1)).unwrap(); + assert_eq!(received.len(), 1); + assert_eq!(received[0].path, path); + assert_eq!(received[0].kind, WatchEventKind::Modified); + } #[test] fn dotgit_status_filter_matches_worktree_state_changes() { diff --git a/crates/fff-core/src/watcher/mod.rs b/crates/fff-core/src/watcher/mod.rs new file mode 100644 index 000000000..4a1b390b1 --- /dev/null +++ b/crates/fff-core/src/watcher/mod.rs @@ -0,0 +1,5 @@ +mod background_watcher; +pub use background_watcher::*; + +mod watch; +pub use watch::*; diff --git a/crates/fff-core/src/watcher/watch.rs b/crates/fff-core/src/watcher/watch.rs new file mode 100644 index 000000000..2a8773081 --- /dev/null +++ b/crates/fff-core/src/watcher/watch.rs @@ -0,0 +1,1164 @@ +use crate::error::Error; +use crate::index::constraints::{GlobPattern, compile_one, glob_matches_into}; +use parking_lot::Mutex; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::mpsc; +use tracing::{debug, error}; + +/// Watcher subscription/watch id +#[repr(transparent)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct WatchId(pub u64); + +pub(crate) type WatchCallback = Box; + +/// The kind of filesystem change. +/// +/// Event kinds are normalized on a best-effort basis. Editors and operating +/// systems may represent the same operation with different native events. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WatchEventKind { + Created = 0, + Modified = 1, + Removed = 2, + /// Individual events were lost; rescan the reported path. + Rescan = 3, +} + +impl WatchEventKind { + pub fn as_str(&self) -> &'static str { + match self { + WatchEventKind::Created => "created", + WatchEventKind::Modified => "modified", + WatchEventKind::Removed => "removed", + WatchEventKind::Rescan => "rescan", + } + } +} + +/// A single change notification delivered to subscribers. +#[derive(Debug, Clone)] +pub struct WatchEvent { + /// Absolute affected path (the indexed base path for `Rescan`). + pub path: PathBuf, + pub kind: WatchEventKind, +} + +/// Per-subscription options. +#[derive(Debug, Clone, Default)] +pub struct WatchOptions { + /// Additional glob or path-prefix exclusions. + pub ignore: Vec, +} + +type WatchMask = u128; +const MAX_BATCH_EVENTS: usize = WatchMask::BITS as usize; + +pub(crate) struct RawWatchEvent { + pub(crate) path: PathBuf, + pub(crate) kind: WatchEventKind, + pub(crate) is_ignored: bool, +} + +enum WatchMatcher { + Glob(GlobPattern), + Exact(PathBuf), + Dir(PathBuf), +} + +impl WatchMatcher { + fn new(pattern: &str, base: &Path) -> Result { + let pattern = pattern.trim(); + if pattern.is_empty() { + return Ok(WatchMatcher::Dir(PathBuf::new())); + } + + let Some(relative) = relative_pattern(pattern, base) else { + return Err(Error::InvalidGlobPattern { + pattern: pattern.to_string(), + reason: "watch patterns must be inside the indexed base path".into(), + }); + }; + + if fff_query_parser::glob_detect::has_wildcards(pattern) { + let glob = relative.to_string_lossy().replace('\\', "/"); + return compile_one(&glob).map(WatchMatcher::Glob).ok_or_else(|| { + Error::InvalidGlobPattern { + pattern: pattern.to_string(), + reason: "failed to compile glob".into(), + } + }); + } + + if base.join(&relative).is_dir() { + return Ok(WatchMatcher::Dir(relative)); + } + + Ok(WatchMatcher::Exact(relative)) + } +} + +#[derive(Default)] +struct SubIgnore { + globs: Vec, + prefixes: Vec, +} + +impl SubIgnore { + fn prefix_matches(&self, path: &Path) -> bool { + self.prefixes.iter().any(|prefix| path.starts_with(prefix)) + } +} + +fn relative_pattern(pattern: &str, base: &Path) -> Option { + let expanded = crate::path_utils::expand_tilde(pattern); + let relative = if expanded.is_absolute() || expanded.has_root() { + match expanded.strip_prefix(base) { + Ok(rel) => rel, + // Windows: the caller may pass an 8.3 short-name or differently + // cased path; canonicalize and retry before rejecting. + Err(_) => { + let canonical = crate::path_utils::canonicalize(&expanded).ok()?; + return relative_from_canonical(&canonical, base); + } + } + } else { + &expanded + }; + + reject_parent_components(relative) +} + +fn relative_from_canonical(canonical: &Path, base: &Path) -> Option { + let relative = canonical.strip_prefix(base).ok()?; + reject_parent_components(relative) +} + +fn reject_parent_components(path: &Path) -> Option { + if path + .components() + .any(|component| component == std::path::Component::ParentDir) + { + return None; + } + + Some(path.components().collect()) +} + +fn resolve_sub_ignore(patterns: &[String], base: &Path) -> Result { + let mut ignore = SubIgnore::default(); + + for pattern in patterns { + let pattern = pattern.trim(); + if pattern.is_empty() { + continue; + } + let Some(relative) = relative_pattern(pattern, base) else { + return Err(Error::InvalidGlobPattern { + pattern: pattern.to_string(), + reason: "ignore patterns must be inside the indexed base path".into(), + }); + }; + + if fff_query_parser::glob_detect::has_wildcards(pattern) { + match compile_one(&relative.to_string_lossy().replace('\\', "/")) { + Some(compiled) => ignore.globs.push(compiled), + None => { + return Err(Error::InvalidGlobPattern { + pattern: pattern.to_string(), + reason: "failed to compile ignore glob".into(), + }); + } + } + } else { + ignore.prefixes.push(relative); + } + } + + Ok(ignore) +} + +struct WatchSub { + id: WatchId, + matcher: WatchMatcher, + ignore: SubIgnore, + callback: WatchCallback, + active: AtomicBool, + epoch: AtomicU64, +} + +impl WatchSub { + fn filter_mask(&self, paths: &[&str], scratch: &mut Vec) -> WatchMask { + let mut mask = 0; + + match &self.matcher { + WatchMatcher::Glob(g) => { + scratch.clear(); + glob_matches_into(g, paths, scratch); + for &index in scratch.iter() { + mask |= 1 << index; + } + } + WatchMatcher::Dir(d) => { + for (index, path) in paths.iter().enumerate() { + if Path::new(path).starts_with(d) { + mask |= 1 << index; + } + } + } + WatchMatcher::Exact(p) => { + for (index, path) in paths.iter().enumerate() { + if Path::new(path) == p { + mask |= 1 << index; + } + } + } + } + + // Subtract per-subscription ignores from the match mask. + for g in &self.ignore.globs { + scratch.clear(); + glob_matches_into(g, paths, scratch); + for &index in scratch.iter() { + mask &= !(1 << index); + } + } + if !self.ignore.prefixes.is_empty() { + for (index, path) in paths.iter().enumerate() { + if self.ignore.prefix_matches(Path::new(path)) { + mask &= !(1 << index); + } + } + } + + mask + } +} + +struct CallbackDelivery { + sub: Arc, + events: Vec, + epoch: u64, +} + +enum CallbackMessage { + Deliver(Vec), + // used to drain all the callbacks and close the sender right after + Drain(mpsc::Sender<()>), + Stop, +} + +#[derive(Default)] +struct CallbackDispatcherState { + sender: Option>, + thread: Option>, +} + +#[derive(Default)] +struct CallbackDispatcher { + state: Mutex, +} + +impl CallbackDispatcher { + /// We have to use a separate thread becuause the callback is the actual C function pointer which + /// can block on the user side, we can not allow our watcher logic to get into deadlocked state + fn start(&self) -> Result<(), Error> { + let mut state = self.state.lock(); + if state.sender.is_some() { + return Ok(()); + } + + let (sender, receiver) = mpsc::channel(); + let thread = std::thread::Builder::new() + .name("fff-watch-callback".into()) + .spawn(move || { + while let Ok(message) = receiver.recv() { + match message { + CallbackMessage::Deliver(deliveries) => { + for delivery in deliveries { + if !delivery.sub.active.load(Ordering::Acquire) + || delivery.sub.epoch.load(Ordering::Acquire) != delivery.epoch + { + continue; + } + + let id = delivery.sub.id; + let result = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + (delivery.sub.callback)(id, &delivery.events) + })); + if result.is_err() { + error!(sub = id.0, "watch callback panicked"); + } + } + } + CallbackMessage::Drain(done) => { + let _ = done.send(()); + } + CallbackMessage::Stop => break, + } + } + }) + .map_err(Error::WatchDispatcherStart)?; + + state.sender = Some(sender); + state.thread = Some(thread); + Ok(()) + } + + fn deliver(&self, deliveries: Vec) { + if deliveries.is_empty() { + return; + } + let Some(sender) = self.state.lock().sender.clone() else { + error!("watch callback dispatcher is not running"); + return; + }; + if sender.send(CallbackMessage::Deliver(deliveries)).is_err() { + error!("watch callback dispatcher stopped unexpectedly"); + } + } + + fn drain(&self) { + let (sender, is_dispatch_thread) = { + let state = self.state.lock(); + let Some(sender) = state.sender.as_ref() else { + return; + }; + let is_dispatch_thread = state + .thread + .as_ref() + .is_some_and(|thread| thread.thread().id() == std::thread::current().id()); + (sender.clone(), is_dispatch_thread) + }; + + if is_dispatch_thread { + return; + } + + let (done_tx, done_rx) = mpsc::channel(); + if sender.send(CallbackMessage::Drain(done_tx)).is_ok() { + let _ = done_rx.recv(); + } + } +} + +impl Drop for CallbackDispatcher { + fn drop(&mut self) { + let state = self.state.get_mut(); + if let Some(sender) = state.sender.take() { + let _ = sender.send(CallbackMessage::Stop); + } + + if let Some(thread) = state.thread.take() + && thread.thread().id() != std::thread::current().id() + { + let _ = thread.join(); + } + } +} + +#[derive(Default)] +struct WatchRegistryState { + subs: Vec>, + base_path: Option, + epoch: u64, +} + +// External subscribers for one SharedFilePicker. +#[derive(Default)] +pub(crate) struct WatchRegistry { + state: Mutex, + dispatcher: CallbackDispatcher, +} + +// Process-wide ids let FFI clients route all instances through one map. +static NEXT_WATCH_ID: AtomicU64 = AtomicU64::new(1); + +impl WatchRegistry { + #[inline] + pub(crate) fn is_active(&self) -> bool { + !self.state.lock().subs.is_empty() + } + + pub(crate) fn subscribe( + &self, + base_path: &Path, + pattern: &str, + options: WatchOptions, + callback: WatchCallback, + ) -> Result { + let matcher = WatchMatcher::new(pattern, base_path)?; + let ignore = resolve_sub_ignore(&options.ignore, base_path)?; + + let mut state = self.state.lock(); + if state.base_path.as_deref() != Some(base_path) { + return Err(Error::WatchBaseChanged); + } + self.dispatcher.start()?; + + let id = WatchId(NEXT_WATCH_ID.fetch_add(1, Ordering::Relaxed)); + let sub = Arc::new(WatchSub { + id, + matcher, + ignore, + callback, + active: AtomicBool::new(true), + epoch: AtomicU64::new(state.epoch), + }); + + state.subs.push(sub); + Ok(id) + } + + pub(crate) fn unsubscribe(&self, id: WatchId) -> bool { + let mut state = self.state.lock(); + let Some(idx) = state.subs.iter().position(|s| s.id == id) else { + return false; + }; + let sub = state.subs.swap_remove(idx); + sub.active.store(false, Ordering::Release); + drop(state); + drop(sub); + true + } + + pub(crate) fn contains(&self, id: WatchId) -> bool { + self.state.lock().subs.iter().any(|sub| sub.id == id) + } + + pub(crate) fn shutdown(&self) { + let mut state = self.state.lock(); + let subs = std::mem::take(&mut state.subs); + for sub in &subs { + sub.active.store(false, Ordering::Release); + } + drop(state); + } + + pub(crate) fn shutdown_and_wait(&self) { + self.shutdown(); + self.dispatcher.drain(); + } + + pub(crate) fn rebase(&self, base_path: &Path) { + let mut state = self.state.lock(); + if state.base_path.as_deref() == Some(base_path) { + return; + } + + state.base_path = Some(base_path.to_path_buf()); + state.epoch = state.epoch.wrapping_add(1); + for sub in &state.subs { + sub.epoch.store(state.epoch, Ordering::Release); + } + drop(state); + self.dispatcher.drain(); + } + + pub(crate) fn dispatch(&self, base_path: &Path, events: Vec) { + if events.is_empty() { + return; + } + + let state = self.state.lock(); + if state.subs.is_empty() || state.base_path.as_deref() != Some(base_path) { + return; + } + + for batch in events.chunks(MAX_BATCH_EVENTS) { + let mut paths = Vec::with_capacity(batch.len()); + let mut visible_mask = 0; + let mut rescan_mask = 0; + + for (index, event) in batch.iter().enumerate() { + let relative = event + .path + .strip_prefix(base_path) + .expect("watch event path must be inside the indexed base path"); + paths.push(relative.to_string_lossy().replace('\\', "/")); + + let bit = 1 << index; + if event.kind == WatchEventKind::Rescan { + rescan_mask |= bit; + } else if !event.is_ignored { + visible_mask |= bit; + } + } + + let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect(); + let mut scratch = Vec::new(); + let mut deliveries = Vec::with_capacity(state.subs.len()); + for sub in &state.subs { + let matched = sub.filter_mask(&path_refs, &mut scratch); + let mut delivery_mask = (matched & visible_mask) | rescan_mask; + if delivery_mask == 0 { + continue; + } + + let mut filtered = Vec::with_capacity(delivery_mask.count_ones() as usize); + while delivery_mask != 0 { + let index = delivery_mask.trailing_zeros() as usize; + let event = &batch[index]; + filtered.push(WatchEvent { + path: event.path.clone(), + kind: event.kind, + }); + delivery_mask &= delivery_mask - 1; + } + + debug!( + sub = sub.id.0, + count = filtered.len(), + "queueing watch events" + ); + deliveries.push(CallbackDelivery { + sub: Arc::clone(sub), + events: filtered, + epoch: state.epoch, + }); + } + self.dispatcher.deliver(deliveries); + } + } + + // Signal that individual events were lost. + pub(crate) fn dispatch_rescan(&self, base_path: &Path) { + self.dispatch( + base_path, + vec![RawWatchEvent { + path: base_path.to_path_buf(), + kind: WatchEventKind::Rescan, + is_ignored: false, + }], + ); + } +} + +impl Drop for WatchRegistry { + fn drop(&mut self) { + self.shutdown(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use parking_lot::{Condvar, Mutex}; + use std::sync::atomic::{AtomicBool, AtomicUsize}; + use std::time::Duration; + + fn raw(path: &str, kind: WatchEventKind, is_ignored: bool) -> RawWatchEvent { + RawWatchEvent { + path: PathBuf::from(path), + kind, + is_ignored, + } + } + + fn registry(base: &Path) -> Arc { + let registry = Arc::new(WatchRegistry::default()); + registry.rebase(base); + registry + } + + type Collected = Arc>>; + + // Appends every delivered event. + fn collector() -> (WatchCallback, Collected) { + let collected: Collected = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&collected); + let cb: WatchCallback = Box::new(move |_id, events| sink.lock().extend_from_slice(events)); + (cb, collected) + } + + // Dispatch is asynchronous. + fn wait_events(collected: &Collected, n: usize) -> Vec { + for _ in 0..1000 { + if collected.lock().len() >= n { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + collected.lock().clone() + } + + #[test] + fn dispatcher_starts_on_first_subscription() { + let base = Path::new("/repo"); + let registry = WatchRegistry::default(); + registry.rebase(base); + + assert!(registry.dispatcher.state.lock().thread.is_none()); + let (cb, _) = collector(); + registry + .subscribe(base, "**", WatchOptions::default(), cb) + .unwrap(); + assert!(registry.dispatcher.state.lock().thread.is_some()); + } + + #[test] + fn resolve_relative_glob() { + let base = Path::new("/repo"); + assert!(matches!( + WatchMatcher::new("./**/*.rs", base).unwrap(), + WatchMatcher::Glob(_) + )); + assert!(matches!( + WatchMatcher::new("src/*.ts", base).unwrap(), + WatchMatcher::Glob(_) + )); + } + + #[test] + fn reject_absolute_glob_outside_base() { + assert!(WatchMatcher::new("/other/**/*.rs", Path::new("/repo")).is_err()); + } + + #[test] + fn resolve_exact_paths() { + let base = std::env::temp_dir(); + let inside = base.join("some_file.txt"); + match WatchMatcher::new(inside.to_str().unwrap(), &base).unwrap() { + WatchMatcher::Exact(path) => assert_eq!(path, Path::new("some_file.txt")), + _ => panic!("expected exact"), + } + match WatchMatcher::new("relative_file.txt", &base).unwrap() { + WatchMatcher::Exact(path) => assert_eq!(path, Path::new("relative_file.txt")), + _ => panic!("expected exact"), + } + assert!(WatchMatcher::new("../outside", &base).is_err()); + } + + #[test] + fn resolve_existing_dir_as_subtree() { + let tmp = tempfile::TempDir::new().unwrap(); + let base = tmp.path().to_path_buf(); + std::fs::create_dir(base.join("src")).unwrap(); + + match WatchMatcher::new("src", &base).unwrap() { + WatchMatcher::Dir(path) => assert_eq!(path, Path::new("src")), + _ => panic!("expected dir"), + } + match WatchMatcher::new(base.to_str().unwrap(), &base).unwrap() { + WatchMatcher::Dir(path) => assert!(path.as_os_str().is_empty()), + _ => panic!("expected dir"), + } + } + + #[test] + fn resolve_empty_pattern_as_whole_tree() { + let tmp = tempfile::TempDir::new().unwrap(); + let base = tmp.path().to_path_buf(); + + for pattern in ["", " "] { + match WatchMatcher::new(pattern, &base).unwrap() { + WatchMatcher::Dir(path) => assert!(path.as_os_str().is_empty()), + _ => panic!("expected dir"), + } + } + } + + #[test] + fn mixed_batch_dispatch_preserves_order_and_filters() { + let base = Path::new("/repo"); + let registry = registry(base); + + let (glob_cb, glob_events) = collector(); + registry + .subscribe( + base, + "**/*.rs", + WatchOptions { + ignore: vec!["src/vendor".into(), "*.gen.rs".into()], + }, + glob_cb, + ) + .unwrap(); + let (dir_cb, dir_events) = collector(); + registry + .subscribe(base, "src/**", WatchOptions::default(), dir_cb) + .unwrap(); + let (exact_cb, exact_events) = collector(); + registry + .subscribe(base, "dist/out.js", WatchOptions::default(), exact_cb) + .unwrap(); + + registry.dispatch( + base, + vec![ + raw("/repo/src/a.rs", WatchEventKind::Created, false), + raw("/repo/src/b.gen.rs", WatchEventKind::Modified, false), + raw("/repo/src/vendor/c.rs", WatchEventKind::Modified, false), + raw("/repo/lib/d.rs", WatchEventKind::Removed, false), + raw("/repo/dist/out.js", WatchEventKind::Modified, true), // index-ignored + raw("/repo/src/e.txt", WatchEventKind::Created, true), // index-ignored + ], + ); + + let glob = wait_events(&glob_events, 2); + let paths: Vec<_> = glob.iter().map(|e| e.path.clone()).collect(); + assert_eq!( + paths, + vec![ + PathBuf::from("/repo/src/a.rs"), + PathBuf::from("/repo/lib/d.rs"), + ] + ); + + let dir = wait_events(&dir_events, 3); + let paths: Vec<_> = dir.iter().map(|e| e.path.clone()).collect(); + assert_eq!( + paths, + vec![ + PathBuf::from("/repo/src/a.rs"), + PathBuf::from("/repo/src/b.gen.rs"), + PathBuf::from("/repo/src/vendor/c.rs"), + ] + ); + + assert!(exact_events.lock().is_empty()); + } + + #[test] + fn rescan_is_broadcast_to_every_subscription() { + let base = Path::new("/repo"); + let registry = registry(base); + let (glob_cb, glob_events) = collector(); + let (exact_cb, exact_events) = collector(); + registry + .subscribe(base, "src/**", WatchOptions::default(), glob_cb) + .unwrap(); + registry + .subscribe(base, "dist/out.js", WatchOptions::default(), exact_cb) + .unwrap(); + + registry.dispatch_rescan(base); + + for events in [&glob_events, &exact_events] { + let events = wait_events(events, 1); + assert_eq!(events.len(), 1); + assert_eq!(events[0].path, base); + assert_eq!(events[0].kind, WatchEventKind::Rescan); + } + } + + #[test] + fn registry_dispatch_matches_glob_and_batches() { + let base = Path::new("/repo"); + let registry = registry(base); + let hits = Arc::new(Mutex::new(Vec::::new())); + let calls = Arc::new(AtomicUsize::new(0)); + + let hits_cb = Arc::clone(&hits); + let calls_cb = Arc::clone(&calls); + let id = registry + .subscribe( + base, + "**/*.rs", + WatchOptions::default(), + Box::new(move |_id, events| { + calls_cb.fetch_add(1, Ordering::SeqCst); + hits_cb.lock().extend_from_slice(events); + }), + ) + .unwrap(); + + registry.dispatch( + base, + vec![ + raw("/repo/src/a.rs", WatchEventKind::Modified, false), + raw("/repo/src/b.ts", WatchEventKind::Modified, false), + raw("/repo/target/c.rs", WatchEventKind::Created, true), + ], + ); + + for _ in 0..400 { + if calls.load(Ordering::SeqCst) == 1 { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + let events = hits.lock(); + // ignored + non-matching + out-of-tree are all filtered, in ONE call + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(events.len(), 1); + assert_eq!(events[0].path, PathBuf::from("/repo/src/a.rs")); + + assert!(registry.unsubscribe(id)); + assert!(!registry.is_active()); + assert!(!registry.unsubscribe(id)); + } + + #[test] + fn large_batch_is_delivered_without_coalescing() { + let base = Path::new("/repo"); + let registry = registry(base); + + let collected: Collected = Arc::new(Mutex::new(Vec::new())); + let batch_sizes = Arc::new(Mutex::new(Vec::new())); + let collected_cb = Arc::clone(&collected); + let batch_sizes_cb = Arc::clone(&batch_sizes); + registry + .subscribe( + base, + "**/*.rs", + WatchOptions::default(), + Box::new(move |_, events| { + batch_sizes_cb.lock().push(events.len()); + collected_cb.lock().extend_from_slice(events); + }), + ) + .unwrap(); + + let events: Vec = (0..257) + .map(|i| { + raw( + &format!("/repo/src/f{i}.rs"), + WatchEventKind::Modified, + false, + ) + }) + .collect(); + registry.dispatch(base, events); + + let delivered = wait_events(&collected, 257); + assert_eq!(delivered.len(), 257); + assert!( + delivered + .iter() + .all(|event| event.kind == WatchEventKind::Modified) + ); + assert_eq!(*batch_sizes.lock(), vec![128, 128, 1]); + } + + #[test] + fn duplicate_paths_are_delivered_in_order() { + let base = Path::new("/repo"); + let registry = registry(base); + let (cb, collected) = collector(); + registry + .subscribe(base, "**", WatchOptions::default(), cb) + .unwrap(); + + registry.dispatch( + base, + vec![ + raw("/repo/a.rs", WatchEventKind::Created, false), + raw("/repo/a.rs", WatchEventKind::Modified, false), + raw("/repo/a.rs", WatchEventKind::Removed, false), + ], + ); + + let delivered = wait_events(&collected, 3); + let kinds: Vec<_> = delivered.iter().map(|event| event.kind).collect(); + assert_eq!( + kinds, + vec![ + WatchEventKind::Created, + WatchEventKind::Modified, + WatchEventKind::Removed, + ] + ); + } + + #[test] + fn rebase_keeps_relative_subscriptions() { + let old_base = Path::new("/old"); + let new_base = Path::new("/new"); + let registry = registry(old_base); + let (cb, collected) = collector(); + let id = registry + .subscribe(old_base, "src/**", WatchOptions::default(), cb) + .unwrap(); + + registry.dispatch( + old_base, + vec![raw("/old/src/a.rs", WatchEventKind::Created, false)], + ); + assert_eq!(wait_events(&collected, 1).len(), 1); + + registry.rebase(new_base); + assert!(registry.contains(id)); + registry.dispatch( + old_base, + vec![raw("/old/src/stale.rs", WatchEventKind::Created, false)], + ); + registry.dispatch( + new_base, + vec![raw("/new/src/b.rs", WatchEventKind::Created, false)], + ); + + let delivered = wait_events(&collected, 2); + let paths: Vec<_> = delivered.iter().map(|event| event.path.clone()).collect(); + assert_eq!( + paths, + vec![ + PathBuf::from("/old/src/a.rs"), + PathBuf::from("/new/src/b.rs") + ] + ); + } + + #[test] + fn rebase_from_callback_skips_queued_old_events() { + let old_base = Path::new("/old"); + let new_base = PathBuf::from("/new"); + let registry = registry(old_base); + let collected: Collected = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&collected); + let weak = Arc::downgrade(®istry); + let callback_base = new_base.clone(); + + registry + .subscribe( + old_base, + "**", + WatchOptions::default(), + Box::new(move |_, events| { + sink.lock().extend_from_slice(events); + if let Some(registry) = weak.upgrade() { + registry.rebase(&callback_base); + } + }), + ) + .unwrap(); + + registry.dispatch( + old_base, + (0..129) + .map(|index| { + raw( + &format!("/old/file-{index}"), + WatchEventKind::Modified, + false, + ) + }) + .collect(), + ); + + let old_events = wait_events(&collected, 128); + assert_eq!(old_events.len(), 128); + assert!( + !old_events + .iter() + .any(|event| event.path == Path::new("/old/file-128")) + ); + + registry.dispatch( + &new_base, + vec![raw("/new/current", WatchEventKind::Created, false)], + ); + let events = wait_events(&collected, 129); + assert_eq!(events.last().unwrap().path, Path::new("/new/current")); + } + + #[test] + fn callback_panic_does_not_stop_dispatcher() { + let base = Path::new("/repo"); + let registry = registry(base); + registry + .subscribe( + base, + "**", + WatchOptions::default(), + Box::new(|_, _| panic!("test callback panic")), + ) + .unwrap(); + let (cb, collected) = collector(); + registry + .subscribe(base, "**", WatchOptions::default(), cb) + .unwrap(); + + registry.dispatch( + base, + vec![raw("/repo/a.rs", WatchEventKind::Created, false)], + ); + assert_eq!(wait_events(&collected, 1).len(), 1); + } + + #[test] + fn shutdown_and_wait_joins_in_flight_callback() { + let base = Path::new("/repo"); + let registry = registry(base); + let (started_tx, started_rx) = mpsc::channel(); + let release = Arc::new((Mutex::new(false), Condvar::new())); + let release_cb = Arc::clone(&release); + registry + .subscribe( + base, + "**", + WatchOptions::default(), + Box::new(move |_, _| { + let _ = started_tx.send(()); + let (released, ready) = &*release_cb; + ready.wait_while(&mut released.lock(), |released| !*released); + }), + ) + .unwrap(); + registry.dispatch( + base, + vec![raw("/repo/a.rs", WatchEventKind::Created, false)], + ); + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + + let registry_wait = Arc::clone(®istry); + let waiting = std::thread::spawn(move || registry_wait.shutdown_and_wait()); + std::thread::sleep(Duration::from_millis(20)); + assert!(!waiting.is_finished()); + + let (released, ready) = &*release; + *released.lock() = true; + ready.notify_all(); + waiting.join().unwrap(); + assert!(!registry.is_active()); + } + + #[test] + fn index_ignored_events_are_never_delivered() { + let base = Path::new("/repo"); + let registry = registry(base); + + let (cb, events) = collector(); + registry + .subscribe(base, "dist/**", WatchOptions::default(), cb) + .unwrap(); + + registry.dispatch( + base, + vec![ + raw("/repo/dist/bundle.js", WatchEventKind::Created, true), + raw("/repo/dist/keep.js", WatchEventKind::Created, false), + ], + ); + + let got = wait_events(&events, 1); + assert_eq!(got.len(), 1); + assert_eq!(got[0].path, PathBuf::from("/repo/dist/keep.js")); + } + + #[test] + fn callback_receives_its_subscription_id_and_ids_are_unique() { + let base = Path::new("/repo"); + let a = registry(base); + let b = registry(base); + + let seen_id = Arc::new(Mutex::new(None::)); + let seen_cb = Arc::clone(&seen_id); + let id_a = a + .subscribe( + base, + "**", + WatchOptions::default(), + Box::new(move |id, _| { + *seen_cb.lock() = Some(id); + }), + ) + .unwrap(); + let (b_cb, _b_events) = collector(); + let id_b = b + .subscribe(base, "**", WatchOptions::default(), b_cb) + .unwrap(); + + // ids are process-wide unique, even across registries (instances) + assert_ne!(id_a, id_b); + + a.dispatch( + base, + vec![raw("/repo/a.rs", WatchEventKind::Modified, false)], + ); + for _ in 0..200 { + if seen_id.lock().is_some() { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + assert_eq!(*seen_id.lock(), Some(id_a)); + } + + #[test] + fn shutdown_quiesces_and_allows_restart() { + let base = Path::new("/repo"); + let registry = registry(base); + let calls = Arc::new(AtomicUsize::new(0)); + + let calls_cb = Arc::clone(&calls); + registry + .subscribe( + base, + "**", + WatchOptions::default(), + Box::new(move |_, _| { + calls_cb.fetch_add(1, Ordering::SeqCst); + }), + ) + .unwrap(); + + registry.shutdown(); + assert!(!registry.is_active()); + + // after shutdown: no deliveries + registry.dispatch( + base, + vec![raw("/repo/a.rs", WatchEventKind::Modified, false)], + ); + std::thread::sleep(std::time::Duration::from_millis(100)); + assert_eq!(calls.load(Ordering::SeqCst), 0); + + // shutdown is idempotent and the registry restarts on next subscribe + registry.shutdown(); + let (cb, events) = collector(); + registry + .subscribe(base, "**", WatchOptions::default(), cb) + .unwrap(); + registry.dispatch( + base, + vec![raw("/repo/b.rs", WatchEventKind::Created, false)], + ); + assert_eq!(wait_events(&events, 1).len(), 1); + } + + #[test] + fn unsubscribe_from_inside_callback_does_not_deadlock() { + let base = Path::new("/repo"); + let registry = registry(base); + let unsubscribed = Arc::new(AtomicBool::new(false)); + + let registry_cb = Arc::downgrade(®istry); + let unsub_cb = Arc::clone(&unsubscribed); + // one-shot pattern: the callback removes its own subscription + registry + .subscribe( + base, + "**", + WatchOptions::default(), + Box::new(move |id, _| { + if let Some(registry) = registry_cb.upgrade() { + registry.unsubscribe(id); + unsub_cb.store(true, Ordering::SeqCst); + } + }), + ) + .unwrap(); + + registry.dispatch( + base, + vec![raw("/repo/a.rs", WatchEventKind::Modified, false)], + ); + + for _ in 0..400 { + if unsubscribed.load(Ordering::SeqCst) { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + assert!( + unsubscribed.load(Ordering::SeqCst), + "self-unsubscribe from the callback deadlocked" + ); + assert!(!registry.is_active()); + } +} diff --git a/crates/fff-core/tests/dir_index_consistency_test.rs b/crates/fff-core/tests/dir_index_consistency_test.rs new file mode 100644 index 000000000..0fd766e1e --- /dev/null +++ b/crates/fff-core/tests/dir_index_consistency_test.rs @@ -0,0 +1,256 @@ +use fff_search::file_picker::{FFFMode, FilePicker}; +use fff_search::{ + DirSearchConfig, FilePickerOptions, FuzzySearchOptions, PaginationArgs, QueryParser, + SharedFilePicker, SharedFrecency, +}; +use std::fs; +use std::path::Path; +use std::time::{Duration, Instant}; +use tempfile::TempDir; + +fn make_watched_picker(base: &Path) -> (SharedFilePicker, SharedFrecency) { + let shared_picker = SharedFilePicker::default(); + let shared_frecency = SharedFrecency::noop(); + + FilePicker::new_with_shared_state( + shared_picker.clone(), + shared_frecency.clone(), + FilePickerOptions { + base_path: base.to_string_lossy().into_owned(), + enable_mmap_cache: false, + enable_content_indexing: false, + mode: FFFMode::Neovim, + watch: true, + ..Default::default() + }, + ) + .expect("FilePicker::new_with_shared_state"); + + assert!( + shared_picker.wait_for_scan(Duration::from_secs(30)), + "initial scan did not complete" + ); + assert!( + shared_picker.wait_for_watcher(Duration::from_secs(30)), + "watcher did not install" + ); + // macOS FSEvents streams need a beat before they deliver reliably + std::thread::sleep(Duration::from_millis(300)); + + (shared_picker, shared_frecency) +} + +fn search_dirs(picker: &SharedFilePicker, query: &str) -> Vec { + let guard = picker.read().expect("picker read lock"); + let p = guard.as_ref().expect("picker initialized"); + let parser = QueryParser::new(DirSearchConfig); + let parsed = parser.parse(query); + let results = p.fuzzy_search_directories( + &parsed, + FuzzySearchOptions { + pagination: PaginationArgs { + offset: 0, + limit: 100, + }, + ..Default::default() + }, + ); + results.items.iter().map(|d| d.relative_path(p)).collect() +} + +fn wait_until bool>(cond: F, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if cond() { + return true; + } + std::thread::sleep(Duration::from_millis(50)); + } + cond() +} + +#[test] +fn removed_directory_disappears_from_dir_search() { + let tmp = TempDir::new().unwrap(); + let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap(); + fs::create_dir_all(base.join("doomed/nested")).unwrap(); + fs::write(base.join("doomed/a.rs"), "x").unwrap(); + fs::write(base.join("doomed/nested/b.rs"), "x").unwrap(); + fs::write(base.join("keep.rs"), "x").unwrap(); + + let (picker, _frecency) = make_watched_picker(&base); + assert!( + search_dirs(&picker, "doomed") + .iter() + .any(|d| d.starts_with("doomed")), + "sanity: dir indexed after scan" + ); + + fs::remove_dir_all(base.join("doomed")).unwrap(); + + assert!( + wait_until( + || !search_dirs(&picker, "doomed") + .iter() + .any(|d| d.starts_with("doomed")), + Duration::from_secs(10) + ), + "removed dir must disappear from dir search, got: {:?}", + search_dirs(&picker, "doomed") + ); +} + +#[test] +fn moved_out_directory_disappears_from_dir_search() { + let tmp = TempDir::new().unwrap(); + let trash = TempDir::new().unwrap(); + let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap(); + fs::create_dir_all(base.join("doomed/nested")).unwrap(); + fs::write(base.join("doomed/a.rs"), "x").unwrap(); + fs::write(base.join("doomed/nested/b.rs"), "x").unwrap(); + fs::write(base.join("keep.rs"), "x").unwrap(); + + let (picker, _frecency) = make_watched_picker(&base); + assert!( + search_dirs(&picker, "doomed") + .iter() + .any(|d| d.starts_with("doomed")), + "sanity: dir indexed after scan" + ); + + fs::rename(base.join("doomed"), trash.path().join("doomed")).unwrap(); + + assert!( + wait_until( + || !search_dirs(&picker, "doomed") + .iter() + .any(|d| d.starts_with("doomed")), + Duration::from_secs(10) + ), + "moved-out dir must disappear from dir search, got: {:?}", + search_dirs(&picker, "doomed") + ); +} + +#[test] +fn moved_in_directory_appears_in_dir_search() { + let tmp = TempDir::new().unwrap(); + let staging = TempDir::new().unwrap(); + let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap(); + fs::write(base.join("keep.rs"), "x").unwrap(); + + let incoming = staging.path().join("arrived"); + fs::create_dir_all(incoming.join("nested")).unwrap(); + fs::write(incoming.join("a.rs"), "x").unwrap(); + fs::write(incoming.join("nested/b.rs"), "x").unwrap(); + + let (picker, _frecency) = make_watched_picker(&base); + assert!(search_dirs(&picker, "arrived").is_empty(), "sanity"); + + fs::rename(&incoming, base.join("arrived")).unwrap(); + + assert!( + wait_until( + || { + let dirs = search_dirs(&picker, "arrived"); + dirs.iter().any(|d| d.starts_with("arrived")) + }, + Duration::from_secs(10) + ), + "moved-in dir must appear in dir search, got: {:?}", + search_dirs(&picker, "arrived") + ); +} + +#[test] +fn new_file_in_new_directory_surfaces_the_dir() { + let tmp = TempDir::new().unwrap(); + let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap(); + fs::write(base.join("keep.rs"), "x").unwrap(); + + let (picker, _frecency) = make_watched_picker(&base); + assert!(search_dirs(&picker, "brandnew").is_empty(), "sanity"); + + fs::create_dir_all(base.join("brandnew")).unwrap(); + fs::write(base.join("brandnew/file.rs"), "x").unwrap(); + + assert!( + wait_until( + || search_dirs(&picker, "brandnew") + .iter() + .any(|d| d.starts_with("brandnew")), + Duration::from_secs(10) + ), + "new dir must appear in dir search, got: {:?}", + search_dirs(&picker, "brandnew") + ); +} + +#[test] +fn deleting_last_file_keeps_directory_visible() { + let tmp = TempDir::new().unwrap(); + let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap(); + fs::create_dir_all(base.join("lonely")).unwrap(); + fs::write(base.join("lonely/only.rs"), "x").unwrap(); + fs::write(base.join("keep.rs"), "x").unwrap(); + + let (picker, _frecency) = make_watched_picker(&base); + + // the file goes away but the directory itself still exists on disk + fs::remove_file(base.join("lonely/only.rs")).unwrap(); + + assert!( + wait_until( + || { + let guard = picker.read().unwrap(); + let p = guard.as_ref().unwrap(); + p.get_file_by_path(base.join("lonely/only.rs")) + .is_none_or(|f| f.is_deleted()) + }, + Duration::from_secs(10) + ), + "file removal must be applied" + ); + assert!( + search_dirs(&picker, "lonely") + .iter() + .any(|d| d.starts_with("lonely")), + "dir still exists on disk and must stay searchable" + ); +} + +#[test] +fn recreated_directory_reappears_in_dir_search() { + let tmp = TempDir::new().unwrap(); + let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap(); + fs::create_dir_all(base.join("phoenix")).unwrap(); + fs::write(base.join("phoenix/a.rs"), "x").unwrap(); + fs::write(base.join("keep.rs"), "x").unwrap(); + + let (picker, _frecency) = make_watched_picker(&base); + + fs::remove_dir_all(base.join("phoenix")).unwrap(); + assert!( + wait_until( + || !search_dirs(&picker, "phoenix") + .iter() + .any(|d| d.starts_with("phoenix")), + Duration::from_secs(10) + ), + "dir must disappear after removal" + ); + + fs::create_dir_all(base.join("phoenix")).unwrap(); + fs::write(base.join("phoenix/a.rs"), "x").unwrap(); + + assert!( + wait_until( + || search_dirs(&picker, "phoenix") + .iter() + .any(|d| d.starts_with("phoenix")), + Duration::from_secs(10) + ), + "recreated dir must reappear in dir search, got: {:?}", + search_dirs(&picker, "phoenix") + ); +} diff --git a/crates/fff-core/tests/new_directory_watcher_test.rs b/crates/fff-core/tests/new_directory_watcher_test.rs index 9cb9fc11d..a32d17386 100644 --- a/crates/fff-core/tests/new_directory_watcher_test.rs +++ b/crates/fff-core/tests/new_directory_watcher_test.rs @@ -79,11 +79,11 @@ fn make_watched_picker(base: &Path) -> (SharedFilePicker, SharedFrecency) { /// Wait for the initial scan + watcher to be fully ready. fn wait_ready(shared_picker: &SharedFilePicker) { assert!( - shared_picker.wait_for_scan(Duration::from_secs(10)), + shared_picker.wait_for_scan(Duration::from_secs(30)), "Timed out waiting for initial scan" ); assert!( - shared_picker.wait_for_watcher(Duration::from_secs(10)), + shared_picker.wait_for_watcher(Duration::from_secs(30)), "Timed out waiting for watcher" ); } diff --git a/crates/fff-core/tests/watch_subscription_test.rs b/crates/fff-core/tests/watch_subscription_test.rs new file mode 100644 index 000000000..9c6385f37 --- /dev/null +++ b/crates/fff-core/tests/watch_subscription_test.rs @@ -0,0 +1,531 @@ +use fff_search::file_picker::{FFFMode, FilePicker}; +use fff_search::{ + FilePickerOptions, SharedFilePicker, SharedFrecency, WatchEvent, WatchEventKind, WatchOptions, +}; +use parking_lot::Mutex; +use std::fs; +use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; +use tempfile::TempDir; + +fn make_watched_picker(base: &Path) -> (SharedFilePicker, SharedFrecency) { + let shared_picker = SharedFilePicker::default(); + let shared_frecency = SharedFrecency::noop(); + + FilePicker::new_with_shared_state( + shared_picker.clone(), + shared_frecency.clone(), + FilePickerOptions { + base_path: base.to_string_lossy().into_owned(), + enable_mmap_cache: false, + enable_content_indexing: false, + mode: FFFMode::Neovim, + watch: true, + ..Default::default() + }, + ) + .expect("FilePicker::new_with_shared_state"); + + assert!( + shared_picker.wait_for_scan(Duration::from_secs(30)), + "initial scan did not complete" + ); + assert!( + shared_picker.wait_for_watcher(Duration::from_secs(30)), + "watcher did not install" + ); + // macOS FSEvents streams need a beat before they deliver reliably + std::thread::sleep(Duration::from_millis(300)); + + (shared_picker, shared_frecency) +} + +fn wait_for bool>(cond: F, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if cond() { + return true; + } + std::thread::sleep(Duration::from_millis(25)); + } + cond() +} + +fn seed(base: &Path) { + fs::create_dir_all(base.join("src")).unwrap(); + fs::write(base.join("src/main.rs"), "fn main() {}\n").unwrap(); + fs::write(base.join("README.md"), "# seed\n").unwrap(); +} + +type Collected = Arc>>; + +/// Subscribe with a collector callback; returns the shared event sink. +fn watch_collect(picker: &SharedFilePicker, pattern: &str, options: WatchOptions) -> Collected { + let collected: Collected = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&collected); + picker + .watch(pattern, options, move |_id, events| { + sink.lock().extend_from_slice(events) + }) + .expect("watch subscription failed"); + collected +} + +#[test] +fn glob_subscription_receives_created_and_removed_events() { + let tmp = TempDir::new().unwrap(); + let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap(); + seed(&base); + let (picker, _frecency) = make_watched_picker(&base); + + let events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let calls = Arc::new(AtomicUsize::new(0)); + + let events_cb = Arc::clone(&events); + let calls_cb = Arc::clone(&calls); + let id = picker + .watch("**/*.rs", WatchOptions::default(), move |_id, batch| { + calls_cb.fetch_add(1, Ordering::SeqCst); + events_cb.lock().extend_from_slice(batch); + }) + .expect("subscribe glob"); + + let rs_file = base.join("src/new_module.rs"); + let ts_file = base.join("src/ignored_by_glob.ts"); + fs::write(&rs_file, "pub fn hi() {}\n").unwrap(); + fs::write(&ts_file, "export {};\n").unwrap(); + + assert!( + wait_for( + || events.lock().iter().any(|e| e.path == rs_file), + Duration::from_secs(10) + ), + "did not receive event for created .rs file, got: {:?}", + events.lock() + ); + assert!( + !events.lock().iter().any(|e| e.path == ts_file), + ".ts file must not match the *.rs glob" + ); + + fs::remove_file(&rs_file).unwrap(); + assert!( + wait_for( + || events + .lock() + .iter() + .any(|e| e.path == rs_file && e.kind == WatchEventKind::Removed), + Duration::from_secs(10) + ), + "did not receive Removed event, got: {:?}", + events.lock() + ); + + // batching: each debounce window is one callback invocation, so the call + // count must be well below the delivered event count + noise ceiling + assert!(calls.load(Ordering::SeqCst) <= events.lock().len() + 2); + + assert!(picker.unwatch(id)); + let count_after = events.lock().len(); + fs::write(base.join("src/after_unsub.rs"), "\n").unwrap(); + std::thread::sleep(Duration::from_millis(500)); + assert_eq!( + events.lock().len(), + count_after, + "no events after unsubscribe" + ); +} + +#[test] +fn watch_events_reflect_applied_file_transitions() { + let tmp = TempDir::new().unwrap(); + let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap(); + let removed_path = base.join("removed.txt"); + let created_path = base.join("created.txt"); + let replaced_path = base.join("replaced.txt"); + fs::write(&removed_path, "remove me").unwrap(); + fs::write(&replaced_path, "before").unwrap(); + + let (picker, _frecency) = make_watched_picker(&base); + let removed = watch_collect( + &picker, + removed_path.to_str().unwrap(), + WatchOptions::default(), + ); + let created = watch_collect( + &picker, + created_path.to_str().unwrap(), + WatchOptions::default(), + ); + let replaced = watch_collect( + &picker, + replaced_path.to_str().unwrap(), + WatchOptions::default(), + ); + + fs::remove_file(&removed_path).unwrap(); + assert!( + wait_for(|| !removed.lock().is_empty(), Duration::from_secs(10)), + "remove event was not delivered" + ); + + fs::write(&created_path, "created").unwrap(); + assert!( + wait_for(|| !created.lock().is_empty(), Duration::from_secs(10)), + "create event was not delivered" + ); + + fs::remove_file(&replaced_path).unwrap(); + fs::write(&replaced_path, "after").unwrap(); + assert!( + wait_for(|| !replaced.lock().is_empty(), Duration::from_secs(10)), + "replacement event was not delivered" + ); + + std::thread::sleep(Duration::from_millis(300)); + let removed = removed.lock(); + assert_eq!(removed.len(), 1, "unexpected remove events: {removed:?}"); + assert_eq!(removed[0].path, removed_path); + assert_eq!(removed[0].kind, WatchEventKind::Removed); + + let created = created.lock(); + assert_eq!(created.len(), 1, "unexpected create events: {created:?}"); + assert_eq!(created[0].path, created_path); + assert_eq!(created[0].kind, WatchEventKind::Created); + + let replaced = replaced.lock(); + assert_eq!( + replaced.len(), + 1, + "replacement must be one event: {replaced:?}" + ); + assert_eq!(replaced[0].path, replaced_path); + assert_eq!(replaced[0].kind, WatchEventKind::Modified); +} + +#[test] +fn removed_directory_delivers_removed_event_per_file() { + let tmp = TempDir::new().unwrap(); + let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap(); + seed(&base); + let dir = base.join("doomed"); + fs::create_dir_all(dir.join("nested")).unwrap(); + let files = [dir.join("a.rs"), dir.join("b.txt"), dir.join("nested/c.rs")]; + for f in &files { + fs::write(f, "content\n").unwrap(); + } + + let (picker, _frecency) = make_watched_picker(&base); + let events = watch_collect(&picker, "", WatchOptions::default()); + + fs::remove_dir_all(&dir).unwrap(); + + assert!( + wait_for( + || { + let got = events.lock(); + files.iter().all(|f| { + got.iter() + .any(|e| e.path == *f && e.kind == WatchEventKind::Removed) + }) + }, + Duration::from_secs(10) + ), + "expected Removed for every file in the removed dir, got: {:?}", + events.lock() + ); +} + +#[test] +fn moved_out_directory_delivers_removed_event_per_file() { + let tmp = TempDir::new().unwrap(); + let trash = TempDir::new().unwrap(); + let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap(); + seed(&base); + let dir = base.join("doomed"); + fs::create_dir_all(dir.join("nested")).unwrap(); + let files = [dir.join("a.rs"), dir.join("b.txt"), dir.join("nested/c.rs")]; + for f in &files { + fs::write(f, "content\n").unwrap(); + } + + let (picker, _frecency) = make_watched_picker(&base); + let events = watch_collect(&picker, "", WatchOptions::default()); + + // mimics `mv dir elsewhere` / Finder trash: one rename event on the dir, + // no per-file remove events from the OS + fs::rename(&dir, trash.path().join("doomed")).unwrap(); + + assert!( + wait_for( + || { + let got = events.lock(); + files.iter().all(|f| { + got.iter() + .any(|e| e.path == *f && e.kind == WatchEventKind::Removed) + }) + }, + Duration::from_secs(10) + ), + "expected Removed for every file in the moved-out dir, got: {:?}", + events.lock() + ); +} + +#[test] +fn empty_pattern_watches_the_whole_tree() { + let tmp = TempDir::new().unwrap(); + let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap(); + seed(&base); + let (picker, _frecency) = make_watched_picker(&base); + + let events = watch_collect( + &picker, + "", + WatchOptions { + ignore: vec!["*.log".to_string()], + ..Default::default() + }, + ); + + let rs_file = base.join("src/anywhere.rs"); + let txt_file = base.join("notes.txt"); + let log_file = base.join("noise.log"); + fs::write(&rs_file, "\n").unwrap(); + fs::write(&txt_file, "\n").unwrap(); + fs::write(&log_file, "\n").unwrap(); + + assert!( + wait_for( + || { + let got = events.lock(); + got.iter().any(|e| e.path == rs_file) && got.iter().any(|e| e.path == txt_file) + }, + Duration::from_secs(10) + ), + "watch-all did not receive events for both files, got: {:?}", + events.lock() + ); + // the ignore option still filters within a watch-all subscription + std::thread::sleep(Duration::from_millis(300)); + assert!( + !events.lock().iter().any(|e| e.path == log_file), + "*.log must be filtered by the ignore option" + ); +} + +#[test] +fn exact_out_of_tree_paths_are_rejected() { + let tmp = TempDir::new().unwrap(); + let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap(); + seed(&base); + + let outside = TempDir::new().unwrap(); + let outside_file = fff_search::path_utils::canonicalize(outside.path()) + .unwrap() + .join("config.txt"); + fs::write(&outside_file, "v1").unwrap(); + + let (picker, _frecency) = make_watched_picker(&base); + + assert!( + picker + .watch( + outside_file.to_str().unwrap(), + WatchOptions::default(), + |_, _| {} + ) + .is_err(), + "exact paths outside the indexed tree must be rejected" + ); +} + +#[test] +fn gitignored_files_are_never_delivered() { + let tmp = TempDir::new().unwrap(); + let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap(); + seed(&base); + fs::create_dir_all(base.join("dist")).unwrap(); + fs::write(base.join(".gitignore"), "dist/\n*.log\n").unwrap(); + git2::Repository::init(&base).unwrap(); + let (picker, _frecency) = make_watched_picker(&base); + + let events = watch_collect(&picker, "", WatchOptions::default()); + + fs::write(base.join("dist/bundle.js"), "js").unwrap(); + fs::write(base.join("noise.log"), "log").unwrap(); + fs::write(base.join("visible.txt"), "txt").unwrap(); + + assert!( + wait_for( + || events + .lock() + .iter() + .any(|e| e.path == base.join("visible.txt")), + Duration::from_secs(10) + ), + "non-ignored file must be delivered, got {:?}", + events.lock() + ); + + std::thread::sleep(Duration::from_millis(500)); + let collected = events.lock(); + assert!( + !collected + .iter() + .any(|e| e.path == base.join("dist/bundle.js")), + "gitignored directory content must not be delivered: {:?}", + collected + ); + assert!( + !collected.iter().any(|e| e.path == base.join("noise.log")), + "gitignored file must not be delivered: {:?}", + collected + ); +} + +#[test] +fn dir_subscription_with_ignore_option() { + let tmp = TempDir::new().unwrap(); + let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap(); + seed(&base); + fs::create_dir_all(base.join("src/vendor")).unwrap(); + let (picker, _frecency) = make_watched_picker(&base); + + // parcel-style: subscribe to a directory subtree with excludes + let got = watch_collect( + &picker, + "src", + WatchOptions { + ignore: vec!["*.map".to_string(), "src/vendor".to_string()], + ..Default::default() + }, + ); + + fs::write(base.join("src/feature.rs"), "pub fn f() {}\n").unwrap(); + fs::write(base.join("src/feature.js.map"), "{}\n").unwrap(); + fs::write(base.join("src/vendor/lib.js"), "x\n").unwrap(); + fs::write(base.join("outside_dir.txt"), "not in src\n").unwrap(); + + assert!( + wait_for( + || got + .lock() + .iter() + .any(|e| e.path == base.join("src/feature.rs")), + Duration::from_secs(10) + ), + "dir subscriber must see files in its subtree, got {:?}", + got.lock() + ); + let got = got.lock(); + assert!( + !got.iter() + .any(|e| e.path == base.join("src/feature.js.map")), + "ignore glob leaked: {got:?}" + ); + assert!( + !got.iter().any(|e| e.path == base.join("src/vendor/lib.js")), + "ignore prefix leaked: {got:?}" + ); + assert!( + !got.iter().any(|e| e.path == base.join("outside_dir.txt")), + "event outside the subscribed dir leaked: {got:?}" + ); +} + +#[test] +fn shutdown_watches_stops_future_deliveries() { + let tmp = TempDir::new().unwrap(); + let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap(); + seed(&base); + let (picker, _frecency) = make_watched_picker(&base); + + let calls = Arc::new(AtomicUsize::new(0)); + let calls_cb = Arc::clone(&calls); + picker + .watch("**/*.txt", WatchOptions::default(), move |_, _| { + calls_cb.fetch_add(1, Ordering::SeqCst); + }) + .unwrap(); + + fs::write(base.join("one.txt"), "1\n").unwrap(); + assert!( + wait_for(|| calls.load(Ordering::SeqCst) > 0, Duration::from_secs(10)), + "callback never fired before shutdown" + ); + + picker.shutdown_watches(); + let after = calls.load(Ordering::SeqCst); + + fs::write(base.join("two.txt"), "2\n").unwrap(); + std::thread::sleep(Duration::from_millis(500)); + assert_eq!( + calls.load(Ordering::SeqCst), + after, + "callback fired after shutdown_watches returned" + ); +} + +#[test] +fn non_canonical_dir_pattern_resolves_into_the_tree() { + let tmp = TempDir::new().unwrap(); + let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap(); + seed(&base); + let (picker, _) = make_watched_picker(&base); + + // tmp.path() is the non-canonical spelling (e.g. /var/... symlinked to + // /private/var/... on macOS, 8.3 short names on Windows); the watch must + // canonicalize instead of rejecting it + let events = watch_collect( + &picker, + tmp.path().to_str().unwrap(), + WatchOptions::default(), + ); + + fs::write(base.join("via-alias.txt"), "x\n").unwrap(); + assert!( + wait_for( + || events + .lock() + .iter() + .any(|e| e.path == base.join("via-alias.txt")), + Duration::from_secs(10) + ), + "non-canonical base-dir pattern must receive events, got {:?}", + events.lock() + ); +} + +#[test] +fn invalid_patterns_are_rejected() { + let tmp = TempDir::new().unwrap(); + let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap(); + seed(&base); + let (picker, _frecency) = make_watched_picker(&base); + + assert!( + picker + .watch( + "/somewhere/else/**/*.rs", + WatchOptions::default(), + |_, _| {} + ) + .is_err(), + "absolute glob outside base must be rejected" + ); + + // relative exact path resolves against base + let got = watch_collect(&picker, "README.md", WatchOptions::default()); + fs::write(base.join("README.md"), "# updated\n").unwrap(); + assert!( + wait_for( + || got.lock().iter().any(|e| e.path == base.join("README.md")), + Duration::from_secs(10) + ), + "got {:?}", + got.lock() + ); +} diff --git a/crates/fff-grep/Cargo.toml b/crates/fff-grep/Cargo.toml index 98aa8d4f5..31728f6cc 100644 --- a/crates/fff-grep/Cargo.toml +++ b/crates/fff-grep/Cargo.toml @@ -6,6 +6,9 @@ authors = ["Dmitriy Kovalenko "] version = "0.9.6" edition = "2024" +[lints] +workspace = true + [dependencies] bstr = { version = "1.6.2", default-features = false, features = ["std"] } memchr = "2.6.3" diff --git a/crates/fff-mcp/Cargo.toml b/crates/fff-mcp/Cargo.toml index 9f46651b3..b2961154c 100644 --- a/crates/fff-mcp/Cargo.toml +++ b/crates/fff-mcp/Cargo.toml @@ -5,6 +5,9 @@ edition = "2024" description = "MCP server for FFF file finder - drop-in replacement for AI code assistant search tools" license = "MIT" +[lints] +workspace = true + [[bin]] name = "fff-mcp" path = "src/main.rs" diff --git a/crates/fff-nvim/Cargo.toml b/crates/fff-nvim/Cargo.toml index eb24826f1..5a48476a2 100644 --- a/crates/fff-nvim/Cargo.toml +++ b/crates/fff-nvim/Cargo.toml @@ -3,6 +3,9 @@ name = "fff-nvim" version = "0.9.6" edition = "2024" +[lints] +workspace = true + [lib] path = "src/lib.rs" crate-type = ["cdylib", "rlib"] diff --git a/crates/fff-python/Cargo.toml b/crates/fff-python/Cargo.toml index 8112f10ca..7352dd0e1 100644 --- a/crates/fff-python/Cargo.toml +++ b/crates/fff-python/Cargo.toml @@ -3,6 +3,9 @@ name = "fff-python" version = "0.9.6" edition = "2024" +[lints] +workspace = true + [lib] name = "fff_python" crate-type = ["cdylib"] diff --git a/crates/fff-python/src/finder.rs b/crates/fff-python/src/finder.rs index 3384ae7e4..d2a7ad399 100644 --- a/crates/fff-python/src/finder.rs +++ b/crates/fff-python/src/finder.rs @@ -1,4 +1,6 @@ use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use fff::file_picker::FilePicker; @@ -8,14 +10,16 @@ use fff::{ FFFMode, FilePickerOptions, FuzzySearchOptions, GrepSearchOptions, PaginationArgs, QueryParser, SharedFilePicker, SharedFrecency, SharedQueryTracker, }; +use pyo3::exceptions::PyTypeError; use pyo3::prelude::*; use pyo3::types::PyDict; use crate::conversions::MixedItem; use crate::types::{ DirItem, DirSearchResult, FileItem, GrepCursor, GrepMatch, GrepResult, MixedDirItem, - MixedFileItem, MixedSearchResult, ScanProgress, Score, SearchResult, + MixedFileItem, MixedSearchResult, ScanProgress, Score, SearchResult, WatchEvent, }; +use crate::watch::WatchSubscription; use crate::{parse_grep_mode, py_err}; const DEFAULT_SEARCH_PAGE_SIZE: usize = 100; @@ -267,11 +271,20 @@ impl FileFinder { slf } - fn __exit__(&mut self, _exc_type: PyObject, _exc_value: PyObject, _traceback: PyObject) { - let _ = self.close(); + fn __exit__( + &mut self, + py: Python<'_>, + _exc_type: PyObject, + _exc_value: PyObject, + _traceback: PyObject, + ) { + let _ = self.close(py); } - fn close(&mut self) -> PyResult<()> { + fn close(&mut self, py: Python<'_>) -> PyResult<()> { + // Release the GIL while an in-flight callback finishes. + let picker = self.picker.clone(); + py.allow_threads(move || picker.shutdown_watches_and_wait()); clear_shared_state(&self.picker, &self.frecency, &self.query_tracker); Ok(()) } @@ -728,6 +741,62 @@ impl FileFinder { py.allow_threads(move || Ok(picker.wait_for_scan(Duration::from_millis(timeout_ms)))) } + /// Subscribe to filesystem changes matching `pattern`. + /// + /// Patterns may be base-relative globs (./ works), exact paths inside the indexed + /// tree, or existing directories. An empty pattern watches the whole tree. + /// + /// Events are debounced and submitted in batches per 100-ms window at most 128 events. + /// Gitignored and other ignored files are never triggering watcher. + #[pyo3(signature = (pattern, callback, *, ignore = None))] + fn watch( + &self, + py: Python<'_>, + pattern: Option<&str>, + callback: Py, + ignore: Option>, + ) -> PyResult { + if !callback.bind(py).is_callable() { + return Err(PyTypeError::new_err("callback must be callable")); + } + + let pattern = pattern.unwrap_or_default().to_string(); + let options = fff::WatchOptions { + ignore: ignore.unwrap_or_default(), + }; + + // Suppresses invocations racing an unsubscribe: the flag flips before + // core unwatch, so user code never runs after unsubscribe() returns. + let active = Arc::new(AtomicBool::new(true)); + let active_cb = Arc::clone(&active); + + let id = { + let picker = self.picker.clone(); + py.allow_threads(move || { + picker.watch(&pattern, options, move |_id, events| { + Python::with_gil(|py| { + if !active_cb.load(Ordering::Acquire) { + return; + } + let batch: Vec = events + .iter() + .map(|ev| WatchEvent { + path: ev.path.to_string_lossy().to_string(), + kind: ev.kind.as_str().to_string(), + }) + .collect(); + if let Err(e) = callback.call1(py, (batch,)) { + e.write_unraisable(py, None); + } + }) + }) + }) + .map_err(py_err)? + }; + + Ok(WatchSubscription::new(self.picker.clone(), id.0, active)) + } + fn reindex(&self, py: Python<'_>, new_path: PathBuf) -> PyResult<()> { let picker = self.picker.clone(); let frecency = self.frecency.clone(); diff --git a/crates/fff-python/src/lib.rs b/crates/fff-python/src/lib.rs index 94d219461..9f65d5c8f 100644 --- a/crates/fff-python/src/lib.rs +++ b/crates/fff-python/src/lib.rs @@ -4,6 +4,7 @@ use pyo3::prelude::*; mod conversions; mod finder; mod types; +mod watch; create_exception!(fff_python, FFFException, pyo3::exceptions::PyException); @@ -39,6 +40,8 @@ fn _fff_python(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add("FFFException", m.py().get_type::())?; Ok(()) } diff --git a/crates/fff-python/src/types.rs b/crates/fff-python/src/types.rs index 32c2f1342..ec18b18a4 100644 --- a/crates/fff-python/src/types.rs +++ b/crates/fff-python/src/types.rs @@ -388,6 +388,22 @@ impl ScanProgress { } } +#[pyclass] +#[derive(Clone)] +pub struct WatchEvent { + #[pyo3(get)] + pub path: String, + #[pyo3(get)] + pub kind: String, +} + +#[pymethods] +impl WatchEvent { + fn __repr__(&self) -> String { + format!("WatchEvent(path={:?}, kind={:?})", self.path, self.kind) + } +} + #[pyclass] #[derive(Clone)] pub struct GrepCursor { diff --git a/crates/fff-python/src/watch.rs b/crates/fff-python/src/watch.rs new file mode 100644 index 000000000..05b7f8e79 --- /dev/null +++ b/crates/fff-python/src/watch.rs @@ -0,0 +1,58 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use fff::{SharedFilePicker, WatchId}; +use pyo3::prelude::*; + +/// Handle for an active watch subscription returned by [crate::FileFinder::watch] +/// +/// Usable as a context manager: exiting the `with` block unsubscribes +#[pyclass] +pub struct WatchSubscription { + picker: SharedFilePicker, + id: u64, + /// Shared with the delivery closure: flipped before core unwatch so the + /// user callback never runs for events racing the unsubscribe. + active: Arc, +} + +impl WatchSubscription { + pub(crate) fn new(picker: SharedFilePicker, id: u64, active: Arc) -> Self { + Self { picker, id, active } + } +} + +#[pymethods] +impl WatchSubscription { + #[getter] + fn id(&self) -> u64 { + self.id + } + + #[getter] + fn active(&self) -> bool { + self.active.load(Ordering::Acquire) && self.picker.is_watch_active(WatchId(self.id)) + } + + /// Stop delivering events. Idempotent: returns True when the subscription + /// was removed by this call, False if it was already inactive. + fn unsubscribe(&self) -> bool { + if !self.active.swap(false, Ordering::AcqRel) { + return false; + } + self.picker.unwatch(WatchId(self.id)) + } + + fn __enter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + fn __exit__(&self, _exc_type: PyObject, _exc_value: PyObject, _traceback: PyObject) { + self.unsubscribe(); + } + + fn __repr__(&self) -> String { + let active = if self.active() { "True" } else { "False" }; + format!("WatchSubscription(id={}, active={})", self.id, active) + } +} diff --git a/crates/fff-query-parser/Cargo.toml b/crates/fff-query-parser/Cargo.toml index 416d2dbb5..a23844194 100644 --- a/crates/fff-query-parser/Cargo.toml +++ b/crates/fff-query-parser/Cargo.toml @@ -6,6 +6,9 @@ description = "Query parser for fff file finder - includes specific syntax for v license = "MIT" authors = ["Dmitriy Kovalenko "] +[lints] +workspace = true + [lib] path = "src/lib.rs" diff --git a/packages/fff-bun/README.md b/packages/fff-bun/README.md index f50858ef8..6dded878f 100644 --- a/packages/fff-bun/README.md +++ b/packages/fff-bun/README.md @@ -95,6 +95,56 @@ finder.destroy(); ``` +## Watching files + +Subscribe to filesystem changes with a glob, an exact path, or a directory +subtree. Events reflect applied index changes and are delivered in batches of +up to 128, so callbacks stay cheap even under heavy churn. + +```typescript +// Each path appears at most once per batch +const sub = finder.watch("src/**/*.ts", (events) => { + for (const e of events) console.log(e.kind, e.path); // created | modified | removed | rescan +}); + +// No pattern: watch the entire indexed tree +const all = finder.watch((events) => { + for (const e of events) console.log(e.kind, e.path); +}); + +// Unsubscribe: call the handle +if (sub.ok) sub.value(); +``` + +Directory subtrees are watched by passing the directory itself (parcel-watcher +style), with per-subscription excludes: + +```typescript +const dirSub = finder.watch( + projectRoot, + (events) => { + for (const e of events) console.log(e.kind, e.path); + }, + { ignore: ["node_modules", "*.log"] }, +); +``` + +Notes: + +- Globs are matched against the base-path-relative path; absolute globs must + live under `basePath`. Wildcard-free patterns resolve inside the indexed + tree: an existing directory watches its whole subtree, anything else is an + exact file path. +- `ignore` entries exclude matches per subscription: wildcards are globs, + everything else is a path prefix (a file or a whole subtree). +- Gitignored paths never produce events. +- A `rescan` event means changes were lost (index overflow, ignore-file + change) — re-stat anything you care about. +- Unsubscribing takes effect synchronously on the JS thread: once it + returns, the callback will not be invoked again. +- Watching requires the instance to be created with watching enabled + (the default). + ## 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. diff --git a/packages/fff-bun/examples/watch.ts b/packages/fff-bun/examples/watch.ts new file mode 100644 index 000000000..b44ac2343 --- /dev/null +++ b/packages/fff-bun/examples/watch.ts @@ -0,0 +1,57 @@ +#!/usr/bin/env bun +import { FileFinder } from "../src/index"; +import type { WatchEvent } from "../src/index"; + +const KIND = { + created: "+ created ", + modified: "~ modified", + removed: "- removed ", + rescan: "! rescan ", +} as const; + +const targetDir = process.argv[2] || process.cwd(); +const pattern = process.argv[3]; // if omitted watch the entire indexed tree + +const created = FileFinder.create({ basePath: targetDir }); +if (!created.ok) { + console.error(`Init failed: ${created.error}`); + process.exit(1); +} +const finder = created.value; + +// Wait for the initial scan + watcher so indexing noise isn't reported. +await finder.waitForScan(30_000); +for ( + let p = finder.getScanProgress(); + !p.ok || !p.value.isWatcherReady; + p = finder.getScanProgress() +) { + await Bun.sleep(50); +} + +let batch = 0; +const onBatch = (events: WatchEvent[]) => { + console.log(`\nbatch #${++batch} (${events.length} events)`); + for (const e of events) console.log(` ${KIND[e.kind]} ${e.path}`); +}; + +const sub = pattern ? finder.watch(pattern, onBatch) : finder.watch(onBatch); +if (!sub.ok) { + console.error(`Watch failed: ${sub.error}`); + finder.destroy(); + process.exit(1); +} + +console.log( + `Watching ${targetDir} (pattern: ${pattern ?? "whole tree"}), Ctrl-C to stop.`, +); + +// A recurring timer keeps the event loop alive so watch batches are delivered. +const keepAlive = setInterval(() => {}, 1000); + +process.on("SIGINT", () => { + clearInterval(keepAlive); + sub.value(); + finder.destroy(); + process.exit(0); +}); diff --git a/packages/fff-bun/src/fff-api.ts b/packages/fff-bun/src/fff-api.ts index d8842d184..d22c6de5c 100644 --- a/packages/fff-bun/src/fff-api.ts +++ b/packages/fff-bun/src/fff-api.ts @@ -294,8 +294,36 @@ export interface ScanProgress { } /** - * Database health information + * Normalized watch event kind. + * A file removed and recreated in one processed batch is marked as modified. + * + * rescan = internal OS buffers were overloaded, some events might be missing. + * The `path` is going to be a folder needs to be rescanned + */ +export type WatchEventKind = "created" | "modified" | "removed" | "rescan"; + +/** A single filesystem change notification. */ +export interface WatchEvent { + /** Absolute path of the affected file (base path to rescan if `kind ==rescan`) */ + path: string; + kind: WatchEventKind; +} + +/** Options for watch subscriptions. */ +export interface WatchOptions { + /** Additional glob wildcard patterns to ignore */ + ignore?: string[]; +} + +/** + * Receives normalized batches of up to 128 events. Each path appears once. */ +export type WatchBatchCallback = (events: WatchEvent[]) => void; + +/** Call me to unsubscribe. */ +export type WatchUnsubscribe = () => void; + +/** Database health information */ export interface DbHealth { /** Path to the database */ path: string; @@ -545,10 +573,16 @@ export interface FileFinderApi { glob(pattern: string, options?: GlobOptions): Result; /** Fuzzy directory search. */ - directorySearch(query: string, options?: DirSearchOptions): Result; + directorySearch( + query: string, + options?: DirSearchOptions, + ): Result; /** Fuzzy search over files and directories interleaved by score. */ - mixedSearch(query: string, options?: SearchOptions): Result; + mixedSearch( + query: string, + options?: SearchOptions, + ): Result; /** Content search (live grep). */ grep(query: string, options?: GrepOptions): Result; @@ -604,6 +638,25 @@ export interface FileFinderApi { /** Get a historical query by offset (0 = most recent). */ getHistoricalQuery(offset: number): Result; + /** + * Subscribe to filesystem changes matching `pattern`. + * + * Patterns may be base-relative globs (./ works), exact paths inside the indexed + * tree, or existing directories. An empty pattern watches the whole tree. + * + * Events are debounced and submitted in batches per 100-ms window at most 128 events. + * Gitignored and other ignored files are never triggering watcher. + */ + watch( + callback: WatchBatchCallback, + options?: WatchOptions, + ): Result; + watch( + pattern: string, + callback: WatchBatchCallback, + options?: WatchOptions, + ): Result; + /** Health/diagnostics information for this instance. */ healthCheck(testPath?: string): Result; } diff --git a/packages/fff-bun/src/ffi.ts b/packages/fff-bun/src/ffi.ts index 904902d95..0a9f3b075 100644 --- a/packages/fff-bun/src/ffi.ts +++ b/packages/fff-bun/src/ffi.ts @@ -8,7 +8,15 @@ * be passed to all subsequent calls and freed with `ffiDestroy`. */ -import { CString, dlopen, FFIType, type Pointer, ptr, read } from "bun:ffi"; +import { + CString, + dlopen, + FFIType, + type JSCallback, + type Pointer, + ptr, + read, +} from "bun:ffi"; import { findBinary } from "./download"; import { embeddedLibPath } from "./embedded"; import type { @@ -24,6 +32,8 @@ import type { ScanProgress, Score, SearchResult, + WatchEvent, + WatchEventKind, } from "./fff-api"; import { createGrepCursor, err } from "./fff-api"; @@ -192,6 +202,44 @@ const ffiDefinition = { returns: FFIType.ptr, }, + // Watch subscriptions + fff_set_watch_callback: { + args: [ + FFIType.ptr, // handle + FFIType.function, // FffWatchCallback (instance-wide) + FFIType.ptr, // user_data + ], + returns: FFIType.ptr, + }, + fff_watch: { + args: [ + FFIType.ptr, // handle + FFIType.cstring, // pattern + FFIType.ptr, // *const FffWatchOptions (or NULL) + ], + returns: FFIType.ptr, + }, + fff_unwatch: { + args: [FFIType.ptr, FFIType.u64], // handle, watch_id + returns: FFIType.ptr, + }, + fff_free_watch_events: { + args: [FFIType.ptr], + returns: FFIType.void, + }, + fff_watch_events_count: { + args: [FFIType.ptr], + returns: FFIType.u32, + }, + fff_watch_events_get_path: { + args: [FFIType.ptr, FFIType.u32], + returns: FFIType.ptr, + }, + fff_watch_events_get_kind: { + args: [FFIType.ptr, FFIType.u32], + returns: FFIType.u8, + }, + // Git fff_refresh_git_status: { args: [FFIType.ptr], @@ -1327,6 +1375,118 @@ export function ffiRestartIndex(handle: NativeHandle, newPath: string): Result WatchEventKind. Unknown values degrade to "rescan". */ +const WATCH_EVENT_KINDS: readonly WatchEventKind[] = [ + "created", + "modified", + "removed", + "rescan", +]; + +/** + * Parse an FffWatchEventBatch delivered to a watch callback, then free it. + * Ownership of the batch transfers to JS at callback time, so this MUST be + * called exactly once per delivered batch pointer. + */ +export function readWatchEventBatch(batchPtr: Pointer | number | null): WatchEvent[] { + if (batchPtr === null || (batchPtr as unknown as number) === 0) { + return []; + } + + const bp = batchPtr as unknown as Pointer; + const symbols = loadLibrary().symbols; + const count = symbols.fff_watch_events_count(bp); + + const events: WatchEvent[] = []; + for (let i = 0; i < count; i++) { + const path = symbols.fff_watch_events_get_path(bp, i) as Pointer | null; + const kind = symbols.fff_watch_events_get_kind(bp, i) as number; + events.push({ + path: readCString(path) ?? "", + kind: WATCH_EVENT_KINDS[kind] ?? "rescan", + }); + } + + symbols.fff_free_watch_events(bp); + return events; +} + +/** + * Register the instance-wide watch callback. Must be called before the + * first `ffiWatch`. The caller owns `callback` (a threadsafe `JSCallback` + * built in finder.ts) and must keep it alive until after `ffiDestroy` + * returns for this handle — that call is the delivery quiescence barrier. + */ +export function ffiSetWatchCallback( + handle: NativeHandle, + callback: JSCallback, +): Result { + const library = loadLibrary(); + if (callback.ptr === null) { + return err("watch callback has been closed"); + } + const resultPtr = library.symbols.fff_set_watch_callback(handle, callback.ptr, null); + return parseVoidResult(resultPtr); +} + +/** + * Subscribe to filesystem changes; batches are delivered through the + * instance callback registered with `ffiSetWatchCallback`, tagged with the + * watch id this function returns. + * + * @returns The native watch id carried in `FffResult.int_value`. + */ +export function ffiWatch( + handle: NativeHandle, + pattern: string, + ignore: string[] = [], +): Result { + const library = loadLibrary(); + + // Keep every buffer referenced until the FFI call returns: the options + // struct, the pointer array, and each encoded ignore string. + const opts = Buffer.alloc(FFF_WATCH_OPTIONS_SIZE); + opts.writeUInt32LE(FFF_WATCH_OPTIONS_VERSION, FWO_VERSION); + + const ignoreBuffers = ignore.map((entry) => encodeString(entry)); + const ignorePtrs = Buffer.alloc(Math.max(ignoreBuffers.length, 1) * 8); + for (let i = 0; i < ignoreBuffers.length; i++) { + ignorePtrs.writeBigUInt64LE(BigInt(ptr(ignoreBuffers[i] as Uint8Array)), i * 8); + } + opts.writeBigUInt64LE( + ignoreBuffers.length > 0 ? BigInt(ptr(ignorePtrs)) : 0n, + FWO_IGNORE, + ); + opts.writeUInt32LE(ignoreBuffers.length, FWO_IGNORE_COUNT); + + const resultPtr = library.symbols.fff_watch( + handle, + ptr(encodeString(pattern)), + ptr(opts), + ); + return parseIntResult(resultPtr); +} + +/** + * Remove a watch subscription. Returns true if the id was found. + */ +export function ffiUnwatch(handle: NativeHandle, watchId: number): Result { + const library = loadLibrary(); + const resultPtr = library.symbols.fff_unwatch(handle, BigInt(watchId)); + return parseBoolResult(resultPtr); +} + /** * Refresh git status. */ diff --git a/packages/fff-bun/src/finder.ts b/packages/fff-bun/src/finder.ts index 66ed2e57a..d8210b43f 100644 --- a/packages/fff-bun/src/finder.ts +++ b/packages/fff-bun/src/finder.ts @@ -8,6 +8,8 @@ * All methods return Result types for explicit error handling. */ +import { FFIType, JSCallback, type Pointer } from "bun:ffi"; + import { ensureLoaded, ffiCreate, @@ -26,10 +28,14 @@ import { ffiSearch, ffiSearchDirectories, ffiSearchMixed, + ffiSetWatchCallback, ffiTrackQuery, + ffiUnwatch, ffiWaitForScan, + ffiWatch, isAvailable, type NativeHandle, + readWatchEventBatch, } from "./ffi"; import type { @@ -47,6 +53,9 @@ import type { ScanProgress, SearchOptions, SearchResult, + WatchBatchCallback, + WatchOptions, + WatchUnsubscribe, } from "./fff-api"; import { err } from "./fff-api"; @@ -85,6 +94,14 @@ import { err } from "./fff-api"; */ export class FileFinder implements FileFinderApi { private handle: NativeHandle | null; + /** Active watch subscriptions: native watch id -> JS batch handler. */ + private watchHandlers = new Map(); + /** + * ONE threadsafe JSCallback per instance, registered lazily with + * `fff_set_watch_callback` on the first subscription and closed in + * `destroy()` after `fff_destroy` returns (the quiescence barrier). + */ + private watchJsCallback: JSCallback | null = null; private constructor(handle: NativeHandle) { this.handle = handle; @@ -138,14 +155,19 @@ export class FileFinder implements FileFinderApi { /** * Destroy and clean up all resources. * - * Call this when you're done using the file finder to free memory - * and stop background file watching. After calling this, the instance - * must not be used again. + * Frees the native instance (unsubscribing all watches), then closes the + * instance watch trampoline. After calling this, the instance must not be + * used again. */ destroy(): void { if (this.handle !== null) { + this.watchHandlers.clear(); ffiDestroy(this.handle); this.handle = null; + // Handlers were cleared first, so a delivery racing the destroy is a + // benign id-map miss before the trampoline is closed. + this.watchJsCallback?.close(); + this.watchJsCallback = null; } } @@ -571,6 +593,119 @@ export class FileFinder implements FileFinderApi { return ffiGetHistoricalQuery(guard.value, offset); } + /** + * Lazily create + register the instance-wide watch trampoline. Routes + * every delivered batch to the handler registered for its watch id; + * unknown ids (unsubscribe races) are benign — the batch is just freed. + */ + private ensureWatchTrampoline(handle: NativeHandle): Result { + if (this.watchJsCallback !== null) return { ok: true, value: undefined }; + + // Threadsafe: the native callback thread enqueues the invocation onto the + // JS event loop; the batch stays valid because JS owns it until it frees + // it inside readWatchEventBatch. + const jsCallback = new JSCallback( + (watchId: bigint | number, batchPtr: Pointer, _userData: Pointer) => { + const events = readWatchEventBatch(batchPtr); + const handler = this.watchHandlers.get(Number(watchId)); + if (handler !== undefined && events.length > 0) { + handler(events); + } + }, + { + // an attempt to fix the bug that is kept unfixed in the zig version of bun :() + // https://github.com/oven-sh/bun/issues/33840: + // + // watch_id is declared `ptr`, not `u64`: ABI-identical (one 64-bit + // register), but u64 args make bun allocate a JSBigInt on the CALLING + // (non-JS) thread, corrupting the JS heap + args: [FFIType.ptr, FFIType.ptr, FFIType.ptr], + returns: FFIType.void, + threadsafe: true, + }, + ); + + const registered = ffiSetWatchCallback(handle, jsCallback); + if (!registered.ok) { + jsCallback.close(); + return registered; + } + this.watchJsCallback = jsCallback; + return { ok: true, value: undefined }; + } + + /** + * Subscribe to filesystem changes matching `pattern` (glob, exact file, + * or directory subtree). Omit the pattern to watch the entire indexed + * tree. Normalized batches of up to 128 events are delivered on the JS event + * loop, with each path appearing at most once. See `FileFinderApi.watch`. + * + * @example + * ```typescript + * const sub = finder.watch("**\/*.ts", (events) => { + * for (const e of events) console.log(e.kind, e.path); + * }); + * if (sub.ok) sub.value(); // unsubscribe + * + * // no pattern: everything under the indexed base path + * const all = finder.watch((events) => console.log(events.length)); + * ``` + */ + watch(callback: WatchBatchCallback, options?: WatchOptions): Result; + watch( + pattern: string, + callback: WatchBatchCallback, + options?: WatchOptions, + ): Result; + watch( + patternOrCallback: string | WatchBatchCallback, + callbackOrOptions?: WatchBatchCallback | WatchOptions, + maybeOptions?: WatchOptions, + ): Result { + // Overload shift: watch(cb, opts?) -> empty pattern = whole tree. + const noPattern = typeof patternOrCallback === "function"; + const pattern = noPattern ? "" : patternOrCallback; + const callback = noPattern + ? patternOrCallback + : (callbackOrOptions as WatchBatchCallback); + const options = noPattern + ? (callbackOrOptions as WatchOptions | undefined) + : maybeOptions; + + if (typeof callback !== "function") { + return err("watch callback must be a function"); + } + + const guard = this.ensureAlive(); + if (!guard.ok) return guard; + + const trampoline = this.ensureWatchTrampoline(guard.value); + if (!trampoline.ok) return trampoline; + + const result = ffiWatch(guard.value, pattern, options?.ignore ?? []); + if (!result.ok) return result; + + // No startup race: the threadsafe trampoline only runs on the JS event + // loop, so this synchronous set always precedes the first routing lookup. + const watchId = result.value; + this.watchHandlers.set(watchId, callback); + + return { ok: true, value: () => this.unwatchById(watchId) }; + } + + /** + * Remove a subscription from the routing map, then from the native side. + * Map removal is synchronous on the JS thread, so once this returns the + * handler can never run again (late native batches miss the lookup). + * Idempotent. + */ + private unwatchById(watchId: number): void { + if (!this.watchHandlers.delete(watchId)) return; + if (this.handle !== null) { + ffiUnwatch(this.handle, watchId); + } + } + /** * Get health check information. * diff --git a/packages/fff-bun/src/index.ts b/packages/fff-bun/src/index.ts index f483fde12..3b54b82d0 100644 --- a/packages/fff-bun/src/index.ts +++ b/packages/fff-bun/src/index.ts @@ -24,6 +24,11 @@ export type { Score, SearchOptions, SearchResult, + WatchBatchCallback, + WatchEvent, + WatchEventKind, + WatchOptions, + WatchUnsubscribe, } from "./fff-api"; export { FileFinder } from "./finder"; diff --git a/packages/fff-bun/test/watch.test.ts b/packages/fff-bun/test/watch.test.ts new file mode 100644 index 000000000..ab75be465 --- /dev/null +++ b/packages/fff-bun/test/watch.test.ts @@ -0,0 +1,235 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { WatchEvent } from "../src/fff-api"; +import { FileFinder } from "../src/index"; + +/** + * Integration test: filesystem watch subscriptions. + * + * Threadsafe JSCallback delivery happens on the JS event loop, so all + * assertions poll with `Bun.sleep` to keep the loop alive. + */ + +const POLL_INTERVAL_MS = 50; +const EVENT_TIMEOUT_MS = 10_000; +/** Grace period to assert an event did NOT arrive. */ +const SILENCE_MS = 700; + +function sleep(ms: number) { + return Bun.sleep(ms); +} + +/** Poll until `predicate` is true or the timeout expires. */ +async function waitFor( + predicate: () => boolean, + timeoutMs = EVENT_TIMEOUT_MS, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return true; + await sleep(POLL_INTERVAL_MS); + } + return predicate(); +} + +function hasEventFor(events: WatchEvent[], fileName: string): boolean { + return events.some((e) => e.path.endsWith(`/${fileName}`)); +} + +async function createReadyFinder(baseDir: string): Promise { + const result = FileFinder.create({ basePath: baseDir }); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error(result.error); + const finder = result.value; + + const scanned = await finder.waitForScan(10_000); + expect(scanned.ok).toBe(true); + + // Wait for the background watcher to come online, then let FSEvents settle. + await waitFor(() => { + const progress = finder.getScanProgress(); + return progress.ok && progress.value.isWatcherReady; + }); + await sleep(400); + + return finder; +} + +describe("FileFinder - Watch Subscriptions", () => { + let baseDir: string; + let finder: FileFinder; + + beforeAll(async () => { + baseDir = realpathSync(mkdtempSync(join(tmpdir(), "fff-watch-test-"))); + writeFileSync(join(baseDir, "seed-one.txt"), "seed one\n"); + writeFileSync(join(baseDir, "seed-two.js"), "// seed two\n"); + + finder = await createReadyFinder(baseDir); + }, 30_000); + + afterAll(() => { + finder?.destroy(); + rmSync(baseDir, { recursive: true, force: true }); + }, 20_000); + + test("watch delivers batches for matching files only", async () => { + const received: WatchEvent[] = []; + const batchSizes: number[] = []; + + const sub = finder.watch("**/*.txt", (events) => { + batchSizes.push(events.length); + received.push(...events); + }); + expect(sub.ok).toBe(true); + if (!sub.ok) return; + + writeFileSync(join(baseDir, "watched.txt"), "hello\n"); + writeFileSync(join(baseDir, "ignored.js"), "// nope\n"); + + const gotTxt = await waitFor(() => hasEventFor(received, "watched.txt")); + expect(gotTxt).toBe(true); + + // Give the .js event (if any were mistakenly routed) a chance to arrive. + await sleep(SILENCE_MS); + expect(received.some((e) => e.path.endsWith(".js"))).toBe(false); + expect(batchSizes.every((n) => n > 0)).toBe(true); + for (const event of received) { + expect(["created", "modified", "removed", "rescan"]).toContain(event.kind); + } + + sub.value(); + }, 20_000); + + test("per-event consumption is a one-line loop over watch", async () => { + const received: WatchEvent[] = []; + + const sub = finder.watch("**/*.txt", (events) => { + for (const event of events) { + expect(typeof event.path).toBe("string"); + expect(typeof event.kind).toBe("string"); + received.push(event); + } + }); + expect(sub.ok).toBe(true); + if (!sub.ok) return; + + writeFileSync(join(baseDir, "fanout-one.txt"), "1\n"); + writeFileSync(join(baseDir, "fanout-two.txt"), "2\n"); + + const gotBoth = await waitFor( + () => + hasEventFor(received, "fanout-one.txt") && + hasEventFor(received, "fanout-two.txt"), + ); + expect(gotBoth).toBe(true); + + sub.value(); + }, 20_000); + + test("watch without a pattern receives events for the whole tree", async () => { + const received: WatchEvent[] = []; + + const sub = finder.watch((events) => { + received.push(...events); + }); + expect(sub.ok).toBe(true); + if (!sub.ok) return; + + writeFileSync(join(baseDir, "no-pattern.txt"), "1\n"); + writeFileSync(join(baseDir, "no-pattern.js"), "// 2\n"); + + const gotBoth = await waitFor( + () => + hasEventFor(received, "no-pattern.txt") && hasEventFor(received, "no-pattern.js"), + ); + expect(gotBoth).toBe(true); + + sub.value(); + }, 20_000); + + test("unsubscribe stops delivery and is idempotent", async () => { + const received: WatchEvent[] = []; + + const sub = finder.watch("**/*.txt", (events) => { + received.push(...events); + }); + expect(sub.ok).toBe(true); + if (!sub.ok) return; + + writeFileSync(join(baseDir, "before-unsub.txt"), "before\n"); + const gotBefore = await waitFor(() => hasEventFor(received, "before-unsub.txt")); + expect(gotBefore).toBe(true); + + sub.value(); + const countAfterUnsub = received.length; + + writeFileSync(join(baseDir, "after-unsub.txt"), "after\n"); + await sleep(SILENCE_MS); + expect(received.length).toBe(countAfterUnsub); + expect(hasEventFor(received, "after-unsub.txt")).toBe(false); + + // Idempotent: repeating the handle is safe. + sub.value(); + sub.value(); + }, 20_000); + + test("watch on a directory respects the ignore option", async () => { + const received: WatchEvent[] = []; + + const sub = finder.watch( + baseDir, + (events) => { + received.push(...events); + }, + { ignore: ["*.log"] }, + ); + expect(sub.ok).toBe(true); + if (!sub.ok) return; + + writeFileSync(join(baseDir, "dir-shape.txt"), "hello\n"); + writeFileSync(join(baseDir, "dir-noise.log"), "noise\n"); + + const got = await waitFor(() => hasEventFor(received, "dir-shape.txt")); + expect(got).toBe(true); + // the ignore glob filtered the .log file out + expect(hasEventFor(received, "dir-noise.log")).toBe(false); + + sub.value(); + const countAfter = received.length; + writeFileSync(join(baseDir, "dir-after-unsub.txt"), "late\n"); + await sleep(SILENCE_MS); + expect(received.length).toBe(countAfter); + }, 20_000); + + test("destroy with an active watcher does not crash", async () => { + const otherDir = realpathSync(mkdtempSync(join(tmpdir(), "fff-watch-destroy-"))); + try { + writeFileSync(join(otherDir, "seed.txt"), "seed\n"); + const other = await createReadyFinder(otherDir); + + const received: WatchEvent[] = []; + const sub = other.watch("**/*.txt", (events) => { + received.push(...events); + }); + expect(sub.ok).toBe(true); + if (!sub.ok) return; + + writeFileSync(join(otherDir, "active.txt"), "active\n"); + await waitFor(() => hasEventFor(received, "active.txt"), 5_000); + + // Destroy without unsubscribing first; must clean up the subscription. + other.destroy(); + expect(other.isDestroyed).toBe(true); + + // Unsubscribing after destroy is a safe no-op. + sub.value(); + + // Keep the loop alive briefly so any stray delivery would surface. + await sleep(SILENCE_MS); + } finally { + rmSync(otherDir, { recursive: true, force: true }); + } + }, 20_000); +}); diff --git a/packages/fff-node/README.md b/packages/fff-node/README.md index 49c4a975f..b0b7e85aa 100644 --- a/packages/fff-node/README.md +++ b/packages/fff-node/README.md @@ -74,6 +74,56 @@ if (dirs.ok) console.log(dirs.value.items.map((d) => d.relativePath)); finder.destroy(); ``` +## Watching files + +Subscribe to filesystem changes with a glob, an exact path, or a directory +subtree. Events reflect applied index changes and are delivered in batches of +up to 128, so callbacks stay cheap even under heavy churn. + +```typescript +// Each path appears at most once per batch +const sub = finder.watch("src/**/*.ts", (events) => { + for (const e of events) console.log(e.kind, e.path); // created | modified | removed | rescan +}); + +// No pattern: watch the entire indexed tree +const all = finder.watch((events) => { + for (const e of events) console.log(e.kind, e.path); +}); + +// Unsubscribe: call the handle +if (sub.ok) sub.value(); +``` + +Directory subtrees are watched by passing the directory itself (parcel-watcher +style), with per-subscription excludes: + +```typescript +const dirSub = finder.watch( + projectRoot, + (events) => { + for (const e of events) console.log(e.kind, e.path); + }, + { ignore: ["node_modules", "*.log"] }, +); +``` + +Notes: + +- Globs are matched against the base-path-relative path; absolute globs must + live under `basePath`. Wildcard-free patterns resolve inside the indexed + tree: an existing directory watches its whole subtree, anything else is an + exact file path. +- `ignore` entries exclude matches per subscription: wildcards are globs, + everything else is a path prefix (a file or a whole subtree). +- Gitignored paths never produce events. +- A `rescan` event means changes were lost (index overflow, ignore-file + change) — re-stat anything you care about. +- Unsubscribing takes effect synchronously on the JS thread: once it + returns, the callback will not be invoked again. +- Watching requires the instance to be created with watching enabled + (the default). + ## 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. diff --git a/packages/fff-node/package.json b/packages/fff-node/package.json index 374e45d9c..716a62bea 100644 --- a/packages/fff-node/package.json +++ b/packages/fff-node/package.json @@ -17,7 +17,7 @@ ], "scripts": { "build": "tsc", - "test": "node test/e2e.mjs", + "test": "node test/e2e.mjs && node test/watch.mjs", "typecheck": "tsc --noEmit" }, "engines": { diff --git a/packages/fff-node/src/fff-api.ts b/packages/fff-node/src/fff-api.ts index d8842d184..d22c6de5c 100644 --- a/packages/fff-node/src/fff-api.ts +++ b/packages/fff-node/src/fff-api.ts @@ -294,8 +294,36 @@ export interface ScanProgress { } /** - * Database health information + * Normalized watch event kind. + * A file removed and recreated in one processed batch is marked as modified. + * + * rescan = internal OS buffers were overloaded, some events might be missing. + * The `path` is going to be a folder needs to be rescanned + */ +export type WatchEventKind = "created" | "modified" | "removed" | "rescan"; + +/** A single filesystem change notification. */ +export interface WatchEvent { + /** Absolute path of the affected file (base path to rescan if `kind ==rescan`) */ + path: string; + kind: WatchEventKind; +} + +/** Options for watch subscriptions. */ +export interface WatchOptions { + /** Additional glob wildcard patterns to ignore */ + ignore?: string[]; +} + +/** + * Receives normalized batches of up to 128 events. Each path appears once. */ +export type WatchBatchCallback = (events: WatchEvent[]) => void; + +/** Call me to unsubscribe. */ +export type WatchUnsubscribe = () => void; + +/** Database health information */ export interface DbHealth { /** Path to the database */ path: string; @@ -545,10 +573,16 @@ export interface FileFinderApi { glob(pattern: string, options?: GlobOptions): Result; /** Fuzzy directory search. */ - directorySearch(query: string, options?: DirSearchOptions): Result; + directorySearch( + query: string, + options?: DirSearchOptions, + ): Result; /** Fuzzy search over files and directories interleaved by score. */ - mixedSearch(query: string, options?: SearchOptions): Result; + mixedSearch( + query: string, + options?: SearchOptions, + ): Result; /** Content search (live grep). */ grep(query: string, options?: GrepOptions): Result; @@ -604,6 +638,25 @@ export interface FileFinderApi { /** Get a historical query by offset (0 = most recent). */ getHistoricalQuery(offset: number): Result; + /** + * Subscribe to filesystem changes matching `pattern`. + * + * Patterns may be base-relative globs (./ works), exact paths inside the indexed + * tree, or existing directories. An empty pattern watches the whole tree. + * + * Events are debounced and submitted in batches per 100-ms window at most 128 events. + * Gitignored and other ignored files are never triggering watcher. + */ + watch( + callback: WatchBatchCallback, + options?: WatchOptions, + ): Result; + watch( + pattern: string, + callback: WatchBatchCallback, + options?: WatchOptions, + ): Result; + /** Health/diagnostics information for this instance. */ healthCheck(testPath?: string): Result; } diff --git a/packages/fff-node/src/ffi.ts b/packages/fff-node/src/ffi.ts index e1077f9c6..e2315600b 100644 --- a/packages/fff-node/src/ffi.ts +++ b/packages/fff-node/src/ffi.ts @@ -36,12 +36,18 @@ import { close, + createPointer, DataType, + type FieldType, + freePointer, + funcConstructor, isNullPointer, type JsExternal, load, open, + PointerType, restorePointer, + unwrapPointer, wrapPointer, } from "ffi-rs"; import { findBinary } from "./binary.js"; @@ -57,6 +63,8 @@ import type { Result, Score, SearchResult, + WatchEvent, + WatchEventKind, } from "./fff-api.js"; import { createGrepCursor, err } from "./fff-api.js"; @@ -188,7 +196,7 @@ function readCString(ptr: JsExternal): string | null { */ function callRaw( funcName: string, - paramsType: DataType[], + paramsType: FieldType[], paramsValue: unknown[], ): { rawPtr: JsExternal; struct: FffResultRaw } { const rawPtr = load({ @@ -234,11 +242,15 @@ function freeResult(resultPtr: JsExternal): void { */ function readResultEnvelope( funcName: string, - paramsType: DataType[], + paramsType: FieldType[], paramsValue: unknown[], ): { rawPtr: JsExternal; struct: FffResultRaw } | Result { loadLibrary(); - const { rawPtr, struct: structData } = callRaw(funcName, paramsType, paramsValue); + const { rawPtr, struct: structData } = callRaw( + funcName, + paramsType, + paramsValue, + ); if (structData.success === 0) { const errorStr = readCString(structData.error); @@ -252,7 +264,7 @@ function readResultEnvelope( /** Call a function returning FffResult with void payload. */ function callVoidResult( funcName: string, - paramsType: DataType[], + paramsType: FieldType[], paramsValue: unknown[], ): Result { const res = readResultEnvelope(funcName, paramsType, paramsValue); @@ -264,7 +276,7 @@ function callVoidResult( /** Call a function returning FffResult with int_value payload. */ function callIntResult( funcName: string, - paramsType: DataType[], + paramsType: FieldType[], paramsValue: unknown[], ): Result { const res = readResultEnvelope(funcName, paramsType, paramsValue); @@ -277,7 +289,7 @@ function callIntResult( /** Call a function returning FffResult with bool in int_value. */ function callBoolResult( funcName: string, - paramsType: DataType[], + paramsType: FieldType[], paramsValue: unknown[], ): Result { const res = readResultEnvelope(funcName, paramsType, paramsValue); @@ -290,7 +302,7 @@ function callBoolResult( /** Call a function returning FffResult with a C string in handle. */ function callStringResult( funcName: string, - paramsType: DataType[], + paramsType: FieldType[], paramsValue: unknown[], ): Result { const res = readResultEnvelope(funcName, paramsType, paramsValue); @@ -306,7 +318,7 @@ function callStringResult( /** Call a function returning FffResult with a JSON string in handle. */ function callJsonResult( funcName: string, - paramsType: DataType[], + paramsType: FieldType[], paramsValue: unknown[], ): Result { const res = readResultEnvelope(funcName, paramsType, paramsValue); @@ -316,7 +328,8 @@ function callJsonResult( if (isNullPointer(handlePtr)) return { ok: true, value: undefined as T }; const jsonStr = readCString(handlePtr); freeString(handlePtr); - if (jsonStr === null || jsonStr === "") return { ok: true, value: undefined as T }; + if (jsonStr === null || jsonStr === "") + return { ok: true, value: undefined as T }; try { return { ok: true, value: snakeToCamel(JSON.parse(jsonStr)) as T }; } catch { @@ -836,10 +849,16 @@ function readGrepMatchFromRaw(raw: FffGrepMatchRaw): GrepMatch { match.fuzzyScore = raw.fuzzy_score; } if (raw.context_before_count > 0) { - match.contextBefore = readCStringArray(raw.context_before, raw.context_before_count); + match.contextBefore = readCStringArray( + raw.context_before, + raw.context_before_count, + ); } if (raw.context_after_count > 0) { - match.contextAfter = readCStringArray(raw.context_after, raw.context_after_count); + match.contextAfter = readCStringArray( + raw.context_after, + raw.context_after_count, + ); } if (raw.is_definition !== 0) { match.isDefinition = true; @@ -908,7 +927,8 @@ function parseGrepResult(rawPtr: JsExternal): Result { totalFilesSearched: gr.total_files_searched, totalFiles: gr.total_files, filteredFileCount: gr.filtered_file_count, - nextCursor: gr.next_file_offset > 0 ? createGrepCursor(gr.next_file_offset) : null, + nextCursor: + gr.next_file_offset > 0 ? createGrepCursor(gr.next_file_offset) : null, }; if (regexFallbackError) { grepResult.regexFallbackError = regexFallbackError; @@ -1260,7 +1280,14 @@ export function ffiGlob( DataType.U32, // page_index DataType.U32, // page_size ], - paramsValue: [handle, pattern, currentFile, maxThreads, pageIndex, pageSize], + paramsValue: [ + handle, + pattern, + currentFile, + maxThreads, + pageIndex, + pageSize, + ], freeResultMemory: false, }) as JsExternal; @@ -1292,7 +1319,14 @@ export function ffiSearchDirectories( DataType.U32, // page_index DataType.U32, // page_size ], - paramsValue: [handle, query, currentFile ?? "", maxThreads, pageIndex, pageSize], + paramsValue: [ + handle, + query, + currentFile ?? "", + maxThreads, + pageIndex, + pageSize, + ], freeResultMemory: false, }) as JsExternal; @@ -1511,7 +1545,11 @@ export function ffiGetScanProgress(handle: NativeHandle): Result<{ isWarmupComplete: boolean; }> { loadLibrary(); - const res = readResultEnvelope("fff_get_scan_progress", [DataType.External], [handle]); + const res = readResultEnvelope( + "fff_get_scan_progress", + [DataType.External], + [handle], + ); if ("ok" in res) return res; const handlePtr = res.struct.handle; @@ -1546,7 +1584,10 @@ export function ffiGetScanProgress(handle: NativeHandle): Result<{ /** * Wait for a tree scan to complete. */ -export function ffiWaitForScan(handle: NativeHandle, timeoutMs: number): Result { +export function ffiWaitForScan( + handle: NativeHandle, + timeoutMs: number, +): Result { return callBoolResult( "fff_wait_for_scan", [DataType.External, DataType.U64], @@ -1557,7 +1598,10 @@ export function ffiWaitForScan(handle: NativeHandle, timeoutMs: number): Result< /** * Restart index in new path. */ -export function ffiRestartIndex(handle: NativeHandle, newPath: string): Result { +export function ffiRestartIndex( + handle: NativeHandle, + newPath: string, +): Result { return callVoidResult( "fff_restart_index", [DataType.External, DataType.String], @@ -1601,6 +1645,225 @@ export function ffiGetHistoricalQuery( ); } +// ALWAYS KEEP IN SYNC WITH fff.h +// +// Note: node uses `fff_watch_args` (flattened options) because ffi-rs cannot +// marshal a `*const *const c_char` field inside a struct param — StringArray +// is only supported as a top-level parameter. +// +// Batch contents are read through the C accessors (fff_watch_events_count / +// fff_watch_events_get_path / fff_watch_events_get_kind), so no struct +// layout knowledge lives on this side. + +/** Map the C kind byte to the public WatchEventKind. */ +function watchKindFromU8(kind: number): WatchEventKind { + switch (kind) { + case 0: + return "created"; + case 1: + return "modified"; + case 2: + return "removed"; + default: + return "rescan"; + } +} + +/** Trampoline signature: (watch id, batch address, user_data — unused). */ +const WATCH_TRAMPOLINE_TYPE = funcConstructor({ + paramsType: [DataType.U64, DataType.U64, DataType.U64], + retType: DataType.Void, +}); + +/** JS handlers keyed by process-unique native watch id. */ +const watchHandlers = new Map void>(); +/** Instances (by handle identity) that ever created a watch subscription. */ +const watchInstances = new Set(); + +/** Lazily created process-wide trampoline (createPointer result). */ +let watchTrampoline: JsExternal[] | null = null; + +/** Convert a raw u64 address delivered through the trampoline to a JsExternal. */ +function addressToExternal(address: number): JsExternal { + return load({ + library: LIBRARY_KEY, + funcName: "fff_ptr_offset", + retType: DataType.External, + paramsType: [DataType.U64, DataType.U64], + paramsValue: [address, 0], + }) as unknown as JsExternal; +} + +/** Parse an FffWatchEventBatch at `address` and free the native memory. */ +function consumeWatchBatch(address: number): WatchEvent[] { + const batchPtr = addressToExternal(address); + const count = load({ + library: LIBRARY_KEY, + funcName: "fff_watch_events_count", + retType: DataType.U32, + paramsType: [DataType.External], + paramsValue: [batchPtr], + }) as unknown as number; + + const events: WatchEvent[] = []; + for (let i = 0; i < count; i++) { + const path = load({ + library: LIBRARY_KEY, + funcName: "fff_watch_events_get_path", + retType: DataType.External, + paramsType: [DataType.External, DataType.U32], + paramsValue: [batchPtr, i], + }) as unknown as JsExternal; + const kind = load({ + library: LIBRARY_KEY, + funcName: "fff_watch_events_get_kind", + retType: DataType.U8, + paramsType: [DataType.External, DataType.U32], + paramsValue: [batchPtr, i], + }) as unknown as number; + events.push({ + path: readCString(path) ?? "", + kind: watchKindFromU8(kind), + }); + } + + load({ + library: LIBRARY_KEY, + funcName: "fff_free_watch_events", + retType: DataType.Void, + paramsType: [DataType.U64], + paramsValue: [address], + }); + + return events; +} + +/** + * The single native->JS entry point for all watch subscriptions. Runs on + * the JS thread (threadsafe_function delivery); the batch is owned by us + * and freed inside `consumeWatchBatch`. Unknown watch ids (unsubscribe + * races) are benign: the batch is freed and dropped. + */ +function watchTrampolineImpl( + watchId: number, + batchAddress: number, + _userData: number, +): void { + const events = consumeWatchBatch(batchAddress); + const handler = watchHandlers.get(Number(watchId)); + if (handler === undefined || events.length === 0) return; + try { + handler(events); + } catch { + // User callback errors must not propagate into the FFI layer + } +} + +function ensureWatchTrampoline(): JsExternal { + if (watchTrampoline === null) { + watchTrampoline = createPointer({ + paramsType: [WATCH_TRAMPOLINE_TYPE], + paramsValue: [watchTrampolineImpl], + }); + } + return unwrapPointer(watchTrampoline)[0] as JsExternal; +} + +// fff watcher uses a single cross-boundary FFI callback to deliver all events which we then manually +// mapping to the user's javascript functions +function ensureWatchCallbackRegistered(handle: NativeHandle): Result { + if (watchInstances.has(handle as unknown)) + return { ok: true, value: undefined }; + const trampoline = ensureWatchTrampoline(); + const registered = callVoidResult( + "fff_set_watch_callback", + [DataType.External, DataType.External, DataType.U64], + [handle, trampoline, 0], + ); + if (registered.ok) watchInstances.add(handle as unknown); + return registered; +} + +function releaseWatchTrampolineIfIdle(): void { + if ( + watchHandlers.size > 0 || + watchInstances.size > 0 || + watchTrampoline === null + ) + return; + freePointer({ + paramsType: [WATCH_TRAMPOLINE_TYPE], + paramsValue: watchTrampoline, + pointerType: PointerType.RsPointer, + }); + watchTrampoline = null; +} + +/** + * Create a push-mode watch subscription. `callback` receives a normalized + * batch of up to 128 events, delivered on the JS event loop. + * + * Returns the native watch id to pass to `ffiUnwatch`. + */ +export function ffiWatch( + handle: NativeHandle, + pattern: string, + ignore: string[], + callback: (events: WatchEvent[]) => void, +): Result { + loadLibrary(); + + const registered = ensureWatchCallbackRegistered(handle); + if (!registered.ok) return registered; + + const created = callIntResult( + "fff_watch_args", + [DataType.External, DataType.String, DataType.StringArray, DataType.U32], + [handle, pattern, ignore, ignore.length], + ); + + if (!created.ok) return created; + + // No startup race: threadsafe delivery lands on the JS event loop, so this + // synchronous set always precedes the first routing lookup for this id. + watchHandlers.set(created.value, callback); + return created; +} + +/** + * Remove a watch subscription. Drops the JS handler synchronously — once + * this returns the callback can never run again (a late native tail batch + * misses the map lookup and is dropped). + */ +export function ffiUnwatch( + handle: NativeHandle, + watchId: number, +): Result { + const result = callBoolResult( + "fff_unwatch", + [DataType.External, DataType.U64], + [handle, watchId], + ); + watchHandlers.delete(watchId); + return result; +} + +/** + * Post-`ffiDestroy` cleanup for an instance's watch state: drops any + * handlers that were never explicitly unwatched and releases the process + * trampoline when this was the last watching instance. + */ +export function ffiWatchCleanupAfterDestroy( + handle: NativeHandle, + watchIds: Iterable, +): void { + for (const id of watchIds) { + watchHandlers.delete(id); + } + watchInstances.delete(handle as unknown); + releaseWatchTrampolineIfIdle(); +} + /** * Health check. * diff --git a/packages/fff-node/src/finder.ts b/packages/fff-node/src/finder.ts index 3d1b4584a..e80b4ef66 100644 --- a/packages/fff-node/src/finder.ts +++ b/packages/fff-node/src/finder.ts @@ -27,7 +27,10 @@ import { ffiSearchDirectories, ffiSearchMixed, ffiTrackQuery, + ffiUnwatch, ffiWaitForScan, + ffiWatch, + ffiWatchCleanupAfterDestroy, isAvailable, type NativeHandle, } from "./ffi.js"; @@ -47,6 +50,9 @@ import type { ScanProgress, SearchOptions, SearchResult, + WatchBatchCallback, + WatchOptions, + WatchUnsubscribe, } from "./fff-api.js"; import { err } from "./fff-api.js"; @@ -86,6 +92,8 @@ import { err } from "./fff-api.js"; */ export class FileFinder implements FileFinderApi { private handle: NativeHandle | null; + /** Native ids of this instance's active watch subscriptions. */ + private watchers = new Set(); private constructor(handle: NativeHandle) { this.handle = handle; @@ -146,8 +154,15 @@ export class FileFinder implements FileFinderApi { */ destroy(): void { if (this.handle !== null) { - ffiDestroy(this.handle); + const handle = this.handle; + ffiDestroy(handle); this.handle = null; + // ffiDestroy unsubscribes every watch (a delivery racing it is a + // benign id-map miss); dropping the remaining handlers afterwards is + // safe, and the process-wide trampoline is released when this was the + // last watching instance. + ffiWatchCleanupAfterDestroy(handle, this.watchers); + this.watchers.clear(); } } @@ -582,6 +597,83 @@ export class FileFinder implements FileFinderApi { return ffiGetHistoricalQuery(guard.value, offset); } + /** + * Subscribe to filesystem changes matching `pattern`. + * + * Pattern semantics: + * - Wildcards (`*.rs`, `src/**`, `./**\/*.ts`) — glob matched against the + * base-path-relative path. Absolute globs must be under the base path. + * - No wildcards — resolved against the base path (must stay inside it): + * an existing directory subscribes to its whole subtree, anything else + * is an exact file path. + * - Omitted — subscribes to the entire indexed tree: `watch(callback)`. + * + * Push-based: events are delivered by the native watcher through a single + * process-wide callback trampoline (no polling, no idle wakeups). The + * callback receives normalized batches of up to 128 events on the JS event + * loop, with each path appearing at most once. + * + * Requires the instance to be created with watching enabled (default). + * + * @param pattern - Glob pattern, exact file path, or directory + * @param callback - Invoked with each batch of events + * @param options - Watch options + * @returns Unsubscribe handle; call it to stop + * + * @example + * ```typescript + * const sub = finder.watch("**\/*.rs", (events) => { + * for (const e of events) console.log(e.kind, e.path); + * }); + * if (sub.ok) sub.value(); // unsubscribe + * + * // no pattern: everything under the indexed base path + * const all = finder.watch((events) => console.log(events.length)); + * ``` + */ + watch(callback: WatchBatchCallback, options?: WatchOptions): Result; + watch( + pattern: string, + callback: WatchBatchCallback, + options?: WatchOptions, + ): Result; + watch( + patternOrCallback: string | WatchBatchCallback, + callbackOrOptions?: WatchBatchCallback | WatchOptions, + maybeOptions?: WatchOptions, + ): Result { + // Overload shift: watch(cb, opts?) -> empty pattern = whole tree. + const noPattern = typeof patternOrCallback === "function"; + const pattern = noPattern ? "" : patternOrCallback; + const callback = noPattern + ? patternOrCallback + : (callbackOrOptions as WatchBatchCallback); + const options = noPattern + ? (callbackOrOptions as WatchOptions | undefined) + : maybeOptions; + + if (typeof callback !== "function") { + return err("watch callback must be a function"); + } + + const guard = this.ensureAlive(); + if (!guard.ok) return guard; + + const created = ffiWatch(guard.value, pattern, options?.ignore ?? [], callback); + if (!created.ok) return created; + + const watchId = created.value; + this.watchers.add(watchId); + + return { + ok: true, + value: () => { + if (!this.watchers.delete(watchId)) return; + if (this.handle !== null) ffiUnwatch(this.handle, watchId); + }, + }; + } + /** * Get health check information. * diff --git a/packages/fff-node/src/index.ts b/packages/fff-node/src/index.ts index 78cc92421..c1ac706fa 100644 --- a/packages/fff-node/src/index.ts +++ b/packages/fff-node/src/index.ts @@ -67,6 +67,11 @@ export type { Score, SearchOptions, SearchResult, + WatchBatchCallback, + WatchEvent, + WatchEventKind, + WatchOptions, + WatchUnsubscribe, } from "./fff-api.js"; // Result helpers export { err, ok } from "./fff-api.js"; diff --git a/packages/fff-node/test/watch.mjs b/packages/fff-node/test/watch.mjs new file mode 100644 index 000000000..4c0b6fc6e --- /dev/null +++ b/packages/fff-node/test/watch.mjs @@ -0,0 +1,263 @@ +/** + * E2E tests for the filesystem watch subscription API + * (watch). + * + * Uses a fresh temp directory so events are fully deterministic. Delivery is + * push-based: the native dispatch thread invokes a single process-wide + * ffi-rs trampoline (napi threadsafe_function) that routes batches to the + * right JS callback by watch id — no polling. + */ + +import { after, before, describe, it, mock } from "node:test"; +import { strict as assert } from "node:assert"; +import { execFile } from "node:child_process"; +import { mkdtempSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, sep } from "node:path"; +import { promisify } from "node:util"; +import { FileFinder } from "../dist/src/index.js"; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Poll `cond` every 50ms until truthy or timeout. Returns cond() result. */ +async function waitFor(cond, timeoutMs = 10_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = cond(); + if (value) return value; + await sleep(50); + } + return cond(); +} + +/** All events delivered to a batch-callback mock, flattened across calls. */ +const deliveredEvents = (fn) => fn.mock.calls.flatMap((call) => call.arguments[0]); + +/** @type {import("../dist/src/finder.js").FileFinder | null} */ +let finder = null; +/** @type {string} */ +let baseDir = ""; + +describe("fff-node watch", { concurrency: 1 }, () => { + before(async () => { + // realpath: Windows tmpdir() may return an 8.3 short path (RUNNER~1) that + // won't prefix-match the core's canonicalized base when used as a pattern + baseDir = realpathSync(mkdtempSync(join(tmpdir(), "fff-watch-test-"))); + const dbDir = mkdtempSync(join(tmpdir(), "fff-watch-db-")); + + // Seed files so the initial scan has content + writeFileSync(join(baseDir, "seed-a.txt"), "seed a\n"); + writeFileSync(join(baseDir, "seed-b.js"), "// seed b\n"); + + const result = FileFinder.create({ + basePath: baseDir, + frecencyDbPath: join(dbDir, "frecency.mdb"), + historyDbPath: join(dbDir, "history.mdb"), + }); + assert.ok(result.ok, `create failed: ${!result.ok ? result.error : ""}`); + finder = result.value; + + const wait = await finder.waitForScan(10_000); + assert.ok(wait.ok, `waitForScan failed: ${!wait.ok ? wait.error : ""}`); + assert.equal(wait.value, true, "scan should finish within 10s"); + + // Wait until the background watcher is ready, then let it settle + const ready = await waitFor(() => { + const progress = finder.getScanProgress(); + return progress.ok && progress.value.isWatcherReady; + }, 10_000); + assert.ok(ready, "watcher should become ready within 10s"); + await sleep(400); + }); + + after(() => { + if (finder && !finder.isDestroyed) { + finder.destroy(); + } + }); + + it("watch delivers matching events and filters by pattern", async () => { + const callback = mock.fn(); + const sub = finder.watch("**/*.txt", callback); + assert.ok(sub.ok, `watch failed: ${!sub.ok ? sub.error : ""}`); + + writeFileSync(join(baseDir, "hello.txt"), "hello watch\n"); + writeFileSync(join(baseDir, "noise.js"), "// should not match\n"); + + const delivered = await waitFor(() => + deliveredEvents(callback).some((e) => e.path.endsWith("hello.txt")), + ); + assert.ok( + delivered, + `expected hello.txt event, got: ${JSON.stringify(deliveredEvents(callback))}`, + ); + + // Batch contract: every invocation receives exactly one WatchEvent[] argument + for (const call of callback.mock.calls) { + assert.equal(call.arguments.length, 1); + assert.ok(Array.isArray(call.arguments[0]), "callback must receive event batches"); + assert.ok(call.arguments[0].length > 0, "empty batches must not be delivered"); + } + + // Give any (incorrect) .js delivery a chance to arrive, then assert absence + await sleep(700); + assert.ok( + deliveredEvents(callback).every((e) => !e.path.endsWith(".js")), + `non-matching .js events must not be delivered: ${JSON.stringify(deliveredEvents(callback))}`, + ); + + for (const event of deliveredEvents(callback)) { + assert.equal(typeof event.path, "string"); + assert.ok( + ["created", "modified", "removed", "rescan"].includes(event.kind), + `unexpected kind: ${event.kind}`, + ); + } + + // Unsubscribe: further changes must not be delivered + sub.value(); + const callsAfterUnsub = callback.mock.callCount(); + writeFileSync(join(baseDir, "after-unsub.txt"), "too late\n"); + await sleep(700); + assert.equal( + callback.mock.callCount(), + callsAfterUnsub, + `no calls after unsubscribe, got: ${JSON.stringify( + callback.mock.calls.slice(callsAfterUnsub).map((c) => c.arguments), + )}`, + ); + + // Idempotent unsubscribe must not throw + sub.value(); + }); + + it("per-event consumption is a one-line loop over watch", async () => { + const perEvent = mock.fn(); + const sub = finder.watch("**/*.md", (events) => { + for (const event of events) perEvent(event); + }); + assert.ok(sub.ok, `watch failed: ${!sub.ok ? sub.error : ""}`); + + writeFileSync(join(baseDir, "notes.md"), "# notes\n"); + + const delivered = await waitFor(() => + perEvent.mock.calls.some((call) => call.arguments[0].path?.endsWith("notes.md")), + ); + assert.ok( + delivered, + `expected notes.md event, got: ${JSON.stringify( + perEvent.mock.calls.map((c) => c.arguments), + )}`, + ); + + sub.value(); + }); + + it("routes events to the right callback across concurrent subscriptions", async () => { + const txtCallback = mock.fn(); + const mdCallback = mock.fn(); + const txtSub = finder.watch("**/*.route-txt", txtCallback); + const mdSub = finder.watch("**/*.route-md", mdCallback); + assert.ok(txtSub.ok && mdSub.ok); + + writeFileSync(join(baseDir, "routed.route-txt"), "txt\n"); + writeFileSync(join(baseDir, "routed.route-md"), "md\n"); + + const bothDelivered = await waitFor( + () => + deliveredEvents(txtCallback).some((e) => e.path.endsWith("routed.route-txt")) && + deliveredEvents(mdCallback).some((e) => e.path.endsWith("routed.route-md")), + ); + assert.ok(bothDelivered, "both subscriptions must receive their events"); + + // No cross-talk through the shared trampoline + assert.ok( + deliveredEvents(txtCallback).every((e) => !e.path.endsWith(".route-md")), + "txt subscription must not receive md events", + ); + assert.ok( + deliveredEvents(mdCallback).every((e) => !e.path.endsWith(".route-txt")), + "md subscription must not receive txt events", + ); + + txtSub.value(); + mdSub.value(); + }); + + it("watch on a directory respects the ignore option", async () => { + const received = []; + const sub = finder.watch( + baseDir, + (events) => { + received.push(...events); + }, + { ignore: ["*.skiplog"] }, + ); + assert.ok(sub.ok, `watch failed: ${!sub.ok ? sub.error : ""}`); + + writeFileSync(join(baseDir, "dir-shape.txt"), "hello\n"); + writeFileSync(join(baseDir, "dir-noise.skiplog"), "noise\n"); + + const got = await waitFor(() => + received.some((e) => e.path.endsWith(`${sep}dir-shape.txt`)), + ); + assert.ok(got, `expected dir-shape.txt event, got ${JSON.stringify(received)}`); + assert.ok( + !received.some((e) => e.path.endsWith(`${sep}dir-noise.skiplog`)), + `ignore glob leaked: ${JSON.stringify(received)}`, + ); + + sub.value(); + const countAfter = received.length; + writeFileSync(join(baseDir, "dir-late.txt"), "late\n"); + await sleep(700); + assert.equal(received.length, countAfter, "no events after unsubscribe"); + }); + + it("destroy() unsubscribes active watchers without crashing", async () => { + const callback = mock.fn(); + const sub = finder.watch("**/*.txt", callback); + assert.ok(sub.ok, `watch failed: ${!sub.ok ? sub.error : ""}`); + + finder.destroy(); + assert.equal(finder.isDestroyed, true); + + // destroy() joins the native dispatch thread; nothing may arrive after + await sleep(700); + assert.equal(callback.mock.callCount(), 0); + + // Late unsubscribe after destroy must be a no-op + sub.value(); + }); + + it("process exits naturally after unsubscribing (trampoline released)", async () => { + // The threadsafe_function refs the event loop; if the trampoline is not + // freed on last unsubscribe, this child process would hang and time out. + const script = ` + import { mkdtempSync, writeFileSync } from "node:fs"; + import { tmpdir } from "node:os"; + import { join } from "node:path"; + import { FileFinder } from ${JSON.stringify(new URL("../dist/src/index.js", import.meta.url).href)}; + + const dir = mkdtempSync(join(tmpdir(), "fff-watch-exit-")); + writeFileSync(join(dir, "seed.txt"), "seed"); + const created = FileFinder.create({ basePath: dir }); + if (!created.ok) throw new Error(created.error); + const finder = created.value; + await finder.waitForScan(10_000); + const sub = finder.watch("**/*.txt", () => {}); + if (!sub.ok) throw new Error(sub.error); + await new Promise((r) => setTimeout(r, 300)); + sub.value(); + finder.destroy(); + console.log("DONE"); + // no process.exit(): exit must happen naturally + `; + const { stdout } = await promisify(execFile)( + process.execPath, + ["--input-type=module", "-e", script], + { timeout: 15_000 }, + ); + assert.match(stdout, /DONE/); + }); +}); diff --git a/packages/fff-python/README.md b/packages/fff-python/README.md index f70686d64..61dd2624d 100644 --- a/packages/fff-python/README.md +++ b/packages/fff-python/README.md @@ -65,6 +65,42 @@ async def main(): asyncio.run(main()) ``` +### Watching files + +Subscribe to filesystem changes with a glob, an exact path, or a directory +subtree (requires `watch=True`, the default). The callback receives normalized +batches of up to 128 events on a dedicated callback thread. Each path appears +at most once; avoid long-running work so later callbacks are not delayed. + +```python +from fff import FileFinder + +with FileFinder("/path/to/project") as finder: + finder.wait_for_scan_blocking(timeout_ms=5000) + + def on_change(events): + for e in events: + print(e.kind, e.path) # created | modified | removed | rescan + + # Globs are relative to the project root; wildcard-free patterns resolve + # inside the indexed tree — an existing directory watches its whole + # subtree, anything else is an exact file path. + sub = finder.watch("src/**/*.py", on_change) + ... + sub.unsubscribe() # non-blocking; the callback never runs after this + + # No pattern (None) watches the entire indexed tree + with finder.watch(None, on_change): + ... + + # Directory subtree with per-subscription excludes (parcel-watcher style) + with finder.watch("src", on_change, ignore=["*.log", "src/vendor"]): + ... +``` + +A `rescan` event means individual changes were lost (index overflow or an +ignore-file change) — re-check anything you care about. + ## Building wheels ```bash diff --git a/packages/fff-python/src/fff/__init__.py b/packages/fff-python/src/fff/__init__.py index bd64bc647..7f87ca3f0 100644 --- a/packages/fff-python/src/fff/__init__.py +++ b/packages/fff-python/src/fff/__init__.py @@ -19,6 +19,8 @@ ScanProgress, Score, SearchResult, + WatchEvent, + WatchSubscription, ) from fff._fff_python import FileFinder as _FileFinder @@ -66,5 +68,7 @@ async def wait_for_scan(self, timeout_ms: int = 5000) -> bool: "GrepResult", "GrepCursor", "ScanProgress", + "WatchEvent", + "WatchSubscription", "__version__", ] diff --git a/packages/fff-python/src/fff/__init__.pyi b/packages/fff-python/src/fff/__init__.pyi index 1b1c65645..44cee2f1d 100644 --- a/packages/fff-python/src/fff/__init__.pyi +++ b/packages/fff-python/src/fff/__init__.pyi @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Callable, Sequence from os import PathLike from typing import Any, Literal, TypeAlias @@ -145,6 +145,21 @@ class GrepCursor: def __init__(self, offset: int) -> None: ... def __repr__(self) -> str: ... +class WatchEvent: + path: str + kind: Literal["created", "modified", "removed", "rescan"] + def __repr__(self) -> str: ... + +class WatchSubscription: + @property + def id(self) -> int: ... + @property + def active(self) -> bool: ... + def unsubscribe(self) -> bool: ... + def __enter__(self) -> WatchSubscription: ... + def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: ... + def __repr__(self) -> str: ... + class FileFinder: def __init__( self, @@ -163,6 +178,7 @@ class FileFinder: cache_budget_max_file_size: int = 0, enable_fs_root_scanning: bool = False, enable_home_dir_scanning: bool = False, + follow_symlinks: bool = False, ) -> None: ... def __enter__(self) -> FileFinder: ... def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: ... @@ -249,6 +265,13 @@ class FileFinder: def is_scanning(self) -> bool: ... async def wait_for_scan(self, timeout_ms: int = 5000) -> bool: ... def wait_for_scan_blocking(self, timeout_ms: int = 5000) -> bool: ... + def watch( + self, + pattern: str | None, + callback: Callable[[list[WatchEvent]], object], + *, + ignore: list[str] | None = None, + ) -> WatchSubscription: ... def reindex(self, new_path: _PathInput) -> None: ... def refresh_git_status(self) -> int: ... def track_query(self, query: str, selected_file_path: _PathInput) -> bool: ... diff --git a/packages/fff-python/tests/test_watch.py b/packages/fff-python/tests/test_watch.py new file mode 100644 index 000000000..2e55c5d14 --- /dev/null +++ b/packages/fff-python/tests/test_watch.py @@ -0,0 +1,307 @@ +"""Tests for filesystem watch subscriptions.""" + +from __future__ import annotations + +import sys +import tempfile +import threading +import time +from pathlib import Path + +import pytest + +from fff import FFFException, FileFinder, WatchEvent, WatchSubscription + +WATCHER_SETTLE_SECONDS = 0.5 +EVENT_TIMEOUT_SECONDS = 10.0 +QUIET_PERIOD_SECONDS = 0.7 + + +@pytest.fixture +def watch_dir() -> str: + root = Path(tempfile.mkdtemp(prefix="fff-watch-test-")).resolve() + (root / "docs").mkdir() + (root / "docs" / "seed.txt").write_text("seed\n") + (root / "main.py").write_text("print('hi')\n") + yield str(root) + + +@pytest.fixture +def finder(watch_dir: str) -> FileFinder: + with FileFinder(watch_dir, watch=True, enable_content_indexing=False) as f: + assert f.wait_for_scan_blocking(timeout_ms=10000) + wait_for_watcher(f) + yield f + + +def wait_for_watcher(finder: FileFinder) -> None: + deadline = time.monotonic() + EVENT_TIMEOUT_SECONDS + while not finder.scan_progress.is_watcher_ready: + assert time.monotonic() < deadline, "watcher never became ready" + time.sleep(0.05) + # the OS watcher needs a beat after readiness before events flow reliably + time.sleep(WATCHER_SETTLE_SECONDS) + + +def wait_for_event(events: list[WatchEvent], lock: threading.Lock, suffix: str) -> WatchEvent: + deadline = time.monotonic() + EVENT_TIMEOUT_SECONDS + while time.monotonic() < deadline: + with lock: + for ev in events: + if ev.path.endswith(suffix): + return ev + time.sleep(0.05) + with lock: + raise AssertionError(f"no event for {suffix!r} within timeout, got: {events}") + + +def test_watch_delivers_events_and_unsubscribe_stops_them( + finder: FileFinder, watch_dir: str +) -> None: + events: list[WatchEvent] = [] + lock = threading.Lock() + + def on_events(batch: list[WatchEvent]) -> None: + with lock: + events.extend(batch) + + sub = finder.watch("**/*.txt", on_events) + assert isinstance(sub, WatchSubscription) + assert sub.active is True + assert sub.id > 0 + assert repr(sub) == f"WatchSubscription(id={sub.id}, active=True)" + + target = Path(watch_dir) / "docs" / "note.txt" + target.write_text("hello\n") + + ev = wait_for_event(events, lock, "note.txt") + assert ev.path == str(target) + # macOS FSEvents may report creations as modifications after debouncing + assert ev.kind in ("created", "modified") + assert repr(ev).startswith("WatchEvent(") + + # non-matching extensions never show up + with lock: + assert not any(ev.path.endswith(".py") for ev in events) + + assert sub.unsubscribe() is True + assert sub.active is False + assert sub.unsubscribe() is False # idempotent + + with lock: + seen = len(events) + (Path(watch_dir) / "docs" / "after.txt").write_text("too late\n") + time.sleep(QUIET_PERIOD_SECONDS) + with lock: + assert len(events) == seen + + +def test_watch_reports_removed_events(finder: FileFinder, watch_dir: str) -> None: + events: list[WatchEvent] = [] + lock = threading.Lock() + + def on_events(batch: list[WatchEvent]) -> None: + with lock: + events.extend(batch) + + target = Path(watch_dir) / "docs" / "doomed.txt" + with finder.watch("**/*.txt", on_events): + target.write_text("short lived\n") + wait_for_event(events, lock, "doomed.txt") + with lock: + events.clear() + + target.unlink() + ev = wait_for_event(events, lock, "doomed.txt") + assert ev.path == str(target) + assert ev.kind == "removed" + + +def test_multiple_subscriptions_are_filtered_independently( + finder: FileFinder, watch_dir: str +) -> None: + txt_events: list[WatchEvent] = [] + py_events: list[WatchEvent] = [] + lock = threading.Lock() + + def on_txt(batch: list[WatchEvent]) -> None: + with lock: + txt_events.extend(batch) + + def on_py(batch: list[WatchEvent]) -> None: + with lock: + py_events.extend(batch) + + txt_sub = finder.watch("**/*.txt", on_txt) + py_sub = finder.watch("**/*.py", on_py) + assert txt_sub.id != py_sub.id + + try: + (Path(watch_dir) / "both-a.txt").write_text("a\n") + (Path(watch_dir) / "both-b.py").write_text("b = 1\n") + + wait_for_event(txt_events, lock, "both-a.txt") + wait_for_event(py_events, lock, "both-b.py") + + # each subscription only sees paths matching its own pattern + with lock: + assert all(ev.path.endswith(".txt") for ev in txt_events), txt_events + assert all(ev.path.endswith(".py") for ev in py_events), py_events + + # dropping one subscription must not affect the other + assert txt_sub.unsubscribe() is True + with lock: + txt_seen = len(txt_events) + + (Path(watch_dir) / "late.txt").write_text("x\n") + (Path(watch_dir) / "late.py").write_text("y = 2\n") + wait_for_event(py_events, lock, "late.py") + with lock: + assert len(txt_events) == txt_seen + finally: + txt_sub.unsubscribe() + py_sub.unsubscribe() + + +def test_watch_context_manager_unsubscribes(finder: FileFinder, watch_dir: str) -> None: + events: list[WatchEvent] = [] + lock = threading.Lock() + + def on_events(batch: list[WatchEvent]) -> None: + with lock: + events.extend(batch) + + with finder.watch("**/*.txt", on_events) as sub: + assert sub.active is True + (Path(watch_dir) / "inside.txt").write_text("x\n") + wait_for_event(events, lock, "inside.txt") + + assert sub.active is False + with lock: + seen = len(events) + (Path(watch_dir) / "outside.txt").write_text("y\n") + time.sleep(QUIET_PERIOD_SECONDS) + with lock: + assert len(events) == seen + + +def test_watch_callback_exception_does_not_crash(finder: FileFinder, watch_dir: str) -> None: + unraisable: list[object] = [] + invoked = threading.Event() + old_hook = sys.unraisablehook + sys.unraisablehook = lambda args: (unraisable.append(args), invoked.set()) + + def on_events(_batch: list[WatchEvent]) -> None: + raise RuntimeError("boom from callback") + + try: + with finder.watch("**/*.txt", on_events): + (Path(watch_dir) / "explode.txt").write_text("x\n") + assert invoked.wait(EVENT_TIMEOUT_SECONDS), "unraisable hook never fired" + finally: + sys.unraisablehook = old_hook + + # process survived; the finder still works after the callback raised + assert finder.wait_for_scan_blocking(timeout_ms=5000) + assert finder.search("main").total_matched >= 1 + + +def test_watch_validates_inputs(finder: FileFinder, watch_dir: str) -> None: + with pytest.raises(TypeError, match="callback must be callable"): + finder.watch("**/*.txt", "not a callable") + + with pytest.raises(FFFException): + finder.watch("/somewhere/else/**/*.txt", lambda batch: None) + + +def test_watch_without_pattern_watches_whole_tree( + finder: FileFinder, watch_dir: str +) -> None: + """`pattern=None` (and "") subscribes to the entire indexed tree.""" + events: list[WatchEvent] = [] + lock = threading.Lock() + + def on_events(batch: list[WatchEvent]) -> None: + with lock: + events.extend(batch) + + with finder.watch(None, on_events): + (Path(watch_dir) / "anywhere.txt").write_text("x\n") + (Path(watch_dir) / "other.rs").write_text("y\n") + wait_for_event(events, lock, "anywhere.txt") + wait_for_event(events, lock, "other.rs") + + +def test_watch_requires_open_finder(watch_dir: str) -> None: + finder = FileFinder(watch_dir, watch=True, enable_content_indexing=False) + assert finder.wait_for_scan_blocking(timeout_ms=10000) + finder.close() + with pytest.raises(FFFException): + finder.watch("**/*.txt", lambda batch: None) + + +def test_watch_directory_with_ignore(finder: FileFinder, watch_dir: str) -> None: + """A wildcard-free directory pattern subscribes to the whole subtree; + `ignore` entries filter matches out (parcel-watcher style).""" + events: list[WatchEvent] = [] + lock = threading.Lock() + + def on_events(batch: list[WatchEvent]) -> None: + with lock: + events.extend(batch) + + with finder.watch(watch_dir, on_events, ignore=["*.skiplog"]): + (Path(watch_dir) / "subtree.txt").write_text("x\n") + (Path(watch_dir) / "noise.skiplog").write_text("y\n") + wait_for_event(events, lock, "subtree.txt") + + with lock: + assert not any(e.path.endswith("noise.skiplog") for e in events), events + + +def test_unsubscribe_is_nonblocking_and_final(finder: FileFinder, watch_dir: str) -> None: + """unsubscribe() must not block on an in-flight callback (it may finish + concurrently), and no NEW callback invocation starts after it returns.""" + in_callback = threading.Event() + calls: list[float] = [] + lock = threading.Lock() + + def slow_callback(_batch: list[WatchEvent]) -> None: + with lock: + calls.append(time.monotonic()) + in_callback.set() + time.sleep(0.4) + + sub = finder.watch("**/*.txt", slow_callback) + (Path(watch_dir) / "slow.txt").write_text("x\n") + assert in_callback.wait(EVENT_TIMEOUT_SECONDS), "callback never started" + + start = time.monotonic() + assert sub.unsubscribe() is True + assert time.monotonic() - start < 0.3, "unsubscribe must not wait out the callback" + + # no new invocations after unsubscribe returned + with lock: + seen = len(calls) + (Path(watch_dir) / "after-unsub.txt").write_text("y\n") + time.sleep(QUIET_PERIOD_SECONDS) + with lock: + assert len(calls) == seen, "callback started after unsubscribe returned" + + +def test_unsubscribe_from_inside_callback_does_not_deadlock( + finder: FileFinder, watch_dir: str +) -> None: + unsubscribed = threading.Event() + sub_holder: list[WatchSubscription] = [] + + def one_shot(_batch: list[WatchEvent]) -> None: + # self-unsubscribe from the dispatch thread (one-shot pattern) + if sub_holder and sub_holder[0].unsubscribe(): + unsubscribed.set() + + sub_holder.append(finder.watch("**/*.txt", one_shot)) + (Path(watch_dir) / "once.txt").write_text("x\n") + + assert unsubscribed.wait(EVENT_TIMEOUT_SECONDS), "self-unsubscribe deadlocked" + assert sub_holder[0].active is False diff --git a/packages/shared/fff-api.ts b/packages/shared/fff-api.ts index c8a960181..01b2201d8 100644 --- a/packages/shared/fff-api.ts +++ b/packages/shared/fff-api.ts @@ -288,8 +288,36 @@ export interface ScanProgress { } /** - * Database health information + * Normalized watch event kind. + * A file removed and recreated in one processed batch is marked as modified. + * + * rescan = internal OS buffers were overloaded, some events might be missing. + * The `path` is going to be a folder needs to be rescanned + */ +export type WatchEventKind = "created" | "modified" | "removed" | "rescan"; + +/** A single filesystem change notification. */ +export interface WatchEvent { + /** Absolute path of the affected file (base path to rescan if `kind ==rescan`) */ + path: string; + kind: WatchEventKind; +} + +/** Options for watch subscriptions. */ +export interface WatchOptions { + /** Additional glob wildcard patterns to ignore */ + ignore?: string[]; +} + +/** + * Receives normalized batches of up to 128 events. Each path appears once. */ +export type WatchBatchCallback = (events: WatchEvent[]) => void; + +/** Call me to unsubscribe. */ +export type WatchUnsubscribe = () => void; + +/** Database health information */ export interface DbHealth { /** Path to the database */ path: string; @@ -539,10 +567,16 @@ export interface FileFinderApi { glob(pattern: string, options?: GlobOptions): Result; /** Fuzzy directory search. */ - directorySearch(query: string, options?: DirSearchOptions): Result; + directorySearch( + query: string, + options?: DirSearchOptions, + ): Result; /** Fuzzy search over files and directories interleaved by score. */ - mixedSearch(query: string, options?: SearchOptions): Result; + mixedSearch( + query: string, + options?: SearchOptions, + ): Result; /** Content search (live grep). */ grep(query: string, options?: GrepOptions): Result; @@ -598,6 +632,25 @@ export interface FileFinderApi { /** Get a historical query by offset (0 = most recent). */ getHistoricalQuery(offset: number): Result; + /** + * Subscribe to filesystem changes matching `pattern`. + * + * Patterns may be base-relative globs (./ works), exact paths inside the indexed + * tree, or existing directories. An empty pattern watches the whole tree. + * + * Events are debounced and submitted in batches per 100-ms window at most 128 events. + * Gitignored and other ignored files are never triggering watcher. + */ + watch( + callback: WatchBatchCallback, + options?: WatchOptions, + ): Result; + watch( + pattern: string, + callback: WatchBatchCallback, + options?: WatchOptions, + ): Result; + /** Health/diagnostics information for this instance. */ healthCheck(testPath?: string): Result; } diff --git a/tests/screenshots/tests-picker_ui_snap.lua---combo---boost_bottom b/tests/screenshots/tests-picker_ui_snap.lua---combo---boost_bottom index 37666d042..688309db5 100644 --- a/tests/screenshots/tests-picker_ui_snap.lua---combo---boost_bottom +++ b/tests/screenshots/tests-picker_ui_snap.lua---combo---boost_bottom @@ -4,19 +4,19 @@ 03|~ 04|~ ┌ FFFiles ────────────────────────────────────────────┬ src/main.rs ───────────────────────────────────────────┐ 05|~ │ │fn main() {} │ -06|~ │ table.tsx src/components │ │ -07|~ │ list.tsx src/components │ │ -08|~ │ dialog.tsx src/components │ │ -09|~ │ button.tsx src/components │ │ -10|~ │ api.rs src │ │ +06|~ │ api.rs src │ │ +07|~ │ table.tsx src/components │ │ +08|~ │ list.tsx src/components │ │ +09|~ │ dialog.tsx src/components │ │ +10|~ │ button.tsx src/components │ │ 11|~ │ license.md docs │ │ 12|~ │ regression.rs tests │ │ 13|~ │ menu.tsx src/components │ │ 14|~ │ changelog.md docs │ │ -15|~ │ contributing.md docs │ │ -16|~ │ integration.rs tests │ │ -17|~ │ input.tsx src/components │ │ -18|~ │ intro.md docs │ │ +15|~ │ intro.md docs │ │ +16|~ │ contributing.md docs │ │ +17|~ │ integration.rs tests │ │ +18|~ │ input.tsx src/components │ │ 19|~ │ main_test.rs tests │ │ 20|~ │ main_utils.rs src │ │ 21|~ │ main_runner.rs src │ │ @@ -38,19 +38,19 @@ 03|11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 04|11111111111111123333333332222222222222222222222222222222222222222222223333333333333222222222222222222222222222222222222222222221111111111111 05|11111111111111124422222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222221111111111111 -06|11111111111111124422222222225555555555555522222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -07|11111111111111124422222222255555555555555222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -08|11111111111111124422222222222555555555555552222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +06|11111111111111124422222225552222222222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +07|11111111111111124422222222225555555555555522222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +08|11111111111111124422222222255555555555555222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 09|11111111111111124422222222222555555555555552222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -10|11111111111111124422222225552222222222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +10|11111111111111124422222222222555555555555552222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 11|11111111111111124422222222222555522222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 12|11111111111111124422222222222222555552222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 13|11111111111111124422222222255555555555555222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 14|11111111111111124422222222222225555222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -15|11111111111111124422222222222222225555222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -16|11111111111111124422222222222222255555222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -17|11111111111111124422222222225555555555555522222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -18|11111111111111124422222222255552222222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +15|11111111111111124422222222255552222222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +16|11111111111111124422222222222222225555222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +17|11111111111111124422222222222222255555222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +18|11111111111111124422222222225555555555555522222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 19|11111111111111124466662222222225555522222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 20|11111111111111124466662222222222555222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 21|11111111111111124466662222222222255522222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 diff --git a/tests/screenshots/tests-picker_ui_snap.lua---combo---boost_top b/tests/screenshots/tests-picker_ui_snap.lua---combo---boost_top index 478cd2d39..f9b3d5d30 100644 --- a/tests/screenshots/tests-picker_ui_snap.lua---combo---boost_top +++ b/tests/screenshots/tests-picker_ui_snap.lua---combo---boost_top @@ -12,19 +12,19 @@ 11|~ │ main_runner.rs src │ │ 12|~ │ main_utils.rs src │ │ 13|~ │ main_test.rs tests │ │ -14|~ │ intro.md docs │ │ -15|~ │ input.tsx src/components │ │ -16|~ │ integration.rs tests │ │ -17|~ │ contributing.md docs │ │ +14|~ │ input.tsx src/components │ │ +15|~ │ integration.rs tests │ │ +16|~ │ contributing.md docs │ │ +17|~ │ intro.md docs │ │ 18|~ │ changelog.md docs │ │ 19|~ │ menu.tsx src/components │ │ 20|~ │ regression.rs tests │ │ 21|~ │ license.md docs │ │ -22|~ │ api.rs src │ │ -23|~ │ button.tsx src/components │ │ -24|~ │ dialog.tsx src/components │ │ -25|~ │ list.tsx src/components │ │ -26|~ │ table.tsx src/components │ │ +22|~ │ button.tsx src/components │ │ +23|~ │ dialog.tsx src/components │ │ +24|~ │ list.tsx src/components │ │ +25|~ │ table.tsx src/components │ │ +26|~ │ api.rs src │ │ 27|~ │ │ │ 28|~ └─────────────────────────────────────────────────────┴────────────────────────────────────────────────────────┘ 29|~ @@ -46,19 +46,19 @@ 11|111111111111111244666622222222222:::22222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 12|11111111111111124466662222222222:::222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 13|1111111111111112446666222222222:::::22222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -14|111111111111111244222222222::::2222222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -15|1111111111111112442222222222::::::::::::::22222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -16|111111111111111244222222222222222:::::222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -17|1111111111111112442222222222222222::::222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +14|1111111111111112442222222222::::::::::::::22222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +15|111111111111111244222222222222222:::::222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +16|1111111111111112442222222222222222::::222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +17|111111111111111244222222222::::2222222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 18|1111111111111112442222222222222::::222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 19|111111111111111244222222222::::::::::::::222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 20|11111111111111124422222222222222:::::2222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 21|11111111111111124422222222222::::22222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -22|1111111111111112442222222:::2222222222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +22|11111111111111124422222222222::::::::::::::2222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 23|11111111111111124422222222222::::::::::::::2222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -24|11111111111111124422222222222::::::::::::::2222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -25|111111111111111244222222222::::::::::::::222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -26|1111111111111112442222222222::::::::::::::22222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +24|111111111111111244222222222::::::::::::::222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +25|1111111111111112442222222222::::::::::::::22222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +26|1111111111111112442222222:::2222222222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 27|11111111111111124444444444444444444444444444444444444444444444444444424444444444444444444444444444444444444444444444444444444421111111111111 28|11111111111111122222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222221111111111111 29|11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 diff --git a/tests/screenshots/tests-picker_ui_snap.lua---debug_narrow---file_info_panel_bottom b/tests/screenshots/tests-picker_ui_snap.lua---debug_narrow---file_info_panel_bottom index ed19ef2b8..c66c54f8d 100644 --- a/tests/screenshots/tests-picker_ui_snap.lua---debug_narrow---file_info_panel_bottom +++ b/tests/screenshots/tests-picker_ui_snap.lua---debug_narrow---file_info_panel_bottom @@ -5,19 +5,19 @@ 04|~ ┌ FFFiles ────────────────────────────────────────────┬ src/main.rs ───────────────────────────────────────────┐ 05|~ │ │ Size 13 B Type rust │ 06|~ │ │ Git clean Opened never │ -07|~ │ table.tsx src/components │─ Score ────────────────────────────────────────────────│ -08|~ │ list.tsx src/components │ Total 60 fuzzy_filename Frecency acc 0 / mod 0 │ -09|~ │ dialog.tsx src/components │ base 52 +name 8 +special 0 +frec 0 +combo 0 penal…│ -10|~ │ button.tsx src/components │─ Path ─────────────────────────────────────────────────│ -11|~ │ api.rs src │ src/main.rs │ +07|~ │ api.rs src │─ Score ────────────────────────────────────────────────│ +08|~ │ table.tsx src/components │ Total 56 fuzzy_filename Frecency acc 0 / mod 0 │ +09|~ │ list.tsx src/components │ base 48 +name 8 +special 0 +frec 0 +combo 0 penal…│ +10|~ │ dialog.tsx src/components │─ Path ─────────────────────────────────────────────────│ +11|~ │ button.tsx src/components │ src/main.rs │ 12|~ │ license.md docs ├────────────────────────────────────────────────────────┤ 13|~ │ regression.rs tests │fn main() {} │ 14|~ │ menu.tsx src/components │ │ 15|~ │ changelog.md docs │ │ -16|~ │ contributing.md docs │ │ -17|~ │ integration.rs tests │ │ -18|~ │ input.tsx src/components │ │ -19|~ │ intro.md docs │ │ +16|~ │ intro.md docs │ │ +17|~ │ contributing.md docs │ │ +18|~ │ integration.rs tests │ │ +19|~ │ input.tsx src/components │ │ 20|~ │ main_test.rs tests │ │ 21|~ │ main_utils.rs src │ │ 22|~ │ main_runner.rs src │ │ @@ -39,19 +39,19 @@ 04|11111111111111123333333332222222222222222222222222222222222222222222223333333333333222222222222222222222222222222222222222222221111111111111 05|11111111111111124422222222222222222222222222222222222222222222222222222555522666622222222222555522226666222222222222222222222221111111111111 06|11111111111111124422222222222222222222222222222222222222222222222222222555222777772222222222555555224444422222222222222222222221111111111111 -07|11111111111111124422222222225555555555555522222222222222222222222222222288888222222222222222222222222222222222222222222222222221111111111111 -08|11111111111111124422222222255555555555555222222222222222222222222222222555552299299999999999999225555555522777777777777722222221111111111111 -09|111111111111111244222222222225555555555555522222222222222222222222222227777777:::::::::777777777777:::::::::77777777777777777721111111111111 +07|11111111111111124422222225552222222222222222222222222222222222222222222288888222222222222222222222222222222222222222222222222221111111111111 +08|11111111111111124422222222225555555555555522222222222222222222222222222555552299299999999999999225555555522777777777777722222221111111111111 +09|111111111111111244222222222555555555555552222222222222222222222222222227777777:::::::::777777777777:::::::::77777777777777777721111111111111 10|11111111111111124422222222222555555555555552222222222222222222222222222288882222222222222222222222222222222222222222222222222221111111111111 -11|11111111111111124422222225552222222222222222222222222222222222222222222;;;;;;;;;;;2222222222222222222222222222222222222222222221111111111111 +11|11111111111111124422222222222555555555555552222222222222222222222222222;;;;;;;;;;;2222222222222222222222222222222222222222222221111111111111 12|11111111111111124422222222222555522222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222221111111111111 13|11111111111111124422222222222222555552222222222222222222222222222222222222222222222222222222222222222222222222222222222222222221111111111111 14|11111111111111124422222222255555555555555222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 15|11111111111111124422222222222225555222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -16|11111111111111124422222222222222225555222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -17|11111111111111124422222222222222255555222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -18|11111111111111124422222222225555555555555522222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -19|11111111111111124422222222255552222222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +16|11111111111111124422222222255552222222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +17|11111111111111124422222222222222225555222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +18|11111111111111124422222222222222255555222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +19|11111111111111124422222222225555555555555522222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 20|111111111111111244<<<<2222222225555522222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 21|111111111111111244<<<<2222222222555222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 22|111111111111111244<<<<2222222222255522222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 diff --git a/tests/screenshots/tests-picker_ui_snap.lua---debug_narrow---file_info_panel_top b/tests/screenshots/tests-picker_ui_snap.lua---debug_narrow---file_info_panel_top index 54b127fd8..c0837f473 100644 --- a/tests/screenshots/tests-picker_ui_snap.lua---debug_narrow---file_info_panel_top +++ b/tests/screenshots/tests-picker_ui_snap.lua---debug_narrow---file_info_panel_top @@ -6,24 +6,24 @@ 05|~ │> main 19/32 │ Size 13 B Type rust │ 06|~ ├─────────────────────────────────────────────────────┤ Git clean Opened never │ 07|~ │ main.rs src │─ Score ────────────────────────────────────────────────│ -08|~ │ main_helper.rs src │ Total 60 fuzzy_filename Frecency acc 0 / mod 0 │ -09|~ │ main_loop.rs src │ base 52 +name 8 +special 0 +frec 0 +combo 0 penal…│ +08|~ │ main_helper.rs src │ Total 56 fuzzy_filename Frecency acc 0 / mod 0 │ +09|~ │ main_loop.rs src │ base 48 +name 8 +special 0 +frec 0 +combo 0 penal…│ 10|~ │ main_runner.rs src │─ Path ─────────────────────────────────────────────────│ 11|~ │ main_utils.rs src │ src/main.rs │ 12|~ │ main_test.rs tests ├────────────────────────────────────────────────────────┤ -13|~ │ intro.md docs │fn main() {} │ -14|~ │ input.tsx src/components │ │ -15|~ │ integration.rs tests │ │ -16|~ │ contributing.md docs │ │ +13|~ │ input.tsx src/components │fn main() {} │ +14|~ │ integration.rs tests │ │ +15|~ │ contributing.md docs │ │ +16|~ │ intro.md docs │ │ 17|~ │ changelog.md docs │ │ 18|~ │ menu.tsx src/components │ │ 19|~ │ regression.rs tests │ │ 20|~ │ license.md docs │ │ -21|~ │ api.rs src │ │ -22|~ │ button.tsx src/components │ │ -23|~ │ dialog.tsx src/components │ │ -24|~ │ list.tsx src/components │ │ -25|~ │ table.tsx src/components │ │ +21|~ │ button.tsx src/components │ │ +22|~ │ dialog.tsx src/components │ │ +23|~ │ list.tsx src/components │ │ +24|~ │ table.tsx src/components │ │ +25|~ │ api.rs src │ │ 26|~ │ │ │ 27|~ │ │ │ 28|~ └─────────────────────────────────────────────────────┴────────────────────────────────────────────────────────┘ @@ -45,19 +45,19 @@ 10|111111111111111244999922222222222555222222222222222222222222222222222222<<<<2222222222222222222222222222222222222222222222222221111111111111 11|11111111111111124499992222222222555222222222222222222222222222222222222???????????2222222222222222222222222222222222222222222221111111111111 12|11111111111111124499992222222225555522222222222222222222222222222222222222222222222222222222222222222222222222222222222222222221111111111111 -13|11111111111111124422222222255552222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222221111111111111 -14|11111111111111124422222222225555555555555522222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -15|11111111111111124422222222222222255555222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -16|11111111111111124422222222222222225555222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +13|11111111111111124422222222225555555555555522222222222222222222222222222222222222222222222222222222222222222222222222222222222221111111111111 +14|11111111111111124422222222222222255555222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +15|11111111111111124422222222222222225555222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +16|11111111111111124422222222255552222222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 17|11111111111111124422222222222225555222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 18|11111111111111124422222222255555555555555222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 19|11111111111111124422222222222222555552222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 20|11111111111111124422222222222555522222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -21|11111111111111124422222225552222222222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +21|11111111111111124422222222222555555555555552222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 22|11111111111111124422222222222555555555555552222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -23|11111111111111124422222222222555555555555552222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -24|11111111111111124422222222255555555555555222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -25|11111111111111124422222222225555555555555522222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +23|11111111111111124422222222255555555555555222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +24|11111111111111124422222222225555555555555522222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +25|11111111111111124422222225552222222222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 26|11111111111111124444444444444444444444444444444444444444444444444444424444444444444444444444444444444444444444444444444444444421111111111111 27|11111111111111124444444444444444444444444444444444444444444444444444424444444444444444444444444444444444444444444444444444444421111111111111 28|11111111111111122222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222221111111111111 diff --git a/tests/screenshots/tests-picker_ui_snap.lua---debug_wide---file_info_panel_bottom b/tests/screenshots/tests-picker_ui_snap.lua---debug_wide---file_info_panel_bottom index 2962c9753..83427fbc4 100644 --- a/tests/screenshots/tests-picker_ui_snap.lua---debug_wide---file_info_panel_bottom +++ b/tests/screenshots/tests-picker_ui_snap.lua---debug_wide---file_info_panel_bottom @@ -8,8 +8,8 @@ 07|~ │ │ Size 13 B Type rust │ 08|~ │ │ Git clean Opened never │ 09|~ │ │─ Score ────────────────────────────────────────────────────────────────────────────────────────│ -10|~ │ │ Total 60 fuzzy_filename Frecency acc 0 / mod 0 │ -11|~ │ │ base 52 +name 8 +special 0 +frec 0 +combo 0 penalty 0 │ +10|~ │ │ Total 56 fuzzy_filename Frecency acc 0 / mod 0 │ +11|~ │ │ base 48 +name 8 +special 0 +frec 0 +combo 0 penalty 0 │ 12|~ │ │─ Path ─────────────────────────────────────────────────────────────────────────────────────────│ 13|~ │ │ src/main.rs │ 14|~ │ ├────────────────────────────────────────────────────────────────────────────────────────────────┤ @@ -20,19 +20,19 @@ 19|~ │ │ │ 20|~ │ │ │ 21|~ │ │ │ -22|~ │ table.tsx src/components │ │ -23|~ │ list.tsx src/components │ │ -24|~ │ dialog.tsx src/components │ │ -25|~ │ button.tsx src/components │ │ -26|~ │ api.rs src │ │ +22|~ │ api.rs src │ │ +23|~ │ table.tsx src/components │ │ +24|~ │ list.tsx src/components │ │ +25|~ │ dialog.tsx src/components │ │ +26|~ │ button.tsx src/components │ │ 27|~ │ license.md docs │ │ 28|~ │ regression.rs tests │ │ 29|~ │ menu.tsx src/components │ │ 30|~ │ changelog.md docs │ │ -31|~ │ contributing.md docs │ │ -32|~ │ integration.rs tests │ │ -33|~ │ input.tsx src/components │ │ -34|~ │ intro.md docs │ │ +31|~ │ intro.md docs │ │ +32|~ │ contributing.md docs │ │ +33|~ │ integration.rs tests │ │ +34|~ │ input.tsx src/components │ │ 35|~ │ main_test.rs tests │ │ 36|~ │ main_utils.rs src │ │ 37|~ │ main_runner.rs src │ │ @@ -70,19 +70,19 @@ 19|111111111111111111111111124422222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 20|111111111111111111111111124422222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 21|111111111111111111111111124422222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -22|111111111111111111111111124422222222225555555555555522222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -23|111111111111111111111111124422222222255555555555555222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -24|111111111111111111111111124422222222222555555555555552222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +22|111111111111111111111111124422222225552222222222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +23|111111111111111111111111124422222222225555555555555522222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +24|111111111111111111111111124422222222255555555555555222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 25|111111111111111111111111124422222222222555555555555552222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -26|111111111111111111111111124422222225552222222222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +26|111111111111111111111111124422222222222555555555555552222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 27|111111111111111111111111124422222222222555522222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 28|111111111111111111111111124422222222222222555552222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 29|111111111111111111111111124422222222255555555555555222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 30|111111111111111111111111124422222222222225555222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -31|111111111111111111111111124422222222222222225555222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -32|111111111111111111111111124422222222222222255555222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -33|111111111111111111111111124422222222225555555555555522222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -34|111111111111111111111111124422222222255552222222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +31|111111111111111111111111124422222222255552222222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +32|111111111111111111111111124422222222222222225555222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +33|111111111111111111111111124422222222222222255555222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +34|111111111111111111111111124422222222225555555555555522222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 35|1111111111111111111111111244<<<<2222222225555522222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 36|1111111111111111111111111244<<<<2222222222555222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 37|1111111111111111111111111244<<<<2222222222255522222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 diff --git a/tests/screenshots/tests-picker_ui_snap.lua---debug_wide---file_info_panel_top b/tests/screenshots/tests-picker_ui_snap.lua---debug_wide---file_info_panel_top index 306bea203..e7c83255d 100644 --- a/tests/screenshots/tests-picker_ui_snap.lua---debug_wide---file_info_panel_top +++ b/tests/screenshots/tests-picker_ui_snap.lua---debug_wide---file_info_panel_top @@ -8,24 +8,24 @@ 07|~ │> main 19/32 │ Size 13 B Type rust │ 08|~ ├─────────────────────────────────────────────────────────────────────────────────────────────┤ Git clean Opened never │ 09|~ │ main.rs src │─ Score ────────────────────────────────────────────────────────────────────────────────────────│ -10|~ │ main_helper.rs src │ Total 60 fuzzy_filename Frecency acc 0 / mod 0 │ -11|~ │ main_loop.rs src │ base 52 +name 8 +special 0 +frec 0 +combo 0 penalty 0 │ +10|~ │ main_helper.rs src │ Total 56 fuzzy_filename Frecency acc 0 / mod 0 │ +11|~ │ main_loop.rs src │ base 48 +name 8 +special 0 +frec 0 +combo 0 penalty 0 │ 12|~ │ main_runner.rs src │─ Path ─────────────────────────────────────────────────────────────────────────────────────────│ 13|~ │ main_utils.rs src │ src/main.rs │ 14|~ │ main_test.rs tests ├────────────────────────────────────────────────────────────────────────────────────────────────┤ -15|~ │ intro.md docs │fn main() {} │ -16|~ │ input.tsx src/components │ │ -17|~ │ integration.rs tests │ │ -18|~ │ contributing.md docs │ │ +15|~ │ input.tsx src/components │fn main() {} │ +16|~ │ integration.rs tests │ │ +17|~ │ contributing.md docs │ │ +18|~ │ intro.md docs │ │ 19|~ │ changelog.md docs │ │ 20|~ │ menu.tsx src/components │ │ 21|~ │ regression.rs tests │ │ 22|~ │ license.md docs │ │ -23|~ │ api.rs src │ │ -24|~ │ button.tsx src/components │ │ -25|~ │ dialog.tsx src/components │ │ -26|~ │ list.tsx src/components │ │ -27|~ │ table.tsx src/components │ │ +23|~ │ button.tsx src/components │ │ +24|~ │ dialog.tsx src/components │ │ +25|~ │ list.tsx src/components │ │ +26|~ │ table.tsx src/components │ │ +27|~ │ api.rs src │ │ 28|~ │ │ │ 29|~ │ │ │ 30|~ │ │ │ @@ -63,19 +63,19 @@ 12|11111111111111111111111112449999222222222225552222222222222222222222222222222222222222222222222222222222222222222222222222<<<<222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222211111111111111111111111 13|1111111111111111111111111244999922222222225552222222222222222222222222222222222222222222222222222222222222222222222222222???????????222222222222222222222222222222222222222222222222222222222222222222222222222222222222211111111111111111111111 14|111111111111111111111111124499992222222225555522222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222211111111111111111111111 -15|111111111111111111111111124422222222255552222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222211111111111111111111111 -16|111111111111111111111111124422222222225555555555555522222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -17|111111111111111111111111124422222222222222255555222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -18|111111111111111111111111124422222222222222225555222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +15|111111111111111111111111124422222222225555555555555522222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222211111111111111111111111 +16|111111111111111111111111124422222222222222255555222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +17|111111111111111111111111124422222222222222225555222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +18|111111111111111111111111124422222222255552222222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 19|111111111111111111111111124422222222222225555222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 20|111111111111111111111111124422222222255555555555555222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 21|111111111111111111111111124422222222222222555552222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 22|111111111111111111111111124422222222222555522222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -23|111111111111111111111111124422222225552222222222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +23|111111111111111111111111124422222222222555555555555552222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 24|111111111111111111111111124422222222222555555555555552222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -25|111111111111111111111111124422222222222555555555555552222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -26|111111111111111111111111124422222222255555555555555222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -27|111111111111111111111111124422222222225555555555555522222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +25|111111111111111111111111124422222222255555555555555222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +26|111111111111111111111111124422222222225555555555555522222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +27|111111111111111111111111124422222225552222222222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 28|111111111111111111111111124444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444442444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 29|111111111111111111111111124444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444442444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 30|111111111111111111111111124444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444442444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 diff --git a/tests/screenshots/tests-picker_ui_snap.lua---default---query_main_bottom b/tests/screenshots/tests-picker_ui_snap.lua---default---query_main_bottom index aee41b7e9..287736428 100644 --- a/tests/screenshots/tests-picker_ui_snap.lua---default---query_main_bottom +++ b/tests/screenshots/tests-picker_ui_snap.lua---default---query_main_bottom @@ -5,19 +5,19 @@ 04|~ ┌ FFFiles ────────────────────────────────────────────┬ src/main.rs ───────────────────────────────────────────┐ 05|~ │ │fn main() {} │ 06|~ │ │ │ -07|~ │ table.tsx src/components │ │ -08|~ │ list.tsx src/components │ │ -09|~ │ dialog.tsx src/components │ │ -10|~ │ button.tsx src/components │ │ -11|~ │ api.rs src │ │ +07|~ │ api.rs src │ │ +08|~ │ table.tsx src/components │ │ +09|~ │ list.tsx src/components │ │ +10|~ │ dialog.tsx src/components │ │ +11|~ │ button.tsx src/components │ │ 12|~ │ license.md docs │ │ 13|~ │ regression.rs tests │ │ 14|~ │ menu.tsx src/components │ │ 15|~ │ changelog.md docs │ │ -16|~ │ contributing.md docs │ │ -17|~ │ integration.rs tests │ │ -18|~ │ input.tsx src/components │ │ -19|~ │ intro.md docs │ │ +16|~ │ intro.md docs │ │ +17|~ │ contributing.md docs │ │ +18|~ │ integration.rs tests │ │ +19|~ │ input.tsx src/components │ │ 20|~ │ main_test.rs tests │ │ 21|~ │ main_utils.rs src │ │ 22|~ │ main_runner.rs src │ │ @@ -39,19 +39,19 @@ 04|11111111111111123333333332222222222222222222222222222222222222222222223333333333333222222222222222222222222222222222222222222221111111111111 05|11111111111111124422222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222221111111111111 06|11111111111111124422222222222222222222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -07|11111111111111124422222222225555555555555522222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -08|11111111111111124422222222255555555555555222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -09|11111111111111124422222222222555555555555552222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +07|11111111111111124422222225552222222222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +08|11111111111111124422222222225555555555555522222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +09|11111111111111124422222222255555555555555222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 10|11111111111111124422222222222555555555555552222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -11|11111111111111124422222225552222222222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +11|11111111111111124422222222222555555555555552222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 12|11111111111111124422222222222555522222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 13|11111111111111124422222222222222555552222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 14|11111111111111124422222222255555555555555222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 15|11111111111111124422222222222225555222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -16|11111111111111124422222222222222225555222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -17|11111111111111124422222222222222255555222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -18|11111111111111124422222222225555555555555522222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -19|11111111111111124422222222255552222222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +16|11111111111111124422222222255552222222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +17|11111111111111124422222222222222225555222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +18|11111111111111124422222222222222255555222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +19|11111111111111124422222222225555555555555522222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 20|11111111111111124466662222222225555522222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 21|11111111111111124466662222222222555222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 22|11111111111111124466662222222222255522222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 diff --git a/tests/screenshots/tests-picker_ui_snap.lua---default---query_main_top b/tests/screenshots/tests-picker_ui_snap.lua---default---query_main_top index 81feeb47f..fd3ac8a42 100644 --- a/tests/screenshots/tests-picker_ui_snap.lua---default---query_main_top +++ b/tests/screenshots/tests-picker_ui_snap.lua---default---query_main_top @@ -11,19 +11,19 @@ 10|~ │ main_runner.rs src │ │ 11|~ │ main_utils.rs src │ │ 12|~ │ main_test.rs tests │ │ -13|~ │ intro.md docs │ │ -14|~ │ input.tsx src/components │ │ -15|~ │ integration.rs tests │ │ -16|~ │ contributing.md docs │ │ +13|~ │ input.tsx src/components │ │ +14|~ │ integration.rs tests │ │ +15|~ │ contributing.md docs │ │ +16|~ │ intro.md docs │ │ 17|~ │ changelog.md docs │ │ 18|~ │ menu.tsx src/components │ │ 19|~ │ regression.rs tests │ │ 20|~ │ license.md docs │ │ -21|~ │ api.rs src │ │ -22|~ │ button.tsx src/components │ │ -23|~ │ dialog.tsx src/components │ │ -24|~ │ list.tsx src/components │ │ -25|~ │ table.tsx src/components │ │ +21|~ │ button.tsx src/components │ │ +22|~ │ dialog.tsx src/components │ │ +23|~ │ list.tsx src/components │ │ +24|~ │ table.tsx src/components │ │ +25|~ │ api.rs src │ │ 26|~ │ │ │ 27|~ │ │ │ 28|~ └─────────────────────────────────────────────────────┴────────────────────────────────────────────────────────┘ @@ -45,19 +45,19 @@ 10|11111111111111124466662222222222299922222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 11|11111111111111124466662222222222999222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 12|11111111111111124466662222222229999922222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -13|11111111111111124422222222299992222222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -14|11111111111111124422222222229999999999999922222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -15|11111111111111124422222222222222299999222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -16|11111111111111124422222222222222229999222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +13|11111111111111124422222222229999999999999922222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +14|11111111111111124422222222222222299999222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +15|11111111111111124422222222222222229999222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +16|11111111111111124422222222299992222222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 17|11111111111111124422222222222229999222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 18|11111111111111124422222222299999999999999222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 19|11111111111111124422222222222222999992222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 20|11111111111111124422222222222999922222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -21|11111111111111124422222229992222222222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +21|11111111111111124422222222222999999999999992222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 22|11111111111111124422222222222999999999999992222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -23|11111111111111124422222222222999999999999992222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -24|11111111111111124422222222299999999999999222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -25|11111111111111124422222222229999999999999922222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +23|11111111111111124422222222299999999999999222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +24|11111111111111124422222222229999999999999922222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 +25|11111111111111124422222229992222222222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 26|11111111111111124444444444444444444444444444444444444444444444444444424444444444444444444444444444444444444444444444444444444421111111111111 27|11111111111111124444444444444444444444444444444444444444444444444444424444444444444444444444444444444444444444444444444444444421111111111111 28|11111111111111122222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222221111111111111 diff --git a/tests/screenshots/tests-picker_ui_snap.lua---narrow---query_main_bottom b/tests/screenshots/tests-picker_ui_snap.lua---narrow---query_main_bottom index a09ee0d20..5022b06e4 100644 --- a/tests/screenshots/tests-picker_ui_snap.lua---narrow---query_main_bottom +++ b/tests/screenshots/tests-picker_ui_snap.lua---narrow---query_main_bottom @@ -2,15 +2,15 @@ 01| 02|~ 03|~ ╭ FFFiles ─────────────────────────────────────────────╮ -04|~ │ api.rs src │ +04|~ │ button.tsx src/components │ 05|~ │ license.md docs │ 06|~ │ regression.rs tests │ 07|~ │ menu.tsx src/components │ 08|~ │ changelog.md docs │ -09|~ │ contributing.md docs │ -10|~ │ integration.rs tests │ -11|~ │ input.tsx src/components │ -12|~ │ intro.md docs │ +09|~ │ intro.md docs │ +10|~ │ contributing.md docs │ +11|~ │ integration.rs tests │ +12|~ │ input.tsx src/components │ 13|~ │ main_test.rs tests │ 14|~ │ main_utils.rs src │ 15|~ │ main_runner.rs src │ @@ -28,15 +28,15 @@ 01|0000000000000000000000000000000000000000000000000000000000000000000000 02|1111111111111111111111111111111111111111111111111111111111111111111111 03|1111111123333333332222222222222222222222222222222222222222222222111111 -04|1111111124422222225552222222222222222222222222222222222222222222111111 +04|1111111124422222222222555555555555552222222222222222222222222222111111 05|1111111124422222222222555522222222222222222222222222222222222222111111 06|1111111124422222222222222555552222222222222222222222222222222222111111 07|1111111124422222222255555555555555222222222222222222222222222222111111 08|1111111124422222222222225555222222222222222222222222222222222222111111 -09|1111111124422222222222222225555222222222222222222222222222222222111111 -10|1111111124422222222222222255555222222222222222222222222222222222111111 -11|1111111124422222222225555555555555522222222222222222222222222222111111 -12|1111111124422222222255552222222222222222222222222222222222222222111111 +09|1111111124422222222255552222222222222222222222222222222222222222111111 +10|1111111124422222222222222225555222222222222222222222222222222222111111 +11|1111111124422222222222222255555222222222222222222222222222222222111111 +12|1111111124422222222225555555555555522222222222222222222222222222111111 13|1111111124466662222222225555522222222222222222222222222222222222111111 14|1111111124466662222222222555222222222222222222222222222222222222111111 15|1111111124466662222222222255522222222222222222222222222222222222111111 diff --git a/tests/screenshots/tests-picker_ui_snap.lua---narrow---query_main_top b/tests/screenshots/tests-picker_ui_snap.lua---narrow---query_main_top index 12a935384..2fc73c51a 100644 --- a/tests/screenshots/tests-picker_ui_snap.lua---narrow---query_main_top +++ b/tests/screenshots/tests-picker_ui_snap.lua---narrow---query_main_top @@ -10,10 +10,10 @@ 09|~ │ main_runner.rs src │ 10|~ │ main_utils.rs src │ 11|~ │ main_test.rs tests │ -12|~ │ intro.md docs │ -13|~ │ input.tsx src/components │ -14|~ │ integration.rs tests │ -15|~ │ contributing.md docs │ +12|~ │ input.tsx src/components │ +13|~ │ integration.rs tests │ +14|~ │ contributing.md docs │ +15|~ │ intro.md docs │ 16|~ │ changelog.md docs │ 17|~ │ menu.tsx src/components │ 18|~ │ regression.rs tests │ @@ -36,10 +36,10 @@ 09|1111111124466662222222222299922222222222222222222222222222222222111111 10|1111111124466662222222222999222222222222222222222222222222222222111111 11|1111111124466662222222229999922222222222222222222222222222222222111111 -12|1111111124422222222299992222222222222222222222222222222222222222111111 -13|1111111124422222222229999999999999922222222222222222222222222222111111 -14|1111111124422222222222222299999222222222222222222222222222222222111111 -15|1111111124422222222222222229999222222222222222222222222222222222111111 +12|1111111124422222222229999999999999922222222222222222222222222222111111 +13|1111111124422222222222222299999222222222222222222222222222222222111111 +14|1111111124422222222222222229999222222222222222222222222222222222111111 +15|1111111124422222222299992222222222222222222222222222222222222222111111 16|1111111124422222222222229999222222222222222222222222222222222222111111 17|1111111124422222222299999999999999222222222222222222222222222222111111 18|1111111124422222222222222999992222222222222222222222222222222222111111 diff --git a/tests/screenshots/tests-picker_ui_snap.lua---wide---query_main_bottom b/tests/screenshots/tests-picker_ui_snap.lua---wide---query_main_bottom index 812f3a035..4fbb5c905 100644 --- a/tests/screenshots/tests-picker_ui_snap.lua---wide---query_main_bottom +++ b/tests/screenshots/tests-picker_ui_snap.lua---wide---query_main_bottom @@ -13,19 +13,19 @@ 12|~ ║ ║ ║ 13|~ ║ ║ ║ 14|~ ║ ║ ║ -15|~ ║ table.tsx src/components ║ ║ -16|~ ║ list.tsx src/components ║ ║ -17|~ ║ dialog.tsx src/components ║ ║ -18|~ ║ button.tsx src/components ║ ║ -19|~ ║ api.rs src ║ ║ +15|~ ║ api.rs src ║ ║ +16|~ ║ table.tsx src/components ║ ║ +17|~ ║ list.tsx src/components ║ ║ +18|~ ║ dialog.tsx src/components ║ ║ +19|~ ║ button.tsx src/components ║ ║ 20|~ ║ license.md docs ║ ║ 21|~ ║ regression.rs tests ║ ║ 22|~ ║ menu.tsx src/components ║ ║ 23|~ ║ changelog.md docs ║ ║ -24|~ ║ contributing.md docs ║ ║ -25|~ ║ integration.rs tests ║ ║ -26|~ ║ input.tsx src/components ║ ║ -27|~ ║ intro.md docs ║ ║ +24|~ ║ intro.md docs ║ ║ +25|~ ║ contributing.md docs ║ ║ +26|~ ║ integration.rs tests ║ ║ +27|~ ║ input.tsx src/components ║ ║ 28|~ ║ main_test.rs tests ║ ║ 29|~ ║ main_utils.rs src ║ ║ 30|~ ║ main_runner.rs src ║ ║ @@ -55,19 +55,19 @@ 12|111111111111111111124422222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 13|111111111111111111124422222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 14|111111111111111111124422222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 -15|111111111111111111124422222222225555555555555522222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 -16|111111111111111111124422222222255555555555555222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 -17|111111111111111111124422222222222555555555555552222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 +15|111111111111111111124422222225552222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 +16|111111111111111111124422222222225555555555555522222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 +17|111111111111111111124422222222255555555555555222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 18|111111111111111111124422222222222555555555555552222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 -19|111111111111111111124422222225552222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 +19|111111111111111111124422222222222555555555555552222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 20|111111111111111111124422222222222555522222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 21|111111111111111111124422222222222222555552222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 22|111111111111111111124422222222255555555555555222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 23|111111111111111111124422222222222225555222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 -24|111111111111111111124422222222222222225555222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 -25|111111111111111111124422222222222222255555222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 -26|111111111111111111124422222222225555555555555522222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 -27|111111111111111111124422222222255552222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 +24|111111111111111111124422222222255552222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 +25|111111111111111111124422222222222222225555222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 +26|111111111111111111124422222222222222255555222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 +27|111111111111111111124422222222225555555555555522222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 28|111111111111111111124466662222222225555522222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 29|111111111111111111124466662222222222555222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 30|111111111111111111124466662222222222255522222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 diff --git a/tests/screenshots/tests-picker_ui_snap.lua---wide---query_main_top b/tests/screenshots/tests-picker_ui_snap.lua---wide---query_main_top index e927430f0..0eea182e1 100644 --- a/tests/screenshots/tests-picker_ui_snap.lua---wide---query_main_top +++ b/tests/screenshots/tests-picker_ui_snap.lua---wide---query_main_top @@ -12,19 +12,19 @@ 11|~ ║ main_runner.rs src ║ ║ 12|~ ║ main_utils.rs src ║ ║ 13|~ ║ main_test.rs tests ║ ║ -14|~ ║ intro.md docs ║ ║ -15|~ ║ input.tsx src/components ║ ║ -16|~ ║ integration.rs tests ║ ║ -17|~ ║ contributing.md docs ║ ║ +14|~ ║ input.tsx src/components ║ ║ +15|~ ║ integration.rs tests ║ ║ +16|~ ║ contributing.md docs ║ ║ +17|~ ║ intro.md docs ║ ║ 18|~ ║ changelog.md docs ║ ║ 19|~ ║ menu.tsx src/components ║ ║ 20|~ ║ regression.rs tests ║ ║ 21|~ ║ license.md docs ║ ║ -22|~ ║ api.rs src ║ ║ -23|~ ║ button.tsx src/components ║ ║ -24|~ ║ dialog.tsx src/components ║ ║ -25|~ ║ list.tsx src/components ║ ║ -26|~ ║ table.tsx src/components ║ ║ +22|~ ║ button.tsx src/components ║ ║ +23|~ ║ dialog.tsx src/components ║ ║ +24|~ ║ list.tsx src/components ║ ║ +25|~ ║ table.tsx src/components ║ ║ +26|~ ║ api.rs src ║ ║ 27|~ ║ ║ ║ 28|~ ║ ║ ║ 29|~ ║ ║ ║ @@ -54,19 +54,19 @@ 11|111111111111111111124466662222222222299922222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 12|111111111111111111124466662222222222999222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 13|111111111111111111124466662222222229999922222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 -14|111111111111111111124422222222299992222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 -15|111111111111111111124422222222229999999999999922222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 -16|111111111111111111124422222222222222299999222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 -17|111111111111111111124422222222222222229999222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 +14|111111111111111111124422222222229999999999999922222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 +15|111111111111111111124422222222222222299999222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 +16|111111111111111111124422222222222222229999222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 +17|111111111111111111124422222222299992222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 18|111111111111111111124422222222222229999222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 19|111111111111111111124422222222299999999999999222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 20|111111111111111111124422222222222222999992222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 21|111111111111111111124422222222222999922222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 -22|111111111111111111124422222229992222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 +22|111111111111111111124422222222222999999999999992222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 23|111111111111111111124422222222222999999999999992222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 -24|111111111111111111124422222222222999999999999992222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 -25|111111111111111111124422222222299999999999999222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 -26|111111111111111111124422222222229999999999999922222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 +24|111111111111111111124422222222299999999999999222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 +25|111111111111111111124422222222229999999999999922222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 +26|111111111111111111124422222229992222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 27|111111111111111111124444444444444444444444444444444444444444444444444444444444444444444442444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 28|111111111111111111124444444444444444444444444444444444444444444444444444444444444444444442444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 29|111111111111111111124444444444444444444444444444444444444444444444444444444444444444444442444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111 diff --git a/tests/screenshots/tests-picker_ui_snap.lua---xwide---query_main_bottom b/tests/screenshots/tests-picker_ui_snap.lua---xwide---query_main_bottom index 881c04c9a..c857b9c56 100644 --- a/tests/screenshots/tests-picker_ui_snap.lua---xwide---query_main_bottom +++ b/tests/screenshots/tests-picker_ui_snap.lua---xwide---query_main_bottom @@ -20,19 +20,19 @@ 19|~ │ │ │ 20|~ │ │ │ 21|~ │ │ │ -22|~ │ table.tsx src/components │ │ -23|~ │ list.tsx src/components │ │ -24|~ │ dialog.tsx src/components │ │ -25|~ │ button.tsx src/components │ │ -26|~ │ api.rs src │ │ +22|~ │ api.rs src │ │ +23|~ │ table.tsx src/components │ │ +24|~ │ list.tsx src/components │ │ +25|~ │ dialog.tsx src/components │ │ +26|~ │ button.tsx src/components │ │ 27|~ │ license.md docs │ │ 28|~ │ regression.rs tests │ │ 29|~ │ menu.tsx src/components │ │ 30|~ │ changelog.md docs │ │ -31|~ │ contributing.md docs │ │ -32|~ │ integration.rs tests │ │ -33|~ │ input.tsx src/components │ │ -34|~ │ intro.md docs │ │ +31|~ │ intro.md docs │ │ +32|~ │ contributing.md docs │ │ +33|~ │ integration.rs tests │ │ +34|~ │ input.tsx src/components │ │ 35|~ │ main_test.rs tests │ │ 36|~ │ main_utils.rs src │ │ 37|~ │ main_runner.rs src │ │ @@ -70,19 +70,19 @@ 19|111111111111111111111111124422222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 20|111111111111111111111111124422222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 21|111111111111111111111111124422222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -22|111111111111111111111111124422222222225555555555555522222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -23|111111111111111111111111124422222222255555555555555222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -24|111111111111111111111111124422222222222555555555555552222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +22|111111111111111111111111124422222225552222222222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +23|111111111111111111111111124422222222225555555555555522222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +24|111111111111111111111111124422222222255555555555555222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 25|111111111111111111111111124422222222222555555555555552222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -26|111111111111111111111111124422222225552222222222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +26|111111111111111111111111124422222222222555555555555552222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 27|111111111111111111111111124422222222222555522222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 28|111111111111111111111111124422222222222222555552222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 29|111111111111111111111111124422222222255555555555555222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 30|111111111111111111111111124422222222222225555222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -31|111111111111111111111111124422222222222222225555222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -32|111111111111111111111111124422222222222222255555222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -33|111111111111111111111111124422222222225555555555555522222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -34|111111111111111111111111124422222222255552222222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +31|111111111111111111111111124422222222255552222222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +32|111111111111111111111111124422222222222222225555222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +33|111111111111111111111111124422222222222222255555222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +34|111111111111111111111111124422222222225555555555555522222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 35|111111111111111111111111124466662222222225555522222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 36|111111111111111111111111124466662222222222555222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 37|111111111111111111111111124466662222222222255522222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 diff --git a/tests/screenshots/tests-picker_ui_snap.lua---xwide---query_main_top b/tests/screenshots/tests-picker_ui_snap.lua---xwide---query_main_top index bb334a47e..5d89e52b8 100644 --- a/tests/screenshots/tests-picker_ui_snap.lua---xwide---query_main_top +++ b/tests/screenshots/tests-picker_ui_snap.lua---xwide---query_main_top @@ -13,19 +13,19 @@ 12|~ │ main_runner.rs src │ │ 13|~ │ main_utils.rs src │ │ 14|~ │ main_test.rs tests │ │ -15|~ │ intro.md docs │ │ -16|~ │ input.tsx src/components │ │ -17|~ │ integration.rs tests │ │ -18|~ │ contributing.md docs │ │ +15|~ │ input.tsx src/components │ │ +16|~ │ integration.rs tests │ │ +17|~ │ contributing.md docs │ │ +18|~ │ intro.md docs │ │ 19|~ │ changelog.md docs │ │ 20|~ │ menu.tsx src/components │ │ 21|~ │ regression.rs tests │ │ 22|~ │ license.md docs │ │ -23|~ │ api.rs src │ │ -24|~ │ button.tsx src/components │ │ -25|~ │ dialog.tsx src/components │ │ -26|~ │ list.tsx src/components │ │ -27|~ │ table.tsx src/components │ │ +23|~ │ button.tsx src/components │ │ +24|~ │ dialog.tsx src/components │ │ +25|~ │ list.tsx src/components │ │ +26|~ │ table.tsx src/components │ │ +27|~ │ api.rs src │ │ 28|~ │ │ │ 29|~ │ │ │ 30|~ │ │ │ @@ -63,19 +63,19 @@ 12|111111111111111111111111124466662222222222299922222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 13|111111111111111111111111124466662222222222999222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 14|111111111111111111111111124466662222222229999922222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -15|111111111111111111111111124422222222299992222222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -16|111111111111111111111111124422222222229999999999999922222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -17|111111111111111111111111124422222222222222299999222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -18|111111111111111111111111124422222222222222229999222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +15|111111111111111111111111124422222222229999999999999922222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +16|111111111111111111111111124422222222222222299999222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +17|111111111111111111111111124422222222222222229999222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +18|111111111111111111111111124422222222299992222222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 19|111111111111111111111111124422222222222229999222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 20|111111111111111111111111124422222222299999999999999222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 21|111111111111111111111111124422222222222222999992222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 22|111111111111111111111111124422222222222999922222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -23|111111111111111111111111124422222229992222222222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +23|111111111111111111111111124422222222222999999999999992222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 24|111111111111111111111111124422222222222999999999999992222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -25|111111111111111111111111124422222222222999999999999992222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -26|111111111111111111111111124422222222299999999999999222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 -27|111111111111111111111111124422222222229999999999999922222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +25|111111111111111111111111124422222222299999999999999222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +26|111111111111111111111111124422222222229999999999999922222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 +27|111111111111111111111111124422222229992222222222222222222222222222222222222222222222222222222222222222222222222222222222444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 28|111111111111111111111111124444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444442444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 29|111111111111111111111111124444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444442444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111 30|111111111111111111111111124444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444442444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444211111111111111111111111