CMake the way CLion does it (profiles and presets, targets, run and debug configurations, CTest), moved out of personal-assistant's cmake-extension and renamed from CMake Targets. - Gitea Actions: typecheck, package the .vsix, release it on a v* tag - Updates: the latest Gitea release is looked at every 10 minutes and offered for install; a private repository's token in the secret storage - cmakeHelper.toolbar: the toolbar in the status bar, the editors' title, or VS Code's title bar (workbench.html patched, offered again after VS Code or extension updates) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f8032fd65d
commit
fe6fd8be69
28 files changed
+9061
No files matched your search
@@ -0,0 +1,53 @@
|
|||||||
|
name: Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["v*"]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: Check tag matches package.json version
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
run: |
|
||||||
|
pkg="v$(node -p 'require("./package.json").version')"
|
||||||
|
if [ "$pkg" != "${GITHUB_REF_NAME}" ]; then
|
||||||
|
echo "Tag ${GITHUB_REF_NAME} does not match package.json version $pkg"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Package
|
||||||
|
run: npm run package -- --skip-license
|
||||||
|
|
||||||
|
# upload-artifact@v4 is not supported by older Gitea versions
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: vsix
|
||||||
|
path: "*.vsix"
|
||||||
|
|
||||||
|
- name: Release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
uses: akkuman/gitea-release-action@v1
|
||||||
|
with:
|
||||||
|
files: "*.vsix"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
out/
|
||||||
|
*.vsix
|
||||||
Vendored
+13
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Run Extension",
|
||||||
|
"type": "extensionHost",
|
||||||
|
"request": "launch",
|
||||||
|
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
|
||||||
|
"outFiles": ["${workspaceFolder}/out/**/*.js"],
|
||||||
|
"preLaunchTask": "npm: compile"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
src/**
|
||||||
|
node_modules/**
|
||||||
|
tsconfig.json
|
||||||
|
**/*.map
|
||||||
|
.gitignore
|
||||||
|
.vscode/**
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# VS Code CMake Helper
|
||||||
|
|
||||||
|
CMake in VS Code the way CLion does it. Standalone: it doesn't need (or want) Microsoft's CMake Tools, so turn that off in workspaces where this one runs. Projects are read through CMake's File API (CMake 3.14+; presets need 3.19+, test results 3.21+).
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- **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`.
|
||||||
|
- **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.
|
||||||
|
- **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`).
|
||||||
|
- For `launch.json` / `tasks.json`: `${command:cmakeHelper.activeProgram}`, `${command:cmakeHelper.activeTarget}`, `${command:cmakeHelper.buildDirectory}`, `${command:cmakeHelper.buildType}`.
|
||||||
|
|
||||||
|
## Embedded and remote debugging
|
||||||
|
|
||||||
|
As CLion's *Embedded GDB Server* and *Remote GDB Server* run configurations: **CMake: Edit GDB Server Configurations…** (or *New GDB Server Configuration…* in the target picker). A configuration chosen in the target picker (it shows there under the targets) is what the toolbar's 🔨, ▶ and 🐞 act on; choosing a target goes back to running it here.
|
||||||
|
|
||||||
|
- **Embedded GDB Server**: a GDB server started here (OpenOCD, SEGGER J-Link, ST-LINK's st-util, pyOCD: *Fill in for* sets the usual arguments), gdb (`gdb-multiarch`, `arm-none-eabi-gdb`…) connected with `target remote`, the program downloaded (*Always*, *If updated*, *Never*) between two reset commands. 🐞 debugs it; ▶ is *Download & Run*: flash, start it (`monitor reset run` by default), detach, no debugger.
|
||||||
|
- **Remote GDB Server**: gdb connected to a gdbserver elsewhere, with a sysroot and path mappings. With an SSH host the program is first uploaded (scp) and gdbserver started there over SSH, and ▶ runs it there.
|
||||||
|
|
||||||
|
Debugging goes through C/C++ (`ms-vscode.cpptools`, `cppdbg`), which starts the server and connects. The configurations are kept in the workspace setting `cmakeHelper.gdbConfigurations`.
|
||||||
|
|
||||||
|
## Profiles in settings
|
||||||
|
|
||||||
|
**CMake: Edit Profiles…** (the ⚙ on a profile, or in the profile picker) edits them as CLion's Settings › Build › CMake does: the list with add, remove, copy and move, and each profile's name, *Enable profile*, build type, toolchain file, generator, CMake options, build directory, build options and environment; OK / Cancel / Apply. Presets are shown read-only; copying one makes a profile of it. It writes the workspace setting below (cache variables become `-D` CMake options).
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"cmakeHelper.profiles": [
|
||||||
|
{ "name": "Debug", "buildType": "Debug" },
|
||||||
|
{ "name": "Release", "buildType": "Release", "generator": "Ninja" },
|
||||||
|
{
|
||||||
|
"name": "ASan",
|
||||||
|
"buildType": "RelWithDebInfo",
|
||||||
|
"buildDirectory": "cmake-build-asan",
|
||||||
|
"cacheVariables": { "CMAKE_CXX_FLAGS": "-fsanitize=address", "BUILD_TESTING": true },
|
||||||
|
"environment": { "ASAN_OPTIONS": "detect_leaks=1" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Toolbar
|
||||||
|
|
||||||
|
`cmakeHelper.toolbar` says where CLion's toolbar (profile, target, Build, Run,
|
||||||
|
Debug) goes:
|
||||||
|
|
||||||
|
- `statusBar` (default): in the status bar.
|
||||||
|
- `editorTitle`: Build, Run, Debug and the profile and target pickers as
|
||||||
|
buttons at the top of the editors; the names stay in the status bar.
|
||||||
|
- `titleBar`: in VS Code's title bar, as in Jarvis's web IDE. An extension
|
||||||
|
can't put anything there, so VS Code itself is patched: `media/titlebar.js`
|
||||||
|
goes next to its `workbench.html` and into it, and `product.json`'s checksum
|
||||||
|
is updated so VS Code doesn't call its installation corrupt. VS Code is
|
||||||
|
usually root's, so it asks for your password (pkexec). After a VS Code update
|
||||||
|
(which drops the patch) or an update of this extension, it offers to patch
|
||||||
|
again. **CMake: Remove the CMake Toolbar from the Title Bar** undoes it.
|
||||||
|
|
||||||
|
## Updates
|
||||||
|
|
||||||
|
The extension looks at the latest release of `shin/VS-Code-CMake-helper` on
|
||||||
|
git.cernikovi.cz on start and every 10 minutes, and offers a newer one: Install
|
||||||
|
downloads its `.vsix` and installs it. The repository is private, so it needs a
|
||||||
|
Gitea access token with read access: **CMake: Set Gitea Token for CMake Helper
|
||||||
|
Updates** (kept in VS Code's secret storage). **CMake: Check for CMake Helper
|
||||||
|
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`.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
npm run compile # out/
|
||||||
|
npm run package # .vsix
|
||||||
|
```
|
||||||
|
|
||||||
|
To try it, open this folder in VS Code and press F5 (Run Extension), or install the `.vsix`.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"><path d="M12 2 2 21h20L12 2Z"/><path d="M12 2v13l10 6"/><path d="M12 15 2 21"/></svg>
|
||||||
|
After Width: | Height: | Size: 223 B |
@@ -0,0 +1,149 @@
|
|||||||
|
/*
|
||||||
|
* CLion's toolbar in VS Code's title bar: the CMake profile and target, then
|
||||||
|
* Build, Run and Debug, next to the command center. An extension can't put
|
||||||
|
* anything in the title bar, so CMake Helper copies this script next to VS
|
||||||
|
* Code's workbench.html and adds it there (src/titleBar.ts). It draws nothing
|
||||||
|
* of its own: it mirrors the extension's status bar items (text, icon,
|
||||||
|
* tooltip, colours) and a click on one clicks the item, so the extension
|
||||||
|
* keeps all the logic. While it's shown, those status bar items are hidden.
|
||||||
|
* Plain DOM, no innerHTML: the workbench requires trusted types.
|
||||||
|
*/
|
||||||
|
(() => {
|
||||||
|
const PREFIX = "cernik.vs-code-cmake-helper.cmakeHelper.";
|
||||||
|
/** The mirrored items, in the toolbar's order; `group` items share one bordered box, like CLion's run configuration. */
|
||||||
|
const ITEMS = [
|
||||||
|
{ id: "reload", kind: "text" },
|
||||||
|
{ id: "profile", kind: "text", group: true },
|
||||||
|
{ id: "target", kind: "text", group: true },
|
||||||
|
{ id: "build", kind: "icon" },
|
||||||
|
{ id: "run", kind: "icon", color: "run" },
|
||||||
|
{ id: "debug", kind: "icon", color: "run" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const css = `
|
||||||
|
body.cmake-helper-toolbar ${ITEMS.map((i) => `#${CSS.escape(PREFIX + i.id)}`).join(", ")} { display: none !important; }
|
||||||
|
/* Above the title bar's drag region, as VS Code's own toolbars are. */
|
||||||
|
.cmake-helper-toolbar-bar { position: relative; z-index: 2500; display: flex; align-items: center; gap: 2px; height: 100%; padding: 0 6px; -webkit-app-region: no-drag; app-region: no-drag; }
|
||||||
|
.cmake-helper-toolbar-bar button { display: flex; align-items: center; gap: 4px; height: 22px; padding: 0 5px; border: 0; border-radius: 5px; background: none;
|
||||||
|
color: inherit; font: inherit; font-size: 12px; cursor: pointer; white-space: nowrap; max-width: 220px; }
|
||||||
|
.cmake-helper-toolbar-bar button:hover { background: var(--vscode-toolbar-hoverBackground); }
|
||||||
|
.cmake-helper-toolbar-bar button .label { overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.cmake-helper-toolbar-bar button.icon { width: 24px; justify-content: center; padding: 0; }
|
||||||
|
.cmake-helper-toolbar-bar button.run .codicon { color: var(--vscode-debugIcon-startForeground, #89d185); }
|
||||||
|
.cmake-helper-toolbar-bar button .chevron { opacity: .7; font-size: 12px; }
|
||||||
|
.cmake-helper-toolbar-bar .group { display: flex; align-items: center; height: 22px; margin-right: 4px; border: 1px solid var(--vscode-commandCenter-border, rgba(128,128,128,.35)); border-radius: 6px; }
|
||||||
|
.cmake-helper-toolbar-bar .group button { border-radius: 0; height: 20px; }
|
||||||
|
.cmake-helper-toolbar-bar .group button:first-child { border-radius: 5px 0 0 5px; }
|
||||||
|
.cmake-helper-toolbar-bar .group button:last-child { border-radius: 0 5px 5px 0; }
|
||||||
|
.cmake-helper-toolbar-bar .group button + button { border-left: 1px solid var(--vscode-commandCenter-border, rgba(128,128,128,.35)); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** The status bar item's own clickable label, if the extension shows it now. */
|
||||||
|
const original = (id) => document.getElementById(PREFIX + id)?.querySelector("a.statusbar-item-label");
|
||||||
|
|
||||||
|
/** "play, Run test (Shift+F10)": the tooltip part of VS Code's aria-label (the part before is the icon's name). */
|
||||||
|
const tooltipOf = (a) => {
|
||||||
|
const label = a.getAttribute("aria-label") ?? "";
|
||||||
|
const i = label.indexOf(", ");
|
||||||
|
return i >= 0 ? label.slice(i + 2) : label;
|
||||||
|
};
|
||||||
|
|
||||||
|
function button(item, a) {
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.type = "button";
|
||||||
|
b.className = [item.kind, item.color ?? ""].join(" ").trim();
|
||||||
|
b.title = tooltipOf(a);
|
||||||
|
b.setAttribute("aria-label", b.title);
|
||||||
|
// The icon (a codicon span, VS Code's own font) and the text, as the item shows them.
|
||||||
|
const icon = a.querySelector(".codicon")?.cloneNode(true);
|
||||||
|
if (icon) b.append(icon);
|
||||||
|
const text = a.textContent.trim();
|
||||||
|
if (item.kind === "text" && text) {
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "label";
|
||||||
|
label.textContent = text;
|
||||||
|
b.append(label);
|
||||||
|
if (item.group) {
|
||||||
|
const chevron = document.createElement("span");
|
||||||
|
chevron.className = "codicon codicon-chevron-down chevron";
|
||||||
|
b.append(chevron);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A warning-coloured item (Reload CMake) keeps its colours.
|
||||||
|
const box = a.parentElement;
|
||||||
|
if (box?.style.backgroundColor) b.style.backgroundColor = box.style.backgroundColor;
|
||||||
|
if (box?.style.color || a.style.color) b.style.color = box.style.color || a.style.color;
|
||||||
|
b.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
original(item.id)?.click();
|
||||||
|
});
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bar = null;
|
||||||
|
let last = "";
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const right = document.querySelector(".part.titlebar .titlebar-right");
|
||||||
|
const present = ITEMS.map((item) => ({ item, a: original(item.id) })).filter((x) => x.a);
|
||||||
|
// No project (or no title bar): the status bar shows whatever there is.
|
||||||
|
if (!right || !present.length) {
|
||||||
|
bar?.remove();
|
||||||
|
bar = null;
|
||||||
|
last = "";
|
||||||
|
document.body.classList.remove("cmake-helper-toolbar");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Rebuild only when something shown changed.
|
||||||
|
const key = present.map(({ item, a }) => `${item.id}\u0000${a.outerHTML}\u0000${a.parentElement?.getAttribute("style") ?? ""}`).join("\u0001");
|
||||||
|
if (bar && bar.isConnected && key === last) return;
|
||||||
|
last = key;
|
||||||
|
if (!bar || !bar.isConnected) {
|
||||||
|
bar = document.createElement("div");
|
||||||
|
bar.className = "cmake-helper-toolbar-bar";
|
||||||
|
right.prepend(bar);
|
||||||
|
}
|
||||||
|
bar.replaceChildren();
|
||||||
|
let group = null;
|
||||||
|
for (const { item, a } of present) {
|
||||||
|
const b = button(item, a);
|
||||||
|
if (item.group) {
|
||||||
|
if (!group) {
|
||||||
|
group = document.createElement("div");
|
||||||
|
group.className = "group";
|
||||||
|
bar.append(group);
|
||||||
|
}
|
||||||
|
group.append(b);
|
||||||
|
} else {
|
||||||
|
group = null;
|
||||||
|
bar.append(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.body.classList.add("cmake-helper-toolbar");
|
||||||
|
}
|
||||||
|
|
||||||
|
let queued = false;
|
||||||
|
const schedule = () => {
|
||||||
|
if (queued) return;
|
||||||
|
queued = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
queued = false;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.textContent = css;
|
||||||
|
document.head.append(style);
|
||||||
|
// The status bar changes (items come, go and change text) and the title bar may be redrawn: follow both.
|
||||||
|
new MutationObserver((records) => {
|
||||||
|
if (bar && records.every((r) => bar.contains(r.target))) return;
|
||||||
|
schedule();
|
||||||
|
}).observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ["aria-label", "style", "class"] });
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.body) start();
|
||||||
|
else document.addEventListener("DOMContentLoaded", start);
|
||||||
|
})();
|
||||||
Generated
+3606
File diff suppressed because it is too large.
Load diff
+1022
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* CLion's gutter ▶, as code lenses: above main() of an executable's source,
|
||||||
|
* and above each add_executable / add_library in the CMake files.
|
||||||
|
*/
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import type { CMakeProject } from "./project";
|
||||||
|
import { samePath, settings } from "./util";
|
||||||
|
|
||||||
|
const MAIN = /^[ \t]*(?:(?:int|auto|signed|void)[ \t]+)?(?:__cdecl[ \t]+)?(?:w?main|wWinMain|WinMain)[ \t]*\(/gm;
|
||||||
|
|
||||||
|
export class LensProvider implements vscode.CodeLensProvider {
|
||||||
|
private readonly changed = new vscode.EventEmitter<void>();
|
||||||
|
readonly onDidChangeCodeLenses = this.changed.event;
|
||||||
|
|
||||||
|
constructor(private readonly project: () => CMakeProject | undefined) {}
|
||||||
|
|
||||||
|
refresh(): void {
|
||||||
|
this.changed.fire();
|
||||||
|
}
|
||||||
|
|
||||||
|
provideCodeLenses(doc: vscode.TextDocument): vscode.CodeLens[] {
|
||||||
|
const p = this.project();
|
||||||
|
if (!p?.model || !settings().get<boolean>("codeLens", true)) return [];
|
||||||
|
return doc.languageId === "cmake" || /(CMakeLists\.txt|\.cmake)$/i.test(doc.fileName) ? this.cmakeLenses(p, doc) : this.mainLenses(p, doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mainLenses(p: CMakeProject, doc: vscode.TextDocument): vscode.CodeLens[] {
|
||||||
|
const exes = p.targetsOf(doc.fileName).filter((t) => t.type === "EXECUTABLE");
|
||||||
|
if (!exes.length) return [];
|
||||||
|
const text = doc.getText();
|
||||||
|
MAIN.lastIndex = 0;
|
||||||
|
const m = MAIN.exec(text);
|
||||||
|
if (!m) return [];
|
||||||
|
const range = new vscode.Range(doc.positionAt(m.index), doc.positionAt(m.index));
|
||||||
|
const lenses: vscode.CodeLens[] = [];
|
||||||
|
for (const t of exes) {
|
||||||
|
const suffix = exes.length > 1 ? ` ${t.name}` : "";
|
||||||
|
lenses.push(
|
||||||
|
new vscode.CodeLens(range, { title: `$(play) Run${suffix}`, command: "cmakeHelper.run", arguments: [t.name], tooltip: `Run ${t.name}` }),
|
||||||
|
new vscode.CodeLens(range, { title: `$(debug-alt) Debug${suffix}`, command: "cmakeHelper.debug", arguments: [t.name], tooltip: `Debug ${t.name}` }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lenses;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cmakeLenses(p: CMakeProject, doc: vscode.TextDocument): vscode.CodeLens[] {
|
||||||
|
const lenses: vscode.CodeLens[] = [];
|
||||||
|
for (const t of p.visibleTargets()) {
|
||||||
|
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") {
|
||||||
|
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] }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lenses.push(new vscode.CodeLens(range, { title: "$(tools) Build", command: "cmakeHelper.build", arguments: [t.name] }));
|
||||||
|
}
|
||||||
|
return lenses;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* The frame of the settings editors (profiles, GDB server configurations), as
|
||||||
|
* CLion's dialogs: the list on the left with add, remove, copy and move, the
|
||||||
|
* selected item's form on the right, OK / Cancel / Apply at the bottom. Each
|
||||||
|
* editor brings its own script, which fills #list and #form. An editor of a
|
||||||
|
* fixed list (the run configurations: one per target) goes without the tools.
|
||||||
|
*/
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
body { padding: 0; margin: 0; color: var(--vscode-foreground); font: var(--vscode-font-size) var(--vscode-font-family); }
|
||||||
|
.wrap { display: flex; flex-direction: column; height: 100vh; }
|
||||||
|
.main { display: flex; flex: 1; min-height: 0; }
|
||||||
|
.side { width: 240px; border-right: 1px solid var(--vscode-panel-border); display: flex; flex-direction: column; }
|
||||||
|
.tools { display: flex; gap: 2px; padding: 4px; border-bottom: 1px solid var(--vscode-panel-border); }
|
||||||
|
.tools[hidden] { display: none; }
|
||||||
|
.tools button { background: none; border: none; color: var(--vscode-icon-foreground); padding: 3px; cursor: pointer; border-radius: 4px; display: flex; }
|
||||||
|
.tools svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.3; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
|
.tools button:hover:not(:disabled) { background: var(--vscode-toolbar-hoverBackground); }
|
||||||
|
.tools button:disabled { opacity: .4; cursor: default; }
|
||||||
|
ul { list-style: none; margin: 0; padding: 0; overflow: auto; flex: 1; }
|
||||||
|
li { padding: 3px 10px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
li.sel { background: var(--vscode-list-activeSelectionBackground); color: var(--vscode-list-activeSelectionForeground); }
|
||||||
|
li:hover:not(.sel) { background: var(--vscode-list-hoverBackground); }
|
||||||
|
li.off, li.preset { opacity: .65; }
|
||||||
|
li .tag { opacity: .7; font-size: .9em; margin-left: 6px; }
|
||||||
|
li.group { cursor: default; opacity: .7; font-size: .85em; text-transform: uppercase; padding-top: 10px; }
|
||||||
|
li.group:hover { background: none; }
|
||||||
|
.form { flex: 1; overflow: auto; padding: 16px 20px; }
|
||||||
|
.grid { display: grid; grid-template-columns: max-content 1fr; gap: 8px 14px; align-items: center; max-width: 820px; }
|
||||||
|
.grid > label { text-align: right; }
|
||||||
|
.grid .full { grid-column: 2; display: flex; align-items: center; gap: 6px; }
|
||||||
|
input[type=text], textarea { width: 100%; box-sizing: border-box; background: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border, transparent); padding: 4px 6px; font: inherit; border-radius: 2px; }
|
||||||
|
textarea { font-family: var(--vscode-editor-font-family); min-height: 52px; resize: vertical; }
|
||||||
|
select { background: var(--vscode-dropdown-background); color: var(--vscode-dropdown-foreground); border: 1px solid var(--vscode-dropdown-border, transparent); padding: 3px 4px; font: inherit; border-radius: 2px; }
|
||||||
|
input:focus, textarea:focus, select:focus { outline: 1px solid var(--vscode-focusBorder); }
|
||||||
|
.grid .sec { grid-column: 1 / -1; margin: 10px 0 0; padding-bottom: 3px; font-weight: 600; border-bottom: 1px solid var(--vscode-panel-border); }
|
||||||
|
.grid .radios { display: flex; gap: 16px; }
|
||||||
|
.grid .radios label, .grid .check { display: flex; align-items: center; gap: 5px; }
|
||||||
|
input:disabled, textarea:disabled { opacity: .7; }
|
||||||
|
.hint { opacity: .7; font-size: .9em; }
|
||||||
|
.note { margin-bottom: 12px; padding: 6px 10px; border-left: 3px solid var(--vscode-textLink-foreground); background: var(--vscode-textBlockQuote-background); }
|
||||||
|
.empty { opacity: .7; padding: 20px; }
|
||||||
|
.foot { display: flex; gap: 8px; justify-content: flex-end; align-items: center; padding: 10px 16px; border-top: 1px solid var(--vscode-panel-border); }
|
||||||
|
.foot .err { color: var(--vscode-errorForeground); margin-right: auto; }
|
||||||
|
.foot button { padding: 4px 14px; border: 1px solid var(--vscode-button-border, transparent); border-radius: 2px; cursor: pointer; font: inherit; background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); }
|
||||||
|
.foot button.primary { background: var(--vscode-button-background); color: var(--vscode-button-foreground); }
|
||||||
|
.foot button:disabled { opacity: .5; cursor: default; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** The whole page: `noun` names an item in the buttons' tooltips, `extra` goes after the frame (datalists), then the script; `tools: false`, no add/remove/copy/move. */
|
||||||
|
export function editorPage(webview: vscode.Webview, nonce: string, opts: { noun: string; extra?: string; script: string; tools?: boolean }): string {
|
||||||
|
const { noun } = opts;
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource} 'nonce-${nonce}'; script-src 'nonce-${nonce}';">
|
||||||
|
<style nonce="${nonce}">${CSS}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="main">
|
||||||
|
<div class="side">
|
||||||
|
<div class="tools"${opts.tools === false ? " hidden" : ""}>
|
||||||
|
<button id="add" title="Add ${noun}"><svg viewBox="0 0 16 16"><path d="M8 3v10M3 8h10"/></svg></button>
|
||||||
|
<button id="remove" title="Remove ${noun}"><svg viewBox="0 0 16 16"><path d="M3 8h10"/></svg></button>
|
||||||
|
<button id="copy" title="Copy ${noun}"><svg viewBox="0 0 16 16"><rect x="5.5" y="5.5" width="8" height="8" rx="1"/><path d="M10.5 3.5v-1a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h1"/></svg></button>
|
||||||
|
<button id="up" title="Move up"><svg viewBox="0 0 16 16"><path d="M8 13V3M4 7l4-4 4 4"/></svg></button>
|
||||||
|
<button id="down" title="Move down"><svg viewBox="0 0 16 16"><path d="M8 3v10M4 9l4 4 4-4"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<ul id="list"></ul>
|
||||||
|
</div>
|
||||||
|
<div class="form" id="form"></div>
|
||||||
|
</div>
|
||||||
|
<div class="foot">
|
||||||
|
<span class="err" id="err"></span>
|
||||||
|
<button class="primary" id="ok">OK</button>
|
||||||
|
<button id="cancel">Cancel</button>
|
||||||
|
<button id="apply" disabled>Apply</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${opts.extra ?? ""}
|
||||||
|
<script nonce="${nonce}">
|
||||||
|
${opts.script}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The extension's icon for an editor's tab. */
|
||||||
|
export function editorIcon(): vscode.Uri {
|
||||||
|
return vscode.Uri.joinPath(vscode.extensions.getExtension("cernik.vs-code-cmake-helper")?.extensionUri ?? vscode.Uri.file("/"), "media", "cmake.svg");
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
/**
|
||||||
|
* CMake Helper: working with a CMake project the way CLion does. Profiles
|
||||||
|
* (and presets), the targets with their sources, one active target to build,
|
||||||
|
* run and debug from the status bar, run configurations per target, CTest in
|
||||||
|
* the Test Explorer, and a reload when a CMake file changes.
|
||||||
|
*/
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import { type Target, executableOf } from "./fileApi";
|
||||||
|
import { LensProvider } from "./codeLens";
|
||||||
|
import { debugTarget, programOf, runTarget } from "./launcher";
|
||||||
|
import { openRunConfigEditor } from "./runConfigEditor";
|
||||||
|
import { openGdbEditor } from "./gdbEditor";
|
||||||
|
import { type GdbConfiguration, KINDS, debugWithConfiguration, gdbConfigurations, runWithConfiguration } from "./gdbConfigs";
|
||||||
|
import { openProfileEditor } from "./profileEditor";
|
||||||
|
import { removeProfile } from "./profiles";
|
||||||
|
import { CMakeProject } from "./project";
|
||||||
|
import { StatusBar } from "./statusBar";
|
||||||
|
import { TitleBar } from "./titleBar";
|
||||||
|
import { CTestProvider } from "./tests";
|
||||||
|
import { Updates } from "./updates";
|
||||||
|
import { ProfilesProvider, type ProfileNode, type TargetNode, TargetsProvider } from "./views";
|
||||||
|
import { exists, expand, pathKey, settings } from "./util";
|
||||||
|
|
||||||
|
let project: CMakeProject | undefined;
|
||||||
|
|
||||||
|
export function activate(context: vscode.ExtensionContext): void {
|
||||||
|
const output = vscode.window.createOutputChannel("CMake", "log");
|
||||||
|
const current = () => project;
|
||||||
|
const targetsView = new TargetsProvider(current);
|
||||||
|
const profilesView = new ProfilesProvider(current);
|
||||||
|
const lenses = new LensProvider(current);
|
||||||
|
const statusBar = new StatusBar();
|
||||||
|
const ctest = new CTestProvider(current);
|
||||||
|
const targetsTree = vscode.window.createTreeView("cmakeHelper.targets", { treeDataProvider: targetsView, showCollapseAll: true });
|
||||||
|
const profilesTree = vscode.window.createTreeView("cmakeHelper.profiles", { treeDataProvider: profilesView });
|
||||||
|
let projectSubs: vscode.Disposable[] = [];
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
const p = project;
|
||||||
|
void vscode.commands.executeCommand("setContext", "cmakeHelper.hasProject", !!p);
|
||||||
|
void vscode.commands.executeCommand("setContext", "cmakeHelper.loaded", !!p?.model);
|
||||||
|
void vscode.commands.executeCommand("setContext", "cmakeHelper.busy", !!p && p.state !== "idle");
|
||||||
|
void vscode.commands.executeCommand("setContext", "cmakeHelper.needsReload", !!p && (p.needsReload || !p.model) && p.state !== "configuring");
|
||||||
|
// The CMakeLists.txt the project is: its context menu reloads it, any other's loads that one instead.
|
||||||
|
void vscode.commands.executeCommand("setContext", "cmakeHelper.loadedCMakeLists", p ? [path.join(p.sourceDir, "CMakeLists.txt")] : []);
|
||||||
|
targetsTree.description = p ? `${p.activeProfile.name}${p.needsReload ? " · needs reload" : ""}` : undefined;
|
||||||
|
targetsTree.message = p?.state === "configuring" && !p.model ? "Loading the CMake project…" : undefined;
|
||||||
|
targetsView.refresh();
|
||||||
|
profilesView.refresh();
|
||||||
|
lenses.refresh();
|
||||||
|
statusBar.update(p);
|
||||||
|
};
|
||||||
|
|
||||||
|
let notified = false;
|
||||||
|
/** A CMakeLists.txt's folder "Load CMake Project" asked for: configured once the project there is open. */
|
||||||
|
let loading: string | undefined;
|
||||||
|
const openProject = () => {
|
||||||
|
projectSubs.forEach((d) => d.dispose());
|
||||||
|
projectSubs = [];
|
||||||
|
project?.dispose();
|
||||||
|
project = undefined;
|
||||||
|
const folder = (vscode.workspace.workspaceFolders ?? []).find((f) => {
|
||||||
|
const dir = path.resolve(f.uri.fsPath, expand(settings().get<string>("sourceDirectory") || "${workspaceFolder}", { workspaceFolder: f.uri.fsPath }));
|
||||||
|
return exists(path.join(dir, "CMakeLists.txt"));
|
||||||
|
});
|
||||||
|
if (folder) {
|
||||||
|
const p = new CMakeProject(context, folder, output);
|
||||||
|
project = p;
|
||||||
|
projectSubs.push(
|
||||||
|
p.onDidChange(() => {
|
||||||
|
refresh();
|
||||||
|
// CLion's "CMake project needs to be reloaded": once per change.
|
||||||
|
if (p.needsReload && !notified) {
|
||||||
|
notified = true;
|
||||||
|
void vscode.window.showInformationMessage("CMake files changed. Reload the project?", "Reload", "Always Reload").then(async (a) => {
|
||||||
|
if (a === "Always Reload") await settings().update("autoReload", "always", vscode.ConfigurationTarget.Global);
|
||||||
|
if (a) await p.configure();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!p.needsReload) notified = false;
|
||||||
|
}),
|
||||||
|
p.onDidLoadModel(() => void ctest.discover()),
|
||||||
|
p.onDidBuild(() => ctest.discoverSoon()),
|
||||||
|
);
|
||||||
|
// Loaded from a CMakeLists.txt's menu: configured now, as CLion does; else what the last configure left, if anything.
|
||||||
|
if (loading && pathKey(loading) === pathKey(p.sourceDir)) void p.configure();
|
||||||
|
else void p.open();
|
||||||
|
loading = undefined;
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
if (!project) void ctest.discover();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A command's target: a name (code lens), a tree node, or the active one. */
|
||||||
|
const targetOf = async (arg: unknown, executable = false): Promise<Target | undefined> => {
|
||||||
|
const p = project;
|
||||||
|
if (!p) {
|
||||||
|
void vscode.window.showWarningMessage("There's no CMake project (CMakeLists.txt) in this workspace.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!p.model && !(await p.configure())) return;
|
||||||
|
if (typeof arg === "string") return p.target(arg);
|
||||||
|
if (arg && typeof arg === "object" && "target" in arg) return (arg as { target: Target }).target;
|
||||||
|
const active = p.activeTarget;
|
||||||
|
if (active && (!executable || active.type === "EXECUTABLE")) return active;
|
||||||
|
return pickTarget(p, executable);
|
||||||
|
};
|
||||||
|
|
||||||
|
const withProject = (fn: (p: CMakeProject) => unknown) => () => {
|
||||||
|
if (project) return fn(project);
|
||||||
|
void vscode.window.showWarningMessage("There's no CMake project (CMakeLists.txt) in this workspace.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const command = (id: string, fn: (...args: any[]) => unknown) => context.subscriptions.push(vscode.commands.registerCommand(id, fn));
|
||||||
|
|
||||||
|
command("cmakeHelper.reload", withProject((p) => p.configure()));
|
||||||
|
// CLion's "Load CMake Project" on a CMakeLists.txt: that folder becomes the project (cmakeHelper.sourceDirectory), and it's configured.
|
||||||
|
command("cmakeHelper.loadProject", async (arg?: unknown) => {
|
||||||
|
const uri = arg instanceof vscode.Uri ? arg : vscode.window.activeTextEditor?.document.uri;
|
||||||
|
if (!uri || path.basename(uri.fsPath) !== "CMakeLists.txt") {
|
||||||
|
void vscode.window.showWarningMessage("Load CMake Project works on a CMakeLists.txt: open one, or use it from one's context menu.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dir = path.dirname(uri.fsPath);
|
||||||
|
if (project && pathKey(project.sourceDir) === pathKey(dir)) return void project.configure();
|
||||||
|
const folder = vscode.workspace.getWorkspaceFolder(uri);
|
||||||
|
if (!folder) {
|
||||||
|
void vscode.window.showWarningMessage(`${uri.fsPath} isn't in this workspace's folders.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rel = path.relative(folder.uri.fsPath, dir).split(path.sep).join("/");
|
||||||
|
const value = rel ? `\${workspaceFolder}/${rel}` : undefined;
|
||||||
|
loading = dir;
|
||||||
|
// A change opens the project there (onDidChangeConfiguration → openProject), which then configures it; no change: opened here.
|
||||||
|
if ((settings().get<string>("sourceDirectory") || undefined) === value) openProject();
|
||||||
|
else await settings().update("sourceDirectory", value, vscode.ConfigurationTarget.Workspace);
|
||||||
|
});
|
||||||
|
command("cmakeHelper.resetCacheAndReload", withProject((p) => p.configure({ reset: true })));
|
||||||
|
command("cmakeHelper.cancelReload", withProject((p) => p.cancelConfigure()));
|
||||||
|
command("cmakeHelper.showOutput", () => output.show());
|
||||||
|
|
||||||
|
const updates = new Updates(context, output);
|
||||||
|
context.subscriptions.push(updates);
|
||||||
|
command("cmakeHelper.checkForUpdates", () => updates.check(true));
|
||||||
|
command("cmakeHelper.setUpdateToken", () => updates.setToken());
|
||||||
|
|
||||||
|
const titleBar = new TitleBar(context);
|
||||||
|
command("cmakeHelper.patchTitleBar", () => titleBar.patch());
|
||||||
|
command("cmakeHelper.unpatchTitleBar", () => titleBar.unpatch());
|
||||||
|
void titleBar.reconcile();
|
||||||
|
|
||||||
|
command("cmakeHelper.selectProfile", async (arg?: string | ProfileNode) => {
|
||||||
|
const p = project;
|
||||||
|
if (!p) return;
|
||||||
|
let key = typeof arg === "string" ? arg : arg?.key;
|
||||||
|
if (!key) {
|
||||||
|
const items = [
|
||||||
|
...p.profiles.map((x) => ({
|
||||||
|
label: `${x.key === p.activeProfile.key ? "$(check) " : ""}${x.name}`,
|
||||||
|
description: [x.buildType, path.relative(p.sourceDir, x.buildDir), x.source === "preset" ? "preset" : ""].filter(Boolean).join(" · "),
|
||||||
|
key: x.key,
|
||||||
|
})),
|
||||||
|
{ label: "", kind: vscode.QuickPickItemKind.Separator, key: "" },
|
||||||
|
{ label: "$(add) New Profile…", key: "#new" },
|
||||||
|
{ label: "$(gear) Edit Profiles…", key: "#edit" },
|
||||||
|
];
|
||||||
|
const pick = await vscode.window.showQuickPick(items, { title: "CMake Profile" });
|
||||||
|
if (!pick) return;
|
||||||
|
if (pick.key.startsWith("#")) return openProfileEditor(p, { add: pick.key === "#new" });
|
||||||
|
key = pick.key;
|
||||||
|
}
|
||||||
|
if (key) await p.setActiveProfile(key);
|
||||||
|
});
|
||||||
|
command("cmakeHelper.newProfile", withProject((p) => openProfileEditor(p, { add: true })));
|
||||||
|
command("cmakeHelper.editProfiles", (node?: ProfileNode) => {
|
||||||
|
if (project) openProfileEditor(project, { select: node?.key });
|
||||||
|
});
|
||||||
|
command("cmakeHelper.editProfilesJson", () => vscode.commands.executeCommand("workbench.action.openWorkspaceSettingsFile"));
|
||||||
|
command("cmakeHelper.removeProfile", async (node?: ProfileNode) => {
|
||||||
|
const prof = project?.profiles.find((x) => x.key === node?.key);
|
||||||
|
if (!prof) return;
|
||||||
|
const ok = await vscode.window.showWarningMessage(`Remove the profile “${prof.name}”? Its build directory stays.`, { modal: true }, "Remove");
|
||||||
|
if (ok) await removeProfile(prof);
|
||||||
|
});
|
||||||
|
command("cmakeHelper.revealBuildDirectory", (node?: ProfileNode) => {
|
||||||
|
const p = project;
|
||||||
|
const prof = p?.profiles.find((x) => x.key === node?.key) ?? p?.activeProfile;
|
||||||
|
if (prof) void vscode.commands.executeCommand("revealFileInOS", vscode.Uri.file(prof.buildDir));
|
||||||
|
});
|
||||||
|
|
||||||
|
command("cmakeHelper.selectTarget", async (arg?: unknown) => {
|
||||||
|
const p = project;
|
||||||
|
if (!p) return;
|
||||||
|
// From an editor's title bar the argument is its file: the picker, as with none.
|
||||||
|
if (typeof arg === "string" || (arg && typeof arg === "object" && !(arg instanceof vscode.Uri))) {
|
||||||
|
const t = await targetOf(arg);
|
||||||
|
if (t) await p.setActiveTarget(t.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!p.model && !(await p.configure())) return;
|
||||||
|
const pick = await pickTargetOrConfiguration(p);
|
||||||
|
if (!pick) return;
|
||||||
|
if ("target" in pick) await p.setActiveTarget(pick.target.name);
|
||||||
|
else if (pick.gdb) await p.setActiveGdbConfiguration(pick.gdb.name);
|
||||||
|
else if (pick.action === "run") openRunConfigEditor(p);
|
||||||
|
else openGdbEditor(p, { add: pick.action === "new" });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** With no target given and a GDB server configuration chosen: its target. */
|
||||||
|
const gdbTarget = async (arg: unknown): Promise<Target | undefined> => {
|
||||||
|
const gdb = arg === undefined ? project?.activeGdbConfiguration : undefined;
|
||||||
|
if (gdb?.target) return targetOf(gdb.target);
|
||||||
|
return targetOf(arg);
|
||||||
|
};
|
||||||
|
|
||||||
|
command("cmakeHelper.build", async (arg?: unknown) => {
|
||||||
|
const t = await gdbTarget(arg);
|
||||||
|
if (t) await project!.build([t.name]);
|
||||||
|
});
|
||||||
|
command("cmakeHelper.rebuild", async (arg?: unknown) => {
|
||||||
|
const t = await targetOf(arg);
|
||||||
|
if (t) await project!.build([t.name], { cleanFirst: true });
|
||||||
|
});
|
||||||
|
command("cmakeHelper.buildAll", withProject((p) => p.build()));
|
||||||
|
command("cmakeHelper.rebuildAll", withProject((p) => p.build([], { cleanFirst: true })));
|
||||||
|
command("cmakeHelper.clean", withProject((p) => p.build([], { clean: true })));
|
||||||
|
// A code lens or a tree item names its target; the toolbar's ▶ and 🐞 (no argument) mean the chosen configuration.
|
||||||
|
command("cmakeHelper.run", async (arg?: unknown) => {
|
||||||
|
const gdb = arg === undefined ? project?.activeGdbConfiguration : undefined;
|
||||||
|
if (gdb) {
|
||||||
|
if (project!.model || (await project!.configure())) await runWithConfiguration(project!, gdb);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Any target: one without a program to run (a custom target) is built, as CLion does.
|
||||||
|
const t = await targetOf(arg);
|
||||||
|
if (t) await runTarget(project!, t);
|
||||||
|
});
|
||||||
|
command("cmakeHelper.debug", async (arg?: unknown) => {
|
||||||
|
const gdb = arg === undefined ? project?.activeGdbConfiguration : undefined;
|
||||||
|
if (gdb) {
|
||||||
|
if (project!.model || (await project!.configure())) await debugWithConfiguration(project!, gdb);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = await targetOf(arg);
|
||||||
|
if (t) await debugTarget(project!, t);
|
||||||
|
});
|
||||||
|
command("cmakeHelper.editGdbConfigurations", (name?: string) => {
|
||||||
|
if (project) openGdbEditor(project, { select: typeof name === "string" ? name : project.activeGdbConfiguration?.name });
|
||||||
|
else void vscode.window.showWarningMessage("There's no CMake project (CMakeLists.txt) in this workspace.");
|
||||||
|
});
|
||||||
|
command("cmakeHelper.newGdbConfiguration", withProject((p) => openGdbEditor(p, { add: true })));
|
||||||
|
// CLion's Run/Debug Configurations: every target, the given one (or the active one) selected.
|
||||||
|
command("cmakeHelper.editRunConfiguration", async (arg?: unknown) => {
|
||||||
|
const p = project;
|
||||||
|
if (!p) return void vscode.window.showWarningMessage("There's no CMake project (CMakeLists.txt) in this workspace.");
|
||||||
|
if (!p.model && !(await p.configure())) return;
|
||||||
|
const name = typeof arg === "string" ? arg : arg && typeof arg === "object" && "target" in arg ? (arg as { target: Target }).target.name : undefined;
|
||||||
|
openRunConfigEditor(p, name);
|
||||||
|
});
|
||||||
|
command("cmakeHelper.revealDefinition", async (arg?: unknown) => {
|
||||||
|
const t = await targetOf(arg);
|
||||||
|
const d = t?.definition;
|
||||||
|
if (!d) return;
|
||||||
|
const pos = new vscode.Position(d.line - 1, 0);
|
||||||
|
await vscode.window.showTextDocument(vscode.Uri.file(d.file), { selection: new vscode.Range(pos, pos) });
|
||||||
|
});
|
||||||
|
command("cmakeHelper.copyArtifactPath", async (arg?: unknown) => {
|
||||||
|
const t = await targetOf(arg);
|
||||||
|
const a = t && (executableOf(t) ?? t.artifacts[0]);
|
||||||
|
if (a) await vscode.env.clipboard.writeText(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
// For launch.json and tasks.json: ${command:cmakeHelper.activeProgram} and the like.
|
||||||
|
command("cmakeHelper.activeProgram", () => {
|
||||||
|
const t = project?.activeTarget;
|
||||||
|
return t ? programOf(project!, t) : undefined;
|
||||||
|
});
|
||||||
|
command("cmakeHelper.activeTarget", () => project?.activeTarget?.name);
|
||||||
|
command("cmakeHelper.buildDirectory", () => project?.buildDir);
|
||||||
|
command("cmakeHelper.buildType", () => project?.configuration);
|
||||||
|
|
||||||
|
context.subscriptions.push(
|
||||||
|
output,
|
||||||
|
statusBar,
|
||||||
|
ctest,
|
||||||
|
targetsTree,
|
||||||
|
profilesTree,
|
||||||
|
vscode.languages.registerCodeLensProvider([{ language: "c" }, { language: "cpp" }, { language: "cuda-cpp" }, { language: "objective-c" }, { language: "objective-cpp" }, { language: "cmake" }, { pattern: "**/CMakeLists.txt" }, { pattern: "**/*.cmake" }], lenses),
|
||||||
|
vscode.workspace.onDidChangeWorkspaceFolders(openProject),
|
||||||
|
vscode.workspace.onDidChangeConfiguration((e) => {
|
||||||
|
if (e.affectsConfiguration("cmakeHelper.toolbar")) void titleBar.reconcile();
|
||||||
|
if (e.affectsConfiguration("cmakeHelper.sourceDirectory")) openProject();
|
||||||
|
else if (e.affectsConfiguration("cmakeHelper")) refresh();
|
||||||
|
}),
|
||||||
|
// A CMakeLists.txt that appears in an empty workspace makes it a project.
|
||||||
|
vscode.workspace.createFileSystemWatcher("**/CMakeLists.txt", false, true, true).onDidCreate(() => {
|
||||||
|
if (!project) openProject();
|
||||||
|
}),
|
||||||
|
{ dispose: () => projectSubs.forEach((d) => d.dispose()) },
|
||||||
|
{ dispose: () => project?.dispose() },
|
||||||
|
);
|
||||||
|
openProject();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickTarget(p: CMakeProject, executable: boolean): Promise<Target | undefined> {
|
||||||
|
const list = p.visibleTargets().filter((t) => !executable || t.type === "EXECUTABLE");
|
||||||
|
if (!list.length) {
|
||||||
|
void vscode.window.showWarningMessage(executable ? "The project has no executables." : "The project has no targets.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const active = p.activeTarget?.name;
|
||||||
|
const items = list.map((t) => ({
|
||||||
|
label: `${t.name === active ? "$(check) " : ""}${t.name}`,
|
||||||
|
description: t.type === "EXECUTABLE" ? "executable" : t.type.toLowerCase().replace("_", " "),
|
||||||
|
detail: t.definition ? path.relative(p.sourceDir, t.definition.file) + `:${t.definition.line}` : undefined,
|
||||||
|
target: t,
|
||||||
|
}));
|
||||||
|
const pick = await vscode.window.showQuickPick(items, { title: `CMake Target [${p.activeProfile.name}]`, matchOnDescription: true });
|
||||||
|
return pick?.target;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TargetOrConfiguration = { target: Target } | { gdb: GdbConfiguration; action?: undefined } | { gdb?: undefined; action: "new" | "edit" | "run" };
|
||||||
|
|
||||||
|
/** The target picker as CLion's run configuration combo: the targets, then the GDB server configurations. */
|
||||||
|
async function pickTargetOrConfiguration(p: CMakeProject): Promise<TargetOrConfiguration | undefined> {
|
||||||
|
const gdbActive = p.activeGdbConfiguration?.name;
|
||||||
|
const active = gdbActive ? undefined : p.activeTarget?.name;
|
||||||
|
type Item = vscode.QuickPickItem & { value?: TargetOrConfiguration };
|
||||||
|
const items: Item[] = p.visibleTargets().map((t) => ({
|
||||||
|
label: `${t.name === active ? "$(check) " : ""}${t.name}`,
|
||||||
|
description: t.type === "EXECUTABLE" ? "executable" : t.type.toLowerCase().replace("_", " "),
|
||||||
|
detail: t.definition ? path.relative(p.sourceDir, t.definition.file) + `:${t.definition.line}` : undefined,
|
||||||
|
value: { target: t },
|
||||||
|
}));
|
||||||
|
items.push({ label: "GDB Server Configurations", kind: vscode.QuickPickItemKind.Separator });
|
||||||
|
for (const c of gdbConfigurations()) {
|
||||||
|
items.push({
|
||||||
|
label: `${c.name === gdbActive ? "$(check) " : ""}$(${c.kind === "embedded" ? "chip" : "remote"}) ${c.name}`,
|
||||||
|
description: [KINDS[c.kind], c.target, c.ssh || c.remote].filter(Boolean).join(" · "),
|
||||||
|
value: { gdb: c },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
items.push(
|
||||||
|
{ label: "", kind: vscode.QuickPickItemKind.Separator },
|
||||||
|
{ label: "$(gear) Edit Configurations…", description: "the targets' run configurations", value: { action: "run" } },
|
||||||
|
{ label: "$(add) New GDB Server Configuration…", value: { action: "new" } },
|
||||||
|
{ label: "$(chip) Edit GDB Server Configurations…", value: { action: "edit" } },
|
||||||
|
);
|
||||||
|
const pick = await vscode.window.showQuickPick(items, { title: `CMake Target [${p.activeProfile.name}]`, matchOnDescription: true });
|
||||||
|
return pick?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deactivate(): void {
|
||||||
|
project?.dispose();
|
||||||
|
project = undefined;
|
||||||
|
}
|
||||||
+185
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* CMake's File API (cmake-file-api(7)): before configuring, a query file in
|
||||||
|
* the build directory asks for the code model, the cache, the CMake input
|
||||||
|
* files and the toolchains; CMake answers with JSON files in reply/, which is
|
||||||
|
* what CLion reads too. Nothing here parses CMakeLists.txt.
|
||||||
|
*/
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { readJson } from "./util";
|
||||||
|
|
||||||
|
const CLIENT = "client-cmake-helper";
|
||||||
|
|
||||||
|
export type TargetType =
|
||||||
|
| "EXECUTABLE"
|
||||||
|
| "STATIC_LIBRARY"
|
||||||
|
| "SHARED_LIBRARY"
|
||||||
|
| "MODULE_LIBRARY"
|
||||||
|
| "OBJECT_LIBRARY"
|
||||||
|
| "INTERFACE_LIBRARY"
|
||||||
|
| "UTILITY";
|
||||||
|
|
||||||
|
export interface Location {
|
||||||
|
file: string;
|
||||||
|
/** 1-based, as CMake gives it. */
|
||||||
|
line: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceFile {
|
||||||
|
path: string;
|
||||||
|
group: string;
|
||||||
|
generated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Target {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: TargetType;
|
||||||
|
/** Absolute paths of what it builds (the executable, the library). */
|
||||||
|
artifacts: string[];
|
||||||
|
sources: SourceFile[];
|
||||||
|
/** Where it's defined: the add_executable / add_library call. */
|
||||||
|
definition?: Location;
|
||||||
|
sourceDir: string;
|
||||||
|
buildDir: string;
|
||||||
|
folder?: string;
|
||||||
|
/** ALL_BUILD, ZERO_CHECK and the like. */
|
||||||
|
generatorProvided: boolean;
|
||||||
|
/** Ids of the targets it depends on. */
|
||||||
|
dependencies: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Model {
|
||||||
|
sourceDir: string;
|
||||||
|
buildDir: string;
|
||||||
|
/** The configuration the targets are of (Debug, Release…; "" for a single-config generator without a build type). */
|
||||||
|
configuration: string;
|
||||||
|
configurations: string[];
|
||||||
|
generator: string;
|
||||||
|
targets: Target[];
|
||||||
|
/** The project's own CMake files (CMakeLists.txt, *.cmake), the ones a change to should reload it. */
|
||||||
|
inputs: string[];
|
||||||
|
cache: Map<string, string>;
|
||||||
|
compilerIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeQuery(buildDir: string): void {
|
||||||
|
const dir = path.join(buildDir, ".cmake", "api", "v1", "query", CLIENT);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const query = {
|
||||||
|
requests: [
|
||||||
|
{ kind: "codemodel", version: 2 },
|
||||||
|
{ kind: "cache", version: 2 },
|
||||||
|
{ kind: "cmakeFiles", version: 1 },
|
||||||
|
{ kind: "toolchains", version: 1 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
fs.writeFileSync(path.join(dir, "query.json"), JSON.stringify(query, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function multiConfigGenerator(generator: string): boolean {
|
||||||
|
return generator === "Ninja Multi-Config" || generator === "Xcode" || generator.startsWith("Visual Studio");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model from the latest reply in `buildDir`, or undefined when there's
|
||||||
|
* none for our query (never configured, or configured before we asked).
|
||||||
|
* `configuration` picks one of a multi-config generator's configurations.
|
||||||
|
*/
|
||||||
|
export function readReply(buildDir: string, configuration?: string): Model | undefined {
|
||||||
|
const replyDir = path.join(buildDir, ".cmake", "api", "v1", "reply");
|
||||||
|
let indexes: string[];
|
||||||
|
try {
|
||||||
|
indexes = fs.readdirSync(replyDir).filter((f) => /^index-.*\.json$/.test(f)).sort();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const last = indexes[indexes.length - 1];
|
||||||
|
if (!last) return undefined;
|
||||||
|
const index = readJson(path.join(replyDir, last));
|
||||||
|
const responses: any[] | undefined = index?.reply?.[CLIENT]?.["query.json"]?.responses;
|
||||||
|
if (!Array.isArray(responses)) return undefined;
|
||||||
|
const file = (kind: string) => {
|
||||||
|
const r = responses.find((x) => x?.kind === kind && x.jsonFile);
|
||||||
|
return r ? readJson(path.join(replyDir, r.jsonFile)) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const codemodel = file("codemodel");
|
||||||
|
if (!codemodel) return undefined;
|
||||||
|
const sourceDir: string = codemodel.paths.source;
|
||||||
|
const absBuild: string = codemodel.paths.build;
|
||||||
|
|
||||||
|
const cache = new Map<string, string>();
|
||||||
|
for (const e of file("cache")?.entries ?? []) cache.set(e.name, String(e.value));
|
||||||
|
|
||||||
|
const inputs: string[] = [];
|
||||||
|
for (const i of file("cmakeFiles")?.inputs ?? []) {
|
||||||
|
if (i.isExternal || i.isGenerated || i.isCMake) continue;
|
||||||
|
inputs.push(path.resolve(sourceDir, i.path));
|
||||||
|
}
|
||||||
|
|
||||||
|
const compilerIds: string[] = [];
|
||||||
|
for (const t of file("toolchains")?.toolchains ?? []) if (t.compiler?.id) compilerIds.push(t.compiler.id);
|
||||||
|
|
||||||
|
const configs: any[] = codemodel.configurations ?? [];
|
||||||
|
const config = configs.find((c) => c.name === configuration) ?? configs[0];
|
||||||
|
const targets: Target[] = [];
|
||||||
|
for (const ref of config?.targets ?? []) {
|
||||||
|
const t = readJson(path.join(replyDir, ref.jsonFile));
|
||||||
|
if (t) targets.push(toTarget(t, sourceDir, absBuild));
|
||||||
|
}
|
||||||
|
targets.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceDir,
|
||||||
|
buildDir: absBuild,
|
||||||
|
configuration: config?.name ?? "",
|
||||||
|
configurations: configs.map((c) => c.name),
|
||||||
|
generator: cache.get("CMAKE_GENERATOR") ?? "",
|
||||||
|
targets,
|
||||||
|
inputs,
|
||||||
|
cache,
|
||||||
|
compilerIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTarget(t: any, sourceDir: string, buildDir: string): Target {
|
||||||
|
const groups: string[] = (t.sourceGroups ?? []).map((g: any) => g.name);
|
||||||
|
const sources: SourceFile[] = (t.sources ?? []).map((s: any) => ({
|
||||||
|
path: path.resolve(sourceDir, s.path),
|
||||||
|
group: groups[s.sourceGroupIndex] ?? "",
|
||||||
|
generated: !!s.isGenerated,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
type: t.type,
|
||||||
|
artifacts: (t.artifacts ?? []).map((a: any) => path.resolve(buildDir, a.path)),
|
||||||
|
sources,
|
||||||
|
definition: definitionOf(t, sourceDir),
|
||||||
|
sourceDir: path.resolve(sourceDir, t.paths?.source ?? "."),
|
||||||
|
buildDir: path.resolve(buildDir, t.paths?.build ?? "."),
|
||||||
|
folder: t.folder?.name,
|
||||||
|
generatorProvided: !!t.isGeneratorProvided,
|
||||||
|
dependencies: (t.dependencies ?? []).map((d: any) => d.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The node the target's backtrace starts at is the add_* call that made it. */
|
||||||
|
function definitionOf(t: any, sourceDir: string): Location | undefined {
|
||||||
|
const graph = t.backtraceGraph;
|
||||||
|
const node = typeof t.backtrace === "number" ? graph?.nodes?.[t.backtrace] : undefined;
|
||||||
|
if (!node || typeof node.line !== "number") return undefined;
|
||||||
|
const file = graph.files?.[node.file];
|
||||||
|
return file ? { file: path.resolve(sourceDir, file), line: node.line } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The executable a target runs as: on Windows its artifacts include the .pdb too. */
|
||||||
|
export function executableOf(t: Target): string | undefined {
|
||||||
|
if (t.type !== "EXECUTABLE") return undefined;
|
||||||
|
return t.artifacts.find((a) => /\.(exe|com)$/i.test(a)) ?? t.artifacts.find((a) => !/\.(pdb|ilk|lib|exp)$/i.test(a)) ?? t.artifacts[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLibrary(t: Target): boolean {
|
||||||
|
return t.type.endsWith("_LIBRARY");
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
/**
|
||||||
|
* GDB server configurations, as CLion's run configurations of those kinds:
|
||||||
|
*
|
||||||
|
* - Embedded GDB Server: a GDB server started here (OpenOCD, J-Link, ST-LINK,
|
||||||
|
* pyOCD…), gdb connected to it, the program downloaded (flashed) and the
|
||||||
|
* board reset. Run is CLion's "Download & Run": flash and start, no debugger.
|
||||||
|
* - Remote GDB Server: gdb connected to a gdbserver elsewhere, with a sysroot
|
||||||
|
* and path mappings; optionally the program uploaded over SSH first and
|
||||||
|
* gdbserver started there. Run then runs it there over SSH.
|
||||||
|
*
|
||||||
|
* They're kept in the setting `cmakeHelper.gdbConfigurations`; debugging goes
|
||||||
|
* through C/C++ (cppdbg), which starts the server and connects by itself.
|
||||||
|
*/
|
||||||
|
import * as crypto from "node:crypto";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import { type Target, executableOf } from "./fileApi";
|
||||||
|
import { buildFirst } from "./launcher";
|
||||||
|
import type { CMakeProject } from "./project";
|
||||||
|
import { errorText, expand, onPath, parseEnv, settings, splitArgs } from "./util";
|
||||||
|
|
||||||
|
export type GdbKind = "embedded" | "remote";
|
||||||
|
export type Download = "always" | "updated" | "never";
|
||||||
|
|
||||||
|
export interface GdbConfiguration {
|
||||||
|
name: string;
|
||||||
|
kind: GdbKind;
|
||||||
|
/** The CMake target whose executable is debugged (its symbols) and flashed or uploaded; none: the active target. */
|
||||||
|
target?: string;
|
||||||
|
/** A file instead of the target's (an .elf built some other way). */
|
||||||
|
program?: string;
|
||||||
|
/** gdb: one that knows the board's architecture (gdb-multiarch, arm-none-eabi-gdb…). */
|
||||||
|
debugger?: string;
|
||||||
|
/** What `target remote` gets: localhost:3333, tcp:192.168.1.5:2345… */
|
||||||
|
remote: string;
|
||||||
|
/** Embedded: the GDB server started first, its arguments, and the text it prints once it listens. */
|
||||||
|
server?: string;
|
||||||
|
serverArgs?: string;
|
||||||
|
serverReady?: string;
|
||||||
|
serverEnvironment?: Record<string, string>;
|
||||||
|
/** Embedded: flash the program when debugging: always, only when it changed since the last time, or never. */
|
||||||
|
download?: Download;
|
||||||
|
/** Embedded: run before downloading (and to start it after "Download & Run"'s download). */
|
||||||
|
resetCommand?: string;
|
||||||
|
/** Embedded, Download & Run: what starts the program after the download. */
|
||||||
|
runCommands?: string[];
|
||||||
|
/** Remote: the target's root, for its shared libraries. */
|
||||||
|
sysroot?: string;
|
||||||
|
/** Remote: where its sources were built (as gdb sees them) → where they are here. */
|
||||||
|
pathMappings?: Record<string, string>;
|
||||||
|
/** Remote over SSH: user@host; the program is uploaded to `remotePath` and gdbserver started there. */
|
||||||
|
ssh?: string;
|
||||||
|
remotePath?: string;
|
||||||
|
gdbserver?: string;
|
||||||
|
/** The program's arguments (remote over SSH). */
|
||||||
|
args?: string;
|
||||||
|
/** gdb commands before connecting, and after connecting (before the program goes on). */
|
||||||
|
initCommands?: string[];
|
||||||
|
connectCommands?: string[];
|
||||||
|
buildBeforeDebug?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KINDS: Record<GdbKind, string> = { embedded: "Embedded GDB Server", remote: "Remote GDB Server" };
|
||||||
|
|
||||||
|
export function gdbConfigurations(): GdbConfiguration[] {
|
||||||
|
const list = settings().get<GdbConfiguration[]>("gdbConfigurations") ?? [];
|
||||||
|
return list.filter((c) => c && typeof c.name === "string" && c.name.trim() && (c.kind === "embedded" || c.kind === "remote"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gdbConfiguration(name: string | undefined): GdbConfiguration | undefined {
|
||||||
|
return name ? gdbConfigurations().find((c) => c.name === name) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The debugger when the configuration names none: one for any architecture if there is one. */
|
||||||
|
export function defaultDebugger(): string {
|
||||||
|
for (const g of ["gdb-multiarch", "arm-none-eabi-gdb", "gdb"]) if (onPath(g)) return g;
|
||||||
|
return "gdb";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A program on PATH (or a path) as a full path: C/C++ starts the server and gdb without a shell. */
|
||||||
|
function resolveProgram(p: string): string | undefined {
|
||||||
|
if (p.includes("/") || p.includes("\\")) return fs.existsSync(p) ? p : undefined;
|
||||||
|
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
||||||
|
const full = dir && path.join(dir, p);
|
||||||
|
if (full && fs.existsSync(full)) return full;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** user@host:2345 → host:2345 for `target remote`, from the SSH host and the port in `remote`. */
|
||||||
|
function sshRemote(c: GdbConfiguration): { host: string; port: string } {
|
||||||
|
const host = (c.ssh ?? "").replace(/^.*@/, "").replace(/:\d+$/, "");
|
||||||
|
const port = /:(\d+)\s*$/.exec(c.remote)?.[1] ?? "2345";
|
||||||
|
return { host, port };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Resolved {
|
||||||
|
target?: Target;
|
||||||
|
program: string;
|
||||||
|
vars: ReturnType<CMakeProject["vars"]> & { program: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The configuration's program (after building it, if it's a target and that's on). */
|
||||||
|
async function resolve(project: CMakeProject, c: GdbConfiguration, build: boolean): Promise<Resolved | undefined> {
|
||||||
|
const target = c.target ? project.target(c.target) : project.activeTarget;
|
||||||
|
if (c.target && !target && !c.program) {
|
||||||
|
void vscode.window.showErrorMessage(`“${c.name}”: the project has no target ${c.target}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (build && target && (c.buildBeforeDebug ?? true) && !(await buildFirst(project, target))) return;
|
||||||
|
const base = project.vars(target);
|
||||||
|
const program = c.program ? path.resolve(project.sourceDir, expand(c.program, base)) : target && executableOf(target);
|
||||||
|
if (!program) {
|
||||||
|
void vscode.window.showErrorMessage(`“${c.name}” has no program: choose an executable target, or a file.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(program)) {
|
||||||
|
void vscode.window.showErrorMessage(`${program} isn't there. Build it first.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return { target, program, vars: { ...base, program } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ex = (s: string, vars: Resolved["vars"]) => expand(s, vars as Parameters<typeof expand>[1]).replace(/\$\{program\}/g, vars.program);
|
||||||
|
|
||||||
|
/** The program's content, to tell whether it changed since it was last downloaded. */
|
||||||
|
function fingerprint(file: string): string {
|
||||||
|
return crypto.createHash("sha1").update(fs.readFileSync(file)).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sh = (a: string) => `'${a.replace(/'/g, `'\\''`)}'`;
|
||||||
|
|
||||||
|
const downloadedKey = (c: GdbConfiguration) => `cmakeHelper.downloaded:${c.name}`;
|
||||||
|
|
||||||
|
/** 🐞 with a configuration: (build,) (upload,) start the server, connect gdb, (download,) go. */
|
||||||
|
export async function debugWithConfiguration(project: CMakeProject, c: GdbConfiguration): Promise<boolean> {
|
||||||
|
const r = await resolve(project, c, true);
|
||||||
|
if (!r) return false;
|
||||||
|
const gdbName = c.debugger?.trim() || defaultDebugger();
|
||||||
|
const gdb = resolveProgram(ex(gdbName, r.vars));
|
||||||
|
if (!gdb) {
|
||||||
|
void vscode.window.showErrorMessage(`“${c.name}” debugs with ${gdbName}, and it isn't installed here (not on PATH). Set the debugger in the configuration.`, "Edit Configuration").then((a) => {
|
||||||
|
if (a) void vscode.commands.executeCommand("cmakeHelper.editGdbConfigurations", c.name);
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!vscode.extensions.getExtension("ms-vscode.cpptools")) {
|
||||||
|
void vscode.window.showErrorMessage("Remote and embedded debugging go through the C/C++ extension (ms-vscode.cpptools): install it.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const setup: { text: string; ignoreFailures?: boolean; description?: string }[] = [
|
||||||
|
{ description: "Pretty printing", text: "-enable-pretty-printing", ignoreFailures: true },
|
||||||
|
];
|
||||||
|
const connect: { text: string; ignoreFailures?: boolean }[] = [];
|
||||||
|
const config: vscode.DebugConfiguration = {
|
||||||
|
type: "cppdbg",
|
||||||
|
request: "launch",
|
||||||
|
name: c.name,
|
||||||
|
program: r.program,
|
||||||
|
args: [],
|
||||||
|
cwd: project.sourceDir,
|
||||||
|
MIMode: "gdb",
|
||||||
|
miDebuggerPath: gdb,
|
||||||
|
externalConsole: false,
|
||||||
|
stopAtEntry: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (c.kind === "embedded") {
|
||||||
|
config.miDebuggerServerAddress = ex(c.remote, r.vars).replace(/^tcp:/, "");
|
||||||
|
if (c.server?.trim()) {
|
||||||
|
const server = resolveProgram(ex(c.server.trim(), r.vars));
|
||||||
|
if (!server) {
|
||||||
|
void vscode.window.showErrorMessage(`“${c.name}”: the GDB server ${c.server} isn't installed here (not on PATH).`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Its environment through env(1): C/C++'s own `environment` is the program's.
|
||||||
|
const env = Object.entries(c.serverEnvironment ?? {}).map(([k, v]) => `${k}=${ex(v, r.vars)}`);
|
||||||
|
config.debugServerPath = env.length ? (resolveProgram("env") ?? "/usr/bin/env") : server;
|
||||||
|
config.debugServerArgs = [...env.map(sh), ...(env.length ? [sh(server)] : []), ex(c.serverArgs ?? "", r.vars)].filter(Boolean).join(" ");
|
||||||
|
// Without the text it prints once it listens, C/C++ only waits a moment.
|
||||||
|
if (c.serverReady?.trim()) config.serverStarted = c.serverReady.trim();
|
||||||
|
config.filterStdout = true;
|
||||||
|
config.filterStderr = true;
|
||||||
|
config.serverLaunchTimeout = 20000;
|
||||||
|
}
|
||||||
|
const reset = c.resetCommand?.trim();
|
||||||
|
const mode = c.download ?? "always";
|
||||||
|
const print = fingerprint(r.program);
|
||||||
|
const download = mode === "always" || (mode === "updated" && project.workspaceState.get(downloadedKey(c)) !== print);
|
||||||
|
if (reset) connect.push({ text: reset });
|
||||||
|
if (download) {
|
||||||
|
connect.push({ text: "load" });
|
||||||
|
if (reset) connect.push({ text: reset });
|
||||||
|
await project.workspaceState.update(downloadedKey(c), print);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (c.sysroot?.trim()) setup.push({ text: `set sysroot ${ex(c.sysroot.trim(), r.vars)}` });
|
||||||
|
if (c.ssh?.trim()) {
|
||||||
|
const remotePath = c.remotePath?.trim() || `/tmp/${path.basename(r.program)}`;
|
||||||
|
if (!(await upload(project, c, r.program, remotePath))) return false;
|
||||||
|
const { host, port } = sshRemote(c);
|
||||||
|
config.miDebuggerServerAddress = `${host}:${port}`;
|
||||||
|
config.debugServerPath = resolveProgram("ssh") ?? "ssh";
|
||||||
|
config.debugServerArgs = ["-o", "BatchMode=yes", c.ssh.trim(), c.gdbserver?.trim() || "gdbserver", `:${port}`, remotePath, ...splitArgs(ex(c.args ?? "", r.vars))]
|
||||||
|
.map((a) => (/[\s"'\\]/.test(a) ? `'${a.replace(/'/g, `'\\''`)}'` : a))
|
||||||
|
.join(" ");
|
||||||
|
config.serverStarted = "Listening on port";
|
||||||
|
config.filterStdout = true;
|
||||||
|
config.filterStderr = true;
|
||||||
|
config.serverLaunchTimeout = 20000;
|
||||||
|
} else {
|
||||||
|
config.miDebuggerServerAddress = ex(c.remote, r.vars).replace(/^tcp:/, "");
|
||||||
|
}
|
||||||
|
if (c.pathMappings && Object.keys(c.pathMappings).length) {
|
||||||
|
config.sourceFileMap = Object.fromEntries(Object.entries(c.pathMappings).map(([from, to]) => [from, path.resolve(project.sourceDir, ex(to, r.vars))]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const t of c.initCommands ?? []) if (t.trim()) setup.push({ text: ex(t.trim(), r.vars) });
|
||||||
|
for (const t of c.connectCommands ?? []) if (t.trim()) connect.push({ text: ex(t.trim(), r.vars) });
|
||||||
|
config.setupCommands = setup;
|
||||||
|
if (connect.length) config.postRemoteConnectCommands = connect;
|
||||||
|
// The owner's additions for cppdbg apply here too (e.g. a longer timeout).
|
||||||
|
const extra = settings().get<Record<string, Record<string, unknown>>>("debugConfiguration")?.cppdbg;
|
||||||
|
try {
|
||||||
|
return await vscode.debug.startDebugging(project.folder, extra ? { ...config, ...extra, miDebuggerPath: gdb } : config);
|
||||||
|
} catch (err) {
|
||||||
|
void vscode.window.showErrorMessage(`Can't start the debugger: ${errorText(err)}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remote over SSH: the program copied to the target (scp), in the build terminal. */
|
||||||
|
async function upload(project: CMakeProject, c: GdbConfiguration, program: string, remotePath: string): Promise<boolean> {
|
||||||
|
const scp = resolveProgram("scp");
|
||||||
|
if (!scp) {
|
||||||
|
void vscode.window.showErrorMessage("Uploading needs scp, and it isn't installed here.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const code = await project.runTask(`Upload ${path.basename(program)} → ${c.ssh}`, "build", scp, ["-o", "BatchMode=yes", program, `${c.ssh!.trim()}:${remotePath}`], {
|
||||||
|
cwd: project.sourceDir,
|
||||||
|
key: `upload:${c.name}`,
|
||||||
|
});
|
||||||
|
if (code === 0) return true;
|
||||||
|
void vscode.window.showErrorMessage(`Uploading to ${c.ssh} failed (see the terminal).`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ▶ with a configuration. Embedded: CLion's "Download & Run" (start the server,
|
||||||
|
* flash with gdb in batch mode, start the program, detach). Remote over SSH:
|
||||||
|
* upload and run it there. Remote without SSH: only the debugger reaches it.
|
||||||
|
*/
|
||||||
|
export async function runWithConfiguration(project: CMakeProject, c: GdbConfiguration): Promise<void> {
|
||||||
|
if (c.kind === "remote" && !c.ssh?.trim()) {
|
||||||
|
void vscode.window.showInformationMessage(`“${c.name}” reaches its target only through gdb: debug it (Shift+F9), or set an SSH host to run it there.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = await resolve(project, c, true);
|
||||||
|
if (!r) return;
|
||||||
|
if (c.kind === "remote") {
|
||||||
|
const remotePath = c.remotePath?.trim() || `/tmp/${path.basename(r.program)}`;
|
||||||
|
if (!(await upload(project, c, r.program, remotePath))) return;
|
||||||
|
const ssh = resolveProgram("ssh") ?? "ssh";
|
||||||
|
await project.runTask(`Run ${c.name}`, "run", ssh, ["-t", c.ssh!.trim(), [remotePath, ...splitArgs(ex(c.args ?? "", r.vars))].map(sh).join(" ")], {
|
||||||
|
cwd: project.sourceDir,
|
||||||
|
key: `run:${c.name}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const gdbName = c.debugger?.trim() || defaultDebugger();
|
||||||
|
const gdb = resolveProgram(ex(gdbName, r.vars));
|
||||||
|
if (!gdb) {
|
||||||
|
void vscode.window.showErrorMessage(`Download & Run needs ${gdbName}, and it isn't installed here (not on PATH).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reset = c.resetCommand?.trim();
|
||||||
|
const run = (c.runCommands?.length ? c.runCommands : ["monitor reset run"]).map((t) => ex(t, r.vars)).filter((t) => t.trim());
|
||||||
|
const gdbArgs = ["-batch", "-nx", ...(c.initCommands ?? []).flatMap((t) => ["-ex", ex(t, r.vars)]), "-ex", `target remote ${ex(c.remote, r.vars).replace(/^tcp:/, "")}`];
|
||||||
|
if (reset) gdbArgs.push("-ex", reset);
|
||||||
|
gdbArgs.push("-ex", "load");
|
||||||
|
for (const t of run) gdbArgs.push("-ex", t);
|
||||||
|
gdbArgs.push("-ex", "detach", r.program);
|
||||||
|
// The server for the while gdb flashes: started first, waited for (its "ready" text, else a moment), stopped after.
|
||||||
|
const lines = ["set -e"];
|
||||||
|
if (c.server?.trim()) {
|
||||||
|
const server = resolveProgram(ex(c.server.trim(), r.vars));
|
||||||
|
if (!server) {
|
||||||
|
void vscode.window.showErrorMessage(`“${c.name}”: the GDB server ${c.server} isn't installed here (not on PATH).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const log = `/tmp/cmake-helper-server-${process.pid}.log`;
|
||||||
|
lines.push(
|
||||||
|
`${[server, ...splitArgs(ex(c.serverArgs ?? "", r.vars))].map(sh).join(" ")} > ${sh(log)} 2>&1 &`,
|
||||||
|
"server=$!",
|
||||||
|
`trap 'kill $server 2>/dev/null; cat ${sh(log)}; rm -f ${sh(log)}' EXIT`,
|
||||||
|
c.serverReady?.trim()
|
||||||
|
? `for i in $(seq 1 100); do grep -qE ${sh(c.serverReady.trim())} ${sh(log)} && break; kill -0 $server 2>/dev/null || { echo "The GDB server stopped:"; exit 1; }; sleep 0.2; done`
|
||||||
|
: "sleep 1",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push([gdb, ...gdbArgs].map(sh).join(" "));
|
||||||
|
const env = Object.fromEntries(Object.entries(c.serverEnvironment ?? {}).map(([k, v]) => [k, ex(v, r.vars)]));
|
||||||
|
await project.runTask(`Download & Run ${c.name}`, "run", "/bin/sh", ["-c", lines.join("\n")], { cwd: project.sourceDir, env, key: `run:${c.name}` });
|
||||||
|
await project.workspaceState.update(downloadedKey(c), fingerprint(r.program));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- the configuration as the editor's form shows it ---- */
|
||||||
|
|
||||||
|
export interface GdbRow {
|
||||||
|
id: string;
|
||||||
|
origName?: string;
|
||||||
|
name: string;
|
||||||
|
kind: GdbKind;
|
||||||
|
target: string;
|
||||||
|
program: string;
|
||||||
|
debugger: string;
|
||||||
|
remote: string;
|
||||||
|
server: string;
|
||||||
|
serverArgs: string;
|
||||||
|
serverReady: string;
|
||||||
|
serverEnvironment: string;
|
||||||
|
download: Download;
|
||||||
|
resetCommand: string;
|
||||||
|
runCommands: string;
|
||||||
|
sysroot: string;
|
||||||
|
pathMappings: string;
|
||||||
|
ssh: string;
|
||||||
|
remotePath: string;
|
||||||
|
gdbserver: string;
|
||||||
|
args: string;
|
||||||
|
initCommands: string;
|
||||||
|
connectCommands: string;
|
||||||
|
buildBeforeDebug: boolean;
|
||||||
|
rest: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHOWN = ["name", "kind", "target", "program", "debugger", "remote", "server", "serverArgs", "serverReady", "serverEnvironment", "download", "resetCommand", "runCommands", "sysroot", "pathMappings", "ssh", "remotePath", "gdbserver", "args", "initCommands", "connectCommands", "buildBeforeDebug"];
|
||||||
|
|
||||||
|
const lines = (l: string[] | undefined) => (l ?? []).join("\n");
|
||||||
|
const unlines = (s: string) => s.split("\n").map((x) => x.trim()).filter(Boolean);
|
||||||
|
const mappings = (m: Record<string, string> | undefined) => Object.entries(m ?? {}).map(([a, b]) => `${a}=${b}`).join("\n");
|
||||||
|
|
||||||
|
export function toRow(c: GdbConfiguration): GdbRow {
|
||||||
|
const rest: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(c)) if (!SHOWN.includes(k)) rest[k] = v;
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
origName: c.name,
|
||||||
|
name: c.name,
|
||||||
|
kind: c.kind,
|
||||||
|
target: c.target ?? "",
|
||||||
|
program: c.program ?? "",
|
||||||
|
debugger: c.debugger ?? "",
|
||||||
|
remote: c.remote ?? "",
|
||||||
|
server: c.server ?? "",
|
||||||
|
serverArgs: c.serverArgs ?? "",
|
||||||
|
serverReady: c.serverReady ?? "",
|
||||||
|
serverEnvironment: Object.entries(c.serverEnvironment ?? {}).map(([k, v]) => `${k}=${v}`).join(";"),
|
||||||
|
download: c.download ?? "always",
|
||||||
|
resetCommand: c.resetCommand ?? "",
|
||||||
|
runCommands: lines(c.runCommands),
|
||||||
|
sysroot: c.sysroot ?? "",
|
||||||
|
pathMappings: mappings(c.pathMappings),
|
||||||
|
ssh: c.ssh ?? "",
|
||||||
|
remotePath: c.remotePath ?? "",
|
||||||
|
gdbserver: c.gdbserver ?? "",
|
||||||
|
args: c.args ?? "",
|
||||||
|
initCommands: lines(c.initCommands),
|
||||||
|
connectCommands: lines(c.connectCommands),
|
||||||
|
buildBeforeDebug: c.buildBeforeDebug ?? true,
|
||||||
|
rest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The row as the setting keeps it: only what's set, and only the fields of its kind. */
|
||||||
|
export function fromRow(r: GdbRow): GdbConfiguration {
|
||||||
|
const c: GdbConfiguration = { ...(r.rest as object), name: r.name.trim(), kind: r.kind, remote: r.remote.trim() };
|
||||||
|
const set = <K extends keyof GdbConfiguration>(k: K, v: GdbConfiguration[K] | "" | undefined) => {
|
||||||
|
if (v !== "" && v !== undefined && !(Array.isArray(v) && !v.length) && !(typeof v === "object" && v && !Array.isArray(v) && !Object.keys(v).length)) c[k] = v as GdbConfiguration[K];
|
||||||
|
};
|
||||||
|
set("target", r.target.trim());
|
||||||
|
set("program", r.program.trim());
|
||||||
|
set("debugger", r.debugger.trim());
|
||||||
|
set("initCommands", unlines(r.initCommands));
|
||||||
|
set("connectCommands", unlines(r.connectCommands));
|
||||||
|
if (!r.buildBeforeDebug) c.buildBeforeDebug = false;
|
||||||
|
if (r.kind === "embedded") {
|
||||||
|
set("server", r.server.trim());
|
||||||
|
set("serverArgs", r.serverArgs.trim());
|
||||||
|
set("serverReady", r.serverReady.trim());
|
||||||
|
set("serverEnvironment", parseEnv(r.serverEnvironment.trim()));
|
||||||
|
c.download = r.download;
|
||||||
|
set("resetCommand", r.resetCommand.trim());
|
||||||
|
set("runCommands", unlines(r.runCommands));
|
||||||
|
} else {
|
||||||
|
set("sysroot", r.sysroot.trim());
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const l of unlines(r.pathMappings)) {
|
||||||
|
const i = l.indexOf("=");
|
||||||
|
if (i > 0) map[l.slice(0, i).trim()] = l.slice(i + 1).trim();
|
||||||
|
}
|
||||||
|
set("pathMappings", map);
|
||||||
|
set("ssh", r.ssh.trim());
|
||||||
|
set("remotePath", r.remotePath.trim());
|
||||||
|
set("gdbserver", r.gdbserver.trim());
|
||||||
|
set("args", r.args.trim());
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a list can't be saved with: the first problem, or none. */
|
||||||
|
export function problem(rows: GdbRow[]): string | undefined {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const name = r.name.trim();
|
||||||
|
if (!name) return "Every configuration needs a name.";
|
||||||
|
if (seen.has(name)) return `There are two configurations named “${name}”.`;
|
||||||
|
seen.add(name);
|
||||||
|
if (!r.remote.trim() && !(r.kind === "remote" && r.ssh.trim())) return `“${name}” needs the 'target remote' address (e.g. localhost:3333).`;
|
||||||
|
if (r.serverReady.trim()) {
|
||||||
|
try {
|
||||||
|
new RegExp(r.serverReady.trim());
|
||||||
|
} catch {
|
||||||
|
return `“${name}”: the server's "ready" text isn't a valid regular expression.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* The GDB server configurations editor, as CLion's Run/Debug Configurations
|
||||||
|
* for "Embedded GDB Server" and "Remote GDB Server": the list on the left, the
|
||||||
|
* selected one's fields on the right (those of its kind), OK / Cancel / Apply.
|
||||||
|
* It edits the workspace setting `cmakeHelper.gdbConfigurations`.
|
||||||
|
*/
|
||||||
|
import * as crypto from "node:crypto";
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import { editorIcon, editorPage } from "./editorPage";
|
||||||
|
import { type GdbRow, defaultDebugger, fromRow, gdbConfigurations, problem, toRow } from "./gdbConfigs";
|
||||||
|
import type { CMakeProject } from "./project";
|
||||||
|
import { settings } from "./util";
|
||||||
|
|
||||||
|
let panel: vscode.WebviewPanel | undefined;
|
||||||
|
|
||||||
|
/** Opens the editor (or brings it forward). `select`: a configuration's name; `add`: start with a new one. */
|
||||||
|
export function openGdbEditor(project: CMakeProject, opts: { select?: string; add?: boolean } = {}): void {
|
||||||
|
const init = () => ({
|
||||||
|
type: "init",
|
||||||
|
rows: gdbConfigurations().map(toRow),
|
||||||
|
targets: project.visibleTargets().filter((t) => t.type === "EXECUTABLE").map((t) => t.name),
|
||||||
|
activeTarget: project.activeTarget?.name ?? "",
|
||||||
|
active: project.activeGdbConfiguration?.name ?? "",
|
||||||
|
defaultDebugger: defaultDebugger(),
|
||||||
|
select: opts.select,
|
||||||
|
add: !!opts.add,
|
||||||
|
});
|
||||||
|
if (panel) {
|
||||||
|
panel.reveal();
|
||||||
|
void panel.webview.postMessage(init());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
panel = vscode.window.createWebviewPanel("cmakeHelper.gdbConfigurations", "GDB Server Configurations", vscode.ViewColumn.Active, { enableScripts: true, retainContextWhenHidden: true });
|
||||||
|
panel.iconPath = editorIcon();
|
||||||
|
const nonce = crypto.randomBytes(16).toString("base64");
|
||||||
|
panel.webview.html = editorPage(panel.webview, nonce, { noun: "configuration", extra: DATALISTS, script: SCRIPT });
|
||||||
|
panel.onDidDispose(() => (panel = undefined));
|
||||||
|
panel.webview.onDidReceiveMessage(async (msg: { type: string; rows?: GdbRow[]; close?: boolean }) => {
|
||||||
|
if (msg.type === "ready") {
|
||||||
|
void panel?.webview.postMessage(init());
|
||||||
|
} else if (msg.type === "cancel") {
|
||||||
|
panel?.dispose();
|
||||||
|
} else if (msg.type === "save" && msg.rows) {
|
||||||
|
const err = problem(msg.rows);
|
||||||
|
if (err) {
|
||||||
|
void panel?.webview.postMessage({ type: "error", text: err });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The chosen configuration stays chosen under its new name; a removed one isn't chosen any more.
|
||||||
|
const active = project.activeGdbConfiguration?.name;
|
||||||
|
if (active) {
|
||||||
|
const row = msg.rows.find((r) => r.origName === active);
|
||||||
|
if (!row) await project.setActiveGdbConfiguration(undefined);
|
||||||
|
else if (row.name.trim() !== active) await project.setActiveGdbConfiguration(row.name.trim());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await settings().update("gdbConfigurations", msg.rows.map(fromRow), vscode.ConfigurationTarget.Workspace);
|
||||||
|
} catch (e) {
|
||||||
|
void panel?.webview.postMessage({ type: "error", text: e instanceof Error ? e.message : String(e) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.close) panel?.dispose();
|
||||||
|
else void panel?.webview.postMessage({ ...init(), type: "saved" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATALISTS = `<datalist id="debuggers"><option value="gdb-multiarch"><option value="arm-none-eabi-gdb"><option value="gdb"></datalist>
|
||||||
|
<datalist id="targets"></datalist>`;
|
||||||
|
|
||||||
|
/** The editor's script: the list, the form of the selected configuration's kind, and the presets for common GDB servers. */
|
||||||
|
const SCRIPT = String.raw`
|
||||||
|
const vscode = acquireVsCodeApi();
|
||||||
|
let rows = [], sel = null, dirty = false, active = "", defaultDebugger = "gdb", activeTarget = "";
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||||
|
const uid = () => Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||||
|
const current = () => rows.find((r) => r.id === sel);
|
||||||
|
|
||||||
|
/* Starting points for common servers (CLion's GDB server field); the board's own files and names go in after. */
|
||||||
|
const PRESETS = {
|
||||||
|
openocd: { label: "OpenOCD", server: "openocd", serverArgs: "-f interface/stlink.cfg -f target/stm32f4x.cfg", serverReady: "Listening on port 3333 for gdb connections", remote: "localhost:3333", resetCommand: "monitor reset halt", runCommands: "monitor reset run" },
|
||||||
|
jlink: { label: "SEGGER J-Link", server: "JLinkGDBServerCLExe", serverArgs: "-device STM32F407VG -if SWD -speed 4000 -port 2331 -nogui -silent", serverReady: "Waiting for GDB connection", remote: "localhost:2331", resetCommand: "monitor reset", runCommands: "monitor reset\nmonitor go" },
|
||||||
|
stlink: { label: "ST-LINK (st-util)", server: "st-util", serverArgs: "-p 4242", serverReady: "Listening at", remote: "localhost:4242", resetCommand: "monitor reset halt", runCommands: "monitor reset" },
|
||||||
|
pyocd: { label: "pyOCD", server: "pyocd", serverArgs: "gdbserver --target stm32f407vg --port 3333", serverReady: "GDB server listening on port", remote: "localhost:3333", resetCommand: "monitor reset halt", runCommands: "monitor reset" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY = { kind: "embedded", target: "", program: "", debugger: "", remote: "", server: "", serverArgs: "", serverReady: "", serverEnvironment: "", download: "always", resetCommand: "", runCommands: "", sysroot: "", pathMappings: "", ssh: "", remotePath: "", gdbserver: "", args: "", initCommands: "", connectCommands: "", buildBeforeDebug: true, rest: {} };
|
||||||
|
|
||||||
|
function freeName(base) {
|
||||||
|
const taken = new Set(rows.map((r) => r.name.trim()));
|
||||||
|
if (!taken.has(base)) return base;
|
||||||
|
for (let i = 2; ; i++) if (!taken.has(base + " " + i)) return base + " " + i;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDirty(v) {
|
||||||
|
dirty = v;
|
||||||
|
$("apply").disabled = !v;
|
||||||
|
if (v) $("err").textContent = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
$("list").innerHTML = rows.map((r) => {
|
||||||
|
const tag = r.origName && r.origName === active ? '<span class="tag">chosen</span>' : '<span class="tag">' + (r.kind === "embedded" ? "embedded" : "remote") + "</span>";
|
||||||
|
return '<li class="' + (r.id === sel ? "sel" : "") + '" data-id="' + esc(r.id) + '">' + esc(r.name || "(unnamed)") + tag + "</li>";
|
||||||
|
}).join("");
|
||||||
|
const r = current();
|
||||||
|
const i = rows.indexOf(r);
|
||||||
|
$("remove").disabled = !r;
|
||||||
|
$("copy").disabled = !r;
|
||||||
|
$("up").disabled = !r || i <= 0;
|
||||||
|
$("down").disabled = !r || i >= rows.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function input(label, key, value, o = {}) {
|
||||||
|
const attrs = (o.list ? ' list="' + o.list + '"' : "") + (o.placeholder ? ' placeholder="' + esc(o.placeholder) + '"' : "");
|
||||||
|
const field = o.area
|
||||||
|
? '<textarea data-k="' + key + '"' + attrs + ">" + esc(value) + "</textarea>"
|
||||||
|
: '<input type="text" data-k="' + key + '" value="' + esc(value) + '"' + attrs + ">";
|
||||||
|
return "<label>" + label + ":</label><div>" + field + (o.hint ? '<div class="hint">' + o.hint + "</div>" : "") + "</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(label, key, value, options, hint) {
|
||||||
|
return "<label>" + label + ":</label><div><select data-k=\"" + key + "\">" +
|
||||||
|
options.map(([v, l]) => '<option value="' + esc(v) + '"' + (v === value ? " selected" : "") + ">" + esc(l) + "</option>").join("") +
|
||||||
|
"</select>" + (hint ? '<div class="hint">' + hint + "</div>" : "") + "</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
const sec = (t) => '<div class="sec">' + t + "</div>";
|
||||||
|
|
||||||
|
function renderForm() {
|
||||||
|
const r = current();
|
||||||
|
if (!r) {
|
||||||
|
$("form").innerHTML = '<p class="empty">' + (rows.length ? "Select a configuration." : "No GDB server configurations yet. Add one with +: an embedded board through OpenOCD, J-Link, ST-LINK…, or a gdbserver on another machine.") + "</p>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const emb = r.kind === "embedded";
|
||||||
|
let h = '<div class="grid">' +
|
||||||
|
input("Name", "name", r.name) +
|
||||||
|
'<label>Kind:</label><div class="radios">' +
|
||||||
|
'<label><input type="radio" name="kind" data-k="kind" value="embedded"' + (emb ? " checked" : "") + "> Embedded GDB Server</label>" +
|
||||||
|
'<label><input type="radio" name="kind" data-k="kind" value="remote"' + (!emb ? " checked" : "") + "> Remote GDB Server</label></div>" +
|
||||||
|
input("Target", "target", r.target, { list: "targets", placeholder: "the active target (" + (activeTarget || "none") + ")", hint: "The CMake executable that's debugged" + (emb ? " and flashed" : "") + "; it's built first." }) +
|
||||||
|
input("Executable", "program", r.program, { placeholder: "the target's", hint: "Another file instead (an .elf built some other way)." }) +
|
||||||
|
input("Debugger", "debugger", r.debugger, { list: "debuggers", placeholder: defaultDebugger, hint: "A gdb that knows the target's architecture." }) +
|
||||||
|
"<span></span><label class=\"check\"><input type=\"checkbox\" data-k=\"buildBeforeDebug\"" + (r.buildBeforeDebug ? " checked" : "") + "> Build before debugging</label>";
|
||||||
|
if (emb) {
|
||||||
|
h += sec("GDB server") +
|
||||||
|
'<label>Fill in for:</label><div><select id="preset"><option value="">Choose a server…</option>' +
|
||||||
|
Object.entries(PRESETS).map(([k, p]) => '<option value="' + k + '">' + esc(p.label) + "</option>").join("") +
|
||||||
|
'</select><div class="hint">Common settings to start from; then set your board\'s config files or device.</div></div>' +
|
||||||
|
input("GDB server", "server", r.server, { placeholder: "none: it's already running", hint: "Started before gdb connects, and stopped after." }) +
|
||||||
|
input("Server arguments", "serverArgs", r.serverArgs, { area: true, placeholder: "-f interface/stlink.cfg -f target/stm32f4x.cfg" }) +
|
||||||
|
input("Ready when it prints", "serverReady", r.serverReady, { placeholder: "Listening on port 3333", hint: "A regular expression; empty: it gets a moment." }) +
|
||||||
|
input("Server environment", "serverEnvironment", r.serverEnvironment, { placeholder: "A=1;B=2" }) +
|
||||||
|
input("'target remote' args", "remote", r.remote, { placeholder: "localhost:3333" }) +
|
||||||
|
sec("Download") +
|
||||||
|
select("Download executable", "download", r.download, [["always", "Always"], ["updated", "If updated"], ["never", "Never"]], "Flash the program when debugging starts.") +
|
||||||
|
input("Reset command", "resetCommand", r.resetCommand, { placeholder: "monitor reset halt", hint: "Before the download, and again after it." }) +
|
||||||
|
input("Download & Run starts it with", "runCommands", r.runCommands, { area: true, placeholder: "monitor reset run", hint: "gdb commands, one per line, after the download (▶ Run, without a debugger)." });
|
||||||
|
} else {
|
||||||
|
h += sec("Connection") +
|
||||||
|
input("'target remote' args", "remote", r.remote, { placeholder: r.ssh ? "its port: :2345" : "192.168.1.20:2345", hint: r.ssh ? "With SSH, the host is the SSH host's; only the port counts." : "Where gdbserver listens." }) +
|
||||||
|
input("Sysroot", "sysroot", r.sysroot, { placeholder: "none", hint: "The target's root, for its shared libraries." }) +
|
||||||
|
input("Path mappings", "pathMappings", r.pathMappings, { area: true, placeholder: "/remote/src=" + "$" + "{sourceDir}", hint: "Where its sources were built=where they are here, one per line." }) +
|
||||||
|
sec("Upload and start over SSH (optional)") +
|
||||||
|
input("SSH host", "ssh", r.ssh, { placeholder: "user@host", hint: "The program is copied there (scp) and gdbserver started on it; ▶ Run runs it there." }) +
|
||||||
|
input("Remote path", "remotePath", r.remotePath, { placeholder: "/tmp/<program>" }) +
|
||||||
|
input("gdbserver", "gdbserver", r.gdbserver, { placeholder: "gdbserver" }) +
|
||||||
|
input("Program arguments", "args", r.args, { placeholder: "--verbose" });
|
||||||
|
}
|
||||||
|
h += sec("Advanced") +
|
||||||
|
input("gdb commands before connecting", "initCommands", r.initCommands, { area: true, placeholder: "set architecture arm", hint: "One per line." }) +
|
||||||
|
input("gdb commands after connecting", "connectCommands", r.connectCommands, { area: true, placeholder: "monitor arm semihosting enable" }) +
|
||||||
|
"</div>";
|
||||||
|
$("form").innerHTML = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
renderList();
|
||||||
|
renderForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
$("list").addEventListener("click", (e) => {
|
||||||
|
const li = e.target.closest("li[data-id]");
|
||||||
|
if (!li) return;
|
||||||
|
sel = li.dataset.id;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("form").addEventListener("input", (e) => {
|
||||||
|
const r = current();
|
||||||
|
if (!r) return;
|
||||||
|
if (e.target.id === "preset") {
|
||||||
|
const p = PRESETS[e.target.value];
|
||||||
|
if (!p) return;
|
||||||
|
for (const k of ["server", "serverArgs", "serverReady", "remote", "resetCommand", "runCommands"]) r[k] = p[k];
|
||||||
|
if (/^(Embedded|New)/.test(r.name)) r.name = freeName(p.label);
|
||||||
|
setDirty(true);
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const k = e.target.dataset.k;
|
||||||
|
if (!k) return;
|
||||||
|
r[k] = e.target.type === "checkbox" ? e.target.checked : e.target.value;
|
||||||
|
setDirty(true);
|
||||||
|
if (k === "kind") render();
|
||||||
|
else if (k === "name") renderList();
|
||||||
|
else if (k === "ssh") renderForm();
|
||||||
|
});
|
||||||
|
|
||||||
|
function addRow(from) {
|
||||||
|
const r = Object.assign({}, EMPTY, from || {}, { id: uid(), origName: undefined });
|
||||||
|
r.name = freeName(from && from.name ? from.name : "Embedded");
|
||||||
|
const at = current() ? rows.indexOf(current()) + 1 : rows.length;
|
||||||
|
rows.splice(at, 0, r);
|
||||||
|
sel = r.id;
|
||||||
|
setDirty(true);
|
||||||
|
render();
|
||||||
|
const name = $("form").querySelector('[data-k="name"]');
|
||||||
|
if (name) { name.focus(); name.select(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
$("add").onclick = () => addRow();
|
||||||
|
$("copy").onclick = () => {
|
||||||
|
const r = current();
|
||||||
|
if (r) addRow({ ...r, rest: { ...r.rest } });
|
||||||
|
};
|
||||||
|
$("remove").onclick = () => {
|
||||||
|
const r = current();
|
||||||
|
if (!r) return;
|
||||||
|
const i = rows.indexOf(r);
|
||||||
|
rows.splice(i, 1);
|
||||||
|
sel = rows[Math.min(i, rows.length - 1)]?.id ?? null;
|
||||||
|
setDirty(true);
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
function move(d) {
|
||||||
|
const r = current();
|
||||||
|
const i = rows.indexOf(r);
|
||||||
|
if (!r || i + d < 0 || i + d >= rows.length) return;
|
||||||
|
rows.splice(i, 1);
|
||||||
|
rows.splice(i + d, 0, r);
|
||||||
|
setDirty(true);
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
$("up").onclick = () => move(-1);
|
||||||
|
$("down").onclick = () => move(1);
|
||||||
|
|
||||||
|
function save(close) {
|
||||||
|
if (!dirty && close) return vscode.postMessage({ type: "cancel" });
|
||||||
|
vscode.postMessage({ type: "save", rows, close });
|
||||||
|
}
|
||||||
|
$("ok").onclick = () => save(true);
|
||||||
|
$("apply").onclick = () => save(false);
|
||||||
|
$("cancel").onclick = () => vscode.postMessage({ type: "cancel" });
|
||||||
|
|
||||||
|
window.addEventListener("message", (e) => {
|
||||||
|
const m = e.data;
|
||||||
|
if (m.type === "init" || m.type === "saved") {
|
||||||
|
const keep = m.type === "saved" && current() ? current().name.trim() : null;
|
||||||
|
rows = m.rows; active = m.active; defaultDebugger = m.defaultDebugger; activeTarget = m.activeTarget;
|
||||||
|
$("targets").innerHTML = m.targets.map((t) => '<option value="' + esc(t) + '">').join("");
|
||||||
|
if (m.type === "saved") sel = (keep && rows.find((r) => r.name === keep)?.id) || rows[0]?.id || null;
|
||||||
|
else sel = (m.select && rows.find((r) => r.origName === m.select)?.id) || rows.find((r) => r.origName === active)?.id || rows[0]?.id || null;
|
||||||
|
setDirty(false);
|
||||||
|
render();
|
||||||
|
if (m.type === "init" && m.add) addRow();
|
||||||
|
} else if (m.type === "error") {
|
||||||
|
$("err").textContent = m.text;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
vscode.postMessage({ type: "ready" });
|
||||||
|
`;
|
||||||
+192
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* Running and debugging targets, with each target's run configuration as
|
||||||
|
* CLion's CMake Application has it (the executable, program arguments,
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import { type Target, executableOf } from "./fileApi";
|
||||||
|
import type { CMakeProject } from "./project";
|
||||||
|
import { errorText, exists, expand, isWindows, onPath, settings, splitArgs } from "./util";
|
||||||
|
|
||||||
|
export interface RunConfiguration {
|
||||||
|
/** What runs after the target is built: its own executable unless this is set (a custom target's output, a test runner for a library…). */
|
||||||
|
executable?: string;
|
||||||
|
args?: string;
|
||||||
|
workingDirectory?: string;
|
||||||
|
environment?: Record<string, string>;
|
||||||
|
buildBeforeRun?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runConfiguration(target: string): RunConfiguration {
|
||||||
|
return settings().get<Record<string, RunConfiguration>>("runConfigurations")?.[target] ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Without the empty fields: a configuration with none left goes. */
|
||||||
|
export function cleanRunConfiguration(conf: RunConfiguration): RunConfiguration | undefined {
|
||||||
|
const clean = Object.fromEntries(Object.entries(conf).filter(([, v]) => v !== undefined && v !== "" && !(typeof v === "object" && !Object.keys(v).length)));
|
||||||
|
return Object.keys(clean).length ? clean : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All targets' configurations at once (the editor's OK / Apply); those of targets not in `targets` are kept. */
|
||||||
|
export async function saveRunConfigurations(confs: Record<string, RunConfiguration>, targets: string[]): Promise<void> {
|
||||||
|
const all = { ...(settings().get<Record<string, RunConfiguration>>("runConfigurations") ?? {}) };
|
||||||
|
for (const t of targets) {
|
||||||
|
const clean = confs[t] && cleanRunConfiguration(confs[t]!);
|
||||||
|
if (clean) all[t] = clean;
|
||||||
|
else delete all[t];
|
||||||
|
}
|
||||||
|
await settings().update("runConfigurations", all, vscode.ConfigurationTarget.Workspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The program a target runs: the executable set in its run configuration, else its own (none for a custom target or a library). */
|
||||||
|
export function programOf(project: CMakeProject, target: Target): string | undefined {
|
||||||
|
const set = runConfiguration(target.name).executable;
|
||||||
|
return set ? path.resolve(project.buildDir, expand(set, project.vars(target))) : executableOf(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Launch {
|
||||||
|
name: string;
|
||||||
|
program: string;
|
||||||
|
args: string[];
|
||||||
|
cwd: string;
|
||||||
|
env: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function launchOf(project: CMakeProject, target: Target, extraArgs: string[] = []): Launch | undefined {
|
||||||
|
const program = programOf(project, target);
|
||||||
|
if (!program) return undefined;
|
||||||
|
const conf = runConfiguration(target.name);
|
||||||
|
const vars = project.vars(target);
|
||||||
|
const cwd = path.resolve(project.sourceDir, expand(conf.workingDirectory || settings().get<string>("defaultWorkingDirectory") || "${targetDir}", vars));
|
||||||
|
const env: Record<string, string> = { ...project.activeProfile.env };
|
||||||
|
for (const [k, v] of Object.entries(conf.environment ?? {})) env[k] = expand(String(v), vars);
|
||||||
|
const args = [...splitArgs(expand(conf.args ?? "", vars)), ...extraArgs];
|
||||||
|
return { name: target.name, program, args, cwd, env };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the target before it's run or debugged, if that's on; false: don't go on. */
|
||||||
|
export async function buildFirst(project: CMakeProject, target: Target): Promise<boolean> {
|
||||||
|
const conf = runConfiguration(target.name);
|
||||||
|
if (!(conf.buildBeforeRun ?? settings().get<boolean>("buildBeforeRun", true))) return true;
|
||||||
|
if (await project.build([target.name])) return true;
|
||||||
|
const action = await vscode.window.showErrorMessage(`Building ${target.name} failed.`, "Run Anyway");
|
||||||
|
return action === "Run Anyway";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asks for an executable when a target has none to run or debug. */
|
||||||
|
function noProgram(target: Target, what: string): void {
|
||||||
|
void vscode.window
|
||||||
|
.showWarningMessage(`${target.name} has no executable to ${what}: set one in its run configuration.`, "Edit Configuration")
|
||||||
|
.then((a) => {
|
||||||
|
if (a) void vscode.commands.executeCommand("cmakeHelper.editRunConfiguration", target.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ▶ Run: builds it (if set to), then runs its program in its own terminal; a target with none (a custom target) is only built, as in CLion. */
|
||||||
|
export async function runTarget(project: CMakeProject, target: Target): Promise<void> {
|
||||||
|
if (!programOf(project, target)) {
|
||||||
|
if (target.type === "UTILITY") await project.build([target.name]);
|
||||||
|
else noProgram(target, "run");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(await buildFirst(project, target))) return;
|
||||||
|
const l = launchOf(project, target);
|
||||||
|
if (!l) return;
|
||||||
|
if (!exists(l.program)) {
|
||||||
|
void vscode.window.showErrorMessage(`${l.program} isn't there. Build ${target.name} first.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await project.runTask(`Run ${target.name}`, "run", l.program, l.args, { cwd: l.cwd, env: l.env, key: `run:${target.name}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 🐞 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)) return noProgram(target, "debug");
|
||||||
|
if (!(await buildFirst(project, target))) return;
|
||||||
|
const l = launchOf(project, target);
|
||||||
|
if (l) await debugProgram(project, l);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
if (!config) return false;
|
||||||
|
try {
|
||||||
|
return await vscode.debug.startDebugging(project.folder, config);
|
||||||
|
} catch (err) {
|
||||||
|
void vscode.window.showErrorMessage(`Can't start the debugger: ${errorText(err)}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Debugger = "cppdbg" | "cppvsdbg" | "lldb" | "lldb-dap";
|
||||||
|
|
||||||
|
function installed(id: string): boolean {
|
||||||
|
return !!vscode.extensions.getExtension(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickDebugger(project: CMakeProject): Debugger | undefined {
|
||||||
|
const choice = settings().get<string>("debugger", "auto");
|
||||||
|
const msvc = project.model?.compilerIds.includes("MSVC") ?? false;
|
||||||
|
if (choice === "cppdbg") return isWindows && msvc ? "cppvsdbg" : "cppdbg";
|
||||||
|
if (choice === "lldb" || choice === "lldb-dap" || choice === "cppvsdbg") return choice;
|
||||||
|
if (installed("ms-vscode.cpptools")) return isWindows && msvc ? "cppvsdbg" : "cppdbg";
|
||||||
|
if (installed("vadimcn.vscode-lldb")) return "lldb";
|
||||||
|
if (installed("llvm-vs-code-extensions.lldb-dap")) return "lldb-dap";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function debugConfiguration(project: CMakeProject, l: Launch): vscode.DebugConfiguration | undefined {
|
||||||
|
const kind = pickDebugger(project);
|
||||||
|
if (!kind) {
|
||||||
|
void vscode.window
|
||||||
|
.showErrorMessage("Debugging needs a debugger extension: C/C++ (ms-vscode.cpptools), CodeLLDB or LLDB DAP.", "Find One")
|
||||||
|
.then((a) => {
|
||||||
|
if (a) void vscode.commands.executeCommand("workbench.extensions.search", "@category:debuggers c++");
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const base = { name: `Debug ${l.name}`, request: "launch", program: l.program, args: l.args, cwd: l.cwd };
|
||||||
|
let config: vscode.DebugConfiguration;
|
||||||
|
switch (kind) {
|
||||||
|
case "cppdbg":
|
||||||
|
config = {
|
||||||
|
...base,
|
||||||
|
type: "cppdbg",
|
||||||
|
MIMode: process.platform === "darwin" ? "lldb" : "gdb",
|
||||||
|
environment: Object.entries(l.env).map(([name, value]) => ({ name, value })),
|
||||||
|
externalConsole: false,
|
||||||
|
setupCommands: [{ description: "Pretty printing", text: "-enable-pretty-printing", ignoreFailures: true }],
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case "cppvsdbg":
|
||||||
|
config = { ...base, type: "cppvsdbg", environment: Object.entries(l.env).map(([name, value]) => ({ name, value })), console: "integratedTerminal" };
|
||||||
|
break;
|
||||||
|
case "lldb":
|
||||||
|
config = { ...base, type: "lldb", env: l.env, terminal: "integrated" };
|
||||||
|
break;
|
||||||
|
case "lldb-dap":
|
||||||
|
config = { ...base, type: "lldb-dap", env: Object.entries(l.env).map(([k, v]) => `${k}=${v}`) };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// The owner's additions (a gdb path, more setup commands…) per debugger type.
|
||||||
|
const extra = settings().get<Record<string, Record<string, unknown>>>("debugConfiguration")?.[config.type];
|
||||||
|
// C/C++ only says it can't "determine path to debugger": say which one is missing.
|
||||||
|
if (config.type === "cppdbg" && !extra?.miDebuggerPath && !onPath(config.MIMode)) {
|
||||||
|
void vscode.window
|
||||||
|
.showErrorMessage(`Debugging needs ${config.MIMode}, and it isn't installed here (not on PATH). Install it, or set its path in cmakeHelper.debugConfiguration (cppdbg.miDebuggerPath).`, "Open Settings")
|
||||||
|
.then((a) => {
|
||||||
|
if (a) void vscode.commands.executeCommand("workbench.action.openSettings", "cmakeHelper.debugConfiguration");
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return extra ? { ...config, ...extra } : config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function launchOfTest(name: string, command: string[], cwd: string, env: Record<string, string>): Launch {
|
||||||
|
return { name, program: command[0]!, args: command.slice(1), cwd, env };
|
||||||
|
}
|
||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* CMakePresets.json and CMakeUserPresets.json (cmake-presets(7)): their
|
||||||
|
* configure presets become profiles, like in CLion. Configuring still goes
|
||||||
|
* through `cmake --preset`, so CMake does the real work; here only what's
|
||||||
|
* needed to show a preset and to find its build directory: `include`,
|
||||||
|
* `inherits`, `condition` and macro expansion.
|
||||||
|
*/
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { exists, readJson } from "./util";
|
||||||
|
|
||||||
|
export interface ConfigurePreset {
|
||||||
|
name: string;
|
||||||
|
displayName?: string;
|
||||||
|
description?: string;
|
||||||
|
generator?: string;
|
||||||
|
binaryDir?: string;
|
||||||
|
buildType?: string;
|
||||||
|
environment: Record<string, string | null>;
|
||||||
|
/** The file it's in, for ${fileDir}. */
|
||||||
|
file: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawPreset {
|
||||||
|
name: string;
|
||||||
|
hidden?: boolean;
|
||||||
|
inherits?: string | string[];
|
||||||
|
displayName?: string;
|
||||||
|
description?: string;
|
||||||
|
generator?: string;
|
||||||
|
binaryDir?: string;
|
||||||
|
cacheVariables?: Record<string, unknown>;
|
||||||
|
environment?: Record<string, string | null>;
|
||||||
|
condition?: unknown;
|
||||||
|
__file: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRESET_FILES = ["CMakePresets.json", "CMakeUserPresets.json"];
|
||||||
|
|
||||||
|
/** All files the presets come from, includes too: a change to any of them reloads the list. */
|
||||||
|
export function presetFiles(sourceDir: string): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const visit = (file: string) => {
|
||||||
|
if (seen.has(file) || !exists(file)) return;
|
||||||
|
seen.add(file);
|
||||||
|
for (const inc of readJson(file)?.include ?? []) {
|
||||||
|
if (typeof inc === "string") visit(path.resolve(path.dirname(file), inc));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const f of PRESET_FILES) visit(path.join(sourceDir, f));
|
||||||
|
return [...seen];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfigurePresets(sourceDir: string): ConfigurePreset[] {
|
||||||
|
const raw = new Map<string, RawPreset>();
|
||||||
|
for (const file of presetFiles(sourceDir)) {
|
||||||
|
for (const p of readJson(file)?.configurePresets ?? []) {
|
||||||
|
if (p && typeof p.name === "string" && !raw.has(p.name)) raw.set(p.name, { ...p, __file: file });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = new Map<string, RawPreset>();
|
||||||
|
const resolve = (name: string, stack: string[] = []): RawPreset | undefined => {
|
||||||
|
const done = resolved.get(name);
|
||||||
|
if (done) return done;
|
||||||
|
const p = raw.get(name);
|
||||||
|
if (!p || stack.includes(name)) return p;
|
||||||
|
const parents = (typeof p.inherits === "string" ? [p.inherits] : p.inherits ?? [])
|
||||||
|
.map((n) => resolve(n, [...stack, name]))
|
||||||
|
.filter((x): x is RawPreset => !!x);
|
||||||
|
// The preset's own values win, then its parents' in the order listed.
|
||||||
|
const out: RawPreset = { ...p, cacheVariables: {}, environment: {} };
|
||||||
|
for (const key of ["generator", "binaryDir", "condition"] as const) {
|
||||||
|
if (out[key] === undefined) (out as any)[key] = parents.find((x) => x[key] !== undefined)?.[key];
|
||||||
|
}
|
||||||
|
for (const x of [...parents].reverse()) {
|
||||||
|
Object.assign(out.cacheVariables!, x.cacheVariables);
|
||||||
|
Object.assign(out.environment!, x.environment);
|
||||||
|
}
|
||||||
|
Object.assign(out.cacheVariables!, p.cacheVariables);
|
||||||
|
Object.assign(out.environment!, p.environment);
|
||||||
|
resolved.set(name, out);
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const out: ConfigurePreset[] = [];
|
||||||
|
for (const name of raw.keys()) {
|
||||||
|
const p = resolve(name);
|
||||||
|
if (!p || p.hidden) continue;
|
||||||
|
const ctx: MacroContext = { sourceDir, preset: p };
|
||||||
|
if (p.condition !== undefined && !evalCondition(p.condition, ctx)) continue;
|
||||||
|
const buildType = cacheValue(p.cacheVariables?.CMAKE_BUILD_TYPE);
|
||||||
|
out.push({
|
||||||
|
name: p.name,
|
||||||
|
displayName: p.displayName,
|
||||||
|
description: p.description,
|
||||||
|
generator: p.generator,
|
||||||
|
binaryDir: p.binaryDir !== undefined ? path.resolve(sourceDir, expandMacros(p.binaryDir, ctx)) : undefined,
|
||||||
|
buildType: buildType !== undefined ? expandMacros(buildType, ctx) : undefined,
|
||||||
|
environment: p.environment ?? {},
|
||||||
|
file: p.__file,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheValue(v: unknown): string | undefined {
|
||||||
|
if (typeof v === "string") return v;
|
||||||
|
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||||
|
if (v && typeof v === "object" && "value" in v) return cacheValue((v as { value: unknown }).value);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MacroContext {
|
||||||
|
sourceDir: string;
|
||||||
|
preset: RawPreset;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostSystemName(): string {
|
||||||
|
return process.platform === "win32" ? "Windows" : process.platform === "darwin" ? "Darwin" : os.type();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandMacros(s: string, ctx: MacroContext, depth = 0): string {
|
||||||
|
return s.replace(/\$(env|penv|vendor)?\{([^}]*)\}/g, (_all, ns: string | undefined, name: string) => {
|
||||||
|
if (ns === "penv") return process.env[name] ?? "";
|
||||||
|
if (ns === "vendor") return "";
|
||||||
|
if (ns === "env") {
|
||||||
|
const own = ctx.preset.environment?.[name];
|
||||||
|
if (typeof own === "string" && depth < 8) return expandMacros(own, ctx, depth + 1);
|
||||||
|
return process.env[name] ?? "";
|
||||||
|
}
|
||||||
|
switch (name) {
|
||||||
|
case "sourceDir": return ctx.sourceDir;
|
||||||
|
case "sourceParentDir": return path.dirname(ctx.sourceDir);
|
||||||
|
case "sourceDirName": return path.basename(ctx.sourceDir);
|
||||||
|
case "presetName": return ctx.preset.name;
|
||||||
|
case "generator": return ctx.preset.generator ?? "";
|
||||||
|
case "hostSystemName": return hostSystemName();
|
||||||
|
case "fileDir": return path.dirname(ctx.preset.__file);
|
||||||
|
case "dollar": return "$";
|
||||||
|
case "pathListSep": return path.delimiter;
|
||||||
|
default: return "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function evalCondition(c: any, ctx: MacroContext): boolean {
|
||||||
|
if (c === null || c === undefined) return true;
|
||||||
|
if (typeof c === "boolean") return c;
|
||||||
|
const str = (v: unknown) => (typeof v === "string" ? expandMacros(v, ctx) : "");
|
||||||
|
switch (c.type) {
|
||||||
|
case "const": return !!c.value;
|
||||||
|
case "equals": return str(c.lhs) === str(c.rhs);
|
||||||
|
case "notEquals": return str(c.lhs) !== str(c.rhs);
|
||||||
|
case "inList": return (c.list ?? []).map(str).includes(str(c.string));
|
||||||
|
case "notInList": return !(c.list ?? []).map(str).includes(str(c.string));
|
||||||
|
case "matches": return safeRegex(str(c.regex))?.test(str(c.string)) ?? false;
|
||||||
|
case "notMatches": return !(safeRegex(str(c.regex))?.test(str(c.string)) ?? false);
|
||||||
|
case "anyOf": return (c.conditions ?? []).some((x: unknown) => evalCondition(x, ctx));
|
||||||
|
case "allOf": return (c.conditions ?? []).every((x: unknown) => evalCondition(x, ctx));
|
||||||
|
case "not": return !evalCondition(c.condition, ctx);
|
||||||
|
default: return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRegex(s: string): RegExp | undefined {
|
||||||
|
try {
|
||||||
|
return new RegExp(s);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
/**
|
||||||
|
* The profiles editor, as in CLion's Settings › Build › CMake: the list on the
|
||||||
|
* left (add, remove, copy, move), the selected profile's fields on the right,
|
||||||
|
* OK / Cancel / Apply. It edits the setting `cmakeHelper.profiles` of the
|
||||||
|
* workspace; presets are shown read-only (CMakePresets.json is theirs), and
|
||||||
|
* copying one makes a profile of it.
|
||||||
|
*/
|
||||||
|
import * as crypto from "node:crypto";
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import { editorIcon, editorPage } from "./editorPage";
|
||||||
|
import { BUILD_TYPES, type ProfileSetting, profileSettings } from "./profiles";
|
||||||
|
import type { CMakeProject } from "./project";
|
||||||
|
import { formatEnv, parseEnv, settings, splitArgs } from "./util";
|
||||||
|
|
||||||
|
/** A profile as the form shows it. */
|
||||||
|
interface Row {
|
||||||
|
id: string;
|
||||||
|
/** Its name in the settings when the editor opened (none: new), to keep it active when renamed. */
|
||||||
|
origName?: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
buildType: string;
|
||||||
|
toolchainFile: string;
|
||||||
|
generator: string;
|
||||||
|
options: string;
|
||||||
|
buildDirectory: string;
|
||||||
|
buildOptions: string;
|
||||||
|
environment: string;
|
||||||
|
/** What the form doesn't show, kept as it was. */
|
||||||
|
rest: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PresetRow {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
buildType: string;
|
||||||
|
generator: string;
|
||||||
|
buildDir: string;
|
||||||
|
environment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHOWN = ["name", "enabled", "buildType", "toolchainFile", "generator", "cacheVariables", "configureArgs", "buildDirectory", "buildArgs", "environment"];
|
||||||
|
|
||||||
|
/** An argument as a shell would need it written. */
|
||||||
|
function quote(a: string): string {
|
||||||
|
return a && !/[\s"'\\$`]/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const joinArgs = (args: string[] | undefined) => (args ?? []).map(quote).join(" ");
|
||||||
|
|
||||||
|
function toRow(p: ProfileSetting & { enabled?: boolean }): Row {
|
||||||
|
// Cache variables go into the options as -D, like CLion's one text field.
|
||||||
|
const vars = Object.entries(p.cacheVariables ?? {}).map(([k, v]) => (typeof v === "boolean" ? `-D${k}:BOOL=${v ? "ON" : "OFF"}` : `-D${k}=${v}`));
|
||||||
|
const rest: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(p)) if (!SHOWN.includes(k)) rest[k] = v;
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
origName: p.name,
|
||||||
|
name: p.name,
|
||||||
|
enabled: p.enabled !== false,
|
||||||
|
buildType: p.buildType ?? "Debug",
|
||||||
|
toolchainFile: p.toolchainFile ?? "",
|
||||||
|
generator: p.generator ?? "",
|
||||||
|
options: joinArgs([...vars, ...(p.configureArgs ?? [])]),
|
||||||
|
buildDirectory: p.buildDirectory ?? "",
|
||||||
|
buildOptions: joinArgs(p.buildArgs),
|
||||||
|
environment: formatEnv(p.environment),
|
||||||
|
rest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromRow(r: Row): ProfileSetting & { enabled?: boolean } {
|
||||||
|
const out: ProfileSetting & { enabled?: boolean } = { ...r.rest, name: r.name.trim() } as ProfileSetting;
|
||||||
|
if (!r.enabled) out.enabled = false;
|
||||||
|
out.buildType = r.buildType.trim() || "Debug";
|
||||||
|
if (r.toolchainFile.trim()) out.toolchainFile = r.toolchainFile.trim();
|
||||||
|
if (r.generator.trim()) out.generator = r.generator.trim();
|
||||||
|
const configureArgs = splitArgs(r.options);
|
||||||
|
if (configureArgs.length) out.configureArgs = configureArgs;
|
||||||
|
if (r.buildDirectory.trim()) out.buildDirectory = r.buildDirectory.trim();
|
||||||
|
const buildArgs = splitArgs(r.buildOptions);
|
||||||
|
if (buildArgs.length) out.buildArgs = buildArgs;
|
||||||
|
const env = parseEnv(r.environment.trim());
|
||||||
|
if (Object.keys(env).length) out.environment = env;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a broken list would do: the first problem, or none. */
|
||||||
|
function problem(rows: Row[]): string | undefined {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const name = r.name.trim();
|
||||||
|
if (!name) return "Every profile needs a name.";
|
||||||
|
if (seen.has(name)) return `There are two profiles named “${name}”.`;
|
||||||
|
seen.add(name);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let panel: vscode.WebviewPanel | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the editor (or brings it forward). `select`: the profile to show
|
||||||
|
* (a profile's key); `add`: start with a new profile.
|
||||||
|
*/
|
||||||
|
export function openProfileEditor(project: CMakeProject, opts: { select?: string; add?: boolean } = {}): void {
|
||||||
|
const rows = profileSettings().map(toRow);
|
||||||
|
const presets: PresetRow[] = project.profiles
|
||||||
|
.filter((p) => p.source === "preset")
|
||||||
|
.map((p) => ({ key: p.key, name: p.name, description: p.description ?? "", buildType: p.buildType, generator: p.generator ?? "", buildDir: p.buildDir, environment: formatEnv(p.env) }));
|
||||||
|
const init = {
|
||||||
|
rows,
|
||||||
|
presets,
|
||||||
|
buildTypes: BUILD_TYPES,
|
||||||
|
generators: ["Ninja", "Ninja Multi-Config", "Unix Makefiles"],
|
||||||
|
defaultGenerator: settings().get<string>("generator") || "",
|
||||||
|
select: opts.select,
|
||||||
|
add: !!opts.add,
|
||||||
|
active: project.activeProfile.key,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (panel) {
|
||||||
|
panel.reveal();
|
||||||
|
void panel.webview.postMessage({ type: "init", ...init });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
panel = vscode.window.createWebviewPanel("cmakeHelper.profiles", "CMake Profiles", vscode.ViewColumn.Active, { enableScripts: true, retainContextWhenHidden: true });
|
||||||
|
panel.iconPath = editorIcon();
|
||||||
|
const nonce = crypto.randomBytes(16).toString("base64");
|
||||||
|
panel.webview.html = html(panel.webview, nonce);
|
||||||
|
panel.onDidDispose(() => (panel = undefined));
|
||||||
|
panel.webview.onDidReceiveMessage(async (msg: { type: string; rows?: Row[]; close?: boolean }) => {
|
||||||
|
if (msg.type === "ready") {
|
||||||
|
void panel?.webview.postMessage({ type: "init", ...init });
|
||||||
|
} else if (msg.type === "cancel") {
|
||||||
|
panel?.dispose();
|
||||||
|
} else if (msg.type === "save" && msg.rows) {
|
||||||
|
const err = problem(msg.rows);
|
||||||
|
if (err) {
|
||||||
|
void panel?.webview.postMessage({ type: "error", text: err });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The active profile stays active under its new name.
|
||||||
|
const active = project.activeProfile.key;
|
||||||
|
const renamed = msg.rows.find((r) => r.origName && `settings:${r.origName}` === active && r.origName !== r.name.trim());
|
||||||
|
if (renamed) await project.rememberActiveProfile(`settings:${renamed.name.trim()}`);
|
||||||
|
try {
|
||||||
|
await settings().update("profiles", msg.rows.map(fromRow), vscode.ConfigurationTarget.Workspace);
|
||||||
|
} catch (e) {
|
||||||
|
void panel?.webview.postMessage({ type: "error", text: e instanceof Error ? e.message : String(e) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.close) panel?.dispose();
|
||||||
|
else void panel?.webview.postMessage({ type: "saved", rows: profileSettings().map(toRow), active: project.activeProfile.key });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function html(webview: vscode.Webview, nonce: string): string {
|
||||||
|
return editorPage(webview, nonce, {
|
||||||
|
noun: "profile",
|
||||||
|
extra: `<datalist id="buildTypes"></datalist>
|
||||||
|
<datalist id="generators"></datalist>`,
|
||||||
|
script: `const vscode = acquireVsCodeApi();
|
||||||
|
let rows = [], presets = [], sel = null, dirty = false, active = "", defaultGenerator = "";
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||||
|
const uid = () => Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||||
|
const current = () => rows.find((r) => r.id === sel);
|
||||||
|
const currentPreset = () => presets.find((p) => p.key === sel);
|
||||||
|
|
||||||
|
function freeName(base) {
|
||||||
|
const taken = new Set(rows.map((r) => r.name.trim()));
|
||||||
|
if (!taken.has(base)) return base;
|
||||||
|
for (let i = 2; ; i++) if (!taken.has(base + "-" + i)) return base + "-" + i;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDirty(v) {
|
||||||
|
dirty = v;
|
||||||
|
$("apply").disabled = !v;
|
||||||
|
if (v) $("err").textContent = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
const items = rows.map((r) => {
|
||||||
|
const cls = [r.id === sel ? "sel" : "", r.enabled ? "" : "off"].join(" ");
|
||||||
|
const tag = ("settings:" + (r.origName ?? "")) === active ? '<span class="tag">active</span>' : r.enabled ? "" : '<span class="tag">disabled</span>';
|
||||||
|
return '<li class="' + cls + '" data-id="' + esc(r.id) + '">' + esc(r.name || "(unnamed)") + tag + "</li>";
|
||||||
|
});
|
||||||
|
if (presets.length) {
|
||||||
|
items.push('<li class="group">CMake presets</li>');
|
||||||
|
for (const p of presets) items.push('<li class="preset ' + (p.key === sel ? "sel" : "") + '" data-id="' + esc(p.key) + '">' + esc(p.name) + (p.key === active ? '<span class="tag">active</span>' : "") + "</li>");
|
||||||
|
}
|
||||||
|
$("list").innerHTML = items.join("");
|
||||||
|
const r = current();
|
||||||
|
const i = rows.indexOf(r);
|
||||||
|
$("remove").disabled = !r;
|
||||||
|
$("copy").disabled = !r && !currentPreset();
|
||||||
|
$("up").disabled = !r || i <= 0;
|
||||||
|
$("down").disabled = !r || i >= rows.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function field(label, key, value, opts = {}) {
|
||||||
|
const attrs = (opts.list ? ' list="' + opts.list + '"' : "") + (opts.placeholder ? ' placeholder="' + esc(opts.placeholder) + '"' : "") + (opts.disabled ? " disabled" : "");
|
||||||
|
const input = opts.area
|
||||||
|
? '<textarea data-k="' + key + '"' + attrs + ">" + esc(value) + "</textarea>"
|
||||||
|
: '<input type="text" data-k="' + key + '" value="' + esc(value) + '"' + attrs + ">";
|
||||||
|
return "<label>" + label + ":</label><div>" + input + (opts.hint ? '<div class="hint">' + opts.hint + "</div>" : "") + "</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderForm() {
|
||||||
|
const r = current();
|
||||||
|
const p = currentPreset();
|
||||||
|
if (!r && !p) {
|
||||||
|
$("form").innerHTML = '<p class="empty">' + (rows.length || presets.length ? "Select a profile." : "No profiles: CLion's default, Debug in cmake-build-debug, is used. Add one with +.") + "</p>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (p) {
|
||||||
|
$("form").innerHTML = '<div class="note">A preset from CMakePresets.json: change it there, or copy it into a profile of your own.</div>' +
|
||||||
|
'<div class="grid">' +
|
||||||
|
field("Name", "", p.name, { disabled: true }) +
|
||||||
|
(p.description ? field("Description", "", p.description, { disabled: true }) : "") +
|
||||||
|
field("Build type", "", p.buildType, { disabled: true }) +
|
||||||
|
field("Generator", "", p.generator || "Default", { disabled: true }) +
|
||||||
|
field("Build directory", "", p.buildDir, { disabled: true }) +
|
||||||
|
field("Environment", "", p.environment, { disabled: true }) +
|
||||||
|
"</div>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const genDefault = defaultGenerator ? "Use default (" + defaultGenerator + ")" : "Use default";
|
||||||
|
$("form").innerHTML = '<div class="grid">' +
|
||||||
|
field("Name", "name", r.name) +
|
||||||
|
'<span></span><label class="full"><input type="checkbox" data-k="enabled"' + (r.enabled ? " checked" : "") + "> Enable profile</label>" +
|
||||||
|
field("Build type", "buildType", r.buildType, { list: "buildTypes" }) +
|
||||||
|
field("Toolchain file", "toolchainFile", r.toolchainFile, { placeholder: "None", hint: "CMAKE_TOOLCHAIN_FILE, relative to the source directory." }) +
|
||||||
|
field("Generator", "generator", r.generator, { list: "generators", placeholder: genDefault }) +
|
||||||
|
field("CMake options", "options", r.options, { area: true, placeholder: "-DOPTION=value", hint: "As on cmake's command line, e.g. <code>-DBUILD_TESTING=ON -Wno-dev</code>." }) +
|
||||||
|
field("Build directory", "buildDirectory", r.buildDirectory, { placeholder: "cmake-build-" + (r.name.trim().toLowerCase().replace(/[^\\w.-]+/g, "-") || "name"), hint: "Relative to the source directory. <code>\${profile}</code>, <code>\${buildType}</code>, <code>\${env:X}</code> are expanded." }) +
|
||||||
|
field("Build options", "buildOptions", r.buildOptions, { placeholder: "-- -k 0", hint: "After <code>cmake --build <dir></code>; the native tool's after <code>--</code>." }) +
|
||||||
|
field("Environment", "environment", r.environment, { placeholder: "A=1;B=2", hint: "For configuring, building, running and testing." }) +
|
||||||
|
"</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
renderList();
|
||||||
|
renderForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
$("list").addEventListener("click", (e) => {
|
||||||
|
const li = e.target.closest("li[data-id]");
|
||||||
|
if (!li) return;
|
||||||
|
sel = li.dataset.id;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("form").addEventListener("input", (e) => {
|
||||||
|
const r = current();
|
||||||
|
const k = e.target.dataset.k;
|
||||||
|
if (!r || !k) return;
|
||||||
|
r[k] = e.target.type === "checkbox" ? e.target.checked : e.target.value;
|
||||||
|
setDirty(true);
|
||||||
|
if (k === "name") {
|
||||||
|
renderList();
|
||||||
|
const dir = $("form").querySelector('[data-k="buildDirectory"]');
|
||||||
|
if (dir) dir.placeholder = "cmake-build-" + (r.name.trim().toLowerCase().replace(/[^\\w.-]+/g, "-") || "name");
|
||||||
|
}
|
||||||
|
if (k === "enabled") renderList();
|
||||||
|
});
|
||||||
|
|
||||||
|
function addRow(from) {
|
||||||
|
const r = Object.assign(
|
||||||
|
{ name: "Debug", enabled: true, buildType: "Debug", toolchainFile: "", generator: "", options: "", buildDirectory: "", buildOptions: "", environment: "", rest: {} },
|
||||||
|
from || {},
|
||||||
|
{ id: uid(), origName: undefined },
|
||||||
|
);
|
||||||
|
r.name = freeName(from ? r.name : r.buildType);
|
||||||
|
const at = current() ? rows.indexOf(current()) + 1 : rows.length;
|
||||||
|
rows.splice(at, 0, r);
|
||||||
|
sel = r.id;
|
||||||
|
setDirty(true);
|
||||||
|
render();
|
||||||
|
const name = $("form").querySelector('[data-k="name"]');
|
||||||
|
if (name) { name.focus(); name.select(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
$("add").onclick = () => {
|
||||||
|
// CLion's next profile: Release after Debug, then the other build types.
|
||||||
|
const types = ${JSON.stringify(BUILD_TYPES)};
|
||||||
|
const used = new Set(rows.map((r) => r.buildType));
|
||||||
|
const buildType = types.find((t) => !used.has(t)) || "Debug";
|
||||||
|
addRow({ name: buildType, buildType });
|
||||||
|
};
|
||||||
|
$("copy").onclick = () => {
|
||||||
|
const r = current();
|
||||||
|
const p = currentPreset();
|
||||||
|
// The copy gets its own build directory (the default for its name), as in CLion.
|
||||||
|
if (r) addRow({ ...r, buildDirectory: "", rest: { ...r.rest } });
|
||||||
|
else if (p) addRow({ name: p.name, buildType: p.buildType, generator: p.generator, environment: p.environment });
|
||||||
|
};
|
||||||
|
$("remove").onclick = () => {
|
||||||
|
const r = current();
|
||||||
|
if (!r) return;
|
||||||
|
const i = rows.indexOf(r);
|
||||||
|
rows.splice(i, 1);
|
||||||
|
sel = rows[Math.min(i, rows.length - 1)]?.id ?? presets[0]?.key ?? null;
|
||||||
|
setDirty(true);
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
function move(d) {
|
||||||
|
const r = current();
|
||||||
|
const i = rows.indexOf(r);
|
||||||
|
if (!r || i + d < 0 || i + d >= rows.length) return;
|
||||||
|
rows.splice(i, 1);
|
||||||
|
rows.splice(i + d, 0, r);
|
||||||
|
setDirty(true);
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
$("up").onclick = () => move(-1);
|
||||||
|
$("down").onclick = () => move(1);
|
||||||
|
|
||||||
|
function save(close) {
|
||||||
|
if (!dirty && close) return vscode.postMessage({ type: "cancel" });
|
||||||
|
vscode.postMessage({ type: "save", rows, close });
|
||||||
|
}
|
||||||
|
$("ok").onclick = () => save(true);
|
||||||
|
$("apply").onclick = () => save(false);
|
||||||
|
$("cancel").onclick = () => vscode.postMessage({ type: "cancel" });
|
||||||
|
|
||||||
|
window.addEventListener("message", (e) => {
|
||||||
|
const m = e.data;
|
||||||
|
if (m.type === "init") {
|
||||||
|
rows = m.rows; presets = m.presets; active = m.active; defaultGenerator = m.defaultGenerator;
|
||||||
|
$("buildTypes").innerHTML = m.buildTypes.map((t) => '<option value="' + esc(t) + '">').join("");
|
||||||
|
$("generators").innerHTML = m.generators.map((t) => '<option value="' + esc(t) + '">').join("");
|
||||||
|
const wanted = m.select && (m.select.startsWith("settings:") ? rows.find((r) => "settings:" + r.origName === m.select)?.id : presets.find((p) => p.key === m.select)?.key);
|
||||||
|
sel = wanted || rows.find((r) => "settings:" + r.origName === active)?.id || rows[0]?.id || presets[0]?.key || null;
|
||||||
|
setDirty(false);
|
||||||
|
render();
|
||||||
|
if (m.add) $("add").onclick();
|
||||||
|
} else if (m.type === "saved") {
|
||||||
|
const keep = current() ? current().name.trim() : null;
|
||||||
|
rows = m.rows; active = m.active;
|
||||||
|
sel = (keep && rows.find((r) => r.name === keep)?.id) || sel;
|
||||||
|
setDirty(false);
|
||||||
|
render();
|
||||||
|
} else if (m.type === "error") {
|
||||||
|
$("err").textContent = m.text;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
vscode.postMessage({ type: "ready" });
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* CMake profiles, as in CLion: each is a build directory with its own build
|
||||||
|
* type, generator, toolchain, cache variables and environment. They come from
|
||||||
|
* the setting `cmakeHelper.profiles` and from the configure presets; with
|
||||||
|
* neither there's CLion's default, Debug in cmake-build-debug.
|
||||||
|
*/
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import { loadConfigurePresets } from "./presets";
|
||||||
|
import { expand, onPath, settings } from "./util";
|
||||||
|
|
||||||
|
export interface ProfileSetting {
|
||||||
|
name: string;
|
||||||
|
/** false: kept, but not offered (CLion's "Enable profile"). */
|
||||||
|
enabled?: boolean;
|
||||||
|
buildType?: string;
|
||||||
|
buildDirectory?: string;
|
||||||
|
generator?: string;
|
||||||
|
toolchainFile?: string;
|
||||||
|
cacheVariables?: Record<string, string | boolean>;
|
||||||
|
environment?: Record<string, string>;
|
||||||
|
configureArgs?: string[];
|
||||||
|
buildArgs?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Profile {
|
||||||
|
/** Unique across both kinds: `settings:<name>` or `preset:<name>`. */
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
source: "settings" | "preset";
|
||||||
|
buildType: string;
|
||||||
|
buildDir: string;
|
||||||
|
generator?: string;
|
||||||
|
env: Record<string, string>;
|
||||||
|
/** cmake's arguments for configuring, run in the source directory. */
|
||||||
|
configureArgs: string[];
|
||||||
|
buildArgs: string[];
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BUILD_TYPES = ["Debug", "Release", "RelWithDebInfo", "MinSizeRel"];
|
||||||
|
|
||||||
|
export function profileSettings(): ProfileSetting[] {
|
||||||
|
const list = settings().get<ProfileSetting[]>("profiles") ?? [];
|
||||||
|
return list.filter((p) => p && typeof p.name === "string" && p.name.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultGenerator(): string | undefined {
|
||||||
|
const g = settings().get<string>("generator")?.trim();
|
||||||
|
if (g) return g;
|
||||||
|
// CLion's default is Ninja when there is one.
|
||||||
|
return onPath("ninja") ? "Ninja" : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadProfiles(sourceDir: string, workspaceFolder: string): Profile[] {
|
||||||
|
const cfg = settings();
|
||||||
|
const common: string[] = [];
|
||||||
|
if (cfg.get<boolean>("exportCompileCommands", true)) common.push("-DCMAKE_EXPORT_COMPILE_COMMANDS=ON");
|
||||||
|
common.push(...(cfg.get<string[]>("configureArgs") ?? []));
|
||||||
|
const globalEnv = cfg.get<Record<string, string>>("environment") ?? {};
|
||||||
|
|
||||||
|
const out: Profile[] = [];
|
||||||
|
for (const p of profileSettings()) {
|
||||||
|
if (p.enabled === false) continue;
|
||||||
|
const buildType = p.buildType?.trim() || "Debug";
|
||||||
|
const vars = { workspaceFolder, sourceDir, profile: p.name, buildType };
|
||||||
|
const buildDir = path.resolve(sourceDir, expand(p.buildDirectory?.trim() || `cmake-build-${p.name.toLowerCase().replace(/[^\w.-]+/g, "-")}`, vars));
|
||||||
|
const generator = p.generator?.trim() || defaultGenerator();
|
||||||
|
const args = ["-S", sourceDir, "-B", buildDir];
|
||||||
|
if (generator) args.push("-G", generator);
|
||||||
|
args.push(`-DCMAKE_BUILD_TYPE=${buildType}`);
|
||||||
|
if (p.toolchainFile) args.push(`-DCMAKE_TOOLCHAIN_FILE=${path.resolve(sourceDir, expand(p.toolchainFile, vars))}`);
|
||||||
|
for (const [k, v] of Object.entries(p.cacheVariables ?? {})) {
|
||||||
|
args.push(typeof v === "boolean" ? `-D${k}:BOOL=${v ? "ON" : "OFF"}` : `-D${k}=${expand(String(v), vars)}`);
|
||||||
|
}
|
||||||
|
args.push(...common, ...(p.configureArgs ?? []).map((a) => expand(a, vars)));
|
||||||
|
out.push({
|
||||||
|
key: `settings:${p.name}`,
|
||||||
|
name: p.name,
|
||||||
|
source: "settings",
|
||||||
|
buildType,
|
||||||
|
buildDir,
|
||||||
|
generator,
|
||||||
|
env: { ...globalEnv, ...expandAll(p.environment, vars) },
|
||||||
|
configureArgs: args,
|
||||||
|
buildArgs: (p.buildArgs ?? []).map((a) => expand(a, vars)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg.get<boolean>("usePresets", true)) {
|
||||||
|
for (const p of loadConfigurePresets(sourceDir)) {
|
||||||
|
const buildDir = p.binaryDir ?? path.join(sourceDir, "build", p.name);
|
||||||
|
const args = ["--preset", p.name];
|
||||||
|
if (!p.binaryDir) args.push("-B", buildDir);
|
||||||
|
args.push(...common);
|
||||||
|
const env: Record<string, string> = { ...globalEnv };
|
||||||
|
// The preset's environment is CMake's business when configuring; building and running need it too.
|
||||||
|
for (const [k, v] of Object.entries(p.environment)) if (typeof v === "string" && !v.includes("$")) env[k] = v;
|
||||||
|
out.push({
|
||||||
|
key: `preset:${p.name}`,
|
||||||
|
name: p.displayName || p.name,
|
||||||
|
source: "preset",
|
||||||
|
buildType: p.buildType || "Debug",
|
||||||
|
buildDir,
|
||||||
|
generator: p.generator,
|
||||||
|
env,
|
||||||
|
configureArgs: args,
|
||||||
|
buildArgs: [],
|
||||||
|
description: p.description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!out.length) {
|
||||||
|
const buildDir = path.join(sourceDir, "cmake-build-debug");
|
||||||
|
const generator = defaultGenerator();
|
||||||
|
const args = ["-S", sourceDir, "-B", buildDir, ...(generator ? ["-G", generator] : []), "-DCMAKE_BUILD_TYPE=Debug", ...common];
|
||||||
|
out.push({ key: "default:Debug", name: "Debug", source: "settings", buildType: "Debug", buildDir, generator, env: globalEnv, configureArgs: args, buildArgs: [] });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandAll(env: Record<string, string> | undefined, vars: Parameters<typeof expand>[1]): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(env ?? {})) out[k] = expand(String(v), vars);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeProfile(profile: Profile): Promise<void> {
|
||||||
|
if (profile.source !== "settings") return;
|
||||||
|
const list = (settings().get<ProfileSetting[]>("profiles") ?? []).filter((p) => p.name !== profile.name);
|
||||||
|
await settings().update("profiles", list, vscode.ConfigurationTarget.Workspace);
|
||||||
|
}
|
||||||
+468
@@ -0,0 +1,468 @@
|
|||||||
|
/**
|
||||||
|
* The CMake project of a workspace folder: its profiles, the one that's
|
||||||
|
* active, the model CMake gave for it (targets and sources), configuring
|
||||||
|
* ("Reload CMake Project") and building.
|
||||||
|
*/
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import { type Model, type Target, executableOf, multiConfigGenerator, readReply, writeQuery } from "./fileApi";
|
||||||
|
import { type GdbConfiguration, gdbConfiguration } from "./gdbConfigs";
|
||||||
|
import { type Profile, loadProfiles } from "./profiles";
|
||||||
|
import { presetFiles } from "./presets";
|
||||||
|
import { debounce, errorText, exists, expand, isWindows, pathKey, run, settings } from "./util";
|
||||||
|
|
||||||
|
export type State = "idle" | "configuring" | "building";
|
||||||
|
|
||||||
|
const ACTIVE_PROFILE = "cmakeHelper.activeProfile";
|
||||||
|
const ACTIVE_TARGET = "cmakeHelper.activeTarget";
|
||||||
|
const ACTIVE_GDB = "cmakeHelper.activeGdbConfiguration";
|
||||||
|
|
||||||
|
export class CMakeProject implements vscode.Disposable {
|
||||||
|
profiles: Profile[] = [];
|
||||||
|
activeProfile!: Profile;
|
||||||
|
model: Model | undefined;
|
||||||
|
state: State = "idle";
|
||||||
|
/** A CMake file changed since the last configure. */
|
||||||
|
needsReload = false;
|
||||||
|
readonly sourceDir: string;
|
||||||
|
|
||||||
|
private readonly changed = new vscode.EventEmitter<void>();
|
||||||
|
/** Anything shown changed: profiles, the model, the state, the active target. */
|
||||||
|
readonly onDidChange = this.changed.event;
|
||||||
|
private readonly built = new vscode.EventEmitter<void>();
|
||||||
|
/** A build ended (tests may have appeared: gtest_discover_tests finds them after building). */
|
||||||
|
readonly onDidBuild = this.built.event;
|
||||||
|
private readonly modelLoaded = new vscode.EventEmitter<void>();
|
||||||
|
readonly onDidLoadModel = this.modelLoaded.event;
|
||||||
|
|
||||||
|
private disposables: vscode.Disposable[] = [];
|
||||||
|
private inputs = new Set<string>();
|
||||||
|
private presetInputs = new Set<string>();
|
||||||
|
private configuring: Promise<boolean> | undefined;
|
||||||
|
private configureCancel: vscode.CancellationTokenSource | undefined;
|
||||||
|
private fileTargets: Map<string, Target[]> | undefined;
|
||||||
|
private runs = new Map<string, vscode.TaskExecution>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly context: vscode.ExtensionContext,
|
||||||
|
readonly folder: vscode.WorkspaceFolder,
|
||||||
|
readonly output: vscode.OutputChannel,
|
||||||
|
) {
|
||||||
|
this.sourceDir = path.resolve(folder.uri.fsPath, expand(settings().get<string>("sourceDirectory") || "${workspaceFolder}", { workspaceFolder: folder.uri.fsPath }));
|
||||||
|
this.loadProfileList();
|
||||||
|
this.watch();
|
||||||
|
this.disposables.push(
|
||||||
|
vscode.workspace.onDidChangeConfiguration((e) => {
|
||||||
|
if (e.affectsConfiguration("cmakeHelper.profiles") || e.affectsConfiguration("cmakeHelper.usePresets") || e.affectsConfiguration("cmakeHelper.generator")) {
|
||||||
|
this.profilesChanged();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On opening: the model left from the last time, else configure (when `configureOnOpen`). */
|
||||||
|
async open(): Promise<void> {
|
||||||
|
if (this.loadModel()) return;
|
||||||
|
if (settings().get<boolean>("configureOnOpen", true)) await this.configure();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- profiles ---- */
|
||||||
|
|
||||||
|
private loadProfileList(): void {
|
||||||
|
this.profiles = loadProfiles(this.sourceDir, this.folder.uri.fsPath);
|
||||||
|
const key = this.context.workspaceState.get<string>(ACTIVE_PROFILE);
|
||||||
|
this.activeProfile = this.profiles.find((p) => p.key === key) ?? this.profiles[0]!;
|
||||||
|
this.presetInputs = new Set(presetFiles(this.sourceDir).map(pathKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
private profilesChanged(): void {
|
||||||
|
const before = this.activeProfile;
|
||||||
|
this.loadProfileList();
|
||||||
|
const after = this.activeProfile;
|
||||||
|
if (before.key !== after.key || before.buildDir !== after.buildDir || before.configureArgs.join("\0") !== after.configureArgs.join("\0")) {
|
||||||
|
if (before.key !== after.key || before.buildDir !== after.buildDir) {
|
||||||
|
this.model = undefined;
|
||||||
|
this.fileTargets = undefined;
|
||||||
|
this.loadModel();
|
||||||
|
}
|
||||||
|
this.markDirty();
|
||||||
|
}
|
||||||
|
this.changed.fire();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** This workspace's memory (what was downloaded to a board last, …). */
|
||||||
|
get workspaceState(): vscode.Memento {
|
||||||
|
return this.context.workspaceState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The profile to be active once the list changes (one being renamed). */
|
||||||
|
async rememberActiveProfile(key: string): Promise<void> {
|
||||||
|
await this.context.workspaceState.update(ACTIVE_PROFILE, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setActiveProfile(key: string): Promise<void> {
|
||||||
|
const p = this.profiles.find((x) => x.key === key);
|
||||||
|
if (!p || p.key === this.activeProfile.key) return;
|
||||||
|
this.activeProfile = p;
|
||||||
|
await this.context.workspaceState.update(ACTIVE_PROFILE, key);
|
||||||
|
this.model = undefined;
|
||||||
|
this.fileTargets = undefined;
|
||||||
|
this.needsReload = false;
|
||||||
|
this.changed.fire();
|
||||||
|
// Its build directory may already be configured; CLion keeps every profile's.
|
||||||
|
if (!this.loadModel()) await this.configure();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- the model ---- */
|
||||||
|
|
||||||
|
/** Reads the active profile's reply; false when there's none to read. */
|
||||||
|
loadModel(): boolean {
|
||||||
|
const p = this.activeProfile;
|
||||||
|
const model = readReply(p.buildDir, p.buildType);
|
||||||
|
if (!model) return false;
|
||||||
|
this.model = model;
|
||||||
|
this.fileTargets = undefined;
|
||||||
|
this.inputs = new Set(model.inputs.map(pathKey));
|
||||||
|
this.changed.fire();
|
||||||
|
this.modelLoaded.fire();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get multiConfig(): boolean {
|
||||||
|
return multiConfigGenerator(this.model?.generator ?? this.activeProfile.generator ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
get targets(): Target[] {
|
||||||
|
return this.model?.targets ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
target(name: string): Target | undefined {
|
||||||
|
return this.targets.find((t) => t.name === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Targets the owner works with: not ALL_BUILD and the like; custom ones (add_custom_target) too,
|
||||||
|
* unless set off, but not the dashboard targets include(CTest) adds (some twenty of them).
|
||||||
|
*/
|
||||||
|
visibleTargets(): Target[] {
|
||||||
|
const utility = settings().get<boolean>("showUtilityTargets", true);
|
||||||
|
const ctest = (t: Target) => t.type === "UTILITY" && !!t.definition && path.basename(t.definition.file) === "CTestTargets.cmake";
|
||||||
|
return this.targets.filter((t) => !t.generatorProvided && (t.type !== "UTILITY" || (utility && !ctest(t))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The targets a source file is compiled into. */
|
||||||
|
targetsOf(file: string): Target[] {
|
||||||
|
if (!this.fileTargets) {
|
||||||
|
this.fileTargets = new Map();
|
||||||
|
for (const t of this.targets) {
|
||||||
|
for (const s of t.sources) {
|
||||||
|
const key = pathKey(s.path);
|
||||||
|
const list = this.fileTargets.get(key) ?? [];
|
||||||
|
list.push(t);
|
||||||
|
this.fileTargets.set(key, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.fileTargets.get(pathKey(file)) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeTarget(): Target | undefined {
|
||||||
|
const name = this.context.workspaceState.get<string>(ACTIVE_TARGET);
|
||||||
|
const visible = this.visibleTargets();
|
||||||
|
return visible.find((t) => t.name === name) ?? visible.find((t) => t.type === "EXECUTABLE") ?? visible[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Choosing a target leaves a GDB server configuration chosen before. */
|
||||||
|
async setActiveTarget(name: string): Promise<void> {
|
||||||
|
await this.context.workspaceState.update(ACTIVE_TARGET, name);
|
||||||
|
await this.context.workspaceState.update(ACTIVE_GDB, undefined);
|
||||||
|
this.changed.fire();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The GDB server configuration chosen in place of a target (CLion's run configuration), if any. */
|
||||||
|
get activeGdbConfiguration(): GdbConfiguration | undefined {
|
||||||
|
return gdbConfiguration(this.context.workspaceState.get<string>(ACTIVE_GDB));
|
||||||
|
}
|
||||||
|
|
||||||
|
async setActiveGdbConfiguration(name: string | undefined): Promise<void> {
|
||||||
|
await this.context.workspaceState.update(ACTIVE_GDB, name);
|
||||||
|
this.changed.fire();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- programs and their environment ---- */
|
||||||
|
|
||||||
|
get cmakePath(): string {
|
||||||
|
return expand(settings().get<string>("cmakePath") || "cmake", { workspaceFolder: this.folder.uri.fsPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
get ctestPath(): string {
|
||||||
|
const own = settings().get<string>("ctestPath");
|
||||||
|
if (own) return expand(own, { workspaceFolder: this.folder.uri.fsPath });
|
||||||
|
const cmake = this.cmakePath;
|
||||||
|
// Next to the cmake that's set, if that's a path.
|
||||||
|
if (cmake.includes("/") || cmake.includes("\\")) return path.join(path.dirname(cmake), isWindows ? "ctest.exe" : "ctest");
|
||||||
|
return "ctest";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The active profile's environment over this process's. */
|
||||||
|
env(): NodeJS.ProcessEnv {
|
||||||
|
return { ...process.env, ...this.activeProfile.env };
|
||||||
|
}
|
||||||
|
|
||||||
|
get buildDir(): string {
|
||||||
|
return this.model?.buildDir ?? this.activeProfile.buildDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The configuration to build and test: the profile's build type, for a multi-config generator. */
|
||||||
|
get configuration(): string {
|
||||||
|
return this.model?.configuration || this.activeProfile.buildType;
|
||||||
|
}
|
||||||
|
|
||||||
|
vars(target?: Target) {
|
||||||
|
return {
|
||||||
|
workspaceFolder: this.folder.uri.fsPath,
|
||||||
|
sourceDir: this.sourceDir,
|
||||||
|
buildDir: this.buildDir,
|
||||||
|
profile: this.activeProfile.name,
|
||||||
|
buildType: this.configuration,
|
||||||
|
targetName: target?.name,
|
||||||
|
targetDir: target ? path.dirname(executableOf(target) ?? target.buildDir) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- configure ---- */
|
||||||
|
|
||||||
|
/** "Reload CMake Project"; with `reset`, "Reset Cache and Reload Project". */
|
||||||
|
configure(opts: { reset?: boolean } = {}): Promise<boolean> {
|
||||||
|
if (this.configuring) return this.configuring;
|
||||||
|
this.configuring = this.doConfigure(opts).finally(() => {
|
||||||
|
this.configuring = undefined;
|
||||||
|
this.configureCancel = undefined;
|
||||||
|
this.setState("idle");
|
||||||
|
});
|
||||||
|
return this.configuring;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelConfigure(): void {
|
||||||
|
this.configureCancel?.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doConfigure(opts: { reset?: boolean }): Promise<boolean> {
|
||||||
|
const p = this.activeProfile;
|
||||||
|
this.setState("configuring");
|
||||||
|
this.output.clear();
|
||||||
|
if (settings().get<boolean>("revealOutputOnConfigure", false)) this.output.show(true);
|
||||||
|
if (opts.reset) this.resetCache(p.buildDir);
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(p.buildDir, { recursive: true });
|
||||||
|
writeQuery(p.buildDir);
|
||||||
|
} catch (err) {
|
||||||
|
this.output.appendLine(`Can't write to the build directory ${p.buildDir}: ${errorText(err)}`);
|
||||||
|
this.output.show(true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.output.appendLine(`[${p.name}] ${this.cmakePath} ${p.configureArgs.map(quote).join(" ")}`);
|
||||||
|
this.output.appendLine("");
|
||||||
|
this.configureCancel = new vscode.CancellationTokenSource();
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await vscode.window.withProgress(
|
||||||
|
{ location: vscode.ProgressLocation.Window, title: `CMake: loading ${p.name}` },
|
||||||
|
() => run(this.cmakePath, p.configureArgs, { cwd: this.sourceDir, env: this.env(), onOutput: (t) => this.output.append(t), token: this.configureCancel!.token }),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const text = (err as NodeJS.ErrnoException).code === "ENOENT" ? `CMake isn't there: "${this.cmakePath}". Set cmakeHelper.cmakePath.` : errorText(err);
|
||||||
|
this.output.appendLine(text);
|
||||||
|
void vscode.window.showErrorMessage(text, "Open Settings").then((a) => {
|
||||||
|
if (a) void vscode.commands.executeCommand("workbench.action.openSettings", "cmakeHelper.cmakePath");
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.code !== 0) {
|
||||||
|
this.output.appendLine(`\n[Failed: exit code ${result.code}]`);
|
||||||
|
this.output.show(true);
|
||||||
|
const mismatch = /Does not match the (generator|platform|toolset) used previously/.test(result.stdout + result.stderr);
|
||||||
|
// Not awaited: an error stays until it's closed, and the configure (and every Reload after it) would wait for it.
|
||||||
|
void vscode.window.showErrorMessage(
|
||||||
|
mismatch ? `CMake: the build directory of ${p.name} was configured differently. Reset its cache?` : `CMake: loading ${p.name} failed.`,
|
||||||
|
...(mismatch ? ["Reset Cache and Reload"] : ["Show Output"]),
|
||||||
|
).then((action) => {
|
||||||
|
if (action === "Reset Cache and Reload") void this.configure({ reset: true });
|
||||||
|
else if (action === "Show Output") this.output.show();
|
||||||
|
});
|
||||||
|
// The old model, if any, is still what's built.
|
||||||
|
this.loadModel();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.output.appendLine("\n[Finished]");
|
||||||
|
this.needsReload = false;
|
||||||
|
if (!this.loadModel()) {
|
||||||
|
this.output.appendLine("CMake didn't answer the File API query (CMake 3.14 or newer is needed).");
|
||||||
|
this.output.show(true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.copyCompileCommands();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetCache(buildDir: string): void {
|
||||||
|
for (const f of ["CMakeCache.txt", "CMakeFiles"]) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(path.join(buildDir, f), { recursive: true, force: true });
|
||||||
|
} catch (err) {
|
||||||
|
this.output.appendLine(`Can't remove ${f}: ${errorText(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** compile_commands.json where the language server looks (`copyCompileCommands`). */
|
||||||
|
private copyCompileCommands(): void {
|
||||||
|
const to = settings().get<string>("copyCompileCommands")?.trim();
|
||||||
|
if (!to) return;
|
||||||
|
const from = path.join(this.buildDir, "compile_commands.json");
|
||||||
|
if (!exists(from)) return;
|
||||||
|
const dest = path.resolve(this.sourceDir, expand(to, this.vars()));
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
|
fs.copyFileSync(from, dest);
|
||||||
|
} catch (err) {
|
||||||
|
this.output.appendLine(`Can't copy compile_commands.json to ${dest}: ${errorText(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- reload on changes ---- */
|
||||||
|
|
||||||
|
private watch(): void {
|
||||||
|
const pattern = new vscode.RelativePattern(this.sourceDir, "**/{CMakeLists.txt,*.cmake,CMakePresets.json,CMakeUserPresets.json}");
|
||||||
|
const watcher = vscode.workspace.createFileSystemWatcher(pattern);
|
||||||
|
const presetsChanged = debounce(() => this.profilesChanged(), 300);
|
||||||
|
const onFile = (uri: vscode.Uri) => {
|
||||||
|
const key = pathKey(uri.fsPath);
|
||||||
|
if (this.presetInputs.has(key) || /CMake(User)?Presets\.json$/.test(uri.fsPath)) presetsChanged();
|
||||||
|
else if (this.inputs.has(key)) this.markDirty();
|
||||||
|
};
|
||||||
|
watcher.onDidChange(onFile);
|
||||||
|
watcher.onDidCreate(onFile);
|
||||||
|
watcher.onDidDelete(onFile);
|
||||||
|
this.disposables.push(watcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
private autoReload = debounce(() => void this.configure(), 800);
|
||||||
|
|
||||||
|
private markDirty(): void {
|
||||||
|
if (!this.model || this.state === "configuring") return;
|
||||||
|
const mode = settings().get<string>("autoReload", "ask");
|
||||||
|
if (mode === "always") {
|
||||||
|
this.autoReload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode === "never") return;
|
||||||
|
if (this.needsReload) return;
|
||||||
|
this.needsReload = true;
|
||||||
|
this.changed.fire();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- build ---- */
|
||||||
|
|
||||||
|
/** Builds targets (none: all); true when it succeeded. */
|
||||||
|
async build(targets: string[] = [], opts: { clean?: boolean; cleanFirst?: boolean } = {}): Promise<boolean> {
|
||||||
|
if (this.configuring) await this.configuring;
|
||||||
|
if (!this.model && !(await this.configure())) return false;
|
||||||
|
const args = ["--build", this.buildDir];
|
||||||
|
if (opts.clean) args.push("--target", "clean");
|
||||||
|
else if (targets.length) args.push("--target", ...targets);
|
||||||
|
if (this.multiConfig) args.push("--config", this.configuration);
|
||||||
|
if (opts.cleanFirst) args.push("--clean-first");
|
||||||
|
const jobs = settings().get<number>("parallelJobs", 0) || os.cpus().length;
|
||||||
|
args.push("--parallel", String(jobs));
|
||||||
|
args.push(...this.activeProfile.buildArgs);
|
||||||
|
const what = opts.clean ? "Clean" : `Build ${targets.length ? targets.join(", ") : "All"}`;
|
||||||
|
this.setState("building");
|
||||||
|
try {
|
||||||
|
const code = await this.runTask(`${what} [${this.activeProfile.name}]`, "build", this.cmakePath, args, {
|
||||||
|
cwd: this.sourceDir,
|
||||||
|
problemMatchers: isWindows ? ["$cmake-helper-gcc", "$msCompile"] : ["$cmake-helper-gcc"],
|
||||||
|
reveal: settings().get<boolean>("revealBuildOutput", true) ? vscode.TaskRevealKind.Always : vscode.TaskRevealKind.Silent,
|
||||||
|
});
|
||||||
|
if (code !== 0) void vscode.window.setStatusBarMessage(`$(error) ${what} failed`, 5000);
|
||||||
|
else void vscode.window.setStatusBarMessage(`$(check) ${what} finished`, 3000);
|
||||||
|
return code === 0;
|
||||||
|
} finally {
|
||||||
|
this.setState("idle");
|
||||||
|
this.built.fire();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a program as a task in the terminal panel and waits for its exit
|
||||||
|
* code (undefined when it couldn't start). A run of the same `key` that's
|
||||||
|
* still going is stopped first, as CLion's "Stop and Rerun".
|
||||||
|
*/
|
||||||
|
async runTask(
|
||||||
|
name: string,
|
||||||
|
kind: "build" | "run",
|
||||||
|
program: string,
|
||||||
|
args: string[],
|
||||||
|
opts: { cwd: string; env?: Record<string, string>; problemMatchers?: string[]; reveal?: vscode.TaskRevealKind; key?: string },
|
||||||
|
): Promise<number | undefined> {
|
||||||
|
const key = opts.key ?? name;
|
||||||
|
const previous = this.runs.get(key);
|
||||||
|
if (previous) previous.terminate();
|
||||||
|
const env = { ...this.activeProfile.env, ...opts.env };
|
||||||
|
const task = new vscode.Task(
|
||||||
|
{ type: "cmakeHelper", kind, name: key },
|
||||||
|
this.folder,
|
||||||
|
name,
|
||||||
|
"CMake",
|
||||||
|
new vscode.ProcessExecution(program, args, { cwd: opts.cwd, env }),
|
||||||
|
opts.problemMatchers ?? [],
|
||||||
|
);
|
||||||
|
task.presentationOptions = {
|
||||||
|
reveal: opts.reveal ?? vscode.TaskRevealKind.Always,
|
||||||
|
panel: kind === "build" ? vscode.TaskPanelKind.Shared : vscode.TaskPanelKind.Dedicated,
|
||||||
|
clear: true,
|
||||||
|
showReuseMessage: false,
|
||||||
|
focus: false,
|
||||||
|
};
|
||||||
|
if (kind === "build") task.group = vscode.TaskGroup.Build;
|
||||||
|
const execution = await vscode.tasks.executeTask(task);
|
||||||
|
this.runs.set(key, execution);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let code: number | undefined;
|
||||||
|
const subs = [
|
||||||
|
vscode.tasks.onDidEndTaskProcess((e) => {
|
||||||
|
if (e.execution === execution) code = e.exitCode;
|
||||||
|
}),
|
||||||
|
vscode.tasks.onDidEndTask((e) => {
|
||||||
|
if (e.execution !== execution) return;
|
||||||
|
subs.forEach((s) => s.dispose());
|
||||||
|
if (this.runs.get(key) === execution) this.runs.delete(key);
|
||||||
|
resolve(code);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setState(s: State): void {
|
||||||
|
if (this.state === s) return;
|
||||||
|
this.state = s;
|
||||||
|
this.changed.fire();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.configureCancel?.cancel();
|
||||||
|
this.disposables.forEach((d) => d.dispose());
|
||||||
|
this.changed.dispose();
|
||||||
|
this.built.dispose();
|
||||||
|
this.modelLoaded.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function quote(a: string): string {
|
||||||
|
return /[\s"'$`\\]/.test(a) ? JSON.stringify(a) : a;
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* The run configurations editor, as CLion's Run/Debug Configurations for
|
||||||
|
* "CMake Application": every target on the left (executables, custom targets,
|
||||||
|
* libraries), the selected one's fields on the right: the executable it runs
|
||||||
|
* (its own, another target's, or a file), program arguments, working
|
||||||
|
* directory, environment, build before launch. OK / Cancel / Apply. It edits
|
||||||
|
* the workspace setting `cmakeHelper.runConfigurations` (by target name).
|
||||||
|
*/
|
||||||
|
import * as crypto from "node:crypto";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import { editorIcon, editorPage } from "./editorPage";
|
||||||
|
import { executableOf } from "./fileApi";
|
||||||
|
import { type RunConfiguration, runConfiguration, saveRunConfigurations } from "./launcher";
|
||||||
|
import type { CMakeProject } from "./project";
|
||||||
|
import { formatEnv, parseEnv, settings } from "./util";
|
||||||
|
|
||||||
|
let panel: vscode.WebviewPanel | undefined;
|
||||||
|
|
||||||
|
/** A target's row in the editor: what it is, and its configuration as the form's fields. */
|
||||||
|
type Row = {
|
||||||
|
name: string;
|
||||||
|
kind: "executable" | "utility" | "library";
|
||||||
|
own: string;
|
||||||
|
executable: string;
|
||||||
|
args: string;
|
||||||
|
workingDirectory: string;
|
||||||
|
environment: string;
|
||||||
|
/** null: the setting's default (buildBeforeRun). */
|
||||||
|
buildBeforeRun: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const kindOf = (type: string): Row["kind"] => (type === "EXECUTABLE" ? "executable" : type === "UTILITY" ? "utility" : "library");
|
||||||
|
|
||||||
|
function rowsOf(project: CMakeProject): Row[] {
|
||||||
|
return project.visibleTargets().map((t) => {
|
||||||
|
const c = runConfiguration(t.name);
|
||||||
|
return {
|
||||||
|
name: t.name,
|
||||||
|
kind: kindOf(t.type),
|
||||||
|
own: executableOf(t) ? path.relative(project.buildDir, executableOf(t)!) : "",
|
||||||
|
executable: c.executable ?? "",
|
||||||
|
args: c.args ?? "",
|
||||||
|
workingDirectory: c.workingDirectory ?? "",
|
||||||
|
environment: formatEnv(c.environment),
|
||||||
|
buildBeforeRun: c.buildBeforeRun ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const toConfiguration = (r: Row): RunConfiguration => ({
|
||||||
|
executable: r.executable.trim() || undefined,
|
||||||
|
args: r.args.trim() || undefined,
|
||||||
|
workingDirectory: r.workingDirectory.trim() || undefined,
|
||||||
|
environment: parseEnv(r.environment),
|
||||||
|
buildBeforeRun: r.buildBeforeRun ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Opens the editor (or brings it forward) on `select` (a target's name; else the active target). */
|
||||||
|
export function openRunConfigEditor(project: CMakeProject, select?: string): void {
|
||||||
|
const init = () => ({
|
||||||
|
type: "init",
|
||||||
|
rows: rowsOf(project),
|
||||||
|
// Another target's program, for the Executable field (CLion's combo): relative to the build directory.
|
||||||
|
programs: project.visibleTargets().map((t) => executableOf(t)).filter((p): p is string => !!p).map((p) => path.relative(project.buildDir, p)),
|
||||||
|
active: project.activeTarget?.name ?? "",
|
||||||
|
buildBefore: settings().get<boolean>("buildBeforeRun", true),
|
||||||
|
defaultCwd: settings().get<string>("defaultWorkingDirectory") || "${targetDir}",
|
||||||
|
profile: project.activeProfile.name,
|
||||||
|
select: select ?? project.activeTarget?.name,
|
||||||
|
});
|
||||||
|
if (panel) {
|
||||||
|
panel.reveal();
|
||||||
|
void panel.webview.postMessage(init());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
panel = vscode.window.createWebviewPanel("cmakeHelper.runConfigurations", "Run/Debug Configurations", vscode.ViewColumn.Active, { enableScripts: true, retainContextWhenHidden: true });
|
||||||
|
panel.iconPath = editorIcon();
|
||||||
|
const nonce = crypto.randomBytes(16).toString("base64");
|
||||||
|
panel.webview.html = editorPage(panel.webview, nonce, { noun: "configuration", extra: '<datalist id="programs"></datalist>', script: SCRIPT, tools: false });
|
||||||
|
panel.onDidDispose(() => (panel = undefined));
|
||||||
|
panel.webview.onDidReceiveMessage(async (msg: { type: string; rows?: Row[]; close?: boolean }) => {
|
||||||
|
if (msg.type === "ready") {
|
||||||
|
void panel?.webview.postMessage(init());
|
||||||
|
} else if (msg.type === "cancel") {
|
||||||
|
panel?.dispose();
|
||||||
|
} else if (msg.type === "save" && msg.rows) {
|
||||||
|
try {
|
||||||
|
await saveRunConfigurations(Object.fromEntries(msg.rows.map((r) => [r.name, toConfiguration(r)])), msg.rows.map((r) => r.name));
|
||||||
|
} catch (e) {
|
||||||
|
void panel?.webview.postMessage({ type: "error", text: e instanceof Error ? e.message : String(e) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.close) panel?.dispose();
|
||||||
|
else void panel?.webview.postMessage({ ...init(), type: "saved" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The editor's script: the targets by kind, the selected one's form. */
|
||||||
|
const SCRIPT = String.raw`
|
||||||
|
const vscode = acquireVsCodeApi();
|
||||||
|
let rows = [], sel = null, dirty = false, active = "", buildBefore = true, defaultCwd = "", profile = "";
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||||
|
const current = () => rows.find((r) => r.name === sel);
|
||||||
|
const GROUPS = [["executable", "CMake Applications"], ["utility", "Custom Targets"], ["library", "Libraries"]];
|
||||||
|
|
||||||
|
function setDirty(v) {
|
||||||
|
dirty = v;
|
||||||
|
$("apply").disabled = !v;
|
||||||
|
if (v) $("err").textContent = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A target with something set shows it in the list (CLion's configurations exist only once made). */
|
||||||
|
const customised = (r) => !!(r.executable || r.args || r.workingDirectory || r.environment || r.buildBeforeRun !== null);
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
$("list").innerHTML = GROUPS.map(([kind, label]) => {
|
||||||
|
const of = rows.filter((r) => r.kind === kind);
|
||||||
|
if (!of.length) return "";
|
||||||
|
return '<li class="group">' + label + "</li>" + of.map((r) => {
|
||||||
|
const tag = r.name === active ? '<span class="tag">active</span>' : customised(r) ? '<span class="tag">set</span>' : "";
|
||||||
|
return '<li class="' + (r.name === sel ? "sel" : "") + '" data-name="' + esc(r.name) + '">' + esc(r.name) + tag + "</li>";
|
||||||
|
}).join("");
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function input(label, key, value, o = {}) {
|
||||||
|
const attrs = (o.list ? ' list="' + o.list + '"' : "") + (o.placeholder ? ' placeholder="' + esc(o.placeholder) + '"' : "");
|
||||||
|
const field = o.area
|
||||||
|
? '<textarea data-k="' + key + '"' + attrs + ">" + esc(value) + "</textarea>"
|
||||||
|
: '<input type="text" data-k="' + key + '" value="' + esc(value) + '"' + attrs + ">";
|
||||||
|
return "<label>" + label + ":</label><div>" + field + (o.hint ? '<div class="hint">' + o.hint + "</div>" : "") + "</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
const sec = (t) => '<div class="sec">' + t + "</div>";
|
||||||
|
const VARS = "$" + "{buildDir}, $" + "{sourceDir}, $" + "{targetDir} and $" + "{env:X} are expanded.";
|
||||||
|
|
||||||
|
function renderForm() {
|
||||||
|
const r = current();
|
||||||
|
if (!r) {
|
||||||
|
$("form").innerHTML = '<p class="empty">' + (rows.length ? "Select a target." : "The project has no targets yet: reload CMake.") + "</p>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const own = r.kind === "executable";
|
||||||
|
const before = r.buildBeforeRun === null ? buildBefore : r.buildBeforeRun;
|
||||||
|
const exeHint = own
|
||||||
|
? "Empty: the target's own (" + esc(r.own) + "). Another runs instead after building it: a script, a test runner."
|
||||||
|
: "What ▶ and 🐞 run after building " + esc(r.name) + ": another target's executable (pick one) or a file. Empty: " + (r.kind === "utility" ? "▶ only builds it." : "it can't be run.");
|
||||||
|
$("form").innerHTML = '<div class="grid">' +
|
||||||
|
'<label>Name:</label><div><input type="text" value="' + esc(r.name) + '" disabled><div class="hint">' + (r.kind === "executable" ? "CMake Application" : r.kind === "utility" ? "Custom target (add_custom_target)" : "Library") + ", profile " + esc(profile) + "</div></div>" +
|
||||||
|
sec("Run") +
|
||||||
|
input("Executable", "executable", r.executable, { list: "programs", placeholder: own ? r.own : "Not selected", hint: exeHint + " Relative to the build directory." }) +
|
||||||
|
input("Program arguments", "args", r.args, { placeholder: "--verbose input.txt", hint: "As on a command line. " + VARS }) +
|
||||||
|
input("Working directory", "workingDirectory", r.workingDirectory, { placeholder: defaultCwd + " (default)", hint: "Relative paths are from the source directory." }) +
|
||||||
|
input("Environment variables", "environment", r.environment, { area: true, placeholder: "A=1;B=2", hint: "NAME=value, separated by ;. On top of the profile's environment." }) +
|
||||||
|
sec("Before launch") +
|
||||||
|
'<span></span><label class="check"><input type="checkbox" data-k="buildBeforeRun"' + (before ? " checked" : "") + "> Build " + esc(r.name) + "</label>" +
|
||||||
|
(r.buildBeforeRun === null ? "" : '<span></span><div class="hint"><a href="#" id="default-build">Use the default</a> (' + (buildBefore ? "build" : "don't build") + ", cmakeHelper.buildBeforeRun)</div>") +
|
||||||
|
"</div>";
|
||||||
|
const d = $("default-build");
|
||||||
|
if (d) d.onclick = (e) => { e.preventDefault(); r.buildBeforeRun = null; setDirty(true); render(); };
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
renderList();
|
||||||
|
renderForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
$("list").addEventListener("click", (e) => {
|
||||||
|
const li = e.target.closest("li[data-name]");
|
||||||
|
if (!li) return;
|
||||||
|
sel = li.dataset.name;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("form").addEventListener("input", (e) => {
|
||||||
|
const r = current();
|
||||||
|
const k = e.target.dataset.k;
|
||||||
|
if (!r || !k) return;
|
||||||
|
r[k] = e.target.type === "checkbox" ? e.target.checked : e.target.value;
|
||||||
|
setDirty(true);
|
||||||
|
if (k === "buildBeforeRun") renderForm();
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
|
||||||
|
function save(close) {
|
||||||
|
if (!dirty && close) return vscode.postMessage({ type: "cancel" });
|
||||||
|
vscode.postMessage({ type: "save", rows, close });
|
||||||
|
}
|
||||||
|
$("ok").onclick = () => save(true);
|
||||||
|
$("apply").onclick = () => save(false);
|
||||||
|
$("cancel").onclick = () => vscode.postMessage({ type: "cancel" });
|
||||||
|
|
||||||
|
window.addEventListener("message", (e) => {
|
||||||
|
const m = e.data;
|
||||||
|
if (m.type === "init" || m.type === "saved") {
|
||||||
|
const keep = sel;
|
||||||
|
rows = m.rows; active = m.active; buildBefore = m.buildBefore; defaultCwd = m.defaultCwd; profile = m.profile;
|
||||||
|
$("programs").innerHTML = m.programs.map((p) => '<option value="' + esc(p) + '">').join("");
|
||||||
|
sel = (m.type === "saved" ? keep : m.select) ?? null;
|
||||||
|
if (!rows.some((r) => r.name === sel)) sel = rows[0]?.name ?? null;
|
||||||
|
setDirty(false);
|
||||||
|
render();
|
||||||
|
} else if (m.type === "error") {
|
||||||
|
$("err").textContent = m.text;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
vscode.postMessage({ type: "ready" });
|
||||||
|
`;
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* CLion's toolbar in the status bar: the profile, the target (or a GDB server
|
||||||
|
* configuration), then Build, Run and Debug for it; and a "Reload" when a CMake file changed.
|
||||||
|
*/
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import { KINDS } from "./gdbConfigs";
|
||||||
|
import { programOf } from "./launcher";
|
||||||
|
import type { CMakeProject } from "./project";
|
||||||
|
import { settings } from "./util";
|
||||||
|
|
||||||
|
export class StatusBar implements vscode.Disposable {
|
||||||
|
private readonly reload = vscode.window.createStatusBarItem("cmakeHelper.reload", vscode.StatusBarAlignment.Left, 104);
|
||||||
|
private readonly profile = vscode.window.createStatusBarItem("cmakeHelper.profile", vscode.StatusBarAlignment.Left, 103);
|
||||||
|
private readonly target = vscode.window.createStatusBarItem("cmakeHelper.target", vscode.StatusBarAlignment.Left, 102);
|
||||||
|
private readonly build = vscode.window.createStatusBarItem("cmakeHelper.build", vscode.StatusBarAlignment.Left, 101.3);
|
||||||
|
private readonly run = vscode.window.createStatusBarItem("cmakeHelper.run", vscode.StatusBarAlignment.Left, 101.2);
|
||||||
|
private readonly debug = vscode.window.createStatusBarItem("cmakeHelper.debug", vscode.StatusBarAlignment.Left, 101.1);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.reload.name = "CMake: Reload";
|
||||||
|
this.profile.name = "CMake: Profile";
|
||||||
|
this.target.name = "CMake: Target";
|
||||||
|
this.build.name = "CMake: Build";
|
||||||
|
this.run.name = "CMake: Run";
|
||||||
|
this.debug.name = "CMake: Debug";
|
||||||
|
this.profile.command = "cmakeHelper.selectProfile";
|
||||||
|
this.target.command = "cmakeHelper.selectTarget";
|
||||||
|
this.build.command = "cmakeHelper.build";
|
||||||
|
this.run.command = "cmakeHelper.run";
|
||||||
|
this.debug.command = "cmakeHelper.debug";
|
||||||
|
this.build.text = "$(tools)";
|
||||||
|
this.run.text = "$(play)";
|
||||||
|
this.debug.text = "$(debug-alt)";
|
||||||
|
}
|
||||||
|
|
||||||
|
update(p: CMakeProject | undefined): void {
|
||||||
|
this.show(p);
|
||||||
|
// Build, Run and Debug are the editors' buttons then (package.json's editor/title).
|
||||||
|
if (settings().get<string>("toolbar") === "editorTitle") [this.build, this.run, this.debug].forEach((i) => i.hide());
|
||||||
|
}
|
||||||
|
|
||||||
|
private show(p: CMakeProject | undefined): void {
|
||||||
|
const all = [this.reload, this.profile, this.target, this.build, this.run, this.debug];
|
||||||
|
if (!p) {
|
||||||
|
all.forEach((i) => i.hide());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (p.state === "configuring") {
|
||||||
|
this.reload.text = "$(sync~spin) CMake";
|
||||||
|
this.reload.tooltip = "Loading the CMake project… (click to stop)";
|
||||||
|
this.reload.command = "cmakeHelper.cancelReload";
|
||||||
|
this.reload.backgroundColor = undefined;
|
||||||
|
this.reload.show();
|
||||||
|
} else if (p.needsReload || !p.model) {
|
||||||
|
this.reload.text = p.model ? "$(refresh) Reload CMake" : "$(warning) Load CMake";
|
||||||
|
this.reload.tooltip = p.model ? "CMake files changed since the project was loaded" : "The CMake project isn't loaded";
|
||||||
|
this.reload.command = "cmakeHelper.reload";
|
||||||
|
this.reload.backgroundColor = new vscode.ThemeColor("statusBarItem.warningBackground");
|
||||||
|
this.reload.show();
|
||||||
|
} else {
|
||||||
|
this.reload.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.profile.text = `$(layers) ${p.activeProfile.name}`;
|
||||||
|
this.profile.tooltip = `CMake profile: ${p.activeProfile.name} (${p.activeProfile.buildType})\n${p.activeProfile.buildDir}`;
|
||||||
|
this.profile.show();
|
||||||
|
|
||||||
|
const busy = p.state !== "idle";
|
||||||
|
const gdb = p.activeGdbConfiguration;
|
||||||
|
if (p.model && gdb) {
|
||||||
|
// A GDB server configuration in place of a target, as CLion's run configuration.
|
||||||
|
const target = gdb.target ?? p.activeTarget?.name;
|
||||||
|
this.target.text = `$(${gdb.kind === "embedded" ? "chip" : "remote"}) ${gdb.name}`;
|
||||||
|
this.target.tooltip = `${KINDS[gdb.kind]}: ${gdb.name}${target ? ` (${target})` : ""}\nClick to choose another target or configuration`;
|
||||||
|
this.target.show();
|
||||||
|
this.build.tooltip = busy ? "Busy…" : `Build ${target ?? ""} (Ctrl+F9)`;
|
||||||
|
this.build.text = p.state === "building" ? "$(loading~spin)" : "$(tools)";
|
||||||
|
this.build.show();
|
||||||
|
if (gdb.kind === "embedded" || gdb.ssh) {
|
||||||
|
this.run.tooltip = gdb.kind === "embedded" ? `Download & Run ${gdb.name} (Shift+F10)` : `Run ${gdb.name} on ${gdb.ssh} (Shift+F10)`;
|
||||||
|
this.run.show();
|
||||||
|
} else {
|
||||||
|
this.run.hide();
|
||||||
|
}
|
||||||
|
this.debug.tooltip = `Debug ${gdb.name} (Shift+F9)`;
|
||||||
|
this.debug.show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = p.activeTarget;
|
||||||
|
if (!p.model || !t) {
|
||||||
|
[this.target, this.build, this.run, this.debug].forEach((i) => i.hide());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.target.text = `$(target) ${t.name}`;
|
||||||
|
this.target.tooltip = "Target to build, run and debug";
|
||||||
|
this.target.show();
|
||||||
|
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.
|
||||||
|
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)`;
|
||||||
|
if (program || t.type === "UTILITY") this.run.show();
|
||||||
|
else this.run.hide();
|
||||||
|
if (program) this.debug.show();
|
||||||
|
else this.debug.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
[this.reload, this.profile, this.target, this.build, this.run, this.debug].forEach((i) => i.dispose());
|
||||||
|
}
|
||||||
|
}
|
||||||
+386
@@ -0,0 +1,386 @@
|
|||||||
|
/**
|
||||||
|
* CTest in VS Code's Test Explorer. The tests come from
|
||||||
|
* `ctest --show-only=json-v1`, grouped by the target whose executable they
|
||||||
|
* run and, for names like Suite.Test (GoogleTest's), by suite. Each is placed
|
||||||
|
* at its TEST(…) / TEST_CASE("…") in the target's sources when it's found
|
||||||
|
* there (the gutter's ▶), else at its add_test. Runs go through ctest with a
|
||||||
|
* JUnit report for the results; debugging runs a test's command under the
|
||||||
|
* debugger.
|
||||||
|
*/
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import { type Target, executableOf } from "./fileApi";
|
||||||
|
import { debugProgram, launchOfTest } from "./launcher";
|
||||||
|
import type { CMakeProject } from "./project";
|
||||||
|
import { debounce, errorText, pathKey, regexEscape, run, settings } from "./util";
|
||||||
|
|
||||||
|
interface CTest {
|
||||||
|
name: string;
|
||||||
|
command?: string[];
|
||||||
|
cwd: string;
|
||||||
|
env: Record<string, string>;
|
||||||
|
labels: string[];
|
||||||
|
disabled: boolean;
|
||||||
|
target?: Target;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE_EXT = /\.(c|cc|cpp|cxx|c\+\+|cu|m|mm|h|hh|hpp|hxx|ipp|inl)$/i;
|
||||||
|
const GTEST = /^[ \t]*(?:TEST|TEST_F|TEST_P|TYPED_TEST|TYPED_TEST_P)[ \t]*\([ \t]*(\w+)[ \t]*,[ \t]*(\w+)[ \t]*\)/gm;
|
||||||
|
const CATCH = /^[ \t]*(?:TEST_CASE|SCENARIO|TEST_CASE_METHOD[ \t]*\([ \t]*[\w:]+[ \t]*,|TEMPLATE_TEST_CASE)[ \t]*\(?[ \t]*"((?:[^"\\]|\\.)*)"/gm;
|
||||||
|
const BOOST = /^[ \t]*BOOST_(?:AUTO|FIXTURE)_TEST_CASE[ \t]*\([ \t]*(\w+)/gm;
|
||||||
|
|
||||||
|
export class CTestProvider implements vscode.Disposable {
|
||||||
|
private readonly controller = vscode.tests.createTestController("cmakeHelper.ctest", "CTest");
|
||||||
|
private readonly tests = new WeakMap<vscode.TestItem, CTest>();
|
||||||
|
private disposables: vscode.Disposable[] = [];
|
||||||
|
private discovering: Promise<void> | undefined;
|
||||||
|
|
||||||
|
constructor(private readonly project: () => CMakeProject | undefined) {
|
||||||
|
this.controller.refreshHandler = () => this.discover();
|
||||||
|
this.controller.createRunProfile("Run", vscode.TestRunProfileKind.Run, (req, token) => this.runTests(req, token), true);
|
||||||
|
this.controller.createRunProfile("Debug", vscode.TestRunProfileKind.Debug, (req, token) => this.debugTests(req, token), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-reads the tests a little after a build, which may have made new ones. */
|
||||||
|
readonly discoverSoon = debounce(() => void this.discover(), 500);
|
||||||
|
|
||||||
|
discover(): Promise<void> {
|
||||||
|
if (this.discovering) return this.discovering;
|
||||||
|
this.discovering = this.doDiscover().finally(() => (this.discovering = undefined));
|
||||||
|
return this.discovering;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doDiscover(): Promise<void> {
|
||||||
|
const p = this.project();
|
||||||
|
if (!p?.model) {
|
||||||
|
this.controller.items.replace([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const args = ["--show-only=json-v1"];
|
||||||
|
if (p.multiConfig) args.push("-C", p.configuration);
|
||||||
|
let info: any;
|
||||||
|
try {
|
||||||
|
const r = await run(p.ctestPath, args, { cwd: p.buildDir, env: p.env() });
|
||||||
|
info = JSON.parse(r.stdout);
|
||||||
|
} catch (err) {
|
||||||
|
p.output.appendLine(`CTest: can't list the tests: ${errorText(err)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tests: CTest[] = [];
|
||||||
|
const byArtifact = new Map<string, Target>();
|
||||||
|
for (const t of p.targets) {
|
||||||
|
const exe = executableOf(t);
|
||||||
|
if (exe) byArtifact.set(pathKey(exe), t);
|
||||||
|
}
|
||||||
|
const nodes = info.backtraceGraph?.nodes ?? [];
|
||||||
|
const files: string[] = info.backtraceGraph?.files ?? [];
|
||||||
|
const definitions = new Map<string, { file: string; line: number }>();
|
||||||
|
for (const t of info.tests ?? []) {
|
||||||
|
const props = new Map<string, any>((t.properties ?? []).map((x: any) => [x.name, x.value]));
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
for (const e of props.get("ENVIRONMENT") ?? []) {
|
||||||
|
const i = String(e).indexOf("=");
|
||||||
|
if (i > 0) env[String(e).slice(0, i)] = String(e).slice(i + 1);
|
||||||
|
}
|
||||||
|
const command: string[] | undefined = Array.isArray(t.command) ? t.command : undefined;
|
||||||
|
tests.push({
|
||||||
|
name: t.name,
|
||||||
|
command,
|
||||||
|
cwd: props.get("WORKING_DIRECTORY") ?? p.buildDir,
|
||||||
|
env,
|
||||||
|
labels: props.get("LABELS") ?? [],
|
||||||
|
disabled: props.get("DISABLED") === true,
|
||||||
|
target: command?.[0] ? byArtifact.get(pathKey(command[0])) : undefined,
|
||||||
|
});
|
||||||
|
const node = nodes[t.backtrace];
|
||||||
|
if (node && files[node.file]) definitions.set(t.name, { file: path.resolve(p.sourceDir, files[node.file]!), line: node.line ?? 1 });
|
||||||
|
}
|
||||||
|
// Tests not built yet have no command: tell them by the target ctest would have run, if named in add_test.
|
||||||
|
for (const t of tests) if (!t.target) t.target = guessTarget(t, p);
|
||||||
|
|
||||||
|
const roots: vscode.TestItem[] = [];
|
||||||
|
const groups = new Map<string, Target | undefined>();
|
||||||
|
for (const t of tests) groups.set(t.target?.name ?? "", t.target);
|
||||||
|
for (const [name, target] of [...groups].sort((a, b) => a[0].localeCompare(b[0]))) {
|
||||||
|
const own = tests.filter((t) => (t.target?.name ?? "") === name);
|
||||||
|
const where = target ? await scanSources(target) : new Map<string, vscode.Location>();
|
||||||
|
const locate = (t: CTest): vscode.Location | undefined => {
|
||||||
|
const hit = where.get(sourceKey(t.name)) ?? where.get(t.name) ?? where.get(t.name.replace(/^Scenario: /, ""));
|
||||||
|
if (hit) return hit;
|
||||||
|
const d = definitions.get(t.name);
|
||||||
|
return d ? new vscode.Location(vscode.Uri.file(d.file), new vscode.Position(Math.max(0, d.line - 1), 0)) : undefined;
|
||||||
|
};
|
||||||
|
const parent = this.controller.createTestItem(`target:${name}`, name || "Other tests", target?.definition ? vscode.Uri.file(target.definition.file) : undefined);
|
||||||
|
if (target?.definition) parent.range = new vscode.Range(target.definition.line - 1, 0, target.definition.line - 1, 0);
|
||||||
|
const suites = new Map<string, vscode.TestItem>();
|
||||||
|
for (const t of own) {
|
||||||
|
const loc = locate(t);
|
||||||
|
const leaf = this.controller.createTestItem(`test:${t.name}`, t.name, loc?.uri);
|
||||||
|
if (loc) leaf.range = loc.range;
|
||||||
|
if (t.labels.length) leaf.tags = t.labels.map((l) => new vscode.TestTag(l));
|
||||||
|
if (t.labels.length || t.disabled) leaf.description = [t.disabled ? "disabled" : "", ...t.labels].filter(Boolean).join(" ");
|
||||||
|
this.tests.set(leaf, t);
|
||||||
|
const m = /^(?:\w+\/)?([A-Za-z_]\w*(?:\/\d+)?)\.(\S+)$/.exec(t.name);
|
||||||
|
if (m && own.length > 1) {
|
||||||
|
let suite = suites.get(m[1]!);
|
||||||
|
if (!suite) {
|
||||||
|
suite = this.controller.createTestItem(`suite:${name}:${m[1]}`, m[1]!, loc?.uri);
|
||||||
|
if (loc) suite.range = loc.range;
|
||||||
|
suites.set(m[1]!, suite);
|
||||||
|
parent.children.add(suite);
|
||||||
|
}
|
||||||
|
leaf.label = m[2]!;
|
||||||
|
suite.children.add(leaf);
|
||||||
|
} else {
|
||||||
|
parent.children.add(leaf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roots.push(parent);
|
||||||
|
}
|
||||||
|
// One target's tests need no level above them.
|
||||||
|
if (roots.length === 1) {
|
||||||
|
const only = roots[0]!;
|
||||||
|
const kids: vscode.TestItem[] = [];
|
||||||
|
only.children.forEach((c) => kids.push(c));
|
||||||
|
this.controller.items.replace(kids);
|
||||||
|
} else {
|
||||||
|
this.controller.items.replace(roots);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private leaves(req: vscode.TestRunRequest): vscode.TestItem[] {
|
||||||
|
const out: vscode.TestItem[] = [];
|
||||||
|
const excluded = new Set(req.exclude ?? []);
|
||||||
|
const visit = (item: vscode.TestItem) => {
|
||||||
|
if (excluded.has(item)) return;
|
||||||
|
if (this.tests.has(item)) out.push(item);
|
||||||
|
item.children.forEach(visit);
|
||||||
|
};
|
||||||
|
if (req.include) req.include.forEach(visit);
|
||||||
|
else this.controller.items.forEach(visit);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds what the tests run; false (and the tests errored) when that fails. */
|
||||||
|
private async buildFor(p: CMakeProject, items: vscode.TestItem[], run: vscode.TestRun): Promise<boolean> {
|
||||||
|
if (!settings().get<boolean>("buildBeforeTest", true)) return true;
|
||||||
|
const targets = [...new Set(items.map((i) => this.tests.get(i)?.target?.name).filter((n): n is string => !!n))];
|
||||||
|
const ok = await p.build(targets);
|
||||||
|
if (!ok) for (const i of items) run.errored(i, new vscode.TestMessage("The build failed."));
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runTests(req: vscode.TestRunRequest, token: vscode.CancellationToken): Promise<void> {
|
||||||
|
const p = this.project();
|
||||||
|
const run = this.controller.createTestRun(req);
|
||||||
|
try {
|
||||||
|
if (!p) return;
|
||||||
|
const items = this.leaves(req);
|
||||||
|
if (!items.length) return;
|
||||||
|
items.forEach((i) => run.enqueued(i));
|
||||||
|
if (!(await this.buildFor(p, items, run)) || token.isCancellationRequested) return;
|
||||||
|
await this.ctest(p, items, run, token, !req.include && !req.exclude?.length);
|
||||||
|
} finally {
|
||||||
|
run.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ctest(p: CMakeProject, items: vscode.TestItem[], run: vscode.TestRun, token: vscode.CancellationToken, all: boolean): Promise<void> {
|
||||||
|
const byName = new Map(items.map((i) => [this.tests.get(i)!.name, i]));
|
||||||
|
const junit = path.join(os.tmpdir(), `cmake-helper-${process.pid}-${Date.now()}.xml`);
|
||||||
|
const args = ["--output-junit", junit, "--output-on-failure"];
|
||||||
|
const jobs = settings().get<number>("testParallelJobs", 0) || os.cpus().length;
|
||||||
|
args.push("-j", String(jobs));
|
||||||
|
if (p.multiConfig) args.push("-C", p.configuration);
|
||||||
|
if (!all) args.push("-R", `^(${[...byName.keys()].map(regexEscape).join("|")})$`);
|
||||||
|
args.push(...(settings().get<string[]>("ctestArgs") ?? []));
|
||||||
|
run.appendOutput(`> ctest ${args.join(" ")}\r\n\r\n`);
|
||||||
|
|
||||||
|
const lineResults = new Map<string, { status: string; seconds: number }>();
|
||||||
|
let pending = "";
|
||||||
|
const onOutput = (text: string) => {
|
||||||
|
run.appendOutput(text.replace(/\r?\n/g, "\r\n"));
|
||||||
|
pending += text;
|
||||||
|
let i;
|
||||||
|
while ((i = pending.indexOf("\n")) >= 0) {
|
||||||
|
const line = pending.slice(0, i).trimEnd();
|
||||||
|
pending = pending.slice(i + 1);
|
||||||
|
const start = /^\s*Start\s+\d+: (.+)$/.exec(line);
|
||||||
|
if (start) {
|
||||||
|
const item = byName.get(start[1]!);
|
||||||
|
if (item) run.started(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// "1/3 Test #1: Math.Add ......... Passed 0.01 sec"
|
||||||
|
const done = /^\s*\d+\/\d+ Test\s+#\d+: (.+?) \.+\s*(?:\*+)?(.+?)\s+([\d.]+) sec$/.exec(line);
|
||||||
|
if (done) lineResults.set(done[1]!, { status: done[2]!.trim(), seconds: Number(done[3]) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await run_(p, args, onOutput, token);
|
||||||
|
} catch (err) {
|
||||||
|
for (const i of items) run.errored(i, new vscode.TestMessage(`ctest didn't run: ${errorText(err)}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reported = new Set<string>();
|
||||||
|
let xml = "";
|
||||||
|
try {
|
||||||
|
xml = fs.readFileSync(junit, "utf8");
|
||||||
|
fs.rmSync(junit, { force: true });
|
||||||
|
} catch {
|
||||||
|
/* CTest older than 3.21: the lines are all there is */
|
||||||
|
}
|
||||||
|
for (const tc of xml.matchAll(/<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g)) {
|
||||||
|
const attrs = attributes(tc[1]!);
|
||||||
|
const item = byName.get(attrs.name ?? "");
|
||||||
|
if (!item) continue;
|
||||||
|
reported.add(attrs.name!);
|
||||||
|
const body = tc[2] ?? "";
|
||||||
|
const ms = Number(attrs.time ?? 0) * 1000;
|
||||||
|
const out = unescapeXml(/<system-out>([\s\S]*?)<\/system-out>/.exec(body)?.[1] ?? "");
|
||||||
|
if (attrs.status === "run") run.passed(item, ms);
|
||||||
|
else if (attrs.status === "fail") {
|
||||||
|
const why = unescapeXml(/<failure\b[^>]*message="([^"]*)"/.exec(body)?.[1] ?? "Failed");
|
||||||
|
run.failed(item, message(why, out, item), ms);
|
||||||
|
} else {
|
||||||
|
const why = unescapeXml(/<skipped\b[^>]*message="([^"]*)"/.exec(body)?.[1] ?? "");
|
||||||
|
if (/Unable to find|Could not find executable/i.test(why + out)) run.errored(item, message(why, out, item));
|
||||||
|
else run.skipped(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [name, item] of byName) {
|
||||||
|
if (reported.has(name)) continue;
|
||||||
|
const r = lineResults.get(name);
|
||||||
|
if (!r) run.skipped(item);
|
||||||
|
else if (/^Passed/.test(r.status)) run.passed(item, r.seconds * 1000);
|
||||||
|
else if (/Not Run|Skipped|Disabled/.test(r.status)) run.skipped(item);
|
||||||
|
else run.failed(item, new vscode.TestMessage(r.status), r.seconds * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async debugTests(req: vscode.TestRunRequest, token: vscode.CancellationToken): Promise<void> {
|
||||||
|
const p = this.project();
|
||||||
|
const run = this.controller.createTestRun(req);
|
||||||
|
try {
|
||||||
|
if (!p) return;
|
||||||
|
let items = this.leaves(req);
|
||||||
|
if (!(await this.buildFor(p, items, run))) return;
|
||||||
|
// Built only now, a test may have its command (or exist) only now.
|
||||||
|
await this.discover();
|
||||||
|
const names = new Set(items.map((i) => this.tests.get(i)!.name));
|
||||||
|
items = [];
|
||||||
|
const visit = (i: vscode.TestItem) => {
|
||||||
|
const t = this.tests.get(i);
|
||||||
|
if (t && names.has(t.name)) items.push(i);
|
||||||
|
i.children.forEach(visit);
|
||||||
|
};
|
||||||
|
this.controller.items.forEach(visit);
|
||||||
|
for (const item of items) {
|
||||||
|
if (token.isCancellationRequested) break;
|
||||||
|
const t = this.tests.get(item)!;
|
||||||
|
if (!t.command?.length) {
|
||||||
|
run.errored(item, new vscode.TestMessage("CTest has no command for this test (not built?)."));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
run.started(item);
|
||||||
|
const ended = new Promise<void>((resolve) => {
|
||||||
|
const sub = vscode.debug.onDidTerminateDebugSession((s) => {
|
||||||
|
if (s.name === `Debug ${t.name}`) {
|
||||||
|
sub.dispose();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
token.onCancellationRequested(() => {
|
||||||
|
sub.dispose();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (!(await debugProgram(p, launchOfTest(t.name, t.command, t.cwd, { ...p.activeProfile.env, ...t.env })))) {
|
||||||
|
run.errored(item, new vscode.TestMessage("The debugger didn't start."));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await ended;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
run.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.disposables.forEach((d) => d.dispose());
|
||||||
|
this.controller.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function run_(p: CMakeProject, args: string[], onOutput: (t: string) => void, token: vscode.CancellationToken) {
|
||||||
|
return run(p.ctestPath, args, { cwd: p.buildDir, env: p.env(), onOutput, token });
|
||||||
|
}
|
||||||
|
|
||||||
|
function message(why: string, out: string, item: vscode.TestItem): vscode.TestMessage {
|
||||||
|
const m = new vscode.TestMessage(out.trim() ? `${why}\n\n${out.trim()}` : why);
|
||||||
|
// GoogleTest's "file.cpp:12: Failure" puts the message at the failing line.
|
||||||
|
const at = /^(.+?):(\d+): Failure/m.exec(out) ?? /^(.+?)\((\d+)\): error/m.exec(out);
|
||||||
|
if (at && path.isAbsolute(at[1]!)) m.location = new vscode.Location(vscode.Uri.file(at[1]!), new vscode.Position(Number(at[2]) - 1, 0));
|
||||||
|
else if (item.uri && item.range) m.location = new vscode.Location(item.uri, item.range);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attributes(s: string): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const m of s.matchAll(/(\w[\w-]*)="([^"]*)"/g)) out[m[1]!] = unescapeXml(m[2]!);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unescapeXml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&#(\d+);/g, (_m, n: string) => String.fromCodePoint(Number(n)))
|
||||||
|
.replace(/&/g, "&");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GoogleTest's discovered names to what's in the source: "Inst/Suite.Name/0" → "Suite.Name". */
|
||||||
|
function sourceKey(name: string): string {
|
||||||
|
return name.replace(/^\w+\/(?=\w+(?:\/\d+)?\.)/, "").replace(/\/\d+(?=\.|$)/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function guessTarget(t: CTest, p: CMakeProject): Target | undefined {
|
||||||
|
const exe = t.command?.[0];
|
||||||
|
if (exe) return p.targets.find((x) => x.type === "EXECUTABLE" && path.basename(executableOf(x) ?? "") === path.basename(exe));
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where each test case is in a target's sources, by the name CTest would give it. */
|
||||||
|
async function scanSources(target: Target): Promise<Map<string, vscode.Location>> {
|
||||||
|
const out = new Map<string, vscode.Location>();
|
||||||
|
const files = target.sources.filter((s) => !s.generated && SOURCE_EXT.test(s.path)).slice(0, 2000);
|
||||||
|
for (const s of files) {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
const stat = await fs.promises.stat(s.path);
|
||||||
|
if (stat.size > 2_000_000) continue;
|
||||||
|
text = await fs.promises.readFile(s.path, "utf8");
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!/TEST|SCENARIO/.test(text)) continue;
|
||||||
|
const uri = vscode.Uri.file(s.path);
|
||||||
|
const lineOf = (index: number) => {
|
||||||
|
let line = 0;
|
||||||
|
for (let i = text.indexOf("\n"); i >= 0 && i < index; i = text.indexOf("\n", i + 1)) line++;
|
||||||
|
return line;
|
||||||
|
};
|
||||||
|
const add = (key: string, index: number) => {
|
||||||
|
if (!out.has(key)) out.set(key, new vscode.Location(uri, new vscode.Position(lineOf(index), 0)));
|
||||||
|
};
|
||||||
|
for (const m of text.matchAll(GTEST)) add(`${m[1]}.${m[2]}`, m.index!);
|
||||||
|
for (const m of text.matchAll(CATCH)) add(m[1]!.replace(/\\(.)/g, "$1"), m.index!);
|
||||||
|
for (const m of text.matchAll(BOOST)) add(m[1]!, m.index!);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* The toolbar in VS Code's title bar (`cmakeHelper.toolbar`: "titleBar").
|
||||||
|
* An extension can't put anything there, so media/titlebar.js is copied next
|
||||||
|
* to VS Code's workbench.html and added to it, and product.json's checksum of
|
||||||
|
* workbench.html is updated (else VS Code says its installation is corrupt).
|
||||||
|
* A VS Code update brings a new workbench.html without it, an update of this
|
||||||
|
* extension a new script: either is noticed on start and offered to patch again.
|
||||||
|
* The installation is usually root's: then the files go in with pkexec.
|
||||||
|
*/
|
||||||
|
import * as crypto from "node:crypto";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import { errorText, exists, run, settings } from "./util";
|
||||||
|
|
||||||
|
const SCRIPT = "cmake-helper-titlebar.js";
|
||||||
|
const HTML = "vs/code/electron-browser/workbench/workbench.html";
|
||||||
|
const MARK = "<!-- CMake Helper: the CMake toolbar in the title bar -->";
|
||||||
|
const LINE = `\t${MARK}<script src="./${SCRIPT}"></script>\n`;
|
||||||
|
|
||||||
|
type State = "absent" | "outdated" | "current" | "unsupported";
|
||||||
|
|
||||||
|
/** A file to put in place (`content`), or to remove. */
|
||||||
|
interface Change {
|
||||||
|
file: string;
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TitleBar {
|
||||||
|
constructor(private readonly context: vscode.ExtensionContext) {}
|
||||||
|
|
||||||
|
private get files() {
|
||||||
|
const out = path.join(vscode.env.appRoot, "out");
|
||||||
|
return { html: path.join(out, HTML), script: path.join(out, path.dirname(HTML), SCRIPT), product: path.join(vscode.env.appRoot, "product.json") };
|
||||||
|
}
|
||||||
|
|
||||||
|
private get own(): string {
|
||||||
|
return fs.readFileSync(path.join(this.context.extensionPath, "media", "titlebar.js"), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
state(): State {
|
||||||
|
// A remote extension host has the server's installation, not the window's; the web has no workbench.html.
|
||||||
|
if (vscode.env.uiKind !== vscode.UIKind.Desktop || vscode.env.remoteName || !exists(this.files.html)) return "unsupported";
|
||||||
|
try {
|
||||||
|
if (!fs.readFileSync(this.files.html, "utf8").includes(MARK)) return "absent";
|
||||||
|
return fs.readFileSync(this.files.script, "utf8") === this.own ? "current" : "outdated";
|
||||||
|
} catch {
|
||||||
|
return "absent";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On start and when the setting changes: what's in VS Code against what the setting asks for. */
|
||||||
|
async reconcile(): Promise<void> {
|
||||||
|
const wanted = settings().get<string>("toolbar") === "titleBar";
|
||||||
|
const state = this.state();
|
||||||
|
if (wanted && state === "unsupported") {
|
||||||
|
void vscode.window.showWarningMessage("CMake's toolbar in the title bar works only in desktop VS Code with a local workspace; here it stays in the status bar.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (wanted && state === "absent") {
|
||||||
|
const patchedFor = this.context.globalState.get<string>("titleBar.vscodeVersion");
|
||||||
|
const message = patchedFor
|
||||||
|
? `VS Code was updated (${patchedFor} → ${vscode.version}) and its title bar lost the CMake toolbar. Patch VS Code again?`
|
||||||
|
: "The CMake toolbar in the title bar needs VS Code's workbench.html patched (you may be asked for your password). Patch it now?";
|
||||||
|
const a = await vscode.window.showInformationMessage(message, "Patch VS Code", "Use Status Bar");
|
||||||
|
if (a === "Patch VS Code") await this.patch();
|
||||||
|
if (a === "Use Status Bar") await settings().update("toolbar", "statusBar", vscode.ConfigurationTarget.Global);
|
||||||
|
} else if (wanted && state === "outdated") {
|
||||||
|
const a = await vscode.window.showInformationMessage("CMake Helper was updated: update its toolbar in VS Code's title bar too?", "Update");
|
||||||
|
if (a) await this.patch();
|
||||||
|
} else if (!wanted && (state === "current" || state === "outdated")) {
|
||||||
|
const a = await vscode.window.showInformationMessage("VS Code still has the CMake toolbar patched into its title bar. Remove it?", "Remove", "Use Title Bar");
|
||||||
|
if (a === "Remove") await this.unpatch();
|
||||||
|
if (a === "Use Title Bar") await settings().update("toolbar", "titleBar", vscode.ConfigurationTarget.Global);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async patch(): Promise<void> {
|
||||||
|
if (this.state() === "unsupported") {
|
||||||
|
void vscode.window.showWarningMessage("Patching the title bar works only in desktop VS Code with a local workspace.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const html = fs.readFileSync(this.files.html, "utf8").replace(LINE, "");
|
||||||
|
const patched = html.replace("</html>", `${LINE}</html>`);
|
||||||
|
if (await this.write([{ file: this.files.script, content: this.own }, ...this.withChecksum(patched)])) {
|
||||||
|
await this.context.globalState.update("titleBar.vscodeVersion", vscode.version);
|
||||||
|
await this.reload("The CMake toolbar is in VS Code's title bar now: reload the window (and VS Code's other windows) to see it.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async unpatch(): Promise<void> {
|
||||||
|
if (this.state() === "unsupported") return;
|
||||||
|
const html = fs.readFileSync(this.files.html, "utf8").replace(LINE, "");
|
||||||
|
if (await this.write([...this.withChecksum(html), { file: this.files.script }])) {
|
||||||
|
await this.context.globalState.update("titleBar.vscodeVersion", undefined);
|
||||||
|
await this.reload("The CMake toolbar is out of VS Code's title bar: reload the window to see it in the status bar again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** workbench.html, and product.json with its checksum (base64 of the SHA-256, unpadded) to match. */
|
||||||
|
private withChecksum(html: string): Change[] {
|
||||||
|
const changes: Change[] = [{ file: this.files.html, content: html }];
|
||||||
|
const product = fs.readFileSync(this.files.product, "utf8");
|
||||||
|
const sum = crypto.createHash("sha256").update(html, "utf8").digest("base64").replace(/=+$/, "");
|
||||||
|
const key = new RegExp(`("${HTML.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}"\\s*:\\s*")[^"]*"`);
|
||||||
|
if (key.test(product)) changes.push({ file: this.files.product, content: product.replace(key, `$1${sum}"`) });
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Straight in if VS Code's files are this user's; else, on Linux, as root with pkexec. */
|
||||||
|
private async write(changes: Change[]): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
for (const c of changes) {
|
||||||
|
if (c.content !== undefined) fs.writeFileSync(c.file, c.content);
|
||||||
|
else fs.rmSync(c.file, { force: true });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as NodeJS.ErrnoException).code;
|
||||||
|
if ((code !== "EACCES" && code !== "EPERM") || process.platform !== "linux") {
|
||||||
|
void vscode.window.showErrorMessage(`Patching VS Code failed: ${errorText(err)}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dir = path.join(this.context.globalStorageUri.fsPath, "titlebar");
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const args: string[] = [];
|
||||||
|
const steps: string[] = [];
|
||||||
|
changes.forEach((c, i) => {
|
||||||
|
if (c.content !== undefined) {
|
||||||
|
const tmp = path.join(dir, String(i));
|
||||||
|
fs.writeFileSync(tmp, c.content);
|
||||||
|
args.push(tmp, c.file);
|
||||||
|
steps.push(`cp "\${${args.length - 1}}" "\${${args.length}}"`);
|
||||||
|
} else {
|
||||||
|
args.push(c.file);
|
||||||
|
steps.push(`rm -f "\${${args.length}}"`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const r = await run("pkexec", ["/bin/sh", "-c", steps.join(" && "), "sh", ...args]);
|
||||||
|
// 126: the password dialog was dismissed.
|
||||||
|
if (r.code === 126) return false;
|
||||||
|
if (r.code !== 0) throw new Error(r.stderr.trim() || `pkexec exited with ${r.code}`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
void vscode.window.showErrorMessage(`Patching VS Code failed: ${errorText(err)}`);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reload(message: string): Promise<void> {
|
||||||
|
const a = await vscode.window.showInformationMessage(message, "Reload Window");
|
||||||
|
if (a) void vscode.commands.executeCommand("workbench.action.reloadWindow");
|
||||||
|
}
|
||||||
|
}
|
||||||
+180
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* Updates from the Gitea releases: the latest release is looked at on start
|
||||||
|
* and every 10 minutes, and a newer one is offered for install (its .vsix).
|
||||||
|
* A private repository needs a token, kept in the secret storage.
|
||||||
|
*/
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import { errorText, settings } from "./util";
|
||||||
|
|
||||||
|
const TOKEN = "giteaToken";
|
||||||
|
const LAST_CHECK = "updates.lastCheck";
|
||||||
|
const SKIPPED = "updates.skipped";
|
||||||
|
const TOKEN_ASKED = "updates.tokenAsked";
|
||||||
|
const INTERVAL = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
interface Release {
|
||||||
|
tag_name: string;
|
||||||
|
name: string;
|
||||||
|
html_url: string;
|
||||||
|
assets: { name: string; browser_download_url: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Latest = { release: Release } | { error: "unauthorized" | string };
|
||||||
|
|
||||||
|
export class Updates implements vscode.Disposable {
|
||||||
|
private readonly timer: NodeJS.Timeout;
|
||||||
|
private checking = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly context: vscode.ExtensionContext,
|
||||||
|
private readonly output: vscode.OutputChannel,
|
||||||
|
) {
|
||||||
|
// Not right on start: the project's loading comes first.
|
||||||
|
const first = setTimeout(() => void this.check(false), 30_000);
|
||||||
|
this.timer = setInterval(() => void this.check(false), INTERVAL);
|
||||||
|
context.subscriptions.push({ dispose: () => clearTimeout(first) });
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get version(): string {
|
||||||
|
return (this.context.extension.packageJSON as { version: string }).version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `manual`: asked for, so always looked at, and "up to date" and errors are said. */
|
||||||
|
async check(manual: boolean): Promise<void> {
|
||||||
|
if (this.checking) return;
|
||||||
|
if (!manual) {
|
||||||
|
if (!settings().get<boolean>("updates.check", true)) return;
|
||||||
|
const last = this.context.globalState.get<number>(LAST_CHECK, 0);
|
||||||
|
if (Date.now() - last < INTERVAL) return;
|
||||||
|
}
|
||||||
|
this.checking = true;
|
||||||
|
try {
|
||||||
|
const latest = await this.latest();
|
||||||
|
if ("error" in latest) return void (await this.failed(latest.error, manual));
|
||||||
|
await this.context.globalState.update(LAST_CHECK, Date.now());
|
||||||
|
const { release } = latest;
|
||||||
|
const version = release.tag_name.replace(/^v/, "");
|
||||||
|
if (compareVersions(version, this.version) <= 0) {
|
||||||
|
if (manual) void vscode.window.showInformationMessage(`CMake Helper ${this.version} is the latest version.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!manual && this.context.globalState.get<string>(SKIPPED) === version) return;
|
||||||
|
await this.offer(release, version);
|
||||||
|
} finally {
|
||||||
|
this.checking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setToken(): Promise<void> {
|
||||||
|
const token = await vscode.window.showInputBox({
|
||||||
|
title: "Gitea Access Token",
|
||||||
|
prompt: `A token with read access to ${repository()} on ${server()} (Settings → Applications). Empty removes it.`,
|
||||||
|
password: true,
|
||||||
|
ignoreFocusOut: true,
|
||||||
|
});
|
||||||
|
if (token === undefined) return;
|
||||||
|
if (token) await this.context.secrets.store(TOKEN, token.trim());
|
||||||
|
else await this.context.secrets.delete(TOKEN);
|
||||||
|
if (token) await this.check(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async headers(): Promise<Record<string, string>> {
|
||||||
|
const token = await this.context.secrets.get(TOKEN);
|
||||||
|
return token ? { Authorization: `token ${token}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async latest(): Promise<Latest> {
|
||||||
|
const url = `${server()}/api/v1/repos/${repository()}/releases/latest`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { headers: { Accept: "application/json", ...(await this.headers()) }, signal: AbortSignal.timeout(15_000) });
|
||||||
|
// A private repository is a 404 to whoever can't see it.
|
||||||
|
if (res.status === 401 || res.status === 403 || (res.status === 404 && !(await this.context.secrets.get(TOKEN)))) return { error: "unauthorized" };
|
||||||
|
if (res.status === 404) return { error: `no release in ${repository()}` };
|
||||||
|
if (!res.ok) return { error: `${url}: HTTP ${res.status}` };
|
||||||
|
return { release: (await res.json()) as Release };
|
||||||
|
} catch (err) {
|
||||||
|
return { error: `${url}: ${errorText(err)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async failed(error: string, manual: boolean): Promise<void> {
|
||||||
|
this.output.appendLine(`[updates] ${error === "unauthorized" ? `${repository()} needs a token` : error}`);
|
||||||
|
if (error !== "unauthorized") {
|
||||||
|
if (manual) void vscode.window.showErrorMessage(`Checking for CMake Helper updates failed: ${error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Asked once on its own; again only when the check was asked for.
|
||||||
|
if (!manual && this.context.globalState.get<boolean>(TOKEN_ASKED)) return;
|
||||||
|
await this.context.globalState.update(TOKEN_ASKED, true);
|
||||||
|
const a = await vscode.window.showWarningMessage(
|
||||||
|
`CMake Helper can't see the releases of ${repository()} to look for updates: it needs a Gitea access token.`,
|
||||||
|
"Set Token",
|
||||||
|
"Don't Check",
|
||||||
|
);
|
||||||
|
if (a === "Set Token") await this.setToken();
|
||||||
|
if (a === "Don't Check") await settings().update("updates.check", false, vscode.ConfigurationTarget.Global);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async offer(release: Release, version: string): Promise<void> {
|
||||||
|
const vsix = release.assets.find((a) => a.name.endsWith(".vsix"));
|
||||||
|
const actions = [vsix ? "Install" : undefined, "Release Notes", "Skip This Version"].filter((a): a is string => !!a);
|
||||||
|
const a = await vscode.window.showInformationMessage(`CMake Helper ${version} is available (this is ${this.version}).`, ...actions);
|
||||||
|
if (a === "Install" && vsix) await this.install(vsix, release);
|
||||||
|
if (a === "Release Notes") void vscode.env.openExternal(vscode.Uri.parse(release.html_url));
|
||||||
|
if (a === "Skip This Version") await this.context.globalState.update(SKIPPED, version);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async install(asset: Release["assets"][number], release: Release): Promise<void> {
|
||||||
|
try {
|
||||||
|
const file = await vscode.window.withProgress(
|
||||||
|
{ location: vscode.ProgressLocation.Notification, title: `Downloading ${asset.name}…` },
|
||||||
|
async () => {
|
||||||
|
const res = await fetch(asset.browser_download_url, { headers: await this.headers(), signal: AbortSignal.timeout(120_000) });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const dir = this.context.globalStorageUri.fsPath;
|
||||||
|
await fs.promises.mkdir(dir, { recursive: true });
|
||||||
|
const file = path.join(dir, asset.name);
|
||||||
|
await fs.promises.writeFile(file, Buffer.from(await res.arrayBuffer()));
|
||||||
|
return file;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await vscode.commands.executeCommand("workbench.extensions.installExtension", vscode.Uri.file(file));
|
||||||
|
const a = await vscode.window.showInformationMessage(`CMake Helper ${release.tag_name.replace(/^v/, "")} is installed. Reload the window to use it.`, "Reload Window");
|
||||||
|
if (a === "Reload Window") void vscode.commands.executeCommand("workbench.action.reloadWindow");
|
||||||
|
} catch (err) {
|
||||||
|
this.output.appendLine(`[updates] installing ${asset.browser_download_url} failed: ${errorText(err)}`);
|
||||||
|
const a = await vscode.window.showErrorMessage(`Installing the update failed: ${errorText(err)}`, "Download in Browser");
|
||||||
|
if (a) void vscode.env.openExternal(vscode.Uri.parse(asset.browser_download_url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function server(): string {
|
||||||
|
return (settings().get<string>("updates.server") || "https://git.cernikovi.cz").replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function repository(): string {
|
||||||
|
return settings().get<string>("updates.repository") || "shin/VS-Code-CMake-helper";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "1.10.0" > "1.9.2"; a pre-release ("1.0.0-rc.1") is before its release. */
|
||||||
|
export function compareVersions(a: string, b: string): number {
|
||||||
|
const [ma, pa] = a.split("-", 2);
|
||||||
|
const [mb, pb] = b.split("-", 2);
|
||||||
|
const na = ma!.split(".").map((n) => parseInt(n, 10) || 0);
|
||||||
|
const nb = mb!.split(".").map((n) => parseInt(n, 10) || 0);
|
||||||
|
for (let i = 0; i < Math.max(na.length, nb.length); i++) {
|
||||||
|
const d = (na[i] ?? 0) - (nb[i] ?? 0);
|
||||||
|
if (d) return d;
|
||||||
|
}
|
||||||
|
if (pa === pb) return 0;
|
||||||
|
if (pa === undefined) return 1;
|
||||||
|
if (pb === undefined) return -1;
|
||||||
|
return pa < pb ? -1 : 1;
|
||||||
|
}
|
||||||
+181
@@ -0,0 +1,181 @@
|
|||||||
|
import * as cp from "node:child_process";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
|
||||||
|
export const isWindows = process.platform === "win32";
|
||||||
|
|
||||||
|
/** The extension's settings (`cmakeHelper.*`). */
|
||||||
|
export function settings(): vscode.WorkspaceConfiguration {
|
||||||
|
return vscode.workspace.getConfiguration("cmakeHelper");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exists(p: string): boolean {
|
||||||
|
try {
|
||||||
|
fs.accessSync(p);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readJson<T = any>(file: string): T | undefined {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(file, "utf8")) as T;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paths compared the way the file system does (case-insensitive on Windows). */
|
||||||
|
export function samePath(a: string, b: string): boolean {
|
||||||
|
const na = path.normalize(a);
|
||||||
|
const nb = path.normalize(b);
|
||||||
|
return isWindows ? na.toLowerCase() === nb.toLowerCase() : na === nb;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pathKey(p: string): string {
|
||||||
|
const n = path.normalize(p);
|
||||||
|
return isWindows ? n.toLowerCase() : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits a command line the way a POSIX shell would (quotes and backslashes), without expanding anything. */
|
||||||
|
export function splitArgs(line: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
let cur = "";
|
||||||
|
let has = false;
|
||||||
|
let quote: '"' | "'" | null = null;
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const c = line[i]!;
|
||||||
|
if (quote) {
|
||||||
|
if (c === quote) quote = null;
|
||||||
|
else if (c === "\\" && quote === '"' && i + 1 < line.length && '"\\$`'.includes(line[i + 1]!)) cur += line[++i];
|
||||||
|
else cur += c;
|
||||||
|
} else if (c === '"' || c === "'") {
|
||||||
|
quote = c;
|
||||||
|
has = true;
|
||||||
|
} else if (c === "\\" && !isWindows && i + 1 < line.length) {
|
||||||
|
cur += line[++i];
|
||||||
|
has = true;
|
||||||
|
} else if (/\s/.test(c)) {
|
||||||
|
if (has || cur) out.push(cur);
|
||||||
|
cur = "";
|
||||||
|
has = false;
|
||||||
|
} else {
|
||||||
|
cur += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (has || cur) out.push(cur);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "A=1;B=two" (CLion's notation) to an object, and back. */
|
||||||
|
export function parseEnv(text: string): Record<string, string> {
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
for (const part of text.split(/;(?=\s*[A-Za-z_][\w.]*=)/)) {
|
||||||
|
const i = part.indexOf("=");
|
||||||
|
if (i > 0) env[part.slice(0, i).trim()] = part.slice(i + 1);
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatEnv(env: Record<string, string> | undefined): string {
|
||||||
|
return Object.entries(env ?? {}).map(([k, v]) => `${k}=${v}`).join(";");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Vars {
|
||||||
|
workspaceFolder?: string;
|
||||||
|
sourceDir?: string;
|
||||||
|
buildDir?: string;
|
||||||
|
profile?: string;
|
||||||
|
buildType?: string;
|
||||||
|
targetName?: string;
|
||||||
|
targetDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ${workspaceFolder}, ${sourceDir}, ${buildDir}, ${profile}, ${buildType}, ${targetName}, ${targetDir}, ${userHome}, ${env:X}. */
|
||||||
|
export function expand(s: string, vars: Vars): string {
|
||||||
|
return s.replace(/\$\{([^}]+)\}/g, (all, name: string) => {
|
||||||
|
if (name.startsWith("env:")) return process.env[name.slice(4)] ?? "";
|
||||||
|
if (name === "userHome") return os.homedir();
|
||||||
|
if (name === "workspaceFolderBasename") return vars.workspaceFolder ? path.basename(vars.workspaceFolder) : all;
|
||||||
|
const v = (vars as Record<string, string | undefined>)[name];
|
||||||
|
return v ?? all;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunResult {
|
||||||
|
code: number | null;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a program to its end; `onOutput` sees the output as it comes. */
|
||||||
|
export function run(
|
||||||
|
program: string,
|
||||||
|
args: string[],
|
||||||
|
opts: { cwd?: string; env?: NodeJS.ProcessEnv; onOutput?: (text: string) => void; token?: vscode.CancellationToken } = {},
|
||||||
|
): Promise<RunResult> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let child: cp.ChildProcess;
|
||||||
|
try {
|
||||||
|
child = cp.spawn(program, args, { cwd: opts.cwd, env: opts.env ?? process.env, windowsHide: true });
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
const sub = opts.token?.onCancellationRequested(() => killTree(child));
|
||||||
|
child.stdout?.setEncoding("utf8").on("data", (d: string) => {
|
||||||
|
stdout += d;
|
||||||
|
opts.onOutput?.(d);
|
||||||
|
});
|
||||||
|
child.stderr?.setEncoding("utf8").on("data", (d: string) => {
|
||||||
|
stderr += d;
|
||||||
|
opts.onOutput?.(d);
|
||||||
|
});
|
||||||
|
child.on("error", (err) => {
|
||||||
|
sub?.dispose();
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
child.on("close", (code) => {
|
||||||
|
sub?.dispose();
|
||||||
|
resolve({ code, stdout, stderr });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function killTree(child: cp.ChildProcess): void {
|
||||||
|
if (child.pid === undefined) return;
|
||||||
|
if (isWindows) cp.spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true });
|
||||||
|
else child.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a program can be found on PATH. */
|
||||||
|
export function onPath(program: string): boolean {
|
||||||
|
const exts = isWindows ? (process.env.PATHEXT ?? ".EXE").split(";") : [""];
|
||||||
|
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
||||||
|
if (!dir) continue;
|
||||||
|
for (const ext of exts) if (exists(path.join(dir, program + ext))) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function debounce(fn: () => void, ms: number): () => void {
|
||||||
|
let timer: NodeJS.Timeout | undefined;
|
||||||
|
return () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(fn, ms);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escapes a string for use in a regular expression (CTest's -R takes one). */
|
||||||
|
export function regexEscape(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorText(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* The CMake view container: Targets (executables, libraries and the rest,
|
||||||
|
* each with its sources), and Profiles.
|
||||||
|
*/
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import { type Target, type TargetType, isLibrary } from "./fileApi";
|
||||||
|
import type { CMakeProject } from "./project";
|
||||||
|
import { programOf } from "./launcher";
|
||||||
|
|
||||||
|
const TYPE_LABEL: Record<TargetType, string> = {
|
||||||
|
EXECUTABLE: "executable",
|
||||||
|
STATIC_LIBRARY: "static library",
|
||||||
|
SHARED_LIBRARY: "shared library",
|
||||||
|
MODULE_LIBRARY: "module library",
|
||||||
|
OBJECT_LIBRARY: "object library",
|
||||||
|
INTERFACE_LIBRARY: "interface library",
|
||||||
|
UTILITY: "utility",
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TargetNode =
|
||||||
|
| { kind: "group"; label: string; targets: Target[]; icon: string }
|
||||||
|
| { kind: "target"; target: Target }
|
||||||
|
| { kind: "sourceGroup"; target: Target; label: string; files: string[] }
|
||||||
|
| { kind: "source"; target: Target; file: string; generated: boolean };
|
||||||
|
|
||||||
|
export class TargetsProvider implements vscode.TreeDataProvider<TargetNode> {
|
||||||
|
private readonly changed = new vscode.EventEmitter<TargetNode | undefined>();
|
||||||
|
readonly onDidChangeTreeData = this.changed.event;
|
||||||
|
|
||||||
|
constructor(private readonly project: () => CMakeProject | undefined) {}
|
||||||
|
|
||||||
|
refresh(): void {
|
||||||
|
this.changed.fire(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
getChildren(node?: TargetNode): TargetNode[] {
|
||||||
|
const p = this.project();
|
||||||
|
if (!p?.model) return [];
|
||||||
|
if (!node) {
|
||||||
|
const targets = p.visibleTargets();
|
||||||
|
const groups: TargetNode[] = [];
|
||||||
|
const add = (label: string, icon: string, list: Target[]) => {
|
||||||
|
if (list.length) groups.push({ kind: "group", label, icon, targets: list });
|
||||||
|
};
|
||||||
|
add("Executables", "play-circle", targets.filter((t) => t.type === "EXECUTABLE"));
|
||||||
|
add("Libraries", "library", targets.filter(isLibrary));
|
||||||
|
add("Utility", "gear", targets.filter((t) => t.type === "UTILITY"));
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
if (node.kind === "group") return node.targets.map((target) => ({ kind: "target", target }));
|
||||||
|
if (node.kind === "target") {
|
||||||
|
const t = node.target;
|
||||||
|
const groups = new Map<string, string[]>();
|
||||||
|
for (const s of t.sources) {
|
||||||
|
const list = groups.get(s.group) ?? [];
|
||||||
|
list.push(s.path);
|
||||||
|
groups.set(s.group, list);
|
||||||
|
}
|
||||||
|
// One group (CMake's own "Source Files") isn't worth a level.
|
||||||
|
if (groups.size <= 1) return sources(t, t.sources.map((s) => s.path));
|
||||||
|
return [...groups].map(([label, files]) => ({ kind: "sourceGroup", target: t, label: label || "Other", files }));
|
||||||
|
}
|
||||||
|
if (node.kind === "sourceGroup") return sources(node.target, node.files);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getTreeItem(node: TargetNode): vscode.TreeItem {
|
||||||
|
const p = this.project();
|
||||||
|
switch (node.kind) {
|
||||||
|
case "group": {
|
||||||
|
const item = new vscode.TreeItem(node.label, vscode.TreeItemCollapsibleState.Expanded);
|
||||||
|
item.iconPath = new vscode.ThemeIcon(node.icon);
|
||||||
|
item.description = String(node.targets.length);
|
||||||
|
item.contextValue = "group";
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
case "target": {
|
||||||
|
const t = node.target;
|
||||||
|
const active = p?.activeTarget?.name === t.name;
|
||||||
|
const item = new vscode.TreeItem(t.name, t.sources.length ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None);
|
||||||
|
item.id = `target:${t.id}`;
|
||||||
|
item.description = [active ? "● active" : "", t.type === "EXECUTABLE" ? "" : TYPE_LABEL[t.type]].filter(Boolean).join(" ");
|
||||||
|
item.iconPath = new vscode.ThemeIcon(t.type === "EXECUTABLE" ? "terminal" : t.type === "UTILITY" ? "gear" : "package", active ? new vscode.ThemeColor("charts.green") : undefined);
|
||||||
|
// ".run": a custom target or a library with an executable set in its run configuration.
|
||||||
|
item.contextValue = (t.type === "EXECUTABLE" ? "target.executable" : t.type === "UTILITY" ? "target.utility" : "target.library") + (t.type !== "EXECUTABLE" && p && programOf(p, t) ? ".run" : "");
|
||||||
|
const md = new vscode.MarkdownString(`**${t.name}** — ${TYPE_LABEL[t.type]}`);
|
||||||
|
if (t.artifacts.length) md.appendMarkdown(`\n\n${t.artifacts.map((a) => `\`${a}\``).join(" \n")}`);
|
||||||
|
if (t.definition) md.appendMarkdown(`\n\nDefined in \`${path.relative(p?.sourceDir ?? "", t.definition.file)}:${t.definition.line}\``);
|
||||||
|
item.tooltip = md;
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
case "sourceGroup": {
|
||||||
|
const item = new vscode.TreeItem(node.label, vscode.TreeItemCollapsibleState.Collapsed);
|
||||||
|
item.id = `group:${node.target.id}:${node.label}`;
|
||||||
|
item.iconPath = vscode.ThemeIcon.Folder;
|
||||||
|
item.description = String(node.files.length);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
case "source": {
|
||||||
|
const uri = vscode.Uri.file(node.file);
|
||||||
|
const item = new vscode.TreeItem(uri, vscode.TreeItemCollapsibleState.None);
|
||||||
|
item.id = `source:${node.target.id}:${node.file}`;
|
||||||
|
const rel = path.relative(node.target.sourceDir, node.file);
|
||||||
|
item.label = rel.startsWith("..") ? path.relative(p?.sourceDir ?? "", node.file) : rel;
|
||||||
|
item.description = node.generated ? "generated" : undefined;
|
||||||
|
item.command = { command: "vscode.open", title: "Open", arguments: [uri] };
|
||||||
|
item.contextValue = "source";
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sources(target: Target, files: string[]): TargetNode[] {
|
||||||
|
const generated = new Set(target.sources.filter((s) => s.generated).map((s) => s.path));
|
||||||
|
return [...files]
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.map((file) => ({ kind: "source", target, file, generated: generated.has(file) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileNode {
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProfilesProvider implements vscode.TreeDataProvider<ProfileNode> {
|
||||||
|
private readonly changed = new vscode.EventEmitter<ProfileNode | undefined>();
|
||||||
|
readonly onDidChangeTreeData = this.changed.event;
|
||||||
|
|
||||||
|
constructor(private readonly project: () => CMakeProject | undefined) {}
|
||||||
|
|
||||||
|
refresh(): void {
|
||||||
|
this.changed.fire(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
getChildren(node?: ProfileNode): ProfileNode[] {
|
||||||
|
if (node) return [];
|
||||||
|
return this.project()?.profiles.map((p) => ({ key: p.key })) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getTreeItem(node: ProfileNode): vscode.TreeItem {
|
||||||
|
const project = this.project()!;
|
||||||
|
const p = project.profiles.find((x) => x.key === node.key)!;
|
||||||
|
const active = project.activeProfile.key === p.key;
|
||||||
|
const item = new vscode.TreeItem(p.name);
|
||||||
|
item.id = p.key;
|
||||||
|
item.description = [p.buildType, path.relative(project.sourceDir, p.buildDir), p.source === "preset" ? "preset" : ""].filter(Boolean).join(" · ");
|
||||||
|
item.iconPath = new vscode.ThemeIcon(active ? "pass-filled" : "circle-large-outline", active ? new vscode.ThemeColor("charts.green") : undefined);
|
||||||
|
item.tooltip = new vscode.MarkdownString(
|
||||||
|
[`**${p.name}**${p.description ? ` — ${p.description}` : ""}`, `Build type: ${p.buildType}`, `Build directory: \`${p.buildDir}\``, p.generator ? `Generator: ${p.generator}` : "", `\`cmake ${p.configureArgs.join(" ")}\``]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n"),
|
||||||
|
);
|
||||||
|
item.command = { command: "cmakeHelper.selectProfile", title: "Select", arguments: [p.key] };
|
||||||
|
item.contextValue = p.source === "preset" ? "profile.preset" : p.key.startsWith("default:") ? "profile.default" : "profile.settings";
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"module": "commonjs",
|
||||||
|
"types": ["node"],
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "out",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user