feat(run): Add VM options to Java run configurations (#206)

Adds Application and Spring Boot run configuration types with main class, VM options, program arguments, classpath and active profiles. Maven passes them through spring-boot.run.jvmArguments / profiles / argLine; Gradle through an init script.

Fixes #187

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
This commit is contained in:
l0ng-ai
2026-10-29 11:39:20 +08:00
committed by GitHub
parent ed7b0f88ef
commit 8ebeaa2663
15 changed files with 690 additions and 8 deletions
+15
View File
@@ -21,6 +21,18 @@ pub fn run_system_env(_host: &Host, _window: &str) -> std::collections::BTreeMap
ide_run::system_env()
}
/// Writes the Gradle init script that adds `jvm_args` to the JVMs the build forks
/// (`bootRun`, `run`, tests) and returns its path, for `--init-script`.
pub async fn run_gradle_jvm_args_script(_host: &Host, _window: &str, jvm_args: Vec<String>) -> IdeResult<String> {
tokio::task::spawn_blocking(move || {
ide_run::gradle_jvm::write_init_script(&ide_run::gradle_jvm::default_dir(), &jvm_args)
.map(|p| p.to_string_lossy().into_owned())
.map_err(|e| IdeError::internal(format!("cannot write the Gradle init script: {e}")))
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
/// `module` (relative to the project root) written since `since_ms` (Unix epoch, ms).
pub async fn run_test_reports(
@@ -42,6 +54,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("run_templates", |c| async move { run_templates(&c.host, &c.window) });
d.add("run_save_templates", |c| async move { run_save_templates(&c.host, &c.window, c.arg("templates")?) });
d.add("run_system_env", |c| async move { Ok(run_system_env(&c.host, &c.window)) });
d.add("run_gradle_jvm_args_script", |c| async move {
run_gradle_jvm_args_script(&c.host, &c.window, c.arg("jvmArgs")?).await
});
d.add("run_test_reports", |c| async move {
run_test_reports(&c.host, &c.window, c.arg("module")?, c.arg("sinceMs")?).await
});
+97
View File
@@ -0,0 +1,97 @@
//! VM options for the JVMs a Gradle build forks (`bootRun`, `run`, `test`): Gradle has no
//! command-line switch for them, so a launch passes an init script that adds them to every
//! `JavaExec` and `Test` task (`--init-script <file>`). Gradle's own JVM is left alone.
use std::path::{Path, PathBuf};
/// Groovy string literal (single-quoted, `\` and `'` escaped).
fn groovy_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('\'');
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'\'' => out.push_str("\\'"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
c => out.push(c),
}
}
out.push('\'');
out
}
/// The init script adding `jvm_args` (one argument each) to the forked JVMs.
pub fn init_script(jvm_args: &[String]) -> String {
let list = jvm_args.iter().map(|a| groovy_string(a)).collect::<Vec<_>>().join(", ");
format!(
"// Written by OpenIDEA: VM options of the run configuration.\n\
allprojects {{\n \
tasks.withType(JavaExec).configureEach {{ jvmArgs([{list}]) }}\n \
tasks.withType(Test).configureEach {{ jvmArgs([{list}]) }}\n\
}}\n"
)
}
/// Where the scripts are written.
pub fn default_dir() -> PathBuf {
std::env::temp_dir().join("openidea-gradle")
}
/// Writes the script for `jvm_args` under `dir` (named by its content, so launches share
/// it and nothing needs deleting) and returns its path.
pub fn write_init_script(dir: &Path, jvm_args: &[String]) -> std::io::Result<PathBuf> {
use std::hash::{Hash, Hasher};
let text = init_script(jvm_args);
let mut h = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut h);
let file = dir.join(format!("jvm-args-{:016x}.init.gradle", h.finish()));
if std::fs::read_to_string(&file).is_ok_and(|t| t == text) {
return Ok(file);
}
std::fs::create_dir_all(dir)?;
let tmp = file.with_extension(format!("{}.tmp", std::process::id()));
std::fs::write(&tmp, &text)?;
std::fs::rename(&tmp, &file).inspect_err(|_| {
let _ = std::fs::remove_file(&tmp);
})?;
Ok(file)
}
#[cfg(test)]
mod tests {
use super::*;
fn args(a: &[&str]) -> Vec<String> {
a.iter().map(|s| (*s).to_owned()).collect()
}
#[test]
fn adds_each_argument_to_java_exec_and_test_tasks() {
let script = init_script(&args(&["-Ddemo.flag=on", "--add-opens", "java.base/java.lang=ALL-UNNAMED"]));
let list = "['-Ddemo.flag=on', '--add-opens', 'java.base/java.lang=ALL-UNNAMED']";
assert!(script.contains(&format!("tasks.withType(JavaExec).configureEach {{ jvmArgs({list}) }}")), "{script}");
assert!(script.contains(&format!("tasks.withType(Test).configureEach {{ jvmArgs({list}) }}")), "{script}");
}
#[test]
fn escapes_quotes_backslashes_and_keeps_spaces_and_dollars() {
let script = init_script(&args(&[r"-Dpath=C:\Program Files\app", "-Dq=it's", "-Dv=${x}"]));
assert!(script.contains(r"'-Dpath=C:\\Program Files\\app'"), "{script}");
assert!(script.contains(r"'-Dq=it\'s'"), "{script}");
// Single-quoted Groovy strings don't interpolate.
assert!(script.contains("'-Dv=${x}'"), "{script}");
}
#[test]
fn writes_one_file_per_content() {
let dir = tempfile::tempdir().unwrap();
let a = write_init_script(dir.path(), &args(&["-Xmx1g"])).unwrap();
let again = write_init_script(dir.path(), &args(&["-Xmx1g"])).unwrap();
let b = write_init_script(dir.path(), &args(&["-Xmx2g"])).unwrap();
assert_eq!(a, again);
assert_ne!(a, b);
assert_eq!(std::fs::read_to_string(&a).unwrap(), init_script(&args(&["-Xmx1g"])));
assert!(a.to_string_lossy().ends_with(".init.gradle"));
}
}
+1
View File
@@ -3,6 +3,7 @@
mod config;
mod detect;
pub mod envfile;
pub mod gradle_jvm;
pub mod java_cmdline;
pub mod junit;
mod output_encoding;
+42
View File
@@ -169,3 +169,45 @@ fn extras_round_trip_and_local_file() {
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");
}
+1
View File
@@ -266,6 +266,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
run::run_list,
run::run_close_stdin,
run::run_resize,
run::run_gradle_jvm_args_script,
run::run_test_reports,
build::build_model,
build::build_sync,
+11
View File
@@ -89,6 +89,17 @@ pub fn run_system_env(state: State<'_, AppState>) -> std::collections::BTreeMap<
ide_host::ops::run::run_system_env(&state.host, "")
}
/// Writes the Gradle init script adding `jvm_args` to the JVMs the build forks; returns its path.
#[tauri::command]
#[specta::specta]
pub async fn run_gradle_jvm_args_script(
window: Window,
state: State<'_, AppState>,
jvm_args: Vec<String>,
) -> IdeResult<String> {
ide_host::ops::run::run_gradle_jvm_args_script(&state.host, window.label(), jvm_args).await
}
/// Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
/// `module` (relative to the project root) written since `since_ms` (Unix epoch, ms).
#[tauri::command]
+2
View File
@@ -515,6 +515,8 @@ export const commands = {
runCloseStdin: (id: RunId) => typedError<null, IdeError>(__TAURI_INVOKE("run_close_stdin", { id })),
/** Terminal size of a run started with "Emulate terminal in output console". */
runResize: (id: RunId, cols: number, rows: number) => typedError<null, IdeError>(__TAURI_INVOKE("run_resize", { id, cols, rows })),
/** Writes the Gradle init script adding `jvm_args` to the JVMs the build forks; returns its path. */
runGradleJvmArgsScript: (jvmArgs: string[]) => typedError<string, IdeError>(__TAURI_INVOKE("run_gradle_jvm_args_script", { jvmArgs })),
/**
* Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
* `module` (relative to the project root) written since `since_ms` (Unix epoch, ms).
@@ -75,6 +75,23 @@ describe("Java debugging (java-debug through jdtls)", () => {
});
});
it("passes the configuration's VM options to java-debug's vmArgs, before the main class", async () => {
const args = [
"-Ddemo.flag=on",
"--add-opens",
"java.base/java.lang=ALL-UNNAMED",
"-Dname=a b",
"-cp",
"${classpath}",
];
const l = await javaLaunchConfig({ ...app, args: [...args, "a.App", "--spring.profiles.active=dev"] }, "/p");
expect(JSON.parse(l.extraJson!)).toMatchObject({
mainClass: "a.App",
vmArgs: '-Ddemo.flag=on --add-opens java.base/java.lang=ALL-UNNAMED "-Dname=a b"',
args: "--spring.profiles.active=dev",
});
});
it("lets java-debug shorten a classpath too long for the OS, as Run does", async () => {
for (const how of ["jarmanifest", "argfile"]) {
shortenCommandLine = how;
+1 -1
View File
@@ -648,7 +648,7 @@ function TypeField({
spellCheck={false}
value={value}
placeholder={tx(field.placeholder)}
rows={4}
rows={field.rows ?? 4}
onChange={(e) => onChange(e.target.value)}
/>
);
+12 -4
View File
@@ -258,8 +258,11 @@ export function validateConfig(config: RunConfig, all: readonly RunConfig[] = []
export const hasErrors = (e: ConfigErrors) => Object.keys(e).length > 0;
/** Splits a command line into arguments: whitespace, '…' and "…" quoting, backslash escapes. */
export function parseArgs(input: string): string[] {
/**
* Splits a command line into arguments: whitespace, '…' and "…" quoting, backslash escapes.
* `windowsPaths`: a backslash only escapes a quote, so `C:\dir\app.jar` stays as typed.
*/
export function parseArgs(input: string, windowsPaths = false): string[] {
const out: string[] = [];
let cur = "";
let has = false;
@@ -268,13 +271,18 @@ export function parseArgs(input: string): string[] {
const ch = input[i]!;
if (quote) {
if (ch === quote) quote = null;
else if (ch === "\\" && quote === '"' && i + 1 < input.length && '"\\$`'.includes(input[i + 1]!))
else if (
ch === "\\" &&
quote === '"' &&
i + 1 < input.length &&
(windowsPaths ? '"' : '"\\$`').includes(input[i + 1]!)
)
cur += input[++i];
else cur += ch;
} else if (ch === "'" || ch === '"') {
quote = ch;
has = true;
} else if (ch === "\\" && i + 1 < input.length) {
} else if (ch === "\\" && i + 1 < input.length && (!windowsPaths || "\"'".includes(input[i + 1]!))) {
cur += input[++i];
has = true;
} else if (/\s/.test(ch)) {
+249
View File
@@ -0,0 +1,249 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RunConfig } from "@/platform/ipc";
const scripts: string[][] = [];
vi.mock("@/platform/ipc", async (orig) => {
const real = await orig<typeof import("@/platform/ipc")>();
return {
...real,
commands: {
...real.commands,
runGradleJvmArgsScript: (jvmArgs: string[]) => {
scripts.push(jvmArgs);
return Promise.resolve({ status: "ok", data: "/tmp/openidea-gradle/jvm-args-1.init.gradle" });
},
},
};
});
const { parseJavaArgs, joinJavaArgs } = await import("@/platform/javaLaunch");
const { parseArgs } = await import("./configs");
const { jdwpLaunch } = await import("./javaDebug");
const { applicationType, gradleJvmArgs, joinPropertyArgs, springBootType } = await import("./jvmTypes");
const { formParams, newConfigOfType, registerBuiltinTypes, typeFor, withParams } = await import("./types");
const REPORTER_VM = "-Ddemo.flag=on --add-opens java.base/java.lang=ALL-UNNAMED";
const cfg = (over: Partial<RunConfig>): RunConfig => ({
id: "c",
name: "c",
kind: "spring-boot",
command: "java",
args: [],
cwd: null,
env: {},
detected: false,
isTest: false,
debugAdapter: "java-debug",
...over,
});
let dispose: () => void;
beforeEach(() => {
dispose = registerBuiltinTypes();
scripts.length = 0;
});
afterEach(() => dispose());
describe("Application / Spring Boot command line", () => {
it.each([
["application", applicationType],
["spring-boot", springBootType],
] as const)("%s puts VM options before the main class and program arguments after it", (_, type) => {
const line = type.commandLine({
mainClass: "com.example.Application",
vmOptions: REPORTER_VM,
programArgs: "--server.port=8081 'two words'",
classpath: "${classpath}",
});
expect(line).toEqual({
command: "java",
args: [
"-Ddemo.flag=on",
"--add-opens",
"java.base/java.lang=ALL-UNNAMED",
"-cp",
"${classpath}",
"com.example.Application",
"--server.port=8081",
"two words",
],
});
const parsed = parseJavaArgs(line!.args);
expect(parsed.vmArgs).toEqual(["-Ddemo.flag=on", "--add-opens", "java.base/java.lang=ALL-UNNAMED"]);
expect(parsed.mainClass).toBe("com.example.Application");
expect(parsed.programArgs).toEqual(["--server.port=8081", "two words"]);
});
it("reads VM options over several lines and with quoted spaces", () => {
const line = applicationType.commandLine({
mainClass: "a.Main",
vmOptions: '-Xmx1g\n-Dapp.name="My App"\n -Dempty=""',
});
expect(line!.args.slice(0, 3)).toEqual(["-Xmx1g", "-Dapp.name=My App", "-Dempty="]);
expect(joinJavaArgs(parseJavaArgs(line!.args).vmArgs)).toBe('-Xmx1g "-Dapp.name=My App" -Dempty=');
});
it("keeps Windows paths as typed on Windows", () => {
expect(parseArgs(String.raw`-javaagent:C:\tools\agent.jar "-Dlog=C:\Program Files\x.log"`, true)).toEqual([
String.raw`-javaagent:C:\tools\agent.jar`,
String.raw`-Dlog=C:\Program Files\x.log`,
]);
expect(parseArgs(String.raw`-Dq=\"x\" "a \"b\""`, true)).toEqual(['-Dq="x"', 'a "b"']);
// Elsewhere a backslash still escapes.
expect(parseArgs(String.raw`a\ b`)).toEqual(["a b"]);
});
it("passes Spring Boot's active profiles before the program arguments", () => {
const line = springBootType.commandLine({ mainClass: "a.App", activeProfiles: "dev,local", programArgs: "--x" });
expect(line!.args).toEqual(["-cp", "${classpath}", "a.App", "--spring.profiles.active=dev,local", "--x"]);
// Application has no profiles field.
expect(applicationType.commandLine({ mainClass: "a.App", activeProfiles: "dev" })!.args).toEqual([
"-cp",
"${classpath}",
"a.App",
]);
});
it("uses the test classpath when chosen", () => {
expect(applicationType.commandLine({ mainClass: "a.T", classpath: "${testClasspath}" })!.args).toEqual([
"-cp",
"${testClasspath}",
"a.T",
]);
});
});
describe("configurations saved or detected as a raw command line", () => {
it("opens a detected Spring Boot configuration in the form", () => {
const detected = cfg({ args: ["-cp", "${classpath}", "com.example.DemoApplication"], detected: true });
expect(formParams(detected)).toEqual({
mainClass: "com.example.DemoApplication",
vmOptions: "",
programArgs: "",
classpath: "${classpath}",
});
});
it("splits an older configuration's arguments into VM options, main class, profiles and program arguments", () => {
const old = cfg({
args: ["-Xmx1g", "-Dname=a b", "-cp", "${classpath}", "a.App", "--spring.profiles.active=dev", "x y"],
});
const params = formParams(old);
expect(params).toEqual({
mainClass: "a.App",
vmOptions: "-Xmx1g '-Dname=a b'",
programArgs: "'x y'",
classpath: "${classpath}",
activeProfiles: "dev",
});
// Editing VM options keeps the rest of the command line.
const next = withParams(old, { ...params!, vmOptions: REPORTER_VM });
expect(next.args).toEqual([
"-Ddemo.flag=on",
"--add-opens",
"java.base/java.lang=ALL-UNNAMED",
"-cp",
"${classpath}",
"a.App",
"--spring.profiles.active=dev",
"x y",
]);
expect(next.params).toMatchObject({ vmOptions: REPORTER_VM });
});
it("keeps an explicit classpath and edits other command lines as a command", () => {
expect(formParams(cfg({ kind: "application", args: ["-cp", "lib/*", "a.Main"] }))).toMatchObject({
classpath: "lib/*",
});
expect(formParams(cfg({ command: "./run.sh", args: ["a.Main"] }))).toBeNull();
expect(formParams(cfg({ args: ["a.Main"] }))).toBeNull();
});
it("prefers the stored params", () => {
const c = cfg({
params: { mainClass: "a.App", vmOptions: "-Xss2m" },
args: ["-Xss2m", "-cp", "${classpath}", "a.App"],
});
expect(formParams(c)).toEqual({ mainClass: "a.App", vmOptions: "-Xss2m" });
});
});
describe("templates", () => {
it("applies the template's VM options to a new configuration", () => {
const template = cfg({ id: "template:spring-boot", params: { classpath: "${classpath}", vmOptions: "-Xmx512m" } });
const c = newConfigOfType(springBootType, [], template);
expect(c.kind).toBe("spring-boot");
expect(c.debugAdapter).toBe("java-debug");
expect(c.params).toMatchObject({ vmOptions: "-Xmx512m" });
expect(c.args).toEqual(["-Xmx512m", "-cp", "${classpath}"]);
});
});
describe("Maven / Gradle: VM options of the forked JVM", () => {
const maven = () => typeFor("maven")!;
const gradle = () => typeFor("gradle")!;
it("passes spring-boot:run's VM options and profiles to the application, not to Maven", () => {
const line = maven().commandLine({
tasks: "spring-boot:run",
vmOptions: REPORTER_VM + " '-Dname=a b'",
activeProfiles: "dev",
});
expect(line!.args).toEqual([
"spring-boot:run",
`-Dspring-boot.run.jvmArguments=-Ddemo.flag=on --add-opens java.base/java.lang=ALL-UNNAMED "-Dname=a b"`,
"-Dspring-boot.run.profiles=dev",
]);
// Debugging adds the JDWP agent to the same property.
const debug = jdwpLaunch(cfg({ kind: "maven", command: "mvn", args: line!.args }));
expect(debug!.args[1]).toMatch(/^-Dspring-boot\.run\.jvmArguments=-Ddemo\.flag=on .* -agentlib:jdwp=/);
});
it("passes test goals' VM options to Surefire and nothing for other goals", () => {
expect(maven().commandLine({ tasks: "test", vmOptions: "-Xmx1g" })!.args).toEqual(["test", "-DargLine=-Xmx1g"]);
expect(maven().commandLine({ tasks: "package", vmOptions: "-Xmx1g" })!.args).toEqual(["package"]);
const vm = maven().fields.find((f) => f.key === "vmOptions")!;
expect(vm.visible!({ tasks: "clean spring-boot:run" })).toBe(true);
expect(vm.visible!({ tasks: "package" })).toBe(false);
});
it("quotes property values the plugins split", () => {
expect(joinPropertyArgs(["-Xmx1g", "-Dp=C:\\Program Files\\x", 'say "hi"', ""])).toBe(
`-Xmx1g "-Dp=C:\\Program Files\\x" 'say "hi"' ""`,
);
});
it("hands Gradle's VM options to an init script for the forked JVMs", async () => {
const params = { tasks: "bootRun", vmOptions: REPORTER_VM, activeProfiles: "dev", wrapper: "true" };
expect(gradleJvmArgs(params)).toEqual([
"-Ddemo.flag=on",
"--add-opens",
"java.base/java.lang=ALL-UNNAMED",
"-Dspring.profiles.active=dev",
]);
expect(gradleJvmArgs({ tasks: "build", vmOptions: "-Xmx1g" })).toEqual([]);
const c = withParams(cfg({ kind: "gradle" }), params);
// Gradle's own command line is unchanged.
expect(c.args).toEqual(["bootRun"]);
const launched = await gradle().prepareLaunch!(c);
expect(scripts).toEqual([gradleJvmArgs(params)]);
expect(launched.args).toEqual(["--init-script", "/tmp/openidea-gradle/jvm-args-1.init.gradle", "bootRun"]);
// Debugging keeps them: `--debug-jvm` is added before the launch is prepared.
const debug = await gradle().prepareLaunch!(jdwpLaunch(c)!);
expect(debug.args).toEqual([
"--init-script",
"/tmp/openidea-gradle/jvm-args-1.init.gradle",
"bootRun",
"--debug-jvm",
]);
});
it("leaves Gradle configurations without VM options alone", async () => {
const c = withParams(cfg({ kind: "gradle" }), { tasks: "bootRun" });
expect(await gradle().prepareLaunch!(c)).toBe(c);
expect(scripts).toEqual([]);
const remote = withParams(cfg({ kind: "gradle", target: "ssh-1" }), { tasks: "bootRun", vmOptions: "-Xmx1g" });
await expect(gradle().prepareLaunch!(remote)).rejects.toThrow(/run targets/);
});
});
+206
View File
@@ -0,0 +1,206 @@
/**
* JVM run configuration types: Application and Spring Boot
* (`java [VM options] -cp ${classpath} <main class> [program arguments]`), and the
* VM options Maven / Gradle configurations hand to the JVM the build forks
* (`spring-boot:run`, tests, `bootRun`) rather than to the build tool's own JVM.
*/
import { call, commands, type RunConfig } from "@/platform/ipc";
import { CLASSPATH, parseJavaArgs, TEST_CLASSPATH } from "@/platform/javaLaunch";
import type { RunConfigTypeContribution, RunConfigTypeField } from "@/workbench/contributions";
import { formatArgs, parseArgs } from "./configs";
export const isWindows = () => typeof navigator !== "undefined" && /win/i.test(navigator.platform);
/** VM options / program arguments as typed; on Windows a backslash is a path separator. */
export const splitJvmArgs = (text: string | undefined, windows = isWindows()) => parseArgs(text ?? "", windows);
export const PROFILES_ARG = "--spring.profiles.active=";
const VM_OPTIONS_PLACEHOLDER = "e.g. -Xmx1g -Dkey=value --add-opens java.base/java.lang=ALL-UNNAMED";
const vmOptionsField = (hint?: string, visible?: RunConfigTypeField["visible"]): RunConfigTypeField => ({
key: "vmOptions",
label: "VM options",
control: "textarea",
rows: 2,
mono: true,
placeholder: VM_OPTIONS_PLACEHOLDER,
...(hint ? { hint } : {}),
...(visible ? { visible } : {}),
});
const profilesField = (hint: string, visible?: RunConfigTypeField["visible"]): RunConfigTypeField => ({
key: "activeProfiles",
label: "Active profiles",
mono: true,
placeholder: "e.g. dev,local",
hint,
...(visible ? { visible } : {}),
});
const isJava = (command: string) => /^java(\.exe)?$/i.test(command.replace(/\\/g, "/").split("/").pop() ?? "");
/** Main classes of the JVM configurations, offered while typing. */
function mainClasses(configs: readonly RunConfig[]): string[] {
const out = new Set<string>();
for (const c of configs) {
if (c.kind !== "application" && c.kind !== "spring-boot") continue;
const main = c.params?.mainClass || parseJavaArgs(c.args).mainClass;
if (main) out.add(main);
}
return [...out];
}
function jvmApplicationType(spring: boolean): RunConfigTypeContribution {
return {
id: spring ? "spring-boot" : "application",
label: spring ? "Spring Boot" : "Application",
icon: spring ? "spring-boot" : "class",
description: spring ? "Run a Spring Boot application" : "Run a Java main class",
defaults: { classpath: CLASSPATH },
debugAdapter: "java-debug",
fields: [
{
key: "mainClass",
label: "Main class",
mono: true,
required: true,
placeholder: "e.g. com.example.Application",
suggestions: mainClasses,
},
vmOptionsField(),
{ key: "programArgs", label: "Program arguments", mono: true },
...(spring ? [profilesField("Comma separated; passed as --spring.profiles.active")] : []),
{
key: "classpath",
label: "Classpath",
control: "select",
options: [
{ value: CLASSPATH, label: "Runtime classpath of the module" },
{ value: TEST_CLASSPATH, label: "Test classpath of the module" },
],
},
],
commandLine(p) {
const main = p.mainClass?.trim() ?? "";
const profiles = spring ? (p.activeProfiles?.trim() ?? "") : "";
return {
command: "java",
args: [
...splitJvmArgs(p.vmOptions),
"-cp",
p.classpath || CLASSPATH,
...(main ? [main] : []),
...(profiles ? [PROFILES_ARG + profiles] : []),
...splitJvmArgs(p.programArgs),
],
};
},
// Configurations detected or saved before these fields existed: `java [vm] -cp <cp> Main [args]`.
paramsFrom(c) {
if (!isJava(c.command)) return null;
const parsed = parseJavaArgs(c.args);
const classpath =
parsed.classpath === "runtime"
? CLASSPATH
: parsed.classpath === "test"
? TEST_CLASSPATH
: parsed.explicitClasspath;
if (!parsed.mainClass || classpath === null) return null;
let rest = parsed.programArgs;
let activeProfiles = "";
if (spring && rest[0]?.startsWith(PROFILES_ARG)) {
activeProfiles = rest[0].slice(PROFILES_ARG.length);
rest = rest.slice(1);
}
return {
mainClass: parsed.mainClass,
vmOptions: formatArgs(parsed.vmArgs),
programArgs: formatArgs(rest),
classpath,
...(activeProfiles ? { activeProfiles } : {}),
};
},
};
}
export const applicationType = jvmApplicationType(false);
export const springBootType = jvmApplicationType(true);
/**
* One value for a Maven property holding JVM arguments (`spring-boot.run.jvmArguments`,
* `argLine`): the plugins split it on whitespace, honouring "…" and '…'.
*/
export function joinPropertyArgs(args: readonly string[]): string {
return args
.map((a) => {
if (a && !/[\s"']/.test(a)) return a;
return a.includes('"') ? `'${a}'` : `"${a}"`;
})
.join(" ");
}
const words = (tasks: string | undefined) => (tasks ?? "").split(/\s+/).filter(Boolean);
const isSpringBootRun = (goal: string) =>
goal === "spring-boot:run" || /:spring-boot-maven-plugin:[^:]*:run$/.test(goal);
const isMavenTestGoal = (goal: string) =>
/^(test|verify|integration-test|surefire:test|failsafe:integration-test)$/.test(goal);
const isBootRun = (task: string) => /(^|:)bootRun$/.test(task);
const isGradleJvmTask = (task: string) => /(^|:)(test|bootRun|run|integrationTest|check)$/.test(task);
/** What a build tool configuration does with VM options and Spring Boot profiles. */
export interface ForkedJvm {
fields: readonly RunConfigTypeField[];
/** Arguments appended to the build tool's command line. */
args(params: Readonly<Record<string, string>>): string[];
prepareLaunch?(config: RunConfig): Promise<RunConfig>;
}
export const mavenForkedJvm: ForkedJvm = {
fields: [
vmOptionsField("Passed to the JVM that spring-boot:run or the tests start, not to Maven", (p) =>
words(p.tasks).some((g) => isSpringBootRun(g) || isMavenTestGoal(g)),
),
profilesField("Comma separated; passed as -Dspring-boot.run.profiles", (p) => words(p.tasks).some(isSpringBootRun)),
],
args(p) {
const goals = words(p.tasks);
const vm = splitJvmArgs(p.vmOptions);
const out: string[] = [];
if (goals.some(isSpringBootRun)) {
if (vm.length) out.push(`-Dspring-boot.run.jvmArguments=${joinPropertyArgs(vm)}`);
const profiles = p.activeProfiles?.trim();
if (profiles) out.push(`-Dspring-boot.run.profiles=${profiles}`);
} else if (vm.length && goals.some(isMavenTestGoal)) {
out.push(`-DargLine=${joinPropertyArgs(vm)}`);
}
return out;
},
};
/** VM options (and profiles, as `-Dspring.profiles.active`) of a Gradle configuration's forked JVMs. */
export function gradleJvmArgs(p: Readonly<Record<string, string>>): string[] {
const tasks = words(p.tasks);
if (!tasks.some(isGradleJvmTask)) return [];
const profiles = tasks.some(isBootRun) ? p.activeProfiles?.trim() : "";
return [...splitJvmArgs(p.vmOptions), ...(profiles ? [`-Dspring.profiles.active=${profiles}`] : [])];
}
export const gradleForkedJvm: ForkedJvm = {
fields: [
vmOptionsField("Passed to the JVMs the tasks start (bootRun, run, test), not to Gradle", (p) =>
words(p.tasks).some(isGradleJvmTask),
),
profilesField("Comma separated; passed as -Dspring.profiles.active", (p) => words(p.tasks).some(isBootRun)),
],
// Gradle has no command-line switch for them: an init script adds them to the JavaExec / Test tasks.
args: () => [],
async prepareLaunch(config) {
const jvmArgs = gradleJvmArgs(config.params ?? {});
if (!jvmArgs.length) return config;
if (config.target) throw new Error("VM options of Gradle configurations are not applied on run targets");
const script = await call(commands.runGradleJvmArgsScript(jvmArgs));
return { ...config, args: ["--init-script", script, ...config.args] };
},
};
+17 -3
View File
@@ -1,6 +1,6 @@
/**
* Built-in run configuration types (Shell Script, Program, npm, Gradle, Maven,
* Compound) and the helpers that turn a type's params into the launched
* Application, Spring Boot, Compound) and the helpers that turn a type's params into the launched
* command line. Other features contribute types with `registerRunConfigType`.
*/
import type { RunConfig } from "@/platform/ipc";
@@ -11,11 +11,18 @@ import {
type RunConfigTypeContribution,
} from "@/workbench/contributions";
import { formatArgs, parseArgs, uid, uniqueName } from "./configs";
import {
applicationType,
gradleForkedJvm,
isWindows,
mavenForkedJvm,
springBootType,
type ForkedJvm,
} from "./jvmTypes";
import { tx } from "@/platform/i18n";
export const COMPOUND = "compound";
const isWindows = () => typeof navigator !== "undefined" && /win/i.test(navigator.platform);
const on = (v: string | undefined) => v === "true";
const baseName = (p: string) => p.replace(/\\/g, "/").split("/").pop() ?? p;
const dirOf = (cwd: string | null | undefined) => cwd ?? "";
@@ -213,6 +220,7 @@ function buildToolType(
icon: string,
tasks: { label: string; description: string },
wrapper: { unix: string; windows: string; plain: string; label: string; byDefault: boolean },
jvm: ForkedJvm,
): RunConfigTypeContribution {
return {
id,
@@ -231,13 +239,15 @@ function buildToolType(
suggestions: (configs) => argsOf(configs, id, 0),
},
{ key: "arguments", label: "Arguments", mono: true },
...jvm.fields,
{ key: "wrapper", label: wrapper.label, control: "checkbox" },
],
commandLine: (p) => ({
command: wrapperCommand(wrapper.unix, wrapper.windows, wrapper.plain, on(p.wrapper)),
args: [...parseArgs(p.tasks ?? ""), ...parseArgs(p.arguments ?? "")],
args: [...parseArgs(p.tasks ?? ""), ...parseArgs(p.arguments ?? ""), ...jvm.args(p)],
cwd: p.projectDir?.trim() || null,
}),
...(jvm.prepareLaunch ? { prepareLaunch: jvm.prepareLaunch } : {}),
paramsFrom(c) {
const name = baseName(c.command);
const usesWrapper = name === wrapper.unix || name === wrapper.windows;
@@ -264,6 +274,7 @@ const gradle = buildToolType(
label: "Use the Gradle wrapper",
byDefault: true,
},
gradleForkedJvm,
);
const maven = buildToolType(
@@ -278,6 +289,7 @@ const maven = buildToolType(
label: "Use the Maven wrapper",
byDefault: false,
},
mavenForkedJvm,
);
const compound: RunConfigTypeContribution = {
@@ -296,6 +308,8 @@ export const BUILTIN_TYPES: readonly RunConfigTypeContribution[] = [
gradle,
maven,
godot,
applicationType,
springBootType,
compound,
];
+17
View File
@@ -2828,6 +2828,23 @@
"Executable": "可执行文件",
"Path or command on PATH": "路径或 PATH 中的命令",
"Program arguments": "程序实参",
"VM options": "VM 选项",
"e.g. -Xmx1g -Dkey=value --add-opens java.base/java.lang=ALL-UNNAMED": "例如 -Xmx1g -Dkey=value --add-opens java.base/java.lang=ALL-UNNAMED",
"Main class": "主类",
"e.g. com.example.Application": "例如 com.example.Application",
"Active profiles": "活动配置文件",
"e.g. dev,local": "例如 dev,local",
"Comma separated; passed as --spring.profiles.active": "以逗号分隔;作为 --spring.profiles.active 传递",
"Comma separated; passed as -Dspring-boot.run.profiles": "以逗号分隔;作为 -Dspring-boot.run.profiles 传递",
"Comma separated; passed as -Dspring.profiles.active": "以逗号分隔;作为 -Dspring.profiles.active 传递",
"Classpath": "类路径",
"Runtime classpath of the module": "模块的运行时类路径",
"Test classpath of the module": "模块的测试类路径",
"Run a Java main class": "运行 Java 主类",
"Run a Spring Boot application": "运行 Spring Boot 应用程序",
"Spring Boot": "Spring Boot",
"Passed to the JVM that spring-boot:run or the tests start, not to Maven": "传给 spring-boot:run 或测试启动的 JVM,而不是 Maven 本身",
"Passed to the JVMs the tasks start (bootRun, run, test), not to Gradle": "传给任务启动的 JVM(bootRun、run、test),而不是 Gradle 本身",
"Run a package.json script": "运行 package.json 脚本",
"package.json directory": "package.json 目录",
"Tasks": "任务",
+2
View File
@@ -886,6 +886,8 @@ export interface RunConfigTypeField {
*/
control?: "text" | "textarea" | "select" | "checkbox" | "output";
mono?: boolean;
/** Visible lines of a `textarea` (default 4). */
rows?: number;
placeholder?: string;
options?: ReadonlyArray<{ value: string; label: string }>;
/** Values offered while typing (e.g. the scripts of detected configurations). */