From 8bb6dfddc2e080ef2c654378de95fba302165174 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:53:45 +0800 Subject: [PATCH] fix(maven): unlink modules of a reactor so jdtls stops importing them (#1059) Only root Maven projects could be unlinked, and jdtls imports an aggregator with all of its modules whatever java.import.exclusions say. A module of a reactor can now be unlinked from the Maven tool window; when one is, jdtls gets initializationOptions.projectConfigurations listing the linked POMs except the aggregators above the unlinked module (plus the Gradle build file if there is one), and a Maven sync only names the POMs jdtls imported. Unlinking whole roots keeps using exclusions. Fixes #767 --- crates/ide-build/src/maven.rs | 81 +++++++++++++++++++ crates/ide-host/src/ops/build.rs | 11 ++- crates/ide-host/src/project.rs | 54 ++++++++++++- crates/ide-sdk/src/apply.rs | 29 ++++++- src/features/build-tools/BuildToolView.tsx | 3 +- src/features/build-tools/linkage.test.tsx | 93 +++++++++++++++++++--- src/features/build-tools/store.ts | 7 +- src/platform/locales/text/zh.json | 1 + 8 files changed, 257 insertions(+), 22 deletions(-) diff --git a/crates/ide-build/src/maven.rs b/crates/ide-build/src/maven.rs index 1dc1c8d8..ff4f2cf2 100644 --- a/crates/ide-build/src/maven.rs +++ b/crates/ide-build/src/maven.rs @@ -1208,6 +1208,40 @@ impl UnlinkedProjects { self.poms.iter().map(String::as_str) } + /// The POMs the Java language server should import when an unlinked POM is a module of a + /// linked reactor, else `None` (excluding the unlinked directories is enough then). + /// + /// jdtls imports an aggregator together with all of its modules, whatever its import + /// exclusions say, so a reactor keeping an unlinked module is left out; its other linked + /// modules are listed on their own. Such an aggregator (`pom` packaging) has no sources. + pub fn import_poms(&self, model: &MavenModel) -> Option> { + let linked: Vec<&MavenProject> = model.projects.iter().filter(|p| !self.contains(&p.pom)).collect(); + if !linked.iter().any(|p| p.modules.iter().any(|m| self.contains(m))) { + return None; + } + let by_pom: std::collections::HashMap = + model.projects.iter().map(|p| (normalized_pom(&p.pom), p)).collect(); + // Whether `pom`'s module tree reaches an unlinked POM. + let reaches_unlinked = |pom: &str| { + let mut seen = std::collections::HashSet::new(); + let mut pending = vec![normalized_pom(pom)]; + while let Some(pom) = pending.pop() { + if !seen.insert(pom.clone()) { + continue; + } + let Some(project) = by_pom.get(&pom) else { continue }; + for module in &project.modules { + if self.contains(module) { + return true; + } + pending.push(normalized_pom(module)); + } + } + false + }; + Some(linked.into_iter().filter(|p| !reaches_unlinked(&p.pom)).map(|p| normalized_pom(&p.pom)).collect()) + } + pub fn filter(&self, model: &mut MavenModel) { model.projects.retain(|p| !self.contains(&p.pom)); for project in &mut model.projects { @@ -1993,4 +2027,51 @@ mod linkage_tests { UnlinkedProjects::from_model(&original, &MavenSettings::default()).filter(&mut restored); assert_eq!(restored, original); } + + #[test] + fn language_server_imports_reactor_modules_individually_once_one_is_unlinked() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + // r ─┬─ x + // ├─ y ─┬─ y/y1 + // │ └─ y/y2 + // └─ z other/ is a separate root. + for (dir, modules) in [ + ("r", "xyz"), + ("r/x", ""), + ("r/y", "y1y2"), + ("r/y/y1", ""), + ("r/y/y2", ""), + ("r/z", ""), + ("other", ""), + ] { + std::fs::create_dir_all(root.join(dir)).unwrap(); + std::fs::write( + root.join(dir).join("pom.xml"), + format!("{dir}{modules}"), + ) + .unwrap(); + } + let model = load_with_repo(root, None); + let cases: [(&[&str], Option<&[&str]>); 5] = [ + // Nothing unlinked, or whole roots: the import exclusions do it. + (&[], None), + (&["other/pom.xml"], None), + (&["r/pom.xml"], None), + // A module of a linked reactor: every other linked POM, minus the aggregators above it. + (&["r/y/y1/pom.xml"], Some(&["other/pom.xml", "r/x/pom.xml", "r/y/y2/pom.xml", "r/z/pom.xml"])), + (&["./r/y/pom.xml"], Some(&["other/pom.xml", "r/x/pom.xml", "r/z/pom.xml"])), + ]; + for (unlinked, expected) in cases { + let settings = MavenSettings { + unlinked_projects: unlinked.iter().map(|s| s.to_string()).collect(), + ..Default::default() + }; + let mut poms = UnlinkedProjects::from_model(&model, &settings).import_poms(&model); + if let Some(poms) = &mut poms { + poms.sort(); + } + assert_eq!(poms, expected.map(|e| e.iter().map(|s| s.to_string()).collect::>()), "{unlinked:?}"); + } + } } diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index 4f1bc5a7..77a40646 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -175,7 +175,16 @@ pub async fn build_sync( } }; let lsp = project.lsp.clone(); - let uris = build_files.iter().map(|f| ide_lsp::path_to_uri_string(&project.root.join(f))).collect(); + // With a module of a reactor unlinked, jdtls imports the other modules one by one and + // not the reactor: naming the reactor here would import it again, all modules included. + let imported = match tool { + BuildTool::Maven => crate::project::jdtls_maven_import(&project.root, &project.settings.effective()).1, + BuildTool::Gradle => None, + }; + let uris = match imported { + Some(files) => files.into_iter().filter(|f| f.ends_with("/pom.xml")).collect(), + None => build_files.iter().map(|f| ide_lsp::path_to_uri_string(&project.root.join(f))).collect(), + }; tokio::spawn(async move { if profiles_changed { // jdtls takes the profiles from its Maven settings (see `ide_sdk::maven_profiles`). diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 2ab2c7b8..1fb6617a 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -218,8 +218,10 @@ impl Host { ide_sdk::jdtls::launch_directly(cfg); } ide_sdk::apply::server_protocol_overrides(cfg, &effective); - let excluded_poms = unlinked_maven_poms(root, &effective); - ide_sdk::apply::configure_maven_import(cfg, root, &excluded_poms); + if cfg.name == "jdtls" { + let (excluded_poms, build_files) = jdtls_maven_import(root, &effective); + ide_sdk::apply::configure_maven_import(cfg, root, &excluded_poms, build_files.as_deref()); + } })); } { @@ -1035,6 +1037,31 @@ 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 unlinked Maven POMs jdtls leaves out, and the build files (`file:` URIs) it is to +/// import when that takes more than exclusions: an unlinked module of a linked reactor. +/// Those then include the Gradle build, which jdtls would otherwise find by itself. +pub(crate) fn jdtls_maven_import( + root: &Path, + settings: &ide_core::settings::Settings, +) -> (Vec, Option>) { + let maven = &settings.languages.java.maven; + if maven.unlinked_projects.is_empty() { + return (Vec::new(), None); + } + let model = ide_build::maven::load(root); + let unlinked = ide_build::maven::UnlinkedProjects::from_model(&model, maven); + let excluded = unlinked.poms().map(PathBuf::from).collect(); + let build_files = unlinked.import_poms(&model).map(|poms| { + let gradle = ide_build::gradle::load(root).and_then(|g| { + let dir = root.join(&g.dir); + let names = ide_build::gradle::SETTINGS_FILES.iter().chain(ide_build::gradle::BUILD_FILES); + names.map(|n| dir.join(n)).find(|f| f.is_file()) + }); + poms.iter().map(|pom| root.join(pom)).chain(gradle).map(|f| ide_lsp::path_to_uri_string(&f)).collect() + }); + (excluded, build_files) +} + /// 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`). @@ -1212,6 +1239,27 @@ pub fn apply_git_executable(settings: &ide_core::settings::Settings) { #[cfg(test)] mod maven_linkage_tests { + #[test] + fn jdtls_imports_the_linked_modules_and_the_gradle_build_once_a_reactor_module_is_unlinked() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + for (dir, body) in [("", "xy"), ("x", ""), ("y", "")] { + std::fs::create_dir_all(root.join(dir)).unwrap(); + std::fs::write(root.join(dir).join("pom.xml"), format!("{body}")).unwrap(); + } + std::fs::write(root.join("settings.gradle"), "rootProject.name = 'g'").unwrap(); + let mut settings = ide_core::settings::Settings::default(); + assert_eq!(super::jdtls_maven_import(root, &settings), (Vec::new(), None)); + settings.languages.java.maven.unlinked_projects.push("y/pom.xml".into()); + let (excluded, files) = super::jdtls_maven_import(root, &settings); + assert_eq!(excluded, vec![std::path::PathBuf::from("y/pom.xml")]); + let uri = |f: &str| ide_lsp::path_to_uri_string(&root.join(f)); + assert_eq!(files, Some(vec![uri("x/pom.xml"), uri("settings.gradle")])); + // The whole reactor unlinked: the import exclusions are enough. + settings.languages.java.maven.unlinked_projects = vec!["pom.xml".into()]; + assert_eq!(super::jdtls_maven_import(root, &settings).1, None); + } + #[test] fn unlinked_roots_remain_plain_files() { let tmp = tempfile::tempdir().unwrap(); @@ -1240,7 +1288,7 @@ mod maven_linkage_tests { serde_json::json!({"name": "jdtls", "command": "jdtls"}), ) .unwrap(); - ide_sdk::apply::configure_maven_import(&mut server, tmp.path(), &excluded_poms); + ide_sdk::apply::configure_maven_import(&mut server, tmp.path(), &excluded_poms, None); let options = serde_json::to_value(server.initialization_options).unwrap(); let exclusions = options.pointer("/settings/java/import/exclusions").unwrap().as_array().unwrap(); assert!(exclusions.contains(&serde_json::json!(tmp.path().join("a").to_string_lossy().replace('\\', "/")))); diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 2e5e0d3b..3c1eb64f 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -236,7 +236,14 @@ fn merge(base: &mut Value, layer: Value) { } /// Excludes project-relative Maven POM paths from JDTLS import, preserving user exclusions. -pub fn configure_maven_import(cfg: &mut LanguageServerConfig, root: &Path, excluded_poms: &[PathBuf]) { +/// `build_files` (`file:` URIs), when given, are the only build files jdtls imports: the +/// exclusions cannot leave out a module of a reactor jdtls imports. +pub fn configure_maven_import( + cfg: &mut LanguageServerConfig, + root: &Path, + excluded_poms: &[PathBuf], + build_files: Option<&[String]>, +) { if cfg.name != "jdtls" { return; } @@ -268,6 +275,9 @@ pub fn configure_maven_import(cfg: &mut LanguageServerConfig, root: &Path, exclu } if !excluded_poms.is_empty() { merge_init_options(cfg, json!({"settings": {"java": {"import": {"exclusions": exclusions}}}})); + if let Some(files) = build_files { + merge_init_options(cfg, json!({"projectConfigurations": files})); + } if let Some(settings) = &mut cfg.settings { merge( settings, @@ -948,12 +958,27 @@ mod maven_linkage_tests { let mut cfg = custom_server("jdtls", &LanguageServerSettings::default()); cfg.args = vec!["-data".into(), "/cache/jdtls".into()]; merge_init_options(&mut cfg, json!({"settings":{"java":{"import":{"exclusions":["**/custom"]}}}})); - configure_maven_import(&mut cfg, tmp.path(), &["a/child/pom.xml".into(), "a/pom.xml".into()]); + configure_maven_import(&mut cfg, tmp.path(), &["a/child/pom.xml".into(), "a/pom.xml".into()], None); let options = serde_json::to_value(cfg.initialization_options).unwrap(); let exclusions = options.pointer("/settings/java/import/exclusions").unwrap().as_array().unwrap(); assert!(exclusions.contains(&json!("**/custom"))); assert!(exclusions.contains(&json!(tmp.path().join("a").to_string_lossy().replace('\\', "/")))); assert!(exclusions.contains(&json!(tmp.path().join("a/child").to_string_lossy().replace('\\', "/")))); assert!(cfg.args[1].starts_with("/cache/jdtls-maven-")); + assert!(options.get("projectConfigurations").is_none()); + } + + #[test] + fn import_lists_the_build_files_when_given() { + let tmp = tempfile::tempdir().unwrap(); + let mut cfg = custom_server("jdtls", &LanguageServerSettings::default()); + let files = ["file:///p/r/x/pom.xml".to_owned(), "file:///p/build.gradle".to_owned()]; + configure_maven_import(&mut cfg, tmp.path(), &["r/y/pom.xml".into()], Some(&files)); + let options = serde_json::to_value(cfg.initialization_options).unwrap(); + assert_eq!(options["projectConfigurations"], json!(files)); + // Nothing unlinked: jdtls finds the build files itself. + let mut cfg = custom_server("jdtls", &LanguageServerSettings::default()); + configure_maven_import(&mut cfg, tmp.path(), &[], Some(&files)); + assert!(serde_json::to_value(cfg.initialization_options).unwrap().get("projectConfigurations").is_none()); } } diff --git a/src/features/build-tools/BuildToolView.tsx b/src/features/build-tools/BuildToolView.tsx index c6209f48..fe4f9697 100644 --- a/src/features/build-tools/BuildToolView.tsx +++ b/src/features/build-tools/BuildToolView.tsx @@ -306,8 +306,7 @@ function BuildToolView({ tool }: { tool: BuildTool }) { else items.push({ label: "Create Run Configuration…", icon: "edit", run: () => void createRunConfig(config) }); } if (node.kind === "mavenProject") { - if (!node.project.aggregated) - items.push({ kind: "action", action: "maven.unlinkProjects", arg: { pom: node.project.pom } }); + items.push({ kind: "action", action: "maven.unlinkProjects", arg: { pom: node.project.pom } }); items.push( { label: "Open pom.xml", diff --git a/src/features/build-tools/linkage.test.tsx b/src/features/build-tools/linkage.test.tsx index f629cdd6..cb156d22 100644 --- a/src/features/build-tools/linkage.test.tsx +++ b/src/features/build-tools/linkage.test.tsx @@ -34,7 +34,10 @@ vi.mock("@/platform/ipc", async (original) => { }); vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.open })); vi.mock("@/workbench/messages", () => ({ confirm: mocks.confirm })); -vi.mock("@/ui", async (original) => ({ ...(await original()), showContextMenu: mocks.menu })); +vi.mock("@/ui", async (original) => ({ + ...(await original()), + showContextMenu: mocks.menu, +})); const model: BuildModel = { gradle: null, @@ -69,7 +72,9 @@ const model: BuildModel = { let dispose: () => void; beforeEach(async () => { vi.clearAllMocks(); - mocks.get.mockResolvedValue({ languages: { java: { maven: { unlinkedProjects: [] } } } }); + mocks.get.mockResolvedValue({ + languages: { java: { maven: { unlinkedProjects: [] } } }, + }); mocks.set.mockResolvedValue({}); mocks.model.mockResolvedValue(model); mocks.configs.mockResolvedValue([]); @@ -91,7 +96,12 @@ it("offers unlink on a root's context menu and persists a confirmed project sett fireEvent.contextMenu(screen.getByText("project-a")); expect(mocks.menu).toHaveBeenCalledWith( expect.anything(), - expect.arrayContaining([expect.objectContaining({ action: "maven.unlinkProjects", arg: { pom: "a/pom.xml" } })]), + expect.arrayContaining([ + expect.objectContaining({ + action: "maven.unlinkProjects", + arg: { pom: "a/pom.xml" }, + }), + ]), ); await act(async () => { await executeAction("maven.unlinkProjects", { pom: "a/pom.xml" }); @@ -101,24 +111,83 @@ it("offers unlink on a root's context menu and persists a confirmed project sett expect(mocks.model).toHaveBeenCalledTimes(2); }); -it("does not unlink modules or write after cancelled confirmation", async () => { +it("does not write after cancelled confirmation", async () => { mocks.confirm.mockResolvedValue(false); await executeAction("maven.unlinkProjects", { pom: "a/pom.xml" }); + expect(mocks.confirm).toHaveBeenCalledTimes(1); expect(mocks.set).not.toHaveBeenCalled(); - act(() => - useBuild.setState({ - model: { ...model, maven: { ...model.maven!, projects: [{ ...model.maven!.projects[0]!, aggregated: true }] } }, +}); + +it("unlinks a module of a linked reactor so only the modules in use are imported", async () => { + const root = model.maven!.projects[0]!; + const child = { + ...root, + pom: "a/child/pom.xml", + dir: "a/child", + artifactId: "child", + displayName: "child-module", + packaging: "jar", + parent: "a/pom.xml", + aggregated: true, + }; + const reactor: BuildModel = { + ...model, + maven: { + ...model.maven!, + projects: [{ ...root, modules: ["a/child/pom.xml"] }, child], + }, + }; + mocks.model.mockResolvedValue(reactor); + mocks.get.mockResolvedValue({ + languages: { java: { maven: { unlinkedProjects: ["b/pom.xml"] } } }, + }); + await act(async () => { + useBuild.setState({ model: reactor }); + }); + render(); + fireEvent.contextMenu(await screen.findByText("child-module")); + expect(mocks.menu).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([ + expect.objectContaining({ + action: "maven.unlinkProjects", + arg: { pom: "a/child/pom.xml" }, + }), + ]), + ); + await act(async () => { + await executeAction("maven.unlinkProjects", { pom: "a/child/pom.xml" }); + }); + expect(mocks.confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Java language server stops importing them"), }), ); - render(); - await executeAction("maven.unlinkProjects", { pom: "a/pom.xml" }); - expect(mocks.confirm).toHaveBeenCalledTimes(1); + expect(mocks.set).toHaveBeenCalledWith( + "project", + "languages.java.maven.unlinkedProjects", + '["b/pom.xml","a/child/pom.xml"]', + ); +}); + +it("ignores a POM that is not in the Maven model", async () => { + await executeAction("maven.unlinkProjects", { pom: "missing/pom.xml" }); + expect(mocks.confirm).not.toHaveBeenCalled(); + expect(mocks.set).not.toHaveBeenCalled(); }); it("re-adds a picked POM using the plus button and preserves other unlinked roots", async () => { - mocks.get.mockResolvedValue({ languages: { java: { maven: { unlinkedProjects: ["a/pom.xml", "b/pom.xml"] } } } }); + mocks.get.mockResolvedValue({ + languages: { + java: { maven: { unlinkedProjects: ["a/pom.xml", "b/pom.xml"] } }, + }, + }); mocks.open.mockResolvedValue("/p/a/pom.xml"); - act(() => useBuild.setState({ model: { ...model, maven: { ...model.maven!, projects: [] } } })); + act(() => + useBuild.setState({ + model: { ...model, maven: { ...model.maven!, projects: [] } }, + }), + ); render(); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Add Maven Projects" })); diff --git a/src/features/build-tools/store.ts b/src/features/build-tools/store.ts index 33f141b2..6afd30dc 100644 --- a/src/features/build-tools/store.ts +++ b/src/features/build-tools/store.ts @@ -255,11 +255,14 @@ async function setLinked(root: string, pom: string, linked: boolean) { export async function unlinkProjects(pom: string | null) { const { root, model } = useBuild.getState(); - if (!root || !pom || !model?.maven?.projects.some((p) => p.pom === pom && !p.aggregated)) return; + const project = model?.maven?.projects.find((p) => p.pom === pom); + if (!root || !pom || !project) return; if ( !(await confirm({ title: "Unlink Maven projects?", - message: "Remove this project and its modules from Maven? Files will remain in the Project view.", + message: project.aggregated + ? "Remove this module and its submodules from Maven? The Java language server stops importing them, so they no longer use memory. Files will remain in the Project view; add the pom.xml again to restore them." + : "Remove this project and its modules from Maven? Files will remain in the Project view.", ok: "Unlink", })) ) diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 80ea6fff..756086f8 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -146,6 +146,7 @@ "Unlink Maven Projects": "取消链接 Maven 项目", "Unlink Maven projects?": "取消链接 Maven 项目?", "Remove this project and its modules from Maven? Files will remain in the Project view.": "从 Maven 中移除此项目及其模块?文件将保留在项目视图中。", + "Remove this module and its submodules from Maven? The Java language server stops importing them, so they no longer use memory. Files will remain in the Project view; add the pom.xml again to restore them.": "从 Maven 中移除此模块及其子模块?Java 语言服务器将不再导入它们,也就不再为它们占用内存。文件将保留在项目视图中;重新添加其 pom.xml 即可恢复。", "Unlink": "取消链接", "(does not exist)": "(不存在)", ", e.g.": ",例如",