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 @@
-**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",