Run commands if completion items require so (#44008)

Abide the LSP better and actually run commands if completion items
request those:


https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#completionItem
```
/**
   * An optional command that is executed *after* inserting this completion.
   * *Note* that additional modifications to the current document should be
   * described with the additionalTextEdits-property.
   */
   command?: [Command](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#command);
```

Release Notes:

- N/A
This commit is contained in:
Kirill Bulatov
2025-12-02 19:38:01 +02:00
committed by GitHub
parent 7c95834b7b
commit 7bea1ba555
3 changed files with 235 additions and 17 deletions
@@ -18,14 +18,14 @@ use gpui::{
use language::{Anchor, Buffer, CharScopeContext, CodeLabel, TextBufferSnapshot, ToOffset};
use menu::{Confirm, SelectNext, SelectPrevious};
use project::{
Completion, CompletionDisplayOptions, CompletionResponse,
CompletionDisplayOptions, CompletionResponse,
debugger::session::{CompletionsQuery, OutputToken, Session},
lsp_store::CompletionDocumentation,
search_history::{SearchHistory, SearchHistoryCursor},
};
use settings::Settings;
use std::fmt::Write;
use std::{cell::RefCell, ops::Range, rc::Rc, usize};
use std::{ops::Range, rc::Rc, usize};
use theme::{Theme, ThemeSettings};
use ui::{ContextMenu, Divider, PopoverMenu, SplitButton, Tooltip, prelude::*};
use util::ResultExt;
@@ -553,17 +553,6 @@ impl CompletionProvider for ConsoleQueryBarCompletionProvider {
}
}
fn apply_additional_edits_for_completion(
&self,
_buffer: Entity<Buffer>,
_completions: Rc<RefCell<Box<[Completion]>>>,
_completion_index: usize,
_push_to_history: bool,
_cx: &mut Context<Editor>,
) -> gpui::Task<anyhow::Result<Option<language::Transaction>>> {
Task::ready(Ok(None))
}
fn is_completion_trigger(
&self,
buffer: &Entity<Buffer>,
+59 -4
View File
@@ -146,8 +146,8 @@ use persistence::DB;
use project::{
BreakpointWithPosition, CodeAction, Completion, CompletionDisplayOptions, CompletionIntent,
CompletionResponse, CompletionSource, DisableAiSettings, DocumentHighlight, InlayHint, InlayId,
InvalidationStrategy, Location, LocationLink, PrepareRenameResponse, Project, ProjectItem,
ProjectPath, ProjectTransaction, TaskSourceKind,
InvalidationStrategy, Location, LocationLink, LspAction, PrepareRenameResponse, Project,
ProjectItem, ProjectPath, ProjectTransaction, TaskSourceKind,
debugger::{
breakpoint_store::{
Breakpoint, BreakpointEditAction, BreakpointSessionState, BreakpointState,
@@ -6151,9 +6151,43 @@ impl Editor {
}
let provider = self.completion_provider.as_ref()?;
let lsp_store = self.project().map(|project| project.read(cx).lsp_store());
let command = lsp_store.as_ref().and_then(|lsp_store| {
let CompletionSource::Lsp {
lsp_completion,
server_id,
..
} = &completion.source
else {
return None;
};
let lsp_command = lsp_completion.command.as_ref()?;
let available_commands = lsp_store
.read(cx)
.lsp_server_capabilities
.get(server_id)
.and_then(|server_capabilities| {
server_capabilities
.execute_command_provider
.as_ref()
.map(|options| options.commands.as_slice())
})?;
if available_commands.contains(&lsp_command.command) {
Some(CodeAction {
server_id: *server_id,
range: language::Anchor::MIN..language::Anchor::MIN,
lsp_action: LspAction::Command(lsp_command.clone()),
resolved: false,
})
} else {
None
}
});
drop(completion);
let apply_edits = provider.apply_additional_edits_for_completion(
buffer_handle,
buffer_handle.clone(),
completions_menu.completions.clone(),
candidate_id,
true,
@@ -6167,8 +6201,29 @@ impl Editor {
self.show_signature_help(&ShowSignatureHelp, window, cx);
}
Some(cx.foreground_executor().spawn(async move {
Some(cx.spawn_in(window, async move |editor, cx| {
apply_edits.await?;
if let Some((lsp_store, command)) = lsp_store.zip(command) {
let title = command.lsp_action.title().to_owned();
let project_transaction = lsp_store
.update(cx, |lsp_store, cx| {
lsp_store.apply_code_action(buffer_handle, command, false, cx)
})?
.await
.context("applying post-completion command")?;
if let Some(workspace) = editor.read_with(cx, |editor, _| editor.workspace())? {
Self::open_project_transaction(
&editor,
workspace.downgrade(),
project_transaction,
title,
cx,
)
.await?;
}
}
Ok(())
}))
}
+174
View File
@@ -14755,6 +14755,180 @@ async fn test_completion(cx: &mut TestAppContext) {
apply_additional_edits.await.unwrap();
}
#[gpui::test]
async fn test_completion_can_run_commands(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/a"),
json!({
"main.rs": "",
}),
)
.await;
let project = Project::test(fs, [path!("/a").as_ref()], cx).await;
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
language_registry.add(rust_lang());
let command_calls = Arc::new(AtomicUsize::new(0));
let registered_command = "_the/command";
let closure_command_calls = command_calls.clone();
let mut fake_servers = language_registry.register_fake_lsp(
"Rust",
FakeLspAdapter {
capabilities: lsp::ServerCapabilities {
completion_provider: Some(lsp::CompletionOptions {
trigger_characters: Some(vec![".".to_string(), ":".to_string()]),
..lsp::CompletionOptions::default()
}),
execute_command_provider: Some(lsp::ExecuteCommandOptions {
commands: vec![registered_command.to_owned()],
..lsp::ExecuteCommandOptions::default()
}),
..lsp::ServerCapabilities::default()
},
initializer: Some(Box::new(move |fake_server| {
fake_server.set_request_handler::<lsp::request::Completion, _, _>(
move |params, _| async move {
Ok(Some(lsp::CompletionResponse::Array(vec![
lsp::CompletionItem {
label: "registered_command".to_owned(),
text_edit: gen_text_edit(&params, ""),
command: Some(lsp::Command {
title: registered_command.to_owned(),
command: "_the/command".to_owned(),
arguments: Some(vec![serde_json::Value::Bool(true)]),
}),
..lsp::CompletionItem::default()
},
lsp::CompletionItem {
label: "unregistered_command".to_owned(),
text_edit: gen_text_edit(&params, ""),
command: Some(lsp::Command {
title: "????????????".to_owned(),
command: "????????????".to_owned(),
arguments: Some(vec![serde_json::Value::Null]),
}),
..lsp::CompletionItem::default()
},
])))
},
);
fake_server.set_request_handler::<lsp::request::ExecuteCommand, _, _>({
let command_calls = closure_command_calls.clone();
move |params, _| {
assert_eq!(params.command, registered_command);
let command_calls = command_calls.clone();
async move {
command_calls.fetch_add(1, atomic::Ordering::Release);
Ok(Some(json!(null)))
}
}
});
})),
..FakeLspAdapter::default()
},
);
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let editor = workspace
.update(cx, |workspace, window, cx| {
workspace.open_abs_path(
PathBuf::from(path!("/a/main.rs")),
OpenOptions::default(),
window,
cx,
)
})
.unwrap()
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let _fake_server = fake_servers.next().await.unwrap();
editor.update_in(cx, |editor, window, cx| {
cx.focus_self(window);
editor.move_to_end(&MoveToEnd, window, cx);
editor.handle_input(".", window, cx);
});
cx.run_until_parked();
editor.update(cx, |editor, _| {
assert!(editor.context_menu_visible());
if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref()
{
let completion_labels = menu
.completions
.borrow()
.iter()
.map(|c| c.label.text.clone())
.collect::<Vec<_>>();
assert_eq!(
completion_labels,
&["registered_command", "unregistered_command",],
);
} else {
panic!("expected completion menu to be open");
}
});
editor
.update_in(cx, |editor, window, cx| {
editor
.confirm_completion(&ConfirmCompletion::default(), window, cx)
.unwrap()
})
.await
.unwrap();
cx.run_until_parked();
assert_eq!(
command_calls.load(atomic::Ordering::Acquire),
1,
"For completion with a registered command, Zed should send a command execution request",
);
editor.update_in(cx, |editor, window, cx| {
cx.focus_self(window);
editor.handle_input(".", window, cx);
});
cx.run_until_parked();
editor.update(cx, |editor, _| {
assert!(editor.context_menu_visible());
if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref()
{
let completion_labels = menu
.completions
.borrow()
.iter()
.map(|c| c.label.text.clone())
.collect::<Vec<_>>();
assert_eq!(
completion_labels,
&["registered_command", "unregistered_command",],
);
} else {
panic!("expected completion menu to be open");
}
});
editor
.update_in(cx, |editor, window, cx| {
editor.context_menu_next(&Default::default(), window, cx);
editor
.confirm_completion(&ConfirmCompletion::default(), window, cx)
.unwrap()
})
.await
.unwrap();
cx.run_until_parked();
assert_eq!(
command_calls.load(atomic::Ordering::Acquire),
1,
"For completion with an unregistered command, Zed should not send a command execution request",
);
}
#[gpui::test]
async fn test_completion_reuse(cx: &mut TestAppContext) {
init_test(cx, |_| {});