From d5946c519e53fff65dbf48462ee9561f9f841a6d Mon Sep 17 00:00:00 2001 From: HactarCE <6060305+HactarCE@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:52:37 -0400 Subject: [PATCH] wip Co-authored-by: Cole Miller --- .../agent_ui/src/acp/completion_provider.rs | 8 +++ .../src/context_picker/completion_provider.rs | 7 ++ crates/agent_ui/src/slash_command.rs | 2 + .../src/session/running/console.rs | 2 + crates/editor/src/code_completion_tests.rs | 1 + crates/editor/src/code_context_menus.rs | 65 ++++++++++--------- crates/editor/src/editor.rs | 40 +++++------- crates/editor/src/editor_tests.rs | 24 ++++--- crates/editor/src/test/editor_test_context.rs | 11 ++++ crates/inspector_ui/src/div_inspector.rs | 1 + crates/keymap_editor/src/keymap_editor.rs | 1 + crates/project/src/lsp_store.rs | 3 + crates/project/src/project.rs | 3 + 13 files changed, 107 insertions(+), 61 deletions(-) diff --git a/crates/agent_ui/src/acp/completion_provider.rs b/crates/agent_ui/src/acp/completion_provider.rs index 03e036e951dca7b3457d45aca0907e7104d60ce0..c6e99f2734a23c4475287c035eb265c575fa9b13 100644 --- a/crates/agent_ui/src/acp/completion_provider.rs +++ b/crates/agent_ui/src/acp/completion_provider.rs @@ -108,6 +108,7 @@ impl ContextPickerCompletionProvider { icon_path: Some(mode.icon().path().into()), documentation: None, source: project::CompletionSource::Custom, + buffer_match: None, insert_text_mode: None, // This ensures that when a user accepts this completion, the // completion menu will still be shown after "@category " is @@ -145,6 +146,7 @@ impl ContextPickerCompletionProvider { documentation: None, insert_text_mode: None, source: project::CompletionSource::Custom, + buffer_match: None, icon_path: Some(icon_for_completion), confirm: Some(confirm_completion_callback( thread_entry.title().clone(), @@ -176,6 +178,7 @@ impl ContextPickerCompletionProvider { documentation: None, insert_text_mode: None, source: project::CompletionSource::Custom, + buffer_match: None, icon_path: Some(icon_path), confirm: Some(confirm_completion_callback( rule.title, @@ -232,6 +235,7 @@ impl ContextPickerCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(completion_icon_path), + buffer_match: None, insert_text_mode: None, confirm: Some(confirm_completion_callback( file_name, @@ -278,6 +282,7 @@ impl ContextPickerCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(icon_path), + buffer_match: None, insert_text_mode: None, confirm: Some(confirm_completion_callback( symbol.name.into(), @@ -310,6 +315,7 @@ impl ContextPickerCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(icon_path), + buffer_match: None, insert_text_mode: None, confirm: Some(confirm_completion_callback( url_to_fetch.to_string().into(), @@ -378,6 +384,7 @@ impl ContextPickerCompletionProvider { icon_path: Some(action.icon().path().into()), documentation: None, source: project::CompletionSource::Custom, + buffer_match: None, insert_text_mode: None, // This ensures that when a user accepts this completion, the // completion menu will still be shown after "@category " is @@ -749,6 +756,7 @@ impl CompletionProvider for ContextPickerCompletionProvider { )), source: project::CompletionSource::Custom, icon_path: None, + buffer_match: None, // todo! is this right? 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 94d14ae2b47db87bcbc6c99a4f6c5d93458e6eb5..830259008390042494fbfdc8f0df6a108c346490 100644 --- a/crates/agent_ui/src/context_picker/completion_provider.rs +++ b/crates/agent_ui/src/context_picker/completion_provider.rs @@ -287,6 +287,7 @@ impl ContextPickerCompletionProvider { icon_path: Some(mode.icon().path().into()), documentation: None, source: project::CompletionSource::Custom, + buffer_match: None, insert_text_mode: None, // This ensures that when a user accepts this completion, the // completion menu will still be shown after "@category " is @@ -395,6 +396,7 @@ impl ContextPickerCompletionProvider { icon_path: Some(action.icon().path().into()), documentation: None, source: project::CompletionSource::Custom, + buffer_match: None, insert_text_mode: None, // This ensures that when a user accepts this completion, the // completion menu will still be shown after "@category " is @@ -426,6 +428,7 @@ impl ContextPickerCompletionProvider { replace_range: source_range.clone(), new_text, label: CodeLabel::plain(thread_entry.title().to_string(), None), + buffer_match: None, documentation: None, insert_text_mode: None, source: project::CompletionSource::Custom, @@ -495,6 +498,7 @@ impl ContextPickerCompletionProvider { replace_range: source_range.clone(), new_text, label: CodeLabel::plain(rules.title.to_string(), None), + buffer_match: None, documentation: None, insert_text_mode: None, source: project::CompletionSource::Custom, @@ -535,6 +539,7 @@ impl ContextPickerCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(IconName::ToolWeb.path().into()), + buffer_match: None, insert_text_mode: None, confirm: Some(confirm_completion_callback( IconName::ToolWeb.path().into(), @@ -623,6 +628,7 @@ impl ContextPickerCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(completion_icon_path), + buffer_match: None, insert_text_mode: None, confirm: Some(confirm_completion_callback( crease_icon_path, @@ -701,6 +707,7 @@ impl ContextPickerCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(IconName::Code.path().into()), + buffer_match: 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 13cc99cccc970f4a82507604cc3a2a8a3eb3a07b..7f45a5ac6747649adf7a84f01888a6c56100732f 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, + buffer_match: None, insert_text_mode: None, confirm, source: CompletionSource::Custom, @@ -232,6 +233,7 @@ impl SlashCommandCompletionProvider { icon_path: None, new_text, documentation: None, + buffer_match: 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 7bdd6cf69fc025eb61868b4d502f7e98c3c07c83..ad774c22efbf1457f64fdb2cd7a453895f61716f 100644 --- a/crates/debugger_ui/src/session/running/console.rs +++ b/crates/debugger_ui/src/session/running/console.rs @@ -671,6 +671,7 @@ impl ConsoleQueryBarCompletionProvider { ), new_text: string_match.string.clone(), label: CodeLabel::plain(string_match.string.clone(), None), + buffer_match: None, icon_path: None, documentation: Some(CompletionDocumentation::MultiLineMarkdown( variable_value.into(), @@ -784,6 +785,7 @@ impl ConsoleQueryBarCompletionProvider { documentation: completion.detail.map(|detail| { CompletionDocumentation::MultiLineMarkdown(detail.into()) }), + buffer_match: 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..10c708eaf6788a9bc7b34b377c5d7c9d2494531d 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, + buffer_match: None, } } } diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index 359c985ee9208a1a83e3458635df883c2cf991a8..054186341ea9ee19fe0e7b1259b21b53028f3b23 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,10 @@ pub struct CompletionsMenu { pub is_incomplete: bool, pub buffer: Entity, pub completions: Rc>>, + /// Match candidates for completions that have `buffer_match = None` match_candidates: Arc<[StringMatchCandidate]>, + /// Precomputed `buffer_match` for candidates that have it + precomputed_entries: Arc<[StringMatch]>, pub entries: Rc>>, pub selected_item: usize, filter_task: Task<()>, @@ -281,8 +284,18 @@ impl CompletionsMenu { let match_candidates = completions .iter() .enumerate() + .filter(|(_id, completion)| completion.buffer_match.is_none()) .map(|(id, completion)| StringMatchCandidate::new(id, completion.label.filter_text())) .collect(); + let precomputed_entries = completions + .iter() + .enumerate() + .filter_map(|(id, completion)| { + let mut m = completion.buffer_match.clone()?; + m.candidate_id = id; + Some(m) + }) + .collect(); let completions_menu = Self { id, @@ -295,6 +308,7 @@ impl CompletionsMenu { show_completion_documentation, completions: RefCell::new(completions).into(), match_candidates, + precomputed_entries, entries: Rc::new(RefCell::new(Box::new([]))), selected_item: 0, filter_task: Task::ready(()), @@ -329,6 +343,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), + buffer_match: None, icon_path: None, documentation: None, confirm: None, @@ -361,6 +376,7 @@ impl CompletionsMenu { is_incomplete: false, buffer, completions: RefCell::new(completions).into(), + precomputed_entries: Arc::new([]), match_candidates, entries: RefCell::new(entries).into(), selected_item: 0, @@ -912,7 +928,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 { @@ -1027,10 +1043,11 @@ impl CompletionsMenu { let matches_task = cx.background_spawn({ let query = query.clone(); let match_candidates = self.match_candidates.clone(); + let precomputed_entries = self.precomputed_entries.clone(); let cancel_filter = self.cancel_filter.clone(); let background_executor = cx.background_executor().clone(); async move { - fuzzy::match_strings( + let mut matches = fuzzy::match_strings( &match_candidates, &query, query.chars().any(|c| c.is_uppercase()), @@ -1039,7 +1056,9 @@ impl CompletionsMenu { &cancel_filter, background_executor, ) - .await + .await; + matches.extend(precomputed_entries.iter().cloned()); + matches } }); @@ -1074,6 +1093,7 @@ impl CompletionsMenu { positions: Default::default(), string: candidate.string.clone(), }) + .chain(self.precomputed_entries.iter().cloned()) .collect(); if self.sort_completions { @@ -1130,28 +1150,12 @@ 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()); } 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 +1167,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(); + 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 } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 7673a9526ff095d5d63ef23df858bd718e0b5500..6592f81247344689c02826965261ebb1a3e7c8bc 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -119,7 +119,7 @@ use inlay_hint_cache::{InlayHintCache, InlaySplice, InvalidationStrategy}; 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, @@ -5772,6 +5772,7 @@ impl Editor { replace_range: word_replace_range.clone(), new_text: word.clone(), label: CodeLabel::plain(word, None), + buffer_match: None, icon_path: None, documentation: None, source: CompletionSource::BufferWord { @@ -22997,6 +22998,8 @@ fn snippet_completions( 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 max_buffer_window: String = snapshot .text_for_range(window_start..buffer_offset) .collect(); @@ -23015,17 +23018,13 @@ fn snippet_completions( .iter() .enumerate() .flat_map(|(snippet_ix, snippet)| { - snippet - .prefix - .iter() - .enumerate() - .map(move |(prefix_ix, prefix)| { - ( - (snippet_ix, prefix_ix), - prefix, - snippet_candidate_suffixes(prefix).count(), - ) - }) + snippet.prefix.iter().map(move |prefix| { + ( + snippet_ix, + prefix, + snippet_candidate_suffixes(prefix).count(), + ) + }) }) .collect_vec(); sorted_snippet_candidates @@ -23066,9 +23065,10 @@ fn snippet_completions( 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, _snippet_word_count))| { + .filter(|(_ix, prefix)| { itertools::equal( prefix .chars() @@ -23083,12 +23083,8 @@ fn snippet_completions( ) }) // Match each prefix only once - .filter(|(ix, (_, _prefix, _snippet_word_count))| { - sorted_snippet_candidates_seen.insert(*ix) - }) - .map(|(ix, (_, prefix, _snippet_word_count))| { - StringMatchCandidate::new(ix, prefix) - }) + .filter(|(ix, _prefix)| sorted_snippet_candidates_seen.insert(*ix)) + .map(|(ix, prefix)| StringMatchCandidate::new(ix, prefix)) .collect::>(); matches.extend( @@ -23128,10 +23124,9 @@ fn snippet_completions( matches .iter() .filter_map(|(string_match, buffer_window_len)| { - let (snippet_index, prefix_index) = - sorted_snippet_candidates[string_match.candidate_id].0; + let (snippet_index, matching_prefix) = + sorted_snippet_candidates[string_match.candidate_id]; let snippet = &snippets[snippet_index]; - let matching_prefix = &snippet.prefix[prefix_index]; let start = buffer_offset - buffer_window_len; let start = snapshot.anchor_before(start); let range = start..buffer_anchor; @@ -23187,6 +23182,7 @@ fn snippet_completions( ), insert_text_mode: None, confirm: None, + buffer_match: Some(string_match.clone()), }) }), ); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 0b260d006cbe161b2b54fe62d9d0a48f822bc3b9..24d84ac63e89fa16f485308fda5a9bc08a49e95b 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -11088,13 +11088,13 @@ 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, window, cx| { + 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: "multi word".to_string(), + prefix: vec!["multi word".to_string()], body: "this is many words".to_string(), - description: "description".to_string(), + description: Some("description".to_string()), name: "multi-word snippet test".to_string(), }; snippets.add_snippet_for_test( @@ -11107,21 +11107,25 @@ async fn test_snippet_with_multi_word_prefix(cx: &mut TestAppContext) { }) }); - cx.set_state("mˇ"); - cx.simulate_input("u"); + cx.set_state("ˇ"); + // cx.simulate_input("m"); + // cx.simulate_input("m "); + // cx.simulate_input("m w"); + // cx.simulate_input("aa m w"); + cx.simulate_input("aa m g"); // fails correctly - cx.update_editor(|editor, window, cx| { - let CodeContextMenu::Completions(context_menu) = editor.context_menu.borrow().unwrap() + cx.update_editor(|editor, _, _| { + let Some(CodeContextMenu::Completions(context_menu)) = &*editor.context_menu.borrow() else { - panic!("expected completion menu") + panic!("expected completion menu"); }; assert!(context_menu.visible()); - let completions = context_menu; + let completions = context_menu.completions.borrow(); assert!( completions .iter() - .any(|c| c.string.as_str() == "multi word"), + .any(|c| c.new_text == "this is many words"), "Expected to find 'multi word' snippet in completions" ); }); diff --git a/crates/editor/src/test/editor_test_context.rs b/crates/editor/src/test/editor_test_context.rs index 601eb9512cdef472ce0a5d660309d671c339ebe9..196906d13f545ffe9f101d90e7e2b8f9dcc78c75 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 268fba573c5c51fe26a21382f37ae614e7d9759c..eacc8f3418057a4e9084b022cbcf037ff5242f4f 100644 --- a/crates/inspector_ui/src/div_inspector.rs +++ b/crates/inspector_ui/src/div_inspector.rs @@ -665,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), + buffer_match: None, // todo! is this right? 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 82b97e5ff1bfd8c746d26d228bd2da32d2594571..d88f718783b9947ecbd530b95273ec0347866c4f 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -2931,6 +2931,7 @@ impl CompletionProvider for KeyContextCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: None, + buffer_match: None, insert_text_mode: None, confirm: None, }) diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index c33f922a62d6ce702748a92063c8c2a1a2095a56..999401bd90eef52c4695711ee9e27d0ea8b98d7c 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -9568,6 +9568,7 @@ impl LspStore { source: completion.source, documentation: None, label: CodeLabel::default(), + buffer_match: None, insert_text_mode: None, icon_path: None, confirm: None, @@ -12052,6 +12053,7 @@ async fn populate_labels_for_completions( source: completion.source, icon_path: None, confirm: None, + buffer_match: None, }); } None => { @@ -12066,6 +12068,7 @@ async fn populate_labels_for_completions( insert_text_mode: None, icon_path: None, confirm: None, + buffer_match: None, }); } } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 0c18306119a6c27faf34748ce98f21824b3038b3..f79dd62368686835d63d3f8e13a56c2dd9b2e990 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -27,6 +27,7 @@ mod environment; use buffer_diff::BufferDiff; use context_server_store::ContextServerStore; pub use environment::{EnvironmentErrorMessage, ProjectEnvironmentEvent}; +use fuzzy::StringMatch; use git::repository::get_git_committer; use git_store::{Repository, RepositoryId}; pub mod search_history; @@ -456,6 +457,8 @@ pub struct Completion { pub source: CompletionSource, /// A path to an icon for this completion that is shown in the menu. pub icon_path: Option, + /// String match against part of the buffer contents (typically the last word). + pub buffer_match: Option, /// Whether to adjust indentation (the default) or not. pub insert_text_mode: Option, /// An optional callback to invoke when this completion is confirmed.