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

## 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
This commit is contained in:
Sergey Borovikov authored and GitHub committed 2026-04-21 08:25:58 +00:00
1 parent 1ebb960992
commit 3ca38c7e1b
2 files changed
+101

No files matched your search

@@ -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![
+30
View File
@@ -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,