fix(gradle): discover nested builds and select compatible JVMs

This commit is contained in:
l0ng-ai
2026-11-06 13:57:19 +08:00
parent fc938ac567
commit 5dbadf5d9e
5 changed files with 273 additions and 25 deletions
+85 -13
View File
@@ -218,21 +218,41 @@ fn normalize(p: &Path) -> PathBuf {
out
}
/// The directory of the Gradle build under `root`: the root itself, or a direct
/// subfolder, holding a settings or build script.
/// The first automatically discovered build, for callers operating on one build.
pub fn build_dir(root: &Path) -> Option<PathBuf> {
build_dirs(root).into_iter().next()
}
fn build_dirs(root: &Path) -> Vec<PathBuf> {
let has = |d: &Path| SETTINGS_FILES.iter().chain(BUILD_FILES).any(|f| d.join(f).is_file());
if has(root) {
return Some(root.to_path_buf());
return vec![root.to_path_buf()];
}
let mut dirs: Vec<PathBuf> = std::fs::read_dir(root)
.ok()?
.flatten()
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()) && !e.file_name().to_string_lossy().starts_with('.'))
.map(|e| e.path())
.collect();
dirs.sort();
dirs.into_iter().find(|d| SETTINGS_FILES.iter().any(|f| d.join(f).is_file()))
let mut builds = Vec::new();
let mut pending = vec![(root.to_path_buf(), 0)];
while let Some((dir, depth)) = pending.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for entry in entries.flatten() {
if !entry.file_type().is_ok_and(|t| t.is_dir()) {
continue;
}
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with('.')
|| matches!(name.as_ref(), "node_modules" | "target" | "build" | "dist" | "vendor" | "src" | "out")
{
continue;
}
let path = entry.path();
if has(&path) {
builds.push(path);
} else if depth + 1 < 3 {
pending.push((path, depth + 1));
}
}
}
builds.sort();
builds
}
fn wrapper(dir: &Path) -> String {
@@ -495,7 +515,7 @@ pub fn linked_project_key(root: &Path, dir: &str) -> String {
/// Linked build identities without reading their scripts (also used by index hot paths).
pub(crate) fn linked_dirs(root: &Path, settings: &ide_core::settings::GradleSettings) -> Vec<String> {
match &settings.linked_projects {
None => build_dir(root).map(|dir| rel(root, &dir)).into_iter().collect(),
None => build_dirs(root).iter().map(|dir| rel(root, dir)).collect(),
Some(dirs) => {
let mut out = Vec::new();
for dir in dirs {
@@ -686,6 +706,55 @@ pub(crate) fn has_war_plugin(script: &str) -> bool {
#[cfg(test)]
mod tests {
#[test]
fn default_linkage_discovers_all_shallow_builds() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
for file in [
"Cargo.toml",
"package.json",
"fixtures/kotlin-gradle/settings.gradle.kts",
"fixtures/kotlin-gradle/build.gradle.kts",
"fixtures/kotlin-gradle/tools/settings.gradle",
"services/backend/api/settings.gradle",
"standalone/build.gradle",
"ignored/src/settings.gradle",
"ignored/out/build.gradle",
"node_modules/example/settings.gradle",
"target/example/build.gradle",
"build/example/settings.gradle",
"dist/example/build.gradle",
"vendor/example/settings.gradle",
".hidden/settings.gradle",
"too/deep/to/find/settings.gradle",
] {
let path = root.join(file);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "").unwrap();
}
let models = super::load_linked(root, &Default::default());
assert_eq!(
models.iter().map(|m| m.dir.as_str()).collect::<Vec<_>>(),
["fixtures/kotlin-gradle", "services/backend/api", "standalone"]
);
std::fs::write(root.join("build.gradle.kts"), "").unwrap();
let models = super::load_linked(root, &Default::default());
assert_eq!(models.len(), 1);
assert!(models[0].dir.is_empty());
}
#[cfg(unix)]
#[test]
fn default_linkage_does_not_follow_symlinks() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
std::fs::create_dir(root.join("real")).unwrap();
std::fs::write(root.join("real/build.gradle"), "").unwrap();
std::os::unix::fs::symlink(root.join("real"), root.join("alias")).unwrap();
let models = super::load_linked(root, &Default::default());
assert_eq!(models.iter().map(|m| m.dir.as_str()).collect::<Vec<_>>(), ["real"]);
}
#[test]
fn a_path_from_the_ui_finds_the_saved_link() {
let temp = tempfile::tempdir().unwrap();
@@ -718,7 +787,10 @@ mod tests {
std::fs::create_dir(root.join(dir)).unwrap();
std::fs::write(root.join(dir).join("settings.gradle"), format!("rootProject.name = '{dir}'")).unwrap();
}
assert_eq!(super::load_linked(root, &Default::default())[0].dir, "a");
assert_eq!(
super::load_linked(root, &Default::default()).iter().map(|m| m.dir.as_str()).collect::<Vec<_>>(),
["a", "b"]
);
let mut settings = ide_core::settings::GradleSettings {
linked_projects: Some(vec!["b".into(), "./b".into(), "a".into()]),
..Default::default()
+1 -1
View File
@@ -47,8 +47,8 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
}
}
for g in &mut model.gradle {
g.env = ide_sdk::apply::gradle_env(root, settings, java_env(jdk.as_ref(), g.java_version));
g.command = ide_sdk::apply::gradle_command(root, &root.join(&g.dir), settings).to_string_lossy().into_owned();
g.env = ide_sdk::apply::gradle_build_env(root, &root.join(&g.dir), &g.command, settings, g.java_version);
}
// A failed sync of a project no installed JDK can build says so first (with a Download JDK action).
let missing = |release: Option<u32>| match jdk {
+15
View File
@@ -301,6 +301,21 @@ fn nested_builds_of_every_kind_and_depth() {
let builds: Vec<&str> =
ids(&c).into_iter().filter(|id| id.starts_with("maven:package") || id.starts_with("gradle:build")).collect();
assert_eq!(builds, ["gradle:build:services/api", "maven:package:billing"]);
let model = ide_build::load(dir.path(), &dir.path().join("cache"), None);
for config in c.iter().filter(|c| c.kind == "gradle") {
let cwd = config.cwd.as_deref().unwrap();
assert!(model.gradle.iter().any(|g| Path::new(&g.dir) == cwd), "{} has no build model", config.id);
}
let plan = ide_build::compile::plan(
dir.path(),
&model,
ide_build::compile::BuildKind::Build,
Some(&dir.path().join("services/api/app/src/main/kotlin/com/acme/Api.kt")),
None,
)
.unwrap();
assert_eq!(plan.steps[0].cwd, dir.path().join("services/api"));
assert_eq!(plan.steps[0].args, ["--console=plain", ":app:classes", ":app:testClasses"]);
}
#[test]
+158 -7
View File
@@ -245,6 +245,102 @@ fn resolve_gradle_command(root: &Path, build_dir: &Path, s: &Settings, use_wrapp
.unwrap_or_else(|| name.into())
}
fn gradle_run_jdk<'a>(
release: Option<u32>,
configured: Option<&'a crate::java::Jdk>,
installed: &'a [crate::java::Jdk],
gradle: Option<(u32, u32)>,
) -> Option<&'a crate::java::Jdk> {
configured.or_else(|| {
let release = release?;
let gradle = gradle?;
let compatible: Vec<_> = installed.iter().filter(|jdk| gradle_supports_java(gradle, jdk.major)).collect();
compatible
.iter()
.copied()
.find(|jdk| jdk.major == release)
.or_else(|| compatible.iter().copied().filter(|jdk| jdk.major > release).min_by_key(|jdk| jdk.major))
.or_else(|| compatible.iter().copied().max_by_key(|jdk| jdk.major))
})
}
/// Default environment shared by Gradle model builds and Run configurations.
pub fn gradle_build_env(
root: &Path,
build_dir: &Path,
command: &str,
settings: &Settings,
release: Option<u32>,
) -> std::collections::BTreeMap<String, String> {
let configured = project_jdk(root, settings);
let installed = crate::java::installed_jdks();
let jdk = gradle_run_jdk(
release.or_else(|| crate::java::project_java_version(build_dir)),
configured.as_ref(),
&installed,
gradle_distribution_version(build_dir, command),
);
let env = jdk
.map(|jdk| std::collections::BTreeMap::from([("JAVA_HOME".into(), jdk.home.to_string_lossy().into_owned())]))
.unwrap_or_default();
gradle_env(root, settings, env)
}
fn gradle_supports_java(gradle: (u32, u32), java: u32) -> bool {
// https://docs.gradle.org/current/userguide/compatibility.html#java_runtime
let minimum = match java {
8 => (2, 0),
9 => (4, 3),
10 => (4, 7),
11 => (5, 0),
12 => (5, 4),
13 => (6, 0),
14 => (6, 3),
15 => (6, 7),
16 => (7, 0),
17 => (7, 3),
18 => (7, 5),
19 => (7, 6),
20 => (8, 3),
21 => (8, 5),
22 => (8, 8),
23 => (8, 10),
24 => (8, 14),
25 => (9, 1),
26 => (9, 4),
27 => (9, 8),
_ => return false,
};
gradle >= minimum && (java >= 17 || gradle < (9, 0))
}
fn gradle_distribution_version(build_dir: &Path, command: &str) -> Option<(u32, u32)> {
let version = |text: &str| {
let mut parts = text.split('.');
Some((parts.next()?.parse().ok()?, parts.next()?.parse().ok()?))
};
if Path::new(command).file_stem().is_some_and(|name| name == "gradlew") {
let properties = std::fs::read_to_string(build_dir.join("gradle/wrapper/gradle-wrapper.properties")).ok()?;
let url = properties.lines().find_map(|line| line.trim().strip_prefix("distributionUrl="))?;
return version(url.rsplit_once("gradle-")?.1.split('-').next()?);
}
let path = Path::new(command);
let path = if path.is_absolute() { path.to_path_buf() } else { crate::exec::which(command)? };
let path = dunce::canonicalize(path).ok()?;
let home = path.parent()?.parent()?;
for lib in [home.join("lib"), home.join("libexec/lib")] {
let Ok(entries) = std::fs::read_dir(lib) else { continue };
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if let Some(v) = name.strip_prefix("gradle-launcher-").and_then(|name| name.strip_suffix(".jar")) {
return version(v);
}
}
}
None
}
/// Gradle JVM overrides Project JDK; model callers supply the module's fallback environment.
pub fn gradle_env(
root: &Path,
@@ -1044,13 +1140,7 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
if matches!(c.command.as_str(), "gradle" | "gradle.bat") {
c.command = resolve_gradle_command(root, &build_dir, s, false).to_string_lossy().into_owned();
}
let defaults = jdk
.as_ref()
.map(|j| {
std::collections::BTreeMap::from([("JAVA_HOME".into(), j.home.to_string_lossy().into_owned())])
})
.unwrap_or_default();
for (key, value) in gradle_env(root, s, defaults) {
for (key, value) in gradle_build_env(root, &build_dir, &c.command, s, None) {
c.env.entry(key).or_insert(value);
}
}
@@ -1302,6 +1392,67 @@ mod java_compiler_tests {
}
}
#[cfg(test)]
mod gradle_runtime_tests {
use super::*;
use std::collections::BTreeMap;
#[test]
fn gradle_runs_use_the_builds_toolchain_without_overriding_user_choices() {
let temp = tempfile::tempdir().unwrap();
let installed =
[25, 21, 17, 11, 8].map(|major| crate::java::Jdk { major, home: temp.path().join(format!("jdk{major}")) });
for (name, script, expected) in [
("kotlin", "kotlin { jvmToolchain(21) }", Some(21)),
("java", "java { toolchain { languageVersion = JavaLanguageVersion.of(17) } }", Some(17)),
("unspecified", "plugins { id 'java' }", None),
] {
let dir = temp.path().join(name);
std::fs::create_dir(&dir).unwrap();
std::fs::write(dir.join("build.gradle.kts"), script).unwrap();
let picked = gradle_run_jdk(crate::java::project_java_version(&dir), None, &installed, Some((9, 0)));
assert_eq!(picked.map(|jdk| jdk.major), expected);
assert_eq!(gradle_run_jdk(expected, Some(&installed[0]), &installed, Some((9, 0))), Some(&installed[0]));
let mut settings = Settings::default();
settings.languages.java.gradle.jvm = "custom-gradle-jdk".into();
let env = gradle_env(temp.path(), &settings, BTreeMap::from([("JAVA_HOME".into(), "/fallback".into())]));
assert_eq!(env["JAVA_HOME"], temp.path().join("custom-gradle-jdk").to_string_lossy());
}
let dir = temp.path().join("kotlin");
for (gradle, target, expected) in
[((9, 0), 8, 17), ((8, 4), 21, 17), ((8, 5), 21, 21), ((7, 2), 21, 11), ((9, 0), 25, 21), ((9, 1), 25, 25)]
{
std::fs::write(dir.join("build.gradle.kts"), format!("kotlin {{ jvmToolchain({target}) }}")).unwrap();
assert_eq!(gradle_run_jdk(Some(target), None, &installed, Some(gradle)).unwrap().major, expected);
}
assert!(gradle_run_jdk(Some(21), None, &installed, None).is_none());
}
#[test]
fn reads_wrapper_and_local_gradle_versions_without_running_them() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
std::fs::create_dir_all(root.join("gradle/wrapper")).unwrap();
std::fs::write(
root.join("gradle/wrapper/gradle-wrapper.properties"),
"distributionUrl=https\\://services.gradle.org/distributions/gradle-8.4.0-bin.zip\n",
)
.unwrap();
for command in ["./gradlew", "gradlew.bat"] {
assert_eq!(gradle_distribution_version(root, command), Some((8, 4)));
}
for layout in ["lib", "libexec/lib"] {
let home = root.join(layout.replace('/', "-"));
std::fs::create_dir_all(home.join("bin")).unwrap();
std::fs::create_dir_all(home.join(layout)).unwrap();
let command = home.join("bin/gradle");
std::fs::write(&command, "").unwrap();
std::fs::write(home.join(layout).join("gradle-launcher-9.0.0.jar"), "").unwrap();
assert_eq!(gradle_distribution_version(root, &command.to_string_lossy()), Some((9, 0)));
}
}
}
#[cfg(test)]
mod maven_linkage_tests {
use super::*;
+14 -4
View File
@@ -540,10 +540,12 @@ pub fn project_java_version(module_dir: &Path) -> Option<u32> {
}
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 ["JavaLanguageVersion.of(", "jvmToolchain("] {
if let Some(i) = text.find(key) {
let rest = &text[i + key.len()..];
if let Some(v) = parse_java_version(rest.split(')').next().unwrap_or_default()) {
return Some(v);
}
}
}
for key in ["sourceCompatibility", "targetCompatibility"] {
@@ -1138,6 +1140,14 @@ mod tests {
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));
for (script, version) in [
("kotlin { jvmToolchain(21) }", Some(21)),
("kotlin { jvmToolchain(17) }", Some(17)),
("kotlin { jvmToolchain(projectVersion) }", None),
] {
std::fs::write(dir.join("build.gradle.kts"), script).unwrap();
assert_eq!(project_java_version(dir), version);
}
}
}