diff --git a/crates/ide-build/src/lib.rs b/crates/ide-build/src/lib.rs index 315418546..861f5a06d 100644 --- a/crates/ide-build/src/lib.rs +++ b/crates/ide-build/src/lib.rs @@ -75,9 +75,10 @@ pub fn load_linked( settings: &ide_core::settings::MavenSettings, gradle_settings: &ide_core::settings::GradleSettings, ) -> BuildModel { - let maven = maven::is_maven_project(root).then(|| { + let maven = maven::has_projects(root, settings).then(|| { let profiles = sync::maven_profiles(cache_dir); - let mut m = maven::load_with(root, maven_repo.or_else(maven::default_local_repo), &profiles); + let repo = maven_repo.or_else(maven::default_local_repo); + let mut m = maven::load_with_linked(root, repo, &profiles, &settings.linked_projects); maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m); sync::apply_maven_cache(root, cache_dir, &mut m); m diff --git a/crates/ide-build/src/maven.rs b/crates/ide-build/src/maven.rs index 12c8b14a8..fd105ee81 100644 --- a/crates/ide-build/src/maven.rs +++ b/crates/ide-build/src/maven.rs @@ -1065,10 +1065,9 @@ pub fn load(root: &Path) -> MavenModel { load_with_repo(root, default_local_repo()) } -/// [`load`] with the chosen `profiles` (`-P` entries; `!id` deactivates): their modules, -/// dependencies and properties are part of the model, those turned off are not. -pub fn load_with_profiles(root: &Path, profiles: &[String]) -> MavenModel { - load_with(root, default_local_repo(), profiles) +/// [`load`] with the POMs `settings` link (Add Maven Projects). +pub fn load_linked(root: &Path, settings: &ide_core::settings::MavenSettings) -> MavenModel { + load_with_linked(root, default_local_repo(), &[], &settings.linked_projects) } pub(crate) fn load_with_repo(root: &Path, local_repo: Option) -> MavenModel { @@ -1090,11 +1089,53 @@ fn profile_choice(chosen: &[String], id: &str) -> Option { } pub(crate) fn load_with(root: &Path, local_repo: Option, profiles: &[String]) -> MavenModel { + load_with_linked(root, local_repo, profiles, &[]) +} + +/// The POMs of `linked` (project-relative, from Settings) that exist inside `root`. +fn linked_poms(root: &Path, linked: &[String]) -> Vec { + let base = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + let mut out: Vec = Vec::new(); + for pom in linked { + let pom = normalized_pom(pom); + if pom.is_empty() || pom.starts_with("../") || pom == ".." { + continue; + } + let path = root.join(&pom); + let inside = dunce::canonicalize(&path).is_ok_and(|p| p.starts_with(&base)); + if path.is_file() && inside && !out.contains(&path) { + out.push(path); + } + } + out +} + +/// Whether `root` holds a Maven build or the settings link one inside it. +pub fn has_projects(root: &Path, settings: &ide_core::settings::MavenSettings) -> bool { + is_maven_project(root) || !linked_poms(root, &settings.linked_projects).is_empty() +} + +/// The model with the chosen `profiles` (`-P` entries; `!id` deactivates: their modules, +/// dependencies and properties are part of the model, those turned off are not) and the +/// `linked` POMs (Add Maven Projects): builds that no reactor lists, shown as independent +/// top-level projects. A linked POM that turns out to be a module of the root reactor stays that module. +pub fn load_with_linked( + root: &Path, + local_repo: Option, + profiles: &[String], + linked: &[String], +) -> MavenModel { let mut resolver = Resolver::new(local_repo, profiles); let mut model = MavenModel::default(); let mut seen: HashSet = HashSet::new(); - // (pom, aggregated) - let mut stack: Vec<(PathBuf, bool)> = top_level_poms(root).into_iter().rev().map(|p| (p, false)).collect(); + let mut tops = top_level_poms(root); + for pom in linked_poms(root, linked) { + if !tops.contains(&pom) { + tops.push(pom); + } + } + // (pom, aggregated); the root reactor and its modules come before linked POMs. + let mut stack: Vec<(PathBuf, bool)> = tops.into_iter().rev().map(|p| (p, false)).collect(); while let Some((path, aggregated)) = stack.pop() { if model.projects.len() >= MAX_PROJECTS { break; @@ -1216,7 +1257,7 @@ impl UnlinkedProjects { if settings.unlinked_projects.is_empty() { return Self::default(); } - Self::from_model(&load(root), settings) + Self::from_model(&load_linked(root, settings), settings) } pub fn from_model(model: &MavenModel, settings: &ide_core::settings::MavenSettings) -> Self { @@ -1733,6 +1774,49 @@ mod tests { assert!(model.projects.iter().all(|p| p.command == if cfg!(windows) { "mvn.cmd" } else { "mvn" })); } + /// Add Maven Projects: a POM inside the project that no reactor lists becomes an + /// independent top-level project; one the root reactor lists stays its module. + #[test] + fn linked_poms_outside_the_reactor_are_top_level_projects() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write( + root, + "pom.xml", + "demomain1.0pomcore", + ); + write( + root, + "core/pom.xml", + "democore1.0", + ); + write( + root, + "stub/pom.xml", + "demostub1.0", + ); + let top = |m: &MavenModel| m.projects.iter().map(|p| (p.dir.clone(), p.aggregated)).collect::>(); + + assert_eq!(top(&load_with_linked(root, None, &[], &[])), [("".into(), false), ("core".into(), true)]); + let linked: Vec = + ["stub/pom.xml", "./core/pom.xml", "../outside/pom.xml", "missing/pom.xml"].map(String::from).into(); + assert_eq!( + top(&load_with_linked(root, None, &[], &linked)), + [("".into(), false), ("core".into(), true), ("stub".into(), false)] + ); + + // Deeper than the folder search reaches: only the link makes it a Maven project. + let deep = tempfile::tempdir().unwrap(); + write(deep.path(), "a/b/c/tool/pom.xml", "tool"); + let settings = ide_core::settings::MavenSettings { + linked_projects: vec!["a/b/c/tool/pom.xml".into()], + ..Default::default() + }; + assert!(!has_projects(deep.path(), &Default::default())); + assert!(has_projects(deep.path(), &settings)); + assert_eq!(top(&load_linked(deep.path(), &settings)), [("a/b/c/tool".into(), false)]); + } + #[test] fn chosen_profiles_bring_their_modules_and_dependencies() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-build/src/sync.rs b/crates/ide-build/src/sync.rs index eca3daa41..b390b2cca 100644 --- a/crates/ide-build/src/sync.rs +++ b/crates/ide-build/src/sync.rs @@ -283,7 +283,12 @@ pub fn sync_maven( cancel: &CancellationToken, progress: &dyn Fn(SyncProgress), ) -> IdeResult<()> { - let mut model = maven::load_with_profiles(root, &options.profiles); + let mut model = maven::load_with_linked( + root, + maven::default_local_repo(), + &options.profiles, + &options.maven_settings.linked_projects, + ); maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model); std::fs::create_dir_all(cache_dir)?; let mut cache = MavenCache { diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index b0b86e6d0..6f65eff4c 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -866,6 +866,8 @@ pub enum GradleDistribution { pub struct MavenSettings { /// Project-relative root POMs removed from the Maven project model. pub unlinked_projects: Vec, + /// Project-relative POMs outside the root reactor added as independent top-level projects. + pub linked_projects: Vec, /// Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`. pub home: String, /// User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`. diff --git a/crates/ide-host/src/ops/project.rs b/crates/ide-host/src/ops/project.rs index f739d95c4..8d71234c8 100644 --- a/crates/ide-host/src/ops/project.rs +++ b/crates/ide-host/src/ops/project.rs @@ -197,6 +197,7 @@ pub fn set_settings( let roots_changed = key == "projectStructure" || key.starts_with("projectStructure.") || "languages.java.maven.unlinkedProjects".starts_with(key) + || "languages.java.maven.linkedProjects".starts_with(key) || "languages.java.gradle.linkedProjects".starts_with(key); // Content roots and excludes (files.exclude, ignored names, excluded folders) reach the // index and the watchers without reopening the project (the rescan runs in the background). diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 0c5e96483..6b935077d 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -1197,7 +1197,7 @@ pub(crate) fn jdtls_maven_import( if maven.unlinked_projects.is_empty() && gradle.linked_projects.is_none() { return (Vec::new(), None); } - let model = ide_build::maven::load(root); + let model = ide_build::maven::load_linked(root, maven); let unlinked = ide_build::maven::UnlinkedProjects::from_model(&model, maven); let excluded = unlinked.poms().map(PathBuf::from).collect(); let poms = unlinked.import_poms(&model).or_else(|| { diff --git a/src/bindings.ts b/src/bindings.ts index 93bbd9089..1b10a6f03 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -5131,6 +5131,8 @@ export type MavenProject = { export type MavenSettings = { /** Project-relative root POMs removed from the Maven project model. */ unlinkedProjects?: string[], + /** Project-relative POMs outside the root reactor added as independent top-level projects. */ + linkedProjects?: string[], /** Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`. */ home?: string, /** User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`. */ diff --git a/src/features/build-tools/linkage.test.tsx b/src/features/build-tools/linkage.test.tsx index c51c5ac83..2006e1c7c 100644 --- a/src/features/build-tools/linkage.test.tsx +++ b/src/features/build-tools/linkage.test.tsx @@ -226,6 +226,32 @@ it.each([null, "/outside/pom.xml", "/p/a/build.xml", "/p/../outside/pom.xml", "/ }, ); +it("links picked POMs that no reactor lists as independent top-level projects", async () => { + mocks.get.mockResolvedValue({ + languages: { java: { maven: { unlinkedProjects: ["b/pom.xml"], linkedProjects: ["tools/pom.xml"] } } }, + }); + mocks.open.mockResolvedValue(["/p/stub/pom.xml", "/p/b/pom.xml", "/p/a/pom.xml", "/p/tools/pom.xml"]); + await act(async () => { + await executeAction("maven.addProjects"); + }); + expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ multiple: true })); + expect(mocks.set).toHaveBeenCalledWith("project", "languages.java.maven.unlinkedProjects", "[]"); + expect(mocks.set).toHaveBeenCalledWith( + "project", + "languages.java.maven.linkedProjects", + '["tools/pom.xml","stub/pom.xml"]', + ); +}); + +it("unlinking a linked independent project removes the link instead of hiding it", async () => { + mocks.get.mockResolvedValue({ languages: { java: { maven: { linkedProjects: ["a/pom.xml"] } } } }); + await act(async () => { + await executeAction("maven.unlinkProjects", { pom: "a/pom.xml" }); + }); + expect(mocks.set).toHaveBeenCalledWith("project", "languages.java.maven.linkedProjects", "[]"); + expect(mocks.set).not.toHaveBeenCalledWith("project", "languages.java.maven.unlinkedProjects", expect.anything()); +}); + const gradleBuild = (dir: string): GradleModel => ({ dir, command: `/p/${dir}/gradlew`, diff --git a/src/features/build-tools/store.ts b/src/features/build-tools/store.ts index 47212cccd..c4976709b 100644 --- a/src/features/build-tools/store.ts +++ b/src/features/build-tools/store.ts @@ -233,15 +233,33 @@ export async function loadModel(): Promise { } const UNLINKED_PROJECTS = "languages.java.maven.unlinkedProjects"; +const LINKED_PROJECTS = "languages.java.maven.linkedProjects"; -async function setLinked(root: string, pom: string, linked: boolean) { +/** + * Adds `poms` to Maven (back from unlinked, or as independent top-level projects when no + * reactor lists them), or removes them (a linked one is dropped, a discovered one unlinked). + */ +async function setLinked(root: string, poms: string[], linked: boolean) { if (useProject.getState().project?.root !== root) return; const settings = await commands.settingsGet(); if (useProject.getState().project?.root !== root) return; - const previous = settings.languages.java?.maven?.unlinkedProjects ?? []; - const next = linked ? previous.filter((p) => p !== pom) : [...new Set([...previous, pom])]; - if (JSON.stringify(next) === JSON.stringify(previous)) return; - await call(commands.settingsSet("project", UNLINKED_PROJECTS, JSON.stringify(next))); + const maven = settings.languages.java?.maven; + const unlinkedBefore = maven?.unlinkedProjects ?? []; + const linkedBefore = maven?.linkedProjects ?? []; + const known = new Set(useBuild.getState().model?.maven?.projects.map((p) => p.pom)); + let unlinked = unlinkedBefore; + let extra = linkedBefore; + for (const pom of poms) { + if (linked && unlinked.includes(pom)) unlinked = unlinked.filter((p) => p !== pom); + else if (linked) { + if (!known.has(pom) && !extra.includes(pom)) extra = [...extra, pom]; + } else if (extra.includes(pom)) extra = extra.filter((p) => p !== pom); + else if (!unlinked.includes(pom)) unlinked = [...unlinked, pom]; + } + if (unlinked === unlinkedBefore && extra === linkedBefore) return; + if (unlinked !== unlinkedBefore) + await call(commands.settingsSet("project", UNLINKED_PROJECTS, JSON.stringify(unlinked))); + if (extra !== linkedBefore) await call(commands.settingsSet("project", LINKED_PROJECTS, JSON.stringify(extra))); await loadModel(); } @@ -260,7 +278,19 @@ export async function unlinkProjects(pom: string | null) { ) return; cancelSync("maven"); - await setLinked(root, pom, false); + await setLinked(root, [pom], false); +} + +/** Project-relative `pom.xml` paths of `picked` inside `root`; others are skipped. */ +function projectPoms(root: string, picked: readonly string[]): string[] { + const base = root.replace(/\\/g, "/").replace(/\/$/, ""); + return picked.flatMap((p) => { + const path = p.replace(/\\/g, "/"); + if (!path.startsWith(`${base}/`)) return []; + const pom = path.slice(base.length + 1); + const parts = pom.split("/"); + return parts.some((part) => part === ".." || part === ".") || parts.pop() !== "pom.xml" ? [] : [pom]; + }); } export async function addProjects() { @@ -270,17 +300,12 @@ export async function addProjects() { const picked = await open({ title: tx`Add Maven Projects`, directory: false, - multiple: false, + multiple: true, defaultPath: root, filters: [{ name: "pom.xml", extensions: ["xml"] }], }); - if (typeof picked !== "string") return; - const base = root.replace(/\\/g, "/").replace(/\/$/, ""); - const path = picked.replace(/\\/g, "/"); - if (!path.startsWith(`${base}/`)) return; - const pom = path.slice(base.length + 1); - if (pom.split("/").some((part) => part === ".." || part === ".") || pom.split("/").pop() !== "pom.xml") return; - await setLinked(root, pom, true); + const poms = projectPoms(root, typeof picked === "string" ? [picked] : (picked ?? [])); + if (poms.length) await setLinked(root, poms, true); } /** Links independent Gradle builds without changing their files or build scripts. */ diff --git a/src/features/settings/__tests__/languages.test.ts b/src/features/settings/__tests__/languages.test.ts index c4b266812..46d86a3f7 100644 --- a/src/features/settings/__tests__/languages.test.ts +++ b/src/features/settings/__tests__/languages.test.ts @@ -42,6 +42,7 @@ describe("languages settings", () => { userSettings: "", localRepository: "", unlinkedProjects: [], + linkedProjects: [], daemon: false, mvndHome: "/opt/mvnd", }); diff --git a/src/platform/languageSettings.ts b/src/platform/languageSettings.ts index 8ed81941f..ad9fd4eb5 100644 --- a/src/platform/languageSettings.ts +++ b/src/platform/languageSettings.ts @@ -116,6 +116,7 @@ export interface LanguagesValid { userSettings: string; localRepository: string; unlinkedProjects: string[]; + linkedProjects: string[]; daemon: boolean; mvndHome: string; }; @@ -162,7 +163,15 @@ export const DEFAULT_LANGUAGES: LanguagesValid = { jdtlsJdk: "", jdtlsVmArgs: "", gradle: { linkedProjects: null, jvm: "", distribution: "wrapper", home: "", userHome: "", runUsing: "gradle" }, - maven: { home: "", userSettings: "", localRepository: "", unlinkedProjects: [], daemon: false, mvndHome: "" }, + maven: { + home: "", + userSettings: "", + localRepository: "", + unlinkedProjects: [], + linkedProjects: [], + daemon: false, + mvndHome: "", + }, }, markdown: { previewFontSize: null }, }; @@ -307,6 +316,7 @@ export function validateLanguages(raw: unknown): LanguagesValid { userSettings: text(maven.userSettings, d.java.maven.userSettings), localRepository: text(maven.localRepository, d.java.maven.localRepository), unlinkedProjects: folders(maven.unlinkedProjects), + linkedProjects: folders(maven.linkedProjects), daemon: bool(maven.daemon, d.java.maven.daemon), mvndHome: text(maven.mvndHome, d.java.maven.mvndHome), },