diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..bbc95b0 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,14 @@ +{ + "name": "VS Code CMake Helper", + "build": { "dockerfile": "../Dockerfile", "context": ".." }, + "workspaceFolder": "/workspace", + "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind", + "remoteUser": "node", + "postCreateCommand": "npm ci", + "customizations": { + "vscode": { + // C/C++ for example/, and the debugger the extension's Debug uses. + "extensions": ["ms-vscode.cpptools"] + } + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a68a532 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules/ +out/ +*.vsix +.git/ +example/build/ +example/cmake-build-*/ diff --git a/.gitignore b/.gitignore index d3e15b1..5ae47d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules/ out/ *.vsix +example/build/ +example/cmake-build-*/ diff --git a/.vscode/launch.json b/.vscode/launch.json index a142310..84b4477 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,6 +8,14 @@ "args": ["--extensionDevelopmentPath=${workspaceFolder}"], "outFiles": ["${workspaceFolder}/out/**/*.js"], "preLaunchTask": "npm: compile" + }, + { + "name": "Run Extension (example)", + "type": "extensionHost", + "request": "launch", + "args": ["--extensionDevelopmentPath=${workspaceFolder}", "${workspaceFolder}/example"], + "outFiles": ["${workspaceFolder}/out/**/*.js"], + "preLaunchTask": "npm: compile" } ] } diff --git a/.vscodeignore b/.vscodeignore index a47ca29..000230a 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -4,3 +4,9 @@ tsconfig.json **/*.map .gitignore .vscode/** +Dockerfile +.dockerignore +.devcontainer/** +.gitea/** +scripts/** +example/** diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bdb0240 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +# Everything the extension needs to be built and packaged (Node), and what it +# drives, to try it on example/: CMake, Ninja, a C++ compiler, GDB. +# Used by scripts/docker.sh and by the dev container (.devcontainer/). +FROM node:22-bookworm + +RUN apt-get update \ + && apt-get install -y --no-install-recommends cmake ninja-build make g++ gdb gdbserver git ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +ENV NPM_CONFIG_UPDATE_NOTIFIER=false +WORKDIR /workspace +USER node +CMD ["bash"] diff --git a/README.md b/README.md index 76192d3..7978f17 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,10 @@ CMake in VS Code the way CLion does it. Standalone: it doesn't need (or want) Mi - **Profiles** (the Profiles view, the `$(layers)` item in the status bar): each one is a build directory with its own build type, generator, toolchain, cache variables and environment. They come from the `cmakeHelper.profiles` setting and from the configure presets in `CMakePresets.json` / `CMakeUserPresets.json`. With neither, there's CLion's default: **Debug** in `cmake-build-debug`. Switching profiles reads that directory's last configure if there is one, and configures otherwise. - **Targets** (the Targets view): executables, libraries and custom targets (`add_custom_target`; `showUtilityTargets` turns them off; CTest's dashboard targets are never listed), each with its sources in CMake's source groups. Inline buttons ▶ Run, 🐞 Debug, 🔨 Build. The menu has *Set as Active*, *Edit Run Configuration…*, *Rebuild*, *Go to Definition in CMakeLists* and *Copy Path of the Built File*. - **One active target** in the status bar: `$(target) app`, then 🔨 ▶ 🐞. Keys as in CLion: **Ctrl+F9** build, **Shift+F10** run, **Shift+F9** debug, **Alt+Shift+F10** choose the target. -- **Run configurations per target**, edited as CLion's *Run/Debug Configurations* (*Edit Run Configuration…* on a target, or *Edit Configurations…* in the target picker): every target on the left, by kind; on the right its executable (the target's own, another target's, or a file: what a custom target or a library runs after it's built), program arguments, working directory (default: the executable's directory), environment (`A=1;B=2`) and *Before launch: Build*. A custom target with no executable is only built by ▶, as in CLion. They're kept in the workspace settings under `cmakeHelper.runConfigurations`. +- **Run configurations per target**, edited as CLion's *Run/Debug Configurations* (*Edit Run Configuration…* on a target, or *Edit Configurations…* in the target picker): every target on the left, by kind; on the right its executable (the target's own, another target's, or a file: what a custom target or a library runs after it's built), program arguments, working directory (default: the executable's directory), environment (`A=1;B=2`) and *Before launch: Build*. A custom target with no executable is only built by ▶, as in CLion; 🐞 debugs the program its command runs (`COMMAND app --flag`: `app` with `--flag`, in the command's directory, with `cmake -E env`'s variables), after building what the target depends on (not the target itself, which would run the command). The command comes from the build system, so this needs a Ninja or Makefile generator. They're kept in the workspace settings under `cmakeHelper.runConfigurations`. - **Debugging** through C/C++ (`cppdbg`, or `cppvsdbg` with MSVC), CodeLLDB or LLDB DAP: the first one installed, or the one set in `cmakeHelper.debugger`. Extra launch properties go in `cmakeHelper.debugConfiguration`. - **CTest in the Test Explorer**: tests are grouped by the target whose executable they run, and by suite for names like `Suite.Test`. Each one sits at its `TEST(…)` / `TEST_CASE("…")` / `BOOST_AUTO_TEST_CASE(…)` in the sources (with ▶ in the gutter), or else at its `add_test`. Running builds the tests' targets first, then calls `ctest` and reads the results from JUnit. Debugging starts the test's command under the debugger. Tests that `gtest_discover_tests` finds only after a build show up once the build is done. -- **Code lenses**: ▶ Run / 🐞 Debug above `main()` in an executable's sources, and Run / Debug / Build above each `add_executable` / `add_library` in CMake files. +- **Code lenses**: ▶ Run / 🐞 Debug above `main()` in an executable's sources, and Run / Debug / Build above each `add_executable` / `add_custom_target`, and Build above each `add_library`, in CMake files. - **Load CMake Project** on a `CMakeLists.txt` (the explorer's and the editor's context menu, as in CLion): that folder becomes the project (`cmakeHelper.sourceDirectory`) and it's configured; on the project's own `CMakeLists.txt` the menu has *Reload CMake Project*. - **Reload**: when one of the project's CMake files changes, a notification and a `Reload CMake` item in the status bar appear (`cmakeHelper.autoReload`: `ask`, `always`, `never`). *Reset Cache and Reload Project* removes `CMakeCache.txt` and `CMakeFiles/` first. A generator mismatch offers the reset by itself. - `compile_commands.json` is exported (`exportCompileCommands`), and can be copied where clangd looks for it (`copyCompileCommands`, e.g. `${sourceDir}/compile_commands.json`). @@ -70,6 +70,23 @@ Updates** looks now; `cmakeHelper.updates.check` turns it off. A release is made by the CI on a `v*` tag matching the version in `package.json`. +## Development + +Everything runs in the image of the `Dockerfile`: Node for the extension, and +CMake, Ninja, g++ and GDB for what it drives. + +- **Dev container:** open this folder in VS Code and *Reopen in Container* + (Dev Containers extension). `npm ci` runs on creation; F5 **Run Extension + (example)** opens `example/`, a small project (a library, an executable, a + CTest test) to try it on. +- **Without VS Code:** `./scripts/docker.sh ` runs a command in the + image, in this folder and as you, e.g. `./scripts/docker.sh npm run package -- + --skip-license`; without a command it's a shell. + +The title bar patch (`cmakeHelper.toolbar`: `titleBar`) can't be tried in the +container: there the extension runs remotely and VS Code's installation is +the host's. + ## Building ``` diff --git a/example/CMakeLists.txt b/example/CMakeLists.txt new file mode 100644 index 0000000..647e9af --- /dev/null +++ b/example/CMakeLists.txt @@ -0,0 +1,25 @@ +# A small project to try the extension on: F5 "Run Extension (example)". +cmake_minimum_required(VERSION 3.20) +project(example CXX) + +set(CMAKE_CXX_STANDARD 17) + +add_library(greet src/greet.cpp) +target_include_directories(greet PUBLIC src) + +add_executable(hello src/main.cpp) +target_link_libraries(hello PRIVATE greet) + +enable_testing() +add_executable(greet_test tests/greet_test.cpp) +target_link_libraries(greet_test PRIVATE greet) +add_test(NAME greet COMMAND greet_test) + +# Custom targets: ▶ builds them (runs their command), 🐞 debugs the program their command runs. +add_custom_target(run_hello + COMMAND hello CMake + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + COMMENT "Running hello") +add_custom_target(run_hello_env + COMMAND ${CMAKE_COMMAND} -E env GREETING=hi $ "with spaces" + COMMAND ${CMAKE_COMMAND} -E echo done) diff --git a/example/src/greet.cpp b/example/src/greet.cpp new file mode 100644 index 0000000..9ff41fb --- /dev/null +++ b/example/src/greet.cpp @@ -0,0 +1,5 @@ +#include "greet.h" + +std::string greet(const std::string& name) { + return "Hello, " + name + "!"; +} diff --git a/example/src/greet.h b/example/src/greet.h new file mode 100644 index 0000000..34bde22 --- /dev/null +++ b/example/src/greet.h @@ -0,0 +1,4 @@ +#pragma once +#include + +std::string greet(const std::string& name); diff --git a/example/src/main.cpp b/example/src/main.cpp new file mode 100644 index 0000000..6ac9029 --- /dev/null +++ b/example/src/main.cpp @@ -0,0 +1,7 @@ +#include +#include "greet.h" + +int main(int argc, char** argv) { + std::cout << greet(argc > 1 ? argv[1] : "world") << std::endl; + return 0; +} diff --git a/example/tests/greet_test.cpp b/example/tests/greet_test.cpp new file mode 100644 index 0000000..2e3dd9f --- /dev/null +++ b/example/tests/greet_test.cpp @@ -0,0 +1,10 @@ +#include +#include "greet.h" + +int main() { + if (greet("CMake") != "Hello, CMake!") { + std::cerr << "unexpected greeting: " << greet("CMake") << std::endl; + return 1; + } + return 0; +} diff --git a/package.json b/package.json index c18b292..68e8ad5 100644 --- a/package.json +++ b/package.json @@ -377,7 +377,7 @@ }, { "command": "cmakeHelper.debug", - "when": "view == cmakeHelper.targets && viewItem =~ /^target\\.executable|\\.run$/", + "when": "view == cmakeHelper.targets && viewItem =~ /^target\\.(executable|utility)|\\.run$/", "group": "inline@2" }, { @@ -397,7 +397,7 @@ }, { "command": "cmakeHelper.debug", - "when": "view == cmakeHelper.targets && viewItem =~ /^target\\.executable|\\.run$/", + "when": "view == cmakeHelper.targets && viewItem =~ /^target\\.(executable|utility)|\\.run$/", "group": "2_run@2" }, { diff --git a/scripts/docker.sh b/scripts/docker.sh new file mode 100755 index 0000000..e303f41 --- /dev/null +++ b/scripts/docker.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# Runs a command in the development image, in this folder, as you (so the +# files it makes are yours): ./scripts/docker.sh npm run package +# Without a command: a shell. The image is (re)built when the Dockerfile changes. +set -e +root=$(cd "$(dirname "$0")/.." && pwd) +image=vs-code-cmake-helper-dev +docker build -q -t "$image" "$root" >/dev/null +tty=; [ -t 0 ] && tty=-it +exec docker run --rm $tty -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$root:/workspace" -w /workspace "$image" "${@:-bash}" diff --git a/src/codeLens.ts b/src/codeLens.ts index 89c350d..e4fbebc 100644 --- a/src/codeLens.ts +++ b/src/codeLens.ts @@ -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] }), diff --git a/src/customTarget.ts b/src/customTarget.ts new file mode 100644 index 0000000..b802204 --- /dev/null +++ b/src/customTarget.ts @@ -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 && && …"; + * `cmake -E env A=B ` 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; + /** 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 { + 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 `/CMakeFiles/` (`-` with Ninja Multi-Config) is the one with the target's commands. */ +async function ninjaCommands(project: CMakeProject, target: Target): Promise { + 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/:` 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 | undefined { + const isCMake = (p: string) => (cmake && samePath(p, cmake)) || /^cmake(\.exe)?$/i.test(path.basename(p)); + const env: Record = {}; + 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; +} diff --git a/src/launcher.ts b/src/launcher.ts index 305b5c9..8ec00b1 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -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 { + 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 { + 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("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 = { ...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 { const config = debugConfiguration(project, l); diff --git a/src/statusBar.ts b/src/statusBar.ts index fc66749..a968ff0 100644 --- a/src/statusBar.ts +++ b/src/statusBar.ts @@ -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(); }