diff --git a/crates/fff-c/src/lib.rs b/crates/fff-c/src/lib.rs index 0e0d0c91..cdbcb710 100644 --- a/crates/fff-c/src/lib.rs +++ b/crates/fff-c/src/lib.rs @@ -737,6 +737,7 @@ pub unsafe extern "C" fn fff_live_grep( max_file_size: default_u64(max_file_size, 10 * 1024 * 1024), max_matches_per_file: max_matches_per_file as usize, smart_case, + case_mode: None, file_offset: file_offset as usize, page_limit: default_u32(page_limit, 50) as usize, mode: grep_mode_from_u8(mode), @@ -840,6 +841,7 @@ pub unsafe extern "C" fn fff_multi_grep( max_file_size: default_u64(max_file_size, 10 * 1024 * 1024), max_matches_per_file: max_matches_per_file as usize, smart_case, + case_mode: None, file_offset: file_offset as usize, page_limit: default_u32(page_limit, 50) as usize, mode: fff::GrepMode::PlainText, // ignored by multi_grep_search diff --git a/crates/fff-core/src/grep.rs b/crates/fff-core/src/grep.rs index 5c73fa5b..9960e814 100644 --- a/crates/fff-core/src/grep.rs +++ b/crates/fff-core/src/grep.rs @@ -228,6 +228,14 @@ fn replace_unescaped_newline_escapes(text: &str) -> String { String::from_utf8(result).unwrap_or_else(|_| text.to_string()) } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CaseMode { + #[default] + Smart, + Sensitive, + Insensitive, +} + /// Controls how the grep pattern is interpreted. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum GrepMode { @@ -335,6 +343,10 @@ pub struct GrepSearchOptions { pub max_file_size: u64, pub max_matches_per_file: usize, pub smart_case: bool, + /// Explicit case mode. When `Some`, overrides `smart_case`. When `None`, + /// `smart_case` is used (true => Smart, false => Sensitive). Non-breaking + /// addition; existing callers can ignore this field. + pub case_mode: Option, /// File-based pagination offset: index into the sorted/filtered file list /// to start searching from. Pass 0 for the first page, then use /// `GrepResult::next_file_offset` for subsequent pages. @@ -364,12 +376,25 @@ pub struct GrepSearchOptions { pub abort_signal: Option>, } +impl GrepSearchOptions { + /// Resolves the effective case mode, preferring explicit `case_mode` over + /// the legacy `smart_case` boolean. + pub fn effective_case_mode(&self) -> CaseMode { + match self.case_mode { + Some(m) => m, + None if self.smart_case => CaseMode::Smart, + None => CaseMode::Sensitive, + } + } +} + impl Default for GrepSearchOptions { fn default() -> Self { Self { max_file_size: MAX_FFFILE_SIZE, max_matches_per_file: 200, smart_case: true, + case_mode: None, file_offset: 0, page_limit: 50, mode: GrepMode::default(), @@ -1045,11 +1070,10 @@ pub(crate) fn multi_grep_search<'a>( }; } - // Smart case: case-insensitive when all patterns are lowercase - let case_insensitive = if options.smart_case { - !patterns.iter().any(|p| p.chars().any(|c| c.is_uppercase())) - } else { - false + let case_insensitive = match options.effective_case_mode() { + CaseMode::Smart => !patterns.iter().any(|p| p.chars().any(|c| c.is_uppercase())), + CaseMode::Insensitive => true, + CaseMode::Sensitive => false, }; let ac = aho_corasick::AhoCorasickBuilder::new() @@ -1117,7 +1141,7 @@ const fn is_utf8_char_boundary(b: u8) -> bool { /// - The input is passed directly to the regex engine without escaping /// - Smart case still applies /// - Returns `None` for invalid regex patterns — the caller falls back to literal mode -fn build_regex(pattern: &str, smart_case: bool) -> Result { +fn build_regex(pattern: &str, case_mode: CaseMode) -> Result { if pattern.is_empty() { return Err("empty pattern".to_string()); } @@ -1128,10 +1152,10 @@ fn build_regex(pattern: &str, smart_case: bool) -> Result !pattern.chars().any(|c| c.is_uppercase()), + CaseMode::Insensitive => true, + CaseMode::Sensitive => false, }; regex::bytes::RegexBuilder::new(®ex_pattern) @@ -1995,10 +2019,10 @@ pub(crate) fn grep_search<'a>( }; } - let case_insensitive = if options.smart_case { - !grep_text.chars().any(|c| c.is_uppercase()) - } else { - false + let case_insensitive = match options.effective_case_mode() { + CaseMode::Smart => !grep_text.chars().any(|c| c.is_uppercase()), + CaseMode::Insensitive => true, + CaseMode::Sensitive => false, }; let mut regex_fallback_error: Option = None; @@ -2089,7 +2113,7 @@ pub(crate) fn grep_search<'a>( overflow_arena, ); } - GrepMode::Regex => build_regex(&grep_text, options.smart_case) + GrepMode::Regex => build_regex(&grep_text, options.effective_case_mode()) .inspect_err(|err| { tracing::warn!("Regex compilation failed for {}. Error {}", grep_text, err); @@ -2445,6 +2469,7 @@ mod tests { max_file_size: MAX_FFFILE_SIZE, max_matches_per_file: 0, smart_case: true, + case_mode: None, file_offset: 0, page_limit: 100, mode: super::GrepMode::PlainText, @@ -2629,6 +2654,7 @@ mod tests { max_file_size: MAX_FFFILE_SIZE, max_matches_per_file: 0, smart_case: true, + case_mode: None, file_offset: 0, page_limit: 100, mode: super::GrepMode::PlainText, diff --git a/crates/fff-core/tests/bigram_overlay_coherence_test.rs b/crates/fff-core/tests/bigram_overlay_coherence_test.rs index 6cee64e4..c5429516 100644 --- a/crates/fff-core/tests/bigram_overlay_coherence_test.rs +++ b/crates/fff-core/tests/bigram_overlay_coherence_test.rs @@ -1310,6 +1310,7 @@ fn grep_opts() -> GrepSearchOptions { max_file_size: 10 * 1024 * 1024, max_matches_per_file: 200, smart_case: true, + case_mode: None, file_offset: 0, page_limit: 500, mode: GrepMode::PlainText, diff --git a/crates/fff-core/tests/bigram_overlay_integration.rs b/crates/fff-core/tests/bigram_overlay_integration.rs index c663acf8..da9d7730 100644 --- a/crates/fff-core/tests/bigram_overlay_integration.rs +++ b/crates/fff-core/tests/bigram_overlay_integration.rs @@ -369,6 +369,7 @@ fn grep_opts() -> GrepSearchOptions { max_file_size: 10 * 1024 * 1024, max_matches_per_file: 200, smart_case: true, + case_mode: None, file_offset: 0, page_limit: 200, mode: GrepMode::PlainText, diff --git a/crates/fff-core/tests/fuzz_file_operations.rs b/crates/fff-core/tests/fuzz_file_operations.rs index e39a6906..7dc5e537 100644 --- a/crates/fff-core/tests/fuzz_file_operations.rs +++ b/crates/fff-core/tests/fuzz_file_operations.rs @@ -627,6 +627,7 @@ fn grep_plain_opts() -> GrepSearchOptions { max_file_size: 10 * 1024 * 1024, max_matches_per_file: 200, smart_case: true, + case_mode: None, file_offset: 0, page_limit: 500, mode: GrepMode::PlainText, diff --git a/crates/fff-core/tests/fuzz_git_watcher_stress.rs b/crates/fff-core/tests/fuzz_git_watcher_stress.rs index f7ca1e80..673253bd 100644 --- a/crates/fff-core/tests/fuzz_git_watcher_stress.rs +++ b/crates/fff-core/tests/fuzz_git_watcher_stress.rs @@ -892,6 +892,7 @@ fn grep_plain_matches(shared: &SharedFilePicker, query: &str) -> Vec { max_file_size: 10 * 1024 * 1024, max_matches_per_file: 200, smart_case: true, + case_mode: None, file_offset: 0, page_limit: 500, mode: GrepMode::PlainText, @@ -929,6 +930,7 @@ fn grep_fuzzy_matches(shared: &SharedFilePicker, query: &str) -> Vec { max_file_size: 10 * 1024 * 1024, max_matches_per_file: 200, smart_case: true, + case_mode: None, file_offset: 0, page_limit: 500, mode: GrepMode::Fuzzy, @@ -961,6 +963,7 @@ fn grep_regex_matches(shared: &SharedFilePicker, query: &str) -> Vec { max_file_size: 10 * 1024 * 1024, max_matches_per_file: 200, smart_case: true, + case_mode: None, file_offset: 0, page_limit: 500, mode: GrepMode::Regex, diff --git a/crates/fff-core/tests/fuzz_real_repos.rs b/crates/fff-core/tests/fuzz_real_repos.rs index f83a0f52..e6aa978d 100644 --- a/crates/fff-core/tests/fuzz_real_repos.rs +++ b/crates/fff-core/tests/fuzz_real_repos.rs @@ -227,6 +227,7 @@ fn grep_opts(mode: GrepMode) -> GrepSearchOptions { max_file_size: 10 * 1024 * 1024, max_matches_per_file: 200, smart_case: true, + case_mode: None, file_offset: 0, page_limit: 500, mode, diff --git a/crates/fff-core/tests/grep_integration.rs b/crates/fff-core/tests/grep_integration.rs index 2765de3c..423b2c5c 100644 --- a/crates/fff-core/tests/grep_integration.rs +++ b/crates/fff-core/tests/grep_integration.rs @@ -32,6 +32,7 @@ fn plain_opts() -> GrepSearchOptions { max_file_size: 10 * 1024 * 1024, max_matches_per_file: 200, smart_case: true, + case_mode: None, file_offset: 0, page_limit: 200, mode: GrepMode::PlainText, @@ -50,6 +51,7 @@ fn regex_opts() -> GrepSearchOptions { max_file_size: 10 * 1024 * 1024, max_matches_per_file: 200, smart_case: true, + case_mode: None, file_offset: 0, page_limit: 200, mode: GrepMode::Regex, @@ -68,6 +70,7 @@ fn fuzzy_opts() -> GrepSearchOptions { max_file_size: 10 * 1024 * 1024, max_matches_per_file: 200, smart_case: true, + case_mode: None, file_offset: 0, page_limit: 200, mode: GrepMode::Fuzzy, diff --git a/crates/fff-core/tests/new_directory_watcher_test.rs b/crates/fff-core/tests/new_directory_watcher_test.rs index 9cb9fc11..3c1878e8 100644 --- a/crates/fff-core/tests/new_directory_watcher_test.rs +++ b/crates/fff-core/tests/new_directory_watcher_test.rs @@ -133,6 +133,7 @@ fn grep_plain_count(picker: &FilePicker, query: &str) -> usize { max_file_size: 10 * 1024 * 1024, max_matches_per_file: 200, smart_case: true, + case_mode: None, file_offset: 0, page_limit: 500, mode: GrepMode::PlainText, diff --git a/crates/fff-core/tests/path_separator_constraint_test.rs b/crates/fff-core/tests/path_separator_constraint_test.rs index 9519ef83..a26c44d6 100644 --- a/crates/fff-core/tests/path_separator_constraint_test.rs +++ b/crates/fff-core/tests/path_separator_constraint_test.rs @@ -37,6 +37,7 @@ fn plain_opts() -> GrepSearchOptions { max_file_size: 10 * 1024 * 1024, max_matches_per_file: 200, smart_case: true, + case_mode: None, file_offset: 0, page_limit: 200, mode: GrepMode::PlainText, diff --git a/crates/fff-core/tests/real_binary_fixtures.rs b/crates/fff-core/tests/real_binary_fixtures.rs index d7f383b6..f4de9af8 100644 --- a/crates/fff-core/tests/real_binary_fixtures.rs +++ b/crates/fff-core/tests/real_binary_fixtures.rs @@ -31,6 +31,7 @@ fn plain_opts() -> GrepSearchOptions { max_file_size: 10 * 1024 * 1024, max_matches_per_file: 200, smart_case: true, + case_mode: None, file_offset: 0, page_limit: 200, mode: GrepMode::PlainText, diff --git a/crates/fff-mcp/src/server.rs b/crates/fff-mcp/src/server.rs index 0fc6102a..22a87e4c 100644 --- a/crates/fff-mcp/src/server.rs +++ b/crates/fff-mcp/src/server.rs @@ -67,6 +67,7 @@ fn make_grep_options( max_file_size: 10 * 1024 * 1024, max_matches_per_file: matches_per_file, smart_case: true, + case_mode: None, file_offset, page_limit: 50, mode, diff --git a/crates/fff-nvim/benches/fuzzy_search_bench.rs b/crates/fff-nvim/benches/fuzzy_search_bench.rs index 8a1fcd57..12450aea 100644 --- a/crates/fff-nvim/benches/fuzzy_search_bench.rs +++ b/crates/fff-nvim/benches/fuzzy_search_bench.rs @@ -628,6 +628,7 @@ fn bench_grep_search(c: &mut Criterion) { max_file_size: 10 * 1024 * 1024, max_matches_per_file: 0, smart_case: true, + case_mode: None, file_offset: 0, page_limit: 100, mode: GrepMode::PlainText, diff --git a/crates/fff-nvim/benches/grep_bench.rs b/crates/fff-nvim/benches/grep_bench.rs index 64939946..529b3bcd 100644 --- a/crates/fff-nvim/benches/grep_bench.rs +++ b/crates/fff-nvim/benches/grep_bench.rs @@ -111,6 +111,7 @@ fn plain_options() -> GrepSearchOptions { max_file_size: 10 * 1024 * 1024, max_matches_per_file: 200, smart_case: true, + case_mode: None, file_offset: 0, page_limit: 50, mode: GrepMode::PlainText, diff --git a/crates/fff-nvim/src/bin/bench_grep_query.rs b/crates/fff-nvim/src/bin/bench_grep_query.rs index 063d0816..b9c70609 100644 --- a/crates/fff-nvim/src/bin/bench_grep_query.rs +++ b/crates/fff-nvim/src/bin/bench_grep_query.rs @@ -23,6 +23,7 @@ fn run_grep(picker: &FilePicker, query: &str, iters: usize) { max_file_size: 10 * 1024 * 1024, max_matches_per_file: 200, smart_case: true, + case_mode: None, file_offset: 0, page_limit: usize::MAX, mode: GrepMode::PlainText, diff --git a/crates/fff-nvim/src/bin/fuzzy_grep_test.rs b/crates/fff-nvim/src/bin/fuzzy_grep_test.rs index e3bceac8..5b1b1a9b 100644 --- a/crates/fff-nvim/src/bin/fuzzy_grep_test.rs +++ b/crates/fff-nvim/src/bin/fuzzy_grep_test.rs @@ -28,6 +28,7 @@ fn run_fuzzy_query(picker: &FilePicker, query: &str, label: &str) { max_file_size: 10 * 1024 * 1024, max_matches_per_file: 200, smart_case: true, + case_mode: None, file_offset: 0, page_limit: 100, mode: GrepMode::Fuzzy, diff --git a/crates/fff-nvim/src/bin/grep_profiler.rs b/crates/fff-nvim/src/bin/grep_profiler.rs index 4c0386b2..186999ae 100644 --- a/crates/fff-nvim/src/bin/grep_profiler.rs +++ b/crates/fff-nvim/src/bin/grep_profiler.rs @@ -89,6 +89,7 @@ impl<'a> GrepBench<'a> { max_file_size: 10 * 1024 * 1024, max_matches_per_file: 200, smart_case: true, + case_mode: None, file_offset: 0, page_limit: 50, mode, @@ -410,6 +411,7 @@ fn main() { max_file_size: 10 * 1024 * 1024, max_matches_per_file: 200, smart_case: true, + case_mode: None, file_offset, page_limit: 50, mode: Default::default(), diff --git a/crates/fff-nvim/src/bin/grep_vs_rg.rs b/crates/fff-nvim/src/bin/grep_vs_rg.rs index f202fc1f..eb278d16 100644 --- a/crates/fff-nvim/src/bin/grep_vs_rg.rs +++ b/crates/fff-nvim/src/bin/grep_vs_rg.rs @@ -163,6 +163,7 @@ fn run_fff_full(picker: &FilePicker, query: &str) -> (usize, Duration) { max_file_size: 10 * 1024 * 1024, max_matches_per_file: usize::MAX, smart_case: true, + case_mode: None, file_offset: 0, page_limit: usize::MAX, mode: Default::default(), @@ -186,6 +187,7 @@ fn run_fff_page(picker: &FilePicker, query: &str) -> (usize, Duration) { max_file_size: 10 * 1024 * 1024, max_matches_per_file: 200, smart_case: true, + case_mode: None, file_offset: 0, page_limit: 50, mode: Default::default(), diff --git a/crates/fff-nvim/src/lib.rs b/crates/fff-nvim/src/lib.rs index a9f3cb2d..25dccdde 100644 --- a/crates/fff-nvim/src/lib.rs +++ b/crates/fff-nvim/src/lib.rs @@ -436,6 +436,7 @@ pub fn live_grep( grep_mode, time_budget_ms, trim_whitespace, + case_mode, ): ( String, Option, @@ -446,6 +447,7 @@ pub fn live_grep( Option, Option, Option, + Option, ), ) -> LuaResult { let file_picker_guard = FILE_PICKER.read().into_lua_result()?; @@ -460,10 +462,18 @@ pub fn live_grep( _ => fff::GrepMode::PlainText, // "plain" or nil or unknown }; + let case_mode = match case_mode.as_deref() { + Some("smart") => Some(fff::CaseMode::Smart), + Some("sensitive") => Some(fff::CaseMode::Sensitive), + Some("insensitive") => Some(fff::CaseMode::Insensitive), + _ => None, + }; + let options = fff::GrepSearchOptions { max_file_size: max_file_size.unwrap_or(10 * 1024 * 1024), max_matches_per_file: max_matches_per_file.unwrap_or(200), smart_case: smart_case.unwrap_or(true), + case_mode, file_offset: file_offset.unwrap_or(0), page_limit: page_size.unwrap_or(50), mode, diff --git a/crates/fff-python/src/finder.rs b/crates/fff-python/src/finder.rs index e61232fc..d12d575f 100644 --- a/crates/fff-python/src/finder.rs +++ b/crates/fff-python/src/finder.rs @@ -91,6 +91,7 @@ fn grep_options( max_file_size: defaulted_u64(max_file_size, defaults.max_file_size), max_matches_per_file: max_matches_per_file as usize, smart_case, + case_mode: None, file_offset: cursor_offset, page_limit: defaulted_usize(page_limit, defaults.page_limit), mode, diff --git a/lua/fff/conf.lua b/lua/fff/conf.lua index dcb53e01..d5c3d968 100644 --- a/lua/fff/conf.lua +++ b/lua/fff/conf.lua @@ -56,6 +56,7 @@ local M = {} --- @field max_file_size number --- @field max_matches_per_file number --- @field smart_case boolean +--- @field case_mode? "smart"|"sensitive"|"insensitive" --- @field time_budget_ms number --- @field modes string[] --- @field trim_whitespace boolean @@ -414,7 +415,8 @@ local function init() grep = { max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB max_matches_per_file = 100, -- Maximum matches per file (set 0 to unlimited) - smart_case = true, -- Case-insensitive unless query has uppercase + smart_case = true, -- Case-insensitive unless query has uppercase (legacy; prefer case_mode) + case_mode = nil, -- Optional: "smart" | "sensitive" | "insensitive". Overrides smart_case when set. 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 trim_whitespace = false, -- Strip leading whitespace from matched lines (useful for cleaner display) diff --git a/lua/fff/main.lua b/lua/fff/main.lua index 1418a2d7..59eee551 100644 --- a/lua/fff/main.lua +++ b/lua/fff/main.lua @@ -302,6 +302,7 @@ end --- @field max_file_size? number Skip files larger than N bytes (default: config.grep.max_file_size). --- @field max_matches_per_file? number Cap matches per file, 0 = unlimited (default: config.grep.max_matches_per_file). --- @field smart_case? boolean Case-insensitive when query is all lowercase (default: config.grep.smart_case). +--- @field case_mode? "smart"|"sensitive"|"insensitive" Explicit case mode (default: config.grep.case_mode). Overrides smart_case. --- @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). @@ -348,6 +349,7 @@ function M.content_search(query, opts) max_file_size = opts.max_file_size or grep_cfg.max_file_size, 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, + case_mode = opts.case_mode == nil and grep_cfg.case_mode or opts.case_mode, time_budget_ms = opts.time_budget_ms or grep_cfg.time_budget_ms, 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 ef022afc..db20acc7 100644 --- a/lua/fff/picker_ui/grep_renderer.lua +++ b/lua/fff/picker_ui/grep_renderer.lua @@ -40,7 +40,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.case_mode ) return last_result end