diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index fb7c7b4cc..5ed1de270 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -271,10 +271,24 @@ fn string_at(structure: &ide_syntax::StructureService, snap: &ide_syntax::Snapsh stringy(&at.node.kind) || (part(&at.node.kind) && at.ancestors.first().is_some_and(|parent| stringy(&parent.kind))) } -fn identifier_at(structure: &ide_syntax::StructureService, snap: &ide_syntax::Snapshot, range: Range) -> bool { - let cancel = ide_core::CancellationToken::new(); - let Ok(Some(at)) = structure.node_at(snap, range, &cancel) else { return false }; - at.node.kind.contains("identifier") || matches!(at.node.kind.as_str(), "name" | "constant") +fn identifier_at( + structure: &ide_syntax::StructureService, + snap: &ide_syntax::Snapshot, + range: Range, + cancel: &ide_core::CancellationToken, +) -> bool { + let Ok(Some(at)) = structure.node_at(snap, range, cancel) else { return false }; + at.node.kind.contains("identifier") + || matches!( + at.node.kind.as_str(), + "name" + | "constant" + | "label_name" + | "variable_name" + | "instance_variable" + | "class_variable" + | "global_variable" + ) } fn definition_targets( @@ -794,7 +808,7 @@ async fn is_client_follow_up( pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult> { if let Some(symbol) = indexed_symbol(host, window, doc, pos).await? { let project = host.project(window)?; - if let Some(refusal) = indexed_code_identifier(host, window, doc, symbol.range).await? + if let Some(refusal) = indexed_code_identifier(host, window, doc, symbol.range, None).await? && let Err(e) = project.lsp.prepare_rename(doc, pos).await && indexed_lsp_error(e)?.is_none() { @@ -898,7 +912,8 @@ pub async fn lsp_rename( let incomplete = indexed_lsp_error(e)?; if incomplete.is_none() && let Some(symbol) = &symbol - && let Some(refusal) = indexed_code_identifier(host, window, doc, symbol.range).await? + && let Some(refusal) = + indexed_code_identifier(host, window, doc, symbol.range, Some(cancel.clone())).await? { return Err(refusal); } @@ -945,21 +960,29 @@ async fn indexed_code_identifier( window: &str, doc: DocId, range: Range, + cancel: Option, ) -> IdeResult> { let project = host.project(window)?; + let cancel = cancel.unwrap_or_default(); blocking(move || { + if cancel.is_cancelled() { + return Err(ide_core::IdeError::cancelled()); + } let snap = project.documents.text_snapshot(doc)?; if snap.large { return Ok(None); } let s = ide_syntax::Snapshot::doc(doc, snap.version, &snap.language, &snap.text); - if !identifier_at(&project.structure, &s, range) { + let identifier = identifier_at(&project.structure, &s, range, &cancel); + if cancel.is_cancelled() { + return Err(ide_core::IdeError::cancelled()); + } + if !identifier { return Ok(None); } let language = project.languages.get(&snap.language).map_or_else(|| snap.language.to_string(), |l| l.name); Ok(Some(ide_core::IdeError::invalid(format!( - "The {language} language server can't rename this symbol; \ - renaming only the plugin's indexed names would leave it half renamed" + "The {language} language server can't rename this symbol; renaming only the plugin's indexed names would leave it half renamed" )))) }) .await @@ -1655,33 +1678,70 @@ mod tests { fn indexed_names_on_code_identifiers_are_told_apart_from_strings_and_comments() { let syntax = std::sync::Arc::new(ide_language::Syntax::new()); let structure = ide_syntax::StructureService::new(std::sync::Arc::new(ide_language::SyntaxCache::new(syntax))); + let cancel = ide_core::CancellationToken::new(); let anchor = |text: &str, line: u32, name: &str| { let col = text.lines().nth(line as usize).unwrap().find(name).unwrap() as u32; Range::new(Pos::new(line, col), Pos::new(line, col + name.len() as u32)) }; + for (language, text, name) in [ + ("java", "class Order {}", "Order"), + ("kotlin", "class Order", "Order"), + ("typescript", "class Order {}", "Order"), + ("python", "class Order:\n pass", "Order"), + ("go", "package app\ntype Order struct {}", "Order"), + ("rust", "struct Order {}", "Order"), + ("cpp", "class Order {};", "Order"), + ("go", "package app\nfunc f() { retry: goto retry }", "retry"), + ("ruby", "@order = 1", "@order"), + ("ruby", "@@order = 1", "@@order"), + ("ruby", "$order = 1", "$order"), + ("php", "", "id"), + ] { + let language = ide_core::LanguageId::new(language); + let snap = ide_syntax::Snapshot::text(&language, text); + let line = u32::from(text.starts_with("package")); + assert!(!identifier_at(&structure, &snap, anchor(text, line, name), &cancel), "{language}: {name}"); + } let php = ide_core::LanguageId::new("php"); let text = "\n \n\n"; let snap = ide_syntax::Snapshot::text(&xml, text); - assert!(!identifier_at(&structure, &snap, anchor(text, 1, "selectById")), "a MyBatis statement id"); + assert!(!identifier_at(&structure, &snap, anchor(text, 1, "selectById"), &cancel), "a MyBatis statement id"); let idx = ide_core::LanguageId::new("idx"); let text = "def route\n"; let snap = ide_syntax::Snapshot::text(&idx, text); - assert!(!identifier_at(&structure, &snap, anchor(text, 0, "route")), "a language without a grammar"); + assert!(!identifier_at(&structure, &snap, anchor(text, 0, "route"), &cancel), "a language without a grammar"); } #[test] diff --git a/crates/ide-host/tests/plugin_refactoring.rs b/crates/ide-host/tests/plugin_refactoring.rs index 5ce4b539a..c49901d1c 100644 --- a/crates/ide-host/tests/plugin_refactoring.rs +++ b/crates/ide-host/tests/plugin_refactoring.rs @@ -398,7 +398,7 @@ async fn code_identifier_rename(host: &Host, plugins: &PluginHost, root: &Path, use ide_core::{FileEdit, Range}; use ide_jsonrpc::{Handler, Peer}; use serde_json::{Value, json}; - use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::atomic::{AtomicU8, Ordering}; let plugin = root.join("ruby-indexer"); std::fs::create_dir_all(plugin.join("dist")).unwrap(); std::fs::copy(fixture.join("dist/plugin.wasm"), plugin.join("dist/plugin.wasm")).unwrap(); @@ -477,18 +477,22 @@ async fn code_identifier_rename(host: &Host, plugins: &PluginHost, root: &Path, [("order.rb".to_owned(), 3, 4, list.clone()), ("orders_controller.rb".to_owned(), 1, 4, list)] ); project.lsp.did_close(doc.id); - struct Native(Arc); + struct Native(Arc); impl Handler for Native { fn on_notification(&self, _: &str, _: Value) {} fn on_request(&self, method: &str, params: Value) -> Result { - let owned = self.0.load(Ordering::SeqCst); + let mode = self.0.load(Ordering::SeqCst); let name = json!({"start": {"line": 2, "character": 4}, "end": {"line": 2, "character": 9}}); Ok(match method { "initialize" => { json!({"capabilities": {"textDocumentSync": 1, "renameProvider": {"prepareProvider": true}}}) } - "textDocument/prepareRename" if owned => name, - "textDocument/rename" if owned => { + "textDocument/prepareRename" if mode == 0 => name, + "textDocument/prepareRename" if mode == 2 => { + json!({"start": {"line": 2, "character": 0}, "end": {"line": 2, "character": 3}}) + } + "textDocument/prepareRename" if mode == 3 => return Err((-32603, "cannot check name".into())), + "textDocument/rename" if mode == 0 => { let uri = params["textDocument"]["uri"].as_str().unwrap().to_owned(); json!({"changes": {uri: [{"range": name, "newText": params["newName"]}]}}) } @@ -496,12 +500,12 @@ async fn code_identifier_rename(host: &Host, plugins: &PluginHost, root: &Path, }) } } - let owned = Arc::new(AtomicBool::new(true)); + let mode = Arc::new(AtomicU8::new(0)); let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); let port = listener.local_addr().unwrap().port(); let peers = Arc::new(Mutex::new(Vec::::new())); let accepted = peers.clone(); - let state = owned.clone(); + let state = mode.clone(); let task = tokio::spawn(async move { while let Ok((stream, _)) = listener.accept().await { let (reader, writer) = stream.into_split(); @@ -525,10 +529,26 @@ async fn code_identifier_rename(host: &Host, plugins: &PluginHost, root: &Path, ops::lsp::lsp_rename(host, "ruby", RequestId(122), doc.id, Pos::new(2, 5), "purchase".into()).await.unwrap(); assert!(renamed.preview); assert_eq!(edits(&renamed.edit.files), everywhere, "the server's rename and the indexed names merge"); - owned.store(false, Ordering::SeqCst); - let renamed = - ops::lsp::lsp_rename(host, "ruby", RequestId(123), doc.id, Pos::new(2, 5), "purchase".into()).await.unwrap(); - assert_eq!(edits(&renamed.edit.files), everywhere, "a server that declines the name leaves it to the plugin"); + for value in 1..=3 { + mode.store(value, Ordering::SeqCst); + assert_eq!( + ops::lsp::lsp_prepare_rename(host, "ruby", doc.id, Pos::new(2, 5)).await.unwrap(), + Some(Range::new(Pos::new(2, 4), Pos::new(2, 9))) + ); + let renamed = ops::lsp::lsp_rename(host, "ruby", RequestId(123), doc.id, Pos::new(2, 5), "purchase".into()) + .await + .unwrap(); + assert_eq!(edits(&renamed.edit.files), everywhere, "declined or failed preparation retains indexed edits"); + assert_eq!(renamed.incomplete.is_some(), value == 3, "only protocol errors warn about incomplete results"); + } + host.cancel_request(RequestId(124)); + assert_eq!( + ops::lsp::lsp_rename(host, "ruby", RequestId(124), doc.id, Pos::new(2, 5), "purchase".into()) + .await + .unwrap_err() + .kind, + ErrorKind::Cancelled + ); assert_eq!(std::fs::read_to_string(&model).unwrap(), source); assert_eq!(std::fs::read_to_string(&usage).unwrap(), used); project.lsp.did_close(doc.id); diff --git a/src/platform/i18n.test.ts b/src/platform/i18n.test.ts index dbe04e01e..215fc6879 100644 --- a/src/platform/i18n.test.ts +++ b/src/platform/i18n.test.ts @@ -162,6 +162,16 @@ describe("t / locales", () => { expect(txTemplate("Retry failed")).toBe(`${tx("Retry")}失败`); expect(txTemplate("Sync with OS")).toBe("与操作系统同步"); expect(txTemplate("Something else entirely")).toBe("Something else entirely"); + expect( + txTemplate( + "The PHP language server can't rename this symbol; renaming only the plugin's indexed names would leave it half renamed", + ), + ).toBe("语言服务器无法重命名此符号(PHP);仅重命名插件索引的名称会导致重命名不完整"); + expect( + txTemplate( + "The Ruby language server can't rename this symbol; renaming only the plugin's indexed names would leave it half renamed", + ), + ).toBe("语言服务器无法重命名此符号(Ruby 语言);仅重命名插件索引的名称会导致重命名不完整"); }); it("translates text built by the tx tag, again after the locale changes", async () => { diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index cf5934c3a..41195d8eb 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -52,6 +52,7 @@ "Select Python Environment Folder": "Select Python Environment Folder", "Checking interpreter": "Checking interpreter", "Cannot run this Python interpreter. Select a working Python executable or virtualenv folder.": "Cannot run this Python interpreter. Select a working Python executable or virtualenv folder.", + "The {language} language server can't rename this symbol; renaming only the plugin's indexed names would leave it half renamed": "The {language} language server can't rename this symbol; renaming only the plugin's indexed names would leave it half renamed", "Native server declined rename": "Native server declined rename", "document is too large or is not a regular file": "document is too large or is not a regular file", "document is too large": "document is too large", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 6eeaaea3f..e120de9b4 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -39,6 +39,7 @@ "Select Python Environment Folder": "选择 Python 环境文件夹", "Checking interpreter": "正在检查解释器", "Cannot run this Python interpreter. Select a working Python executable or virtualenv folder.": "无法运行此 Python 解释器。请选择可用的 Python 可执行文件或虚拟环境文件夹。", + "The {language} language server can't rename this symbol; renaming only the plugin's indexed names would leave it half renamed": "语言服务器无法重命名此符号({language});仅重命名插件索引的名称会导致重命名不完整", "Native server declined rename": "语言服务器拒绝重命名", "document is too large or is not a regular file": "文档过大或不是普通文件", "document is too large": "文档过大",