feat(debugger): Kotlin breakpoints through the Kotlin Debug Adapter (#434)

Kotlin line breakpoints never verified through java-debug because jdtls cannot resolve classes for .kt files. Kotlin Application configurations and JDWP attach for modules with Kotlin sources now use the official Kotlin Debug Adapter (installable from the debugger prompt, running on the language-server JDK); pure Java keeps java-debug.

The adapter resolves classes from source paths, so sources whose directories don't match their package are mirrored package-aligned outside the project (symlinks, copies where links fail) and paths are mapped back for breakpoints and stack frames; files that can't be mirrored are skipped rather than failing the session. Kotlin applications start with JDWP suspend=y and a session-owned JDWP relay holds the adapter's early resume until attach completes, so breakpoints are installed before the program runs.

Limitations (docs/kotlin-debugging.md): @file:JvmName facades are not resolved; package/source-root changes need a new session. Not verified on a real Windows machine.

Fixes #374
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-30 18:15:42 +08:00
1 джерело 92b3ae508c
коміт 379a52d6e7
23 files changed
+850 -14

No files matched your search

+38
Переглянути файл
@@ -3,6 +3,7 @@ import type { RunConfig } from "@/platform/ipc";
const invoked: Array<[string, unknown[]]> = [];
let shortenCommandLine: string | null = null;
let kotlinFiles = false;
vi.mock("@/platform/ipc", async (orig) => {
const real = await orig<typeof import("@/platform/ipc")>();
const ok = (data: unknown) => Promise.resolve({ status: "ok", data });
@@ -10,6 +11,12 @@ vi.mock("@/platform/ipc", async (orig) => {
...real,
commands: {
...real.commands,
fsReadDir: (path: string) =>
ok(
kotlinFiles && path.endsWith("/src")
? [{ name: "Main.kt", path: `${path}/Main.kt`, kind: "file", excluded: false }]
: [],
),
javaResolveLaunch: (...a: unknown[]) => {
invoked.push(["javaResolveLaunch", a]);
return ok({
@@ -52,6 +59,7 @@ describe("Java debugging (java-debug through jdtls)", () => {
beforeEach(() => {
invoked.length = 0;
shortenCommandLine = null;
kotlinFiles = false;
});
it("launches Application configs with the resolved classpath on jdtls' debug server", async () => {
@@ -100,6 +108,36 @@ describe("Java debugging (java-debug through jdtls)", () => {
}
});
it("routes a Kotlin main to the Kotlin adapter and preserves JVM launch arguments", async () => {
const config = { ...app, debugAdapter: "kotlin-debug-adapter" };
expect(isJavaDebuggable(config)).toBe(true);
const l = await javaLaunchConfig(config, "/p");
expect(l).toMatchObject({ adapter: "kotlin-debug-adapter", request: "launch", cwd: "/p/api", env: { A: "1" } });
expect(JSON.parse(l.extraJson!)).toMatchObject({
projectRoot: "/p/api",
mainClass: "a.Main",
classPaths: ["/p/target/classes", "/m2/a.jar"],
javaExec: "/jdk21/bin/java",
vmArguments: ["-Xmx1g"],
programArgs: ["--port", "8081 x"],
});
expect(invoked.some(([command]) => command === "javaDebugServer")).toBe(false);
});
it("routes launch and JDWP attach in a mixed module to Kotlin without starting jdtls' adapter", async () => {
kotlinFiles = true;
expect((await javaLaunchConfig(app, "/p")).adapter).toBe("kotlin-debug-adapter");
const l = await javaAttachConfig({ name: "mvn test", module: "api", hostName: "remote", port: 5005 }, "/p");
expect(l.adapter).toBe("kotlin-debug-adapter");
expect(JSON.parse(l.extraJson!)).toEqual({
projectRoot: "/p/api",
hostName: "remote",
port: 5005,
timeout: 30_000,
});
expect(invoked.some(([command]) => command === "javaDebugServer")).toBe(false);
});
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" });
+3
Переглянути файл
@@ -18,6 +18,9 @@ describe("missing debug adapters", () => {
tool: "js-debug",
title: "The JavaScript debugger (vscode-js-debug) is not installed",
});
expect(
missingAdapter("kotlin-debug-adapter", unavailable("debug adapter `kotlin-debug-adapter` not found (x)"))?.tool,
).toBe("kotlin-debug-adapter");
expect(missingAdapter("delve", unavailable("debug adapter `delve` not found (x)"))?.tool).toBe("delve");
});
+55 -2
Переглянути файл
@@ -16,9 +16,10 @@ import {
import { notifications } from "@/workbench/contributions";
export const JAVA_ADAPTER = "java-debug";
export const KOTLIN_ADAPTER = "kotlin-debug-adapter";
export function isJavaDebuggable(rc: RunConfig): boolean {
return isJvmApplication(rc) && (rc.debugAdapter ?? JAVA_ADAPTER) === JAVA_ADAPTER;
return isJvmApplication(rc) && [JAVA_ADAPTER, KOTLIN_ADAPTER].includes(rc.debugAdapter ?? JAVA_ADAPTER);
}
function join(base: string, p: string) {
@@ -27,6 +28,24 @@ function join(base: string, p: string) {
return rel === "." || rel === "" ? base : `${base.replace(/\/$/, "")}/${rel}`;
}
/** 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.
*/
export async function moduleHasKotlinSources(cwd: string): Promise<boolean> {
const pending = [join(cwd, "src")];
while (pending.length) {
const path = pending.pop()!;
const entries = await call(commands.fsReadDir(path)).catch(() => []);
for (const entry of entries) {
if (entry.excluded) continue;
if ((entry.kind === "file" || entry.targetKind === "file") && entry.name.endsWith(".kt")) return true;
// Do not follow directory symlinks: a source tree can link back to a parent.
if (entry.kind === "dir") pending.push(entry.path);
}
}
return false;
}
/** java-debug missing from jdtls: offer the one-click install (jdtls restarts with the bundle). */
function offerInstall(e: unknown): never {
if (e instanceof IpcError && e.kind === "unavailable" && /java-debug|debugger/i.test(e.message)) {
@@ -67,8 +86,29 @@ export async function javaLaunchConfig(rc: RunConfig, root: string): Promise<Lau
body: formatCompileErrors(info).trimEnd(),
});
const resolved = resolveJavaCommand(rc, info);
const port = await debugServer(module);
const kotlin = rc.debugAdapter === KOTLIN_ADAPTER || (await moduleHasKotlinSources(cwd));
const classPaths = parsed.explicitClasspath ? parsed.explicitClasspath.split(/[:;]/) : info.classPaths;
if (kotlin) {
return {
name: rc.name,
adapter: KOTLIN_ADAPTER,
request: "launch",
program: null,
args: [],
cwd,
env: { ...rc.env },
extraJson: JSON.stringify({
projectRoot: cwd,
mainClass: parsed.mainClass,
classPaths,
modulePaths: info.modulePaths,
javaExec: resolved.command,
vmArguments: parsed.vmArgs,
programArgs: parsed.programArgs,
}),
};
}
const port = await debugServer(module);
return {
name: rc.name,
adapter: JAVA_ADAPTER,
@@ -110,6 +150,19 @@ export function isJavaAttachTarget(
/** Attach request for a JVM listening with a JDWP agent. */
export async function javaAttachConfig(target: JavaAttachTarget, root: string): Promise<LaunchConfig> {
const cwd = target.module ? join(root, target.module) : root;
if (await moduleHasKotlinSources(cwd)) {
return {
name: target.name,
adapter: KOTLIN_ADAPTER,
request: "attach",
program: null,
args: [],
cwd,
env: {},
extraJson: JSON.stringify({ projectRoot: cwd, hostName: target.hostName, port: target.port, timeout: 30_000 }),
};
}
const port = await debugServer(target.module);
return {
name: target.name,
+1
Переглянути файл
@@ -6,6 +6,7 @@ import { IpcError } from "@/platform/ipc";
/** Adapters the tools installer can provide (adapter id → tool name). */
const INSTALLABLE: Record<string, { tool: string; title: string }> = {
"kotlin-debug-adapter": { tool: "kotlin-debug-adapter", title: "The Kotlin debugger is not installed" },
debugpy: { tool: "debugpy", title: "debugpy is not installed" },
"js-debug": { tool: "js-debug", title: "The JavaScript debugger (vscode-js-debug) is not installed" },
delve: { tool: "delve", title: "Delve is not installed" },