editor: Improve fuzzy match bucket logic for code completions (#29442)

Smit Barmase created

Add new test and improve fuzzy match bucket logic which results into far
better balance between LSP and fuzzy search.

Before:
<img width="500" alt="before"
src="https://github.com/user-attachments/assets/3e8900a6-c0ff-4f37-b88e-b0e3783b7e9a"
/>

After:
<img width="500" alt="after"
src="https://github.com/user-attachments/assets/738c074c-d446-4697-aac6-9814362e88db"
/>

Release Notes:

- N/A

Change summary

crates/editor/src/code_completion_tests.rs | 149 ++++++++++++++++++++++++
crates/editor/src/code_context_menus.rs    |  18 +-
2 files changed, 160 insertions(+), 7 deletions(-)

Detailed changes

crates/editor/src/code_completion_tests.rs 🔗

@@ -1006,3 +1006,152 @@ fn test_sort_matches_for_snippets(_cx: &mut TestAppContext) {
         "Match order not expected"
     );
 }
+
+#[gpui::test]
+fn test_sort_matches_for_exact_match(_cx: &mut TestAppContext) {
+    // Case 1: "set_text"
+    let query: Option<&str> = Some("set_text");
+    let mut matches: Vec<SortableMatch<'_>> = vec![
+        SortableMatch {
+            string_match: StringMatch {
+                candidate_id: 0,
+                score: 1.0,
+                positions: vec![],
+                string: "set_text".to_string(),
+            },
+            is_snippet: false,
+            sort_text: Some("7fffffff"),
+            sort_key: (3, "set_text"),
+        },
+        SortableMatch {
+            string_match: StringMatch {
+                candidate_id: 0,
+                score: 0.32000000000000006,
+                positions: vec![],
+                string: "set_placeholder_text".to_string(),
+            },
+            is_snippet: false,
+            sort_text: Some("7fffffff"),
+            sort_key: (3, "set_placeholder_text"),
+        },
+        SortableMatch {
+            string_match: StringMatch {
+                candidate_id: 0,
+                score: 0.32,
+                positions: vec![],
+                string: "set_text_style_refinement".to_string(),
+            },
+            is_snippet: false,
+            sort_text: Some("7fffffff"),
+            sort_key: (3, "set_text_style_refinement"),
+        },
+        SortableMatch {
+            string_match: StringMatch {
+                candidate_id: 0,
+                score: 0.16666666666666666,
+                positions: vec![],
+                string: "set_context_menu_options".to_string(),
+            },
+            is_snippet: false,
+            sort_text: Some("7fffffff"),
+            sort_key: (3, "set_context_menu_options"),
+        },
+        SortableMatch {
+            string_match: StringMatch {
+                candidate_id: 0,
+                score: 0.08695652173913043,
+                positions: vec![],
+                string: "select_to_next_word_end".to_string(),
+            },
+            is_snippet: false,
+            sort_text: Some("7fffffff"),
+            sort_key: (3, "select_to_next_word_end"),
+        },
+        SortableMatch {
+            string_match: StringMatch {
+                candidate_id: 0,
+                score: 0.07692307692307693,
+                positions: vec![],
+                string: "select_to_next_subword_end".to_string(),
+            },
+            is_snippet: false,
+            sort_text: Some("7fffffff"),
+            sort_key: (3, "select_to_next_subword_end"),
+        },
+        SortableMatch {
+            string_match: StringMatch {
+                candidate_id: 0,
+                score: 0.06956521739130435,
+                positions: vec![],
+                string: "set_custom_context_menu".to_string(),
+            },
+            is_snippet: false,
+            sort_text: Some("7fffffff"),
+            sort_key: (3, "set_custom_context_menu"),
+        },
+        SortableMatch {
+            string_match: StringMatch {
+                candidate_id: 0,
+                score: 0.06,
+                positions: vec![],
+                string: "select_to_end_of_excerpt".to_string(),
+            },
+            is_snippet: false,
+            sort_text: Some("7fffffff"),
+            sort_key: (3, "select_to_end_of_excerpt"),
+        },
+        SortableMatch {
+            string_match: StringMatch {
+                candidate_id: 0,
+                score: 0.055384615384615386,
+                positions: vec![],
+                string: "select_to_start_of_excerpt".to_string(),
+            },
+            is_snippet: false,
+            sort_text: Some("7fffffff"),
+            sort_key: (3, "select_to_start_of_excerpt"),
+        },
+        SortableMatch {
+            string_match: StringMatch {
+                candidate_id: 0,
+                score: 0.0464516129032258,
+                positions: vec![],
+                string: "select_to_start_of_next_excerpt".to_string(),
+            },
+            is_snippet: false,
+            sort_text: Some("7fffffff"),
+            sort_key: (3, "select_to_start_of_next_excerpt"),
+        },
+        SortableMatch {
+            string_match: StringMatch {
+                candidate_id: 0,
+                score: 0.04363636363636363,
+                positions: vec![],
+                string: "select_to_end_of_previous_excerpt".to_string(),
+            },
+            is_snippet: false,
+            sort_text: Some("7fffffff"),
+            sort_key: (3, "select_to_end_of_previous_excerpt"),
+        },
+    ];
+    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::Top);
+    assert_eq!(
+        matches
+            .iter()
+            .map(|m| m.string_match.string.as_str())
+            .collect::<Vec<&str>>(),
+        vec![
+            "set_text",
+            "set_context_menu_options",
+            "set_placeholder_text",
+            "set_text_style_refinement",
+            "select_to_end_of_excerpt",
+            "select_to_end_of_previous_excerpt",
+            "select_to_next_subword_end",
+            "select_to_next_word_end",
+            "select_to_start_of_excerpt",
+            "select_to_start_of_next_excerpt",
+            "set_custom_context_menu"
+        ]
+    );
+}

