diff --git a/crates/ide-host/src/ops/run/commands.rs b/crates/ide-host/src/ops/run/commands.rs index ec0075e2..c792a44c 100644 --- a/crates/ide-host/src/ops/run/commands.rs +++ b/crates/ide-host/src/ops/run/commands.rs @@ -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) -> IdeResult { + 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 }); diff --git a/crates/ide-run/src/gradle_jvm.rs b/crates/ide-run/src/gradle_jvm.rs new file mode 100644 index 00000000..0734a851 --- /dev/null +++ b/crates/ide-run/src/gradle_jvm.rs @@ -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 `). 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::>().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 { + 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 { + 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")); + } +} diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index 4490b7f7..d2110912 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -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; diff --git a/crates/ide-run/tests/config.rs b/crates/ide-run/tests/config.rs index b4bb34ac..6fd423f1 100644 --- a/crates/ide-run/tests/config.rs +++ b/crates/ide-run/tests/config.rs @@ -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"); +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 0db71f35..c89190cf 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -266,6 +266,7 @@ pub fn builder() -> tauri_specta::Builder { 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, diff --git a/src-tauri/src/commands/run.rs b/src-tauri/src/commands/run.rs index 800919d3..a99eaf2a 100644 --- a/src-tauri/src/commands/run.rs +++ b/src-tauri/src/commands/run.rs @@ -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, +) -> IdeResult { + 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] diff --git a/src/bindings.ts b/src/bindings.ts index 70bcd3d2..219eefcc 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -515,6 +515,8 @@ export const commands = { runCloseStdin: (id: RunId) => typedError(__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(__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(__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). diff --git a/src/features/debugger/__tests__/java.test.ts b/src/features/debugger/__tests__/java.test.ts index 2166a49b..0dde9a59 100644 --- a/src/features/debugger/__tests__/java.test.ts +++ b/src/features/debugger/__tests__/java.test.ts @@ -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; diff --git a/src/features/run/ConfigForm.tsx b/src/features/run/ConfigForm.tsx index 99aaf1f6..fea94088 100644 --- a/src/features/run/ConfigForm.tsx +++ b/src/features/run/ConfigForm.tsx @@ -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)} /> ); diff --git a/src/features/run/configs.ts b/src/features/run/configs.ts index 53f2ffa1..fd5161ed 100644 --- a/src/features/run/configs.ts +++ b/src/features/run/configs.ts @@ -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)) { diff --git a/src/features/run/jvmTypes.test.ts b/src/features/run/jvmTypes.test.ts new file mode 100644 index 00000000..921f0afc --- /dev/null +++ b/src/features/run/jvmTypes.test.ts @@ -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(); + 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 => ({ + 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/); + }); +}); diff --git a/src/features/run/jvmTypes.ts b/src/features/run/jvmTypes.ts new file mode 100644 index 00000000..95000589 --- /dev/null +++ b/src/features/run/jvmTypes.ts @@ -0,0 +1,206 @@ +/** + * JVM run configuration types: Application and Spring Boot + * (`java [VM options] -cp ${classpath}
[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(); + 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 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>): string[]; + prepareLaunch?(config: RunConfig): Promise; +} + +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>): 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] }; + }, +}; diff --git a/src/features/run/types.ts b/src/features/run/types.ts index 8b4f6893..d685482c 100644 --- a/src/features/run/types.ts +++ b/src/features/run/types.ts @@ -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, ]; diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index c722229d..cb84e750 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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": "任务", diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index ad1a5358..b7bdeb2e 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -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). */