mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
Root cause: missing_runtime_message only suggested JAVA_HOME (process-wide) although a per-app Language Server JDK setting exists since #21. Siblings covered: server_jdk_too_old_message now names the same Settings page via a shared SERVER_JDK_SETTING const; both asserted in tests.
714 lines
30 KiB
Rust
714 lines
30 KiB
Rust
//! Installed JDKs and the jdtls launch configuration that depends on them.
|
||
|
||
use crate::apply::merge_init_options;
|
||
use crate::home_dir;
|
||
use ide_language::LanguageServerConfig;
|
||
use serde_json::json;
|
||
use std::collections::BTreeMap;
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::{Arc, Mutex};
|
||
use std::time::{Duration, Instant};
|
||
|
||
/// A JDK home and its feature version (8, 11, 17, 21…).
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct Jdk {
|
||
pub major: u32,
|
||
pub home: PathBuf,
|
||
}
|
||
|
||
/// `JAVA_VERSION="21.0.3"` (or `"1.8.0_372"`) from a JDK's `release` file.
|
||
pub fn parse_release(release: &str) -> Option<u32> {
|
||
let line = release.lines().find_map(|l| l.trim().strip_prefix("JAVA_VERSION="))?;
|
||
let v = line.trim().trim_matches('"');
|
||
let mut parts = v.split(['.', '_', '-', '+']);
|
||
let first: u32 = parts.next()?.parse().ok()?;
|
||
if first == 1 { parts.next()?.parse().ok() } else { Some(first) }
|
||
}
|
||
|
||
/// The JDK at `home`, if it is one (a `release` file and `bin/java`).
|
||
pub fn jdk_at(home: &Path) -> Option<Jdk> {
|
||
let release = std::fs::read_to_string(home.join("release")).ok()?;
|
||
if !home.join("bin").join(crate::exe("java")).is_file() {
|
||
return None;
|
||
}
|
||
Some(Jdk {
|
||
major: parse_release(&release)?,
|
||
home: dunce::canonicalize(home).unwrap_or_else(|_| home.to_path_buf()),
|
||
})
|
||
}
|
||
|
||
fn children(dir: &Path) -> Vec<PathBuf> {
|
||
let mut out: Vec<PathBuf> =
|
||
std::fs::read_dir(dir).map(|rd| rd.flatten().map(|e| e.path()).collect()).unwrap_or_default();
|
||
out.sort();
|
||
out
|
||
}
|
||
|
||
/// Vendor directories JDK installers use on Windows, under `Program Files`
|
||
/// and (per-user installs) `%LOCALAPPDATA%\Programs`.
|
||
const WINDOWS_VENDORS: &[&str] =
|
||
&["Java", "Eclipse Adoptium", "Microsoft", "Zulu", "Amazon Corretto", "BellSoft", "Semeru", "OpenJDK"];
|
||
|
||
/// JDK homes in the usual install locations (macOS `JavaVirtualMachines`,
|
||
/// `/usr/lib/jvm`, SDKMAN, IDEA's `~/.jdks`, Homebrew, Windows vendor
|
||
/// directories), `JAVA_HOME` first, then the `java` on `PATH`.
|
||
fn scan() -> Vec<Jdk> {
|
||
let mut homes: Vec<PathBuf> = Vec::new();
|
||
if let Some(h) = std::env::var_os("JAVA_HOME") {
|
||
homes.push(PathBuf::from(h));
|
||
}
|
||
let path = std::env::var_os("PATH").unwrap_or_default();
|
||
if let Some(java) = std::env::split_paths(&path).map(|d| d.join(crate::exe("java"))).find(|j| j.is_file()) {
|
||
// Through symlinks (`/usr/bin/java`, Homebrew, SDKMAN `current`) to `<home>/bin/java`.
|
||
if let Some(home) = dunce::canonicalize(java).ok().and_then(|j| j.parent()?.parent().map(Path::to_path_buf)) {
|
||
homes.push(home);
|
||
}
|
||
}
|
||
let home = home_dir();
|
||
let mut roots: Vec<PathBuf> = vec![
|
||
PathBuf::from("/Library/Java/JavaVirtualMachines"),
|
||
PathBuf::from("/usr/lib/jvm"),
|
||
PathBuf::from("/opt/homebrew/opt"),
|
||
PathBuf::from("/usr/local/opt"),
|
||
];
|
||
if let Some(h) = &home {
|
||
roots.extend([h.join("Library/Java/JavaVirtualMachines"), h.join(".jdks"), h.join(".sdkman/candidates/java")]);
|
||
}
|
||
if cfg!(windows) {
|
||
let bases = ["ProgramFiles", "ProgramW6432", "LOCALAPPDATA"].into_iter().filter_map(|v| {
|
||
let dir = PathBuf::from(std::env::var_os(v)?);
|
||
Some(if v == "LOCALAPPDATA" { dir.join("Programs") } else { dir })
|
||
});
|
||
for base in bases {
|
||
roots.extend(WINDOWS_VENDORS.iter().map(|v| base.join(v)));
|
||
}
|
||
}
|
||
for (i, root) in roots.iter().enumerate() {
|
||
// `ProgramFiles` and `ProgramW6432` are usually the same directory.
|
||
if roots[..i].contains(root) {
|
||
continue;
|
||
}
|
||
for dir in children(root) {
|
||
homes.extend([dir.join("Contents/Home"), dir.join("libexec/openjdk.jdk/Contents/Home"), dir]);
|
||
}
|
||
}
|
||
let mut out: Vec<Jdk> = Vec::new();
|
||
for h in homes {
|
||
if let Some(jdk) = jdk_at(&h)
|
||
&& !out.iter().any(|j| j.home == jdk.home)
|
||
{
|
||
out.push(jdk);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// How long a scan that found no JDK able to run jdtls is reused before
|
||
/// [`jdtls_jdks`] scans again.
|
||
const RESCAN_AFTER: Duration = Duration::from_secs(10);
|
||
|
||
struct Scan {
|
||
at: Instant,
|
||
jdks: Arc<[Jdk]>,
|
||
}
|
||
|
||
static JDKS: Mutex<Option<Scan>> = Mutex::new(None);
|
||
|
||
fn rescan(slot: &mut Option<Scan>) -> Arc<[Jdk]> {
|
||
slot.insert(Scan { at: Instant::now(), jdks: scan().into() }).jdks.clone()
|
||
}
|
||
|
||
/// Scans for installed JDKs again (Settings › Detect again).
|
||
pub fn rescan_jdks() -> Arc<[Jdk]> {
|
||
rescan(&mut JDKS.lock().unwrap_or_else(|e| e.into_inner()))
|
||
}
|
||
|
||
/// Installed JDKs (scanned on first use; [`jdtls_jdks`] may refresh them).
|
||
pub fn installed_jdks() -> Arc<[Jdk]> {
|
||
let mut slot = JDKS.lock().unwrap_or_else(|e| e.into_inner());
|
||
match &*slot {
|
||
Some(s) => s.jdks.clone(),
|
||
None => rescan(&mut slot),
|
||
}
|
||
}
|
||
|
||
/// Like [`installed_jdks`], but while none can run jdtls (on Java `min_java`)
|
||
/// the list is rescanned (at most every [`RESCAN_AFTER`]), so a JDK installed
|
||
/// after startup is picked up when the server is restarted.
|
||
pub fn jdtls_jdks(min_java: u32) -> Arc<[Jdk]> {
|
||
let mut slot = JDKS.lock().unwrap_or_else(|e| e.into_inner());
|
||
match &*slot {
|
||
Some(s) if jdtls_runtime(&s.jdks, min_java).is_some() || s.at.elapsed() < RESCAN_AFTER => s.jdks.clone(),
|
||
_ => rescan(&mut slot),
|
||
}
|
||
}
|
||
|
||
/// Oldest Java version jdtls runs on when its install can't tell (its launcher's check).
|
||
pub const JDTLS_MIN_JAVA: u32 = 21;
|
||
|
||
/// The JDK to run jdtls with: the first found that is new enough (`JAVA_HOME` wins).
|
||
pub fn jdtls_runtime(jdks: &[Jdk], min_java: u32) -> Option<&Jdk> {
|
||
jdks.iter().find(|j| j.major >= min_java)
|
||
}
|
||
|
||
/// The Java version the jdtls that `cfg` launches needs: the highest any of its
|
||
/// bundles requires, never below [`JDTLS_MIN_JAVA`]. Its launcher only checks
|
||
/// for 21, but the Eclipse platform it ships moves on (1.63 needs 25), and on
|
||
/// an older JDK the server dies resolving its bundles.
|
||
pub fn jdtls_min_java(cfg: &LanguageServerConfig) -> u32 {
|
||
let search = cfg.env.get("PATH").map(std::ffi::OsString::from).or_else(|| std::env::var_os("PATH"));
|
||
let required = search
|
||
.and_then(|s| ide_run::which::find_executable(&cfg.command, &s))
|
||
.and_then(|exe| dunce::canonicalize(exe).ok())
|
||
.and_then(|exe| Some(exe.parent()?.parent()?.to_path_buf()))
|
||
.and_then(|home| bundles_min_java(&home));
|
||
required.map_or(JDTLS_MIN_JAVA, |v| v.max(JDTLS_MIN_JAVA))
|
||
}
|
||
|
||
/// Per install: (plugins directory mtime, its requirement).
|
||
static BUNDLE_JAVA: Mutex<BTreeMap<PathBuf, (std::time::SystemTime, Option<u32>)>> = Mutex::new(BTreeMap::new());
|
||
|
||
/// Highest Java version required by the bundles in `home`'s `plugins` (or Homebrew's
|
||
/// `libexec/plugins`); `None` when there are none to read.
|
||
fn bundles_min_java(home: &Path) -> Option<u32> {
|
||
let plugins = [home.join("plugins"), home.join("libexec").join("plugins")].into_iter().find(|p| p.is_dir())?;
|
||
let modified = std::fs::metadata(&plugins).and_then(|m| m.modified()).ok()?;
|
||
let mut cache = BUNDLE_JAVA.lock().unwrap_or_else(|e| e.into_inner());
|
||
if let Some((at, v)) = cache.get(&plugins)
|
||
&& *at == modified
|
||
{
|
||
return *v;
|
||
}
|
||
let required = std::fs::read_dir(&plugins)
|
||
.ok()?
|
||
.filter_map(Result::ok)
|
||
.map(|e| e.path())
|
||
.filter(|p| p.extension().is_some_and(|x| x == "jar"))
|
||
.filter_map(|jar| jar_manifest(&jar))
|
||
.filter_map(|manifest| manifest_min_java(&manifest))
|
||
.max();
|
||
cache.insert(plugins, (modified, required));
|
||
required
|
||
}
|
||
|
||
fn jar_manifest(jar: &Path) -> Option<String> {
|
||
use std::io::Read;
|
||
let mut zip = zip::ZipArchive::new(std::fs::File::open(jar).ok()?).ok()?;
|
||
let mut entry = zip.by_name("META-INF/MANIFEST.MF").ok()?;
|
||
let mut text = String::new();
|
||
entry.read_to_string(&mut text).ok()?;
|
||
Some(text)
|
||
}
|
||
|
||
/// The Java version an OSGi bundle manifest requires: its `osgi.ee` capability
|
||
/// (`version=25`), else the oldest of its `Bundle-RequiredExecutionEnvironment`s.
|
||
fn manifest_min_java(manifest: &str) -> Option<u32> {
|
||
// Long headers continue on lines starting with a space.
|
||
let mut headers: Vec<String> = Vec::new();
|
||
for line in manifest.lines() {
|
||
match (line.strip_prefix(' '), headers.last_mut()) {
|
||
(Some(rest), Some(last)) => last.push_str(rest),
|
||
_ => headers.push(line.to_owned()),
|
||
}
|
||
}
|
||
let header = |name: &str| headers.iter().find_map(|h| h.strip_prefix(name)?.strip_prefix(':')).map(str::trim);
|
||
if let Some(caps) = header("Require-Capability") {
|
||
let ee = caps.split(',').filter(|c| c.trim_start().starts_with("osgi.ee")).find_map(|c| {
|
||
let v = c.split("version=").nth(1)?;
|
||
let digits: String = v.chars().take_while(char::is_ascii_digit).collect();
|
||
digits.parse::<u32>().ok()
|
||
});
|
||
if ee.is_some() {
|
||
return ee;
|
||
}
|
||
}
|
||
header("Bundle-RequiredExecutionEnvironment")?
|
||
.split(',')
|
||
.filter_map(|ee| ee.trim().strip_prefix("JavaSE-"))
|
||
.filter_map(|v| if v == "1.8" { Some(8) } else { v.parse().ok() })
|
||
.min()
|
||
}
|
||
|
||
/// jdtls `java.configuration.runtimes`: one JDK per Java version (the first
|
||
/// found, i.e. `JAVA_HOME` wins), named after its execution environment, so a
|
||
/// project targeting Java 21 compiles against a JDK 21 rather than the JDK
|
||
/// jdtls itself runs on.
|
||
pub fn runtimes(jdks: &[Jdk]) -> Vec<serde_json::Value> {
|
||
let mut by_major: BTreeMap<u32, &Jdk> = BTreeMap::new();
|
||
for j in jdks {
|
||
by_major.entry(j.major).or_insert(j);
|
||
}
|
||
by_major
|
||
.into_iter()
|
||
.filter(|(major, _)| *major >= 8)
|
||
.map(|(major, j)| {
|
||
let name = if major == 8 { "JavaSE-1.8".to_owned() } else { format!("JavaSE-{major}") };
|
||
json!({ "name": name, "path": j.home.to_string_lossy() })
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Stable FNV-1a hash of a path, for per-workspace directories.
|
||
fn path_key(p: &Path) -> String {
|
||
let mut h: u64 = 0xcbf29ce484222325;
|
||
for b in p.to_string_lossy().as_bytes() {
|
||
h ^= *b as u64;
|
||
h = h.wrapping_mul(0x100000001b3);
|
||
}
|
||
format!("{h:016x}")
|
||
}
|
||
|
||
const METADATA_PROPERTY: &str = "java.import.generatesMetadataFilesAtProjectRoot";
|
||
|
||
/// jdtls: its Eclipse workspace (`-data`) lives under the IDE's data
|
||
/// directory, one per server root, and no Eclipse metadata is written into
|
||
/// the project; it runs on a JDK `min_java`+ (via `JAVA_HOME`, which its
|
||
/// launcher reads), installed JDKs are registered as runtimes and `bundles`
|
||
/// (e.g. the java-debug plugin jar) are loaded into the server. The project
|
||
/// JDK, when set, is the default runtime and preferred for running the server;
|
||
/// `server_jdk` (the user's choice for the server alone) overrides that.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn configure_jdtls(
|
||
cfg: &mut LanguageServerConfig,
|
||
server_root: &Path,
|
||
data_dir: &Path,
|
||
jdks: &[Jdk],
|
||
project_jdk: Option<&Jdk>,
|
||
server_jdk: Option<&Jdk>,
|
||
min_java: u32,
|
||
bundles: &[PathBuf],
|
||
lombok: Option<&Path>,
|
||
) {
|
||
if cfg.name != "jdtls" {
|
||
return;
|
||
}
|
||
// A Lombok agent from the user's JVM arguments wins.
|
||
let own_agent = crate::lombok::agent_in_args(cfg.args.iter().map(String::as_str));
|
||
let agent = own_agent.as_deref().or(lombok);
|
||
if !cfg.args.iter().any(|a| a == "-data") {
|
||
// ECJ builds incrementally: classes compiled without (or with another) Lombok
|
||
// agent would be kept, so each agent gets its own workspace and a full build.
|
||
let key = match agent {
|
||
Some(jar) => format!("{}-{}", path_key(server_root), path_key(jar)),
|
||
None => path_key(server_root),
|
||
};
|
||
let dir = data_dir.join("lsp").join("jdtls").join(key);
|
||
cfg.args.extend(["-data".to_owned(), dir.to_string_lossy().into_owned()]);
|
||
}
|
||
// jdtls reads this one as a system property (its file-system bundle
|
||
// relocates .project / .classpath / .settings into the workspace).
|
||
if !cfg.args.iter().any(|a| a.contains(METADATA_PROPERTY)) {
|
||
cfg.args.push(format!("--jvm-arg=-D{METADATA_PROPERTY}=false"));
|
||
}
|
||
if own_agent.is_none()
|
||
&& let Some(jar) = lombok
|
||
{
|
||
cfg.args.push(format!("--jvm-arg=-javaagent:{}", jar.to_string_lossy()));
|
||
}
|
||
let mut all: Vec<Jdk> = Vec::new();
|
||
for j in project_jdk.into_iter().chain(jdks).chain(server_jdk) {
|
||
if !all.contains(j) {
|
||
all.push(j.clone());
|
||
}
|
||
}
|
||
let jdks = all.as_slice();
|
||
if !cfg.env.contains_key("JAVA_HOME") {
|
||
let runtime = match server_jdk {
|
||
Some(jdk) if jdk.major < min_java => Err(server_jdk_too_old_message(jdk, min_java)),
|
||
Some(jdk) => Ok(jdk),
|
||
None => jdtls_runtime(jdks, min_java).ok_or_else(|| missing_runtime_message(jdks, min_java)),
|
||
};
|
||
match runtime {
|
||
Ok(jdk) => {
|
||
cfg.env.insert("JAVA_HOME".to_owned(), jdk.home.to_string_lossy().into_owned());
|
||
}
|
||
Err(e) => cfg.launch_error = Some(e),
|
||
}
|
||
}
|
||
let mut runtimes = runtimes(jdks);
|
||
if let Some(project) = project_jdk {
|
||
let home = project.home.to_string_lossy();
|
||
for r in &mut runtimes {
|
||
if r["path"] == *home {
|
||
r["default"] = json!(true);
|
||
}
|
||
}
|
||
}
|
||
if !runtimes.is_empty() {
|
||
merge_init_options(cfg, json!({ "settings": { "java": { "configuration": { "runtimes": runtimes } } } }));
|
||
}
|
||
if !bundles.is_empty() {
|
||
let bundles: Vec<String> = bundles.iter().map(|b| b.to_string_lossy().into_owned()).collect();
|
||
merge_init_options(cfg, json!({ "bundles": bundles }));
|
||
}
|
||
}
|
||
|
||
/// Where the JDK that runs jdtls is chosen, named as the settings dialog shows it.
|
||
const SERVER_JDK_SETTING: &str = "Settings › Languages & Frameworks › Java › Language Server › JDK";
|
||
|
||
fn missing_runtime_message(jdks: &[Jdk], min_java: u32) -> String {
|
||
let mut found: Vec<u32> = jdks.iter().map(|j| j.major).collect();
|
||
found.sort_unstable();
|
||
found.dedup();
|
||
let found = if found.is_empty() {
|
||
"no JDK was found".to_owned()
|
||
} else {
|
||
let list: Vec<String> = found.iter().map(u32::to_string).collect();
|
||
format!("found JDK {}", list.join(", "))
|
||
};
|
||
format!(
|
||
"jdtls needs JDK {min_java} or newer to run ({found}). Pick one in {SERVER_JDK_SETTING} (it restarts \
|
||
automatically), or install a newer JDK and restart the server; the project can still target an older \
|
||
Java version."
|
||
)
|
||
}
|
||
|
||
fn server_jdk_too_old_message(jdk: &Jdk, min_java: u32) -> String {
|
||
format!(
|
||
"The language server JDK ({}, JDK {}) is too old: jdtls needs JDK {min_java} or newer. Choose another \
|
||
one in {SERVER_JDK_SETTING}, or set it to Auto.",
|
||
jdk.home.display(),
|
||
jdk.major
|
||
)
|
||
}
|
||
|
||
/// The `java` launcher of a JDK home.
|
||
pub fn java_executable(home: &Path) -> PathBuf {
|
||
home.join("bin").join(crate::exe("java"))
|
||
}
|
||
|
||
/// The JDK to run a project targeting Java `release` with: that exact version,
|
||
/// else the oldest newer one, else the newest installed (`None`: any, newest).
|
||
pub fn pick_jdk(jdks: &[Jdk], release: Option<u32>) -> Option<&Jdk> {
|
||
let newest = jdks.iter().max_by_key(|j| j.major);
|
||
let Some(release) = release else { return newest };
|
||
jdks.iter()
|
||
.find(|j| j.major == release)
|
||
.or_else(|| jdks.iter().filter(|j| j.major > release).min_by_key(|j| j.major))
|
||
.or(newest)
|
||
}
|
||
|
||
/// Java version a Maven / Gradle module targets: `<java.version>`,
|
||
/// `<maven.compiler.release|source|target>` / `<release>`, Gradle toolchains
|
||
/// (`JavaLanguageVersion.of(21)`) or `sourceCompatibility`.
|
||
pub fn project_java_version(module_dir: &Path) -> Option<u32> {
|
||
let read = |name: &str| std::fs::read_to_string(module_dir.join(name)).ok();
|
||
if let Some(pom) = read("pom.xml") {
|
||
for tag in
|
||
["java.version", "maven.compiler.release", "release", "maven.compiler.source", "maven.compiler.target"]
|
||
{
|
||
if let Some(v) = xml_value(&pom, tag).and_then(|v| parse_java_version(&v)) {
|
||
return Some(v);
|
||
}
|
||
}
|
||
}
|
||
for name in ["build.gradle.kts", "build.gradle"] {
|
||
let Some(text) = read(name) else { continue };
|
||
if let Some(i) = text.find("JavaLanguageVersion.of(") {
|
||
let rest = &text[i + "JavaLanguageVersion.of(".len()..];
|
||
if let Some(v) = parse_java_version(rest.split(')').next().unwrap_or_default()) {
|
||
return Some(v);
|
||
}
|
||
}
|
||
for key in ["sourceCompatibility", "targetCompatibility"] {
|
||
if let Some(i) = text.find(key) {
|
||
let rest = text[i + key.len()..].trim_start_matches([' ', '=', '\t']);
|
||
let token: String = rest
|
||
.chars()
|
||
.take_while(|c| !c.is_whitespace() && *c != '\n')
|
||
.collect::<String>()
|
||
.trim_matches(['\'', '"'])
|
||
.replace("JavaVersion.VERSION_", "");
|
||
if let Some(v) = parse_java_version(&token.replace('_', ".")) {
|
||
return Some(v);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
|
||
let open = format!("<{tag}>");
|
||
let start = text.find(&open)? + open.len();
|
||
let end = text[start..].find('<')?;
|
||
Some(text[start..start + end].trim().to_owned())
|
||
}
|
||
|
||
/// `21`, `"17"`, `1.8`, `'11'` → feature version.
|
||
fn parse_java_version(v: &str) -> Option<u32> {
|
||
let v = v.trim().trim_matches(['"', '\'']);
|
||
let mut parts = v.split('.');
|
||
let first: u32 = parts.next()?.trim().parse().ok()?;
|
||
if first == 1 { parts.next()?.trim().parse().ok() } else { Some(first) }
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn release_versions() {
|
||
assert_eq!(parse_release("IMPLEMENTOR=\"x\"\nJAVA_VERSION=\"21.0.3\"\n"), Some(21));
|
||
assert_eq!(parse_release("JAVA_VERSION=\"1.8.0_372\""), Some(8));
|
||
assert_eq!(parse_release("JAVA_VERSION=\"25\""), Some(25));
|
||
assert_eq!(parse_release("OTHER=1"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn jdtls_workspace_and_runtimes() {
|
||
if cfg!(windows) {
|
||
return; // Unix paths and shells
|
||
}
|
||
let mut cfg: LanguageServerConfig = toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap();
|
||
let jdks = [
|
||
Jdk { major: 25, home: PathBuf::from("/jdk25") },
|
||
Jdk { major: 21, home: PathBuf::from("/jdk21a") },
|
||
Jdk { major: 21, home: PathBuf::from("/jdk21b") },
|
||
Jdk { major: 8, home: PathBuf::from("/jdk8") },
|
||
];
|
||
let bundles = [PathBuf::from("/tools/java-debug/bin/com.microsoft.java.debug.plugin.jar")];
|
||
configure_jdtls(
|
||
&mut cfg,
|
||
Path::new("/p/demo"),
|
||
Path::new("/data"),
|
||
&jdks,
|
||
None,
|
||
None,
|
||
JDTLS_MIN_JAVA,
|
||
&bundles,
|
||
None,
|
||
);
|
||
assert_eq!(cfg.args[0], "-data");
|
||
assert!(cfg.args[1].starts_with("/data/lsp/jdtls/"), "{}", cfg.args[1]);
|
||
assert_eq!(cfg.args[2], "--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false");
|
||
let opts = serde_json::to_value(cfg.initialization_options.clone().unwrap()).unwrap();
|
||
let rt = &opts["settings"]["java"]["configuration"]["runtimes"];
|
||
assert_eq!(rt[0], json!({ "name": "JavaSE-1.8", "path": "/jdk8" }));
|
||
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": "/jdk21a" }));
|
||
assert_eq!(rt[2], json!({ "name": "JavaSE-25", "path": "/jdk25" }));
|
||
assert_eq!(opts["bundles"], json!(["/tools/java-debug/bin/com.microsoft.java.debug.plugin.jar"]));
|
||
assert_eq!(cfg.env["JAVA_HOME"], "/jdk25", "first JDK new enough to run jdtls");
|
||
assert_eq!(cfg.launch_error, None);
|
||
|
||
// Deterministic (reconfigure compares configs) and idempotent.
|
||
let before = cfg.clone();
|
||
configure_jdtls(
|
||
&mut cfg,
|
||
Path::new("/p/demo"),
|
||
Path::new("/data"),
|
||
&jdks,
|
||
None,
|
||
None,
|
||
JDTLS_MIN_JAVA,
|
||
&bundles,
|
||
None,
|
||
);
|
||
assert_eq!(cfg, before);
|
||
|
||
// The project JDK is the default runtime, and runs the server when new enough.
|
||
let project = Jdk { major: 21, home: PathBuf::from("/jdk21b") };
|
||
let mut cfg: LanguageServerConfig = toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap();
|
||
configure_jdtls(
|
||
&mut cfg,
|
||
Path::new("/p"),
|
||
Path::new("/data"),
|
||
&jdks,
|
||
Some(&project),
|
||
None,
|
||
JDTLS_MIN_JAVA,
|
||
&[],
|
||
None,
|
||
);
|
||
let opts = serde_json::to_value(cfg.initialization_options.clone().unwrap()).unwrap();
|
||
let rt = &opts["settings"]["java"]["configuration"]["runtimes"];
|
||
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": "/jdk21b", "default": true }));
|
||
assert_eq!(cfg.env["JAVA_HOME"], "/jdk21b");
|
||
|
||
let mut other: LanguageServerConfig = toml::from_str("name = \"pyright\"\ncommand = \"x\"").unwrap();
|
||
configure_jdtls(
|
||
&mut other,
|
||
Path::new("/p"),
|
||
Path::new("/data"),
|
||
&jdks,
|
||
None,
|
||
None,
|
||
JDTLS_MIN_JAVA,
|
||
&bundles,
|
||
None,
|
||
);
|
||
assert!(other.args.is_empty() && other.initialization_options.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn jdtls_lombok_agent() {
|
||
let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() };
|
||
let jar = Path::new("/m2/lombok-1.18.48.jar");
|
||
let mut cfg = jdtls();
|
||
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], Some(jar));
|
||
let agent = format!("--jvm-arg=-javaagent:{}", jar.to_string_lossy());
|
||
assert!(cfg.args.contains(&agent), "{:?}", cfg.args);
|
||
let mut plain = jdtls();
|
||
configure_jdtls(&mut plain, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], None);
|
||
assert_ne!(cfg.args[1], plain.args[1], "a separate workspace, so everything is rebuilt with the agent");
|
||
let before = cfg.clone();
|
||
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], Some(jar));
|
||
assert_eq!(cfg, before, "idempotent");
|
||
|
||
let mut cfg = jdtls();
|
||
cfg.args.push("--jvm-arg=-javaagent:/own/lombok.jar".into());
|
||
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], Some(jar));
|
||
assert!(!cfg.args.contains(&agent), "the user's agent wins");
|
||
let mut own = jdtls();
|
||
own.args.push("--jvm-arg=-javaagent:/own/lombok.jar".into());
|
||
configure_jdtls(&mut own, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], None);
|
||
assert_eq!(cfg.args, own.args, "its workspace follows the agent in use");
|
||
}
|
||
|
||
#[test]
|
||
fn jdtls_needs_a_new_enough_jdk_to_run() {
|
||
let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() };
|
||
let old = [Jdk { major: 17, home: PathBuf::from("/jdk17") }, Jdk { major: 8, home: PathBuf::from("/jdk8") }];
|
||
let mut cfg = jdtls();
|
||
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, None, None, JDTLS_MIN_JAVA, &[], None);
|
||
assert!(!cfg.env.contains_key("JAVA_HOME"));
|
||
let err = cfg.launch_error.unwrap();
|
||
assert!(err.contains("JDK 21 or newer") && err.contains("found JDK 8, 17"), "{err}");
|
||
// The user cannot be sent to JAVA_HOME alone: the language server JDK is a setting.
|
||
assert!(err.contains(SERVER_JDK_SETTING) && !err.contains("point JAVA_HOME at it"), "{err}");
|
||
|
||
let mut cfg = jdtls();
|
||
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], None);
|
||
assert!(cfg.launch_error.unwrap().contains("no JDK was found"));
|
||
|
||
// An explicit JAVA_HOME is the user's call.
|
||
let mut cfg = jdtls();
|
||
cfg.env.insert("JAVA_HOME".into(), "/custom".into());
|
||
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, None, None, JDTLS_MIN_JAVA, &[], None);
|
||
assert_eq!((cfg.env["JAVA_HOME"].as_str(), cfg.launch_error), ("/custom", None));
|
||
|
||
let mixed = [old[0].clone(), Jdk { major: 21, home: PathBuf::from("/jdk21") }];
|
||
assert_eq!(jdtls_runtime(&mixed, 21).unwrap().major, 21);
|
||
|
||
// A jdtls whose bundles need 25 doesn't start on 21, and says what it needs.
|
||
let mut cfg = jdtls();
|
||
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &mixed, None, None, 25, &[], None);
|
||
let err = cfg.launch_error.unwrap();
|
||
assert!(err.contains("JDK 25 or newer") && err.contains("found JDK 17, 21"), "{err}");
|
||
}
|
||
|
||
#[test]
|
||
fn jdtls_runs_on_the_chosen_server_jdk() {
|
||
let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() };
|
||
let jdk = |major: u32, home: &str| Jdk { major, home: PathBuf::from(home) };
|
||
let installed = [jdk(25, "/jdk25"), jdk(21, "/jdk21"), jdk(17, "/jdk17")];
|
||
let project = jdk(17, "/jdk17");
|
||
let run = |server: Option<&Jdk>, min_java: u32| {
|
||
let mut cfg = jdtls();
|
||
configure_jdtls(
|
||
&mut cfg,
|
||
Path::new("/p"),
|
||
Path::new("/data"),
|
||
&installed,
|
||
Some(&project),
|
||
server,
|
||
min_java,
|
||
&[],
|
||
None,
|
||
);
|
||
cfg
|
||
};
|
||
|
||
// Project on JDK 17, server on the chosen 21: each keeps its own JDK.
|
||
let cfg = run(Some(&jdk(21, "/jdk21")), JDTLS_MIN_JAVA);
|
||
assert_eq!((cfg.env["JAVA_HOME"].as_str(), cfg.launch_error), ("/jdk21", None));
|
||
let opts = serde_json::to_value(cfg.initialization_options.unwrap()).unwrap();
|
||
let rt = &opts["settings"]["java"]["configuration"]["runtimes"];
|
||
assert_eq!(rt[0], json!({ "name": "JavaSE-17", "path": "/jdk17", "default": true }), "still compiles on 17");
|
||
|
||
// Not set: the first installed JDK new enough, as before.
|
||
assert_eq!(run(None, JDTLS_MIN_JAVA).env["JAVA_HOME"], "/jdk25");
|
||
|
||
// A chosen JDK outside the scanned locations.
|
||
let own = jdk(25, "/opt/own-jdk");
|
||
let cfg = run(Some(&own), 25);
|
||
assert_eq!(cfg.env["JAVA_HOME"], "/opt/own-jdk");
|
||
|
||
// Too old for this jdtls: the user's choice is not overridden, it is explained.
|
||
let cfg = run(Some(&jdk(21, "/jdk21")), 25);
|
||
assert!(!cfg.env.contains_key("JAVA_HOME"));
|
||
let err = cfg.launch_error.unwrap();
|
||
assert!(err.contains("/jdk21") && err.contains("JDK 25 or newer"), "{err}");
|
||
assert!(err.contains(SERVER_JDK_SETTING), "both messages point at the same setting: {err}");
|
||
}
|
||
|
||
#[test]
|
||
fn bundle_manifests_give_the_java_jdtls_needs() {
|
||
let ee = |m: &str| manifest_min_java(m);
|
||
assert_eq!(ee("Bundle-RequiredExecutionEnvironment: JavaSE-25\n"), Some(25));
|
||
assert_eq!(ee("Bundle-RequiredExecutionEnvironment: JavaSE-17, JavaSE-1.8\n"), Some(8));
|
||
// The capability wins, also when folded over continuation lines.
|
||
let folded = "Require-Capability: osgi.service;filter:=\"(x=1)\",osgi.ee;filter:=\"(&(osgi.ee=Ja\r\n \
|
||
vaSE)(version=21))\"\r\nBundle-RequiredExecutionEnvironment: JavaSE-17\r\n";
|
||
assert_eq!(ee(folded), Some(21));
|
||
assert_eq!(ee("Bundle-Name: x\n"), None);
|
||
|
||
let home = tempfile::tempdir().unwrap();
|
||
let plugins = home.path().join("plugins");
|
||
std::fs::create_dir(&plugins).unwrap();
|
||
let jar = |name: &str, manifest: &str| {
|
||
let mut zip = zip::ZipWriter::new(std::fs::File::create(plugins.join(name)).unwrap());
|
||
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||
zip.start_file("META-INF/MANIFEST.MF", opts).unwrap();
|
||
std::io::Write::write_all(&mut zip, manifest.as_bytes()).unwrap();
|
||
zip.finish().unwrap();
|
||
};
|
||
jar(
|
||
"org.eclipse.jdt.ls.core_1.63.0.jar",
|
||
"Require-Capability: osgi.ee;filter:=\"(&(osgi.ee=JavaSE)(version=21))\"\n",
|
||
);
|
||
jar("org.eclipse.core.filesystem_1.12.0.jar", "Bundle-RequiredExecutionEnvironment: JavaSE-25\n");
|
||
jar("plain.jar", "Manifest-Version: 1.0\n");
|
||
std::fs::write(plugins.join("notes.txt"), "not a jar").unwrap();
|
||
assert_eq!(bundles_min_java(home.path()), Some(25));
|
||
assert_eq!(bundles_min_java(&home.path().join("missing")), None);
|
||
}
|
||
|
||
#[test]
|
||
fn picks_the_jdk_matching_the_project() {
|
||
let jdks = [
|
||
Jdk { major: 25, home: PathBuf::from("/jdk25") },
|
||
Jdk { major: 21, home: PathBuf::from("/jdk21") },
|
||
Jdk { major: 17, home: PathBuf::from("/jdk17") },
|
||
];
|
||
assert_eq!(pick_jdk(&jdks, Some(21)).unwrap().major, 21);
|
||
assert_eq!(pick_jdk(&jdks, Some(19)).unwrap().major, 21, "oldest newer one");
|
||
assert_eq!(pick_jdk(&jdks, Some(26)).unwrap().major, 25, "newest as last resort");
|
||
assert_eq!(pick_jdk(&jdks, None).unwrap().major, 25);
|
||
assert!(pick_jdk(&[], Some(21)).is_none());
|
||
assert_eq!(java_executable(Path::new("/jdk21")), Path::new("/jdk21/bin").join(crate::exe("java")));
|
||
}
|
||
|
||
#[test]
|
||
fn project_java_versions() {
|
||
let tmp = tempfile::tempdir().unwrap();
|
||
let dir = tmp.path();
|
||
assert_eq!(project_java_version(dir), None);
|
||
std::fs::write(dir.join("pom.xml"), "<properties>\n<java.version>21</java.version>\n</properties>").unwrap();
|
||
assert_eq!(project_java_version(dir), Some(21));
|
||
std::fs::write(dir.join("pom.xml"), "<maven.compiler.source>1.8</maven.compiler.source>").unwrap();
|
||
assert_eq!(project_java_version(dir), Some(8));
|
||
std::fs::remove_file(dir.join("pom.xml")).unwrap();
|
||
std::fs::write(
|
||
dir.join("build.gradle.kts"),
|
||
"java {\n toolchain {\n languageVersion = JavaLanguageVersion.of(17)\n }\n}",
|
||
)
|
||
.unwrap();
|
||
assert_eq!(project_java_version(dir), Some(17));
|
||
std::fs::write(dir.join("build.gradle.kts"), "java { sourceCompatibility = JavaVersion.VERSION_11 }").unwrap();
|
||
assert_eq!(project_java_version(dir), Some(11));
|
||
std::fs::write(dir.join("build.gradle.kts"), "sourceCompatibility = '1.8'").unwrap();
|
||
assert_eq!(project_java_version(dir), Some(8));
|
||
}
|
||
}
|