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:
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![
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user