дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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
Цей коміт міститься в:
1 джерело
7c72411dd7
коміт
482241ebb9
29 files changed
+427
-57
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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;"));
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
Reference in new issue
Заблокувати користувача