Resolve completion items once exactly (#22448)

Closes https://github.com/zed-industries/zed/issues/19214
Closes https://github.com/zed-industries/zed/pull/22443

Adds `resolved` property into Zed completion item data, to ensure we
resolve every completion item exactly once.

There are 2 paths for singplayer Zed, and corresponding 2 analogues for
multi player code, where resolve may happen:
* completions menu display & selection, that ends up using
`resolve_completions` in `lsp_store.rs`
* applying a completion menu entry, that ends up using
`apply_additional_edits_for_completion` in `lsp_store.rs`

Now, all local counterparts check `enabled` field before resolving and
set it to true afterwards, and reuse the same `resolve_completion_local`
method for resolving the items.

A logic for re-generating docs and item labels was moved out from the
`resolve_completion_local` method into a separate method, as
`apply_additional_edits_for_completion` does not need that, but needs
the rest of the logic for resolving.
During the extraction, I've noted that multiplayer clients are not
getting the item labels, regenerated after the resolve — as the Zed
protocol-based flow is not the exact copy of the local resolving.
To improve that, `resolve_completion_remote` needs to be adjusted, but
this change is omitted to avoid bloating the PR.

Release Notes:

- Fixed autocomplete inserting multiple imports
This commit is contained in:
Kirill Bulatov 2024-12-27 18:43:01 +02:00 committed by GitHub
parent d71180abc2
commit ed61abb8b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 371 additions and 201 deletions

View file

@ -224,6 +224,7 @@ impl CompletionsMenu {
documentation: None,
lsp_completion: Default::default(),
confirm: None,
resolved: true,
})
.collect();

View file

