diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index a0105b87..36764719 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -438,12 +438,30 @@ pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action let edit = edit.clone(); blocking(move || apply_edit(&documents, &edit)).await?; } - if let Some(command) = action.command { + if let Some(command) = action.command + && !(action.edit.is_some() && is_client_follow_up(&project, doc, None, &command).await) + { project.lsp.execute_command(doc, command).await?; } Ok(()) } +/// A command after a code action's edit that no server provides: what other editors' +/// extensions do once the edit is applied (vtsls' `_typescript.didOrganizeImports`). The edit +/// is the action; there is nothing to run. +async fn is_client_follow_up( + project: &crate::project::Project, + doc: DocId, + server: Option, + command: &Command, +) -> bool { + let provided = project.lsp.provides_command(doc, server, &command.command).await; + if !provided { + tracing::debug!("skipping `{}` after the code action's edit: no server provides it", command.command); + } + !provided +} + pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult> { host.project(window)?.lsp.prepare_rename(doc, pos).await } @@ -676,6 +694,7 @@ pub async fn lsp_execute_code_action( if cancel.is_cancelled() { return Err(ide_core::IdeError::cancelled()); } + let edited = action.edit.is_some(); if let Some(edit) = action.edit { let documents = project.documents.clone(); tokio::task::spawn_blocking(move || apply_edit(&documents, &edit)) @@ -683,6 +702,9 @@ pub async fn lsp_execute_code_action( .map_err(|e| ide_core::IdeError::internal(e.to_string()))??; } match action.command { + Some(command) if edited && is_client_follow_up(&project, doc, Some(action.server), &command).await => { + Ok(Vec::new()) + } Some(command) => project .lsp .execute_action_command(doc, action.server, command, answers, cancel) diff --git a/crates/ide-host/tests/workspace_edits.rs b/crates/ide-host/tests/workspace_edits.rs index 7bdc5c57..2994603d 100644 --- a/crates/ide-host/tests/workspace_edits.rs +++ b/crates/ide-host/tests/workspace_edits.rs @@ -175,3 +175,66 @@ async fn contended_version_reads_do_not_block_the_async_worker() { assert!(watchdog.join().unwrap(), "request {request_kind} blocked the only async worker"); } } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_actions_skip_a_command_no_server_provides_after_their_edit() { + use ide_lsp::types::{CodeAction, Command}; + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("imports.txt"); + std::fs::write(&file, "unused\nused\n").unwrap(); + let host = Host::new(HostConfig { + config_dir: tmp.path().join("config"), + data_dir: tmp.path().join("data"), + local_data_dir: tmp.path().join("local-data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(|_| {})), + owner: |window| window, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + host.open_light("main", &file).unwrap(); + let info = ops::doc::open(&host, "main", file.clone()).await.unwrap(); + // Optimize Imports as vtsls offers it: the edit, then a command its client runs itself. + let action = |edit: bool| CodeAction { + title: "Organize Imports".into(), + kind: Some("source.organizeImports".into()), + is_preferred: false, + disabled_reason: None, + edit: edit.then(|| { + serde_json::from_value(json!({ "files": [{ "kind": "edit", "path": file, "edits": [ + { "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 1, "col": 0 } }, "new_text": "" } + ] }] })) + .unwrap() + }), + command: Some(Command { + title: String::new(), + command: "_typescript.didOrganizeImports".into(), + arguments: vec![], + }), + data: None, + server: ide_core::ServerId(1), + }; + let follow_up = action(true).command.unwrap(); + let asked = ops::lsp::lsp_resolve_command( + &host, + "main", + ide_core::RequestId(1), + info.id, + ide_core::ServerId(1), + follow_up, + vec![], + ) + .await; + assert!(matches!(asked, Ok(None)), "{asked:?}"); + ops::lsp::lsp_execute_code_action(&host, "main", ide_core::RequestId(2), info.id, action(true), vec![]) + .await + .unwrap(); + assert_eq!(ops::doc::snapshot(&host, "main", info.id).await.unwrap().text, "used\n"); + ops::lsp::lsp_apply_code_action(&host, "main", info.id, action(true)).await.unwrap(); + assert_eq!(ops::doc::snapshot(&host, "main", info.id).await.unwrap().text, ""); + // Without an edit the command is the action: it still can't run. + let ran = + ops::lsp::lsp_execute_code_action(&host, "main", ide_core::RequestId(3), info.id, action(false), vec![]).await; + assert!(ran.is_err(), "{ran:?}"); +} diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 1ea13785..dfa2ef9b 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -1692,6 +1692,20 @@ impl LspManager { Ok(()) } + /// Whether a server of `doc` (`server`, when given) provides the command `name`. Code actions + /// can carry commands other editors' extensions run themselves after the edit (vtsls' + /// `_typescript.didOrganizeImports`): no server provides those. + pub async fn provides_command(&self, doc: DocId, server: Option, name: &str) -> bool { + let servers = match self.snapshot(doc) { + Ok(snap) => snap.servers, + Err(_) => self.servers.lock().clone(), + }; + self.ready_targets(&servers, |c| c.commands().iter().any(|c| c == name)) + .await + .iter() + .any(|t| server.is_none_or(|s| t.server.id == s)) + } + async fn command_target(&self, doc: DocId, server: ide_core::ServerId, name: &str) -> IdeResult { let snap = self.snapshot(doc)?; self.ready_targets(&snap.servers, |c| c.commands().iter().any(|c| c == name)) @@ -1710,6 +1724,10 @@ impl LspManager { answers: Vec, cancel: CancellationToken, ) -> IdeResult> { + // Nothing to ask for a command its server doesn't provide (executing it decides). + if !self.provides_command(doc, Some(server), &command.command).await { + return Ok(None); + } let t = self.command_target(doc, server, &command.command).await?; let interactive = t.server.name == "gopls" && t.caps()