Debug custom targets, and a Docker image to build and develop in
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:
Tomáš ČerníkandClaude Opus 5.5 committed 2026-10-26 09:38:08 +01:00
1 parent fe6fd8be69
commit 27d924ed06
18 files changed
+300 -9

No files matched your search

+2 -1
View File
@@ -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] }),
+118
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}