mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
fix(lsp): open library classes behind jar: URIs (#1048)
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
This commit is contained in:
Generated
+1
@@ -4387,6 +4387,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"tracing",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -24,6 +24,7 @@ globset.workspace = true
|
||||
ignore.workspace = true
|
||||
toml.workspace = true
|
||||
percent-encoding = "2"
|
||||
zip.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
+112
-24
@@ -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<T> Outcome<T> {
|
||||
/// 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<Location>) -> Vec<Location> {
|
||||
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<VirtualDocument> {
|
||||
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<Arc<Server>> = 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<String>>(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<Value>>(
|
||||
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<String>,
|
||||
languages: &[LanguageId],
|
||||
server: Option<ide_core::ServerId>,
|
||||
) -> 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<ConfigFn>) {
|
||||
|
||||
@@ -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<JarEntry> {
|
||||
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<String> {
|
||||
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`: `<name>-sources.jar` next to it (Maven
|
||||
/// repository), or in a sibling hash folder (Gradle `files-2.1/<g>/<a>/<v>/<hash>/`).
|
||||
fn sources_jars(jar: &Path) -> Vec<PathBuf> {
|
||||
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<PathBuf> = 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 `<File>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<std::fs::File>, 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<String> = 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 <T> 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<Foo>(arg1, arg2)` instead of `SpringApplication.run(Foo::class.java, arg1, arg2)`.\n */\ninline fun <reified T : Any> 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<Application>(*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);
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 <reified T : Any> 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<String>) {\n runApplication<App>(*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.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
Reference in New Issue
Block a user