-
Notifications
You must be signed in to change notification settings - Fork 62
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #242 from amantuladhar/master
Run and Debug zig program and tests
- Loading branch information
Showing
6 changed files
with
354 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters