feat(maven): choose the JRE Maven runs with (#2020)

Closes #1976. Settings › Build Tools › Maven gets a JRE field (per project or global): empty uses the Project JDK, else the chosen JDK runs Maven sync, builds, goals and tests (JAVA_HOME). One resolver (ide_sdk::apply::maven_jdk / maven_build_env) now serves the build model, compile steps, Maven run configurations and the mvnd Java 17 check; an explicit JAVA_HOME in a run configuration still wins.
This commit is contained in:
l0ng-ai authored and GitHub committed 2026-11-07 23:56:04 +08:00
1 parent fdb07e580c
commit b43d9bf7fb
13 files changed
+256 -30

No files matched your search

+13
View File
@@ -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<String>,
/// 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::<MavenSettings>(&encoded).unwrap(), settings);
}
#[test]
fn maven_linkage_defaults_and_round_trips() {
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
+35 -11
View File
@@ -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<u32>) -> BTreeMap<String, String> {
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#"<project><modelVersion>4.0.0</modelVersion><groupId>test</groupId><artifactId>app</artifactId><version>1</version></project>"#
).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();
+36 -15
View File
@@ -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<u32>) -> Option<crate::java::Jdk> {
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<u32>) -> std::collections::BTreeMap<String, String> {
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, `<home>/bin/mvn` or `<bin>/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<PathBuf> {
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<String>, 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);
}
_ => {}
}
+86
View File
@@ -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();
+2
View File
@@ -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. */
@@ -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("");
@@ -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(
<PageContext.Provider value={{ pageId: page.id, scope: page.defaultScope! }}>
<MavenPage />
</PageContext.Provider>,
);
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("");
});
@@ -498,6 +498,16 @@ export function MavenPage() {
return (
<div className={styles.page}>
<Section>
<SdkRow
k="languages.java.maven.runnerJre"
label="JRE"
kind="jdk"
emptyLabel="Use Project JDK"
directory
browseTitle="Select JDK Home Directory"
aria-label="JRE"
hint="JDK used for Maven sync, builds, goals and tests. An explicit JAVA_HOME in a run configuration takes precedence."
/>
<SdkRow
k="languages.java.maven.home"
label="Maven home path"
@@ -145,6 +145,10 @@ export const EXECUTION_PAGES: PageSpec[] = [
// IntelliJ IDEA: Build Tools › Maven, before Console / Debugger.
order: 359,
keywords: [
"JRE",
"runner",
"JDK",
"JAVA_HOME",
"maven home",
"mvn",
"mvnw",
+3
View File
@@ -172,6 +172,7 @@ export interface LanguagesValid {
runUsing: "gradle" | "ide";
};
maven: {
runnerJre: string;
home: string;
userSettings: string;
localRepository: string;
@@ -238,6 +239,7 @@ export const DEFAULT_LANGUAGES: LanguagesValid = {
jdtlsVmArgs: "",
gradle: { linkedProjects: null, jvm: "", distribution: "wrapper", home: "", userHome: "", runUsing: "gradle" },
maven: {
runnerJre: "",
home: "",
userSettings: "",
localRepository: "",
@@ -395,6 +397,7 @@ export function validateLanguages(raw: unknown): LanguagesValid {
runUsing: oneOf(gradle.runUsing, ["gradle", "ide"] as const, d.java.gradle.runUsing),
},
maven: {
runnerJre: text(maven.runnerJre, d.java.maven.runnerJre),
home: text(maven.home, d.java.maven.home),
userSettings: text(maven.userSettings, d.java.maven.userSettings),
localRepository: text(maven.localRepository, d.java.maven.localRepository),
+3 -1
View File
@@ -189,5 +189,7 @@
"Java compiler output must be inside its module: {output}": "Java compiler output must be inside its module: {output}",
"Cannot choose a directory": "Cannot choose a directory",
"Open in HTTP Client": "Open in HTTP Client",
"Cannot generate the HTTP request": "Cannot generate the HTTP request"
"Cannot generate the HTTP request": "Cannot generate the HTTP request",
"Use Project JDK": "Use Project JDK",
"JDK used for Maven sync, builds, goals and tests. An explicit JAVA_HOME in a run configuration takes precedence.": "JDK used for Maven sync, builds, goals and tests. An explicit JAVA_HOME in a run configuration takes precedence."
}
+3 -2
View File
@@ -5355,7 +5355,6 @@
"Context path": "上下文路径",
"Enter / or a context path such as /app": "请输入 / 或 /app 等上下文路径",
"HTTP port": "HTTP 端口",
"JRE": "Java 运行环境",
"Java installation directory; empty uses the project JDK": "Java 安装目录;留空则使用项目 JDK",
"Open browser after launch": "启动后打开浏览器",
"Browser URL": "浏览器网址",
@@ -6567,5 +6566,7 @@
"Remove Module Overrides": "移除模块覆盖设置",
"Java compiler output must be inside its module: {output}": "Java 编译输出必须位于所属模块目录内:{output}",
"Cannot choose a directory": "无法选择目录",
"Open in HTTP Client": "在 HTTP 客户端中打开"
"Open in HTTP Client": "在 HTTP 客户端中打开",
"Use Project JDK": "使用项目 SDK",
"JDK used for Maven sync, builds, goals and tests. An explicit JAVA_HOME in a run configuration takes precedence.": "用于 Maven 同步、构建、目标和测试的 JDK。运行配置中显式设置的 JAVA_HOME 优先。"
}
+1
View File
@@ -206,6 +206,7 @@ const UNTRANSLATED = new Set<string>([
"JDK 1.4.x",
"JDK 5 - 8",
"JPA",
"JRE",
"LTS",
"NULL",
"ORDER BY",