feat(java): open class files as decompiled sources with breakpoints (#657)

Opening a .class file showed binary content. Class files from library jars, project output directories, navigation, Go to Class and debugger frames now all go through one route to JDT class documents:

- Library classes open the attached source, or the FernFlower decompilation when there is none, read-only and marked as decompiled. java-debug's decompiled-source support is enabled, so breakpoints set in decompiled code map to the original lines and hit, and stepping into such classes lands on the right line.
- Output-directory classes (target/classes, build/classes) open their project source when it exists; otherwise the decompiled text is shown read-only with breakpoints disabled, because jdt.ls returns no line mapping for classes outside the Java model.
- The preferred content provider id is corrected to fernflowerContentProvider (attached sources still win), and the decompiled marker recognises the current FernFlower header.

Verified on macOS with a Maven project: library class from Libraries and Go to Class, breakpoint hit and step into decompiled code, output class with and without source. Not run on Windows, Linux or Gradle projects.

Fixes #394
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-01 15:04:28 +08:00
1 джерело 7c72411dd7
коміт 482241ebb9
29 files changed
+427 -57

No files matched your search

+12 -2
Переглянути файл
@@ -83,12 +83,18 @@ pub async fn lsp_implementations(
result
}
/// Contents of a virtual document (library class, decompiled code) that a
/// language server returned a non-`file:` URI for. Cached per project.
/// Contents of a read-only language-server document, including JDT library
/// classes and disk class files. Cached per project.
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
host.project(window)?.lsp.virtual_document(&uri).await
}
/// Opens compiled classes through JDT, with project source taking priority on disk.
pub async fn lsp_resolve_class_file(host: &Host, window: &str, path: std::path::PathBuf) -> IdeResult<String> {
let archive = ide_vfs::split_archive_path(&path);
host.project(window)?.lsp.java_class_file(&path, archive).await
}
/// References of the symbol at `pos`. `project_wide` also searches project files
/// outside the language server's analysis scope; bulk scans over many symbols
/// leave it off. Cancel with `cancel_request(request)`.
@@ -367,6 +373,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("lsp_implementations", |c| async move {
lsp_implementations(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("positions")?).await
});
d.add(
"lsp_resolve_class_file",
|c| async move { lsp_resolve_class_file(&c.host, &c.window, c.arg("path")?).await },
);
d.add("lsp_virtual_document", |c| async move { lsp_virtual_document(&c.host, &c.window, c.arg("uri")?).await });
d.add("lsp_references", |c| async move {
lsp_references(
+55 -1
Переглянути файл
@@ -532,6 +532,59 @@ impl LspManager {
collect()
}
/// Resolve a disk/archive class to project source or the server's binary document.
/// Archive URIs must come from JDT's Java model: fabricated handles cannot map debug lines.
pub async fn java_class_file(&self, path: &Path, archive: Option<(PathBuf, String)>) -> IdeResult<String> {
let cancel = CancellationToken::new();
self.jdtls(&self.project_root, &cancel).await?;
if let Some((jar, entry)) = archive {
let entry = entry
.strip_prefix("META-INF/versions/")
.and_then(|s| s.split_once('/').map(|(_, s)| s))
.unwrap_or(&entry);
let class = entry.strip_suffix(".class").ok_or_else(|| IdeError::not_found("expected a class file"))?;
let outer = class.split('$').next().unwrap_or(class);
let (package, name) = outer.rsplit_once('/').unwrap_or(("", outer));
let query = outer.replace('/', ".");
let symbols = self.workspace_symbols(&query, cancel).await?;
let package = package.replace('/', ".");
let name = name.to_owned();
let outer = outer.to_owned();
return tokio::task::spawn_blocking(move || {
symbols
.into_iter()
.filter_map(|s| s.location.uri)
.find(|uri| crate::virtual_doc::matches_class(uri, &jar, &package, &name))
.ok_or_else(|| {
IdeError::not_found(format!("jdtls could not resolve {} in {}", outer, jar.display()))
})
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
}
let uri = crate::uri::path_to_uri_string(path);
// classFileContents accepts file URIs, but java-debug's line mapping only accepts JDT handles.
let doc = self.virtual_document(&uri).await?;
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").split('$').next().unwrap_or("");
let package = doc
.text
.lines()
.find_map(|l| l.trim().strip_prefix("package ").and_then(|p| p.strip_suffix(';')))
.unwrap_or("");
let relative = PathBuf::from(package.replace('.', "/")).join(format!("{name}.java"));
let key = "org.eclipse.jdt.ls.core.sourcePaths";
let settings = self
.jdtls_project_command(path, "java.project.getSettings", vec![json!(uri), json!([key])], &cancel)
.await?;
let roots: Vec<PathBuf> =
settings[key].as_array().into_iter().flatten().filter_map(Value::as_str).map(PathBuf::from).collect();
let source =
tokio::task::spawn_blocking(move || roots.into_iter().map(|r| r.join(&relative)).find(|p| p.is_file()))
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
Ok(source.map(|s| s.to_string_lossy().into_owned()).unwrap_or(uri))
}
/// Starts (or reuses) java-debug's DAP server inside jdtls and returns its port.
pub async fn java_debug_server(&self, path: &Path) -> IdeResult<u16> {
let v = self.jdtls_command(path, "vscode.java.startDebugSession", Vec::new()).await.map_err(|e| {
@@ -542,7 +595,7 @@ impl LspManager {
}
})?;
// Hot code replace after each (auto-)build; errors only cost the feature.
let settings = json!({ "hotCodeReplace": "auto", "showStaticVariables": false, "showQualifiedNames": false,
let settings = json!({ "debugSupportOnDecompiledSource": "on", "hotCodeReplace": "auto", "showStaticVariables": false, "showQualifiedNames": false,
"showLogicalStructure": true, "showToString": true, "maxStringLength": 0,
// java-debug NPEs on a missing level (java.util.logging names).
"logLevel": "WARNING" });
@@ -574,6 +627,7 @@ mod tests {
assert_eq!(settings["java"]["project"]["sourcePaths"], json!(["src/main/java", "tests"]));
assert_eq!(settings["java"]["project"]["referencedLibraries"], json!(["lib/**/*.jar"]));
assert_eq!(settings["java"]["autobuild"]["enabled"], true);
assert_eq!(settings["java"]["contentProvider"]["preferred"], "fernflowerContentProvider");
assert!(settings.get("bundles").is_none());
assert!(settings.get("settings").is_none(), "didChangeConfiguration takes java settings directly");
}
+3 -2
Переглянути файл
@@ -2166,6 +2166,7 @@ impl LspManager {
return Ok(d.clone());
}
let scheme = virtual_doc::scheme(uri).to_owned();
let class_file = scheme == "jdt" || (scheme == "file" && uri.ends_with(".class"));
let origin = self.origin_server(uri);
let mut servers: Vec<Arc<Server>> = origin.iter().cloned().collect();
servers.extend(self.servers.lock().iter().filter(|s| Some(s.id) != origin.as_ref().map(|o| o.id)).cloned());
@@ -2174,7 +2175,7 @@ impl LspManager {
for t in &targets {
let languages = t.server.languages();
let serves_scheme = t.caps().content_schemes().contains(&scheme);
let result: IdeResult<Option<String>> = if serves_scheme {
let result: IdeResult<Option<String>> = if serves_scheme && !class_file {
self.call::<_, Option<Value>>(
t,
"workspace/textDocumentContent",
@@ -2184,7 +2185,7 @@ impl LspManager {
)
.await
.map(|v| v.and_then(|v| v.get("text").and_then(Value::as_str).map(str::to_owned)))
} else if scheme == "jdt" && languages.iter().any(|l| l.as_str() == "java") {
} 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 {
+1 -1
Переглянути файл
@@ -335,7 +335,7 @@ pub struct VirtualDocument {
pub title: String,
/// Origin shown in the tooltip / banner, e.g. the jar path.
pub description: Option<String>,
/// Generated by a decompiler / disassembler rather than real sources.
/// Generated Java from a decompiler, rather than real source or a disassembler stub.
pub decompiled: bool,
}
+77 -15
Переглянути файл
@@ -1,21 +1,29 @@
//! Virtual documents: read-only contents a language server provides for a
//! non-`file:` URI (library classes from `jdt://`, `workspace/textDocumentContent`).
//! URI (library classes from `jdt://`, disk classes from `file://`, or
//! `workspace/textDocumentContent`).
use percent_encoding::percent_decode_str;
/// Markers decompilers / disassemblers put at the top of generated code.
const GENERATED_MARKERS: [&str; 5] = [
"generated by the fernflower decompiler",
"failed to get sources",
"decompiled with cfr",
"decompiled with procyon",
"generated by the disassembler",
];
/// Whether `text` is decompiled / disassembled code rather than real source.
/// Recognize decompiler headers, without treating disassembler stubs as Java source.
pub fn is_decompiled(text: &str) -> bool {
let head: String = text.chars().take(600).collect::<String>().to_ascii_lowercase();
GENERATED_MARKERS.iter().any(|m| head.contains(m))
let head: String = text.chars().take(600).collect();
head.lines()
.take(12)
.take_while(|line| {
let line = line.trim();
line.is_empty() || line.starts_with("//") || line.starts_with("/*") || line.starts_with('*')
})
.any(|line| {
let line = line.trim().trim_start_matches('/').trim_start_matches('*').trim().to_ascii_lowercase();
[
"source code is unavailable, and was generated by the fernflower decompiler.",
"source code is decompiled from a .class file using fernflower decompiler",
"decompiled with cfr",
"decompiled with procyon",
]
.iter()
.any(|marker| line.starts_with(marker))
})
}
/// `scheme` of a URI (`jdt` for `jdt://…`).
@@ -34,7 +42,16 @@ fn segments(uri: &str) -> Vec<String> {
/// Editor tab title: the last path segment; a class file whose real source
/// was found is shown as `.java` (IDEA: `String.java` vs `String.class`).
pub fn title(uri: &str, decompiled: bool) -> String {
let name = segments(uri).pop().unwrap_or_else(|| uri.to_owned());
let mut name = segments(uri).pop().unwrap_or_else(|| uri.to_owned());
// Recent jdtls versions use a .java path even for decompiled class handles.
if decompiled
&& scheme(uri) == "jdt"
&& name.ends_with(".java")
&& uri.split_once('?').is_some_and(|(_, q)| percent_decode_str(q).decode_utf8_lossy().contains(".class"))
{
name.truncate(name.len() - 5);
name.push_str(".class");
}
match name.strip_suffix(".class") {
Some(stem) if !decompiled && scheme(uri) == "jdt" => format!("{stem}.java"),
_ => name,
@@ -68,6 +85,19 @@ pub fn description(uri: &str) -> Option<String> {
}
}
/// Check both the package and actual archive, so duplicate type names never pick another jar.
pub(crate) fn matches_class(uri: &str, jar: &std::path::Path, package: &str, name: &str) -> bool {
let segs = segments(uri);
scheme(uri) == "jdt"
&& (package.is_empty() && segs.len() == 3 || segs.get(2).is_some_and(|s| s == package))
&& segs.last().is_some_and(|s| s == &format!("{name}.class") || s == &format!("{name}.java"))
&& description(uri).is_some_and(|p| {
let path = std::path::Path::new(&p);
path == jar
|| dunce::canonicalize(path).ok().zip(dunce::canonicalize(jar).ok()).is_some_and(|(a, b)| a == b)
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -84,6 +114,30 @@ mod tests {
#[test]
fn jdt_description() {
assert!(matches_class(
JDT,
std::path::Path::new("/Users/me/.m2/repository/spring-boot-4.1.1.jar"),
"org.springframework.boot",
"SpringApplication"
));
assert!(!matches_class(
JDT,
std::path::Path::new("/elsewhere/spring-boot-4.1.1.jar"),
"org.springframework.boot",
"SpringApplication"
));
assert!(!matches_class(
JDT,
std::path::Path::new("/Users/me/.m2/repository/spring-boot-4.1.1.jar"),
"other.package",
"SpringApplication"
));
assert!(matches_class(
"jdt://contents/a.jar//C.java?=p/%5C/m2%5C/a.jar%3C%28C.class",
std::path::Path::new("/m2/a.jar"),
"",
"C"
));
assert_eq!(description(JDT).as_deref(), Some("/Users/me/.m2/repository/spring-boot-4.1.1.jar"));
assert_eq!(
description(
@@ -98,10 +152,18 @@ mod tests {
#[test]
fn detects_generated_code() {
assert!(is_decompiled(
"// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage a;"
));
assert!(!is_decompiled("package a;\n// generated by the fernflower decompiler"));
assert!(!is_decompiled("// Documentation about code generated by the Fernflower decompiler.\npackage a;"));
let recent = "jdt://contents/a.jar/p/C.java?=p/a.jar%3Cp%28C.class";
assert_eq!(title(recent, true), "C.class");
assert_eq!(title(recent, false), "C.java");
assert!(is_decompiled(
"// Source code is unavailable, and was generated by the Fernflower decompiler.\npackage a;"
));
assert!(is_decompiled(
assert!(!is_decompiled(
"// Failed to get sources. Instead, stub sources have been generated by the disassembler."
));
assert!(!is_decompiled("/*\n * Copyright 2012-2024 the original author or authors.\n */\npackage a;"));
+52
Переглянути файл
@@ -1617,6 +1617,58 @@ async fn jdt_class_file_contents() {
assert_eq!(vd.title, "Lib.class");
assert_eq!(vd.language_id.as_str(), "java");
assert_eq!(vd.description.as_deref(), Some("/m2/lib-1.0.jar"));
let source_uri = jdt.replace("Lib.class", "WithSources.class");
s.respond(
"java/classFileContents",
json!("/* Copyright library author */\npackage org.example;\npublic class WithSources {}"),
);
let source = h.mgr.virtual_document(&source_uri).await.unwrap();
assert!(!source.decompiled);
assert_eq!(source.title, "WithSources.java");
}
#[tokio::test(flavor = "multi_thread")]
async fn class_file_opening_routes_sources_and_binary_documents() {
let mut caps = incremental_caps();
caps["workspaceSymbolProvider"] = json!(true);
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
h.open("App.java", "class App {}\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let jdt = "jdt://contents/lib.jar/p/Outer.java?=demo/%5C/m2%5C/lib.jar%3Cp%28Outer.class";
s.respond("workspace/symbol", json!([
{"name":"Outer", "kind":5, "location":{"uri":jdt,"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":0}}}}
]));
let archive = Some((PathBuf::from("/m2/lib.jar"), "p/Outer$1.class".into()));
assert_eq!(h.mgr.java_class_file(Path::new("/m2/lib.jar!/p/Outer$1.class"), archive).await.unwrap(), jdt);
assert!(
h.mgr
.java_class_file(
Path::new("/other/lib.jar!/p/Outer.class"),
Some(("/other/lib.jar".into(), "p/Outer.class".into()))
)
.await
.is_err()
);
let sources = h.root.join("src/main/java");
std::fs::create_dir_all(sources.join("p")).unwrap();
let source = sources.join("p/Outer.java");
std::fs::write(&source, "package p; class Outer {} ").unwrap();
s.respond("java/classFileContents", json!("// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage p;\npublic class Outer {}"));
s.respond("workspace/executeCommand", json!({"org.eclipse.jdt.ls.core.sourcePaths":[sources]}));
for class in ["Outer", "Outer$Nested", "Outer$1"] {
let path = h.root.join(format!("target/classes/p/{class}.class"));
assert_eq!(h.mgr.java_class_file(&path, None).await.unwrap(), source.to_string_lossy());
}
let path = h.root.join("build/classes/java/main/p/Missing.class");
let uri = uri_of(&path);
assert_eq!(h.mgr.java_class_file(&path, None).await.unwrap(), uri);
let vd = h.mgr.virtual_document(&uri).await.unwrap();
assert!(vd.decompiled);
assert_eq!(vd.language_id.as_str(), "java");
assert_eq!(vd.title, "Missing.class");
assert!(s.requested("java/classFileContents").iter().any(|p| p["uri"] == uri));
}
#[tokio::test(flavor = "multi_thread")]
+4 -4
Переглянути файл
@@ -8,7 +8,7 @@
//! result is cached until the manifests (or the relevant settings) change.
//!
//! Archives are listed as browsable roots (`<archive>!`, see `ide_vfs`): the
//! JDK's `src.zip` and the Maven / Gradle dependency jars (sources jars preferred).
//! JDK's `src.zip` and the Maven / Gradle dependency jars (source attachments resolved by JDT).
use crate::exe;
use crate::exec::{query, query_in, which};
@@ -248,7 +248,7 @@ fn gradle_user_home() -> Option<PathBuf> {
}
/// One `Maven: g:a:v` / `Gradle: g:a:v` group per dependency whose jar is in a
/// local repository, its root being the jar (the `-sources.jar` when downloaded).
/// local repository, its root being the binary jar so Java classes open through JDT's source provider.
pub(crate) fn dependency_libraries(
deps: &[BuildDependency],
maven_repo: Option<&Path>,
@@ -286,7 +286,7 @@ pub(crate) fn maven_jar(repo: &Path, d: &BuildDependency) -> Option<PathBuf> {
return None;
}
let dir = repo.join(d.group.replace('.', "/")).join(&d.artifact).join(&d.version);
["-sources.jar", ".jar"]
[".jar", "-sources.jar"]
.iter()
.map(|suffix| dir.join(format!("{}-{}{suffix}", d.artifact, d.version)))
.find(|p| p.is_file())
@@ -300,7 +300,7 @@ pub(crate) fn gradle_jar(home: &Path, d: &BuildDependency) -> Option<PathBuf> {
let dir = home.join("caches/modules-2/files-2.1").join(&d.group).join(&d.artifact).join(&d.version);
let mut hashes: Vec<PathBuf> = std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).collect();
hashes.sort();
["-sources.jar", ".jar"].iter().find_map(|suffix| {
[".jar", "-sources.jar"].iter().find_map(|suffix| {
let file = format!("{}-{}{suffix}", d.artifact, d.version);
hashes.iter().map(|h| h.join(&file)).find(|p| p.is_file())
})
+8 -8
Переглянути файл
@@ -540,12 +540,12 @@ fn build_dependencies_resolve_to_local_jars() {
std::fs::write(&p, "").unwrap();
p
};
touch(m2.join("com/google/guava/guava/33.0/guava-33.0.jar"));
let guava_src = touch(m2.join("com/google/guava/guava/33.0/guava-33.0-sources.jar"));
let guava = touch(m2.join("com/google/guava/guava/33.0/guava-33.0.jar"));
touch(m2.join("com/google/guava/guava/33.0/guava-33.0-sources.jar"));
let junit = touch(m2.join("junit/junit/4.13/junit-4.13.jar"));
let files = gradle.join("caches/modules-2/files-2.1/org.slf4j/slf4j-api/2.0.9");
touch(files.join("a1b2/slf4j-api-2.0.9.jar"));
let slf4j_src = touch(files.join("c3d4/slf4j-api-2.0.9-sources.jar"));
let slf4j = touch(files.join("a1b2/slf4j-api-2.0.9.jar"));
touch(files.join("c3d4/slf4j-api-2.0.9-sources.jar"));
let dep = |gradle: bool, g: &str, a: &str, v: &str| BuildDependency {
gradle,
group: g.into(),
@@ -566,14 +566,14 @@ fn build_dependencies_resolve_to_local_jars() {
assert_eq!(
shown,
[
("Maven: com.google.guava:guava:33.0", "maven", "guava-33.0-sources.jar"),
("Maven: com.google.guava:guava:33.0", "maven", "guava-33.0.jar"),
("Maven: junit:junit:4.13", "maven", "junit-4.13.jar"),
("Gradle: org.slf4j:slf4j-api:2.0.9", "gradle", "slf4j-api-2.0.9-sources.jar"),
("Gradle: org.slf4j:slf4j-api:2.0.9", "gradle", "slf4j-api-2.0.9.jar"),
]
);
assert_eq!(groups[0].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&guava_src)));
assert_eq!(groups[0].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&guava)));
assert_eq!(groups[1].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&junit)));
assert_eq!(groups[2].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&slf4j_src)));
assert_eq!(groups[2].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&slf4j)));
// A Gradle build also finds jars installed in the Maven local repository (`mavenLocal()`).
let groups = dependency_libraries(&[dep(true, "junit", "junit", "4.13")], Some(&m2), Some(&gradle));
assert_eq!(groups[0].name, "Gradle: junit:junit:4.13");
+1 -1
Переглянути файл
@@ -32,7 +32,7 @@ resolveAdditionalTextEditsSupport = true
[language-servers.initialization-options.settings.java]
autobuild = { enabled = true }
contentProvider = { preferred = "fernflower" }
contentProvider = { preferred = "fernflowerContentProvider" }
maven = { downloadSources = true, updateSnapshots = false }
eclipse = { downloadSources = true }
references = { includeDecompiledSources = true }
+12 -2
Переглянути файл
@@ -95,8 +95,8 @@ pub async fn lsp_implementations(
ide_host::ops::lsp::lsp_implementations(&state.host, window.label(), request, doc, positions).await
}
/// Contents of a virtual document (library class, decompiled code) that a
/// language server returned a non-`file:` URI for. Cached per project.
/// Contents of a read-only language-server document, including JDT library
/// classes and disk class files. Cached per project.
#[tauri::command]
#[specta::specta]
pub async fn lsp_virtual_document(
@@ -107,6 +107,16 @@ pub async fn lsp_virtual_document(
ide_host::ops::lsp::lsp_virtual_document(&state.host, window.label(), uri).await
}
#[tauri::command]
#[specta::specta]
pub async fn lsp_resolve_class_file(
window: Window,
state: State<'_, AppState>,
path: std::path::PathBuf,
) -> IdeResult<String> {
ide_host::ops::lsp::lsp_resolve_class_file(&state.host, window.label(), path).await
}
/// References of the symbol at `pos`. `project_wide` also searches project files
/// outside the language server's analysis scope; bulk scans over many symbols
/// leave it off. Cancel with `cancel_request(request)`.
+1
Переглянути файл
@@ -201,6 +201,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
lsp::lsp_goto,
lsp::lsp_implementations,
lsp::lsp_virtual_document,
lsp::lsp_resolve_class_file,
lsp::lsp_references,
lsp::lsp_document_highlights,
lsp::lsp_workspace_symbols,
+4 -3
Переглянути файл
@@ -372,10 +372,11 @@ export const commands = {
*/
lspImplementations: (request: RequestId, doc: DocId, positions: Pos[]) => typedError<Location[][], IdeError>(__TAURI_INVOKE("lsp_implementations", { request, doc, positions })),
/**
* Contents of a virtual document (library class, decompiled code) that a
* language server returned a non-`file:` URI for. Cached per project.
* Contents of a read-only language-server document, including JDT library
* classes and disk class files. Cached per project.
*/
lspVirtualDocument: (uri: string) => typedError<VirtualDocument, IdeError>(__TAURI_INVOKE("lsp_virtual_document", { uri })),
lspResolveClassFile: (path: string) => typedError<string, IdeError>(__TAURI_INVOKE("lsp_resolve_class_file", { path })),
/**
* References of the symbol at `pos`. `project_wide` also searches project files
* outside the language server's analysis scope; bulk scans over many symbols
@@ -6310,7 +6311,7 @@ export type VirtualDocument = {
title: string,
/** Origin shown in the tooltip / banner, e.g. the jar path. */
description: string | null,
/** Generated by a decompiler / disassembler rather than real sources. */
/** Generated Java from a decompiler, rather than real source or a disassembler stub. */
decompiled: boolean,
};
+1 -1
Переглянути файл
@@ -42,7 +42,7 @@ export interface EditorDocContext {
/**
* A read-only virtual document (library class contents from a language
* server); `path` is then its URI, not a file: skip file-based features
* (VCS, local history, TODO scanning, breakpoints…).
* (VCS, local history, TODO scanning…). JDT class documents support breakpoints.
*/
virtual?: boolean;
}
+1 -1
Переглянути файл
@@ -36,7 +36,7 @@ export interface DocPatchedEvent {
export interface DocBackend {
open(path: string): Promise<DocumentInfo>;
/** Opens the read-only virtual document behind a non-`file:` URI (library class contents). */
/** Opens a read-only language-server document (library or disk class contents). */
openVirtual(uri: string): Promise<DocumentInfo>;
close(doc: DocId): Promise<void>;
edit(doc: DocId, baseVersion: number, changes: TextChange[]): Promise<number>;
+15 -1
Переглянути файл
@@ -89,6 +89,7 @@ import { NavigationHistory } from "./model/navHistory";
import { EditHistory } from "./model/editHistory";
import { loadSession, restoreTabsState, serializeSession, storeSession, type FileViewState } from "./model/session";
import { docBackend, type DocBackend } from "./backend";
import { isClassFile } from "@/platform/classFiles";
import { isVirtualUri, virtualDocument } from "@/platform/virtualDocs";
import type { VirtualDocument } from "@/platform/ipc";
import type { Scheduler } from "./sync/DocSync";
@@ -860,9 +861,21 @@ export class EditorController implements SlotHost {
throw new Error("The project is closing");
}
async openFile(path: string, o: OpenOptions = {}) {
async openFile(path: string, o: OpenOptions = {}): Promise<void> {
const generation = this.openingGeneration();
if (isVirtualUri(path)) return this.openVirtual(path, o);
if (isClassFile(path)) {
await this.projectTransition;
this.checkOpening(generation);
try {
const target = await call(commands.lspResolveClassFile(path));
this.checkOpening(generation);
return isVirtualUri(target) ? this.openVirtual(target, o) : this.openFile(target, o);
} catch (e) {
notifications().notify({ level: "warning", title: "Cannot open library class", body: String(e), group: "lsp" });
return;
}
}
await this.projectTransition;
this.checkOpening(generation);
// Images, binary and huge files open in a contributed viewer unless already open as text.
@@ -901,6 +914,7 @@ export class EditorController implements SlotHost {
}
async openInput(input: EditorInput, o: OpenOptions = {}) {
if (input.path && isClassFile(input.path)) return this.openFile(input.path, o);
return this.admitInput(input, o, this.openingGeneration());
}
+13 -2
Переглянути файл
@@ -3,6 +3,7 @@ import { Icon, T } from "@/ui";
import { useEditorBanners, type EditorBannerContribution } from "@/workbench/contributions";
import { useDocMeta } from "../model/document";
import styles from "./EditorArea.module.css";
import { classFileBreakpointReason } from "@/platform/classFiles";
import { tx } from "@/platform/i18n";
const TEXT = {
@@ -28,12 +29,22 @@ export const documentBanner: EditorBannerContribution = {
* Notice above a library class opened from a language server (IDEA's
* "Decompiled .class file" / library source bar): read-only, with its origin.
*/
export function VirtualBanner({ decompiled, description }: { decompiled: boolean; description: string | null }) {
export function VirtualBanner({
decompiled,
description,
path,
}: {
decompiled: boolean;
description: string | null;
path: string;
}) {
const reason = classFileBreakpointReason(path);
return (
<div className={`${styles.banner} ${styles.libraryBanner}`} role="status">
<Icon name={decompiled ? "info" : "lock"} size={14} className={styles.bannerIcon} />
<span className={styles.bannerText}>
<span className={styles.virtualBannerText}>
{decompiled ? tx("Decompiled .class file: sources are not available.") : tx("Library source (read-only).")}
{reason && ` ${tx(reason)}`}
</span>
{description && (
<span className={styles.bannerDetail} title={description}>
+4
Переглянути файл
@@ -90,6 +90,10 @@
background: var(--bg-feedback-warning);
border-bottom-color: var(--border-feedback-warning);
}
.virtualBannerText {
min-width: 0;
padding: 8px 0;
}
.bannerText {
flex: none;
white-space: nowrap;
+1 -1
Переглянути файл
@@ -398,7 +398,7 @@ function PaneContent({ paneId, tab, tabsVisible }: { paneId: string; tab: Tab; t
return (
<>
{virtual ? (
<VirtualBanner decompiled={virtual.decompiled} description={virtual.description} />
<VirtualBanner path={tab.input.path!} decompiled={virtual.decompiled} description={virtual.description} />
) : (
<EditorBanners path={tab.input.path} />
)}
+20 -1
Переглянути файл
@@ -10,7 +10,7 @@ vi.mock("@/platform/virtualDocs", async (importOriginal) => {
return {
...actual,
virtualDocument: vi.fn(async (uri: string): Promise<VirtualDocument> => {
if (uri !== JDT) throw new Error(`no contents for ${uri}`);
if (uri !== JDT && uri !== "file:///p/target/classes/Lib.class") throw new Error(`no contents for ${uri}`);
return {
uri,
text: TEXT,
@@ -30,6 +30,7 @@ const { serializeSession } = await import("./model/session");
const { useDocMeta } = await import("./model/document");
const { registerMenuGroup } = await import("@/workbench/contributions");
const { registerActions } = await import("@/platform/actions");
const { commands } = await import("@/platform/ipc");
const { resolveMenuItems } = await import("@/ui");
let ctrl: InstanceType<typeof EditorController> | null = null;
@@ -55,6 +56,24 @@ describe("virtual editor input", () => {
expect(isTextInput({ kind: "diff", key: "d" })).toBe(false);
});
it.each([
["/m2/lib.jar!/org/example/Lib.class", JDT, "virtual"],
["/p/target/classes/Lib.class", "/p/Lib.java", "file"],
["/p/build/classes/Lib.class", "file:///p/target/classes/Lib.class", "virtual"],
])("routes %s before choosing a binary viewer", async (path, target, kind) => {
const resolve = vi.spyOn(commands, "lspResolveClassFile").mockResolvedValue({ status: "ok", data: target });
ctrl = new EditorController(createMemoryBackend({ [target]: TEXT }));
ctrl.start();
try {
await ctrl.openFile(path!, { focus: false });
expect(resolve).toHaveBeenCalledWith(path);
expect(activeTab().input).toMatchObject({ path: target, kind });
expect(ctrl.slotFor(activeTab().id)!.model.ctx().readOnly).toBe(kind === "virtual");
} finally {
resolve.mockRestore();
}
});
it("opens a URI read-only at the target and keeps it out of the saved session", async () => {
ctrl = new EditorController(createMemoryBackend({ "/p/App.java": "class App {}\n", [JDT]: TEXT }));
ctrl.start();
+20
Переглянути файл
@@ -268,3 +268,23 @@ describe("debugger actions (IDEA parity)", () => {
expect(useOverlays.getState().evaluate?.expression).toBe("total = order.items\nfor i in x:");
});
});
describe("output class breakpoint restrictions", () => {
it.each([
"debug.toggleBreakpoint",
"debug.toggleTemporaryBreakpoint",
"debug.toggleMethodBreakpoint",
"debug.toggleFieldWatchpoint",
"debug.runToCursor",
"debug.forceRunToCursor",
])("blocks %s for a source-less output class", async (id) => {
paused();
const path = "file:///p/target/classes/p/C.class";
caret(path, "java", "class C {\n int n;\n void run() {}\n}", 2);
await executeAction(id);
expect(breakpointsOf(path)).toHaveLength(0);
expect(useSpecialBreakpoints.getState().methods).toHaveLength(0);
expect(useSpecialBreakpoints.getState().fields).toHaveLength(0);
expect(ipc.debugRunTo).not.toHaveBeenCalled();
});
});
+26
Переглянути файл
@@ -53,6 +53,32 @@ describe("breakpoint gutter", () => {
});
});
describe("output class gutter", () => {
it("disables ordinary, temporary and logging breakpoints and explains why", () => {
loadBreakpoints("/proj");
const path = "file:///proj/target/classes/C.class";
const view = new EditorView({
state: EditorState.create({
doc: "class C {}",
extensions: [
lineNumbers(),
breakpointGutter({ ...ctx, path, language: "java", readOnly: true, virtual: true }),
],
}),
parent: document.body,
});
views.push(view);
const cell = view.dom.querySelector(".cm-lineNumbers .cm-gutterElement")!;
cell.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
expect(cell.closest(".cm-gutters")?.getAttribute("title")).toMatch(/output classes without source code/);
for (const modifier of [{}, { altKey: true }, { shiftKey: true }]) {
cell.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, ...modifier }));
cell.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0, ...modifier }));
}
expect(breakpointsOf(path)).toHaveLength(0);
});
});
describe("breakpoint look", () => {
it("shows the verified icon only while a session has bound it", () => {
const b = { id: "1", line: 0, enabled: true, condition: null, logMessage: null, hitCondition: null };
+17 -7
Переглянути файл
@@ -5,6 +5,7 @@ import { downloadJdkActions } from "@/platform/jdk";
import { useProject } from "@/platform/project";
import { activeEditor } from "@/editor/api";
import { maybeEditorService, notifications, showStatusInfo, toolWindowService } from "@/workbench/contributions";
import { classFileBreakpointReason } from "@/platform/classFiles";
import { canPlaceBreakpoint } from "./breakpoints/languages";
import {
removeAllBreakpoints,
@@ -75,10 +76,18 @@ function pausedTarget(): { s: DebugSession; thread: number } | null {
return thread == null ? null : { s, thread };
}
function breakpointPathAllowed(path: string): boolean {
const reason = classFileBreakpointReason(path);
if (!reason) return true;
showStatusInfo(tx(reason));
return false;
}
/** Run to Cursor at `line` (0-based) of `path` in the paused session. */
export function runToLine(path: string, line: number) {
const t = pausedTarget();
if (t) void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, path, line, false)));
if (t && breakpointPathAllowed(path))
void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, path, line, false)));
}
function notSupported(what: string, s: DebugSession) {
@@ -518,7 +527,7 @@ export function registerDebugActions(): () => void {
run: () => {
const t = pausedTarget();
const c = cursor();
if (t && c)
if (t && c && breakpointPathAllowed(c.path))
void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, false)));
},
},
@@ -532,7 +541,7 @@ export function registerDebugActions(): () => void {
run: () => {
const t = pausedTarget();
const c = cursor();
if (t && c)
if (t && c && breakpointPathAllowed(c.path))
void report("Force Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, true)));
},
},
@@ -543,7 +552,7 @@ export function registerDebugActions(): () => void {
icon: "breakpoint",
run: () => {
const c = cursor();
if (!c) return;
if (!c || !breakpointPathAllowed(c.path)) return;
const exists = useBreakpoints.getState().byFile[c.path]?.some((b) => b.line === c.line);
const language = activeEditor()?.ctx.language ?? "";
if (!exists && !canPlaceBreakpoint(language)) {
@@ -568,7 +577,8 @@ export function registerDebugActions(): () => void {
description: "A line breakpoint that is removed after it is hit",
run: () => {
const c = cursor();
if (c && canPlaceBreakpoint(activeEditor()?.ctx.language ?? "")) toggleTemporaryBreakpoint(c.path, c.line);
if (c && breakpointPathAllowed(c.path) && canPlaceBreakpoint(activeEditor()?.ctx.language ?? "", c.path))
toggleTemporaryBreakpoint(c.path, c.line);
},
},
{
@@ -579,7 +589,7 @@ export function registerDebugActions(): () => void {
run: () => {
const ed = activeEditor();
const c = cursor();
if (!ed || !c) return;
if (!ed || !c || !breakpointPathAllowed(c.path)) return;
const existing = specialAt(c.path).find((b) => b.kind === "method" && b.line === c.line);
const method = methodAt(ed.ctx.language, ed.view.state.doc.toString(), c.line);
if (!existing && !method) {
@@ -602,7 +612,7 @@ export function registerDebugActions(): () => void {
run: () => {
const ed = activeEditor();
const c = cursor();
if (!ed || !c) return;
if (!ed || !c || !breakpointPathAllowed(c.path)) return;
const existing = specialAt(c.path).find((b) => b.kind === "field" && b.line === c.line);
const field = fieldAt(ed.ctx.language, ed.view.state.doc.toString(), c.line);
if (!existing && !field) {
+3 -1
Переглянути файл
@@ -3,6 +3,7 @@
* when off, new line breakpoints go only into files of languages that have a
* debug adapter (`languages/*.toml` `[debugger]`).
*/
import { classFileBreakpointReason } from "@/platform/classFiles";
import { commands, isTauri } from "@/platform/ipc";
import { workflowSettings } from "@/platform/workflowSettings";
@@ -22,7 +23,8 @@ function load() {
}
/** Whether a new breakpoint may be placed in a file of `language`. */
export function canPlaceBreakpoint(language: string): boolean {
export function canPlaceBreakpoint(language: string, path = ""): boolean {
if (classFileBreakpointReason(path)) return false;
if (workflowSettings().debugger.breakpointsAnyFile) return true;
load();
// Until the language list is known, don't block anything.
+28
Переглянути файл
@@ -15,6 +15,9 @@ import {
updateBreakpoint,
useBreakpoints,
} from "../breakpoints/store";
import { classFileBreakpointReason } from "@/platform/classFiles";
import { tx } from "@/platform/i18n";
import { showStatusInfo } from "@/workbench/contributions";
import { canPlaceBreakpoint } from "../breakpoints/languages";
import {
removeSpecial,
@@ -129,6 +132,31 @@ function inBreakpointArea(e: MouseEvent): boolean {
}
export function breakpointGutter(ctx: EditorDocContext): Extension {
const reason = classFileBreakpointReason(ctx.path);
if (reason) {
const message = tx(reason);
return ViewPlugin.fromClass(
class {
constructor(readonly view: EditorView) {
view.dom.addEventListener("mouseover", this.hover);
view.dom.addEventListener("mousedown", this.down, true);
}
hover = (e: MouseEvent) => {
if (inBreakpointArea(e)) (e.target as HTMLElement).closest(".cm-gutters")?.setAttribute("title", message);
};
down = (e: MouseEvent) => {
if (!inBreakpointArea(e)) return;
showStatusInfo(message);
};
destroy() {
this.view.dom.removeEventListener("mouseover", this.hover);
this.view.dom.removeEventListener("mousedown", this.down, true);
const gutters = this.view.dom.querySelector(".cm-gutters");
if (gutters?.getAttribute("title") === message) gutters.removeAttribute("title");
}
},
);
}
const path = ctx.path;
const field = StateField.define<RangeSet<BreakpointMarker>>({
create: (state) => buildMarkers(state, path),
+2 -1
Переглянути файл
@@ -3,6 +3,7 @@
* line shows a small button left of its text that runs to that line (Resume on
* the execution line itself). Settings › Debugger › "Show inlay Run to Cursor popup".
*/
import { classFileBreakpointReason } from "@/platform/classFiles";
import type { Extension } from "@codemirror/state";
import { EditorView, ViewPlugin } from "@codemirror/view";
import type { EditorDocContext } from "@/editor/api";
@@ -30,7 +31,7 @@ export function hoveredLine(
}
export function runToCursorInlay(ctx: EditorDocContext): Extension {
if (ctx.virtual) return [];
if (classFileBreakpointReason(ctx.path) || (ctx.virtual && !ctx.path.startsWith("jdt:"))) return [];
const path = ctx.path;
return ViewPlugin.fromClass(
class {
+34
Переглянути файл
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from "vitest";
import { classFileBreakpointReason, isClassFile } from "./classFiles";
import { isVirtualUri } from "./virtualDocs";
import { canPlaceBreakpoint } from "@/features/debugger/breakpoints/languages";
vi.mock("@/platform/workflowSettings", () => ({
workflowSettings: () => ({ debugger: { breakpointsAnyFile: true } }),
}));
describe("compiled class routing and breakpoint policy", () => {
it.each([
"/p/target/classes/p/C.class",
"/p/build/classes/java/main/p/C$1.class",
"C:\\p\\out\\C.class",
"file:///p/out/C.class",
])("disables output breakpoints even with any-file enabled: %s", (path) => {
expect(isClassFile(path)).toBe(true);
expect(classFileBreakpointReason(path)).toBeTruthy();
expect(canPlaceBreakpoint("java", path)).toBe(false);
});
it.each([
"jdt://contents/a.jar/p/C.class?=p/a.jar%3Cp%28C.class",
"jdt://contents/a.jar/p/C.java?=p/a.jar%3Cp%28C.class",
"/p/src/main/java/p/C.java",
])("keeps source and JDT breakpoints enabled: %s", (path) => {
expect(isClassFile(path)).toBe(false);
expect(classFileBreakpointReason(path)).toBeNull();
expect(canPlaceBreakpoint("java", path)).toBe(true);
});
it("treats disk class URIs as read-only server documents", () => {
expect(isVirtualUri("file:///p/out/C.class")).toBe(true);
expect(isVirtualUri("file:///p/src/C.java")).toBe(false);
expect(isClassFile("/m2/a.jar!/p/C.class")).toBe(true);
});
});
+8
Переглянути файл
@@ -0,0 +1,8 @@
/** Compiled Java files are resolved before the binary viewer, at every opening entry. */
export function isClassFile(path: string): boolean {
return !path.startsWith("jdt:") && /\.class$/i.test(path);
}
export function classFileBreakpointReason(path: string): string | null {
return isClassFile(path) ? "Breakpoints are not supported in output classes without source code." : null;
}
+1
Переглянути файл
@@ -4467,6 +4467,7 @@
"Plan node properties": "计划节点属性",
"Values": "值",
"Vim rc file": "Vim rc 文件",
"Breakpoints are not supported in output classes without source code.": "输出目录中无源码的类不支持断点。",
"Decompiled .class file: sources are not available.": "已反编译的 .class 文件:没有可用的源代码。",
"No column of the file matches the table": "文件中没有与该表匹配的列",
"Not a URL of PostgreSQL, MySQL, SQLite or a compatible database": "不是 PostgreSQL、MySQL、SQLite 或兼容数据库的 URL",
+3 -2
Переглянути файл
@@ -4,12 +4,13 @@
* code). Locations into them carry the URI as their `path`; the editor opens
* them as `virtual` editor inputs instead of files.
*/
import { isClassFile } from "./classFiles";
import { call, commands, type VirtualDocument } from "./ipc";
/** True for `scheme:…` URIs other than `file:` (scheme of 2+ chars, so `C:\…` is a path). */
/** Server document URIs, including decompiled disk classes; drive letters remain paths. */
export function isVirtualUri(s: string | null | undefined): boolean {
const m = /^([a-zA-Z][a-zA-Z0-9+.-]+):/.exec(s ?? "");
return !!m && m[1]!.toLowerCase() !== "file";
return !!m && (m[1]!.toLowerCase() !== "file" || isClassFile(s!));
}
/** Last path segment of a URI (without query / fragment), e.g. `SpringApplication.class`. */