Skip to content

Commit

Permalink
Stop mutating completion match state + reject fuzzy match text change (
Browse files Browse the repository at this point in the history
…zed-industries#22061)

This fixes zed-industries#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:

* zed-industries#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.

* zed-industries#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 zed-industries#21837

Release Notes:

- N/A
  • Loading branch information
mgsloan authored Dec 16, 2024
1 parent 18b6d14 commit 7b721ef
Show file tree
Hide file tree
Showing 3 changed files with 77 additions and 24 deletions.
16 changes: 7 additions & 9 deletions crates/editor/src/code_context_menus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
72 changes: 58 additions & 14 deletions crates/editor/src/editor_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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::<lsp::request::Completion, _, _>(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;
Expand All @@ -10695,21 +10711,47 @@ 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<_>>(),
vec!["id", "other"]
)
}
CodeContextMenu::CodeActions(_) => panic!("Should show the completions menu"),
}
});

cx.handle_request::<lsp::request::ResolveCompletionItem, _, _>(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::<lsp::request::ResolveCompletionItem, _, _>(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()
})
Expand All @@ -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<_>>(),
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"),
Expand Down
13 changes: 12 additions & 1 deletion crates/project/src/lsp_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down

0 comments on commit 7b721ef

Please sign in to comment.