feat(csharp): navigate to decompiled assembly definitions (#1689)

Цей коміт міститься в:
Hacbit authored and GitHub committed 2026-11-07 03:19:32 +08:00
1 джерело 0f0458b966
коміт c3e8f20f53
10 files changed
+333 -13

No files matched your search

+44 -5
Переглянути файл
@@ -797,6 +797,9 @@ impl LspManager {
if server.name == "gopls" {
params["capabilities"]["experimental"]["interactiveResolve"] = json!({ "inputTypes": ["string"] });
}
if server.name == "csharp-ls" {
params["capabilities"]["experimental"]["csharp"]["metadataUris"] = json!(true);
}
if !init_options.is_null() {
params["initializationOptions"] = init_options;
}
@@ -1092,7 +1095,15 @@ impl LspManager {
cancel: Option<&CancellationToken>,
timeout: Duration,
) -> IdeResult<R> {
let fut = t.run.peer.request::<P, R>(method, params, cancel.cloned());
let fut = async {
if t.server.name == "csharp-ls" {
let mut value = t.run.peer.request::<P, Value>(method, params, cancel.cloned()).await?;
virtual_doc::normalize_csharp_uris(&mut value);
serde_json::from_value(value).map_err(|e| RpcError::Protocol(format!("{method}: {e}")))
} else {
t.run.peer.request::<P, R>(method, params, cancel.cloned()).await
}
};
match tokio::time::timeout(timeout, fut).await {
Ok(r) => r.map_err(|e| rpc_err(&t.server.name, method, e)),
Err(_) => Err(rpc_err(&t.server.name, method, RpcError::Timeout)),
@@ -2630,7 +2641,14 @@ impl LspManager {
};
if let Some((entry, text)) = found {
let title = entry.rsplit('/').next().unwrap_or(&entry).to_owned();
return Ok(self.store_virtual(uri, text, Some(title), &[], self.origin_server(uri).map(|s| s.id)));
return Ok(self.store_virtual(
uri,
text,
Some(title),
None,
&[],
self.origin_server(uri).map(|s| s.id),
));
}
}
let class_file = scheme == "jdt" || (scheme == "file" && uri.ends_with(".class"));
@@ -2641,6 +2659,7 @@ impl LspManager {
let mut error = None;
for t in &targets {
let languages = t.server.languages();
let mut description = None;
let serves_scheme = t.caps().content_schemes().contains(&scheme);
let result: IdeResult<Option<String>> = if serves_scheme && !class_file {
self.call::<_, Option<Value>>(
@@ -2655,6 +2674,21 @@ impl LspManager {
} else if class_file && languages.iter().any(|l| l.as_str() == "java") {
self.call::<_, Option<String>>(t, "java/classFileContents", json!({ "uri": uri }), None, LONG_TIMEOUT)
.await
} else if scheme == "csharp" && t.server.name == "csharp-ls" {
self.call::<_, Option<virtual_doc::CSharpMetadata>>(
t,
"csharp/metadata",
json!({ "textDocument": { "uri": uri } }),
None,
LONG_TIMEOUT,
)
.await
.map(|metadata| {
metadata.map(|m| {
description = m.assembly_name.filter(|name| !name.is_empty());
m.source
})
})
} else if jar_class && t.caps().commands().iter().any(|c| c == "decompile") {
// The official Kotlin LSP: `decompile` answers `{ code, language }`.
self.call::<_, Option<Value>>(
@@ -2685,7 +2719,7 @@ impl LspManager {
continue;
}
};
return Ok(self.store_virtual(uri, text, None, &languages, Some(t.server.id)));
return Ok(self.store_virtual(uri, text, None, description, &languages, Some(t.server.id)));
}
if jar_class && error.is_none() {
return Err(IdeError::not_found(format!(
@@ -2702,10 +2736,15 @@ impl LspManager {
uri: &str,
text: String,
title: Option<String>,
description: Option<String>,
languages: &[LanguageId],
server: Option<ide_core::ServerId>,
) -> VirtualDocument {
let decompiled = virtual_doc::is_decompiled(&text);
let decompiled = if virtual_doc::scheme(uri) == "csharp" {
virtual_doc::is_csharp_decompiled(uri)
} else {
virtual_doc::is_decompiled(&text)
};
let title = title.unwrap_or_else(|| virtual_doc::title(uri, decompiled));
let detected = self.languages.detect(Path::new(&title), None);
let language_id = if detected == LanguageId::plain_text() {
@@ -2718,7 +2757,7 @@ impl LspManager {
text,
language_id,
title,
description: virtual_doc::description(uri),
description: description.or_else(|| virtual_doc::description(uri)),
decompiled,
};
if let Some(server) = server {
+5 -1
Переглянути файл
@@ -5,7 +5,7 @@
//! and shown together.
use crate::server::{Running, Server};
use crate::{LspManager, convert};
use crate::{LspManager, convert, virtual_doc};
use ide_core::{DocId, IdeEvent};
use ide_jsonrpc::RpcError;
use lsp_types as lsp;
@@ -43,6 +43,10 @@ impl LspManager {
let targets: Vec<PullTarget> = {
let docs = self.docs.lock();
let Some(d) = docs.get(&doc) else { return };
// Decompiled C# is a source view, not compilable project code (e.g. Unity's older language version).
if virtual_doc::is_csharp_decompiled(d.uri.as_str()) {
return;
}
d.servers
.iter()
.filter(|a| only.is_none_or(|s| s.id == a.server.id))
+104 -1
Переглянути файл
@@ -2,7 +2,68 @@
//! URI (library classes from `jdt://`, disk classes from `file://`, or
//! `workspace/textDocumentContent`).
use percent_encoding::percent_decode_str;
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
const URI_ESCAPES: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'<')
.add(b'>')
.add(b'[')
.add(b']')
.add(b'\\')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');
/// csharp-ls uses .NET Uri.ToString(), which can leave spaces and Unicode unescaped.
/// Normalize only document URI fields, before lsp-types' strict URI deserialization.
pub(crate) fn normalize_csharp_uris(value: &mut serde_json::Value) {
match value {
serde_json::Value::Array(values) => values.iter_mut().for_each(normalize_csharp_uris),
serde_json::Value::Object(fields) => {
for (key, value) in fields {
// Opaque payloads must survive resolve/execute round trips unchanged.
if matches!(key.as_str(), "data" | "arguments") {
continue;
}
if matches!(key.as_str(), "uri" | "targetUri")
&& let Some(uri) = value.as_str().filter(|s| s.starts_with("csharp:"))
{
*value = utf8_percent_encode(uri, URI_ESCAPES).to_string().into();
} else {
normalize_csharp_uris(value);
}
}
}
_ => {}
}
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CSharpMetadata {
pub source: String,
pub assembly_name: Option<String>,
}
/// csharp-ls serves both decompiled and source-generated documents through metadata.
pub(crate) fn is_csharp_decompiled(uri: &str) -> bool {
if scheme(uri) != "csharp" {
return false;
}
let segs = segments(uri);
// The kind follows the project file, not a directory that happens to have this name.
if let Some(kind) = segs.windows(2).find_map(|s| {
(s[0].ends_with(".csproj") && matches!(s[1].as_str(), "decompiled" | "generated")).then_some(s[1].as_str())
}) {
return kind == "decompiled";
}
// Older csharp-ls versions used csharp:/metadata/... for decompiled symbols.
segs.first().is_some_and(|s| s == "metadata")
}
/// Recognize decompiler headers, without treating disassembler stubs as Java source.
pub fn is_decompiled(text: &str) -> bool {
@@ -107,6 +168,48 @@ mod tests {
const JDT: &str = "jdt://contents/spring-boot-4.1.1.jar/org.springframework.boot/SpringApplication.class?=spring-demo/%5C/Users%5C/me%5C/.m2%5C/repository%5C/spring-boot-4.1.1.jar%3Corg.springframework.boot%28SpringApplication.class";
#[test]
fn csharp_metadata_titles_and_kinds() {
for (uri, expected) in [
("csharp:/D:/My%20Game/Game.csproj/decompiled/UnityEngine.MonoBehaviour.cs", true),
("csharp:/home/me/%E9%A1%B9%E7%9B%AE/App.csproj/decompiled/System.String.cs", true),
("csharp:/metadata/projects/App/assemblies/System.Runtime/symbols/System.String.cs", true),
("csharp:/work/App.csproj/generated/Generator/Player.g.cs", false),
("csharp:/work/decompiled/App.csproj/generated/Generator/Player.g.cs", false),
("csharp:/metadata/App.csproj/generated/Player.g.cs", false),
("csharp:/work/App.csproj/generated/decompiled/Player.g.cs", false),
("file:///work/App.csproj/decompiled/Player.cs", false),
] {
assert_eq!(is_csharp_decompiled(uri), expected, "{uri}");
}
assert_eq!(
title("csharp:/D:/My%20Game/Game.csproj/decompiled/UnityEngine.MonoBehaviour.cs", true),
"UnityEngine.MonoBehaviour.cs"
);
assert_eq!(title("csharp:/home/me/App.csproj/decompiled/Example.%E7%B1%BB.cs", true), "Example.类.cs");
}
#[test]
fn csharp_uri_encoding_preserves_escapes_and_other_response_fields() {
let uri = "csharp:/D:/My Game/项目 [1]/App%23%25.csproj/decompiled/Example.C%601.cs";
let encoded = "csharp:/D:/My%20Game/%E9%A1%B9%E7%9B%AE%20%5B1%5D/App%23%25.csproj/decompiled/Example.C%601.cs";
let mut response = serde_json::json!([
{ "uri": uri, "source": uri },
{ "location": { "targetUri": uri } },
{ "uri": encoded },
{ "uri": "file:///D:/project/App.cs" },
{ "uri": "jdt://contents/a.jar/p/C.class?=p/%5C/a.jar" },
{ "data": { "uri": uri }, "command": { "arguments": [{ "targetUri": uri }] } }
]);
let original = response.clone();
normalize_csharp_uris(&mut response);
assert_eq!(response[0]["uri"], encoded);
assert_eq!(response[0]["source"], uri);
assert_eq!(response[1]["location"]["targetUri"], encoded);
assert_eq!(response.as_array().unwrap()[2..], original.as_array().unwrap()[2..]);
assert!(encoded.parse::<lsp_types::Uri>().is_ok());
}
#[test]
fn jdt_titles() {
assert_eq!(title(JDT, true), "SpringApplication.class");
+100
Переглянути файл
@@ -2253,6 +2253,106 @@ async fn noop_handler_is_usable_for_raw_peers() {
// ------------------------------------------------------------------ virtual documents
#[tokio::test(flavor = "multi_thread")]
async fn csharp_metadata_navigation_and_generated_documents() {
let mut caps = incremental_caps();
caps["diagnosticProvider"] = json!({ "interFileDependencies": false, "workspaceDiagnostics": false });
let h = harness_for("csharp", "cs", &[("other-csharp", incremental_caps()), ("csharp-ls", caps)], &[]);
let (doc, path) = h.open("Player.cs", "class Player : MonoBehaviour {}\n");
h.wait_status("other-csharp", ServerStatus::Ready).await;
h.wait_status("csharp-ls", ServerStatus::Ready).await;
let s = h.launcher.state("csharp-ls");
let other = h.launcher.state("other-csharp");
eventually(|| !s.requested("textDocument/diagnostic").is_empty()).await;
assert_eq!(s.requested("initialize")[0]["capabilities"]["experimental"]["csharp"]["metadataUris"], true);
assert!(other.requested("initialize")[0]["capabilities"]["experimental"]["csharp"].is_null());
let uri = "csharp:/D:/My%20Game/Game.csproj/decompiled/UnityEngine.MonoBehaviour.cs";
let text = "namespace UnityEngine;\npublic class MonoBehaviour {\n public void CancelInvoke() {}\n}\n";
s.respond("csharp/metadata", json!({ "assemblyName": "UnityEngine.CoreModule", "source": text }));
// Both Location and LocationLink preserve the server's type/member selection.
for (response, range) in [
(
json!({ "uri": uri.replace("%20", " "), "range": {"start":{"line":1,"character":13},"end":{"line":1,"character":26}} }),
rng(1, 13, 1, 26),
),
(
json!([{ "targetUri": uri, "targetRange": {"start":{"line":0,"character":0},"end":{"line":3,"character":1}}, "targetSelectionRange": {"start":{"line":2,"character":16},"end":{"line":2,"character":28}} }]),
rng(2, 16, 2, 28),
),
] {
s.respond("textDocument/definition", response);
let defs = h.mgr.goto(doc, Pos::new(0, 16), GotoKind::Definition).await.unwrap();
assert_eq!(defs, vec![ide_core::Location { path: uri.into(), range, uri: Some(uri.into()) }]);
let vd = h.mgr.virtual_document(uri).await.unwrap();
assert_eq!(vd.text, text);
assert_eq!(vd.title, "UnityEngine.MonoBehaviour.cs");
assert_eq!(vd.language_id.as_str(), "csharp");
assert_eq!(vd.description.as_deref(), Some("UnityEngine.CoreModule"));
assert!(vd.decompiled);
}
assert_eq!(s.requested("csharp/metadata"), vec![json!({"textDocument":{"uri":uri}})]);
assert!(other.requested("csharp/metadata").is_empty());
let vdoc = DocId::next();
h.mgr.did_open_virtual(vdoc, uri, &h.lang, 0, text);
eventually(|| s.docs.lock().contains_key(uri)).await;
s.respond("textDocument/hover", json!({"contents":"class MonoBehaviour"}));
h.mgr.hover(vdoc, Pos::new(1, 13), Default::default()).await.unwrap();
assert_eq!(s.requested("textDocument/hover").last().unwrap()["textDocument"]["uri"], uri);
assert!(other.requested("textDocument/hover").is_empty());
h.mgr.did_close(vdoc);
eventually(|| !s.docs.lock().contains_key(uri)).await;
assert!(s.notified("textDocument/didChange").is_empty());
// Generated sources use the same request but are not decompiled, even under a directory named decompiled.
let generated = "csharp:/work/decompiled/Game.csproj/generated/Generator/Player.g.cs";
s.respond("csharp/metadata", json!({"source":"partial class Player {}"}));
let vd = h.mgr.virtual_document(generated).await.unwrap();
assert_eq!(vd.title, "Player.g.cs");
assert_eq!(vd.language_id.as_str(), "csharp");
assert!(vd.description.is_none());
assert!(!vd.decompiled);
// Generated and ordinary source still get diagnostics; decompiled assemblies do not.
let generated_doc = DocId::next();
h.mgr.did_open_virtual(generated_doc, generated, &vd.language_id, 0, &vd.text);
eventually(|| s.requested("textDocument/diagnostic").iter().any(|p| p["textDocument"]["uri"] == generated)).await;
assert!(s.requested("textDocument/diagnostic").iter().all(|p| p["textDocument"]["uri"] != uri));
h.mgr.did_close(generated_doc);
// Ordinary definitions still resolve to a disk path.
s.respond(
"textDocument/definition",
json!({"uri":uri_of(&path),"range":{"start":{"line":0,"character":6},"end":{"line":0,"character":12}}}),
);
let defs = h.mgr.goto(doc, Pos::new(0, 6), GotoKind::Definition).await.unwrap();
assert_eq!(defs[0].path, path);
assert!(defs[0].uri.is_none());
}
#[tokio::test(flavor = "multi_thread")]
async fn csharp_metadata_failures_can_be_retried() {
let h = harness_for("csharp", "cs", &[("csharp-ls", incremental_caps())], &[]);
h.open("App.cs", "class App {}\n");
h.wait_status("csharp-ls", ServerStatus::Ready).await;
let s = h.launcher.state("csharp-ls");
let uri = "csharp:/work/App.csproj/decompiled/System.String.cs";
for response in [Value::Null, json!({"source":""})] {
s.respond("csharp/metadata", response);
let err = h.mgr.virtual_document(uri).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::NotFound);
assert!(err.message.contains("System.String.cs"));
}
s.errors.lock().insert("csharp/metadata".into(), (-32603, "decompilation failed".into()));
assert!(h.mgr.virtual_document(uri).await.unwrap_err().message.contains("decompilation failed"));
s.errors.lock().remove("csharp/metadata");
s.respond("csharp/metadata", json!({"source":"public class String {}", "assemblyName":""}));
let vd = h.mgr.virtual_document(uri).await.unwrap();
assert!(vd.decompiled);
assert!(vd.description.is_none());
assert_eq!(s.requested("csharp/metadata").len(), 4);
}
#[tokio::test(flavor = "multi_thread")]
async fn virtual_locations_and_text_document_content() {
let mut caps = incremental_caps();
+1
Переглянути файл
@@ -126,6 +126,7 @@ const EXT_LANG: Record<string, string> = {
kt: "kotlin",
kts: "kotlin",
java: "java",
cs: "csharp",
py: "python",
gd: "gdscript",
go: "go",
+5 -1
Переглянути файл
@@ -43,7 +43,11 @@ export function VirtualBanner({
<div className={`${styles.banner} ${styles.libraryBanner}`} role="status">
<Icon name={decompiled ? "info" : "lock"} size={14} className={styles.bannerIcon} />
<span className={styles.virtualBannerText}>
{decompiled ? tx("Decompiled .class file: sources are not available.") : tx("Library source (read-only).")}
{decompiled
? path.startsWith("csharp:")
? tx("Decompiled assembly (read-only).")
: tx("Decompiled .class file: sources are not available.")
: tx("Library source (read-only).")}
{reason && ` ${tx(reason)}`}
</span>
{description && (
+11 -1
Переглянути файл
@@ -2,9 +2,19 @@ import { describe, expect, it } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { registerEditorBanner } from "@/workbench/contributions";
import { useDocMeta, type DocMeta } from "../model/document";
import { documentBanner, EditorBanners } from "./Banner";
import { documentBanner, EditorBanners, VirtualBanner } from "./Banner";
describe("editor banners", () => {
it.each([
["jdt://contents/lib.jar/p/Lib.class", true, "Decompiled .class file: sources are not available."],
["csharp:/p/App.csproj/decompiled/Lib.cs", true, "Decompiled assembly (read-only)."],
["csharp:/p/App.csproj/generated/Lib.g.cs", false, "Library source (read-only)."],
])("describes %s", (path, decompiled, text) => {
render(<VirtualBanner path={path} decompiled={decompiled} description="Example.Library" />);
expect(screen.getByRole("status").textContent).toContain(text);
expect(screen.getByTitle("Example.Library")).toBeTruthy();
});
it("shows the document notice through the banner extension point, before contributed ones", () => {
const off = [
registerEditorBanner(documentBanner),
+61 -4
Переглянути файл
@@ -4,12 +4,23 @@ import type { VirtualDocument } from "@/platform/ipc";
const JDT = "jdt://contents/lib-1.0.jar/org.example/Lib.class?=demo/%5C/m2%5C/lib-1.0.jar%3Corg.example%28Lib.class";
const TEXT =
"// Source code is unavailable, and was generated by the Fernflower decompiler.\npackage org.example;\n\npublic class Lib {\n void run() {}\n}\n";
const CSHARP = "csharp:/D:/My%20Game/Game.csproj/decompiled/UnityEngine.MonoBehaviour.cs";
const CSHARP_TEXT = "namespace UnityEngine;\npublic class MonoBehaviour {\n public void CancelInvoke() {}\n}\n";
vi.mock("@/platform/virtualDocs", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/platform/virtualDocs")>();
return {
...actual,
virtualDocument: vi.fn(async (uri: string): Promise<VirtualDocument> => {
if (uri === CSHARP)
return {
uri,
text: CSHARP_TEXT,
languageId: "csharp",
title: "UnityEngine.MonoBehaviour.cs",
description: "UnityEngine.CoreModule",
decompiled: true,
};
if (uri !== JDT && uri !== "file:///p/target/classes/Lib.class") throw new Error(`no contents for ${uri}`);
return {
uri,
@@ -128,10 +139,56 @@ describe("virtual editor input", () => {
}
});
it("does not open a tab when the server has no contents", async () => {
ctrl = new EditorController(createMemoryBackend());
it("navigates C# types and members in one read-only assembly tab", async () => {
const backend = createMemoryBackend({ "/p/Player.cs": "class Player {}\n", [CSHARP]: CSHARP_TEXT });
const save = vi.spyOn(backend, "save");
ctrl = new EditorController(backend);
ctrl.start();
await ctrl.openFile("jdt://contents/missing.jar/p/X.class", { focus: false });
expect(Object.values(useTabs.getState().panes).flatMap((p) => p.tabs)).toHaveLength(0);
await ctrl.openFile("/p/Player.cs", { focus: false });
await ctrl.openFile(CSHARP, { selection: { line: 1, col: 13, endLine: 1, endCol: 26 }, focus: false });
const tab = activeTab();
const slot = ctrl.slotFor(tab.id)!;
expect(tab.input).toMatchObject({
kind: "virtual",
path: CSHARP,
data: { title: "UnityEngine.MonoBehaviour.cs", description: "UnityEngine.CoreModule", decompiled: true },
});
expect(slot.model.ctx()).toMatchObject({ language: "csharp", readOnly: true, virtual: true });
const selected = () => {
const state = slot.currentState();
return state.sliceDoc(state.selection.main.from, state.selection.main.to);
};
expect(selected()).toBe("MonoBehaviour");
await ctrl.openFile(CSHARP, { selection: { line: 2, col: 16, endLine: 2, endCol: 28 }, focus: false });
expect(activeTab().id).toBe(tab.id);
expect(selected()).toBe("CancelInvoke");
expect(Object.values(useTabs.getState().panes).flatMap((p) => p.tabs)).toHaveLength(2);
const doc = [...backend.docs.values()].find((d) => d.path === CSHARP)!;
await expect(backend.edit(doc.id, doc.version, [{ from: 0, to: 0, insert: "x" }])).rejects.toMatchObject({
kind: "permissionDenied",
});
await slot.model.save("explicit");
expect(save).not.toHaveBeenCalled();
expect((await backend.snapshot(doc.id)).text).toBe(CSHARP_TEXT);
await ctrl.back();
expect(activeTab().input.path).toBe(CSHARP);
expect(slot.currentState().selection.main.head).toBe(slot.currentState().doc.line(2).from + 13);
await ctrl.back();
expect(activeTab().input.path).toBe("/p/Player.cs");
await ctrl.forward();
expect(activeTab().input.path).toBe(CSHARP);
await ctrl.forward();
expect(slot.currentState().selection.main.head).toBe(slot.currentState().doc.line(3).from + 16);
expect(JSON.stringify(serializeSession(useTabs.getState(), {}))).not.toContain("csharp:");
});
it.each(["jdt://contents/missing.jar/p/X.class", "csharp:/p/App.csproj/decompiled/Missing.cs"])(
"does not open a tab when the server has no contents for %s",
async (uri) => {
ctrl = new EditorController(createMemoryBackend());
ctrl.start();
await ctrl.openFile(uri, { focus: false });
expect(Object.values(useTabs.getState().panes).flatMap((p) => p.tabs)).toHaveLength(0);
},
);
});
+1
Переглянути файл
@@ -1,5 +1,6 @@
{
"Cannot copy reference": "Cannot copy reference",
"Decompiled assembly (read-only).": "Decompiled assembly (read-only).",
"Connect through:": "Connect through:",
"Connect through": "Connect through",
"GitHub Commit Checks": "GitHub Commit Checks",
+1
Переглянути файл
@@ -4715,6 +4715,7 @@
"Vim rc file": "Vim rc 文件",
"Breakpoints are not supported in output classes without source code.": "输出目录中无源码的类不支持断点。",
"Decompiled .class file: sources are not available.": "已反编译的 .class 文件:没有可用的源代码。",
"Decompiled assembly (read-only).": "已反编译的程序集(只读)。",
"No column of the file matches the table": "文件中没有与该表匹配的列",
"Not a URL of PostgreSQL, MySQL, SQLite or a compatible database": "不是 PostgreSQL、MySQL、SQLite 或兼容数据库的 URL",
"The Database tree shows only the checked names and patterns (* and ?); the connected database always shows.": "数据库树只显示选中的名称和模式(* 和 ?);已连接的数据库始终显示。",