From d54ef98a59e622015a4d32e30864675d96687efe Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:04:05 +0800 Subject: [PATCH] fix(lsp): open library classes behind jar: URIs (#1048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The official Kotlin LSP answers navigation into a dependency with a `jar:///…/lib.jar!/pkg/Name.class` URI but does not serve its contents through `workspace/textDocumentContent`, so opening the target failed with "no language server provides the contents of jar:…", locally and remotely. `jar:` URIs are now resolved by the backend, where the jars are: - An entry that is not a class (a sources jar entry, a resource) is read from its archive. - A class is shown as its source from the artifact's `-sources.jar` in the Maven repository or the Gradle cache (`Name.java`/`Name.kt`, an inner class's outer file, a `FileKt` facade's `File.kt`, a `@file:JvmName` file); Go to Declaration then lands on the declaration of the name it was invoked on. - Otherwise the server's `decompile` command is used when it offers one, and the error says that neither a source nor a decompiler is available. - Library tabs name the archive and package (`spring-boot-3.5.16.jar › org.springframework.boot`). Fixes #982 --- Cargo.lock | 1 + crates/ide-lsp/Cargo.toml | 1 + crates/ide-lsp/src/lib.rs | 136 +++++++++--- crates/ide-lsp/src/library_source.rs | 299 +++++++++++++++++++++++++++ crates/ide-lsp/src/virtual_doc.rs | 3 + crates/ide-lsp/tests/mock_server.rs | 65 ++++++ src/platform/virtualDocs.test.ts | 9 + src/platform/virtualDocs.ts | 14 ++ 8 files changed, 504 insertions(+), 24 deletions(-) create mode 100644 crates/ide-lsp/src/library_source.rs diff --git a/Cargo.lock b/Cargo.lock index 461692d3..196478cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4387,6 +4387,7 @@ dependencies = [ "tokio", "toml 0.9.12+spec-1.1.0", "tracing", + "zip", ] [[package]] diff --git a/crates/ide-lsp/Cargo.toml b/crates/ide-lsp/Cargo.toml index 81cd8381..876c8130 100644 --- a/crates/ide-lsp/Cargo.toml +++ b/crates/ide-lsp/Cargo.toml @@ -24,6 +24,7 @@ globset.workspace = true ignore.workspace = true toml.workspace = true percent-encoding = "2" +zip.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 45bef903..91de3dd3 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -16,6 +16,7 @@ mod handler; mod inspect; pub mod jdtls; mod launch; +mod library_source; mod scope; mod server; mod tcp; @@ -273,6 +274,30 @@ impl Outcome { /// Implementation requests of one line-marker pass in flight at once. const IMPLEMENTATION_CONCURRENCY: usize = 4; +/// Library classes in `jar:` targets (the official Kotlin LSP) go to their source +/// in the artifact's sources jar when it is cached, at the declaration of the +/// name navigated from; [`LspManager::virtual_document`] serves it. +async fn library_sources(snap: &DocSnap, pos: Pos, locs: Vec) -> Vec { + if !locs.iter().any(|l| l.uri.as_deref().is_some_and(|u| u.starts_with("jar:") && u.ends_with(".class"))) { + return locs; + } + let name = library_source::identifier_at(snap.text.line(pos.line as usize), pos.col).map(str::to_owned); + tokio::task::spawn_blocking(move || { + locs.into_iter() + .map(|loc| { + let Some(uri) = loc.uri.as_deref().filter(|u| u.starts_with("jar:") && u.ends_with(".class")) else { + return loc; + }; + let Some((source, text)) = library_source::class_source(uri) else { return loc }; + let range = name.as_deref().map(|n| library_source::declaration_range(&text, n)).unwrap_or_default(); + Location { path: source.clone().into(), range, uri: Some(source) } + }) + .collect() + }) + .await + .unwrap_or_default() +} + fn goto_method(kind: GotoKind) -> (&'static str, &'static str, &'static str) { match kind { GotoKind::Definition => ("textDocument/definition", "definitionProvider", "go to definition"), @@ -1187,6 +1212,7 @@ impl LspManager { { Ok(Some(r)) => { let locs = convert::goto_response(&self.conv(t.enc()), r); + let locs = library_sources(snap, pos, locs).await; self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref())); if !locs.is_empty() { return Ok(locs); @@ -2218,12 +2244,35 @@ impl LspManager { /// Contents of a virtual document, from the server that owns `uri`: /// `workspace/textDocumentContent` (LSP 3.18) when the server serves the - /// scheme, jdtls' `java/classFileContents` for `jdt:` URIs. Cached. + /// scheme, jdtls' `java/classFileContents` for `jdt:` URIs. A `jar:` entry + /// is read from its archive, a `jar:` class shown as its library source + /// (see [`library_source`]) or else decompiled by the server. Cached. pub async fn virtual_document(&self, uri: &str) -> IdeResult { if let Some(d) = self.virtual_docs.lock().get(uri) { return Ok(d.clone()); } let scheme = virtual_doc::scheme(uri).to_owned(); + let jar_class = scheme == "jar" && library_source::parse(uri).is_some_and(|e| e.entry.ends_with(".class")); + if scheme == "jar" { + let found = { + let uri = uri.to_owned(); + tokio::task::spawn_blocking(move || match library_source::parse(&uri) { + Some(e) if !e.entry.ends_with(".class") => { + library_source::read_entry(&e.jar, &e.entry).map(|text| (e.entry, text)) + } + Some(_) => library_source::class_source(&uri) + .and_then(|(source, text)| Some((library_source::parse(&source)?.entry, text))), + None => None, + }) + .await + .ok() + .flatten() + }; + if let Some((entry, text)) = found { + let title = entry.rsplit('/').next().unwrap_or(&entry).to_owned(); + return Ok(self.store_virtual(uri, text, Some(title), &[], self.origin_server(uri).map(|s| s.id))); + } + } 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(); @@ -2246,6 +2295,25 @@ impl LspManager { } 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 if jar_class && t.caps().commands().iter().any(|c| c == "decompile") { + // The official Kotlin LSP: `decompile` answers `{ code, language }`. + self.call::<_, Option>( + t, + "workspace/executeCommand", + json!({ "command": "decompile", "arguments": [uri] }), + None, + LONG_TIMEOUT, + ) + .await + .map(|v| { + v.and_then(|v| match v { + Value::String(text) => Some(text), + v => ["code", "text", "content"] + .iter() + .find_map(|k| v.get(k).and_then(Value::as_str)) + .map(str::to_owned), + }) + }) } else { continue; }; @@ -2257,33 +2325,53 @@ impl LspManager { continue; } }; - let decompiled = virtual_doc::is_decompiled(&text); - let title = virtual_doc::title(uri, decompiled); - let detected = self.languages.detect(Path::new(&title), None); - let language_id = if detected == LanguageId::plain_text() { - languages.first().cloned().unwrap_or(detected) - } else { - detected - }; - let doc = VirtualDocument { - uri: uri.to_owned(), - text, - language_id, - title, - description: virtual_doc::description(uri), - decompiled, - }; - self.virtual_origins.lock().entry(uri.to_owned()).or_insert(t.server.id); - let mut cache = self.virtual_docs.lock(); - if cache.len() >= VIRTUAL_CACHE_LIMIT { - cache.clear(); - } - cache.insert(uri.to_owned(), doc.clone()); - return Ok(doc); + return Ok(self.store_virtual(uri, text, None, &languages, Some(t.server.id))); + } + if jar_class && error.is_none() { + return Err(IdeError::not_found(format!( + "no source or decompiler is available for {}: its sources jar is not in the local Maven or Gradle cache, and no language server decompiles it", + virtual_doc::title(uri, false) + ))); } Err(error.unwrap_or_else(|| IdeError::not_found(format!("no language server provides the contents of {uri}")))) } + /// Caches `text` as the virtual document `uri`, titled `title` (else after the URI). + fn store_virtual( + &self, + uri: &str, + text: String, + title: Option, + languages: &[LanguageId], + server: Option, + ) -> VirtualDocument { + let decompiled = virtual_doc::is_decompiled(&text); + let title = title.unwrap_or_else(|| virtual_doc::title(uri, decompiled)); + let detected = self.languages.detect(Path::new(&title), None); + let language_id = if detected == LanguageId::plain_text() { + languages.first().cloned().unwrap_or(detected) + } else { + detected + }; + let doc = VirtualDocument { + uri: uri.to_owned(), + text, + language_id, + title, + description: virtual_doc::description(uri), + decompiled, + }; + if let Some(server) = server { + self.virtual_origins.lock().entry(uri.to_owned()).or_insert(server); + } + let mut cache = self.virtual_docs.lock(); + if cache.len() >= VIRTUAL_CACHE_LIMIT { + cache.clear(); + } + cache.insert(uri.to_owned(), doc.clone()); + doc + } + /// Called when a server sends `workspace/applyEdit`; installed by `src-tauri` /// so edits flow through `ide-workspace::Documents`. pub fn set_config_hook(&self, hook: Box) { diff --git a/crates/ide-lsp/src/library_source.rs b/crates/ide-lsp/src/library_source.rs new file mode 100644 index 00000000..50f91a44 --- /dev/null +++ b/crates/ide-lsp/src/library_source.rs @@ -0,0 +1,299 @@ +//! `jar:` URIs (`jar:///…/lib.jar!/pkg/Name.class`, also `jar:file:///…`) that +//! language servers return for library code: the official Kotlin LSP for the +//! classes of a dependency, Metals and others for entries of a sources jar. +//! Not every server can serve their contents (`workspace/textDocumentContent`), +//! so they are read here, where the jars are (also on a remote backend): an +//! entry as is, a class as its source from the artifact's `-sources.jar` next to +//! it in the Maven or Gradle cache. + +use ide_core::{Pos, Range}; +use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode}; +use std::io::Read; +use std::path::{Path, PathBuf}; + +/// An entry of an archive named by a `jar:` URI. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct JarEntry { + /// The URI up to the archive path (`jar:///` or `jar:file:///`), kept for new URIs. + prefix: String, + pub jar: PathBuf, + /// `/`-separated, without a leading `/`. + pub entry: String, +} + +pub(crate) fn parse(uri: &str) -> Option { + let rest = uri.strip_prefix("jar:")?; + let (archive, entry) = rest.split_once("!/")?; + let (prefix, path) = if let Some(path) = archive.strip_prefix("file://") { + ("jar:file://", path) + } else if let Some(path) = archive.strip_prefix("//") { + ("jar://", path) + } else { + ("jar:", archive) + }; + let path = percent_decode_str(path).decode_utf8().ok()?; + let path = if cfg!(windows) { + // `/C:/x` → `C:\x` + let trimmed = path.strip_prefix('/').filter(|s| s.as_bytes().get(1) == Some(&b':')).unwrap_or(&path); + PathBuf::from(trimmed.replace('/', "\\")) + } else { + PathBuf::from(path.into_owned()) + }; + let entry = percent_decode_str(entry.split(['?', '#']).next().unwrap_or(entry)).decode_utf8().ok()?; + Some(JarEntry { prefix: prefix.to_owned(), jar: path, entry: entry.trim_start_matches('/').to_owned() }) +} + +const PATH: &AsciiSet = &CONTROLS.add(b' ').add(b'#').add(b'%').add(b'?').add(b'!'); + +impl JarEntry { + /// The URI of `entry` in `jar`, written like the URI this one came from. + fn uri_of(&self, jar: &Path, entry: &str) -> String { + let mut path = jar.to_string_lossy().replace('\\', "/"); + if !path.starts_with('/') { + path.insert(0, '/'); + } + let encode = |s: &str| utf8_percent_encode(s, PATH).to_string(); + format!("{}{}!/{}", self.prefix, encode(&path), encode(entry)) + } +} + +/// The text of `entry` in the archive `jar`, when it is a UTF-8 file. +pub(crate) fn read_entry(jar: &Path, entry: &str) -> Option { + let mut zip = zip::ZipArchive::new(std::fs::File::open(jar).ok()?).ok()?; + let mut file = zip.by_name(entry).ok()?; + let mut text = String::new(); + file.read_to_string(&mut text).ok()?; + Some(text) +} + +/// The library source for a class entry: its `.kt` / `.java` file in the +/// artifact's sources jar, as `(uri, text)`. +pub(crate) fn class_source(uri: &str) -> Option<(String, String)> { + let jar = parse(uri)?; + let class = jar.entry.strip_suffix(".class")?; + for sources in sources_jars(&jar.jar) { + if let Some((entry, text)) = source_entry(&sources, class) { + return Some((jar.uri_of(&sources, &entry), text)); + } + } + None +} + +/// Sources jars of the artifact `jar`: `-sources.jar` next to it (Maven +/// repository), or in a sibling hash folder (Gradle `files-2.1/////`). +fn sources_jars(jar: &Path) -> Vec { + let (Some(dir), Some(stem)) = (jar.parent(), jar.file_stem().and_then(|s| s.to_str())) else { return vec![] }; + let name = format!("{stem}-sources.jar"); + let mut out: Vec = vec![dir.join(&name)]; + if let Some(version_dir) = dir.parent() + && version_dir.ancestors().any(|a| a.file_name().is_some_and(|n| n == "files-2.1")) + && let Ok(entries) = std::fs::read_dir(version_dir) + { + out.extend(entries.flatten().map(|e| e.path().join(&name))); + } + out.retain(|p| p.is_file()); + out +} + +/// The source file of `class` (`pkg/Name`, `pkg/Outer$Inner`, `pkg/FileKt`) in `sources`. +fn source_entry(sources: &Path, class: &str) -> Option<(String, String)> { + let mut zip = zip::ZipArchive::new(std::fs::File::open(sources).ok()?).ok()?; + let (package, name) = class.rsplit_once('/').unwrap_or(("", class)); + let outer = name.split('$').next().unwrap_or(name); + let dir = if package.is_empty() { String::new() } else { format!("{package}/") }; + let mut candidates = vec![format!("{dir}{outer}.java"), format!("{dir}{outer}.kt")]; + // Top-level Kotlin declarations compile to a `Kt` facade class. + if let Some(file) = outer.strip_suffix("Kt").filter(|f| !f.is_empty()) { + candidates.push(format!("{dir}{file}.kt")); + } + let read = |zip: &mut zip::ZipArchive, entry: &str| { + let mut text = String::new(); + zip.by_name(entry).ok()?.read_to_string(&mut text).ok()?; + Some(text) + }; + for entry in &candidates { + if let Some(text) = read(&mut zip, entry) { + return Some((entry.clone(), text)); + } + } + // A facade renamed with `@file:JvmName("…")`. + let kotlin_files: Vec = zip + .file_names() + .filter_map(|n| n.ok().map(|n| n.into_owned())) + .filter(|n| n.ends_with(".kt") && n.strip_prefix(&dir).is_some_and(|rest| !rest.contains('/'))) + .collect(); + let jvm_name = format!("@file:JvmName(\"{outer}\")"); + for entry in kotlin_files { + if let Some(text) = read(&mut zip, &entry) + && text.contains(&jvm_name) + { + return Some((entry, text)); + } + } + None +} + +/// Where `name` is declared in a Kotlin or Java source `text` (the start of the +/// name), else the start of the file. +pub(crate) fn declaration_range(text: &str, name: &str) -> Range { + const KEYWORDS: [&str; 9] = ["fun", "class", "interface", "object", "val", "var", "typealias", "enum", "record"]; + let is_ident = |c: char| c.is_alphanumeric() || c == '_' || c == '$'; + let mut fallback = None; + for (line_no, line) in text.lines().enumerate() { + let mut from = 0; + while let Some(i) = line[from..].find(name) { + let start = from + i; + let end = start + name.len(); + from = end; + let before = line[..start].chars().next_back(); + let after = line[end..].chars().next(); + if before.is_some_and(is_ident) || after.is_some_and(is_ident) { + continue; + } + let head = &line[..start]; + let words: Vec<&str> = head.split(|c: char| !is_ident(c)).filter(|w| !w.is_empty()).collect(); + let col = head.encode_utf16().count() as u32; + let range = Range { + start: Pos::new(line_no as u32, col), + end: Pos::new(line_no as u32, col + name.encode_utf16().count() as u32), + }; + // Kotlin: `fun Foo.name(`, `class Name`; Java: `… Type name(`. + if words.iter().any(|w| KEYWORDS.contains(w)) { + return range; + } + let java_member = + after == Some('(') && !head.trim_end().ends_with('.') && !head.trim().is_empty() && !head.contains('='); + if java_member && fallback.is_none() { + fallback = Some(range); + } + } + } + fallback.unwrap_or_default() +} + +/// The identifier around `col` (UTF-16) of `line`. +pub(crate) fn identifier_at(line: &str, col: u32) -> Option<&str> { + let is_ident = |c: char| c.is_alphanumeric() || c == '_' || c == '$'; + let mut utf16 = 0u32; + let mut at = line.len(); + for (i, c) in line.char_indices() { + if utf16 >= col { + at = i; + break; + } + utf16 += c.len_utf16() as u32; + } + let start = + line[..at].rfind(|c: char| !is_ident(c)).map_or(0, |i| i + line[i..].chars().next().map_or(1, char::len_utf8)); + let end = line[at..].find(|c: char| !is_ident(c)).map_or(line.len(), |i| at + i); + let word = &line[start..end]; + (!word.is_empty() && !word.starts_with(|c: char| c.is_ascii_digit())).then_some(word) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn jar(path: &Path, entries: &[(&str, &str)]) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap()); + for (name, text) in entries { + zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); + zip.write_all(text.as_bytes()).unwrap(); + } + zip.finish().unwrap(); + } + + const EXTENSIONS: &str = "package org.springframework.boot\n\nimport kotlin.reflect.KClass\n\n/**\n * Top-level function acting as a Kotlin shortcut allowing to write\n * `runApplication(arg1, arg2)` instead of `SpringApplication.run(Foo::class.java, arg1, arg2)`.\n */\ninline fun runApplication(vararg args: String): ConfigurableApplicationContext =\n\t\tSpringApplication.run(T::class.java, *args)\n"; + + #[test] + fn parses_both_jar_uri_forms() { + for uri in [ + "jar:///home/me/.m2/repository/b/spring-boot-3.5.16.jar!/org/springframework/boot/SpringApplicationExtensionsKt.class", + "jar:file:///home/me/.m2/repository/b/spring-boot-3.5.16.jar!/org/springframework/boot/SpringApplicationExtensionsKt.class", + ] { + let e = parse(uri).unwrap(); + if !cfg!(windows) { + assert_eq!(e.jar, PathBuf::from("/home/me/.m2/repository/b/spring-boot-3.5.16.jar")); + } + assert_eq!(e.entry, "org/springframework/boot/SpringApplicationExtensionsKt.class"); + assert_eq!(e.uri_of(&e.jar, &e.entry), uri, "written back the same way"); + } + let spaced = parse("jar:///tmp/my%20libs/a.jar!/A.class").unwrap(); + assert!(spaced.jar.to_string_lossy().contains("my libs")); + assert_eq!(spaced.uri_of(&spaced.jar, "A.kt"), "jar:///tmp/my%20libs/a.jar!/A.kt"); + assert_eq!(parse("jdt://contents/a.jar/A.class"), None); + assert_eq!(parse("jar:///no/entry.jar"), None); + } + + #[test] + fn finds_the_source_of_each_kind_of_class_in_maven_and_gradle_caches() { + let tmp = tempfile::tempdir().unwrap(); + let m2 = tmp.path().join("m2/org/springframework/boot/spring-boot/3.5.16"); + let gradle = tmp.path().join("gradle/caches/modules-2/files-2.1/org.springframework.boot/spring-boot/3.5.16"); + let sources: &[(&str, &str)] = &[ + ("org/springframework/boot/SpringApplicationExtensions.kt", EXTENSIONS), + ("org/springframework/boot/SpringApplication.java", "public class SpringApplication {}"), + ( + "org/springframework/boot/Renamed.kt", + "@file:JvmName(\"BootUtils\")\npackage org.springframework.boot\nfun util() {}", + ), + ("org/springframework/boot/Builder.kt", "class Builder"), + ("Root.java", "class Root {}"), + ]; + jar(&m2.join("spring-boot-3.5.16.jar"), &[]); + jar(&m2.join("spring-boot-3.5.16-sources.jar"), sources); + // Gradle keeps each file under its own hash folder. + jar(&gradle.join("aaa/spring-boot-3.5.16.jar"), &[]); + jar(&gradle.join("bbb/spring-boot-3.5.16-sources.jar"), sources); + for binary in [m2.join("spring-boot-3.5.16.jar"), gradle.join("aaa/spring-boot-3.5.16.jar")] { + let uri = |class: &str| format!("jar://{}!/{class}", binary.to_string_lossy().replace('\\', "/")); + for (class, file) in [ + ("org/springframework/boot/SpringApplicationExtensionsKt.class", "SpringApplicationExtensions.kt"), + ("org/springframework/boot/SpringApplication.class", "SpringApplication.java"), + ("org/springframework/boot/SpringApplication$Running.class", "SpringApplication.java"), + ("org/springframework/boot/BootUtils.class", "Renamed.kt"), + ("org/springframework/boot/Builder.class", "Builder.kt"), + ("Root.class", "Root.java"), + ] { + let (source_uri, text) = class_source(&uri(class)).unwrap_or_else(|| panic!("{class} in {binary:?}")); + let dir = class.rsplit_once('/').map_or(String::new(), |(p, _)| format!("{p}/")); + assert!(source_uri.ends_with(&format!("-sources.jar!/{dir}{file}")), "{source_uri}"); + assert_eq!( + parse(&source_uri).and_then(|e| read_entry(&e.jar, &e.entry)).as_deref(), + Some(text.as_str()) + ); + } + assert_eq!(class_source(&uri("org/springframework/boot/Missing.class")), None); + } + // No sources jar: nothing to show from here. + let lone = tmp.path().join("lone/x-1.jar"); + jar(&lone, &[]); + assert_eq!(class_source(&format!("jar://{}!/X.class", lone.to_string_lossy().replace('\\', "/"))), None); + } + + #[test] + fn locates_declarations_in_kotlin_and_java_sources() { + let at = |text: &str, name: &str| { + let r = declaration_range(text, name); + (r.start.line, r.start.col, r.end.col) + }; + assert_eq!(at(EXTENSIONS, "runApplication"), (8, 29, 43), "the declaration, not the KDoc mention"); + let java = "package a;\n\npublic class SpringApplication {\n public static ConfigurableApplicationContext run(Class source, String... args) {\n return run(new Class[] { source }, args);\n }\n}\n"; + assert_eq!(at(java, "SpringApplication"), (2, 13, 30)); + assert_eq!(at(java, "run"), (3, 49, 52), "the member, not the call in its body"); + assert_eq!(at("val x = 1", "missing"), (0, 0, 0)); + assert_eq!(at("// é\nfun ü() {}", "ü"), (1, 4, 5), "UTF-16 columns"); + } + + #[test] + fn identifier_under_the_caret() { + let line = " runApplication(*args)"; + assert_eq!(identifier_at(line, 4), Some("runApplication")); + assert_eq!(identifier_at(line, 10), Some("runApplication")); + assert_eq!(identifier_at(line, 18), Some("runApplication"), "right after it"); + assert_eq!(identifier_at(line, 20), Some("Application")); + assert_eq!(identifier_at("a + b", 2), None); + } +} diff --git a/crates/ide-lsp/src/virtual_doc.rs b/crates/ide-lsp/src/virtual_doc.rs index 110b206f..f0535413 100644 --- a/crates/ide-lsp/src/virtual_doc.rs +++ b/crates/ide-lsp/src/virtual_doc.rs @@ -60,6 +60,9 @@ pub fn title(uri: &str, decompiled: bool) -> String { /// Where the document comes from, e.g. the jar path of a `jdt://` class. pub fn description(uri: &str) -> Option { + if scheme(uri) == "jar" { + return crate::library_source::parse(uri).map(|e| e.jar.to_string_lossy().into_owned()); + } if scheme(uri) != "jdt" { return None; } diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 7b46a9d6..a535caee 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -1234,6 +1234,71 @@ async fn open_java_files_are_checked_again_after_a_project_update() { eventually(|| refreshed() == expected).await; } +#[tokio::test(flavor = "multi_thread")] +async fn library_classes_in_jars_open_as_their_source_or_decompiled() { + use std::io::Write; + let jar = |path: &Path, entries: &[(&str, &str)]| { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap()); + for (name, text) in entries { + zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); + zip.write_all(text.as_bytes()).unwrap(); + } + zip.finish().unwrap(); + }; + let repo = tempfile::tempdir().unwrap(); + let dir = repo.path().join("org/springframework/boot/spring-boot/3.5.16"); + let source = "package org.springframework.boot\n\ninline fun runApplication(vararg args: String) =\n SpringApplication.run(T::class.java, *args)\n"; + jar( + &dir.join("spring-boot-3.5.16.jar"), + &[("org/springframework/boot/SpringApplicationExtensionsKt.class", "\u{0}")], + ); + jar( + &dir.join("spring-boot-3.5.16-sources.jar"), + &[("org/springframework/boot/SpringApplicationExtensions.kt", source)], + ); + let other = repo.path().join("acme/lib/1.0/lib-1.0.jar"); + jar(&other, &[("acme/Thing.class", "\u{0}")]); + let jar_uri = |jar: &Path, entry: &str| format!("jar://{}!/{entry}", uri_of(jar).trim_start_matches("file://")); + let r = |l: u32, c0: u32, c1: u32| json!({"start":{"line":l,"character":c0},"end":{"line":l,"character":c1}}); + + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({ "commands": ["decompile"] }); + let h = harness_for("kotlin", "kt", &[("kotlin-lsp", caps)], &[]); + let (doc, _) = h.open("Main.kt", "fun main(args: Array) {\n runApplication(*args)\n}\n"); + h.wait_status("kotlin-lsp", ServerStatus::Ready).await; + let s = h.launcher.state("kotlin-lsp"); + + // A class with a cached sources jar: navigation lands on the declaration in its source. + let class = + jar_uri(&dir.join("spring-boot-3.5.16.jar"), "org/springframework/boot/SpringApplicationExtensionsKt.class"); + s.respond("textDocument/definition", json!({ "uri": class, "range": r(7, 43, 57) })); + let defs = h.mgr.goto(doc, Pos::new(1, 8), GotoKind::Definition).await.unwrap(); + let sources = + jar_uri(&dir.join("spring-boot-3.5.16-sources.jar"), "org/springframework/boot/SpringApplicationExtensions.kt"); + assert_eq!( + defs, + vec![ide_core::Location { path: sources.clone().into(), range: rng(2, 29, 2, 43), uri: Some(sources.clone()) }] + ); + let vd = h.mgr.virtual_document(&sources).await.unwrap(); + assert_eq!((vd.text.as_str(), vd.title.as_str(), vd.decompiled), (source, "SpringApplicationExtensions.kt", false)); + assert_eq!(vd.language_id.as_str(), "kotlin"); + // The class URI itself (from usages, say) shows the same source. + assert_eq!(h.mgr.virtual_document(&class).await.unwrap().text, source); + assert!(s.requested("workspace/executeCommand").is_empty(), "no server round trip for cached sources"); + + // No sources jar: the server's `decompile` command. + let thing = jar_uri(&other, "acme/Thing.class"); + s.respond("textDocument/definition", json!({ "uri": thing, "range": r(0, 6, 11) })); + let defs = h.mgr.goto(doc, Pos::new(1, 8), GotoKind::Definition).await.unwrap(); + assert_eq!(defs[0].uri.as_deref(), Some(thing.as_str()), "kept as the server returned it"); + s.respond("workspace/executeCommand", json!({ "code": "package acme\n\nclass Thing", "language": "kotlin" })); + let vd = h.mgr.virtual_document(&thing).await.unwrap(); + assert_eq!(vd.text, "package acme\n\nclass Thing"); + let asked = s.requested("workspace/executeCommand"); + assert_eq!(asked[0], json!({ "command": "decompile", "arguments": [thing] })); +} + #[tokio::test(flavor = "multi_thread")] async fn fallback_servers_only_run_when_the_preferred_one_is_missing() { // Preferred installed: the alternative is never started. diff --git a/src/platform/virtualDocs.test.ts b/src/platform/virtualDocs.test.ts index 1154105f..86293023 100644 --- a/src/platform/virtualDocs.test.ts +++ b/src/platform/virtualDocs.test.ts @@ -19,6 +19,15 @@ describe("virtual document URIs", () => { expect(virtualName(JDT)).toBe("SpringApplication.class"); expect(virtualParent(JDT)).toBe("jdt://contents/spring-boot-4.1.1.jar/org.springframework.boot"); expect(virtualContainer(JDT)).toBe("spring-boot-4.1.1.jar › org.springframework.boot"); + // `jar:` targets (official Kotlin LSP, sources jars), in both URI forms. + for (const prefix of ["jar:///", "jar:file:///"]) + expect( + virtualContainer( + `${prefix}home/me/.m2/repository/spring-boot-3.5.16-sources.jar!/org/springframework/boot/SpringApplicationExtensions.kt`, + ), + ).toBe("spring-boot-3.5.16-sources.jar › org.springframework.boot"); + expect(virtualContainer("jar:///tmp/my%20libs/a.jar!/Root.class")).toBe("a.jar"); + expect(virtualName("jar:///tmp/a.jar!/org/x/Y.class")).toBe("Y.class"); }); it("makes path helpers URI-aware", () => { diff --git a/src/platform/virtualDocs.ts b/src/platform/virtualDocs.ts index f0ced78e..b464464c 100644 --- a/src/platform/virtualDocs.ts +++ b/src/platform/virtualDocs.ts @@ -37,6 +37,20 @@ export function virtualParent(uri: string): string { */ export function virtualContainer(uri: string): string { const scheme = uri.slice(0, uri.indexOf(":")); + // `jar:///…/spring-boot-3.5.16.jar!/org/springframework/boot/X.class` → `spring-boot-3.5.16.jar › org.springframework.boot` + const jar = scheme === "jar" ? /^jar:(?:file:)?(?:\/\/)?(.*?)!\/(.*)$/.exec(uri.split(/[?#]/)[0]!) : null; + if (jar) { + const decode = (s: string) => { + try { + return decodeURIComponent(s); + } catch { + return s; + } + }; + const archive = decode(jar[1]!.split("/").pop() ?? jar[1]!); + const pkg = jar[2]!.split("/").slice(0, -1).map(decode).join("."); + return pkg ? `${archive} › ${pkg}` : archive; + } const rest = uri.replace(/^[^:]+:(\/\/)?/, "").split(/[?#]/)[0]!; const segs = rest .split("/")