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 <[email protected]>
Цей коміт міститься в:
misswellтаmisswell authored and GitHub committed 2026-11-09 03:18:13 +08:00
1 джерело 43bcd66b2b
коміт 9f9a2d8cf0
41 files changed
+868 -136

No files matched your search

+1 -1
Переглянути файл
@@ -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<String, String> = std::env::vars()
.map(|(key, value)| (key.to_ascii_uppercase(), value))
.filter(|(key, _)| {
+38 -24
Переглянути файл
@@ -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<BuildPlan> {
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<BuildPlan> {
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<PathBuf>,
pub failure: Option<String>,
}
/// 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<Vec<PathBuf>> {
let plan = launch_plan(root, model, module, test)?;
) -> IdeResult<LaunchBuild> {
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<String> = 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<PathBuf> {
@@ -1309,13 +1314,17 @@ mod tests {
write(root, "app/pom.xml", "<project/>");
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
);
}
+61 -11
Переглянути файл
@@ -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<JavaLaunchInfo> {
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<JavaLaunchInfo> {
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<String>) {
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::<Vec<_>>());
}
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};
+113 -20
Переглянути файл
@@ -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<LaunchFingerprint>,
pub snapshot: PathBuf,
pub originals: Vec<PathBuf>,
#[serde(skip)]
pub failure: Option<String>,
}
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<PathBuf>,
cancel: &CancellationToken,
) -> IdeResult<Outputs> {
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::<Outputs>(&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);
}
}
+11 -7
Переглянути файл
@@ -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<JavaBuildStatus> {
/// `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<JavaBuildStatus> {
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<JavaLaunchInfo> {
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
+72 -8
Переглянути файл
@@ -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())], &[]);
+1 -1
Переглянути файл
@@ -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(
+7
Переглянути файл
@@ -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<String>,
#[serde(default, alias = "beforeLaunch")]
before_launch: Vec<BeforeLaunchTask>,
#[serde(alias = "buildPolicy")]
build_policy: Option<BuildPolicy>,
#[serde(default)]
compound: Vec<String>,
#[serde(default, alias = "showOnStdout")]
@@ -171,6 +174,7 @@ fn convert(configs: Vec<FileConfig>, local: bool) -> Vec<RunConfig> {
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::<Array>()));
}
+36
Переглянути файл
@@ -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<BeforeLaunchTask>,
/// 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<BuildPolicy>,
/// 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,
+24
Переглянути файл
@@ -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);
}
+4 -3
Переглянути файл
@@ -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<JavaLaunchInfo> {
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).
+22 -2
Переглянути файл
@@ -772,11 +772,11 @@ export const commands = {
buildRun: (kind: BuildKind, file: string | null, cmake: string | null, request: RequestId, clear: string[], onEvent: Channel<BuildEvent>) => typedError<BuildResult, IdeError>(__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<JavaLaunchInfo, IdeError>(__TAURI_INVOKE("java_resolve_launch", { module, test, build, request })),
javaResolveLaunch: (module: string, test: boolean, policy: BuildPolicy, request: RequestId) => typedError<JavaLaunchInfo, IdeError>(__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<number, IdeError>(__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,
+25 -1
Переглянути файл
@@ -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 });
});
+15 -2
Переглянути файл
@@ -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([]);
});
+8 -5
Переглянути файл
@@ -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);
}
+4 -1
Переглянути файл
@@ -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)
+56 -5
Переглянути файл
@@ -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<OptionId> = new Set(["stdinFile", "envFiles", "outputFile", "beforeLaunch"]);
const VALUE_OPTIONS: ReadonlySet<OptionId> = new Set([
"stdinFile",
"envFiles",
"outputFile",
"beforeLaunch",
"buildPolicy",
]);
const OPTION_LABELS: Record<OptionId, string> = {
allowParallel: "Allow multiple instances",
@@ -91,6 +100,7 @@ const OPTION_LABELS: Record<OptionId, string> = {
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<RunConfig> {
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<RunConfig> {
}
}
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<ReadonlySet<OptionId>>(() => new Set());
const [argsText, setArgsText] = useState<string | null>(null);
const [envText, setEnvText] = useState<string | null>(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) && (
<Field label="Build" hint="Before launch build for this Java configuration">
<Select
value={config.buildPolicy ?? "automatic"}
options={buildPolicyOptions}
onChange={(v) => update({ buildPolicy: v === "automatic" ? null : (v as BuildPolicy) })}
aria-label="Build"
/>
</Field>
)}
{isShown("beforeLaunch") && (
<Field label="Before launch" error={errors.beforeLaunch} top>
<div className={styles.taskList} role="list" aria-label={tx("Before launch tasks")}>
+2 -2
Переглянути файл
@@ -131,7 +131,7 @@ const boot = {
describe("Spring Boot application updates", () => {
it("processes resources and publishes them to the resolved runtime output", async () => {
await updateTomcat(boot, "resources");
expect(mocks.resolve).toHaveBeenCalledWith("app", false, false, 1);
expect(mocks.resolve).toHaveBeenCalledWith("app", false, "noBuild", 1);
expect(mocks.runBuild).toHaveBeenCalledWith({ id: "build" });
expect(mocks.publish).toHaveBeenCalledWith(boot.config, ["/p/app/bin/main"], "a.Main");
expect(mocks.update).not.toHaveBeenCalled();
@@ -139,7 +139,7 @@ describe("Spring Boot application updates", () => {
});
it("compiles and invokes the existing scoped HotSwap action in debug", async () => {
await updateTomcat(boot, "classes");
expect(mocks.resolve).toHaveBeenCalledWith("app", false, true, 1);
expect(mocks.resolve).toHaveBeenCalledWith("app", false, "make", 1);
expect(mocks.action).toHaveBeenCalledWith("debug.reloadChangedClasses", { configId: "boot" });
});
it("explains compiled classes require restart without a debugger", async () => {
+6 -1
Переглянути файл
@@ -43,7 +43,12 @@ async function updateSpringBoot(config: RunConfig, mode: UpdateMode, debugging:
const parsed = parseJavaArgs(config.args);
if (!parsed.mainClass) throw new Error(tx("The Spring Boot configuration has no main class"));
const info = await call(
commands.javaResolveLaunch(module, parsed.classpath === "test", mode === "classes", nextRequestId()),
commands.javaResolveLaunch(
module,
parsed.classpath === "test",
mode === "classes" ? "make" : "noBuild",
nextRequestId(),
),
);
if (info.build === "failed" || info.build === "withErrors" || info.build === "cancelled")
throw new Error(tx("Application update build failed"));
+4 -2
Переглянути файл
@@ -2,9 +2,9 @@
* Before launch tasks (IDEA's "Before launch" list): run in order before a
* configuration starts; the first failure aborts the launch.
*/
import { useProject } from "@/platform/project";
import { buildCMake, isCMakeBuild } from "./cmake";
import { dotnetBuildConfig } from "./dotnetTypes";
import { useProject } from "@/platform/project";
import { useBuildPresentation } from "@/platform/projectPresentation";
import { buildTask } from "./buildTask";
import { moduleTaskError } from "./configs";
@@ -88,7 +88,9 @@ async function runTask(
return (await runToEnd(dotnetBuildConfig(config), chain, show)) ? null : "Build failed";
case "build": {
if (target) return buildCMake(target, show === true);
return buildTask(BUILD_ACTION, { activate: false });
const error = await buildTask(BUILD_ACTION, { activate: false });
if (error) return error;
return null;
}
case "buildModule": {
const error = moduleTaskError(config, target);
+44
Переглянути файл
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import type { RunConfig } from "@/platform/ipc";
import { applyBuildPolicyToCommand } from "./buildCommand";
const cfg = (extra: Partial<RunConfig>): RunConfig => ({
id: "m",
name: "m",
kind: "maven",
command: "mvn",
args: ["-B", "spring-boot:run"],
cwd: null,
env: {},
detected: false,
isTest: false,
debugAdapter: null,
...extra,
});
describe("build policy for build-tool commands", () => {
it("puts clean first for Maven and Gradle Rebuild", () => {
expect(applyBuildPolicyToCommand({ ...cfg({ buildPolicy: "rebuild" }), kind: "maven" }).args).toEqual([
"clean",
"-B",
"spring-boot:run",
]);
expect(
applyBuildPolicyToCommand({ ...cfg({ kind: "gradle", args: ["bootRun"] }), buildPolicy: "rebuild" }).args,
).toEqual(["clean", "bootRun"]);
});
it("lets Maven continue on compile errors only when asked", () => {
expect(applyBuildPolicyToCommand({ ...cfg({ buildPolicy: "makeAndContinueOnErrors" }) }).args).toContain(
"-Dmaven.compiler.failOnError=false",
);
expect(applyBuildPolicyToCommand({ ...cfg({ buildPolicy: "make" }) }).args).not.toContain(
"-Dmaven.compiler.failOnError=false",
);
});
it("leaves Automatic and NoBuild commands alone", () => {
for (const buildPolicy of ["automatic", "noBuild"] as const)
expect(applyBuildPolicyToCommand(cfg({ buildPolicy }))).toEqual(cfg({ buildPolicy }));
});
});
+22
Переглянути файл
@@ -0,0 +1,22 @@
/**
* Build policies for configurations whose command is itself a build tool (Maven,
* Gradle). Application/Spring Boot configurations build through `prepareJavaLaunch`
* instead; this only rewrites the command of an already chosen build-tool launch.
*/
import type { RunConfig } from "@/platform/ipc";
const MAVEN = new Set(["maven"]);
const GRADLE = new Set(["gradle"]);
export function applyBuildPolicyToCommand(config: RunConfig): RunConfig {
const rebuild = config.buildPolicy === "rebuild";
if (rebuild && !config.args.includes("clean") && (MAVEN.has(config.kind) || GRADLE.has(config.kind))) {
// Maven and Gradle both accept the clean task before their other goals.
return { ...config, args: ["clean", ...config.args] };
}
if (config.buildPolicy === "makeAndContinueOnErrors" && MAVEN.has(config.kind)) {
const flag = "-Dmaven.compiler.failOnError=false";
return config.args.includes(flag) ? config : { ...config, args: [...config.args, flag] };
}
return config;
}
+12
Переглянути файл
@@ -265,3 +265,15 @@ describe("delegation across independent builds", () => {
expect(run!.args.at(-1)).toBe(":app:openideaRun");
});
});
it("uses the same Rebuild preparation for delegated Run and Debug", () => {
const app = {
...applicationConfig({ className: "a.Main" }, "app/src/main/java/a/Main.java", []),
buildPolicy: "rebuild" as const,
};
const run = delegatedGradleConfig(app, "app", ctx)!;
expect(run.args[0]).toBe("clean");
const debug = jdwpLaunch(run)!;
expect(debug.args.filter((a) => a === "clean")).toHaveLength(1);
expect(debug.args).toContain(DELEGATED_DEBUG);
});
+13 -6
Переглянути файл
@@ -10,8 +10,10 @@ import TESTS_SCRIPT from "./gradleTests.gradle?raw";
import BOOT_SCRIPT from "./gradleBoot.gradle?raw";
import { useBuildModel } from "@/platform/buildModel";
import { call, commands, isTauri, type GradleModel, type RunConfig } from "@/platform/ipc";
import { findJavaModule, isJvmApplication, parseJavaArgs } from "@/platform/javaLaunch";
import { isJvmApplication, parseJavaArgs, prepareJavaModule } from "@/platform/javaLaunch";
import { useProject } from "@/platform/project";
import { maybeEditorService } from "@/workbench/contributions";
import { applyBuildPolicyToCommand } from "./buildCommand";
/** System property that marks a delegated run (read by `jdwpLaunch` to add the debug flag). */
export const DELEGATED_MAIN = "-Dopenidea.run.main=";
@@ -21,6 +23,10 @@ const TASK = "openideaRun";
const isAbsolute = (p: string) => p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p);
const absolute = (root: string, p: string) => (isAbsolute(p) ? p : p ? `${root}/${p}` : root);
export function gradleContainsModule(gradle: GradleModel, root: string, module: string): boolean {
return gradle.projects.some((p) => absolute(root, p.dir) === absolute(root, module));
}
export interface DelegationContext {
/** Project root. */
root: string;
@@ -74,7 +80,7 @@ export function delegatedGradleConfig(config: RunConfig, module: string, ctx: De
// A Gradle run from here on: not an Application again (it would be delegated twice), and
// without the Application's params (Gradle's own VM-options init script would repeat them).
const { params: _params, ...rest } = config;
return {
return applyBuildPolicyToCommand({
...rest,
kind: "gradle",
command: ctx.gradle.command,
@@ -83,7 +89,7 @@ export function delegatedGradleConfig(config: RunConfig, module: string, ctx: De
// The Gradle JVM's JAVA_HOME first; the configuration's own environment wins.
env: { ...ctx.gradle.env, ...config.env },
module: module,
};
});
}
/**
@@ -148,8 +154,9 @@ export async function gradleDelegation(config: RunConfig): Promise<RunConfig | n
if (!project?.root || settings?.languages?.java?.gradle?.runUsing === "ide") return null;
const { model, gradleOffline } = useBuildModel.getState();
if (!model?.gradle?.length) return null;
const module = config.module ?? (await findJavaModule(config));
if (module === null) return null;
const module = await prepareJavaModule(config, async () => {
await maybeEditorService()?.saveAll();
});
const dir = absolute(project.root, module);
// A composite can report a sibling's module; prefer a linked root that contains it.
const ownerDepth = (g: GradleModel) => {
@@ -158,7 +165,7 @@ export async function gradleDelegation(config: RunConfig): Promise<RunConfig | n
return path === root || path.startsWith(`${root}/`) ? root.split("/").length : 0;
};
const gradle = model.gradle
.filter((g) => g.projects.some((p) => absolute(project.root, p.dir) === dir))
.filter((g) => gradleContainsModule(g, project.root, module))
.sort((a, b) => ownerDepth(b) - ownerDepth(a))[0];
if (!gradle) return null;
const ctx = {
+12 -1
Переглянути файл
@@ -137,7 +137,18 @@ async function debugConfig(config: RunConfig, chain: readonly string[] = [], sho
return;
}
// "Build and run using: Gradle": the JavaExec JVM gets the agent, the debugger attaches to it.
const delegated = getAction("debug.attach") ? await gradleDelegation(config) : null;
let delegated: RunConfig | null;
try {
delegated = getAction("debug.attach") ? await gradleDelegation(config) : null;
} catch (error) {
notifications().notify({
group: "run",
level: "error",
title: `Cannot run '${config.name}'`,
body: error instanceof Error ? error.message : String(error),
});
return;
}
if (delegated) {
await debugWithAgent(delegated, show);
return;
+6
Переглянути файл
@@ -366,6 +366,12 @@ describe("debugging build tool configurations", () => {
expect(out.env.MAVEN_OPTS).toMatch(AGENT);
});
it("honours Rebuild on a Maven debug command", () => {
const out = jdwpLaunch(cfg({ id: "r", kind: "maven", args: ["spring-boot:run"], buildPolicy: "rebuild" }))!;
expect(out.args[0]).toBe("clean");
expect(out.args.at(-1)).toMatch(/^-Dspring-boot\.run\.jvmArguments=-agentlib:jdwp=/);
});
it("keeps plain Maven builds out of the debugger unless set to the Java debugger", () => {
expect(jdwpLaunch(cfg({ id: "p", kind: "maven", args: ["exec:exec"] }))).toBeNull();
const chosen = jdwpLaunch(cfg({ id: "p", kind: "maven", args: ["package"], debugAdapter: "java-debug" }))!;
+2
Переглянути файл
@@ -11,6 +11,7 @@
import { executeAction } from "@/platform/actions";
import type { RunConfig } from "@/platform/ipc";
import { isJvmApplication, JDWP_AGENT, jdwpListeningPort } from "@/platform/javaLaunch";
import { applyBuildPolicyToCommand } from "./buildCommand";
import { startRun, useRun } from "./store";
import { DELEGATED_DEBUG, DELEGATED_MAIN } from "./gradleRun";
@@ -38,6 +39,7 @@ function withProperty(args: readonly string[], key: string, value: string): stri
/** The configuration with a waiting JDWP agent, or null when it starts no debuggable JVM. */
export function jdwpLaunch(config: RunConfig): RunConfig | null {
config = applyBuildPolicyToCommand(config);
if (config.kind === "tomcat") return { ...config, params: { ...config.params, tomcatDebug: "true" } };
const args = config.args;
// A single source file (`java Hi.java`): the agent goes before the file. Compiled first
+70 -6
Переглянути файл
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { IpcError, type RunConfig } from "@/platform/ipc";
import { findJavaModule, prepareJavaLaunch } from "@/platform/javaLaunch";
import { useProject } from "@/platform/project";
import { findJavaModule, prepareJavaLaunch, prepareJavaModule } from "@/platform/javaLaunch";
import { _resetI18nForTests, setLocale } from "@/platform/i18n";
import { provideNotificationService } from "@/workbench/contributions";
import { applicationConfig } from "./application";
@@ -72,7 +73,7 @@ afterEach(() => {
it.each(["sibling", "/outside/modules"])("runs from %s using the stored module's classpath", async (cwd) => {
const session = await startRun({ ...config, module: "app", cwd });
expect(session?.status).toBe("running");
expect(ipc.javaResolveLaunch).toHaveBeenCalledWith("app", false, true, expect.any(Number));
expect(ipc.javaResolveLaunch).toHaveBeenCalledWith("app", false, "automatic", expect.any(Number));
expect(ipc.runStart).toHaveBeenCalledWith(
expect.objectContaining({ cwd, args: ["-cp", "/p/app/target/classes", "a.Main"] }),
expect.anything(),
@@ -80,10 +81,59 @@ it.each(["sibling", "/outside/modules"])("runs from %s using the stored module's
expect(ipc.findFiles).not.toHaveBeenCalled();
});
const launchInfo = (build: "succeeded" | "failed" | "withErrors") => ({
build,
projectRoot: "/p",
classPaths: ["/p/app/target/classes"],
modulePaths: [],
javaHome: null,
javaExec: "java",
shortenCommandLine: null,
errors: build === "succeeded" ? [] : [{ path: "/p/app/src/Main.java", line: 1, column: 1, message: "boom" }],
errorCount: build === "succeeded" ? 0 : 1,
hints: [],
});
it.each(["noBuild", "rebuild"] as const)("passes the %s build policy to the resolver", async (buildPolicy) => {
await startRun({ ...config, module: "app", buildPolicy });
expect(ipc.javaResolveLaunch).toHaveBeenCalledWith("app", false, buildPolicy, expect.any(Number));
});
it("always applies the launch policy after a Before launch Build Project", async () => {
useProject.setState({ project: { root: "/p" } as never });
await startRun({ ...config, module: "app" });
expect(ipc.javaResolveLaunch).toHaveBeenCalledWith("app", false, "automatic", expect.any(Number));
useProject.setState({ project: null });
});
it("does not start a configuration whose build failed", async () => {
ipc.javaResolveLaunch.mockResolvedValueOnce({ status: "ok", data: launchInfo("failed") });
const session = await startRun({ ...config, module: "app", buildPolicy: "make" });
expect(session?.status).toBe("failed");
expect(ipc.runStart).not.toHaveBeenCalled();
});
it("starts with the last compiled classes for makeAndContinueOnErrors", async () => {
ipc.javaResolveLaunch.mockResolvedValueOnce({ status: "ok", data: launchInfo("withErrors") });
const session = await startRun({ ...config, module: "app", buildPolicy: "makeAndContinueOnErrors" });
expect(session?.status).toBe("running");
expect(ipc.runStart).toHaveBeenCalled();
});
it.each([undefined, "automatic", "make", "rebuild"] as const)(
"does not launch scoped compilation-error bytecode under %s",
async (buildPolicy) => {
ipc.javaResolveLaunch.mockResolvedValueOnce({ status: "ok", data: launchInfo("withErrors") });
const session = await startRun({ ...config, module: "app", buildPolicy });
expect(session?.status).toBe("failed");
expect(ipc.runStart).not.toHaveBeenCalled();
},
);
it("finds the source module for an old saved configuration without a module", async () => {
await startRun({ ...config, cwd: "/outside/modules" });
expect(ipc.findFiles).toHaveBeenCalledWith("Main.java", 1000);
expect(ipc.javaResolveLaunch).toHaveBeenCalledWith("app", false, true, expect.any(Number));
expect(ipc.javaResolveLaunch).toHaveBeenCalledWith("app", false, "automatic", expect.any(Number));
expect(ipc.runStart).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/outside/modules" }), expect.anything());
});
@@ -96,7 +146,7 @@ it.each([["-cp", "lib/*"], []])("colours Spring Boot runs without classpath reso
expect.objectContaining({ args: ["-Dspring.output.ansi.enabled=always", ...args] }),
expect.anything(),
);
expect(ipc.javaResolveLaunch).not.toHaveBeenCalled();
expect(ipc.javaResolveLaunch).toHaveBeenCalledWith("app", false, "automatic", expect.any(Number));
expect(boot.args).toBe(args);
expect(args).not.toContain("-Dspring.output.ansi.enabled=always");
});
@@ -105,14 +155,14 @@ it.each(["never", "detect", "always"])("preserves an explicit Spring Boot consol
const args = [`-Dspring.output.ansi.enabled=${value}`, "-cp", "lib/*", "a.Main"];
await startRun({ ...config, kind: "spring-boot", args });
expect(ipc.runStart).toHaveBeenCalledWith(expect.objectContaining({ args }), expect.anything());
expect(ipc.javaResolveLaunch).not.toHaveBeenCalled();
expect(ipc.javaResolveLaunch).toHaveBeenCalledWith("app", false, "automatic", expect.any(Number));
});
it("keeps plain Java runs with an explicit classpath free of Spring Boot options", async () => {
const args = ["-cp", "lib/*", "a.Main"];
await startRun({ ...config, args });
expect(ipc.runStart).toHaveBeenCalledWith(expect.objectContaining({ args }), expect.anything());
expect(ipc.javaResolveLaunch).not.toHaveBeenCalled();
expect(ipc.javaResolveLaunch).toHaveBeenCalledWith("app", false, "automatic", expect.any(Number));
});
it("adds the Spring Boot console option once when resolving the module classpath", async () => {
@@ -197,3 +247,17 @@ it("translates import recovery guidance for a root Spring Boot configuration", a
),
).rejects.toThrow("Java 模块 '.' 尚未导入");
});
it("does not resolve or launch when saving fails", async () => {
const saveAll = vi.fn().mockRejectedValue(new Error("save failed"));
await expect(
prepareJavaLaunch(config, {
request: 1,
signal: new AbortController().signal,
saveAll,
}),
).rejects.toThrow("save failed");
await expect(prepareJavaModule(config, saveAll)).rejects.toThrow("save failed");
expect(ipc.javaResolveLaunch).not.toHaveBeenCalled();
expect(ipc.runStart).not.toHaveBeenCalled();
});
+61
Переглянути файл
@@ -1,3 +1,4 @@
import { useBuildModel } from "@/platform/buildModel";
import { useBuildPresentation } from "@/platform/projectPresentation";
import { useProject } from "@/platform/project";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -116,6 +117,22 @@ describe("configuration types", () => {
expect(formParams(saved)).toMatchObject({ mainClass: "a.Main", module: "." });
});
it("sets the before launch build policy of a Java configuration", async () => {
const type = typeFor("application")!;
const config = withParams(newConfigOfType(type, []), {
...newConfigOfType(type, []).params,
mainClass: "a.Main",
});
const changed = vi.fn();
render(<ConfigForm config={config} all={[config]} type={type} errors={{}} onChange={changed} modifyRequest={0} />);
fireEvent.click(screen.getByRole("button", { name: /Modify options/ }));
fireEvent.click(await screen.findByText("Build"));
expect(screen.getByRole("button", { name: "Build" }).textContent).toContain("Automatic");
fireEvent.click(screen.getByRole("button", { name: "Build" }));
fireEvent.click(await screen.findByRole("option", { name: "Rebuild" }));
expect(changed).toHaveBeenCalledWith(expect.objectContaining({ buildPolicy: "rebuild" }));
});
it.each(["spring-boot", "tomcat"])("edits shared update policies in the %s form", async (kind) => {
const type = typeFor(kind)!;
const original = newConfigOfType(type, []);
@@ -700,6 +717,16 @@ describe("before launch tasks", () => {
useBuildPresentation.setState({ modules: new Map() });
disposeBuild();
});
it("shows the build policy for a Maven configuration", async () => {
const type = typeFor("maven")!;
const config = newConfigOfType(type, []);
const changed = vi.fn();
render(<ConfigForm config={config} all={[config]} type={type} errors={{}} onChange={changed} modifyRequest={0} />);
fireEvent.click(screen.getByRole("button", { name: /Modify options/ }));
fireEvent.click(await screen.findByText("Build"));
expect(screen.getByRole("button", { name: "Build" })).toBeTruthy();
});
});
describe("environment variables", () => {
@@ -866,3 +893,37 @@ describe("new Gradle wrapper default", () => {
},
);
});
it.each(["maven", "gradle", "delegated", "absolute", "legacy"])(
"offers only supported build policies for %s",
(tool) => {
const kind = tool === "maven" || tool === "gradle" ? tool : "application";
const config = cfg("main", { kind, module: tool === "legacy" ? null : "app", buildPolicy: "make" });
const previous = useProject.getState();
if (kind === "application") {
useProject.setState({ project: { root: "/work" } as never, settings: null });
useBuildModel.setState({
model: { gradle: [{ projects: [{ dir: tool === "absolute" ? "/work/app" : "app" }] }] } as never,
});
}
try {
render(
<ConfigForm
config={config}
all={[config]}
type={typeFor(kind)}
errors={{}}
onChange={vi.fn()}
modifyRequest={0}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Build" }));
expect(screen.queryByRole("option", { name: "No build" })).toBeNull();
expect(screen.queryByRole("option", { name: "Build, no error check" }) !== null).toBe(tool === "maven");
expect(screen.getByRole("option", { name: "Rebuild" })).toBeTruthy();
} finally {
useBuildModel.setState({ model: null });
useProject.setState(previous);
}
},
);
+10 -3
Переглянути файл
@@ -19,9 +19,9 @@ import {
type TestCaseReport,
} from "@/platform/ipc";
import {
consoleVmArgs,
findJavaModule,
isJvmApplication,
JavaBuildFailedError,
parseJavaArgs,
prepareJavaLaunch,
} from "@/platform/javaLaunch";
@@ -30,6 +30,7 @@ import { useProject } from "@/platform/project";
import { executeAction } from "@/platform/actions";
import { workflowSettings } from "@/platform/workflowSettings";
import { maybeEditorService, notifications, toolWindowService, useDebugRunSessions } from "@/workbench/contributions";
import { applyBuildPolicyToCommand } from "./buildCommand";
import { ConsoleModel } from "./console";
import { jvmFrameTarget } from "./links";
import { confirmRerun, confirmTerminate } from "./dialogs";
@@ -851,6 +852,8 @@ export async function startRun(
};
try {
let launch = options.launch ?? config;
// A Maven/Gradle configuration's own command is its build; Rebuild adds clean.
if (!options.launch) launch = applyBuildPolicyToCommand(launch);
// "Build and run using: Gradle": the module's Gradle build compiles and launches it.
const delegated = isJvmApplication(launch) ? await gradleDelegation(launch) : null;
if (runtimes.get(sessionId)?.generation !== generation) return getSession(sessionId) ?? null;
@@ -931,7 +934,8 @@ async function resolveJvmApplication(sessionId: string, config: RunConfig): Prom
say(`No main class in '${config.name}': expected java [options] -cp \${classpath} <main class> [args]\n`, "stderr");
return null;
}
if (!parsed.classpath) return { ...config, args: [...consoleVmArgs(config), ...config.args] };
// Always prepare, exactly like Debug: even an explicit classpath needs the module, the
// project JDK and the selected build, and both modes must share the same result.
const rt = runtime(sessionId)!;
const request = { id: nextRequestId(), controller: new AbortController() };
rt.resolveRequest = request;
@@ -940,6 +944,9 @@ async function resolveJvmApplication(sessionId: string, config: RunConfig): Prom
request: request.id,
signal: request.controller.signal,
report: say,
saveAll: async () => {
await maybeEditorService()?.saveAll();
},
});
// No installed JDK is new enough for the module: offer to download one.
const jdkHint = info.hints.find((h) => downloadJdkActions(h).length);
@@ -959,7 +966,7 @@ async function resolveJvmApplication(sessionId: string, config: RunConfig): Prom
if (runtimes.get(sessionId) === rt && rt.resolveRequest === request) patchSession(sessionId, { stopped: true });
return null;
}
say(`Cannot resolve the classpath: ${message}\n`, "stderr");
say(e instanceof JavaBuildFailedError ? `${message}\n` : `Cannot resolve the classpath: ${message}\n`, "stderr");
// jdtls can't start without a newer JDK: "Download JDK 25".
const actions = downloadJdkActions(message);
notifications().notify({
+1 -1
Переглянути файл
@@ -278,7 +278,7 @@ describe("updating a running Tomcat deployment", () => {
it("compiles the selected WAR module and copies its outputs instead of rebuilding the WAR", async () => {
const c = config({ deployment: "artifact", artifact: "maven:web/pom.xml" });
expect(await syncTomcatOutputs(c, false)).toBe(true);
expect(commands.javaResolveLaunch).toHaveBeenCalledWith("web", false, true, expect.anything());
expect(commands.javaResolveLaunch).toHaveBeenCalledWith("web", false, "make", expect.anything());
expect(commands.runTomcatSync).toHaveBeenCalledWith(c, "web", ["/p/web/target/classes", "/m2/lib.jar"], false);
});
+3 -1
Переглянути файл
@@ -161,7 +161,9 @@ export async function syncTomcatOutputs(config: RunConfig, resourcesOnly: boolea
const artifacts = await call(commands.runTomcatArtifacts());
const artifact = p.artifact ? artifacts.find((a) => a.id === p.artifact) : artifacts[0];
if (!artifact) return false;
const info = await call(commands.javaResolveLaunch(artifact.module, false, true, nextRequestId())).catch(() => null);
const info = await call(commands.javaResolveLaunch(artifact.module, false, "make", nextRequestId())).catch(
() => null,
);
if (!info) return false;
if (info.build === "failed" || info.build === "withErrors" || info.build === "cancelled")
throw new Error(tx("Application update build failed"));
+30 -1
Переглянути файл
@@ -243,6 +243,34 @@ describe("catalogs", () => {
const COMPLETE = ["zh"];
/** Written in another script, so a text equal to its English source was not translated. */
const OWN_SCRIPT = new Set(["zh", "ko"]);
/** Product names intentionally kept as written in every locale. */
const SAME_AS_ENGLISH = new Set([
"Docker",
"Shell",
"Java",
"Python",
"C/C++",
"C#",
"Go",
"Rust",
"Kotlin",
"PHP",
"SQL",
"JavaScript",
"TypeScript",
"HTML",
"CSS",
"JSON",
"YAML",
"XML",
"Maven",
"Gradle",
"npm",
"pnpm",
"Yarn",
"Cargo",
"CMake",
]);
const translated = SUPPORTED_LOCALES.filter((locale) => locale !== "en").sort();
it("exist for exactly the supported locales", () => {
@@ -259,7 +287,8 @@ describe("catalogs", () => {
it("translate source text into text different from English, in locales with another script", () => {
for (const [locale, catalog] of Object.entries(texts))
if (OWN_SCRIPT.has(locale))
for (const [source, text] of Object.entries(catalog)) expect(text, `${locale}:${source}`).not.toBe(source);
for (const [source, text] of Object.entries(catalog))
if (!SAME_AS_ENGLISH.has(source)) expect(text, `${locale}:${source}`).not.toBe(source);
});
it("keep the placeholders of source-text templates", () => {
+43 -7
Переглянути файл
@@ -4,7 +4,15 @@
* `${classpath}` (or `${testClasspath}`) and the `java` launcher are resolved
* through jdtls when the configuration starts.
*/
import { call, commands, IpcError, type JavaLaunchInfo, type RunConfig, type RequestId } from "@/platform/ipc";
import {
call,
commands,
IpcError,
type BuildPolicy,
type JavaLaunchInfo,
type RunConfig,
type RequestId,
} from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { useBuildPresentation } from "@/platform/projectPresentation";
@@ -170,25 +178,47 @@ export async function resolveJavaModule(config: RunConfig): Promise<string> {
return module;
}
/** Saves before resolving the module, for direct and delegated Run and Debug. */
export async function prepareJavaModule(config: RunConfig, saveAll?: () => Promise<void>, signal?: AbortSignal) {
signal?.throwIfAborted();
await saveAll?.();
signal?.throwIfAborted();
const module = await resolveJavaModule(config);
signal?.throwIfAborted();
return module;
}
/** Shared, cancellable build and classpath preparation for Run and Debug. */
export interface JavaPreparation {
request: RequestId;
signal: AbortSignal;
report?: (text: string, kind?: "system" | "stderr") => void;
/** Before launch build; unset reads the run configuration (default: automatic/incremental). */
policy?: BuildPolicy;
/** Saves open documents first, as IDEA's Before launch does. */
saveAll?: () => Promise<void>;
}
/** The build the launch would do: the configuration's policy, else incremental. */
export function buildPolicyOf(config: RunConfig): BuildPolicy {
return config.buildPolicy ?? "automatic";
}
/** A build stopped the launch: the message is ready for the console and the notification. */
export class JavaBuildFailedError extends Error {}
export async function prepareJavaLaunch(config: RunConfig, preparation: JavaPreparation) {
const { signal, report } = preparation;
const policy = preparation.policy ?? buildPolicyOf(config);
signal.throwIfAborted();
const parsed = parseJavaArgs(config.args);
if (!parsed.mainClass) throw new Error(`No main class in '${config.name}'`);
report?.(tx`Building and resolving the classpath of ${parsed.mainClass}…` + "\n");
const started = performance.now();
const module = await resolveJavaModule(config);
signal.throwIfAborted();
const module = await prepareJavaModule(config, preparation.saveAll, signal);
const moduleMs = performance.now() - started;
const info = await call(
commands.javaResolveLaunch(module, parsed.classpath === "test", true, preparation.request),
commands.javaResolveLaunch(module, parsed.classpath === "test", policy, preparation.request),
).catch((error: unknown) => {
if (
error instanceof IpcError &&
@@ -204,11 +234,17 @@ export async function prepareJavaLaunch(config: RunConfig, preparation: JavaPrep
});
signal.throwIfAborted();
console.info("Java launch preparation", { module, moduleMs, resolveMs: performance.now() - started - moduleMs });
if (info.build === "withErrors")
report?.(tx("Build completed with errors; launching with the last compiled classes.") + "\n", "stderr");
if (info.build === "failed") report?.(tx("Build failed; launching with the last compiled classes.") + "\n", "stderr");
const errors = formatCompileErrors(info);
if (errors) report?.(errors, "stderr");
// IDEA's default: a failed build (or one with compile errors) does not start the program;
// only "Build, no error check" continues with the last compiled classes.
if ((info.build === "failed" || info.build === "withErrors") && policy !== "makeAndContinueOnErrors") {
const reason =
info.build === "failed"
? tx`Build failed; '${config.name}' was not started.`
: tx`Build has compile errors; '${config.name}' was not started.`;
throw new JavaBuildFailedError(reason);
}
if (parsed.classpath && !info.classPaths.length)
throw new Error(tx("The classpath is empty: is the module imported by the Java language server?"));
return { module, info, resolved: resolveJavaCommand(config, info) };
+8
Переглянути файл
@@ -540,5 +540,13 @@
"dev,integration": "dev,integration",
"Comma-separated profiles (-P), combined with Maven tool window choices; !id excludes a profile. A run configuration’s own -P replaces these profiles.": "Comma-separated profiles (-P), combined with Maven tool window choices; !id excludes a profile. A run configuration’s own -P replaces these profiles.",
"Active profiles": "Active profiles",
"Build failed; '{name}' was not started.": "Build failed; '{name}' was not started.",
"Build has compile errors; '{name}' was not started.": "Build has compile errors; '{name}' was not started.",
"Automatic (incremental)": "Automatic (incremental)",
"Build (Make)": "Build (Make)",
"Rebuild": "Rebuild",
"No build": "No build",
"Build, no error check": "Build, no error check",
"Before launch build for this Java configuration": "Before launch build for this Java configuration",
"the bundle of {gemfile} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`": "the bundle of {gemfile} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`"
}
-2
Переглянути файл
@@ -3488,8 +3488,6 @@
"What should the agent do?": "¿Qué debe hacer el agente?",
"New worktree": "Nuevo árbol de trabajo",
"Building and resolving the classpath of {name}…": "Compilando y resolviendo el classpath de {name}…",
"Build completed with errors; launching with the last compiled classes.": "La compilación terminó con errores; iniciando con las últimas clases compiladas.",
"Build failed; launching with the last compiled classes.": "La compilación falló; iniciando con las últimas clases compiladas.",
"The classpath is empty: is the module imported by the Java language server?": "El classpath está vacío: ¿el servidor de lenguaje Java importó el módulo?",
"Launch cancelled.": "Inicio cancelado.",
"Starting debugger for {name}…": "Iniciando depurador para {name}…",
-2
Переглянути файл
@@ -3354,8 +3354,6 @@
"What should the agent do?": "Que doit faire l'agent ?",
"New worktree": "Nouveau worktree",
"Building and resolving the classpath of {name}…": "Compilation et résolution du classpath de {name}…",
"Build completed with errors; launching with the last compiled classes.": "Build terminé avec des erreurs ; lancement avec les dernières classes compilées.",
"Build failed; launching with the last compiled classes.": "Échec du build ; lancement avec les dernières classes compilées.",
"The classpath is empty: is the module imported by the Java language server?": "Le classpath est vide : le module est-il importé par le serveur de langage Java ?",
"Launch cancelled.": "Lancement annulé.",
"Starting debugger for {name}…": "Démarrage du débogueur pour {name}…",
-2
Переглянути файл
@@ -3543,8 +3543,6 @@
"What should the agent do?": "에이전트가 무엇을 해야 하나요?",
"New worktree": "새 워크트리",
"Building and resolving the classpath of {name}…": "{name}의 클래스 경로를 빌드하고 해결하는 중…",
"Build completed with errors; launching with the last compiled classes.": "빌드가 오류와 함께 완료되었습니다. 마지막으로 컴파일된 클래스로 실행합니다.",
"Build failed; launching with the last compiled classes.": "빌드에 실패했습니다. 마지막으로 컴파일된 클래스로 실행합니다.",
"The classpath is empty: is the module imported by the Java language server?": "클래스 경로가 비어 있습니다. Java 언어 서버에서 모듈을 가져왔습니까?",
"Launch cancelled.": "실행이 취소되었습니다.",
"Starting debugger for {name}…": "{name}의 디버거 시작 중…",
-2
Переглянути файл
@@ -3460,8 +3460,6 @@
"What should the agent do?": "Co agent powinien zrobić?",
"New worktree": "Nowe worktree",
"Building and resolving the classpath of {name}…": "Kompilowanie i ustalanie ścieżki klas (classpath) dla {name}…",
"Build completed with errors; launching with the last compiled classes.": "Kompilacja zakończona z błędami; uruchamianie z ostatnio skompilowanymi klasami.",
"Build failed; launching with the last compiled classes.": "Kompilacja nie powiodła się; uruchamianie z ostatnio skompilowanymi klasami.",
"The classpath is empty: is the module imported by the Java language server?": "Ścieżka klas jest pusta: czy moduł został zaimportowany przez serwer językowy Java?",
"Launch cancelled.": "Uruchomienie anulowane.",
"Starting debugger for {name}…": "Uruchamianie debugera dla {name}…",
-2
Переглянути файл
@@ -3461,8 +3461,6 @@
"What should the agent do?": "O que o agente deve fazer?",
"New worktree": "Novo worktree",
"Building and resolving the classpath of {name}…": "Compilando e resolvendo o classpath de {name}…",
"Build completed with errors; launching with the last compiled classes.": "Build concluído com erros; iniciando com as últimas classes compiladas.",
"Build failed; launching with the last compiled classes.": "Falha no build; iniciando com as últimas classes compiladas.",
"The classpath is empty: is the module imported by the Java language server?": "O classpath está vazio: o módulo foi importado pelo servidor de linguagem Java?",
"Launch cancelled.": "Inicialização cancelada.",
"Starting debugger for {name}…": "Iniciando o depurador para {name}…",
+21 -4
Переглянути файл
@@ -1581,7 +1581,6 @@
"Open in New Window": "在新窗口中打开",
"Open in Terminal": "在终端中打开",
"Open in editor": "在编辑器中打开",
"Open main menu with a lone Alt press": "单独按下 Alt 打开主菜单",
"Open moved files in editor": "在编辑器中打开已移动的文件",
"Open new tabs at the end": "在末尾打开新标签页",
"Open on GitHub": "在 GitHub 上打开",
@@ -3768,8 +3767,6 @@
"What should the agent do?": "让 agent 做点什么?",
"New worktree": "新 worktree",
"Building and resolving the classpath of {name}…": "正在构建并解析 {name} 的类路径…",
"Build completed with errors; launching with the last compiled classes.": "构建完成但存在错误;使用上次编译的类启动。",
"Build failed; launching with the last compiled classes.": "构建失败;使用上次编译的类启动。",
"The classpath is empty: is the module imported by the Java language server?": "类路径为空:Java 语言服务器是否已导入此模块?",
"Launch cancelled.": "启动已取消。",
"Starting debugger for {name}…": "正在启动 {name} 的调试器…",
@@ -5088,7 +5085,6 @@
"Only one of these runs at a time. Automatic uses the first one available: {order}.": "这些服务器同一时间只运行一个。自动模式使用第一个可用的:{order}。",
"Configure the settings related to version control used in your project": "配置项目中使用的版本控制相关设置",
"Use non-modal commit interface": "使用非模态提交界面",
"When off, Alt key combinations still work but pressing Alt alone does not focus the main menu": "关闭后,Alt 组合键仍然可用,但单独按下 Alt 不会聚焦主菜单",
"When off, Local Changes and Stash are tabs of the Git tool window and Commit opens a dialog.": "关闭时,本地更改和贮藏作为 Git 工具窗口的标签页显示,提交时打开对话框。",
"Clear initial commit message": "清除初始提交消息",
"Show options before Update": "更新前显示选项",
@@ -6876,5 +6872,26 @@
"Select Maven toolchains.xml": "选择 Maven toolchains.xml",
"dev,integration": "开发,集成",
"Comma-separated profiles (-P), combined with Maven tool window choices; !id excludes a profile. A run configuration’s own -P replaces these profiles.": "以逗号分隔的 profiles(-P),与 Maven 工具窗口中的选择合并;!id 排除指定 profile。运行配置自身的 -P 会替代这些 profiles。",
"Build failed; '{name}' was not started.": "构建失败;'{name}' 未启动。",
"Build has compile errors; '{name}' was not started.": "构建存在编译错误;'{name}' 未启动。",
"Automatic (incremental)": "自动(增量)",
"Build (Make)": "构建(Make)",
"Rebuild": "重新构建",
"No build": "不构建",
"Build, no error check": "构建,不检查错误",
"Before launch build for this Java configuration": "此 Java 配置启动前的构建方式",
"Docker": "Docker",
"Shell": "Shell",
"C/C++": "C/C++",
"Go": "Go",
"Rust": "Rust",
"Python": "Python",
"JavaScript": "JavaScript",
"Java": "Java",
"Add Service ({kind})": "添加服务({kind})",
"Others": "其他",
"Servers": "服务器",
"Open main menu with a lone Alt press": "单独按下 Alt 打开主菜单",
"When off, Alt key combinations still work but pressing Alt alone does not focus the main menu": "关闭后,Alt 组合键仍然可用,但单独按下 Alt 不会聚焦主菜单",
"the bundle of {gemfile} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`": "{gemfile} 的 bundle 中没有 `debug` gem。请在 Gemfile 中添加 `gem \"debug\"`,然后运行 `bundle install`"
}