From b4cd712993bc89bb62492ad126d1d63b83cca123 Mon Sep 17 00:00:00 2001 From: pompos02 Date: Tue, 14 Jul 2026 18:19:40 +0300 Subject: [PATCH 1/3] fix(picker_ui): highlight fuzzy query matches on file picker --- lua/fff/picker_ui/file_renderer.lua | 72 +++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/lua/fff/picker_ui/file_renderer.lua b/lua/fff/picker_ui/file_renderer.lua index b185dc0c..5e580021 100644 --- a/lua/fff/picker_ui/file_renderer.lua +++ b/lua/fff/picker_ui/file_renderer.lua @@ -199,16 +199,78 @@ 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 + local matched_hl = ctx.config.hl.matched or 'IncSearch' + local line_lower = line_content:lower() + local matched_ranges = {} + local token_count = 0 + + local function add_range(start_col, end_col) + if #matched_ranges >= 16 then return end + + table.insert(matched_ranges, { start_col, end_col }) + end + + local function add_subsequence_ranges(token) + local token_lower = token:lower() + local line_pos = 1 + local token_ranges = {} + + for i = 1, #token_lower do + local ch = token_lower:sub(i, i) + local match_start = line_lower:find(ch, line_pos, true) + if not match_start then return end + + table.insert(token_ranges, { match_start - 1, match_start }) + line_pos = match_start + 1 + end + + for _, range in ipairs(token_ranges) do + add_range(range[1], range[2]) + if #matched_ranges >= 16 then return end + end + end + + for token in ctx.query:gmatch('%S+') do + token_count = token_count + 1 + if token_count > 8 or #matched_ranges >= 16 then break end + + if #token >= 2 then + local token_lower = token:lower() + local match_start, match_end = line_lower:find(token_lower, 1, true) + + if match_start and match_end then + add_range(match_start - 1, match_end) + else + add_subsequence_ranges(token) + end + end + end + + table.sort(matched_ranges, function(a, b) + if a[1] == b[1] then return a[2] < b[2] end + return a[1] < b[1] + end) + + local merged = {} + -- merge adjacent matches + for _, range in ipairs(matched_ranges) do + local last = merged[#merged] + if last and range[1] <= last[2] then + last[2] = math.max(last[2], range[2]) + else + table.insert(merged, range) + end + end + + for _, range in ipairs(merged) do 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' } + range[1], + { end_col = range[2], hl_group = matched_hl, priority = 200 } ) end end From 095929d6fc87586f31ee72b79cbd2de03a98233b Mon Sep 17 00:00:00 2001 From: pompos02 Date: Wed, 15 Jul 2026 00:50:21 +0300 Subject: [PATCH 2/3] fix(file-picker): gate fuzzy query highlights behind config --- README.md | 3 +++ lua/fff/conf.lua | 1 + lua/fff/picker_ui/file_renderer.lua | 13 +++++++++++++ 3 files changed, 17 insertions(+) diff --git a/README.md b/README.md index c7bb11e0..6340d7df 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,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/lua/fff/conf.lua b/lua/fff/conf.lua index ab0683f9..e9496104 100644 --- a/lua/fff/conf.lua +++ b/lua/fff/conf.lua @@ -414,6 +414,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 5e580021..a9a592b7 100644 --- a/lua/fff/picker_ui/file_renderer.lua +++ b/lua/fff/picker_ui/file_renderer.lua @@ -202,6 +202,19 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont -- 9. Query matches if ctx.query and ctx.query ~= '' then 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 line_lower = line_content:lower() local matched_ranges = {} local token_count = 0 From 199a8ea58f0f933466b789de88280d244f9e6369 Mon Sep 17 00:00:00 2001 From: pompos02 Date: Wed, 15 Jul 2026 21:53:11 +0300 Subject: [PATCH 3/3] fix(file-picker): use rust fuzzy match ranges for highlights Compute fuzzy match byte ranges on the Rust side for paginated file results and pass them through the nvim Lua binding. --- crates/fff-core/src/file_picker.rs | 5 +- crates/fff-core/src/score.rs | 125 +++++++++++++++++++++++++++- crates/fff-core/src/types.rs | 1 + crates/fff-nvim/src/lib.rs | 1 + crates/fff-nvim/src/lua_types.rs | 31 ++++--- lua/fff/picker_ui/file_renderer.lua | 96 ++++++++------------- 6 files changed, 187 insertions(+), 72 deletions(-) diff --git a/crates/fff-core/src/file_picker.rs b/crates/fff-core/src/file_picker.rs index 0d51745d..c6d7bc97 100644 --- a/crates/fff-core/src/file_picker.rs +++ b/crates/fff-core/src/file_picker.rs @@ -40,7 +40,7 @@ use crate::git::GitStatusCache; use crate::grep::{GrepResult, GrepSearchOptions, grep_search, multi_grep_search}; 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 f757f481..0160ae04 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 932fb10d..bc74ea6b 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 a9f3cb2d..4ea4208a 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 83ef10af..fa82fe82 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/picker_ui/file_renderer.lua b/lua/fff/picker_ui/file_renderer.lua index a9a592b7..caed1e36 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 @@ -215,77 +216,52 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont return end - local line_lower = line_content:lower() - local matched_ranges = {} - local token_count = 0 + local ranges = item.match_ranges + if not ranges or #ranges == 0 then return end - local function add_range(start_col, end_col) - if #matched_ranges >= 16 then return end + local rel_path = item.relative_path or '' + if type(rel_path) ~= 'string' then rel_path = tostring(rel_path) end - table.insert(matched_ranges, { start_col, end_col }) - end - - local function add_subsequence_ranges(token) - local token_lower = token:lower() - local line_pos = 1 - local token_ranges = {} + 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 - for i = 1, #token_lower do - local ch = token_lower:sub(i, i) - local match_start = line_lower:find(ch, line_pos, true) - if not match_start then return 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 } } - table.insert(token_ranges, { match_start - 1, match_start }) - line_pos = match_start + 1 - end - - for _, range in ipairs(token_ranges) do - add_range(range[1], range[2]) - if #matched_ranges >= 16 then return end - end + if original_dir_path ~= '' and dir_path == original_dir_path then + segments[#segments + 1] = { 0, #original_dir_path, dir_line_start } end - for token in ctx.query:gmatch('%S+') do - token_count = token_count + 1 - if token_count > 8 or #matched_ranges >= 16 then break end - - if #token >= 2 then - local token_lower = token:lower() - local match_start, match_end = line_lower:find(token_lower, 1, true) - - if match_start and match_end then - add_range(match_start - 1, match_end) - else - add_subsequence_ranges(token) - 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 - table.sort(matched_ranges, function(a, b) - if a[1] == b[1] then return a[2] < b[2] end - return a[1] < b[1] - end) - - local merged = {} - -- merge adjacent matches - for _, range in ipairs(matched_ranges) do - local last = merged[#merged] - if last and range[1] <= last[2] then - last[2] = math.max(last[2], range[2]) - else - table.insert(merged, range) + 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 - - for _, range in ipairs(merged) do - vim.api.nvim_buf_set_extmark( - buf, - ns_id, - line_idx - 1, - range[1], - { end_col = range[2], hl_group = matched_hl, priority = 200 } - ) - end end end