diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index dfa2ef9b..2ace34ad 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -23,6 +23,7 @@ mod scope; mod server; mod tcp; mod text; +mod ts_file_moves; mod tsserver_bridge; pub mod types; mod uri; @@ -2010,7 +2011,30 @@ impl LspManager { servers.iter().zip(ready).filter_map(|(s, run)| Some(Target { server: s.clone(), run: run? })).collect(); let mut out = FileRenameEdits::default(); for t in targets { - let Some(params) = Self::file_op_params(&t, "willRename", &moves) else { continue }; + let Some(params) = Self::file_op_params(&t, "willRename", &moves) else { + if t.server.config.tsserver_project && t.caps().commands().iter().any(|c| c == ts_file_moves::COMMAND) { + out.servers.push(t.server.name.clone()); + let opened = self.open_for_file_op(&t, &moves); + for (from, to, _) in &moves { + let r: IdeResult = self + .call( + &t, + "workspace/executeCommand", + ts_file_moves::request(from, to), + Some(&cancel), + LONG_TIMEOUT, + ) + .await; + match r { + Ok(result) => out.edit.files.extend(ts_file_moves::edit(&result).files), + Err(e) if e.kind == ErrorKind::Cancelled && cancel.is_cancelled() => return Err(e), + Err(e) => out.errors.push(e.message), + } + } + drop(opened); + } + continue; + }; out.servers.push(t.server.name.clone()); let opened = self.open_for_file_op(&t, &moves); let r: IdeResult> = diff --git a/crates/ide-lsp/src/ts_file_moves.rs b/crates/ide-lsp/src/ts_file_moves.rs new file mode 100644 index 00000000..d46ed7c3 --- /dev/null +++ b/crates/ide-lsp/src/ts_file_moves.rs @@ -0,0 +1,89 @@ +//! Reference updates for moved files from vtsls, which has no `workspace/willRenameFiles`: +//! it updates imports itself after `didRenameFiles`, behind a prompt the client answers. The +//! IDE asks tsserver for the same edits up front (`getEditsForFileRename`, through +//! `typescript.tsserverRequest`), so they join the move's preview and undo, and the server's +//! own update is off (`updateImportsOnFileMove.enabled = "never"` in `languages/*.toml`). + +use ide_core::{FileEdit, Pos, Range, TextEdit, WorkspaceEdit}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::path::PathBuf; + +pub(crate) const COMMAND: &str = "typescript.tsserverRequest"; + +/// The `typescript.tsserverRequest` arguments asking for the edits of moving `from` to `to`. +pub(crate) fn request(from: &std::path::Path, to: &std::path::Path) -> Value { + json!({ + "command": COMMAND, + "arguments": ["getEditsForFileRename", { "oldFilePath": from, "newFilePath": to }], + }) +} + +#[derive(Deserialize)] +struct Location { + line: u32, + offset: u32, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Change { + start: Location, + end: Location, + new_text: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct FileChanges { + file_name: PathBuf, + text_changes: Vec, +} + +/// tsserver's `FileCodeEdits[]` (the command's `body`; 1-based lines and UTF-16 offsets) as an edit. +pub(crate) fn edit(result: &Value) -> WorkspaceEdit { + let body = result.get("body").unwrap_or(result); + let files: Vec = serde_json::from_value(body.clone()).unwrap_or_default(); + let pos = |l: &Location| Pos::new(l.line.saturating_sub(1), l.offset.saturating_sub(1)); + WorkspaceEdit { + files: files + .into_iter() + .filter(|f| !f.text_changes.is_empty()) + .map(|f| FileEdit::Edit { + path: f.file_name.into(), + edits: f + .text_changes + .into_iter() + .map(|c| TextEdit { range: Range::new(pos(&c.start), pos(&c.end)), new_text: c.new_text }) + .collect(), + version: None, + }) + .collect(), + preconditions: Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn converts_tsserver_file_code_edits() { + let result = json!({ "success": true, "body": [ + { "fileName": "/p/src/index.ts", "textChanges": [ + { "start": { "line": 2, "offset": 31 }, "end": { "line": 2, "offset": 41 }, "newText": "./lib/money.ts" } + ] }, + { "fileName": "/p/src/unchanged.ts", "textChanges": [] } + ] }); + let edit = edit(&result); + assert_eq!(edit.files.len(), 1); + let FileEdit::Edit { path, edits, version } = &edit.files[0] else { panic!("{edit:?}") }; + assert_eq!(**path, *std::path::Path::new("/p/src/index.ts")); + assert_eq!( + edits, + &[TextEdit { range: Range::new(Pos::new(1, 30), Pos::new(1, 40)), new_text: "./lib/money.ts".into() }] + ); + assert!(version.is_none()); + assert!(super::edit(&json!({ "success": false })).files.is_empty()); + } +} diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 6475c872..274e1ba7 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -2336,6 +2336,41 @@ async fn will_and_did_rename_files_honour_filters() { ); } +#[tokio::test(flavor = "multi_thread")] +async fn will_rename_files_asks_tsserver_when_the_server_has_no_file_operations() { + // vtsls: no `willRename` filters, but tsserver's `getEditsForFileRename` through its command. + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({ "commands": ["typescript.tsserverRequest"] }); + let h = harness_configured("typescript", "ts", &[("vtsls", caps)], |cfg| cfg.tsserver_project = true); + let money = h.root.join("money.ts"); + let index = h.root.join("index.ts"); + std::fs::write(&money, "export const one = 1;\n").unwrap(); + std::fs::write(&index, "import { one } from \"./money\";\n").unwrap(); + let s = h.launcher.state("vtsls"); + *s.responder.lock() = Some(Arc::new(move |_, method, p| { + (method == "workspace/executeCommand" && p["arguments"][0] == "getEditsForFileRename").then(|| { + json!({ "success": true, "body": [{ "fileName": p["arguments"][1]["oldFilePath"].as_str().unwrap().replace("money.ts", "index.ts"), + "textChanges": [{ "start": { "line": 1, "offset": 22 }, "end": { "line": 1, "offset": 29 }, "newText": "./lib/money" }] }] }) + }) + })); + let to = h.root.join("lib/money.ts"); + let result = + h.mgr.will_rename_files(&[(money.clone(), to.clone())], ide_core::CancellationToken::new()).await.unwrap(); + assert_eq!(result.servers, vec!["vtsls".to_owned()]); + assert!(result.errors.is_empty(), "{:?}", result.errors); + match &result.edit.files[..] { + [FileEdit::Edit { path, edits, version: None }] => { + assert_eq!(path, &index); + assert_eq!(edits[0].range, rng(0, 21, 0, 28)); + assert_eq!(edits[0].new_text, "./lib/money"); + } + other => panic!("unexpected edit {other:?}"), + } + let asked = s.requested("workspace/executeCommand"); + let asked = asked.iter().find(|r| r["arguments"][0] == "getEditsForFileRename").unwrap(); + assert_eq!(asked["arguments"][1], json!({ "oldFilePath": money, "newFilePath": to })); +} + // ------------------------------------------------------------------ inspect code struct InspectTestHost { diff --git a/languages/javascript.toml b/languages/javascript.toml index 8d31fefe..70954b50 100644 --- a/languages/javascript.toml +++ b/languages/javascript.toml @@ -24,6 +24,14 @@ useAliasesForRenames = false [language-servers.initialization-options.javascript.preferences] useAliasesForRenames = false +# Moving a file: the IDE asks for the import updates up front (preview, undo); the server +# doesn't prompt after the move. +[language-servers.initialization-options.typescript.updateImportsOnFileMove] +enabled = "never" + +[language-servers.initialization-options.javascript.updateImportsOnFileMove] +enabled = "never" + [[language-servers]] name = "typescript-language-server" tsserver-project = true diff --git a/languages/javascriptreact.toml b/languages/javascriptreact.toml index d3c6c57d..cdab8c31 100644 --- a/languages/javascriptreact.toml +++ b/languages/javascriptreact.toml @@ -23,6 +23,14 @@ useAliasesForRenames = false [language-servers.initialization-options.javascript.preferences] useAliasesForRenames = false +# Moving a file: the IDE asks for the import updates up front (preview, undo); the server +# doesn't prompt after the move. +[language-servers.initialization-options.typescript.updateImportsOnFileMove] +enabled = "never" + +[language-servers.initialization-options.javascript.updateImportsOnFileMove] +enabled = "never" + [[language-servers]] name = "typescript-language-server" tsserver-project = true diff --git a/languages/typescript.toml b/languages/typescript.toml index c0da4595..951c0922 100644 --- a/languages/typescript.toml +++ b/languages/typescript.toml @@ -23,6 +23,14 @@ useAliasesForRenames = false [language-servers.initialization-options.javascript.preferences] useAliasesForRenames = false +# Moving a file: the IDE asks for the import updates up front (preview, undo); the server +# doesn't prompt after the move. +[language-servers.initialization-options.typescript.updateImportsOnFileMove] +enabled = "never" + +[language-servers.initialization-options.javascript.updateImportsOnFileMove] +enabled = "never" + [[language-servers]] name = "typescript-language-server" tsserver-project = true diff --git a/languages/typescriptreact.toml b/languages/typescriptreact.toml index 82a1a5bc..3d4b1377 100644 --- a/languages/typescriptreact.toml +++ b/languages/typescriptreact.toml @@ -23,6 +23,14 @@ useAliasesForRenames = false [language-servers.initialization-options.javascript.preferences] useAliasesForRenames = false +# Moving a file: the IDE asks for the import updates up front (preview, undo); the server +# doesn't prompt after the move. +[language-servers.initialization-options.typescript.updateImportsOnFileMove] +enabled = "never" + +[language-servers.initialization-options.javascript.updateImportsOnFileMove] +enabled = "never" + [[language-servers]] name = "typescript-language-server" tsserver-project = true diff --git a/languages/vue.toml b/languages/vue.toml index 2f1b2d73..e47d985a 100644 --- a/languages/vue.toml +++ b/languages/vue.toml @@ -30,6 +30,14 @@ useAliasesForRenames = false [language-servers.initialization-options.javascript.preferences] useAliasesForRenames = false +# Moving a file: the IDE asks for the import updates up front (preview, undo); the server +# doesn't prompt after the move. +[language-servers.initialization-options.typescript.updateImportsOnFileMove] +enabled = "never" + +[language-servers.initialization-options.javascript.updateImportsOnFileMove] +enabled = "never" + [[language-servers]] name = "oxlint" command = "oxlint"