diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index 953f878a7..0c85d1065 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -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 { 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 { + 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( diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index 6b47e143c..e66cafe63 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -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 { + 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 = + 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 { 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"); } diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 6d44afeb9..7091c69a3 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -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> = 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> = if serves_scheme { + let result: IdeResult> = if serves_scheme && !class_file { self.call::<_, Option>( 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>(t, "java/classFileContents", json!({ "uri": uri }), None, LONG_TIMEOUT) .await } else { diff --git a/crates/ide-lsp/src/types.rs b/crates/ide-lsp/src/types.rs index 45be5d0c3..beaee53d1 100644 --- a/crates/ide-lsp/src/types.rs +++ b/crates/ide-lsp/src/types.rs @@ -335,7 +335,7 @@ pub struct VirtualDocument { pub title: String, /// Origin shown in the tooltip / banner, e.g. the jar path. pub description: Option, - /// 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, } diff --git a/crates/ide-lsp/src/virtual_doc.rs b/crates/ide-lsp/src/virtual_doc.rs index 71394d7a1..110b206fd 100644 --- a/crates/ide-lsp/src/virtual_doc.rs +++ b/crates/ide-lsp/src/virtual_doc.rs @@ -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::().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 { /// 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 { } } +/// 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;")); diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index e909af815..2cd2b4f9a 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -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")] diff --git a/crates/ide-sdk/src/libraries.rs b/crates/ide-sdk/src/libraries.rs index 4c50b2f55..bdc450b7e 100644 --- a/crates/ide-sdk/src/libraries.rs +++ b/crates/ide-sdk/src/libraries.rs @@ -8,7 +8,7 @@ //! result is cached until the manifests (or the relevant settings) change. //! //! Archives are listed as browsable roots (`!`, 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 { } /// 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 { 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 { let dir = home.join("caches/modules-2/files-2.1").join(&d.group).join(&d.artifact).join(&d.version); let mut hashes: Vec = 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()) }) diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 8ce5a774b..37adca8be 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -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"); diff --git a/languages/java.toml b/languages/java.toml index 7e2e87c7f..7e7837b08 100644 --- a/languages/java.toml +++ b/languages/java.toml @@ -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 } diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index c311cb8eb..765d7e755 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -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 { + 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)`. diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 5111d421b..ab121db65 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -201,6 +201,7 @@ pub fn builder() -> tauri_specta::Builder { 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, diff --git a/src/bindings.ts b/src/bindings.ts index 20ee0eba2..79eba7f80 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -372,10 +372,11 @@ export const commands = { */ lspImplementations: (request: RequestId, doc: DocId, positions: Pos[]) => typedError(__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(__TAURI_INVOKE("lsp_virtual_document", { uri })), + lspResolveClassFile: (path: string) => typedError(__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, }; diff --git a/src/editor/api.ts b/src/editor/api.ts index 8ce6db51b..9f2ab9a0f 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -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; } diff --git a/src/editor/core/backend.ts b/src/editor/core/backend.ts index 5c2250168..8e4b020cf 100644 --- a/src/editor/core/backend.ts +++ b/src/editor/core/backend.ts @@ -36,7 +36,7 @@ export interface DocPatchedEvent { export interface DocBackend { open(path: string): Promise; - /** 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; close(doc: DocId): Promise; edit(doc: DocId, baseVersion: number, changes: TextChange[]): Promise; diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index be343fbf4..90e12af6d 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -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 { 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()); } diff --git a/src/editor/core/view/Banner.tsx b/src/editor/core/view/Banner.tsx index 0023ee02a..963665f22 100644 --- a/src/editor/core/view/Banner.tsx +++ b/src/editor/core/view/Banner.tsx @@ -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 (
- + {decompiled ? tx("Decompiled .class file: sources are not available.") : tx("Library source (read-only).")} + {reason && ` ${tx(reason)}`} {description && ( diff --git a/src/editor/core/view/EditorArea.module.css b/src/editor/core/view/EditorArea.module.css index 40fbc0dd1..593cd1ac4 100644 --- a/src/editor/core/view/EditorArea.module.css +++ b/src/editor/core/view/EditorArea.module.css @@ -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; diff --git a/src/editor/core/view/EditorArea.tsx b/src/editor/core/view/EditorArea.tsx index bd4b709a3..cc172aa56 100644 --- a/src/editor/core/view/EditorArea.tsx +++ b/src/editor/core/view/EditorArea.tsx @@ -398,7 +398,7 @@ function PaneContent({ paneId, tab, tabsVisible }: { paneId: string; tab: Tab; t return ( <> {virtual ? ( - + ) : ( )} diff --git a/src/editor/core/virtual.test.ts b/src/editor/core/virtual.test.ts index e4417eb4d..aaa464afa 100644 --- a/src/editor/core/virtual.test.ts +++ b/src/editor/core/virtual.test.ts @@ -10,7 +10,7 @@ vi.mock("@/platform/virtualDocs", async (importOriginal) => { return { ...actual, virtualDocument: vi.fn(async (uri: string): Promise => { - 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 | 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(); diff --git a/src/features/debugger/__tests__/actions.test.ts b/src/features/debugger/__tests__/actions.test.ts index 0fe45b753..295b6944e 100644 --- a/src/features/debugger/__tests__/actions.test.ts +++ b/src/features/debugger/__tests__/actions.test.ts @@ -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(); + }); +}); diff --git a/src/features/debugger/__tests__/editor.test.ts b/src/features/debugger/__tests__/editor.test.ts index 57d96067d..b975cca61 100644 --- a/src/features/debugger/__tests__/editor.test.ts +++ b/src/features/debugger/__tests__/editor.test.ts @@ -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 }; diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index 78a08fe4e..3112affc7 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -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) { diff --git a/src/features/debugger/breakpoints/languages.ts b/src/features/debugger/breakpoints/languages.ts index 1496645db..e532b40d3 100644 --- a/src/features/debugger/breakpoints/languages.ts +++ b/src/features/debugger/breakpoints/languages.ts @@ -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. diff --git a/src/features/debugger/editor/breakpointGutter.ts b/src/features/debugger/editor/breakpointGutter.ts index 075dc954b..ada2140ce 100644 --- a/src/features/debugger/editor/breakpointGutter.ts +++ b/src/features/debugger/editor/breakpointGutter.ts @@ -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>({ create: (state) => buildMarkers(state, path), diff --git a/src/features/debugger/editor/runToCursor.ts b/src/features/debugger/editor/runToCursor.ts index f1f3ca7d2..1db51c42c 100644 --- a/src/features/debugger/editor/runToCursor.ts +++ b/src/features/debugger/editor/runToCursor.ts @@ -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 { diff --git a/src/platform/classFiles.test.ts b/src/platform/classFiles.test.ts new file mode 100644 index 000000000..a7c869099 --- /dev/null +++ b/src/platform/classFiles.test.ts @@ -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); + }); +}); diff --git a/src/platform/classFiles.ts b/src/platform/classFiles.ts new file mode 100644 index 000000000..8af7e7351 --- /dev/null +++ b/src/platform/classFiles.ts @@ -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; +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index f66b845de..0f69670d7 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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", diff --git a/src/platform/virtualDocs.ts b/src/platform/virtualDocs.ts index 8a31ef0ef..f0ced78e8 100644 --- a/src/platform/virtualDocs.ts +++ b/src/platform/virtualDocs.ts @@ -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`. */