editor: Fix edit predictions polluting completions menu (#50403)

João Soares and Ben Kunkle created

Closes #49565

Before you mark this PR as ready for review, make sure that you have:
- [x] Added a solid test coverage and/or screenshots from doing manual
testing
- [x] Done a self-review taking into account security and performance
aspects
- [ ] Aligned any UI changes with the [UI

checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

Screenshots
Before:
<img width="1509" height="726" alt="image"
src="https://github.com/user-attachments/assets/4931262a-e243-4e54-8ba8-f5fd13ec7bff"
/>



After:
<img width="1284" height="611" alt="image"
src="https://github.com/user-attachments/assets/0466cab6-d303-484c-a22b-4c168d21cec6"
/>
<img width="1284" height="611" alt="image"
src="https://github.com/user-attachments/assets/d6bce35b-e599-42da-9c4d-214e490677d5"
/>


Release Notes:

- Fixed edit predictions polluting the completions menu with unrelated
snippets (e.g. Unicode symbols) when
`show_in_completions_menu` is disabled

---------

Co-authored-by: Ben Kunkle <ben.kunkle@gmail.com>

Change summary

crates/editor/src/edit_prediction_tests.rs | 71 +++++++++++++++++++
crates/editor/src/editor.rs                | 87 +++++++++++++++++++++--
2 files changed, 148 insertions(+), 10 deletions(-)

Detailed changes

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<T: ToOffset>(
     });
 }
 
+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<FakeEditPredictionDelegate>,
     cx: &mut EditorTestContext,

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<Project> {
     }
 }
 
+fn has_strong_snippet_prefix_match(
+    project: &Project,
+    buffer: &Entity<Buffer>,
+    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<Buffer>,