feat(run): JAR Application run configurations and their import (#2409)

Fixes #2393

Importing run configurations from .run/ and .idea/ skipped every JarApplication entry because there was no equivalent type. Adds a JAR Application run configuration (path to JAR, VM options, program arguments, JRE, working directory, environment) that runs java [VM options] -jar <jar> [args] and debugs over JDWP like the Application type, and maps JarApplication on import (JAR_PATH, VM/program parameters, working directory, alternative JRE path, envs).
This commit is contained in:
l0ng-ai
2026-11-10 03:28:30 +08:00
committed by GitHub
parent 718e07c831
commit 98cef2aa48
12 changed files with 255 additions and 3 deletions
+42 -1
View File
@@ -4,7 +4,7 @@
//! than one place is taken from the first, in that order.
//!
//! Python, Django server, Django tests, pytest, npm, Jest, Vitest, Node.js, Shell Script,
//! Application, Spring Boot, Go Application / Go Test and compound configurations become the
//! Application, JAR Application, Spring Boot, Go Application / Go Test and compound configurations become the
//! matching kinds; other types are skipped with their type.
//! `$PROJECT_DIR$` (and `$MODULE_DIR$`, `$MODULE_WORKING_DIR$`, `$ContentRoot$`,
//! `$ProjectFileDir$`, all the project directory here) and `$USER_HOME$` are replaced; another
@@ -164,6 +164,7 @@ const NODE: &str = "NodeJSConfigurationType";
const SHELL: &str = "ShConfigurationType";
const COMPOUND: &str = "CompoundRunConfigurationType";
const APPLICATION: &str = "Application";
const JAR_APPLICATION: &str = "JarApplication";
const SPRING_BOOT: &str = "SpringBootApplicationConfigurationType";
const GO_APPLICATION: &str = "GoApplicationRunConfiguration";
const GO_TEST: &str = "GoTestRunConfiguration";
@@ -344,6 +345,7 @@ impl Cx<'_> {
NODE => self.node(name, c, &mut ignored)?,
SHELL => self.shell(name, c)?,
APPLICATION => self.java_app(name, c, false, &mut ignored)?,
JAR_APPLICATION => self.jar_app(name, c, &mut ignored)?,
SPRING_BOOT => self.java_app(name, c, true, &mut ignored)?,
GO_APPLICATION => self.go_config(name, c, false, &mut ignored)?,
GO_TEST => self.go_config(name, c, true, &mut ignored)?,
@@ -793,6 +795,45 @@ impl Cx<'_> {
// ------------------------------------------------------------- Java
fn jar_app(&self, name: &str, c: &El, ignored: &mut Vec<String>) -> Res<RunConfig> {
let Some(jar) = self.path(c.option("JAR_PATH"))? else { return Err(SkipReason::NothingToRun) };
let (dir, cwd) = self.cwd(c.option("WORKING_DIRECTORY"))?;
let jar = path_from(self.root, jar, &dir);
let vm = self.words(c.option("VM_PARAMETERS"))?;
let program = self.words(c.option("PROGRAM_PARAMETERS"))?;
let mut params = BTreeMap::from([
("jarPath".to_owned(), jar.clone()),
("vmOptions".to_owned(), format_args(&vm)),
("programArgs".to_owned(), format_args(&program)),
]);
let mut command = "java".to_owned();
if c.flag("ALTERNATIVE_JRE_PATH_ENABLED")
&& let Some(jre) = self.path(c.option("ALTERNATIVE_JRE_PATH"))?
{
// IDEA also stores SDK names here; those don't identify a JRE on this machine.
if jre.contains('/') || jre.contains('\\') {
let jre = jre.replace('\\', "/");
command = if jre.ends_with("/bin/java") || jre.to_ascii_lowercase().ends_with("/bin/java.exe") {
jre.clone()
} else {
format!("{}/bin/{}", jre.trim_end_matches('/'), if cfg!(windows) { "java.exe" } else { "java" })
};
params.insert("jre".to_owned(), jre);
} else {
ignored.push(format!("JRE {jre}"));
}
}
let mut args = vm;
args.extend(["-jar".to_owned(), jar]);
args.extend(program);
let mut rc = config(name, "jar-application", command, args, Some("java-debug"));
rc.cwd = cwd.map(Into::into);
rc.extras.params = params;
rc.extras.include_system_env = c.option("PASS_PARENT_ENVS") != Some("false");
self.envs(c, &mut rc);
Ok(rc)
}
/// IDEA's Application and Spring Boot types, as the `application` / `spring-boot` forms run
/// them: `java <vm options> -cp ${classpath} <main> [--spring.profiles.active=…] <arguments>`
/// with the module's runtime classpath.
+72
View File
@@ -60,6 +60,78 @@ fn cwd(c: &RunConfig) -> Option<PathBuf> {
c.cwd.as_ref().map(|p| p.as_ref().to_path_buf())
}
// JarApplicationConfiguration.writeExternal serializes its bean as options and its environment as envs:
// https://github.com/JetBrains/intellij-community/blob/master/java/execution/impl/src/com/intellij/execution/jar/JarApplicationConfiguration.java
const JAR: &str = r#"<configuration default="false" name="NAME" type="JarApplication">
<option name="JAR_PATH" value="$PROJECT_DIR$/dist/server app.jar" />
<option name="VM_PARAMETERS" value="-Xmx1g -Dname=&quot;My Server&quot;" />
<option name="PROGRAM_PARAMETERS" value="--config &quot;$PROJECT_DIR$/server config.yml&quot; &quot;&quot;" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/server" />
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" />
<option name="ALTERNATIVE_JRE_PATH" value="$PROJECT_DIR$/jdk" />
<option name="PASS_PARENT_ENVS" value="false" />
<envs><env name="MODE" value="production" /></envs>
<method v="2" />
</configuration>"#;
#[test]
fn jar_applications_from_every_configuration_location() {
for location in [".run/Server.run.xml", ".idea/runConfigurations/Server.xml", ".idea/workspace.xml"] {
let dir = tempfile::tempdir().unwrap();
let xml = if location.ends_with("workspace.xml") {
workspace(&JAR.replace("NAME", "Server"))
} else {
run_file("Server", JAR)
};
write(dir.path(), location, &xml);
let r = import_from(dir.path(), &[]);
let jar = get(&r, "Server");
let root = dir.path().to_string_lossy().replace('\\', "/");
assert_eq!(jar.kind, "jar-application");
let java = if cfg!(windows) { "java.exe" } else { "java" };
assert_eq!(jar.command, format!("{root}/jdk/bin/{java}"));
assert_eq!(
jar.args,
s(&[
"-Xmx1g",
"-Dname=My Server",
"-jar",
"../dist/server app.jar",
"--config",
&format!("{root}/server config.yml"),
""
])
);
assert_eq!(cwd(jar), Some(PathBuf::from("server")));
assert_eq!(jar.debug_adapter.as_deref(), Some("java-debug"));
assert_eq!(jar.env["MODE"], "production");
assert!(!jar.extras.include_system_env);
assert_eq!(jar.extras.params["jarPath"], "../dist/server app.jar");
assert_eq!(jar.extras.params["jre"], format!("{root}/jdk"));
assert!(ignored(&r, "Server").is_empty());
}
}
#[test]
fn jar_application_defaults_and_invalid_targets() {
let dir = tempfile::tempdir().unwrap();
for (name, xml) in [
("Default", JAR.replace("value=\"true\"", "value=\"false\"")),
("Named", JAR.replace("$PROJECT_DIR$/jdk", "temurin-21")),
("NoJar", JAR.replace("$PROJECT_DIR$/dist/server app.jar", "")),
("UnknownMacro", JAR.replace("$PROJECT_DIR$/dist", "$JAR_HOME$")),
] {
write(dir.path(), &format!(".run/{name}.run.xml"), &run_file(name, &xml));
}
let r = import_from(dir.path(), &[]);
assert_eq!(get(&r, "Default").command, "java");
assert!(!get(&r, "Default").extras.params.contains_key("jre"));
assert_eq!(get(&r, "Named").command, "java");
assert_eq!(ignored(&r, "Named"), ["JRE temurin-21"]);
assert_eq!(reason(&r, "NoJar"), &SkipReason::NothingToRun);
assert_eq!(reason(&r, "UnknownMacro"), &SkipReason::PathVariable { variable: "$JAR_HOME$".into() });
}
/// A Django project in `src/` with a root virtualenv.
fn django_project() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
+3
View File
@@ -22,6 +22,7 @@ const KIND_LABELS: Record<string, string> = {
program: "Program",
compound: "Compound",
application: "Application",
"jar-application": "JAR Application",
"spring-boot": "Spring Boot",
node: "Node.js",
"java-file": "Java Single File",
@@ -51,6 +52,7 @@ export const KIND_OPTIONS = Object.entries(KIND_LABELS).map(([value, label]) =>
export const KIND_CATEGORIES: Record<string, string> = {
"spring-boot": "Java",
application: "Java",
"jar-application": "Java",
native: "Java",
"java-file": "Java",
kotlin: "Java",
@@ -105,6 +107,7 @@ export function configIcon(config: Pick<RunConfig, "kind" | "isTest">): string {
case "compound":
return "gutter-run-all";
case "program":
case "jar-application":
return "file-binary";
case "spring-boot":
return "spring-boot";
+1
View File
@@ -45,6 +45,7 @@ export function jdwpLaunch(config: RunConfig, jdwpPort?: number): RunConfig | nu
config = applyBuildPolicyToCommand(config);
if (config.kind === "tomcat") return { ...config, params: { ...config.params, tomcatDebug: "true" } };
const args = config.args;
if (config.kind === "jar-application") return { ...config, args: [JDWP_AGENT, ...args] };
// A single source file (`java Hi.java`): the agent goes before the file. Compiled first
// (`javac … && java -cp …`): through `JDK_JAVA_OPTIONS`, which only the `java` launcher reads.
if (config.kind === "java-file")
+60 -2
View File
@@ -17,10 +17,11 @@ vi.mock("@/platform/ipc", async (orig) => {
});
const { usePlatform } = await import("@/platform/platformInfo");
const { parseJavaArgs, joinJavaArgs } = await import("@/platform/javaLaunch");
const { parseJavaArgs, joinJavaArgs, isJvmApplication, JDWP_AGENT } = await import("@/platform/javaLaunch");
const { parseArgs } = await import("./configs");
const { jdwpLaunch } = await import("./javaDebug");
const { applicationType, gradleJvmArgs, joinPropertyArgs, springBootType } = await import("./jvmTypes");
const { applicationType, jarApplicationType, 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";
@@ -46,6 +47,63 @@ beforeEach(() => {
});
afterEach(() => dispose());
describe("JAR Application", () => {
it("creates, edits and recovers a JAR configuration without a source module", () => {
const created = newConfigOfType(jarApplicationType, []);
expect(created.debugAdapter).toBe("java-debug");
const edited = withParams(created, {
jarPath: "dist/my app.jar",
vmOptions: '-Xmx1g --add-opens java.base/java.lang=ALL-UNNAMED -Dname="My App"',
programArgs: '--config "a b.yml" "" -jar other.jar',
jre: "/jdk 21",
});
expect(edited.command).toBe("/jdk 21/bin/java");
expect(edited.args).toEqual([
"-Xmx1g",
"--add-opens",
"java.base/java.lang=ALL-UNNAMED",
"-Dname=My App",
"-jar",
"dist/my app.jar",
"--config",
"a b.yml",
"",
"-jar",
"other.jar",
]);
const recovered = formParams({ ...edited, params: undefined });
expect(recovered?.jarPath).toBe("dist/my app.jar");
expect(jarApplicationType.commandLine(recovered!)).toEqual({ command: edited.command, args: edited.args });
expect(parseJavaArgs(edited.args).mainClass).toBeNull();
expect(isJvmApplication(edited)).toBe(false);
const debug = jdwpLaunch(edited)!;
expect(debug.args).toEqual([JDWP_AGENT, ...edited.args]);
expect(debug.command).toBe(edited.command);
});
it("uses java by default and keeps Windows JRE and JAR paths", () => {
expect(jarApplicationType.commandLine({ jarPath: "app.jar" })).toEqual({
command: "java",
args: ["-jar", "app.jar"],
});
usePlatform.setState({ os: "windows" });
try {
expect(
jarApplicationType.commandLine({
jre: String.raw`C:\Program Files\jdk`,
jarPath: String.raw`C:\apps\my app.jar`,
programArgs: String.raw`"C:\data files\x"`,
}),
).toEqual({
command: "C:\\Program Files\\jdk/bin/java.exe",
args: ["-jar", String.raw`C:\apps\my app.jar`, String.raw`C:\data files\x`],
});
} finally {
usePlatform.setState({ os: "macos" });
}
});
});
describe("Application / Spring Boot command line", () => {
it.each([
["application", applicationType],
+31
View File
@@ -175,6 +175,37 @@ function jvmApplicationType(spring: boolean): RunConfigTypeContribution {
export const applicationType = jvmApplicationType(false);
export const springBootType = jvmApplicationType(true);
export const jarApplicationType: RunConfigTypeContribution = {
id: "jar-application",
label: "JAR Application",
icon: "file-binary",
description: "Run an executable JAR",
debugAdapter: "java-debug",
fields: [
{ key: "jarPath", label: "Path to JAR", mono: true, required: true, browse: "file" },
vmOptionsField(),
{ key: "programArgs", label: "Program arguments", mono: true },
{ ...jreField, options: [{ value: "", label: "Default (java on PATH)" }] },
],
commandLine(p) {
return {
command: p.jre ? jreJava(p.jre) : p.executable || "java",
args: [...splitJvmArgs(p.vmOptions), "-jar", p.jarPath?.trim() ?? "", ...splitJvmArgs(p.programArgs)],
};
},
paramsFrom(c) {
if (!/^java$/i.test(commandName(c.command))) return null;
const parsed = parseJavaArgs(c.args);
if (parsed.jarPath === undefined) return null;
return {
...(c.command !== "java" ? { executable: c.command } : {}),
jarPath: parsed.jarPath,
vmOptions: formatArgs(parsed.vmArgs),
programArgs: formatArgs(parsed.programArgs),
};
},
};
/**
* One value for a Maven property holding JVM arguments (`spring-boot.run.jvmArguments`,
* `argLine`): the plugins split it on whitespace, honouring "…" and '…'.
+25
View File
@@ -91,6 +91,31 @@ it.each([undefined, false])(
);
describe("configuration types", () => {
it("shows the JAR fields, validates the path and edits shared working directory and environment", () => {
const type = typeFor("jar-application")!;
const config = newConfigOfType(type, []);
expect(missingParams(config)).toEqual({ jarPath: "Path to JAR is required" });
const edited = withParams(config, { jarPath: "app.jar", vmOptions: "-Xmx1g", programArgs: "hello" });
const change = vi.fn();
render(
<ConfigForm
config={{ ...edited, cwd: "server", env: { MODE: "test" } }}
all={[]}
type={type}
errors={{}}
onChange={change}
modifyRequest={0}
/>,
);
expect(screen.getByLabelText("Path to JAR")).toHaveValue("app.jar");
expect(screen.getByLabelText("VM options")).toHaveValue("-Xmx1g");
expect(screen.getByLabelText("Program arguments")).toHaveValue("hello");
expect(screen.getByLabelText("Working directory")).toHaveValue("server");
expect(screen.getByLabelText("Environment variables")).toHaveValue("MODE=test");
fireEvent.change(screen.getByLabelText("Path to JAR"), { target: { value: "new.jar" } });
expect(change.mock.calls.at(-1)?.[0]).toMatchObject({ args: ["-Xmx1g", "-jar", "new.jar", "hello"] });
});
it("runs an Application with the chosen JRE and the classpath of the chosen module", () => {
const type = typeFor("application")!;
const base = withParams(newConfigOfType(type, []), { ...newConfigOfType(type, []).params, mainClass: "a.Main" });
+1
View File
@@ -403,6 +403,7 @@ describe("add service categories", () => {
program: "Shell",
compound: "Others",
application: "Java",
"jar-application": "Java",
"spring-boot": "Java",
native: "Java",
"java-file": "Java",
+2
View File
@@ -10,6 +10,7 @@ import { formatArgs, parseArgs, uid, uniqueName } from "./configs";
import { commandName } from "./commandName";
import {
applicationType,
jarApplicationType,
gradleForkedJvm,
isWindows,
mavenForkedJvm,
@@ -447,6 +448,7 @@ export const BUILTIN_TYPES: readonly RunConfigTypeContribution[] = [
goBuildType,
goTestType,
applicationType,
jarApplicationType,
springBootType,
tomcatType,
compound,
+10
View File
@@ -46,6 +46,7 @@ export interface JavaCommandLine {
/** VM options, without the classpath. */
vmArgs: string[];
mainClass: string | null;
jarPath?: string;
programArgs: string[];
/** Which classpath placeholder the configuration uses (`null`: an explicit classpath or none). */
classpath: "runtime" | "test" | null;
@@ -60,6 +61,15 @@ export function parseJavaArgs(args: readonly string[]): JavaCommandLine {
let explicitClasspath: string | null = null;
for (let i = 0; i < args.length; i++) {
const a = args[i]!;
if (a === "-jar")
return {
vmArgs,
mainClass: null,
jarPath: args[i + 1],
programArgs: args.slice(i + 2),
classpath,
explicitClasspath,
};
if (a === "-cp" || a === "-classpath" || a === "--class-path") {
const value = args[++i] ?? "";
if (value === CLASSPATH) classpath = "runtime";
+4
View File
@@ -1,4 +1,8 @@
{
"JAR Application": "JAR Application",
"Run an executable JAR": "Run an executable JAR",
"Path to JAR": "Path to JAR",
"Default (java on PATH)": "Default (java on PATH)",
"Stream Sample TUI": "Stream Sample TUI",
"Arduino tool '{configured}' was not found. Set its path in Settings > Languages & Frameworks > Language Servers, or in settings.toml under languages.arduino.": "Arduino tool '{configured}' was not found. Set its path in Settings > Languages & Frameworks > Language Servers, or in settings.toml under languages.arduino.",
"Arduino tool '{name}' was not found on PATH or in Arduino IDE. Install Arduino IDE 2, or set its path in Settings > Languages & Frameworks > Language Servers or settings.toml under languages.arduino.": "Arduino tool '{name}' was not found on PATH or in Arduino IDE. Install Arduino IDE 2, or set its path in Settings > Languages & Frameworks > Language Servers or settings.toml under languages.arduino.",
+4
View File
@@ -1,4 +1,8 @@
{
"JAR Application": "JAR 应用程序",
"Run an executable JAR": "运行可执行 JAR",
"Path to JAR": "JAR 路径",
"Default (java on PATH)": "默认(PATH 中的 java)",
"Arduino tool '{configured}' was not found. Set its path in Settings > Languages & Frameworks > Language Servers, or in settings.toml under languages.arduino.": "未找到 Arduino 工具“{configured}”。请在设置 > 语言和框架 > 语言服务器中配置其路径,或在 settings.toml 的 languages.arduino 下配置。",
"Arduino tool '{name}' was not found on PATH or in Arduino IDE. Install Arduino IDE 2, or set its path in Settings > Languages & Frameworks > Language Servers or settings.toml under languages.arduino.": "在 PATH 或 Arduino IDE 中未找到 Arduino 工具“{name}”。请安装 Arduino IDE 2,或在设置 > 语言和框架 > 语言服务器中配置其路径,也可在 settings.toml 的 languages.arduino 下配置。",
"Install Arduino IDE 2 (includes Arduino Language Server, Arduino CLI and clangd) and the board's core. Configure the server command and arguments in Settings > Languages & Frameworks > Language Servers, or set languages.arduino.cli, languages.arduino.clangd, languages.arduino.languageServer and languages.arduino.cliConfig in settings.toml. Set the board FQBN in sketch.yaml or languages.arduino.fqbn in settings.toml.": "请安装 Arduino IDE 2(包含 Arduino Language Server、Arduino CLI 和 clangd)及开发板核心。在设置 > 语言和框架 > 语言服务器中配置服务器命令和参数,或在 settings.toml 中设置 languages.arduino.cli、languages.arduino.clangd、languages.arduino.languageServer 和 languages.arduino.cliConfig。在 sketch.yaml 或 settings.toml 的 languages.arduino.fqbn 中设置开发板 FQBN。",