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
This commit is contained in:
l0ng-ai
2026-11-04 01:53:45 +08:00
committed by GitHub
parent 6eb5584beb
commit 8bb6dfddc2
8 changed files with 257 additions and 22 deletions
+81
View File
@@ -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<Vec<String>> {
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<String, &MavenProject> =
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", "<modules><module>x</module><module>y</module><module>z</module></modules>"),
("r/x", ""),
("r/y", "<modules><module>y1</module><module>y2</module></modules>"),
("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!("<project><artifactId>{dir}</artifactId>{modules}</project>"),
)
.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::<Vec<_>>()), "{unlinked:?}");
}
}
}
+10 -1
View File
@@ -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`).
+51 -3
View File
@@ -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<PathBuf>, Option<Vec<String>>) {
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 [("", "<modules><module>x</module><module>y</module></modules>"), ("x", ""), ("y", "")] {
std::fs::create_dir_all(root.join(dir)).unwrap();
std::fs::write(root.join(dir).join("pom.xml"), format!("<project>{body}</project>")).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('\\', "/"))));
+27 -2
View File
@@ -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());
}
}
+1 -2
View File
@@ -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",
+81 -12
View File
@@ -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<typeof import("@/ui")>()), showContextMenu: mocks.menu }));
vi.mock("@/ui", async (original) => ({
...(await original<typeof import("@/ui")>()),
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(<MavenView />);
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(<MavenView />);
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(<MavenView />);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Add Maven Projects" }));
+5 -2
View File
@@ -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",
}))
)
+1
View File
@@ -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.": ",例如",