diff --git a/crates/ide-build/src/gradle.rs b/crates/ide-build/src/gradle.rs index b4bc8839..30fbe575 100644 --- a/crates/ide-build/src/gradle.rs +++ b/crates/ide-build/src/gradle.rs @@ -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 { + build_dirs(root).into_iter().next() +} + +fn build_dirs(root: &Path) -> Vec { 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 = 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 { 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::>(), + ["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::>(), ["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::>(), + ["a", "b"] + ); let mut settings = ide_core::settings::GradleSettings { linked_projects: Some(vec!["b".into(), "./b".into(), "a".into()]), ..Default::default() diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index 074cbcad..b09d138c 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -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| match jdk { diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs index 5cbb5818..eafadce5 100644 --- a/crates/ide-run/tests/detect.rs +++ b/crates/ide-run/tests/detect.rs @@ -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] diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 91e1d6e8..2e05dd40 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -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, + 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, +) -> std::collections::BTreeMap { + 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::*; diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index b81b9dd0..40f4ccf2 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -540,10 +540,12 @@ pub fn project_java_version(module_dir: &Path) -> Option { } 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); + } } }