diff --git a/crates/fff-core/src/score.rs b/crates/fff-core/src/score.rs index 8fe21ceb..6793bf57 100644 --- a/crates/fff-core/src/score.rs +++ b/crates/fff-core/src/score.rs @@ -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), @@ -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 { @@ -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"]);