diff --git a/Cargo.lock b/Cargo.lock index c381a395..02796730 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -240,6 +240,7 @@ dependencies = [ "notify-debouncer-full", "pathdiff", "rayon", + "strsim", "thiserror 2.0.12", "tracing", "tracing-appender", @@ -1163,6 +1164,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.104" diff --git a/Cargo.toml b/Cargo.toml index 73c4c3fc..6a9f61db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,3 +28,4 @@ thiserror = "2.0.10" tracing = "0.1" tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +strsim = "0.11.0" diff --git a/README.md b/README.md index 725bb044..dd74661e 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Contributors

-**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an opinionated fuzzy file picker for neovim. Just for files, but we'll try to solve file picking completely. +**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an opinionated fuzzy file picker for neovim. Just for files, but we'll try to solve file picking completely. It comes with a dedicated rust backend runtime that keep tracks of the file index, your file access and modifications, git status, and provides a comprehensive typo-resistant fuzzy search experience. @@ -113,6 +113,11 @@ require("fff").setup({ debug = 'Comment', }, + -- Scoring configuration + scoring = { + same_dir_preference = 0.7, -- How much to prefer files near current file (0.0-1.0) + }, + -- Debug options debug = { show_scores = false, -- Toggle with F2 or :FFFDebug @@ -196,6 +201,11 @@ require("fff").setup({ debug = 'Comment', }, + -- Scoring configuration + scoring = { + same_dir_preference = 0.7, -- How much to prefer files near current file (0.0-1.0) + }, + debug = { show_scores = true, -- We hope for your collaboratio }, diff --git a/doc/fff.nvim.txt b/doc/fff.nvim.txt index d6bacb3d..9dad5441 100644 --- a/doc/fff.nvim.txt +++ b/doc/fff.nvim.txt @@ -73,10 +73,7 @@ LAZY.NVIM < -DEFAULT CONFIGURATION ~ - -FFF.nvim comes with sensible defaults. Here’s the complete default -configuration: +DEFAULT CONFIGURATION *fff.nvim-default-configuration* >lua require("fff").setup({ @@ -93,7 +90,7 @@ configuration: title = 'FFF Files', -- Window title max_results = 60, -- Maximum search results to display max_threads = 4, -- Maximum threads for fuzzy search - + -- Key mappings (supports both single keys and arrays for multiple bindings) keymaps = { close = '', @@ -106,7 +103,7 @@ configuration: preview_scroll_up = '', preview_scroll_down = '', }, - + -- Highlight groups hl = { border = 'FloatBorder', @@ -119,7 +116,12 @@ configuration: frecency = 'Number', debug = 'Comment', }, - + + -- Scoring configuration + scoring = { + same_dir_preference = 0.7, -- How much to prefer files near current file (0.0 - 1.0) + }, + -- Debug options debug = { show_scores = false, -- Toggle with F2 or :FFFDebug @@ -160,9 +162,9 @@ Toggle scoring information display: - Enable by default with `debug.show_scores = true` > - + #### vim-plug - + ```vim Plug 'MunifTanjim/nui.nvim' Plug 'dmtrKovalenko/fff.nvim', { 'do': 'cargo build --release' } @@ -171,53 +173,6 @@ Toggle scoring information display: CONFIGURATION *fff.nvim-configuration* - -DEFAULT CONFIGURATION ~ - -FFF.nvim comes with sensible defaults. Here’s the complete default -configuration: - ->lua - require("fff").setup({ - -- UI dimensions and appearance - width = 0.8, -- Window width as fraction of screen - height = 0.8, -- Window height as fraction of screen - preview_width = 0.5, -- Preview pane width as fraction of picker - prompt = '🪿 ', -- Input prompt symbol - title = 'FFF Files', -- Window title - max_results = 60, -- Maximum search results to display - max_threads = 4, -- Maximum threads for fuzzy search - - keymaps = { - close = '', - select = '', - select_split = '', - select_vsplit = '', - select_tab = '', - move_up = { '', '' }, -- Multiple bindings supported - move_down = { '', '' }, -- Multiple bindings supported - preview_scroll_up = '', - preview_scroll_down = '', - }, - - hl = { - border = 'FloatBorder', - normal = 'Normal', - cursor = 'CursorLine', - matched = 'IncSearch', - title = 'Title', - prompt = 'Question', - active_file = 'Visual', - frecency = 'Number', - debug = 'Comment', - }, - - debug = { - show_scores = true, -- We hope for your collaboratio - }, - }) -< - Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: diff --git a/lua/fff/config_utils.lua b/lua/fff/config_utils.lua new file mode 100644 index 00000000..c364cda4 --- /dev/null +++ b/lua/fff/config_utils.lua @@ -0,0 +1,44 @@ +local M = {} + +M.DEFAULT_SAME_DIR_PREFERENCE = 0.7 +M.DEFAULT_SCORING_CONFIG = { + same_dir_preference = M.DEFAULT_SAME_DIR_PREFERENCE, +} + +--- @param config table Configuration table to validate +--- @param default_value number Default value to use if invalid +--- @return boolean True if value was valid, false if it was corrected +function M.validate_same_dir_preference(config, default_value) + default_value = default_value or M.DEFAULT_SAME_DIR_PREFERENCE + + if not config.scoring or not config.scoring.same_dir_preference then return true end + + local preference = config.scoring.same_dir_preference + if preference < 0.0 or preference > 1.0 then + vim.notify( + string.format( + "Invalid 'scoring.same_dir_preference' (%g). Must be between 0.0 and 1.0. Using default (%.1f).", + preference, + default_value + ), + vim.log.levels.WARN + ) + config.scoring.same_dir_preference = default_value + return false + end + + return true +end + +--- @param preference number User preference value between 0.0 and 1.0 +--- @return table Internal scoring parameters +function M.map_preference_to_scoring(preference) + return { + directory_distance_penalty = -8, -- Balanced penalty for different directories + filename_similarity_bonus_max = math.floor(50 * preference), -- Moderate sibling bonus (35 with default 0.7) + filename_similarity_threshold = 0.5, -- Good relevance/performance balance + max_search_directory_levels = math.floor(1 + 3 * preference), + } +end + +return M diff --git a/lua/fff/file_picker/init.lua b/lua/fff/file_picker/init.lua index 6b66597e..302a3f34 100644 --- a/lua/fff/file_picker/init.lua +++ b/lua/fff/file_picker/init.lua @@ -47,9 +47,17 @@ function M.setup(config) height = 0.8, width = 0.8, }, + scoring = { + same_dir_preference = require('fff.config_utils').DEFAULT_SAME_DIR_PREFERENCE, + }, } M.config = vim.tbl_deep_extend('force', defaults, config) + + local config_utils = require('fff.config_utils') + local internal_scoring = config_utils.map_preference_to_scoring(M.config.scoring.same_dir_preference) + M.config.scoring = vim.tbl_extend('force', M.config.scoring, internal_scoring) + M.state.config = M.config local db_path = vim.fn.stdpath('cache') .. '/fff_nvim' @@ -105,7 +113,19 @@ function M.search_files(query, max_results, max_threads, current_file) max_results = max_results or M.config.max_results max_threads = max_threads or M.config.max_threads - local ok, search_result = pcall(fuzzy.fuzzy_search_files, query, max_results, max_threads, current_file) + local distance_penalty = M.config.scoring.directory_distance_penalty + local relation_bonus_max = M.config.scoring.filename_similarity_bonus_max + local relation_similarity_threshold = M.config.scoring.filename_similarity_threshold + local ok, search_result = pcall( + fuzzy.fuzzy_search_files, + query, + max_results, + max_threads, + current_file, + distance_penalty, + relation_bonus_max, + relation_similarity_threshold + ) if not ok then vim.notify('Failed to search files: ' .. tostring(search_result), vim.log.levels.ERROR) return {} @@ -144,6 +164,7 @@ function M.get_file_score(index) special_filename_bonus = score.special_filename_bonus or 0, frecency_boost = score.frecency_boost or 0, distance_penalty = score.distance_penalty or 0, + relation_bonus = score.relation_bonus or 0, match_type = score.match_type or 'unknown', } end diff --git a/lua/fff/file_picker/preview.lua b/lua/fff/file_picker/preview.lua index 47bdc06e..a33ab236 100644 --- a/lua/fff/file_picker/preview.lua +++ b/lua/fff/file_picker/preview.lua @@ -242,7 +242,12 @@ function M.create_file_info_content(file, info, file_index) ) table.insert( lines, - string.format('Score Modifiers: frec_boost=%d, dist_penalty=%d', score.frecency_boost, score.distance_penalty) + string.format( + 'Score Modifiers: frec_boost=%d, dist_penalty=%d, rel_bonus=%d', + score.frecency_boost, + score.distance_penalty, + score.relation_bonus + ) ) else table.insert(lines, 'Score Breakdown: N/A (no score data available)') diff --git a/lua/fff/main.lua b/lua/fff/main.lua index 92d2744e..ad2121a5 100644 --- a/lua/fff/main.lua +++ b/lua/fff/main.lua @@ -76,12 +76,20 @@ function M.setup(config) icons = { enabled = true, }, + scoring = { + same_dir_preference = require('fff.config_utils').DEFAULT_SAME_DIR_PREFERENCE, + }, ui_enabled = true, } local merged_config = vim.tbl_deep_extend('force', default_config, config or {}) M.config = merged_config + local config_utils = require('fff.config_utils') + config_utils.validate_same_dir_preference(merged_config, config_utils.DEFAULT_SAME_DIR_PREFERENCE) + local internal_scoring = config_utils.map_preference_to_scoring(merged_config.scoring.same_dir_preference) + merged_config.scoring = vim.tbl_extend('force', merged_config.scoring, internal_scoring) + local db_path = merged_config.frecency.db_path or (vim.fn.stdpath('cache') .. '/fff_nvim') local ok, result = pcall(fuzzy.init_db, db_path, true) if not ok then vim.notify('Failed to initialize frecency database: ' .. result, vim.log.levels.WARN) end @@ -411,13 +419,14 @@ function M.debug_file_ordering() if score then print( string.format( - ' Total Score: %d (base=%d, name_bonus=%d, special_bonus=%d, frec=%d, dist=%d)', + ' Total Score: %d (base=%d, name_bonus=%d, special_bonus=%d, frec=%d, dist=%d, rel=%d)', score.total, score.base_score, score.filename_bonus, score.special_filename_bonus, score.frecency_boost, - score.distance_penalty + score.distance_penalty, + score.relation_bonus ) ) else diff --git a/lua/fff/picker_ui.lua b/lua/fff/picker_ui.lua index 875261e9..1398ac0d 100644 --- a/lua/fff/picker_ui.lua +++ b/lua/fff/picker_ui.lua @@ -139,6 +139,8 @@ function M.create_ui() preview.set_preview_window(M.state.preview_win) + -- Brief wait to allow immediate sibling files to appear, then render. + file_picker.wait_for_initial_scan(50) M.update_results_sync() M.clear_preview() M.update_status() @@ -463,15 +465,6 @@ function M.update_results() M.update_results_sync() end function M.update_results_sync() if not M.state.active then return end - if not M.state.current_file_cache then - local current_buf = vim.api.nvim_get_current_buf() - if current_buf and vim.api.nvim_buf_is_valid(current_buf) then - local current_file = vim.api.nvim_buf_get_name(current_buf) - M.state.current_file_cache = (current_file ~= '' and vim.fn.filereadable(current_file) == 1) and current_file - or nil - end - end - local results = file_picker.search_files( M.state.query, M.state.config.max_results, @@ -1025,15 +1018,31 @@ end function M.open(opts) if M.state.active then return end + -- Detect current file BEFORE opening picker UI + local current_buf = vim.api.nvim_get_current_buf() + if current_buf and vim.api.nvim_buf_is_valid(current_buf) then + local current_file = vim.api.nvim_buf_get_name(current_buf) + if current_file ~= '' and vim.fn.filereadable(current_file) == 1 then + -- Convert to relative path to match file picker storage format + M.state.current_file_cache = vim.fn.fnamemodify(current_file, ':.') + else + M.state.current_file_cache = nil + end + end + if not file_picker.is_initialized() then - local config = { + -- Get the main config that includes scoring settings + local main = require('fff.main') + local main_config = main.config or {} + + local config = vim.tbl_deep_extend('force', { base_path = opts and opts.cwd or vim.fn.getcwd(), max_results = 100, frecency = { enabled = true, db_path = vim.fn.stdpath('cache') .. '/fff_nvim', }, - } + }, main_config) if not file_picker.setup(config) then vim.notify('Failed to initialize file picker', vim.log.levels.ERROR) @@ -1070,11 +1079,6 @@ function M.monitor_scan_progress() vim.defer_fn(function() M.monitor_scan_progress() end, 500) else M.update_results() - - vim.defer_fn(function() - local refreshed = file_picker.refresh_git_status() - if refreshed and #refreshed > 0 then M.update_results() end - end, 500) -- Wait 500ms for git status to complete end end diff --git a/lua/fff/rust/file_picker.rs b/lua/fff/rust/file_picker.rs index 39db5df6..1ad8022b 100644 --- a/lua/fff/rust/file_picker.rs +++ b/lua/fff/rust/file_picker.rs @@ -230,8 +230,11 @@ impl FilePicker { query: &str, max_results: usize, max_threads: usize, - current_file: Option<&String>, - ) -> SearchResult { + current_file: Option, + distance_penalty: i32, + relation_bonus_max: i32, + relation_similarity_threshold: f64, + ) -> Result { let max_threads = max_threads.max(1); // Ensure at least 1 to avoid neo_frizbee division by zero debug!( @@ -244,12 +247,26 @@ impl FilePicker { let total_files = sync_data.files.len(); // small queries with a large number of results can match absolutely everything - let max_typos = (query.len() as u16 / 4).clamp(2, 6); + let max_typos = if query.len() <= 4 { + 0 + } else { + (query.len() as u16 / 5).clamp(1, 3) + }; + + // Pre-compute current file data for performance optimization. + let current_file_data = current_file + .as_deref() + .and_then(crate::types::CurrentFileData::from_path); + let context = ScoringContext { query, max_typos, max_threads, - current_file, + current_file: current_file.as_deref(), + current_file_data, + directory_distance_penalty: distance_penalty, + filename_similarity_bonus_max: relation_bonus_max, + filename_similarity_threshold: relation_similarity_threshold, }; let scored_indices = match_and_score_files(&sync_data.files, &context); @@ -280,12 +297,12 @@ impl FilePicker { ); debug!("Total search time: {:?}", time.elapsed()); - SearchResult { + Ok(SearchResult { items, scores, total_matched, total_files, - } + }) } pub fn get_cached_files(&self) -> Vec { @@ -614,7 +631,10 @@ fn scan_filesystem( }) }); - let mut files = Arc::try_unwrap(files).unwrap().into_inner().unwrap(); + let mut files = Arc::try_unwrap(files) + .map_err(|_| Error::InvalidPath("Arc unwrap failed - Arc is still shared".to_string()))? + .into_inner() + .map_err(|_| Error::InvalidPath("Mutex poisoned during file scanning".to_string()))?; let walker_time = walker_start.elapsed(); info!("SCAN: File walking completed in {:?}", walker_time); diff --git a/lua/fff/rust/lib.rs b/lua/fff/rust/lib.rs index c675b67e..e730f435 100644 --- a/lua/fff/rust/lib.rs +++ b/lua/fff/rust/lib.rs @@ -67,7 +67,23 @@ pub fn get_cached_files(_: &Lua, _: ()) -> LuaResult> { pub fn fuzzy_search_files( _: &Lua, - (query, max_results, max_threads, current_file): (String, usize, usize, Option), + ( + query, + max_results, + max_threads, + current_file, + distance_penalty, + relation_bonus_max, + relation_similarity_threshold, + ): ( + String, + usize, + usize, + Option, + Option, + Option, + Option, + ), ) -> LuaResult { let time = std::time::Instant::now(); let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?; @@ -76,7 +92,18 @@ pub fn fuzzy_search_files( .as_ref() .ok_or_else(|| Error::InvalidPath("File picker not initialized".to_string()))?; - let results = picker.fuzzy_search(&query, max_results, max_threads, current_file.as_ref()); + let distance_penalty = distance_penalty.unwrap_or(-8); + let relation_bonus_max = relation_bonus_max.unwrap_or(35); + let relation_similarity_threshold = relation_similarity_threshold.unwrap_or(0.5); + let results = picker.fuzzy_search( + &query, + max_results, + max_threads, + current_file, + distance_penalty, + relation_bonus_max, + relation_similarity_threshold, + )?; Ok(results) } diff --git a/lua/fff/rust/path_utils.rs b/lua/fff/rust/path_utils.rs index 69dd3414..4732a260 100644 --- a/lua/fff/rust/path_utils.rs +++ b/lua/fff/rust/path_utils.rs @@ -1,34 +1,156 @@ -pub fn calculate_distance_penalty(current_file: Option<&str>, candidate_path: &str) -> i32 { - let Some(ref current_path) = current_file else { - return 0; // No penalty if no current file +const MAX_PENALTY_LEVEL_MULTIPLIER: i32 = 10; + +pub fn calculate_filename_similarity_bonus( + current_file_path: &str, + candidate_file_path: &str, + max_bonus: i32, + similarity_threshold: f64, +) -> i32 { + use std::path::Path; + use strsim::jaro_winkler; + + let current_path = Path::new(current_file_path); + let candidate_path = Path::new(candidate_file_path); + + let current_stem = match current_path.file_stem().and_then(|s| s.to_str()) { + Some(stem) => stem, + None => return 0, }; + let candidate_stem = match candidate_path.file_stem().and_then(|s| s.to_str()) { + Some(stem) => stem, + None => return 0, + }; + + if current_file_path == candidate_file_path { + return 0; + } - let current_dir = if let Some(parent) = std::path::Path::new(current_path).parent() { - parent.to_string_lossy().to_string() + let similarity = jaro_winkler(current_stem, candidate_stem); + + if similarity >= similarity_threshold { + (similarity * max_bonus as f64) as i32 } else { - String::new() + 0 + } +} + +pub fn calculate_filename_similarity_bonus_optimized( + current_stem: &str, + candidate_file_path: &str, + max_bonus: i32, + similarity_threshold: f64, +) -> i32 { + use std::path::Path; + use strsim::jaro_winkler; + + if current_stem.is_empty() { + return 0; + } + + let candidate_stem = match Path::new(candidate_file_path) + .file_stem() + .and_then(|s| s.to_str()) + { + Some(stem) => stem, + None => return 0, }; - let candidate_dir = if let Some(parent) = std::path::Path::new(candidate_path).parent() { - parent.to_string_lossy().to_string() + let similarity = jaro_winkler(current_stem, candidate_stem); + + if similarity >= similarity_threshold { + (similarity * max_bonus as f64) as i32 } else { - String::new() + 0 + } +} + +pub fn calculate_directory_distance_penalty( + current_file: Option<&str>, + candidate_path: &str, + penalty_per_level: i32, +) -> i32 { + use std::path::{Component, Path}; + + let Some(current_path_str) = current_file else { + return 0; + }; + + let current_path = Path::new(current_path_str); + let candidate_path = Path::new(candidate_path); + + let current_dir = match current_path.parent() { + Some(p) => p, + None => return 0, + }; + let candidate_dir = match candidate_path.parent() { + Some(p) => p, + None => return 0, }; if current_dir == candidate_dir { - return 0; // Same directory, no penalty + return 0; + } + + let current_components: Vec<_> = current_dir + .components() + .filter(|c| matches!(c, Component::Normal(_))) + .collect(); + let candidate_components: Vec<_> = candidate_dir + .components() + .filter(|c| matches!(c, Component::Normal(_))) + .collect(); + + let common_len = current_components + .iter() + .zip(candidate_components.iter()) + .take_while(|(a, b)| a == b) + .count(); + + let current_depth_from_common = current_components.len() - common_len; + let candidate_depth_from_common = candidate_components.len() - common_len; + let total_distance = current_depth_from_common + candidate_depth_from_common; + + if total_distance == 0 { + return 0; } - let current_parts: Vec<&str> = current_dir.split('/').filter(|s| !s.is_empty()).collect(); - let candidate_parts: Vec<&str> = candidate_dir.split('/').filter(|s| !s.is_empty()).collect(); + let penalty = total_distance as i32 * penalty_per_level; - let common_len = current_parts + if penalty_per_level < 0 { + penalty.max(penalty_per_level * MAX_PENALTY_LEVEL_MULTIPLIER) + } else { + penalty.min(penalty_per_level * MAX_PENALTY_LEVEL_MULTIPLIER) + } +} + +pub fn calculate_directory_distance_penalty_optimized( + current_directory_parts: &[String], + candidate_path: &str, + penalty_per_level: i32, +) -> i32 { + use std::path::{Component, Path}; + + let candidate_path = Path::new(candidate_path); + let candidate_dir = match candidate_path.parent() { + Some(p) => p, + None => return 0, + }; + + let candidate_parts: Vec = candidate_dir + .components() + .filter_map(|c| match c { + Component::Normal(os_str) => os_str.to_str().map(|s| s.to_string()), + _ => None, + }) + .collect(); + + let common_len = current_directory_parts .iter() .zip(candidate_parts.iter()) .take_while(|(a, b)| a == b) .count(); - let current_depth_from_common = current_parts.len() - common_len; + let current_depth_from_common = current_directory_parts.len() - common_len; let candidate_depth_from_common = candidate_parts.len() - common_len; let total_distance = current_depth_from_common + candidate_depth_from_common; @@ -36,55 +158,176 @@ pub fn calculate_distance_penalty(current_file: Option<&str>, candidate_path: &s return 0; // Same path } - let penalty = -(total_distance as i32 * 2); + let penalty = total_distance as i32 * penalty_per_level; - penalty.max(-20) + if penalty_per_level < 0 { + penalty.max(penalty_per_level * MAX_PENALTY_LEVEL_MULTIPLIER) + } else { + penalty.min(penalty_per_level * MAX_PENALTY_LEVEL_MULTIPLIER) + } } #[cfg(test)] mod tests { use super::*; + + #[test] + fn test_calculate_filename_similarity_bonus() { + // Test with Jaro-Winkler similarity (different from Levenshtein scores) + + // Perfect similarity (same stem, different extensions) + assert_eq!( + calculate_filename_similarity_bonus("vector.h", "vector.cpp", 50, 0.6), + 50 + ); // 1.0 similarity + assert_eq!( + calculate_filename_similarity_bonus("api.rs", "api.md", 50, 0.6), + 50 + ); // 1.0 similarity + assert_eq!( + calculate_filename_similarity_bonus("main.js", "main.ts", 50, 0.6), + 50 + ); // 1.0 similarity + + // High similarity cases (Jaro-Winkler prefers prefix matches) + let utils_similarity = + calculate_filename_similarity_bonus("utils.rs", "utils_test.rs", 50, 0.6); + assert!( + utils_similarity > 0, + "utils.rs and utils_test.rs should have high Jaro-Winkler similarity" + ); + + let button_similarity = + calculate_filename_similarity_bonus("Button.tsx", "Button.test.tsx", 50, 0.6); + assert!( + button_similarity > 0, + "Button.tsx and Button.test.tsx should have high Jaro-Winkler similarity" + ); + + // Low similarity (below threshold) + assert_eq!( + calculate_filename_similarity_bonus("Button.tsx", "Modal.tsx", 50, 0.6), + 0 + ); + assert_eq!( + calculate_filename_similarity_bonus("user.rs", "main.rs", 50, 0.6), + 0 + ); + + // Same file = no bonus + assert_eq!( + calculate_filename_similarity_bonus("Button.tsx", "Button.tsx", 50, 0.6), + 0 + ); + assert_eq!( + calculate_filename_similarity_bonus("main.rs", "main.rs", 50, 0.6), + 0 + ); + + // Invalid files = no bonus + assert_eq!( + calculate_filename_similarity_bonus("", "Button.tsx", 50, 0.6), + 0 + ); + assert_eq!( + calculate_filename_similarity_bonus("Button.tsx", "", 50, 0.6), + 0 + ); + + // Test that threshold works correctly + let low_threshold_bonus = + calculate_filename_similarity_bonus("data.py", "data_backup.py", 30, 0.3); + let high_threshold_bonus = + calculate_filename_similarity_bonus("data.py", "data_backup.py", 30, 0.9); + assert!( + low_threshold_bonus > 0, + "Low threshold should allow more matches" + ); + assert_eq!( + high_threshold_bonus, 0, + "High threshold should reject moderate similarity" + ); + } + #[test] - fn test_calculate_distance_penalty() { - assert_eq!(calculate_distance_penalty(None, "/path/to/file.txt"), 0); + fn test_calculate_directory_distance_penalty() { + const PENALTY_PER_LEVEL: i32 = -2; + // No current file = no penalty assert_eq!( - calculate_distance_penalty( + calculate_directory_distance_penalty(None, "/path/to/file.txt", PENALTY_PER_LEVEL), + 0 + ); + + // Same directory = no penalty + assert_eq!( + calculate_directory_distance_penalty( Some("/path/to/current/file.txt"), - "/path/to/current/other.txt" + "/path/to/current/other.txt", + PENALTY_PER_LEVEL ), 0 ); + // 1 level up = 1 * penalty assert_eq!( - calculate_distance_penalty(Some("/path/to/current/file.txt"), "/path/to/file.txt"), - -2 + calculate_directory_distance_penalty( + Some("/path/to/current/file.txt"), + "/path/to/file.txt", + PENALTY_PER_LEVEL + ), + 1 * PENALTY_PER_LEVEL ); + // 2 levels apart = 2 * penalty assert_eq!( - calculate_distance_penalty( + calculate_directory_distance_penalty( Some("/path/to/current/file.txt"), - "/path/to/other/file.txt" + "/path/to/other/file.txt", + PENALTY_PER_LEVEL ), - -4 + 2 * PENALTY_PER_LEVEL ); + // 3 levels apart = 3 * penalty assert_eq!( - calculate_distance_penalty( + calculate_directory_distance_penalty( Some("/path/to/current/file.txt"), - "/path/to/another/dir/file.txt" + "/path/to/another/dir/file.txt", + PENALTY_PER_LEVEL ), - -6 + 3 * PENALTY_PER_LEVEL ); + // Completely different paths = 8 levels apart = 8 * penalty assert_eq!( - calculate_distance_penalty(Some("/a/b/c/d/file.txt"), "/x/y/z/w/file.txt"), - -16 + calculate_directory_distance_penalty( + Some("/a/b/c/d/file.txt"), + "/x/y/z/w/file.txt", + PENALTY_PER_LEVEL + ), + 8 * PENALTY_PER_LEVEL ); + // Files in root directory = same directory = no penalty assert_eq!( - calculate_distance_penalty(Some("/file1.txt"), "/file2.txt"), + calculate_directory_distance_penalty( + Some("/file1.txt"), + "/file2.txt", + PENALTY_PER_LEVEL + ), 0 ); + + // Test with different penalty values to ensure logic is independent of -2 + const DIFFERENT_PENALTY: i32 = -5; + assert_eq!( + calculate_directory_distance_penalty( + Some("/path/to/current/file.txt"), + "/path/to/file.txt", + DIFFERENT_PENALTY + ), + 1 * DIFFERENT_PENALTY + ); } } diff --git a/lua/fff/rust/score.rs b/lua/fff/rust/score.rs index fd4db28c..805731cd 100644 --- a/lua/fff/rust/score.rs +++ b/lua/fff/rust/score.rs @@ -1,10 +1,68 @@ use crate::{ - git::is_modified_status, - path_utils::calculate_distance_penalty, + path_utils::{ + calculate_directory_distance_penalty, calculate_directory_distance_penalty_optimized, + calculate_filename_similarity_bonus, calculate_filename_similarity_bonus_optimized, + }, types::{FileItem, Score, ScoringContext}, }; use rayon::prelude::*; +const MAX_DISTANCE_FOR_SIMILARITY_BONUS: i32 = 2; + +fn calculate_proximity_scores(context: &ScoringContext, file: &FileItem) -> (i32, i32) { + if let Some(ref current_data) = context.current_file_data { + if Some(file.relative_path.as_str()) == context.current_file { + return (0, 0); + } + + let penalty = calculate_directory_distance_penalty_optimized( + ¤t_data.directory_parts, + &file.relative_path, + context.directory_distance_penalty, + ); + + // Only calculate relation bonus for files that are "close" (within MAX_DISTANCE_FOR_SIMILARITY_BONUS levels). + // This gates the expensive Jaro-Winkler calculation for performance. + let bonus = + if penalty / context.directory_distance_penalty <= MAX_DISTANCE_FOR_SIMILARITY_BONUS { + calculate_filename_similarity_bonus_optimized( + ¤t_data.stem, + &file.relative_path, + context.filename_similarity_bonus_max, + context.filename_similarity_threshold, + ) + } else { + 0 + }; + + (penalty, bonus) + } else { + // Fallback to original functions if no pre-computed data. + let penalty = calculate_directory_distance_penalty( + context.current_file, + &file.relative_path, + context.directory_distance_penalty, + ); + + let bonus = + if penalty / context.directory_distance_penalty <= MAX_DISTANCE_FOR_SIMILARITY_BONUS { + match context.current_file { + Some(current_file) => calculate_filename_similarity_bonus( + current_file, + &file.relative_path, + context.filename_similarity_bonus_max, + context.filename_similarity_threshold, + ), + None => 0, + } + } else { + 0 + }; + + (penalty, bonus) + } +} + pub fn match_and_score_files(files: &[FileItem], context: &ScoringContext) -> Vec<(usize, Score)> { if context.query.len() < 2 { return score_all_by_frecency(files, context); @@ -64,10 +122,7 @@ pub fn match_and_score_files(files: &[FileItem], context: &ScoringContext) -> Ve let base_score = neo_frizbee_match.score as i32; let frecency_boost = base_score.saturating_mul(file.total_frecency_score as i32) / 100; - let distance_penalty = calculate_distance_penalty( - context.current_file.map(|s| s.as_str()), - &file.relative_path, - ); + let (distance_penalty, relation_bonus) = calculate_proximity_scores(context, file); let filename_match = filename_matches .get(next_filename_match_index) @@ -100,7 +155,8 @@ pub fn match_and_score_files(files: &[FileItem], context: &ScoringContext) -> Ve let total = base_score .saturating_add(frecency_boost) .saturating_add(distance_penalty) - .saturating_add(filename_bonus); + .saturating_add(filename_bonus) + .saturating_add(relation_bonus); let score = Score { total, @@ -113,6 +169,7 @@ pub fn match_and_score_files(files: &[FileItem], context: &ScoringContext) -> Ve }, frecency_boost, distance_penalty, + relation_bonus, match_type: match filename_match { Some(filename_match) if filename_match.exact => "exact_filename", Some(_) => "fuzzy_filename", @@ -162,15 +219,10 @@ fn score_all_by_frecency(files: &[FileItem], context: &ScoringContext) -> Vec<(u let total_frecency_score = file.access_frecency_score as i32 + (file.modification_frecency_score as i32).saturating_mul(4); - let distance_penalty = calculate_distance_penalty( - context.current_file.map(|x| x.as_str()), - &file.relative_path, - ); - + let (distance_penalty, relation_bonus) = calculate_proximity_scores(context, file); let total = total_frecency_score .saturating_add(distance_penalty) - .saturating_add(calculate_file_bonus(file, context)); - + .saturating_add(relation_bonus); let score = Score { total, base_score: 0, @@ -178,6 +230,7 @@ fn score_all_by_frecency(files: &[FileItem], context: &ScoringContext) -> Vec<(u special_filename_bonus: 0, frecency_boost: total_frecency_score, distance_penalty, + relation_bonus, match_type: "frecency", }; @@ -185,19 +238,3 @@ fn score_all_by_frecency(files: &[FileItem], context: &ScoringContext) -> Vec<(u }) .collect() } - -#[inline] -fn calculate_file_bonus(file: &FileItem, context: &ScoringContext) -> i32 { - let mut bonus = 0i32; - - if let Some(current) = context.current_file { - if file.relative_path == *current { - bonus -= match file.git_status { - Some(status) if is_modified_status(status) => 150, - _ => 300, - }; - } - } - - bonus -} diff --git a/lua/fff/rust/types.rs b/lua/fff/rust/types.rs index 50892f8b..63537747 100644 --- a/lua/fff/rust/types.rs +++ b/lua/fff/rust/types.rs @@ -27,15 +27,49 @@ pub struct Score { pub special_filename_bonus: i32, pub frecency_boost: i32, pub distance_penalty: i32, + pub relation_bonus: i32, pub match_type: &'static str, } +#[derive(Debug, Clone)] +pub struct CurrentFileData { + pub stem: String, + pub directory_parts: Vec, +} + +impl CurrentFileData { + pub fn from_path(path: &str) -> Option { + use std::path::{Component, Path}; + + let path = Path::new(path); + let stem = path.file_stem()?.to_str()?.to_string(); + let dir = path.parent()?; + + let directory_parts: Vec = dir + .components() + .filter_map(|c| match c { + Component::Normal(os_str) => os_str.to_str().map(|s| s.to_string()), + _ => None, + }) + .collect(); + + Some(CurrentFileData { + stem, + directory_parts, + }) + } +} + #[derive(Debug, Clone)] pub struct ScoringContext<'a> { pub query: &'a str, - pub current_file: Option<&'a String>, + pub current_file: Option<&'a str>, + pub current_file_data: Option, pub max_typos: u16, pub max_threads: usize, + pub directory_distance_penalty: i32, + pub filename_similarity_bonus_max: i32, + pub filename_similarity_threshold: f64, } #[derive(Debug, Clone, Default)] @@ -77,6 +111,7 @@ impl IntoLua for Score { table.set("special_filename_bonus", self.special_filename_bonus)?; table.set("frecency_boost", self.frecency_boost)?; table.set("distance_penalty", self.distance_penalty)?; + table.set("relation_bonus", self.relation_bonus)?; table.set("match_type", self.match_type)?; Ok(LuaValue::Table(table)) } diff --git a/src/bin/test_watcher.rs b/src/bin/test_watcher.rs index d26eae8d..8fd5b9c8 100644 --- a/src/bin/test_watcher.rs +++ b/src/bin/test_watcher.rs @@ -153,7 +153,9 @@ fn main() -> Result<(), Box> { } if iteration % 40 == 0 { - let search_results = picker.fuzzy_search("rs", 5, 2, None); + let search_results = picker + .fuzzy_search("rs", 5, 2, None, -8, 35, 0.5) + .unwrap_or_default(); let timestamp = chrono::Local::now().format("%H:%M:%S"); println!( "🔍 [{}] Search test 'rs': {} matches",