Prefer exact case matches when breaking completion ties (#54072)

Sergey Borovikov created

## Summary

Fix completion ordering when two items are otherwise tied and only
differ by letter case.

In cases like `abc` vs `ABC`, if the user types `a`, Zed should prefer
`abc`. If the user types `A`, Zed should prefer `ABC`. This matches the
expectation described in #37081 and #27993, where `subscription` should
rank above `Subscription` for query `s`.

## What changed

In `CompletionsMenu::sort_string_matches`, I added a tie-breaker that
prefers completions with more exact case-sensitive matches at the
fuzzy-match positions.

This only applies after the existing higher-priority sort keys, so it
does not replace fuzzy score, match positions, snippet ordering, or LSP
`sortText`. It only resolves ambiguous ties more intuitively.

## Tests

Added regression coverage in `code_completion_tests` for abstract
case-only examples:

- `a` prefers `abc` over `ABC`
- `A` prefers `ABC` over `abc`
- `ab` prefers `abc` over `ABC`
- `AB` prefers `ABC` over `abc`
- mixed-case multi-letter queries like `Ab` and `aB`

## Verification

Ran:

```bash
cargo test -p editor code_completion_tests
```

PS: all code and description is generated by Codex

Release Notes:

- Fixed completions not tie braking by case

Change summary

crates/editor/src/code_completion_tests.rs | 71 ++++++++++++++++++++++++
crates/editor/src/code_context_menus.rs    | 30 ++++++++++
2 files changed, 101 insertions(+)

Detailed changes

crates/editor/src/code_completion_tests.rs 🔗

@@ -217,6 +217,77 @@ async fn test_sort_positions(cx: &mut TestAppContext) {
     assert_eq!(matches[0].string, "rounded-full");
 }
 
+#[gpui::test]
+async fn test_case_sensitive_match_tie_breaker(cx: &mut TestAppContext) {
+    let completions = vec![
+        CompletionBuilder::variable("abc", None, "11"),
+        CompletionBuilder::variable("ABC", None, "11"),
+    ];
+
+    let matches = filter_and_sort_matches("a", &completions, SnippetSortOrder::default(), cx).await;
+    assert_eq!(
+        matches
+            .iter()
+            .map(|m| m.string.as_str())
+            .collect::<Vec<_>>(),
+        vec!["abc", "ABC"]
+    );
+
+    let matches = filter_and_sort_matches("A", &completions, SnippetSortOrder::default(), cx).await;
+    assert_eq!(
+        matches
+            .iter()
+            .map(|m| m.string.as_str())
+            .collect::<Vec<_>>(),
+        vec!["ABC", "abc"]
+    );
+
+    let matches =
+        filter_and_sort_matches("ab", &completions, SnippetSortOrder::default(), cx).await;
+    assert_eq!(
+        matches
+            .iter()
+            .map(|m| m.string.as_str())
+            .collect::<Vec<_>>(),
+        vec!["abc", "ABC"]
+    );
+
+    let matches =
+        filter_and_sort_matches("AB", &completions, SnippetSortOrder::default(), cx).await;
+    assert_eq!(
+        matches
+            .iter()
+            .map(|m| m.string.as_str())
+            .collect::<Vec<_>>(),
+        vec!["ABC", "abc"]
+    );
+
+    let completions = vec![
+        CompletionBuilder::variable("aBc", None, "11"),
+        CompletionBuilder::variable("Abc", None, "11"),
+    ];
+
+    let matches =
+        filter_and_sort_matches("Ab", &completions, SnippetSortOrder::default(), cx).await;
+    assert_eq!(
+        matches
+            .iter()
+            .map(|m| m.string.as_str())
+            .collect::<Vec<_>>(),
+        vec!["Abc", "aBc"]
+    );
+
+    let matches =
+        filter_and_sort_matches("aB", &completions, SnippetSortOrder::default(), cx).await;
+    assert_eq!(
+        matches
+            .iter()
+            .map(|m| m.string.as_str())
+            .collect::<Vec<_>>(),
+        vec!["aBc", "Abc"]
+    );
+}
+
 #[gpui::test]
 async fn test_fuzzy_over_sort_positions(cx: &mut TestAppContext) {
     let completions = vec![

crates/editor/src/code_context_menus.rs 🔗

@@ -1256,6 +1256,7 @@ impl CompletionsMenu {
                 sort_snippet: Reverse<i32>,
                 sort_score: Reverse<OrderedFloat<f64>>,
                 sort_positions: Vec<usize>,
+                sort_exact_case_matches: Reverse<usize>,
                 sort_text: Option<&'a str>,
                 sort_kind: usize,
                 sort_label: &'a str,
@@ -1311,6 +1312,10 @@ impl CompletionsMenu {
                     SnippetSortOrder::None => Reverse(0),
                 };
                 let sort_positions = string_match.positions.clone();
+                let sort_exact_case_matches = Reverse(exact_case_match_count(
+                    query.unwrap_or_default(),
+                    string_match,
+                ));
                 // This exact matching won't work for multi-word snippets, but it's fine
                 let sort_exact = Reverse(if Some(completion.label.filter_text()) == query {
                     1
@@ -1323,6 +1328,7 @@ impl CompletionsMenu {
                     sort_snippet,
                     sort_score,
                     sort_positions,
+                    sort_exact_case_matches,
                     sort_text,
                     sort_kind,
                     sort_label,
@@ -1379,6 +1385,30 @@ impl CompletionsMenu {
     }
 }
 
+fn exact_case_match_count(query: &str, string_match: &StringMatch) -> usize {
+    let mut exact_matches = 0;
+    let mut query_chars = query.chars();
+    let mut next_query_char = query_chars.next();
+    let mut matched_positions = string_match.positions.iter().copied().peekable();
+
+    for (index, candidate_char) in string_match.string.char_indices() {
+        if matched_positions.peek() == Some(&index) {
+            let Some(query_char) = next_query_char else {
+                break;
+            };
+
+            if query_char == candidate_char {
+                exact_matches += 1;
+            }
+
+            matched_positions.next();
+            next_query_char = query_chars.next();
+        }
+    }
+
+    exact_matches
+}
+
 #[derive(Clone)]
 pub struct AvailableCodeAction {
     pub action: CodeAction,