Files
oh-my-pi/packages/coding-agent/src/dap/config.ts
T
roboomp 8c25f7d4aa fix(debug): prefer dlv for go directory launches
Prefer directory-capable adapters when selecting a launch adapter for a
resolved directory program. This keeps Go package directories on dlv in
mixed projects that also expose native-debugger root markers such as a
Makefile, instead of selecting gdb/lldb-dap first and rejecting the
directory during validation.

Added a regression test covering a Go module with both go.mod and
Makefile plus local dlv/gdb adapter shims.

Fixes #2020
2026-06-07 03:03:57 +00:00

190 lines
7.1 KiB
TypeScript

import * as path from "node:path";
import { isRecord } from "@oh-my-pi/pi-utils";
import { hasRootMarkers, resolveCommand } from "../lsp/config";
import DEFAULTS from "./defaults.json" with { type: "json" };
import type { DapAdapterConfig, DapResolvedAdapter } from "./types";
const EXTENSIONLESS_DEBUGGER_ORDER = ["gdb", "lldb-dap"] as const;
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
}
function normalizeObject(value: unknown): Record<string, unknown> {
return isRecord(value) ? { ...value } : {};
}
function normalizeAdapterConfig(config: unknown): DapAdapterConfig | null {
if (!isRecord(config)) return null;
if (typeof config.command !== "string" || config.command.length === 0) return null;
const connectMode = config.connectMode === "socket" ? ("socket" as const) : undefined;
return {
command: config.command,
args: normalizeStringArray(config.args),
languages: normalizeStringArray(config.languages),
fileTypes: normalizeStringArray(config.fileTypes).map(entry => entry.toLowerCase()),
rootMarkers: normalizeStringArray(config.rootMarkers),
launchDefaults: normalizeObject(config.launchDefaults),
attachDefaults: normalizeObject(config.attachDefaults),
acceptsDirectoryProgram: config.acceptsDirectoryProgram === true,
...(connectMode ? { connectMode } : {}),
};
}
function getDefaults(): Record<string, DapAdapterConfig> {
const adapters: Record<string, DapAdapterConfig> = {};
for (const [name, config] of Object.entries(DEFAULTS)) {
const normalized = normalizeAdapterConfig(config);
if (normalized) {
adapters[name] = normalized;
}
}
return adapters;
}
const DEFAULT_ADAPTERS = getDefaults();
export function getAdapterConfigs(): Record<string, DapAdapterConfig> {
return { ...DEFAULT_ADAPTERS };
}
export function resolveAdapter(adapterName: string, cwd: string): DapResolvedAdapter | null {
const config = DEFAULT_ADAPTERS[adapterName];
if (!config) return null;
const resolvedCommand = resolveCommand(config.command, cwd);
if (!resolvedCommand) return null;
return {
name: adapterName,
command: config.command,
args: config.args ?? [],
resolvedCommand,
languages: config.languages ?? [],
fileTypes: config.fileTypes ?? [],
rootMarkers: config.rootMarkers ?? [],
launchDefaults: config.launchDefaults ?? {},
attachDefaults: config.attachDefaults ?? {},
connectMode: config.connectMode ?? "stdio",
acceptsDirectoryProgram: config.acceptsDirectoryProgram === true,
};
}
export function getAvailableAdapters(cwd: string): DapResolvedAdapter[] {
return Object.keys(DEFAULT_ADAPTERS)
.map(name => resolveAdapter(name, cwd))
.filter((adapter): adapter is DapResolvedAdapter => adapter !== null);
}
function getMatchingAdapters(program: string, cwd: string): DapResolvedAdapter[] {
const extension = path.extname(program).toLowerCase();
const available = getAvailableAdapters(cwd);
if (!extension) {
// For extensionless binaries, only consider native debuggers (gdb, lldb-dap)
// or adapters that match by root markers. Don't silently fall back to
// unrelated adapters like debugpy for a C binary.
const nativeDebuggers: ReadonlySet<string> = new Set(EXTENSIONLESS_DEBUGGER_ORDER);
return available.filter(
adapter =>
nativeDebuggers.has(adapter.name) ||
(adapter.rootMarkers.length > 0 && hasRootMarkers(cwd, adapter.rootMarkers)),
);
}
const exactMatches = available.filter(adapter => adapter.fileTypes.includes(extension));
if (exactMatches.length > 0) {
return exactMatches;
}
return available;
}
function sortAdaptersForLaunch(program: string, cwd: string, adapters: DapResolvedAdapter[]): DapResolvedAdapter[] {
const extension = path.extname(program).toLowerCase();
const rootAware = adapters.map(adapter => ({
adapter,
hasExtensionMatch: extension.length > 0 && adapter.fileTypes.includes(extension),
hasRootMatch: adapter.rootMarkers.length > 0 && hasRootMarkers(cwd, adapter.rootMarkers),
}));
rootAware.sort((left, right) => {
if (left.hasExtensionMatch !== right.hasExtensionMatch) {
return left.hasExtensionMatch ? -1 : 1;
}
if (left.hasRootMatch !== right.hasRootMatch) {
return left.hasRootMatch ? -1 : 1;
}
const leftDebuggerRank = EXTENSIONLESS_DEBUGGER_ORDER.indexOf(
left.adapter.name as (typeof EXTENSIONLESS_DEBUGGER_ORDER)[number],
);
const rightDebuggerRank = EXTENSIONLESS_DEBUGGER_ORDER.indexOf(
right.adapter.name as (typeof EXTENSIONLESS_DEBUGGER_ORDER)[number],
);
const normalizedLeftRank = leftDebuggerRank === -1 ? Number.MAX_SAFE_INTEGER : leftDebuggerRank;
const normalizedRightRank = rightDebuggerRank === -1 ? Number.MAX_SAFE_INTEGER : rightDebuggerRank;
if (normalizedLeftRank !== normalizedRightRank) {
return normalizedLeftRank - normalizedRightRank;
}
return left.adapter.name.localeCompare(right.adapter.name);
});
return rootAware.map(entry => entry.adapter);
}
export function selectLaunchAdapter(
program: string,
cwd: string,
adapterName?: string,
programKind: LaunchProgramKind = "file",
): DapResolvedAdapter | null {
if (adapterName) {
return resolveAdapter(adapterName, cwd);
}
const matches = getMatchingAdapters(program, cwd);
const candidates =
programKind === "directory" ? matches.filter(adapter => adapter.acceptsDirectoryProgram) : matches;
const sorted = sortAdaptersForLaunch(program, cwd, candidates.length > 0 ? candidates : matches);
return sorted[0] ?? null;
}
export function selectAttachAdapter(cwd: string, adapterName?: string, port?: number): DapResolvedAdapter | null {
if (adapterName) {
return resolveAdapter(adapterName, cwd);
}
const available = getAvailableAdapters(cwd);
if (port !== undefined) {
const debugpy = available.find(adapter => adapter.name === "debugpy");
if (debugpy) return debugpy;
}
for (const preferred of EXTENSIONLESS_DEBUGGER_ORDER) {
const match = available.find(adapter => adapter.name === preferred);
if (match) return match;
}
return available[0] ?? null;
}
/** How the launch `program` resolves on disk. `"missing"` is reserved for
* programs the adapter creates on demand (rare); we treat them like files. */
export type LaunchProgramKind = "file" | "directory" | "missing";
/** Compute adapter-specific launch arguments that depend on the resolved
* program. Returned values are spread over `adapter.launchDefaults` so they
* take precedence over the static defaults but can still be overridden by
* the fields `DapSessionManager.launch` sets explicitly (program, cwd, args).
*
* Currently scoped to dlv, where `mode` selects how the program path is
* interpreted: directories and `.go` source files debug as a Go package
* (`mode=debug`), anything else is treated as a compiled binary (`mode=exec`).
*/
export function resolveLaunchOverrides(
adapter: DapResolvedAdapter,
program: string,
programKind: LaunchProgramKind,
): Record<string, unknown> {
if (adapter.name === "dlv") {
const extension = path.extname(program).toLowerCase();
if (programKind === "directory" || extension === ".go") {
return { mode: "debug" };
}
if (programKind === "file") {
return { mode: "exec" };
}
}
return {};
}