From 73cd3e62585697db6ac74b4492d130bd306e2da7 Mon Sep 17 00:00:00 2001 From: Oskar Grunning Date: Sat, 2 Aug 2025 22:46:17 +0200 Subject: [PATCH 1/5] feat: add string similarity scoring with performance optimizations Implement Jaro-Winkler string similarity algorithm for enhanced file prioritization. Add optimized scoring functions that gate expensive similarity calculations behind distance checks to maintain performance while improving relevance of file suggestions. - Add strsim dependency for Jaro-Winkler similarity calculations - Implement optimized path distance and filename similarity functions - Add CurrentFileData struct to cache frequently accessed path components - Gate similarity bonus calculations behind MAX_DISTANCE_FOR_SIMILARITY_BONUS --- Cargo.lock | 7 ++ Cargo.toml | 1 + lua/fff/rust/path_utils.rs | 251 +++++++++++++++++++++++++++++++------ lua/fff/rust/score.rs | 95 +++++++++----- lua/fff/rust/types.rs | 31 +++++ 5 files changed, 317 insertions(+), 68 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0804d307..1e3ad3fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -270,6 +270,7 @@ dependencies = [ "notify-debouncer-full", "pathdiff", "rayon", + "strsim", "thiserror 2.0.12", "tokio", "tracing", @@ -1241,6 +1242,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 eb51c0e1..18cfeb02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,3 +28,4 @@ chrono = { version = "0.4", features = ["serde"] } tracing = "0.1" tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +strsim = "0.11.0" diff --git a/lua/fff/rust/path_utils.rs b/lua/fff/rust/path_utils.rs index 8cc7bc61..8069cd5c 100644 --- a/lua/fff/rust/path_utils.rs +++ b/lua/fff/rust/path_utils.rs @@ -1,34 +1,148 @@ -pub fn calculate_distance_penalty(current_file: &Option, 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 similarity = jaro_winkler(current_stem, candidate_stem); - let current_dir = if let Some(parent) = std::path::Path::new(current_path).parent() { - parent.to_string_lossy().to_string() + 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::{Path, Component}; + + 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_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 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_parts + 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 penalty = total_distance as i32 * penalty_per_level; + + 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::{Path, Component}; + + 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,58 +150,123 @@ pub fn calculate_distance_penalty(current_file: &Option, candidate_path: 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_directory_distance_penalty(None, "/path/to/file.txt", PENALTY_PER_LEVEL), 0); + + // Same directory = no penalty assert_eq!( - calculate_distance_penalty( - &Some("/path/to/current/file.txt".to_string()), - "/path/to/current/other.txt" + calculate_directory_distance_penalty( + Some("/path/to/current/file.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".to_string()), - "/path/to/file.txt" + calculate_directory_distance_penalty( + Some("/path/to/current/file.txt"), + "/path/to/file.txt", + PENALTY_PER_LEVEL ), - -2 + 1 * PENALTY_PER_LEVEL ); + // 2 levels apart = 2 * penalty assert_eq!( - calculate_distance_penalty( - &Some("/path/to/current/file.txt".to_string()), - "/path/to/other/file.txt" + calculate_directory_distance_penalty( + Some("/path/to/current/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( - &Some("/path/to/current/file.txt".to_string()), - "/path/to/another/dir/file.txt" + calculate_directory_distance_penalty( + Some("/path/to/current/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".to_string()), "/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".to_string()), "/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 33945953..d7f9435c 100644 --- a/lua/fff/rust/score.rs +++ b/lua/fff/rust/score.rs @@ -1,10 +1,64 @@ use crate::{ - git::is_modified_status, - path_utils::calculate_distance_penalty, + path_utils::{calculate_directory_distance_penalty, calculate_filename_similarity_bonus, + calculate_directory_distance_penalty_optimized, 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); @@ -65,10 +119,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.to_string()), - &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 +151,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 +165,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", @@ -154,12 +207,8 @@ 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(|s| s.to_string()), - &file.relative_path, - ); - - let total = total_frecency_score.saturating_add(distance_penalty); + let (distance_penalty, relation_bonus) = calculate_proximity_scores(context, file); + let total = total_frecency_score.saturating_add(distance_penalty).saturating_add(relation_bonus); let score = Score { total, base_score: 0, @@ -167,6 +216,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", }; @@ -174,22 +224,3 @@ fn score_all_by_frecency(files: &[FileItem], context: &ScoringContext) -> Vec<(u }) .collect() } - -#[inline] -#[allow(dead_code)] -fn calculate_file_bonus(file: &FileItem, context: &ScoringContext) -> i32 { - let mut bonus = 0i32; - - if let Some(current) = context.current_file { - let is_current = file.relative_path == current || file.relative_path == current; - - if is_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 28cedff1..343d9b27 100644 --- a/lua/fff/rust/types.rs +++ b/lua/fff/rust/types.rs @@ -27,15 +27,45 @@ 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::{Path, Component}; + + 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 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 +107,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)) } From 702ca82965d060fd0ba6f4621493453f3286775c Mon Sep 17 00:00:00 2001 From: Oskar Grunning Date: Sat, 2 Aug 2025 22:46:31 +0200 Subject: [PATCH 2/5] feat: add configurable sibling file prioritization system Introduce same_dir_preference configuration option to control how strongly the file picker prioritizes files near the current file. This allows users to customize the balance between relevance and broader search results. - Add config_utils.lua with preference validation and parameter mapping - Integrate scoring configuration into main setup function - Map user preference (0.0-1.0) to internal scoring parameters - Add validation with helpful error messages for invalid ranges --- lua/fff/config_utils.lua | 45 ++++++++++++++++++++++++++++++++++++ lua/fff/file_picker/init.lua | 23 +++++++++++++++++- lua/fff/main.lua | 13 +++++++++-- 3 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 lua/fff/config_utils.lua diff --git a/lua/fff/config_utils.lua b/lua/fff/config_utils.lua new file mode 100644 index 00000000..84439a7f --- /dev/null +++ b/lua/fff/config_utils.lua @@ -0,0 +1,45 @@ +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/main.lua b/lua/fff/main.lua index b5c7cee7..3009ee39 100644 --- a/lua/fff/main.lua +++ b/lua/fff/main.lua @@ -58,12 +58,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 @@ -393,13 +401,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 From e049729a927f6b292f62ed60d4c52b5bf6231d90 Mon Sep 17 00:00:00 2001 From: Oskar Grunning Date: Sat, 2 Aug 2025 22:46:42 +0200 Subject: [PATCH 3/5] feat: integrate optimized scoring into file picker UI Connect the new scoring system to the file picker interface and preview functionality. Optimize scoring operations by pre-computing current file data and passing it to the scoring context for better performance. - Update file picker to use optimized scoring with CurrentFileData - Integrate scoring parameters from configuration into picker context - Add tracing for scoring performance monitoring - Export new scoring functions from Rust library --- lua/fff/file_picker/preview.lua | 7 +++++- lua/fff/picker_ui.lua | 42 +++++++++++++++++---------------- lua/fff/rust/file_picker.rs | 23 ++++++++++++++++-- lua/fff/rust/lib.rs | 7 ++++-- 4 files changed, 54 insertions(+), 25 deletions(-) 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/picker_ui.lua b/lua/fff/picker_ui.lua index 90fcb5f5..e7343703 100644 --- a/lua/fff/picker_ui.lua +++ b/lua/fff/picker_ui.lua @@ -171,6 +171,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() @@ -491,15 +493,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, @@ -1012,11 +1005,9 @@ function M.close() M.state.preview_buf, M.state.file_info_buf, } - + for _, buf in ipairs(buffers) do - if buf and vim.api.nvim_buf_is_valid(buf) then - vim.api.nvim_buf_delete(buf, { force = true }) - end + if buf and vim.api.nvim_buf_is_valid(buf) then vim.api.nvim_buf_delete(buf, { force = true }) end end M.state.input_win = nil @@ -1053,15 +1044,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) @@ -1098,11 +1105,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 0aa100b8..b6380d0e 100644 --- a/lua/fff/rust/file_picker.rs +++ b/lua/fff/rust/file_picker.rs @@ -231,6 +231,9 @@ impl FilePicker { max_results: usize, max_threads: usize, 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 @@ -244,12 +247,25 @@ 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.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); @@ -614,7 +630,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 be6e6c58..88bffe15 100644 --- a/lua/fff/rust/lib.rs +++ b/lua/fff/rust/lib.rs @@ -66,7 +66,7 @@ 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)?; @@ -75,7 +75,10 @@ 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)?; + 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) } From 75f8498b01ba1ffdfcd7f11e1336cd758541e7ee Mon Sep 17 00:00:00 2001 From: Oskar Grunning Date: Sat, 2 Aug 2025 22:46:53 +0200 Subject: [PATCH 4/5] docs: document sibling file prioritization feature Add comprehensive documentation for the new same_dir_preference configuration option. Update both README and Vim help documentation to explain how users can control file prioritization behavior. - Document scoring.same_dir_preference configuration option - Add usage examples and recommended values (0.0-1.0 range) - Update Vim help documentation with new configuration - Add performance monitoring traces for scoring operations --- README.md | 12 +++++++++++- doc/fff.nvim.txt | 30 ++++++++++++++++++++---------- src/bin/test_watcher.rs | 2 +- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f7569146..9698a8e6 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. @@ -143,6 +143,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 @@ -226,6 +231,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 ed950b64..bf632fe6 100644 --- a/doc/fff.nvim.txt +++ b/doc/fff.nvim.txt @@ -83,7 +83,7 @@ DEFAULT CONFIGURATION *fff.nvim-default-configuration* title = 'FFF Files', max_results = 60, -- Maximum number of search results max_threads = 4, -- Maximum threads for fuzzy search - + keymaps = { close = '', select = '', @@ -95,7 +95,7 @@ DEFAULT CONFIGURATION *fff.nvim-default-configuration* preview_scroll_up = '', preview_scroll_down = '', }, - + hl = { border = 'FloatBorder', normal = 'Normal', @@ -126,7 +126,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 = '', @@ -139,7 +139,7 @@ configuration: preview_scroll_up = '', preview_scroll_down = '', }, - + -- Highlight groups hl = { border = 'FloatBorder', @@ -152,7 +152,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 @@ -193,9 +198,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' } @@ -220,7 +225,7 @@ configuration: title = 'FFF Files', -- Window title max_results = 60, -- Maximum search results to display max_threads = 4, -- Maximum threads for fuzzy search - + keymaps = { close = '', select = '', @@ -232,7 +237,7 @@ configuration: preview_scroll_up = '', preview_scroll_down = '', }, - + hl = { border = 'FloatBorder', normal = 'Normal', @@ -244,7 +249,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 = { show_scores = true, -- We hope for your collaboratio }, diff --git a/src/bin/test_watcher.rs b/src/bin/test_watcher.rs index 364cc187..dcfabf31 100644 --- a/src/bin/test_watcher.rs +++ b/src/bin/test_watcher.rs @@ -152,7 +152,7 @@ fn main() -> Result<(), Box> { } if iteration % 40 == 0 { - let search_results = picker.fuzzy_search("rs", 5, 2, None).unwrap_or_default(); + 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", From 21ba7ee0f2032ddfe49cd495ffd4608e0e0b12a5 Mon Sep 17 00:00:00 2001 From: Oskar Grunning Date: Sat, 2 Aug 2025 23:16:10 +0200 Subject: [PATCH 5/5] style: format and organize imports for sibling prioritization feature - Apply consistent Rust formatting with proper line breaks and indentation - Organize imports following standard Rust conventions - Fix comment spacing in Lua configuration files - Improve code readability without changing functionality --- lua/fff/config_utils.lua | 7 +-- lua/fff/rust/file_picker.rs | 51 +++++++-------- lua/fff/rust/lib.rs | 28 ++++++++- lua/fff/rust/path_utils.rs | 121 +++++++++++++++++++++++++++--------- lua/fff/rust/score.rs | 58 +++++++++-------- lua/fff/rust/types.rs | 10 ++- src/bin/test_watcher.rs | 4 +- 7 files changed, 188 insertions(+), 91 deletions(-) diff --git a/lua/fff/config_utils.lua b/lua/fff/config_utils.lua index 84439a7f..c364cda4 100644 --- a/lua/fff/config_utils.lua +++ b/lua/fff/config_utils.lua @@ -34,12 +34,11 @@ end --- @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 + 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/rust/file_picker.rs b/lua/fff/rust/file_picker.rs index 1a86d215..1ad8022b 100644 --- a/lua/fff/rust/file_picker.rs +++ b/lua/fff/rust/file_picker.rs @@ -254,7 +254,8 @@ impl FilePicker { }; // Pre-compute current file data for performance optimization. - let current_file_data = current_file.as_deref() + let current_file_data = current_file + .as_deref() .and_then(crate::types::CurrentFileData::from_path); let context = ScoringContext { @@ -630,32 +631,32 @@ fn scan_filesystem( }) }); - 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); - - let git_cache = git_handle - .join() - .map_err(|_| Error::InvalidPath("Git status thread panicked".to_string()))?; - - if let Some(git_cache) = &git_cache { - files.par_iter_mut().for_each(|file| { - file.git_status = git_cache.lookup_status(&file.path); - file.update_frecency_scores(); - }); - } + 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); - let total_time = scan_start.elapsed(); - info!( - "SCAN: Total scan time {:?} for {} files", - total_time, - files.len() - ); + let git_cache = git_handle + .join() + .map_err(|_| Error::InvalidPath("Git status thread panicked".to_string()))?; + + if let Some(git_cache) = &git_cache { + files.par_iter_mut().for_each(|file| { + file.git_status = git_cache.lookup_status(&file.path); + file.update_frecency_scores(); + }); + } + + let total_time = scan_start.elapsed(); + info!( + "SCAN: Total scan time {:?} for {} files", + total_time, + files.len() + ); - Ok((files, git_cache)) + Ok((files, git_cache)) }) } diff --git a/lua/fff/rust/lib.rs b/lua/fff/rust/lib.rs index f5406c6f..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, distance_penalty, relation_bonus_max, relation_similarity_threshold): (String, usize, usize, Option, Option, Option, 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)?; @@ -79,7 +95,15 @@ pub fn fuzzy_search_files( 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)?; + 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 8069cd5c..4732a260 100644 --- a/lua/fff/rust/path_utils.rs +++ b/lua/fff/rust/path_utils.rs @@ -1,4 +1,3 @@ - const MAX_PENALTY_LEVEL_MULTIPLIER: i32 = 10; pub fn calculate_filename_similarity_bonus( @@ -48,7 +47,10 @@ pub fn calculate_filename_similarity_bonus_optimized( return 0; } - let candidate_stem = match Path::new(candidate_file_path).file_stem().and_then(|s| s.to_str()) { + let candidate_stem = match Path::new(candidate_file_path) + .file_stem() + .and_then(|s| s.to_str()) + { Some(stem) => stem, None => return 0, }; @@ -62,9 +64,12 @@ pub fn calculate_filename_similarity_bonus_optimized( } } - -pub fn calculate_directory_distance_penalty(current_file: Option<&str>, candidate_path: &str, penalty_per_level: i32) -> i32 { - use std::path::{Path, Component}; +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; @@ -86,10 +91,12 @@ pub fn calculate_directory_distance_penalty(current_file: Option<&str>, candidat return 0; } - let current_components: Vec<_> = current_dir.components() + let current_components: Vec<_> = current_dir + .components() .filter(|c| matches!(c, Component::Normal(_))) .collect(); - let candidate_components: Vec<_> = candidate_dir.components() + let candidate_components: Vec<_> = candidate_dir + .components() .filter(|c| matches!(c, Component::Normal(_))) .collect(); @@ -121,7 +128,7 @@ pub fn calculate_directory_distance_penalty_optimized( candidate_path: &str, penalty_per_level: i32, ) -> i32 { - use std::path::{Path, Component}; + use std::path::{Component, Path}; let candidate_path = Path::new(candidate_path); let candidate_dir = match candidate_path.parent() { @@ -129,7 +136,8 @@ pub fn calculate_directory_distance_penalty_optimized( None => return 0, }; - let candidate_parts: Vec = candidate_dir.components() + 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, @@ -168,43 +176,88 @@ mod tests { // 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 + 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 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"); + 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); + 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); + 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); + 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"); + 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_directory_distance_penalty() { const PENALTY_PER_LEVEL: i32 = -2; // No current file = no penalty - assert_eq!(calculate_directory_distance_penalty(None, "/path/to/file.txt", PENALTY_PER_LEVEL), 0); + assert_eq!( + calculate_directory_distance_penalty(None, "/path/to/file.txt", PENALTY_PER_LEVEL), + 0 + ); // Same directory = no penalty assert_eq!( @@ -248,13 +301,21 @@ mod tests { // Completely different paths = 8 levels apart = 8 * penalty assert_eq!( - calculate_directory_distance_penalty(Some("/a/b/c/d/file.txt"), "/x/y/z/w/file.txt", PENALTY_PER_LEVEL), + 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_directory_distance_penalty(Some("/file1.txt"), "/file2.txt", PENALTY_PER_LEVEL), + calculate_directory_distance_penalty( + Some("/file1.txt"), + "/file2.txt", + PENALTY_PER_LEVEL + ), 0 ); diff --git a/lua/fff/rust/score.rs b/lua/fff/rust/score.rs index 62b79147..805731cd 100644 --- a/lua/fff/rust/score.rs +++ b/lua/fff/rust/score.rs @@ -1,6 +1,8 @@ use crate::{ - path_utils::{calculate_directory_distance_penalty, calculate_filename_similarity_bonus, - calculate_directory_distance_penalty_optimized, calculate_filename_similarity_bonus_optimized}, + 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::*; @@ -21,16 +23,17 @@ fn calculate_proximity_scores(context: &ScoringContext, file: &FileItem) -> (i32 // 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 - }; + 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 { @@ -41,19 +44,20 @@ fn calculate_proximity_scores(context: &ScoringContext, file: &FileItem) -> (i32 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 - }; + 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) } @@ -216,7 +220,9 @@ fn score_all_by_frecency(files: &[FileItem], context: &ScoringContext) -> Vec<(u + (file.modification_frecency_score as i32).saturating_mul(4); let (distance_penalty, relation_bonus) = calculate_proximity_scores(context, file); - let total = total_frecency_score.saturating_add(distance_penalty).saturating_add(relation_bonus); + let total = total_frecency_score + .saturating_add(distance_penalty) + .saturating_add(relation_bonus); let score = Score { total, base_score: 0, diff --git a/lua/fff/rust/types.rs b/lua/fff/rust/types.rs index 343d9b27..63537747 100644 --- a/lua/fff/rust/types.rs +++ b/lua/fff/rust/types.rs @@ -39,20 +39,24 @@ pub struct CurrentFileData { impl CurrentFileData { pub fn from_path(path: &str) -> Option { - use std::path::{Path, Component}; + 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() + 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 }) + Some(CurrentFileData { + stem, + directory_parts, + }) } } diff --git a/src/bin/test_watcher.rs b/src/bin/test_watcher.rs index 993419e2..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, -8, 35, 0.5).unwrap_or_default(); + 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",