From 9f9a2d8cf0339711b675b6da7060a33e982f96ed Mon Sep 17 00:00:00 2001 From: misswell Date: Mon, 9 Nov 2026 03:18:13 +0800 Subject: [PATCH] feat(run): share Java preparation and add incremental build policies (#2156) Run and Debug now share one Java preparation path: save documents, build with the configured policy, resolve the module classpath and JDK, and stop on a failed build. Incremental Make is the default; Rebuild, NoBuild and Build-no-error-check are persisted on the run configuration, and the form offers only what a build tool supports. Delegated Gradle goes through the same path, Debug reports save failures, Maven snapshots honour Rebuild, and scoped diagnostics are retained so incremental Maven cannot erase compilation errors. Co-authored-by: misswell <28730815+misswell@users.noreply.github.com> --- crates/ide-build/src/compile/fingerprint.rs | 2 +- crates/ide-build/src/compile/mod.rs | 62 ++++---- crates/ide-host/src/ops/java.rs | 72 ++++++++-- crates/ide-host/src/ops/java/cache.rs | 133 +++++++++++++++--- crates/ide-lsp/src/jdtls.rs | 18 ++- crates/ide-lsp/tests/mock_server.rs | 80 +++++++++-- crates/ide-lsp/tests/real_servers.rs | 2 +- crates/ide-run/src/config.rs | 7 + crates/ide-run/src/lib.rs | 36 +++++ crates/ide-run/tests/config.rs | 24 ++++ src-tauri/src/commands/java.rs | 7 +- src/bindings.ts | 24 +++- .../debugger/__tests__/actions.test.ts | 26 +++- src/features/debugger/__tests__/java.test.ts | 17 ++- src/features/debugger/actions.ts | 13 +- src/features/debugger/java.ts | 5 +- src/features/run/ConfigForm.tsx | 61 +++++++- src/features/run/applicationUpdate.test.ts | 4 +- src/features/run/applicationUpdate.ts | 7 +- src/features/run/beforeLaunch.ts | 6 +- src/features/run/buildCommand.test.ts | 44 ++++++ src/features/run/buildCommand.ts | 22 +++ src/features/run/gradleRun.test.ts | 12 ++ src/features/run/gradleRun.ts | 19 ++- src/features/run/index.ts | 13 +- src/features/run/java.test.ts | 6 + src/features/run/javaDebug.ts | 2 + src/features/run/javaModule.test.ts | 76 +++++++++- src/features/run/runConfigTypes.test.tsx | 61 ++++++++ src/features/run/store.ts | 13 +- src/features/run/tomcat.test.tsx | 2 +- src/features/run/tomcat.ts | 4 +- src/platform/i18n.test.ts | 31 +++- src/platform/javaLaunch.ts | 50 ++++++- src/platform/locales/text/en.json | 8 ++ src/platform/locales/text/es.json | 2 - src/platform/locales/text/fr.json | 2 - src/platform/locales/text/ko.json | 2 - src/platform/locales/text/pl.json | 2 - src/platform/locales/text/pt.json | 2 - src/platform/locales/text/zh.json | 25 +++- 41 files changed, 868 insertions(+), 136 deletions(-) create mode 100644 src/features/run/buildCommand.test.ts create mode 100644 src/features/run/buildCommand.ts diff --git a/crates/ide-build/src/compile/fingerprint.rs b/crates/ide-build/src/compile/fingerprint.rs index 7ed4b9237..a9297c106 100644 --- a/crates/ide-build/src/compile/fingerprint.rs +++ b/crates/ide-build/src/compile/fingerprint.rs @@ -95,7 +95,7 @@ pub fn launch_fingerprint( return Ok(None); } let Some(mut inputs) = maven::launch_inputs(root, &projects, local_repo) else { return Ok(None) }; - let plan = launch_plan(root, model, module, test)?; + let plan = launch_plan(root, model, module, test, BuildKind::Build)?; let mut environment: BTreeMap = std::env::vars() .map(|(key, value)| (key.to_ascii_uppercase(), value)) .filter(|(key, _)| { diff --git a/crates/ide-build/src/compile/mod.rs b/crates/ide-build/src/compile/mod.rs index 954c95962..993d510a3 100644 --- a/crates/ide-build/src/compile/mod.rs +++ b/crates/ide-build/src/compile/mod.rs @@ -772,13 +772,7 @@ pub fn launch_has_non_java_sources(root: &Path, model: &BuildModel, module: &Pat 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 - }; +fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool, kind: BuildKind) -> IdeResult { let mut plan = plan(root, model, kind, Some(module), None)?; if plan.system == BuildSystem::Maven && !test { for step in &mut plan.steps { @@ -790,6 +784,12 @@ fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> Id Ok(plan) } +#[derive(Debug)] +pub struct LaunchBuild { + pub class_dirs: Vec, + pub failure: Option, +} + /// 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( @@ -797,11 +797,12 @@ pub fn build_for_launch( model: &BuildModel, module: &Path, test: bool, + kind: BuildKind, cancel: &CancellationToken, -) -> IdeResult> { - let plan = launch_plan(root, model, module, test)?; +) -> IdeResult { + let plan = launch_plan(root, model, module, test, kind)?; let result = run(root, &plan, cancel, &|_| {})?; - if !result.success { + let failure = if !result.success { let errors: Vec = result .messages .iter() @@ -812,17 +813,21 @@ pub fn build_for_launch( _ => m.text.clone(), }) .collect(); - return Err(IdeError::internal(format!("Build failed:\n{}", errors.join("\n")))); - } - if plan.system == BuildSystem::Maven + Some(format!("Build failed:\n{}", errors.join("\n"))) + } else { + None + }; + let class_dirs = if plan.system == BuildSystem::Maven && let Some(m) = &model.maven { - return Ok(maven_launch_projects(root, m, module, test) + 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)) + .collect() + } else { + launch_class_dirs(plan.system, module, test) + }; + Ok(LaunchBuild { class_dirs, failure }) } fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec { @@ -1309,13 +1314,17 @@ mod tests { 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(); + // A normal launch is incremental: no `clean` (IDEA's Make, not Rebuild). + let plan = launch_plan(root, &model, &root.join("app"), test, BuildKind::Build).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", goal]); + // Rebuild cleans first, as IDEA's Rebuild does. + let plan = launch_plan(root, &model, &root.join("app"), test, BuildKind::Rebuild).unwrap(); assert_eq!( - step.args, + plan.steps[0].args, ["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal] ); } @@ -1337,9 +1346,10 @@ mod tests { 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(); + let dirs = build_for_launch(root, &model, &root.join("app"), true, BuildKind::Build, &CancellationToken::new()) + .unwrap(); assert_eq!( - dirs, + dirs.class_dirs, ["app/target/test-classes", "app/target/classes", "middle/target/classes", "lib/target/classes"] .map(|p| root.join(p)) ); @@ -1348,12 +1358,16 @@ mod tests { "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 error = + build_for_launch(root, &model, &root.join("app"), false, BuildKind::Build, &CancellationToken::new()) + .unwrap() + .failure + .unwrap(); + assert!(error.contains("Kotlin compilation failed"), "{error}"); let cancel = CancellationToken::new(); cancel.cancel(); assert_eq!( - build_for_launch(root, &model, &root.join("app"), false, &cancel).unwrap_err().kind, + build_for_launch(root, &model, &root.join("app"), false, BuildKind::Build, &cancel).unwrap_err().kind, ide_core::error::ErrorKind::Cancelled ); } diff --git a/crates/ide-host/src/ops/java.rs b/crates/ide-host/src/ops/java.rs index adb8c63d3..9b954c3c8 100644 --- a/crates/ide-host/src/ops/java.rs +++ b/crates/ide-host/src/ops/java.rs @@ -8,6 +8,7 @@ use super::build::load; use ide_build::compile; use ide_core::{CancellationToken, IdeError, IdeResult, RequestId}; use ide_lsp::jdtls::{JavaBuildStatus, JavaLaunchInfo}; +use ide_run::BuildPolicy; use std::path::PathBuf; /// Cancellable with `cancel_request(request)`. @@ -19,12 +20,12 @@ pub async fn java_resolve_launch( window: &str, module: PathBuf, test: bool, - build: bool, + policy: BuildPolicy, request: RequestId, ) -> IdeResult { host.require_trust(window)?; let cancel = host.begin_project_request(window, request); - let result = resolve_launch(host, window, module, test, build, &cancel).await; + let result = resolve_launch(host, window, module, test, policy, &cancel).await; host.end_request(request); result } @@ -34,13 +35,17 @@ async fn resolve_launch( window: &str, module: PathBuf, test: bool, - build: bool, + policy: BuildPolicy, cancel: &CancellationToken, ) -> IdeResult { let started = std::time::Instant::now(); let project = host.project(window)?; let module = project.root.join(module); let settings = project.settings.effective(); + // A launch builds incrementally unless the configuration asks for Rebuild or NoBuild. + let build = policy != BuildPolicy::NoBuild; + let full = policy == BuildPolicy::Rebuild; + let build_kind = if full { compile::BuildKind::Rebuild } else { compile::BuildKind::Build }; let (model, external, delegated) = { let project = project.clone(); let settings = settings.clone(); @@ -59,7 +64,7 @@ async fn resolve_launch( .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, delegated, cancel).await?; + let mut info = project.lsp.java_launch(&module, test, build && !delegated, full, delegated, cancel).await?; let lsp_ms = started.elapsed().as_millis(); let data_dir = host.local_data_dir.clone(); let cancel = cancel.clone(); @@ -70,17 +75,23 @@ async fn resolve_launch( &project.root, &model, &module, - test, + cache::BuildOptions { test, policy }, ide_sdk::apply::maven_local_repository(&project.root, &settings), &cancel, )?; let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot); - info.build = Some(JavaBuildStatus::Succeeded); - info.errors.clear(); - info.error_count = 0; + apply_external_build_result(&mut info, outputs.failure); copies } else if external { - compile::build_for_launch(&project.root, &model, &module, test, &cancel)? + let built = compile::build_for_launch(&project.root, &model, &module, test, build_kind, &cancel)?; + if let Some(failure) = built.failure { + if policy != BuildPolicy::MakeAndContinueOnErrors { + return Err(IdeError::internal(failure)); + } + info.build = Some(JavaBuildStatus::Failed); + info.hints.push(failure); + } + built.class_dirs } else { Vec::new() }; @@ -138,6 +149,18 @@ async fn resolve_launch( .map_err(|e| IdeError::internal(e.to_string()))? } +fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option) { + info.build = Some(if let Some(failure) = failure { + info.hints.push(failure); + JavaBuildStatus::Failed + } else if info.error_count > 0 { + // Incremental Maven compilation can accept error-stub classes written by JDT. + JavaBuildStatus::WithErrors + } else { + JavaBuildStatus::Succeeded + }); +} + /// A classpath entry as a comparable key. Entries come from JDT LS and from our own /// build model, which spell one directory differently on Windows (`\\?\` prefix, /// `/` or `\\`, drive-letter case); compared as text, a module output stayed on the @@ -242,7 +265,7 @@ pub async fn java_build_workspace( let module = project.root.join(module); // HotSwap may cancel the compile (the request id is allocated by the caller). let cancel = request.map_or_else(CancellationToken::new, |id| host.begin_request(id)); - let result = project.lsp.java_build(&module, &cancel).await; + let result = project.lsp.java_build(&module, false, &cancel).await; if let Some(id) = request { host.end_request(id); } @@ -252,7 +275,7 @@ pub async fn java_build_workspace( /// Commands of this module the remote server answers. pub(crate) fn register(d: &mut Dispatcher) { d.add("java_resolve_launch", |c| async move { - java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("build")?, c.arg("request")?) + java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("policy")?, c.arg("request")?) .await }); d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await }); @@ -265,6 +288,33 @@ pub(crate) fn register(d: &mut Dispatcher) { mod tests { use super::*; + #[test] + fn successful_external_compilation_does_not_erase_scoped_jdt_errors() { + for (failure, expected) in + [(None, JavaBuildStatus::WithErrors), (Some("compile failed"), JavaBuildStatus::Failed)] + { + let mut info = JavaLaunchInfo { + error_count: 1, + errors: vec![ide_lsp::jdtls::JavaCompileError { + path: "src/main/java/Main.java".into(), + line: 4, + column: 28, + message: "missingName cannot be resolved to a variable".into(), + }], + ..Default::default() + }; + let errors = info.errors.clone(); + apply_external_build_result(&mut info, failure.map(str::to_owned)); + assert_eq!(info.build, Some(expected)); + assert_eq!(info.error_count, 1); + assert_eq!(info.errors, errors); + assert_eq!(info.hints, failure.into_iter().collect::>()); + } + let mut info = JavaLaunchInfo::default(); + apply_external_build_result(&mut info, None); + assert_eq!(info.build, Some(JavaBuildStatus::Succeeded)); + } + #[test] fn library_scopes_filter_jdt_classpaths_and_module_paths() { use ide_core::settings::{DependencyScope, ProjectLibrary}; diff --git a/crates/ide-host/src/ops/java/cache.rs b/crates/ide-host/src/ops/java/cache.rs index 3f169c620..dc76873ac 100644 --- a/crates/ide-host/src/ops/java/cache.rs +++ b/crates/ide-host/src/ops/java/cache.rs @@ -1,6 +1,7 @@ //! Immutable Maven outputs retained in the project cache, including across close/reopen. use ide_build::compile::{self, LaunchFingerprint}; use ide_core::{CancellationToken, IdeError, IdeResult}; +use ide_run::BuildPolicy; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::fs; @@ -11,6 +12,8 @@ pub(super) struct Outputs { fingerprint: Option, pub snapshot: PathBuf, pub originals: Vec, + #[serde(skip)] + pub failure: Option, } impl Outputs { @@ -35,6 +38,12 @@ pub(super) fn has_record(root: &Path, cache: &Path, module: &Path) -> bool { [false, true].into_iter().any(|test| record_path(root, cache, module, test).is_some_and(|record| record.is_file())) } +#[derive(Default)] +pub(super) struct BuildOptions { + pub test: bool, + pub policy: BuildPolicy, +} + /// Owned by Project: running JVMs may still load superseded outputs until it drops. pub(crate) struct Session { cache: PathBuf, @@ -61,11 +70,12 @@ impl Session { root: &Path, model: &ide_build::BuildModel, module: &Path, - test: bool, + options: BuildOptions, local_repo: Option, cancel: &CancellationToken, ) -> IdeResult { self.initialize(); + let BuildOptions { test, policy } = options; let cache = &self.cache; let record = record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?; @@ -81,6 +91,7 @@ impl Session { let before = fingerprint()?; let previous = fs::read(&record).ok().and_then(|bytes| serde_json::from_slice::(&bytes).ok()); if let Some(outputs) = previous.as_ref() + && policy != BuildPolicy::Rebuild && outputs.reusable(&before, &snapshots) { tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot"); @@ -90,10 +101,27 @@ impl Session { // source still delegates to Maven to clean its old classes on the next launch. fs::create_dir_all(record.parent().unwrap())?; fs::write(&record, b"null")?; - if let Some(previous) = previous { - self.unreferenced.push(previous.snapshot); + if let Some(previous) = &previous { + self.unreferenced.push(previous.snapshot.clone()); } - let originals = compile::build_for_launch(root, model, module, test, cancel)?; + let kind = if policy == BuildPolicy::Rebuild { compile::BuildKind::Rebuild } else { compile::BuildKind::Build }; + let built = compile::build_for_launch(root, model, module, test, kind, cancel)?; + if let Some(failure) = &built.failure { + if policy != BuildPolicy::MakeAndContinueOnErrors { + return Err(IdeError::internal(failure.clone())); + } + if let Some(mut previous) = + previous.filter(|p| (0..p.originals.len()).all(|i| p.snapshot.join(i.to_string()).is_dir())) + { + previous.fingerprint = None; + previous.failure = built.failure; + let mut file = tempfile::NamedTempFile::new_in(record.parent().unwrap())?; + serde_json::to_writer(file.as_file_mut(), &previous).map_err(|e| IdeError::internal(e.to_string()))?; + file.persist(&record).map_err(|e| IdeError::from(e.error))?; + return Ok(previous); + } + } + let originals = built.class_dirs; fs::create_dir_all(&snapshots)?; let snapshot = tempfile::Builder::new().prefix("launch-").tempdir_in(&snapshots)?.keep(); // Retain every new snapshot even if copying or publishing its record fails. @@ -108,8 +136,8 @@ impl Session { if cancel.is_cancelled() { return Err(IdeError::cancelled()); } - let mut outputs = Outputs { fingerprint: None, snapshot, originals }; - if before.is_some() && before == after { + let mut outputs = Outputs { fingerprint: None, snapshot, originals, failure: built.failure }; + if outputs.failure.is_none() && before.is_some() && before == after { outputs.fingerprint = before; fs::create_dir_all(record.parent().unwrap())?; let mut file = tempfile::NamedTempFile::new_in(record.parent().unwrap())?; @@ -199,13 +227,77 @@ mod tests { ) .unwrap(); let script = root.join("mvnw"); - fs::write(&script, "#!/bin/sh\necho build >> calls\nif [ -f src/main/kotlin/broken ]; then exit 1; fi\nmkdir -p target/classes\ncp src/main/kotlin/Main.kt target/classes/Main.class\n").unwrap(); + fs::write(&script, "#!/bin/sh\necho build \"$@\" >> calls\nif [ -f src/main/kotlin/broken ]; then exit 1; fi\nmkdir -p target/classes\ncp src/main/kotlin/Main.kt target/classes/Main.class\n").unwrap(); fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap(); fs::create_dir_all(root.join("src/main/kotlin")).unwrap(); fs::write(root.join("src/main/kotlin/Main.kt"), "first").unwrap(); ide_build::load(root, &root.join("cache"), None) } + #[test] + fn rebuild_bypasses_snapshots_and_error_continuation_retains_previous_classes() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let model = fixture(root); + let cancel = CancellationToken::new(); + let mut session = Session::new(root.join("cache")); + let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap(); + let rebuilt = session + .build( + root, + &model, + root, + BuildOptions { policy: BuildPolicy::Rebuild, ..Default::default() }, + None, + &cancel, + ) + .unwrap(); + assert_ne!(first.snapshot, rebuilt.snapshot); + assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 2); + assert!(fs::read_to_string(root.join("calls")).unwrap().lines().last().unwrap().contains("clean")); + fs::write(root.join("src/main/kotlin/broken"), "broken").unwrap(); + let continued = session + .build( + root, + &model, + root, + BuildOptions { policy: BuildPolicy::MakeAndContinueOnErrors, ..Default::default() }, + None, + &cancel, + ) + .unwrap(); + assert_eq!(continued.snapshot, rebuilt.snapshot); + assert!(continued.failure.unwrap().contains("Build failed")); + assert_eq!(fs::read_to_string(continued.snapshot.join("0/Main.class")).unwrap(), "first"); + let again = session + .build( + root, + &model, + root, + BuildOptions { policy: BuildPolicy::MakeAndContinueOnErrors, ..Default::default() }, + None, + &cancel, + ) + .unwrap(); + assert_eq!(again.snapshot, rebuilt.snapshot); + assert!(again.failure.is_some()); + cancel.cancel(); + assert_eq!( + session + .build( + root, + &model, + root, + BuildOptions { policy: BuildPolicy::MakeAndContinueOnErrors, ..Default::default() }, + None, + &cancel + ) + .unwrap_err() + .kind, + ide_core::error::ErrorKind::Cancelled + ); + } + #[test] fn successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() { let dir = tempfile::tempdir().unwrap(); @@ -214,7 +306,7 @@ mod tests { let cache = root.join("cache"); let cancel = CancellationToken::new(); let mut session = Session::new(cache.clone()); - let mut build = || session.build(root, &model, root, false, None, &cancel); + let mut build = || session.build(root, &model, root, BuildOptions::default(), None, &cancel); let first = build().unwrap(); let snapshot = first.snapshot.clone(); drop(first); @@ -253,14 +345,14 @@ mod tests { let cache = root.join("cache"); let cancel = CancellationToken::new(); let mut session = Session::new(cache.clone()); - let first = session.build(root, &model, root, false, None, &cancel).unwrap(); + let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap(); assert_eq!(fs::read_to_string(first.snapshot.join("0/Main.class")).unwrap(), "first"); let source = root.join("src/main/kotlin/Main.kt"); let mtime = fs::metadata(&source).unwrap().modified().unwrap(); fs::write(&source, "other").unwrap(); // 5 bytes, same length as "first" fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap(); - let rebuilt = session.build(root, &model, root, false, None, &cancel).unwrap(); + let rebuilt = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap(); assert_ne!(rebuilt.snapshot, first.snapshot, "a same-length, same-mtime content change must rebuild"); assert_eq!(fs::read_to_string(rebuilt.snapshot.join("0/Main.class")).unwrap(), "other"); } @@ -273,9 +365,9 @@ mod tests { let cache = root.join("cache"); let cancel = CancellationToken::new(); let mut session = Session::new(cache.clone()); - let first = session.build(root, &model, root, false, None, &cancel).unwrap(); + let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap(); fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap(); - let second = session.build(root, &model, root, false, None, &cancel).unwrap(); + let second = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap(); assert!(first.snapshot.is_dir()); assert!(second.snapshot.is_dir()); // Another mode can keep a superseded snapshot alive. @@ -286,10 +378,10 @@ mod tests { assert!(first.snapshot.is_dir()); assert!(second.snapshot.is_dir()); let mut session = Session::new(cache.clone()); - let reused = session.build(root, &model, root, false, None, &cancel).unwrap(); + let reused = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap(); assert_eq!(reused.snapshot, second.snapshot); fs::write(root.join("src/main/kotlin/Main.kt"), "third version").unwrap(); - let third = session.build(root, &model, root, false, None, &cancel).unwrap(); + let third = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap(); assert!(second.snapshot.is_dir()); drop(session); assert!(!second.snapshot.exists()); @@ -316,7 +408,7 @@ mod tests { fs::write(snapshots.join("launch-file"), "keep").unwrap(); let record = cache.join("java-launch/records/deep/module/test.json"); fs::create_dir_all(record.parent().unwrap()).unwrap(); - let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new() }; + let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new(), failure: None }; fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap(); let mut session = Session::new(cache); session.initialize(); @@ -347,7 +439,7 @@ mod tests { fs::write(script, text).unwrap(); let cache = root.join("cache"); let mut session = Session::new(cache.clone()); - assert!(session.build(root, &model, root, false, None, &CancellationToken::new()).is_err()); + assert!(session.build(root, &model, root, BuildOptions::default(), None, &CancellationToken::new()).is_err()); assert_eq!(session.unreferenced.len(), 1); let snapshot = session.unreferenced[0].clone(); assert!(snapshot.join("0/Main.class").is_file()); @@ -366,7 +458,8 @@ mod tests { fs::write(script, text).unwrap(); let cache = root.join("cache"); let mut session = Session::new(cache.clone()); - let outputs = session.build(root, &model, root, false, None, &CancellationToken::new()).unwrap(); + let outputs = + session.build(root, &model, root, BuildOptions::default(), None, &CancellationToken::new()).unwrap(); assert!(outputs.fingerprint.is_none()); assert_eq!(fs::read(cache.join("java-launch/records/main.json")).unwrap(), b"null"); assert!(outputs.snapshot.join("0/Main.class").is_file()); @@ -381,14 +474,14 @@ mod tests { let cache = root.join("cache"); let cancel = CancellationToken::new(); let mut session = Session::new(cache.clone()); - let first = session.build(root, &model, root, false, None, &cancel).unwrap(); + let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap(); fs::remove_file(root.join("src/main/kotlin/Main.kt")).unwrap(); assert!(!compile::launch_has_non_java_sources(root, &model, root, false)); assert!(has_record(root, &cache, root)); - assert!(session.build(root, &model, root, false, None, &cancel).is_err()); + assert!(session.build(root, &model, root, BuildOptions::default(), None, &cancel).is_err()); assert!(has_record(root, &cache, root)); fs::write(root.join("src/main/kotlin/Main.kt"), "restored").unwrap(); - let restored = session.build(root, &model, root, false, None, &cancel).unwrap(); + let restored = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap(); assert_ne!(first.snapshot, restored.snapshot); } } diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index 021ade51a..c9603e48b 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -788,10 +788,11 @@ impl LspManager { /// 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 { + /// `full` asks jdtls for a clean build (`java/buildWorkspace(true)`, IDEA's Rebuild). + pub async fn java_build(&self, path: &Path, full: bool, 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?; + let mut v = self.jdtls_request(path, "java/buildWorkspace", json!(full), BUILD_TIMEOUT, cancel).await?; // The first build can finish the Maven import before ServiceReady is handled. let (server, run) = self.jdtls(path, cancel).await?; let changed = cancellable_wait(cancel, self.java_configure_compiler(&server, &run)).await??; @@ -799,7 +800,7 @@ impl LspManager { let recheck = v.as_i64() == Some(2) && compiler_defaults(&server.effective_config()).as_object().is_some_and(|o| !o.is_empty()); if changed || recheck { - v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT, cancel).await?; + v = self.jdtls_request(path, "java/buildWorkspace", json!(full), BUILD_TIMEOUT, cancel).await?; } Ok(JavaBuildStatus::from_code(v.as_i64().unwrap_or(0))) } @@ -842,10 +843,11 @@ impl LspManager { module: &Path, test: bool, build: bool, + full_build: bool, build_lock_held: bool, cancel: &CancellationToken, ) -> IdeResult { - let build = if build { Some(self.java_build(module, cancel).await?) } else { None }; + let build = if build { Some(self.java_build(module, full_build, cancel).await?) } else { None }; if build == Some(JavaBuildStatus::Cancelled) { return Err(IdeError::cancelled()); } @@ -875,8 +877,8 @@ impl LspManager { }; (cp, java_home) }; - let (errors, error_count, scoped) = match build { - Some(JavaBuildStatus::WithErrors | JavaBuildStatus::Failed) => { + let (errors, error_count, scoped) = match (build, build_lock_held) { + (Some(JavaBuildStatus::WithErrors | JavaBuildStatus::Failed), _) | (None, true) => { let scope_module = module.to_path_buf(); let cp = cp.clone(); let scope = tokio::task::spawn_blocking(move || LaunchScope::new(&scope_module, test, cp.as_ref())) @@ -888,7 +890,9 @@ impl LspManager { } _ => (Vec::new(), 0, false), }; - let build = if build == Some(JavaBuildStatus::WithErrors) && error_count == 0 && scoped { + let build = if build.is_none() && build_lock_held && error_count > 0 { + Some(JavaBuildStatus::WithErrors) + } else if build == Some(JavaBuildStatus::WithErrors) && error_count == 0 && scoped { Some(JavaBuildStatus::Succeeded) } else { build diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 06b730de8..4abe3d289 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -1752,7 +1752,7 @@ async fn java_source_paths_reach_initialization_live_configuration_and_builds() assert_eq!(state.launches.load(Ordering::SeqCst), 1); state.respond("java/buildWorkspace", json!(1)); assert_eq!( - h.mgr.java_build(&h.root, &Default::default()).await.unwrap(), + h.mgr.java_build(&h.root, false, &Default::default()).await.unwrap(), ide_lsp::jdtls::JavaBuildStatus::Succeeded ); assert_eq!(state.requested("java/buildWorkspace"), vec![json!(false)]); @@ -1768,6 +1768,18 @@ async fn java_source_paths_reach_initialization_live_configuration_and_builds() assert_eq!(init["initializationOptions"]["triggerFiles"], json!([uri_of(&file)])); } +#[tokio::test(flavor = "multi_thread")] +async fn java_build_asks_for_a_full_build_when_rebuilding() { + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + let state = h.launcher.state("jdtls"); + state.respond("java/buildWorkspace", json!(1)); + assert_eq!( + h.mgr.java_build(&h.root, true, &Default::default()).await.unwrap(), + ide_lsp::jdtls::JavaBuildStatus::Succeeded + ); + assert_eq!(state.requested("java/buildWorkspace"), vec![json!(true)]); +} + #[tokio::test(flavor = "multi_thread")] async fn unmanaged_java_run_imports_sources_without_an_open_editor() { let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); @@ -1780,7 +1792,7 @@ async fn unmanaged_java_run_imports_sources_without_an_open_editor() { let state = h.launcher.state("jdtls"); state.respond("java/buildWorkspace", json!(1)); assert_eq!( - h.mgr.java_build(&h.root, &Default::default()).await.unwrap(), + h.mgr.java_build(&h.root, false, &Default::default()).await.unwrap(), ide_lsp::jdtls::JavaBuildStatus::Succeeded ); assert_eq!(state.requested("initialize")[0]["initializationOptions"]["triggerFiles"], json!([uri_of(&file)])); @@ -1798,7 +1810,7 @@ async fn java_launch_serializes_the_classpath_with_the_build_lock() { // A launch with a build re-takes the lock after java_build has released it. let info = tokio::time::timeout( Duration::from_secs(30), - h.mgr.java_launch(&h.root, false, true, false, &Default::default()), + h.mgr.java_launch(&h.root, false, true, false, false, &Default::default()), ) .await .expect("java_launch deadlocked on the build lock") @@ -1808,10 +1820,13 @@ async fn java_launch_serializes_the_classpath_with_the_build_lock() { // The delegated external build holds the lock while java_launch resolves the // classpath; java_launch must not re-take it, or the launch would deadlock. let guard = h.mgr.java_external_build(&h.root, &Default::default()).await.unwrap(); - tokio::time::timeout(Duration::from_secs(30), h.mgr.java_launch(&h.root, false, false, true, &Default::default())) - .await - .expect("java_launch deadlocked on the delegated external build's lock") - .unwrap(); + tokio::time::timeout( + Duration::from_secs(30), + h.mgr.java_launch(&h.root, false, false, false, true, &Default::default()), + ) + .await + .expect("java_launch deadlocked on the delegated external build's lock") + .unwrap(); drop(guard); let commands: Vec<_> = state .requested("workspace/executeCommand") @@ -1858,7 +1873,7 @@ async fn java_launch_scopes_build_status_and_does_not_wait_for_unrelated_errors( eventually(|| diagnostics_for(&h, &file) == 1).await; let info = tokio::time::timeout( Duration::from_secs(1), - h.mgr.java_launch(&web, test, true, false, &Default::default()), + h.mgr.java_launch(&web, test, true, false, false, &Default::default()), ) .await .expect("an already-published error must not incur the diagnostics wait") @@ -1874,6 +1889,55 @@ async fn java_launch_scopes_build_status_and_does_not_wait_for_unrelated_errors( } } +#[tokio::test(flavor = "multi_thread")] +async fn delegated_java_launch_retains_scoped_errors_without_a_jdt_build() { + use ide_lsp::jdtls::JavaBuildStatus; + + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + h.open("Main.java", "class Main {}"); + h.wait_status("jdtls", ServerStatus::Ready).await; + let state = h.launcher.state("jdtls"); + let web = h.root.join("web"); + state.respond( + "workspace/executeCommand", + json!({"classpaths": [web.join("target/classes"), h.root.join("service/target/classes")]}), + ); + for (path, test, errors) in [ + ("web/src/main/java/Main.java", false, 1), + ("web/src/test/java/MainTest.java", false, 0), + ("web/src/test/java/MainTest.java", true, 1), + ("service/src/main/java/Service.java", false, 1), + ("service/src/test/java/ServiceTest.java", true, 0), + ("other/src/main/java/Other.java", false, 0), + ] { + let file = h.root.join(path); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + std::fs::write(&file, "System.out.println(missingName);").unwrap(); + let diag = json!({"range": {"start":{"line":0,"character":19},"end":{"line":0,"character":30}}, + "severity": 1, "message": "missingName cannot be resolved to a variable"}); + state + .peer() + .notify("textDocument/publishDiagnostics", json!({"uri": uri_of(&file), "diagnostics": [diag]})) + .unwrap(); + eventually(|| diagnostics_for(&h, &file) == 1).await; + let guard = h.mgr.java_external_build(&web, &Default::default()).await.unwrap(); + let info = h.mgr.java_launch(&web, test, false, false, true, &Default::default()).await.unwrap(); + drop(guard); + assert_eq!(info.error_count, errors, "{path}, test={test}"); + assert_eq!(info.build, (errors > 0).then_some(JavaBuildStatus::WithErrors)); + if errors > 0 { + assert_eq!(info.errors[0].message, "missingName cannot be resolved to a variable"); + } + state + .peer() + .notify("textDocument/publishDiagnostics", json!({"uri": uri_of(&file), "diagnostics": []})) + .unwrap(); + eventually(|| diagnostics_for(&h, &file) == 2).await; + h.events.lock().clear(); + } + assert!(state.requested("java/buildWorkspace").is_empty()); +} + #[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())], &[]); diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 6846f1060..8df943d60 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -156,7 +156,7 @@ async fn java_searches_skip_indexed_sources_but_keep_exclusions_outside_scope() let (mgr, _) = manager(&root, LanguageRegistry::from_configs(vec![java])); let doc = DocId::next(); mgr.did_open(doc, &path, &LanguageId::new("java"), 1, source); - mgr.java_build(&path, &Default::default()).await.unwrap(); + mgr.java_build(&path, false, &Default::default()).await.unwrap(); let uri = ide_lsp::path_to_uri_string; let covered = retry("imported source scope", Duration::from_secs(120), || async { mgr.jdtls_command( diff --git a/crates/ide-run/src/config.rs b/crates/ide-run/src/config.rs index 60616c60a..cf40ffc93 100644 --- a/crates/ide-run/src/config.rs +++ b/crates/ide-run/src/config.rs @@ -30,6 +30,7 @@ //! Configurations not stored as project files live in the same format in the //! workspace-local, git-ignored `run.local.toml`. +use crate::BuildPolicy; use crate::{BeforeLaunchTask, RunConfig, RunExtras}; use ide_core::{IdeError, IdeResult, project_dir}; use serde::Deserialize; @@ -100,6 +101,8 @@ struct FileConfig { output_file: Option, #[serde(default, alias = "beforeLaunch")] before_launch: Vec, + #[serde(alias = "buildPolicy")] + build_policy: Option, #[serde(default)] compound: Vec, #[serde(default, alias = "showOnStdout")] @@ -171,6 +174,7 @@ fn convert(configs: Vec, local: bool) -> Vec { stdin_file: non_empty(c.stdin_file), output_file: non_empty(c.output_file), before_launch: c.before_launch.into_iter().filter(|t| !t.kind.is_empty()).collect(), + build_policy: c.build_policy, compound: c.compound, show_on_stdout: c.show_on_stdout, show_on_stderr: c.show_on_stderr, @@ -269,6 +273,9 @@ fn to_table(c: &RunConfig) -> Table { } t.insert("before-launch", value(tasks)); } + if let Some(policy) = x.build_policy { + t.insert("build-policy", value(policy.key())); + } if !x.compound.is_empty() { t.insert("compound", value(x.compound.iter().collect::())); } diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index f54bbc46f..6b3845db4 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -56,6 +56,37 @@ pub struct RunConfig { pub extras: RunExtras, } +/// How a Java launch builds the project before starting (IDEA's Before launch › Build). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum BuildPolicy { + /// Build the way this project's backend does it: incremental when possible. + #[default] + Automatic, + /// Incremental build (IDEA's "Build"/Make). + Make, + /// Clean the output, then build (IDEA's Rebuild). + Rebuild, + /// Do not build; still resolve the module, JDK and classpath. + NoBuild, + /// Incremental build; a project with compile errors may still start with its last + /// successfully compiled classes (IDEA's "Build, no error check"). + MakeAndContinueOnErrors, +} + +impl BuildPolicy { + /// The spelling used in `run.toml` and the run configuration dialog. + pub fn key(self) -> &'static str { + match self { + Self::Automatic => "automatic", + Self::Make => "make", + Self::Rebuild => "rebuild", + Self::NoBuild => "noBuild", + Self::MakeAndContinueOnErrors => "makeAndContinueOnErrors", + } + } +} + /// A step run before a configuration launches; a failing step aborts the launch. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -115,6 +146,10 @@ pub struct RunExtras { #[serde(default, skip_serializing_if = "Vec::is_empty")] #[specta(optional)] pub before_launch: Vec, + /// Before launch build for JVM launches; unset keeps the project's default (incremental). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[specta(optional)] + pub build_policy: Option, /// Compound configurations: ids of the configurations started together. #[serde(default, skip_serializing_if = "Vec::is_empty")] #[specta(optional)] @@ -160,6 +195,7 @@ impl Default for RunExtras { stdin_file: None, output_file: None, before_launch: Vec::new(), + build_policy: None, compound: Vec::new(), show_on_stdout: false, show_on_stderr: false, diff --git a/crates/ide-run/tests/config.rs b/crates/ide-run/tests/config.rs index 7ba18d86e..ef565b4f0 100644 --- a/crates/ide-run/tests/config.rs +++ b/crates/ide-run/tests/config.rs @@ -262,3 +262,27 @@ cwd = "sibling" assert_eq!(loaded[0].cwd.as_deref(), Some(std::path::Path::new("sibling"))); } } + +#[test] +fn java_config_without_build_policy_loads_and_saves_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let path = user_configs_path(dir.path()); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + r#"[[configurations]] +id = "user:main" +name = "Main" +kind = "application" +command = "java" +args = ["-cp", "${classpath}", "demo.Main"] +"#, + ) + .unwrap(); + let configs = load_user_configs(dir.path()).unwrap(); + assert_eq!(configs.len(), 1); + assert_eq!(configs[0].extras.build_policy, None); + save_user_configs(dir.path(), &configs).unwrap(); + assert!(!fs::read_to_string(path).unwrap().contains("build-policy")); + assert_eq!(load_user_configs(dir.path()).unwrap(), configs); +} diff --git a/src-tauri/src/commands/java.rs b/src-tauri/src/commands/java.rs index cc148d4fc..1cebd1316 100644 --- a/src-tauri/src/commands/java.rs +++ b/src-tauri/src/commands/java.rs @@ -5,11 +5,12 @@ use ide_core::{IdeResult, RequestId}; #[allow(unused_imports)] pub use ide_host::ops::java::*; use ide_lsp::jdtls::JavaLaunchInfo; +use ide_run::BuildPolicy; use std::path::PathBuf; use tauri::{State, Window}; /// Cancellable with `cancel_request(request)`. -/// Builds the workspace (when `build`), then resolves the runtime (or test) +/// Builds according to `policy`, then resolves the runtime (or test) /// classpath and the JDK of the module at `module` (absolute, or relative to the project root). /// A module with Kotlin / Groovy / Scala sources is built by its build system, whose class directories come first. #[tauri::command] @@ -19,10 +20,10 @@ pub async fn java_resolve_launch( state: State<'_, AppState>, module: PathBuf, test: bool, - build: bool, + policy: BuildPolicy, request: RequestId, ) -> IdeResult { - ide_host::ops::java::java_resolve_launch(&state.host, window.label(), module, test, build, request).await + ide_host::ops::java::java_resolve_launch(&state.host, window.label(), module, test, policy, request).await } /// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand). diff --git a/src/bindings.ts b/src/bindings.ts index 412365a3e..7ab772183 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -772,11 +772,11 @@ export const commands = { buildRun: (kind: BuildKind, file: string | null, cmake: string | null, request: RequestId, clear: string[], onEvent: Channel) => typedError(__TAURI_INVOKE("build_run", { kind, file, cmake, request, clear, onEvent })), /** * Cancellable with `cancel_request(request)`. - * Builds the workspace (when `build`), then resolves the runtime (or test) + * Builds according to `policy`, then resolves the runtime (or test) * classpath and the JDK of the module at `module` (absolute, or relative to the project root). * A module with Kotlin / Groovy / Scala sources is built by its build system, whose class directories come first. */ - javaResolveLaunch: (module: string, test: boolean, build: boolean, request: RequestId) => typedError(__TAURI_INVOKE("java_resolve_launch", { module, test, build, request })), + javaResolveLaunch: (module: string, test: boolean, policy: BuildPolicy, request: RequestId) => typedError(__TAURI_INVOKE("java_resolve_launch", { module, test, policy, request })), /** Port of java-debug's DAP server inside the jdtls serving `module` (started on demand). */ javaDebugServer: (module: string) => typedError(__TAURI_INVOKE("java_debug_server", { module })), /** Incremental build of the jdtls workspace serving `module` (e.g. before HotSwap). */ @@ -2131,6 +2131,22 @@ export type BuildModel = { gradle?: GradleModel[], }; +/** How a Java launch builds the project before starting (IDEA's Before launch › Build). */ +export type BuildPolicy = +/** Build the way this project's backend does it: incremental when possible. */ +"automatic" | +/** Incremental build (IDEA's "Build"/Make). */ +"make" | +/** Clean the output, then build (IDEA's Rebuild). */ +"rebuild" | +/** Do not build; still resolve the module, JDK and classpath. */ +"noBuild" | +/** + * Incremental build; a project with compile errors may still start with its last + * successfully compiled classes (IDEA's "Build, no error check"). + */ +"makeAndContinueOnErrors"; + export type BuildResult = { system: BuildSystem, title: string, @@ -7038,6 +7054,8 @@ export type RunExtras_Deserialize = { /** "Save console output to file" (overwritten on each run). */ outputFile?: string | null, beforeLaunch?: BeforeLaunchTask[], + /** Before launch build for JVM launches; unset keeps the project's default (incremental). */ + buildPolicy?: BuildPolicy | null, /** Compound configurations: ids of the configurations started together. */ compound?: string[], showOnStdout?: boolean, @@ -7080,6 +7098,8 @@ export type RunExtras_Serialize = { /** "Save console output to file" (overwritten on each run). */ outputFile?: string | null, beforeLaunch?: BeforeLaunchTask[], + /** Before launch build for JVM launches; unset keeps the project's default (incremental). */ + buildPolicy?: BuildPolicy | null, /** Compound configurations: ids of the configurations started together. */ compound?: string[], showOnStdout?: boolean, diff --git a/src/features/debugger/__tests__/actions.test.ts b/src/features/debugger/__tests__/actions.test.ts index 679715b4f..e9d9dd08d 100644 --- a/src/features/debugger/__tests__/actions.test.ts +++ b/src/features/debugger/__tests__/actions.test.ts @@ -40,6 +40,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => { return { ...real, commands: { ...real.commands, ...cmds } }; }); +const saveAll = vi.hoisted(() => vi.fn(async () => undefined)); const editor = vi.hoisted(() => ({ current: null as null | { ctx: { path: string; language: string }; view: { state: unknown } }, })); @@ -55,7 +56,7 @@ vi.mock("@/workbench/contributions", async (importOriginal) => { ...real, maybeEditorService: () => ({ openFile: async (path: string, o?: { focus?: boolean }) => void opened.push({ path, focus: o?.focus }), - saveAll: async () => undefined, + saveAll, }), }; }); @@ -438,3 +439,26 @@ it("stops the waiting Python container if attaching fails", async () => { }); expect(ipc.runStop).toHaveBeenCalledWith(9); }); + +it("reports Java Debug save failures and does not start the adapter", async () => { + const notify = vi.fn(); + provideNotificationService({ notify }); + useProject.setState({ project: { root: "/p" } as never }); + saveAll.mockRejectedValueOnce(new Error("save failed")); + await executeAction("debug.startConfig", { + id: "main", + name: "Main", + kind: "application", + command: "java", + args: ["a.Main"], + cwd: null, + module: "", + env: {}, + detected: false, + isTest: false, + debugAdapter: "java-debug", + }); + expect(ipc.debugStart).not.toHaveBeenCalled(); + expect(notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error", body: "save failed" })); + useProject.setState({ project: null }); +}); diff --git a/src/features/debugger/__tests__/java.test.ts b/src/features/debugger/__tests__/java.test.ts index 1f6801231..dfa91e832 100644 --- a/src/features/debugger/__tests__/java.test.ts +++ b/src/features/debugger/__tests__/java.test.ts @@ -78,7 +78,7 @@ describe("Java debugging (java-debug through jdtls)", () => { expect(isJavaDebuggable({ ...app, kind: "maven" })).toBe(false); const l = await javaLaunchConfig(app, "/p"); expect(invoked).toEqual([ - ["javaResolveLaunch", ["api", false, true, expect.any(Number)]], + ["javaResolveLaunch", ["api", false, "automatic", expect.any(Number)]], ["javaDebugServer", ["api"]], ]); expect(l).toMatchObject({ adapter: "java-debug", request: "launch", cwd: "/p/api", args: [], env: { A: "1" } }); @@ -178,7 +178,7 @@ it.each(["sibling", "/outside/modules"])("debugs from %s with the main class mod for (const module of ["api", undefined]) { invoked.length = 0; const launch = await javaLaunchConfig({ ...app, module, cwd }, "/p"); - expect(invoked).toContainEqual(["javaResolveLaunch", ["api", false, true, expect.any(Number)]]); + expect(invoked).toContainEqual(["javaResolveLaunch", ["api", false, "automatic", expect.any(Number)]]); expect(invoked).toContainEqual(["javaDebugServer", ["api"]]); expect(launch.cwd).toBe(cwd.startsWith("/") ? cwd : `/p/${cwd}`); } @@ -208,3 +208,16 @@ describe("naming the Java project jdtls imported", () => { expect(await javaProjectName("/p")).toBe("p"); }); }); + +it("reports a save failure before starting Java Debug", async () => { + invoked.length = 0; + const saveAll = vi.fn().mockRejectedValue(new Error("save failed")); + await expect( + javaLaunchConfig(app, "/p", { + request: 1, + signal: new AbortController().signal, + saveAll, + }), + ).rejects.toThrow("save failed"); + expect(invoked).toEqual([]); +}); diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index c7d1ccddb..a87e18827 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -266,11 +266,14 @@ async function startJavaConfig(config: RunConfig, root: string, origin: RunConfi }; signal.addEventListener("abort", cancel, { once: true }); try { - await maybeEditorService() - ?.saveAll() - .catch(() => undefined); - signal.throwIfAborted(); - return await javaLaunchConfig(config, root, { request, signal, report }); + return await javaLaunchConfig(config, root, { + request, + signal, + report, + saveAll: async () => { + await maybeEditorService()?.saveAll(); + }, + }); } finally { signal.removeEventListener("abort", cancel); } diff --git a/src/features/debugger/java.ts b/src/features/debugger/java.ts index 35873c67d..37f91d5bb 100644 --- a/src/features/debugger/java.ts +++ b/src/features/debugger/java.ts @@ -15,7 +15,7 @@ import { prepareJavaLaunch, type JavaPreparation, } from "@/platform/javaLaunch"; -import { notifications } from "@/workbench/contributions"; +import { maybeEditorService, notifications } from "@/workbench/contributions"; export const JAVA_ADAPTER = "java-debug"; export const KOTLIN_ADAPTER = "kotlin-debug-adapter"; @@ -119,6 +119,9 @@ export async function javaLaunchConfig( preparation ?? { request: nextRequestId(), signal: new AbortController().signal, + saveAll: async () => { + await maybeEditorService()?.saveAll(); + }, }, ); if (info.errorCount > 0 && !preparation?.report) diff --git a/src/features/run/ConfigForm.tsx b/src/features/run/ConfigForm.tsx index 51ef2ebe4..61989a47c 100644 --- a/src/features/run/ConfigForm.tsx +++ b/src/features/run/ConfigForm.tsx @@ -3,10 +3,10 @@ * common ones, the options added through "Modify options" (⌥M) and the * Before launch list. */ -import { parseJavaArgs } from "@/platform/javaLaunch"; +import { isJvmApplication, parseJavaArgs } from "@/platform/javaLaunch"; import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react"; import clsx from "clsx"; -import { isTauri, type BeforeLaunchTask, type RunConfig } from "@/platform/ipc"; +import { isTauri, type BeforeLaunchTask, type BuildPolicy, type RunConfig } from "@/platform/ipc"; import { useWorkflowSettings } from "@/platform/workflowSettings"; import { relativePath, useProject } from "@/platform/project"; import { isRemoteWindow } from "@/platform/remote"; @@ -42,7 +42,9 @@ import { parseEnv, type ConfigErrors, } from "./configs"; +import { gradleContainsModule } from "./gradleRun"; import { EnvDialog } from "./EnvDialog"; +import { useBuildModel } from "@/platform/buildModel"; import { withTarget } from "./remote"; import { COMPOUND, formParams, terminalApplies, withParams } from "./types"; import { TomcatSettings } from "./TomcatSettings"; @@ -76,12 +78,19 @@ export type OptionId = | "showOnStdout" | "showOnStderr" | "beforeLaunch" + | "buildPolicy" | "openToolWindow" | "focusToolWindow" | "isTest" | "emulateTerminal"; -const VALUE_OPTIONS: ReadonlySet = new Set(["stdinFile", "envFiles", "outputFile", "beforeLaunch"]); +const VALUE_OPTIONS: ReadonlySet = new Set([ + "stdinFile", + "envFiles", + "outputFile", + "beforeLaunch", + "buildPolicy", +]); const OPTION_LABELS: Record = { allowParallel: "Allow multiple instances", @@ -91,6 +100,7 @@ const OPTION_LABELS: Record = { showOnStdout: "Show console when a message is printed to stdout", showOnStderr: "Show console when a message is printed to stderr", beforeLaunch: "Add before launch task", + buildPolicy: "Build", openToolWindow: "Open run/debug tool window when started", focusToolWindow: "Focus run/debug tool window when started", isTest: "Show results in the test runner", @@ -101,7 +111,11 @@ const OPTION_GROUPS: ReadonlyArray<{ label: string; options: OptionId[]; process { label: "Operating System", options: ["allowParallel", "stdinFile", "envFiles", "emulateTerminal"], process: true }, { label: "Logs", options: ["outputFile", "showOnStdout", "showOnStderr"], process: true }, { label: "Test", options: ["isTest"], process: true }, - { label: "Before Launch", options: ["beforeLaunch", "openToolWindow", "focusToolWindow"], process: false }, + { + label: "Before Launch", + options: ["buildPolicy", "beforeLaunch", "openToolWindow", "focusToolWindow"], + process: false, + }, ]; /** Whether the option currently has a (non-default) value. */ @@ -121,6 +135,8 @@ export function optionSet(config: RunConfig, id: OptionId, openByDefault: boolea return !!config.showOnStderr; case "beforeLaunch": return !!config.beforeLaunch?.length; + case "buildPolicy": + return (config.buildPolicy ?? "automatic") !== "automatic"; case "openToolWindow": return config.openToolWindow ?? openByDefault; case "focusToolWindow": @@ -145,6 +161,8 @@ export function optionPatch(id: OptionId, on: boolean): Partial { return { outputFile: null }; case "beforeLaunch": return { beforeLaunch: [] }; + case "buildPolicy": + return { buildPolicy: on ? "automatic" : null }; case "showOnStdout": return { showOnStdout: on }; case "showOnStderr": @@ -160,6 +178,14 @@ export function optionPatch(id: OptionId, on: boolean): Partial { } } +const BUILD_POLICY_OPTIONS: SelectOption[] = [ + { value: "automatic", label: "Automatic (incremental)" }, + { value: "make", label: "Build (Make)" }, + { value: "rebuild", label: "Rebuild" }, + { value: "noBuild", label: "No build" }, + { value: "makeAndContinueOnErrors", label: "Build, no error check" }, +]; + export interface ConfigFormProps { config: RunConfig; /** Every configuration of the dialog (compound members, before launch targets, name checks). */ @@ -222,6 +248,17 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi const tx = useTx(); const txTemplate = useTxTemplate(); const root = useProject((s) => s.project?.root ?? null); + const gradle = useBuildModel((s) => s.model?.gradle); + const runUsing = useProject((s) => s.settings?.languages?.java?.gradle?.runUsing); + const delegated = + isJvmApplication(config) && + runUsing !== "ide" && + gradle?.some((g) => config.module == null || gradleContainsModule(g, root ?? "", config.module)); + const buildPolicyOptions = BUILD_POLICY_OPTIONS.filter((option) => { + if (config.kind === "gradle" || delegated) + return option.value !== "noBuild" && option.value !== "makeAndContinueOnErrors"; + return config.kind !== "maven" || option.value !== "noBuild"; + }); const [shown, setShown] = useState>(() => new Set()); const [argsText, setArgsText] = useState(null); const [envText, setEnvText] = useState(null); @@ -239,6 +276,7 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi } const compound = config.kind === COMPOUND; + const buildPolicyApplies = (c: RunConfig) => isJvmApplication(c) || c.kind === "maven" || c.kind === "gradle"; // Compound and debug-only types (Remote JVM Debug) start no process of their own. const noProcess = compound || !!type?.debugOnly; const params = compound ? {} : formParams(config, type); @@ -275,7 +313,9 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi ...g.options .filter( (id) => - (!compound || id === "beforeLaunch") && (id !== "emulateTerminal" || terminal || !!config.emulateTerminal), + (!compound || id === "beforeLaunch") && + (id !== "emulateTerminal" || terminal || !!config.emulateTerminal) && + (id !== "buildPolicy" || buildPolicyApplies(config)), ) .map((id): MenuItem => ({ label: OPTION_LABELS[id], @@ -629,6 +669,17 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi )} + {isShown("buildPolicy") && buildPolicyApplies(config) && ( + +