From da94f898e6318de5dc85fd8ed2fe38a85365d4a6 Mon Sep 17 00:00:00 2001 From: Andrew Farkas <6060305+HactarCE@users.noreply.github.com> Date: Tue, 11 Nov 2025 16:34:25 -0500 Subject: [PATCH] Add support for multi-word snippet prefixes (#42398) Supercedes #41126 Closes #39559, #35397, and #41426 Release Notes: - Added support for multi-word snippet prefixes --------- Co-authored-by: Agus Zubiaga Co-authored-by: Conrad Irwin Co-authored-by: Cole Miller --- .../agent_ui/src/acp/completion_provider.rs | 16 ++ .../src/context_picker/completion_provider.rs | 14 + crates/agent_ui/src/slash_command.rs | 4 + .../src/session/running/console.rs | 4 + crates/editor/src/code_completion_tests.rs | 2 + crates/editor/src/code_context_menus.rs | 201 ++++++------- crates/editor/src/editor.rs | 271 ++++++++++++------ crates/editor/src/editor_tests.rs | 271 ++++++++++++++++++ crates/editor/src/test/editor_test_context.rs | 11 + crates/inspector_ui/src/div_inspector.rs | 2 + crates/keymap_editor/src/keymap_editor.rs | 2 + crates/project/Cargo.toml | 2 + crates/project/src/lsp_store.rs | 6 + crates/project/src/project.rs | 16 ++ crates/snippet_provider/Cargo.toml | 3 + crates/snippet_provider/src/lib.rs | 13 + 16 files changed, 652 insertions(+), 186 deletions(-) diff --git a/crates/agent_ui/src/acp/completion_provider.rs b/crates/agent_ui/src/acp/completion_provider.rs index 408dbedcfdd4998ca8d2e094aab4799bad168629..e87526957ce844a10c7c4f07f7ec6790927b142e 100644 --- a/crates/agent_ui/src/acp/completion_provider.rs +++ b/crates/agent_ui/src/acp/completion_provider.rs @@ -109,6 +109,8 @@ impl ContextPickerCompletionProvider { icon_path: Some(mode.icon().path().into()), documentation: None, source: project::CompletionSource::Custom, + match_start: None, + snippet_deduplication_key: None, insert_text_mode: None, // This ensures that when a user accepts this completion, the // completion menu will still be shown after "@category " is @@ -146,6 +148,8 @@ impl ContextPickerCompletionProvider { documentation: None, insert_text_mode: None, source: project::CompletionSource::Custom, + match_start: None, + snippet_deduplication_key: None, icon_path: Some(icon_for_completion), confirm: Some(confirm_completion_callback( thread_entry.title().clone(), @@ -177,6 +181,8 @@ impl ContextPickerCompletionProvider { documentation: None, insert_text_mode: None, source: project::CompletionSource::Custom, + match_start: None, + snippet_deduplication_key: None, icon_path: Some(icon_path), confirm: Some(confirm_completion_callback( rule.title, @@ -233,6 +239,8 @@ impl ContextPickerCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(completion_icon_path), + match_start: None, + snippet_deduplication_key: None, insert_text_mode: None, confirm: Some(confirm_completion_callback( file_name, @@ -284,6 +292,8 @@ impl ContextPickerCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(icon_path), + match_start: None, + snippet_deduplication_key: None, insert_text_mode: None, confirm: Some(confirm_completion_callback( symbol.name.into(), @@ -316,6 +326,8 @@ impl ContextPickerCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(icon_path), + match_start: None, + snippet_deduplication_key: None, insert_text_mode: None, confirm: Some(confirm_completion_callback( url_to_fetch.to_string().into(), @@ -384,6 +396,8 @@ impl ContextPickerCompletionProvider { icon_path: Some(action.icon().path().into()), documentation: None, source: project::CompletionSource::Custom, + match_start: None, + snippet_deduplication_key: None, insert_text_mode: None, // This ensures that when a user accepts this completion, the // completion menu will still be shown after "@category " is @@ -774,6 +788,8 @@ impl CompletionProvider for ContextPickerCompletionProvider { )), source: project::CompletionSource::Custom, icon_path: None, + match_start: None, + snippet_deduplication_key: None, insert_text_mode: None, confirm: Some(Arc::new({ let editor = editor.clone(); diff --git a/crates/agent_ui/src/context_picker/completion_provider.rs b/crates/agent_ui/src/context_picker/completion_provider.rs index 1fa128cde82dba900136ad6d136aad858512f169..5dee769b4d0f0d2556b407721eac5dc70f647060 100644 --- a/crates/agent_ui/src/context_picker/completion_provider.rs +++ b/crates/agent_ui/src/context_picker/completion_provider.rs @@ -278,6 +278,8 @@ impl ContextPickerCompletionProvider { icon_path: Some(mode.icon().path().into()), documentation: None, source: project::CompletionSource::Custom, + match_start: None, + snippet_deduplication_key: None, insert_text_mode: None, // This ensures that when a user accepts this completion, the // completion menu will still be shown after "@category " is @@ -386,6 +388,8 @@ impl ContextPickerCompletionProvider { icon_path: Some(action.icon().path().into()), documentation: None, source: project::CompletionSource::Custom, + match_start: None, + snippet_deduplication_key: None, insert_text_mode: None, // This ensures that when a user accepts this completion, the // completion menu will still be shown after "@category " is @@ -417,6 +421,8 @@ impl ContextPickerCompletionProvider { replace_range: source_range.clone(), new_text, label: CodeLabel::plain(thread_entry.title().to_string(), None), + match_start: None, + snippet_deduplication_key: None, documentation: None, insert_text_mode: None, source: project::CompletionSource::Custom, @@ -484,6 +490,8 @@ impl ContextPickerCompletionProvider { replace_range: source_range.clone(), new_text, label: CodeLabel::plain(rules.title.to_string(), None), + match_start: None, + snippet_deduplication_key: None, documentation: None, insert_text_mode: None, source: project::CompletionSource::Custom, @@ -524,6 +532,8 @@ impl ContextPickerCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(IconName::ToolWeb.path().into()), + match_start: None, + snippet_deduplication_key: None, insert_text_mode: None, confirm: Some(confirm_completion_callback( IconName::ToolWeb.path().into(), @@ -612,6 +622,8 @@ impl ContextPickerCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(completion_icon_path), + match_start: None, + snippet_deduplication_key: None, insert_text_mode: None, confirm: Some(confirm_completion_callback( crease_icon_path, @@ -689,6 +701,8 @@ impl ContextPickerCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(IconName::Code.path().into()), + match_start: None, + snippet_deduplication_key: None, insert_text_mode: None, confirm: Some(confirm_completion_callback( IconName::Code.path().into(), diff --git a/crates/agent_ui/src/slash_command.rs b/crates/agent_ui/src/slash_command.rs index c2f26c4f2ed33860196790746dd296e8c617b810..7d3ea0105a0aafb4cfccf4076cb95e28c99dec28 100644 --- a/crates/agent_ui/src/slash_command.rs +++ b/crates/agent_ui/src/slash_command.rs @@ -127,6 +127,8 @@ impl SlashCommandCompletionProvider { new_text, label: command.label(cx), icon_path: None, + match_start: None, + snippet_deduplication_key: None, insert_text_mode: None, confirm, source: CompletionSource::Custom, @@ -232,6 +234,8 @@ impl SlashCommandCompletionProvider { icon_path: None, new_text, documentation: None, + match_start: None, + snippet_deduplication_key: None, confirm, insert_text_mode: None, source: CompletionSource::Custom, diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs index e157d832b440b8016f152c88b376a9418ee3c843..23b3ca481722c7869caf43958754889f92dc2fe5 100644 --- a/crates/debugger_ui/src/session/running/console.rs +++ b/crates/debugger_ui/src/session/running/console.rs @@ -677,6 +677,8 @@ impl ConsoleQueryBarCompletionProvider { ), new_text: string_match.string.clone(), label: CodeLabel::plain(string_match.string.clone(), None), + match_start: None, + snippet_deduplication_key: None, icon_path: None, documentation: Some(CompletionDocumentation::MultiLineMarkdown( variable_value.into(), @@ -790,6 +792,8 @@ impl ConsoleQueryBarCompletionProvider { documentation: completion.detail.map(|detail| { CompletionDocumentation::MultiLineMarkdown(detail.into()) }), + match_start: None, + snippet_deduplication_key: None, confirm: None, source: project::CompletionSource::Dap { sort_text }, insert_text_mode: None, diff --git a/crates/editor/src/code_completion_tests.rs b/crates/editor/src/code_completion_tests.rs index ec97c0ebb31952da9ad8e9e6f4f75b4b0078c4a3..364b310f367ff195f9aee8693a815be94db0b44d 100644 --- a/crates/editor/src/code_completion_tests.rs +++ b/crates/editor/src/code_completion_tests.rs @@ -305,6 +305,8 @@ impl CompletionBuilder { icon_path: None, insert_text_mode: None, confirm: None, + match_start: None, + snippet_deduplication_key: None, } } } diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index 9e29cd955a80c7025ef2ff1ee5aaf38c665bed1a..ac8f26764b5a037a0a1618052a34466effd80563 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -17,7 +17,6 @@ use project::{CompletionDisplayOptions, CompletionSource}; use task::DebugScenario; use task::TaskContext; -use std::collections::VecDeque; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::{ @@ -36,12 +35,13 @@ use util::ResultExt; use crate::hover_popover::{hover_markdown_style, open_markdown_url}; use crate::{ - CodeActionProvider, CompletionId, CompletionItemKind, CompletionProvider, DisplayRow, Editor, - EditorStyle, ResolvedTasks, + CodeActionProvider, CompletionId, CompletionProvider, DisplayRow, Editor, EditorStyle, + ResolvedTasks, actions::{ConfirmCodeAction, ConfirmCompletion}, split_words, styled_runs_for_code_label, }; use crate::{CodeActionSource, EditorSettings}; +use collections::{HashSet, VecDeque}; use settings::{Settings, SnippetSortOrder}; pub const MENU_GAP: Pixels = px(4.); @@ -220,7 +220,9 @@ pub struct CompletionsMenu { pub is_incomplete: bool, pub buffer: Entity, pub completions: Rc>>, - match_candidates: Arc<[StringMatchCandidate]>, + /// String match candidate for each completion, grouped by `match_start`. + match_candidates: Arc<[(Option, Vec)]>, + /// Entries displayed in the menu, which is a filtered and sorted subset of `match_candidates`. pub entries: Rc>>, pub selected_item: usize, filter_task: Task<()>, @@ -308,6 +310,8 @@ impl CompletionsMenu { .iter() .enumerate() .map(|(id, completion)| StringMatchCandidate::new(id, completion.label.filter_text())) + .into_group_map_by(|candidate| completions[candidate.id].match_start) + .into_iter() .collect(); let completions_menu = Self { @@ -355,6 +359,8 @@ impl CompletionsMenu { replace_range: selection.start.text_anchor..selection.end.text_anchor, new_text: choice.to_string(), label: CodeLabel::plain(choice.to_string(), None), + match_start: None, + snippet_deduplication_key: None, icon_path: None, documentation: None, confirm: None, @@ -363,11 +369,14 @@ impl CompletionsMenu { }) .collect(); - let match_candidates = choices - .iter() - .enumerate() - .map(|(id, completion)| StringMatchCandidate::new(id, completion)) - .collect(); + let match_candidates = Arc::new([( + None, + choices + .iter() + .enumerate() + .map(|(id, completion)| StringMatchCandidate::new(id, completion)) + .collect(), + )]); let entries = choices .iter() .enumerate() @@ -948,7 +957,7 @@ impl CompletionsMenu { } let mat = &self.entries.borrow()[self.selected_item]; - let completions = self.completions.borrow_mut(); + let completions = self.completions.borrow(); let multiline_docs = match completions[mat.candidate_id].documentation.as_ref() { Some(CompletionDocumentation::MultiLinePlainText(text)) => div().child(text.clone()), Some(CompletionDocumentation::SingleLineAndMultiLinePlainText { @@ -1026,57 +1035,74 @@ impl CompletionsMenu { pub fn filter( &mut self, - query: Option>, + query: Arc, + query_end: text::Anchor, + buffer: &Entity, provider: Option>, window: &mut Window, cx: &mut Context, ) { self.cancel_filter.store(true, Ordering::Relaxed); - if let Some(query) = query { - self.cancel_filter = Arc::new(AtomicBool::new(false)); - let matches = self.do_async_filtering(query, cx); - let id = self.id; - self.filter_task = cx.spawn_in(window, async move |editor, cx| { - let matches = matches.await; - editor - .update_in(cx, |editor, window, cx| { - editor.with_completions_menu_matching_id(id, |this| { - if let Some(this) = this { - this.set_filter_results(matches, provider, window, cx); - } - }); - }) - .ok(); - }); - } else { - self.filter_task = Task::ready(()); - let matches = self.unfiltered_matches(); - self.set_filter_results(matches, provider, window, cx); - } + self.cancel_filter = Arc::new(AtomicBool::new(false)); + let matches = self.do_async_filtering(query, query_end, buffer, cx); + let id = self.id; + self.filter_task = cx.spawn_in(window, async move |editor, cx| { + let matches = matches.await; + editor + .update_in(cx, |editor, window, cx| { + editor.with_completions_menu_matching_id(id, |this| { + if let Some(this) = this { + this.set_filter_results(matches, provider, window, cx); + } + }); + }) + .ok(); + }); } pub fn do_async_filtering( &self, query: Arc, + query_end: text::Anchor, + buffer: &Entity, cx: &Context, ) -> Task> { - let matches_task = cx.background_spawn({ - let query = query.clone(); - let match_candidates = self.match_candidates.clone(); - let cancel_filter = self.cancel_filter.clone(); - let background_executor = cx.background_executor().clone(); - async move { - fuzzy::match_strings( - &match_candidates, - &query, - query.chars().any(|c| c.is_uppercase()), - false, - 1000, - &cancel_filter, - background_executor, - ) - .await + let buffer_snapshot = buffer.read(cx).snapshot(); + let background_executor = cx.background_executor().clone(); + let match_candidates = self.match_candidates.clone(); + let cancel_filter = self.cancel_filter.clone(); + let default_query = query.clone(); + + let matches_task = cx.background_spawn(async move { + let queries_and_candidates = match_candidates + .iter() + .map(|(query_start, candidates)| { + let query_for_batch = match query_start { + Some(start) => { + Arc::new(buffer_snapshot.text_for_range(*start..query_end).collect()) + } + None => default_query.clone(), + }; + (query_for_batch, candidates) + }) + .collect_vec(); + + let mut results = vec![]; + for (query, match_candidates) in queries_and_candidates { + results.extend( + fuzzy::match_strings( + &match_candidates, + &query, + query.chars().any(|c| c.is_uppercase()), + false, + 1000, + &cancel_filter, + background_executor.clone(), + ) + .await, + ); } + results }); let completions = self.completions.clone(); @@ -1085,45 +1111,31 @@ impl CompletionsMenu { cx.foreground_executor().spawn(async move { let mut matches = matches_task.await; + let completions_ref = completions.borrow(); + if sort_completions { matches = Self::sort_string_matches( matches, - Some(&query), + Some(&query), // used for non-snippets only snippet_sort_order, - completions.borrow().as_ref(), + &completions_ref, ); } + // Remove duplicate snippet prefixes (e.g., "cool code" will match + // the text "c c" in two places; we should only show the longer one) + let mut snippets_seen = HashSet::<(usize, usize)>::default(); + matches.retain(|result| { + match completions_ref[result.candidate_id].snippet_deduplication_key { + Some(key) => snippets_seen.insert(key), + None => true, + } + }); + matches }) } - /// Like `do_async_filtering` but there is no filter query, so no need to spawn tasks. - pub fn unfiltered_matches(&self) -> Vec { - let mut matches = self - .match_candidates - .iter() - .enumerate() - .map(|(candidate_id, candidate)| StringMatch { - candidate_id, - score: Default::default(), - positions: Default::default(), - string: candidate.string.clone(), - }) - .collect(); - - if self.sort_completions { - matches = Self::sort_string_matches( - matches, - None, - self.snippet_sort_order, - self.completions.borrow().as_ref(), - ); - } - - matches - } - pub fn set_filter_results( &mut self, matches: Vec, @@ -1166,28 +1178,13 @@ impl CompletionsMenu { .and_then(|c| c.to_lowercase().next()); if snippet_sort_order == SnippetSortOrder::None { - matches.retain(|string_match| { - let completion = &completions[string_match.candidate_id]; - - let is_snippet = matches!( - &completion.source, - CompletionSource::Lsp { lsp_completion, .. } - if lsp_completion.kind == Some(CompletionItemKind::SNIPPET) - ); - - !is_snippet - }); + matches + .retain(|string_match| !completions[string_match.candidate_id].is_snippet_kind()); } matches.sort_unstable_by_key(|string_match| { let completion = &completions[string_match.candidate_id]; - let is_snippet = matches!( - &completion.source, - CompletionSource::Lsp { lsp_completion, .. } - if lsp_completion.kind == Some(CompletionItemKind::SNIPPET) - ); - let sort_text = match &completion.source { CompletionSource::Lsp { lsp_completion, .. } => lsp_completion.sort_text.as_deref(), CompletionSource::Dap { sort_text } => Some(sort_text.as_str()), @@ -1199,14 +1196,17 @@ impl CompletionsMenu { let score = string_match.score; let sort_score = Reverse(OrderedFloat(score)); - let query_start_doesnt_match_split_words = query_start_lower - .map(|query_char| { - !split_words(&string_match.string).any(|word| { - word.chars().next().and_then(|c| c.to_lowercase().next()) - == Some(query_char) + // Snippets do their own first-letter matching logic elsewhere. + let is_snippet = completion.is_snippet_kind(); + let query_start_doesnt_match_split_words = !is_snippet + && query_start_lower + .map(|query_char| { + !split_words(&string_match.string).any(|word| { + word.chars().next().and_then(|c| c.to_lowercase().next()) + == Some(query_char) + }) }) - }) - .unwrap_or(false); + .unwrap_or(false); if query_start_doesnt_match_split_words { MatchTier::OtherMatch { sort_score } @@ -1218,6 +1218,7 @@ impl CompletionsMenu { SnippetSortOrder::None => Reverse(0), }; let sort_positions = string_match.positions.clone(); + // 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 } else { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index f7eb309fd1b67272103133d47303ef7f0b9e5f35..d4647300996ecfb14dbc470ef8d9cc8a5db3d1dd 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -5517,7 +5517,14 @@ impl Editor { if let Some(CodeContextMenu::Completions(menu)) = self.context_menu.borrow_mut().as_mut() { if filter_completions { - menu.filter(query.clone(), provider.clone(), window, cx); + menu.filter( + query.clone().unwrap_or_default(), + buffer_position.text_anchor, + &buffer, + provider.clone(), + window, + cx, + ); } // When `is_incomplete` is false, no need to re-query completions when the current query // is a suffix of the initial query. @@ -5526,7 +5533,7 @@ impl Editor { // If the new query is a suffix of the old query (typing more characters) and // the previous result was complete, the existing completions can be filtered. // - // Note that this is always true for snippet completions. + // Note that snippet completions are always complete. let query_matches = match (&menu.initial_query, &query) { (Some(initial_query), Some(query)) => query.starts_with(initial_query.as_ref()), (None, _) => true, @@ -5656,12 +5663,15 @@ impl Editor { }; let mut words = if load_word_completions { - cx.background_spawn(async move { - buffer_snapshot.words_in_range(WordsQuery { - fuzzy_contents: None, - range: word_search_range, - skip_digits, - }) + cx.background_spawn({ + let buffer_snapshot = buffer_snapshot.clone(); + async move { + buffer_snapshot.words_in_range(WordsQuery { + fuzzy_contents: None, + range: word_search_range, + skip_digits, + }) + } }) } else { Task::ready(BTreeMap::default()) @@ -5671,8 +5681,11 @@ impl Editor { && provider.show_snippets() && let Some(project) = self.project() { + let char_classifier = buffer_snapshot + .char_classifier_at(buffer_position) + .scope_context(Some(CharScopeContext::Completion)); project.update(cx, |project, cx| { - snippet_completions(project, &buffer, buffer_position, cx) + snippet_completions(project, &buffer, buffer_position, char_classifier, cx) }) } else { Task::ready(Ok(CompletionResponse { @@ -5727,6 +5740,8 @@ impl Editor { replace_range: word_replace_range.clone(), new_text: word.clone(), label: CodeLabel::plain(word, None), + match_start: None, + snippet_deduplication_key: None, icon_path: None, documentation: None, source: CompletionSource::BufferWord { @@ -5775,11 +5790,12 @@ impl Editor { ); let query = if filter_completions { query } else { None }; - let matches_task = if let Some(query) = query { - menu.do_async_filtering(query, cx) - } else { - Task::ready(menu.unfiltered_matches()) - }; + let matches_task = menu.do_async_filtering( + query.unwrap_or_default(), + buffer_position, + &buffer, + cx, + ); (menu, matches_task) }) else { return; @@ -5796,7 +5812,7 @@ impl Editor { return; }; - // Only valid to take prev_menu because it the new menu is immediately set + // Only valid to take prev_menu because either the new menu is immediately set // below, or the menu is hidden. if let Some(CodeContextMenu::Completions(prev_menu)) = editor.context_menu.borrow_mut().take() @@ -23201,10 +23217,11 @@ impl CodeActionProvider for Entity { fn snippet_completions( project: &Project, buffer: &Entity, - buffer_position: text::Anchor, + buffer_anchor: text::Anchor, + classifier: CharClassifier, cx: &mut App, ) -> Task> { - let languages = buffer.read(cx).languages_at(buffer_position); + let languages = buffer.read(cx).languages_at(buffer_anchor); let snippet_store = project.snippets().read(cx); let scopes: Vec<_> = languages @@ -23233,97 +23250,146 @@ fn snippet_completions( let executor = cx.background_executor().clone(); cx.background_spawn(async move { + let is_word_char = |c| classifier.is_word(c); + let mut is_incomplete = false; let mut completions: Vec = Vec::new(); - for (scope, snippets) in scopes.into_iter() { - let classifier = - CharClassifier::new(Some(scope)).scope_context(Some(CharScopeContext::Completion)); - - const MAX_WORD_PREFIX_LEN: usize = 128; - let last_word: String = snapshot - .reversed_chars_for_range(text::Anchor::MIN..buffer_position) - .take(MAX_WORD_PREFIX_LEN) - .take_while(|c| classifier.is_word(*c)) - .collect::() - .chars() - .rev() - .collect(); - if last_word.is_empty() { - return Ok(CompletionResponse { - completions: vec![], - display_options: CompletionDisplayOptions::default(), - is_incomplete: true, - }); - } + const MAX_PREFIX_LEN: usize = 128; + let buffer_offset = text::ToOffset::to_offset(&buffer_anchor, &snapshot); + let window_start = buffer_offset.saturating_sub(MAX_PREFIX_LEN); + let window_start = snapshot.clip_offset(window_start, Bias::Left); - let as_offset = text::ToOffset::to_offset(&buffer_position, &snapshot); - let to_lsp = |point: &text::Anchor| { - let end = text::ToPointUtf16::to_point_utf16(point, &snapshot); - point_to_lsp(end) - }; - let lsp_end = to_lsp(&buffer_position); + let max_buffer_window: String = snapshot + .text_for_range(window_start..buffer_offset) + .collect(); + + if max_buffer_window.is_empty() { + return Ok(CompletionResponse { + completions: vec![], + display_options: CompletionDisplayOptions::default(), + is_incomplete: true, + }); + } - let candidates = snippets + for (_scope, snippets) in scopes.into_iter() { + // Sort snippets by word count to match longer snippet prefixes first. + let mut sorted_snippet_candidates = snippets .iter() .enumerate() - .flat_map(|(ix, snippet)| { + .flat_map(|(snippet_ix, snippet)| { snippet .prefix .iter() - .map(move |prefix| StringMatchCandidate::new(ix, prefix)) + .enumerate() + .map(move |(prefix_ix, prefix)| { + let word_count = + snippet_candidate_suffixes(prefix, is_word_char).count(); + ((snippet_ix, prefix_ix), prefix, word_count) + }) }) - .collect::>(); + .collect_vec(); + sorted_snippet_candidates + .sort_unstable_by_key(|(_, _, word_count)| Reverse(*word_count)); + + // Each prefix may be matched multiple times; the completion menu must filter out duplicates. + + let buffer_windows = snippet_candidate_suffixes(&max_buffer_window, is_word_char) + .take( + sorted_snippet_candidates + .first() + .map(|(_, _, word_count)| *word_count) + .unwrap_or_default(), + ) + .collect_vec(); const MAX_RESULTS: usize = 100; - let mut matches = fuzzy::match_strings( - &candidates, - &last_word, - last_word.chars().any(|c| c.is_uppercase()), - true, - MAX_RESULTS, - &Default::default(), - executor.clone(), - ) - .await; + // Each match also remembers how many characters from the buffer it consumed + let mut matches: Vec<(StringMatch, usize)> = vec![]; + + let mut snippet_list_cutoff_index = 0; + for (buffer_index, buffer_window) in buffer_windows.iter().enumerate().rev() { + let word_count = buffer_index + 1; + // Increase `snippet_list_cutoff_index` until we have all of the + // snippets with sufficiently many words. + while sorted_snippet_candidates + .get(snippet_list_cutoff_index) + .is_some_and(|(_ix, _prefix, snippet_word_count)| { + *snippet_word_count >= word_count + }) + { + snippet_list_cutoff_index += 1; + } - if matches.len() >= MAX_RESULTS { - is_incomplete = true; - } + // Take only the candidates with at least `word_count` many words + let snippet_candidates_at_word_len = + &sorted_snippet_candidates[..snippet_list_cutoff_index]; - // Remove all candidates where the query's start does not match the start of any word in the candidate - if let Some(query_start) = last_word.chars().next() { - matches.retain(|string_match| { - split_words(&string_match.string).any(|word| { - // Check that the first codepoint of the word as lowercase matches the first - // codepoint of the query as lowercase - word.chars() - .flat_map(|codepoint| codepoint.to_lowercase()) - .zip(query_start.to_lowercase()) - .all(|(word_cp, query_cp)| word_cp == query_cp) + let candidates = snippet_candidates_at_word_len + .iter() + .map(|(_snippet_ix, prefix, _snippet_word_count)| prefix) + .enumerate() // index in `sorted_snippet_candidates` + // First char must match + .filter(|(_ix, prefix)| { + itertools::equal( + prefix + .chars() + .next() + .into_iter() + .flat_map(|c| c.to_lowercase()), + buffer_window + .chars() + .next() + .into_iter() + .flat_map(|c| c.to_lowercase()), + ) }) - }); + .map(|(ix, prefix)| StringMatchCandidate::new(ix, prefix)) + .collect::>(); + + matches.extend( + fuzzy::match_strings( + &candidates, + &buffer_window, + buffer_window.chars().any(|c| c.is_uppercase()), + true, + MAX_RESULTS - matches.len(), // always prioritize longer snippets + &Default::default(), + executor.clone(), + ) + .await + .into_iter() + .map(|string_match| (string_match, buffer_window.len())), + ); + + if matches.len() >= MAX_RESULTS { + break; + } } - let matched_strings = matches - .into_iter() - .map(|m| m.string) - .collect::>(); + let to_lsp = |point: &text::Anchor| { + let end = text::ToPointUtf16::to_point_utf16(point, &snapshot); + point_to_lsp(end) + }; + let lsp_end = to_lsp(&buffer_anchor); - completions.extend(snippets.iter().filter_map(|snippet| { - let matching_prefix = snippet - .prefix - .iter() - .find(|prefix| matched_strings.contains(*prefix))?; - let start = as_offset - last_word.len(); + if matches.len() >= MAX_RESULTS { + is_incomplete = true; + } + + completions.extend(matches.iter().map(|(string_match, buffer_window_len)| { + let ((snippet_index, prefix_index), matching_prefix, _snippet_word_count) = + sorted_snippet_candidates[string_match.candidate_id]; + let snippet = &snippets[snippet_index]; + let start = buffer_offset - buffer_window_len; let start = snapshot.anchor_before(start); - let range = start..buffer_position; + let range = start..buffer_anchor; let lsp_start = to_lsp(&start); let lsp_range = lsp::Range { start: lsp_start, end: lsp_end, }; - Some(Completion { + Completion { replace_range: range, new_text: snippet.body.clone(), source: CompletionSource::Lsp { @@ -23353,7 +23419,11 @@ fn snippet_completions( }), lsp_defaults: None, }, - label: CodeLabel::plain(matching_prefix.clone(), None), + label: CodeLabel { + text: matching_prefix.clone(), + runs: Vec::new(), + filter_range: 0..matching_prefix.len(), + }, icon_path: None, documentation: Some(CompletionDocumentation::SingleLineAndMultiLinePlainText { single_line: snippet.name.clone().into(), @@ -23364,8 +23434,10 @@ fn snippet_completions( }), insert_text_mode: None, confirm: None, - }) - })) + match_start: Some(start), + snippet_deduplication_key: Some((snippet_index, prefix_index)), + } + })); } Ok(CompletionResponse { @@ -24611,6 +24683,33 @@ pub(crate) fn split_words(text: &str) -> impl std::iter::Iterator + }) } +/// Given a string of text immediately before the cursor, iterates over possible +/// strings a snippet could match to. More precisely: returns an iterator over +/// suffixes of `text` created by splitting at word boundaries (before & after +/// every non-word character). +/// +/// Shorter suffixes are returned first. +pub(crate) fn snippet_candidate_suffixes( + text: &str, + is_word_char: impl Fn(char) -> bool, +) -> impl std::iter::Iterator { + let mut prev_index = text.len(); + let mut prev_codepoint = None; + text.char_indices() + .rev() + .chain([(0, '\0')]) + .filter_map(move |(index, codepoint)| { + let prev_index = std::mem::replace(&mut prev_index, index); + let prev_codepoint = prev_codepoint.replace(codepoint)?; + if is_word_char(prev_codepoint) && is_word_char(codepoint) { + None + } else { + let chunk = &text[prev_index..]; // go to end of string + Some(chunk) + } + }) +} + pub trait RangeToAnchorExt: Sized { fn to_anchors(self, snapshot: &MultiBufferSnapshot) -> Range; diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 4510e61b74c9bd9ca8ace634f7554f63c4981dd7..36d7023db33587e43260640782f47522dbb41c6b 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -11414,6 +11414,53 @@ async fn test_snippet_indentation(cx: &mut TestAppContext) { ˇ"}); } +#[gpui::test] +async fn test_snippet_with_multi_word_prefix(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_editor(|editor, _, cx| { + editor.project().unwrap().update(cx, |project, cx| { + project.snippets().update(cx, |snippets, _cx| { + let snippet = project::snippet_provider::Snippet { + prefix: vec!["multi word".to_string()], + body: "this is many words".to_string(), + description: Some("description".to_string()), + name: "multi-word snippet test".to_string(), + }; + snippets.add_snippet_for_test( + None, + PathBuf::from("test_snippets.json"), + vec![Arc::new(snippet)], + ); + }); + }) + }); + + for (input_to_simulate, should_match_snippet) in [ + ("m", true), + ("m ", true), + ("m w", true), + ("aa m w", true), + ("aa m g", false), + ] { + cx.set_state("ˇ"); + cx.simulate_input(input_to_simulate); // fails correctly + + cx.update_editor(|editor, _, _| { + let Some(CodeContextMenu::Completions(context_menu)) = &*editor.context_menu.borrow() + else { + assert!(!should_match_snippet); // no completions! don't even show the menu + return; + }; + assert!(context_menu.visible()); + let completions = context_menu.completions.borrow(); + + assert_eq!(!completions.is_empty(), should_match_snippet); + }); + } +} + #[gpui::test] async fn test_document_format_during_save(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -17369,6 +17416,41 @@ fn test_split_words() { assert_eq!(split(":do_the_thing"), &[":", "do_", "the_", "thing"]); } +#[test] +fn test_split_words_for_snippet_prefix() { + fn split(text: &str) -> Vec<&str> { + snippet_candidate_suffixes(text, |c| c.is_alphanumeric() || c == '_').collect() + } + + assert_eq!(split("HelloWorld"), &["HelloWorld"]); + assert_eq!(split("hello_world"), &["hello_world"]); + assert_eq!(split("_hello_world_"), &["_hello_world_"]); + assert_eq!(split("Hello_World"), &["Hello_World"]); + assert_eq!(split("helloWOrld"), &["helloWOrld"]); + assert_eq!(split("helloworld"), &["helloworld"]); + assert_eq!( + split("this@is!@#$^many . symbols"), + &[ + "symbols", + " symbols", + ". symbols", + " . symbols", + " . symbols", + " . symbols", + "many . symbols", + "^many . symbols", + "$^many . symbols", + "#$^many . symbols", + "@#$^many . symbols", + "!@#$^many . symbols", + "is!@#$^many . symbols", + "@is!@#$^many . symbols", + "this@is!@#$^many . symbols", + ], + ); + assert_eq!(split("a.s"), &["s", ".s", "a.s"]); +} + #[gpui::test] async fn test_move_to_enclosing_bracket(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -25620,6 +25702,195 @@ pub fn check_displayed_completions(expected: Vec<&'static str>, cx: &mut EditorL }); } +#[gpui::test] +async fn test_mixed_completions_with_multi_word_snippet(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + cx.lsp + .set_request_handler::(move |_, _| async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "unsafe".into(), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 9, + }, + end: lsp::Position { + line: 0, + character: 11, + }, + }, + new_text: "unsafe".to_string(), + })), + insert_text_mode: Some(lsp::InsertTextMode::AS_IS), + ..Default::default() + }, + ]))) + }); + + cx.update_editor(|editor, _, cx| { + editor.project().unwrap().update(cx, |project, cx| { + project.snippets().update(cx, |snippets, _cx| { + snippets.add_snippet_for_test( + None, + PathBuf::from("test_snippets.json"), + vec![ + Arc::new(project::snippet_provider::Snippet { + prefix: vec![ + "unlimited word count".to_string(), + "unlimit word count".to_string(), + "unlimited unknown".to_string(), + ], + body: "this is many words".to_string(), + description: Some("description".to_string()), + name: "multi-word snippet test".to_string(), + }), + Arc::new(project::snippet_provider::Snippet { + prefix: vec!["unsnip".to_string(), "@few".to_string()], + body: "fewer words".to_string(), + description: Some("alt description".to_string()), + name: "other name".to_string(), + }), + Arc::new(project::snippet_provider::Snippet { + prefix: vec!["ab aa".to_string()], + body: "abcd".to_string(), + description: None, + name: "alphabet".to_string(), + }), + ], + ); + }); + }) + }); + + let get_completions = |cx: &mut EditorLspTestContext| { + cx.update_editor(|editor, _, _| match &*editor.context_menu.borrow() { + Some(CodeContextMenu::Completions(context_menu)) => { + let entries = context_menu.entries.borrow(); + entries + .iter() + .map(|entry| entry.string.clone()) + .collect_vec() + } + _ => vec![], + }) + }; + + // snippets: + // @foo + // foo bar + // + // when typing: + // + // when typing: + // - if I type a symbol "open the completions with snippets only" + // - if I type a word character "open the completions menu" (if it had been open snippets only, clear it out) + // + // stuff we need: + // - filtering logic change? + // - remember how far back the completion started. + + let test_cases: &[(&str, &[&str])] = &[ + ( + "un", + &[ + "unsafe", + "unlimit word count", + "unlimited unknown", + "unlimited word count", + "unsnip", + ], + ), + ( + "u ", + &[ + "unlimit word count", + "unlimited unknown", + "unlimited word count", + ], + ), + ("u a", &["ab aa", "unsafe"]), // unsAfe + ( + "u u", + &[ + "unsafe", + "unlimit word count", + "unlimited unknown", // ranked highest among snippets + "unlimited word count", + "unsnip", + ], + ), + ("uw c", &["unlimit word count", "unlimited word count"]), + ( + "u w", + &[ + "unlimit word count", + "unlimited word count", + "unlimited unknown", + ], + ), + ("u w ", &["unlimit word count", "unlimited word count"]), + ( + "u ", + &[ + "unlimit word count", + "unlimited unknown", + "unlimited word count", + ], + ), + ("wor", &[]), + ("uf", &["unsafe"]), + ("af", &["unsafe"]), + ("afu", &[]), + ( + "ue", + &["unsafe", "unlimited unknown", "unlimited word count"], + ), + ("@", &["@few"]), + ("@few", &["@few"]), + ("@ ", &[]), + ("a@", &["@few"]), + ("a@f", &["@few", "unsafe"]), + ("a@fw", &["@few"]), + ("a", &["ab aa", "unsafe"]), + ("aa", &["ab aa"]), + ("aaa", &["ab aa"]), + ("ab", &["ab aa"]), + ("ab ", &["ab aa"]), + ("ab a", &["ab aa", "unsafe"]), + ("ab ab", &["ab aa"]), + ("ab ab aa", &["ab aa"]), + ]; + + for &(input_to_simulate, expected_completions) in test_cases { + cx.set_state("fn a() { ˇ }\n"); + for c in input_to_simulate.split("") { + cx.simulate_input(c); + cx.run_until_parked(); + } + let expected_completions = expected_completions + .iter() + .map(|s| s.to_string()) + .collect_vec(); + assert_eq!( + get_completions(&mut cx), + expected_completions, + "< actual / expected >, input = {input_to_simulate:?}", + ); + } +} + /// Handle completion request passing a marked string specifying where the completion /// should be triggered from using '|' character, what range should be replaced, and what completions /// should be returned using '<' and '>' to delimit the range. diff --git a/crates/editor/src/test/editor_test_context.rs b/crates/editor/src/test/editor_test_context.rs index 7f5bb227fb98d1ebe5df51d59bdae22825bc4fef..200c1f08cfb87dec47d66760c385aa357e45ce95 100644 --- a/crates/editor/src/test/editor_test_context.rs +++ b/crates/editor/src/test/editor_test_context.rs @@ -59,6 +59,17 @@ impl EditorTestContext { }) .await .unwrap(); + + let language = project + .read_with(cx, |project, _cx| { + project.languages().language_for_name("Plain Text") + }) + .await + .unwrap(); + buffer.update(cx, |buffer, cx| { + buffer.set_language(Some(language), cx); + }); + let editor = cx.add_window(|window, cx| { let editor = build_editor_with_project( project, diff --git a/crates/inspector_ui/src/div_inspector.rs b/crates/inspector_ui/src/div_inspector.rs index da99c5b92c1e6ad4d8a3e92ed2e565bcb518e227..8c75c2674dfe0c0b7cd7e42897f868b3990b54b8 100644 --- a/crates/inspector_ui/src/div_inspector.rs +++ b/crates/inspector_ui/src/div_inspector.rs @@ -664,6 +664,8 @@ impl CompletionProvider for RustStyleCompletionProvider { replace_range: replace_range.clone(), new_text: format!(".{}()", method.name), label: CodeLabel::plain(method.name.to_string(), None), + match_start: None, + snippet_deduplication_key: None, icon_path: None, documentation: method.documentation.map(|documentation| { CompletionDocumentation::MultiLineMarkdown(documentation.into()) diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index 3d840de64d67f5bad7646339d66229ff47831028..b5b9f92a491b1f8f5b3f68828095b5a7b6cecb39 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -2993,6 +2993,8 @@ impl CompletionProvider for KeyContextCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: None, + match_start: None, + snippet_deduplication_key: None, insert_text_mode: None, confirm: None, }) diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index d9285a8c24ec5130dd8ce8abf5bbd77c830e0f3f..9b67fde1e0bd31856bfa19d01818c1a5c6564218 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -18,6 +18,7 @@ test-support = [ "client/test-support", "language/test-support", "settings/test-support", + "snippet_provider/test-support", "text/test-support", "prettier/test-support", "worktree/test-support", @@ -107,6 +108,7 @@ pretty_assertions.workspace = true release_channel.workspace = true rpc = { workspace = true, features = ["test-support"] } settings = { workspace = true, features = ["test-support"] } +snippet_provider = { workspace = true, features = ["test-support"] } unindent.workspace = true util = { workspace = true, features = ["test-support"] } worktree = { workspace = true, features = ["test-support"] } diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 540a1a8eb0ac205d5f777e1728bbe7322bbe6187..358bf164d9a26c58f1bbf1bd5829184f6d86e7e4 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -10143,6 +10143,8 @@ impl LspStore { source: completion.source, documentation: None, label: CodeLabel::default(), + match_start: None, + snippet_deduplication_key: None, insert_text_mode: None, icon_path: None, confirm: None, @@ -12847,6 +12849,8 @@ async fn populate_labels_for_completions( source: completion.source, icon_path: None, confirm: None, + match_start: None, + snippet_deduplication_key: None, }); } None => { @@ -12861,6 +12865,8 @@ async fn populate_labels_for_completions( insert_text_mode: None, icon_path: None, confirm: None, + match_start: None, + snippet_deduplication_key: None, }); } } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 13ed42847d522c371226988d8ca133a1748d5fec..25afd501d6c66d699a9238314f6a3d6886b8baa1 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -103,6 +103,7 @@ use search_history::SearchHistory; use settings::{InvalidSettingsError, RegisterSetting, Settings, SettingsLocation, SettingsStore}; use smol::channel::Receiver; use snippet::Snippet; +pub use snippet_provider; use snippet_provider::SnippetProvider; use std::{ borrow::Cow, @@ -476,6 +477,12 @@ pub struct Completion { pub source: CompletionSource, /// A path to an icon for this completion that is shown in the menu. pub icon_path: Option, + /// Text starting here and ending at the cursor will be used as the query for filtering this completion. + /// + /// If None, the start of the surrounding word is used. + pub match_start: Option, + /// Key used for de-duplicating snippets. If None, always considered unique. + pub snippet_deduplication_key: Option<(usize, usize)>, /// Whether to adjust indentation (the default) or not. pub insert_text_mode: Option, /// An optional callback to invoke when this completion is confirmed. @@ -5643,6 +5650,15 @@ impl Completion { } /// Whether this completion is a snippet. + pub fn is_snippet_kind(&self) -> bool { + matches!( + &self.source, + CompletionSource::Lsp { lsp_completion, .. } + if lsp_completion.kind == Some(CompletionItemKind::SNIPPET) + ) + } + + /// Whether this completion is a snippet or snippet-style LSP completion. pub fn is_snippet(&self) -> bool { self.source // `lsp::CompletionListItemDefaults` has `insert_text_format` field diff --git a/crates/snippet_provider/Cargo.toml b/crates/snippet_provider/Cargo.toml index d71439118e90213335213e1365c766eb760bff44..c1f04117d483998ad076e9f4ed2c8d9677695503 100644 --- a/crates/snippet_provider/Cargo.toml +++ b/crates/snippet_provider/Cargo.toml @@ -8,6 +8,9 @@ license = "GPL-3.0-or-later" [lints] workspace = true +[features] +test-support = [] + [dependencies] anyhow.workspace = true collections.workspace = true diff --git a/crates/snippet_provider/src/lib.rs b/crates/snippet_provider/src/lib.rs index eac06924a7906aba08d90c0d1c3d1f1743531954..64711cfc3a7247f6250b65e4f7325dd0bfdc1dcb 100644 --- a/crates/snippet_provider/src/lib.rs +++ b/crates/snippet_provider/src/lib.rs @@ -235,6 +235,19 @@ impl SnippetProvider { user_snippets } + #[cfg(any(test, feature = "test-support"))] + pub fn add_snippet_for_test( + &mut self, + language: SnippetKind, + path: PathBuf, + snippet: Vec>, + ) { + self.snippets + .entry(language) + .or_default() + .insert(path, snippet); + } + pub fn snippets_for(&self, language: SnippetKind, cx: &App) -> Vec> { let mut requested_snippets = self.lookup_snippets::(&language, cx);