Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ require('fff').setup({
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
trim_whitespace = false, -- Strip leading whitespace from matched lines
},
})
```
Expand Down
2 changes: 2 additions & 0 deletions crates/fff-c/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ pub unsafe extern "C" fn fff_live_grep(
before_context: before_context as usize,
after_context: after_context as usize,
classify_definitions,
trim_whitespace: false,
};

let result = picker.grep(&parsed, &options);
Expand Down Expand Up @@ -491,6 +492,7 @@ pub unsafe extern "C" fn fff_multi_grep(
before_context: before_context as usize,
after_context: after_context as usize,
classify_definitions,
trim_whitespace: false,
};

let result = fff::multi_grep_search(
Expand Down
58 changes: 58 additions & 0 deletions crates/fff-core/src/grep.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,35 @@ pub struct GrepMatch {
pub context_after: Vec<String>,
}

impl GrepMatch {
/// Strip leading whitespace from `line_content` and all context lines,
/// adjusting `col` and `match_byte_offsets` so highlights remain correct.
pub fn trim_leading_whitespace(&mut self) {
let strip_len = self.line_content.len() - self.line_content.trim_start().len();
if strip_len > 0 {
self.line_content.drain(..strip_len);
let off = strip_len as u32;
self.col = self.col.saturating_sub(strip_len);
for range in &mut self.match_byte_offsets {
range.0 = range.0.saturating_sub(off);
range.1 = range.1.saturating_sub(off);
}
}
for line in &mut self.context_before {
let n = line.len() - line.trim_start().len();
if n > 0 {
line.drain(..n);
}
}
for line in &mut self.context_after {
let n = line.len() - line.trim_start().len();
if n > 0 {
line.drain(..n);
}
}
}
}

/// Result of a grep search.
#[derive(Debug, Clone, Default)]
pub struct GrepResult<'a> {
Expand Down Expand Up @@ -326,6 +355,28 @@ pub struct GrepSearchOptions {
/// Whether to classify each match as a definition line. Adds ~2% overhead
/// on large repos; disable for interactive grep where it is not needed.
pub classify_definitions: bool,
/// Strip leading whitespace from matched lines and context lines, adjusting
/// highlight byte offsets accordingly. Useful for AI/MCP consumers and UIs
/// that don't need indentation. Default: false.
pub trim_whitespace: bool,
}

impl Default for GrepSearchOptions {
fn default() -> Self {
Self {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 200,
smart_case: true,
file_offset: 0,
page_limit: 50,
mode: GrepMode::default(),
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
}
}
}

#[derive(Clone, Copy)]
Expand Down Expand Up @@ -1157,6 +1208,9 @@ where

for mut m in file_matches {
m.file_index = file_result_idx;
if options.trim_whitespace {
m.trim_leading_whitespace();
}
all_matches.push(m);
}

Expand Down Expand Up @@ -1237,6 +1291,9 @@ fn collect_grep_results<'a>(

for mut m in file_matches {
m.file_index = file_result_idx;
if options.trim_whitespace {
m.trim_leading_whitespace();
}
all_matches.push(m);
}

Expand Down Expand Up @@ -2158,6 +2215,7 @@ mod tests {
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
};

// Test with 3 patterns
Expand Down
1 change: 1 addition & 0 deletions crates/fff-core/tests/bigram_overlay_coherence_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1373,6 +1373,7 @@ fn grep_opts() -> GrepSearchOptions {
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/fff-core/tests/bigram_overlay_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ fn grep_opts() -> GrepSearchOptions {
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
}
}

Expand Down
3 changes: 3 additions & 0 deletions crates/fff-core/tests/grep_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ fn plain_opts() -> GrepSearchOptions {
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
}
}

Expand All @@ -45,6 +46,7 @@ fn regex_opts() -> GrepSearchOptions {
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
}
}

Expand All @@ -61,6 +63,7 @@ fn fuzzy_opts() -> GrepSearchOptions {
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
}
}

Expand Down
46 changes: 17 additions & 29 deletions crates/fff-mcp/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,34 +81,19 @@ fn trauncate_line_for_ai(
match_ranges: Option<&[(u32, u32)]>,
max_len: usize,
) -> String {
// Strip leading/trailing whitespace to save tokens — the LLM has file:line for location.
let trimmed = line.trim();
// Leading whitespace is already stripped by core (trim_whitespace option).
// Only strip trailing whitespace here.
let trimmed = line.trim_end();
if trimmed.is_empty() {
return String::new();
}

let strip_offset = line.len() - line.trim_start().len();

if trimmed.len() <= max_len {
return trimmed.to_string();
}

// Adjust match ranges for the stripped leading whitespace
let adjusted: Vec<(u32, u32)>;
let ranges = match match_ranges {
Some(r) if strip_offset > 0 => {
let off = strip_offset as u32;
adjusted = r
.iter()
.map(|&(s, e)| (s.saturating_sub(off), e.saturating_sub(off)))
.collect();
Some(adjusted.as_slice())
}
other => other,
};

// Use first match range to center the window
if let Some(ranges) = ranges
if let Some(ranges) = match_ranges
&& let Some(&(match_start, match_end)) = ranges.first()
{
let match_start = match_start as usize;
Expand Down Expand Up @@ -569,26 +554,29 @@ mod tests {
use super::*;

#[test]
fn trunc_strips_whitespace() {
assert_eq!(trauncate_line_for_ai(" foo()", None, 180), "foo()");
assert_eq!(trauncate_line_for_ai(" bar ", None, 180), "bar");
fn trunc_strips_trailing_whitespace() {
// Leading whitespace is now stripped by core's trim_whitespace option.
// This function only strips trailing whitespace.
assert_eq!(trauncate_line_for_ai("foo()", None, 180), "foo()");
assert_eq!(trauncate_line_for_ai("bar ", None, 180), "bar");
assert_eq!(trauncate_line_for_ai(" ", None, 180), "");
}

#[test]
fn trunc_adjusts_match_ranges_after_strip() {
// " hello" — match on "hello" at bytes 4..9
let line = " hello";
let ranges = [(4, 9)];
fn trunc_preserves_pre_trimmed_match_ranges() {
// Core already stripped leading whitespace and adjusted offsets,
// so "hello" arrives with match at bytes 0..5.
let line = "hello";
let ranges = [(0, 5)];
let result = trauncate_line_for_ai(line, Some(&ranges), 180);
// After stripping 4 leading spaces, the trimmed line is "hello"
assert_eq!(result, "hello");
}

#[test]
fn trunc_long_line_centered() {
let line = format!("{}match_here{}", " ".repeat(8), "x".repeat(200));
let ranges = [(8u32, 18u32)];
// Core already stripped leading whitespace; offsets are pre-adjusted.
let line = format!("match_here{}", "x".repeat(200));
let ranges = [(0u32, 10u32)];
let result = trauncate_line_for_ai(&line, Some(&ranges), 50);
assert!(result.contains("match_here"));
assert!(result.len() <= 55); // budget + ellipsis chars
Expand Down
1 change: 1 addition & 0 deletions crates/fff-mcp/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ fn make_grep_options(
before_context: ctx_lines,
after_context: after_ctx,
classify_definitions: true,
trim_whitespace: true,
},
auto_expand,
)
Expand Down
1 change: 1 addition & 0 deletions crates/fff-nvim/benches/indexing_and_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,7 @@ fn bench_grep_search(c: &mut Criterion) {
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
};

let test_queries = vec![
Expand Down
1 change: 1 addition & 0 deletions crates/fff-nvim/src/bin/bench_grep_query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ fn run_grep(files: &[fff::FileItem], index: Option<&fff::BigramFilter>, query: &
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
};

let parsed = parse_grep_query(query);
Expand Down
1 change: 1 addition & 0 deletions crates/fff-nvim/src/bin/fuzzy_grep_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ fn run_fuzzy_query(files: &[FileItem], query: &str, label: &str) {
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
};

let parsed = parse_grep_query(query);
Expand Down
2 changes: 2 additions & 0 deletions crates/fff-nvim/src/bin/grep_profiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ impl<'a> GrepBench<'a> {
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
},
}
}
Expand Down Expand Up @@ -505,6 +506,7 @@ fn main() {
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
};
let start = Instant::now();
let result = grep_search(
Expand Down
3 changes: 3 additions & 0 deletions crates/fff-nvim/src/bin/grep_vs_rg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ fn run_fff_full(files: &[FileItem], query: &str) -> (usize, Duration) {
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
};
let start = Instant::now();
let result = grep_search(
Expand Down Expand Up @@ -241,6 +242,7 @@ fn benchmark_fff_smart_case(files: &[FileItem], parsed: &FFFQuery<'_>) -> (usize
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
};
let start = Instant::now();
let result = grep_search(
Expand Down Expand Up @@ -270,6 +272,7 @@ fn run_fff_page(files: &[FileItem], query: &str) -> (usize, Duration) {
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
};
let start = Instant::now();
let result = grep_search(
Expand Down
3 changes: 3 additions & 0 deletions crates/fff-nvim/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ pub fn live_grep(
smart_case,
grep_mode,
time_budget_ms,
trim_whitespace,
): (
String,
Option<usize>,
Expand All @@ -289,6 +290,7 @@ pub fn live_grep(
Option<bool>,
Option<String>,
Option<u64>,
Option<bool>,
),
) -> LuaResult<LuaValue> {
let file_picker_guard = FILE_PICKER.read().into_lua_result()?;
Expand All @@ -314,6 +316,7 @@ pub fn live_grep(
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: trim_whitespace.unwrap_or(false),
};

let result = picker.grep(&parsed, &options);
Expand Down
1 change: 1 addition & 0 deletions doc/fff.nvim.txt
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ all available options:
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
trim_whitespace = false, -- Strip leading whitespace from matched lines
},
})
<
Expand Down
2 changes: 2 additions & 0 deletions lua/fff/conf.lua
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ local M = {}
--- @field smart_case boolean
--- @field time_budget_ms number
--- @field modes string[]
--- @field trim_whitespace boolean

--- @class FffConfig
--- @field base_path string
Expand Down Expand Up @@ -332,6 +333,7 @@ local function init()
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
trim_whitespace = false, -- Strip leading whitespace from matched lines (useful for cleaner display)
},
}

Expand Down
3 changes: 2 additions & 1 deletion lua/fff/grep/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ function M.search(query, file_offset, page_size, config, grep_mode)
conf.max_matches_per_file,
conf.smart_case,
grep_mode or 'plain',
conf.time_budget_ms
conf.time_budget_ms,
conf.trim_whitespace
)
return last_result
end
Expand Down
Loading