Files
openidea/crates/ide-lsp/src/virtual_doc.rs
l0ng-ai d54ef98a59 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
2026-11-03 22:04:05 +08:00

175 lines
7.2 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Virtual documents: read-only contents a language server provides for a
//! URI (library classes from `jdt://`, disk classes from `file://`, or
//! `workspace/textDocumentContent`).
use percent_encoding::percent_decode_str;
/// 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();
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://…`).
pub fn scheme(uri: &str) -> &str {
uri.split_once(':').map(|(s, _)| s).unwrap_or("")
}
/// The URI's path segments (without query / fragment), percent-decoded.
fn segments(uri: &str) -> Vec<String> {
let rest = uri.split_once(':').map(|(_, r)| r).unwrap_or(uri);
let rest = rest.split(['?', '#']).next().unwrap_or(rest);
let rest = rest.strip_prefix("//").unwrap_or(rest);
rest.split('/').filter(|s| !s.is_empty()).map(|s| percent_decode_str(s).decode_utf8_lossy().into_owned()).collect()
}
/// 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 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,
}
}
/// 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;
}
let segs = segments(uri);
// jdt://contents/<jar or module>/<package>/<Class.class>?=<project>/<escaped jar path><package(Class.class
let container = segs.get(1).cloned();
let query = uri.split_once('?').map(|(_, q)| q).unwrap_or("");
let query = percent_decode_str(query).decode_utf8_lossy();
let jar = query
.split_once('/')
.map(|(_, rest)| rest.replace('\\', ""))
.and_then(|rest| rest.split(['<', '(', '`', '=']).next().map(str::to_owned))
.filter(|p| p.ends_with(".jar") || p.ends_with(".jmod"));
let package = segs.get(2).filter(|_| segs.len() >= 4).cloned();
match (jar, container) {
(Some(jar), _) if jar.ends_with("jrt-fs.jar") => segs.get(1).cloned(),
(Some(jar), _) => Some(jar),
(None, Some(c)) => Some(match package {
Some(p) => format!("{c} › {p}"),
None => c,
}),
(None, None) => None,
}
}
/// 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::*;
const JDT: &str = "jdt://contents/spring-boot-4.1.1.jar/org.springframework.boot/SpringApplication.class?=spring-demo/%5C/Users%5C/me%5C/.m2%5C/repository%5C/spring-boot-4.1.1.jar%3Corg.springframework.boot%28SpringApplication.class";
#[test]
fn jdt_titles() {
assert_eq!(title(JDT, true), "SpringApplication.class");
assert_eq!(title(JDT, false), "SpringApplication.java");
assert_eq!(title("untitled:foo/bar.txt", false), "bar.txt");
assert_eq!(scheme(JDT), "jdt");
}
#[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(
"jdt://contents/java.base/java.lang/String.class?=p/%5C/jdk%5C/lib%5C/jrt-fs.jar%60java.base=x"
)
.as_deref(),
Some("java.base")
);
assert_eq!(description("jdt://contents/a.jar/p/C.class").as_deref(), Some("a.jar › p"));
assert_eq!(description("foo://x/y"), None);
}
#[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(
"// 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;"));
}
}