дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
feat(maven): add POMs outside the root reactor as Maven projects (#1446)
feat(maven): add POMs outside the root reactor as Maven projects Add Maven Projects only re-linked POMs Maven had already discovered: picking a pom.xml inside the project that the root POM doesn't list as a module (an independent build kept out of the reactor) closed the dialog and changed nothing. Such POMs are now kept in the project's Maven settings (linkedProjects) and loaded as independent top-level projects, after the root reactor, by the static model, the sync, the Java language server import and the content roots; each runs its goals in its own directory. A picked POM that the root reactor lists stays its module. The dialog takes several POMs at once, and unlinking a linked project drops the link instead of hiding it.
Цей коміт міститься в:
1 джерело
0dfdbbc323
коміт
d3cb7c14ca
11 files changed
+183
-26
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<PathBuf>) -> MavenModel {
|
||||
@@ -1090,11 +1089,53 @@ fn profile_choice(chosen: &[String], id: &str) -> Option<bool> {
|
||||
}
|
||||
|
||||
pub(crate) fn load_with(root: &Path, local_repo: Option<PathBuf>, 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<PathBuf> {
|
||||
let base = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
let mut out: Vec<PathBuf> = 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<PathBuf>,
|
||||
profiles: &[String],
|
||||
linked: &[String],
|
||||
) -> MavenModel {
|
||||
let mut resolver = Resolver::new(local_repo, profiles);
|
||||
let mut model = MavenModel::default();
|
||||
let mut seen: HashSet<PathBuf> = 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",
|
||||
"<project><groupId>demo</groupId><artifactId>main</artifactId><version>1.0</version><packaging>pom</packaging><modules><module>core</module></modules></project>",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"core/pom.xml",
|
||||
"<project><groupId>demo</groupId><artifactId>core</artifactId><version>1.0</version></project>",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"stub/pom.xml",
|
||||
"<project><groupId>demo</groupId><artifactId>stub</artifactId><version>1.0</version></project>",
|
||||
);
|
||||
let top = |m: &MavenModel| m.projects.iter().map(|p| (p.dir.clone(), p.aggregated)).collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(top(&load_with_linked(root, None, &[], &[])), [("".into(), false), ("core".into(), true)]);
|
||||
let linked: Vec<String> =
|
||||
["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", "<project><artifactId>tool</artifactId></project>");
|
||||
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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -866,6 +866,8 @@ pub enum GradleDistribution {
|
||||
pub struct MavenSettings {
|
||||
/// Project-relative root POMs removed from the Maven project model.
|
||||
pub unlinked_projects: Vec<String>,
|
||||
/// Project-relative POMs outside the root reactor added as independent top-level projects.
|
||||
pub linked_projects: Vec<String>,
|
||||
/// 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`.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(|| {
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -233,15 +233,33 @@ export async function loadModel(): Promise<void> {
|
||||
}
|
||||
|
||||
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. */
|
||||
|
||||
@@ -42,6 +42,7 @@ describe("languages settings", () => {
|
||||
userSettings: "",
|
||||
localRepository: "",
|
||||
unlinkedProjects: [],
|
||||
linkedProjects: [],
|
||||
daemon: false,
|
||||
mvndHome: "/opt/mvnd",
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
Reference in new issue
Заблокувати користувача