editor: Improve code completions by prioritizing prefix matching (#29456)

Smit Barmase created

- Use common prefix length-based matching as primary criteria.
- Test added for multiple cases.

Before:
<img width="500" alt="image"
src="https://github.com/user-attachments/assets/8c653225-cac2-41bd-95f0-0fb8724284c9"
/>

After:
<img width="500" alt="image"
src="https://github.com/user-attachments/assets/a3d59399-cff2-435d-9b56-69a530f35da4"
/>

Release Notes:

- Fixed issues with code completions where they wouldn't show
completions with matched prefix at top.

Change summary

crates/editor/src/code_completion_tests.rs | 208 ++++++++++++++++++++++-
crates/editor/src/code_context_menus.rs    |  42 +++-
crates/editor/src/editor_tests.rs          |   4 
3 files changed, 220 insertions(+), 34 deletions(-)

Detailed changes

crates/editor/src/code_completion_tests.rs 🔗

@@ -90,12 +90,12 @@ fn test_sort_matches_local_variable_over_global_variable(_cx: &mut TestAppContex
     );
     assert_eq!(
         matches[2].string_match.string.as_str(),
-        "floorf128",
+        "floorf16",
         "Match order not expected"
     );
     assert_eq!(
         matches[3].string_match.string.as_str(),
-        "floorf16",
+        "floorf32",
         "Match order not expected"
     );
 
@@ -433,7 +433,51 @@ fn test_sort_matches_for_unreachable(_cx: &mut TestAppContext) {
         "Match order not expected"
     );
 
-    // Case 4: "unreachable"
+    // Case 4: "unreachabl"
+    let query: Option<&str> = Some("unreachable");
+    let mut matches: Vec<SortableMatch<'_>> = vec![
+        SortableMatch {
+            string_match: StringMatch {
+                candidate_id: 0,
+                score: 0.9090909090909092,
+                positions: vec![],
+                string: "unreachable".to_string(),
+            },
+            is_snippet: false,
+            sort_text: Some("80000000"),
+            sort_key: (3, "unreachable"),
+        },
+        SortableMatch {
+            string_match: StringMatch {
+                candidate_id: 0,
+                score: 0.6666666666666666,
+                positions: vec![],
+                string: "unreachable!(…)".to_string(),
+            },
+            is_snippet: false,
+            sort_text: Some("7fffffff"),
+            sort_key: (3, "unreachable!(…)"),
+        },
+        SortableMatch {
+            string_match: StringMatch {
+                candidate_id: 0,
+                score: 0.47619047619047616,
+                positions: vec![],
+                string: "unreachable_unchecked".to_string(),
+            },
+            is_snippet: false,
+            sort_text: Some("80000000"),
+            sort_key: (3, "unreachable_unchecked"),
+        },
+    ];
+    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
+    assert_eq!(
+        matches[0].string_match.string.as_str(),
+        "unreachable!(…)",
+        "Match order not expected"
+    );
+
+    // Case 5: "unreachable"
     let query: Option<&str> = Some("unreachable");
     let mut matches: Vec<SortableMatch<'_>> = vec![
         SortableMatch {
@@ -956,17 +1000,17 @@ fn test_sort_matches_jsx_event_handler(_cx: &mut TestAppContext) {
             .collect::<Vec<&str>>(),
         vec![
             "onAbort?",
+            "onAuxClick?",
             "onAbortCapture?",
             "onAnimationEnd?",
-            "onAnimationEndCapture?",
-            "onAnimationIteration?",
             "onAnimationStart?",
-            "onAuxClick?",
             "onAuxClickCapture?",
-            "onCanPlay?",
-            "onChange?",
+            "onAnimationIteration?",
+            "onAnimationEndCapture?",
             "onDrag?",
-            "onDragEnd?",
+            "onLoad?",
+            "onPlay?",
+            "onPaste?",
         ]
     );
 }
@@ -985,7 +1029,7 @@ fn test_sort_matches_for_snippets(_cx: &mut TestAppContext) {
             },
             is_snippet: false,
             sort_text: Some("80000000"),
