diff --git a/src/features/run/commandName.ts b/src/features/run/commandName.ts new file mode 100644 index 000000000..95987b087 --- /dev/null +++ b/src/features/run/commandName.ts @@ -0,0 +1,5 @@ +/** Executable identity for form matching; keep the original command for launching. */ +export function commandName(command: string): string { + const name = command.replace(/\\/g, "/").split("/").pop() ?? command; + return /\.(exe|cmd|bat|com)$/i.test(name) ? name.replace(/\.(exe|cmd|bat|com)$/i, "").toLowerCase() : name; +} diff --git a/src/features/run/jvmTypes.ts b/src/features/run/jvmTypes.ts index 950005897..421b98b7a 100644 --- a/src/features/run/jvmTypes.ts +++ b/src/features/run/jvmTypes.ts @@ -8,6 +8,7 @@ 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"; +import { commandName } from "./commandName"; export const isWindows = () => typeof navigator !== "undefined" && /win/i.test(navigator.platform); @@ -38,8 +39,6 @@ const profilesField = (hint: string, visible?: RunConfigTypeField["visible"]): R ...(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(); @@ -85,7 +84,7 @@ function jvmApplicationType(spring: boolean): RunConfigTypeContribution { const main = p.mainClass?.trim() ?? ""; const profiles = spring ? (p.activeProfiles?.trim() ?? "") : ""; return { - command: "java", + command: p.executable || "java", args: [ ...splitJvmArgs(p.vmOptions), "-cp", @@ -98,7 +97,7 @@ function jvmApplicationType(spring: boolean): RunConfigTypeContribution { }, // Configurations detected or saved before these fields existed: `java [vm] -cp Main [args]`. paramsFrom(c) { - if (!isJava(c.command)) return null; + if (!/^java$/i.test(commandName(c.command))) return null; const parsed = parseJavaArgs(c.args); const classpath = parsed.classpath === "runtime" @@ -114,6 +113,7 @@ function jvmApplicationType(spring: boolean): RunConfigTypeContribution { rest = rest.slice(1); } return { + ...(c.command !== "java" ? { executable: c.command } : {}), mainClass: parsed.mainClass, vmOptions: formatArgs(parsed.vmArgs), programArgs: formatArgs(rest), diff --git a/src/features/run/runConfigTypes.test.tsx b/src/features/run/runConfigTypes.test.tsx index 3b1a2130a..168a6e1d8 100644 --- a/src/features/run/runConfigTypes.test.tsx +++ b/src/features/run/runConfigTypes.test.tsx @@ -71,6 +71,47 @@ describe("configuration types", () => { expect(runConfigTypes().map((t) => t.id)).toEqual(expect.arrayContaining(BUILTIN_TYPES.map((t) => t.id))); }); + it.each([ + ["maven", "mvn.cmd", ["clean", "test"]], + ["maven", "MVN.CMD", ["verify"]], + ["maven", "mvn.bat", ["test"]], + ["maven", "/opt/maven/bin/mvn", ["package"]], + ["maven", String.raw`C:\tools\maven\bin\mvn.cmd`, ["test", "-Dname=two words"]], + ["maven", "mvnw.cmd", ["test"]], + ["gradle", "gradle.bat", ["build"]], + ["gradle", "gradle.cmd", ["test"]], + ["gradle", "gradlew.bat", ["test"]], + ["pytest", "pytest.exe", ["tests/test_app.py", "-q"]], + ["pytest", String.raw`C:\project\.venv\Scripts\PYTEST.EXE`, ["-q"]], + ["pytest", "PYTHON.EXE", ["-m", "pytest", "tests"]], + ["npm", String.raw`C:\Program Files\nodejs\NPM.CMD`, ["run", "test", "--", "two words"]], + ["npm", "pnpm.COM", ["test"]], + ["application", String.raw`C:\jdk\bin\JAVA.EXE`, ["-cp", "lib/*", "a.Main"]], + ["spring-boot", "java.cmd", ["-cp", "lib/*", "a.App"]], + ["godot", "GODOT.EXE", ["--path", ".", "res://a.tscn"]], + ])("opens %s launcher %s in its form and preserves the command line", (kind, command, args) => { + const original = cfg("windows", { kind, command, args, cwd: "C:/project" }); + const params = formParams(original); + expect(params).not.toBeNull(); + expect(withParams(original, params!)).toMatchObject({ command, args, cwd: original.cwd }); + if (kind === "maven" || kind === "gradle") { + expect(params!.wrapper === "true").toBe(/^(mvnw|gradlew)/.test(command)); + expect(withParams(original, { ...params!, tasks: "clean" })).toMatchObject({ command, args: ["clean"] }); + } + }); + + it("switches launchers when the wrapper or package manager selection changes", () => { + const maven = cfg("m", { kind: "maven", command: "mvnw.cmd", args: ["test"] }); + expect(withParams(maven, { ...formParams(maven)!, wrapper: "false" }).command).toBe("mvn"); + const npm = cfg("n", { kind: "npm", command: "NPM.CMD", args: ["test"] }); + expect(formParams(npm)!.packageManager).toBe("npm"); + expect(withParams(npm, { ...formParams(npm)!, packageManager: "pnpm" }).command).toBe("pnpm"); + }); + + it.each(["mvn.cmd.sh", "other.exe", "/bin/MVN"])("does not accept unrelated Maven commands: %s", (command) => { + expect(formParams(cfg("m", { kind: "maven", command, args: ["test"] }))).toBeNull(); + }); + it("old run.toml entries edit as a Shell Script command line", () => { const old = cfg("user:server", { command: "cargo", args: ["run", "--bin", "my app"] }); const params = formParams(old)!; @@ -96,9 +137,19 @@ describe("configuration types", () => { command: "/Apps/Godot.app/Contents/MacOS/Godot", args: ["--path", "."], }); - expect(godot.paramsFrom!(detected)).toEqual({ projectDir: "", scene: "", arguments: "" }); + expect(godot.paramsFrom!(detected)).toEqual({ + projectDir: "", + scene: "", + arguments: "", + executable: detected.command, + }); const scene = { ...detected, cwd: "demo", args: ["--path", ".", "res://a.tscn", "--verbose"] }; - expect(godot.paramsFrom!(scene)).toEqual({ projectDir: "demo", scene: "res://a.tscn", arguments: "--verbose" }); + expect(godot.paramsFrom!(scene)).toEqual({ + projectDir: "demo", + scene: "res://a.tscn", + arguments: "--verbose", + executable: detected.command, + }); expect(godot.paramsFrom!(cfg("x", { command: "cargo" }))).toBeNull(); }); @@ -221,8 +272,8 @@ describe("configuration types", () => { }); const gradle = cfg("gradle:test", { kind: "gradle", command: "./gradlew", args: ["test"], detected: true }); expect(formParams(gradle)).toMatchObject({ tasks: "test", wrapper: "true" }); - // A toolchain-specific command line the form can't reproduce stays a plain command. - expect(formParams(cfg("m", { kind: "maven", command: "/opt/maven/bin/mvn", args: ["package"] }))).toBeNull(); + // An unrelated executable stays a plain command. + expect(formParams(cfg("m", { kind: "maven", command: "/opt/tools/custom-build", args: ["package"] }))).toBeNull(); expect(withParams(npm, { packageManager: "npm", command: "test", packageDir: "" })).toMatchObject({ command: "npm", args: ["test"], diff --git a/src/features/run/types.ts b/src/features/run/types.ts index db5b01108..95c499db8 100644 --- a/src/features/run/types.ts +++ b/src/features/run/types.ts @@ -6,6 +6,7 @@ import type { RunConfig } from "@/platform/ipc"; import { registerRunConfigType, useRunConfigTypes, type RunConfigTypeContribution } from "@/workbench/contributions"; import { formatArgs, parseArgs, uid, uniqueName } from "./configs"; +import { commandName } from "./commandName"; import { applicationType, gradleForkedJvm, @@ -19,7 +20,6 @@ import { tx } from "@/platform/i18n"; export const COMPOUND = "compound"; const on = (v: string | undefined) => v === "true"; -const baseName = (p: string) => p.replace(/\\/g, "/").split("/").pop() ?? p; const dirOf = (cwd: string | null | undefined) => cwd ?? ""; /** Values of `args[index]` across detected configurations of `kind`. */ @@ -115,7 +115,7 @@ const program: RunConfigTypeContribution = { }; const isScene = (a: string) => /\.t?scn$/i.test(a); -const isGodot = (cmd: string) => /^godot([-.\w]*)$/i.test(baseName(cmd).replace(/\.exe$/i, "")); +const isGodot = (cmd: string) => /^godot([-.\w]*)$/i.test(commandName(cmd)); /** * Godot: `godot --path ` runs the main scene, `… ` one scene. The @@ -148,7 +148,7 @@ const godot: RunConfigTypeContribution = { }, ], commandLine: (p) => ({ - command: "godot", + command: p.executable || "godot", args: ["--path", ".", ...(p.scene?.trim() ? [p.scene.trim()] : []), ...parseArgs(p.arguments ?? "")], cwd: p.projectDir?.trim() || null, }), @@ -158,7 +158,12 @@ const godot: RunConfigTypeContribution = { const path = rest.indexOf("--path"); if (path >= 0) rest.splice(path, 2); const scene = rest.find(isScene) ?? ""; - return { projectDir: dirOf(c.cwd), scene, arguments: formatArgs(rest.filter((a) => a !== scene)) }; + return { + projectDir: dirOf(c.cwd), + scene, + arguments: formatArgs(rest.filter((a) => a !== scene)), + ...(c.command !== "godot" ? { executable: c.command } : {}), + }; }, }; @@ -194,13 +199,22 @@ const npm: RunConfigTypeContribution = { commandLine(p) { const command = p.command || "run"; const args = [command, ...(command === "run" ? [p.script ?? ""] : []), ...parseArgs(p.arguments ?? "")]; - return { command: p.packageManager || "npm", args, cwd: p.packageDir?.trim() || null }; + const manager = p.packageManager || "npm"; + const executable = p.executable && commandName(p.executable) === manager ? p.executable : manager; + return { command: executable, args, cwd: p.packageDir?.trim() || null }; }, paramsFrom(c) { const [command, ...rest] = c.args; - if (!PACKAGE_MANAGERS.includes(c.command) || !command || !NPM_COMMANDS.includes(command)) return null; + if (!PACKAGE_MANAGERS.includes(commandName(c.command)) || !command || !NPM_COMMANDS.includes(command)) return null; const script = command === "run" ? (rest.shift() ?? "") : ""; - return { packageManager: c.command, command, script, arguments: formatArgs(rest), packageDir: dirOf(c.cwd) }; + return { + packageManager: commandName(c.command), + command, + script, + arguments: formatArgs(rest), + packageDir: dirOf(c.cwd), + ...(c.command !== commandName(c.command) ? { executable: c.command } : {}), + }; }, }; @@ -238,16 +252,20 @@ function buildToolType( { key: "wrapper", label: wrapper.label, control: "checkbox" }, ], commandLine: (p) => ({ - command: wrapperCommand(wrapper.unix, wrapper.windows, wrapper.plain, on(p.wrapper)), + command: + p.executable && commandName(p.executable) === (on(p.wrapper) ? wrapper.unix : wrapper.plain) + ? p.executable + : wrapperCommand(wrapper.unix, wrapper.windows, wrapper.plain, on(p.wrapper)), 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; + const name = commandName(c.command); + const usesWrapper = name === wrapper.unix; if (!usesWrapper && name !== wrapper.plain) return null; return { + executable: c.command, projectDir: dirOf(c.cwd), tasks: formatArgs(c.args), arguments: "", @@ -350,7 +368,7 @@ const pytest: RunConfigTypeContribution = { ? 2 : m >= 0 && c.args[m + 1] === "pytest" ? m + 2 - : baseName(c.command).replace(/\.exe$/i, "") === "pytest" + : commandName(c.command) === "pytest" ? 0 : null; if (runner === null) return null;