Skip to content

Commit

Permalink
Merge pull request #242 from amantuladhar/master
Browse files Browse the repository at this point in the history
Run and Debug zig program and tests
  • Loading branch information
Vexu authored Nov 20, 2024
2 parents 133b167 + b945cdc commit 6e5e984
Show file tree
Hide file tree
Showing 6 changed files with 354 additions and 0 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
- syntax highlighting
- basic compiler linting
- automatic formatting
- Run/Debug zig program
- Run/Debug tests
- optional [Zig Language Server](https://github.com/zigtools/zls) features
- completions
- goto definition/declaration
Expand Down
12 changes: 12 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,18 @@
}
},
"commands": [
{
"command": "zig.run",
"title": "Run Zig",
"category": "Zig",
"description": "Run the current Zig project / file"
},
{
"command": "zig.debug",
"title": "Debug Zig",
"category": "Zig",
"description": "Debug the current Zig project / file"
},
{
"command": "zig.build.workspace",
"title": "Build Workspace",
Expand Down
13 changes: 13 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import vscode from "vscode";

import { activate as activateZls, deactivate as deactivateZls } from "./zls";
import ZigCompilerProvider from "./zigCompilerProvider";
import ZigMainCodeLensProvider from "./zigMainCodeLens";
import ZigTestRunnerProvider from "./zigTestRunnerProvider";
import { registerDocumentFormatting } from "./zigFormat";
import { setupZig } from "./zigSetup";

Expand All @@ -12,6 +14,17 @@ export async function activate(context: vscode.ExtensionContext) {

context.subscriptions.push(registerDocumentFormatting());

const testRunner = new ZigTestRunnerProvider();
testRunner.activate(context.subscriptions);

ZigMainCodeLensProvider.registerCommands(context);
context.subscriptions.push(
vscode.languages.registerCodeLensProvider(
{ language: "zig", scheme: "file" },
new ZigMainCodeLensProvider(),
),
);

void activateZls(context);
});
}
Expand Down
107 changes: 107 additions & 0 deletions src/zigMainCodeLens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import vscode from "vscode";

import childProcess from "child_process";
import fs from "fs";
import path from "path";
import util from "util";

import { getWorkspaceFolder, getZigPath, isWorkspaceFile } from "./zigUtil";

const execFile = util.promisify(childProcess.execFile);

export default class ZigMainCodeLensProvider implements vscode.CodeLensProvider {
public provideCodeLenses(document: vscode.TextDocument): vscode.ProviderResult<vscode.CodeLens[]> {
const codeLenses: vscode.CodeLens[] = [];
const text = document.getText();

const mainRegex = /pub\s+fn\s+main\s*\(/g;
let match;
while ((match = mainRegex.exec(text))) {
const position = document.positionAt(match.index);
const range = new vscode.Range(position, position);
codeLenses.push(
new vscode.CodeLens(range, { title: "Run", command: "zig.run", arguments: [document.uri.fsPath] }),
);
codeLenses.push(
new vscode.CodeLens(range, { title: "Debug", command: "zig.debug", arguments: [document.uri.fsPath] }),
);
}
return codeLenses;
}

public static registerCommands(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.commands.registerCommand("zig.run", zigRun),
vscode.commands.registerCommand("zig.debug", zigDebug),
);
}
}

function zigRun() {
if (!vscode.window.activeTextEditor) return;
const filePath = vscode.window.activeTextEditor.document.uri.fsPath;
const terminal = vscode.window.createTerminal("Run Zig Program");
terminal.show();
const wsFolder = getWorkspaceFolder(filePath);
if (wsFolder && isWorkspaceFile(filePath) && hasBuildFile(wsFolder.uri.fsPath)) {
terminal.sendText(`${getZigPath()} build run`);
return;
}
terminal.sendText(`${getZigPath()} run "${filePath}"`);
}

function hasBuildFile(workspaceFspath: string): boolean {
const buildZigPath = path.join(workspaceFspath, "build.zig");
return fs.existsSync(buildZigPath);
}

async function zigDebug() {
if (!vscode.window.activeTextEditor) return;
const filePath = vscode.window.activeTextEditor.document.uri.fsPath;
try {
const workspaceFolder = getWorkspaceFolder(filePath);
let binaryPath = "";
if (workspaceFolder && isWorkspaceFile(filePath) && hasBuildFile(workspaceFolder.uri.fsPath)) {
binaryPath = await buildDebugBinaryWithBuildFile(workspaceFolder.uri.fsPath);
} else {
binaryPath = await buildDebugBinary(filePath);
}

const debugConfig: vscode.DebugConfiguration = {
type: "lldb",
name: `Debug Zig`,
request: "launch",
program: binaryPath,
cwd: path.dirname(workspaceFolder?.uri.fsPath ?? path.dirname(filePath)),
stopAtEntry: false,
};
await vscode.debug.startDebugging(undefined, debugConfig);
} catch (e) {
void vscode.window.showErrorMessage(`Failed to build debug binary: ${(e as Error).message}`);
}
}