-            sort_key: (2, "unreachable"),
+            sort_key: (2, "println"),
         },
         SortableMatch {
             string_match: StringMatch {
@@ -1142,16 +1186,148 @@ fn test_sort_matches_for_exact_match(_cx: &mut TestAppContext) {
             .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",
+            "set_placeholder_text",
+            "set_context_menu_options",
+            "set_custom_context_menu",
             "select_to_next_word_end",
+            "select_to_next_subword_end",
+            "select_to_end_of_excerpt",
             "select_to_start_of_excerpt",
             "select_to_start_of_next_excerpt",
-            "set_custom_context_menu"
+            "select_to_end_of_previous_excerpt",
+        ]
+    );
+}
+
+#[gpui::test]
+fn test_sort_matches_for_prefix_matches(_cx: &mut TestAppContext) {
+    // Case 1: "set"
+    let query: Option<&str> = Some("set");
+    let mut matches: Vec<SortableMatch<'_>> = vec![
+        SortableMatch {
+            string_match: StringMatch {
+                candidate_id: 0,
+                score: 0.12631578947368421,
+                positions: vec![],
+                string: "select_to_beginning".to_string(),
+            },
+            is_snippet: false,
+            sort_text: Some("7fffffff"),
+            sort_key: (3, "select_to_beginning"),
+        },
+        SortableMatch {
+            string_match: StringMatch {
+                candidate_id: 0,
+                score: 0.15000000000000002,
+                positions: vec![],
+                string: "set_collapse_matches".to_string(),
+            },
+            is_snippet: false,
+            sort_text: Some("7fffffff"),
+            sort_key: (3, "set_collapse_matches"),
+        },
+        SortableMatch {
+            string_match: StringMatch {
+                candidate_id: 0,
+                score: 0.21428571428571427,
+                positions: vec![],
+                string: "set_autoindent".to_string(),
+            },
+            is_snippet: false,
+            sort_text: Some("7fffffff"),
+            sort_key: (3, "set_autoindent"),
+        },
+        SortableMatch {
+            string_match: StringMatch {
+                candidate_id: 0,
+                score: 0.11538461538461539,
+                positions: vec![],
+                string: "set_all_diagnostics_active".to_string(),
+            },
+            is_snippet: false,
+            sort_text: Some("7fffffff"),
+            sort_key: (3, "set_all_diagnostics_active"),
+        },
+        SortableMatch {
+            string_match: StringMatch {
+                candidate_id: 0,
+                score: 0.1142857142857143,
+                positions: vec![],
+                string: "select_to_end_of_line".to_string(),
+            },
+            is_snippet: false,
+            sort_text: Some("7fffffff"),
+            sort_key: (3, "select_to_end_of_line"),
+        },
+        SortableMatch {
+            string_match: StringMatch {
+                candidate_id: 0,
+                score: 0.15000000000000002,
+                positions: vec![],
+                string: "select_all".to_string(),
+            },
+            is_snippet: false,
+            sort_text: Some("7fffffff"),
+            sort_key: (3, "select_all"),
+        },
+        SortableMatch {
+            string_match: StringMatch {
+                candidate_id: 0,
+                score: 0.13636363636363635,
+                positions: vec![],
+                string: "select_line".to_string(),
+            },
+            is_snippet: false,
+            sort_text: Some("7fffffff"),
+            sort_key: (3, "select_line"),
+        },
+        SortableMatch {
+            string_match: StringMatch {
+                candidate_id: 0,
+                score: 0.13636363636363635,
+                positions: vec![],
+                string: "select_left".to_string(),
+            },
+            is_snippet: false,
+            sort_text: Some("7fffffff"),
+            sort_key: (3, "select_left"),
+        },
+        SortableMatch {
+            string_match: StringMatch {
+                candidate_id: 0,
+                score: 0.13636363636363635,
+                positions: vec![],
+                string: "select_down".to_string(),
+            },
+            is_snippet: false,
+            sort_text: Some("7fffffff"),
+            sort_key: (3, "select_down"),
+        },
+    ];
+    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::Top);
+    println!(
+        "{:?}",
+        matches
+            .iter()
+            .map(|m| m.string_match.string.as_str())
+            .collect::<Vec<&str>>(),
+    );
+    assert_eq!(
+        matches
+            .iter()
+            .map(|m| m.string_match.string.as_str())
+            .collect::<Vec<&str>>(),
+        vec![
+            "set_autoindent",
+            "set_collapse_matches",
+            "set_all_diagnostics_active",
+            "select_all",
+            "select_down",
+            "select_left",
+            "select_line",
+            "select_to_beginning",
+            "select_to_end_of_line",
         ]
     );
 }

