From ae406ca2fb7bfb3c5b67474a9b257391c3349fb8 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:02:40 +0800 Subject: [PATCH] fix(java): resolve Maven projects with the profiles chosen in the Maven tool window (#1044) The profiles checked in the Maven tool window only reached `mvn` (sync and goal runs) and the Kotlin bridge. jdtls imported every module without them, so a dependency whose version, or module, comes from a profile stayed unresolved and its classes were reported as missing, while Build Project ran without `-P` as well. - A Maven sync records the profiles it ran with. jdtls gets a copy of the user settings.xml with them in `` (m2e has no other input for profiles), sent as a setting so a running server re-imports instead of restarting; the file name follows its content so m2e reloads it. - Toggling a profile reloads the Maven projects (once for several toggles in a row, again after a sync that started with the previous choice). - Build Project passes the synced profiles with `-P`. - When jdtls finishes an import or project update it does not re-check open files, so their errors stayed until the next edit; open Java files are now re-checked on its ProjectStatus report. This also covers a POM edit or a dependency added to ~/.m2. Fixes #993 --- crates/ide-build/src/lib.rs | 2 +- crates/ide-build/src/sync.rs | 18 ++ crates/ide-host/src/ops/build.rs | 27 +- crates/ide-host/src/project.rs | 10 + crates/ide-lsp/src/handler.rs | 3 + crates/ide-lsp/src/jdtls.rs | 29 +++ crates/ide-lsp/tests/mock_server.rs | 34 +++ crates/ide-sdk/src/lib.rs | 1 + crates/ide-sdk/src/maven_profiles.rs | 239 ++++++++++++++++++ .../build-tools/profileReload.test.ts | 92 +++++++ src/features/build-tools/store.ts | 24 ++ 11 files changed, 476 insertions(+), 3 deletions(-) create mode 100644 crates/ide-sdk/src/maven_profiles.rs create mode 100644 src/features/build-tools/profileReload.test.ts diff --git a/crates/ide-build/src/lib.rs b/crates/ide-build/src/lib.rs index 96358419..230ea671 100644 --- a/crates/ide-build/src/lib.rs +++ b/crates/ide-build/src/lib.rs @@ -18,7 +18,7 @@ mod xml; pub use gradle::{GradleModel, GradleProject, GradleTask}; pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject, Omission}; -pub use sync::{SyncOptions, SyncProgress, sync_gradle, sync_maven}; +pub use sync::{SyncOptions, SyncProgress, maven_profiles, save_maven_profiles, sync_gradle, sync_maven}; use serde::{Deserialize, Serialize}; use specta::Type; diff --git a/crates/ide-build/src/sync.rs b/crates/ide-build/src/sync.rs index 815c4631..dec09289 100644 --- a/crates/ide-build/src/sync.rs +++ b/crates/ide-build/src/sync.rs @@ -88,6 +88,24 @@ fn maven_sync_goals(effective: &Path, offline: bool) -> Vec { ] } const GRADLE_CACHE: &str = "gradle.json"; +const MAVEN_PROFILES: &str = "maven-profiles.json"; + +/// The Maven profiles (`-P` entries, `!id` deactivates) the project was last synced +/// with: what the IDE resolves it with besides `mvn` (the Java language server, Build Project). +pub fn maven_profiles(cache_dir: &Path) -> Vec { + read_cache(cache_dir, MAVEN_PROFILES).unwrap_or_default() +} + +/// Records the profiles of a sync for [`maven_profiles`]; `true` when they changed. +pub fn save_maven_profiles(cache_dir: &Path, profiles: &[String]) -> IdeResult { + if maven_profiles(cache_dir) == profiles { + return Ok(false); + } + std::fs::create_dir_all(cache_dir)?; + let json = serde_json::to_vec(profiles).map_err(|e| IdeError::internal(e.to_string()))?; + std::fs::write(cache_dir.join(MAVEN_PROFILES), json)?; + Ok(true) +} fn read_cache(dir: &Path, name: &str) -> Option { serde_json::from_slice(&std::fs::read(dir.join(name)).ok()?).ok() diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index 6ae0b0bd..03bf903d 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -74,6 +74,18 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel { model } +/// `model` building with the Maven profiles of the last sync (`-P`), as Maven runs from +/// the tool window do. +fn with_profiles(mut model: BuildModel, cache: &Path) -> BuildModel { + let profiles = ide_build::maven_profiles(cache); + if let (Some(m), false) = (&mut model.maven, profiles.is_empty()) { + for p in &mut m.projects { + p.options.push(format!("-P{}", profiles.join(","))); + } + } + model +} + /// Maven and Gradle projects of the window's project, from the build files plus the last sync. pub async fn build_model(host: &Host, window: &str) -> IdeResult { host.require_trust(window)?; @@ -98,6 +110,12 @@ pub async fn build_sync( let project = host.project(window)?; let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective()); let bridge_profiles = profiles.clone(); + // What the Java language server and Build Project resolve the project with from now on. + let profiles_changed = tool == BuildTool::Maven + && ide_build::save_maven_profiles(&cache, &profiles).unwrap_or_else(|e| { + tracing::warn!("Maven profiles not saved: {e}"); + false + }); let token = host.begin_project_request(window, request); let result = tokio::task::spawn_blocking(move || { let current = load(&root, &cache, &settings); @@ -152,6 +170,10 @@ 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(); tokio::spawn(async move { + if profiles_changed { + // jdtls takes the profiles from its Maven settings (see `ide_sdk::maven_profiles`). + lsp.reconfigure().await; + } if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await { tracing::debug!("java/projectConfigurationsUpdate: {e}"); } @@ -257,7 +279,8 @@ pub async fn build_run( for member in workspace.projects.iter().filter(|p| file.as_ref().is_none_or(|f| f.starts_with(p))) { let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective(); let member_cache = cache.join(crate::project::project_key(member)); - let plan = compile::plan(member, &load(member, &member_cache, &settings), kind, file.as_deref())?; + let model = with_profiles(load(member, &member_cache, &settings), &member_cache); + let plan = compile::plan(member, &model, kind, file.as_deref())?; match &mut combined { Some(combined) => combined.steps.extend(plan.steps), None => combined = Some(plan), @@ -267,7 +290,7 @@ pub async fn build_run( plan.title = workspace.name; plan } else { - compile::plan(&root, &load(&root, &cache, &settings), kind, file.as_deref())? + compile::plan(&root, &with_profiles(load(&root, &cache, &settings), &cache), kind, file.as_deref())? }; let result = compile::run(&root, &plan, &token, &|e| { let _ = on_event.send(e); diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index e7d26808..63d69ae8 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -129,6 +129,7 @@ impl Host { let root = root.clone(); let data_dir = self.local_data_dir.clone(); let config_dir = self.config_dir.clone(); + let build_cache = self.local_data_dir.join("cache").join(&project_key).join("build"); let sink = sink.clone(); let formatter_problem = std::sync::Mutex::new(None); lsp.set_config_hook(Box::new(move |cfg, server_root| { @@ -142,6 +143,15 @@ impl Host { let root = member.as_ref().unwrap_or(&root); ide_sdk::apply::server_launch_overrides(cfg, &effective); ide_sdk::apply::configure_server(cfg, root, server_root, &effective); + if cfg.name == "jdtls" { + // A workspace member keeps its build model under its own key (Build Project). + let cache = match &member { + Some(member) => build_cache.join(crate::project::project_key(member)), + None => build_cache.clone(), + }; + let profiles = ide_build::maven_profiles(&cache); + ide_sdk::maven_profiles::configure_jdtls(cfg, root, &effective, &profiles, &cache); + } ide_sdk::java::cap_java_home(cfg, &ide_sdk::java::installed_jdks()); if cfg.name == "jdtls" { let problem = ide_lsp::jdtls::configure_formatter( diff --git a/crates/ide-lsp/src/handler.rs b/crates/ide-lsp/src/handler.rs index fec8a9a7..4e023798 100644 --- a/crates/ide-lsp/src/handler.rs +++ b/crates/ide-lsp/src/handler.rs @@ -241,6 +241,9 @@ impl Handler for ClientHandler { "language/status" if self.server.name == "jdtls" && params["type"] == "ServiceReady" => { mgr.java_import_ready(self.server.clone(), self.generation); } + "language/status" if self.server.name == "jdtls" && params["type"] == "ProjectStatus" => { + mgr.java_projects_updated(self.server.clone(), self.generation); + } // The Angular server's project loading, shown like `$/progress`; one // task while several projects load. "angular/projectLoadingStart" if !self.server.progress.lock().contains_key(ANGULAR_LOADING) => self diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index fab72d9e..5852f53e 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -336,6 +336,35 @@ impl LspManager { }); } + /// jdtls reports `ProjectStatus` when an import or project update ends (a POM + /// change, a Maven sync, other Maven profiles), but does not check open documents + /// again against the new classpath: their errors would stay until the next edit. + pub(crate) fn java_projects_updated(self: &Arc, server: Arc, generation: u64) { + let uris: Vec = self + .docs + .lock() + .values() + .filter(|d| d.language.as_str() == "java" && d.path.starts_with(&server.root)) + .map(|d| crate::uri::path_to_uri_string(&d.path)) + .collect(); + if uris.is_empty() { + return; + } + self.runtime.spawn(async move { + let Some(run) = server.running_gen(generation) else { return }; + for uri in uris { + let params = + json!({ "command": "java.project.refreshDiagnostics", "arguments": [uri, "thisFile", false] }); + let request = run.peer.request::<_, Value>("workspace/executeCommand", params, None); + if let Ok(Err(e)) | Err(e) = + tokio::time::timeout(COMMAND_TIMEOUT, request).await.map_err(|_| ide_jsonrpc::RpcError::Timeout) + { + tracing::debug!("jdtls: java.project.refreshDiagnostics {uri}: {e}"); + } + } + }); + } + /// The jdtls instance serving `path` (a module directory or source file), /// started if needed and waited for. async fn jdtls(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<(Arc, Running)> { diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 147d6351..7b46a9d6 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -1200,6 +1200,40 @@ async fn java_launch_serializes_the_classpath_with_the_build_lock() { assert!(commands.contains(&"java.project.getClasspaths".to_owned())); } +#[tokio::test(flavor = "multi_thread")] +async fn open_java_files_are_checked_again_after_a_project_update() { + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + let (_, a) = h.open("A.java", "class A {}"); + let (_, b) = h.open("B.java", "class B {}"); + h.wait_status("jdtls", ServerStatus::Ready).await; + let state = h.launcher.state("jdtls"); + let refreshed = || { + let mut uris: Vec = state + .requested("workspace/executeCommand") + .iter() + .filter(|p| p["command"] == "java.project.refreshDiagnostics") + .map(|p| { + assert_eq!(p["arguments"][1], "thisFile"); + p["arguments"][0].clone() + }) + .collect(); + uris.sort_by_key(|u| u.to_string()); + uris + }; + // Other status messages leave the documents alone. + state + .peer() + .notify("language/status", json!({ "type": "Message", "message": "Updating project configurations..." })) + .unwrap(); + tokio::time::sleep(Duration::from_millis(200)).await; + assert!(refreshed().is_empty()); + + state.peer().notify("language/status", json!({ "type": "ProjectStatus", "message": "OK" })).unwrap(); + let mut expected = vec![json!(uri_of(&a)), json!(uri_of(&b))]; + expected.sort_by_key(|u| u.to_string()); + eventually(|| refreshed() == expected).await; +} + #[tokio::test(flavor = "multi_thread")] async fn fallback_servers_only_run_when_the_preferred_one_is_missing() { // Preferred installed: the alternative is never started. diff --git a/crates/ide-sdk/src/lib.rs b/crates/ide-sdk/src/lib.rs index 79ccfda6..2d90ad2a 100644 --- a/crates/ide-sdk/src/lib.rs +++ b/crates/ide-sdk/src/lib.rs @@ -16,6 +16,7 @@ pub mod jdtls; pub mod libraries; pub mod locate; pub mod lombok; +pub mod maven_profiles; mod npm; pub mod pinned; pub mod python; diff --git a/crates/ide-sdk/src/maven_profiles.rs b/crates/ide-sdk/src/maven_profiles.rs new file mode 100644 index 00000000..b5eeed57 --- /dev/null +++ b/crates/ide-sdk/src/maven_profiles.rs @@ -0,0 +1,239 @@ +//! Maven profiles for the Java language server. jdtls (m2e) has no setting for the +//! profiles a project is imported with; it takes them from `` of the +//! user `settings.xml`. The profiles chosen in the Maven tool window are therefore +//! passed as a copy of the user's settings with those profiles activated. + +use crate::apply::setting_path; +use ide_core::settings::Settings; +use ide_language::LanguageServerConfig; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; + +const PREFIX: &str = "jdtls-maven-settings-"; + +/// Points jdtls at a user `settings.xml` activating `profiles` (`-P` entries of the +/// last Maven sync), written to `dir`. Through `settings`, so a running server +/// re-imports its Maven projects instead of restarting. `!id` entries can't be +/// expressed in `settings.xml` and are left to the POMs' own activation. +pub fn configure_jdtls(cfg: &mut LanguageServerConfig, root: &Path, s: &Settings, profiles: &[String], dir: &Path) { + if cfg.name != "jdtls" { + return; + } + let active: Vec<&str> = + profiles.iter().map(|p| p.trim()).filter(|p| !p.is_empty() && !p.starts_with('!')).collect(); + let Some(file) = (!active.is_empty()).then(|| write_settings(root, s, &active, dir)).flatten() else { + remove_generated(dir, None); + return; + }; + let layer = json!({ "java": { "configuration": { "maven": { "userSettings": file.to_string_lossy() } } } }); + match &mut cfg.settings { + Some(settings) => merge(settings, layer), + None => cfg.settings = Some(layer), + } +} + +/// The user's settings (Settings › Maven, else `~/.m2/settings.xml`) with `active` +/// added to ``. Named after its content: m2e reloads a settings file +/// only when the configured path changes. +fn write_settings(root: &Path, s: &Settings, active: &[&str], dir: &Path) -> Option { + let user = setting_path(root, &s.languages.java.maven.user_settings) + .or_else(|| crate::home_dir().map(|h| h.join(".m2").join("settings.xml"))); + let base = user.and_then(|f| std::fs::read_to_string(f).ok()).unwrap_or_else(|| "\n\n".into()); + let Some(text) = with_active_profiles(&base, active) else { + tracing::warn!("Maven profiles not passed to jdtls: the user settings.xml has no element"); + return None; + }; + let mut hash = 0xcbf29ce484222325u64; + for byte in text.bytes() { + hash = (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3); + } + let file = dir.join(format!("{PREFIX}{hash:016x}.xml")); + if !file.is_file() { + let written = std::fs::create_dir_all(dir).and_then(|()| std::fs::write(&file, &text)); + if let Err(e) = written { + tracing::warn!("Maven profiles not passed to jdtls: {}: {e}", file.display()); + return None; + } + } + remove_generated(dir, Some(&file)); + Some(file) +} + +/// Deletes the settings files written for earlier profile choices. +fn remove_generated(dir: &Path, keep: Option<&Path>) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + let generated = path.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with(PREFIX)); + if generated && Some(path.as_path()) != keep { + let _ = std::fs::remove_file(path); + } + } +} + +/// `text` (a `settings.xml`) with `ids` appended to its ``, created +/// when missing; `None` when there is no `` element to add it to. +pub fn with_active_profiles(text: &str, ids: &[&str]) -> Option { + let entries: String = ids.iter().map(|id| format!(" {}\n", escape(id))).collect(); + let masked = mask_comments(text); + if let Some(start) = find_tag(&masked, "')?; + if masked[..end].ends_with('/') { + let element = format!("\n{entries} "); + return Some(format!("{}{element}{}", &text[..start], &text[end + 1..])); + } + let close = end + masked[end..].find("\n{entries} \n"); + Some(format!("{}\n{element}{}", text[..close].trim_end(), &text[close..])) +} + +/// Start of the element `open` (` Option { + let mut at = from; + while let Some(i) = masked[at..].find(open) { + let start = at + i; + let next = masked[start + open.len()..].chars().next(); + if next.is_some_and(|c| c == '>' || c == '/' || c.is_whitespace()) { + return Some(start); + } + at = start + open.len(); + } + None +} + +/// `text` with comments and CDATA blanked out, byte offsets unchanged. +fn mask_comments(text: &str) -> String { + let mut out = text.as_bytes().to_vec(); + let mut at = 0; + while at < text.len() { + let rest = &text[at..]; + let (open, close) = match (rest.find(""), + (None, Some(d)) => (d, "]]>"), + (None, None) => break, + }; + let start = at + open; + let end = text[start..].find(close).map_or(text.len(), |i| start + i + close.len()); + out[start..end].fill(b' '); + at = end; + } + String::from_utf8(out).unwrap_or_default() +} + +fn escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">") +} + +fn merge(base: &mut Value, layer: Value) { + match (base, layer) { + (Value::Object(base), Value::Object(layer)) => { + for (k, v) in layer { + match base.get_mut(&k) { + Some(existing) => merge(existing, v), + None => { + base.insert(k, v); + } + } + } + } + (base, layer) => *base = layer, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn jdtls() -> LanguageServerConfig { + serde_json::from_value(json!({ "name": "jdtls", "command": "jdtls" })).unwrap() + } + + fn user_settings(cfg: &LanguageServerConfig) -> Option { + cfg.settings.as_ref()?.pointer("/java/configuration/maven/userSettings")?.as_str().map(Into::into) + } + + #[test] + fn adds_profiles_to_every_shape_of_settings_xml() { + let ids = ["native", "本地"]; + let added = "native\n 本地"; + for (text, keeps) in [ + ("\n\n", ""), + ("m", "m"), + ( + "\n \n nexus\n \n", + "nexus", + ), + ("", ""), + ("", ""), + // Commented-out elements are not where the profiles go. + ( + "\n\n", + "", + ), + ( + "\n", + "", + ), + ] { + let out = with_active_profiles(text, &ids).unwrap_or_else(|| panic!("{text}")); + assert!(out.contains(added), "{text}\n→\n{out}"); + assert!(out.contains(keeps), "{text}\n→\n{out}"); + let out = if keeps.starts_with("