From 7bea1ba5556de14fd53d42ba5e0895c33185349d Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Tue, 2 Dec 2025 19:38:01 +0200 Subject: [PATCH] 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 --- .../src/session/running/console.rs | 15 +- crates/editor/src/editor.rs | 63 ++++++- crates/editor/src/editor_tests.rs | 174 ++++++++++++++++++ 3 files changed, 235 insertions(+), 17 deletions(-) diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs index 2939079f256d4c2742e514f002a4c9fe5e58b49a..717169ff5ad1d0f479075ca996c550a774a4307a 100644 --- a/crates/debugger_ui/src/session/running/console.rs +++ b/crates/debugger_ui/src/session/running/console.rs @@ -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, - _completions: Rc>>, - _completion_index: usize, - _push_to_history: bool, - _cx: &mut Context, - ) -> gpui::Task>> { - Task::ready(Ok(None)) - } - fn is_completion_trigger( &self, buffer: &Entity, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 3ceb9d8d699a5aa7f743e3c30042b47c486f17b4..114dbac23e80814c64471d7123ef73a29ccfc115 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -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(()) })) } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 0bcfad7b881f4d90a2ffe0aa5c1d330d89470e98..61d316e3915a740cb35b24a3afa445a34a608336 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -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::( + move |params, _| async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "registered_command".to_owned(), + text_edit: gen_text_edit(¶ms, ""), + 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(¶ms, ""), + 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::({ + 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::() + .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::>(); + 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::>(); + 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, |_| {});