From 95b0c5aeefeec2c1245883991920339b53738310 Mon Sep 17 00:00:00 2001 From: Sergey Borovikov <48716255+foretoo@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:25:58 +0100 Subject: [PATCH] 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 --- crates/editor/src/code_completion_tests.rs | 71 ++++++++++++++++++++++ crates/editor/src/code_context_menus.rs | 30 +++++++++ 2 files changed, 101 insertions(+) diff --git a/crates/editor/src/code_completion_tests.rs b/crates/editor/src/code_completion_tests.rs index 3211f0b818eb3079007db4bf268e84bd53d3cbf1..b3d05e23e574867de91d46adfeb4aadefe075161 100644 --- a/crates/editor/src/code_completion_tests.rs +++ b/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!["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!["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!["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!["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!["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!["aBc", "Abc"] + ); +} + #[gpui::test] async fn test_fuzzy_over_sort_positions(cx: &mut TestAppContext) { let completions = vec![ diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index 2db2086eef422a87a0825c4a4ad820d422b160e9..2c609e5ba81a000e44983c0a0d8709f2a4ad3556 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -1256,6 +1256,7 @@ impl CompletionsMenu { sort_snippet: Reverse, sort_score: Reverse>, sort_positions: Vec, + sort_exact_case_matches: Reverse, 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,