async function buildDebugBinaryWithBuildFile(workspacePath: string): Promise<string> {
// Workaround because zig build doesn't support specifying the output binary name
// `zig run` does support -femit-bin, but preferring `zig build` if possible
const outputDir = path.join(workspacePath, "zig-out", "tmp-debug-build");
const zigPath = getZigPath();
await execFile(zigPath, ["build", "--prefix", outputDir], { cwd: workspacePath });
const dirFiles = await vscode.workspace.fs.readDirectory(vscode.Uri.file(path.join(outputDir, "bin")));
const files = dirFiles.find(([, type]) => type === vscode.FileType.File);
if (!files) {
throw new Error("Unable to build debug binary");
}
return path.join(outputDir, "bin", files[0]);
}

async function buildDebugBinary(filePath: string): Promise<string> {
const zigPath = getZigPath();
const fileDirectory = path.dirname(filePath);
const binaryName = `debug-${path.basename(filePath, ".zig")}`;
const binaryPath = path.join(fileDirectory, "zig-out", "bin", binaryName);
void vscode.workspace.fs.createDirectory(vscode.Uri.file(path.dirname(binaryPath)));

await execFile(zigPath, ["run", filePath, `-femit-bin=${binaryPath}`], { cwd: fileDirectory });
return binaryPath;
}
206 changes: 206 additions & 0 deletions src/zigTestRunnerProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import vscode from "vscode";

import childProcess from "child_process";
import path from "path";
import util from "util";

import { DebouncedFunc, throttle } from "lodash-es";

import { getWorkspaceFolder, getZigPath, isWorkspaceFile } from "./zigUtil";

const execFile = util.promisify(childProcess.execFile);

export default class ZigTestRunnerProvider {
private testController: vscode.TestController;
private updateTestItems: DebouncedFunc<(document: vscode.TextDocument) => void>;

constructor() {
this.updateTestItems = throttle(
(document: vscode.TextDocument) => {
this._updateTestItems(document);
},
500,
{ trailing: true },
);

this.testController = vscode.tests.createTestController("zigTestController", "Zig Tests");
this.testController.createRunProfile("Run", vscode.TestRunProfileKind.Run, this.runTests.bind(this), true);
this.testController.createRunProfile(
"Debug",
vscode.TestRunProfileKind.Debug,
this.debugTests.bind(this),
false,
);
void this.findAndRegisterTests();
}

public activate(subscriptions: vscode.Disposable[]) {
subscriptions.push(
vscode.workspace.onDidOpenTextDocument((document) => {
this.updateTestItems(document);
}),
vscode.workspace.onDidCloseTextDocument((document) => {
!isWorkspaceFile(document.uri.fsPath) && this.deleteTestForAFile(document.uri);
}),
vscode.workspace.onDidChangeTextDocument((change) => {
this.updateTestItems(change.document);
}),
vscode.workspace.onDidDeleteFiles((event) => {
event.files.forEach((file) => {
this.deleteTestForAFile(file);
});
}),
vscode.workspace.onDidRenameFiles((event) => {
event.files.forEach((file) => {
this.deleteTestForAFile(file.oldUri);
});
}),
);
}

private deleteTestForAFile(uri: vscode.Uri) {
this.testController.items.forEach((item) => {
if (!item.uri) return;
if (item.uri.fsPath === uri.fsPath) {
this.testController.items.delete(item.id);
}
});
}

private async findAndRegisterTests() {
const files = await vscode.workspace.findFiles("**/*.zig");
for (const file of files) {
try {
const doc = await vscode.workspace.openTextDocument(file);
this._updateTestItems(doc);
} catch {}
}
}

private _updateTestItems(textDocument: vscode.TextDocument) {
if (textDocument.languageId !== "zig") return;

const regex = /\btest\s+(?:"([^"]+)"|([^\s{]+))\s*\{/g;
const matches = Array.from(textDocument.getText().matchAll(regex));
this.deleteTestForAFile(textDocument.uri);

for (const match of matches) {
const testDesc = match[1] || match[2];
const isDocTest = !!match[2];
const position = textDocument.positionAt(match.index);
const range = new vscode.Range(position, position.translate(0, match[0].length));
const fileName = path.basename(textDocument.uri.fsPath);

// Add doctest prefix to handle scenario where test name matches one with non doctest. E.g `test foo` and `test "foo"`
const testItem = this.testController.createTestItem(
`${fileName}.test.${isDocTest ? "doctest." : ""}${testDesc}`, // Test id needs to be unique, so adding file name prefix
`${fileName} - ${testDesc}`,
textDocument.uri,
);
testItem.range = range;
this.testController.items.add(testItem);
}
}

