diff --git a/README.md b/README.md index b156988..6a6e371 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package.json b/package.json index c28375b..9f32a8d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/extension.ts b/src/extension.ts index a503e78..07667ed 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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"; @@ -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); }); } diff --git a/src/zigMainCodeLens.ts b/src/zigMainCodeLens.ts new file mode 100644 index 0000000..0aba94f --- /dev/null +++ b/src/zigMainCodeLens.ts @@ -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 { + 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 { + // 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 { + 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; +} diff --git a/src/zigTestRunnerProvider.ts b/src/zigTestRunnerProvider.ts new file mode 100644 index 0000000..d1eb8c6 --- /dev/null +++ b/src/zigTestRunnerProvider.ts @@ -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 { + 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]; +} diff --git a/src/zigUtil.ts b/src/zigUtil.ts index ad5916c..8d5ce53 100644 --- a/src/zigUtil.ts +++ b/src/zigUtil.ts @@ -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); +}