/** * Java debugging through Microsoft java-debug, a jdtls bundle: jdtls starts its * DAP server (`vscode.java.startDebugSession`) and the session connects to that * port (`debugServer`). Application configurations are launched by java-debug; * JVMs started by Maven / Gradle with a JDWP agent are attached to. */ import { executeAction } from "@/platform/actions"; import { call, commands, IpcError, nextRequestId, type LaunchConfig, type RunConfig } from "@/platform/ipc"; import { consoleVmArgs, formatCompileErrors, isJvmApplication, joinJavaArgs, parseJavaArgs, prepareJavaLaunch, type JavaPreparation, } from "@/platform/javaLaunch"; 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) && [JAVA_ADAPTER, KOTLIN_ADAPTER].includes(rc.debugAdapter ?? JAVA_ADAPTER); } function join(base: string, p: string) { if (p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p)) return p; const rel = p.replace(/^\.\//, ""); 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. */ export async function moduleHasKotlinSources(cwd: string): Promise { 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)) { notifications().notify({ group: "run", level: "warning", key: "debug.java-debug-missing", title: "The Java debugger is not installed", body: "Debugging Java needs Microsoft java-debug, loaded into the Java language server. Install it and debug again once jdtls has restarted.", sticky: true, actions: [{ label: "Install", run: () => void executeAction("tools.install", JAVA_ADAPTER) }], }); } throw e; } async function debugServer(module: string, signal?: AbortSignal): Promise { try { return await call(commands.javaDebugServer(module)); } catch (e) { signal?.throwIfAborted(); offerInstall(e); } } /** Launch request for an Application configuration (`java … -cp ${classpath} Main args`). */ export async function javaLaunchConfig( rc: RunConfig, root: string, preparation?: JavaPreparation, ): Promise { const cwd = rc.cwd ? join(root, rc.cwd) : root; const parsed = parseJavaArgs(rc.args); if (!parsed.mainClass) throw new Error(`No main class in '${rc.name}'`); preparation?.signal.throwIfAborted(); const { module, info, resolved } = await prepareJavaLaunch( rc, preparation ?? { request: nextRequestId(), signal: new AbortController().signal, }, ); if (info.errorCount > 0 && !preparation?.report) notifications().notify({ group: "run", level: "warning", key: "debug.java-compile-errors", title: `'${rc.name}' has compile errors; debugging the last compiled classes`, body: formatCompileErrors(info).trimEnd(), }); const kotlin = rc.debugAdapter === KOTLIN_ADAPTER || (await moduleHasKotlinSources(join(root, module))); preparation?.signal.throwIfAborted(); const classPaths = parsed.explicitClasspath ? parsed.explicitClasspath.split(info.pathListSeparator) : info.classPaths; if (kotlin) { return { name: rc.name, adapter: KOTLIN_ADAPTER, request: "launch", program: null, args: [], cwd, env: { ...rc.env }, extraJson: JSON.stringify({ projectRoot: join(root, module), mainClass: parsed.mainClass, classPaths, modulePaths: info.modulePaths, javaExec: resolved.command, vmArguments: [...consoleVmArgs(rc), ...parsed.vmArgs], programArgs: parsed.programArgs, }), }; } 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, request: "launch", program: null, // java-debug takes `args` / `vmArgs` as strings. args: [], cwd, env: { ...rc.env }, extraJson: JSON.stringify({ debugServer: port, 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), // java-debug spawns the JVM itself: it must shorten a classpath too long for the OS the same way Run does. shortenCommandLine: info.shortenCommandLine ?? "none", console: "internalConsole", stopOnEntry: false, }), }; } export interface JavaAttachTarget { name: string; /** Module directory (relative to the project root) whose jdtls resolves sources. */ module: string; hostName: string; port: number; } export function isJavaAttachTarget( arg: unknown, ): arg is JavaAttachTarget & { process?: number | null; runConfig?: RunConfig } { return !!arg && typeof arg === "object" && "port" in arg && typeof (arg as { port: unknown }).port === "number"; } /** Attach request for a JVM listening with a JDWP agent. */ export async function javaAttachConfig(target: JavaAttachTarget, root: string): Promise { 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); // 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, request: "attach", program: null, args: [], cwd: target.module ? join(root, target.module) : root, env: {}, extraJson: JSON.stringify({ debugServer: port, hostName: target.hostName, port: target.port, timeout: 30_000, projectName: project, }), }; }