From ab9bb7c26691597115d0731de1dcb6394ff15551 Mon Sep 17 00:00:00 2001 From: WangTongchao Date: Sat, 7 Nov 2026 03:10:52 +0800 Subject: [PATCH] fix(debugger): name the Java project in debug requests (#1866) java-debug resolves the Java project for Evaluate from `mainClass` only when the class belongs to a project jdtls knows, and a JDWP attach cannot even see the main class, so every expression failed with "Cannot evaluate, please specify projectName in launch.json". Both builders now send `projectName`: the launch one for the project root jdtls resolved, the attach one for the module directory. The name is read from Eclipse's `.project` descriptor, else the Maven `artifactId` (jdtls names a module after its artifact, not its folder), else the folder name. Co-authored-by: chao --- src/features/debugger/__tests__/java.test.ts | 38 ++++++++++++++-- src/features/debugger/java.ts | 48 +++++++++++++++++++- 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/features/debugger/__tests__/java.test.ts b/src/features/debugger/__tests__/java.test.ts index a4a16b2a0..1f6801231 100644 --- a/src/features/debugger/__tests__/java.test.ts +++ b/src/features/debugger/__tests__/java.test.ts @@ -4,6 +4,7 @@ import type { RunConfig } from "@/platform/ipc"; const invoked: Array<[string, unknown[]]> = []; let shortenCommandLine: string | null = null; let kotlinFiles = false; +const files: Record = {}; vi.mock("@/platform/ipc", async (orig) => { const real = await orig(); const ok = (data: unknown) => Promise.resolve({ status: "ok", data }); @@ -17,7 +18,7 @@ vi.mock("@/platform/ipc", async (orig) => { ? [{ path: "/p/api/src/main/java/a/Main.java", relPath: "api/src/main/java/a/Main.java" }] : [], ), - fsReadText: () => ok("package a;\npublic class Main {}"), + fsReadText: (path: string) => ok(files[path] ?? "package a;\npublic class Main {}"), runConfigs: () => ok([]), fsReadDir: (path: string) => ok( @@ -49,7 +50,7 @@ vi.mock("@/platform/ipc", async (orig) => { }; }); -const { isJavaDebuggable, javaAttachConfig, javaLaunchConfig } = await import("../java"); +const { isJavaDebuggable, javaAttachConfig, javaLaunchConfig, javaProjectName } = await import("../java"); const app: RunConfig = { id: "application:a.Main", @@ -90,6 +91,7 @@ describe("Java debugging (java-debug through jdtls)", () => { args: '--port "8081 x"', console: "internalConsole", shortenCommandLine: "none", + projectName: "p", }); }); @@ -162,7 +164,12 @@ describe("Java debugging (java-debug through jdtls)", () => { it("attaches to a JDWP agent", async () => { const l = await javaAttachConfig({ name: "mvn test", module: "", hostName: "127.0.0.1", port: 5005 }, "/p"); expect(l).toMatchObject({ request: "attach", cwd: "/p" }); - expect(JSON.parse(l.extraJson!)).toMatchObject({ debugServer: 41234, hostName: "127.0.0.1", port: 5005 }); + expect(JSON.parse(l.extraJson!)).toMatchObject({ + debugServer: 41234, + hostName: "127.0.0.1", + port: 5005, + projectName: "p", + }); }); }); @@ -176,3 +183,28 @@ it.each(["sibling", "/outside/modules"])("debugs from %s with the main class mod expect(launch.cwd).toBe(cwd.startsWith("/") ? cwd : `/p/${cwd}`); } }); + +describe("naming the Java project jdtls imported", () => { + beforeEach(() => { + invoked.length = 0; + for (const key of Object.keys(files)) delete files[key]; + }); + + it("uses the Maven artifactId, not the folder name, and skips the parent's", async () => { + files["/p/pom.xml"] = + "orders-parentopenidea-fixture-orders"; + expect(await javaProjectName("/p")).toBe("openidea-fixture-orders"); + }); + + it("prefers the Eclipse project descriptor", async () => { + files["/p/.project"] = "taihe"; + files["/p/pom.xml"] = "orders"; + expect(await javaProjectName("/p")).toBe("taihe"); + }); + + it("falls back to the folder name when the project says nothing, including unresolved properties", async () => { + expect(await javaProjectName("/p/api")).toBe("api"); + files["/p/pom.xml"] = "${revision}"; + expect(await javaProjectName("/p")).toBe("p"); + }); +}); diff --git a/src/features/debugger/java.ts b/src/features/debugger/java.ts index 8420f841c..35873c67d 100644 --- a/src/features/debugger/java.ts +++ b/src/features/debugger/java.ts @@ -30,6 +30,37 @@ function join(base: string, p: string) { return rel === "." || rel === "" ? base : `${base.replace(/\/$/, "")}/${rel}`; } +/** Last path segment: jdtls names a Java project after the folder it imported. */ +function baseName(p: string): string { + const s = p.replace(/[\\/]+$/, ""); + const cut = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\")); + return cut >= 0 ? s.slice(cut + 1) : s; +} +/** The name jdtls knows this folder's Java project by: Eclipse's project descriptor for an Eclipse + * project, the Maven artifactId for a module (jdtls names those after the artifact, not the + * folder), and the folder name when neither says anything. + */ +export async function javaProjectName(root: string): Promise { + const descriptor = await readText(join(root, ".project")); + const eclipse = /([^<]+)<\/name>/.exec(descriptor)?.[1]?.trim(); + if (eclipse) return eclipse; + const pom = await readText(join(root, "pom.xml")); + // A child module's own follows the block, which names the parent. + const own = pom.replace(/[\s\S]*?<\/parent>/, ""); + const artifact = /\s*([^<\s]+)\s*<\/artifactId>/.exec(own)?.[1]; + if (artifact && !artifact.includes("${")) return artifact; + return baseName(root); +} + +/** A file of the project, or "" when it is missing (or when there is no filesystem bridge). */ +async function readText(path: string): Promise { + try { + return await call(commands.fsReadText(path)); + } catch { + return ""; + } +} + /** Conventional JVM source sets, including Kotlin kept in a Java source directory. * A mixed module uses Kotlin's adapter, which also accepts Java line breakpoints. */ @@ -125,6 +156,10 @@ export async function javaLaunchConfig( } const port = await debugServer(module, preparation?.signal); preparation?.signal.throwIfAborted(); + // java-debug resolves the Java project for Evaluate on its own from `mainClass` only when + // the class belongs to a project it knows; name the project it imported so that works for + // classes compiled outside the language server's build (plain folders, custom outputs). + const project = await javaProjectName(info.projectRoot ?? root); return { name: rc.name, adapter: JAVA_ADAPTER, @@ -139,6 +174,7 @@ export async function javaLaunchConfig( mainClass: parsed.mainClass, classPaths, modulePaths: info.modulePaths, + projectName: project, javaExec: resolved.command === "java" ? undefined : resolved.command, vmArgs: joinJavaArgs([...consoleVmArgs(rc), ...parsed.vmArgs]), args: joinJavaArgs(parsed.programArgs), @@ -180,6 +216,10 @@ export async function javaAttachConfig(target: JavaAttachTarget, root: string): }; } const port = await debugServer(target.module); + // java-debug resolves the Java project for Evaluate from `mainClass` only when the class is in + // a project it imported; a JDWP attach cannot even see the main class, so name the project the + // language server imported (the directory name of the module it opened). + const project = await javaProjectName(cwd); return { name: target.name, adapter: JAVA_ADAPTER, @@ -188,6 +228,12 @@ export async function javaAttachConfig(target: JavaAttachTarget, root: string): args: [], cwd: target.module ? join(root, target.module) : root, env: {}, - extraJson: JSON.stringify({ debugServer: port, hostName: target.hostName, port: target.port, timeout: 30_000 }), + extraJson: JSON.stringify({ + debugServer: port, + hostName: target.hostName, + port: target.port, + timeout: 30_000, + projectName: project, + }), }; }