crates/editor/src/code_context_menus.rs 🔗

@@ -673,9 +673,10 @@ impl CompletionsMenu {
         #[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
         enum MatchTier<'a> {
             WordStartMatch {
-                sort_bucket: Reverse<i32>,
+                sort_prefix: Reverse<usize>,
                 sort_snippet: Reverse<i32>,
                 sort_text: Option<&'a str>,
+                sort_score: Reverse<OrderedFloat<f64>>,
                 sort_key: (usize, &'a str),
             },
             OtherMatch {
@@ -685,10 +686,6 @@ 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 multiple
-        // buckets. Among these buckets matches are then compared by
-        // various criteria like snippet, LSP hints, kind, label text etc.
 
         let query_start_lower = query
             .and_then(|q| q.chars().next())
@@ -696,8 +693,9 @@ impl CompletionsMenu {
 
         matches.sort_unstable_by_key(|mat| {
             let score = mat.string_match.score;
+            let sort_score = Reverse(OrderedFloat(score));
 
-            let is_other_match = query_start_lower
+            let query_start_doesnt_match_split_words = query_start_lower
                 .map(|query_char| {
                     !split_words(&mat.string_match.string).any(|word| {
                         word.chars()
@@ -708,26 +706,38 @@ impl CompletionsMenu {
                 })
                 .unwrap_or(false);
 
-            if is_other_match {
-                let sort_score = Reverse(OrderedFloat(score));
+            if query_start_doesnt_match_split_words {
                 MatchTier::OtherMatch { sort_score }
             } else {
-                // 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),
                 };
+                let mixed_case_prefix_length = Reverse(
+                    query
+                        .map(|q| {
+                            q.chars()
+                                .zip(mat.string_match.string.chars())
+                                .enumerate()
+                                .take_while(|(i, (q_char, match_char))| {
+                                    if *i == 0 {
+                                        // Case-sensitive comparison for first character
+                                        q_char == match_char
+                                    } else {
+                                        // Case-insensitive comparison for other characters
+                                        q_char.to_lowercase().eq(match_char.to_lowercase())
+                                    }
+                                })
+                                .count()
+                        })
+                        .unwrap_or(0),
+                );
                 MatchTier::WordStartMatch {
-                    sort_bucket,
+                    sort_prefix: mixed_case_prefix_length,
                     sort_snippet,
                     sort_text: mat.sort_text,
+                    sort_score,
                     sort_key: mat.sort_key,
                 }
             }

crates/editor/src/editor_tests.rs 🔗

@@ -10732,7 +10732,7 @@ async fn test_completion(cx: &mut TestAppContext) {
             .confirm_completion(&ConfirmCompletion::default(), window, cx)
             .unwrap()
     });
-    cx.assert_editor_state("editor.clobberˇ");
+    cx.assert_editor_state("editor.closeˇ");
     handle_resolve_completion_request(&mut cx, None).await;
     apply_additional_edits.await.unwrap();
 }
@@ -13982,7 +13982,7 @@ async fn test_completions_in_languages_with_extra_word_characters(cx: &mut TestA
         {
             assert_eq!(
                 completion_menu_entries(&menu),
-                &["bg-blue", "bg-red", "bg-yellow"]
+                &["bg-red", "bg-blue", "bg-yellow"]
             );
         } else {
             panic!("expected completion menu to be open");