fix(plugins): cover indexed code names and translate rename refusals (#2209)

The refusal added for indexed-only renames missed code names in Go, Ruby and PHP, ran without the rename cancellation, and was English only. It now covers those node kinds, honours cancellation, and translates the hint.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-09 07:15:13 +08:00
1 джерело 9a76d1766d
коміт 75565927d8
5 files changed
+122 -30

No files matched your search

+79 -19
Переглянути файл
@@ -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<Option<Range>> {
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<ide_core::CancellationToken>,
) -> IdeResult<Option<ide_core::IdeError>> {
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", "<?php $order = 1;", "$order"),
] {
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}");
}
for (language, text, name) in [
("java", "class App { String route = \"orders.index\"; }", "orders.index"),
("kotlin", "val route = \"orders.index\"", "orders.index"),
("typescript", "const route = \"orders.index\";", "orders.index"),
("python", "route = \"orders.index\"", "orders.index"),
("go", "package app\nvar route = \"orders.index\"", "orders.index"),
("rust", "const ROUTE: &str = \"orders.index\";", "orders.index"),
("cpp", "const char* route = \"orders.index\";", "orders.index"),
("json", "{\"orders.index\": \"orders.index\"}", "orders.index"),
("yaml", "orders.index: orders.index", "orders.index"),
("xml", "<select id=\"orders.index\" />", "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 = "<?php\nuse App\\Models\\Order;\nclass Order {}\n// Order is a model\n\
return route('users.show', Order::query());\n";
let snap = ide_syntax::Snapshot::text(&php, text);
assert!(identifier_at(&structure, &snap, anchor(text, 1, "Order")), "an imported class");
assert!(identifier_at(&structure, &snap, anchor(text, 2, "Order")), "a class declaration");
assert!(identifier_at(&structure, &snap, anchor(text, 4, "Order")), "a static call's class");
assert!(!identifier_at(&structure, &snap, anchor(text, 4, "users.show")), "a route name");
assert!(!identifier_at(&structure, &snap, anchor(text, 3, "Order")), "a comment");
assert!(identifier_at(&structure, &snap, anchor(text, 1, "Order"), &cancel), "an imported class");
assert!(identifier_at(&structure, &snap, anchor(text, 2, "Order"), &cancel), "a class declaration");
assert!(identifier_at(&structure, &snap, anchor(text, 4, "Order"), &cancel), "a static call's class");
assert!(!identifier_at(&structure, &snap, anchor(text, 4, "users.show"), &cancel), "a route name");
assert!(!identifier_at(&structure, &snap, anchor(text, 3, "Order"), &cancel), "a comment");
let ruby = ide_core::LanguageId::new("ruby");
let text = "class Order\nend\nOrder.where(status: \"paid\")\n";
let snap = ide_syntax::Snapshot::text(&ruby, text);
assert!(identifier_at(&structure, &snap, anchor(text, 0, "Order")), "a Ruby class name");
assert!(identifier_at(&structure, &snap, anchor(text, 2, "Order")), "a Ruby constant reference");
assert!(!identifier_at(&structure, &snap, anchor(text, 2, "paid")), "a Ruby string");
assert!(identifier_at(&structure, &snap, anchor(text, 0, "Order"), &cancel), "a Ruby class name");
assert!(identifier_at(&structure, &snap, anchor(text, 2, "Order"), &cancel), "a Ruby constant reference");
assert!(!identifier_at(&structure, &snap, anchor(text, 2, "paid"), &cancel), "a Ruby string");
let xml = ide_core::LanguageId::new("xml");
let text = "<mapper>\n <select id=\"selectById\">select 1</select>\n</mapper>\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]
+31 -11
Переглянути файл
@@ -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<AtomicBool>);
struct Native(Arc<AtomicU8>);
impl Handler for Native {
fn on_notification(&self, _: &str, _: Value) {}
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
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::<Peer>::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);
+10
Переглянути файл
@@ -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 () => {
+1
Переглянути файл
@@ -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",
+1
Переглянути файл
@@ -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": "文档过大",