diff --git a/crates/editor/src/edit_prediction_tests.rs b/crates/editor/src/edit_prediction_tests.rs index 472e06b11b37048a32c5cf11619cb84edb3a0a3b..8078c90fa597fc3afde1cdda71509ad5de8c05a4 100644 --- a/crates/editor/src/edit_prediction_tests.rs +++ b/crates/editor/src/edit_prediction_tests.rs @@ -12,6 +12,7 @@ use multi_buffer::{Anchor, MultiBufferSnapshot, ToPoint}; use project::{Completion, CompletionResponse, CompletionSource}; use std::{ ops::Range, + path::PathBuf, rc::Rc, sync::{ Arc, @@ -22,7 +23,7 @@ use text::{Point, ToOffset}; use ui::prelude::*; use crate::{ - AcceptEditPrediction, CompletionContext, CompletionProvider, EditPrediction, + AcceptEditPrediction, CodeContextMenu, CompletionContext, CompletionProvider, EditPrediction, EditPredictionKeybindAction, EditPredictionKeybindSurface, MenuEditPredictionsPolicy, ShowCompletions, editor_tests::{init_test, update_test_language_settings}, @@ -488,6 +489,43 @@ async fn test_edit_prediction_preview_cleanup_on_toggle_off(cx: &mut gpui::TestA }); } +#[gpui::test] +async fn test_hidden_edit_prediction_does_not_open_snippet_menu_on_word_input( + cx: &mut gpui::TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = hidden_edit_prediction_snippet_test_context(cx).await; + cx.simulate_input("t"); + cx.run_until_parked(); + + cx.update_editor(|editor, _, _| { + assert!(editor.has_active_edit_prediction()); + assert!(editor.context_menu.borrow().is_none()); + }); +} + +#[gpui::test] +async fn test_hidden_edit_prediction_opens_snippet_menu_for_strong_prefix_match( + cx: &mut gpui::TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = hidden_edit_prediction_snippet_test_context(cx).await; + cx.simulate_input("t"); + cx.run_until_parked(); + cx.simulate_input("h"); + cx.run_until_parked(); + + cx.update_editor(|editor, _, _| { + let Some(CodeContextMenu::Completions(menu)) = &*editor.context_menu.borrow() else { + panic!("expected completions menu"); + }; + let entries = menu.entries.borrow(); + assert!(entries.iter().any(|entry| entry.string == "Theta")); + }); +} + #[gpui::test] async fn test_edit_prediction_preview_activates_when_prediction_arrives_with_modifier_held( cx: &mut gpui::TestAppContext, @@ -1395,6 +1433,37 @@ fn propose_edits_with_cursor_position_in_insertion( }); } +async fn hidden_edit_prediction_snippet_test_context( + cx: &mut gpui::TestAppContext, +) -> EditorTestContext { + let mut cx = EditorTestContext::new(cx).await; + let provider = cx.new(|_| FakeEditPredictionDelegate::default()); + assign_editor_completion_provider(provider.clone(), &mut cx); + cx.update_editor(|editor, _, cx| { + editor.set_menu_edit_predictions_policy(MenuEditPredictionsPolicy::Never); + editor.project().unwrap().update(cx, |project, cx| { + project.snippets().update(cx, |snippets, _cx| { + let snippet = project::snippet_provider::Snippet { + prefix: vec!["Theta".to_string(), "turnstile".to_string()], + body: "⊢".to_string(), + description: Some("unicode symbol".to_string()), + name: "unicode snippets".to_string(), + }; + snippets.add_snippet_for_test( + None, + PathBuf::from("test_snippets.json"), + vec![Arc::new(snippet)], + ); + }); + }) + }); + cx.set_state("ˇ"); + + propose_edits(&provider, vec![(0..0, "x")], &mut cx); + cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx)); + cx +} + fn assign_editor_completion_provider( provider: Entity, cx: &mut EditorTestContext, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index d41db01368430b9273686dc9eff2d6d925410451..8f207b2ff6f8fc9295c0b9301c1def133dfd3fcb 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -5837,7 +5837,7 @@ impl Editor { _ => self.open_or_update_completions_menu( None, Some(text.to_owned()).filter(|x| !x.is_empty()), - true, + trigger_in_words, window, cx, ), @@ -6325,8 +6325,15 @@ impl Editor { let provider_responses = if let Some(provider) = &provider && load_provider_completions { - let trigger_character = - trigger.filter(|trigger| buffer.read(cx).completion_triggers().contains(trigger)); + let trigger_character = trigger + .as_ref() + .filter(|trigger| { + buffer + .read(cx) + .completion_triggers() + .contains(trigger.as_str()) + }) + .cloned(); let completion_context = CompletionContext { trigger_kind: match &trigger_character { Some(_) => CompletionTriggerKind::TRIGGER_CHARACTER, @@ -6374,16 +6381,51 @@ impl Editor { Task::ready(BTreeMap::default()) }; + let snippet_char_classifier = buffer_snapshot + .char_classifier_at(buffer_position) + .scope_context(Some(CharScopeContext::Completion)); + let snippets = if let Some(provider) = &provider && 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, char_classifier, cx) - }) + let word_trigger = trigger.as_ref().is_some_and(|trigger| { + !trigger.is_empty() + && trigger + .chars() + .all(|character| snippet_char_classifier.is_word(character)) + }); + let requires_strong_snippet_match = !menu_is_open && !trigger_in_words && word_trigger; + let load_snippet_completions = !requires_strong_snippet_match + || query.as_ref().is_some_and(|query| { + let project = project.read(cx); + has_strong_snippet_prefix_match( + &project, + &buffer, + buffer_position, + &snippet_char_classifier, + query, + cx, + ) + }); + + if load_snippet_completions { + project.update(cx, |project, cx| { + snippet_completions( + project, + &buffer, + buffer_position, + snippet_char_classifier, + cx, + ) + }) + } else { + Task::ready(Ok(CompletionResponse { + completions: Vec::new(), + display_options: Default::default(), + is_incomplete: false, + })) + } } else { Task::ready(Ok(CompletionResponse { completions: Vec::new(), @@ -27751,6 +27793,33 @@ impl CodeActionProvider for Entity { } } +fn has_strong_snippet_prefix_match( + project: &Project, + buffer: &Entity, + buffer_anchor: text::Anchor, + classifier: &CharClassifier, + query: &str, + cx: &App, +) -> bool { + if query.chars().take(2).count() < 2 { + return false; + } + + let query = query.to_lowercase(); + let is_word_char = |character| classifier.is_word(character); + let languages = buffer.read(cx).languages_at(buffer_anchor); + let snippet_store = project.snippets().read(cx); + + languages.iter().any(|language| { + snippet_store + .snippets_for(Some(language.lsp_id()), cx) + .iter() + .flat_map(|snippet| snippet.prefix.iter()) + .flat_map(|prefix| snippet_candidate_suffixes(prefix, &is_word_char)) + .any(|candidate| candidate.to_lowercase().starts_with(&query)) + }) +} + fn snippet_completions( project: &Project, buffer: &Entity,