private async runTests(request: vscode.TestRunRequest, token: vscode.CancellationToken) {
const run = this.testController.createTestRun(request);
// request.include will have individual test when we run test from gutter icon
// if test is run from test explorer, request.include will be undefined and we run all tests that are active
for (const item of request.include ?? this.testController.items) {
if (token.isCancellationRequested) break;
const testItem = Array.isArray(item) ? item[1] : item;

run.started(testItem);
const start = new Date();
run.appendOutput(`[${start.toISOString()}] Running test: ${testItem.label}\r\n`);
const { output, success } = await this.runTest(testItem);
run.appendOutput(output.replaceAll("\n", "\r\n"));
run.appendOutput("\r\n");
const elapsed = new Date().getMilliseconds() - start.getMilliseconds();

if (!success) {
run.failed(testItem, new vscode.TestMessage(output), elapsed);
} else {
run.passed(testItem, elapsed);
}
}
run.end();
}

private async runTest(test: vscode.TestItem): Promise<{ output: string; success: boolean }> {
const zigPath = getZigPath();
if (test.uri === undefined) {
return { output: "Unable to determine file location", success: false };
}
const parts = test.id.split(".");
const lastPart = parts[parts.length - 1];
const args = ["test", "--test-filter", lastPart, test.uri.fsPath];
try {
const { stderr: output } = await execFile(zigPath, args);
return { output: output.replaceAll("\n", "\r\n"), success: true };
} catch (e) {
return { output: (e as Error).message.replaceAll("\n", "\r\n"), success: false };
}
}

private async debugTests(req: vscode.TestRunRequest, token: vscode.CancellationToken) {
const run = this.testController.createTestRun(req);
for (const item of req.include ?? this.testController.items) {
if (token.isCancellationRequested) break;
const test = Array.isArray(item) ? item[1] : item;
run.started(test);
try {
await this.debugTest(run, test);
run.passed(test);
} catch (e) {
run.failed(test, new vscode.TestMessage((e as Error).message));
}
}
run.end();
}

private async debugTest(run: vscode.TestRun, testItem: vscode.TestItem) {
if (testItem.uri === undefined) {
throw new Error("Unable to determine file location");
}
const testBinaryPath = await this.buildTestBinary(run, testItem.uri.fsPath, getTestDesc(testItem));
const debugConfig: vscode.DebugConfiguration = {
type: "lldb",
name: `Debug ${testItem.label}`,
request: "launch",
program: testBinaryPath,
cwd: path.dirname(testItem.uri.fsPath),
stopAtEntry: false,
};
await vscode.debug.startDebugging(undefined, debugConfig);
}

private async buildTestBinary(run: vscode.TestRun, testFilePath: string, testDesc: string): Promise<string> {
const wsFolder = getWorkspaceFolder(testFilePath)?.uri.fsPath ?? path.dirname(testFilePath);
const outputDir = path.join(wsFolder, "zig-out", "tmp-debug-build", "bin");
const binaryName = `test-${path.basename(testFilePath, ".zig")}`;
const binaryPath = path.join(outputDir, binaryName);
await vscode.workspace.fs.createDirectory(vscode.Uri.file(outputDir));

const zigPath = getZigPath();
const { stdout, stderr } = await execFile(zigPath, [
"test",
testFilePath,
"--test-filter",
testDesc,
"--test-no-exec",
`-femit-bin=${binaryPath}`,
]);
if (stderr) {
run.appendOutput(stderr.replaceAll("\n", "\r\n"));
throw new Error(`Failed to build test binary: ${stderr}`);
}
run.appendOutput(stdout.replaceAll("\n", "\r\n"));
return binaryPath;
}
}

function getTestDesc(testItem: vscode.TestItem): string {
const parts = testItem.id.split(".");
return parts[parts.length - 1];
}
14 changes: 14 additions & 0 deletions src/zigUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,17 @@ export async function downloadAndExtractArtifact(
},
);
}

export function getWorkspaceFolder(filePath: string): vscode.WorkspaceFolder | undefined {
const workspaceFolder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(filePath));
if (!workspaceFolder && vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) {
return vscode.workspace.workspaceFolders[0];
}
return workspaceFolder;
}

export function isWorkspaceFile(filePath: string): boolean {
const wsFolder = getWorkspaceFolder(filePath);
if (!wsFolder) return false;
return filePath.startsWith(wsFolder.uri.fsPath);
}

0 comments on commit 6e5e984

Please sign in to comment.