diff --git a/README.md b/README.md index e84a5360d..f34b7211e 100644 --- a/README.md +++ b/README.md @@ -340,6 +340,9 @@ require('fff').setup({ git = { status_text_color = false, -- true to color filenames by git status }, + file_picker = { + fuzzy_query_highlighting = false, -- true to highlight fuzzy query matches in file picker results + }, select = { -- Return winid to open the chosen file in, or nil to open in the original window select_window = function(current_buf, action) --[[ default impl ]] end, diff --git a/crates/fff-core/src/file_picker.rs b/crates/fff-core/src/file_picker.rs index 126352f04..2037bb435 100644 --- a/crates/fff-core/src/file_picker.rs +++ b/crates/fff-core/src/file_picker.rs @@ -40,7 +40,7 @@ use crate::grep::{GrepResult, GrepSearchOptions, grep_search, multi_grep_search} use crate::index::{BigramFilter, BigramOverlay}; use crate::query_tracker::QueryTracker; use crate::scan::{ScanConfig, ScanJob, ScanSignals}; -use crate::score::fuzzy_match_and_score_files; +use crate::score::{fuzzy_match_and_score_files, fuzzy_match_byte_offsets_for_page}; use crate::shared::{SharedFilePicker, SharedFrecency}; use crate::simd_path::ArenaPtr; use crate::stable_vec::StableVec; @@ -994,6 +994,8 @@ impl FilePicker { base_arena, overflow_arena, ); + let match_byte_offsets = + fuzzy_match_byte_offsets_for_page(query, &items, max_typos, base_arena, overflow_arena); info!( ?query, @@ -1007,6 +1009,7 @@ impl FilePicker { SearchResult { items, scores, + match_byte_offsets, total_matched, total_files, location, diff --git a/crates/fff-core/src/score.rs b/crates/fff-core/src/score.rs index 00cbff2ab..b74a4cf1b 100644 --- a/crates/fff-core/src/score.rs +++ b/crates/fff-core/src/score.rs @@ -6,9 +6,10 @@ use crate::{ sort_buffer::{sort_by_key_with_buffer, sort_with_buffer}, types::{DirItem, FileItem, Score, ScoringContext}, }; -use fff_query_parser::FuzzyQuery; +use fff_query_parser::{FFFQuery, FuzzyQuery}; use neo_frizbee::Scoring; use rayon::prelude::*; +use smallvec::SmallVec; use std::{borrow::Cow, path::MAIN_SEPARATOR}; enum FileItems<'a> { @@ -168,6 +169,128 @@ pub(crate) fn fuzzy_match_and_score_files<'a>( sort_and_paginate(results, context) } +pub(crate) fn fuzzy_match_byte_offsets_for_page<'q>( + query: &'q FFFQuery<'q>, + items: &[&FileItem], + max_typos: u16, + base_arena: ArenaPtr, + overflow_arena: ArenaPtr, +) -> Vec> { + let parts: Vec<&str> = match &query.fuzzy_query { + FuzzyQuery::Text(text) if text.len() >= 2 => vec![*text], + FuzzyQuery::Parts(parts) => parts.iter().copied().filter(|p| p.len() >= 2).collect(), + _ => Vec::new(), + }; + + let mut ranges_by_item = vec![SmallVec::new(); items.len()]; + if parts.is_empty() || items.is_empty() { + return ranges_by_item; + } + + let paths: Vec = items + .iter() + .map(|item| { + let arena = if item.is_overflow() { + overflow_arena + } else { + base_arena + }; + let mut path = String::with_capacity(item.relative_path_len()); + item.write_relative_path_from_arena(arena, &mut path); + path + }) + .collect(); + + let has_uppercase = parts + .iter() + .any(|part| part.chars().any(|ch| ch.is_uppercase())); + let config = neo_frizbee::Config { + max_typos: Some(max_typos), + sort: false, + scoring: Scoring { + capitalization_bonus: if has_uppercase { 8 } else { 0 }, + matching_case_bonus: if has_uppercase { 4 } else { 0 }, + ..Default::default() + }, + ..Default::default() + }; + + for (idx, part) in parts.iter().copied().enumerate() { + let mut part_config = config; + if idx > 0 { + part_config.max_typos = config.max_typos.map(|t| t.min(part.len() as u16)); + } + + let mut matcher = neo_frizbee::Matcher::new(part, &part_config); + for mut matched in matcher.match_list_indices(&paths) { + let item_idx = matched.index as usize; + let Some(path) = paths.get(item_idx) else { + continue; + }; + + matched.indices.sort_unstable(); + ranges_by_item[item_idx].extend(char_indices_to_byte_offsets(path, &matched.indices)); + } + } + + for ranges in &mut ranges_by_item { + *ranges = merge_byte_offsets(std::mem::take(ranges)); + } + + ranges_by_item +} + +fn char_indices_to_byte_offsets(line: &str, char_indices: &[usize]) -> SmallVec<[(u32, u32); 4]> { + let char_byte_ranges: Vec<(usize, usize)> = line + .char_indices() + .map(|(byte_pos, ch)| (byte_pos, byte_pos + ch.len_utf8())) + .collect(); + let mut result: SmallVec<[(u32, u32); 4]> = SmallVec::with_capacity(char_indices.len()); + + for &char_idx in char_indices { + let Some(&(start, end)) = char_byte_ranges.get(char_idx) else { + continue; + }; + + if let Some(last) = result.last_mut() + && last.1 == start as u32 + { + last.1 = end as u32; + continue; + } + + result.push((start as u32, end as u32)); + } + + result +} + +fn merge_byte_offsets(mut ranges: SmallVec<[(u32, u32); 4]>) -> SmallVec<[(u32, u32); 4]> { + if ranges.len() <= 1 { + return ranges; + } + + ranges.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1))); + let mut merged: SmallVec<[(u32, u32); 4]> = SmallVec::with_capacity(ranges.len()); + + for (start, end) in ranges { + if end <= start { + continue; + } + + if let Some(last) = merged.last_mut() + && start <= last.1 + { + last.1 = last.1.max(end); + continue; + } + + merged.push((start, end)); + } + + merged +} + /// Resolve a DirItem's chunked path into frizbee's pointer buffer. #[inline] fn resolve_dir_chunks( diff --git a/crates/fff-core/src/types.rs b/crates/fff-core/src/types.rs index 516953671..76a24cec3 100644 --- a/crates/fff-core/src/types.rs +++ b/crates/fff-core/src/types.rs @@ -831,6 +831,7 @@ impl ScoringContext<'_> { pub struct SearchResult<'a> { pub items: Vec<&'a FileItem>, pub scores: Vec, + pub match_byte_offsets: Vec>, pub total_matched: usize, pub total_files: usize, pub location: Option, diff --git a/crates/fff-nvim/src/lib.rs b/crates/fff-nvim/src/lib.rs index a9f3cb2d4..4ea4208af 100644 --- a/crates/fff-nvim/src/lib.rs +++ b/crates/fff-nvim/src/lib.rs @@ -322,6 +322,7 @@ pub fn fuzzy_search_files( match_type: "path", ..Default::default() }], + match_byte_offsets: vec![Default::default()], total_matched: 1, total_files: results.total_files, location: parsed_query.location, diff --git a/crates/fff-nvim/src/lua_types.rs b/crates/fff-nvim/src/lua_types.rs index 83ef10af1..fa82fe82d 100644 --- a/crates/fff-nvim/src/lua_types.rs +++ b/crates/fff-nvim/src/lua_types.rs @@ -109,6 +109,19 @@ fn score_into_lua(score: &Score, lua: &Lua) -> LuaResult { Ok(LuaValue::Table(table)) } +fn set_match_ranges(lua: &Lua, item: &LuaTable, ranges: &[(u32, u32)]) -> LuaResult<()> { + let ranges_table = lua.create_table()?; + + for (i, &(start, end)) in ranges.iter().enumerate() { + let range = lua.create_table()?; + range.set(1, start)?; + range.set(2, end)?; + ranges_table.set(i + 1, range)?; + } + + item.set("match_ranges", ranges_table) +} + fn location_into_lua(location: &Location, lua: &Lua) -> LuaResult { let table = lua.create_table()?; match location { @@ -134,7 +147,13 @@ impl IntoLua for SearchResultLua<'_> { // Convert items let items_table = lua.create_table()?; for (i, item) in self.inner.items.iter().enumerate() { - items_table.set(i + 1, file_item_into_lua(item, lua, self.picker)?)?; + let lua_item = file_item_into_lua(item, lua, self.picker)?; + if let LuaValue::Table(item_table) = &lua_item + && let Some(ranges) = self.inner.match_byte_offsets.get(i) + { + set_match_ranges(lua, item_table, ranges.as_slice())?; + } + items_table.set(i + 1, lua_item)?; } table.set("items", items_table)?; @@ -246,15 +265,7 @@ impl IntoLua for GrepResultLua<'_> { item.set("is_binary_content", is_binary_content)?; item.set("line_content", m.line_content.as_str())?; - // Match byte ranges within line_content - let ranges = lua.create_table()?; - for (j, &(start, end)) in m.match_byte_offsets.iter().enumerate() { - let range = lua.create_table()?; - range.set(1, start)?; - range.set(2, end)?; - ranges.set(j + 1, range)?; - } - item.set("match_ranges", ranges)?; + set_match_ranges(lua, &item, m.match_byte_offsets.as_slice())?; // Fuzzy match score (only set in fuzzy grep mode, nil otherwise) if let Some(score) = m.fuzzy_score { diff --git a/lua/fff/conf.lua b/lua/fff/conf.lua index 6b5644471..d9d806433 100644 --- a/lua/fff/conf.lua +++ b/lua/fff/conf.lua @@ -418,6 +418,7 @@ local function init() -- find_files settings file_picker = { current_file_label = '(current)', + fuzzy_query_highlighting = false, }, -- grep settings grep = { diff --git a/lua/fff/picker_ui/file_renderer.lua b/lua/fff/picker_ui/file_renderer.lua index b185dc0cd..caed1e36f 100644 --- a/lua/fff/picker_ui/file_renderer.lua +++ b/lua/fff/picker_ui/file_renderer.lua @@ -14,6 +14,7 @@ local M = {} --- @field access_frecency_score number Access-based frecency score --- @field modification_frecency_score number Modification-based frecency score --- @field git_status string|nil Git status string (e.g. 'modified', 'untracked') if file is in git repo +--- @field match_ranges number[][]|nil Byte ranges for fuzzy query matches --- Render a file item line --- @param item FileItem File item from Rust @@ -199,17 +200,67 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont }) end - -- 9. Query match + -- 9. Query matches if ctx.query and ctx.query ~= '' then - local match_start, match_end = string.find(line_content, ctx.query, 1, true) - if match_start and match_end then - vim.api.nvim_buf_set_extmark( - buf, - ns_id, - line_idx - 1, - match_start - 1, - { end_col = match_end, hl_group = ctx.config.hl.matched or 'IncSearch' } - ) + local matched_hl = ctx.config.hl.matched or 'IncSearch' + local fuzzy_highlighting = ctx.config.file_picker and ctx.config.file_picker.fuzzy_query_highlighting + + if not fuzzy_highlighting then + local match_start, match_end = string.find(line_content, ctx.query, 1, true) + if match_start and match_end then + vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, match_start - 1, { + end_col = match_end, + hl_group = matched_hl, + }) + end + return + end + + local ranges = item.match_ranges + if not ranges or #ranges == 0 then return end + + local rel_path = item.relative_path or '' + if type(rel_path) ~= 'string' then rel_path = tostring(rel_path) end + + local original_dir_path = '' + local parent_dir = vim.fn.fnamemodify(rel_path, ':h') + if parent_dir ~= '.' and parent_dir ~= '' then original_dir_path = parent_dir end + + local filename_rel_start = math.max(0, #rel_path - #filename) + local filename_rel_end = filename_rel_start + #filename + local filename_line_start = icon and (#icon + 1) or 0 + local dir_line_start = filename_line_start + #filename + 1 + local segments = { { filename_rel_start, filename_rel_end, filename_line_start } } + + if original_dir_path ~= '' and dir_path == original_dir_path then + segments[#segments + 1] = { 0, #original_dir_path, dir_line_start } + end + + local function apply_segment(raw_start, raw_end, segment) + local source_start, source_end, target_start = segment[1], segment[2], segment[3] + local start_col = math.max(raw_start, source_start) + local end_col = math.min(raw_end, source_end) + if end_col <= start_col then return end + + local hl_start = target_start + (start_col - source_start) + local hl_end = target_start + (end_col - source_start) + if hl_start < #line_content and hl_end <= #line_content then + vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, hl_start, { + end_col = hl_end, + hl_group = matched_hl, + priority = 200, + }) + end + end + + for _, range in ipairs(ranges) do + local raw_start = range[1] or 0 + local raw_end = range[2] or 0 + if raw_end > raw_start then + for _, segment in ipairs(segments) do + apply_segment(raw_start, raw_end, segment) + end + end end end end