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,
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);
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