From a46ae78035aae26c550f40f7a6a809666226bb60 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:07:21 +0800 Subject: [PATCH] fix(java): run the language server on a full JDK --- crates/ide-core/src/java_runtime.rs | 25 +++++ crates/ide-core/src/lib.rs | 1 + crates/ide-sdk/src/apply.rs | 3 + crates/ide-sdk/src/java.rs | 149 ++++++++++++++++++++++------ crates/ide-sdk/src/jdtls.rs | 20 +++- crates/ide-sdk/src/tests.rs | 13 ++- crates/ide-tools/src/jdk.rs | 106 +++++++++++--------- src/platform/locales/text/zh.json | 1 + 8 files changed, 236 insertions(+), 82 deletions(-) create mode 100644 crates/ide-core/src/java_runtime.rs diff --git a/crates/ide-core/src/java_runtime.rs b/crates/ide-core/src/java_runtime.rs new file mode 100644 index 00000000..83d1ee21 --- /dev/null +++ b/crates/ide-core/src/java_runtime.rs @@ -0,0 +1,25 @@ +//! Capabilities needed by the JVM hosting Java language-server bundles. + +use std::path::Path; + +/// jdtls' javac bundle, java-debug / hot code replace, and Lombok / Gradle tooling. +const BUNDLE_MODULES: &[&str] = &["jdk.compiler", "jdk.jdi", "jdk.unsupported"]; + +/// Require a full JDK, including the modules used by jdtls and its bundles. +/// Temurin JREs can list the build JDK's modules in `release` even when the runtime +/// image omits them, so module metadata alone does not establish suitability. +pub fn supports_java_bundles(home: &Path) -> bool { + let Ok(release) = std::fs::read_to_string(home.join("release")) else { return false }; + let property = + |key: &str| release.lines().find_map(|line| line.trim().strip_prefix(key)).map(|v| v.trim().trim_matches('"')); + let javac = if cfg!(windows) { "javac.exe" } else { "javac" }; + if property("IMAGE_TYPE=").is_some_and(|image| image.eq_ignore_ascii_case("jre")) + || !home.join("bin").join(javac).is_file() + { + return false; + } + match property("MODULES=") { + Some(modules) => BUNDLE_MODULES.iter().all(|required| modules.split_whitespace().any(|m| m == *required)), + None => BUNDLE_MODULES.iter().all(|m| home.join("jmods").join(format!("{m}.jmod")).is_file()), + } +} diff --git a/crates/ide-core/src/lib.rs b/crates/ide-core/src/lib.rs index fcb39a1f..023f9d72 100644 --- a/crates/ide-core/src/lib.rs +++ b/crates/ide-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod ids; pub mod ignored; pub mod inspection_settings; pub mod ipc_path; +pub mod java_runtime; pub mod jsonc; pub mod output_encoding; pub mod process; diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index a397529c..8f60fa5d 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -88,6 +88,9 @@ pub fn project_jdk(root: &Path, s: &Settings) -> Option { pub fn jdtls_jdk(root: &Path, s: &Settings) -> Result, String> { match jdk_setting(root, &s.languages.java.jdtls_jdk) { None => Ok(None), + Some(Ok(jdk)) if !ide_core::java_runtime::supports_java_bundles(&jdk.home) => { + Err(crate::java::server_jdk_incomplete_message(&jdk, jdk.major.max(crate::java::JDTLS_MIN_JAVA))) + } Some(Ok(jdk)) => Ok(Some(jdk)), Some(Err(p)) => Err(format!( "The language server JDK {} is not a JDK home (Settings › Languages & Frameworks › Java).", diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 814ccf7d..23835c3d 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -16,6 +16,13 @@ pub struct Jdk { 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="))?; @@ -199,7 +206,7 @@ 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) + 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 @@ -421,6 +428,9 @@ pub fn configure_jdtls( 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)), }; @@ -430,6 +440,10 @@ pub fn configure_jdtls( } 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))) { @@ -573,6 +587,19 @@ fn parse_java_version(v: &str) -> Option { 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(); @@ -599,8 +626,13 @@ mod tests { 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\"\n").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(); @@ -608,8 +640,13 @@ mod tests { 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\"\n").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)); @@ -628,11 +665,47 @@ mod tests { ); } + #[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 installed = - [Jdk { major: 21, home: PathBuf::from("/jdk21a") }, Jdk { major: 21, home: PathBuf::from("/jdk21b") }]; - let server = Jdk { major: 25, home: PathBuf::from("/custom-server-runtime") }; + 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(); @@ -642,7 +715,7 @@ mod tests { "name": "JavaSE-21", "path": pick_jdk(&installed, None).unwrap().home, "default": true }]) ); - assert_eq!(cfg.env["JAVA_HOME"], "/custom-server-runtime"); + assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("custom-server-runtime").to_string_lossy()); } #[test] @@ -655,15 +728,16 @@ mod tests { #[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 = [ - 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") }, + 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( @@ -690,11 +764,15 @@ mod tests { ); 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", "default": true })); + 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"], "/jdk25", "first JDK new enough to run jdtls"); + 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. @@ -713,7 +791,7 @@ mod tests { 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 project = test_jdk(fixtures.path(), 21, "/jdk21b"); let mut cfg: LanguageServerConfig = toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap(); configure_jdtls( &mut cfg, @@ -728,8 +806,8 @@ mod tests { ); 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"); + 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( @@ -836,8 +914,9 @@ mod tests { #[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 = [Jdk { major: 17, home: PathBuf::from("/jdk17") }, Jdk { major: 8, home: PathBuf::from("/jdk8") }]; + 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")); @@ -856,7 +935,7 @@ mod tests { 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") }]; + 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. @@ -869,11 +948,12 @@ mod tests { #[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 = [ - Jdk { major: 21, home: PathBuf::from("/jdk21") }, - Jdk { major: 17, home: PathBuf::from("/jdk17") }, - Jdk { major: 8, home: PathBuf::from("/jdk8") }, + 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); @@ -882,12 +962,11 @@ mod tests { assert!(err.contains("Download JDK 25"), "{err}"); // Once a JDK 25 has been downloaded, Auto runs jdtls on it. - let downloaded = - [installed.as_slice(), &[Jdk { major: 25, home: PathBuf::from("/tools/jdks/temurin-25") }]].concat(); + 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"], Path::new("/tools/jdks/temurin-25").to_string_lossy()); + assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("tools/jdks/temurin-25").to_string_lossy()); } #[test] @@ -924,8 +1003,9 @@ mod tests { #[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| Jdk { major, home: PathBuf::from(home) }; + 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| { @@ -946,18 +1026,25 @@ mod tests { // 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)); + 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": "/jdk17", "default": true }), "still compiles on 17"); + 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"], "/jdk25"); + 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"], "/opt/own-jdk"); + 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 cfg = run(Some(&jdk(21, "/jdk21")), 25); diff --git a/crates/ide-sdk/src/jdtls.rs b/crates/ide-sdk/src/jdtls.rs index f9ee7239..75520eed 100644 --- a/crates/ide-sdk/src/jdtls.rs +++ b/crates/ide-sdk/src/jdtls.rs @@ -249,6 +249,10 @@ pub fn launch_directly(cfg: &mut LanguageServerConfig) { }), }; let Some((java, jdk)) = jdk.filter(|(java, _)| java.is_file()) else { return }; + if !ide_core::java_runtime::supports_java_bundles(&jdk.home) { + cfg.launch_error = Some(crate::java::server_jdk_incomplete_message(&jdk, crate::java::jdtls_min_java(cfg))); + return; + } cfg.args = java_args(&install, jdk.major, &cfg.args); cfg.command = java.to_string_lossy().into_owned(); } @@ -465,7 +469,12 @@ mod tests { std::fs::set_permissions(&script, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap(); let jdk = dir.path().join("jdk-25"); std::fs::create_dir_all(jdk.join("bin")).unwrap(); - std::fs::write(jdk.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap(); + std::fs::write( + jdk.join("release"), + "JAVA_VERSION=\"25.0.1\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n", + ) + .unwrap(); + std::fs::write(jdk.join("bin/javac"), "").unwrap(); std::fs::write(jdk.join("bin/java"), "").unwrap(); let cfg = |command: &str| -> LanguageServerConfig { @@ -486,6 +495,15 @@ mod tests { assert!(direct.args[jar + 1].ends_with("org.eclipse.equinox.launcher_1.7.0.jar")); assert_eq!(direct.args[jar + 2..], strings(&["-data", "/ws"])); + let jre = dir.path().join("jre-25"); + std::fs::create_dir_all(jre.join("bin")).unwrap(); + std::fs::write(jre.join("bin/java"), "").unwrap(); + std::fs::write(jre.join("release"), "JAVA_VERSION=\"25\"\nIMAGE_TYPE=\"JRE\"\n").unwrap(); + let mut overridden = cfg("jdtls"); + overridden.args.push(format!("--java-executable={}", jre.join("bin/java").display())); + launch_directly(&mut overridden); + assert!(overridden.launch_error.unwrap().contains("jdk.jdi")); + // Someone's own wrapper, or no usable JDK: the command is kept. std::fs::write(home.join("bin/my-jdtls"), "#!/bin/sh\n").unwrap(); std::fs::set_permissions(home.join("bin/my-jdtls"), std::os::unix::fs::PermissionsExt::from_mode(0o755)) diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 37adca8b..5f3ed5a0 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -166,7 +166,12 @@ fn run_configs_use_selected_interpreter_and_package_manager() { fn fake_jdk(dir: &Path) { std::fs::create_dir_all(dir.join("bin")).unwrap(); std::fs::write(dir.join("bin").join(exe("java")), "").unwrap(); - std::fs::write(dir.join("release"), "JAVA_VERSION=\"17.0.9\"\n").unwrap(); + std::fs::write( + dir.join("release"), + "JAVA_VERSION=\"17.0.9\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n", + ) + .unwrap(); + std::fs::write(dir.join("bin").join(crate::exe("javac")), "").unwrap(); } #[test] @@ -307,6 +312,12 @@ fn jdtls_jdk_setting() { assert_eq!(jdtls_jdk(tmp.path(), &s), Ok(None)); s.languages.java.jdtls_jdk = "jdk17/bin".into(); assert_eq!(jdtls_jdk(tmp.path(), &s).unwrap().map(|j| j.major), Some(17)); + std::fs::write( + tmp.path().join("jdk17/release"), + "JAVA_VERSION=\"25\"\nIMAGE_TYPE=\"JRE\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n", + ) + .unwrap(); + assert!(jdtls_jdk(tmp.path(), &s).unwrap_err().contains("full JDK")); // A chosen path that is not a JDK is an error, not a silent fallback. s.languages.java.jdtls_jdk = "missing".into(); assert!(jdtls_jdk(tmp.path(), &s).unwrap_err().contains("is not a JDK home")); diff --git a/crates/ide-tools/src/jdk.rs b/crates/ide-tools/src/jdk.rs index a96ff292..b8264ec7 100644 --- a/crates/ide-tools/src/jdk.rs +++ b/crates/ide-tools/src/jdk.rs @@ -108,26 +108,7 @@ fn adoptium_arch(arch: Arch) -> &'static str { /// The latest Temurin JDK build of Java `feature` for `platform`. pub fn adoptium_assets_url(feature: u32, platform: Platform) -> String { - assets_url(feature, platform, ImageType::Jdk) -} - -#[derive(Clone, Copy)] -enum ImageType { - Jdk, - Jre, -} - -impl ImageType { - fn name(self) -> &'static str { - match self { - Self::Jdk => "jdk", - Self::Jre => "jre", - } - } -} - -fn assets_url(feature: u32, platform: Platform, image: ImageType) -> String { - let image = image.name(); + let image = "jdk"; format!( "{ADOPTIUM_API}/assets/latest/{feature}/hotspot?architecture={}&image_type={image}&os={}&vendor=eclipse", adoptium_arch(platform.arch), @@ -190,16 +171,12 @@ impl AssetVersion { /// The JDK archive (`.tar.gz` on macOS / Linux, `.zip` on Windows) in an Adoptium /// `assets/latest` response; `None` when there is no build for the platform. pub fn adoptium_pick(json: &str, platform: Platform) -> IdeResult> { - pick_image(json, platform, ImageType::Jdk) -} - -fn pick_image(json: &str, platform: Platform, image: ImageType) -> IdeResult> { let assets: Vec = serde_json::from_str(json).map_err(|e| proto(format!("unexpected Adoptium response: {e}")))?; let ext = if platform.os == Os::Windows { ".zip" } else { ".tar.gz" }; let os = adoptium_os(platform.os); let found = assets.into_iter().find(|a| { - a.binary.image_type.as_deref().is_none_or(|t| t == image.name()) + a.binary.image_type.as_deref().is_none_or(|t| t == "jdk") && a.binary.heap_size.as_deref().is_none_or(|h| h == "normal") && a.binary.os.as_deref().is_none_or(|o| o == os) && a.binary.package.name.ends_with(ext) @@ -249,6 +226,10 @@ pub fn find_jdk_home(dir: &Path, platform: Platform) -> Option { subdirs.iter().flat_map(|d| candidates(d)).find(|h| is_jdk_home(h, platform)) } +fn usable_java_home(dir: &Path, platform: Platform, runtime: bool) -> Option { + find_jdk_home(dir, platform).filter(|home| !runtime || ide_core::java_runtime::supports_java_bundles(home)) +} + /// The directory to move into place: the archive's single top-level folder /// holding the JDK, else everything that was unpacked. fn unpacked_root(pkg: &Path, home: &Path) -> PathBuf { @@ -283,8 +264,8 @@ impl Tools { self.download_java(vendor, feature, location, false, cancel, emit) } - /// Downloads a runtime for a language server, preferring a JRE. Only a - /// platform without a JRE build falls back to a full JDK. + /// Downloads a full JDK for a language server and its bundles (java-debug + /// needs `jdk.jdi`). Kept outside project JDK discovery roots. pub fn download_java_runtime( &self, feature: u32, @@ -310,19 +291,8 @@ impl Tools { let http = self.http()?; let location = location.map(Path::to_path_buf).unwrap_or_else(|| self.root.join("jdks")); emit(InstallEvent::Line { text: format!("Resolving the latest Eclipse Temurin {feature}…") }); - let mut picked = if runtime { - pick_image( - &http.get_text_or_empty_on_404(&assets_url(feature, self.platform, ImageType::Jre), cancel)?, - self.platform, - ImageType::Jre, - )? - } else { - None - }; - if picked.is_none() { - picked = - adoptium_pick(&http.get_text(&adoptium_assets_url(feature, self.platform), cancel)?, self.platform)?; - } + let mut picked = + adoptium_pick(&http.get_text(&adoptium_assets_url(feature, self.platform), cancel)?, self.platform)?; if picked.is_none() && matches!(self.platform.os, Os::Mac | Os::Windows) && self.platform.arch == Arch::Arm64 { // Older JDKs may need the OS-provided x64 emulation. let intel = Platform { os: self.platform.os, arch: Arch::X64 }; @@ -336,7 +306,7 @@ impl Tools { })?; let target = location.join(jdk_dir_name(vendor, &dl.version)); let done = |home: PathBuf| DownloadedJdk { home: home.into(), version: dl.version.clone(), major }; - if let Some(home) = find_jdk_home(&target, self.platform) { + if let Some(home) = usable_java_home(&target, self.platform, runtime) { emit(InstallEvent::Line { text: format!("{} is already installed", target.display()) }); return Ok(done(home)); } @@ -351,7 +321,7 @@ impl Tools { emit(InstallEvent::Line { text: "Extracting…".into() }); let pkg = staging.join("pkg"); archive::extract(&archive, archive::ArchiveKind::from_name(&dl.name), &pkg, "java", cancel)?; - let home = find_jdk_home(&pkg, self.platform) + let home = usable_java_home(&pkg, self.platform, runtime) .ok_or_else(|| IdeError::new(ErrorKind::Io, format!("no JDK found in {}", dl.name)))?; // Last point to stop: from here the JDK replaces what is at `target`. if cancel.is_cancelled() { @@ -362,7 +332,7 @@ impl Tools { } std::fs::rename(unpacked_root(&pkg, &home), &target)?; clear_quarantine(&target); - find_jdk_home(&target, self.platform) + usable_java_home(&target, self.platform, runtime) .ok_or_else(|| IdeError::new(ErrorKind::Io, format!("no JDK found in {}", target.display()))) })(); let _ = std::fs::remove_dir_all(&staging); @@ -437,18 +407,56 @@ mod tests { } #[test] - fn runtime_assets_use_jre_without_changing_jdk_downloads() { + fn runtime_assets_require_jdk_images() { for platform in [MAC_ARM, LINUX_X64, WIN_X64] { - assert!(assets_url(25, platform, ImageType::Jre).contains("image_type=jre")); assert!(adoptium_assets_url(25, platform).contains("image_type=jdk")); let ext = if platform.os == Os::Windows { "zip" } else { "tar.gz" }; let jdk = asset(&format!("java.{ext}"), adoptium_os(platform.os), "normal", ""); let jre = jdk.replace("\"image_type\":\"jdk\"", "\"image_type\":\"jre\""); - assert!(pick_image(&format!("[{jre}]"), platform, ImageType::Jre).unwrap().is_some()); + assert!(adoptium_pick(&format!("[{jre},{jdk}]"), platform).unwrap().is_some()); assert!(adoptium_pick(&format!("[{jre}]"), platform).unwrap().is_none()); - assert!(pick_image(&format!("[{jdk}]"), platform, ImageType::Jre).unwrap().is_none()); - assert!(pick_image("[]", platform, ImageType::Jre).unwrap().is_none()); - assert!(pick_image("offline", platform, ImageType::Jre).is_err()); + } + } + + #[test] + fn stale_runtime_is_replaced_even_at_the_same_version() { + for platform in [MAC_ARM, LINUX_X64, WIN_X64] { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join(jdk_dir_name(TEMURIN, "25.0.1")); + let home = if platform.os == Os::Mac { target.join("Contents/Home") } else { target.clone() }; + fake_jdk(&home, platform); + let javac = home.join("bin").join(if cfg!(windows) { "javac.exe" } else { "javac" }); + std::fs::write(&javac, "").unwrap(); + for modules in [None, Some("java.base java.compiler jdk.jdix"), Some("java.base jdk.jdi.agent")] { + let line = modules.map(|m| format!("MODULES=\"{m}\"\n")).unwrap_or_default(); + std::fs::write(home.join("release"), format!("JAVA_VERSION=\"25.0.1\"\n{line}")).unwrap(); + assert!(usable_java_home(&target, platform, true).is_none(), "{modules:?}"); + assert_eq!(usable_java_home(&target, platform, false), Some(home.clone())); + } + std::fs::write( + home.join("release"), + "JAVA_VERSION=\"25.0.1\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n", + ) + .unwrap(); + assert_eq!(usable_java_home(&target, platform, true), Some(home.clone())); + std::fs::write(home.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap(); + std::fs::create_dir_all(home.join("jmods")).unwrap(); + for module in ["jdk.compiler", "jdk.jdi", "jdk.unsupported"] { + std::fs::write(home.join("jmods").join(format!("{module}.jmod")), "").unwrap(); + } + assert_eq!(usable_java_home(&target, platform, true), Some(home.clone())); + std::fs::write( + home.join("release"), + "IMAGE_TYPE=\"JRE\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n", + ) + .unwrap(); + assert!(usable_java_home(&target, platform, true).is_none(), "Temurin JRE release can list absent modules"); + std::fs::write(home.join("release"), "MODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n") + .unwrap(); + std::fs::remove_file(javac).unwrap(); + assert!(usable_java_home(&target, platform, true).is_none(), "custom runtime without compiler"); + std::fs::write(home.join("release"), "MODULES=\"java.base\"\n").unwrap(); + assert!(usable_java_home(&target, platform, true).is_none(), "release describes the runtime image"); } } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 844d5081..f12eafbd 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,5 @@ { + "The language server JDK ({path}) must be a full JDK with jdk.compiler, jdk.jdi and jdk.unsupported for Java language support and debugging. jdtls needs JDK {version} or newer. Download JDK {version}, choose a full JDK in {setting}, or set it to Auto.": "语言服务器 JDK({path})必须是包含 jdk.compiler、jdk.jdi 和 jdk.unsupported 的完整 JDK,才能支持 Java 语言功能和调试。jdtls 需要 JDK {version} 或更高版本。请下载 JDK {version},在 {setting} 中选择完整 JDK,或设为自动。", "Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.": "Apple 的 clangd 不运行 clang-tidy,因此不会显示 .clang-tidy 中配置的检查。请安装 clangd(LLVM)以启用这些检查。", "Newer dependency version available": "有更新的依赖版本可用", "Reports newer crate versions that Cargo.toml requirements do not already allow.": "报告 Cargo.toml 版本要求尚未允许的较新 crate 版本。",