From 4a620122e867ebcda34eed0aac34333f480bf491 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:00:01 +0800 Subject: [PATCH] fix(java): use the project host classpath separator (#492) --- crates/ide-lsp/src/jdtls.rs | 3 + src/bindings.ts | 2 + src/features/debugger/__tests__/java.test.ts | 1 + .../debugger/__tests__/javaSeparator.test.ts | 73 +++++++++++++++++++ src/features/debugger/java.ts | 4 +- src/features/run/java.test.ts | 9 ++- src/features/run/resolveCancellation.test.ts | 29 +++++++- src/features/run/store.ts | 4 +- src/platform/javaLaunch.ts | 6 +- 9 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 src/features/debugger/__tests__/javaSeparator.test.ts diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index 3e4698b0f..fa00ed9e5 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -71,6 +71,8 @@ pub struct JavaLaunchInfo { pub project_root: Option, pub class_paths: Vec, pub module_paths: Vec, + /// Path list separator of the host that runs the JVM. + pub path_list_separator: String, /// JDK home jdtls compiles the project against. pub java_home: Option, /// The `java` launcher to use (filled in by the caller from the installed JDKs). @@ -351,6 +353,7 @@ impl LspManager { project_root: cp.project_root, class_paths: cp.class_paths, module_paths: cp.module_paths, + path_list_separator: if cfg!(windows) { ";" } else { ":" }.into(), java_home: java_home.map(Into::into), java_exec: None, shorten_command_line: None, diff --git a/src/bindings.ts b/src/bindings.ts index 632aff179..559ec6695 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -3679,6 +3679,8 @@ export type JavaLaunchInfo = { projectRoot: IpcPath | null, classPaths: string[], modulePaths: string[], + /** Path list separator of the host that runs the JVM. */ + pathListSeparator: string, /** JDK home jdtls compiles the project against. */ javaHome: IpcPath | null, /** The `java` launcher to use (filled in by the caller from the installed JDKs). */ diff --git a/src/features/debugger/__tests__/java.test.ts b/src/features/debugger/__tests__/java.test.ts index 3c5743a2c..1bc17d92e 100644 --- a/src/features/debugger/__tests__/java.test.ts +++ b/src/features/debugger/__tests__/java.test.ts @@ -32,6 +32,7 @@ vi.mock("@/platform/ipc", async (orig) => { projectRoot: "/p", classPaths: ["/p/target/classes", "/m2/a.jar"], modulePaths: [], + pathListSeparator: ":", javaHome: "/jdk21", javaExec: "/jdk21/bin/java", shortenCommandLine, diff --git a/src/features/debugger/__tests__/javaSeparator.test.ts b/src/features/debugger/__tests__/javaSeparator.test.ts new file mode 100644 index 000000000..a6a93d131 --- /dev/null +++ b/src/features/debugger/__tests__/javaSeparator.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { JavaLaunchInfo, RunConfig } from "@/platform/ipc"; +import { resolveJavaCommand } from "@/platform/javaLaunch"; + +const ipc = vi.hoisted(() => ({ + javaResolveLaunch: vi.fn(), + javaDebugServer: vi.fn(async () => ({ status: "ok", data: 5005 })), + fsReadDir: vi.fn(async () => ({ status: "ok", data: [] })), +})); +vi.mock("@/platform/ipc", async (importOriginal) => ({ + ...(await importOriginal()), + commands: ipc, +})); + +import { javaLaunchConfig } from "../java"; + +const config: RunConfig = { + id: "main", + name: "Main", + kind: "application", + command: "java", + args: ["-cp", "${classpath}", "Main"], + cwd: null, + env: {}, + detected: false, + isTest: false, + debugAdapter: null, +}; + +afterEach(() => vi.restoreAllMocks()); + +const hosts = [ + { client: "Win32", host: "Linux", separator: ":", paths: ["/p/classes", "/lib/a;b.jar"] }, + { client: "MacIntel", host: "Windows", separator: ";", paths: ["C:\\p\\classes", "D:\\lib\\a.jar"] }, + { client: "MacIntel", host: "Mac", separator: ":", paths: ["/p/classes", "/lib/a.jar"] }, + { client: "Win32", host: "Windows", separator: ";", paths: ["C:\\p\\classes", "D:\\lib\\a.jar"] }, + { client: "Linux x86_64", host: "Linux", separator: ":", paths: ["/p/classes", "/lib/a.jar"] }, +]; + +describe.each(hosts)("Java launch on $host from $client", ({ client, separator, paths }) => { + const info: JavaLaunchInfo = { + build: "succeeded", + projectRoot: null, + classPaths: paths, + modulePaths: paths, + pathListSeparator: separator, + javaHome: null, + javaExec: "java", + shortenCommandLine: null, + errors: [], + errorCount: 0, + hints: [], + }; + + it.each(["${classpath}", "${testClasspath}"])("joins %s and module paths using the host separator", (placeholder) => { + vi.spyOn(navigator, "platform", "get").mockReturnValue(client); + const result = resolveJavaCommand({ ...config, args: ["-cp", placeholder, "Main"] }, info); + expect(result.args).toEqual(["--module-path", paths.join(separator), "-cp", paths.join(separator), "Main"]); + }); + + it.each(["java-debug", "kotlin-debug-adapter"])( + "splits explicit classpaths for %s without damaging paths", + async (adapter) => { + vi.spyOn(navigator, "platform", "get").mockReturnValue(client); + ipc.javaResolveLaunch.mockResolvedValue({ status: "ok", data: info }); + const result = await javaLaunchConfig( + { ...config, debugAdapter: adapter, args: ["-cp", paths.join(separator), "Main"] }, + "/p", + ); + expect(JSON.parse(result.extraJson!).classPaths).toEqual(paths); + }, + ); +}); diff --git a/src/features/debugger/java.ts b/src/features/debugger/java.ts index 459daca80..3ee175f8c 100644 --- a/src/features/debugger/java.ts +++ b/src/features/debugger/java.ts @@ -107,7 +107,9 @@ export async function javaLaunchConfig( const resolved = resolveJavaCommand(rc, info); const kotlin = rc.debugAdapter === KOTLIN_ADAPTER || (await moduleHasKotlinSources(join(root, module))); preparation?.signal.throwIfAborted(); - const classPaths = parsed.explicitClasspath ? parsed.explicitClasspath.split(/[:;]/) : info.classPaths; + const classPaths = parsed.explicitClasspath + ? parsed.explicitClasspath.split(info.pathListSeparator) + : info.classPaths; if (kotlin) { return { name: rc.name, diff --git a/src/features/run/java.test.ts b/src/features/run/java.test.ts index 6750d3d31..3aae4705c 100644 --- a/src/features/run/java.test.ts +++ b/src/features/run/java.test.ts @@ -194,6 +194,7 @@ describe("java compile errors", () => { projectRoot: "/p", classPaths: [], modulePaths: [], + pathListSeparator: ":", javaHome: null, javaExec: null, shortenCommandLine: null, @@ -220,6 +221,7 @@ describe("java command lines", () => { projectRoot: "/p", classPaths: ["/p/target/classes", "/m2/a.jar"], modulePaths: [], + pathListSeparator: ":", javaHome: "/jdk21", javaExec: "/jdk21/bin/java", shortenCommandLine: "none", @@ -245,10 +247,9 @@ describe("java command lines", () => { const r = resolveJavaCommand({ ...c, args: ["-Dx=1", ...c.args, "arg"] }, info); expect(r.command).toBe("/jdk21/bin/java"); expect(r.args).toEqual(["-Dx=1", "-cp", "/p/target/classes:/m2/a.jar", "a.Main", "arg"]); - expect(resolveJavaCommand(c, { ...info, modulePaths: ["/m/x.jar"] }, ";").args.slice(0, 2)).toEqual([ - "--module-path", - "/m/x.jar", - ]); + expect( + resolveJavaCommand(c, { ...info, modulePaths: ["/m/x.jar"], pathListSeparator: ";" }).args.slice(0, 2), + ).toEqual(["--module-path", "/m/x.jar"]); }); it("joins arguments for java-debug and reads JDWP's port", () => { diff --git a/src/features/run/resolveCancellation.test.ts b/src/features/run/resolveCancellation.test.ts index 2cb382ac8..51702fabf 100644 --- a/src/features/run/resolveCancellation.test.ts +++ b/src/features/run/resolveCancellation.test.ts @@ -38,6 +38,7 @@ const info: JavaLaunchInfo = { projectRoot: "/p", classPaths: ["/p/target/classes"], modulePaths: [], + pathListSeparator: ":", javaHome: null, javaExec: "java", shortenCommandLine: null, @@ -50,7 +51,10 @@ beforeEach(() => { vi.clearAllMocks(); provideNotificationService({ notify }); }); -afterEach(() => resetRuns()); +afterEach(() => { + resetRuns(); + vi.restoreAllMocks(); +}); describe("Stop during Java launch resolution", () => { it.each(["cancelled", "success"])("cancels the request and ignores a late %s response", async (response) => { @@ -81,3 +85,26 @@ describe("Stop during Java launch resolution", () => { expect(runtime(session.id)!.resolveRequest).toBeUndefined(); }); }); + +describe("Java run host separator", () => { + it.each([ + { client: "Win32", separator: ":", paths: ["/p/classes", "/lib/a.jar"] }, + { client: "MacIntel", separator: ";", paths: ["C:\\p\\classes", "D:\\lib\\a.jar"] }, + { client: "MacIntel", separator: ":", paths: ["/p/classes", "/lib/a.jar"] }, + ])("uses $separator from the host on $client", async ({ client, separator, paths }) => { + vi.spyOn(navigator, "platform", "get").mockReturnValue(client); + ipc.javaResolveLaunch.mockResolvedValue({ + status: "ok", + data: { ...info, classPaths: paths, modulePaths: paths, pathListSeparator: separator }, + }); + ipc.runStart.mockResolvedValue({ status: "ok", data: 42 }); + await startRun(config); + expect(ipc.runStart.mock.calls[0]![0].args).toEqual([ + "--module-path", + paths.join(separator), + "-cp", + paths.join(separator), + "Main", + ]); + }); +}); diff --git a/src/features/run/store.ts b/src/features/run/store.ts index e4eb58f2e..ea4011ca9 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -691,8 +691,6 @@ function revealSession(sessionId: string) { } } -const pathSeparator = () => (typeof navigator !== "undefined" && /win/i.test(navigator.platform) ? ";" : ":"); - /** * Builds the module (jdtls workspace build) and resolves `${classpath}` and the * project JDK of a Java application configuration. Null (and a console message) on failure. @@ -737,7 +735,7 @@ async function resolveJvmApplication(sessionId: string, config: RunConfig): Prom say("The classpath is empty: is the module imported by the Java language server?\n", "stderr"); return null; } - return resolveJavaCommand(config, info, pathSeparator()); + return resolveJavaCommand(config, info); } catch (e) { const message = e instanceof Error ? e.message : String(e); if (request.cancelled || isCancelled(e)) { diff --git a/src/platform/javaLaunch.ts b/src/platform/javaLaunch.ts index 35a8db44a..6dde76525 100644 --- a/src/platform/javaLaunch.ts +++ b/src/platform/javaLaunch.ts @@ -72,11 +72,11 @@ export function parseJavaArgs(args: readonly string[]): JavaCommandLine { } /** The configuration with its placeholders and launcher resolved. */ -export function resolveJavaCommand(config: RunConfig, info: JavaLaunchInfo, pathSeparator = ":"): RunConfig { - const cp = info.classPaths.join(pathSeparator); +export function resolveJavaCommand(config: RunConfig, info: JavaLaunchInfo): RunConfig { + const cp = info.classPaths.join(info.pathListSeparator); const args: string[] = []; if (info.modulePaths.length && !config.args.some((a) => a === "-p" || a === "--module-path")) - args.push("--module-path", info.modulePaths.join(pathSeparator)); + args.push("--module-path", info.modulePaths.join(info.pathListSeparator)); args.push(...config.args.map((a) => (a === CLASSPATH || a === TEST_CLASSPATH ? cp : a))); const command = config.command === "java" && info.javaExec ? info.javaExec : config.command; return { ...config, command, args };