Compare commits

...

1 Commits

Author SHA1 Message Date
Dmitriy Kovalenko 171f448ee9 feat: Grep perfromance and stability improvements (#235)
* feat: Allow many fff consumers over ffi

* parallize grep

* chore: Update docs for - parallize grep
2026-02-19 15:43:58 -08:00
41 changed files with 2493 additions and 1632 deletions
@@ -1,4 +1,4 @@
name: Lua E2E Tests
name: e2e Tests
on:
push:
@@ -12,7 +12,7 @@ env:
jobs:
lua-tests:
name: Lua E2E (${{ matrix.os }})
name: e2e (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -24,6 +24,7 @@ jobs:
target: x86_64-pc-windows-msvc
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- name: Install Zig
uses: mlugg/setup-zig@v2
@@ -82,6 +83,8 @@ jobs:
- name: Run Lua tests
shell: bash
run: |
nvim --headless -u tests/minimal_init.lua \
-c "PlenaryBustedFile tests/fff_core_spec.lua" 2>&1
run: make test-lua
- name: Run bun tests
shell: bash
run: make test-bun
+3 -3
View File
@@ -2,7 +2,7 @@ name: Prebuild
on:
push:
branches: [main, feat/binaries]
branches: [main, feat/interchangable-ffi]
pull_request:
jobs:
@@ -305,8 +305,8 @@ jobs:
needs: [build-c]
runs-on: ubuntu-latest
if: >-
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feat/binaries'))
|| (github.event_name == 'pull_request' && (github.head_ref == 'main' || github.head_ref == 'feat/binaries'))
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feat/interchangable-ffi'))
|| (github.event_name == 'pull_request' && (github.head_ref == 'main' || github.head_ref == 'feat/interchangable-ffi'))
permissions:
contents: read
steps:
Generated
-1
View File
@@ -463,7 +463,6 @@ dependencies = [
"fff-query-parser",
"git2",
"mimalloc",
"once_cell",
"serde",
"serde_json",
"tracing",
+13 -3
View File
@@ -1,6 +1,6 @@
PLENARY_DIR ?= ../plenary.nvim
.PHONY: build test test-rust test-lua test-setup
.PHONY: build test test-rust test-lua test-bun test-setup prepare-bun
build:
cargo build --release
@@ -12,13 +12,23 @@ test-setup:
fi
test-rust:
cargo test --verbose --workspace --exclude fff-nvim
cargo test --workspace
test-lua: test-setup build
nvim --headless -u tests/minimal_init.lua \
-c "PlenaryBustedFile tests/fff_core_spec.lua" 2>&1
test: test-rust test-lua
prepare-bun: build
mkdir -p packages/fff-bun/bin
cp target/release/libfff_c.dylib packages/fff-bun/bin/ 2>/dev/null; \
cp target/release/libfff_c.so packages/fff-bun/bin/ 2>/dev/null; \
cp target/release/fff_c.dll packages/fff-bun/bin/ 2>/dev/null; \
true
test-bun: prepare-bun
cd packages/fff-bun && bun test src/
test: test-rust test-lua test-bun
format-rust:
cargo fmt --all
+6 -3
View File
@@ -170,13 +170,16 @@ require('fff').setup({
preview_scroll_up = '<C-u>',
preview_scroll_down = '<C-d>',
toggle_debug = '<F2>',
-- grep mode: cycle between plain text, regex, and fuzzy search
toggle_grep_regex = '<S-Tab>',
-- goes to the previous query in history
cycle_previous_query = '<C-Up>',
-- multi-select keymaps for quickfix
toggle_select = '<Tab>',
send_to_quickfix = '<C-q>',
-- grep mode: cycle between plain text, regex, and fuzzy search
toggle_grep_regex = '<S-Tab>',
-- this are specific for the normal mode (you can exit it using any other keybind like jj)
focus_list = '<leader>l',
focus_preview = '<leader>p',
},
hl = {
border = 'FloatBorder',
@@ -252,7 +255,7 @@ require('fff').setup({
-- Live grep search configuration
grep = {
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
max_matches_per_file = 200, -- Maximum matches per file
max_matches_per_file = 100, -- Maximum matches per file (set 0 to unlimited)
smart_case = true, -- Case-insensitive unless query has uppercase
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
-1
View File
@@ -10,7 +10,6 @@ crate-type = ["cdylib"]
[dependencies]
mimalloc.workspace = true
once_cell.workspace = true
tracing.workspace = true
git2.workspace = true
+17 -2
View File
@@ -3,7 +3,7 @@
//! These types use #[repr(C)] for C ABI compatibility and implement
//! serde traits for JSON serialization.
use std::ffi::{CString, c_char};
use std::ffi::{CString, c_char, c_void};
use std::ptr;
use fff_core::git::format_git_status;
@@ -20,6 +20,8 @@ pub struct FffResult {
pub data: *mut c_char,
/// Error message on failure (null-terminated string, caller must free)
pub error: *mut c_char,
/// Opaque handle pointer (used by fff_create to return the instance)
pub handle: *mut c_void,
}
impl FffResult {
@@ -29,6 +31,7 @@ impl FffResult {
success: true,
data: ptr::null_mut(),
error: ptr::null_mut(),
handle: ptr::null_mut(),
}))
}
@@ -38,6 +41,17 @@ impl FffResult {
success: true,
data: CString::new(data).unwrap_or_default().into_raw(),
error: ptr::null_mut(),
handle: ptr::null_mut(),
}))
}
/// Create a successful result carrying an opaque instance handle.
pub fn ok_handle(handle: *mut c_void) -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: true,
data: ptr::null_mut(),
error: ptr::null_mut(),
handle,
}))
}
@@ -47,6 +61,7 @@ impl FffResult {
success: false,
data: ptr::null_mut(),
error: CString::new(error).unwrap_or_default().into_raw(),
handle: ptr::null_mut(),
}))
}
}
@@ -326,7 +341,7 @@ impl GrepResultJson {
GrepMatchJson::from_grep_match(m, file)
})
.collect(),
total_matched: result.total_match_count,
total_matched: result.matches.len(),
total_files_searched: result.total_files_searched,
total_files: result.total_files,
filtered_file_count: result.filtered_file_count,
+390 -242
View File
@@ -3,12 +3,21 @@
//! 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 functions return a pointer to a heap-allocated `FffResult` struct containing
//! success status and either data (as JSON string) or an error message.
//! Memory must be freed using `fff_free_result`.
//! # Instance-based API
//!
//! All state is owned by an opaque `FffInstance` fff_handle. Callers create an instance
//! with `fff_create`, 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`.
use std::ffi::{CStr, CString, c_char};
use std::ffi::{CStr, CString, c_char, c_void};
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::time::Duration;
mod ffi_types;
@@ -17,13 +26,23 @@ use fff_core::file_picker::FilePicker;
use fff_core::frecency::FrecencyTracker;
use fff_core::query_tracker::QueryTracker;
use fff_core::{DbHealthChecker, FuzzySearchOptions, PaginationArgs, QueryParser};
use fff_core::{FILE_PICKER, FRECENCY, QUERY_TRACKER};
use fff_core::{SharedFrecency, SharedPicker};
use ffi_types::{FffResult, GrepSearchOptionsJson, InitOptions, ScanProgress, SearchOptions};
use mimalloc::MiMalloc;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
/// 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`.
struct FffInstance {
picker: SharedPicker,
frecency: SharedFrecency,
query_tracker: Arc<RwLock<Option<QueryTracker>>>,
}
/// Helper to convert C string to Rust &str.
///
/// Returns `None` if the pointer is null or the string is not valid UTF-8.
@@ -38,12 +57,28 @@ unsafe fn cstr_to_str<'a>(s: *const c_char) -> Option<&'a str> {
}
}
/// Initialize the file finder with the given options (JSON string)
/// 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> {
if fff_handle.is_null() {
Err(FffResult::err(
"Instance handle is null. Create one with fff_create first.",
))
} else {
Ok(unsafe { &*(fff_handle as *const FffInstance) })
}
}
/// Create a new file finder instance.
///
/// Returns an opaque pointer that must be passed to all other `fff_*` calls
/// and eventually freed with `fff_destroy`.
///
/// # Safety
/// `opts_json` must be a valid null-terminated UTF-8 string
/// `opts_json` must be a valid null-terminated UTF-8 string.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_init(opts_json: *const c_char) -> *mut FffResult {
pub unsafe extern "C" fn fff_create(opts_json: *const c_char) -> *mut FffResult {
let opts_str = match unsafe { cstr_to_str(opts_json) } {
Some(s) => s,
None => return FffResult::err("Options JSON is null or invalid UTF-8"),
@@ -54,104 +89,120 @@ pub unsafe extern "C" fn fff_init(opts_json: *const c_char) -> *mut FffResult {
Err(e) => return FffResult::err(&format!("Failed to parse options: {}", e)),
};
// Create shared state that background threads will write into.
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
let query_tracker: Arc<RwLock<Option<QueryTracker>>> = Arc::new(RwLock::new(None));
// Initialize frecency tracker if path is provided
if let Some(frecency_path) = opts.frecency_db_path {
// Ensure directory exists
if let Some(parent) = PathBuf::from(&frecency_path).parent() {
let _ = std::fs::create_dir_all(parent);
}
let mut frecency = match FRECENCY.write() {
Ok(f) => f,
Err(e) => return FffResult::err(&format!("Failed to acquire frecency lock: {}", e)),
};
*frecency = None;
match FrecencyTracker::new(&frecency_path, opts.use_unsafe_no_lock) {
Ok(tracker) => *frecency = Some(tracker),
Ok(tracker) => {
let mut guard = match shared_frecency.write() {
Ok(g) => g,
Err(e) => {
return FffResult::err(&format!("Failed to acquire frecency lock: {}", e));
}
};
*guard = Some(tracker);
}
Err(e) => return FffResult::err(&format!("Failed to init frecency db: {}", e)),
}
drop(frecency);
}
// Initialize query tracker if path is provided
if let Some(history_path) = opts.history_db_path {
// Ensure directory exists
if let Some(parent) = PathBuf::from(&history_path).parent() {
let _ = std::fs::create_dir_all(parent);
}
let mut query_tracker = match QUERY_TRACKER.write() {
Ok(q) => q,
Err(e) => {
return FffResult::err(&format!("Failed to acquire query tracker lock: {}", e));
}
};
*query_tracker = None;
match QueryTracker::new(&history_path, opts.use_unsafe_no_lock) {
Ok(tracker) => *query_tracker = Some(tracker),
Ok(tracker) => {
let mut guard = match query_tracker.write() {
Ok(g) => g,
Err(e) => {
return FffResult::err(&format!(
"Failed to acquire query tracker lock: {}",
e
));
}
};
*guard = Some(tracker);
}
Err(e) => return FffResult::err(&format!("Failed to init query tracker db: {}", e)),
}
drop(query_tracker);
}
// Initialize file picker
let mut file_picker = match FILE_PICKER.write() {
Ok(f) => f,
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
};
if file_picker.is_some() {
// Already initialized, clean up first
if let Some(mut picker) = file_picker.take() {
picker.stop_background_monitor();
}
// Initialize file picker (writes directly into shared_picker)
if let Err(e) = FilePicker::new_with_shared_state(
opts.base_path,
opts.warmup_mmap_cache,
Arc::clone(&shared_picker),
Arc::clone(&shared_frecency),
) {
return FffResult::err(&format!("Failed to init file picker: {}", e));
}
match FilePicker::with_options(opts.base_path, opts.warmup_mmap_cache) {
Ok(picker) => {
*file_picker = Some(picker);
FffResult::ok_empty()
}
Err(e) => FffResult::err(&format!("Failed to init file picker: {}", e)),
}
let instance = Box::new(FffInstance {
picker: shared_picker,
frecency: shared_frecency,
query_tracker,
});
// Return the instance pointer inside the data field of FffResult.
// We encode the pointer as a hex string so consumers can store it as an
// opaque token. The actual pointer is also returned as the `data` pointer
// for FFI consumers that can directly use it.
let fff_handle = Box::into_raw(instance) as *mut c_void;
FffResult::ok_handle(fff_handle)
}
/// Destroy all resources and clean up
/// Destroy a file finder instance and free all its resources.
///
/// # Safety
/// `fff_handle` must be a valid pointer returned by `fff_create`, or null (no-op).
#[unsafe(no_mangle)]
pub extern "C" fn fff_destroy() -> *mut FffResult {
// Clean up file picker
if let Ok(mut file_picker) = FILE_PICKER.write()
&& let Some(mut picker) = file_picker.take()
pub unsafe extern "C" fn fff_destroy(fff_handle: *mut c_void) {
if fff_handle.is_null() {
return;
}
let instance = unsafe { Box::from_raw(fff_handle as *mut FffInstance) };
if let Ok(mut guard) = instance.picker.write()
&& let Some(mut picker) = guard.take()
{
picker.stop_background_monitor();
}
// Clean up frecency
if let Ok(mut frecency) = FRECENCY.write() {
*frecency = None;
if let Ok(mut guard) = instance.frecency.write() {
*guard = None;
}
// Clean up query tracker
if let Ok(mut query_tracker) = QUERY_TRACKER.write() {
*query_tracker = None;
if let Ok(mut guard) = instance.query_tracker.write() {
*guard = None;
}
FffResult::ok_empty()
}
// ============================================================================
// Search Functions
// ============================================================================
/// Perform fuzzy search on indexed files
/// Perform fuzzy search on indexed files.
///
/// # Safety
/// `query` and `opts_json` must be valid null-terminated UTF-8 strings
/// * `fff_handle` must be a valid instance pointer from `fff_create`.
/// * `query` and `opts_json` must be valid null-terminated UTF-8 strings.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_search(
fff_handle: *mut c_void,
query: *const c_char,
opts_json: *const c_char,
) -> *mut FffResult {
let inst = match unsafe { instance_ref(fff_handle) } {
Ok(i) => i,
Err(e) => return e,
};
let query_str = match unsafe { cstr_to_str(query) } {
Some(s) => s,
None => return FffResult::err("Query is null or invalid UTF-8"),
@@ -165,14 +216,14 @@ pub unsafe extern "C" fn fff_search(
.unwrap_or_default()
};
let file_picker_guard = match FILE_PICKER.read() {
Ok(f) => f,
let picker_guard = match inst.picker.read() {
Ok(g) => g,
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
};
let picker = match file_picker_guard.as_ref() {
let picker = match picker_guard.as_ref() {
Some(p) => p,
None => return FffResult::err("File picker not initialized. Call fff_init first."),
None => return FffResult::err("File picker not initialized. Call fff_create first."),
};
let base_path = picker.base_path();
@@ -180,12 +231,12 @@ pub unsafe extern "C" fn fff_search(
// Get last same query entry for combo matching
let last_same_query_entry = {
let query_tracker = match QUERY_TRACKER.read() {
let qt_guard = match inst.query_tracker.read() {
Ok(q) => q,
Err(_) => return FffResult::err("Failed to acquire query tracker lock"),
};
query_tracker.as_ref().and_then(|tracker| {
qt_guard.as_ref().and_then(|tracker| {
tracker
.get_last_query_entry(query_str, base_path, min_combo_count)
.ok()
@@ -193,7 +244,6 @@ pub unsafe extern "C" fn fff_search(
})
};
// Parse the query
let parser = QueryParser::default();
let parsed = parser.parse(query_str);
@@ -215,7 +265,6 @@ pub unsafe extern "C" fn fff_search(
},
);
// Convert to JSON
let json_result = ffi_types::SearchResultJson::from_search_result(&results);
match serde_json::to_string(&json_result) {
Ok(json) => FffResult::ok_data(&json),
@@ -223,24 +272,22 @@ pub unsafe extern "C" fn fff_search(
}
}
/// Perform content search (grep) across indexed files
///
/// Searches file contents using the specified mode:
/// - "plain" (default): SIMD-accelerated literal text matching
/// - "regex": Regular expression matching
/// - "fuzzy": Smith-Waterman fuzzy matching per line
///
/// Results include file metadata and match locations with byte offsets
/// for highlighting. Supports file-based pagination via `file_offset`
/// and `next_file_offset` in the result.
/// Perform content search (grep) across indexed files.
///
/// # Safety
/// `query` and `opts_json` must be valid null-terminated UTF-8 strings
/// * `fff_handle` must be a valid instance pointer from `fff_create`.
/// * `query` and `opts_json` must be valid null-terminated UTF-8 strings.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_live_grep(
fff_handle: *mut c_void,
query: *const c_char,
opts_json: *const c_char,
) -> *mut FffResult {
let inst = match unsafe { instance_ref(fff_handle) } {
Ok(i) => i,
Err(e) => return e,
};
let query_str = match unsafe { cstr_to_str(query) } {
Some(s) => s,
None => return FffResult::err("Query is null or invalid UTF-8"),
@@ -254,14 +301,14 @@ pub unsafe extern "C" fn fff_live_grep(
.unwrap_or_default()
};
let file_picker_guard = match FILE_PICKER.read() {
Ok(f) => f,
let picker_guard = match inst.picker.read() {
Ok(g) => g,
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
};
let picker = match file_picker_guard.as_ref() {
let picker = match picker_guard.as_ref() {
Some(p) => p,
None => return FffResult::err("File picker not initialized. Call fff_init first."),
None => return FffResult::err("File picker not initialized. Call fff_create first."),
};
let mode = match opts.mode.as_deref() {
@@ -274,7 +321,7 @@ pub unsafe extern "C" fn fff_live_grep(
let options = fff_core::GrepSearchOptions {
max_file_size: opts.max_file_size.unwrap_or(10 * 1024 * 1024),
max_matches_per_file: opts.max_matches_per_file.unwrap_or(200),
max_matches_per_file: opts.max_matches_per_file.unwrap_or(0),
smart_case: opts.smart_case.unwrap_or(true),
file_offset: opts.file_offset.unwrap_or(0),
page_limit: opts.page_limit.unwrap_or(50),
@@ -291,48 +338,68 @@ pub unsafe extern "C" fn fff_live_grep(
}
}
// ============================================================================
// File Index Functions
// ============================================================================
/// Trigger a rescan of the file index
/// Trigger a rescan of the file index.
///
/// # Safety
/// `fff_handle` must be a valid instance pointer from `fff_create`.
#[unsafe(no_mangle)]
pub extern "C" fn fff_scan_files() -> *mut FffResult {
let mut file_picker = match FILE_PICKER.write() {
Ok(f) => f,
pub unsafe extern "C" fn fff_scan_files(fff_handle: *mut c_void) -> *mut FffResult {
let inst = match unsafe { instance_ref(fff_handle) } {
Ok(i) => i,
Err(e) => return e,
};
let mut guard = match inst.picker.write() {
Ok(g) => g,
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
};
let picker = match file_picker.as_mut() {
let picker = match guard.as_mut() {
Some(p) => p,
None => return FffResult::err("File picker not initialized"),
};
match picker.trigger_rescan() {
match picker.trigger_rescan(&inst.frecency) {
Ok(_) => FffResult::ok_empty(),
Err(e) => FffResult::err(&format!("Failed to trigger rescan: {}", e)),
}
}
/// Check if a scan is currently in progress
/// Check if a scan is currently in progress.
///
/// # Safety
/// `fff_handle` must be a valid instance pointer from `fff_create`.
#[unsafe(no_mangle)]
pub extern "C" fn fff_is_scanning() -> bool {
FILE_PICKER
pub unsafe extern "C" fn fff_is_scanning(fff_handle: *mut c_void) -> bool {
let inst = match unsafe { instance_ref(fff_handle) } {
Ok(i) => i,
Err(_) => return false,
};
inst.picker
.read()
.ok()
.and_then(|guard| guard.as_ref().map(|p| p.is_scan_active()))
.unwrap_or(false)
}
/// Get scan progress information
/// Get scan progress information.
///
/// # Safety
/// `fff_handle` must be a valid instance pointer from `fff_create`.
#[unsafe(no_mangle)]
pub extern "C" fn fff_get_scan_progress() -> *mut FffResult {
let file_picker = match FILE_PICKER.read() {
Ok(f) => f,
pub unsafe extern "C" fn fff_get_scan_progress(fff_handle: *mut c_void) -> *mut FffResult {
let inst = match unsafe { instance_ref(fff_handle) } {
Ok(i) => i,
Err(e) => return e,
};
let guard = match inst.picker.read() {
Ok(g) => g,
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
};
let picker = match file_picker.as_ref() {
let picker = match guard.as_ref() {
Some(p) => p,
None => return FffResult::err("File picker not initialized"),
};
@@ -349,24 +416,42 @@ pub extern "C" fn fff_get_scan_progress() -> *mut FffResult {
}
}
/// Wait for initial scan to complete
/// Wait for initial scan to complete.
///
/// # Safety
/// `fff_handle` must be a valid instance pointer from `fff_create`.
#[unsafe(no_mangle)]
pub extern "C" fn fff_wait_for_scan(timeout_ms: u64) -> *mut FffResult {
let file_picker = match FILE_PICKER.read() {
Ok(f) => f,
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
pub unsafe extern "C" fn fff_wait_for_scan(
fff_handle: *mut c_void,
timeout_ms: u64,
) -> *mut FffResult {
let inst = match unsafe { instance_ref(fff_handle) } {
Ok(i) => i,
Err(e) => return e,
};
let picker = match file_picker.as_ref() {
Some(p) => p,
None => return FffResult::err("File picker not initialized"),
// Clone the scanning flag so we can drop the picker lock before polling.
// Otherwise the read lock blocks the scan thread from writing results.
let scan_signal = {
let guard = match inst.picker.read() {
Ok(g) => g,
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
};
let picker = match guard.as_ref() {
Some(p) => p,
None => return FffResult::err("File picker not initialized"),
};
picker.scan_signal()
// guard is dropped here, releasing the read lock
};
let timeout = Duration::from_millis(timeout_ms);
let start = std::time::Instant::now();
let mut sleep_duration = Duration::from_millis(1);
while picker.is_scan_active() {
while scan_signal.load(std::sync::atomic::Ordering::Relaxed) {
if start.elapsed() >= timeout {
return FffResult::ok_data("false");
}
@@ -377,12 +462,21 @@ pub extern "C" fn fff_wait_for_scan(timeout_ms: u64) -> *mut FffResult {
FffResult::ok_data("true")
}
/// Restart indexing in a new directory
/// Restart indexing in a new directory.
///
/// # Safety
/// `new_path` must be a valid null-terminated UTF-8 string
/// * `fff_handle` must be a valid instance pointer from `fff_create`.
/// * `new_path` must be a valid null-terminated UTF-8 string.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_restart_index(new_path: *const c_char) -> *mut FffResult {
pub unsafe extern "C" fn fff_restart_index(
fff_handle: *mut c_void,
new_path: *const c_char,
) -> *mut FffResult {
let inst = match unsafe { instance_ref(fff_handle) } {
Ok(i) => i,
Err(e) => return e,
};
let path_str = match unsafe { cstr_to_str(new_path) } {
Some(s) => s,
None => return FffResult::err("Path is null or invalid UTF-8"),
@@ -398,13 +492,13 @@ pub unsafe extern "C" fn fff_restart_index(new_path: *const c_char) -> *mut FffR
Err(e) => return FffResult::err(&format!("Failed to canonicalize path: {}", e)),
};
let mut file_picker = match FILE_PICKER.write() {
Ok(f) => f,
let mut guard = match inst.picker.write() {
Ok(g) => g,
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
};
// Stop existing picker, preserving warmup setting
let warmup = if let Some(mut picker) = file_picker.take() {
let warmup = if let Some(mut picker) = guard.take() {
let warmup = picker.warmup_mmap_cache();
picker.stop_background_monitor();
warmup
@@ -412,26 +506,37 @@ pub unsafe extern "C" fn fff_restart_index(new_path: *const c_char) -> *mut FffR
false
};
// Create new picker
match FilePicker::with_options(canonical_path.to_string_lossy().to_string(), warmup) {
Ok(picker) => {
*file_picker = Some(picker);
FffResult::ok_empty()
}
// Drop the write lock before calling new_with_shared_state,
// which will acquire its own write lock to place the picker.
drop(guard);
// Create new picker backed by the same shared state
match FilePicker::new_with_shared_state(
canonical_path.to_string_lossy().to_string(),
warmup,
Arc::clone(&inst.picker),
Arc::clone(&inst.frecency),
) {
Ok(()) => FffResult::ok_empty(),
Err(e) => FffResult::err(&format!("Failed to init file picker: {}", e)),
}
}
// ============================================================================
// Frecency Functions
// ============================================================================
/// Track file access for frecency scoring
/// Track file access for frecency scoring.
///
/// # Safety
/// `file_path` must be a valid null-terminated UTF-8 string
/// * `fff_handle` must be a valid instance pointer from `fff_create`.
/// * `file_path` must be a valid null-terminated UTF-8 string.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_track_access(file_path: *const c_char) -> *mut FffResult {
pub unsafe extern "C" fn fff_track_access(
fff_handle: *mut c_void,
file_path: *const c_char,
) -> *mut FffResult {
let inst = match unsafe { instance_ref(fff_handle) } {
Ok(i) => i,
Err(e) => return e,
};
let path_str = match unsafe { cstr_to_str(file_path) } {
Some(s) => s,
None => return FffResult::err("File path is null or invalid UTF-8"),
@@ -440,14 +545,14 @@ pub unsafe extern "C" fn fff_track_access(file_path: *const c_char) -> *mut FffR
let file_path = PathBuf::from(&path_str);
// Track in frecency DB
let frecency_guard = match FRECENCY.read() {
let frecency_guard = match inst.frecency.read() {
Ok(f) => f,
Err(e) => return FffResult::err(&format!("Failed to acquire frecency lock: {}", e)),
};
let frecency = match frecency_guard.as_ref() {
Some(f) => f,
None => return FffResult::ok_data("false"), // Frecency not initialized, skip
None => return FffResult::ok_data("false"),
};
if let Err(e) = frecency.track_access(&file_path) {
@@ -456,17 +561,17 @@ pub unsafe extern "C" fn fff_track_access(file_path: *const c_char) -> *mut FffR
drop(frecency_guard);
// Update in file picker
let mut file_picker = match FILE_PICKER.write() {
Ok(f) => f,
let mut picker_guard = match inst.picker.write() {
Ok(g) => g,
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
};
let picker = match file_picker.as_mut() {
let picker = match picker_guard.as_mut() {
Some(p) => p,
None => return FffResult::ok_data("false"),
};
let frecency_guard = match FRECENCY.read() {
let frecency_guard = match inst.frecency.read() {
Ok(f) => f,
Err(_) => return FffResult::ok_data("false"),
};
@@ -478,32 +583,41 @@ pub unsafe extern "C" fn fff_track_access(file_path: *const c_char) -> *mut FffR
FffResult::ok_data("true")
}
// ============================================================================
// Git Functions
// ============================================================================
/// Refresh git status cache
/// Refresh git status cache.
///
/// # Safety
/// `fff_handle` must be a valid instance pointer from `fff_create`.
#[unsafe(no_mangle)]
pub extern "C" fn fff_refresh_git_status() -> *mut FffResult {
match FilePicker::refresh_git_status_global() {
pub unsafe extern "C" fn fff_refresh_git_status(fff_handle: *mut c_void) -> *mut FffResult {
let inst = match unsafe { instance_ref(fff_handle) } {
Ok(i) => i,
Err(e) => return e,
};
match FilePicker::refresh_git_status(&inst.picker, &inst.frecency) {
Ok(count) => FffResult::ok_data(&count.to_string()),
Err(e) => FffResult::err(&format!("Failed to refresh git status: {}", e)),
}
}
// ============================================================================
// Query Tracking Functions
// ============================================================================
/// Track query completion for smart suggestions
/// Track query completion for smart suggestions.
///
/// # Safety
/// `query` and `file_path` must be valid null-terminated UTF-8 strings
/// * `fff_handle` must be a valid instance pointer from `fff_create`.
/// * `query` and `file_path` must be valid null-terminated UTF-8 strings.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_track_query(
fff_handle: *mut c_void,
query: *const c_char,
file_path: *const c_char,
) -> *mut FffResult {
let inst = match unsafe { instance_ref(fff_handle) } {
Ok(i) => i,
Err(e) => return e,
};
let query_str = match unsafe { cstr_to_str(query) } {
Some(s) => s,
None => return FffResult::err("Query is null or invalid UTF-8"),
@@ -520,22 +634,22 @@ pub unsafe extern "C" fn fff_track_query(
};
let project_path = {
let file_picker = match FILE_PICKER.read() {
Ok(f) => f,
let guard = match inst.picker.read() {
Ok(g) => g,
Err(_) => return FffResult::ok_data("false"),
};
match file_picker.as_ref() {
match guard.as_ref() {
Some(p) => p.base_path().to_path_buf(),
None => return FffResult::ok_data("false"),
}
};
let mut query_tracker = match QUERY_TRACKER.write() {
let mut qt_guard = match inst.query_tracker.write() {
Ok(q) => q,
Err(_) => return FffResult::ok_data("false"),
};
if let Some(ref mut tracker) = *query_tracker
if let Some(ref mut tracker) = *qt_guard
&& let Err(e) = tracker.track_query_completion(query_str, &project_path, &file_path)
{
return FffResult::err(&format!("Failed to track query: {}", e));
@@ -544,26 +658,37 @@ pub unsafe extern "C" fn fff_track_query(
FffResult::ok_data("true")
}
/// Get historical query by offset (0 = most recent)
/// Get historical query by offset (0 = most recent).
///
/// # Safety
/// `fff_handle` must be a valid instance pointer from `fff_create`.
#[unsafe(no_mangle)]
pub extern "C" fn fff_get_historical_query(offset: u64) -> *mut FffResult {
pub unsafe extern "C" fn fff_get_historical_query(
fff_handle: *mut c_void,
offset: u64,
) -> *mut FffResult {
let inst = match unsafe { instance_ref(fff_handle) } {
Ok(i) => i,
Err(e) => return e,
};
let project_path = {
let file_picker = match FILE_PICKER.read() {
Ok(f) => f,
let guard = match inst.picker.read() {
Ok(g) => g,
Err(_) => return FffResult::ok_data("null"),
};
match file_picker.as_ref() {
match guard.as_ref() {
Some(p) => p.base_path().to_path_buf(),
None => return FffResult::ok_data("null"),
}
};
let query_tracker = match QUERY_TRACKER.read() {
let qt_guard = match inst.query_tracker.read() {
Ok(q) => q,
Err(_) => return FffResult::ok_data("null"),
};
let tracker = match query_tracker.as_ref() {
let tracker = match qt_guard.as_ref() {
Some(t) => t,
None => return FffResult::ok_data("null"),
};
@@ -578,12 +703,17 @@ pub extern "C" fn fff_get_historical_query(offset: u64) -> *mut FffResult {
}
}
/// Get health check information
/// Get health check information.
///
/// # Safety
/// `test_path` can be null or a valid null-terminated UTF-8 string
/// * `fff_handle` must be a valid instance pointer from `fff_create`, or null for
/// a limited health check (version + git only).
/// * `test_path` can be null or a valid null-terminated UTF-8 string.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_health_check(test_path: *const c_char) -> *mut FffResult {
pub unsafe extern "C" fn fff_health_check(
fff_handle: *mut c_void,
test_path: *const c_char,
) -> *mut FffResult {
let test_path = unsafe { cstr_to_str(test_path) }
.filter(|s| !s.is_empty())
.map(PathBuf::from)
@@ -632,36 +762,47 @@ pub unsafe extern "C" fn fff_health_check(test_path: *const c_char) -> *mut FffR
}
health.insert("git".to_string(), serde_json::Value::Object(git_info));
// Resolve the instance once (None when handle is null).
let inst: Option<&FffInstance> = if fff_handle.is_null() {
None
} else {
Some(unsafe { &*(fff_handle as *const FffInstance) })
};
// File picker info
let mut picker_info = serde_json::Map::new();
match FILE_PICKER.read() {
Ok(guard) => {
if let Some(ref picker) = *guard {
picker_info.insert("initialized".to_string(), serde_json::Value::Bool(true));
picker_info.insert(
"base_path".to_string(),
serde_json::Value::String(picker.base_path().to_string_lossy().to_string()),
);
picker_info.insert(
"is_scanning".to_string(),
serde_json::Value::Bool(picker.is_scan_active()),
);
let progress = picker.get_scan_progress();
picker_info.insert(
"indexed_files".to_string(),
serde_json::Value::Number(progress.scanned_files_count.into()),
);
} else {
if let Some(inst) = inst {
match inst.picker.read() {
Ok(guard) => {
if let Some(ref picker) = *guard {
picker_info.insert("initialized".to_string(), serde_json::Value::Bool(true));
picker_info.insert(
"base_path".to_string(),
serde_json::Value::String(picker.base_path().to_string_lossy().to_string()),
);
picker_info.insert(
"is_scanning".to_string(),
serde_json::Value::Bool(picker.is_scan_active()),
);
let progress = picker.get_scan_progress();
picker_info.insert(
"indexed_files".to_string(),
serde_json::Value::Number(progress.scanned_files_count.into()),
);
} else {
picker_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
}
}
Err(_) => {
picker_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
picker_info.insert(
"error".to_string(),
serde_json::Value::String("Failed to acquire lock".to_string()),
);
}
}
Err(_) => {
picker_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
picker_info.insert(
"error".to_string(),
serde_json::Value::String("Failed to acquire lock".to_string()),
);
}
} else {
picker_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
}
health.insert(
"file_picker".to_string(),
@@ -670,33 +811,37 @@ pub unsafe extern "C" fn fff_health_check(test_path: *const c_char) -> *mut FffR
// Frecency info
let mut frecency_info = serde_json::Map::new();
match FRECENCY.read() {
Ok(guard) => {
frecency_info.insert(
"initialized".to_string(),
serde_json::Value::Bool(guard.is_some()),
);
if let Some(ref frecency) = *guard
&& let Ok(health_data) = frecency.get_health()
{
let mut db_health = serde_json::Map::new();
db_health.insert(
"path".to_string(),
serde_json::Value::String(health_data.path),
);
db_health.insert(
"disk_size".to_string(),
serde_json::Value::Number(health_data.disk_size.into()),
);
if let Some(inst) = inst {
match inst.frecency.read() {
Ok(guard) => {
frecency_info.insert(
"db_healthcheck".to_string(),
serde_json::Value::Object(db_health),
"initialized".to_string(),
serde_json::Value::Bool(guard.is_some()),
);
if let Some(ref frecency) = *guard
&& let Ok(health_data) = frecency.get_health()
{
let mut db_health = serde_json::Map::new();
db_health.insert(
"path".to_string(),
serde_json::Value::String(health_data.path),
);
db_health.insert(
"disk_size".to_string(),
serde_json::Value::Number(health_data.disk_size.into()),
);
frecency_info.insert(
"db_healthcheck".to_string(),
serde_json::Value::Object(db_health),
);
}
}
Err(_) => {
frecency_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
}
}
Err(_) => {
frecency_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
}
} else {
frecency_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
}
health.insert(
"frecency".to_string(),
@@ -705,33 +850,37 @@ pub unsafe extern "C" fn fff_health_check(test_path: *const c_char) -> *mut FffR
// Query tracker info
let mut query_info = serde_json::Map::new();
match QUERY_TRACKER.read() {
Ok(guard) => {
query_info.insert(
"initialized".to_string(),
serde_json::Value::Bool(guard.is_some()),
);
if let Some(ref tracker) = *guard
&& let Ok(health_data) = tracker.get_health()
{
let mut db_health = serde_json::Map::new();
db_health.insert(
"path".to_string(),
serde_json::Value::String(health_data.path),
);
db_health.insert(
"disk_size".to_string(),
serde_json::Value::Number(health_data.disk_size.into()),
);
if let Some(inst) = inst {
match inst.query_tracker.read() {
Ok(guard) => {
query_info.insert(
"db_healthcheck".to_string(),
serde_json::Value::Object(db_health),
"initialized".to_string(),
serde_json::Value::Bool(guard.is_some()),
);
if let Some(ref tracker) = *guard
&& let Ok(health_data) = tracker.get_health()
{
let mut db_health = serde_json::Map::new();
db_health.insert(
"path".to_string(),
serde_json::Value::String(health_data.path),
);
db_health.insert(
"disk_size".to_string(),
serde_json::Value::Number(health_data.disk_size.into()),
);
query_info.insert(
"db_healthcheck".to_string(),
serde_json::Value::Object(db_health),
);
}
}
Err(_) => {
query_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
}
}
Err(_) => {
query_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
}
} else {
query_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
}
health.insert(
"query_tracker".to_string(),
@@ -744,10 +893,10 @@ pub unsafe extern "C" fn fff_health_check(test_path: *const c_char) -> *mut FffR
}
}
/// Free a result returned by any fff_* function
/// Free a result returned by any `fff_*` function.
///
/// # Safety
/// `result_ptr` must be a valid pointer returned by a fff_* function
/// `result_ptr` must be a valid pointer returned by a `fff_*` function.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_free_result(result_ptr: *mut FffResult) {
if result_ptr.is_null() {
@@ -762,14 +911,13 @@ pub unsafe extern "C" fn fff_free_result(result_ptr: *mut FffResult) {
if !result.error.is_null() {
drop(CString::from_raw(result.error));
}
// Box will be dropped here, freeing the FffResult struct
}
}
/// Free a string returned by fff_* functions
/// Free a string returned by `fff_*` functions.
///
/// # Safety
/// `s` must be a valid C string allocated by this library
/// `s` must be a valid C string allocated by this library.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_free_string(s: *mut c_char) {
unsafe {
+175 -75
View File
@@ -1,8 +1,8 @@
use crate::FILE_PICKER;
use crate::error::Error;
use crate::file_picker::FilePicker;
use crate::git::GitStatusCache;
use crate::sort_buffer::sort_with_buffer;
use crate::{SharedFrecency, SharedPicker};
use git2::Repository;
use notify::event::{AccessKind, AccessMode};
use notify::{Config, EventKind, RecursiveMode};
@@ -12,7 +12,7 @@ use notify_debouncer_full::{
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tracing::{Level, error, info, warn};
use tracing::{Level, debug, error, info, warn};
type Debouncer = notify_debouncer_full::Debouncer<notify::RecommendedWatcher, RecommendedCache>;
@@ -25,13 +25,19 @@ const MAX_PATHS_THRESHOLD: usize = 1024;
const MAX_SELECTIVE_WATCH_DIRS: usize = 100;
impl BackgroundWatcher {
pub fn new(base_path: PathBuf, git_workdir: Option<PathBuf>) -> Result<Self, Error> {
pub fn new(
base_path: PathBuf,
git_workdir: Option<PathBuf>,
shared_picker: SharedPicker,
shared_frecency: SharedFrecency,
) -> Result<Self, Error> {
info!(
"Initializing background watcher for path: {}",
base_path.display()
);
let debouncer = Self::create_debouncer(base_path, git_workdir)?;
let debouncer =
Self::create_debouncer(base_path, git_workdir, shared_picker, shared_frecency)?;
info!("Background file watcher initialized successfully");
Ok(Self {
@@ -42,19 +48,27 @@ impl BackgroundWatcher {
fn create_debouncer(
base_path: PathBuf,
git_workdir: Option<PathBuf>,
shared_picker: SharedPicker,
shared_frecency: SharedFrecency,
) -> Result<Debouncer, Error> {
// do not follow symlinks as then notifiers spawns a bunch of events for symlinked
// files that could be git ignored, we have to property differentiate those and if
// the file was edited through a
let config = Config::default().with_follow_symlinks(false);
let git_workdir_for_handler = git_workdir.clone();
let mut debouncer = new_debouncer_opt(
DEBOUNCE_TIMEOUT,
Some(DEBOUNCE_TIMEOUT / 2), // tick rate for the event span
{
move |result: DebounceEventResult| match result {
Ok(events) => {
handle_debounced_events(events, &git_workdir);
handle_debounced_events(
events,
&git_workdir_for_handler,
&shared_picker,
&shared_frecency,
);
}
Err(errors) => {
error!("File watcher errors: {:?}", errors);
@@ -91,6 +105,11 @@ impl BackgroundWatcher {
}
}
}
// In selective mode the .git directory is excluded from the non-ignored
// dirs, but we still need to observe changes that affect git status
// (staging, unstaging, committing, branch switches, merges, etc.).
watch_git_status_paths(&mut debouncer, git_workdir.as_ref());
}
info!(
@@ -124,8 +143,13 @@ impl Drop for BackgroundWatcher {
}
}
#[tracing::instrument(name = "fs_events", skip(events), level = Level::DEBUG)]
fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<PathBuf>) {
#[tracing::instrument(name = "fs_events", skip(events, shared_picker, shared_frecency), level = Level::DEBUG)]
fn handle_debounced_events(
events: Vec<DebouncedEvent>,
git_workdir: &Option<PathBuf>,
shared_picker: &SharedPicker,
shared_frecency: &SharedFrecency,
) {
// this will be called very often, we have to minimiy the lock time for file picker
let repo = git_workdir.as_ref().and_then(|p| Repository::open(p).ok());
let mut need_full_rescan = false;
@@ -175,14 +199,30 @@ fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<Pat
need_full_git_rescan = true;
}
if !should_include_file(path, &repo) {
if is_git_file(path) {
continue;
}
if !path.exists() {
// Use a combination of event kind and filesystem state to decide
// whether a path is an addition/modification or a removal.
//
// We cannot rely on `path.exists()` alone because:
// - A freshly created file might not be visible yet (race).
// - macOS FSEvents uses Modify(Name(Any)) for both rename-in
// and rename-out, so we must stat the path to disambiguate.
//
// We cannot rely on event kind alone because:
// - Remove events are not always emitted (macOS often sends
// Modify(Name(Any)) instead of Remove).
let is_removal = matches!(debounced_event.event.kind, EventKind::Remove(_));
if is_removal || !path.exists() {
paths_to_remove.push(path.as_path());
} else {
paths_to_add_or_modify.push(path.as_path());
// For additions/modifications, still filter gitignored files.
if should_include_file(path, &repo) {
paths_to_add_or_modify.push(path.as_path());
}
}
}
@@ -204,7 +244,7 @@ fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<Pat
if need_full_rescan {
info!(?affected_paths_count, "Triggering full rescan");
trigger_full_rescan();
trigger_full_rescan(shared_picker, shared_frecency);
return;
}
@@ -220,99 +260,120 @@ fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<Pat
paths_to_add_or_modify.len()
);
// Apply file index updates (add/remove) unconditionally — these must
// happen even when there is no git repository.
let files_to_update_git_status =
if !paths_to_remove.is_empty() || !paths_to_add_or_modify.is_empty() {
debug!(
"Applying file index changes: {} to remove, {} to add/modify",
paths_to_remove.len(),
paths_to_add_or_modify.len(),
);
let apply_changes = |picker: &mut FilePicker| -> Vec<PathBuf> {
for path in &paths_to_remove {
let removed = picker.remove_file_by_path(path);
debug!("remove_file_by_path({:?}) -> {}", path, removed);
}
let mut files_to_update = Vec::with_capacity(paths_to_add_or_modify.len());
for path in &paths_to_add_or_modify {
let result = picker.on_create_or_modify(path);
match result {
Some(file) => {
debug!(
"on_create_or_modify({:?}) -> Some({})",
path,
file.path.display()
);
files_to_update.push(file.path.clone());
}
None => {
error!("on_create_or_modify({:?}) -> None (file not added!)", path);
}
}
}
info!(
"apply_changes complete: {} files to update git status",
files_to_update.len()
);
files_to_update
};
let Ok(mut guard) = shared_picker.write() else {
error!("Failed to acquire file picker write lock");
return;
};
let Some(ref mut picker) = *guard else {
error!("File picker not initialized");
return;
};
apply_changes(picker)
} else {
debug!("No file index changes to apply");
Vec::new()
};
// Git status updates require a repository.
let Some(repo) = repo.as_ref() else {
info!("No git repo, skipping git status updates");
debug!("No git repo available, skipping git status updates");
return;
};
if need_full_git_rescan {
info!("Triggering full git rescan");
if let Err(e) = FilePicker::refresh_git_status_global() {
let result = FilePicker::refresh_git_status(shared_picker, shared_frecency);
if let Err(e) = result {
error!("Failed to refresh git status: {:?}", e);
}
return;
}
if paths_to_remove.is_empty() && paths_to_add_or_modify.is_empty() {
return;
}
if !files_to_update_git_status.is_empty() {
info!(
"Fetching git status for {} files",
files_to_update_git_status.len()
);
let files_to_update_git_status = {
let Ok(mut file_picker_guard) = FILE_PICKER.write() else {
error!("Failed to acquire file picker write lock");
return;
};
let Some(ref mut picker) = *file_picker_guard else {
error!("File picker not initialized");
return;
};
// Apply file removals
for path in paths_to_remove {
picker.remove_file_by_path(path);
// No need to invalidate mmap — the FileItem (and its mmap) is dropped
}
// Apply file additions/modifications and collect paths for git status update
let mut files_to_update_git_status = Vec::with_capacity(paths_to_add_or_modify.len());
for path in paths_to_add_or_modify {
// on_create_or_modify clears the mmap internally when modified time changes
if let Some(file) = picker.on_create_or_modify(path) {
files_to_update_git_status.push(file.path.clone());
let status = match GitStatusCache::git_status_for_paths(repo, &files_to_update_git_status) {
Ok(status) => status,
Err(e) => {
tracing::error!(?e, "Failed to query git status");
return;
}
}
};
files_to_update_git_status
};
info!(
"Fetching git status for {} files",
files_to_update_git_status.len()
);
let status = match GitStatusCache::git_status_for_paths(repo, &files_to_update_git_status) {
Ok(status) => status,
Err(e) => {
tracing::error!(?e, "Failed to query git statue");
return;
}
};
// only lock the picker for theshortest possitble time
if let Ok(mut file_picker_guard) = FILE_PICKER.write()
&& let Some(ref mut picker) = *file_picker_guard
{
if let Err(e) = picker.update_git_statuses(status) {
error!("Failed to update git statuses: {:?}", e);
if let Ok(mut guard) = shared_picker.write()
&& let Some(ref mut picker) = *guard
{
if let Err(e) = picker.update_git_statuses(status, shared_frecency) {
error!("Failed to update git statuses: {:?}", e);
} else {
info!("Successfully updated git statuses in picker");
}
} else {
info!("Successfully updated git statuses in picker");
error!("Failed to acquire picker lock for git status update");
}
} else {
error!("Failed to acquire picker lock for git status update");
}
}
fn trigger_full_rescan() {
fn trigger_full_rescan(shared_picker: &SharedPicker, shared_frecency: &SharedFrecency) {
info!("Triggering full filesystem rescan");
// Note: no need to clear mmaps — they are backed by the kernel page cache
// and automatically reflect file changes. Old FileItems (and their mmaps)
// are dropped when the picker rebuilds its file list.
let Ok(mut file_picker_guard) = FILE_PICKER.write() else {
let Ok(mut guard) = shared_picker.write() else {
error!("Failed to acquire file picker write lock for full rescan");
return;
};
let Some(ref mut picker) = *file_picker_guard else {
let Some(ref mut picker) = *guard else {
error!("File picker not initialized, cannot trigger rescan");
return;
};
if let Err(e) = picker.trigger_rescan() {
if let Err(e) = picker.trigger_rescan(shared_frecency) {
error!("Failed to trigger full rescan: {:?}", e);
} else {
info!("Full filesystem rescan completed successfully");
@@ -320,12 +381,17 @@ fn trigger_full_rescan() {
}
fn should_include_file(path: &Path, repo: &Option<Repository>) -> bool {
if !path.is_file() || is_git_file(path) {
// Directories are not indexed — only regular files (and symlinks to files).
if path.is_dir() {
return false;
}
repo.as_ref()
.is_some_and(|repo| repo.is_path_ignored(path) == Ok(false))
// If there is a git repo, respect its ignore rules.
// If there is no repo (or the check fails), include the file.
match repo.as_ref() {
Some(repo) => repo.is_path_ignored(path) != Ok(true),
None => true,
}
}
#[inline]
@@ -375,6 +441,40 @@ fn is_ignore_definition_path(path: &Path) -> bool {
)
}
fn watch_git_status_paths(debouncer: &mut Debouncer, git_workdir: Option<&PathBuf>) {
let Some(workdir) = git_workdir else {
return;
};
let git_dir = workdir.join(".git");
if !git_dir.is_dir() {
return;
}
// Watch .git/ non-recursively to catch top-level files:
// index, index.lock, HEAD, packed-refs, MERGE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD
if let Err(e) = debouncer.watch(&git_dir, RecursiveMode::NonRecursive) {
warn!("Failed to watch .git directory: {}", e);
return;
}
// Watch refs/ recursively to catch branch/tag changes
let refs_dir = git_dir.join("refs");
if refs_dir.is_dir()
&& let Err(e) = debouncer.watch(&refs_dir, RecursiveMode::Recursive)
{
warn!("Failed to watch .git/refs: {}", e);
}
// Watch info/ non-recursively for exclude and sparse-checkout
let info_dir = git_dir.join("info");
if info_dir.is_dir()
&& let Err(e) = debouncer.watch(&info_dir, RecursiveMode::NonRecursive)
{
warn!("Failed to watch .git/info: {}", e);
}
}
/// Collects immediate non-ignored subdirectories of `base_path` using the `ignore` crate
/// to respect .gitignore, .ignore, and global gitignore rules. This is used to set up
/// selective file watching — only non-ignored directories get a recursive watcher,
+232 -109
View File
@@ -5,6 +5,7 @@ use crate::git::GitStatusCache;
use crate::query_tracker::QueryMatchEntry;
use crate::score::match_and_score_files;
use crate::types::{FileItem, PaginationArgs, ScoringContext, SearchResult};
use crate::{SharedFrecency, SharedPicker};
use fff_query_parser::FFFQuery;
use git2::{Repository, Status, StatusOptions};
use rayon::prelude::*;
@@ -18,8 +19,6 @@ use std::sync::{
use std::time::SystemTime;
use tracing::{Level, debug, error, info, warn};
use crate::{FILE_PICKER, FRECENCY};
/// Detect if a file is binary by checking for NUL bytes in the first 512 bytes.
/// This is the same heuristic used by git and grep — simple, fast, and sufficient.
#[inline]
@@ -52,7 +51,8 @@ pub struct FuzzySearchOptions<'a> {
#[derive(Debug, Clone)]
struct FileSync {
pub files: Vec<FileItem>,
/// Files sorted by path for binary search
files: Vec<FileItem>,
pub git_workdir: Option<PathBuf>,
}
@@ -64,9 +64,68 @@ impl FileSync {
}
}
/// Get all files (read-only). Files are sorted by path.
#[inline]
fn files(&self) -> &[FileItem] {
&self.files
}
fn get_file(&self, index: usize) -> Option<&FileItem> {
self.files.get(index)
}
/// Get mutable file at index
#[inline]
fn get_file_mut(&mut self, index: usize) -> Option<&mut FileItem> {
self.files.get_mut(index)
}
/// Find file index by path using binary search - O(log n)
#[inline]
fn find_file_index(&self, path: &Path) -> Result<usize, usize> {
self.files
.binary_search_by(|file| file.path.as_os_str().cmp(path.as_os_str()))
self.files.binary_search_by(|f| f.path.as_path().cmp(path))
}
/// Get file count
#[inline]
#[allow(dead_code)]
fn len(&self) -> usize {
self.files.len()
}
/// Insert a file at position. Simple - no HashMap to maintain!
fn insert_file(&mut self, position: usize, file: FileItem) {
self.files.insert(position, file);
}
/// Remove file at index. Simple - no HashMap to maintain!
fn remove_file(&mut self, index: usize) {
if index < self.files.len() {
self.files.remove(index);
}
}
/// Remove files matching predicate.
/// Returns number of files removed.
fn retain_files<F>(&mut self, predicate: F) -> usize
where
F: FnMut(&FileItem) -> bool,
{
let initial_len = self.files.len();
self.files.retain(predicate);
initial_len - self.files.len()
}
/// Insert a file in sorted order (by path).
/// Returns true if inserted, false if file already exists.
fn insert_file_sorted(&mut self, file: FileItem) -> bool {
match self.find_file_index(&file.path) {
Ok(_) => false, // File already exists
Err(position) => {
self.insert_file(position, file);
true
}
}
}
}
@@ -118,16 +177,6 @@ impl FileItem {
Ok(())
}
/// Locks the tracker and updates frecensy score for one file. If need multiple files updates
/// use `update_frecency_scores` instead.
pub fn update_frecency_scores_global(&mut self) -> Result<(), Error> {
let Some(ref frecency) = *FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)? else {
return Ok(());
};
self.update_frecency_scores(frecency)
}
}
pub struct FilePicker {
@@ -166,21 +215,26 @@ impl FilePicker {
self.sync_data.git_workdir.as_deref()
}
/// Get all indexed files sorted by path.
/// Note: Files are stored sorted by PATH for efficient insert/remove.
/// For frecency-sorted results, use search() which sorts matched results.
pub fn get_files(&self) -> &[FileItem] {
&self.sync_data.files
self.sync_data.files()
}
pub fn new(base_path: String) -> Result<Self, Error> {
Self::with_options(base_path, false)
}
/// Create a new FilePicker with explicit options.
/// Create a new FilePicker and place it into the provided shared handle.
///
/// When `warmup_mmap_cache` is `true`, all non-binary files will be mmap'd
/// and their pages paged in immediately after the initial scan completes.
/// This makes the first grep search as fast as subsequent ones at the cost
/// of a longer startup time and higher initial memory pressure.
pub fn with_options(base_path: String, warmup_mmap_cache: bool) -> Result<Self, Error> {
/// The background scan thread and file-system watcher write into the
/// provided `SharedPicker` and read frecency data from the provided
/// `SharedFrecency`.
///
/// Multiple independent instances can coexist in the same process.
pub fn new_with_shared_state(
base_path: String,
warmup_mmap_cache: bool,
shared_picker: SharedPicker,
shared_frecency: SharedFrecency,
) -> Result<(), Error> {
info!(
"Initializing FilePicker with base_path: {}, warmup: {}",
base_path, warmup_mmap_cache
@@ -191,10 +245,13 @@ impl FilePicker {
return Err(Error::InvalidPath(path));
}
let scan_signal = Arc::new(AtomicBool::new(false));
// Initialize scan_signal to `true` so that any `wait_for_scan` call
// that races with the background thread sees "scanning in progress"
// rather than a stale `false` (the thread hasn't started yet).
let scan_signal = Arc::new(AtomicBool::new(true));
let synced_files_count = Arc::new(AtomicUsize::new(0));
let picker = Self {
let picker = FilePicker {
base_path: path.clone(),
sync_data: FileSync::new(),
is_scanning: Arc::clone(&scan_signal),
@@ -203,14 +260,23 @@ impl FilePicker {
warmup_mmap_cache,
};
// Place the picker into the shared handle before spawning the
// background thread so the thread can find it immediately.
{
let mut guard = shared_picker.write().map_err(|_| Error::AcquireItemLock)?;
*guard = Some(picker);
}
spawn_scan_and_watcher(
path.clone(),
Arc::clone(&scan_signal),
Arc::clone(&synced_files_count),
warmup_mmap_cache,
shared_picker,
shared_frecency,
);
Ok(picker)
Ok(())
}
/// Perform fuzzy search on files with a pre-parsed query.
@@ -304,38 +370,46 @@ impl FilePicker {
}
}
pub fn update_git_statuses(&mut self, status_cache: GitStatusCache) -> Result<(), Error> {
/// Update git statuses for files, using the provided shared frecency tracker.
pub fn update_git_statuses(
&mut self,
status_cache: GitStatusCache,
shared_frecency: &SharedFrecency,
) -> Result<(), Error> {
debug!(
statuses_count = status_cache.statuses_len(),
"Updating git status",
);
let frecency = FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)?;
let frecency = shared_frecency
.read()
.map_err(|_| Error::AcquireFrecencyLock)?;
status_cache
.into_iter()
.try_for_each(|(path, status)| -> Result<(), Error> {
if let Some(file) = self.get_mut_file_by_path(&path) {
file.git_status = Some(status);
if let Some(frecency) = frecency.as_ref() {
file.update_frecency_scores(frecency)?;
if let Some(ref f) = *frecency {
file.update_frecency_scores(f)?;
}
} else {
error!(?path, "Couldn't update the git status for path");
}
Ok(())
})?;
Ok(())
}
/// Fetches all the git statuses first and updates the global FILE_PICKER
/// with the new statuses with the smallest possible lock time.
pub fn refresh_git_status_global() -> Result<usize, Error> {
/// Refreshes git statuses using the provided shared picker and frecency handles.
pub fn refresh_git_status(
shared_picker: &SharedPicker,
shared_frecency: &SharedFrecency,
) -> Result<usize, Error> {
let git_status = {
let Some(ref picker) = *FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)? else {
return Err(Error::FilePickerMissing)?;
let guard = shared_picker.read().map_err(|_| Error::AcquireItemLock)?;
let Some(ref picker) = *guard else {
return Err(Error::FilePickerMissing);
};
debug!(
@@ -343,29 +417,22 @@ impl FilePicker {
picker.git_root()
);
// we keep here readonly lock but allowing querying the index while it scan lasts
GitStatusCache::read_git_status(
picker.git_root(),
StatusOptions::new()
.include_untracked(true)
.recurse_untracked_dirs(true)
// when manually refreshing git status we want to include all unmodified file
// to make sure that their status is correctly updated when user
// commited/stashed/removed changes
.include_unmodified(true)
.exclude_submodules(true),
)
};
let mut file_picker = FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)?;
let picker = file_picker
.as_mut()
.ok_or_else(|| Error::FilePickerMissing)?;
let mut guard = shared_picker.write().map_err(|_| Error::AcquireItemLock)?;
let picker = guard.as_mut().ok_or(Error::FilePickerMissing)?;
let statuses_count = if let Some(git_status) = git_status {
let count = git_status.statuses_len();
picker.update_git_statuses(git_status)?;
picker.update_git_statuses(git_status, shared_frecency)?;
count
} else {
0
@@ -380,7 +447,7 @@ impl FilePicker {
frecency_tracker: &FrecencyTracker,
) -> Result<(), Error> {
if let Ok(index) = self.sync_data.find_file_index(file_path.as_ref())
&& let Some(file) = self.sync_data.files.get_mut(index)
&& let Some(file) = self.sync_data.get_file_mut(index)
{
file.update_frecency_scores(frecency_tracker)?;
}
@@ -392,35 +459,38 @@ impl FilePicker {
self.sync_data
.find_file_index(path.as_ref())
.ok()
.and_then(|index| self.sync_data.files.get(index))
.and_then(|index| self.sync_data.files().get(index))
}
pub fn get_mut_file_by_path(&mut self, path: impl AsRef<Path>) -> Option<&mut FileItem> {
self.sync_data
.find_file_index(path.as_ref())
.ok()
.and_then(|index| self.sync_data.files.get_mut(index))
.and_then(|index| self.sync_data.get_file_mut(index))
}
/// Add a file to the picker's files in sorted order (used by background watcher)
pub fn add_file_sorted(&mut self, file: FileItem) -> Option<&FileItem> {
match self
.sync_data
.files
.binary_search_by(|f| f.relative_path.cmp(&file.relative_path))
{
Ok(position) => {
warn!(
"Trying to insert a file that already exists: {}",
file.relative_path
);
let path = file.path.clone();
self.sync_data.files.get(position)
}
Err(position) => {
self.sync_data.files.insert(position, file);
self.sync_data.files.get(position)
}
if self.sync_data.insert_file_sorted(file) {
// File was inserted, look it up
self.sync_data
.find_file_index(&path)
.ok()
.and_then(|idx| self.sync_data.get_file_mut(idx))
.map(|file_mut| &*file_mut) // Convert &mut to &
} else {
// File already exists
warn!(
"Trying to insert a file that already exists: {}",
path.display()
);
self.sync_data
.find_file_index(&path)
.ok()
.and_then(|idx| self.sync_data.get_file_mut(idx))
.map(|file_mut| &*file_mut) // Convert &mut to &
}
}
@@ -429,8 +499,12 @@ impl FilePicker {
let path = path.as_ref();
match self.sync_data.find_file_index(path) {
Ok(pos) => {
// safe to read because we are in lock and binary search returned valid position
let file = &mut self.sync_data.files[pos];
debug!(
"on_create_or_modify: file EXISTS at index {}, updating metadata",
pos
);
// File exists - update its metadata (doesn't change indices, safe)
let file = self.sync_data.get_file_mut(pos)?;
let modified = match std::fs::metadata(path) {
Ok(metadata) => metadata
@@ -456,21 +530,40 @@ impl FilePicker {
}
}
Some(file)
Some(&*file) // Convert &mut to &
}
Err(pos) => {
let file_item = FileItem::new(path.to_path_buf(), &self.base_path, None);
self.sync_data.files.insert(pos, file_item);
debug!(
"on_create_or_modify: file NEW, inserting at index {} (total files: {})",
pos,
self.sync_data.files().len()
);
self.sync_data.files.get(pos)
let file_item = FileItem::new(path.to_path_buf(), &self.base_path, None);
let path_buf = file_item.path.clone();
self.sync_data.insert_file(pos, file_item);
let result = self.sync_data.get_file(pos);
if result.is_none() {
error!(
"on_create_or_modify: FAILED to find file after insert! path={:?}",
path_buf
);
} else {
debug!("on_create_or_modify: successfully inserted and found file");
}
result
}
}
}
pub fn remove_file_by_path(&mut self, path: impl AsRef<Path>) -> bool {
match self.sync_data.find_file_index(path.as_ref()) {
let path = path.as_ref();
match self.sync_data.find_file_index(path) {
Ok(index) => {
self.sync_data.files.remove(index);
self.sync_data.remove_file(index);
true
}
Err(_) => false,
@@ -480,13 +573,9 @@ impl FilePicker {
// TODO make this O(n)
pub fn remove_all_files_in_dir(&mut self, dir: impl AsRef<Path>) -> usize {
let dir_path = dir.as_ref();
let initial_len = self.sync_data.files.len();
// Use the safe retain_files method which maintains both indices
self.sync_data
.files
.retain(|file| !file.path.starts_with(dir_path));
initial_len - self.sync_data.files.len()
.retain_files(|file| !file.path.starts_with(dir_path))
}
pub fn stop_background_monitor(&mut self) {
@@ -495,7 +584,7 @@ impl FilePicker {
}
}
pub fn trigger_rescan(&mut self) -> Result<(), Error> {
pub fn trigger_rescan(&mut self, shared_frecency: &SharedFrecency) -> Result<(), Error> {
if self.is_scanning.load(Ordering::Relaxed) {
debug!("Scan already in progress, skipping trigger_rescan");
return Ok(());
@@ -504,7 +593,8 @@ impl FilePicker {
self.is_scanning.store(true, Ordering::Relaxed);
self.scanned_files_count.store(0, Ordering::Relaxed);
let scan_result = scan_filesystem(&self.base_path, &self.scanned_files_count);
let scan_result =
scan_filesystem(&self.base_path, &self.scanned_files_count, shared_frecency);
match scan_result {
Ok(sync) => {
info!(
@@ -515,7 +605,11 @@ impl FilePicker {
self.sync_data = sync;
if self.warmup_mmap_cache {
warmup_mmaps(&self.sync_data.files);
// Warmup in background to avoid blocking
let files = self.sync_data.files().to_vec(); // Clone all files
std::thread::spawn(move || {
warmup_mmaps(&files);
});
}
}
Err(error) => error!(?error, "Failed to scan file system"),
@@ -528,6 +622,12 @@ impl FilePicker {
pub fn is_scan_active(&self) -> bool {
self.is_scanning.load(Ordering::Relaxed)
}
/// Return a clone of the scanning flag so callers can poll it without
/// holding a lock on the picker.
pub fn scan_signal(&self) -> Arc<AtomicBool> {
Arc::clone(&self.is_scanning)
}
}
#[allow(unused)]
@@ -542,13 +642,16 @@ fn spawn_scan_and_watcher(
scan_signal: Arc<AtomicBool>,
synced_files_count: Arc<AtomicUsize>,
warmup_mmap_cache: bool,
shared_picker: SharedPicker,
shared_frecency: SharedFrecency,
) {
std::thread::spawn(move || {
scan_signal.store(true, Ordering::Relaxed);
// scan_signal is already `true` (set by the caller before spawning)
// so waiters see "scanning" even before this thread is scheduled.
info!("Starting initial file scan");
let mut git_workdir = None;
match scan_filesystem(&base_path, &synced_files_count) {
match scan_filesystem(&base_path, &synced_files_count, &shared_frecency) {
Ok(sync) => {
info!(
"Initial filesystem scan completed: found {} files",
@@ -556,14 +659,30 @@ fn spawn_scan_and_watcher(
);
git_workdir = sync.git_workdir.clone();
if let Ok(mut file_picker_guard) = crate::FILE_PICKER.write()
&& let Some(ref mut picker) = *file_picker_guard
{
picker.sync_data = sync;
if warmup_mmap_cache {
warmup_mmaps(&picker.sync_data.files);
// Write results into the provided shared handle.
let write_result = shared_picker.write().ok().map(|mut guard| {
if let Some(ref mut picker) = *guard {
picker.sync_data = sync;
}
});
if write_result.is_none() {
error!("Failed to write scan results into picker");
}
// OPTIMIZATION: Warmup mmap cache in background to avoid blocking first grep.
// The aggressive parallel warmup was causing cache thrashing and delaying
// initial searches. Now it runs async and doesn't block.
//
// We warmup under a read lock on the picker's actual files so that
// the OnceLock<Mmap> instances are populated in-place — no clone needed.
// Read locks allow concurrent readers so this doesn't block searches.
if warmup_mmap_cache
&& let Ok(guard) = shared_picker.read()
&& let Some(ref picker) = *guard
{
warmup_mmaps(picker.sync_data.files());
}
}
Err(e) => {
@@ -572,14 +691,23 @@ fn spawn_scan_and_watcher(
}
scan_signal.store(false, Ordering::Relaxed);
match BackgroundWatcher::new(base_path, git_workdir) {
match BackgroundWatcher::new(
base_path,
git_workdir,
shared_picker.clone(),
shared_frecency.clone(),
) {
Ok(watcher) => {
info!("Background file watcher initialized successfully");
if let Ok(mut file_picker_guard) = crate::FILE_PICKER.write()
&& let Some(ref mut picker) = *file_picker_guard
{
picker.background_watcher = Some(watcher);
let write_result = shared_picker.write().ok().map(|mut guard| {
if let Some(ref mut picker) = *guard {
picker.background_watcher = Some(watcher);
}
});
if write_result.is_none() {
error!("Failed to store background watcher in picker");
}
}
Err(e) => {
@@ -596,8 +724,8 @@ fn spawn_scan_and_watcher(
///
/// Each file is mmap'd and a single byte is read to trigger the page fault.
/// This runs in parallel using rayon.
#[tracing::instrument(skip(files), name = "warmup_mmaps", level = Level::DEBUG)]
fn warmup_mmaps(files: &[FileItem]) {
let warmup_start = std::time::Instant::now();
let warmed = std::sync::atomic::AtomicUsize::new(0);
files.par_iter().for_each(|file| {
@@ -614,18 +742,12 @@ fn warmup_mmaps(files: &[FileItem]) {
warmed.fetch_add(1, Ordering::Relaxed);
}
});
let warmed_count = warmed.load(Ordering::Relaxed);
info!(
"Mmap warmup completed: {warmed_count}/{} files in {:?}",
files.len(),
warmup_start.elapsed()
);
}
fn scan_filesystem(
base_path: &Path,
synced_files_count: &Arc<AtomicUsize>,
shared_frecency: &SharedFrecency,
) -> Result<FileSync, Error> {
use ignore::{WalkBuilder, WalkState};
use std::thread;
@@ -712,7 +834,9 @@ fn scan_filesystem(
Error::ThreadPanic
})?;
let frecency = FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)?;
let frecency = shared_frecency
.read()
.map_err(|_| Error::AcquireFrecencyLock)?;
files
.par_iter_mut()
.try_for_each(|file| -> Result<(), Error> {
@@ -734,7 +858,6 @@ fn scan_filesystem(
files.len()
);
// Sort by OsStr instead of Path to avoid expensive component-by-component comparison
files.par_sort_unstable_by(|a, b| a.path.as_os_str().cmp(b.path.as_os_str()));
Ok(FileSync { files, git_workdir })
})
+1 -1
View File
@@ -127,7 +127,7 @@ pub fn is_modified_status(status: Status) -> bool {
pub fn format_git_status(status: Option<Status>) -> &'static str {
match status {
None => "clear",
None => "clean",
Some(status) => {
if status.contains(Status::WT_NEW) {
"untracked"
File diff suppressed because it is too large Load Diff
+14 -15
View File
@@ -1,7 +1,13 @@
//! fff-core - High-performance file finder library
//!
//! This crate provides the core file indexing and fuzzy search functionality.
//! It maintains global state for the file picker, frecency tracker, and query tracker.
//!
//! # State management
//!
//! All state is instance-based. Callers create their own `SharedPicker` /
//! `SharedFrecency` / `SharedQueryTracker` and pass them into
//! `FilePicker::new_with_shared_state`. Multiple independent instances can
//! coexist in the same process.
mod background_watcher;
pub mod constraints;
@@ -19,25 +25,18 @@ pub mod types;
use file_picker::FilePicker;
use frecency::FrecencyTracker;
use once_cell::sync::Lazy;
use query_tracker::QueryTracker;
use std::sync::RwLock;
use std::sync::{Arc, RwLock};
// Global state - same pattern as fff-nvim
pub static FRECENCY: Lazy<RwLock<Option<FrecencyTracker>>> = Lazy::new(|| RwLock::new(None));
pub static FILE_PICKER: Lazy<RwLock<Option<FilePicker>>> = Lazy::new(|| RwLock::new(None));
pub static QUERY_TRACKER: Lazy<RwLock<Option<QueryTracker>>> = Lazy::new(|| RwLock::new(None));
pub type SharedPicker = Arc<RwLock<Option<FilePicker>>>;
pub type SharedFrecency = Arc<RwLock<Option<FrecencyTracker>>>;
pub type SharedQueryTracker = Arc<RwLock<Option<QueryTracker>>>;
// Re-export main types for convenience
pub use db_healthcheck::{DbHealth, DbHealthChecker};
pub use error::{Error, Result};
pub use file_picker::{FuzzySearchOptions, ScanProgress};
pub use types::{FileItem, PaginationArgs, Score, ScoringContext, SearchResult};
// Re-export grep types
pub use grep::{GrepMatch, GrepMode, GrepResult, GrepSearchOptions};
// Re-export query parser types (including Location which moved there)
pub use fff_query_parser::{
Constraint, FFFQuery, FuzzyQuery, Location, QueryParser, location::parse_location,
};
pub use file_picker::{FuzzySearchOptions, ScanProgress};
pub use grep::{GrepMatch, GrepMode, GrepResult, GrepSearchOptions};
pub use types::{FileItem, PaginationArgs, Score, ScoringContext, SearchResult};
+69 -12
View File
@@ -188,7 +188,6 @@ fn plain_text_across_multiple_files() {
let result = grep_search(&files, "use std", None, &plain_opts());
assert_eq!(result.total_match_count, 3);
assert_eq!(result.matches.len(), 3);
// Should match in files a.txt and b.txt
assert_eq!(result.files.len(), 2);
@@ -279,17 +278,28 @@ fn plain_text_page_limit() {
let result = grep_search(&files, "target", None, &opts);
// page_limit is a soft minimum: we always finish the current file, so we
// get at least page_limit matches (no data loss) and at most
// max_matches_per_file (200) from a single file.
assert!(
result.matches.len() <= 10,
"should respect page_limit: got {}",
result.matches.len() >= opts.page_limit,
"should return at least page_limit matches: got {}",
result.matches.len()
);
assert!(
result.matches.len() <= opts.max_matches_per_file,
"should never exceed max_matches_per_file: got {}",
result.matches.len()
);
// Single file with 100 lines all matching — all should be returned.
assert_eq!(result.matches.len(), 100, "all 100 lines must be returned");
}
#[test]
fn plain_text_file_offset_pagination() {
let tmp = TempDir::new().unwrap();
// Create many files so file-based pagination works
// Create many files (1 match per file) so file-based pagination exercises
// offset tracking across files with and without matches.
let mut files = Vec::new();
for i in 0..20 {
files.push(create_file(
@@ -302,14 +312,51 @@ fn plain_text_file_offset_pagination() {
let mut opts = plain_opts();
opts.page_limit = 5;
let result1 = grep_search(&files, "unique_token", None, &opts);
assert!(result1.matches.len() > 0);
// Collect ALL matches across all pages and verify no duplicates and full coverage.
let mut all_line_texts: Vec<String> = Vec::new();
let mut pages = 0;
let max_pages = 20; // safety limit
if result1.next_file_offset > 0 {
opts.file_offset = result1.next_file_offset;
let result2 = grep_search(&files, "unique_token", None, &opts);
assert!(result2.matches.len() > 0, "second page should have results");
loop {
let result = grep_search(&files, "unique_token", None, &opts);
for m in &result.matches {
let text = m.line_content.trim().to_string();
assert!(
!all_line_texts.contains(&text),
"duplicate match across pages: '{}'",
text
);
all_line_texts.push(text);
}
pages += 1;
assert!(pages <= max_pages, "pagination did not terminate");
if result.next_file_offset == 0 {
break;
}
// Offset must strictly advance
assert!(
result.next_file_offset > opts.file_offset,
"next_file_offset ({}) did not advance past current ({})",
result.next_file_offset,
opts.file_offset
);
opts.file_offset = result.next_file_offset;
}
assert_eq!(
all_line_texts.len(),
20,
"pagination should find all 20 matches across all pages, got {}",
all_line_texts.len()
);
assert!(
pages > 1,
"should require multiple pages with page_limit=5 and 20 files"
);
}
#[test]
@@ -1067,11 +1114,21 @@ fn fuzzy_respects_page_limit() {
let result = grep_search(&files, "target", None, &opts);
// page_limit is a soft minimum: we always finish the current file, so we
// get at least page_limit matches (no data loss) and at most
// max_matches_per_file (200) from a single file.
assert!(
result.matches.len() <= 10,
"should respect page_limit: got {}",
result.matches.len() >= opts.page_limit,
"should return at least page_limit matches: got {}",
result.matches.len()
);
assert!(
result.matches.len() <= opts.max_matches_per_file,
"should never exceed max_matches_per_file: got {}",
result.matches.len()
);
// Single file with 100 lines all matching — all should be returned.
assert_eq!(result.matches.len(), 100, "all 100 lines must be returned");
}
#[test]
+68 -54
View File
@@ -1,8 +1,9 @@
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use fff_nvim::FILE_PICKER;
use fff_nvim::file_picker::{FilePicker, FuzzySearchOptions};
use fff_nvim::types::PaginationArgs;
use fff_core::file_picker::FilePicker;
use fff_core::types::{FileItem, PaginationArgs};
use fff_core::{FuzzySearchOptions, SharedFrecency, SharedPicker};
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::time::Duration;
/// Initialize tracing to output to console
@@ -19,20 +20,26 @@ fn init_tracing() {
// .try_init();
}
/// Initialize FilePicker and insert into global state
fn init_file_picker_internal(path: &str) -> Result<(), String> {
let picker = FilePicker::new(path.to_string())
.map_err(|e| format!("Failed to create FilePicker: {:?}", e))?;
let mut picker_guard = FILE_PICKER
.write()
.map_err(|_| "Failed to acquire write lock")?;
*picker_guard = Some(picker);
Ok(())
/// Initialize FilePicker using shared state
fn init_file_picker_internal(
path: &str,
shared_picker: &SharedPicker,
shared_frecency: &SharedFrecency,
) -> Result<(), String> {
FilePicker::new_with_shared_state(
path.to_string(),
false,
Arc::clone(shared_picker),
Arc::clone(shared_frecency),
)
.map_err(|e| format!("Failed to create FilePicker: {:?}", e))
}
/// Helper function to wait for scanning to complete and get file count
fn wait_for_scan_completion(timeout_secs: u64) -> Result<usize, String> {
fn wait_for_scan_completion(
shared_picker: &SharedPicker,
timeout_secs: u64,
) -> Result<usize, String> {
let start = std::time::Instant::now();
let timeout = Duration::from_secs(timeout_secs);
let mut last_log = std::time::Instant::now();
@@ -42,7 +49,7 @@ fn wait_for_scan_completion(timeout_secs: u64) -> Result<usize, String> {
iteration += 1;
{
let picker_guard = FILE_PICKER
let picker_guard = shared_picker
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
@@ -91,9 +98,9 @@ fn wait_for_scan_completion(timeout_secs: u64) -> Result<usize, String> {
}
}
/// Get files from the global FILE_PICKER
fn get_files_snapshot() -> Result<Vec<fff_nvim::types::FileItem>, String> {
let picker_guard = FILE_PICKER
/// Get files from the shared picker
fn get_files_snapshot(shared_picker: &SharedPicker) -> Result<Vec<FileItem>, String> {
let picker_guard = shared_picker
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
@@ -103,9 +110,9 @@ fn get_files_snapshot() -> Result<Vec<fff_nvim::types::FileItem>, String> {
}
}
/// Clean up global state
fn cleanup_global_state() {
if let Ok(mut picker_guard) = FILE_PICKER.write() {
/// Clean up shared state
fn cleanup_shared_state(shared_picker: &SharedPicker) {
if let Ok(mut picker_guard) = shared_picker.write() {
if let Some(mut picker) = picker_guard.take() {
picker.stop_background_monitor();
}
@@ -113,7 +120,7 @@ fn cleanup_global_state() {
}
/// Initialize FilePicker once and return files snapshot
fn setup_once() -> Result<Vec<fff_nvim::types::FileItem>, String> {
fn setup_once() -> Result<(Vec<FileItem>, SharedPicker, SharedFrecency), String> {
init_tracing();
let big_repo_path = PathBuf::from("./big-repo");
@@ -125,32 +132,24 @@ fn setup_once() -> Result<Vec<fff_nvim::types::FileItem>, String> {
.map_err(|e| format!("Failed to canonicalize path: {}", e))?;
eprintln!(" Path: {:?}", canonical_path);
{
let picker_guard = FILE_PICKER
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
let files = picker.get_files();
if !files.is_empty() {
eprintln!(" Reusing existing index with {} files", files.len());
return Ok(files.to_vec());
}
}
}
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
cleanup_global_state();
std::thread::sleep(Duration::from_millis(500));
init_file_picker_internal(&canonical_path.to_string_lossy())?;
init_file_picker_internal(
&canonical_path.to_string_lossy(),
&shared_picker,
&shared_frecency,
)?;
eprintln!(" Waiting for background scan to complete...");
let file_count = wait_for_scan_completion(120)?;
let file_count = wait_for_scan_completion(&shared_picker, 120)?;
eprintln!(
" ✓ Indexed {} files (will be reused for all benchmarks)\n",
file_count
);
get_files_snapshot()
let files = get_files_snapshot(&shared_picker)?;
Ok((files, shared_picker, shared_frecency))
}
/// Benchmark for indexing the big-repo directory
@@ -179,21 +178,23 @@ fn bench_indexing(c: &mut Criterion) {
group.bench_function("index_big_repo", |b| {
b.iter(|| {
cleanup_global_state();
std::thread::sleep(Duration::from_millis(500));
let sp: SharedPicker = Arc::new(RwLock::new(None));
let sf: SharedFrecency = Arc::new(RwLock::new(None));
let start = std::time::Instant::now();
init_file_picker_internal(black_box(&canonical_path.to_string_lossy()))
init_file_picker_internal(black_box(&canonical_path.to_string_lossy()), &sp, &sf)
.expect("Failed to init FilePicker");
match wait_for_scan_completion(120) {
match wait_for_scan_completion(&sp, 120) {
Ok(file_count) => {
let elapsed = start.elapsed();
eprintln!(" ✓ Indexed {} files in {:?}", file_count, elapsed);
cleanup_shared_state(&sp);
file_count
}
Err(e) => {
eprintln!(" ✗ Error: {}", e);
cleanup_shared_state(&sp);
0
}
}
@@ -205,8 +206,8 @@ fn bench_indexing(c: &mut Criterion) {
/// Benchmark for searching with various query patterns
fn bench_search_queries(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
let (files, _sp, _sf) = match setup_once() {
Ok(result) => result,
Err(e) => {
eprint!("Failed to setup picker {e:?}");
return;
@@ -230,6 +231,7 @@ fn bench_search_queries(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
@@ -254,8 +256,8 @@ fn bench_search_queries(c: &mut Criterion) {
/// Benchmark search with different thread counts
fn bench_search_thread_scaling(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
let (files, _sp, _sf) = match setup_once() {
Ok(result) => result,
Err(e) => {
eprintln!("⚠ Skipping thread scaling benchmarks: {}", e);
return;
@@ -277,6 +279,7 @@ fn bench_search_thread_scaling(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
None,
FuzzySearchOptions {
max_threads: threads,
current_file: None,
@@ -302,8 +305,8 @@ fn bench_search_thread_scaling(c: &mut Criterion) {
/// Benchmark search with different result limits
fn bench_search_result_limits(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
let (files, _sp, _sf) = match setup_once() {
Ok(result) => result,
Err(e) => {
eprintln!("⚠ Skipping result limit benchmarks: {}", e);
return;
@@ -322,6 +325,7 @@ fn bench_search_result_limits(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
@@ -379,6 +383,7 @@ fn bench_search_scalability(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(subset),
black_box(query),
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
@@ -403,8 +408,8 @@ fn bench_search_scalability(c: &mut Criterion) {
/// Benchmark search performance with different ordering modes
fn bench_search_ordering(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
let (files, _sp, _sf) = match setup_once() {
Ok(result) => result,
Err(e) => {
eprintln!("⚠ Skipping ordering benchmarks: {}", e);
return;
@@ -422,6 +427,7 @@ fn bench_search_ordering(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
@@ -446,6 +452,7 @@ fn bench_search_ordering(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
@@ -470,6 +477,7 @@ fn bench_search_ordering(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("mod"),
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
@@ -493,6 +501,7 @@ fn bench_search_ordering(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("mod"),
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
@@ -517,6 +526,7 @@ fn bench_search_ordering(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("controller"),
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
@@ -540,6 +550,7 @@ fn bench_search_ordering(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("controller"),
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
@@ -563,8 +574,8 @@ fn bench_search_ordering(c: &mut Criterion) {
/// Benchmark pagination: first page vs deep page
fn bench_pagination_performance(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
let (files, _sp, _sf) = match setup_once() {
Ok(result) => result,
Err(e) => {
eprintln!("⚠ Skipping pagination benchmarks: {}", e);
return;
@@ -583,6 +594,7 @@ fn bench_pagination_performance(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
@@ -607,6 +619,7 @@ fn bench_pagination_performance(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
@@ -631,6 +644,7 @@ fn bench_pagination_performance(c: &mut Criterion) {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
@@ -1,5 +1,5 @@
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use fff_nvim::query_tracker::QueryTracker;
use fff_core::query_tracker::QueryTracker;
use rand::distributions::Alphanumeric;
use rand::prelude::*;
use std::path::PathBuf;
+1 -5
View File
@@ -139,11 +139,7 @@ impl<'a> GrepBench<'a> {
let start = Instant::now();
let result = grep_search(self.files, query, parsed, &self.options);
let elapsed = start.elapsed();
(
elapsed,
result.total_match_count,
result.total_files_searched,
)
(elapsed, result.matches.len(), result.total_files_searched)
}
/// Benchmark a query with multiple iterations
+1 -3
View File
@@ -208,7 +208,7 @@ fn run_fff_full(files: &[FileItem], query: &str) -> (usize, Duration) {
let start = Instant::now();
let result = grep_search(files, query, parsed, &options);
let elapsed = start.elapsed();
(result.total_match_count, elapsed)
(result.matches.len(), elapsed)
}
/// fff paginated: first 50 results only (real UI scenario).
@@ -226,8 +226,6 @@ fn run_fff_page(files: &[FileItem], query: &str) -> (usize, Duration) {
let start = Instant::now();
let result = grep_search(files, query, parsed, &options);
let elapsed = start.elapsed();
// Use matches.len() — the actual truncated page the UI would display,
// not total_match_count which includes overshoot from parallel batches.
(result.matches.len(), elapsed)
}
+28 -19
View File
@@ -1,6 +1,7 @@
use fff_core::file_picker::FilePicker;
use fff_core::{FILE_PICKER, FuzzySearchOptions, PaginationArgs, QueryParser};
use fff_core::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
use std::env;
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::Duration;
@@ -60,6 +61,7 @@ fn format_bytes(bytes: usize) -> String {
}
fn test_search_memory_pattern(
shared_picker: &SharedPicker,
name: &str,
iterations: usize,
query_pattern: impl Fn(usize) -> String,
@@ -82,8 +84,8 @@ fn test_search_memory_pattern(
let query = query_pattern(i);
let (result_count, _total_matched) = {
let file_picker_guard = FILE_PICKER.read().unwrap();
if let Some(ref picker) = *file_picker_guard {
let guard = shared_picker.read().unwrap();
if let Some(ref picker) = *guard {
let parser = QueryParser::default();
let parsed = parser.parse(&query);
let search_result = FilePicker::fuzzy_search(
@@ -177,20 +179,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("Test directory: {}", base_path);
println!();
// Create shared state
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
// Initialize FilePicker
{
let mut file_picker_guard = FILE_PICKER.write().unwrap();
if file_picker_guard.is_none() {
println!("Initializing FilePicker...");
*file_picker_guard = Some(FilePicker::new(base_path.clone())?);
}
}
println!("Initializing FilePicker...");
FilePicker::new_with_shared_state(
base_path.clone(),
false,
Arc::clone(&shared_picker),
Arc::clone(&shared_frecency),
)?;
// Wait for initial scan
println!("Waiting for file scan...");
loop {
if let Ok(file_picker_guard) = FILE_PICKER.read()
&& let Some(ref picker) = *file_picker_guard
if let Ok(guard) = shared_picker.read()
&& let Some(ref picker) = *guard
&& !picker.is_scan_active()
&& !picker.get_files().is_empty()
{
@@ -200,8 +206,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
let file_count = {
let file_picker_guard = FILE_PICKER.read()?;
file_picker_guard.as_ref().unwrap().get_files().len()
let guard = shared_picker.read().unwrap();
guard.as_ref().unwrap().get_files().len()
};
println!("📊 Found {} files", file_count);
@@ -216,10 +222,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Test different memory patterns
// 1. Repeated same query - should have minimal growth if caching works
test_search_memory_pattern("Same Query Repeated (1000x)", 1000, |_| "test".to_string())?;
test_search_memory_pattern(&shared_picker, "Same Query Repeated (1000x)", 1000, |_| {
"test".to_string()
})?;
// 2. Cycling through different queries
test_search_memory_pattern("Cycling Queries (1000x)", 1000, |i| {
test_search_memory_pattern(&shared_picker, "Cycling Queries (1000x)", 1000, |i| {
let queries = [
"test", "main", "lib", "src", "mod", "file", "picker", "fuzzy", "search",
];
@@ -227,24 +235,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
})?;
// 3. Unique queries each time - worst case for any caching
test_search_memory_pattern("Unique Queries (500x)", 500, |i| {
test_search_memory_pattern(&shared_picker, "Unique Queries (500x)", 500, |i| {
format!("unique_query_{}", i)
})?;
// 4. Queries that return many results
test_search_memory_pattern(
&shared_picker,
"High Result Count (500x)",
500,
|_| "a".to_string(), // Single character likely to match many files
)?;
// 5. Queries with no results
test_search_memory_pattern("No Results (500x)", 500, |_| {
test_search_memory_pattern(&shared_picker, "No Results (500x)", 500, |_| {
"zzzz_no_match_expected".to_string()
})?;
// 6. Long intensive test
test_search_memory_pattern("Long Intensive Test (2000x)", 2000, |i| {
test_search_memory_pattern(&shared_picker, "Long Intensive Test (2000x)", 2000, |i| {
let patterns = [
"rs", "lua", "toml", "mod", "lib", "main", "test", "src", "file",
];
+22 -21
View File
@@ -1,9 +1,12 @@
use fff_core::file_picker::FilePicker;
use fff_core::{FILE_PICKER, FileItem, FuzzySearchOptions, PaginationArgs, QueryParser};
use fff_core::{
FileItem, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker,
};
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
/// Wait for background scan to complete
fn wait_for_scan(timeout_secs: u64) -> Result<usize, String> {
fn wait_for_scan(shared_picker: &SharedPicker, timeout_secs: u64) -> Result<usize, String> {
let start = Instant::now();
let timeout = Duration::from_secs(timeout_secs);
let mut iteration = 0;
@@ -11,7 +14,7 @@ fn wait_for_scan(timeout_secs: u64) -> Result<usize, String> {
loop {
iteration += 1;
let picker_guard = FILE_PICKER
let picker_guard = shared_picker
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
@@ -45,21 +48,9 @@ fn wait_for_scan(timeout_secs: u64) -> Result<usize, String> {
}
}
/// Initialize FilePicker and insert into global state
fn init_file_picker(path: &str) -> Result<(), String> {
let picker = FilePicker::new(path.to_string())
.map_err(|e| format!("Failed to create FilePicker: {:?}", e))?;
let mut picker_guard = FILE_PICKER
.write()
.map_err(|_| "Failed to acquire write lock")?;
*picker_guard = Some(picker);
Ok(())
}
/// Get files snapshot from global state
fn get_files() -> Result<Vec<FileItem>, String> {
let picker_guard = FILE_PICKER
/// Get files snapshot from shared state
fn get_files(shared_picker: &SharedPicker) -> Result<Vec<FileItem>, String> {
let picker_guard = shared_picker
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
@@ -82,17 +73,27 @@ fn main() {
let canonical_path =
fff_core::path_utils::canonicalize(&big_repo_path).expect("Failed to canonicalize path");
// Create shared state
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
eprintln!("Initializing FilePicker for: {:?}", canonical_path);
init_file_picker(&canonical_path.to_string_lossy()).expect("Failed to init FilePicker");
FilePicker::new_with_shared_state(
canonical_path.to_string_lossy().to_string(),
false,
Arc::clone(&shared_picker),
Arc::clone(&shared_frecency),
)
.expect("Failed to init FilePicker");
// Give background thread time to start
std::thread::sleep(Duration::from_millis(200));
eprintln!("Waiting for scan to complete...");
let file_count = wait_for_scan(120).expect("Failed to wait for scan");
let file_count = wait_for_scan(&shared_picker, 120).expect("Failed to wait for scan");
eprintln!("✓ Indexed {} files\n", file_count);
let files = get_files().expect("Failed to get files");
let files = get_files(&shared_picker).expect("Failed to get files");
// Test queries representing different search patterns
let test_queries = vec![
+23 -28
View File
@@ -1,7 +1,8 @@
use fff_core::file_picker::FilePicker;
use fff_core::{FILE_PICKER, FuzzySearchOptions, PaginationArgs, QueryParser};
use fff_core::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
use std::env;
use std::io::{self, Write};
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::{Duration, Instant};
@@ -77,24 +78,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("Test directory: {}", base_path);
println!();
// Initialize the file picker directly
// Create shared state
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
// Initialize the file picker
println!("📁 Initializing FilePicker...");
{
let mut file_picker_guard = FILE_PICKER.write().unwrap();
if file_picker_guard.is_none() {
println!("Creating new FilePicker for path: {}", base_path);
match FilePicker::new(base_path.clone()) {
Ok(picker) => {
println!("FilePicker created successfully");
*file_picker_guard = Some(picker);
}
Err(e) => {
eprintln!("Failed to create FilePicker: {:?}", e);
std::process::exit(1);
}
}
}
}
FilePicker::new_with_shared_state(
base_path.clone(),
false,
Arc::clone(&shared_picker),
Arc::clone(&shared_frecency),
)?;
// Wait for initial scan to complete
println!("⏳ Waiting for initial file scan to complete...");
@@ -102,8 +97,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut scan_completed = false;
loop {
if let Ok(file_picker_guard) = FILE_PICKER.read()
&& let Some(ref picker) = *file_picker_guard
if let Ok(guard) = shared_picker.read()
&& let Some(ref picker) = *guard
{
if !picker.is_scan_active() {
println!("Scan inactive, checking file count...");
@@ -128,10 +123,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// If async scan didn't work, trigger a manual scan
if !scan_completed {
println!("Triggering manual rescan...");
if let Ok(mut file_picker_guard) = FILE_PICKER.write()
&& let Some(ref mut picker) = *file_picker_guard
if let Ok(mut guard) = shared_picker.write()
&& let Some(ref mut picker) = *guard
{
match picker.trigger_rescan() {
match picker.trigger_rescan(&shared_frecency) {
Ok(_) => println!("Manual rescan completed"),
Err(e) => println!("Manual rescan failed: {:?}", e),
}
@@ -139,8 +134,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
let initial_file_count = {
let file_picker_guard = FILE_PICKER.read()?;
if let Some(ref picker) = *file_picker_guard {
let guard = shared_picker.read().unwrap();
if let Some(ref picker) = *guard {
let files = picker.get_files();
println!("Found {} files in picker", files.len());
if !files.is_empty() {
@@ -151,7 +146,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
files.len()
} else {
println!("No picker found in FILE_PICKER static!");
println!("No picker found!");
0
}
};
@@ -199,8 +194,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let search_start = Instant::now();
let parser = QueryParser::default();
let (result_count, search_duration) = {
let file_picker_guard = FILE_PICKER.read().unwrap();
if let Some(ref picker) = *file_picker_guard {
let guard = shared_picker.read().unwrap();
if let Some(ref picker) = *guard {
let parsed = parser.parse(query);
let search_result = FilePicker::fuzzy_search(
picker.get_files(),
+38 -45
View File
@@ -4,33 +4,14 @@
use fff_core::file_picker::FilePicker;
use fff_core::git::format_git_status;
use fff_core::{FILE_PICKER, FRECENCY, FuzzySearchOptions, PaginationArgs, QueryParser};
use fff_core::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
use std::env;
use std::io::{self, Write};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::Duration;
fn cleanup_global_state() {
// Clean up file picker
{
let mut file_picker = FILE_PICKER.write().unwrap();
if let Some(mut picker) = file_picker.take() {
picker.stop_background_monitor();
drop(picker);
println!("🧹 FilePicker cleaned up");
}
}
// Clean up frecency tracker
{
let mut frecency = FRECENCY.write().unwrap();
*frecency = None;
println!("🧹 Frecency tracker cleaned up");
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().collect();
let base_path = if args.len() > 1 {
@@ -43,28 +24,38 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
// Create shared state
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
// Clone for signal handler
let picker_for_cleanup = Arc::clone(&shared_picker);
ctrlc::set_handler(move || {
println!("\n🛑 Received interrupt signal, shutting down...");
cleanup_global_state();
if let Ok(mut guard) = picker_for_cleanup.write() {
if let Some(mut picker) = guard.take() {
picker.stop_background_monitor();
println!("🧹 FilePicker cleaned up");
}
}
r.store(false, Ordering::SeqCst);
std::process::exit(0);
})?;
let mut git_stats = std::collections::HashMap::new();
// Initialize the global file picker using lib.rs function
{
let mut file_picker = FILE_PICKER.write().unwrap();
if file_picker.is_some() {
eprintln!("❌ FilePicker already initialized");
std::process::exit(1);
}
*file_picker = Some(FilePicker::new(base_path.clone())?);
}
// Get initial file count from global state
// Initialize the file picker using shared state
FilePicker::new_with_shared_state(
base_path.clone(),
false,
Arc::clone(&shared_picker),
Arc::clone(&shared_frecency),
)?;
// Get initial file count from shared state
let initial_count = {
let file_picker = FILE_PICKER.read().unwrap();
let files = file_picker.as_ref().unwrap().get_files();
let guard = shared_picker.read().unwrap();
let files = guard.as_ref().unwrap().get_files();
println!("Initial file count: {}", files.len());
if !files.is_empty() {
@@ -96,8 +87,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
iteration += 1;
let current_count = {
let file_picker = FILE_PICKER.read().unwrap();
file_picker.as_ref().unwrap().get_files().len()
let guard = shared_picker.read().unwrap();
guard.as_ref().unwrap().get_files().len()
};
if current_count != last_count {
@@ -111,13 +102,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
);
// Show some recently added files
let file_picker = FILE_PICKER.read().unwrap();
let files = file_picker.as_ref().unwrap().get_files();
let guard = shared_picker.read().unwrap();
let files = guard.as_ref().unwrap().get_files();
let newest_files = files.iter().rev().take(added.min(3));
for file in newest_files {
println!(" {}", file.relative_path);
}
drop(file_picker);
} else {
let removed = last_count - current_count;
println!(
@@ -136,8 +126,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
timestamp, current_count
);
let file_picker = FILE_PICKER.read().unwrap();
let current_files = file_picker.as_ref().unwrap().get_files();
let guard = shared_picker.read().unwrap();
let current_files = guard.as_ref().unwrap().get_files();
git_stats.clear();
for file in current_files {
@@ -156,8 +146,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if iteration % 40 == 0 {
let timestamp = chrono::Local::now().format("%H:%M:%S");
let file_picker = FILE_PICKER.read().unwrap();
let files = file_picker.as_ref().unwrap().get_files();
let guard = shared_picker.read().unwrap();
let files = guard.as_ref().unwrap().get_files();
let parser = QueryParser::default();
let parsed = parser.parse("rs");
let search_results = FilePicker::fuzzy_search(
@@ -197,13 +187,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
score.total
);
}
drop(file_picker);
}
io::stdout().flush().unwrap();
}
// Clean up before exit
cleanup_global_state();
if let Ok(mut guard) = shared_picker.write() {
if let Some(mut picker) = guard.take() {
picker.stop_background_monitor();
}
}
Ok(())
}
+49 -23
View File
@@ -3,12 +3,16 @@ use error::{IntoCoreError, IntoLuaResult};
use fff_core::file_picker::FilePicker;
use fff_core::frecency::FrecencyTracker;
use fff_core::query_tracker::QueryTracker;
use fff_core::{DbHealthChecker, Error, FuzzySearchOptions, PaginationArgs, QueryParser};
use fff_core::{FILE_PICKER, FRECENCY, QUERY_TRACKER};
use fff_core::{
DbHealthChecker, Error, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency,
SharedPicker, SharedQueryTracker,
};
use mimalloc::MiMalloc;
use mlua::prelude::*;
use once_cell::sync::Lazy;
use path_shortening::PathShortenStrategy;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use std::time::Duration;
mod error;
@@ -19,6 +23,12 @@ mod path_shortening;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
// the global state for neovim lives here for efficiency
// lua ffi is pretty bad with the overhead of converting raw pointer into tables
pub static FILE_PICKER: Lazy<SharedPicker> = Lazy::new(|| Arc::new(RwLock::new(None)));
pub static FRECENCY: Lazy<SharedFrecency> = Lazy::new(|| Arc::new(RwLock::new(None)));
pub static QUERY_TRACKER: Lazy<SharedQueryTracker> = Lazy::new(|| Arc::new(RwLock::new(None)));
pub fn init_db(
_: &Lua,
(frecency_db_path, history_db_path, use_unsafe_no_lock): (String, String, bool),
@@ -68,31 +78,45 @@ pub fn destroy_query_db(_: &Lua, _: ()) -> LuaResult<bool> {
}
pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult<bool> {
let mut file_picker = FILE_PICKER
.write()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
if file_picker.is_some() {
return Ok(false);
{
let guard = FILE_PICKER
.read()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
if guard.is_some() {
return Ok(false);
}
}
let picker = FilePicker::new(base_path).into_lua_result()?;
*file_picker = Some(picker);
FilePicker::new_with_shared_state(
base_path,
false,
Arc::clone(&FILE_PICKER),
Arc::clone(&FRECENCY),
)
.into_lua_result()?;
Ok(true)
}
fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> {
let mut file_picker = FILE_PICKER
.write()
.with_lock_error(Error::AcquireItemLock)?;
// drop should clean it anyway but just to be extra sure
if let Some(mut picker) = file_picker.take() {
picker.stop_background_monitor();
// Stop existing picker
{
let mut guard = FILE_PICKER
.write()
.with_lock_error(Error::AcquireItemLock)?;
if let Some(mut picker) = guard.take() {
picker.stop_background_monitor();
}
}
let new_picker = FilePicker::new(path.to_string_lossy().to_string())?;
*file_picker = Some(new_picker);
// Create new picker backed by the same shared state
FilePicker::new_with_shared_state(
path.to_string_lossy().to_string(),
false,
Arc::clone(&FILE_PICKER),
Arc::clone(&FRECENCY),
)?;
Ok(())
}
@@ -136,7 +160,7 @@ pub fn scan_files(_: &Lua, _: ()) -> LuaResult<()> {
.ok_or(Error::FilePickerMissing)
.into_lua_result()?;
picker.trigger_rescan().into_lua_result()?;
picker.trigger_rescan(&FRECENCY).into_lua_result()?;
::tracing::info!("scan_files trigger_rescan completed");
Ok(())
}
@@ -362,7 +386,7 @@ pub fn get_git_root(_: &Lua, _: ()) -> LuaResult<Option<String>> {
}
pub fn refresh_git_status(_: &Lua, _: ()) -> LuaResult<usize> {
FilePicker::refresh_git_status_global().into_lua_result()
FilePicker::refresh_git_status(&FILE_PICKER, &FRECENCY).into_lua_result()
}
pub fn update_single_file_frecency(_: &Lua, file_path: String) -> LuaResult<bool> {
@@ -444,8 +468,9 @@ pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) ->
};
// Spawn background thread to do the actual tracking (expensive DB write)
let query_tracker = Arc::clone(&QUERY_TRACKER);
std::thread::spawn(move || {
if let Ok(Some(tracker)) = QUERY_TRACKER.write().as_deref_mut()
if let Ok(Some(tracker)) = query_tracker.write().as_deref_mut()
&& let Err(e) = tracker.track_query_completion(&query, &project_path, &file_path)
{
tracing::error!(
@@ -497,8 +522,9 @@ pub fn track_grep_query(_: &Lua, query: String) -> LuaResult<bool> {
picker.base_path().to_path_buf()
};
let query_tracker = Arc::clone(&QUERY_TRACKER);
std::thread::spawn(move || {
if let Ok(Some(tracker)) = QUERY_TRACKER.write().as_deref_mut()
if let Ok(Some(tracker)) = query_tracker.write().as_deref_mut()
&& let Err(e) = tracker.track_grep_query(&query, &project_path)
{
tracing::error!(
+1 -1
View File
@@ -167,7 +167,7 @@ impl IntoLua for GrepResultLua<'_> {
}
table.set("items", items_table)?;
table.set("total_matched", self.inner.total_match_count)?;
table.set("total_matched", self.inner.matches.len())?;
table.set("total_files_searched", self.inner.total_files_searched)?;
table.set("total_files", self.inner.total_files)?;
table.set("filtered_file_count", self.inner.filtered_file_count)?;
+127 -10
View File
@@ -66,7 +66,6 @@ impl<C: ParserConfig> QueryParser<C> {
return None;
}
// Stack-allocated buffer for text parts (up to 16 parts)
let mut text_parts = TextPartsBuffer::new();
let tokens = query.split_whitespace();
@@ -123,6 +122,40 @@ impl<C: ParserConfig> QueryParser<C> {
}
}
impl<'a> FFFQuery<'a> {
/// Returns the grep search text by joining all non-constraint text tokens.
///
/// Backslash-escaped tokens (e.g. `\*.rs`) are included as literal text
/// with the leading `\` stripped, since the backslash is only an escape
/// signal to the parser and should not appear in the final pattern.
///
/// `FuzzyQuery::Empty` → empty string
/// `FuzzyQuery::Text("foo")` → `"foo"`
/// `FuzzyQuery::Parts(["a", "\\*.rs", "b"])` → `"a *.rs b"`
pub fn grep_text(&self) -> String {
match &self.fuzzy_query {
FuzzyQuery::Empty => String::new(),
FuzzyQuery::Text(t) => strip_leading_backslash(t).to_string(),
FuzzyQuery::Parts(parts) => parts
.iter()
.map(|t| strip_leading_backslash(t))
.collect::<Vec<_>>()
.join(" "),
}
}
}
/// Strip the leading `\` from a backslash-escaped token, returning the rest.
/// For all other tokens returns the input unchanged.
#[inline]
fn strip_leading_backslash(token: &str) -> &str {
if token.starts_with('\\') && token.len() > 1 {
&token[1..]
} else {
token
}
}
impl Default for QueryParser<crate::FilePickerConfig> {
fn default() -> Self {
Self::new(crate::FilePickerConfig)
@@ -351,7 +384,7 @@ fn parse_git_status(value: &str) -> Option<Constraint<'_>> {
#[cfg(test)]
mod tests {
use super::*;
use crate::FilePickerConfig;
use crate::{FilePickerConfig, GrepConfig};
#[test]
fn test_parse_extension() {
@@ -547,9 +580,100 @@ mod tests {
assert_eq!(result.constraints.len(), 0);
}
#[test]
fn test_grep_text_plain_text() {
// Multi-token plain text — no constraints
let q = QueryParser::new(GrepConfig)
.parse("name =")
.expect("should parse");
assert_eq!(q.grep_text(), "name =");
}
#[test]
fn test_grep_text_strips_constraint() {
let q = QueryParser::new(GrepConfig)
.parse("name = *.rs someth")
.expect("should parse");
assert_eq!(q.grep_text(), "name = someth");
}
#[test]
fn test_grep_text_leading_constraint() {
let q = QueryParser::new(GrepConfig)
.parse("*.rs name =")
.expect("should parse");
assert_eq!(q.grep_text(), "name =");
}
#[test]
fn test_grep_text_only_constraints() {
let q = QueryParser::new(GrepConfig)
.parse("*.rs /src/")
.expect("should parse");
assert_eq!(q.grep_text(), "");
}
#[test]
fn test_grep_text_path_constraint() {
let q = QueryParser::new(GrepConfig)
.parse("name /src/ value")
.expect("should parse");
assert_eq!(q.grep_text(), "name value");
}
#[test]
fn test_grep_text_negation_constraint() {
let q = QueryParser::new(GrepConfig)
.parse("name !*.rs value")
.expect("should parse");
assert_eq!(q.grep_text(), "name value");
}
#[test]
fn test_grep_text_backslash_escape_stripped() {
// \*.rs should be text with the leading \ removed
let q = QueryParser::new(GrepConfig)
.parse("\\*.rs foo")
.expect("should parse");
assert_eq!(q.grep_text(), "*.rs foo");
let q = QueryParser::new(GrepConfig)
.parse("\\/src/ foo")
.expect("should parse");
assert_eq!(q.grep_text(), "/src/ foo");
let q = QueryParser::new(GrepConfig)
.parse("\\!test foo")
.expect("should parse");
assert_eq!(q.grep_text(), "!test foo");
}
#[test]
fn test_grep_text_question_mark_is_text() {
let q = QueryParser::new(GrepConfig)
.parse("foo? bar")
.expect("should parse");
assert_eq!(q.grep_text(), "foo? bar");
}
#[test]
fn test_grep_text_bracket_is_text() {
let q = QueryParser::new(GrepConfig)
.parse("arr[0] more")
.expect("should parse");
assert_eq!(q.grep_text(), "arr[0] more");
}
#[test]
fn test_grep_text_path_glob_is_constraint() {
let q = QueryParser::new(GrepConfig)
.parse("pattern src/**/*.rs")
.expect("should parse");
assert_eq!(q.grep_text(), "pattern");
}
#[test]
fn test_grep_question_mark_is_text() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
// Single token "foo?" should return None (treated as plain text by caller)
let result = parser.parse("foo?");
@@ -558,7 +682,6 @@ mod tests {
#[test]
fn test_grep_bracket_is_text() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
let result = parser.parse("arr[0] something");
let result = result.expect("Should parse multi-token query");
@@ -568,7 +691,6 @@ mod tests {
#[test]
fn test_grep_path_glob_is_constraint() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
let result = parser
.parse("pattern src/**/*.rs")
@@ -583,7 +705,6 @@ mod tests {
#[test]
fn test_grep_brace_is_constraint() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
let result = parser
.parse("pattern {src,lib}")
@@ -597,7 +718,6 @@ mod tests {
#[test]
fn test_grep_bare_star_is_text() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
// "a*b" contains * but no / or {} — should be text in grep mode
let result = parser.parse("a*b something");
@@ -611,7 +731,6 @@ mod tests {
#[test]
fn test_grep_negated_text() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
let result = parser
.parse("pattern !test")
@@ -631,7 +750,6 @@ mod tests {
#[test]
fn test_grep_negated_path_segment() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
let result = parser
.parse("pattern !/src/")
@@ -651,7 +769,6 @@ mod tests {
#[test]
fn test_grep_negated_extension() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
let result = parser
.parse("pattern !*.rs")
+1 -6
View File
@@ -120,12 +120,7 @@ impl Searcher {
/// Execute a search over the given slice and write the results to the
/// given sink.
pub fn search_slice<M, S>(
&mut self,
matcher: M,
slice: &[u8],
write_to: S,
) -> Result<(), S::Error>
pub fn search_slice<M, S>(&self, matcher: M, slice: &[u8], write_to: S) -> Result<(), S::Error>
where
M: Matcher,
S: Sink,
+7 -4
View File
@@ -1,4 +1,4 @@
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 February 17
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 February 19
==============================================================================
Table of Contents *fff.nvim-table-of-contents*
@@ -178,13 +178,16 @@ all available options:
preview_scroll_up = '<C-u>',
preview_scroll_down = '<C-d>',
toggle_debug = '<F2>',
-- grep mode: cycle between plain text, regex, and fuzzy search
toggle_grep_regex = '<S-Tab>',
-- goes to the previous query in history
cycle_previous_query = '<C-Up>',
-- multi-select keymaps for quickfix
toggle_select = '<Tab>',
send_to_quickfix = '<C-q>',
-- grep mode: cycle between plain text, regex, and fuzzy search
toggle_grep_regex = '<S-Tab>',
-- this are specific for the normal mode (you can exit it using any other keybind like jj)
focus_list = '<leader>l',
focus_preview = '<leader>p',
},
hl = {
border = 'FloatBorder',
@@ -260,7 +263,7 @@ all available options:
-- Live grep search configuration
grep = {
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
max_matches_per_file = 200, -- Maximum matches per file
max_matches_per_file = 100, -- Maximum matches per file (set 0 to unlimited)
smart_case = true, -- Case-insensitive unless query has uppercase
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
+1 -1
View File
@@ -219,7 +219,7 @@ local function init()
},
grep = {
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
max_matches_per_file = 200, -- Maximum matches per file
max_matches_per_file = 100, -- Maximum matches per file
smart_case = true, -- Case-insensitive unless query has uppercase
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
+11 -1
View File
@@ -241,6 +241,13 @@ end
ensure_content_loaded_async = function(target_line)
if not M.state.bufnr or not vim.api.nvim_buf_is_valid(M.state.bufnr) then return end
if not M.state.has_more_content or M.state.is_loading then return end
-- Guard against missing file handle: without it load_next_chunk_async returns
-- synchronously with empty data, which triggers apply_location_highlighting
-- -> ensure_content_loaded_async again, causing infinite recursion (stack overflow).
if not M.state.file_operation then
M.state.has_more_content = false
return
end
local current_buffer_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
local buffer_needed = target_line + 50
@@ -278,7 +285,10 @@ ensure_content_loaded_async = function(target_line)
M.apply_location_highlighting(M.state.bufnr)
end
else
-- EOF with no additional data — apply highlighting with whatever we have
-- EOF with no additional data — mark loading as finished to prevent
-- apply_location_highlighting -> ensure_content_loaded_async recursion,
-- then apply highlighting with whatever content we have.
M.state.has_more_content = false
M.apply_location_highlighting(M.state.bufnr)
end
end)
+2 -1
View File
@@ -2129,7 +2129,8 @@ function M.send_to_quickfix()
else
-- No selections: run an exhaustive search to get all matches
local grep = require('fff.grep')
local exhaustive = grep.search(M.state.query, 0, 10000, M.state.grep_config, M.state.grep_mode)
local exhaustive_config = vim.tbl_extend('force', M.state.grep_config or {}, { max_matches_per_file = 0 })
local exhaustive = grep.search(M.state.query, 0, 10000, exhaustive_config, M.state.grep_mode)
local all_items = exhaustive and exhaustive.items or {}
if #all_items == 0 then
+1 -1
View File
@@ -120,7 +120,7 @@ Search file contents with SIMD-accelerated matching.
```typescript
interface GrepOptions {
maxFileSize?: number; // Max file size in bytes (default: 10MB)
maxMatchesPerFile?: number; // Max matches per file (default: 200)
maxMatchesPerFile?: number; // Max matches per file (default: 200, set 0 to unlimited)
smartCase?: boolean; // Case-insensitive if all lowercase (default: true)
fileOffset?: number; // Pagination offset (default: 0)
pageLimit?: number; // Max matches to return (default: 50)
+11 -9
View File
@@ -36,7 +36,7 @@ function formatGitStatus(status: string): string {
return `${RED}D${RESET}`;
case "renamed":
return `${BLUE}R${RESET}`;
case "clear":
case "clean":
case "current":
return `${DIM} ${RESET}`;
default:
@@ -111,22 +111,24 @@ async function main() {
}
console.log(`${DIM}Initializing index for: ${directory}${RESET}`);
const initResult = FileFinder.init({
const createResult = FileFinder.create({
basePath: directory,
warmupMmapCache: true,
});
if (!initResult.ok) {
console.error(`${RED}Init failed: ${initResult.error}${RESET}`);
if (!createResult.ok) {
console.error(`${RED}Init failed: ${createResult.error}${RESET}`);
process.exit(1);
}
const finder = createResult.value;
// Wait for scan
process.stdout.write(`${DIM}Scanning files...${RESET}`);
const startTime = Date.now();
while (FileFinder.isScanning()) {
const progress = FileFinder.getScanProgress();
while (finder.isScanning()) {
const progress = finder.getScanProgress();
if (progress.ok) {
process.stdout.write(
`\r${DIM}Scanning files... ${progress.value.scannedFilesCount}${RESET} `
@@ -136,7 +138,7 @@ async function main() {
}
const scanTime = Date.now() - startTime;
const finalProgress = FileFinder.getScanProgress();
const finalProgress = finder.getScanProgress();
const totalFiles = finalProgress.ok
? finalProgress.value.scannedFilesCount
: 0;
@@ -166,7 +168,7 @@ async function main() {
rl.question(`${CYAN}grep[${modeLabel}]>${RESET} `, (query) => {
if (query.toLowerCase() === "q" || query.toLowerCase() === "quit") {
console.log(`\n${DIM}Goodbye!${RESET}`);
FileFinder.destroy();
finder.destroy();
rl.close();
process.exit(0);
}
@@ -196,7 +198,7 @@ async function main() {
}
const searchStart = Date.now();
const result = FileFinder.liveGrep(query, {
const result = finder.liveGrep(query, {
mode: currentMode,
pageLimit: 30,
timeBudgetMs: 5000,
+13 -11
View File
@@ -35,7 +35,7 @@ function formatGitStatus(status: string): string {
return `${RED}D${RESET}`;
case "renamed":
return `${BLUE}R${RESET}`;
case "clear":
case "clean":
case "current":
return `${DIM} ${RESET}`;
default:
@@ -84,24 +84,26 @@ async function main() {
process.exit(1);
}
// Initialize
// Create instance
console.log(`${DIM}Initializing index for: ${targetDir}${RESET}`);
const initResult = FileFinder.init({
const createResult = FileFinder.create({
basePath: targetDir,
});
if (!initResult.ok) {
console.error(`${RED}Init failed: ${initResult.error}${RESET}`);
if (!createResult.ok) {
console.error(`${RED}Init failed: ${createResult.error}${RESET}`);
process.exit(1);
}
const finder = createResult.value;
// Wait for scan with progress
process.stdout.write(`${DIM}Scanning files...${RESET}`);
const startTime = Date.now();
let lastCount = 0;
while (FileFinder.isScanning()) {
const progress = FileFinder.getScanProgress();
while (finder.isScanning()) {
const progress = finder.getScanProgress();
if (progress.ok && progress.value.scannedFilesCount !== lastCount) {
lastCount = progress.value.scannedFilesCount;
process.stdout.write(`\r${DIM}Scanning files... ${lastCount}${RESET} `);
@@ -110,13 +112,13 @@ async function main() {
}
const scanTime = Date.now() - startTime;
const finalProgress = FileFinder.getScanProgress();
const finalProgress = finder.getScanProgress();
const totalFiles = finalProgress.ok ? finalProgress.value.scannedFilesCount : 0;
console.log(`\r${GREEN}${RESET} Indexed ${BOLD}${totalFiles}${RESET} files in ${scanTime}ms\n`);
// Show index info
const health = FileFinder.healthCheck();
const health = finder.healthCheck();
if (health.ok) {
console.log(`${DIM}Version:${RESET} ${health.value.version}`);
console.log(`${DIM}Base path:${RESET} ${health.value.filePicker.basePath}`);
@@ -137,13 +139,13 @@ async function main() {
rl.question(`${CYAN}search>${RESET} `, (query) => {
if (query.toLowerCase() === "q" || query.toLowerCase() === "quit") {
console.log(`\n${DIM}Goodbye!${RESET}`);
FileFinder.destroy();
finder.destroy();
rl.close();
process.exit(0);
}
const searchStart = Date.now();
const result = FileFinder.search(query, { pageSize: 15 });
const result = finder.search(query, { pageSize: 15 });
const searchTime = Date.now() - searchStart;
if (!result.ok) {
+34 -12
View File
@@ -12,7 +12,12 @@ import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
import { getTriple, getLibExtension, getLibFilename, getNpmPackageName } from "./platform";
import {
getTriple,
getLibExtension,
getLibFilename,
getNpmPackageName,
} from "./platform";
const GITHUB_REPO = "dmtrKovalenko/fff.nvim";
@@ -107,13 +112,30 @@ export function getDevBinaryPath(): string | null {
return null;
}
/**
* Find the binary, checking all known locations in priority order:
* 1. Platform-specific npm package (primary distribution method)
* 2. Local bin/ directory (legacy postinstall download)
* 3. Local dev build (cargo build output)
*/
function isDevWorkspace(): boolean {
const packageDir = getPackageDir();
const workspaceRoot = join(packageDir, "..", "..");
return existsSync(join(workspaceRoot, "Cargo.toml"));
}
export function findBinary(): string | null {
if (isDevWorkspace()) {
// 1. Local bin/ directory (populated by `make prepare-bun`)
const installedPath = getBinaryPath();
if (existsSync(installedPath)) return installedPath;
// 2. Local dev build (target/release or target/debug)
const devPath = getDevBinaryPath();
if (devPath) return devPath;
// 3. Fallback to npm package
const npmPath = resolveFromNpmPackage();
if (npmPath) return npmPath;
return null;
}
// Production: npm package first
// 1. Try platform-specific npm package first
const npmPath = resolveFromNpmPackage();
if (npmPath) return npmPath;
@@ -182,10 +204,10 @@ export async function downloadBinary(tag?: string): Promise<string> {
*/
async function fetchLatestReleaseTag(): Promise<string> {
const url = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`;
const response = await fetch(url, {
headers: {
"Accept": "application/vnd.github.v3+json",
Accept: "application/vnd.github.v3+json",
"User-Agent": "fff-bun-client",
},
});
@@ -194,7 +216,7 @@ async function fetchLatestReleaseTag(): Promise<string> {
const allReleasesUrl = `https://api.github.com/repos/${GITHUB_REPO}/releases`;
const allResponse = await fetch(allReleasesUrl, {
headers: {
"Accept": "application/vnd.github.v3+json",
Accept: "application/vnd.github.v3+json",
"User-Agent": "fff-bun-client",
},
});
@@ -203,7 +225,7 @@ async function fetchLatestReleaseTag(): Promise<string> {
throw new Error(`Failed to fetch releases: ${allResponse.status}`);
}
const releases = await allResponse.json() as Array<{ tag_name: string }>;
const releases = (await allResponse.json()) as Array<{ tag_name: string }>;
if (releases.length === 0) {
throw new Error("No releases found");
}
@@ -211,7 +233,7 @@ async function fetchLatestReleaseTag(): Promise<string> {
return releases[0].tag_name;
}
const release = await response.json() as { tag_name: string };
const release = (await response.json()) as { tag_name: string };
return release.tag_name;
}
+148 -72
View File
@@ -3,6 +3,9 @@
*
* This module uses Bun's native FFI to call into the Rust C library.
* All functions follow the Result pattern for error handling.
*
* The API is instance-based: `ffiCreate` returns an opaque handle that must
* be passed to all subsequent calls and freed with `ffiDestroy`.
*/
import { dlopen, FFIType, ptr, CString, read, type Pointer } from "bun:ffi";
@@ -13,74 +16,74 @@ import { err } from "./types";
// Define the FFI symbols
const ffiDefinition = {
// Lifecycle
fff_init: {
fff_create: {
args: [FFIType.cstring],
returns: FFIType.ptr,
},
fff_destroy: {
args: [],
returns: FFIType.ptr,
args: [FFIType.ptr],
returns: FFIType.void,
},
// Search
fff_search: {
args: [FFIType.cstring, FFIType.cstring],
args: [FFIType.ptr, FFIType.cstring, FFIType.cstring],
returns: FFIType.ptr,
},
// Live grep (content search)
fff_live_grep: {
args: [FFIType.cstring, FFIType.cstring],
args: [FFIType.ptr, FFIType.cstring, FFIType.cstring],
returns: FFIType.ptr,
},
// File index
fff_scan_files: {
args: [],
args: [FFIType.ptr],
returns: FFIType.ptr,
},
fff_is_scanning: {
args: [],
args: [FFIType.ptr],
returns: FFIType.bool,
},
fff_get_scan_progress: {
args: [],
args: [FFIType.ptr],
returns: FFIType.ptr,
},
fff_wait_for_scan: {
args: [FFIType.u64],
args: [FFIType.ptr, FFIType.u64],
returns: FFIType.ptr,
},
fff_restart_index: {
args: [FFIType.cstring],
args: [FFIType.ptr, FFIType.cstring],
returns: FFIType.ptr,
},
// Frecency
fff_track_access: {
args: [FFIType.cstring],
args: [FFIType.ptr, FFIType.cstring],
returns: FFIType.ptr,
},
// Git
fff_refresh_git_status: {
args: [],
args: [FFIType.ptr],
returns: FFIType.ptr,
},
// Query tracking
fff_track_query: {
args: [FFIType.cstring, FFIType.cstring],
args: [FFIType.ptr, FFIType.cstring, FFIType.cstring],
returns: FFIType.ptr,
},
fff_get_historical_query: {
args: [FFIType.u64],
args: [FFIType.ptr, FFIType.u64],
returns: FFIType.ptr,
},
// Utilities
fff_health_check: {
args: [FFIType.cstring],
args: [FFIType.ptr, FFIType.cstring],
returns: FFIType.ptr,
},
@@ -154,19 +157,22 @@ function snakeToCamel(obj: unknown): unknown {
}
/**
* Parse a FffResult from the FFI return value
* The result is a pointer to a struct: { success: bool, data: *char, error: *char }
* Parse a FffResult from the FFI return value.
*
* The result is a pointer to a struct:
* { success: bool, data: *char, error: *char, handle: *void }
*
* Layout (with alignment padding):
* offset 0: success (bool, 1 byte + 7 padding)
* offset 8: data pointer (8 bytes)
* offset 16: error pointer (8 bytes)
* offset 24: handle pointer (8 bytes)
*/
function parseResult<T>(resultPtr: Pointer | null): Result<T> {
if (resultPtr === null) {
return err("FFI returned null pointer");
}
// Read the struct fields
// FffResult layout: bool (1 byte + 7 padding) + pointer (8 bytes) + pointer (8 bytes)
// offset 0: success (bool, 1 byte)
// offset 8: data pointer (8 bytes)
// offset 16: error pointer (8 bytes)
const success = read.u8(resultPtr, 0) !== 0;
const dataPtr = read.ptr(resultPtr, 8);
const errorPtr = read.ptr(resultPtr, 16);
@@ -200,29 +206,63 @@ function parseResult<T>(resultPtr: Pointer | null): Result<T> {
}
/**
* Initialize the file finder
* Opaque native handle type. Callers must not inspect or modify this value.
*/
export function ffiInit(optsJson: string): Result<void> {
export type NativeHandle = Pointer;
/**
* Create a new file finder instance.
*
* Returns the opaque native handle on success. The handle must be passed to
* all subsequent FFI calls and freed with `ffiDestroy`.
*/
export function ffiCreate(optsJson: string): Result<NativeHandle> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_init(ptr(encodeString(optsJson)));
return parseResult<void>(resultPtr);
const resultPtr = library.symbols.fff_create(ptr(encodeString(optsJson)));
if (resultPtr === null) {
return err("FFI returned null pointer");
}
const success = read.u8(resultPtr, 0) !== 0;
const errorPtr = read.ptr(resultPtr, 16);
const handlePtr = read.ptr(resultPtr, 24);
if (success) {
const handle = handlePtr as unknown as Pointer;
library.symbols.fff_free_result(resultPtr);
if (!handle || handle === (0 as unknown as Pointer)) {
return err("fff_create returned null handle");
}
return { ok: true, value: handle };
} else {
const errorMsg = readCString(errorPtr) || "Unknown error";
library.symbols.fff_free_result(resultPtr);
return err(errorMsg);
}
}
/**
* Destroy and clean up resources
* Destroy and clean up an instance.
*/
export function ffiDestroy(): Result<void> {
export function ffiDestroy(handle: NativeHandle): void {
const library = loadLibrary();
const resultPtr = library.symbols.fff_destroy();
return parseResult<void>(resultPtr);
library.symbols.fff_destroy(handle);
}
/**
* Perform fuzzy search
* Perform fuzzy search.
*/
export function ffiSearch(query: string, optsJson: string): Result<unknown> {
export function ffiSearch(
handle: NativeHandle,
query: string,
optsJson: string
): Result<unknown> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_search(
handle,
ptr(encodeString(query)),
ptr(encodeString(optsJson))
);
@@ -230,123 +270,159 @@ export function ffiSearch(query: string, optsJson: string): Result<unknown> {
}
/**
* Trigger file scan
* Trigger file scan.
*/
export function ffiScanFiles(): Result<void> {
export function ffiScanFiles(handle: NativeHandle): Result<void> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_scan_files();
const resultPtr = library.symbols.fff_scan_files(handle);
return parseResult<void>(resultPtr);
}
/**
* Check if scanning
* Check if scanning.
*/
export function ffiIsScanning(): boolean {
export function ffiIsScanning(handle: NativeHandle): boolean {
const library = loadLibrary();
return library.symbols.fff_is_scanning() as boolean;
return library.symbols.fff_is_scanning(handle) as boolean;
}
/**
* Get scan progress
* Get scan progress.
*/
export function ffiGetScanProgress(): Result<unknown> {
export function ffiGetScanProgress(handle: NativeHandle): Result<unknown> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_get_scan_progress();
const resultPtr = library.symbols.fff_get_scan_progress(handle);
return parseResult<unknown>(resultPtr);
}
/**
* Wait for scan to complete
* Wait for scan to complete.
*/
export function ffiWaitForScan(timeoutMs: number): Result<boolean> {
export function ffiWaitForScan(
handle: NativeHandle,
timeoutMs: number
): Result<boolean> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_wait_for_scan(BigInt(timeoutMs));
const result = parseResult<string>(resultPtr);
const resultPtr = library.symbols.fff_wait_for_scan(
handle,
BigInt(timeoutMs)
);
const result = parseResult<boolean | string>(resultPtr);
if (!result.ok) return result;
return { ok: true, value: result.value === "true" };
// JSON.parse("true") returns boolean true, but we also handle
// the string case defensively.
return { ok: true, value: result.value === true || result.value === "true" };
}
/**
* Restart index in new path
* Restart index in new path.
*/
export function ffiRestartIndex(newPath: string): Result<void> {
export function ffiRestartIndex(
handle: NativeHandle,
newPath: string
): Result<void> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_restart_index(
handle,
ptr(encodeString(newPath))
);
return parseResult<void>(resultPtr);
}
/**
* Track file access
* Track file access.
*/
export function ffiTrackAccess(filePath: string): Result<boolean> {
export function ffiTrackAccess(
handle: NativeHandle,
filePath: string
): Result<boolean> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_track_access(
handle,
ptr(encodeString(filePath))
);
const result = parseResult<string>(resultPtr);
const result = parseResult<boolean | string>(resultPtr);
if (!result.ok) return result;
return { ok: true, value: result.value === "true" };
return { ok: true, value: result.value === true || result.value === "true" };
}
/**
* Refresh git status
* Refresh git status.
*/
export function ffiRefreshGitStatus(): Result<number> {
export function ffiRefreshGitStatus(handle: NativeHandle): Result<number> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_refresh_git_status();
const result = parseResult<string>(resultPtr);
const resultPtr = library.symbols.fff_refresh_git_status(handle);
const result = parseResult<number | string>(resultPtr);
if (!result.ok) return result;
return { ok: true, value: parseInt(result.value, 10) };
// JSON.parse("3") returns 3 (number), parseInt handles both
return { ok: true, value: typeof result.value === "number" ? result.value : parseInt(result.value, 10) };
}
/**
* Track query completion
* Track query completion.
*/
export function ffiTrackQuery(
handle: NativeHandle,
query: string,
filePath: string
): Result<boolean> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_track_query(
handle,
ptr(encodeString(query)),
ptr(encodeString(filePath))
);
const result = parseResult<string>(resultPtr);
const result = parseResult<boolean | string>(resultPtr);
if (!result.ok) return result;
return { ok: true, value: result.value === "true" };
return { ok: true, value: result.value === true || result.value === "true" };
}
/**
* Get historical query
* Get historical query.
*/
export function ffiGetHistoricalQuery(offset: number): Result<string | null> {
export function ffiGetHistoricalQuery(
handle: NativeHandle,
offset: number
): Result<string | null> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_get_historical_query(BigInt(offset));
const result = parseResult<string>(resultPtr);
const resultPtr = library.symbols.fff_get_historical_query(
handle,
BigInt(offset)
);
const result = parseResult<string | null>(resultPtr);
if (!result.ok) return result;
if (result.value === "null") return { ok: true, value: null };
return result;
if (result.value === null || result.value === "null") return { ok: true, value: null };
return result as Result<string>;
}
/**
* Health check
* Health check.
*
* `handle` can be null for a limited check (version + git only).
*/
export function ffiHealthCheck(testPath: string): Result<unknown> {
export function ffiHealthCheck(
handle: NativeHandle | null,
testPath: string
): Result<unknown> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_health_check(
handle ?? (0 as unknown as Pointer),
ptr(encodeString(testPath))
);
return parseResult<unknown>(resultPtr);
}
/**
* Live grep - search file contents
* Live grep - search file contents.
*/
export function ffiLiveGrep(query: string, optsJson: string): Result<unknown> {
export function ffiLiveGrep(
handle: NativeHandle,
query: string,
optsJson: string
): Result<unknown> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_live_grep(
handle,
ptr(encodeString(query)),
ptr(encodeString(optsJson))
);
@@ -354,7 +430,7 @@ export function ffiLiveGrep(query: string, optsJson: string): Result<unknown> {
}
/**
* Ensure the library is loaded (for preloading)
* Ensure the library is loaded (for preloading).
*/
export async function ensureLoaded(): Promise<void> {
await ensureBinary();
@@ -362,7 +438,7 @@ export async function ensureLoaded(): Promise<void> {
}
/**
* Check if the library is available
* Check if the library is available.
*/
export function isAvailable(): boolean {
try {
+127 -94
View File
@@ -2,11 +2,14 @@
* FileFinder - High-level API for the fff file finder
*
* This class provides a type-safe, ergonomic API for file finding operations.
* Each instance owns an independent native file picker that can be created
* and destroyed independently. Multiple instances can coexist.
*
* All methods return Result types for explicit error handling.
*/
import {
ffiInit,
ffiCreate,
ffiDestroy,
ffiSearch,
ffiLiveGrep,
@@ -22,6 +25,7 @@ import {
ffiHealthCheck,
ensureLoaded,
isAvailable,
type NativeHandle,
} from "./ffi";
import type {
@@ -35,27 +39,36 @@ import type {
GrepResult,
} from "./types";
import { err, toInternalInitOptions, toInternalSearchOptions, toInternalGrepOptions, createGrepCursor } from "./types";
import {
err,
toInternalInitOptions,
toInternalSearchOptions,
toInternalGrepOptions,
createGrepCursor,
} from "./types";
/**
* FileFinder - Fast file finder with fuzzy search
*
* Each instance is backed by an independent native file picker. Create as many
* as you need and destroy them when done.
*
* @example
* ```typescript
* import { FileFinder } from "fff";
*
* // Initialize
* const result = FileFinder.init({ basePath: "/path/to/project" });
* if (!result.ok) {
* console.error(result.error);
* // Create an instance
* const finder = FileFinder.create({ basePath: "/path/to/project" });
* if (!finder.ok) {
* console.error(finder.error);
* process.exit(1);
* }
*
* // Wait for initial scan
* FileFinder.waitForScan(5000);
* finder.value.waitForScan(5000);
*
* // Search for files
* const search = FileFinder.search("main.ts");
* const search = finder.value.search("main.ts");
* if (search.ok) {
* for (const item of search.value.items) {
* console.log(item.relativePath);
@@ -63,57 +76,75 @@ import { err, toInternalInitOptions, toInternalSearchOptions, toInternalGrepOpti
* }
*
* // Cleanup
* FileFinder.destroy();
* finder.value.destroy();
* ```
*/
export class FileFinder {
private static initialized = false;
private handle: NativeHandle | null;
private constructor(handle: NativeHandle) {
this.handle = handle;
}
/**
* Initialize the file finder with the given options.
* Create a new file finder instance.
*
* @param options - Initialization options
* @returns Result indicating success or failure
* @returns Result containing the new FileFinder instance or an error
*
* @example
* ```typescript
* // Basic initialization
* FileFinder.init({ basePath: "/path/to/project" });
* const finder = FileFinder.create({ basePath: "/path/to/project" });
*
* // With custom database paths
* FileFinder.init({
* const finder = FileFinder.create({
* basePath: "/path/to/project",
* frecencyDbPath: "/custom/frecency.mdb",
* historyDbPath: "/custom/history.mdb",
* });
*
* // Minimal mode (no databases - just omit db paths)
* FileFinder.init({ basePath: "/path/to/project" });
* ```
*/
static init(options: InitOptions): Result<void> {
static create(options: InitOptions): Result<FileFinder> {
const internalOpts = toInternalInitOptions(options);
const result = ffiInit(JSON.stringify(internalOpts));
const result = ffiCreate(JSON.stringify(internalOpts));
if (result.ok) {
this.initialized = true;
if (!result.ok) {
return result;
}
return result;
return { ok: true, value: new FileFinder(result.value) };
}
/**
* Destroy and clean up all resources.
*
* Call this when you're done using the file finder to free memory
* and stop background file watching.
* and stop background file watching. After calling this, the instance
* must not be used again.
*/
static destroy(): Result<void> {
const result = ffiDestroy();
if (result.ok) {
this.initialized = false;
destroy(): void {
if (this.handle !== null) {
ffiDestroy(this.handle);
this.handle = null;
}
return result;
}
/**
* Check if this instance has been destroyed.
*/
get isDestroyed(): boolean {
return this.handle === null;
}
/**
* Guard that returns an error if the instance has been destroyed.
*/
private ensureAlive(): Result<NativeHandle> {
if (this.handle === null) {
return err("FileFinder instance has been destroyed.");
}
return { ok: true, value: this.handle };
}
/**
@@ -131,7 +162,7 @@ export class FileFinder {
*
* @example
* ```typescript
* const result = FileFinder.search("main.ts", { pageSize: 10 });
* const result = finder.search("main.ts", { pageSize: 10 });
* if (result.ok) {
* console.log(`Found ${result.value.totalMatched} files`);
* for (const item of result.value.items) {
@@ -140,19 +171,17 @@ export class FileFinder {
* }
* ```
*/
static search(query: string, options?: SearchOptions): Result<SearchResult> {
if (!this.initialized) {
return err("FileFinder not initialized. Call FileFinder.init() first.");
}
search(query: string, options?: SearchOptions): Result<SearchResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
const internalOpts = toInternalSearchOptions(options);
const result = ffiSearch(query, JSON.stringify(internalOpts));
const result = ffiSearch(guard.value, query, JSON.stringify(internalOpts));
if (!result.ok) {
return result;
}
// The FFI returns the search result already parsed
return result as Result<SearchResult>;
}
@@ -178,27 +207,30 @@ export class FileFinder {
* @example
* ```typescript
* // First page
* const result = FileFinder.liveGrep("TODO", { mode: "plain", pageLimit: 20 });
* const result = finder.liveGrep("TODO", { mode: "plain" });
* if (result.ok) {
* for (const match of result.value.items) {
* console.log(`${match.relativePath}:${match.lineNumber}: ${match.lineContent}`);
* }
* // Fetch next page
* if (result.value.nextCursor) {
* const page2 = FileFinder.liveGrep("TODO", {
* const page2 = finder.liveGrep("TODO", {
* cursor: result.value.nextCursor,
* });
* }
* }
* ```
*/
static liveGrep(query: string, options?: GrepOptions): Result<GrepResult> {
if (!this.initialized) {
return err("FileFinder not initialized. Call FileFinder.init() first.");
}
liveGrep(query: string, options?: GrepOptions): Result<GrepResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
const internalOpts = toInternalGrepOptions(options);
const result = ffiLiveGrep(query, JSON.stringify(internalOpts));
const result = ffiLiveGrep(
guard.value,
query,
JSON.stringify(internalOpts)
);
if (!result.ok) {
return result;
@@ -227,29 +259,27 @@ export class FileFinder {
* This is useful after major file system changes that the
* background watcher might have missed.
*/
static scanFiles(): Result<void> {
if (!this.initialized) {
return err("FileFinder not initialized. Call FileFinder.init() first.");
}
return ffiScanFiles();
scanFiles(): Result<void> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiScanFiles(guard.value);
}
/**
* Check if a scan is currently in progress.
*/
static isScanning(): boolean {
if (!this.initialized) return false;
return ffiIsScanning();
isScanning(): boolean {
if (this.handle === null) return false;
return ffiIsScanning(this.handle);
}
/**
* Get the current scan progress.
*/
static getScanProgress(): Result<ScanProgress> {
if (!this.initialized) {
return err("FileFinder not initialized. Call FileFinder.init() first.");
}
return ffiGetScanProgress() as Result<ScanProgress>;
getScanProgress(): Result<ScanProgress> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiGetScanProgress(guard.value) as Result<ScanProgress>;
}
/**
@@ -260,18 +290,19 @@ export class FileFinder {
*
* @example
* ```typescript
* FileFinder.init({ basePath: "/path/to/project" });
* const completed = FileFinder.waitForScan(10000);
* if (!completed.ok || !completed.value) {
* console.warn("Scan did not complete in time");
* const finder = FileFinder.create({ basePath: "/path/to/project" });
* if (finder.ok) {
* const completed = finder.value.waitForScan(10000);
* if (!completed.ok || !completed.value) {
* console.warn("Scan did not complete in time");
* }
* }
* ```
*/
static waitForScan(timeoutMs: number = 5000): Result<boolean> {
if (!this.initialized) {
return err("FileFinder not initialized. Call FileFinder.init() first.");
}
return ffiWaitForScan(timeoutMs);
waitForScan(timeoutMs: number = 5000): Result<boolean> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiWaitForScan(guard.value, timeoutMs);
}
/**
@@ -281,11 +312,10 @@ export class FileFinder {
*
* @param newPath - New directory path to index
*/
static reindex(newPath: string): Result<void> {
if (!this.initialized) {
return err("FileFinder not initialized. Call FileFinder.init() first.");
}
return ffiRestartIndex(newPath);
reindex(newPath: string): Result<void> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiRestartIndex(guard.value, newPath);
}
/**
@@ -295,11 +325,10 @@ export class FileFinder {
*
* @param filePath - Absolute path to the accessed file
*/
static trackAccess(filePath: string): Result<boolean> {
if (!this.initialized) {
return { ok: true, value: false };
}
return ffiTrackAccess(filePath);
trackAccess(filePath: string): Result<boolean> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiTrackAccess(guard.value, filePath);
}
/**
@@ -307,11 +336,10 @@ export class FileFinder {
*
* @returns Number of files with updated git status
*/
static refreshGitStatus(): Result<number> {
if (!this.initialized) {
return err("FileFinder not initialized. Call FileFinder.init() first.");
}
return ffiRefreshGitStatus();
refreshGitStatus(): Result<number> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiRefreshGitStatus(guard.value);
}
/**
@@ -323,11 +351,10 @@ export class FileFinder {
* @param query - The search query that was used
* @param selectedFilePath - The file path that was selected
*/
static trackQuery(query: string, selectedFilePath: string): Result<boolean> {
if (!this.initialized) {
return { ok: true, value: false };
}
return ffiTrackQuery(query, selectedFilePath);
trackQuery(query: string, selectedFilePath: string): Result<boolean> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiTrackQuery(guard.value, query, selectedFilePath);
}
/**
@@ -336,11 +363,10 @@ export class FileFinder {
* @param offset - Offset from most recent (0 = most recent)
* @returns The historical query string, or null if not found
*/
static getHistoricalQuery(offset: number): Result<string | null> {
if (!this.initialized) {
return { ok: true, value: null };
}
return ffiGetHistoricalQuery(offset);
getHistoricalQuery(offset: number): Result<string | null> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiGetHistoricalQuery(guard.value, offset);
}
/**
@@ -350,8 +376,11 @@ export class FileFinder {
*
* @param testPath - Optional path to test git repository detection
*/
static healthCheck(testPath?: string): Result<HealthCheck> {
return ffiHealthCheck(testPath || "") as Result<HealthCheck>;
healthCheck(testPath?: string): Result<HealthCheck> {
return ffiHealthCheck(
this.handle,
testPath || ""
) as Result<HealthCheck>;
}
/**
@@ -372,9 +401,13 @@ export class FileFinder {
}
/**
* Check if the file finder is initialized.
* Get a health check without requiring an instance.
*
* Returns limited info (version + git only, no picker/frecency/query data).
*
* @param testPath - Optional path to test git repository detection
*/
static isInitialized(): boolean {
return this.initialized;
static healthCheckStatic(testPath?: string): Result<HealthCheck> {
return ffiHealthCheck(null, testPath || "") as Result<HealthCheck>;
}
}
+291
View File
@@ -0,0 +1,291 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { FileFinder } from "./index";
import type { FileItem } from "./types";
import {
mkdtempSync,
writeFileSync,
rmSync,
unlinkSync,
mkdirSync,
realpathSync,
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
/**
* Integration test: full git lifecycle with a real repository.
*
* Creates a temporary git repo, initialises a FileFinder instance pointing at
* it, then walks through:
* 1. Initial scan committed files should have status "clean"
* 2. Add a new untracked file should appear as "untracked"
* 3. Stage the new file should appear as "staged_new"
* 4. Commit should become "clean"
* 5. Modify a tracked file should become "modified"
* 6. Stage the modification should become "staged_modified"
* 7. Commit again back to "clean"
* 8. Delete a file should disappear from the index
*/
const WATCHER_SETTLE_MS = 500; // accompany for the debouncer and replicate real life uasage
function git(cwd: string, ...args: string[]) {
const escaped = args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
execSync(`git ${escaped}`, {
cwd,
stdio: "pipe",
env: {
...process.env,
GIT_AUTHOR_NAME: "test",
GIT_AUTHOR_EMAIL: "test@test.com",
GIT_COMMITTER_NAME: "test",
GIT_COMMITTER_EMAIL: "test@test.com",
},
});
}
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
function findFile(finder: FileFinder, name: string): FileItem | undefined {
const result = finder.search(name, { pageSize: 200 });
if (!result.ok) throw new Error(`search failed: ${result.error}`);
return result.value.items.find((item) => item.fileName === name);
}
describe.skipIf(process.platform === "win32")(
"Git lifecycle integration",
() => {
let tmpDir: string;
let finder: FileFinder;
beforeAll(() => {
// Create temp directory and initialise a git repo with two committed files.
// Use realpathSync to resolve symlinks (macOS /var -> /private/var) so
// that git2's resolved workdir paths match the file picker's base_path.
tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "fff-git-test-")));
git(tmpDir, "init", "-b", "main");
// Need at least one commit for status to work properly
writeFileSync(join(tmpDir, "hello.txt"), "hello world\n");
writeFileSync(join(tmpDir, "readme.md"), "# Test Project\n");
mkdirSync(join(tmpDir, "src"));
writeFileSync(
join(tmpDir, "src", "main.rs"),
'fn main() { println?."hi"); }\n',
);
git(tmpDir, "add", "-A");
git(tmpDir, "commit", "-m", "initial commit");
// Create the FileFinder instance
const result = FileFinder.create({ basePath: tmpDir });
expect(result.ok).toBe(true);
if (!result.ok) throw new Error(result.error);
finder = result.value;
// Wait for the initial scan to finish
const scanResult = finder.waitForScan(10_000);
expect(scanResult.ok).toBe(true);
});
afterAll(() => {
finder?.destroy();
if (tmpDir) {
rmSync(tmpDir, { recursive: true, force: true });
}
});
test("initial scan indexes all committed files", () => {
const result = finder.search("", { pageSize: 200 });
expect(result.ok).toBe(true);
if (!result.ok) return;
const names = result.value.items.map((i) => i.relativePath).sort();
expect(names).toContain("hello.txt");
expect(names).toContain("readme.md");
expect(names).toContain("src/main.rs");
expect(result.value.totalFiles).toBe(3);
});
test("committed files have clean git status", async () => {
// Wait for background watcher to process initial git status
await sleep(WATCHER_SETTLE_MS);
const hello = findFile(finder, "hello.txt");
expect(hello).toBeDefined();
expect(hello?.gitStatus).toBe("clean");
const main = findFile(finder, "main.rs");
expect(main).toBeDefined();
expect(main?.gitStatus).toBe("clean");
});
test("new untracked file appears with 'untracked' status", async () => {
writeFileSync(join(tmpDir, "new_file.ts"), "export const x = 1;\n");
// Wait for the background watcher to pick up the change and update git status
await sleep(WATCHER_SETTLE_MS);
const newFile = findFile(finder, "new_file.ts");
expect(newFile).toBeDefined();
expect(newFile?.gitStatus).toBe("untracked");
// Total should now be 4
const all = finder.search("", { pageSize: 200 });
expect(all.ok).toBe(true);
if (all.ok) {
expect(all.value.totalFiles).toBe(4);
}
});
test("staging a new file changes status to 'staged_new'", async () => {
git(tmpDir, "add", "new_file.ts");
// Wait for background watcher to detect .git/index change
await sleep(WATCHER_SETTLE_MS);
const newFile = findFile(finder, "new_file.ts");
expect(newFile).toBeDefined();
expect(newFile?.gitStatus).toBe("staged_new");
});
test("committing makes the file 'clean'", async () => {
git(tmpDir, "commit", "-m", "add new_file");
// Wait for background watcher to detect .git changes
await sleep(WATCHER_SETTLE_MS);
const newFile = findFile(finder, "new_file.ts");
expect(newFile).toBeDefined();
expect(newFile?.gitStatus).toBe("clean");
});
test("modifying a tracked file changes status to 'modified'", async () => {
writeFileSync(
join(tmpDir, "hello.txt"),
"hello world\nupdated content\n",
);
// Wait for background watcher to detect file modification and update git status
await sleep(WATCHER_SETTLE_MS);
const hello = findFile(finder, "hello.txt");
expect(hello).toBeDefined();
expect(hello?.gitStatus).toBe("modified");
});
test("staging a modification changes status to 'staged_modified'", async () => {
git(tmpDir, "add", "hello.txt");
// Wait for background watcher to detect .git/index change
await sleep(WATCHER_SETTLE_MS);
const hello = findFile(finder, "hello.txt");
expect(hello).toBeDefined();
expect(hello?.gitStatus).toBe("staged_modified");
});
test("committing the modification returns to 'clean'", async () => {
git(tmpDir, "commit", "-m", "update hello");
// Wait for background watcher to detect .git changes
await sleep(WATCHER_SETTLE_MS);
const hello = findFile(finder, "hello.txt");
expect(hello).toBeDefined();
expect(hello?.gitStatus).toBe("clean");
});
test("deleting a file removes it from the index", async () => {
unlinkSync(join(tmpDir, "new_file.ts"));
await sleep(WATCHER_SETTLE_MS);
const result = finder.search("new_file.ts", { pageSize: 200 });
expect(result.ok).toBe(true);
if (!result.ok) return;
const found = result.value.items.find(
(i) => i.fileName === "new_file.ts",
);
expect(found).toBeUndefined();
// Total should be back to 3
const all = finder.search("", { pageSize: 200 });
expect(all.ok).toBe(true);
if (all.ok) {
expect(all.value.totalFiles).toBe(3);
}
});
test("adding a file in a subdirectory works", async () => {
writeFileSync(join(tmpDir, "src", "utils.rs"), "pub fn helper() {}\n");
// Wait for background watcher to detect new file and update git status
await sleep(WATCHER_SETTLE_MS);
const utils = findFile(finder, "utils.rs");
expect(utils).toBeDefined();
expect(utils?.relativePath).toBe("src/utils.rs");
expect(utils?.gitStatus).toBe("untracked");
});
test("live grep finds content in a newly added file", async () => {
writeFileSync(
join(tmpDir, "src", "searchtarget.rs"),
'const UNIQUE_NEEDLE: &str = "xylophone_waterfall_97";\n',
);
await sleep(WATCHER_SETTLE_MS);
const result = finder.liveGrep("xylophone_waterfall_97", {
mode: "plain",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.totalMatched).toBeGreaterThan(0);
const match = result.value.items.find(
(m) => m.relativePath === "src/searchtarget.rs",
);
expect(match).toBeDefined();
expect(match!.lineContent).toContain("xylophone_waterfall_97");
});
test("live grep no longer finds content after file is deleted", async () => {
unlinkSync(join(tmpDir, "src", "searchtarget.rs"));
await sleep(WATCHER_SETTLE_MS);
const result = finder.liveGrep("xylophone_waterfall_97", {
mode: "plain",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.totalMatched).toBe(0);
expect(result.value.items.length).toBe(0);
});
test("full add-commit cycle for subdirectory file", async () => {
git(tmpDir, "add", "src/utils.rs");
await sleep(WATCHER_SETTLE_MS);
let utils = findFile(finder, "utils.rs");
expect(utils).toBeDefined();
expect(utils?.gitStatus).toBe("staged_new");
git(tmpDir, "commit", "-m", "add utils");
// Wait for background watcher to detect .git changes
await sleep(WATCHER_SETTLE_MS);
utils = findFile(finder, "utils.rs");
expect(utils).toBeDefined();
expect(utils?.gitStatus).toBe("clean");
});
},
);
+168 -41
View File
@@ -3,6 +3,13 @@ import { FileFinder } from "./index";
import { findBinary, getDevBinaryPath } from "./download";
import { getTriple, getLibExtension, getLibFilename } from "./platform";
// Cross-platform path normalization helpers
const normalizePath = (path: string | null | undefined): string | null => {
if (!path) return null;
// Convert backslashes to forward slashes for consistent comparison
return path.replace(/\\/g, "/");
};
const testDir = process.cwd();
describe("Platform Detection", () => {
@@ -42,7 +49,9 @@ describe("Binary Detection", () => {
test("getDevBinaryPath finds local build", () => {
const devPath = getDevBinaryPath();
expect(devPath).not.toBeNull();
expect(devPath).toContain("target/release");
// Normalize path for cross-platform comparison (Windows uses backslashes)
const normalizedPath = normalizePath(devPath);
expect(normalizedPath).toContain("target/release");
});
test("findBinary returns a path", () => {
@@ -52,11 +61,8 @@ describe("Binary Detection", () => {
});
describe("FileFinder - Health Check", () => {
test("healthCheck works before initialization", () => {
// Make sure we start fresh
FileFinder.destroy();
const result = FileFinder.healthCheck();
test("healthCheckStatic works without an instance", () => {
const result = FileFinder.healthCheckStatic();
expect(result.ok).toBe(true);
if (result.ok) {
@@ -68,31 +74,32 @@ describe("FileFinder - Health Check", () => {
});
describe("FileFinder - Full Lifecycle", () => {
// Single beforeAll/afterAll for the entire test suite to avoid repeated init/destroy
let finder: FileFinder;
beforeAll(() => {
FileFinder.destroy(); // Clean any previous state
const result = FileFinder.create({ basePath: testDir });
expect(result.ok).toBe(true);
if (result.ok) {
finder = result.value;
}
});
afterAll(() => {
FileFinder.destroy();
finder?.destroy();
});
test("init succeeds with valid path", () => {
const result = FileFinder.init({
basePath: testDir,
});
expect(result.ok).toBe(true);
expect(FileFinder.isInitialized()).toBe(true);
test("create succeeds with valid path", () => {
expect(finder).toBeDefined();
expect(finder.isDestroyed).toBe(false);
});
test("isScanning returns a boolean", () => {
const scanning = FileFinder.isScanning();
const scanning = finder.isScanning();
expect(typeof scanning).toBe("boolean");
});
test("getScanProgress returns valid data", () => {
const result = FileFinder.getScanProgress();
const result = finder.getScanProgress();
expect(result.ok).toBe(true);
if (result.ok) {
@@ -103,22 +110,37 @@ describe("FileFinder - Full Lifecycle", () => {
test("waitForScan completes", () => {
// Small timeout - scan should be fast or already done
const result = FileFinder.waitForScan(500);
const result = finder.waitForScan(500);
expect(result.ok).toBe(true);
});
test("search with empty query returns all files", () => {
const result = FileFinder.search("");
// First check scan progress to see if files were indexed
const progress = finder.getScanProgress();
if (progress.ok) {
}
const result = finder.search("");
expect(result.ok).toBe(true);
if (result.ok) {
if (result.value.items.length > 0) {
// Log first few paths to see format on Windows
// Items are strings (file paths), not objects
const samplePaths = result.value.items
.slice(0, 3)
.map((item) =>
normalizePath(typeof item === "string" ? item : item.relativePath),
);
}
// Empty query should return files (frecency-sorted)
expect(result.value.totalFiles).toBeGreaterThan(0);
} else {
}
});
test("search returns a valid result structure", () => {
const result = FileFinder.search("Cargo.toml");
const result = finder.search("Cargo.toml");
expect(result.ok).toBe(true);
if (result.ok) {
@@ -130,7 +152,7 @@ describe("FileFinder - Full Lifecycle", () => {
});
test("search returns empty for non-matching query", () => {
const result = FileFinder.search("xyznonexistentfilenamexyz123456");
const result = finder.search("xyznonexistentfilenamexyz123456");
expect(result.ok).toBe(true);
if (result.ok) {
@@ -140,7 +162,7 @@ describe("FileFinder - Full Lifecycle", () => {
});
test("search respects pageSize option", () => {
const result = FileFinder.search("ts", { pageSize: 3 });
const result = finder.search("ts", { pageSize: 3 });
expect(result.ok).toBe(true);
if (result.ok) {
@@ -148,19 +170,83 @@ describe("FileFinder - Full Lifecycle", () => {
}
});
test("liveGrep plain text returns matching lines", () => {
const result = finder.liveGrep("fff-core", {
mode: "plain",
});
expect(result.ok).toBe(true);
if (result.ok) {
if (result.value.items.length > 0) {
// Log sample match to verify content on Windows
const first = result.value.items[0];
const normalizedPath = normalizePath(first.relativePath);
}
expect(result.value.totalMatched).toBeGreaterThan(0);
expect(result.value.items.length).toBeGreaterThan(0);
const first = result.value.items[0];
expect(typeof first.relativePath).toBe("string");
// Normalize path for cross-platform validation
const normalizedFirstPath = normalizePath(first.relativePath);
expect(normalizedFirstPath).toBeTruthy();
expect(typeof first.lineNumber).toBe("number");
expect(first.lineNumber).toBeGreaterThan(0);
expect(typeof first.lineContent).toBe("string");
expect(first.lineContent.toLowerCase()).toContain("fff-core");
expect(Array.isArray(first.matchRanges)).toBe(true);
expect(first.matchRanges.length).toBeGreaterThan(0);
expect(typeof result.value.totalFilesSearched).toBe("number");
expect(typeof result.value.totalFiles).toBe("number");
expect(typeof result.value.filteredFileCount).toBe("number");
} else {
}
});
test("liveGrep fuzzy mode returns results with scores", () => {
// Intentional typo: "depdnency" instead of "dependency" to exercise fuzzy matching
const result = finder.liveGrep("depdnency", {
mode: "fuzzy",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.totalMatched).toBeGreaterThan(0);
expect(result.value.items.length).toBeGreaterThan(0);
const first = result.value.items[0];
expect(typeof first.relativePath).toBe("string");
// Normalize path for cross-platform validation
const normalizedFirstPath = normalizePath(first.relativePath);
expect(normalizedFirstPath).toBeTruthy();
expect(typeof first.lineNumber).toBe("number");
expect(typeof first.lineContent).toBe("string");
// Fuzzy mode should produce a fuzzyScore on each match
expect(typeof first.fuzzyScore).toBe("number");
}
});
test("healthCheck shows initialized state", () => {
const result = FileFinder.healthCheck();
const result = finder.healthCheck();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.filePicker.initialized).toBe(true);
expect(result.value.filePicker.basePath).toBeDefined();
// Normalize basePath for cross-platform comparison
const normalizedBasePath = normalizePath(
result.value.filePicker.basePath || "",
);
const normalizedTestDir = normalizePath(testDir);
expect(normalizedBasePath).toBe(normalizedTestDir);
expect(typeof result.value.filePicker.indexedFiles).toBe("number");
}
});
test("healthCheck detects git repository", () => {
const result = FileFinder.healthCheck(testDir);
const result = finder.healthCheck(testDir);
expect(result.ok).toBe(true);
if (result.ok) {
@@ -169,37 +255,78 @@ describe("FileFinder - Full Lifecycle", () => {
}
});
test("destroy and re-init works", () => {
FileFinder.destroy();
expect(FileFinder.isInitialized()).toBe(false);
test("destroy and re-create works", () => {
finder.destroy();
expect(finder.isDestroyed).toBe(true);
const result = FileFinder.init({
basePath: testDir,
});
const result = FileFinder.create({ basePath: testDir });
expect(result.ok).toBe(true);
expect(FileFinder.isInitialized()).toBe(true);
if (result.ok) {
finder = result.value;
}
expect(finder.isDestroyed).toBe(false);
});
test("multiple instances can coexist", () => {
const result2 = FileFinder.create({ basePath: testDir });
expect(result2.ok).toBe(true);
if (result2.ok) {
const finder2 = result2.value;
// Both should work independently
const search1 = finder.search("Cargo");
const search2 = finder2.search("Cargo");
expect(search1.ok).toBe(true);
expect(search2.ok).toBe(true);
// Destroying one should not affect the other
finder2.destroy();
const search3 = finder.search("Cargo");
expect(search3.ok).toBe(true);
}
});
});
describe("FileFinder - Error Handling", () => {
test("search fails when not initialized", () => {
FileFinder.destroy();
test("search fails on destroyed instance", () => {
const createResult = FileFinder.create({ basePath: testDir });
expect(createResult.ok).toBe(true);
if (!createResult.ok) return;
const result = FileFinder.search("test");
const f = createResult.value;
f.destroy();
const result = f.search("test");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("not initialized");
expect(result.error).toContain("destroyed");
}
});
test("getScanProgress fails when not initialized", () => {
const result = FileFinder.getScanProgress();
test("getScanProgress fails on destroyed instance", () => {
const createResult = FileFinder.create({ basePath: testDir });
expect(createResult.ok).toBe(true);
if (!createResult.ok) return;
const f = createResult.value;
f.destroy();
const result = f.getScanProgress();
expect(result.ok).toBe(false);
});
test("init fails with invalid path", () => {
const result = FileFinder.init({
basePath: "/nonexistent/path/that/does/not/exist",
test("create fails with invalid path", () => {
// Use a cross-platform invalid path
const invalidPath =
process.platform === "win32"
? "C:\\nonexistent\\path\\that\\does\\not\\exist"
: "/nonexistent/path/that/does/not/exist";
const result = FileFinder.create({
basePath: invalidPath,
});
expect(result.ok).toBe(false);
+16 -7
View File
@@ -4,22 +4,26 @@
* High-performance fuzzy file finder for Bun, powered by Rust.
* Perfect for LLM agent tools that need to search through codebases.
*
* Each `FileFinder` instance is backed by an independent native file picker.
* Create as many as you need and destroy them when done.
*
* @example
* ```typescript
* import { FileFinder } from "fff";
*
* // Initialize with a directory
* const result = FileFinder.init({ basePath: "/path/to/project" });
* // Create a file finder instance
* const result = FileFinder.create({ basePath: "/path/to/project" });
* if (!result.ok) {
* console.error(result.error);
* process.exit(1);
* }
* const finder = result.value;
*
* // Wait for initial scan
* FileFinder.waitForScan(5000);
* finder.waitForScan(5000);
*
* // Search for files
* const search = FileFinder.search("main.ts");
* const search = finder.search("main.ts");
* if (search.ok) {
* for (const item of search.value.items) {
* console.log(item.relativePath);
@@ -27,10 +31,10 @@
* }
*
* // Track file access (for frecency)
* FileFinder.trackAccess("/path/to/project/src/main.ts");
* finder.trackAccess("/path/to/project/src/main.ts");
*
* // Cleanup when done
* FileFinder.destroy();
* finder.destroy();
* ```
*
* @packageDocumentation
@@ -71,4 +75,9 @@ export {
} from "./download";
// Platform utilities
export { getTriple, getLibExtension, getLibFilename, getNpmPackageName } from "./platform";
export {
getTriple,
getLibExtension,
getLibFilename,
getNpmPackageName,
} from "./platform";
+7 -33
View File
@@ -1,9 +1,7 @@
/**
* Result type for all operations - follows the Result pattern
*/
export type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
export type Result<T> = { ok: true; value: T } | { ok: false; error: string };
/**
* Helper to create a successful result
@@ -252,7 +250,7 @@ export function toInternalInitOptions(opts: InitOptions): InitOptionsInternal {
* @internal
*/
export function toInternalSearchOptions(
opts?: SearchOptions
opts?: SearchOptions,
): SearchOptionsInternal {
return {
max_threads: opts?.maxThreads,
@@ -264,9 +262,6 @@ export function toInternalSearchOptions(
};
}
// ============================================================================
// Grep (live content search) types
// ============================================================================
/**
* Grep search mode
@@ -296,9 +291,7 @@ export function createGrepCursor(offset: number): GrepCursor {
* Options for live grep (content search)
*
* Files are searched sequentially in frecency order (most recently/frequently
* accessed first). The engine collects matching lines across files until
* `pageLimit` total matches are reached, then stops and returns a
* `nextCursor` for fetching the next page.
* accessed first). The engine returns a `nextCursor` for fetching the next page.
*/
export interface GrepOptions {
/** Maximum file size to search in bytes. Files larger than this are skipped. (default: 10MB) */
@@ -312,26 +305,11 @@ export interface GrepOptions {
* Omit (or pass `null`) for the first page.
*/
cursor?: GrepCursor | null;
/**
* Maximum total number of matching lines to return across all files.
* The engine walks files in frecency order, accumulating matches until this
* limit is reached, then truncates and stops.
*
* Pagination is file-based, not match-based: if a single file produces more
* matches than the remaining capacity, the excess matches from that file are
* dropped and the next page resumes from the *next* file. This means some
* matches at the boundary may be skipped, but it guarantees no duplicates
* across pages and requires no server-side cursor state.
*
* Use `nextCursor` from the result to fetch the next page. (default: 50)
*/
pageLimit?: number;
/** Search mode (default: "plain") */
mode?: GrepMode;
/**
* Maximum wall-clock time in milliseconds to spend searching before returning
* partial results. The engine will still return at least `pageLimit / 2` matches
* (if available) before honoring the budget. 0 = unlimited. (default: 0)
* partial results. 0 = unlimited. (default: 0)
*/
timeBudgetMs?: number;
}
@@ -378,9 +356,9 @@ export interface GrepMatch {
* Result from a grep search
*/
export interface GrepResult {
/** Matched items with file and line information. At most `pageLimit` entries. */
/** Matched items with file and line information. At most `max_matches_per_file`. */
items: GrepMatch[];
/** Total number of matches collected (equal to items.length unless truncated by pageLimit) */
/** Total number of matches collected (always equal to items.length). */
totalMatched: number;
/** Number of files actually opened and searched in this call */
totalFilesSearched: number;
@@ -406,7 +384,6 @@ export interface GrepOptionsInternal {
max_matches_per_file?: number;
smart_case?: boolean;
file_offset?: number;
page_limit?: number;
mode?: string;
time_budget_ms?: number;
}
@@ -415,15 +392,12 @@ export interface GrepOptionsInternal {
* Convert public GrepOptions to internal format
* @internal
*/
export function toInternalGrepOptions(
opts?: GrepOptions
): GrepOptionsInternal {
export function toInternalGrepOptions(opts?: GrepOptions): GrepOptionsInternal {
return {
max_file_size: opts?.maxFileSize,
max_matches_per_file: opts?.maxMatchesPerFile,
smart_case: opts?.smartCase,
file_offset: opts?.cursor?._offset ?? 0,
page_limit: opts?.pageLimit,
mode: opts?.mode,
time_budget_ms: opts?.timeBudgetMs,
};
+43 -21
View File
@@ -15,9 +15,9 @@ async function main() {
process.exit(1);
}
// Health check (before init)
console.log("Health check (before init):");
const healthBefore = FileFinder.healthCheck();
// Health check (before creating instance)
console.log("Health check (no instance):");
const healthBefore = FileFinder.healthCheckStatic();
if (healthBefore.ok) {
console.log(` Version: ${healthBefore.value.version}`);
console.log(` Git available: ${healthBefore.value.git.available}`);
@@ -29,25 +29,27 @@ async function main() {
// Initialize with the root project directory to test on more files
const testDir = resolve(dirname(import.meta.path), "../..");
console.log(`Initializing with base path: ${testDir}`);
console.log(`Creating instance with base path: ${testDir}`);
const initResult = FileFinder.init({
const createResult = FileFinder.create({
basePath: testDir,
});
if (!initResult.ok) {
console.error(`Init failed: ${initResult.error}`);
if (!createResult.ok) {
console.error(`Create failed: ${createResult.error}`);
process.exit(1);
}
console.log("Initialization successful!\n");
const finder = createResult.value;
console.log("Instance created successfully!\n");
// Wait for scan with polling to show progress
console.log("Waiting for initial scan...");
const startTime = Date.now();
let lastCount = 0;
while (FileFinder.isScanning()) {
const progress = FileFinder.getScanProgress();
while (finder.isScanning()) {
const progress = finder.getScanProgress();
if (progress.ok && progress.value.scannedFilesCount !== lastCount) {
lastCount = progress.value.scannedFilesCount;
console.log(` Scanning: ${lastCount} files...`);
@@ -61,7 +63,7 @@ async function main() {
}
// Get final scan progress
const progress = FileFinder.getScanProgress();
const progress = finder.getScanProgress();
if (progress.ok) {
console.log(`Scan complete: ${progress.value.scannedFilesCount} files indexed`);
console.log(`Scan time: ${Date.now() - startTime}ms`);
@@ -70,7 +72,7 @@ async function main() {
// Search test
console.log("Searching for 'lib.rs'...");
const searchResult = FileFinder.search("lib.rs", { pageSize: 5 });
const searchResult = finder.search("lib.rs", { pageSize: 5 });
if (searchResult.ok) {
console.log(`Found ${searchResult.value.totalMatched} matches (showing first 5):\n`);
@@ -88,7 +90,7 @@ async function main() {
// Search with different query
console.log("Searching for 'package.json'...");
const searchResult2 = FileFinder.search("package.json", { pageSize: 3 });
const searchResult2 = finder.search("package.json", { pageSize: 3 });
if (searchResult2.ok) {
console.log(`Found ${searchResult2.value.totalMatched} matches:\n`);
@@ -101,8 +103,8 @@ async function main() {
console.log();
// Health check (after init)
console.log("Health check (after init):");
const healthAfter = FileFinder.healthCheck();
console.log("Health check (with instance):");
const healthAfter = finder.healthCheck();
if (healthAfter.ok) {
console.log(` File picker initialized: ${healthAfter.value.filePicker.initialized}`);
console.log(` Base path: ${healthAfter.value.filePicker.basePath}`);
@@ -113,14 +115,34 @@ async function main() {
}
console.log();
// Test multiple instances
console.log("Testing multiple instances...");
const finder2Result = FileFinder.create({ basePath: testDir });
if (finder2Result.ok) {
const finder2 = finder2Result.value;
console.log(" Second instance created successfully");
finder2.waitForScan(5000);
const search2 = finder2.search("Cargo.toml");
if (search2.ok) {
console.log(` Second instance found ${search2.value.totalMatched} matches for 'Cargo.toml'`);
}
finder2.destroy();
console.log(" Second instance destroyed");
// First instance should still work
const search3 = finder.search("Cargo.toml");
if (search3.ok) {
console.log(` First instance still works: ${search3.value.totalMatched} matches`);
}
}
console.log();
// Cleanup
console.log("Cleaning up...");
const destroyResult = FileFinder.destroy();
if (destroyResult.ok) {
console.log("Cleanup successful!");
} else {
console.error(`Cleanup failed: ${destroyResult.error}`);
}
finder.destroy();
console.log(`Cleanup successful! (isDestroyed: ${finder.isDestroyed})`);
console.log("\n=== Test Complete ===");
}