diff --git a/crates/ide-build/src/gradle-model.gradle b/crates/ide-build/src/gradle-model.gradle new file mode 100644 index 00000000..601d2d28 --- /dev/null +++ b/crates/ide-build/src/gradle-model.gradle @@ -0,0 +1,40 @@ +// OpenIDEA Gradle sync: prints each project as Gradle evaluated it, one JSON line per project. +// Read by `ide_build::gradle::parse_reported_projects`. Dependencies are not resolved: the +// Java language server reads the classpath itself, and resolving is the slow part of an import. +import groovy.json.JsonOutput + +def canon = { File f -> f.canonicalPath } +// Init scripts also run in included builds and buildSrc, whose projects reuse `:`, `:core`…: +// the identity path (`:lib:core`, `:buildSrc`) tells them apart and addresses their tasks +// from the root build. Internal API, hence the fallback. +def identity = { Project p -> try { p.identityPath.toString() } catch (ignored) { p.path } } + +def describe = { Project p -> + def m = [path: identity(p), dir: canon(p.projectDir), buildDir: canon(p.layout.buildDirectory.get().asFile)] + if (p.gradle.parent != null) m.build = identity(p.rootProject) + def sets = p.extensions.findByName('sourceSets') + if (sets != null) { + // Test source sets as IDEA classifies them: `test`, test fixtures, JVM test suites, + // and sets whose folders the `idea` plugin lists as test sources. + def suites = [] as Set + try { p.extensions.findByName('testing')?.suites?.each { suites << it.sources.name } } catch (ignored) {} + def ideaTests = [] as Set + try { p.extensions.findByName('idea')?.module?.testSources?.files?.each { ideaTests << canon(it) } } catch (ignored) {} + m.sourceSets = sets.collect { s -> + def res = s.resources.srcDirs.collect(canon) + def src = s.allSource.srcDirs.collect(canon) - res + def test = s.name == 'test' || s.name == 'testFixtures' || suites.contains(s.name) || + s.name.endsWith('Test') || s.name.endsWith('Tests') || + (!src.isEmpty() && src.every { ideaTests.contains(it) }) + [name: s.name, test: test, src: src, res: res] + } + } + try { + def java = p.extensions.findByName('java') + def lv = java?.toolchain?.languageVersion?.getOrNull() + m.java = lv != null ? lv.asInt() : (java?.targetCompatibility?.majorVersion as Integer) + } catch (ignored) {} + println '@openidea-model\t' + JsonOutput.toJson(m) +} + +gradle.projectsEvaluated { g -> g.rootProject.allprojects { describe(it) } } diff --git a/crates/ide-build/src/gradle.rs b/crates/ide-build/src/gradle.rs index 4acc986e..2ebf5848 100644 --- a/crates/ide-build/src/gradle.rs +++ b/crates/ide-build/src/gradle.rs @@ -32,7 +32,8 @@ pub struct GradleModel { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct GradleProject { - /// Gradle path: `:` for the root project, `:app`, `:libs:core`. + /// Gradle path: `:` for the root project, `:app`, `:libs:core`; for a project of a + /// nested build its identity path (`:lib:core`), which addresses its tasks from the root. pub path: String, pub name: String, /// Project-relative directory. @@ -40,6 +41,10 @@ pub struct GradleProject { /// Project-relative build script, if any. pub build_file: Option, pub tasks: Vec, + /// Identity path of the nested build holding the project (`:lib` for `includeBuild`, + /// `:buildSrc`); null for the opened build. + #[serde(default)] + pub build: Option, } #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -129,35 +134,88 @@ fn include_args(after: &str) -> Vec { out } -/// `(root project name, included project paths)` from a settings script. -pub fn parse_settings(text: &str) -> (Option, Vec) { +/// What a settings script declares, read statically. +#[derive(Debug, Default, PartialEq)] +pub struct Settings { + pub name: Option, + /// Included project paths (`:app`), in declaration order. + pub includes: Vec, + /// Project directories that differ from the default `` under the build + /// dir, relative to it: `includeFlat` siblings and literal `projectDir = file(..)`. + pub dirs: BTreeMap, +} + +/// The included projects, root project name and directory overrides of a settings script. +/// Interpolated (`"$m"`) names and directories cannot be read without Gradle and are skipped. +pub fn parse_settings(text: &str) -> Settings { let text = strip_comments(text); - let mut name = None; - let mut includes: Vec = Vec::new(); + let mut s = Settings::default(); for (i, _) in text.match_indices("rootProject.name") { let line = text[i + "rootProject.name".len()..].lines().next().unwrap_or_default(); if let Some(n) = literals(line).into_iter().next() { - name = Some(n); + s.name = Some(n); } } let mut from = 0; while let Some(i) = text[from..].find("include") { let at = from + i; from = at + "include".len(); + let flat = text[from..].starts_with("Flat"); + if flat { + from += "Flat".len(); + } let before_ok = at == 0 || !text[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_' || c == '.'); let after = &text[from..]; let after_ok = after.starts_with(|c: char| c == '(' || c.is_whitespace()); if !before_ok || !after_ok { continue; } - for p in include_args(after) { + for p in include_args(after).into_iter().filter(|p| !p.contains('$')) { let path = if p.starts_with(':') { p } else { format!(":{p}") }; - if path.len() > 1 && !includes.contains(&path) { - includes.push(path); + if path.len() <= 1 { + continue; + } + // `includeFlat 'a'` is the sibling directory `../a` of the root project. + if flat { + s.dirs.insert(path.clone(), format!("../{}", path.trim_start_matches(':'))); + } + if !s.includes.contains(&path) { + s.includes.push(path); } } } - (name, includes) + // `project(':a').projectDir = file('../a')`; a later assignment wins, as in Gradle. + for line in text.lines() { + let Some((left, right)) = line.split_once(".projectDir") else { continue }; + let Some(right) = right.trim_start().strip_prefix('=') else { continue }; + let Some(arg) = right.trim_start().strip_prefix("file") else { continue }; + let (Some(path), Some(dir)) = ( + left.rsplit_once("project").map(|(_, p)| literals(p)).and_then(|l| l.into_iter().next()), + literals(arg).into_iter().next(), + ) else { + continue; + }; + if !path.contains('$') && !dir.contains('$') { + let path = if path.starts_with(':') { path } else { format!(":{path}") }; + s.dirs.insert(path, dir); + } + } + s +} + +/// `a/b/../c` → `a/c`, without touching the filesystem. +fn normalize(p: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for c in p.components() { + match c { + std::path::Component::CurDir => {} + std::path::Component::ParentDir if out.file_name().is_some() => { + out.pop(); + } + c => out.push(c), + } + } + out } /// The directory of the Gradle build under `root`: the root itself, or a direct @@ -235,31 +293,159 @@ pub fn default_tasks(is_root: bool, script: &str) -> Vec { } /// Reads the Gradle build under `root` (no Gradle run), or `None` when there is none. +fn read_first(d: &Path, names: &[&str]) -> Option<(String, String)> { + names.iter().find_map(|n| std::fs::read_to_string(d.join(n)).ok().map(|t| (n.to_string(), t))) +} + +/// The project `path` in `pdir`, with the default tasks of its build script. +/// Outside `root` the directory stays absolute (`rel` falls back). +fn project_at(root: &Path, path: String, name: String, pdir: &Path) -> GradleProject { + let script = read_first(pdir, BUILD_FILES); + let tasks = default_tasks(path == ":", script.as_ref().map(|(_, t)| t.as_str()).unwrap_or_default()); + GradleProject { + build_file: script.map(|(n, _)| rel(root, &pdir.join(n))), + dir: rel(root, pdir), + path, + name, + tasks, + build: None, + } +} + +/// A project as Gradle evaluated it (the sync's init script, `gradle-model.gradle`). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReportedProject { + pub path: String, + /// Absolute, canonical. + pub dir: String, + /// Absolute output folder (`layout.buildDirectory`). + #[serde(default)] + pub build_dir: Option, + #[serde(default)] + pub source_sets: Vec, + /// Toolchain language version, else target compatibility. + #[serde(default)] + pub java: Option, + /// Identity path of the nested build (included build, buildSrc) reporting the project. + #[serde(default)] + pub build: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ReportedSourceSet { + pub name: String, + pub test: bool, + /// Absolute source folders (Java, Kotlin, Groovy, …), which need not exist. + #[serde(default)] + pub src: Vec, + /// Absolute resource folders. + #[serde(default)] + pub res: Vec, +} + +const MODEL_LINE: &str = "@openidea-model\t"; + +/// The projects printed by `gradle-model.gradle`; malformed lines are skipped. +pub fn parse_reported_projects(output: &str) -> Vec { + output.lines().filter_map(|l| serde_json::from_str(l.trim_end().strip_prefix(MODEL_LINE)?).ok()).collect() +} + +/// Source roots of the reported source sets: `(absolute folder, is test, is resources)`. +pub fn reported_source_roots(reported: &[ReportedProject]) -> Vec<(PathBuf, ReportedRoot)> { + let mut out: Vec<(PathBuf, ReportedRoot)> = Vec::new(); + for p in reported { + for set in &p.source_sets { + let (src, res) = if set.test { + (ReportedRoot::Test, ReportedRoot::TestResources) + } else { + (ReportedRoot::Source, ReportedRoot::Resources) + }; + for (dir, kind) in set.src.iter().map(|d| (d, src)).chain(set.res.iter().map(|d| (d, res))) { + let dir = PathBuf::from(dir); + // A folder shared by `main` and another set is `main`'s, as in IDEA. + if !out.iter().any(|(d, _)| *d == dir) { + out.push((dir, kind)); + } + } + } + // IDEA excludes the build folder of every module. + if let Some(b) = &p.build_dir { + out.push((PathBuf::from(b), ReportedRoot::Excluded)); + } + } + out +} + +/// Role of a folder a Gradle sync reported. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReportedRoot { + Source, + Test, + Resources, + TestResources, + Excluded, +} + +/// Replaces the statically read projects with the ones Gradle reported (root first): +/// settings scripts may compute their projects, which no static reading sees. +pub fn merge_reported_projects(root: &Path, model: &mut GradleModel, reported: &[ReportedProject]) { + if reported.is_empty() { + return; + } + let root_name = model.projects.iter().find(|p| p.path == ":").map(|p| p.name.clone()); + model.projects = reported + .iter() + .map(|p| { + let name = match p.path.as_str() { + ":" => root_name.clone().unwrap_or_default(), + path => path.rsplit(':').next().unwrap_or_default().to_owned(), + }; + let mut project = project_at(root, p.path.clone(), name, Path::new(&p.dir)); + // buildSrc is built before the build, never addressable as `:buildSrc:`. + if p.build.as_deref().is_some_and(|b| b.ends_with(":buildSrc")) { + project.tasks.clear(); + } + project.build = p.build.clone(); + project + }) + .collect(); + // Each build reports its projects when it is evaluated (nested builds first). + model.projects.sort_by(|a, b| a.path.cmp(&b.path)); + // One JDK runs the whole build: the newest any module needs. + if let Some(v) = reported.iter().filter_map(|p| p.java).max() { + model.java_version = Some(v); + } +} + +/// Project directories outside `root`, outermost only (`../svc`, not also `../svc/sources/app`): +/// the folders IDEA shows as extra top-level module roots. +pub fn external_roots<'a>(root: &Path, dirs: impl IntoIterator) -> Vec { + let mut dirs: Vec = dirs.into_iter().map(|d| root.join(d)).filter(|d| !d.starts_with(root)).collect(); + dirs.sort(); + dirs.dedup(); + let mut out: Vec = Vec::new(); + for d in dirs { + if !out.iter().any(|o| d.starts_with(o)) { + out.push(d); + } + } + out +} + pub fn load(root: &Path) -> Option { let dir = build_dir(root)?; - let read = |d: &Path, names: &[&str]| { - names.iter().find_map(|n| std::fs::read_to_string(d.join(n)).ok().map(|t| (n.to_string(), t))) - }; - let (name, includes) = read(&dir, SETTINGS_FILES).map(|(_, t)| parse_settings(&t)).unwrap_or_default(); + let settings = read_first(&dir, SETTINGS_FILES).map(|(_, t)| parse_settings(&t)).unwrap_or_default(); let root_name = - name.unwrap_or_else(|| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()); - let mut projects = Vec::new(); - let mut add = |path: String, name: String, pdir: PathBuf| { - let script = read(&pdir, BUILD_FILES); - let tasks = default_tasks(path == ":", script.as_ref().map(|(_, t)| t.as_str()).unwrap_or_default()); - projects.push(GradleProject { - build_file: script.map(|(n, _)| rel(root, &pdir.join(n))), - dir: rel(root, &pdir), - path, - name, - tasks, - }); - }; - add(":".into(), root_name, dir.clone()); - for path in includes { - let pdir = dir.join(path.trim_start_matches(':').replace(':', "/")); + settings.name.unwrap_or_else(|| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()); + let mut projects = vec![project_at(root, ":".into(), root_name, &dir)]; + for path in settings.includes { + let pdir = match settings.dirs.get(&path) { + Some(d) => normalize(&dir.join(d)), + None => dir.join(path.trim_start_matches(':').replace(':', "/")), + }; let name = path.rsplit(':').next().unwrap_or_default().to_owned(); - add(path, name, pdir); + projects.push(project_at(root, path, name, &pdir)); } let java_version = projects .iter() @@ -358,9 +544,32 @@ pub fn build_files(root: &Path, model: &GradleModel) -> Vec { let dir = root.join(&model.dir); let mut out: Vec = SETTINGS_FILES.iter().map(|f| dir.join(f)).filter(|p| p.is_file()).collect(); out.extend(model.projects.iter().filter_map(|p| p.build_file.as_ref().map(|f| root.join(f)))); + // The settings of each nested build (its root project's folder). + for p in model.projects.iter().filter(|p| p.build.as_deref() == Some(p.path.as_str())) { + let nested = root.join(&p.dir); + out.extend(SETTINGS_FILES.iter().map(|f| nested.join(f)).filter(|f| f.is_file())); + } + // What IDEA's auto-import also watches: properties, the wrapper and version catalogs. + let extra = ["gradle.properties", "gradle/wrapper/gradle-wrapper.properties"].map(|f| dir.join(f)); + out.extend(extra.into_iter().filter(|p| p.is_file())); + if let Ok(entries) = std::fs::read_dir(dir.join("gradle")) { + let mut catalogs: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.to_string_lossy().ends_with(".versions.toml") && p.is_file()) + .collect(); + catalogs.sort(); + out.extend(catalogs); + } out } +/// A build script without comments and with whitespace runs collapsed, so editing a +/// comment or reformatting does not make a sync stale (IDEA hashes lexer tokens alike). +pub(crate) fn script_fingerprint(text: &str) -> String { + strip_comments(text).split_whitespace().collect::>().join(" ") +} + /// `(project path, task)` from `gradle tasks --all` console output; tasks listed as /// `app:run` belong to `:app`, unqualified ones to the root project `:`. pub fn parse_tasks(output: &str) -> Vec<(String, GradleTask)> { @@ -451,14 +660,60 @@ dependencies { /* include("block") */ includeBuild("plugins") "#; - let (name, includes) = parse_settings(kts); - assert_eq!(name.as_deref(), Some("gradle-demo")); - assert_eq!(includes, [":app", ":lib", ":libs:core"]); + let s = parse_settings(kts); + assert_eq!(s.name.as_deref(), Some("gradle-demo")); + assert_eq!(s.includes, [":app", ":lib", ":libs:core"]); + assert!(s.dirs.is_empty()); let groovy = "rootProject.name = 'demo'\ninclude 'a',\n 'b'\ninclude ':c'\nprintln 'x'\n"; - let (name, includes) = parse_settings(groovy); - assert_eq!(name.as_deref(), Some("demo")); - assert_eq!(includes, [":a", ":b", ":c"]); + let s = parse_settings(groovy); + assert_eq!(s.name.as_deref(), Some("demo")); + assert_eq!(s.includes, [":a", ":b", ":c"]); + } + + #[test] + fn settings_include_flat_and_project_dirs() { + let groovy = r#" + includeFlat 'svc-a' + includeFlat 'svc-b', 'svc-c' // trailing comment + ['x', 'y'].each { m -> include ":svc-a:$m" } + include ':wo' + project(':wo').projectDir = file('../svc-a/.worktrees/wo') + project(":dyn").projectDir = file("${rootDir}/dyn") + includeFlated 'not-a-call' + "#; + let s = parse_settings(groovy); + assert_eq!(s.includes, [":svc-a", ":svc-b", ":svc-c", ":wo"]); + assert_eq!( + s.dirs.into_iter().collect::>(), + [ + (":svc-a".into(), "../svc-a".into()), + (":svc-b".into(), "../svc-b".into()), + (":svc-c".into(), "../svc-c".into()), + (":wo".into(), "../svc-a/.worktrees/wo".into()), + ] + ); + + let kts = "includeFlat(\"k\")\nproject(\":k\").projectDir = file(\"../other\")"; + let s = parse_settings(kts); + assert_eq!(s.includes, [":k"]); + assert_eq!(s.dirs.get(":k").map(String::as_str), Some("../other")); + } + + #[test] + fn loads_include_flat_siblings() { + let tmp = tempfile::tempdir().unwrap(); + let ws = tmp.path(); + std::fs::create_dir_all(ws.join("root")).unwrap(); + std::fs::create_dir_all(ws.join("svc")).unwrap(); + std::fs::write(ws.join("root/settings.gradle"), "rootProject.name = 'Root'\nincludeFlat 'svc'").unwrap(); + std::fs::write(ws.join("svc/build.gradle"), "plugins { id 'java' }").unwrap(); + let root = ws.join("root"); + let m = load(&root).unwrap(); + let svc = m.projects.iter().find(|p| p.path == ":svc").unwrap(); + assert_eq!(Path::new(&svc.dir), ws.join("svc")); + assert_eq!(svc.build_file.as_deref().map(Path::new), Some(ws.join("svc/build.gradle").as_path())); + assert!(root.join(svc.build_file.as_ref().unwrap()).is_file()); } #[test] @@ -539,4 +794,52 @@ Pattern: clean: Cleans the output files of a task. let none = tempfile::tempdir().unwrap(); assert!(load(none.path()).is_none()); } + + #[test] + fn merges_included_builds_and_build_src_by_identity_path() { + let tmp = tempfile::tempdir().unwrap(); + let ws = tmp.path(); + for d in ["shop/app", "shop/buildSrc", "lib/core"] { + std::fs::create_dir_all(ws.join(d)).unwrap(); + } + std::fs::write(ws.join("shop/settings.gradle"), "rootProject.name = 'shop'\ninclude 'app'").unwrap(); + std::fs::write(ws.join("lib/settings.gradle"), "include 'core'").unwrap(); + let root = ws.join("shop"); + // In evaluation order, as the init script prints it: nested builds first. + let line = |path: &str, d: &str, build: Option<&str>| { + let project = ReportedProject { + path: path.into(), + dir: ws.join(d).to_string_lossy().into_owned(), + build: build.map(Into::into), + ..ReportedProject::default() + }; + format!("{MODEL_LINE}{}\n", serde_json::to_string(&project).unwrap()) + }; + let out = [ + line(":lib", "lib", Some(":lib")), + line(":lib:core", "lib/core", Some(":lib")), + line(":buildSrc", "shop/buildSrc", Some(":buildSrc")), + line(":", "shop", None), + line(":app", "shop/app", None), + ] + .concat(); + let mut model = load(&root).unwrap(); + merge_reported_projects(&root, &mut model, &parse_reported_projects(&out)); + let projects: Vec<_> = + model.projects.iter().map(|p| (p.path.as_str(), p.name.as_str(), p.build.as_deref())).collect(); + assert_eq!( + projects, + [ + (":", "shop", None), + (":app", "app", None), + (":buildSrc", "buildSrc", Some(":buildSrc")), + (":lib", "lib", Some(":lib")), + (":lib:core", "core", Some(":lib")), + ] + ); + let build_src = &model.projects[2]; + assert!(build_src.tasks.is_empty(), "buildSrc tasks cannot run from the root build"); + assert!(!model.projects[4].tasks.is_empty()); + assert!(build_files(&root, &model).contains(&ws.join("lib/settings.gradle"))); + } } diff --git a/crates/ide-build/src/lib.rs b/crates/ide-build/src/lib.rs index 230ea671..a362b480 100644 --- a/crates/ide-build/src/lib.rs +++ b/crates/ide-build/src/lib.rs @@ -65,6 +65,18 @@ pub fn load_linked( BuildModel { maven, gradle } } +/// Gradle project folders outside `root` as of the last sync (`includeFlat` siblings): +/// extra content roots, as IDEA lists module roots beside the project folder. +/// Reads only the sync's cached projects (no build script), so it is cheap enough for hot paths. +pub fn gradle_external_roots(root: &Path, cache_dir: &Path) -> Vec { + gradle::external_roots(root, sync::reported_gradle_projects(cache_dir).iter().map(|p| p.dir.as_str())) +} + +/// Source, resource and build folders of the last Gradle sync, absolute. +pub fn gradle_source_roots(cache_dir: &Path) -> Vec<(PathBuf, gradle::ReportedRoot)> { + gradle::reported_source_roots(&sync::reported_gradle_projects(cache_dir)) +} + /// A library the project depends on, as `groupId:artifactId:version`. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct DependencyCoordinate { diff --git a/crates/ide-build/src/sync.rs b/crates/ide-build/src/sync.rs index dec09289..46e058f8 100644 --- a/crates/ide-build/src/sync.rs +++ b/crates/ide-build/src/sync.rs @@ -4,14 +4,16 @@ //! per project (`maven.json`, `gradle.json` in the cache directory) and merged //! into the static model by [`crate::load`]; a changed build file marks them stale. -use crate::gradle::{self, GradleModel, GradleTask}; +use crate::gradle::{self, GradleModel, GradleTask, ReportedProject, parse_reported_projects}; use crate::maven::{self, MavenDependency, MavenModel, MavenPlugin}; use crate::process::{self, Output}; use ide_core::{CancellationToken, IdeError, IdeResult}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use specta::Type; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Mutex}; +use std::time::SystemTime; #[derive(Clone, Debug, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -68,8 +70,14 @@ struct GradleCache { at_ms: f64, error: Option, tasks: Vec<(String, GradleTask)>, + /// The projects as Gradle evaluated them; empty in caches from before they were read. + #[serde(default)] + projects: Vec, } +/// Init script printing every project, its source sets and Java version once configured. +const MODEL_INIT_SCRIPT: &str = include_str!("gradle-model.gradle"); + const MAVEN_CACHE: &str = "maven.json"; const MAVEN_CACHE_VERSION: u32 = 1; @@ -122,6 +130,20 @@ fn write_cache(dir: &Path, name: &str, value: &T) -> IdeResult<()> /// FNV-1a over the paths and contents of `files` (missing files count as empty). fn hash_files(files: &[PathBuf]) -> String { + hash_contents(files, |f| std::fs::read(f).unwrap_or_default()) +} + +/// Like IDEA's auto-import CRC: editing a comment or reformatting a build script +/// does not make the last sync stale. +fn hash_gradle_files(files: &[PathBuf]) -> String { + hash_contents(files, |f| { + let text = std::fs::read_to_string(f).unwrap_or_default(); + let script = f.extension().is_some_and(|e| e == "gradle" || e == "kts"); + if script { gradle::script_fingerprint(&text) } else { text }.into_bytes() + }) +} + +fn hash_contents(files: &[PathBuf], read: impl Fn(&Path) -> Vec) -> String { let mut h: u64 = 0xcbf29ce484222325; let mut feed = |bytes: &[u8]| { for b in bytes { @@ -132,7 +154,7 @@ fn hash_files(files: &[PathBuf]) -> String { for f in files { feed(f.to_string_lossy().as_bytes()); feed(&[0]); - feed(&std::fs::read(f).unwrap_or_default()); + feed(&read(f)); feed(&[0]); } format!("{h:016x}") @@ -168,9 +190,33 @@ pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel) } } +/// The projects of the last successful Gradle sync. +/// Parsed once per change of the cache file (modification time and size): content and source +/// roots are read on hot paths (every references search, Find in Files, repository scans). +pub fn reported_gradle_projects(cache_dir: &Path) -> Arc> { + type Memo = HashMap, u64, Arc>)>; + static MEMO: LazyLock> = LazyLock::new(Default::default); + let path = cache_dir.join(GRADLE_CACHE); + let Ok(meta) = std::fs::metadata(&path) else { + MEMO.lock().unwrap_or_else(|e| e.into_inner()).remove(&path); + return Arc::default(); + }; + let stamp = (meta.modified().ok(), meta.len()); + let mut memo = MEMO.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((modified, len, projects)) = memo.get(&path) + && (*modified, *len) == stamp + { + return projects.clone(); + } + let projects = Arc::new(read_cache::(cache_dir, GRADLE_CACHE).map(|c| c.projects).unwrap_or_default()); + memo.insert(path, (stamp.0, stamp.1, projects.clone())); + projects +} + /// Merges the cached task list into `model`. pub fn apply_gradle_cache(root: &Path, cache_dir: &Path, model: &mut GradleModel) { let Some(cache) = read_cache::(cache_dir, GRADLE_CACHE) else { return }; + gradle::merge_reported_projects(root, model, &cache.projects); if !cache.tasks.is_empty() { for p in &mut model.projects { let tasks: Vec = @@ -180,9 +226,11 @@ pub fn apply_gradle_cache(root: &Path, cache_dir: &Path, model: &mut GradleModel } } } + // A successful sync from before projects were cached has not seen computed includes. + let without_projects = cache.error.is_none() && cache.projects.is_empty(); model.synced_at = Some(cache.at_ms); model.sync_error = cache.error; - model.stale = cache.hash != hash_files(&gradle::build_files(root, model)); + model.stale = without_projects || cache.hash != hash_gradle_files(&gradle::build_files(root, model)); } // ---------------------------------------------------------------- processes @@ -322,8 +370,18 @@ pub fn sync_gradle( cancel: &CancellationToken, progress: &dyn Fn(SyncProgress), ) -> IdeResult<()> { - let model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?; - let mut args: Vec = vec!["--console=plain".into(), "-q".into(), "tasks".into(), "--all".into()]; + let mut model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?; + std::fs::create_dir_all(cache_dir)?; + let init_script = cache_dir.join("openidea-model.gradle"); + std::fs::write(&init_script, MODEL_INIT_SCRIPT)?; + let mut args: Vec = vec![ + "--console=plain".into(), + "-q".into(), + "--init-script".into(), + init_script.to_string_lossy().into_owned(), + "tasks".into(), + "--all".into(), + ]; if options.offline { args.push("--offline".into()); } @@ -336,11 +394,15 @@ pub fn sync_gradle( cancel, |_| {}, )?; + let projects = if out.success { parse_reported_projects(&out.text) } else { Vec::new() }; + // Hash the build files of the reported projects, or the model is stale right after the sync. + gradle::merge_reported_projects(root, &mut model, &projects); let cache = GradleCache { - hash: hash_files(&gradle::build_files(root, &model)), + hash: hash_gradle_files(&gradle::build_files(root, &model)), at_ms: now_ms(), error: (!out.success).then(|| error_summary(&out.text)), tasks: if out.success { gradle::parse_tasks(&out.text) } else { Vec::new() }, + projects, }; write_cache(cache_dir, GRADLE_CACHE, &cache) } @@ -456,6 +518,93 @@ mod tests { assert!(model.projects[0].dependencies.iter().all(|d| d.artifact_id != "cached")); } + #[test] + fn gradle_reported_projects_replace_static_ones() { + let tmp = tempfile::tempdir().unwrap(); + let ws = dunce::canonicalize(tmp.path()).unwrap(); + let root = ws.join("app"); + let billing = ws.join("billing"); + std::fs::create_dir_all(root.join("shared")).unwrap(); + std::fs::create_dir_all(billing.join("modules/core")).unwrap(); + // Computed includes: nothing a static reading can see. + std::fs::write(root.join("settings.gradle"), "rootProject.name = 'Shop'\n['billing'].each { includeFlat it }") + .unwrap(); + std::fs::write(billing.join("modules/core/build.gradle"), "apply plugin: 'java'").unwrap(); + // Built with serde: a Windows path holds backslashes, which raw JSON text would leave unescaped. + let line = |p: ReportedProject| format!("@openidea-model\t{}\n", serde_json::to_string(&p).unwrap()); + let (r, b) = (root.display().to_string(), billing.display().to_string()); + let core_src = |p: &str| format!("{b}/modules/core/src/{p}"); + let set = |name: &str, test: bool, src: &[&str], res: &[&str]| gradle::ReportedSourceSet { + name: name.into(), + test, + src: src.iter().map(|p| core_src(p)).collect(), + res: res.iter().map(|p| core_src(p)).collect(), + }; + // A settings script's own println comes first; task listing lines follow. + let out = format!( + "configuring billing\n{}{}{}@openidea-model\t{{broken\n\nOther tasks\n-----------\nbilling:core:jar\n", + line(ReportedProject { path: ":".into(), dir: r.clone(), ..ReportedProject::default() }), + line(ReportedProject { + path: ":billing".into(), + dir: b.clone(), + java: Some(17), + ..ReportedProject::default() + }), + line(ReportedProject { + path: ":billing:core".into(), + dir: format!("{b}/modules/core"), + java: Some(21), + source_sets: vec![ + set("main", false, &["main/java"], &["main/resources"]), + set("test", true, &["main/java", "test/java"], &[]), + ], + ..ReportedProject::default() + }), + ); + let projects = parse_reported_projects(&out); + assert_eq!(projects.len(), 3, "the malformed line is skipped"); + let src = |p: &str| PathBuf::from(core_src(p)); + assert_eq!( + gradle::reported_source_roots(&projects), + [ + (src("main/java"), gradle::ReportedRoot::Source), + (src("main/resources"), gradle::ReportedRoot::Resources), + (src("test/java"), gradle::ReportedRoot::Test), + ] + ); + let mut model = gradle::load(&root).unwrap(); + assert_eq!(model.projects.len(), 1); + gradle::merge_reported_projects(&root, &mut model, &projects); + let paths: Vec<_> = model.projects.iter().map(|p| (p.path.as_str(), p.name.as_str())).collect(); + assert_eq!(paths, [(":", "Shop"), (":billing", "billing"), (":billing:core", "core")]); + assert_eq!(model.projects[0].dir, ""); + assert!(root.join(model.projects[2].build_file.as_ref().unwrap()).is_file()); + assert_eq!( + gradle::external_roots(&root, model.projects.iter().map(|p| p.dir.as_str())), + std::slice::from_ref(&billing) + ); + assert_eq!(model.java_version, Some(21), "the newest version any module needs"); + assert_eq!( + gradle::parse_tasks(&out), + [(":billing:core".into(), GradleTask { name: "jar".into(), group: "other".into(), description: None })] + ); + + // The cached projects are parsed once per change of the cache file. + let cache_dir = ws.join("cache"); + let write = |projects: &[ReportedProject]| { + let cache = GradleCache { projects: projects.to_vec(), ..GradleCache::default() }; + write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap(); + }; + write(&projects); + let first = reported_gradle_projects(&cache_dir); + assert!(Arc::ptr_eq(&first, &reported_gradle_projects(&cache_dir)), "unchanged: not parsed again"); + assert_eq!(crate::gradle_external_roots(&root, &cache_dir), [billing]); + write(&projects[..1]); + assert_eq!(reported_gradle_projects(&cache_dir).len(), 1, "a new sync is read"); + std::fs::remove_file(cache_dir.join(GRADLE_CACHE)).unwrap(); + assert!(reported_gradle_projects(&cache_dir).is_empty()); + } + #[test] fn gradle_cache_replaces_tasks_per_project() { let tmp = tempfile::tempdir().unwrap(); @@ -466,16 +615,39 @@ mod tests { std::fs::write(root.join("app/build.gradle"), "apply plugin: 'java'").unwrap(); let mut model = gradle::load(&root).unwrap(); let cache = GradleCache { - hash: hash_files(&gradle::build_files(&root, &model)), + hash: hash_gradle_files(&gradle::build_files(&root, &model)), at_ms: 1.0, error: None, tasks: vec![(":app".into(), GradleTask { name: "hello".into(), group: "demo".into(), description: None })], + projects: [":", ":app"] + .iter() + .zip([root.clone(), root.join("app")]) + .map(|(path, dir)| ReportedProject { + path: (*path).into(), + dir: dir.to_string_lossy().into_owned(), + ..Default::default() + }) + .collect(), }; write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap(); apply_gradle_cache(&root, &cache_dir, &mut model); assert_eq!(model.projects[1].tasks.len(), 1); assert!(model.projects[0].tasks.iter().any(|t| t.name == "wrapper"), "root keeps its defaults"); assert!(!model.stale); + + let stale_after = |script: &str| { + std::fs::write(root.join("app/build.gradle"), script).unwrap(); + let mut m = gradle::load(&root).unwrap(); + apply_gradle_cache(&root, &cache_dir, &mut m); + m.stale + }; + assert!(!stale_after("// a note\napply plugin: 'java'\n"), "comments and spacing don't count"); + assert!(stale_after("apply plugin: 'groovy'")); + std::fs::write(root.join("app/build.gradle"), "apply plugin: 'java'").unwrap(); + std::fs::write(root.join("gradle.properties"), "org.gradle.jvmargs=-Xmx2g").unwrap(); + let mut m = gradle::load(&root).unwrap(); + apply_gradle_cache(&root, &cache_dir, &mut m); + assert!(m.stale, "gradle.properties is watched"); } #[test] diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index dd101f24..95ba3f5f 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -786,6 +786,19 @@ pub struct GradleSettings { pub home: String, /// Empty = Gradle's default user home. pub user_home: String, + /// IDEA's "Build and run using": who builds and launches an Application main. + pub run_using: GradleRunUsing, +} + +/// IDEA's "Build and run using" for Gradle builds; IDEA defaults to Gradle. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum GradleRunUsing { + /// A `JavaExec` task on the source set's runtime classpath (`gradleRun.gradle`). + #[default] + Gradle, + /// The Java language server's classpath, launched by OpenIDEA. + Ide, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] @@ -1026,6 +1039,7 @@ mod gradle_settings_tests { distribution, home: "/gradle".into(), user_home: "/cache/gradle".into(), + run_using: GradleRunUsing::Ide, }; let restored: Settings = serde_json::from_str(&serde_json::to_string(&settings).unwrap()).unwrap(); assert_eq!(restored.languages.java.gradle, settings.languages.java.gradle); diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index 03bf903d..4f1bc5a7 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -150,6 +150,13 @@ pub async fn build_sync( .map_err(join_err); host.end_request(request); let model = result??; + if tool == BuildTool::Gradle { + // The sync may report project folders outside the project (`includeFlat`): index and list them. + project.refresh_index_scope(); + project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() }); + let p = project.clone(); + tokio::task::spawn_blocking(move || p.refresh_repositories()); + } if tool == BuildTool::Maven && model.maven.is_some() && let Some(bridge) = &project.kotlin_bridge diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 82e4b057..2ab2c7b8 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -91,7 +91,8 @@ impl Host { let settings = SettingsStore::load(&self.config_dir, Some(&root)); let detected_roots = Arc::new(RwLock::new(ide_workspace::roots::detect(&root))); let excludes = exclude_globs(&root, &settings.effective(), &detected_roots.read()); - let extra_roots = content_roots(&settings.effective(), &root); + let build_cache = self.local_data_dir.join("cache").join(project_key(&root)).join("build"); + let extra_roots = content_roots(&settings.effective(), &root, &build_cache); mark("roots"); // The project's own registry: File Types associations may be project-scoped. @@ -265,7 +266,7 @@ impl Host { let kotlin_bridge = crate::kotlin_bridge::KotlinBridge::new( root.clone(), - self.local_data_dir.join("cache").join(&project_key).join("build"), + build_cache.clone(), &self.local_data_dir, settings.clone(), lsp.clone(), @@ -292,6 +293,7 @@ impl Host { let runtime = self.runtime.clone(); let lsp = lsp.clone(); let trusted = trusted.clone(); + let build_cache = build_cache.clone(); Watcher::start( &root.clone(), excludes.clone(), @@ -328,7 +330,7 @@ impl Host { // Detected excluded folders (`target`, `build`) leave the index too. let effective = settings.effective(); index.set_scope( - index_roots(&root, content_roots(&effective, &root)), + index_roots(&root, content_roots(&effective, &root, &build_cache)), exclude_globs(&root, &effective, &fresh), ); *detected_roots.write() = fresh; @@ -366,7 +368,7 @@ impl Host { matches!(rest.as_slice(), [first, ..] if first == "worktrees" && rest.len() <= 2) }); if git_dirs { - scan_repositories(&root, &repos, &settings, &sink); + scan_repositories(&root, &repos, &settings, &sink, &build_cache); } let vcs_moved = batch.vcs.iter().any(|p| vcs_path_changed(p)); if git_dirs || vcs_moved { @@ -413,9 +415,10 @@ impl Host { mark("watcher"); { let (root, repos, settings, sink) = (root.clone(), repos.clone(), settings.clone(), sink.clone()); + let build_cache = build_cache.clone(); std::thread::Builder::new() .name("openidea-vcs-roots".into()) - .spawn(move || scan_repositories(&root, &repos, &settings, &sink))?; + .spawn(move || scan_repositories(&root, &repos, &settings, &sink, &build_cache))?; } mark("git"); tracing::info!("opened {} in window {window} in {} ms {laps:?}", root.display(), started.elapsed().as_millis()); @@ -650,11 +653,17 @@ fn light_watcher( /// Looks for repositories nested below the project (and the one containing it); /// emits `VcsChanged` when the set of roots changed. -fn scan_repositories(root: &Path, repos: &RwLock, settings: &SettingsStore, sink: &SharedSink) { +fn scan_repositories( + root: &Path, + repos: &RwLock, + settings: &SettingsStore, + sink: &SharedSink, + build_cache: &Path, +) { let ignored = settings.effective().ignored_names(); let skip = |p: &Path| p.file_name().is_some_and(|n| ignored.is_ignored_name(&n.to_string_lossy())); let mut roots = vec![root.to_path_buf()]; - roots.extend(content_roots(&settings.effective(), root)); + roots.extend(content_roots(&settings.effective(), root, build_cache)); match Repos::discover_roots(&roots, &skip) { Ok(found) => { if found.roots() != repos.read().roots() { @@ -668,7 +677,7 @@ fn scan_repositories(root: &Path, repos: &RwLock, settings: &SettingsStor impl Project { pub fn refresh_repositories(&self) { - scan_repositories(&self.root, &self.repos, &self.settings, &self.sink); + scan_repositories(&self.root, &self.repos, &self.settings, &self.sink, &self.build_cache_dir()); } /// The project's git repositories. @@ -774,7 +783,8 @@ impl Project { /// Effective source / test / resource / excluded roots (detected + Project Structure marks). pub fn roots(&self) -> ProjectRoots { - linked_roots(&self.root, &self.settings.effective(), &self.detected_roots.read()) + let detected = with_gradle_roots(&self.root, &self.build_cache_dir(), &self.detected_roots.read()); + linked_roots(&self.root, &self.settings.effective(), &detected) } /// Where the Maven / Gradle tool windows cache what the build tools reported. @@ -841,7 +851,7 @@ impl Project { /// Project Structure content roots besides the project folder (existing directories). pub fn content_roots(&self) -> Vec { - content_roots(&self.settings.effective(), &self.root) + content_roots(&self.settings.effective(), &self.root, &self.build_cache_dir()) } /// Applies Settings › File Types; open documents whose language changed are @@ -960,14 +970,15 @@ fn index_roots(root: &Path, folders: Vec) -> ide_index::IndexRoots { ide_index::IndexRoots { include_root: !root.join(ide_workspace::workspace::FILE).is_file(), folders } } -/// Workspace members in manifest order, or extra Project Structure directories. -fn content_roots(settings: &ide_core::settings::Settings, root: &Path) -> Vec { +/// Workspace members in manifest order, or extra Project Structure directories and the +/// Gradle project folders outside the project as of the last sync (`build_cache`). +fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cache: &Path) -> Vec { if let Ok(Some(workspace)) = ide_workspace::workspace::Workspace::load(root) { return workspace.projects; } let mut out: Vec = Vec::new(); - for r in &settings.project_structure.content_roots { - let p = PathBuf::from(r.trim()); + let user = settings.project_structure.content_roots.iter().map(|r| PathBuf::from(r.trim())); + for p in user.chain(ide_build::gradle_external_roots(root, build_cache)) { if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) { out.push(p); } @@ -1024,6 +1035,33 @@ pub(crate) fn unlinked_maven_poms(root: &Path, settings: &ide_core::settings::Se ide_build::maven::UnlinkedProjects::load(root, &settings.languages.java.maven).poms().map(PathBuf::from).collect() } +/// The detected roots with the last Gradle sync's source sets first: Gradle evaluated what +/// static detection guesses. Folders outside the project keep their absolute path, which +/// `normalize` turns into the same form the UI compares (`Users/me/svc/src/main/java`). +fn with_gradle_roots(root: &Path, build_cache: &Path, detected: &[SourceFolder]) -> Vec { + use ide_build::gradle::ReportedRoot; + use ide_workspace::roots::RootKind; + let mut out: Vec = Vec::new(); + for (dir, kind) in ide_build::gradle_source_roots(build_cache) { + if !dir.is_dir() { + continue; + } + let path = ide_workspace::roots::normalize(&dir.strip_prefix(root).unwrap_or(&dir).to_string_lossy()); + let kind = match kind { + ReportedRoot::Source => RootKind::Source, + ReportedRoot::Test => RootKind::Test, + ReportedRoot::Resources => RootKind::Resources, + ReportedRoot::TestResources => RootKind::TestResources, + ReportedRoot::Excluded => RootKind::Excluded, + }; + if !out.iter().any(|f| f.path == path) { + out.push(SourceFolder { path, kind, detected_from: Some("Gradle".into()) }); + } + } + out.extend(detected.iter().filter(|d| !out.iter().any(|o| o.path == d.path)).cloned().collect::>()); + out +} + fn linked_roots(root: &Path, settings: &ide_core::settings::Settings, detected: &[SourceFolder]) -> ProjectRoots { let unlinked = ide_build::maven::UnlinkedProjects::load(root, &settings.languages.java.maven); let detected = detected @@ -1225,6 +1263,46 @@ fn vcs_path_changed(path: &Path) -> bool { !path.ends_with(".git/index") || !ide_vcs::is_own_index_write(path) } +#[cfg(test)] +mod gradle_roots_tests { + use super::*; + use ide_workspace::RootKind; + + #[test] + fn gradle_sync_roots_win_and_reach_outside_the_project() { + let tmp = tempfile::tempdir().unwrap(); + let ws = dunce::canonicalize(tmp.path()).unwrap(); + let (root, svc, cache) = (ws.join("p"), ws.join("svc"), ws.join("cache")); + for d in [root.join("src/it/java"), svc.join("src/main/java"), svc.join("build")] { + std::fs::create_dir_all(d).unwrap(); + } + let s = |p: PathBuf| p.to_string_lossy().into_owned(); + let json = serde_json::json!({ "hash": "", "at_ms": 1.0, "error": null, "tasks": [], "projects": [ + { "path": ":", "dir": s(root.clone()), "sourceSets": [ + { "name": "integrationTest", "test": true, "src": [s(root.join("src/it/java"))], "res": [] } ] }, + { "path": ":svc", "dir": s(svc.clone()), "buildDir": s(svc.join("build")), "sourceSets": [ + { "name": "main", "test": false, + "src": [s(svc.join("src/main/java")), s(svc.join("src/main/kotlin"))], "res": [] } ] }, + ]}); + std::fs::create_dir_all(&cache).unwrap(); + std::fs::write(cache.join("gradle.json"), json.to_string()).unwrap(); + let guessed = SourceFolder { path: "src/it/java".into(), kind: RootKind::Source, detected_from: None }; + let other = SourceFolder { path: "web".into(), kind: RootKind::Source, detected_from: None }; + let out = with_gradle_roots(&root, &cache, &[guessed, other.clone()]); + let abs = |p: PathBuf| ide_workspace::roots::normalize(&s(p)); + let got: Vec<(String, RootKind)> = out.iter().map(|f| (f.path.clone(), f.kind)).collect(); + assert_eq!( + got, + [ + ("src/it/java".into(), RootKind::Test), + (abs(svc.join("src/main/java")), RootKind::Source), + (abs(svc.join("build")), RootKind::Excluded), + ("web".into(), RootKind::Source), + ] + ); + } +} + #[cfg(test)] mod index_event_tests { use super::vcs_path_changed; diff --git a/src/bindings.ts b/src/bindings.ts index 074ab6ec..04798231 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -3489,7 +3489,10 @@ export type GradleModel = { }; export type GradleProject = { - /** Gradle path: `:` for the root project, `:app`, `:libs:core`. */ + /** + * Gradle path: `:` for the root project, `:app`, `:libs:core`; for a project of a + * nested build its identity path (`:lib:core`), which addresses its tasks from the root. + */ path: string, name: string, /** Project-relative directory. */ @@ -3497,8 +3500,20 @@ export type GradleProject = { /** Project-relative build script, if any. */ buildFile: string | null, tasks: GradleTask[], + /** + * Identity path of the nested build holding the project (`:lib` for `includeBuild`, + * `:buildSrc`); null for the opened build. + */ + build?: string | null, }; +/** IDEA's "Build and run using" for Gradle builds; IDEA defaults to Gradle. */ +export type GradleRunUsing = +/** A `JavaExec` task on the source set's runtime classpath (`gradleRun.gradle`). */ +"gradle" | +/** The Java language server's classpath, launched by OpenIDEA. */ +"ide"; + /** Build Tools › Gradle. */ export type GradleSettings = { /** Empty = Project JDK; otherwise a JDK home. */ @@ -3508,6 +3523,8 @@ export type GradleSettings = { home?: string, /** Empty = Gradle's default user home. */ userHome?: string, + /** IDEA's "Build and run using": who builds and launches an Application main. */ + runUsing?: GradleRunUsing, }; export type GradleTask = { diff --git a/src/features/build-tools/index.ts b/src/features/build-tools/index.ts index 172d3e3a..58d31bc7 100644 --- a/src/features/build-tools/index.ts +++ b/src/features/build-tools/index.ts @@ -43,7 +43,8 @@ export const MAVEN_WHEN = "projectOpen && build.maven"; export const GRADLE_WHEN = "projectOpen && build.gradle"; /** Build files whose creation / change / removal re-reads the model. */ -const BUILD_FILE = /(?:^|\/)(?:pom\.xml|(?:build|settings)\.gradle(?:\.kts)?|mvnw(?:\.cmd)?|gradlew(?:\.bat)?)$/; +const BUILD_FILE = + /(?:^|\/)(?:pom\.xml|(?:build|settings)\.gradle(?:\.kts)?|mvnw(?:\.cmd)?|gradlew(?:\.bat)?|gradle\.properties|gradle-wrapper\.properties|[^/]+\.versions\.toml)$/; function expandAll(tool: BuildTool) { const { model, configs } = useBuild.getState(); diff --git a/src/features/build-tools/store.ts b/src/features/build-tools/store.ts index 936fbb11..33f141b2 100644 --- a/src/features/build-tools/store.ts +++ b/src/features/build-tools/store.ts @@ -23,6 +23,7 @@ import { type SyncProgress, } from "@/platform/ipc"; import { downloadJdkActions } from "@/platform/jdk"; +import { useBuildModel } from "@/platform/buildModel"; import { useProject } from "@/platform/project"; import { editorService, notifications } from "@/workbench/contributions"; import { cancelTask, removeTask, upsertTask, useTasks } from "@/workbench/tasks"; @@ -74,6 +75,14 @@ export const useBuild = create(() => ({ current: {}, })); +// Shared with the features that follow the build (run, Project view). +useBuild.subscribe((s) => { + const shared = useBuildModel.getState(); + const gradleOffline = s.settings.gradle.offline; + if (shared.root !== s.root || shared.model !== s.model || shared.gradleOffline !== gradleOffline) + useBuildModel.setState({ root: s.root, model: s.model, gradleOffline }); +}); + // --------------------------------------------------------------------------- // Settings (per project) @@ -189,6 +198,9 @@ export function changedOutsideIde(paths: readonly string[], now = Date.now()): b return external; } +/** Root whose never-synced Gradle build was imported on open (once per open). */ +let autoImported: string | null = null; + /** Re-reads the build model (and the run configurations) of the open project. */ export async function loadModel(): Promise { const root = useProject.getState().project?.root ?? null; @@ -218,6 +230,11 @@ export async function loadModel(): Promise { const maven = model.maven; if (maven?.stale && maven.syncedAt !== null && !useBuild.getState().syncing.maven) void syncTool("maven"); } + // Like IDEA, a Gradle build is imported when first opened; later changes wait for Reload. + if (model.gradle && model.gradle.syncedAt === null && autoImported !== root) { + autoImported = root; + void syncTool("gradle"); + } } catch (e) { if (gen === generation) useBuild.setState({ loading: false, error: e instanceof Error ? e.message : String(e) }); } diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx index 363912c4..26548a64 100644 --- a/src/features/project-tree/ProjectView.tsx +++ b/src/features/project-tree/ProjectView.tsx @@ -132,7 +132,9 @@ function iconFor( const kind = folderKind(node, roots, root, build, detected); if (kind !== "plain") return folderIconName(kind); const rel = root ? (node.path === root ? "" : relativePath(root, node.path)) : null; - const module = rel === null ? null : moduleFolderIcon(rel, node.root, build.modules); + // Modules outside the project are keyed by their absolute folder. + const key = rel !== null && build.modules.has(rel) ? rel : node.path; + const module = root ? moduleFolderIcon(key, node.root, build.modules) : null; if (module) return module; return node.root ? "module" : "folder"; } diff --git a/src/features/project-tree/index.ts b/src/features/project-tree/index.ts index 6897ce40..b4d699fb 100644 --- a/src/features/project-tree/index.ts +++ b/src/features/project-tree/index.ts @@ -1,6 +1,7 @@ /** Project view: the "project" tool window, its file operations and actions. */ import { onEditorEvent, openEditorPaths } from "@/editor/api"; import { onIdeEvent } from "@/platform/ipc"; +import { useBuildModel } from "@/platform/buildModel"; import { validateProjectStructure, type RootFolder } from "@/platform/languageSettings"; import { useProject } from "@/platform/project"; import { initProjectRoots, useProjectRoots } from "@/platform/projectRoots"; @@ -15,6 +16,7 @@ import { useProjectViewRoots, } from "@/workbench/contributions"; import { registerProjectActions } from "./actions"; +import { gradleModuleGroups } from "./moduleGroups"; import { FileRefactorDialogHost } from "./FileRefactorDialogs"; import { NewPopupHost } from "./NewPopup"; import { RefactoringPreviewHost } from "./RefactoringPreview"; @@ -65,14 +67,20 @@ export function activate(): () => void { if (s.root && s.root === prev.root && detectedExcluded(s) !== detectedExcluded(prev)) void reloadAll(); }); - // Project Structure content roots besides the project folder, listed after it. + // Project Structure content roots after the project folder, and IDEA's module groups of + // a Gradle build with modules outside it (as of the last sync) before it. let contentRoots: Array<() => void> = []; let shownRoots = ""; const syncContentRoots = () => { const { project, settings } = useProject.getState(); - const paths = - project && !project.workspace ? validateProjectStructure(settings?.projectStructure).contentRoots : []; - const key = JSON.stringify(paths); + const own = project && !project.workspace; + const paths = own ? validateProjectStructure(settings?.projectStructure).contentRoots : []; + // The Build tool window's model (one load per build change), not a load of our own. + const build = useBuildModel.getState(); + const gradleProjects = build.root === project?.root ? (build.model?.gradle?.projects ?? []) : []; + const rootName = gradleProjects.find((p) => p.path === ":")?.name ?? ""; + const groups = own && project ? gradleModuleGroups(gradleProjects, rootName, project.root) : []; + const key = JSON.stringify([paths, groups]); if (key === shownRoots) return; shownRoots = key; contentRoots.forEach((d) => d()); @@ -86,8 +94,14 @@ export function activate(): () => void { order: -100 + i, }), ); + groups.forEach((g, i) => + contentRoots.push(registerProjectViewRoot({ ...g, id: g.id!, order: -200 + i, beforeProject: true })), + ); }; syncContentRoots(); + const unsubGradleRoots = useBuildModel.subscribe((s, prev) => { + if (s.model !== prev.model || s.root !== prev.root) syncContentRoots(); + }); const unsubContentRoots = useProject.subscribe((s, prev) => { if (s.settings !== prev.settings || s.project !== prev.project) syncContentRoots(); }); @@ -124,6 +138,7 @@ export function activate(): () => void { unsubExtraRoots, unsubExcludes, unsubContentRoots, + unsubGradleRoots, () => contentRoots.forEach((d) => d()), disposeRoots, unsubRoots, diff --git a/src/features/project-tree/model.ts b/src/features/project-tree/model.ts index fd009953..832109d3 100644 --- a/src/features/project-tree/model.ts +++ b/src/features/project-tree/model.ts @@ -249,6 +249,8 @@ export interface ExtraRoot { children?: readonly ExtraRoot[]; detail?: string; readOnly?: boolean; + /** Listed above the project folder (IDEA's module group of the build). */ + beforeProject?: boolean; } /** Key prefix of group nodes that show no folder ("External Libraries", an SDK). */ @@ -387,13 +389,24 @@ export function withExtraRoots( readOnly: true, }; out.byKey.set(key, node); - const kids = (extra.children ?? []).map((c) => add(c, key, ro)).filter((n): n is ProjectNode => !!n); - out.children.set(key, kids); + // Deepest folders first: a module folder nested in a sibling's (`billing/modules/core` + // in `billing`) would otherwise already be keyed by the sibling's listing and dropped. + const children = extra.children ?? []; + const depth = (c: ExtraRoot) => c.path?.split("/").length ?? 0; + const built: (ProjectNode | null)[] = []; + for (const i of children.map((_, i) => i).sort((a, b) => depth(children[b]!) - depth(children[a]!))) + built[i] = add(children[i]!, key, ro); + out.children.set( + key, + built.filter((n): n is ProjectNode => !!n), + ); return node; }; + let before = 0; for (const extra of extras) { const node = add(extra, null, false); - if (node) out.roots.push(node); + if (node && extra.beforeProject) out.roots.splice(before++, 0, node); + else if (node) out.roots.push(node); } return out; } diff --git a/src/features/project-tree/moduleGroups.test.ts b/src/features/project-tree/moduleGroups.test.ts new file mode 100644 index 00000000..c7d70ae2 --- /dev/null +++ b/src/features/project-tree/moduleGroups.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import type { FileEntry } from "@/bindings"; +import { buildModel, DEFAULT_TREE_OPTIONS, type ExtraRoot, withExtraRoots } from "./model"; +import { gradleModuleGroups } from "./moduleGroups"; + +type Shown = { label: string; detail?: string; children?: Shown[] }; +const shape = (e: ExtraRoot): Shown => ({ + label: e.label, + ...(e.detail ? { detail: e.detail } : {}), + ...(e.children ? { children: e.children.map(shape) } : {}), +}); + +describe("gradleModuleGroups (IDEA's module groups)", () => { + // `includeFlat` sibling modules, one with layer modules nested in its folder. + const flat = [ + { path: ":", dir: "" }, + { path: ":common", dir: "/w/common" }, + { path: ":billing", dir: "/w/billing" }, + ...["core", "api"].map((l) => ({ path: `:billing:${l}`, dir: `/w/billing/modules/${l}` })), + ]; + + it("lists every module of the group, nested ones flattened, as IntelliJ shows it", () => { + expect(gradleModuleGroups(flat, "Shop", "/w/shop").map(shape)).toEqual([ + { + label: "Shop", + children: [ + { label: "api", detail: "Shop.billing.api" }, + { label: "billing", detail: "Shop.billing" }, + { label: "common", detail: "Shop.common" }, + { label: "core", detail: "Shop.billing.core" }, + ], + }, + ]); + }); + + it("shows no group while every module lives in the project folder", () => { + const inside = [ + { path: ":", dir: "" }, + { path: ":app", dir: "app" }, + { path: ":libs:core", dir: "libs/core" }, + ]; + expect(gradleModuleGroups(inside, "demo", "/w/demo")).toEqual([]); + }); + + it("keeps a subgroup that no module is named like", () => { + const projects = [ + { path: ":", dir: "" }, + { path: ":svc", dir: "/w/svc" }, + { path: ":libs:a", dir: "/w/libs/a" }, + { path: ":libs:b", dir: "/w/libs/b" }, + ]; + expect(gradleModuleGroups(projects, "R", "/w/r").map(shape)).toEqual([ + { + label: "R", + children: [ + { + label: "libs", + children: [ + { label: "a", detail: "R.libs.a" }, + { label: "b", detail: "R.libs.b" }, + ], + }, + { label: "svc", detail: "R.svc" }, + ], + }, + ]); + }); + + it("names an included build's modules after it and lists it as an ungrouped module", () => { + const projects = [ + { path: ":", dir: "" }, + { path: ":app", dir: "app" }, + { path: ":buildSrc", dir: "buildSrc", build: ":buildSrc" }, + { path: ":lib", dir: "/w/lib", build: ":lib" }, + { path: ":lib:core", dir: "/w/lib/core", build: ":lib" }, + ]; + expect(gradleModuleGroups(projects, "Shop", "/w/shop").map(shape)).toEqual([{ label: "lib" }]); + // Beside includeFlat modules: the group (every `Shop.*` module), then the included build. + const both = [...projects, { path: ":svc", dir: "/w/svc" }]; + expect(gradleModuleGroups(both, "Shop", "/w/shop").map(shape)).toEqual([ + { + label: "Shop", + children: [ + { label: "app", detail: "Shop.app" }, + { label: "buildSrc", detail: "Shop.buildSrc" }, + { label: "svc", detail: "Shop.svc" }, + ], + }, + { label: "lib" }, + ]); + }); +}); + +const dir = (path: string): FileEntry => ({ + name: path.slice(path.lastIndexOf("/") + 1), + path, + kind: "dir", + size: 0, + mtimeMs: 1, + ignored: false, + excluded: false, + hidden: false, +}); + +describe("module group nodes in the tree", () => { + it("lists the group above the project and keeps a nested module whose parent is expanded", () => { + const listings = new Map([ + ["/w/billing", [dir("/w/billing/modules")]], + ["/w/billing/modules", [dir("/w/billing/modules/core")]], + ]); + const base = buildModel({ path: "/p", name: "p" }, new Map(), DEFAULT_TREE_OPTIONS); + const group = { + label: "Shop", + icon: "gradle-project", + beforeProject: true, + children: [ + { label: "billing", icon: "gradle-module", path: "/w/billing" }, + { label: "core", icon: "gradle-module", path: "/w/billing/modules/core" }, + ], + }; + const m = withExtraRoots(base, [group], listings, DEFAULT_TREE_OPTIONS); + expect(m.roots.map((r) => r.label)).toEqual(["Shop", "p"]); + expect(m.children.get(m.roots[0]!.key)!.map((n) => n.label)).toEqual(["billing", "core"]); + }); +}); diff --git a/src/features/project-tree/moduleGroups.ts b/src/features/project-tree/moduleGroups.ts new file mode 100644 index 00000000..40a178f7 --- /dev/null +++ b/src/features/project-tree/moduleGroups.ts @@ -0,0 +1,92 @@ +/** + * IDEA's module groups in the Project view, for Gradle builds whose modules live outside + * the project folder (`includeFlat`, computed `projectDir`s, `includeBuild`). Mirrors IntelliJ's + * `AbstractProjectNode.modulesAndGroups`, `ModuleGroup.modulesInGroup` / `childGroups` + * and the `QualifiedNameGrouper` of qualified module names (`.billing.core`). + */ +import type { ExtraRoot } from "./model"; +import { isSameOrInside, nameOf } from "./paths"; + +interface Module { + /** Qualified name: `.`. */ + name: string; + dir: string; +} + +const isAbsolute = (p: string) => p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p); +const segments = (name: string) => name.split("."); +const groupOf = (m: Module) => segments(m.name).slice(0, -1); +const same = (a: readonly string[], b: readonly string[]) => a.length === b.length && a.every((x, i) => x === b[i]); +const isChild = (parent: readonly string[], d: readonly string[]) => + d.length > parent.length && same(d.slice(0, parent.length), parent); +const byLabel = (a: ExtraRoot, b: ExtraRoot) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }); + +/** + * Group nodes to list beside the project folder: none while the project folder is the only + * top-level module root (every module inside it), as in IDEA. + */ +export function gradleModuleGroups( + projects: readonly { path: string; dir: string; build?: string | null }[], + rootName: string, + projectRoot: string, +): ExtraRoot[] { + const modules: Module[] = projects.map((p) => { + const path = p.path.split(":").filter(Boolean); + // An included build's modules are named after that build (`lib.core`); buildSrc and + // the opened build's after the root project (`shop.buildSrc`), as IDEA names them. + const own = !p.build || p.build === ":buildSrc"; + return { + name: (own ? [rootName, ...path] : path).join("."), + dir: isAbsolute(p.dir) ? p.dir : p.dir ? `${projectRoot}/${p.dir}` : projectRoot, + }; + }); + // A content root is top level when no other content root contains it (`isTopLevelRoot`). + const roots = [projectRoot, ...modules.map((m) => m.dir)]; + const topLevel = modules.filter( + (m) => m.dir === projectRoot || !roots.some((r) => r !== m.dir && isSameOrInside(m.dir, r)), + ); + if (topLevel.length <= 1) return []; + const asGroups = modules.map((m) => segments(m.name)); + const isModulePath = (path: readonly string[]) => asGroups.some((g) => same(g, path)); + + const moduleNode = (m: Module): ExtraRoot => { + const label = nameOf(m.dir) || m.dir; + return { id: m.name, label, detail: m.name === label ? undefined : m.name, icon: "gradle-module", path: m.dir }; + }; + // `modulesAndGroups`: ungrouped top-level modules as they are (the project folder is + // shown anyway), one node for the common group path of the grouped ones. + const loose = topLevel.filter((m) => !groupOf(m).length && m.dir !== projectRoot).map(moduleNode); + const grouped = topLevel.map(groupOf).filter((g) => g.length); + if (!grouped.length) return loose.sort(byLabel); + let common = grouped[0]!; + for (const g of grouped.slice(1)) { + let n = 0; + while (n < Math.min(common.length, g.length) && common[n] === g[n]) n++; + common = common.slice(0, n); + } + const tops = common.length ? [common] : [...new Map(grouped.map((g) => [g[0]!, [g[0]!]])).values()]; + + const node = (group: readonly string[]): ExtraRoot => { + // `childGroups`: direct subgroups not named like a module (those fold into this group). + const childGroups = new Map(); + for (const m of modules) { + const g = groupOf(m); + const direct = [...group, g[group.length]!]; + if (isChild(group, g) && !isModulePath(direct)) childGroups.set(direct.join("."), direct); + } + // `modulesInGroup`: this group's modules, and those under a subgroup named like a module. + const members = modules.filter((m) => { + const g = groupOf(m); + return same(g, group) || (isChild(group, g) && isModulePath(g.slice(0, group.length + 1))); + }); + const seen = new Set(); + const moduleNodes = members.filter((m) => !seen.has(m.dir) && !!seen.add(m.dir)).map(moduleNode); + return { + id: `gradleGroup:${group.join(".")}`, + label: group[group.length - 1]!, + icon: "gradle-project", + children: [...[...childGroups.values()].map(node).sort(byLabel), ...moduleNodes.sort(byLabel)], + }; + }; + return [...tops.map(node), ...loose.sort(byLabel)]; +} diff --git a/src/features/run/application.ts b/src/features/run/application.ts index 9be6396a..b77cc1f0 100644 --- a/src/features/run/application.ts +++ b/src/features/run/application.ts @@ -5,14 +5,21 @@ */ import type { RunConfig } from "@/platform/ipc"; import { CLASSPATH, TEST_CLASSPATH, jvmModuleDir } from "@/platform/javaLaunch"; -import { isTestPath } from "@/platform/languageSettings"; +import { isTestPath, rootFor } from "@/platform/languageSettings"; import { currentRoots } from "@/platform/projectRoots"; import { simpleClassName } from "./jvmTargets"; export { jvmModuleDir } from "@/platform/javaLaunch"; -/** In a test source root (detected or marked in Project Structure), or Maven's `src/test`. */ -const isTestSource = (relPath: string) => /(^|\/)src\/test\//.test(relPath) || isTestPath(currentRoots(), relPath); +/** + * In a test source root, as IDEA's `getClasspathType` decides: the root holding the file + * (synced from Gradle, detected or marked in Project Structure) wins; `src/test` only + * while no root is known for the file. + */ +const isTestSource = (relPath: string) => { + const roots = currentRoots(); + return rootFor(roots, relPath) ? isTestPath(roots, relPath) : /(^|\/)src\/test\//.test(relPath); +}; /** Temporary "Application" configuration that runs `className` (a binary name). */ export function applicationConfig( diff --git a/src/features/run/contextRun.ts b/src/features/run/contextRun.ts index 742e6a7c..be9e3918 100644 --- a/src/features/run/contextRun.ts +++ b/src/features/run/contextRun.ts @@ -8,6 +8,8 @@ import { activeEditor } from "@/editor/api"; import { executeAction, getAction } from "@/platform/actions"; import { call, commands, isTauri, type RunConfig } from "@/platform/ipc"; import { useProject } from "@/platform/project"; +import { validateProjectStructure } from "@/platform/languageSettings"; +import { useBuildModel } from "@/platform/buildModel"; import { isVirtualUri } from "@/platform/virtualDocs"; import { showContextMenu, type MenuItem } from "@/ui"; import { @@ -42,6 +44,31 @@ async function ensureConfigs() { } /** The focused editor's file, text and caret line. */ +/** Project folders outside its root: Project Structure content roots and Gradle modules (as of the last sync). */ +export function projectContentRoots(): string[] { + const own = validateProjectStructure(useProject.getState().settings?.projectStructure).contentRoots; + // The build model keeps the folders of modules outside the project absolute. + const gradle = (useBuildModel.getState().model?.gradle?.projects ?? []) + .map((p) => p.dir) + .filter((d) => isAbsolute(d)); + return [...new Set([...own, ...gradle])]; +} + +const isAbsolute = (p: string) => p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p); + +/** + * The project side of every run input (editor, gutter, menus, actions): built in one place + * so no entry point runs a project file as a stray one. + */ +export function projectContext(): Pick { + return { + root: useProject.getState().project?.root ?? null, + configs: useRun.getState().configs, + goModule: goModulePath, + contentRoots: projectContentRoots(), + }; +} + export function editorInput(): ContextInput | null { const path = maybeEditorService()?.activePath() ?? activeEditor()?.ctx.path ?? null; if (!path || isVirtualUri(path)) return null; @@ -49,12 +76,10 @@ export function editorInput(): ContextInput | null { const same = editor && editor.ctx.path === path; const state = same ? editor.view.state : null; return { + ...projectContext(), path, - root: useProject.getState().project?.root ?? null, - configs: useRun.getState().configs, content: state ? state.doc.sliceString(0, CONTENT_LIMIT) : undefined, line: state ? state.doc.lineAt(state.selection.main.head).number - 1 : undefined, - goModule: goModulePath, }; } @@ -148,7 +173,7 @@ export async function compiledDebugPlan(config: RunConfig): Promise + // `target` is an identity path (`:lib:core` for a project of an included build). + def id = { try { p.identityPath.toString() } catch (ignored) { p.path } } + if (id() != target) return + p.plugins.withId('java') { + def json = new JsonSlurper() + def list = { String key -> (List) json.parseText(System.getProperty(key, '[]')) } + p.tasks.register('openideaRun', JavaExec) { t -> + t.description = 'Runs a main class for OpenIDEA.' + t.classpath = p.sourceSets.getByName(System.getProperty('openidea.run.sourceSet', 'main')).runtimeClasspath + t.mainClass.set(System.getProperty('openidea.run.main')) + t.args(list('openidea.run.args')) + t.jvmArgs(list('openidea.run.jvmArgs')) + // The IDE attaches to this JVM, not to the Gradle daemon; port 0 prints the one picked. + if (Boolean.getBoolean('openidea.run.debug')) { + t.jvmArgs('-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:0') + } + def dir = System.getProperty('openidea.run.workingDir') + if (dir) t.workingDir = dir + t.standardInput = System.in + } + } + } +} diff --git a/src/features/run/gradleRun.test.ts b/src/features/run/gradleRun.test.ts new file mode 100644 index 00000000..27584d19 --- /dev/null +++ b/src/features/run/gradleRun.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import type { GradleModel, RunConfig } from "@/platform/ipc"; +import { applicationConfig } from "./application"; +import { DELEGATED_DEBUG, delegatedGradleConfig, wrapperGradleVersion } from "./gradleRun"; +import { jdwpLaunch } from "./javaDebug"; +import { isJvmApplication } from "@/platform/javaLaunch"; + +const ROOT = "/w/shop"; +const CORE = "/w/billing/modules/core"; +const gradle = { + dir: "", + command: "/w/shop/gradlew", + env: { JAVA_HOME: "/jdk21" }, + projects: [ + { path: ":", name: "Shop", dir: "", buildFile: null, tasks: [] }, + { path: ":billing:core", name: "core", dir: CORE, buildFile: null, tasks: [] }, + { path: ":app", name: "app", dir: "app", buildFile: null, tasks: [] }, + ], +} as unknown as GradleModel; +const ctx = { + root: ROOT, + gradle, + script: "/tmp/openidea-gradle-run.gradle", + offline: false, + configurationCache: false, +}; + +describe("Build and run using: Gradle", () => { + it("runs an Application of an includeFlat module as its JavaExec task", () => { + const app: RunConfig = { + ...applicationConfig( + { className: "com.example.ShopApplication", spring: true }, + `${CORE}/src/main/java/com/example/ShopApplication.java`, + [], + ), + args: ["-Xmx1g", "-cp", "${classpath}", "com.example.ShopApplication", "--port", "8081 x"], + env: { SPRING_PROFILES_ACTIVE: "dev" }, + }; + const run = delegatedGradleConfig(app, CORE, ctx)!; + expect(run).toMatchObject({ + kind: "gradle", + name: "ShopApplication", + command: "/w/shop/gradlew", + cwd: null, + module: CORE, + env: { JAVA_HOME: "/jdk21", SPRING_PROFILES_ACTIVE: "dev" }, + }); + expect(run.args).toEqual([ + "--console=plain", + "--init-script", + "/tmp/openidea-gradle-run.gradle", + "-Dopenidea.run.project=:billing:core", + "-Dopenidea.run.main=com.example.ShopApplication", + "-Dopenidea.run.sourceSet=main", + '-Dopenidea.run.args=["--port","8081 x"]', + '-Dopenidea.run.jvmArgs=["-Xmx1g"]', + `-Dopenidea.run.workingDir=${CORE}`, + ":billing:core:openideaRun", + ]); + }); + + it("uses the test classpath, a project-relative module and offline mode", () => { + const test = applicationConfig({ className: "a.Tool" }, "app/src/test/java/a/Tool.java", []); + const run = delegatedGradleConfig(test, "app", { ...ctx, offline: true })!; + expect(run.args).toContain("-Dopenidea.run.sourceSet=test"); + expect(run.args).toContain("-Dopenidea.run.workingDir=/w/shop/app"); + expect(run.args.slice(-2)).toEqual(["--offline", ":app:openideaRun"]); + }); + + it("reuses Gradle's configuration cache on Gradle 8.1+ wrappers", () => { + const app = applicationConfig({ className: "a.Main" }, "app/src/main/java/a/Main.java", []); + const run = delegatedGradleConfig(app, "app", { ...ctx, configurationCache: true })!; + expect(run.args.slice(-3)).toEqual([ + "--configuration-cache", + "--configuration-cache-problems=warn", + ":app:openideaRun", + ]); + const props = (url: string) => `distributionBase=GRADLE_USER_HOME\ndistributionUrl=${url}\n`; + expect(wrapperGradleVersion(props("https\\://services.gradle.org/distributions/gradle-8.10.2-bin.zip"))).toEqual([ + 8, 10, + ]); + expect(wrapperGradleVersion(props("https://x/gradle-7.6-all.zip"))).toEqual([7, 6]); + expect(wrapperGradleVersion("# no distribution\n")).toBeNull(); + }); + + it("leaves mains outside the build's modules to the language server", () => { + const stray = applicationConfig({ className: "x.Main" }, "/elsewhere/src/main/java/x/Main.java", []); + expect(delegatedGradleConfig(stray, "/elsewhere", ctx)).toBeNull(); + }); + + it("runs included builds' modules by identity path, never buildSrc's", () => { + const nested = { + ...ctx, + gradle: { + ...gradle, + projects: [ + ...gradle.projects, + { path: ":lib:core", name: "core", dir: "/w/lib/core", buildFile: null, tasks: [], build: ":lib" }, + { path: ":buildSrc", name: "buildSrc", dir: "buildSrc", buildFile: null, tasks: [], build: ":buildSrc" }, + ], + }, + }; + const main = (file: string) => applicationConfig({ className: "a.Main" }, file, []); + const lib = delegatedGradleConfig(main("/w/lib/core/src/main/java/a/Main.java"), "/w/lib/core", nested)!; + expect(lib.args.at(-1)).toBe(":lib:core:openideaRun"); + expect(delegatedGradleConfig(main("buildSrc/src/main/java/a/Main.java"), "buildSrc", nested)).toBeNull(); + }); + + it("is a Gradle run from then on: never delegated twice, the Application's params dropped", () => { + const app = { ...applicationConfig({ className: "a.Main" }, "app/src/main/java/a/Main.java", []) }; + const run = delegatedGradleConfig({ ...app, params: { vmOptions: "-Xmx1g" } }, "app", ctx)!; + expect(isJvmApplication(run)).toBe(false); + expect(run.params).toBeUndefined(); + }); + + it("debugs the forked JVM: the init script adds the agent", () => { + const app = applicationConfig({ className: "a.Main" }, "app/src/main/java/a/Main.java", []); + const run = delegatedGradleConfig(app, "app", ctx)!; + const debug = jdwpLaunch(run)!; + expect(debug.args.at(-1)).toBe(DELEGATED_DEBUG); + expect(debug.args).not.toContain("--debug-jvm"); + expect(jdwpLaunch(debug)!.args.filter((a) => a === DELEGATED_DEBUG)).toHaveLength(1); + }); +}); diff --git a/src/features/run/gradleRun.ts b/src/features/run/gradleRun.ts new file mode 100644 index 00000000..43a60500 --- /dev/null +++ b/src/features/run/gradleRun.ts @@ -0,0 +1,134 @@ +/** + * IDEA's "Build and run using: Gradle": an Application / Spring Boot configuration of a + * Gradle module keeps its settings, only its launch becomes a Gradle run of a `JavaExec` + * task (`gradleRun.gradle`) on the source set's runtime classpath. Gradle compiles the + * module and its dependencies and processes resources first; the debugger attaches to + * the forked JVM (`-Dopenidea.run.debug`), not to the Gradle daemon. + */ +import RUN_SCRIPT from "./gradleRun.gradle?raw"; +import { useBuildModel } from "@/platform/buildModel"; +import { call, commands, isTauri, type GradleModel, type RunConfig } from "@/platform/ipc"; +import { findJavaModule, isJvmApplication, parseJavaArgs } from "@/platform/javaLaunch"; +import { useProject } from "@/platform/project"; + +/** System property that marks a delegated run (read by `jdwpLaunch` to add the debug flag). */ +export const DELEGATED_MAIN = "-Dopenidea.run.main="; +export const DELEGATED_DEBUG = "-Dopenidea.run.debug=true"; +const TASK = "openideaRun"; + +const isAbsolute = (p: string) => p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p); +const absolute = (root: string, p: string) => (isAbsolute(p) ? p : p ? `${root}/${p}` : root); + +export interface DelegationContext { + /** Project root. */ + root: string; + gradle: GradleModel; + /** Absolute path of the written `gradleRun.gradle`. */ + script: string; + offline: boolean; + /** + * Gradle's configuration cache: a repeated launch skips configuring the build (about half of + * Gradle's share of a warm start). Only for Gradle 8.1+, where it is stable. + */ + configurationCache: boolean; +} + +/** Major / minor of the wrapper's `distributionUrl` (`gradle-8.10.2-bin.zip`), null when unknown. */ +export function wrapperGradleVersion(properties: string): [number, number] | null { + const m = /^\s*distributionUrl\s*=.*gradle-(\d+)\.(\d+)/m.exec(properties); + return m ? [Number(m[1]), Number(m[2])] : null; +} + +const atLeast = (v: [number, number] | null, major: number, minor: number) => + !!v && (v[0] > major || (v[0] === major && v[1] >= minor)); + +/** + * The Gradle run of `config`, or null when it is not an Application of a module of this + * Gradle build (`module`: its folder, project-relative or absolute). + */ +export function delegatedGradleConfig(config: RunConfig, module: string, ctx: DelegationContext): RunConfig | null { + const parsed = parseJavaArgs(config.args); + if (!parsed.mainClass) return null; + const dir = absolute(ctx.root, module); + const project = ctx.gradle.projects.find((p) => absolute(ctx.root, p.dir) === dir); + // buildSrc tasks are not addressable from the root build; included builds' are (`:lib:core:…`). + if (!project || project.build?.endsWith(":buildSrc")) return null; + const task = project.path === ":" ? `:${TASK}` : `${project.path}:${TASK}`; + const args = [ + "--console=plain", + "--init-script", + ctx.script, + `-Dopenidea.run.project=${project.path}`, + `${DELEGATED_MAIN}${parsed.mainClass}`, + `-Dopenidea.run.sourceSet=${parsed.classpath === "test" ? "test" : "main"}`, + `-Dopenidea.run.args=${JSON.stringify(parsed.programArgs)}`, + `-Dopenidea.run.jvmArgs=${JSON.stringify(parsed.vmArgs)}`, + `-Dopenidea.run.workingDir=${config.cwd ? absolute(ctx.root, config.cwd) : dir}`, + ...(ctx.offline ? ["--offline"] : []), + // Problems only warn: a build with incompatible plugins still runs, uncached. + ...(ctx.configurationCache ? ["--configuration-cache", "--configuration-cache-problems=warn"] : []), + task, + ]; + // A Gradle run from here on: not an Application again (it would be delegated twice), and + // without the Application's params (Gradle's own VM-options init script would repeat them). + const { params: _params, ...rest } = config; + return { + ...rest, + kind: "gradle", + command: ctx.gradle.command, + args, + cwd: ctx.gradle.dir || null, + // The Gradle JVM's JAVA_HOME first; the configuration's own environment wins. + env: { ...ctx.gradle.env, ...config.env }, + module: module, + }; +} + +/** Writes the init script where Gradle can read it; it never changes, so once per session. */ +let scriptPath: Promise | null = null; +function writeScript(): Promise { + scriptPath ??= (async () => { + const { tempDir, join } = await import("@tauri-apps/api/path"); + const path = await join(await tempDir(), "openidea-gradle-run.gradle"); + await call(commands.fsDelete(path)).catch(() => {}); + await call(commands.fsCreateFileBytes(path, Array.from(new TextEncoder().encode(RUN_SCRIPT)))); + return path; + })().catch((e) => { + scriptPath = null; + throw e; + }); + return scriptPath; +} + +/** + * The launch of an Application / Spring Boot `config` through Gradle, when the project + * runs with Gradle (Settings › Build Tools › Gradle) and the main class is in one of its + * modules; null to launch it on the language server's classpath. + */ +export async function gradleDelegation(config: RunConfig): Promise { + if (!isTauri() || !isJvmApplication(config)) return null; + const { project, settings } = useProject.getState(); + if (!project?.root || settings?.languages?.java?.gradle?.runUsing === "ide") return null; + const { model, gradleOffline } = useBuildModel.getState(); + if (!model?.gradle) return null; + const module = config.module ?? (await findJavaModule(config)); + if (module === null) return null; + const ctx = { + root: project.root, + gradle: model.gradle, + script: "", + offline: gradleOffline, + configurationCache: false, + }; + if (!delegatedGradleConfig(config, module, ctx)) return null; + const [script, configurationCache] = await Promise.all([writeScript(), supportsConfigurationCache(ctx)]); + return delegatedGradleConfig(config, module, { ...ctx, script, configurationCache }); +} + +/** The wrapper's Gradle is 8.1+ (a local or PATH Gradle's version is not known here). */ +async function supportsConfigurationCache(ctx: Pick): Promise { + if (!/(^|[/\\])gradlew(\.bat)?$/.test(ctx.gradle.command)) return false; + const props = `${absolute(ctx.root, ctx.gradle.dir)}/gradle/wrapper/gradle-wrapper.properties`; + const text = await call(commands.fsReadText(props)).catch(() => ""); + return atLeast(wrapperGradleVersion(text), 8, 1); +} diff --git a/src/features/run/index.ts b/src/features/run/index.ts index 71de5b36..0e16b4db 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -47,6 +47,7 @@ import { isDebugOnly, registerBuiltinTypes } from "./types"; import { listenForApplicationDeactivation, showApplicationUpdate, updatableSessions } from "./applicationUpdate"; import { npmGutter } from "./npmGutter"; import { runGutter } from "./runGutter"; +import { gradleDelegation } from "./gradleRun"; import { activateStackTrace, useStackTraces } from "./stackTrace"; import { clearRuntimeCache } from "./runtimes"; import { @@ -119,6 +120,12 @@ async function debugConfig(config: RunConfig, chain: readonly string[] = []) { await launchProduced(compiled, true); return; } + // "Build and run using: Gradle": the JavaExec JVM gets the agent, the debugger attaches to it. + const delegated = getAction("debug.attach") ? await gradleDelegation(config) : null; + if (delegated) { + await debugWithAgent(delegated); + return; + } // Maven / Gradle: the build tool starts the JVM with a debug agent, the debugger attaches. if (jdwpLaunch(config) && getAction("debug.attach")) { await debugWithAgent(config); diff --git a/src/features/run/java.test.ts b/src/features/run/java.test.ts index 161e1017..4ea7b7cd 100644 --- a/src/features/run/java.test.ts +++ b/src/features/run/java.test.ts @@ -140,6 +140,17 @@ describe("application configurations", () => { expect(jvmModuleDir("odd/App.java", [cfg({ id: "m", kind: "maven", cwd: "odd" })])).toBe("odd"); }); + it("takes the innermost module the build model knows, like IDEA's file index", () => { + const modules = ["", "/w/billing", "/w/billing/modules/core", "legacy"]; + // `sourceSets.main.java.srcDirs = ['src']`: no `src/main/java` to guess from. + expect(jvmModuleDir("legacy/src/a/App.java", [], modules)).toBe("legacy"); + expect(jvmModuleDir("/w/billing/modules/core/src/main/java/a/App.java", [], modules)).toBe( + "/w/billing/modules/core", + ); + // A module the model does not know yet (before the first sync): the source layout. + expect(jvmModuleDir("services/api/src/main/java/a/App.java", [], modules)).toBe("services/api"); + }); + it("adds JDWP to a script explicitly assigned the Kotlin debugger", () => { const rc = cfg({ id: "kotlin-script", kind: "shell", command: "./run.sh", debugAdapter: "kotlin-debug-adapter" }); expect(jdwpLaunch(rc)?.env.JAVA_TOOL_OPTIONS).toContain("-agentlib:jdwp="); diff --git a/src/features/run/javaDebug.ts b/src/features/run/javaDebug.ts index e62c82be..1932608c 100644 --- a/src/features/run/javaDebug.ts +++ b/src/features/run/javaDebug.ts @@ -12,6 +12,7 @@ import { executeAction } from "@/platform/actions"; import type { RunConfig } from "@/platform/ipc"; import { isJvmApplication, JDWP_AGENT, jdwpListeningPort } from "@/platform/javaLaunch"; import { startRun, useRun } from "./store"; +import { DELEGATED_DEBUG, DELEGATED_MAIN } from "./gradleRun"; const isMavenTestGoal = (a: string) => /^(test|verify|integration-test|surefire:test|failsafe:integration-test)$/.test(a); @@ -55,6 +56,9 @@ export function jdwpLaunch(config: RunConfig): RunConfig | null { return null; } if (config.kind === "gradle" || /(^|\/)gradlew?(\.bat)?$/.test(config.command)) { + // A delegated Application: its init script adds the agent (port 0) to the JavaExec JVM. + if (args.some((a) => a.startsWith(DELEGATED_MAIN))) + return args.includes(DELEGATED_DEBUG) ? config : { ...config, args: [...args, DELEGATED_DEBUG] }; if (!args.some(isGradleJvmTask)) return null; return args.includes("--debug-jvm") ? config : { ...config, args: [...args, "--debug-jvm"] }; } @@ -74,7 +78,8 @@ export async function debugWithAgent(config: RunConfig): Promise { const runId = useRun.getState().sessions.find((s) => s.id === sessionId)?.runId ?? null; void executeAction("debug.attach", { name: config.name, - module: config.cwd ?? "", + // The application's module (a delegated run's working directory is the Gradle build). + module: config.module ?? config.cwd ?? "", hostName: "127.0.0.1", port, process: runId, diff --git a/src/features/run/producers.test.ts b/src/features/run/producers.test.ts index 2fb9cb44..4e73b758 100644 --- a/src/features/run/producers.test.ts +++ b/src/features/run/producers.test.ts @@ -309,6 +309,28 @@ describe("single-file producers (scratches)", () => { expect(p.config!.args.at(-1)).toBe("demo.Hi"); }); + it("Java: a main in a Gradle module outside the project runs on the module's classpath", () => { + // `includeFlat 'billing'`: the module folder is a sibling of the project folder. + const svc = "/work/billing/modules/core"; + const boot = + "package com.example;\nimport org.springframework.boot.SpringApplication;\n@SpringBootApplication\n" + + "public class ShopApplication {\n public static void main(String[] args) {\n" + + " SpringApplication.run(ShopApplication.class, args);\n }\n}"; + const gradle = [cfg({ id: "gradle:test", kind: "gradle", command: "./gradlew", args: ["test"], isTest: true })]; + const input = (contentRoots?: string[]) => ({ + path: `${svc}/src/main/java/com/example/ShopApplication.java`, + root: ROOT, + configs: gradle, + content: boot, + line: 4, + contentRoots, + }); + expect(produceContext(input(), ALL)!.config!.kind).toBe("java-file"); + const p = produceContext(input(["/work/billing", svc]), ALL)!; + expect(p.config).toMatchObject({ kind: "spring-boot", module: svc, cwd: svc }); + expect(p.config!.args).toEqual(["-cp", "${classpath}", "com.example.ShopApplication"]); + }); + describe("Java / Kotlin mains in a build nested in a folder of repositories", () => { // RuoYi-Vue-Plus (Maven) next to plus-ui (npm), opened together. const boot = diff --git a/src/features/run/producers.ts b/src/features/run/producers.ts index c0425628..efed66bd 100644 --- a/src/features/run/producers.ts +++ b/src/features/run/producers.ts @@ -56,6 +56,12 @@ export interface ContextInput { goModule?: (dir: string) => string | null | undefined; /** 0-based caret line: the element at the caret wins over the file. */ line?: number; + /** + * Absolute folders outside `root` that belong to the project (IDEA's module content + * roots: `includeFlat` Gradle modules, Project Structure roots); their files run like + * the project's own, through its build. + */ + contentRoots?: readonly string[]; } export interface Missing { @@ -363,8 +369,14 @@ interface FileSite { inProject: boolean; } +/** In the project folder or one of its content roots: run through the project's build. */ +function isProjectPath(input: Pick): boolean { + if (!input.root) return false; + return [input.root, ...(input.contentRoots ?? [])].some((r) => inProject(input.path, r)); +} + function site(input: ContextInput): FileSite { - const inside = inProject(input.path, input.root); + const inside = isProjectPath(input); const rel = relTo(input.path, input.root); return { idPath: rel, @@ -809,8 +821,11 @@ export function standaloneTargets(content: string, path: string): RunTarget[] { } /** Run targets of a file for the caret / gutter: project-aware inside the project, standalone outside. */ -export function fileTargets(input: Pick, content: string): RunTarget[] { - const s = { inProject: inProject(input.path, input.root), rel: relTo(input.path, input.root) }; +export function fileTargets( + input: Pick, + content: string, +): RunTarget[] { + const s = { inProject: isProjectPath(input), rel: relTo(input.path, input.root) }; const rust = /\.rs$/.test(input.path); if (/\.(c|cpp|cc|cxx)$/.test(input.path)) return standaloneTargets(content, input.path); const cargo = @@ -879,8 +894,7 @@ export function produceContext(input: ContextInput, env: ToolEnv): Produced | nu */ export function produceContextChoices(input: ContextInput, env: ToolEnv): Produced[] { const produced = produceContext(input, env); - if (!produced?.config || produced.scope === "directory" || !inProject(input.path, input.root)) - return produced ? [produced] : []; + if (!produced?.config || produced.scope === "directory" || !isProjectPath(input)) return produced ? [produced] : []; const rel = relTo(input.path, input.root); const cmake = cmakeTargets(input); if (cmake.length > 1) return cmake.map((config) => ({ name: config.name, scope: produced.scope, config })); @@ -907,7 +921,7 @@ export function produceContextChoices(input: ContextInput, env: ToolEnv): Produc } function cmakeTargets(input: ContextInput): RunConfig[] { - if (!inProject(input.path, input.root)) return []; + if (!isProjectPath(input)) return []; const lang = scriptLanguage(input.path, input.content); if (lang !== "c" && lang !== "cpp") return []; const rel = relTo(input.path, input.root); @@ -921,7 +935,7 @@ const TEST_DIR = /(^|\/)(tests?|__tests__|specs?|test_?\w*)$/; export function produceDirectory(input: ContextInput): Produced | null { const { root } = input; - if (!root || !inProject(input.path, root) || input.path === root) return null; + if (!root || !isProjectPath(input) || input.path === root) return null; const rel = relTo(input.path, root); const name = baseName(rel); const jvmRoot = /^(?:(.*)\/)?src\/test(?:\/(java|kotlin|scala|groovy))?$/.exec(rel); diff --git a/src/features/run/runGutter.ts b/src/features/run/runGutter.ts index 63836c68..0fe0e8b6 100644 --- a/src/features/run/runGutter.ts +++ b/src/features/run/runGutter.ts @@ -12,8 +12,15 @@ import { isVirtualUri } from "@/platform/virtualDocs"; import { shortcutLabel } from "@/platform/keymap"; import { gutterIconMarkup, showContextMenu, type MenuItem } from "@/ui"; import { notifications, runExecutors } from "@/workbench/contributions"; -import { choiceItems, executeContext, launchProduced, modifyContext, produceResolved } from "./contextRun"; -import { goModulePath, loadGoModules } from "./goModules"; +import { + choiceItems, + executeContext, + launchProduced, + modifyContext, + produceResolved, + projectContext, +} from "./contextRun"; +import { loadGoModules } from "./goModules"; import { fileTargets, OPTIMISTIC, produceContextChoices, type ContextInput } from "./producers"; import { loadConfigs, useRun } from "./store"; import { targetLanguage, type RunTarget } from "./targets"; @@ -52,14 +59,7 @@ const targetsField = StateField.define({ }); function targetInput(path: string, content: string, line: number): ContextInput { - return { - path, - root: useProject.getState().project?.root ?? null, - configs: useRun.getState().configs, - content, - line, - goModule: goModulePath, - }; + return { ...projectContext(), path, content, line }; } async function launch(target: RunTarget, path: string, debug: boolean, content: string) { @@ -197,8 +197,7 @@ export function runGutter(ctx: EditorDocContext): Extension { if (!useProject.getState().project?.root || isVirtualUri(path) || !hasGutterTargets(path)) return []; if (/\.go$/.test(path)) void loadGoModules(useProject.getState().project?.root ?? null, useRun.getState().configs).catch(() => {}); - const detect = (text: string) => - fileTargets({ path, root: useProject.getState().project?.root ?? null, configs: useRun.getState().configs }, text); + const detect = (text: string) => fileTargets({ ...projectContext(), path }, text); const detector = ViewPlugin.fromClass( class { diff --git a/src/features/run/store.ts b/src/features/run/store.ts index b0191447..90d22e7b 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -53,6 +53,7 @@ import { typeFor } from "./types"; import { recordRun } from "./services"; import { startedPort } from "./serverPort"; import { tomcatBrowserUrl } from "./tomcat"; +import { gradleDelegation } from "./gradleRun"; import { openUrl } from "./navigate"; import { tx } from "@/platform/i18n"; @@ -661,7 +662,11 @@ export async function startRun( }; try { let launch = options.launch ?? config; - if (isJvmApplication(launch)) { + // "Build and run using: Gradle": the module's Gradle build compiles and launches it. + const delegated = isJvmApplication(launch) ? await gradleDelegation(launch) : null; + if (runtimes.get(sessionId)?.generation !== generation) return getSession(sessionId) ?? null; + if (delegated) launch = delegated; + else if (isJvmApplication(launch)) { const resolved = await resolveJvmApplication(sessionId, launch); if (runtimes.get(sessionId)?.generation !== generation) return getSession(sessionId) ?? null; if (!resolved) { diff --git a/src/features/settings/languages/LanguagePages.tsx b/src/features/settings/languages/LanguagePages.tsx index aef56ed2..8357c230 100644 --- a/src/features/settings/languages/LanguagePages.tsx +++ b/src/features/settings/languages/LanguagePages.tsx @@ -318,9 +318,24 @@ export function JavaPage() { export function GradlePage() { const [distribution, setDistribution] = useSetting("languages.java.gradle.distribution"); + const [runUsing, setRunUsing] = useSetting("languages.java.gradle.runUsing"); return (
+ +