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) && ( + +