Skip to content
This repository was archived by the owner on Nov 25, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
}