Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion crates/fff-core/src/file_picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -1007,6 +1009,7 @@ impl FilePicker {
SearchResult {
items,
scores,
match_byte_offsets,
total_matched,
total_files,
location,
Expand Down
125 changes: 124 additions & 1 deletion crates/fff-core/src/score.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down Expand Up @@ -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<SmallVec<[(u32, u32); 4]>> {
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<String> = 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(
Expand Down
1 change: 1 addition & 0 deletions crates/fff-core/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,7 @@ impl ScoringContext<'_> {
pub struct SearchResult<'a> {
pub items: Vec<&'a FileItem>,
pub scores: Vec<Score>,
pub match_byte_offsets: Vec<smallvec::SmallVec<[(u32, u32); 4]>>,
pub total_matched: usize,
pub total_files: usize,
pub location: Option<Location>,
Expand Down
1 change: 1 addition & 0 deletions crates/fff-nvim/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 21 additions & 10 deletions crates/fff-nvim/src/lua_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,19 @@ fn score_into_lua(score: &Score, lua: &Lua) -> LuaResult<LuaValue> {
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<LuaValue> {
let table = lua.create_table()?;
match location {
Expand All @@ -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)?;

Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions lua/fff/conf.lua
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ local function init()
-- find_files settings
file_picker = {
current_file_label = '(current)',
fuzzy_query_highlighting = false,
},
-- grep settings
grep = {
Expand Down
71 changes: 61 additions & 10 deletions lua/fff/picker_ui/file_renderer.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment thread
pompos02 marked this conversation as resolved.
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
Expand Down
Loading