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