Файли
misswellтаmisswell 9f9a2d8cf0 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]>
2026-11-09 03:18:13 +08:00

289 рядки
12 KiB
Rust

use ide_run::{RunConfig, load_user_configs, save_user_configs, user_configs_path};
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
fn sample() -> Vec<RunConfig> {
vec![
RunConfig {
id: "user:server".into(),
name: "Server \"prod\"".into(),
kind: "shell".into(),
command: "cargo".into(),
args: vec!["run".into(), "--release".into()],
cwd: Some(PathBuf::from("server").into()),
env: BTreeMap::from([("RUST_LOG".into(), "debug".into()), ("A B".into(), "x=y".into())]),
detected: false,
is_test: false,
debug_adapter: Some("codelldb".into()),
allow_parallel: true,
extras: Default::default(),
},
RunConfig {
id: "user:tests".into(),
name: "Tests".into(),
kind: "pytest".into(),
command: "pytest -q tests/".into(),
args: vec![],
cwd: None,
env: BTreeMap::new(),
detected: false,
is_test: true,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
},
]
}
#[test]
fn missing_file_is_empty() {
let dir = tempfile::tempdir().unwrap();
assert!(load_user_configs(dir.path()).unwrap().is_empty());
// Saving nothing does not create the file.
save_user_configs(dir.path(), &[]).unwrap();
assert!(!user_configs_path(dir.path()).exists());
}
#[test]
fn round_trip() {
let dir = tempfile::tempdir().unwrap();
let configs = sample();
save_user_configs(dir.path(), &configs).unwrap();
assert!(dir.path().join(".openidea/run.toml").is_file());
assert_eq!(load_user_configs(dir.path()).unwrap(), configs);
// Saving a detected config persists it as a user config.
let mut detected = configs[0].clone();
detected.detected = true;
save_user_configs(dir.path(), &[detected]).unwrap();
let loaded = load_user_configs(dir.path()).unwrap();
assert_eq!(loaded.len(), 1);
assert!(!loaded[0].detected);
}
#[test]
fn preserves_unrelated_content() {
let dir = tempfile::tempdir().unwrap();
let path = user_configs_path(dir.path());
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(
&path,
"# OpenIDEA run configurations\ndefault = \"user:server\"\n\n[[configurations]]\nid = \"old\"\ncommand = \"x\"\n\n[ui]\nshow-output = true\n",
)
.unwrap();
save_user_configs(dir.path(), &sample()).unwrap();
let text = fs::read_to_string(&path).unwrap();
assert!(text.contains("# OpenIDEA run configurations"), "{text}");
assert!(text.contains("default = \"user:server\""), "{text}");
assert!(text.contains("show-output = true"), "{text}");
assert!(!text.contains("\"old\""), "{text}");
assert_eq!(load_user_configs(dir.path()).unwrap(), sample());
save_user_configs(dir.path(), &[]).unwrap();
assert!(load_user_configs(dir.path()).unwrap().is_empty());
assert!(fs::read_to_string(&path).unwrap().contains("show-output"));
}
#[test]
fn lenient_hand_written_file() {
let dir = tempfile::tempdir().unwrap();
let path = user_configs_path(dir.path());
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(
&path,
"[[configurations]]\nname = \"Dev Server\"\ncommand = \"npm run dev\"\nisTest = false\n\n[[configurations]]\ncommand = \"make test\"\nis_test = true\ndebugAdapter = \"lldb\"\n",
)
.unwrap();
let c = load_user_configs(dir.path()).unwrap();
assert_eq!(c.len(), 2);
assert_eq!(c[0].id, "user:dev-server");
assert_eq!(c[0].kind, "shell");
assert_eq!(c[1].name, "make test");
assert!(c[1].is_test);
assert_eq!(c[1].debug_adapter.as_deref(), Some("lldb"));
}
#[test]
fn invalid_file_is_reported() {
let dir = tempfile::tempdir().unwrap();
let path = user_configs_path(dir.path());
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "[[configurations]\n").unwrap();
let err = load_user_configs(dir.path()).unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::InvalidInput);
assert!(err.message.contains("run.toml"));
}
#[test]
fn extras_round_trip_and_local_file() {
use ide_run::{BeforeLaunchTask, load_templates, save_templates};
let dir = tempfile::tempdir().unwrap();
let mut shared = sample().remove(0);
shared.extras.folder = Some("Servers".into());
shared.extras.params = BTreeMap::from([("mode".into(), "command".into())]);
shared.extras.include_system_env = false;
shared.extras.env_files = vec![".env".into(), "local.env".into()];
shared.extras.stdin_file = Some("in.txt".into());
shared.extras.output_file = Some("out.log".into());
shared.extras.before_launch = vec![
BeforeLaunchTask { kind: "build".into(), target: String::new() },
BeforeLaunchTask { kind: "run".into(), target: "user:migrate".into() },
];
shared.extras.show_on_stderr = true;
shared.extras.open_tool_window = Some(false);
shared.extras.focus_tool_window = true;
shared.extras.emulate_terminal = true;
shared.extras.stop_command = Some("docker stop server".into());
let mut local = sample().remove(1);
local.extras.local = true;
let mut compound = sample().remove(1);
compound.id = "user:all".into();
compound.name = "All".into();
compound.kind = "compound".into();
compound.command = String::new();
compound.extras.compound = vec!["user:server".into(), "user:tests".into()];
let configs = vec![shared, compound, local];
save_user_configs(dir.path(), &configs).unwrap();
assert_eq!(load_user_configs(dir.path()).unwrap(), configs);
let project = fs::read_to_string(user_configs_path(dir.path())).unwrap();
assert!(!project.contains("\"user:tests\"\n"), "{project}");
let private = fs::read_to_string(dir.path().join(".openidea/run.local.toml")).unwrap();
assert!(private.contains("id = \"user:tests\""), "{private}");
let ignore = fs::read_to_string(dir.path().join(".openidea/.gitignore")).unwrap();
assert!(ignore.lines().any(|l| l == "/run.local.toml"), "{ignore}");
// Making it a project file moves it back.
let mut moved = configs.clone();
moved[2].extras.local = false;
save_user_configs(dir.path(), &moved).unwrap();
assert_eq!(load_user_configs(dir.path()).unwrap(), moved);
assert!(!fs::read_to_string(dir.path().join(".openidea/run.local.toml")).unwrap().contains("user:tests"));
// Templates share run.toml without disturbing configurations.
let mut template = sample().remove(1);
template.id = "template:npm".into();
template.kind = "npm".into();
save_templates(dir.path(), std::slice::from_ref(&template)).unwrap();
assert_eq!(load_templates(dir.path()).unwrap(), vec![template]);
assert_eq!(load_user_configs(dir.path()).unwrap(), moved);
}
#[test]
fn jvm_options_persist_in_the_project_file() {
let dir = tempfile::tempdir().unwrap();
let vm = "-Ddemo.flag=on --add-opens java.base/java.lang=ALL-UNNAMED '-Dname=a b'";
let args =
["-Ddemo.flag=on", "--add-opens", "java.base/java.lang=ALL-UNNAMED", "-Dname=a b", "-cp", "${classpath}"];
let mut app = sample().remove(0);
app.id = "user:demo".into();
app.kind = "spring-boot".into();
app.command = "java".into();
app.args =
args.into_iter().chain(["a.DemoApplication", "--spring.profiles.active=dev"]).map(String::from).collect();
app.extras.params = BTreeMap::from([
("mainClass".into(), "a.DemoApplication".into()),
("vmOptions".into(), vm.into()),
("activeProfiles".into(), "dev".into()),
("classpath".into(), "${classpath}".into()),
]);
save_user_configs(dir.path(), std::slice::from_ref(&app)).unwrap();
let text = fs::read_to_string(user_configs_path(dir.path())).unwrap();
assert!(text.contains(&format!("vmOptions = \"{vm}\"")), "{text}");
assert_eq!(load_user_configs(dir.path()).unwrap(), vec![app.clone()]);
// A file written before the form had these fields loads with its command line only.
fs::write(
user_configs_path(dir.path()),
"[[configurations]]\nid = \"user:old\"\nname = \"Old\"\nkind = \"spring-boot\"\ncommand = \"java\"\n\
args = [\"-Xmx1g\", \"-cp\", \"${classpath}\", \"a.App\"]\n",
)
.unwrap();
let old = load_user_configs(dir.path()).unwrap();
assert_eq!(old[0].args, ["-Xmx1g", "-cp", "${classpath}", "a.App"]);
assert!(old[0].extras.params.is_empty());
// Templates keep default VM options for new configurations.
let mut template = app;
template.id = "template:spring-boot".into();
template.extras.params = BTreeMap::from([("vmOptions".into(), "-Xmx512m".into())]);
ide_run::save_templates(dir.path(), std::slice::from_ref(&template)).unwrap();
assert_eq!(ide_run::load_templates(dir.path()).unwrap()[0].extras.params["vmOptions"], "-Xmx512m");
}
#[test]
fn suppression_round_trip_and_resaving() {
use ide_run::{load_suppressed_config_ids, save_user_configs_with_removals};
let dir = tempfile::tempdir().unwrap();
assert!(load_suppressed_config_ids(dir.path()).unwrap().is_empty());
let configs = sample();
save_user_configs(dir.path(), &configs).unwrap();
// Old files without the field still load.
assert!(load_suppressed_config_ids(dir.path()).unwrap().is_empty());
let ids = vec!["npm:dev".to_owned(), "spring-boot:App".to_owned()];
save_user_configs_with_removals(dir.path(), &configs, &ids).unwrap();
assert_eq!(load_suppressed_config_ids(dir.path()).unwrap(), ids);
assert_eq!(load_user_configs(dir.path()).unwrap(), configs);
save_user_configs(dir.path(), &[]).unwrap();
assert_eq!(load_suppressed_config_ids(dir.path()).unwrap(), ids);
let mut restored = configs[0].clone();
restored.id = ids[0].clone();
restored.extras.local = true;
save_user_configs(dir.path(), &[restored]).unwrap();
assert_eq!(load_suppressed_config_ids(dir.path()).unwrap(), vec![ids[1].clone()]);
assert!(fs::read_to_string(dir.path().join(".openidea/.gitignore")).unwrap().contains("/run.local.toml"));
}
#[test]
fn java_module_is_optional_and_independent_of_working_directory() {
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}", "a.Main"]
cwd = "sibling"
"#,
)
.unwrap();
let mut configs = load_user_configs(dir.path()).unwrap();
assert_eq!(configs[0].extras.module, None);
for module in ["app", ""] {
configs[0].extras.module = Some(module.into());
save_user_configs(dir.path(), &configs).unwrap();
let loaded = load_user_configs(dir.path()).unwrap();
assert_eq!(loaded, configs);
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);
}