Skip to content
83 changes: 83 additions & 0 deletions crates/fff-core/src/grep.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1363,6 +1363,25 @@ fn collect_grep_results<'a>(
}
}

// Prioritize definition matches within each file while preserving
// contiguous per-file grouping for downstream renderers.
// A global sort would split matches from the same file into non-contiguous
// blocks, causing duplicate file headers in the Lua renderer.
if options.classify_definitions {
let mut start = 0;
while start < all_matches.len() {
let file_index = all_matches[start].file_index;
let mut end = start + 1;
while end < all_matches.len() && all_matches[end].file_index == file_index {
end += 1;
}
crate::sort_buffer::sort_by_key_with_buffer(&mut all_matches[start..end], |m| {
!m.is_definition
});
start = end;
}
}

// If no file had any match, we searched the entire slice.
if result_files.is_empty() {
files_consumed = files_to_search_len;
Expand Down Expand Up @@ -2396,6 +2415,70 @@ mod tests {
"Single pattern should find 1 match"
);

// Test that classify_definitions sorts defs first within each file group
// without breaking contiguous per-file ordering.
let options_with_defs = super::GrepSearchOptions {
classify_definitions: true,
..options.clone()
};
// file1 has: "pub enum GrepMode" (def), "pub struct GrepMatch" (def)
// file2 has: "struct PlainTextMatcher" (def)
// Search for both files using a pattern that hits non-def lines too.
let result_defs = super::multi_grep_search(
&files,
&["pub"],
&[],
&options_with_defs,
&ContentCacheBudget::unlimited(),
None,
Comment thread
yashksaini-coder marked this conversation as resolved.
None,
None,
);
// All matches for a given file must be contiguous (no interleaving).
if !result_defs.matches.is_empty() {
let mut last_file = result_defs.matches[0].file_index;
let mut seen_files = std::collections::HashSet::new();
seen_files.insert(last_file);
Comment on lines +2437 to +2441
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new test asserts ordering invariants but doesn’t assert that the result set actually contains at least one is_definition == true match (and at least one non-definition) to make the assertions meaningful. If definition classification regresses to always-false, this test can still pass vacuously; consider adding explicit assertions about the presence of both kinds of matches in result_defs.matches.

Copilot uses AI. Check for mistakes.
for m in result_defs.matches.iter().skip(1) {
if m.file_index != last_file {
assert!(
!seen_files.contains(&m.file_index),
"classify_definitions broke file contiguity: file {} appeared non-contiguously",
m.file_index
);
seen_files.insert(m.file_index);
last_file = m.file_index;
}
}
}
// Within each file group, definition matches must precede non-definition matches.
{
let mut i = 0;
while i < result_defs.matches.len() {
let file_index = result_defs.matches[i].file_index;
let group_start = i;
while i < result_defs.matches.len()
&& result_defs.matches[i].file_index == file_index
{
i += 1;
}
let group = &result_defs.matches[group_start..i];
// Definitions should not appear after a non-definition in the same file.
let mut saw_non_def = false;
for m in group {
if !m.is_definition {
saw_non_def = true;
} else {
assert!(
!saw_non_def,
"classify_definitions: definition appeared after non-definition in file {}",
file_index
);
}
}
}
}

// Test with empty patterns
let result3 = super::multi_grep_search(
&files,
Expand Down
3 changes: 2 additions & 1 deletion 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,
classify_definitions,
trim_whitespace,
): (
String,
Expand Down Expand Up @@ -315,7 +316,7 @@ pub fn live_grep(
time_budget_ms: time_budget_ms.unwrap_or(0),
before_context: 0,
after_context: 0,
classify_definitions: false,
classify_definitions: classify_definitions.unwrap_or(false),
trim_whitespace: trim_whitespace.unwrap_or(false),
};

Expand Down
1 change: 1 addition & 0 deletions crates/fff-nvim/src/lua_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ impl IntoLua for GrepResultLua<'_> {
item.set("col", m.col)?;
item.set("byte_offset", m.byte_offset)?;
item.set("line_content", m.line_content.as_str())?;
item.set("is_definition", m.is_definition)?;

// Match byte ranges within line_content
let ranges = lua.create_table()?;
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 classify_definitions boolean
--- @field trim_whitespace boolean

--- @class FffConfig
Expand Down Expand Up @@ -333,6 +334,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
classify_definitions = false, -- Mark definition lines like fn/struct/class
trim_whitespace = false, -- Strip leading whitespace from matched lines (useful for cleaner display)
},
}
Expand Down
10 changes: 10 additions & 0 deletions lua/fff/grep/grep_renderer.lua
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ end
local function render_match_line(item, ctx)
local location = string.format(':%d:%d', item.line_number or 0, (item.col or 0) + 1)
local separator = ' '

-- vim.json.decode may return Blobs for strings with NUL bytes; coerce to string.
local raw_content = item.line_content
if type(raw_content) ~= 'string' then raw_content = raw_content and tostring(raw_content) or '' end
Expand Down Expand Up @@ -193,6 +194,15 @@ local function apply_match_highlights(item, ctx, item_idx, buf, ns_id, row, line
})
end
end

-- 7. Definition indicator as virtual text
if item.is_definition then
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, 0, {
virt_text = { { ' [def]', config.hl.combo_header or 'Number' } },
virt_text_pos = 'right_align',
priority = 250,
})
end
end

--- Render a single item's lines (called by list_renderer's generate_item_lines).
Expand Down
1 change: 1 addition & 0 deletions lua/fff/grep/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ function M.search(query, file_offset, page_size, config, grep_mode)
conf.smart_case,
grep_mode or 'plain',
conf.time_budget_ms,
conf.classify_definitions,
conf.trim_whitespace
)
return last_result
Expand Down