From 7b721efe2c4256d25ac935d599b79ce2d583bae0 Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Mon, 16 Dec 2024 01:21:26 -0700 Subject: [PATCH] Stop mutating completion match state + reject fuzzy match text change (#22061) This fixes #21837, where CompletionsMenu fuzzy match positions were desynchronized from completion label text. The solution is to not mutate `match_candidates` and instead offset the highlight positions in the rendering code. This solution requires that the fuzzy match text not change on completion resolution. This is a property we want anyway, since fuzzy match text changing means items unexpectedly changing position in the menu. What happened: * #21521 updated completion resolution to modify labels on resolution. - This interacted poorly with the code [here](https://github.com/zed-industries/zed/blob/341e65e12289c355cbea6e91daee5493bbac921f/crates/editor/src/code_context_menus.rs#L604), where the fuzzy match results are updated to include the full label, and the fuzzy match result positions are offset to be in the correct place. The fuzzy mach positions were now invalid because they were based on the old text. * #21705 caused completion resolution to occur more frequently. Before this only the selected item was being resolved. This caused the panic due to invalid positions to happen much more frequently. Closes #21837 Release Notes: - N/A --- crates/editor/src/code_context_menus.rs | 16 +++--- crates/editor/src/editor_tests.rs | 72 ++++++++++++++++++++----- crates/project/src/lsp_store.rs | 13 ++++- 3 files changed, 77 insertions(+), 24 deletions(-) diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index f129eaa617ef163fffe94ae9cd020b4df20fb587..646313262fc0e57864abf3cdc39af6e1190502a4 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -423,8 +423,14 @@ impl CompletionsMenu { &None }; + let filter_start = completion.label.filter_range.start; let highlights = gpui::combine_highlights( - mat.ranges().map(|range| (range, FontWeight::BOLD.into())), + mat.ranges().map(|range| { + ( + filter_start + range.start..filter_start + range.end, + FontWeight::BOLD.into(), + ) + }), styled_runs_for_code_label(&completion.label, &style.syntax).map( |(range, mut highlight)| { // Ignore font weight for syntax highlighting, as we'll use it @@ -594,14 +600,6 @@ impl CompletionsMenu { } }); } - - for mat in &mut matches { - let completion = &completions[mat.candidate_id]; - mat.string.clone_from(&completion.label.text); - for position in &mut mat.positions { - *position += completion.label.filter_range.start; - } - } drop(completions); self.matches = matches.into(); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 1436fed6bf9620350edb75b3bb7b35cbb3b30eac..6994d4e9685d932e374858a233d51201f9f59332 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -10648,7 +10648,9 @@ async fn test_completions_with_additional_edits(cx: &mut gpui::TestAppContext) { } #[gpui::test] -async fn test_completions_resolve_updates_labels(cx: &mut gpui::TestAppContext) { +async fn test_completions_resolve_updates_labels_if_filter_text_matches( + cx: &mut gpui::TestAppContext, +) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( @@ -10667,20 +10669,34 @@ async fn test_completions_resolve_updates_labels(cx: &mut gpui::TestAppContext) cx.set_state(indoc! {"fn main() { let a = 2ˇ; }"}); cx.simulate_keystroke("."); - let completion_item = lsp::CompletionItem { - label: "unresolved".to_string(), + let item1 = lsp::CompletionItem { + label: "id".to_string(), + filter_text: Some("id".to_string()), + detail: None, + documentation: None, + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 22), lsp::Position::new(0, 22)), + new_text: ".id".to_string(), + })), + ..lsp::CompletionItem::default() + }; + + let item2 = lsp::CompletionItem { + label: "other".to_string(), + filter_text: Some("other".to_string()), detail: None, documentation: None, text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 22), lsp::Position::new(0, 22)), - new_text: ".unresolved".to_string(), + new_text: ".other".to_string(), })), ..lsp::CompletionItem::default() }; cx.handle_request::(move |_, _, _| { - let item = completion_item.clone(); - async move { Ok(Some(lsp::CompletionResponse::Array(vec![item]))) } + let item1 = item1.clone(); + let item2 = item2.clone(); + async move { Ok(Some(lsp::CompletionResponse::Array(vec![item1, item2]))) } }) .next() .await; @@ -10695,8 +10711,13 @@ async fn test_completions_resolve_updates_labels(cx: &mut gpui::TestAppContext) match context_menu { CodeContextMenu::Completions(completions_menu) => { let completions = completions_menu.completions.read(); - assert_eq!(completions.len(), 1, "Should have one completion"); - assert_eq!(completions.get(0).unwrap().label.text, "unresolved"); + assert_eq!( + completions + .iter() + .map(|completion| &completion.label.text) + .collect::>(), + vec!["id", "other"] + ) } CodeContextMenu::CodeActions(_) => panic!("Should show the completions menu"), } @@ -10704,12 +10725,33 @@ async fn test_completions_resolve_updates_labels(cx: &mut gpui::TestAppContext) cx.handle_request::(move |_, _, _| async move { Ok(lsp::CompletionItem { - label: "resolved".to_string(), + label: "method id()".to_string(), + filter_text: Some("id".to_string()), detail: Some("Now resolved!".to_string()), documentation: Some(lsp::Documentation::String("Docs".to_string())), text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 22), lsp::Position::new(0, 22)), - new_text: ".resolved".to_string(), + new_text: ".id".to_string(), + })), + ..lsp::CompletionItem::default() + }) + }) + .next() + .await; + cx.run_until_parked(); + + cx.update_editor(|editor, cx| { + editor.context_menu_next(&Default::default(), cx); + }); + + cx.handle_request::(move |_, _, _| async move { + Ok(lsp::CompletionItem { + label: "invalid changed label".to_string(), + detail: Some("Now resolved!".to_string()), + documentation: Some(lsp::Documentation::String("Docs".to_string())), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 22), lsp::Position::new(0, 22)), + new_text: ".id".to_string(), })), ..lsp::CompletionItem::default() }) @@ -10726,11 +10768,13 @@ async fn test_completions_resolve_updates_labels(cx: &mut gpui::TestAppContext) match context_menu { CodeContextMenu::Completions(completions_menu) => { let completions = completions_menu.completions.read(); - assert_eq!(completions.len(), 1, "Should have one completion"); assert_eq!( - completions.get(0).unwrap().label.text, - "resolved", - "Should update the completion label after resolving" + completions + .iter() + .map(|completion| &completion.label.text) + .collect::>(), + vec!["method id()", "other"], + "Should update first completion label, but not second as the filter text did not match." ); } CodeContextMenu::CodeActions(_) => panic!("Should show the completions menu"), diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 471376c0e1c25c39293ac6f76db64f38ad87313a..92a4aee2256387af5146965c47ef6e412a174c05 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -4303,7 +4303,18 @@ impl LspStore { let mut completions = completions.write(); let completion = &mut completions[completion_index]; completion.lsp_completion = completion_item; - completion.label = new_label; + if completion.label.filter_text() == new_label.filter_text() { + completion.label = new_label; + } else { + log::error!( + "Resolved completion changed display label from {} to {}. \ + Refusing to apply this because it changes the fuzzy match text from {} to {}", + completion.label.text(), + new_label.text(), + completion.label.filter_text(), + new_label.filter_text() + ); + } } #[allow(clippy::too_many_arguments)]