diff --git a/crates/agent_ui/src/acp/completion_provider.rs b/crates/agent_ui/src/acp/completion_provider.rs index c5ab47fe189707..d3044b6c54d579 100644 --- a/crates/agent_ui/src/acp/completion_provider.rs +++ b/crates/agent_ui/src/acp/completion_provider.rs @@ -109,6 +109,7 @@ impl ContextPickerCompletionProvider { icon_path: Some(mode.icon().path().into()), documentation: None, source: project::CompletionSource::Custom, + match_start: 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 +147,7 @@ impl ContextPickerCompletionProvider { documentation: None, insert_text_mode: None, source: project::CompletionSource::Custom, + match_start: None, icon_path: Some(icon_for_completion), confirm: Some(confirm_completion_callback( thread_entry.title().clone(), @@ -177,6 +179,7 @@ impl ContextPickerCompletionProvider { documentation: None, insert_text_mode: None, source: project::CompletionSource::Custom, + match_start: None, icon_path: Some(icon_path), confirm: Some(confirm_completion_callback( rule.title, @@ -233,6 +236,7 @@ impl ContextPickerCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(completion_icon_path), + match_start: None, insert_text_mode: None, confirm: Some(confirm_completion_callback( file_name, @@ -279,6 +283,7 @@ impl ContextPickerCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(icon_path), + match_start: None, insert_text_mode: None, confirm: Some(confirm_completion_callback( symbol.name.into(), @@ -311,6 +316,7 @@ impl ContextPickerCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(icon_path), + match_start: None, insert_text_mode: None, confirm: Some(confirm_completion_callback( url_to_fetch.to_string().into(), @@ -379,6 +385,7 @@ impl ContextPickerCompletionProvider { icon_path: Some(action.icon().path().into()), documentation: None, source: project::CompletionSource::Custom, + match_start: None, insert_text_mode: None, // This ensures that when a user accepts this completion, the // completion menu will still be shown after "@category " is @@ -695,6 +702,7 @@ impl CompletionProvider for ContextPickerCompletionProvider { buffer: &Entity, buffer_position: Anchor, _trigger: CompletionContext, + _text: Option<&str>, _window: &mut Window, cx: &mut Context, ) -> Task>> { @@ -751,6 +759,7 @@ impl CompletionProvider for ContextPickerCompletionProvider { )), source: project::CompletionSource::Custom, icon_path: None, + match_start: None, insert_text_mode: None, confirm: Some(Arc::new({ let editor = editor.clone(); diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index c24cefcf2d5fc0..61a1ff3558b1fa 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -1670,6 +1670,7 @@ mod tests { trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER, trigger_character: Some("@".into()), }, + Some("@"), window, cx, ) diff --git a/crates/agent_ui/src/context_picker/completion_provider.rs b/crates/agent_ui/src/context_picker/completion_provider.rs index 56444141f12903..3d2d3dab67408e 100644 --- a/crates/agent_ui/src/context_picker/completion_provider.rs +++ b/crates/agent_ui/src/context_picker/completion_provider.rs @@ -278,6 +278,7 @@ impl ContextPickerCompletionProvider { icon_path: Some(mode.icon().path().into()), documentation: None, source: project::CompletionSource::Custom, + match_start: 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 +387,7 @@ impl ContextPickerCompletionProvider { icon_path: Some(action.icon().path().into()), documentation: None, source: project::CompletionSource::Custom, + match_start: 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 +419,7 @@ impl ContextPickerCompletionProvider { replace_range: source_range.clone(), new_text, label: CodeLabel::plain(thread_entry.title().to_string(), None), + match_start: None, documentation: None, insert_text_mode: None, source: project::CompletionSource::Custom, @@ -484,6 +487,7 @@ impl ContextPickerCompletionProvider { replace_range: source_range.clone(), new_text, label: CodeLabel::plain(rules.title.to_string(), None), + match_start: None, documentation: None, insert_text_mode: None, source: project::CompletionSource::Custom, @@ -524,6 +528,7 @@ impl ContextPickerCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(IconName::ToolWeb.path().into()), + match_start: None, insert_text_mode: None, confirm: Some(confirm_completion_callback( IconName::ToolWeb.path().into(), @@ -612,6 +617,7 @@ impl ContextPickerCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(completion_icon_path), + match_start: None, insert_text_mode: None, confirm: Some(confirm_completion_callback( crease_icon_path, @@ -690,6 +696,7 @@ impl ContextPickerCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(IconName::Code.path().into()), + match_start: None, insert_text_mode: None, confirm: Some(confirm_completion_callback( IconName::Code.path().into(), @@ -738,6 +745,7 @@ impl CompletionProvider for ContextPickerCompletionProvider { buffer: &Entity, buffer_position: Anchor, _trigger: CompletionContext, + _text: Option<&str>, _window: &mut Window, cx: &mut Context, ) -> Task>> { diff --git a/crates/agent_ui/src/slash_command.rs b/crates/agent_ui/src/slash_command.rs index c2f26c4f2ed338..eda0f35f4cd698 100644 --- a/crates/agent_ui/src/slash_command.rs +++ b/crates/agent_ui/src/slash_command.rs @@ -127,6 +127,7 @@ impl SlashCommandCompletionProvider { new_text, label: command.label(cx), icon_path: None, + match_start: None, insert_text_mode: None, confirm, source: CompletionSource::Custom, @@ -232,6 +233,7 @@ impl SlashCommandCompletionProvider { icon_path: None, new_text, documentation: None, + match_start: None, confirm, insert_text_mode: None, source: CompletionSource::Custom, @@ -263,6 +265,7 @@ impl CompletionProvider for SlashCommandCompletionProvider { buffer: &Entity, buffer_position: Anchor, _: editor::CompletionContext, + _text: Option<&str>, window: &mut Window, cx: &mut Context, ) -> Task>> { diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs index 2d01a325a2b005..68b7b80ad576a9 100644 --- a/crates/debugger_ui/src/session/running/console.rs +++ b/crates/debugger_ui/src/session/running/console.rs @@ -520,6 +520,7 @@ impl CompletionProvider for ConsoleQueryBarCompletionProvider { buffer: &Entity, buffer_position: language::Anchor, _trigger: editor::CompletionContext, + _text: Option<&str>, _window: &mut Window, cx: &mut Context, ) -> Task>> { @@ -669,6 +670,7 @@ impl ConsoleQueryBarCompletionProvider { ), new_text: string_match.string.clone(), label: CodeLabel::plain(string_match.string.clone(), None), + match_start: None, icon_path: None, documentation: Some(CompletionDocumentation::MultiLineMarkdown( variable_value.into(), @@ -782,6 +784,7 @@ impl ConsoleQueryBarCompletionProvider { documentation: completion.detail.map(|detail| { CompletionDocumentation::MultiLineMarkdown(detail.into()) }), + match_start: 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 ec97c0ebb31952..f4124337760168 100644 --- a/crates/editor/src/code_completion_tests.rs +++ b/crates/editor/src/code_completion_tests.rs @@ -305,6 +305,7 @@ impl CompletionBuilder { icon_path: None, insert_text_mode: None, confirm: None, + match_start: None, } } } diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index 359c985ee9208a..453ad541f54694 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -34,8 +34,8 @@ use util::ResultExt; use crate::CodeActionSource; 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, }; @@ -217,7 +217,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<()>, @@ -282,6 +284,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 { @@ -329,6 +333,7 @@ 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, icon_path: None, documentation: None, confirm: None, @@ -337,11 +342,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() @@ -912,7 +920,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 { @@ -990,57 +998,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(); @@ -1052,7 +1077,7 @@ impl CompletionsMenu { 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(), ); @@ -1062,32 +1087,6 @@ impl CompletionsMenu { }) } - /// 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, @@ -1130,28 +1129,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()), @@ -1163,14 +1147,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 } @@ -1182,6 +1169,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 c8b0cd37aeed06..2f161afbeab739 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -116,7 +116,7 @@ use inlays::{InlaySplice, inlay_hints::InlayHintRefreshReason}; use itertools::{Either, Itertools}; use language::{ AutoindentMode, BlockCommentConfig, BracketMatch, BracketPair, Buffer, BufferRow, - BufferSnapshot, Capability, CharClassifier, CharKind, CharScopeContext, CodeLabel, CursorShape, + BufferSnapshot, Capability, CharKind, CharScopeContext, CodeLabel, CursorShape, DiagnosticEntryRef, DiffOptions, EditPredictionsMode, EditPreview, HighlightedText, IndentKind, IndentSize, Language, OffsetRangeExt, Point, Runnable, RunnableRange, Selection, SelectionGoal, TextObject, TransactionId, TreeSitterOptions, WordsQuery, @@ -4407,6 +4407,7 @@ impl Editor { ) } } + this.trigger_completion_on_input(&text, trigger_in_words, window, cx); refresh_linked_ranges(this, window, cx); this.refresh_edit_prediction(true, false, window, cx); @@ -5363,7 +5364,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. @@ -5474,6 +5482,7 @@ impl Editor { &buffer, buffer_position, completion_context, + None, window, cx, ); @@ -5555,6 +5564,7 @@ impl Editor { replace_range: word_replace_range.clone(), new_text: word.clone(), label: CodeLabel::plain(word, None), + match_start: None, icon_path: None, documentation: None, source: CompletionSource::BufferWord { @@ -5592,11 +5602,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; @@ -22647,12 +22658,18 @@ pub trait SemanticsProvider { } pub trait CompletionProvider { + /// Provide completions. + /// + /// `text` is the text that was typed to trigger the completion, or `None` + /// if the completion was triggered by some other means (e.g., manually + /// invoked). fn completions( &self, excerpt_id: ExcerptId, buffer: &Entity, buffer_position: text::Anchor, trigger: CompletionContext, + text: Option<&str>, window: &mut Window, cx: &mut Context, ) -> Task>>; @@ -22771,10 +22788,10 @@ impl CodeActionProvider for Entity { fn snippet_completions( project: &Project, buffer: &Entity, - buffer_position: text::Anchor, + buffer_anchor: text::Anchor, 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 @@ -22805,95 +22822,142 @@ fn snippet_completions( cx.background_spawn(async move { 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)| { - snippet - .prefix - .iter() - .map(move |prefix| StringMatchCandidate::new(ix, prefix)) + .flat_map(|(snippet_ix, snippet)| { + snippet.prefix.iter().map(move |prefix| { + ( + snippet_ix, + prefix, + snippet_candidate_suffixes(prefix).count(), + ) + }) }) - .collect::>(); + .collect_vec(); + sorted_snippet_candidates + .sort_unstable_by_key(|(_, _, word_count)| Reverse(*word_count)); + // One snippet may be matched multiple times, but each prefix may only be matched once. + let mut sorted_snippet_candidates_seen = HashSet::::default(); + + let buffer_windows = snippet_candidate_suffixes(&max_buffer_window) + .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() { + 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()), + ) }) - }); + // Match each prefix only once + .filter(|(ix, _prefix)| sorted_snippet_candidates_seen.insert(*ix)) + .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, 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 { @@ -22923,7 +22987,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(), @@ -22934,8 +23002,9 @@ fn snippet_completions( }), insert_text_mode: None, confirm: None, - }) - })) + match_start: Some(start), + } + })); } Ok(CompletionResponse { @@ -22953,12 +23022,36 @@ impl CompletionProvider for Entity { buffer: &Entity, buffer_position: text::Anchor, options: CompletionContext, + text: Option<&str>, _window: &mut Window, cx: &mut Context, ) -> Task>> { self.update(cx, |project, cx| { + let buffer_snapshot = buffer.read(cx).snapshot(); + + // if show_all_completions=false, show only snippets + let show_all_completions = if let Some(text) = text { + let first_char_is_word = match text.chars().next() { + None => return Task::ready(Ok(Vec::new())), // triggered by empty string + Some(first_char) => { + let classifier = buffer_snapshot + .char_classifier_at(buffer_position) + .scope_context(Some(CharScopeContext::Completion)); + classifier.is_word(first_char) + } + }; + first_char_is_word || buffer.read(cx).completion_triggers().contains(text) + } else { + true + }; + let snippets = snippet_completions(project, buffer, buffer_position, cx); - let project_completions = project.completions(buffer, buffer_position, options, cx); + let project_completions = if show_all_completions { + project.completions(buffer, buffer_position, options, cx) + } else { + Task::ready(Ok(Vec::new())) // snippets only + }; + cx.background_spawn(async move { let mut responses = project_completions.await?; let snippets = snippets.await?; @@ -23014,13 +23107,7 @@ impl CompletionProvider for Entity { menu_is_open: bool, cx: &mut Context, ) -> bool { - let mut chars = text.chars(); - let char = if let Some(char) = chars.next() { - char - } else { - return false; - }; - if chars.next().is_some() { + if text.is_empty() { return false; } @@ -23029,10 +23116,7 @@ impl CompletionProvider for Entity { if !menu_is_open && !snapshot.settings_at(position, cx).show_completions_on_input { return false; } - let classifier = snapshot - .char_classifier_at(position) - .scope_context(Some(CharScopeContext::Completion)); - if trigger_in_words && classifier.is_word(char) { + if trigger_in_words { return true; } @@ -24186,6 +24270,31 @@ 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 (for a particular +/// definition of "word"). +/// +/// Shorter suffixes are returned first. +pub(crate) fn snippet_candidate_suffixes(text: &str) -> impl std::iter::Iterator { + let mut prev_index = text.len(); + let mut prev_codepoint = None; + let is_word_char = |c: char| c.is_alphanumeric() || c == '_'; + 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 a319ad654d0162..6c69239214c855 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -11189,6 +11189,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, |_| {}); @@ -17127,6 +17174,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).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, |_| {}); @@ -25378,6 +25460,145 @@ 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(), + }), + ], + ); + }); + }) + }); + + 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![], + }) + }; + + let test_cases: &[(&str, &[&str])] = &[ + ( + "un", + &[ + "unsafe", + "unlimit word count", + "unlimited unknown", + "unlimited word count", + "unsnip", + ], + ), + ( + "u u", + &[ + "unlimited unknown", + "unlimit word count", + "unlimited word count", + ], + ), + ("uw c", &["unlimit word count", "unlimited word count"]), + ("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"]), + ("@ ", &[]), + ]; + + 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 c6779d1e564deb..c87a337db8508c 100644 --- a/crates/editor/src/test/editor_test_context.rs +++ b/crates/editor/src/test/editor_test_context.rs @@ -58,6 +58,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 da99c5b92c1e6a..081a1cd8a39f09 100644 --- a/crates/inspector_ui/src/div_inspector.rs +++ b/crates/inspector_ui/src/div_inspector.rs @@ -645,6 +645,7 @@ impl CompletionProvider for RustStyleCompletionProvider { buffer: &Entity, position: Anchor, _: editor::CompletionContext, + _text: Option<&str>, _window: &mut Window, cx: &mut Context, ) -> Task>> { @@ -664,6 +665,7 @@ 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, 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 8e50a7303fb98f..0f686eadeb125c 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -2911,6 +2911,7 @@ impl CompletionProvider for KeyContextCompletionProvider { buffer: &Entity, buffer_position: language::Anchor, _trigger: editor::CompletionContext, + _text: Option<&str>, _window: &mut Window, cx: &mut Context, ) -> gpui::Task>> { @@ -2937,6 +2938,7 @@ impl CompletionProvider for KeyContextCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: None, + match_start: None, insert_text_mode: None, confirm: None, }) diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index d9285a8c24ec51..9b67fde1e0bd31 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 1d6d4240de0ae8..22664eda0d773d 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -9973,6 +9973,7 @@ impl LspStore { source: completion.source, documentation: None, label: CodeLabel::default(), + match_start: None, insert_text_mode: None, icon_path: None, confirm: None, @@ -12555,6 +12556,7 @@ async fn populate_labels_for_completions( source: completion.source, icon_path: None, confirm: None, + match_start: None, }); } None => { @@ -12569,6 +12571,7 @@ async fn populate_labels_for_completions( insert_text_mode: None, icon_path: None, confirm: None, + match_start: None, }); } } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 910e217a677852..f2c627e0b98429 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -105,6 +105,7 @@ use search_history::SearchHistory; use settings::{InvalidSettingsError, Settings, SettingsLocation, SettingsStore}; use smol::channel::Receiver; use snippet::Snippet; +pub use snippet_provider; use snippet_provider::SnippetProvider; use std::{ borrow::Cow, @@ -475,6 +476,10 @@ 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, /// Whether to adjust indentation (the default) or not. pub insert_text_mode: Option, /// An optional callback to invoke when this completion is confirmed. @@ -5634,6 +5639,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 d71439118e9021..c1f04117d48399 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 eac06924a7906a..64711cfc3a7247 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);