Debug custom targets, and a Docker image to build and develop in
Build / build (pull_request) Successful in 9m40s
Build / build (pull_request) Successful in 9m40s
- 🐞 on a custom target without an executable set debugs the program its command runs, as the build system has it (Ninja's edge, a Makefile's recipe): its arguments, directory and cmake -E env variables; what the target depends on is built first, not the target itself - Debug for custom targets in the status bar, the Targets view and the code lenses above add_custom_target - Dockerfile (Node, CMake, Ninja, g++, GDB), scripts/docker.sh to run a command in it, a dev container, and example/ to try the extension on Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fe6fd8be69
commit
27d924ed06
18 files changed
+300
-9
No files matched your search
+2
-1
@@ -49,7 +49,8 @@ export class LensProvider implements vscode.CodeLensProvider {
|
||||
const d = t.definition;
|
||||
if (!d || !samePath(d.file, doc.fileName) || d.line > doc.lineCount) continue;
|
||||
const range = doc.lineAt(d.line - 1).range;
|
||||
if (t.type === "EXECUTABLE") {
|
||||
// A custom target's ▶ builds it (runs its command), 🐞 debugs the program its command runs.
|
||||
if (t.type === "EXECUTABLE" || t.type === "UTILITY") {
|
||||
lenses.push(
|
||||
new vscode.CodeLens(range, { title: "$(play) Run", command: "cmakeHelper.run", arguments: [t.name] }),
|
||||
new vscode.CodeLens(range, { title: "$(debug-alt) Debug", command: "cmakeHelper.debug", arguments: [t.name] }),
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* What a custom target (add_custom_target) runs, so it can be debugged: the
|
||||
* File API doesn't say, the build system does. Ninja gives the command of the
|
||||
* target's edge (`ninja -t commands -s`), a Makefile has it as the recipe in
|
||||
* the target's build.make. Each command is "cd <dir> && <program> <args> && …";
|
||||
* `cmake -E env A=B <program>` sets its environment, and CMake's own steps
|
||||
* (`cmake -E echo`…) aren't what's debugged.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { Target } from "./fileApi";
|
||||
import type { CMakeProject } from "./project";
|
||||
import { exists, isWindows, run, samePath, splitArgs } from "./util";
|
||||
|
||||
export interface CustomCommand {
|
||||
program: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
/** The program is one of the project's executables. */
|
||||
ownExecutable: boolean;
|
||||
}
|
||||
|
||||
/** The programs the target's commands run, or undefined when its generator isn't one this knows (Visual Studio, Xcode). */
|
||||
export async function customCommands(project: CMakeProject, target: Target): Promise<CustomCommand[] | undefined> {
|
||||
const model = project.model;
|
||||
if (!model) return undefined;
|
||||
let lines: string[] | undefined;
|
||||
if (/Ninja/.test(model.generator)) lines = await ninjaCommands(project, target);
|
||||
else if (/Makefiles/.test(model.generator)) lines = makeCommands(project, target);
|
||||
if (!lines) return undefined;
|
||||
const cmake = model.cache.get("CMAKE_COMMAND");
|
||||
const executables = project.targets.filter((t) => t.type === "EXECUTABLE").flatMap((t) => t.artifacts);
|
||||
const out: CustomCommand[] = [];
|
||||
for (const line of lines) {
|
||||
let cwd = model.buildDir;
|
||||
for (const step of steps(line)) {
|
||||
if (step[0] === "cd" && step.length === 2) {
|
||||
cwd = path.resolve(cwd, step[1]!);
|
||||
continue;
|
||||
}
|
||||
const c = command(step, cwd, cmake);
|
||||
if (c) out.push({ ...c, ownExecutable: executables.some((e) => samePath(e, c.program)) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The edge `<dir>/CMakeFiles/<name>` (`-<config>` with Ninja Multi-Config) is the one with the target's commands. */
|
||||
async function ninjaCommands(project: CMakeProject, target: Target): Promise<string[]> {
|
||||
const model = project.model!;
|
||||
const rel = path.relative(model.buildDir, target.buildDir).split(path.sep).join("/");
|
||||
const config = /Multi-Config/.test(model.generator) ? `-${project.configuration}` : "";
|
||||
const edge = `${rel ? `${rel}/` : ""}CMakeFiles/${target.name}${config}`;
|
||||
const ninja = model.cache.get("CMAKE_MAKE_PROGRAM") || "ninja";
|
||||
const r = await run(ninja, ["-C", model.buildDir, "-t", "commands", "-s", edge]);
|
||||
return r.code === 0 ? r.stdout.split(/\r?\n/).filter((l) => l.trim()) : [];
|
||||
}
|
||||
|
||||
/** The recipe of `…/CMakeFiles/<name>:` in the target's build.make; its lines run in the build directory. */
|
||||
function makeCommands(project: CMakeProject, target: Target): string[] {
|
||||
const file = path.join(target.buildDir, "CMakeFiles", `${target.name}.dir`, "build.make");
|
||||
if (!exists(file)) return [];
|
||||
const cmake = project.model!.cache.get("CMAKE_COMMAND") ?? "cmake";
|
||||
const header = new RegExp(`^(\\S*/)?CMakeFiles/${target.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:`);
|
||||
const lines: string[] = [];
|
||||
let inRule = false;
|
||||
for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
|
||||
if (line.startsWith("\t")) {
|
||||
if (inRule) lines.push(line.slice(1).replace(/^[@-]+/, "").replace(/\$\(CMAKE_COMMAND\)/g, cmake));
|
||||
} else {
|
||||
inRule = header.test(line);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** "a b && c d" to [["a", "b"], ["c", "d"]]. */
|
||||
function steps(line: string): string[][] {
|
||||
const out: string[][] = [[]];
|
||||
for (const word of splitArgs(line)) {
|
||||
if (word === "&&") out.push([]);
|
||||
else out[out.length - 1]!.push(word);
|
||||
}
|
||||
return out.filter((s) => s.length);
|
||||
}
|
||||
|
||||
/** A step to a program to debug: `cmake -E env` unwrapped, CMake's own steps (echo, copy…) left out. */
|
||||
function command(step: string[], cwd: string, cmake: string | undefined): Omit<CustomCommand, "ownExecutable"> | undefined {
|
||||
const isCMake = (p: string) => (cmake && samePath(p, cmake)) || /^cmake(\.exe)?$/i.test(path.basename(p));
|
||||
const env: Record<string, string> = {};
|
||||
let words = step;
|
||||
if (isCMake(words[0]!) && words[1] === "-E" && words[2] === "env") {
|
||||
words = words.slice(3);
|
||||
while (words.length && (/^[A-Za-z_]\w*=/.test(words[0]!) || words[0]!.startsWith("--"))) {
|
||||
const w = words.shift()!;
|
||||
if (w === "--") break;
|
||||
const i = w.indexOf("=");
|
||||
if (!w.startsWith("--")) env[w.slice(0, i)] = w.slice(i + 1);
|
||||
}
|
||||
}
|
||||
if (!words.length || isCMake(words[0]!)) return undefined;
|
||||
return { program: resolveProgram(words[0]!, cwd), args: words.slice(1), cwd, env };
|
||||
}
|
||||
|
||||
/** A path as the shell would take it: relative to the directory, a bare name from PATH. */
|
||||
function resolveProgram(program: string, cwd: string): string {
|
||||
if (path.isAbsolute(program)) return program;
|
||||
if (program.includes("/") || (isWindows && program.includes("\\"))) return path.resolve(cwd, program);
|
||||
const exts = isWindows ? (process.env.PATHEXT ?? ".EXE").split(";") : [""];
|
||||
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
||||
for (const ext of exts) {
|
||||
const p = path.join(dir, program + ext);
|
||||
if (dir && exists(p)) return p;
|
||||
}
|
||||
}
|
||||
return program;
|
||||
}
|
||||
+45
-1
@@ -4,11 +4,13 @@
|
||||
* working directory, environment, build before run), kept in the setting
|
||||
* `cmakeHelper.runConfigurations` by target name. An executable runs itself
|
||||
* unless another is set; a custom target or a library runs the executable set
|
||||
* for it, and without one ▶ only builds it.
|
||||
* for it, and without one ▶ only builds it, and 🐞 debugs the program the
|
||||
* custom target's command runs (customTarget.ts).
|
||||
*/
|
||||
import * as path from "node:path";
|
||||
import * as vscode from "vscode";
|
||||
import { type Target, executableOf } from "./fileApi";
|
||||
import { type CustomCommand, customCommands } from "./customTarget";
|
||||
import type { CMakeProject } from "./project";
|
||||
import { errorText, exists, expand, isWindows, onPath, settings, splitArgs } from "./util";
|
||||
|
||||
@@ -105,12 +107,54 @@ export async function runTarget(project: CMakeProject, target: Target): Promise<
|
||||
|
||||
/** 🐞 Debug: builds it (if set to), then starts a debug session on its program. */
|
||||
export async function debugTarget(project: CMakeProject, target: Target): Promise<void> {
|
||||
if (!programOf(project, target) && target.type === "UTILITY") return debugCustomTarget(project, target);
|
||||
if (!programOf(project, target)) return noProgram(target, "debug");
|
||||
if (!(await buildFirst(project, target))) return;
|
||||
const l = launchOf(project, target);
|
||||
if (l) await debugProgram(project, l);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🐞 on a custom target without an executable set: the program its command runs (the project's
|
||||
* own executable, if one; else a choice), as the command runs it. What it depends on is built
|
||||
* first, not the target itself: that would run the command.
|
||||
*/
|
||||
async function debugCustomTarget(project: CMakeProject, target: Target): Promise<void> {
|
||||
const commands = await customCommands(project, target);
|
||||
if (!commands) {
|
||||
void vscode.window.showWarningMessage(`Debugging a custom target needs a Ninja or Makefile generator (this is ${project.model?.generator}): set an executable for ${target.name} in its run configuration.`);
|
||||
return;
|
||||
}
|
||||
const own = commands.filter((c) => c.ownExecutable);
|
||||
let c: CustomCommand | undefined = own.length === 1 ? own[0] : commands.length === 1 ? commands[0] : undefined;
|
||||
if (!c && commands.length) {
|
||||
const pick = await vscode.window.showQuickPick(
|
||||
(own.length ? own : commands).map((x) => ({ label: path.basename(x.program), description: x.args.join(" "), detail: x.program, command: x })),
|
||||
{ title: `Debug ${target.name}: which of its commands?` },
|
||||
);
|
||||
if (!pick) return;
|
||||
c = pick.command;
|
||||
}
|
||||
if (!c) return noProgram(target, "debug");
|
||||
const conf = runConfiguration(target.name);
|
||||
if (conf.buildBeforeRun ?? settings().get<boolean>("buildBeforeRun", true)) {
|
||||
const deps = target.dependencies.map((id) => project.targets.find((t) => t.id === id)?.name).filter((n): n is string => !!n);
|
||||
if (deps.length && !(await project.build(deps))) {
|
||||
const action = await vscode.window.showErrorMessage(`Building what ${target.name} depends on failed.`, "Debug Anyway");
|
||||
if (action !== "Debug Anyway") return;
|
||||
}
|
||||
}
|
||||
if (!exists(c.program)) {
|
||||
void vscode.window.showErrorMessage(`${c.program} isn't there. Build what ${target.name} depends on first.`);
|
||||
return;
|
||||
}
|
||||
const vars = project.vars(target);
|
||||
const env: Record<string, string> = { ...project.activeProfile.env, ...c.env };
|
||||
for (const [k, v] of Object.entries(conf.environment ?? {})) env[k] = expand(String(v), vars);
|
||||
const cwd = conf.workingDirectory ? path.resolve(project.sourceDir, expand(conf.workingDirectory, vars)) : c.cwd;
|
||||
await debugProgram(project, { name: target.name, program: c.program, args: [...c.args, ...splitArgs(expand(conf.args ?? "", vars))], cwd, env });
|
||||
}
|
||||
|
||||
/** Starts a debugger on a program (also a test's command). */
|
||||
export async function debugProgram(project: CMakeProject, l: Launch): Promise<boolean> {
|
||||
const config = debugConfiguration(project, l);
|
||||
|
||||
+4
-3
@@ -98,13 +98,14 @@ export class StatusBar implements vscode.Disposable {
|
||||
this.build.tooltip = busy ? "Busy…" : `Build ${t.name} (Ctrl+F9)`;
|
||||
this.build.text = p.state === "building" ? "$(loading~spin)" : "$(tools)";
|
||||
this.build.show();
|
||||
// ▶ for what has a program to run, and for a custom target (it's built); 🐞 only with a program.
|
||||
// ▶ for what has a program to run, and for a custom target (it's built); 🐞 for those too.
|
||||
const program = !!programOf(p, t);
|
||||
this.run.tooltip = program ? `Run ${t.name} (Shift+F10)` : `Build ${t.name}: it runs no program (Shift+F10)`;
|
||||
this.debug.tooltip = `Debug ${t.name} (Shift+F9)`;
|
||||
// A custom target's 🐞 debugs the program its command runs.
|
||||
this.debug.tooltip = program || t.type !== "UTILITY" ? `Debug ${t.name} (Shift+F9)` : `Debug the program ${t.name}'s command runs (Shift+F9)`;
|
||||
if (program || t.type === "UTILITY") this.run.show();
|
||||
else this.run.hide();
|
||||
if (program) this.debug.show();
|
||||
if (program || t.type === "UTILITY") this.debug.show();
|
||||
else this.debug.hide();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user