mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:02:17 +00:00
Debugging a Python configuration with no interpreter path (a global `pytest`, `uv run`, `poetry run`) set debugpy's adapter Python to a hardcoded `python3`. On Windows that is at most a Store alias, and the debugpy venv the IDE installs has only `python.exe`, so the managed debugpy was skipped. The launch now uses `python` on Windows, based on the host OS so remote sessions follow the server. Other platforms are unchanged. Sibling covered: `debugLauncherPython` uses the same fallback. Co-authored-by: Bortlesboat <[email protected]>
435 lines
17 KiB
TypeScript
435 lines
17 KiB
TypeScript
import { parseArgs, formatArgs } from "@/platform/commandLine";
|
|
/** Run configuration → DAP launch configuration, per adapter. */
|
|
import { workflowSettings } from "@/platform/workflowSettings";
|
|
import type { LaunchConfig, RunConfig } from "@/platform/ipc";
|
|
import { hostOs } from "@/platform/remote";
|
|
|
|
function isAbsolute(p: string) {
|
|
return p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p);
|
|
}
|
|
|
|
function join(base: string, p: string) {
|
|
if (isAbsolute(p)) return p;
|
|
const rel = p.replace(/^\.\//, "");
|
|
return rel === "." || rel === "" ? base : `${base.replace(/\/$/, "")}/${rel}`;
|
|
}
|
|
|
|
/** A command path with a directory part resolved against `cwd`; bare names stay for PATH lookup. */
|
|
function resolveCommand(cmd: string, cwd: string) {
|
|
return cmd.includes("/") ? join(cwd, cmd) : cmd;
|
|
}
|
|
|
|
function splitAtDashDash(args: readonly string[]): [string[], string[]] {
|
|
const i = args.indexOf("--");
|
|
return i < 0 ? [[...args], []] : [args.slice(0, i), args.slice(i + 1)];
|
|
}
|
|
|
|
function base(rc: RunConfig, adapter: string, cwd: string): LaunchConfig {
|
|
return {
|
|
name: rc.name,
|
|
adapter,
|
|
request: "launch",
|
|
program: null,
|
|
args: [],
|
|
cwd,
|
|
env: { ...rc.env },
|
|
extraJson: null,
|
|
};
|
|
}
|
|
|
|
function cargoLaunch(rc: RunConfig, cwd: string, adapter: string): LaunchConfig {
|
|
const [cargoArgs, programArgs] = splitAtDashDash(rc.args);
|
|
const [sub, ...rest] = cargoArgs;
|
|
let args: string[];
|
|
let filter: { name?: string; kind?: string } | undefined;
|
|
if (sub === "test" || sub === "nextest") {
|
|
args = ["test", "--no-run", ...rest.filter((a) => a !== "--no-run")];
|
|
} else {
|
|
args = ["build", ...rest];
|
|
const bin = rest.indexOf("--bin");
|
|
if (bin >= 0 && rest[bin + 1]) filter = { name: rest[bin + 1], kind: "bin" };
|
|
const example = rest.indexOf("--example");
|
|
if (example >= 0 && rest[example + 1]) filter = { name: rest[example + 1], kind: "example" };
|
|
if (!filter) filter = { kind: "bin" };
|
|
}
|
|
return {
|
|
...base(rc, adapter, cwd),
|
|
args: programArgs,
|
|
extraJson: JSON.stringify({ cargo: filter ? { args, filter } : { args }, sourceLanguages: ["rust"] }),
|
|
};
|
|
}
|
|
|
|
const PYTHON_NAME = /^python[\d.]*(\.exe)?$/;
|
|
|
|
/** `python` on Windows (`python3` is at most a Store alias there, and venvs have no `python3.exe`), `python3` elsewhere. */
|
|
function systemPython(): string {
|
|
return hostOs() === "windows" ? "python" : "python3";
|
|
}
|
|
|
|
function pythonFor(rc: RunConfig, cwd: string): string {
|
|
const cmd = rc.command;
|
|
const name = cmd.split("/").pop() ?? cmd;
|
|
if (PYTHON_NAME.test(name)) return resolveCommand(cmd, cwd);
|
|
// A venv tool such as `.venv/bin/pytest`: use the interpreter next to it.
|
|
if (cmd.includes("/")) return resolveCommand(cmd.slice(0, cmd.lastIndexOf("/") + 1) + "python", cwd);
|
|
return systemPython();
|
|
}
|
|
|
|
/** Project managers whose `run` executes in the project's environment (`uv run python …`). */
|
|
const PYTHON_RUNNERS = new Set(["uv", "poetry", "pdm", "pipenv", "hatch", "rye"]);
|
|
|
|
function debugpyLaunch(rc: RunConfig, cwd: string): LaunchConfig {
|
|
const out = base(rc, "debugpy", cwd);
|
|
const dbg = workflowSettings().debugger;
|
|
const extra: Record<string, unknown> = {
|
|
python: pythonFor(rc, cwd),
|
|
console: "internalConsole",
|
|
justMyCode: dbg.skipLibraryCode,
|
|
subProcess: dbg.python.attachSubprocess,
|
|
redirectOutput: true,
|
|
};
|
|
if (dbg.python.gevent) extra.gevent = true;
|
|
const name = (rc.command.split("/").pop() ?? rc.command).replace(/\.exe$/, "");
|
|
let args = rc.args;
|
|
let tool: string | null = PYTHON_NAME.test(name) ? null : name;
|
|
if (PYTHON_RUNNERS.has(name) && args[0] === "run" && args[1]) {
|
|
// The debuggee runs through the manager (`uv run python <debugpy> …`) to get the project's
|
|
// environment; the adapter and its launcher need a plain interpreter.
|
|
const python = PYTHON_NAME.test(args[1]) ? args[1] : "python";
|
|
extra.python = [resolveCommand(rc.command, cwd), "run", python];
|
|
extra.debugLauncherPython = systemPython();
|
|
tool = PYTHON_NAME.test(args[1]) ? null : args[1];
|
|
args = args.slice(2);
|
|
}
|
|
if (tool) {
|
|
// A console script (`pytest`, `uvicorn`, …): run it as a module.
|
|
extra.module = tool;
|
|
out.args = [...args];
|
|
} else if (args[0] === "-m" && args[1]) {
|
|
extra.module = args[1];
|
|
out.args = args.slice(2);
|
|
} else if (args[0]) {
|
|
out.program = join(cwd, args[0]);
|
|
out.args = args.slice(1);
|
|
if (args[0].endsWith("manage.py")) extra.django = true;
|
|
}
|
|
out.extraJson = JSON.stringify(extra);
|
|
return out;
|
|
}
|
|
|
|
/** Replaced by the backend with one free local port per launch (`ide-dap` `FREE_PORT_PLACEHOLDER`). */
|
|
const FREE_PORT = "${freePort}";
|
|
|
|
/** Deno subcommands that run code and take `--inspect-wait`. */
|
|
const DENO_RUNS = new Set(["run", "test", "bench", "serve", "eval"]);
|
|
|
|
/**
|
|
* `deno run …` (and test / bench / serve / eval) under js-debug: Deno waits on its V8
|
|
* inspector at a free port and js-debug attaches to it (`attachSimplePort`), like the
|
|
* VS Code Deno extension. `null` for subcommands that can't take the flag (`deno task`).
|
|
*/
|
|
function denoRuntime(rc: RunConfig): Record<string, unknown> | null {
|
|
const [sub, ...rest] = rc.args;
|
|
if (!sub || !DENO_RUNS.has(sub)) return null;
|
|
const flags = rest.filter((a) => !/^--inspect(-brk|-wait)?(=|$)/.test(a));
|
|
return {
|
|
runtimeExecutable: rc.command,
|
|
runtimeArgs: [sub, `--inspect-wait=127.0.0.1:${FREE_PORT}`, ...flags],
|
|
attachSimplePort: FREE_PORT,
|
|
};
|
|
}
|
|
|
|
function commandName(cmd: string) {
|
|
return (cmd.split(/[\\/]/).pop() ?? cmd).replace(/\.(exe|cmd)$/i, "");
|
|
}
|
|
|
|
/**
|
|
* Why `rc` can't be debugged even though it names a debugger, or null when it can.
|
|
* Bun's inspector speaks the WebKit Inspector protocol, which js-debug can't attach to.
|
|
* Java configurations reach this only when the Run feature found no JVM to attach to.
|
|
*/
|
|
export function debugUnsupportedReason(rc: RunConfig): string | null {
|
|
// java-debug runs inside jdtls: it launches Application configurations or attaches to a JDWP agent.
|
|
if (rc.debugAdapter === "java-debug")
|
|
return "The Java debugger can't start this command itself. Debug an Application, Spring Boot, Maven or Gradle configuration, or start the JVM with a JDWP agent and attach with a Remote JVM Debug configuration.";
|
|
const name = commandName(rc.command);
|
|
if (rc.kind === "bun" || (name === "bun" && rc.kind !== "npm"))
|
|
return (
|
|
"Bun's inspector speaks the WebKit Inspector protocol, which the JavaScript debugger (vscode-js-debug) " +
|
|
"can't attach to. Run it with `bun --inspect` and open the printed debug.bun.sh link, or debug the file with Node.js or Deno."
|
|
);
|
|
if (rc.kind === "deno" || name === "deno") {
|
|
if (!denoRuntime(rc))
|
|
return `\`deno ${rc.args[0] ?? ""}\` can't be started with the debugger. Debug a \`deno run\` or \`deno test\` configuration instead.`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function jsDebugLaunch(rc: RunConfig, cwd: string): LaunchConfig {
|
|
const out = base(rc, "js-debug", cwd);
|
|
const extra: Record<string, unknown> = {
|
|
type: "pwa-node",
|
|
console: "internalConsole",
|
|
outputCapture: "std",
|
|
skipFiles: workflowSettings().debugger.skipLibraryCode ? ["<node_internals>/**", "**/node_modules/**"] : [],
|
|
autoAttachChildProcesses: true,
|
|
};
|
|
const name = commandName(rc.command);
|
|
const deno = name === "deno" ? denoRuntime(rc) : null;
|
|
if (deno) {
|
|
Object.assign(extra, deno);
|
|
} else if (name === "node" && rc.args[0] && !rc.args[0].startsWith("-")) {
|
|
out.program = join(cwd, rc.args[0]);
|
|
out.args = rc.args.slice(1);
|
|
// The Node.js the run configuration picked (settings / version manager), not PATH's.
|
|
if (rc.command !== name) extra.runtimeExecutable = resolveCommand(rc.command, cwd);
|
|
} else {
|
|
extra.runtimeExecutable = rc.command;
|
|
extra.runtimeArgs = [...rc.args];
|
|
}
|
|
out.extraJson = JSON.stringify(extra);
|
|
return out;
|
|
}
|
|
|
|
function delveLaunch(rc: RunConfig, cwd: string, root: string): LaunchConfig {
|
|
const out = base(rc, "delve", cwd);
|
|
const p = rc.params;
|
|
if (p?.goKind) {
|
|
const module = join(root, p.module || ".");
|
|
const target = p.target || ".";
|
|
out.program =
|
|
p.scope === "package" && !target.startsWith(".") && !isAbsolute(target)
|
|
? target
|
|
: join(module, target.split("|")[0]!.trim());
|
|
const test = p.goKind === "test";
|
|
const parsed = delveLaunch(
|
|
{
|
|
...rc,
|
|
params: undefined,
|
|
args: [
|
|
test ? "test" : "build",
|
|
...parseArgs(p.toolArgs ?? ""),
|
|
...(test && p.pattern ? ["-run", p.pattern] : []),
|
|
target,
|
|
...(test ? ["-args", ...parseArgs(p.programArgs ?? "")] : []),
|
|
],
|
|
},
|
|
module,
|
|
root,
|
|
);
|
|
const toolArgs = parseArgs(JSON.parse(parsed.extraJson!).buildFlags ?? "");
|
|
out.args = test ? parsed.args : parseArgs(p.programArgs ?? "");
|
|
out.extraJson = JSON.stringify({
|
|
mode: p.goKind === "test" ? "test" : "debug",
|
|
buildFlags: toolArgs,
|
|
dlvCwd: module,
|
|
...(p.scope === "file" ? { __goFiles: target.split("|").map((f) => join(module, f.trim())) } : {}),
|
|
__goToolArgs: toolArgs,
|
|
__goUseTags: p.useTags !== "false",
|
|
...(p.outputDir ? { output: join(root, `${p.outputDir}/go-debug`) } : {}),
|
|
});
|
|
return out;
|
|
}
|
|
const [sub, ...rest] = rc.args;
|
|
const test = sub === "test";
|
|
const flags: string[] = [];
|
|
const testFlags: string[] = [];
|
|
const targets: string[] = [];
|
|
let programArgs: string[] = [];
|
|
const boolFlags = new Set([
|
|
"-a",
|
|
"-n",
|
|
"-race",
|
|
"-msan",
|
|
"-asan",
|
|
"-cover",
|
|
"-linkshared",
|
|
"-modcacherw",
|
|
"-buildvcs",
|
|
"-trimpath",
|
|
"-v",
|
|
"-work",
|
|
"-x",
|
|
"-json",
|
|
"-c",
|
|
]);
|
|
for (let i = 0; i < rest.length; i++) {
|
|
const a = rest[i]!;
|
|
if (a === "--" || (test && a === "-args")) {
|
|
programArgs = rest.slice(i + 1);
|
|
break;
|
|
}
|
|
const selection =
|
|
test &&
|
|
/^-(run|skip|bench|benchtime|count|timeout|parallel|cpu|list|fuzz|fuzztime|fuzzminimizetime)(?:=(.*))?$/.exec(a);
|
|
if (test && /^-(short|failfast|benchmem|fullpath|v)(?:=|$)/.test(a)) {
|
|
testFlags.push(a.replace(/^-/, "-test."));
|
|
} else if (selection) {
|
|
const value = selection[2] ?? rest[++i];
|
|
if (value !== undefined) testFlags.push(`-test.${selection[1]}`, value);
|
|
} else if (a.startsWith("-")) {
|
|
if (test && (a === "-json" || a === "-c")) continue;
|
|
flags.push(a);
|
|
if (!a.includes("=") && !boolFlags.has(a) && rest[i + 1] !== undefined) flags.push(rest[++i]!);
|
|
} else if (!test && targets.length) {
|
|
programArgs = rest.slice(i);
|
|
break;
|
|
} else targets.push(a);
|
|
}
|
|
const target = targets[0] ?? ".";
|
|
out.program = target.endsWith("/...") ? cwd : join(cwd, target);
|
|
out.args = [...testFlags, ...programArgs];
|
|
out.extraJson = JSON.stringify({
|
|
mode: test ? "test" : "debug",
|
|
...(flags.length ? { buildFlags: formatArgs(flags) } : {}),
|
|
__goToolArgs: flags,
|
|
__goUseTags: rc.params?.useTags !== "false",
|
|
});
|
|
return out;
|
|
}
|
|
|
|
/** The scene a `godot --path <dir> [scene]` command line plays: `main`, or a `res://` path. */
|
|
export function godotScene(args: readonly string[]): string {
|
|
const scene = args.find((a, i) => args[i - 1] !== "--path" && /\.t?scn$/i.test(a));
|
|
if (!scene) return "main";
|
|
return scene.startsWith("res://") ? scene : `res://${scene.replace(/^\.\//, "")}`;
|
|
}
|
|
|
|
/**
|
|
* Godot: the adapter inside the editor plays the scene itself (with the editor's run
|
|
* settings), so the launch names the project (checked against the editor's) and the scene.
|
|
*/
|
|
function godotLaunch(rc: RunConfig, cwd: string): LaunchConfig {
|
|
const i = rc.args.indexOf("--path");
|
|
const dir = i >= 0 ? rc.args[i + 1] : undefined;
|
|
const project = dir ? join(cwd, dir) : cwd;
|
|
return { ...base(rc, "godot", project), extraJson: JSON.stringify({ project, scene: godotScene(rc.args) }) };
|
|
}
|
|
|
|
/** A native program under CodeLLDB or lldb-dap: program, args, cwd, env. */
|
|
function nativeLaunch(rc: RunConfig, cwd: string, adapter: string): LaunchConfig {
|
|
const out: LaunchConfig = { ...base(rc, adapter, cwd), program: resolveCommand(rc.command, cwd), args: [...rc.args] };
|
|
// lldb-dap takes the environment as `KEY=VALUE` strings.
|
|
if (adapter === "lldb-dap") {
|
|
const from = rc.params?.cmakeSourceRoot;
|
|
const to = rc.params?.cmakeCanonicalSourceRoot;
|
|
out.extraJson = JSON.stringify({
|
|
env: Object.entries(rc.env).map(([k, v]) => `${k}=${v}`),
|
|
...(from && to && from !== to ? { sourceMap: [[from, to]] } : {}),
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Flutter: `flutter debug_adapter` starts the tool itself, so `program` is the
|
|
* entry point (`lib/main.dart`) — never the interpreter named by `command`.
|
|
*/
|
|
function flutterLaunch(rc: RunConfig, cwd: string, adapter: string): LaunchConfig {
|
|
// A file-scoped config carries the entry point as its last `.dart` argument
|
|
// (`dart run lib/main.dart`); a user config may name none, which is the
|
|
// conventional Flutter entry point.
|
|
const entry = [...rc.args].reverse().find((a) => a.endsWith(".dart")) ?? "lib/main.dart";
|
|
return {
|
|
...base(rc, adapter, cwd),
|
|
program: join(cwd, entry),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Flutter add-to-app: the host app is built and installed by its own Gradle
|
|
* build, so the debugger attaches to the already running app instead of
|
|
* launching `lib/main.dart` (which would build the module on its own).
|
|
*
|
|
* `params.vm-service-uri` pins the VM Service endpoint (`flutter attach
|
|
* --debug-uri`); without it `flutter attach` discovers the running app on the
|
|
* device. `params.device` becomes `-d <device>`.
|
|
*/
|
|
function flutterAttach(rc: RunConfig, cwd: string, adapter: string): LaunchConfig {
|
|
const p = rc.params ?? {};
|
|
const uri = p["vm-service-uri"] || p.vmServiceUri;
|
|
const device = p.device;
|
|
// `__flutter` tells the adapter launcher to use `flutter debug_adapter`
|
|
// whatever the working directory holds (an add-to-app module lives below a
|
|
// workspace root that has no Flutter dependency of its own). It is removed
|
|
// before the request reaches the adapter.
|
|
const extra: Record<string, unknown> = { __flutter: true };
|
|
if (uri) extra.vmServiceUri = uri;
|
|
if (device) extra.toolArgs = ["-d", device];
|
|
return {
|
|
...base(rc, adapter, cwd),
|
|
request: "attach",
|
|
program: null,
|
|
extraJson: JSON.stringify(extra),
|
|
};
|
|
}
|
|
|
|
const PHP_NAME = /^php(\d+(\.\d+)*)?(\.exe)?$/i;
|
|
|
|
/**
|
|
* php-debug runs `runtimeExecutable runtimeArgs program args` and doesn't enable Xdebug: debug mode and
|
|
* the connection back come from `-d` options, to the free port php-debug picks for `port: 0` (another
|
|
* IDE may listen on 9003). `php` is the PHP chosen in Settings, used when the command doesn't name one.
|
|
*/
|
|
function phpDebugLaunch(rc: RunConfig, cwd: string, php: string | null): LaunchConfig {
|
|
const name = rc.command.split(/[\\/]/).pop() ?? rc.command;
|
|
const runtimeArgs = ["-dxdebug.mode=debug", "-dxdebug.start_with_request=yes", "-dxdebug.client_port=${port}"];
|
|
let runtime = php;
|
|
let program: string;
|
|
let args: string[];
|
|
if (PHP_NAME.test(name)) {
|
|
// `php [options] script args`: the options stay with PHP; the script is a file (`artisan`).
|
|
if (name !== rc.command) runtime = resolveCommand(rc.command, cwd);
|
|
const at = rc.args.findIndex((a) => !a.startsWith("-"));
|
|
runtimeArgs.push(...(at < 0 ? rc.args : rc.args.slice(0, at)));
|
|
program = at < 0 ? "" : join(cwd, rc.args[at]!);
|
|
args = at < 0 ? [] : rc.args.slice(at + 1);
|
|
} else {
|
|
// A PHP script run directly (`vendor/bin/phpunit`; on Windows its `.bat` shim sits beside it).
|
|
program = resolveCommand(rc.command.replace(/\.bat$/i, ""), cwd);
|
|
args = [...rc.args];
|
|
}
|
|
return {
|
|
...base(rc, "php-debug", cwd),
|
|
program,
|
|
args,
|
|
extraJson: JSON.stringify({ port: 0, runtimeArgs, ...(runtime ? { runtimeExecutable: runtime } : {}) }),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The launch configuration that debugs `rc`, or `null` when it has no adapter.
|
|
* `root` is the project root (default working directory).
|
|
*/
|
|
export function launchConfigFor(rc: RunConfig, root: string, php: string | null = null): LaunchConfig | null {
|
|
const adapter = rc.debugAdapter;
|
|
if (!adapter || debugUnsupportedReason(rc)) return null;
|
|
const cwd = rc.cwd ? join(root, rc.cwd) : root;
|
|
if (adapter === "dart") {
|
|
return rc.params?.request === "attach" ? flutterAttach(rc, cwd, adapter) : flutterLaunch(rc, cwd, adapter);
|
|
}
|
|
if (adapter === "codelldb" || adapter === "lldb-dap") {
|
|
return rc.kind === "cargo" ? cargoLaunch(rc, cwd, adapter) : nativeLaunch(rc, cwd, adapter);
|
|
}
|
|
if (adapter === "debugpy") return debugpyLaunch(rc, cwd);
|
|
if (adapter === "js-debug") return jsDebugLaunch(rc, cwd);
|
|
if (adapter === "delve") return delveLaunch(rc, cwd, root);
|
|
if (adapter === "godot") return godotLaunch(rc, cwd);
|
|
if (adapter === "php-debug") return phpDebugLaunch(rc, cwd, php);
|
|
if (adapter === "netcoredbg" && rc.kind === "dotnet") {
|
|
if (!rc.params?.targetPath) return null;
|
|
return {
|
|
...base(rc, adapter, cwd),
|
|
program: rc.params.targetPath,
|
|
args: parseArgs(rc.params.programArgs?.trim() ? rc.params.programArgs : rc.params.profileArgs || ""),
|
|
extraJson: JSON.stringify({ justMyCode: workflowSettings().debugger.skipLibraryCode }),
|
|
};
|
|
}
|
|
// Adapters without a template (hidden in Edit Configurations): generic arguments.
|
|
return {
|
|
...base(rc, adapter, cwd),
|
|
program: resolveCommand(rc.command, cwd),
|
|
args: [...rc.args],
|
|
};
|
|
}
|