Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 53 additions & 6 deletions crates/fff-core/src/score.rs
Original file line number Diff line number Diff line change
Expand Up @@ -753,16 +753,43 @@ fn match_and_score_in_arena<'a>(

let is_filename_match = end_col_filename_match || simd_filename_match.is_some();
let fname_len = file.path.byte_len as usize - file.path.filename_offset as usize;

let is_exact_filename = simd_filename_match.is_some_and(|m| m.exact)
|| (end_col_filename_match && main_needle_len as usize == fname_len && {
let needle_len = main_needle_len as usize;

// Length-gated exact-filename / exact-stem detection: both variants
// are mutually exclusive by length (exact needs n == fname_len, stem
// needs n + 2 <= fname_len), so the arena read happens at most once
// and is skipped when neither is plausible. Reuses `fname_buf` to
// avoid a per-file allocation.
let (is_exact_filename, is_exact_stem) = if !is_filename_match {
(false, false)
} else if simd_filename_match.is_some_and(|m| m.exact) {
(true, false)
} else {
let could_be_exact = end_col_filename_match && needle_len == fname_len;
let could_be_stem = needle_len + 2 <= fname_len;
if !could_be_exact && !could_be_stem {
(false, false)
} else {
file.write_file_name_from_arena(arena, &mut fname_buf);
main_needle.eq_ignore_ascii_case(fname_buf.as_bytes())
});
let bytes = fname_buf.as_bytes();
if could_be_exact {
(main_needle.eq_ignore_ascii_case(bytes), false)
} else if bytes[needle_len] == b'.'
&& !bytes[needle_len + 1..].contains(&b'.')
&& main_needle.eq_ignore_ascii_case(&bytes[..needle_len])
{
(false, true)
} else {
(false, false)
}
}
};

let mut has_special_filename_bonus = false;
let filename_bonus = if is_exact_filename {
base_score / 5 * 2 // 40% bonus for exact filename match
} else if is_exact_stem {
base_score * 30 / 100 // 30% bonus for exact filename stem match
} else if is_filename_match {
// 16% bonus for fuzzy filename match that landed in the filename region.
// For fallback matches (where the path match landed in a directory segment),
Expand Down Expand Up @@ -868,9 +895,11 @@ fn match_and_score_in_arena<'a>(
distance_penalty,
combo_match_boost,
path_alignment_bonus,
exact_match: is_exact_filename || path_match.exact,
exact_match: is_exact_filename || is_exact_stem || path_match.exact,
match_type: if is_exact_filename {
"exact_filename"
} else if is_exact_stem {
"exact_stem"
} else if is_filename_match {
"fuzzy_filename"
} else if path_match.exact {
Expand Down Expand Up @@ -1393,6 +1422,24 @@ mod filename_bonus_tests {
);
}

/// Regression: query that exactly matches the filename stem (name minus
/// extension) should rank clearly above files that just contain the query
/// as a fuzzy filename substring. https://github.com/dmtrKovalenko/fff/issues/722
#[test]
fn test_exact_stem_beats_fuzzy_filename() {
let (files, arena) = make_files(&["lsp/typos_lsp.lua", "lsp.lua"]);

let results = search(&files, "lsp", arena);

assert!(results.len() >= 2);
assert_eq!(
results[0].0, "lsp.lua",
"lsp.lua (exact filename stem) should rank above typos_lsp.lua"
);
assert_eq!(results[0].1.match_type, "exact_stem");
assert!(results[0].1.filename_bonus > results[1].1.filename_bonus);
}

#[test]
fn test_path_separator_disables_filename_bonus() {
let (files, arena) = make_files(&["src/controllers/user.rs"]);
Expand Down
Loading