mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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] };
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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": "任务",
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user