From 98cef2aa488361bbd87ffcc244e8fc6820f2b4d4 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 10 Nov 2026 03:28:30 +0800 Subject: [PATCH] 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 [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). --- crates/ide-run/src/import/jetbrains.rs | 43 +++++++++++++- crates/ide-run/tests/import_jetbrains.rs | 72 ++++++++++++++++++++++++ src/features/run/configs.ts | 3 + src/features/run/javaDebug.ts | 1 + src/features/run/jvmTypes.test.ts | 62 +++++++++++++++++++- src/features/run/jvmTypes.ts | 31 ++++++++++ src/features/run/runConfigTypes.test.tsx | 25 ++++++++ src/features/run/services.test.ts | 1 + src/features/run/types.ts | 2 + src/platform/javaLaunch.ts | 10 ++++ src/platform/locales/text/en.json | 4 ++ src/platform/locales/text/zh.json | 4 ++ 12 files changed, 255 insertions(+), 3 deletions(-) diff --git a/crates/ide-run/src/import/jetbrains.rs b/crates/ide-run/src/import/jetbrains.rs index ea94f7dbb..c2447a159 100644 --- a/crates/ide-run/src/import/jetbrains.rs +++ b/crates/ide-run/src/import/jetbrains.rs @@ -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) -> Res { + 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 -cp ${classpath}
[--spring.profiles.active=…] ` /// with the module's runtime classpath. diff --git a/crates/ide-run/tests/import_jetbrains.rs b/crates/ide-run/tests/import_jetbrains.rs index b5fc65724..f52aee279 100644 --- a/crates/ide-run/tests/import_jetbrains.rs +++ b/crates/ide-run/tests/import_jetbrains.rs @@ -60,6 +60,78 @@ fn cwd(c: &RunConfig) -> Option { 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#" + "#; + +#[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(); diff --git a/src/features/run/configs.ts b/src/features/run/configs.ts index 931b730f5..5ed6b089c 100644 --- a/src/features/run/configs.ts +++ b/src/features/run/configs.ts @@ -22,6 +22,7 @@ const KIND_LABELS: Record = { 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 = { "spring-boot": "Java", application: "Java", + "jar-application": "Java", native: "Java", "java-file": "Java", kotlin: "Java", @@ -105,6 +107,7 @@ export function configIcon(config: Pick): string { case "compound": return "gutter-run-all"; case "program": + case "jar-application": return "file-binary"; case "spring-boot": return "spring-boot"; diff --git a/src/features/run/javaDebug.ts b/src/features/run/javaDebug.ts index bc84b0b41..688593b1b 100644 --- a/src/features/run/javaDebug.ts +++ b/src/features/run/javaDebug.ts @@ -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") diff --git a/src/features/run/jvmTypes.test.ts b/src/features/run/jvmTypes.test.ts index f8a0cc143..19207aa76 100644 --- a/src/features/run/jvmTypes.test.ts +++ b/src/features/run/jvmTypes.test.ts @@ -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], diff --git a/src/features/run/jvmTypes.ts b/src/features/run/jvmTypes.ts index 971923b7a..e97970b8d 100644 --- a/src/features/run/jvmTypes.ts +++ b/src/features/run/jvmTypes.ts @@ -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 '…'. diff --git a/src/features/run/runConfigTypes.test.tsx b/src/features/run/runConfigTypes.test.tsx index c6e4929dd..d15652f9c 100644 --- a/src/features/run/runConfigTypes.test.tsx +++ b/src/features/run/runConfigTypes.test.tsx @@ -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( + , + ); + 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" }); diff --git a/src/features/run/services.test.ts b/src/features/run/services.test.ts index f35fa725a..d34bfa544 100644 --- a/src/features/run/services.test.ts +++ b/src/features/run/services.test.ts @@ -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", diff --git a/src/features/run/types.ts b/src/features/run/types.ts index 2db897264..c354e950b 100644 --- a/src/features/run/types.ts +++ b/src/features/run/types.ts @@ -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, diff --git a/src/platform/javaLaunch.ts b/src/platform/javaLaunch.ts index fb1e50408..55ea83c59 100644 --- a/src/platform/javaLaunch.ts +++ b/src/platform/javaLaunch.ts @@ -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"; diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 1272fd87a..e3e364c27 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -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.", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 010f09508..3c95124ea 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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。",