diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 0135b185c..7b38c32f1 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -905,6 +905,8 @@ pub enum GradleDistribution { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct MavenSettings { + /// JDK home used to run Maven; empty = Project JDK, else the POM's Java version. + pub runner_jre: String, /// Project-relative root POMs removed from the Maven project model. pub unlinked_projects: Vec, /// Project-relative POMs outside the root reactor added as independent top-level projects. @@ -1329,6 +1331,17 @@ mod tests { mod maven_linkage_tests { use super::*; + #[test] + fn maven_runner_jre_defaults_and_round_trips() { + let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap(); + assert!(old.runner_jre.is_empty()); + assert!(MavenSettings::default().runner_jre.is_empty()); + let settings = MavenSettings { runner_jre: "/opt/jdk-21".into(), ..old }; + let encoded = serde_json::to_string(&settings).unwrap(); + assert!(encoded.contains(r#""runnerJre":"/opt/jdk-21""#)); + assert_eq!(serde_json::from_str::(&encoded).unwrap(), settings); + } + #[test] fn maven_linkage_defaults_and_round_trips() { let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap(); diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index b2b14aad7..3acb817fe 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -17,14 +17,6 @@ fn join_err(e: tokio::task::JoinError) -> IdeError { IdeError::internal(e.to_string()) } -/// `JAVA_HOME`: the project JDK, else the installed JDK matching `release` (none -/// when the project names no version). -fn java_env(project_jdk: Option<&ide_sdk::java::Jdk>, release: Option) -> BTreeMap { - let jdks = ide_sdk::java::installed_jdks(); - let jdk = project_jdk.or_else(|| release.and_then(|r| ide_sdk::java::pick_jdk(&jdks, Some(r)))); - jdk.map(|j| BTreeMap::from([("JAVA_HOME".to_owned(), j.home.to_string_lossy().into_owned())])).unwrap_or_default() -} - /// The build model with the launch settings applied: the JDK (`JAVA_HOME`), and /// the Maven home and options from Settings › Maven. pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel { @@ -40,7 +32,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel { let mvn = ide_sdk::apply::maven_command(root, settings); let options = ide_sdk::apply::maven_options(root, settings); for p in &mut m.projects { - p.env = java_env(jdk.as_ref(), p.java_version); + p.env = ide_sdk::apply::maven_build_env(root, settings, p.java_version); if let Some(mvn) = &mvn { p.command = mvn.to_string_lossy().into_owned(); } @@ -64,7 +56,11 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel { if let Some(m) = &mut model.maven && m.sync_error.is_some() { - let hint = m.projects.iter().find_map(|p| missing(p.java_version)); + let hint = if settings.languages.java.maven.runner_jre.trim().is_empty() { + m.projects.iter().find_map(|p| missing(p.java_version)) + } else { + None + }; explain(&mut m.sync_error, hint); } for g in &mut model.gradle { @@ -625,7 +621,7 @@ pub(crate) fn register(d: &mut Dispatcher) { } #[cfg(test)] -mod gradle_settings_tests { +mod build_tool_settings_tests { use super::*; #[test] @@ -710,6 +706,34 @@ mod gradle_settings_tests { } } + #[test] + fn model_for_maven_sync_goals_and_compile_uses_runner_jre() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); + std::fs::write(root.join("pom.xml"), + r#"4.0.0testapp1"# + ).unwrap(); + for name in ["project-jdk", "runner-jdk"] { + let home = root.join(name); + std::fs::create_dir_all(home.join("bin")).unwrap(); + std::fs::write(home.join("bin").join(if cfg!(windows) { "java.exe" } else { "java" }), "").unwrap(); + std::fs::write(home.join("release"), "JAVA_VERSION=\"21\"\n").unwrap(); + } + let mut settings = Settings::default(); + settings.languages.java.jdk = "project-jdk".into(); + for runner in ["", "runner-jdk"] { + settings.languages.java.maven.runner_jre = runner.into(); + let expected = root.join(if runner.is_empty() { "project-jdk" } else { runner }); + let model = load(&root, &root.join("cache"), &settings); + assert_eq!(model.maven.as_ref().unwrap().projects[0].env["JAVA_HOME"], expected.to_string_lossy()); + let plan = compile::plan(&root, &model, BuildKind::Build, None, None).unwrap(); + assert!(!plan.steps.is_empty()); + for step in plan.steps { + assert_eq!(step.env["JAVA_HOME"], expected.to_string_lossy()); + } + } + } + #[test] fn model_for_sync_tasks_and_compile_uses_gradle_settings() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 471dad4ab..075e61bd9 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -187,19 +187,37 @@ fn not_a_jdk(p: &Path) -> String { } } +/// Maven runner JRE, falling back to the project JDK and then the POM's Java version. +pub fn maven_jdk(root: &Path, s: &Settings, release: Option) -> Option { + match jdk_setting(root, &s.languages.java.maven.runner_jre) { + Some(jdk) => jdk.inspect_err(|p| tracing::warn!("Maven runner JRE {}", not_a_jdk(p))).ok(), + None => project_jdk(root, s).or_else(|| { + let installed = crate::java::installed_jdks(); + release.and_then(|r| crate::java::pick_jdk(&installed, Some(r))).cloned() + }), + } +} + +/// Default environment shared by Maven sync, builds and run configurations. +pub fn maven_build_env(root: &Path, s: &Settings, release: Option) -> std::collections::BTreeMap { + maven_jdk(root, s, release) + .map(|jdk| std::collections::BTreeMap::from([("JAVA_HOME".into(), jdk.home.to_string_lossy().into_owned())])) + .unwrap_or_default() +} + /// `mvn` of the configured Maven home (`languages.java.maven.home`): the /// executable itself, `/bin/mvn` or `/mvn`; else of the Maven the project /// pins for mise / asdf when it has no Maven wrapper. With `maven.daemon` on, the /// Maven Daemon (`mvnd`) replaces all of that — it embeds its own Maven — unless -/// it is not installed or the project JDK is older than the Java 17 its daemon +/// it is not installed or the runner JRE is older than the Java 17 its daemon /// needs, in which case the build stays on mvn (with a logged warning). pub fn maven_command(root: &Path, s: &Settings) -> Option { if s.languages.java.maven.daemon { match mvnd_command(root, s) { - // No project JDK at all lets mvnd pick its own Java, which may still be new enough. - Some(mvnd) if project_jdk(root, s).is_none_or(|j| j.major >= 17) => return Some(mvnd), + // Without a selected JDK, mvnd picks its own Java, which may still be new enough. + Some(mvnd) if maven_jdk(root, s, None).is_none_or(|j| j.major >= 17) => return Some(mvnd), Some(_) => tracing::warn!( - "Maven daemon: the project JDK is older than the Java 17 its daemon needs; building with mvn" + "Maven daemon: the runner JRE is older than the Java 17 its daemon needs; building with mvn" ), None => tracing::warn!("Maven daemon is enabled but mvnd was not found; building with mvn"), } @@ -1134,7 +1152,7 @@ fn add_maven_options(args: &mut Vec, options: &[String]) { /// Points run configurations at the selected toolchains: Python configs use the /// project interpreter, npm scripts the chosen package manager and Node.js, -/// cargo / go configs the selected toolchain, Maven / Gradle the project JDK +/// cargo / go configs the selected toolchain, Maven / Gradle their runner JDK /// (and Maven the configured home and options). pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings) { let explicit_python = setting_path(root, &s.languages.python.interpreter); @@ -1281,20 +1299,23 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings path_dirs.push(bin); } } - "maven" | "tomcat" => { + "tomcat" => { if let Some(jdk) = &jdk { c.env.entry("JAVA_HOME".into()).or_insert_with(|| jdk.home.to_string_lossy().into_owned()); } - if c.kind == "maven" { - if let Some(mvn) = &mvn - && (c.detected - || generated_maven_goal(c) - || matches!(c.command.as_str(), "mvn" | "mvn.cmd" | "mvn.bat")) - { - c.command = mvn.to_string_lossy().into_owned(); - } - add_maven_options(&mut c.args, &mvn_options); + } + "maven" => { + for (key, value) in maven_build_env(root, s, None) { + c.env.entry(key).or_insert(value); } + if let Some(mvn) = &mvn + && (c.detected + || generated_maven_goal(c) + || matches!(c.command.as_str(), "mvn" | "mvn.cmd" | "mvn.bat")) + { + c.command = mvn.to_string_lossy().into_owned(); + } + add_maven_options(&mut c.args, &mvn_options); } _ => {} } diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 4b2ddef99..a84942a7b 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -292,6 +292,92 @@ fn project_jdk_and_maven_settings() { assert_eq!(project_jdk(tmp.path(), &s), None); } +#[test] +fn maven_runner_jre_overrides_the_project_jdk() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); + for name in ["project-jdk", "runner-jdk", "next-jdk"] { + fake_jdk(&root.join(name)); + } + let mut settings: Settings = serde_json::from_value(serde_json::json!({ + "languages": { "java": { + "jdk": "project-jdk", + "maven": { "runnerJre": "runner-jdk" } + } } + })) + .unwrap(); + for runner in ["", "runner-jdk", "runner-jdk/bin", "runner-jdk/bin/java"] { + settings.languages.java.maven.runner_jre = + if runner.ends_with("java") { format!("runner-jdk/bin/{}", exe("java")) } else { runner.into() }; + let expected = root.join(if runner.is_empty() { "project-jdk" } else { "runner-jdk" }); + assert_eq!(crate::apply::maven_build_env(&root, &settings, Some(8))["JAVA_HOME"], expected.to_string_lossy()); + for (detected, is_test, debug) in + [(true, false, false), (false, false, false), (true, true, false), (true, true, true)] + { + let mut config = run("maven", "mvn", &["test"]); + config.detected = detected; + config.is_test = is_test; + if debug { + config.env.insert( + "MAVEN_OPTS".into(), + "-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005".into(), + ); + } + let mut configs = + [config.clone(), run("gradle", "gradle", &["build"]), run("tomcat", "catalina", &["run"])]; + apply_to_run_configs(&mut configs, &root, &settings); + assert_eq!(configs[0].env["JAVA_HOME"], expected.to_string_lossy()); + for other in &configs[1..] { + assert_eq!(other.env["JAVA_HOME"], root.join("project-jdk").to_string_lossy()); + } + let saved = without_derived(&configs[0], &root, &settings); + assert!(!saved.env.contains_key("JAVA_HOME")); + let mut next_settings = settings.clone(); + next_settings.languages.java.maven.runner_jre = "next-jdk".into(); + let mut next = saved; + crate::apply::prepare_maven_run(&mut next, &root, &next_settings); + assert_eq!(next.env["JAVA_HOME"], root.join("next-jdk").to_string_lossy()); + config.env.insert("JAVA_HOME".into(), "explicit-jdk".into()); + apply_to_run_configs(std::slice::from_mut(&mut config), &root, &settings); + assert_eq!(config.env["JAVA_HOME"], "explicit-jdk"); + } + } + settings.languages.java.jdk.clear(); + settings.languages.java.maven.runner_jre.clear(); + assert!(crate::apply::maven_build_env(&root, &settings, None).is_empty()); + for release in [8, 17, 21] { + let installed = crate::java::installed_jdks(); + let expected = crate::java::pick_jdk(&installed, Some(release)); + assert_eq!(crate::apply::maven_jdk(&root, &settings, Some(release)).as_ref(), expected); + } +} + +#[test] +fn maven_daemon_uses_the_runner_jre_version() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); + for major in [8, 21] { + let home = root.join(format!("jdk{major}")); + fake_jdk(&home); + std::fs::write(home.join("release"), format!("JAVA_VERSION=\"{major}\"\n")).unwrap(); + } + let mvn = root.join("maven/bin").join(if cfg!(windows) { "mvn.cmd" } else { "mvn" }); + let mvnd = root.join("mvnd/bin").join(if cfg!(windows) { "mvnd.cmd" } else { "mvnd" }); + for path in [&mvn, &mvnd] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, "").unwrap(); + } + let mut settings = Settings::default(); + settings.languages.java.maven.home = "maven".into(); + settings.languages.java.maven.mvnd_home = "mvnd".into(); + settings.languages.java.maven.daemon = true; + for (project, runner, expected) in [(8, 21, &mvnd), (21, 8, &mvn)] { + settings.languages.java.jdk = format!("jdk{project}"); + settings.languages.java.maven.runner_jre = format!("jdk{runner}"); + assert_eq!(maven_command(&root, &settings).as_ref(), Some(expected)); + } +} + #[test] fn maven_options_carry_the_thread_count_into_the_command_line() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/bindings.ts b/src/bindings.ts index 9e0551c90..f57c5eccf 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -5522,6 +5522,8 @@ export type MavenProject = { /** Build Tools › Maven. */ export type MavenSettings = { + /** JDK home used to run Maven; empty = Project JDK, else the POM's Java version. */ + runnerJre?: string, /** Project-relative root POMs removed from the Maven project model. */ unlinkedProjects?: string[], /** Project-relative POMs outside the root reactor added as independent top-level projects. */ diff --git a/src/features/settings/__tests__/languages.test.ts b/src/features/settings/__tests__/languages.test.ts index fa6ef156c..27f9c7e5e 100644 --- a/src/features/settings/__tests__/languages.test.ts +++ b/src/features/settings/__tests__/languages.test.ts @@ -27,7 +27,13 @@ describe("languages settings", () => { jdtlsVmArgs: " -Xmx2g ", jdk: 21, jdtlsJdk: " /opt/jdk-25 ", - maven: { home: " /opt/maven ", daemon: "yes", mvndHome: " /opt/mvnd ", threads: " 2C " }, + maven: { + runnerJre: " /opt/jdk-21 ", + home: " /opt/maven ", + daemon: "yes", + mvndHome: " /opt/mvnd ", + threads: " 2C ", + }, }, }); expect(v.python).toEqual({ interpreter: "/usr/bin/python3", activateVirtualenv: true }); @@ -39,6 +45,7 @@ describe("languages settings", () => { expect(v.java.jdk).toBe(""); expect(v.java.jdtlsJdk).toBe("/opt/jdk-25"); expect(v.java.maven).toEqual({ + runnerJre: "/opt/jdk-21", home: "/opt/maven", userSettings: "", localRepository: "", @@ -52,6 +59,13 @@ describe("languages settings", () => { expect(validateLanguages({ java: { maven: { threads: 4 } } }).java.maven.threads).toBe(""); }); + it("defaults the Maven runner JRE to the project JDK and preserves a chosen JDK", () => { + expect(validateSettings({}).languages.java.maven.runnerJre).toBe(""); + expect(validateLanguages({ java: { maven: { runnerJre: 21 } } }).java.maven.runnerJre).toBe(""); + const settings = validateSettings({ languages: { java: { maven: { runnerJre: " ./jdks/21 " } } } }); + expect(validateSettings(settings).languages.java.maven.runnerJre).toBe("./jdks/21"); + }); + it("keeps the Ruby interpreter through settings normalization and defaults to Auto", () => { expect(validateSettings({}).languages.ruby.interpreter).toBe(""); expect(validateLanguages({ ruby: { interpreter: 42 } }).ruby.interpreter).toBe(""); diff --git a/src/features/settings/__tests__/mavenRunner.test.tsx b/src/features/settings/__tests__/mavenRunner.test.tsx new file mode 100644 index 000000000..8c0aafb7f --- /dev/null +++ b/src/features/settings/__tests__/mavenRunner.test.tsx @@ -0,0 +1,45 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it } from "vitest"; +import { PageContext } from "../components/context"; +import { MavenPage } from "../languages/LanguagePages"; +import { useSdks } from "../languages/api"; +import { DEFAULT_SETTINGS } from "../model/schema"; +import { stagedValue, useSettingsDialog } from "../model/staging"; +import { EXECUTION_PAGES } from "../pages/execution"; + +beforeEach(() => { + useSettingsDialog.setState({ + base: structuredClone(DEFAULT_SETTINGS), + globalBase: structuredClone(DEFAULT_SETTINGS), + staged: {}, + query: "", + }); + useSdks.setState({ + byKind: { + jdk: [{ path: "/jdks/21", home: "/jdks/21", name: "JDK 21", version: "21", source: "system", extra: null }], + maven: [], + }, + loading: {}, + }); +}); +afterEach(cleanup); + +it("chooses an installed Maven runner JRE in project scope and returns to Project JDK", () => { + const page = EXECUTION_PAGES.find((p) => p.id === "build.maven")!; + expect(page.projectScope).toBe(true); + expect(page.defaultScope).toBe("project"); + expect(page.keywords).toEqual(expect.arrayContaining(["JRE", "runner", "JDK", "JAVA_HOME"])); + render( + + + , + ); + expect(screen.getByRole("button", { name: "JRE" })).toHaveTextContent("Use Project JDK"); + fireEvent.click(screen.getByRole("button", { name: "JRE" })); + fireEvent.click(screen.getByRole("option", { name: /JDK 21/ })); + expect(stagedValue(useSettingsDialog.getState(), "project", "languages.java.maven.runnerJre")).toBe("/jdks/21"); + expect(stagedValue(useSettingsDialog.getState(), "global", "languages.java.maven.runnerJre")).toBe(""); + fireEvent.click(screen.getByRole("button", { name: "JRE" })); + fireEvent.click(screen.getByRole("option", { name: "Use Project JDK" })); + expect(stagedValue(useSettingsDialog.getState(), "project", "languages.java.maven.runnerJre")).toBe(""); +}); diff --git a/src/features/settings/languages/LanguagePages.tsx b/src/features/settings/languages/LanguagePages.tsx index 5a20539ca..c70691b0d 100644 --- a/src/features/settings/languages/LanguagePages.tsx +++ b/src/features/settings/languages/LanguagePages.tsx @@ -498,6 +498,16 @@ export function MavenPage() { return (
+ ([ "JDK 1.4.x", "JDK 5 - 8", "JPA", + "JRE", "LTS", "NULL", "ORDER BY",