@ -3830,8 +3830,11 @@ impl Editor {
};
let buffer_handle = completions_menu.buffer;
let completions = completions_menu.completions.borrow_mut();
let completion = completions.get(mat.candidate_id)?;
let completion = completions_menu
.completions
.borrow()
.get(mat.candidate_id)?
.clone();
cx.stop_propagation();
let snippet;
@ -3975,9 +3978,11 @@ impl Editor {
}
let provider = self.completion_provider.as_ref()?;
drop(completion);
let apply_edits = provider.apply_additional_edits_for_completion(
buffer_handle,
completion.clone(),
completions_menu.completions.clone(),
mat.candidate_id,
true,
cx,
);
@ -5087,7 +5092,7 @@ impl Editor {
}))
}
#[cfg(feature = "test-support")]
#[cfg(any(feature = "test-support", test))]
pub fn context_menu_visible(&self) -> bool {
self.context_menu
.borrow()
@ -13447,11 +13452,14 @@ pub trait CompletionProvider {
fn apply_additional_edits_for_completion(
&self,
buffer: Model<Buffer>,
completion: Completion,
push_to_history: bool,
cx: &mut ViewContext<Editor>,
) -> Task<Result<Option<language::Transaction>>>;
_buffer: Model<Buffer>,
_completions: Rc<RefCell<Box<[Completion]>>>,
_completion_index: usize,
_push_to_history: bool,
_cx: &mut ViewContext<Editor>,
) -> Task<Result<Option<language::Transaction>>> {
Task::ready(Ok(None))
}
fn is_completion_trigger(
&self,
@ -13610,6 +13618,7 @@ fn snippet_completions(
Some(Completion {
old_range: range,
new_text: snippet.body.clone(),
resolved: false,
label: CodeLabel {
text: matching_prefix.clone(),
runs: vec![],
@ -13675,19 +13684,30 @@ impl CompletionProvider for Model<Project> {
cx: &mut ViewContext<Editor>,
) -> Task<Result<bool>> {
self.update(cx, |project, cx| {
project.resolve_completions(buffer, completion_indices, completions, cx)
project.lsp_store().update(cx, |lsp_store, cx| {
lsp_store.resolve_completions(buffer, completion_indices, completions, cx)
})
})
}
fn apply_additional_edits_for_completion(
&self,
buffer: Model<Buffer>,
completion: Completion,
completions: Rc<RefCell<Box<[Completion]>>>,
completion_index: usize,
push_to_history: bool,
cx: &mut ViewContext<Editor>,
) -> Task<Result<Option<language::Transaction>>> {
self.update(cx, |project, cx| {
project.apply_additional_edits_for_completion(buffer, completion, push_to_history, cx)
project.lsp_store().update(cx, |lsp_store, cx| {
lsp_store.apply_additional_edits_for_completion(
buffer,
completions,
completion_index,
push_to_history,
cx,
)
})
})
}

View file

@ -8402,7 +8402,6 @@ async fn test_completion(cx: &mut gpui::TestAppContext) {
additional edit
"});
handle_resolve_completion_request(&mut cx, None).await;
apply_additional_edits.await.unwrap();
update_test_language_settings(&mut cx, |settings| {
@ -10698,10 +10697,14 @@ async fn test_completions_resolve_updates_labels_if_filter_text_matches(
..lsp::CompletionItem::default()
};
cx.handle_request::<lsp::request::Completion, _, _>(move |_, _, _| {
let item1 = item1.clone();
cx.handle_request::<lsp::request::Completion, _, _>({
let item1 = item1.clone();
let item2 = item2.clone();
async move { Ok(Some(lsp::CompletionResponse::Array(vec![item1, item2]))) }
move |_, _, _| {
let item1 = item1.clone();
let item2 = item2.clone();
async move { Ok(Some(lsp::CompletionResponse::Array(vec![item1, item2]))) }
}
})
.next()
.await;
@ -10728,43 +10731,41 @@ async fn test_completions_resolve_updates_labels_if_filter_text_matches(
}
});
cx.handle_request::<lsp::request::ResolveCompletionItem, _, _>(move |_, _, _| async move {
Ok(lsp::CompletionItem {
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: ".id".to_string(),
})),
..lsp::CompletionItem::default()
})
cx.handle_request::<lsp::request::ResolveCompletionItem, _, _>({
let item1 = item1.clone();
move |_, item_to_resolve, _| {
let item1 = item1.clone();
async move {
if item1 == item_to_resolve {
Ok(lsp::CompletionItem {
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: ".id".to_string(),
})),
..lsp::CompletionItem::default()
})
} else {
Ok(item_to_resolve)
}
}
}
})
.next()
.await;
.await
.unwrap();
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()
})
})
.next()
.await;
cx.run_until_parked();
cx.update_editor(|editor, _| {
let context_menu = editor.context_menu.borrow_mut();
let context_menu = context_menu
@ -10787,6 +10788,172 @@ async fn test_completions_resolve_updates_labels_if_filter_text_matches(
});
}
#[gpui::test]
async fn test_completions_resolve_happens_once(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
completion_provider: Some(lsp::CompletionOptions {
trigger_characters: Some(vec![".".to_string()]),
resolve_provider: Some(true),
..Default::default()
}),
..Default::default()
},
cx,
)
.await;
cx.set_state(indoc! {"fn main() { let a = 2ˇ; }"});
cx.simulate_keystroke(".");
let unresolved_item_1 = 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 resolved_item_1 = lsp::CompletionItem {
additional_text_edits: Some(vec![lsp::TextEdit {
range: lsp::Range::new(lsp::Position::new(0, 20), lsp::Position::new(0, 22)),
new_text: "!!".to_string(),
}]),
..unresolved_item_1.clone()
};
let unresolved_item_2 = 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: ".other".to_string(),
})),
..lsp::CompletionItem::default()
};
let resolved_item_2 = lsp::CompletionItem {
additional_text_edits: Some(vec![lsp::TextEdit {
range: lsp::Range::new(lsp::Position::new(0, 20), lsp::Position::new(0, 22)),
new_text: "??".to_string(),
}]),
..unresolved_item_2.clone()
};
let resolve_requests_1 = Arc::new(AtomicUsize::new(0));
let resolve_requests_2 = Arc::new(AtomicUsize::new(0));
cx.lsp
.server
.on_request::<lsp::request::ResolveCompletionItem, _, _>({
let unresolved_item_1 = unresolved_item_1.clone();
let resolved_item_1 = resolved_item_1.clone();
let unresolved_item_2 = unresolved_item_2.clone();
let resolved_item_2 = resolved_item_2.clone();
let resolve_requests_1 = resolve_requests_1.clone();
let resolve_requests_2 = resolve_requests_2.clone();
move |unresolved_request, _| {
let unresolved_item_1 = unresolved_item_1.clone();
let resolved_item_1 = resolved_item_1.clone();
let unresolved_item_2 = unresolved_item_2.clone();
let resolved_item_2 = resolved_item_2.clone();
let resolve_requests_1 = resolve_requests_1.clone();
let resolve_requests_2 = resolve_requests_2.clone();
async move {
if unresolved_request == unresolved_item_1 {
resolve_requests_1.fetch_add(1, atomic::Ordering::Release);
Ok(resolved_item_1.clone())
} else if unresolved_request == unresolved_item_2 {
resolve_requests_2.fetch_add(1, atomic::Ordering::Release);
Ok(resolved_item_2.clone())
} else {
panic!("Unexpected completion item {unresolved_request:?}")
}
}
}
})
.detach();
cx.handle_request::<lsp::request::Completion, _, _>(move |_, _, _| {
let unresolved_item_1 = unresolved_item_1.clone();
let unresolved_item_2 = unresolved_item_2.clone();
async move {
Ok(Some(lsp::CompletionResponse::Array(vec![
unresolved_item_1,
unresolved_item_2,
])))
}
})
.next()
.await;
cx.condition(|editor, _| editor.context_menu_visible())
.await;
cx.update_editor(|editor, _| {
let context_menu = editor.context_menu.borrow_mut();
let context_menu = context_menu
.as_ref()
.expect("Should have the context menu deployed");
match context_menu {
CodeContextMenu::Completions(completions_menu) => {
let completions = completions_menu.completions.borrow_mut();
assert_eq!(
completions
.iter()
.map(|completion| &completion.label.text)
.collect::<Vec<_>>(),
vec!["id", "other"]
)
}
CodeContextMenu::CodeActions(_) => panic!("Should show the completions menu"),
}
});
cx.run_until_parked();
cx.update_editor(|editor, cx| {
editor.context_menu_next(&ContextMenuNext, cx);
});
cx.run_until_parked();
cx.update_editor(|editor, cx| {
editor.context_menu_prev(&ContextMenuPrev, cx);
});
cx.run_until_parked();
cx.update_editor(|editor, cx| {
editor.context_menu_next(&ContextMenuNext, cx);
});
cx.run_until_parked();
cx.update_editor(|editor, cx| {
editor
.compose_completion(&ComposeCompletion::default(), cx)
.expect("No task returned")
})
.await
.expect("Completion failed");
cx.run_until_parked();
cx.update_editor(|editor, cx| {
assert_eq!(
resolve_requests_1.load(atomic::Ordering::Acquire),
1,
"Should always resolve once despite multiple selections"
);
assert_eq!(
resolve_requests_2.load(atomic::Ordering::Acquire),
1,
"Should always resolve once after multiple selections and applying the completion"
);
assert_eq!(
editor.text(cx),
"fn main() { let a = ??.other; }",
"Should use resolved data when applying the completion"
);
});
}
#[gpui::test]
async fn test_completions_default_resolve_data_handling(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
@ -10950,15 +11117,10 @@ async fn test_completions_default_resolve_data_handling(cx: &mut gpui::TestAppCo
// Completions that have already been resolved are skipped.
assert_eq!(
*resolved_items.lock(),
[
// Selected item is always resolved even if it was resolved before.
&items_out[items_out.len() - 1..items_out.len()],
&items_out[items_out.len() - 16..items_out.len() - 4]
]
.concat()
.iter()
.cloned()
.collect::<Vec<lsp::CompletionItem>>()
items_out[items_out.len() - 16..items_out.len() - 4]
.iter()
.cloned()
.collect::<Vec<lsp::CompletionItem>>()
);
resolved_items.lock().clear();
}