diff --git a/README.md b/README.md index c0109fc65..60a2e969c 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,7 @@ local r = require('fff').content_search('TODO', { page_size = 50, file_offset = 0, time_budget_ms = 0, + enforce_time_budget = false, -- also bound zero-match searches trim_whitespace = false, cwd = nil, -- switch indexed root if different wait_for_index_ms = nil, -- override the default scan wait timeout @@ -431,6 +432,7 @@ require('fff').setup({ max_matches_per_file = 100, smart_case = true, time_budget_ms = 150, + enforce_time_budget = false, -- apply time_budget_ms even before anything matched (off = zero-match queries scan everything) modes = { 'plain', 'regex', 'fuzzy' }, trim_whitespace = false, enable_filename_constraint = false, -- treat filename-like tokens (e.g. `score.rs`) in a grep query as a file-path filter scoping the search; off = searched as literal text diff --git a/crates/fff-c/include/fff.h b/crates/fff-c/include/fff.h index 83df6c38c..10b295d25 100644 --- a/crates/fff-c/include/fff.h +++ b/crates/fff-c/include/fff.h @@ -636,6 +636,27 @@ struct FffResult *fff_live_grep(void *fff_handle, uint32_t after_context, bool classify_definitions); +/** + * [`fff_live_grep`] plus `enforce_time_budget`: when true the budget also bounds + * zero-match searches and `next_file_offset` resumes at the first unsearched file. + * + * ## Safety + * Same as [`fff_live_grep`]. + */ +struct FffResult *fff_live_grep_ex(void *fff_handle, + const char *query, + uint8_t mode, + uint64_t max_file_size, + uint32_t max_matches_per_file, + bool smart_case, + uint32_t file_offset, + uint32_t page_limit, + uint64_t time_budget_ms, + bool enforce_time_budget, + uint32_t before_context, + uint32_t after_context, + bool classify_definitions); + /** * Multi-pattern OR search (SIMD Aho-Corasick): lines matching ANY pattern. * @@ -660,6 +681,26 @@ struct FffResult *fff_multi_grep(void *fff_handle, uint32_t after_context, bool classify_definitions); +/** + * [`fff_multi_grep`] plus `enforce_time_budget`, as in [`fff_live_grep_ex`]. + * + * ## Safety + * Same as [`fff_multi_grep`]. + */ +struct FffResult *fff_multi_grep_ex(void *fff_handle, + const char *patterns_joined, + const char *constraints, + uint64_t max_file_size, + uint32_t max_matches_per_file, + bool smart_case, + uint32_t file_offset, + uint32_t page_limit, + uint64_t time_budget_ms, + bool enforce_time_budget, + uint32_t before_context, + uint32_t after_context, + bool classify_definitions); + /** * Trigger a rescan of the file index. * @@ -1321,7 +1362,7 @@ struct FffResult *fff_watch_args(void *fff_handle, struct FffResult *fff_unwatch(void *fff_handle, uint64_t watch_id); /** - * Number of events in a batch; 0 if `batch` is null. + * Number of events in a batch, 0 if `batch` is null. * * ## Safety * `batch` must be a valid `FffWatchEventBatch` pointer or null. diff --git a/crates/fff-c/src/lib.rs b/crates/fff-c/src/lib.rs index 00280ccb2..265b3492d 100644 --- a/crates/fff-c/src/lib.rs +++ b/crates/fff-c/src/lib.rs @@ -626,6 +626,46 @@ pub unsafe extern "C" fn fff_live_grep( before_context: u32, after_context: u32, classify_definitions: bool, +) -> *mut FffResult { + unsafe { + fff_live_grep_ex( + fff_handle, + query, + mode, + max_file_size, + max_matches_per_file, + smart_case, + file_offset, + page_limit, + time_budget_ms, + false, + before_context, + after_context, + classify_definitions, + ) + } +} + +/// [`fff_live_grep`] plus `enforce_time_budget`: when true the budget also bounds +/// zero-match searches and `next_file_offset` resumes at the first unsearched file. +/// +/// ## Safety +/// Same as [`fff_live_grep`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn fff_live_grep_ex( + fff_handle: *mut c_void, + query: *const c_char, + mode: u8, + max_file_size: u64, + max_matches_per_file: u32, + smart_case: bool, + file_offset: u32, + page_limit: u32, + time_budget_ms: u64, + enforce_time_budget: bool, + before_context: u32, + after_context: u32, + classify_definitions: bool, ) -> *mut FffResult { let inst = match unsafe { instance_ref(fff_handle) } { Ok(i) => i, @@ -664,6 +704,7 @@ pub unsafe extern "C" fn fff_live_grep( page_limit: default_u32(page_limit, 50) as usize, mode: grep_mode_from_u8(mode), time_budget_ms, + enforce_time_budget, before_context: before_context as usize, after_context: after_context as usize, classify_definitions, @@ -699,6 +740,45 @@ pub unsafe extern "C" fn fff_multi_grep( before_context: u32, after_context: u32, classify_definitions: bool, +) -> *mut FffResult { + unsafe { + fff_multi_grep_ex( + fff_handle, + patterns_joined, + constraints, + max_file_size, + max_matches_per_file, + smart_case, + file_offset, + page_limit, + time_budget_ms, + false, + before_context, + after_context, + classify_definitions, + ) + } +} + +/// [`fff_multi_grep`] plus `enforce_time_budget`, as in [`fff_live_grep_ex`]. +/// +/// ## Safety +/// Same as [`fff_multi_grep`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn fff_multi_grep_ex( + fff_handle: *mut c_void, + patterns_joined: *const c_char, + constraints: *const c_char, + max_file_size: u64, + max_matches_per_file: u32, + smart_case: bool, + file_offset: u32, + page_limit: u32, + time_budget_ms: u64, + enforce_time_budget: bool, + before_context: u32, + after_context: u32, + classify_definitions: bool, ) -> *mut FffResult { let inst = match unsafe { instance_ref(fff_handle) } { Ok(i) => i, @@ -746,6 +826,7 @@ pub unsafe extern "C" fn fff_multi_grep( page_limit: default_u32(page_limit, 50) as usize, mode: fff::GrepMode::PlainText, // ignored by multi_grep_search time_budget_ms, + enforce_time_budget, before_context: before_context as usize, after_context: after_context as usize, classify_definitions, diff --git a/crates/fff-core/src/grep/fuzzy_grep.rs b/crates/fff-core/src/grep/fuzzy_grep.rs index 6f6becbeb..fb4143b43 100644 --- a/crates/fff-core/src/grep/fuzzy_grep.rs +++ b/crates/fff-core/src/grep/fuzzy_grep.rs @@ -3,7 +3,7 @@ use crate::types::{ContentCacheBudget, FileItem, MmapSlot}; use fff_grep::lines::LineStep; use rayon::prelude::*; use std::path::Path; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use super::sink::{ char_indices_to_byte_offsets, classify_definition, strip_line_terminators, @@ -107,7 +107,8 @@ pub(super) fn fuzzy_grep_search<'a>( None }; let search_start = std::time::Instant::now(); - let budget_exceeded = AtomicBool::new(false); + // Lowest index an abort skipped, i.e. the resume point; usize::MAX means no abort. + let first_skipped = AtomicUsize::new(usize::MAX); let max_matches_per_file = options.max_matches_per_file; // for fuzzy match we need a bit smarter chunking as the amount of work we have to perform is @@ -153,15 +154,14 @@ pub(super) fn fuzzy_grep_search<'a>( ) }, |(matcher, buf, mmap_slot), (local_idx, file)| { - if abort_signal.load(Ordering::Relaxed) { - budget_exceeded.store(true, Ordering::Relaxed); - return None; - } - - if let Some(budget) = time_budget - && search_start.elapsed() > budget + // File 0 is never skipped so a page always consumes at least one + // file (cursor 0 means "done"). + let idx = chunk_offset + local_idx; + if idx > 0 + && (abort_signal.load(Ordering::Relaxed) + || time_budget.is_some_and(|b| search_start.elapsed() > b)) { - budget_exceeded.store(true, Ordering::Relaxed); + first_skipped.fetch_min(idx, Ordering::Relaxed); return None; } @@ -331,7 +331,7 @@ pub(super) fn fuzzy_grep_search<'a>( return None; } - Some((chunk_offset + local_idx, *file, file_matches)) + Some((idx, *file, file_matches)) }, ) .flatten() @@ -342,17 +342,18 @@ pub(super) fn fuzzy_grep_search<'a>( per_file_results.push(result); } - if running_matches >= page_limit || budget_exceeded.load(Ordering::Relaxed) { + if running_matches >= page_limit || first_skipped.load(Ordering::Relaxed) != usize::MAX { break; } } + let abort_resume = Some(first_skipped.load(Ordering::Relaxed)).filter(|&i| i != usize::MAX); GrepResult::collect( per_file_results, files_to_search.len(), options, total_files, filtered_file_count, - budget_exceeded.load(Ordering::Relaxed), + abort_resume, ) } diff --git a/crates/fff-core/src/grep/grep.rs b/crates/fff-core/src/grep/grep.rs index 5a4af83f8..20fb8fa28 100644 --- a/crates/fff-core/src/grep/grep.rs +++ b/crates/fff-core/src/grep/grep.rs @@ -16,7 +16,7 @@ use fff_query_parser::{FFFQuery, GrepConfig, QueryParser}; use rayon::prelude::*; use smallvec::SmallVec; use std::path::Path; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use tracing::Level; #[allow(clippy::large_enum_variant)] @@ -561,12 +561,15 @@ where let search_start = std::time::Instant::now(); let page_limit = options.page_limit; - let budget_exceeded = AtomicBool::new(false); + // Lowest index an abort skipped; everything below it was searched, so it is + // the resume point for the next page. usize::MAX means no abort happened. + let first_skipped = AtomicUsize::new(usize::MAX); let mut result_files: Vec<&'a FileItem> = Vec::new(); let mut all_matches: Vec = Vec::new(); let mut files_consumed: usize = 0; let mut page_filled = false; + let mut aborted = false; // Each chunk is a rayon barrier. A flat small chunk over 500k files = ~7800 // barriers; x2 growth makes it logarithmic. But a too-aggressive growth @@ -593,6 +596,9 @@ where chunk_size = (chunk_size * growth).min(max_chunk); let chunk_offset = files_consumed; + // Unless enforced, the budget stays dormant until something matched. + let budget = time_budget.filter(|_| options.enforce_time_budget || all_matches.len() > 1); + let chunk_results: Vec<(usize, &'a FileItem, Vec)> = chunk .par_iter() .enumerate() @@ -600,23 +606,19 @@ where // tested it out a few times, this is just fine for rayon worker in this specific // case it doesn't reallocate this many times and it is actually faster than using // scoped threads with a predefined local scratch buffers because of spawn cost - || (Vec::with_capacity(64 * 1024), MmapSlot::default()), - |(buf, mmap_slot), (local_idx, file)| { - // perform all the atomic machinery on every 8th - if local_idx % 8 == 0 { - let mut need_abort = ctx.abort_signal.load(Ordering::Relaxed); - if !need_abort - && let Some(budget) = time_budget - && all_matches.len() > 1 - && search_start.elapsed() > budget - { - need_abort = true; - } - - if need_abort { - budget_exceeded.store(true, Ordering::Relaxed); - return None; - } + || (Vec::with_capacity(64 * 1024), MmapSlot::default(), false), + |(buf, mmap_slot, aborted), (local_idx, file)| { + // check the clock on every 8th file only; `aborted` latches for the + // rest of this worker's range. File 0 is never skipped so a page + // always consumes at least one file (cursor 0 means "done"). + let idx = chunk_offset + local_idx; + if !*aborted && local_idx % 8 == 0 && idx > 0 { + *aborted = ctx.abort_signal.load(Ordering::Relaxed) + || budget.is_some_and(|b| search_start.elapsed() > b); + } + if *aborted { + first_skipped.fetch_min(idx, Ordering::Relaxed); + return None; } let content = file.get_content_for_search( @@ -642,17 +644,23 @@ where return None; } - Some((chunk_offset + local_idx, *file, file_matches)) + Some((idx, *file, file_matches)) }, ) .flatten() .collect(); - // Every file in the chunk was visited by rayon (matched or not). - files_consumed = chunk_offset + chunk.len(); + // Every file in the chunk was visited unless an abort cut it short. + let resume_at = first_skipped.load(Ordering::Relaxed); + aborted = resume_at != usize::MAX; + files_consumed = resume_at.min(chunk_offset + chunk.len()); - // Flatten this chunk's results into the accumulator. for (batch_idx, file, file_matches) in chunk_results { + // Matches past the abort point are dropped; the next page re-searches them. + if batch_idx >= resume_at { + continue; + } + let file_result_idx = result_files.len(); result_files.push(file); @@ -673,18 +681,17 @@ where } } - if page_filled || budget_exceeded.load(Ordering::Relaxed) { + if page_filled || aborted { break; } } - // If no file had any match, we searched the entire slice. - if result_files.is_empty() { + // No match and no abort means the whole slice was searched. + if result_files.is_empty() && !aborted { files_consumed = files_to_search.len(); } - let has_more = budget_exceeded.load(Ordering::Relaxed) - || (page_filled && files_consumed < files_to_search.len()); + let has_more = (aborted || page_filled) && files_consumed < files_to_search.len(); let next_file_offset = if has_more { options.file_offset + files_consumed diff --git a/crates/fff-core/src/grep/grep_tests.rs b/crates/fff-core/src/grep/grep_tests.rs index 149bde977..a7c42da34 100644 --- a/crates/fff-core/src/grep/grep_tests.rs +++ b/crates/fff-core/src/grep/grep_tests.rs @@ -150,6 +150,7 @@ fn test_multi_grep_search() { page_limit: 100, mode: super::GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, @@ -422,6 +423,7 @@ fn test_grep_no_duplicates_with_overflow_trailing_bits() { page_limit: 100, mode: super::GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-core/src/grep/types.rs b/crates/fff-core/src/grep/types.rs index 08f354fa8..7c3888564 100644 --- a/crates/fff-core/src/grep/types.rs +++ b/crates/fff-core/src/grep/types.rs @@ -94,6 +94,9 @@ pub struct GrepSearchOptions { /// Maximum time in milliseconds to spend searching before returning partial /// results. Prevents UI freezes on pathological queries. 0 = no limit. pub time_budget_ms: u64, + /// Apply `time_budget_ms` even before anything matched. Off by default: plain/regex + /// grep only starts counting once matches exist, so a zero-match query scans everything. + pub enforce_time_budget: bool, /// Number of context lines to include before each match. 0 = disabled. pub before_context: usize, /// Number of context lines to include after each match. 0 = disabled. @@ -122,6 +125,7 @@ impl Default for GrepSearchOptions { page_limit: 50, mode: GrepMode::default(), time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, @@ -173,7 +177,8 @@ impl<'a> GrepResult<'a> { options: &GrepSearchOptions, total_files: usize, filtered_file_count: usize, - budget_exceeded: bool, + // Lowest unsearched index when an abort stopped the search. + abort_resume: Option, ) -> Self { let page_limit = options.page_limit; @@ -191,6 +196,11 @@ impl<'a> GrepResult<'a> { let mut files_consumed: usize = 0; for (batch_idx, file, file_matches) in per_file_results { + // Matches past the abort point are dropped; the next page re-searches them. + if abort_resume.is_some_and(|at| batch_idx >= at) { + continue; + } + // batch_idx is the 0-based position in files_to_search. // Advance files_consumed to include this file and all no-match files before it. files_consumed = batch_idx + 1; @@ -214,13 +224,15 @@ impl<'a> GrepResult<'a> { } } - // If no file had any match, we searched the entire slice. - if result_files.is_empty() { - files_consumed = files_to_search_len; + // Abort stops at a known index; otherwise zero matches means a full scan. + match abort_resume { + Some(resume_at) => files_consumed = resume_at.min(files_to_search_len), + None if result_files.is_empty() => files_consumed = files_to_search_len, + None => {} } - let has_more = budget_exceeded - || (all_matches.len() >= page_limit && files_consumed < files_to_search_len); + let has_more = files_consumed < files_to_search_len + && (abort_resume.is_some() || all_matches.len() >= page_limit); let next_file_offset = if has_more { options.file_offset + files_consumed diff --git a/crates/fff-core/tests/bigram_overlay_coherence_test.rs b/crates/fff-core/tests/bigram_overlay_coherence_test.rs index 6cee64e4f..fdf81ec76 100644 --- a/crates/fff-core/tests/bigram_overlay_coherence_test.rs +++ b/crates/fff-core/tests/bigram_overlay_coherence_test.rs @@ -1314,6 +1314,7 @@ fn grep_opts() -> GrepSearchOptions { page_limit: 500, mode: GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-core/tests/bigram_overlay_integration.rs b/crates/fff-core/tests/bigram_overlay_integration.rs index c663acf8f..96948ed89 100644 --- a/crates/fff-core/tests/bigram_overlay_integration.rs +++ b/crates/fff-core/tests/bigram_overlay_integration.rs @@ -373,6 +373,7 @@ fn grep_opts() -> GrepSearchOptions { page_limit: 200, mode: GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-core/tests/fuzz_file_operations.rs b/crates/fff-core/tests/fuzz_file_operations.rs index 846849396..822b87d9e 100644 --- a/crates/fff-core/tests/fuzz_file_operations.rs +++ b/crates/fff-core/tests/fuzz_file_operations.rs @@ -620,6 +620,7 @@ fn grep_plain_opts() -> GrepSearchOptions { page_limit: 500, mode: GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-core/tests/fuzz_git_watcher_stress.rs b/crates/fff-core/tests/fuzz_git_watcher_stress.rs index 8fdaff8ed..79969d59e 100644 --- a/crates/fff-core/tests/fuzz_git_watcher_stress.rs +++ b/crates/fff-core/tests/fuzz_git_watcher_stress.rs @@ -957,6 +957,7 @@ fn grep_plain_matches(shared: &SharedFilePicker, query: &str) -> Vec { page_limit: 500, mode: GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, @@ -994,6 +995,7 @@ fn grep_fuzzy_matches(shared: &SharedFilePicker, query: &str) -> Vec { page_limit: 500, mode: GrepMode::Fuzzy, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, @@ -1026,6 +1028,7 @@ fn grep_regex_matches(shared: &SharedFilePicker, query: &str) -> Vec { page_limit: 500, mode: GrepMode::Regex, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-core/tests/fuzz_real_repos.rs b/crates/fff-core/tests/fuzz_real_repos.rs index b06851a36..6a349902e 100644 --- a/crates/fff-core/tests/fuzz_real_repos.rs +++ b/crates/fff-core/tests/fuzz_real_repos.rs @@ -216,6 +216,7 @@ fn grep_opts(mode: GrepMode) -> GrepSearchOptions { page_limit: 500, mode, time_budget_ms: 5000, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-core/tests/grep_integration.rs b/crates/fff-core/tests/grep_integration.rs index d579370ac..ae2f2a233 100644 --- a/crates/fff-core/tests/grep_integration.rs +++ b/crates/fff-core/tests/grep_integration.rs @@ -36,6 +36,7 @@ fn plain_opts() -> GrepSearchOptions { page_limit: 200, mode: GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, @@ -54,6 +55,7 @@ fn regex_opts() -> GrepSearchOptions { page_limit: 200, mode: GrepMode::Regex, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, @@ -72,6 +74,7 @@ fn fuzzy_opts() -> GrepSearchOptions { page_limit: 200, mode: GrepMode::Fuzzy, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-core/tests/grep_time_budget_zero_match.rs b/crates/fff-core/tests/grep_time_budget_zero_match.rs new file mode 100644 index 000000000..d3fe0a177 --- /dev/null +++ b/crates/fff-core/tests/grep_time_budget_zero_match.rs @@ -0,0 +1,145 @@ +use std::fs; +use std::path::Path; +use tempfile::TempDir; + +use fff_search::FilePickerOptions; +use fff_search::file_picker::FilePicker; +use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query}; + +const FILE_COUNT: usize = 10_000; +const NEEDLE: &str = "needle-in-a-haystack"; + +// With `enforce_time_budget` a zero-match search must stop at the budget and +// hand back a resume cursor instead of scanning every candidate. Issue #826. +#[test] +fn zero_match_search_stops_at_enforced_time_budget() { + let tmp = TempDir::new().unwrap(); + let picker = create_picker(tmp.path(), None); + + let parsed = parse_grep_query("zzz-absent-literal"); + let result = picker.grep(&parsed, &budget_opts(GrepMode::PlainText, true)); + + assert_eq!(result.matches.len(), 0, "sanity: query must not match"); + assert_eq!(result.filtered_file_count, FILE_COUNT); + assert!( + result.total_files_searched < result.filtered_file_count, + "budget expired but all {} candidate files were searched", + result.filtered_file_count + ); + assert!( + result.next_file_offset > 0, + "budget expired with no resume cursor" + ); +} + +// Default stays as it always was: plain/regex ignores the budget until matches +// exist, so a zero-match query still scans everything and reports no cursor. +#[test] +fn zero_match_search_ignores_unenforced_time_budget() { + let tmp = TempDir::new().unwrap(); + let picker = create_picker(tmp.path(), None); + + let parsed = parse_grep_query("zzz-absent-literal"); + let result = picker.grep(&parsed, &budget_opts(GrepMode::PlainText, false)); + + assert_eq!(result.matches.len(), 0, "sanity: query must not match"); + assert_eq!(result.total_files_searched, result.filtered_file_count); + assert_eq!(result.next_file_offset, 0); +} + +#[test] +fn zero_match_fuzzy_search_stops_at_time_budget() { + let tmp = TempDir::new().unwrap(); + let picker = create_picker(tmp.path(), None); + + let parsed = parse_grep_query("zzzabsentfuzzy"); + let result = picker.grep(&parsed, &budget_opts(GrepMode::Fuzzy, false)); + + assert_eq!(result.matches.len(), 0, "sanity: query must not match"); + assert!( + result.total_files_searched < result.filtered_file_count, + "budget expired but all {} candidate files were searched", + result.filtered_file_count + ); + assert!( + result.next_file_offset > 0, + "budget expired with no resume cursor" + ); +} + +// The resume cursor must not skip files: paging a budget-limited search to +// exhaustion has to find a needle that lives past the first page. +#[test] +fn budget_resume_cursor_does_not_skip_files() { + let tmp = TempDir::new().unwrap(); + let picker = create_picker(tmp.path(), Some(FILE_COUNT - 1)); + + let parsed = parse_grep_query(NEEDLE); + let mut opts = budget_opts(GrepMode::PlainText, true); + let mut found = 0usize; + let mut pages = 0usize; + + loop { + let result = picker.grep(&parsed, &opts); + found += result.matches.len(); + pages += 1; + + assert!(pages < 5_000, "paging did not terminate"); + if result.next_file_offset == 0 { + break; + } + assert!( + result.next_file_offset > opts.file_offset, + "cursor did not advance: {} -> {}", + opts.file_offset, + result.next_file_offset + ); + opts.file_offset = result.next_file_offset; + } + + assert_eq!( + found, 1, + "resume cursor skipped the file holding the needle" + ); +} + +// 10k files of 4KiB filler. `needle_at` gets NEEDLE appended so paging can be +// checked for skipped files. +fn create_picker(base: &Path, needle_at: Option) -> FilePicker { + let filler = format!("{}\n", "x".repeat(4 * 1024)); + for i in 0..FILE_COUNT { + let path = base.join(format!("file-{i}.txt")); + if needle_at == Some(i) { + fs::write(&path, format!("{filler}{NEEDLE}\n")).unwrap(); + } else { + fs::write(&path, &filler).unwrap(); + } + } + let mut picker = FilePicker::new(FilePickerOptions { + base_path: base.to_string_lossy().to_string(), + enable_mmap_cache: false, + watch: false, + ..Default::default() + }) + .expect("Failed to create FilePicker"); + picker.collect_files().expect("Failed to collect files"); + picker +} + +fn budget_opts(mode: GrepMode, enforce_time_budget: bool) -> GrepSearchOptions { + GrepSearchOptions { + max_file_size: 1024 * 1024, + max_matches_per_file: 200, + smart_case: true, + file_offset: 0, + page_limit: 500, + mode, + time_budget_ms: 5, + enforce_time_budget, + before_context: 0, + after_context: 0, + classify_definitions: false, + trim_whitespace: false, + abort_signal: None, + } +} diff --git a/crates/fff-core/tests/new_directory_watcher_test.rs b/crates/fff-core/tests/new_directory_watcher_test.rs index e76b71b59..e22608888 100644 --- a/crates/fff-core/tests/new_directory_watcher_test.rs +++ b/crates/fff-core/tests/new_directory_watcher_test.rs @@ -138,6 +138,7 @@ fn grep_plain_count(picker: &FilePicker, query: &str) -> usize { page_limit: 500, mode: GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-core/tests/path_separator_constraint_test.rs b/crates/fff-core/tests/path_separator_constraint_test.rs index bc614a158..3637e93c7 100644 --- a/crates/fff-core/tests/path_separator_constraint_test.rs +++ b/crates/fff-core/tests/path_separator_constraint_test.rs @@ -41,6 +41,7 @@ fn plain_opts() -> GrepSearchOptions { page_limit: 200, mode: GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-core/tests/real_binary_fixtures.rs b/crates/fff-core/tests/real_binary_fixtures.rs index e4da06915..22c4c4f63 100644 --- a/crates/fff-core/tests/real_binary_fixtures.rs +++ b/crates/fff-core/tests/real_binary_fixtures.rs @@ -35,6 +35,7 @@ fn plain_opts() -> GrepSearchOptions { page_limit: 200, mode: GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-mcp/src/server.rs b/crates/fff-mcp/src/server.rs index cd2cacfb1..0a5a3fe8b 100644 --- a/crates/fff-mcp/src/server.rs +++ b/crates/fff-mcp/src/server.rs @@ -71,6 +71,7 @@ fn make_grep_options( page_limit: 50, mode, time_budget_ms: 0, + enforce_time_budget: false, before_context: ctx_lines, after_context: after_ctx, classify_definitions: true, diff --git a/crates/fff-nvim/benches/fuzzy_search_bench.rs b/crates/fff-nvim/benches/fuzzy_search_bench.rs index e0ccfee18..c199af085 100644 --- a/crates/fff-nvim/benches/fuzzy_search_bench.rs +++ b/crates/fff-nvim/benches/fuzzy_search_bench.rs @@ -618,6 +618,7 @@ fn bench_grep_search(c: &mut Criterion) { page_limit: 100, mode: GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-nvim/benches/grep_bench.rs b/crates/fff-nvim/benches/grep_bench.rs index 64939946d..21ff6f42f 100644 --- a/crates/fff-nvim/benches/grep_bench.rs +++ b/crates/fff-nvim/benches/grep_bench.rs @@ -115,6 +115,7 @@ fn plain_options() -> GrepSearchOptions { page_limit: 50, mode: GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-nvim/src/bin/bench_grep_query.rs b/crates/fff-nvim/src/bin/bench_grep_query.rs index 063d08160..9209d4b8d 100644 --- a/crates/fff-nvim/src/bin/bench_grep_query.rs +++ b/crates/fff-nvim/src/bin/bench_grep_query.rs @@ -27,6 +27,7 @@ fn run_grep(picker: &FilePicker, query: &str, iters: usize) { page_limit: usize::MAX, mode: GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-nvim/src/bin/fuzzy_grep_test.rs b/crates/fff-nvim/src/bin/fuzzy_grep_test.rs index e3bceac81..d209ecfcf 100644 --- a/crates/fff-nvim/src/bin/fuzzy_grep_test.rs +++ b/crates/fff-nvim/src/bin/fuzzy_grep_test.rs @@ -32,6 +32,7 @@ fn run_fuzzy_query(picker: &FilePicker, query: &str, label: &str) { page_limit: 100, mode: GrepMode::Fuzzy, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-nvim/src/bin/grep_profiler.rs b/crates/fff-nvim/src/bin/grep_profiler.rs index 4c0386b2b..0b6713a9d 100644 --- a/crates/fff-nvim/src/bin/grep_profiler.rs +++ b/crates/fff-nvim/src/bin/grep_profiler.rs @@ -93,6 +93,7 @@ impl<'a> GrepBench<'a> { page_limit: 50, mode, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, @@ -414,6 +415,7 @@ fn main() { page_limit: 50, mode: Default::default(), time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-nvim/src/bin/grep_vs_rg.rs b/crates/fff-nvim/src/bin/grep_vs_rg.rs index f202fc1f9..033353025 100644 --- a/crates/fff-nvim/src/bin/grep_vs_rg.rs +++ b/crates/fff-nvim/src/bin/grep_vs_rg.rs @@ -167,6 +167,7 @@ fn run_fff_full(picker: &FilePicker, query: &str) -> (usize, Duration) { page_limit: usize::MAX, mode: Default::default(), time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, @@ -190,6 +191,7 @@ fn run_fff_page(picker: &FilePicker, query: &str) -> (usize, Duration) { page_limit: 50, mode: Default::default(), time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-nvim/src/lib.rs b/crates/fff-nvim/src/lib.rs index 4ea4208af..7f9c90932 100644 --- a/crates/fff-nvim/src/lib.rs +++ b/crates/fff-nvim/src/lib.rs @@ -437,6 +437,7 @@ pub fn live_grep( grep_mode, time_budget_ms, trim_whitespace, + enforce_time_budget, ): ( String, Option, @@ -447,6 +448,7 @@ pub fn live_grep( Option, Option, Option, + Option, ), ) -> LuaResult { let file_picker_guard = FILE_PICKER.read().into_lua_result()?; @@ -469,6 +471,7 @@ pub fn live_grep( page_limit: page_size.unwrap_or(50), mode, time_budget_ms: time_budget_ms.unwrap_or(0), + enforce_time_budget: enforce_time_budget.unwrap_or(false), before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-python/src/finder.rs b/crates/fff-python/src/finder.rs index f4a27246a..429fcb272 100644 --- a/crates/fff-python/src/finder.rs +++ b/crates/fff-python/src/finder.rs @@ -86,6 +86,7 @@ fn grep_options( smart_case: bool, page_limit: u32, time_budget_ms: u64, + enforce_time_budget: bool, before_context: u32, after_context: u32, classify_definitions: bool, @@ -99,6 +100,7 @@ fn grep_options( page_limit: defaulted_usize(page_limit, defaults.page_limit), mode, time_budget_ms, + enforce_time_budget, before_context: before_context as usize, after_context: after_context as usize, classify_definitions, @@ -594,6 +596,7 @@ impl FileFinder { cursor=None, page_limit=0, time_budget_ms=0, + enforce_time_budget=false, before_context=0, after_context=0, classify_definitions=false, @@ -609,6 +612,7 @@ impl FileFinder { cursor: Option<&GrepCursor>, page_limit: u32, time_budget_ms: u64, + enforce_time_budget: bool, before_context: u32, after_context: u32, classify_definitions: bool, @@ -637,6 +641,7 @@ impl FileFinder { smart_case, page_limit, time_budget_ms, + enforce_time_budget, before_context, after_context, classify_definitions, @@ -658,6 +663,7 @@ impl FileFinder { cursor=None, page_limit=0, time_budget_ms=0, + enforce_time_budget=false, before_context=0, after_context=0, classify_definitions=false, @@ -674,6 +680,7 @@ impl FileFinder { cursor: Option<&GrepCursor>, page_limit: u32, time_budget_ms: u64, + enforce_time_budget: bool, before_context: u32, after_context: u32, classify_definitions: bool, @@ -708,6 +715,7 @@ impl FileFinder { smart_case, page_limit, time_budget_ms, + enforce_time_budget, before_context, after_context, classify_definitions, diff --git a/lua/fff/conf.lua b/lua/fff/conf.lua index 31aa8a32f..7231e7c2e 100644 --- a/lua/fff/conf.lua +++ b/lua/fff/conf.lua @@ -63,6 +63,7 @@ local M = {} --- @field max_matches_per_file number --- @field smart_case boolean --- @field time_budget_ms number +--- @field enforce_time_budget boolean --- @field modes string[] --- @field trim_whitespace boolean --- @field location_format string @@ -463,6 +464,7 @@ local function init() 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) + enforce_time_budget = false, -- Apply time_budget_ms even before anything matched (off = zero-match queries scan everything) modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order trim_whitespace = false, -- Strip leading whitespace from matched lines (useful for cleaner display) -- Treat filename-like tokens (e.g. `score.rs`, `src/main.rs`) in a grep query as a diff --git a/lua/fff/main.lua b/lua/fff/main.lua index 01cf38bbf..2197dd79f 100644 --- a/lua/fff/main.lua +++ b/lua/fff/main.lua @@ -332,6 +332,7 @@ end --- @field page_size? number Max matches returned (default: 50). --- @field file_offset? number File-based pagination offset (default: 0). --- @field time_budget_ms? number Max wall-clock time, 0 = unlimited (default: config.grep.time_budget_ms). +--- @field enforce_time_budget? boolean Apply the budget even with zero matches (default: config.grep.enforce_time_budget). --- @field trim_whitespace? boolean Strip leading whitespace from matched lines (default: config.grep.trim_whitespace). --- @field cwd? string Switch indexed root before grepping (same semantics as `file_search`). --- @field wait_for_index_ms? number Block up to this many ms for the index to be ready. @@ -376,6 +377,7 @@ function M.content_search(query, opts) max_matches_per_file = opts.max_matches_per_file or grep_cfg.max_matches_per_file, smart_case = opts.smart_case == nil and grep_cfg.smart_case or opts.smart_case, time_budget_ms = opts.time_budget_ms or grep_cfg.time_budget_ms, + enforce_time_budget = opts.enforce_time_budget == nil and grep_cfg.enforce_time_budget or opts.enforce_time_budget, trim_whitespace = opts.trim_whitespace == nil and grep_cfg.trim_whitespace or opts.trim_whitespace, } diff --git a/lua/fff/picker_ui/grep_renderer.lua b/lua/fff/picker_ui/grep_renderer.lua index 7bebe9bee..b96088fb7 100644 --- a/lua/fff/picker_ui/grep_renderer.lua +++ b/lua/fff/picker_ui/grep_renderer.lua @@ -38,7 +38,8 @@ function M.search(query, file_offset, page_size, config, grep_mode) conf.smart_case, grep_mode or 'plain', conf.time_budget_ms, - conf.trim_whitespace + conf.trim_whitespace, + conf.enforce_time_budget ) return last_result end diff --git a/packages/fff-bun/src/fff-api.ts b/packages/fff-bun/src/fff-api.ts index 2d0038aab..4573b8d22 100644 --- a/packages/fff-bun/src/fff-api.ts +++ b/packages/fff-bun/src/fff-api.ts @@ -432,6 +432,11 @@ export interface GrepOptions { * partial results. 0 = unlimited. (default: 0) */ timeBudgetMs?: number; + /** + * Apply `timeBudgetMs` even before anything matched. Off by default, so a + * zero-match query scans every candidate file. (default: false) + */ + enforceTimeBudget?: boolean; /** Number of context lines to include before each match (default: 0) */ beforeContext?: number; /** Number of context lines to include after each match (default: 0) */ @@ -538,6 +543,11 @@ export interface MultiGrepOptions { * partial results. 0 = unlimited. (default: 0) */ timeBudgetMs?: number; + /** + * Apply `timeBudgetMs` even before anything matched. Off by default, so a + * zero-match query scans every candidate file. (default: false) + */ + enforceTimeBudget?: boolean; /** Number of context lines to include before each match (default: 0) */ beforeContext?: number; /** Number of context lines to include after each match (default: 0) */ diff --git a/packages/fff-bun/src/ffi.ts b/packages/fff-bun/src/ffi.ts index 0a9f3b075..4cfe65264 100644 --- a/packages/fff-bun/src/ffi.ts +++ b/packages/fff-bun/src/ffi.ts @@ -139,7 +139,7 @@ const ffiDefinition = { }, // Live grep (content search) - fff_live_grep: { + fff_live_grep_ex: { args: [ FFIType.ptr, // handle FFIType.cstring, // query @@ -150,6 +150,7 @@ const ffiDefinition = { FFIType.u32, // file_offset FFIType.u32, // page_limit FFIType.u64, // time_budget_ms + FFIType.bool, // enforce_time_budget FFIType.u32, // before_context FFIType.u32, // after_context FFIType.bool, // classify_definitions @@ -158,7 +159,7 @@ const ffiDefinition = { }, // Multi-pattern grep (Aho-Corasick) - fff_multi_grep: { + fff_multi_grep_ex: { args: [ FFIType.ptr, // handle FFIType.cstring, // patterns_joined (\n-separated) @@ -169,6 +170,7 @@ const ffiDefinition = { FFIType.u32, // file_offset FFIType.u32, // page_limit FFIType.u64, // time_budget_ms + FFIType.bool, // enforce_time_budget FFIType.u32, // before_context FFIType.u32, // after_context FFIType.bool, // classify_definitions @@ -1244,12 +1246,13 @@ export function ffiLiveGrep( fileOffset: number, pageLimit: number, timeBudgetMs: number, + enforceTimeBudget: boolean, beforeContext: number, afterContext: number, classifyDefinitions: boolean, ): Result { const library = loadLibrary(); - const resultPtr = library.symbols.fff_live_grep( + const resultPtr = library.symbols.fff_live_grep_ex( handle, ptr(encodeString(query)), grepModeToU8(mode), @@ -1259,6 +1262,7 @@ export function ffiLiveGrep( fileOffset, pageLimit, BigInt(timeBudgetMs), + enforceTimeBudget, beforeContext, afterContext, classifyDefinitions, @@ -1279,12 +1283,13 @@ export function ffiMultiGrep( fileOffset: number, pageLimit: number, timeBudgetMs: number, + enforceTimeBudget: boolean, beforeContext: number, afterContext: number, classifyDefinitions: boolean, ): Result { const library = loadLibrary(); - const resultPtr = library.symbols.fff_multi_grep( + const resultPtr = library.symbols.fff_multi_grep_ex( handle, ptr(encodeString(patternsJoined)), ptr(encodeString(constraints)), @@ -1294,6 +1299,7 @@ export function ffiMultiGrep( fileOffset, pageLimit, BigInt(timeBudgetMs), + enforceTimeBudget, beforeContext, afterContext, classifyDefinitions, diff --git a/packages/fff-bun/src/finder.ts b/packages/fff-bun/src/finder.ts index d8210b43f..6f5b23aea 100644 --- a/packages/fff-bun/src/finder.ts +++ b/packages/fff-bun/src/finder.ts @@ -373,6 +373,7 @@ export class FileFinder implements FileFinderApi { options?.cursor?._offset ?? 0, options?.pageSize ?? 0, options?.timeBudgetMs ?? 0, + options?.enforceTimeBudget ?? false, options?.beforeContext ?? 0, options?.afterContext ?? 0, options?.classifyDefinitions ?? false, @@ -422,6 +423,7 @@ export class FileFinder implements FileFinderApi { options.cursor?._offset ?? 0, options.pageSize ?? 0, options.timeBudgetMs ?? 0, + options.enforceTimeBudget ?? false, options.beforeContext ?? 0, options.afterContext ?? 0, options.classifyDefinitions ?? false, diff --git a/packages/fff-node/src/fff-api.ts b/packages/fff-node/src/fff-api.ts index 2d0038aab..4573b8d22 100644 --- a/packages/fff-node/src/fff-api.ts +++ b/packages/fff-node/src/fff-api.ts @@ -432,6 +432,11 @@ export interface GrepOptions { * partial results. 0 = unlimited. (default: 0) */ timeBudgetMs?: number; + /** + * Apply `timeBudgetMs` even before anything matched. Off by default, so a + * zero-match query scans every candidate file. (default: false) + */ + enforceTimeBudget?: boolean; /** Number of context lines to include before each match (default: 0) */ beforeContext?: number; /** Number of context lines to include after each match (default: 0) */ @@ -538,6 +543,11 @@ export interface MultiGrepOptions { * partial results. 0 = unlimited. (default: 0) */ timeBudgetMs?: number; + /** + * Apply `timeBudgetMs` even before anything matched. Off by default, so a + * zero-match query scans every candidate file. (default: false) + */ + enforceTimeBudget?: boolean; /** Number of context lines to include before each match (default: 0) */ beforeContext?: number; /** Number of context lines to include after each match (default: 0) */ diff --git a/packages/fff-node/src/ffi.ts b/packages/fff-node/src/ffi.ts index 61080f421..ccfee81dd 100644 --- a/packages/fff-node/src/ffi.ts +++ b/packages/fff-node/src/ffi.ts @@ -1365,6 +1365,7 @@ export function ffiLiveGrep( fileOffset: number, pageLimit: number, timeBudgetMs: number, + enforceTimeBudget: boolean, beforeContext: number, afterContext: number, classifyDefinitions: boolean, @@ -1373,7 +1374,7 @@ export function ffiLiveGrep( const rawPtr = load({ library: LIBRARY_KEY, - funcName: "fff_live_grep", + funcName: "fff_live_grep_ex", retType: DataType.External, paramsType: [ DataType.External, // handle @@ -1385,6 +1386,7 @@ export function ffiLiveGrep( DataType.U32, // file_offset DataType.U32, // page_limit DataType.U64, // time_budget_ms + DataType.Boolean, // enforce_time_budget DataType.U32, // before_context DataType.U32, // after_context DataType.Boolean, // classify_definitions @@ -1399,6 +1401,7 @@ export function ffiLiveGrep( fileOffset, pageLimit, timeBudgetMs, + enforceTimeBudget, beforeContext, afterContext, classifyDefinitions, @@ -1422,6 +1425,7 @@ export function ffiMultiGrep( fileOffset: number, pageLimit: number, timeBudgetMs: number, + enforceTimeBudget: boolean, beforeContext: number, afterContext: number, classifyDefinitions: boolean, @@ -1430,7 +1434,7 @@ export function ffiMultiGrep( const rawPtr = load({ library: LIBRARY_KEY, - funcName: "fff_multi_grep", + funcName: "fff_multi_grep_ex", retType: DataType.External, paramsType: [ DataType.External, // handle @@ -1442,6 +1446,7 @@ export function ffiMultiGrep( DataType.U32, // file_offset DataType.U32, // page_limit DataType.U64, // time_budget_ms + DataType.Boolean, // enforce_time_budget DataType.U32, // before_context DataType.U32, // after_context DataType.Boolean, // classify_definitions @@ -1456,6 +1461,7 @@ export function ffiMultiGrep( fileOffset, pageLimit, timeBudgetMs, + enforceTimeBudget, beforeContext, afterContext, classifyDefinitions, diff --git a/packages/fff-node/src/finder.ts b/packages/fff-node/src/finder.ts index e80b4ef66..d49baf363 100644 --- a/packages/fff-node/src/finder.ts +++ b/packages/fff-node/src/finder.ts @@ -379,6 +379,7 @@ export class FileFinder implements FileFinderApi { options?.cursor?._offset ?? 0, options?.pageSize ?? 0, options?.timeBudgetMs ?? 0, + options?.enforceTimeBudget ?? false, options?.beforeContext ?? 0, options?.afterContext ?? 0, options?.classifyDefinitions ?? false, @@ -428,6 +429,7 @@ export class FileFinder implements FileFinderApi { options.cursor?._offset ?? 0, options.pageSize ?? 0, options.timeBudgetMs ?? 0, + options.enforceTimeBudget ?? false, options.beforeContext ?? 0, options.afterContext ?? 0, options.classifyDefinitions ?? false, diff --git a/packages/fff-python/src/fff/__init__.pyi b/packages/fff-python/src/fff/__init__.pyi index 44cee2f1d..4b841f4d9 100644 --- a/packages/fff-python/src/fff/__init__.pyi +++ b/packages/fff-python/src/fff/__init__.pyi @@ -241,6 +241,7 @@ class FileFinder: cursor: GrepCursor | None = None, page_limit: int = 0, time_budget_ms: int = 0, + enforce_time_budget: bool = False, before_context: int = 0, after_context: int = 0, classify_definitions: bool = False, @@ -257,6 +258,7 @@ class FileFinder: cursor: GrepCursor | None = None, page_limit: int = 0, time_budget_ms: int = 0, + enforce_time_budget: bool = False, before_context: int = 0, after_context: int = 0, classify_definitions: bool = False, diff --git a/packages/shared/fff-api.ts b/packages/shared/fff-api.ts index dcd9f2ac2..4331ca571 100644 --- a/packages/shared/fff-api.ts +++ b/packages/shared/fff-api.ts @@ -426,6 +426,11 @@ export interface GrepOptions { * partial results. 0 = unlimited. (default: 0) */ timeBudgetMs?: number; + /** + * Apply `timeBudgetMs` even before anything matched. Off by default, so a + * zero-match query scans every candidate file. (default: false) + */ + enforceTimeBudget?: boolean; /** Number of context lines to include before each match (default: 0) */ beforeContext?: number; /** Number of context lines to include after each match (default: 0) */ @@ -532,6 +537,11 @@ export interface MultiGrepOptions { * partial results. 0 = unlimited. (default: 0) */ timeBudgetMs?: number; + /** + * Apply `timeBudgetMs` even before anything matched. Off by default, so a + * zero-match query scans every candidate file. (default: false) + */ + enforceTimeBudget?: boolean; /** Number of context lines to include before each match (default: 0) */ beforeContext?: number; /** Number of context lines to include after each match (default: 0) */