diff --git a/crates/ide-build/src/compile/mod.rs b/crates/ide-build/src/compile/mod.rs index 857912ad..b674e1ad 100644 --- a/crates/ide-build/src/compile/mod.rs +++ b/crates/ide-build/src/compile/mod.rs @@ -237,12 +237,7 @@ fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option< if !m.projects.iter().any(|p| p.dir == rel) { return Err(IdeError::not_found("no linked Maven project found for module")); } - let top = tops - .iter() - .filter(|t| t.dir.is_empty() || rel == t.dir || rel.starts_with(&format!("{}/", t.dir))) - .max_by_key(|t| t.dir.len()) - .copied() - .unwrap_or(tops[0]); + let top = maven_reactor(m, &rel).ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?; let top_dir = subdir(root, &top.dir); if module == top_dir { return Ok(vec![step(top, &[])]); @@ -611,6 +606,90 @@ pub fn has_non_java_sources(module: &Path) -> bool { walk(&module.join("src"), 12) } +fn reactor_projects<'a>(m: &'a maven::MavenModel, top: &'a maven::MavenProject) -> Vec<&'a maven::MavenProject> { + let mut projects = Vec::new(); + let mut pending = vec![top]; + let mut seen = HashSet::new(); + while let Some(p) = pending.pop() { + if !seen.insert(&p.pom) { + continue; + } + projects.push(p); + pending.extend(m.projects.iter().filter(|child| p.modules.contains(&child.pom))); + } + projects +} + +fn maven_reactor<'a>(m: &'a maven::MavenModel, module: &str) -> Option<&'a maven::MavenProject> { + m.projects.iter().filter(|p| !p.aggregated).find(|top| reactor_projects(m, top).iter().any(|p| p.dir == module)) +} + +/// Launch module first, followed by its upstream modules in the same Maven reactor. +/// External artifacts and unrelated linked reactors are never rebuilt for this launch. +fn maven_launch_projects<'a>( + root: &Path, + m: &'a maven::MavenModel, + module: &Path, + test: bool, +) -> Vec<&'a maven::MavenProject> { + let rel = rel_str(root, module); + let Some(top) = maven_reactor(m, &rel) else { return Vec::new() }; + let reactor = reactor_projects(m, top); + let Some(start) = reactor.iter().find(|p| p.dir == rel) else { return Vec::new() }; + let mut pending = vec![*start]; + let mut seen = HashSet::new(); + let mut projects = Vec::new(); + while let Some(p) = pending.pop() { + if !seen.insert(&p.pom) { + continue; + } + projects.push(p); + for dep in &p.dependencies { + if !dep.resolved() || (dep.scope == "test" && (!test || p.pom != start.pom)) { + continue; + } + if let Some(upstream) = reactor.iter().find(|u| { + u.group_id == dep.group_id + && u.artifact_id == dep.artifact_id + && dep.version.as_ref().is_none_or(|v| *v == u.version) + }) { + pending.push(*upstream); + } + } + } + projects +} + +/// Whether a launch requires the external JVM compiler. Maven includes upstream +/// reactor dependencies; adapter selection remains a separate, module-local decision. +pub fn launch_has_non_java_sources(root: &Path, model: &BuildModel, module: &Path, test: bool) -> bool { + if let Some(m) = &model.maven { + let projects = maven_launch_projects(root, m, module, test); + if !projects.is_empty() { + return projects.iter().any(|p| has_non_java_sources(&subdir(root, &p.dir))); + } + } + has_non_java_sources(module) +} + +fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult { + let kind = if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| subdir(root, &p.dir) == module)) { + // JDT may have left newer error stubs in Maven's output directories. + BuildKind::Rebuild + } else { + BuildKind::Build + }; + let mut plan = plan(root, model, kind, Some(module))?; + if plan.system == BuildSystem::Maven && !test { + for step in &mut plan.steps { + if let Some(goal) = step.args.last_mut() { + *goal = "compile".into(); + } + } + } + Ok(plan) +} + /// Compiles the module at `module` with its build system before a JVM launch and /// returns the class directories it wrote, to put ahead of the jdtls classpath. pub fn build_for_launch( @@ -620,7 +699,7 @@ pub fn build_for_launch( test: bool, cancel: &CancellationToken, ) -> IdeResult> { - let plan = plan(root, model, BuildKind::Build, Some(module))?; + let plan = launch_plan(root, model, module, test)?; let result = run(root, &plan, cancel, &|_| {})?; if !result.success { let errors: Vec = result @@ -635,6 +714,14 @@ pub fn build_for_launch( .collect(); return Err(IdeError::internal(format!("Build failed:\n{}", errors.join("\n")))); } + if plan.system == BuildSystem::Maven + && let Some(m) = &model.maven + { + return Ok(maven_launch_projects(root, m, module, test) + .iter() + .flat_map(|p| launch_class_dirs(plan.system, &subdir(root, &p.dir), test && subdir(root, &p.dir) == module)) + .collect()); + } Ok(launch_class_dirs(plan.system, module, test)) } @@ -821,6 +908,142 @@ mod tests { } } + fn reactor_model() -> BuildModel { + let project = |dir: &str, deps: &[&str]| maven::MavenProject { + pom: format!("{dir}/pom.xml"), + dir: dir.into(), + group_id: "probe".into(), + artifact_id: dir.into(), + version: "1".into(), + aggregated: true, + dependencies: deps + .iter() + .map(|d| maven::MavenDependency { + group_id: "probe".into(), + artifact_id: (*d).into(), + version: Some("1".into()), + scope: "compile".into(), + ..Default::default() + }) + .collect(), + ..Default::default() + }; + let mut projects = vec![ + project("app", &["middle"]), + project("middle", &["lib"]), + project("lib", &["app"]), + project("unrelated", &[]), + ]; + projects.insert( + 0, + maven::MavenProject { + pom: "pom.xml".into(), + artifact_id: "reactor".into(), + command: "mvnw".into(), + modules: projects.iter().map(|p| p.pom.clone()).collect(), + options: vec!["-s".into(), "settings.xml".into()], + env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]), + ..Default::default() + }, + ); + BuildModel { maven: Some(maven::MavenModel { projects, ..Default::default() }), ..Default::default() } + } + + #[test] + fn mixed_launch_detection_follows_only_reactor_dependencies() { + for extension in ["kt", "groovy", "scala"] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let mut model = reactor_model(); + write(root, "app/src/main/java/Application.java", "class Application {}"); + write(root, &format!("unrelated/src/main/java/Other.{extension}"), ""); + assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false)); + write(root, &format!("lib/src/main/java/Channel.{extension}"), ""); + assert!(launch_has_non_java_sources(root, &model, &root.join("app"), false)); + assert_eq!( + maven_launch_projects(root, model.maven.as_ref().unwrap(), &root.join("app"), false).len(), + 3, + "cycles terminate" + ); + let m = model.maven.as_mut().unwrap(); + m.projects[2].dependencies[0].scope = "test".into(); + assert!( + !launch_has_non_java_sources(root, &model, &root.join("app"), true), + "upstream test dependencies are not transitive" + ); + let m = model.maven.as_mut().unwrap(); + m.projects[1].dependencies[0].scope = "test".into(); + m.projects[2].dependencies[0].scope = "compile".into(); + assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false)); + assert!(launch_has_non_java_sources(root, &model, &root.join("app"), true)); + model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("external".into()); + assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), true)); + model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("1".into()); + model.maven.as_mut().unwrap().projects[3].aggregated = false; + model.maven.as_mut().unwrap().projects[0].modules.retain(|p| p != "lib/pom.xml"); + assert!( + !launch_has_non_java_sources(root, &model, &root.join("app"), true), + "another linked reactor is external" + ); + } + } + + #[test] + fn maven_launch_steps_use_reactor_settings_and_launch_scope() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write(root, "app/pom.xml", ""); + let model = reactor_model(); + for (test, goal) in [(false, "compile"), (true, "test-compile")] { + let plan = launch_plan(root, &model, &root.join("app"), test).unwrap(); + let step = &plan.steps[0]; + assert_eq!(step.cwd, root); + assert_eq!(step.program, "mvnw"); + assert_eq!(step.env["JAVA_HOME"], "/jdk"); + assert_eq!( + step.args, + ["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal] + ); + } + } + + #[cfg(unix)] + #[test] + fn external_launch_returns_upstream_outputs_and_blocks_failure_or_cancellation() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write(root, "app/pom.xml", ""); + write( + root, + "mvnw", + "#!/bin/sh\nmkdir -p app/target/classes middle/target/classes lib/target/classes app/target/test-classes\n", + ); + let wrapper = root.join("mvnw"); + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + let mut model = reactor_model(); + model.maven.as_mut().unwrap().projects[0].command = wrapper.to_string_lossy().into_owned(); + let dirs = build_for_launch(root, &model, &root.join("app"), true, &CancellationToken::new()).unwrap(); + assert_eq!( + dirs, + ["app/target/test-classes", "app/target/classes", "middle/target/classes", "lib/target/classes"] + .map(|p| root.join(p)) + ); + write( + root, + "mvnw", + "#!/bin/sh\necho '[ERROR] Kotlin compilation failed: unresolved reference broken' >&2\nexit 1\n", + ); + let error = build_for_launch(root, &model, &root.join("app"), false, &CancellationToken::new()).unwrap_err(); + assert!(error.message.contains("Kotlin compilation failed"), "{}", error.message); + let cancel = CancellationToken::new(); + cancel.cancel(); + assert_eq!( + build_for_launch(root, &model, &root.join("app"), false, &cancel).unwrap_err().kind, + ide_core::error::ErrorKind::Cancelled + ); + } + #[test] fn non_java_launch_class_dirs() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml index 4518fa0f..c6330434 100644 --- a/crates/ide-host/Cargo.toml +++ b/crates/ide-host/Cargo.toml @@ -48,7 +48,7 @@ parking_lot.workspace = true dunce.workspace = true dirs.workspace = true uuid.workspace = true +tempfile.workspace = true [dev-dependencies] tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] } -tempfile.workspace = true diff --git a/crates/ide-host/src/ops/java.rs b/crates/ide-host/src/ops/java.rs index 4eccfb01..6e334ca7 100644 --- a/crates/ide-host/src/ops/java.rs +++ b/crates/ide-host/src/ops/java.rs @@ -5,7 +5,7 @@ use crate::rpc::Dispatcher; use super::build::load; use ide_build::compile; use ide_core::{CancellationToken, IdeError, IdeResult, RequestId}; -use ide_lsp::jdtls::JavaLaunchInfo; +use ide_lsp::jdtls::{JavaBuildStatus, JavaLaunchInfo}; use std::path::PathBuf; /// Cancellable with `cancel_request(request)`. @@ -39,23 +39,42 @@ async fn resolve_launch( let project = host.project(window)?; let module = project.root.join(module); let settings = project.settings.effective(); - // Let jdtls finish its build before the external compiler writes Kotlin/Scala classes. - let mut info = project.lsp.java_launch(&module, test, build, cancel).await?; + let (model, external, delegated) = { + let project = project.clone(); + let settings = settings.clone(); + let module = module.clone(); + tokio::task::spawn_blocking(move || { + let model = load(&project.root, &project.build_cache_dir(), &settings); + let external = build && compile::launch_has_non_java_sources(&project.root, &model, &module, test); + let delegated = external + && model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| project.root.join(&p.dir) == module)); + (model, external, delegated) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))? + }; + let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None }; + let mut info = project.lsp.java_launch(&module, test, build && !delegated, cancel).await?; let lsp_ms = started.elapsed().as_millis(); let data_dir = host.data_dir.clone(); let cancel = cancel.clone(); tokio::task::spawn_blocking(move || { - let built = if build && compile::has_non_java_sources(&module) { - compile::build_for_launch( - &project.root, - &load(&project.root, &project.build_cache_dir(), &settings), - &module, - test, - &cancel, - )? + let _external_build = external_build; + let built = if external { + compile::build_for_launch(&project.root, &model, &module, test, &cancel)? } else { Vec::new() }; + let built = if delegated { + let (snapshot, copies) = snapshot_outputs(&mut info, &built, &cancel)?; + project.java_launch_outputs.lock().push(snapshot); + info.build = Some(JavaBuildStatus::Succeeded); + info.errors.clear(); + info.error_count = 0; + copies + } else { + built + }; let mut class_paths: Vec = built.iter().map(|p| p.to_string_lossy().into_owned()).collect(); for p in std::mem::take(&mut info.class_paths) { if !class_paths.contains(&p) { @@ -103,6 +122,35 @@ async fn resolve_launch( .map_err(|e| IdeError::internal(e.to_string()))? } +fn snapshot_outputs( + info: &mut JavaLaunchInfo, + built: &[PathBuf], + cancel: &CancellationToken, +) -> IdeResult<(tempfile::TempDir, Vec)> { + let snapshot = tempfile::Builder::new().prefix("openidea-java-launch-").tempdir()?; + let mut copies = Vec::new(); + for (index, source) in built.iter().enumerate() { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let dest = snapshot.path().join(index.to_string()); + ide_vfs::copy(source, &dest)?; + let original = source.to_string_lossy(); + info.class_paths.retain(|p| p != original.as_ref()); + let mut modular = false; + for path in &mut info.module_paths { + if path == original.as_ref() { + *path = dest.to_string_lossy().into_owned(); + modular = true; + } + } + if !modular { + copies.push(dest); + } + } + Ok((snapshot, copies)) +} + /// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand). pub async fn java_debug_server(host: &Host, window: &str, module: PathBuf) -> IdeResult { host.require_trust(window)?; @@ -132,3 +180,43 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await }); d.add("java_build_workspace", |c| async move { java_build_workspace(&c.host, &c.window, c.arg("module")?).await }); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn launch_snapshots_survive_jdt_overwrites_and_replace_module_outputs() { + let root = tempfile::tempdir().unwrap(); + let app = root.path().join("app/target/classes"); + let lib = root.path().join("lib/target/classes"); + for dir in [&app, &lib] { + std::fs::create_dir_all(dir).unwrap(); + std::fs::write(dir.join("Channel.class"), "Maven bytecode").unwrap(); + } + let mut info = JavaLaunchInfo { + class_paths: vec![app.to_string_lossy().into_owned(), "stdlib.jar".into()], + module_paths: vec![lib.to_string_lossy().into_owned()], + ..Default::default() + }; + let (snapshot, paths) = + snapshot_outputs(&mut info, &[app.clone(), lib.clone()], &CancellationToken::new()).unwrap(); + assert_eq!(paths, [snapshot.path().join("0")]); + assert_eq!(info.class_paths, ["stdlib.jar"]); + assert_eq!(info.module_paths, [snapshot.path().join("1").to_string_lossy().into_owned()]); + std::fs::write(app.join("Channel.class"), "JDT error stub").unwrap(); + std::fs::remove_dir_all(lib).unwrap(); + for index in ["0", "1"] { + assert_eq!( + std::fs::read_to_string(snapshot.path().join(index).join("Channel.class")).unwrap(), + "Maven bytecode" + ); + } + let cancel = CancellationToken::new(); + cancel.cancel(); + assert_eq!( + snapshot_outputs(&mut info, &[app], &cancel).unwrap_err().kind, + ide_core::error::ErrorKind::Cancelled + ); + } +} diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 24cc4aa3..caf5afea 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -42,6 +42,8 @@ pub struct Project { pub detected_roots: Arc>>, /// Per-project cache directory (build tool sync results…). pub cache_dir: PathBuf, + /// Launch snapshots stay alive while any JVM of this project may load classes. + pub(crate) java_launch_outputs: Mutex>, /// Database tool window: data sources and their connections. pub db: Arc, /// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir. @@ -366,6 +368,7 @@ impl Host { syntax, detected_roots, cache_dir: self.data_dir.join("cache").join(&project_key), + java_launch_outputs: Mutex::new(Vec::new()), db, http_history, agents, @@ -481,6 +484,7 @@ impl Host { syntax, detected_roots: Arc::default(), cache_dir: self.data_dir.join("cache").join(&project_key), + java_launch_outputs: Mutex::new(Vec::new()), db, http_history, agents, diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index e66cafe6..09d6ed61 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -189,6 +189,24 @@ pub struct JavaCompileError { pub message: String, } +/// Holds off JDT writers while an external compiler builds and snapshots launch outputs. +/// Restores current settings even when preparation fails or is cancelled. +pub struct JavaExternalBuild { + server: Arc, + run: Running, + _lock: tokio::sync::OwnedMutexGuard<()>, +} + +impl Drop for JavaExternalBuild { + fn drop(&mut self) { + self.server.java_autobuild_suspended.store(false, std::sync::atomic::Ordering::SeqCst); + let settings = crate::handler::all_settings(&self.server.effective_config()); + if let Err(e) = self.run.peer.notify("workspace/didChangeConfiguration", json!({ "settings": settings })) { + tracing::debug!("jdtls: restoring autobuild: {e}"); + } + } +} + /// Error diagnostics sorted by file and position, capped at `limit`, and their total. pub fn compile_errors( diagnostics: Vec<(PathBuf, lsp_types::Diagnostic)>, @@ -433,9 +451,28 @@ impl LspManager { .map_err(|e| crate::rpc_err(&server.name, "java/projectConfigurationsUpdate", e)) } + /// Pause autobuild before resolving the launch classpath. That query takes the + /// Java project's workspace scheduling rule, draining the interrupted autobuild. + /// Keep this guard until Maven outputs have been copied out of JDT's reach. + pub async fn java_external_build(&self, path: &Path, cancel: &CancellationToken) -> IdeResult { + let (server, run) = self.jdtls(path, cancel).await?; + let lock = cancellable_wait(cancel, server.java_build_lock.clone().lock_owned()).await?; + server.java_autobuild_suspended.store(true, std::sync::atomic::Ordering::SeqCst); + let guard = JavaExternalBuild { server, run, _lock: lock }; + let settings = crate::handler::all_settings(&guard.server.effective_config()); + guard + .run + .peer + .notify("workspace/didChangeConfiguration", json!({ "settings": settings })) + .map_err(|e| crate::rpc_err(SERVER, "workspace/didChangeConfiguration", e))?; + Ok(guard) + } + /// Incremental build of the jdtls workspace (`java/buildWorkspace`), so /// class files are up to date before a launch. pub async fn java_build(&self, path: &Path, cancel: &CancellationToken) -> IdeResult { + let (server, _) = self.jdtls(path, cancel).await?; + let _lock = cancellable_wait(cancel, server.java_build_lock.lock()).await?; let mut v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT, cancel).await?; // The first build can finish the Maven import before ServiceReady is handled. let (server, run) = self.jdtls(path, cancel).await?; diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index 018c81ee..81c21b02 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -99,6 +99,8 @@ pub struct Server { /// Woken when the last `$/progress` task ends. idle_notify: Notify, transient: Mutex>, + pub java_build_lock: Arc>, + pub java_autobuild_suspended: std::sync::atomic::AtomicBool, pub external_project: tokio::sync::Mutex, pub external_diagnostics: std::sync::atomic::AtomicBool, pub external_refresh_queued: std::sync::atomic::AtomicBool, @@ -126,6 +128,8 @@ impl Server { published_notify: Notify::new(), idle_notify: Notify::new(), transient: Mutex::new(HashMap::new()), + java_build_lock: Arc::new(tokio::sync::Mutex::new(())), + java_autobuild_suspended: std::sync::atomic::AtomicBool::new(false), external_project: tokio::sync::Mutex::new(Default::default()), external_diagnostics: std::sync::atomic::AtomicBool::new(false), external_refresh_queued: std::sync::atomic::AtomicBool::new(false), @@ -134,7 +138,13 @@ impl Server { /// Configuration of the current (or last) instance; the base config before the first launch. pub fn effective_config(&self) -> LanguageServerConfig { - self.effective.lock().clone().unwrap_or_else(|| self.config.clone()) + let mut config = self.effective.lock().clone().unwrap_or_else(|| self.config.clone()); + if self.java_autobuild_suspended.load(std::sync::atomic::Ordering::SeqCst) { + let mut settings = crate::handler::all_settings(&config); + settings["java"]["autobuild"]["enabled"] = serde_json::json!(false); + config.settings = Some(settings); + } + config } pub fn set_effective_config(&self, config: LanguageServerConfig) { @@ -553,6 +563,19 @@ mod tests { assert_eq!(s.record_crash(), Some(Duration::from_millis(500))); } + #[test] + fn external_build_suspends_only_the_effective_autobuild_setting() { + let mut config: LanguageServerConfig = toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap(); + config.settings = Some(serde_json::json!({"java": {"autobuild": {"enabled": true}, "other": "kept"}})); + let server = Server::new(config.clone(), PathBuf::from("/p"), LanguageId::new("java")); + server.java_autobuild_suspended.store(true, std::sync::atomic::Ordering::SeqCst); + let settings = crate::handler::all_settings(&server.effective_config()); + assert_eq!(settings["java"]["autobuild"]["enabled"], false); + assert_eq!(settings["java"]["other"], "kept"); + server.java_autobuild_suspended.store(false, std::sync::atomic::Ordering::SeqCst); + assert_eq!(server.effective_config(), config); + } + #[tokio::test(flavor = "multi_thread")] async fn a_failed_start_reports_its_status_before_waiters_wake() { // The race behind "jdtls is not ready (Starting)": a request woken by the