diff --git a/crates/fff-core/src/grep.rs b/crates/fff-core/src/grep.rs index 633493cc..700e63de 100644 --- a/crates/fff-core/src/grep.rs +++ b/crates/fff-core/src/grep.rs @@ -1248,6 +1248,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; @@ -2197,6 +2216,68 @@ 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, + ); + // 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, diff --git a/crates/fff-nvim/src/lib.rs b/crates/fff-nvim/src/lib.rs index 8e31c38b..a9601eb3 100644 --- a/crates/fff-nvim/src/lib.rs +++ b/crates/fff-nvim/src/lib.rs @@ -279,6 +279,7 @@ pub fn live_grep( smart_case, grep_mode, time_budget_ms, + classify_definitions, ): ( String, Option, @@ -288,6 +289,7 @@ pub fn live_grep( Option, Option, Option, + Option, ), ) -> LuaResult { let file_picker_guard = FILE_PICKER.read().into_lua_result()?; @@ -312,7 +314,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), }; let result = picker.grep(&parsed, &options); diff --git a/crates/fff-nvim/src/lua_types.rs b/crates/fff-nvim/src/lua_types.rs index d196bc1c..e1096386 100644 --- a/crates/fff-nvim/src/lua_types.rs +++ b/crates/fff-nvim/src/lua_types.rs @@ -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()?; diff --git a/lua/fff/conf.lua b/lua/fff/conf.lua index 40ff78e1..2ce4d6eb 100644 --- a/lua/fff/conf.lua +++ b/lua/fff/conf.lua @@ -54,6 +54,7 @@ local M = {} --- @field smart_case boolean --- @field time_budget_ms number --- @field modes string[] +--- @field classify_definitions boolean --- @class FffConfig --- @field base_path string @@ -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 + classify_definitions = false, -- Mark definition lines like fn/struct/class }, } diff --git a/lua/fff/grep/grep_renderer.lua b/lua/fff/grep/grep_renderer.lua index f4ca793e..b3389e09 100644 --- a/lua/fff/grep/grep_renderer.lua +++ b/lua/fff/grep/grep_renderer.lua @@ -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 @@ -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). diff --git a/lua/fff/grep/init.lua b/lua/fff/grep/init.lua index e83be925..c3eaccb1 100644 --- a/lua/fff/grep/init.lua +++ b/lua/fff/grep/init.lua @@ -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.classify_definitions ) return last_result end