//! 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, } pub(crate) fn server_jdk_incomplete_message(jdk: &Jdk, min_java: u32) -> String { format!( "The language server JDK ({}) must be a full JDK with jdk.compiler, jdk.jdi and jdk.unsupported for Java language support and debugging. jdtls needs JDK {min_java} or newer. Download JDK {min_java}, choose a full JDK in {SERVER_JDK_SETTING}, or set it to Auto.", jdk.home.display() ) } /// `JAVA_VERSION="21.0.3"` (or `"1.8.0_372"`) from a JDK's `release` file. pub fn parse_release(release: &str) -> Option { 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 { 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: crate::launch_path(home).unwrap_or_else(|_| home.to_path_buf()) }) } fn children(dir: &Path) -> Vec { let mut out: Vec = std::fs::read_dir(dir).map(|rd| rd.flatten().map(|e| e.path()).collect()).unwrap_or_default(); out.sort(); out } fn jdk_homes(root: &Path) -> impl Iterator { children(root) .into_iter() .flat_map(|dir| [dir.join("Contents/Home"), dir.join("libexec/openjdk.jdk/Contents/Home"), dir]) } /// 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"]; /// Folders the IDE downloads JDKs into ([`add_jdk_root`]). static EXTRA_ROOTS: Mutex> = Mutex::new(Vec::new()); /// Scans the JDKs in `dir` (one per subfolder) too, e.g. where Download JDK puts them. pub fn add_jdk_root(dir: &Path) { let mut roots = EXTRA_ROOTS.lock().unwrap_or_else(|e| e.into_inner()); if !roots.iter().any(|r| r == dir) { roots.push(dir.to_path_buf()); } } /// Whether `home` lies in a folder registered with [`add_jdk_root`]. pub fn is_downloaded(home: &Path) -> bool { let roots = EXTRA_ROOTS.lock().unwrap_or_else(|e| e.into_inner()); roots.iter().any(|r| home.starts_with(r) || dunce::canonicalize(r).is_ok_and(|r| home.starts_with(r))) } /// JDK homes in the usual install locations (macOS `JavaVirtualMachines`, /// `/usr/lib/jvm`, SDKMAN, mise, asdf, IDEA's `~/.jdks`, Homebrew, Windows vendor /// directories, the IDE's downloads), `JAVA_HOME` first, then the `java` on `PATH`. fn scan() -> Vec { let mut homes: Vec = 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 `/bin/java`. if let Some(home) = crate::launch_path(&java).ok().and_then(|j| j.parent()?.parent().map(Path::to_path_buf)) { homes.push(home); } } let home = home_dir(); let mut roots: Vec = 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")]); } roots.extend(EXTRA_ROOTS.lock().unwrap_or_else(|e| e.into_inner()).iter().cloned()); 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; } homes.extend(jdk_homes(root)); } // mise keeps a macOS JDK as its bundle (`/Contents/Home`), asdf flattens it. for (dir, _) in ide_core::version_managers::installs(ide_core::version_managers::Tool::Java) { homes.extend([dir.join("Contents/Home"), dir]); } let mut out: Vec = 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> = Mutex::new(None); fn rescan(slot: &mut Option) -> 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, data_dir: &Path) -> Arc<[Jdk]> { let mut slot = JDKS.lock().unwrap_or_else(|e| e.into_inner()); let installed = match &*slot { Some(s) if jdtls_runtime(&s.jdks, min_java).is_some() || s.at.elapsed() < RESCAN_AFTER => s.jdks.clone(), _ => rescan(&mut slot), }; runtime_candidates(&installed, data_dir).into() } /// Runtime-only downloads are never registered as project discovery roots. pub fn language_server_runtimes_dir(data_dir: &Path) -> PathBuf { data_dir.join("language-server-runtimes") } fn runtime_candidates(installed: &[Jdk], data_dir: &Path) -> Vec { let mut jdks = installed.to_vec(); jdks.extend(jdk_homes(&language_server_runtimes_dir(data_dir)).filter_map(|home| jdk_at(&home))); jdks } /// For a server with `max-java`: when the `JAVA_HOME` it would get (its env, /// else the IDE's) is a newer JDK, points it at the newest of `jdks` that fits. pub fn cap_java_home(cfg: &mut LanguageServerConfig, jdks: &[Jdk]) { let Some(max) = cfg.max_java else { return }; let current = cfg.env.get("JAVA_HOME").cloned().or_else(|| std::env::var("JAVA_HOME").ok()); if current.as_deref().and_then(|h| jdk_at(Path::new(h))).is_some_and(|j| j.major <= max) { return; } if let Some(jdk) = jdks.iter().filter(|j| j.major <= max).max_by_key(|j| j.major) { cfg.env.insert("JAVA_HOME".into(), jdk.home.to_string_lossy().into_owned()); } } /// 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 && ide_core::java_runtime::supports_java_bundles(&j.home)) } /// 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)>> = 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 { 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 { 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 { // Long headers continue on lines starting with a space. let mut headers: Vec = 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::().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 { let mut by_major: BTreeMap = 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 { text_key(&p.to_string_lossy()) } fn text_key(text: &str) -> String { let mut h: u64 = 0xcbf29ce484222325; for b in text.as_bytes() { h ^= *b as u64; h = h.wrapping_mul(0x100000001b3); } format!("{h:016x}") } /// Adds `arg` as its own `--jvm-arg`, unless the user already set the property it /// carries: a value of theirs wins, and a second call adds nothing. fn set_jvm_property(args: &mut Vec, arg: &str) { let key = arg.split('=').next().unwrap_or(arg); let taken = args.iter().enumerate().any(|(i, a)| { let value = a.strip_prefix("--jvm-arg=").or_else(|| (i > 0 && args[i - 1] == "--jvm-arg").then_some(a.as_str())); value.is_some_and(|a| a.split('=').next() == Some(key)) }); if !taken { args.push(format!("--jvm-arg={arg}")); } } fn add_jdtls_proxy_args(args: &mut Vec, proxy: &ide_core::proxy_settings::ProxySettings) { for arg in proxy.jvm_args() { set_jvm_property(args, &arg); } } const METADATA_PROPERTY: &str = "java.import.generatesMetadataFilesAtProjectRoot"; /// JDK 25's `ZipFile` validates the ZIP64 extra field, and jars written by older tools /// carry a size that check rejects: ECJ reports the dependency as "cannot be read or is /// not a valid ZIP file", the project using it gets a build path error, and every project /// depending on that one stops compiling — aspectjweaver 1.8.10 stalls a whole Maven /// build that way, while the same jar loads fine on IDEA's JBR 21. This is how 25 is told /// to behave the same. const ZIP64_LENIENCY: &str = "-Djdk.util.zip.disableZip64ExtraFieldValidation=true"; /// 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 mut key = match agent { Some(jar) => format!("{}-{}", path_key(server_root), path_key(jar)), None => path_key(server_root), }; if let Some(paths) = cfg.settings.as_ref().and_then(|s| s.pointer("/java/project/sourcePaths")) { // Invisible projects retain a cached resource tree across restarts. // A different source model needs a clean import, including new folders. key.push_str(&format!("-sources-{}", text_key(&paths.to_string()))); } 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())); } crate::jdtls::add_default_vm_args(&mut cfg.args); // jdtls resolves Maven dependencies through its own HTTP client, and a JVM reads none // of the `HTTP_PROXY` / `HTTPS_PROXY` it inherits: pass the same proxy as system // properties. One the user set in their own JVM arguments wins. add_jdtls_proxy_args(&mut cfg.args, &ide_core::proxy_settings::proxy()); // ECJ reads the project's jars with the server JVM's own zip reader, so a jar JDK 25 // refuses to open takes the whole Maven build with it. IDEA never notices: its JBR 21 // accepts the same file. set_jvm_property(&mut cfg.args, ZIP64_LENIENCY); let managed = language_server_runtimes_dir(data_dir); let managed = dunce::canonicalize(&managed).unwrap_or(managed); let project_jdks: Vec<_> = project_jdk .into_iter() .chain(jdks) .filter(|jdk| !jdk.home.starts_with(&managed) || project_jdk == Some(*jdk)) .cloned() .collect(); let mut all: Vec = 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) if !ide_core::java_runtime::supports_java_bundles(&jdk.home) => { Err(server_jdk_incomplete_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), } } else if let Some(jdk) = cfg.env.get("JAVA_HOME").and_then(|home| jdk_at(Path::new(home))) && !ide_core::java_runtime::supports_java_bundles(&jdk.home) { cfg.launch_error = Some(server_jdk_incomplete_message(&jdk, min_java)); } let mut runtimes = runtimes(&project_jdks); if let Some(project) = project_jdk.or_else(|| pick_jdk(&project_jdks, project_java_version(server_root))) { let name = if project.major == 8 { "JavaSE-1.8".to_owned() } else { format!("JavaSE-{}", project.major) }; for r in &mut runtimes { if r["name"] == name { r["path"] = json!(project.home.to_string_lossy()); 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 = 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"; /// `found JDK 8, 17` / `no JDK was found`. fn found_jdks(jdks: &[Jdk]) -> String { let mut found: Vec = jdks.iter().map(|j| j.major).collect(); found.sort_unstable(); found.dedup(); if found.is_empty() { "no JDK was found".to_owned() } else { let list: Vec = found.iter().map(u32::to_string).collect(); format!("found JDK {}", list.join(", ")) } } // The UI offers "Download JDK N" for messages saying "needs JDK N or newer". fn missing_runtime_message(jdks: &[Jdk], min_java: u32) -> String { format!( "jdtls needs JDK {min_java} or newer to run ({}). Download JDK {min_java}, or pick an installed one in \ {SERVER_JDK_SETTING} (it restarts automatically); the project can still target an older Java version.", found_jdks(jdks) ) } 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. Download JDK \ {min_java}, choose another one in {SERVER_JDK_SETTING}, or set it to Auto.", jdk.home.display(), jdk.major ) } /// Why a module targeting Java `release` can't be built or run with the installed /// JDKs (none is that new), or `None` when one is (or the release is unknown). pub fn missing_jdk_message(jdks: &[Jdk], release: Option) -> Option { let release = release?; if jdks.iter().any(|j| j.major >= release) { return None; } Some(format!( "This project targets Java {release}: it needs JDK {release} or newer ({}). Download JDK {release}, or \ choose a Project JDK in Settings › Languages & Frameworks › Java.", found_jdks(jdks) )) } /// 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) -> 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: ``, /// `` / ``, Gradle toolchains /// (`JavaLanguageVersion.of(21)`) or `sourceCompatibility`. pub fn project_java_version(module_dir: &Path) -> Option { 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::() .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 { 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 { 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::*; fn test_jdk(root: &Path, major: u32, name: &str) -> Jdk { let home = root.join(name.trim_start_matches('/')); std::fs::create_dir_all(home.join("bin")).unwrap(); std::fs::write( home.join("release"), format!("JAVA_VERSION=\"{major}\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n"), ) .unwrap(); std::fs::write(java_executable(&home), "").unwrap(); std::fs::write(home.join("bin").join(crate::exe("javac")), "").unwrap(); Jdk { major, home } } #[test] fn unmanaged_source_models_have_separate_jdtls_workspaces() { let data = tempfile::tempdir().unwrap(); let workspace = |paths: Option| { let mut cfg: LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap(); if let Some(paths) = paths { cfg.settings = Some(json!({"java": {"project": {"sourcePaths": paths}}})); } configure_jdtls(&mut cfg, Path::new("/project"), data.path(), &[], None, None, 25, &[], None); cfg.args.windows(2).find(|a| a[0] == "-data").unwrap()[1].clone() }; let inferred = workspace(None); let source = workspace(Some(json!(["src/main/java"]))); let tests = workspace(Some(json!(["src/main/java", "src/test/java"]))); assert_ne!(inferred, source); assert_ne!(source, tests); assert_eq!(source, workspace(Some(json!(["src/main/java"])))); assert_eq!(inferred, workspace(None), "build-tool projects retain their existing cache key"); } #[test] fn provisioning_runtime_preserves_implicit_project_jdk() { let data = tempfile::tempdir().unwrap(); let project_root = data.path().join("tools/jdks"); let project_home = project_root.join("temurin-21"); std::fs::create_dir_all(project_home.join("bin")).unwrap(); std::fs::write( project_home.join("release"), "JAVA_VERSION=\"21.0.1\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n", ) .unwrap(); std::fs::write(java_executable(&project_home), "").unwrap(); std::fs::write(project_home.join("bin").join(crate::exe("javac")), "").unwrap(); let project = jdk_at(&project_home).unwrap(); let discover = || jdk_homes(&project_root).filter_map(|home| jdk_at(&home)).collect::>(); let installed = discover(); assert_eq!(pick_jdk(&installed, None), Some(&project)); assert!(jdtls_runtime(&runtime_candidates(&installed, data.path()), 25).is_none()); let home = language_server_runtimes_dir(data.path()).join("temurin-25"); std::fs::create_dir_all(home.join("bin")).unwrap(); std::fs::write( home.join("release"), "JAVA_VERSION=\"25.0.1\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n", ) .unwrap(); std::fs::write(java_executable(&home), "").unwrap(); std::fs::write(home.join("bin").join(crate::exe("javac")), "").unwrap(); let candidates = runtime_candidates(&installed, data.path()); assert_eq!(jdtls_runtime(&candidates, 25).unwrap().major, 25); assert_eq!(jdtls_runtime(&candidates, 21), Some(&project)); assert_eq!(pick_jdk(&discover(), None), Some(&project)); let explicit = jdk_at(&home).unwrap(); assert_eq!(pick_jdk(std::slice::from_ref(&explicit), None), Some(&explicit)); let mut cfg: LanguageServerConfig = serde_json::from_value(json!({"name":"jdtls", "command":"jdtls"})).unwrap(); configure_jdtls(&mut cfg, data.path(), data.path(), &candidates, None, None, 25, &[], None); assert_eq!(cfg.env["JAVA_HOME"], explicit.home.to_string_lossy()); let options = serde_json::to_string(&cfg.initialization_options).unwrap(); assert!(!options.contains("temurin-25"), "runtime-only JDK must not become a compiler runtime"); assert_eq!( cfg.initialization_options.as_ref().unwrap()["settings"]["java"]["configuration"]["runtimes"][0]["default"] .as_bool(), Some(true) ); } #[test] fn jdtls_rejects_jre_for_auto_explicit_and_managed_runtimes() { let tmp = tempfile::tempdir().unwrap(); let jre = test_jdk(tmp.path(), 25, "jre"); std::fs::write(jre.home.join("release"), "JAVA_VERSION=\"25\"\nMODULES=\"java.base java.compiler\"\n").unwrap(); let jdk = test_jdk(tmp.path(), 25, "jdk"); let installed = [jre.clone(), jdk.clone()]; assert_eq!(jdtls_runtime(&installed, 25), Some(&jdk)); assert!(jdtls_runtime(std::slice::from_ref(&jre), 25).is_none()); let managed = language_server_runtimes_dir(tmp.path()).join("temurin-25"); std::fs::create_dir_all(managed.parent().unwrap()).unwrap(); std::fs::rename(&jre.home, &managed).unwrap(); assert!(jdtls_runtime(&runtime_candidates(&[], tmp.path()), 25).is_none()); let jre = jdk_at(&managed).unwrap(); for env_override in [false, true] { let mut cfg: LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap(); if env_override { cfg.env.insert("JAVA_HOME".into(), jre.home.to_string_lossy().into_owned()); } configure_jdtls(&mut cfg, tmp.path(), tmp.path(), &installed[1..], None, Some(&jre), 25, &[], None); let err = cfg.launch_error.unwrap(); assert!( err.contains("full JDK with jdk.compiler, jdk.jdi and jdk.unsupported") && err.contains("Download JDK 25") && err.contains(SERVER_JDK_SETTING), "{err}" ); } std::fs::write( managed.join("release"), "JAVA_VERSION=\"25\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n", ) .unwrap(); assert!(jdtls_runtime(&runtime_candidates(&[], tmp.path()), 25).is_some()); } #[test] fn implicit_compiler_default_matches_project_pick_with_duplicate_versions() { let fixtures = tempfile::tempdir().unwrap(); let installed = [test_jdk(fixtures.path(), 21, "/jdk21a"), test_jdk(fixtures.path(), 21, "/jdk21b")]; let server = test_jdk(fixtures.path(), 25, "/custom-server-runtime"); let mut cfg: LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap(); configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &installed, None, Some(&server), 25, &[], None); let opts = serde_json::to_value(cfg.initialization_options.unwrap()).unwrap(); assert_eq!( opts["settings"]["java"]["configuration"]["runtimes"], json!([{ "name": "JavaSE-21", "path": pick_jdk(&installed, None).unwrap().home, "default": true }]) ); assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("custom-server-runtime").to_string_lossy()); } #[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() { let fixtures = tempfile::tempdir().unwrap(); if cfg!(windows) { return; // Unix paths and shells } let mut cfg: LanguageServerConfig = toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap(); let jdks = [ test_jdk(fixtures.path(), 25, "/jdk25"), test_jdk(fixtures.path(), 21, "/jdk21a"), test_jdk(fixtures.path(), 21, "/jdk21b"), test_jdk(fixtures.path(), 8, "/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, ); let defaults: Vec = crate::jdtls::DEFAULT_VM_ARGS.iter().map(|a| format!("--jvm-arg={a}")).collect(); let n = defaults.len(); assert_eq!(cfg.args[..n], defaults, "our JVM options come first"); assert_eq!(cfg.args[n], "-data"); assert!(cfg.args[n + 1].starts_with("/data/lsp/jdtls/"), "{}", cfg.args[n + 1]); assert_eq!(cfg.args[n + 2], "--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false"); assert!( cfg.args.iter().any(|a| a == "--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=true"), "jars JDK 25 rejects must still load: {:?}", cfg.args ); 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": fixtures.path().join("jdk8") })); assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": fixtures.path().join("jdk21a") })); assert_eq!(rt[2], json!({ "name": "JavaSE-25", "path": fixtures.path().join("jdk25"), "default": true })); assert_eq!(opts["bundles"], json!(["/tools/java-debug/bin/com.microsoft.java.debug.plugin.jar"])); assert_eq!( cfg.env["JAVA_HOME"], fixtures.path().join("jdk25").to_string_lossy(), "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 = test_jdk(fixtures.path(), 21, "/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": fixtures.path().join("jdk21b"), "default": true })); assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("jdk21b").to_string_lossy()); 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_proxy_properties_respect_exact_user_overrides() { use ide_core::proxy_settings::{ProxyMode, ProxySettings}; let proxy = ProxySettings { mode: ProxyMode::Manual, host: "ide.proxy".into(), port: 3128, ..Default::default() }; for (user_arg, overridden) in [ ("--jvm-arg=-Dhttp.proxyHost=user.proxy", true), ("--jvm-arg=-Dhttp.proxyHost", true), ("--jvm-arg=-Dhttp.proxyHostSuffix=unrelated", false), ("--jvm-arg=-Dother=-Dhttp.proxyHost", false), ("/workspace/-Dhttp.proxyHost", false), ] { let mut forms = vec![vec![user_arg.to_owned()]]; if let Some(value) = user_arg.strip_prefix("--jvm-arg=") { forms.push(vec!["--jvm-arg".into(), value.into()]); } for mut args in forms { let user_args = args.clone(); add_jdtls_proxy_args(&mut args, &proxy); assert_eq!(args[..user_args.len()], user_args); assert_eq!(args.contains(&"--jvm-arg=-Dhttp.proxyHost=ide.proxy".into()), !overridden, "{user_arg}"); assert!(args.contains(&"--jvm-arg=-Dhttps.proxyHost=ide.proxy".into())); let before = args.clone(); add_jdtls_proxy_args(&mut args, &proxy); assert_eq!(args, before, "idempotent: {user_arg}"); } } } #[test] fn a_jar_jdk_25_rejects_loads_and_the_users_value_wins() { // aspectjweaver 1.8.10 carries a ZIP64 extra field size 25 refuses to read; on the // JVM that runs jdtls that is a build path error in every project using it. let mut args = Vec::new(); set_jvm_property(&mut args, ZIP64_LENIENCY); assert_eq!(args, ["--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=true"]); set_jvm_property(&mut args, ZIP64_LENIENCY); assert_eq!(args.len(), 1, "added once"); for user in [ "--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=false".to_owned(), "--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation".to_owned(), ] { let mut own = vec![user.clone()]; set_jvm_property(&mut own, ZIP64_LENIENCY); assert_eq!(own, [user], "the user decides"); } } #[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); let data = |c: &LanguageServerConfig| c.args[c.args.iter().position(|a| a == "-data").unwrap() + 1].clone(); assert_ne!(data(&cfg), data(&plain), "a separate workspace, so everything is rebuilt with the agent"); let patched = Path::new("/managed/lombok-1.18.48-openidea.2.jar"); let mut fixed = jdtls(); configure_jdtls( &mut fixed, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], Some(patched), ); assert_ne!(data(&cfg), data(&fixed), "the patch must rebuild classes compiled with the official 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 fixtures = tempfile::tempdir().unwrap(); let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() }; let old = [test_jdk(fixtures.path(), 17, "/jdk17"), test_jdk(fixtures.path(), 8, "/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(), test_jdk(fixtures.path(), 21, "/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}"); assert!(err.contains("Download JDK 25"), "offers the download: {err}"); } #[test] fn issue_49_jdtls_needs_25_with_8_17_21_installed() { let fixtures = tempfile::tempdir().unwrap(); let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() }; let installed = [ test_jdk(fixtures.path(), 21, "/jdk21"), test_jdk(fixtures.path(), 17, "/jdk17"), test_jdk(fixtures.path(), 8, "/jdk8"), ]; let mut cfg = jdtls(); configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &installed, None, None, 25, &[], None); let err = cfg.launch_error.unwrap(); assert!(err.starts_with("jdtls needs JDK 25 or newer to run (found JDK 8, 17, 21)"), "{err}"); assert!(err.contains("Download JDK 25"), "{err}"); // Once a JDK 25 has been downloaded, Auto runs jdtls on it. let downloaded = [installed.as_slice(), &[test_jdk(fixtures.path(), 25, "/tools/jdks/temurin-25")]].concat(); let mut cfg = jdtls(); configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &downloaded, None, None, 25, &[], None); assert_eq!(cfg.launch_error, None); assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("tools/jdks/temurin-25").to_string_lossy()); } #[test] fn missing_jdk_for_the_project_release() { let jdks = [Jdk { major: 17, home: PathBuf::from("/jdk17") }, Jdk { major: 8, home: PathBuf::from("/jdk8") }]; assert_eq!(missing_jdk_message(&jdks, Some(17)), None); assert_eq!(missing_jdk_message(&jdks, Some(11)), None); assert_eq!(missing_jdk_message(&jdks, None), None); let msg = missing_jdk_message(&jdks, Some(21)).unwrap(); assert!(msg.contains("needs JDK 21 or newer (found JDK 8, 17)") && msg.contains("Download JDK 21"), "{msg}"); assert!(missing_jdk_message(&[], Some(8)).unwrap().contains("no JDK was found")); } #[test] fn downloaded_jdks_are_found() { let tmp = tempfile::tempdir().unwrap(); let home = tmp.path().join("jdks/temurin-25.0.1"); std::fs::create_dir_all(home.join("bin")).unwrap(); std::fs::write(home.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap(); std::fs::write(home.join("bin").join(crate::exe("java")), "").unwrap(); // macOS bundles: /Contents/Home let bundle = tmp.path().join("jdks/temurin-21.0.5/Contents/Home"); std::fs::create_dir_all(bundle.join("bin")).unwrap(); std::fs::write(bundle.join("release"), "JAVA_VERSION=\"21.0.5\"\n").unwrap(); std::fs::write(bundle.join("bin").join(crate::exe("java")), "").unwrap(); add_jdk_root(&tmp.path().join("jdks")); let found = rescan_jdks(); let home = dunce::canonicalize(&home).unwrap(); let bundle = dunce::canonicalize(&bundle).unwrap(); assert!(found.iter().any(|j| j.home == home && j.major == 25), "{found:?}"); assert!(found.iter().any(|j| j.home == bundle && j.major == 21), "{found:?}"); assert!(is_downloaded(&home) && !is_downloaded(Path::new("/elsewhere"))); } #[test] fn jdtls_runs_on_the_chosen_server_jdk() { let fixtures = tempfile::tempdir().unwrap(); let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() }; let jdk = |major: u32, home: &str| test_jdk(fixtures.path(), major, 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"].clone(), cfg.launch_error), (fixtures.path().join("jdk21").to_string_lossy().into_owned(), 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": fixtures.path().join("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"], fixtures.path().join("jdk25").to_string_lossy()); // 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"], fixtures.path().join("opt/own-jdk").to_string_lossy()); // Too old for this jdtls: the user's choice is not overridden, it is explained. let old = jdk(21, "/jdk21"); let cfg = run(Some(&old), 25); assert!(!cfg.env.contains_key("JAVA_HOME")); let err = cfg.launch_error.unwrap(); // The message embeds jdk.home as written to disk, so match that instead of the // "/jdk21" literal: its separators are platform-native since test_jdk // materializes real directories. assert!(err.contains(old.home.to_string_lossy().as_ref()) && 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"), "\n21\n").unwrap(); assert_eq!(project_java_version(dir), Some(21)); std::fs::write(dir.join("pom.xml"), "1.8").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)); } } /// The same jar supplies the JDT command and the private Maven output redirector. pub fn kotlin_bridge_bundle(data_dir: &Path) -> Option { let extension = data_dir.join("kotlin-bridge-1.0.0.jar"); let bytes = include_bytes!("../../../java/kotlin-bridge/openidea-kotlin-bridge.jar"); if std::fs::read(&extension).is_ok_and(|old| old == bytes) { return Some(extension); } let temporary = data_dir.join(format!( "kotlin-bridge-{}-{}.tmp", std::process::id(), std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?.as_nanos() )); let result = std::fs::create_dir_all(data_dir) .and_then(|_| std::fs::write(&temporary, bytes)) .and_then(|_| std::fs::rename(&temporary, &extension)); if let Err(error) = result { let _ = std::fs::remove_file(temporary); tracing::warn!("Kotlin bridge extension unavailable: {error}"); return std::fs::read(&extension).is_ok_and(|old| old == bytes).then_some(extension); } Some(extension) } #[cfg(test)] mod kotlin_bridge_bundle_tests { #[test] fn embeds_both_runtime_targets_and_repairs_a_damaged_installation() { let directory = tempfile::tempdir().unwrap(); let path = super::kotlin_bridge_bundle(directory.path()).unwrap(); let expected = std::fs::read(&path).unwrap(); let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&expected)).unwrap(); for (class, major) in [("KotlinLibraries", 65), ("IsolatedOutput", 52)] { use std::io::Read; let mut bytes = Vec::new(); archive.by_name(&format!("ai/openidea/kotlin/{class}.class")).unwrap().read_to_end(&mut bytes).unwrap(); assert_eq!(u16::from_be_bytes([bytes[6], bytes[7]]), major); } std::fs::write(&path, "incomplete").unwrap(); assert_eq!(super::kotlin_bridge_bundle(directory.path()), Some(path.clone())); assert_eq!(std::fs::read(path).unwrap(), expected); } } /// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure. pub fn usages_bundle(data_dir: &Path) -> Option { let extension = data_dir.join("java-usages-1.5.0.jar"); let bytes = include_bytes!("../../../java/java-usages/openidea-java-usages.jar"); if std::fs::read(&extension).is_ok_and(|old| old == bytes) { clean_usages_bundles(data_dir, &extension); return Some(extension); } // Write then rename: another window must never read a partially written jar. let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?.as_nanos(); let temporary = data_dir.join(format!("java-usages-{}-{nonce}.tmp", std::process::id())); let result = std::fs::create_dir_all(data_dir) .and_then(|_| std::fs::write(&temporary, bytes)) .and_then(|_| std::fs::rename(&temporary, &extension)); match result { Ok(()) => { clean_usages_bundles(data_dir, &extension); Some(extension) } Err(e) => { let _ = std::fs::remove_file(&temporary); if std::fs::read(&extension).is_ok_and(|old| old == bytes) { clean_usages_bundles(data_dir, &extension); return Some(extension); } tracing::warn!("Java usages extension unavailable; using standard references: {e}"); None } } } fn clean_usages_bundles(data_dir: &Path, current: &Path) { let Ok(entries) = std::fs::read_dir(data_dir) else { return }; for entry in entries.flatten() { let path = entry.path(); let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue }; let Some(suffix) = name.strip_prefix("java-usages-") else { continue }; let numeric = |s: &str| !s.is_empty() && s.bytes().all(|c| c.is_ascii_digit()); let old_jar = suffix.strip_suffix(".jar").is_some_and(|v| { let parts: Vec<_> = v.split('.').collect(); parts.len() == 3 && parts.into_iter().all(numeric) }) && path != current; // Preserve recent temporary files: another window may still be installing. let stale_temp = suffix .strip_suffix(".tmp") .and_then(|v| v.split_once('-')) .is_some_and(|(pid, nonce)| numeric(pid) && numeric(nonce)) && entry .metadata() .ok() .and_then(|m| m.modified().ok()) .and_then(|t| t.elapsed().ok()) .is_some_and(|age| age.as_secs() > 24 * 60 * 60); if old_jar || stale_temp { // A running server can hold an old bundle open on Windows. let _ = std::fs::remove_file(path); } } } #[cfg(test)] mod usages_bundle_tests { #[test] fn cleans_old_bundles_and_abandoned_temporary_files_on_install_and_reuse() { let dir = tempfile::tempdir().unwrap(); for reuse in [false, true] { let old = dir.path().join("java-usages-1.0.0.jar"); let stale = dir.path().join("java-usages-123-456.tmp"); std::fs::write(&old, b"old").unwrap(); std::fs::write(&stale, b"partial").unwrap(); let times = std::fs::FileTimes::new() .set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(48 * 60 * 60)); std::fs::File::options().write(true).open(&stale).unwrap().set_times(times).unwrap(); for name in ["java-usages-123-789.tmp", "java-usages-custom.jar", "other.jar", "java-usages-not-ours.tmp"] { std::fs::write(dir.path().join(name), b"keep").unwrap(); } let jar = super::usages_bundle(dir.path()).unwrap(); assert!(jar.exists()); assert!(!old.exists(), "reuse={reuse}"); assert!(!stale.exists(), "reuse={reuse}"); assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 5); } } #[test] fn installs_the_exact_bundle_and_reuses_it() { let dir = tempfile::tempdir().unwrap(); let jar = super::usages_bundle(dir.path()).unwrap(); assert_eq!(std::fs::read(&jar).unwrap(), include_bytes!("../../../java/java-usages/openidea-java-usages.jar")); assert_eq!(super::usages_bundle(dir.path()), Some(jar)); assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); } }