crates/editor/src/code_context_menus.rs 🔗

@@ -671,7 +671,7 @@ impl CompletionsMenu {
         #[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
         enum MatchTier<'a> {
             WordStartMatch {
-                sort_score_int: Reverse<i32>,
+                sort_bucket: Reverse<i32>,
                 sort_snippet: Reverse<i32>,
                 sort_text: Option<&'a str>,
                 sort_key: (usize, &'a str),
@@ -684,11 +684,9 @@ impl CompletionsMenu {
         // Our goal here is to intelligently sort completion suggestions. We want to
         // balance the raw fuzzy match score with hints from the language server
         //
-        // We first primary sort using fuzzy score by putting matches into two buckets
-        // strong one and weak one. Among these buckets matches are then compared by
+        // We first primary sort using fuzzy score by putting matches into multiple
+        // buckets. Among these buckets matches are then compared by
         // various criteria like snippet, LSP hints, kind, label text etc.
-        //
-        const FUZZY_THRESHOLD: f64 = 0.1317;
 
         let query_start_lower = query
             .and_then(|q| q.chars().next())
@@ -712,14 +710,20 @@ impl CompletionsMenu {
                 let sort_score = Reverse(OrderedFloat(score));
                 MatchTier::OtherMatch { sort_score }
             } else {
-                let sort_score_int = Reverse(if score >= FUZZY_THRESHOLD { 1 } else { 0 });
+                // Convert fuzzy match score (0.0-1.0) to a priority bucket (0-3)
+                let sort_bucket = Reverse(match (score * 10.0).floor() as i32 {
+                    s if s >= 7 => 3,
+                    s if s >= 1 => 2,
+                    s if s > 0 => 1,
+                    _ => 0,
+                });
                 let sort_snippet = match snippet_sort_order {
                     SnippetSortOrder::Top => Reverse(if mat.is_snippet { 1 } else { 0 }),
                     SnippetSortOrder::Bottom => Reverse(if mat.is_snippet { 0 } else { 1 }),
                     SnippetSortOrder::Inline => Reverse(0),
                 };
                 MatchTier::WordStartMatch {
-                    sort_score_int,
+                    sort_bucket,
                     sort_snippet,
                     sort_text: mat.sort_text,
                     sort_key: mat.sort_key,