Support workspace/executeCommand for actions' data (#26239)

Closes https://github.com/zed-industries/zed/issues/16746
Part of https://github.com/zed-extensions/deno/issues/2

Changes the action-related code so, that

* `lsp::Command` as actions are supported, if server replies with them
* actions with commands are filtered out based on servers'
`executeCommandOptions`
(https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#executeCommandOptions)
— commands that are not listed won't be executed and the corresponding
actions will be hidden in Zed

Release Notes:

- Added support of `workspace/executeCommand` for actions' data

---------

Co-authored-by: Peter Tripp <petertripp@gmail.com>
This commit is contained in:
Kirill Bulatov 2025-03-06 23:26:46 +02:00 committed by GitHub
parent 97c0a0a86e
commit af5af9d7c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 217 additions and 87 deletions

View file

@ -38,7 +38,7 @@ use language_model::{
use language_model_selector::{LanguageModelSelector, LanguageModelSelectorPopoverMenu}; use language_model_selector::{LanguageModelSelector, LanguageModelSelectorPopoverMenu};
use multi_buffer::MultiBufferRow; use multi_buffer::MultiBufferRow;
use parking_lot::Mutex; use parking_lot::Mutex;
use project::{CodeAction, ProjectTransaction}; use project::{ActionVariant, CodeAction, ProjectTransaction};
use prompt_store::PromptBuilder; use prompt_store::PromptBuilder;
use rope::Rope; use rope::Rope;
use settings::{update_settings_file, Settings, SettingsStore}; use settings::{update_settings_file, Settings, SettingsStore};
@ -3569,10 +3569,10 @@ impl CodeActionProvider for AssistantCodeActionProvider {
Task::ready(Ok(vec![CodeAction { Task::ready(Ok(vec![CodeAction {
server_id: language::LanguageServerId(0), server_id: language::LanguageServerId(0),
range: snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end), range: snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end),
lsp_action: lsp::CodeAction { lsp_action: ActionVariant::Action(Box::new(lsp::CodeAction {
title: "Fix with Assistant".into(), title: "Fix with Assistant".into(),
..Default::default() ..Default::default()
}, })),
}])) }]))
} else { } else {
Task::ready(Ok(Vec::new())) Task::ready(Ok(Vec::new()))

View file

@ -27,6 +27,7 @@ use language::{Buffer, Point, Selection, TransactionId};
use language_model::{report_assistant_event, LanguageModelRegistry}; use language_model::{report_assistant_event, LanguageModelRegistry};
use multi_buffer::MultiBufferRow; use multi_buffer::MultiBufferRow;
use parking_lot::Mutex; use parking_lot::Mutex;
use project::ActionVariant;
use project::{CodeAction, ProjectTransaction}; use project::{CodeAction, ProjectTransaction};
use prompt_store::PromptBuilder; use prompt_store::PromptBuilder;
use settings::{Settings, SettingsStore}; use settings::{Settings, SettingsStore};
@ -1727,10 +1728,10 @@ impl CodeActionProvider for AssistantCodeActionProvider {
Task::ready(Ok(vec![CodeAction { Task::ready(Ok(vec![CodeAction {
server_id: language::LanguageServerId(0), server_id: language::LanguageServerId(0),
range: snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end), range: snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end),
lsp_action: lsp::CodeAction { lsp_action: ActionVariant::Action(Box::new(lsp::CodeAction {
title: "Fix with Assistant".into(), title: "Fix with Assistant".into(),
..Default::default() ..Default::default()
}, })),
}])) }]))
} else { } else {
Task::ready(Ok(Vec::new())) Task::ready(Ok(Vec::new()))

View file

@ -851,7 +851,7 @@ impl CodeActionsItem {
pub fn label(&self) -> String { pub fn label(&self) -> String {
match self { match self {
Self::CodeAction { action, .. } => action.lsp_action.title.clone(), Self::CodeAction { action, .. } => action.lsp_action.title().to_owned(),
Self::Task(_, task) => task.resolved_label.clone(), Self::Task(_, task) => task.resolved_label.clone(),
} }
} }
@ -984,7 +984,7 @@ impl CodeActionsMenu {
.overflow_hidden() .overflow_hidden()
.child( .child(
// TASK: It would be good to make lsp_action.title a SharedString to avoid allocating here. // TASK: It would be good to make lsp_action.title a SharedString to avoid allocating here.
action.lsp_action.title.replace("\n", ""), action.lsp_action.title().replace("\n", ""),
) )
.when(selected, |this| { .when(selected, |this| {
this.text_color(colors.text_accent) this.text_color(colors.text_accent)
@ -1029,7 +1029,7 @@ impl CodeActionsMenu {
.max_by_key(|(_, action)| match action { .max_by_key(|(_, action)| match action {
CodeActionsItem::Task(_, task) => task.resolved_label.chars().count(), CodeActionsItem::Task(_, task) => task.resolved_label.chars().count(),
CodeActionsItem::CodeAction { action, .. } => { CodeActionsItem::CodeAction { action, .. } => {
action.lsp_action.title.chars().count() action.lsp_action.title().chars().count()
} }
}) })
.map(|(ix, _)| ix), .map(|(ix, _)| ix),

View file

@ -679,6 +679,9 @@ impl LanguageServer {
..Default::default() ..Default::default()
}), }),
apply_edit: Some(true), apply_edit: Some(true),
execute_command: Some(ExecuteCommandClientCapabilities {
dynamic_registration: Some(false),
}),
..Default::default() ..Default::default()
}), }),
text_document: Some(TextDocumentClientCapabilities { text_document: Some(TextDocumentClientCapabilities {

View file

@ -2,9 +2,10 @@ mod signature_help;
use crate::{ use crate::{
lsp_store::{LocalLspStore, LspStore}, lsp_store::{LocalLspStore, LspStore},
CodeAction, CoreCompletion, DocumentHighlight, Hover, HoverBlock, HoverBlockKind, InlayHint, ActionVariant, CodeAction, CoreCompletion, DocumentHighlight, Hover, HoverBlock,
InlayHintLabel, InlayHintLabelPart, InlayHintLabelPartTooltip, InlayHintTooltip, Location, HoverBlockKind, InlayHint, InlayHintLabel, InlayHintLabelPart, InlayHintLabelPartTooltip,
LocationLink, MarkupContent, PrepareRenameResponse, ProjectTransaction, ResolveState, InlayHintTooltip, Location, LocationLink, MarkupContent, PrepareRenameResponse,
ProjectTransaction, ResolveState,
}; };
use anyhow::{anyhow, Context as _, Result}; use anyhow::{anyhow, Context as _, Result};
use async_trait::async_trait; use async_trait::async_trait;
@ -2218,10 +2219,10 @@ impl LspCommand for GetCodeActions {
async fn response_from_lsp( async fn response_from_lsp(
self, self,
actions: Option<lsp::CodeActionResponse>, actions: Option<lsp::CodeActionResponse>,
_: Entity<LspStore>, lsp_store: Entity<LspStore>,
_: Entity<Buffer>, _: Entity<Buffer>,
server_id: LanguageServerId, server_id: LanguageServerId,
_: AsyncApp, cx: AsyncApp,
) -> Result<Vec<CodeAction>> { ) -> Result<Vec<CodeAction>> {
let requested_kinds_set = if let Some(kinds) = self.kinds { let requested_kinds_set = if let Some(kinds) = self.kinds {
Some(kinds.into_iter().collect::<HashSet<_>>()) Some(kinds.into_iter().collect::<HashSet<_>>())
@ -2229,18 +2230,47 @@ impl LspCommand for GetCodeActions {
None None
}; };
let language_server = cx.update(|cx| {
lsp_store
.read(cx)
.language_server_for_id(server_id)
.with_context(|| {
format!("Missing the language server that just returned a response {server_id}")
})
})??;
let server_capabilities = language_server.capabilities();
let available_commands = server_capabilities
.execute_command_provider
.as_ref()
.map(|options| options.commands.as_slice())
.unwrap_or_default();
Ok(actions Ok(actions
.unwrap_or_default() .unwrap_or_default()
.into_iter() .into_iter()
.filter_map(|entry| { .filter_map(|entry| {
let lsp::CodeActionOrCommand::CodeAction(lsp_action) = entry else { let lsp_action = match entry {
return None; lsp::CodeActionOrCommand::CodeAction(lsp_action) => {
if let Some(command) = lsp_action.command.as_ref() {
if !available_commands.contains(&command.command) {
return None;
}
}
ActionVariant::Action(Box::new(lsp_action))
}
lsp::CodeActionOrCommand::Command(command) => {
if available_commands.contains(&command.command) {
ActionVariant::Command(command)
} else {
return None;
}
}
}; };
if let Some((requested_kinds, kind)) = if let Some((requested_kinds, kind)) =
requested_kinds_set.as_ref().zip(lsp_action.kind.as_ref()) requested_kinds_set.as_ref().zip(lsp_action.action_kind())
{ {
if !requested_kinds.contains(kind) { if !requested_kinds.contains(&kind) {
return None; return None;
} }
} }

View file

@ -11,8 +11,8 @@ use crate::{
toolchain_store::{EmptyToolchainStore, ToolchainStoreEvent}, toolchain_store::{EmptyToolchainStore, ToolchainStoreEvent},
worktree_store::{WorktreeStore, WorktreeStoreEvent}, worktree_store::{WorktreeStore, WorktreeStoreEvent},
yarn::YarnPathStore, yarn::YarnPathStore,
CodeAction, Completion, CoreCompletion, Hover, InlayHint, ProjectItem as _, ProjectPath, ActionVariant, CodeAction, Completion, CoreCompletion, Hover, InlayHint, ProjectItem as _,
ProjectTransaction, ResolveState, Symbol, ToolchainStore, ProjectPath, ProjectTransaction, ResolveState, Symbol, ToolchainStore,
}; };
use anyhow::{anyhow, Context as _, Result}; use anyhow::{anyhow, Context as _, Result};
use async_trait::async_trait; use async_trait::async_trait;
@ -1666,15 +1666,21 @@ impl LocalLspStore {
lang_server: &LanguageServer, lang_server: &LanguageServer,
action: &mut CodeAction, action: &mut CodeAction,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
if GetCodeActions::can_resolve_actions(&lang_server.capabilities()) match &mut action.lsp_action {
&& action.lsp_action.data.is_some() ActionVariant::Action(lsp_action) => {
&& (action.lsp_action.command.is_none() || action.lsp_action.edit.is_none()) if GetCodeActions::can_resolve_actions(&lang_server.capabilities())
{ && lsp_action.data.is_some()
action.lsp_action = lang_server && (lsp_action.command.is_none() || lsp_action.edit.is_none())
.request::<lsp::request::CodeActionResolveRequest>(action.lsp_action.clone()) {
.await?; *lsp_action = Box::new(
lang_server
.request::<lsp::request::CodeActionResolveRequest>(*lsp_action.clone())
.await?,
);
}
}
ActionVariant::Command(_) => {}
} }
anyhow::Ok(()) anyhow::Ok(())
} }
@ -2102,7 +2108,7 @@ impl LocalLspStore {
} }
async fn execute_code_actions_on_servers( async fn execute_code_actions_on_servers(
this: &WeakEntity<LspStore>, lsp_store: &WeakEntity<LspStore>,
adapters_and_servers: &[(Arc<CachedLspAdapter>, Arc<LanguageServer>)], adapters_and_servers: &[(Arc<CachedLspAdapter>, Arc<LanguageServer>)],
code_actions: Vec<lsp::CodeActionKind>, code_actions: Vec<lsp::CodeActionKind>,
buffer: &Entity<Buffer>, buffer: &Entity<Buffer>,
@ -2113,7 +2119,7 @@ impl LocalLspStore {
for (lsp_adapter, language_server) in adapters_and_servers.iter() { for (lsp_adapter, language_server) in adapters_and_servers.iter() {
let code_actions = code_actions.clone(); let code_actions = code_actions.clone();
let actions = this let actions = lsp_store
.update(cx, move |this, cx| { .update(cx, move |this, cx| {
let request = GetCodeActions { let request = GetCodeActions {
range: text::Anchor::MIN..text::Anchor::MAX, range: text::Anchor::MIN..text::Anchor::MAX,
@ -2129,14 +2135,14 @@ impl LocalLspStore {
.await .await
.context("resolving a formatting code action")?; .context("resolving a formatting code action")?;
if let Some(edit) = action.lsp_action.edit { if let Some(edit) = action.lsp_action.edit() {
if edit.changes.is_none() && edit.document_changes.is_none() { if edit.changes.is_none() && edit.document_changes.is_none() {
continue; continue;
} }
let new = Self::deserialize_workspace_edit( let new = Self::deserialize_workspace_edit(
this.upgrade().ok_or_else(|| anyhow!("project dropped"))?, lsp_store.upgrade().context("project dropped")?,
edit, edit.clone(),
push_to_history, push_to_history,
lsp_adapter.clone(), lsp_adapter.clone(),
language_server.clone(), language_server.clone(),
@ -2146,32 +2152,42 @@ impl LocalLspStore {
project_transaction.0.extend(new.0); project_transaction.0.extend(new.0);
} }
if let Some(command) = action.lsp_action.command { if let Some(command) = action.lsp_action.command() {
this.update(cx, |this, _| { let server_capabilities = language_server.capabilities();
if let LspStoreMode::Local(mode) = &mut this.mode { let available_commands = server_capabilities
mode.last_workspace_edits_by_language_server .execute_command_provider
.remove(&language_server.server_id()); .as_ref()
} .map(|options| options.commands.as_slice())
})?; .unwrap_or_default();
if available_commands.contains(&command.command) {
language_server lsp_store.update(cx, |lsp_store, _| {
.request::<lsp::request::ExecuteCommand>(lsp::ExecuteCommandParams { if let LspStoreMode::Local(mode) = &mut lsp_store.mode {
command: command.command,
arguments: command.arguments.unwrap_or_default(),
..Default::default()
})
.await?;
this.update(cx, |this, _| {
if let LspStoreMode::Local(mode) = &mut this.mode {
project_transaction.0.extend(
mode.last_workspace_edits_by_language_server mode.last_workspace_edits_by_language_server
.remove(&language_server.server_id()) .remove(&language_server.server_id());
.unwrap_or_default() }
.0, })?;
)
} language_server
})?; .request::<lsp::request::ExecuteCommand>(lsp::ExecuteCommandParams {
command: command.command.clone(),
arguments: command.arguments.clone().unwrap_or_default(),
..Default::default()
})
.await?;
lsp_store.update(cx, |this, _| {
if let LspStoreMode::Local(mode) = &mut this.mode {
project_transaction.0.extend(
mode.last_workspace_edits_by_language_server
.remove(&language_server.server_id())
.unwrap_or_default()
.0,
)
}
})?;
} else {
log::warn!("Cannot execute a command {} not listed in the language server capabilities", command.command)
}
} }
} }
} }
@ -3896,17 +3912,17 @@ impl LspStore {
self.language_server_for_local_buffer(buffer, action.server_id, cx) self.language_server_for_local_buffer(buffer, action.server_id, cx)
.map(|(adapter, server)| (adapter.clone(), server.clone())) .map(|(adapter, server)| (adapter.clone(), server.clone()))
}) else { }) else {
return Task::ready(Ok(Default::default())); return Task::ready(Ok(ProjectTransaction::default()));
}; };
cx.spawn(move |this, mut cx| async move { cx.spawn(move |this, mut cx| async move {
LocalLspStore::try_resolve_code_action(&lang_server, &mut action) LocalLspStore::try_resolve_code_action(&lang_server, &mut action)
.await .await
.context("resolving a code action")?; .context("resolving a code action")?;
if let Some(edit) = action.lsp_action.edit { if let Some(edit) = action.lsp_action.edit() {
if edit.changes.is_some() || edit.document_changes.is_some() { if edit.changes.is_some() || edit.document_changes.is_some() {
return LocalLspStore::deserialize_workspace_edit( return LocalLspStore::deserialize_workspace_edit(
this.upgrade().ok_or_else(|| anyhow!("no app present"))?, this.upgrade().ok_or_else(|| anyhow!("no app present"))?,
edit, edit.clone(),
push_to_history, push_to_history,
lsp_adapter.clone(), lsp_adapter.clone(),
lang_server.clone(), lang_server.clone(),
@ -3916,31 +3932,41 @@ impl LspStore {
} }
} }
if let Some(command) = action.lsp_action.command { if let Some(command) = action.lsp_action.command() {
this.update(&mut cx, |this, _| { let server_capabilities = lang_server.capabilities();
this.as_local_mut() let available_commands = server_capabilities
.unwrap() .execute_command_provider
.last_workspace_edits_by_language_server .as_ref()
.remove(&lang_server.server_id()); .map(|options| options.commands.as_slice())
})?; .unwrap_or_default();
if available_commands.contains(&command.command) {
this.update(&mut cx, |this, _| {
this.as_local_mut()
.unwrap()
.last_workspace_edits_by_language_server
.remove(&lang_server.server_id());
})?;
let result = lang_server let result = lang_server
.request::<lsp::request::ExecuteCommand>(lsp::ExecuteCommandParams { .request::<lsp::request::ExecuteCommand>(lsp::ExecuteCommandParams {
command: command.command, command: command.command.clone(),
arguments: command.arguments.unwrap_or_default(), arguments: command.arguments.clone().unwrap_or_default(),
..Default::default() ..Default::default()
}) })
.await; .await;
result?; result?;
return this.update(&mut cx, |this, _| { return this.update(&mut cx, |this, _| {
this.as_local_mut() this.as_local_mut()
.unwrap() .unwrap()
.last_workspace_edits_by_language_server .last_workspace_edits_by_language_server
.remove(&lang_server.server_id()) .remove(&lang_server.server_id())
.unwrap_or_default() .unwrap_or_default()
}); });
} else {
log::warn!("Cannot execute a command {} not listed in the language server capabilities", command.command);
}
} }
Ok(ProjectTransaction::default()) Ok(ProjectTransaction::default())
@ -8158,11 +8184,23 @@ impl LspStore {
} }
pub(crate) fn serialize_code_action(action: &CodeAction) -> proto::CodeAction { pub(crate) fn serialize_code_action(action: &CodeAction) -> proto::CodeAction {
let (kind, lsp_action) = match &action.lsp_action {
ActionVariant::Action(code_action) => (
proto::code_action::Kind::Action as i32,
serde_json::to_vec(code_action).unwrap(),
),
ActionVariant::Command(command) => (
proto::code_action::Kind::Command as i32,
serde_json::to_vec(command).unwrap(),
),
};
proto::CodeAction { proto::CodeAction {
server_id: action.server_id.0 as u64, server_id: action.server_id.0 as u64,
start: Some(serialize_anchor(&action.range.start)), start: Some(serialize_anchor(&action.range.start)),
end: Some(serialize_anchor(&action.range.end)), end: Some(serialize_anchor(&action.range.end)),
lsp_action: serde_json::to_vec(&action.lsp_action).unwrap(), lsp_action,
kind,
} }
} }
@ -8175,7 +8213,15 @@ impl LspStore {
.end .end
.and_then(deserialize_anchor) .and_then(deserialize_anchor)
.ok_or_else(|| anyhow!("invalid end"))?; .ok_or_else(|| anyhow!("invalid end"))?;
let lsp_action = serde_json::from_slice(&action.lsp_action)?; let lsp_action = match proto::code_action::Kind::from_i32(action.kind) {
Some(proto::code_action::Kind::Action) => {
ActionVariant::Action(serde_json::from_slice(&action.lsp_action)?)
}
Some(proto::code_action::Kind::Command) => {
ActionVariant::Command(serde_json::from_slice(&action.lsp_action)?)
}
None => anyhow::bail!("Unknown action kind {}", action.kind),
};
Ok(CodeAction { Ok(CodeAction {
server_id: LanguageServerId(action.server_id as usize), server_id: LanguageServerId(action.server_id as usize),
range: start..end, range: start..end,

View file

@ -411,7 +411,48 @@ pub struct CodeAction {
/// The range of the buffer where this code action is applicable. /// The range of the buffer where this code action is applicable.
pub range: Range<Anchor>, pub range: Range<Anchor>,
/// The raw code action provided by the language server. /// The raw code action provided by the language server.
pub lsp_action: lsp::CodeAction, /// Can be either an action or a command.
pub lsp_action: ActionVariant,
}
/// An action sent back by a language server.
#[derive(Clone, Debug)]
pub enum ActionVariant {
/// An action with the full data, may have a command or may not.
/// May require resolving.
Action(Box<lsp::CodeAction>),
/// A command data to run as an action.
Command(lsp::Command),
}
impl ActionVariant {
pub fn title(&self) -> &str {
match self {
Self::Action(action) => &action.title,
Self::Command(command) => &command.title,
}
}
fn action_kind(&self) -> Option<lsp::CodeActionKind> {
match self {
Self::Action(action) => action.kind.clone(),
Self::Command(_) => Some(lsp::CodeActionKind::new("command")),
}
}
fn edit(&self) -> Option<&lsp::WorkspaceEdit> {
match self {
Self::Action(action) => action.edit.as_ref(),
Self::Command(_) => None,
}
}
fn command(&self) -> Option<&lsp::Command> {
match self {
Self::Action(action) => action.command.as_ref(),
Self::Command(command) => Some(command),
}
}
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]

View file

@ -2951,6 +2951,10 @@ async fn test_apply_code_actions_with_commands(cx: &mut gpui::TestAppContext) {
..lsp::CodeActionOptions::default() ..lsp::CodeActionOptions::default()
}, },
)), )),
execute_command_provider: Some(lsp::ExecuteCommandOptions {
commands: vec!["_the/command".to_string()],
..lsp::ExecuteCommandOptions::default()
}),
..lsp::ServerCapabilities::default() ..lsp::ServerCapabilities::default()
}, },
..FakeLspAdapter::default() ..FakeLspAdapter::default()
@ -5372,7 +5376,7 @@ async fn test_code_actions_only_kinds(cx: &mut gpui::TestAppContext) {
let code_actions = code_actions_task.await.unwrap(); let code_actions = code_actions_task.await.unwrap();
assert_eq!(code_actions.len(), 1); assert_eq!(code_actions.len(), 1);
assert_eq!( assert_eq!(
code_actions[0].lsp_action.kind, code_actions[0].lsp_action.action_kind(),
Some(CodeActionKind::SOURCE_ORGANIZE_IMPORTS) Some(CodeActionKind::SOURCE_ORGANIZE_IMPORTS)
); );
} }
@ -5529,7 +5533,7 @@ async fn test_multiple_language_server_actions(cx: &mut gpui::TestAppContext) {
.await .await
.unwrap() .unwrap()
.into_iter() .into_iter()
.map(|code_action| code_action.lsp_action.title) .map(|code_action| code_action.lsp_action.title().to_owned())
.sorted() .sorted()
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
"Should receive code actions responses from all related servers with hover capabilities" "Should receive code actions responses from all related servers with hover capabilities"

View file

@ -1286,6 +1286,11 @@ message CodeAction {
Anchor start = 2; Anchor start = 2;
Anchor end = 3; Anchor end = 3;
bytes lsp_action = 4; bytes lsp_action = 4;
Kind kind = 5;
enum Kind {
Action = 0;
Command = 1;
}
} }
message ProjectTransaction { message ProjectTransaction {