diff --git a/package.json b/package.json index 38d12c7..9389f19 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,9 @@ "Programming Languages" ], "activationEvents": [ - "onLanguage:noir" + "onLanguage:noir", + "onStartupFinished", + "onDebug" ], "main": "./out/extension", "contributes": { @@ -86,6 +88,76 @@ "mac": "shift+alt+cmd+n p" } ], + "breakpoints": [ + { + "language": "noir" + } + ], + "debuggers": [ + { + "type": "noir", + "languages": [ + "noir" + ], + "label": "Noir Debugging", + "configurationAttributes": { + "launch": { + "required": [ + "projectFolder" + ], + "properties": { + "projectFolder": { + "type": "string", + "description": "Absolute path to the Nargo project directory.", + "default": "${workspaceFolder}" + }, + "package": { + "type": "string", + "description": "Optional name of the binary package to debug", + "default": null + }, + "proverName": { + "type": "string", + "description": "Name of the prover input to use (default Prover)", + "default": "Prover" + }, + "generateAcir": { + "type": "boolean", + "description": "If true, generate ACIR opcodes instead of Brillig which will be closer to release binaries but less convenient for debugging", + "default": false + }, + "skipInstrumentation": { + "type": "boolean", + "description": "Skips variables debugging instrumentation of code, making debugging less convenient but the resulting binary smaller and closer to production", + "default": false + } + } + } + }, + "initialConfigurations": [ + { + "type": "noir", + "request": "launch", + "name": "Noir binary package", + "projectFolder": "${workspaceFolder}", + "proverName": "Prover" + } + ], + "configurationSnippets": [ + { + "label": "Noir Debug: Prove package", + "description": "A new configuration for debugging a Noir binary package", + "body": { + "type": "noir", + "request": "launch", + "name": "Noir binary package", + "projectFolder": "${workspaceFolder}", + "proverName": "Prover" + } + } + ] + } + ], "configuration": { "type": "object", "title": "Noir Language Server configuration", diff --git a/src/debugger.ts b/src/debugger.ts new file mode 100644 index 0000000..a557e53 --- /dev/null +++ b/src/debugger.ts @@ -0,0 +1,171 @@ +import { + debug, + window, + workspace, + DebugAdapterDescriptorFactory, + DebugSession, + DebugAdapterExecutable, + DebugAdapterDescriptor, + ExtensionContext, + OutputChannel, + DebugConfigurationProvider, + CancellationToken, + DebugConfiguration, + ProviderResult, +} from 'vscode'; + +import { spawn } from 'child_process'; +import findNargo from './find-nargo'; +import findNearestPackageFrom from './find-nearest-package'; + +let outputChannel: OutputChannel; + +export function activateDebugger(context: ExtensionContext) { + outputChannel = window.createOutputChannel('NoirDebugger'); + + context.subscriptions.push( + debug.registerDebugAdapterDescriptorFactory('noir', new NoirDebugAdapterDescriptorFactory()), + debug.registerDebugConfigurationProvider('noir', new NoirDebugConfigurationProvider()), + debug.onDidTerminateDebugSession(() => { + outputChannel.appendLine(`Debug session ended.`); + }), + ); +} + +export class NoirDebugAdapterDescriptorFactory implements DebugAdapterDescriptorFactory { + async createDebugAdapterDescriptor( + _session: DebugSession, + _executable: DebugAdapterExecutable, + ): ProviderResult { + const config = workspace.getConfiguration('noir'); + + const configuredNargoPath = config.get('nargoPath'); + const nargoPath = configuredNargoPath || findNargo(); + + return new DebugAdapterExecutable(nargoPath, ['dap']); + } +} + +class NoirDebugConfigurationProvider implements DebugConfigurationProvider { + async resolveDebugConfiguration( + _folder: WorkspaceFolder | undefined, + config: DebugConfiguration, + _token?: CancellationToken, + ): ProviderResult { + if ( + (!config.projectFolder || config.projectFolder === ``) && + window.activeTextEditor?.document.languageId != 'noir' + ) + return window.showInformationMessage(`Select a Noir file to debug`); + + const currentFilePath = window.activeTextEditor.document.uri.fsPath; + const projectFolder = + config.projectFolder && config.projectFolder !== `` + ? config.projectFolder + : findNearestPackageFrom(currentFilePath, outputChannel); + + const resolvedConfig = { + type: config.type || 'noir', + name: config.name || 'Noir binary package', + request: 'launch', + program: currentFilePath, + projectFolder, + package: config.package || ``, + proverName: config.proverName || `Prover`, + generateAcir: config.generateAcir || false, + skipInstrumentation: config.skipInstrumentation || false, + }; + + return resolvedConfig; + } + + async resolveDebugConfigurationWithSubstitutedVariables( + _folder: WorkspaceFolder | undefined, + config: DebugConfiguration, + _token?: CancellationToken, + ): ProviderResult { + const workspaceConfig = workspace.getConfiguration('noir'); + const nargoPath = workspaceConfig.get('nargoPath') || findNargo(); + + outputChannel.clear(); + + outputChannel.appendLine(`Using nargo at ${nargoPath}`); + outputChannel.appendLine(`Compiling Noir project...`); + outputChannel.appendLine(``); + + // Run Nargo's DAP in "pre-flight mode", which test runs + // the DAP initialization code without actually starting the DAP server. + // This lets us gracefully handle errors that happen *before* + // the DAP loop is established, which otherwise are considered + // "out of band". + // This was necessary due to the VS Code project being reluctant to let extension authors capture + // stderr output generated by a DAP server wrapped in DebugAdapterExecutable. + // + // More details here: https://github.com/microsoft/vscode/issues/108138 + const preflightArgs = [ + 'dap', + '--preflight-check', + '--preflight-project-folder', + config.projectFolder, + '--preflight-prover-name', + config.proverName, + ]; + + if (config.package !== ``) { + preflightArgs.push(`--preflight-package`); + preflightArgs.push(config.package); + } + + if (config.generateAcir) { + preflightArgs.push(`--preflight-generate-acir`); + } + + if (config.skipInstrumentation) { + preflightArgs.push(`--preflight-skip-instrumentation`); + } + + const preflightCheck = spawn(nargoPath, preflightArgs); + + // Create a promise to block until the preflight check child process + // ends. + let ready: (r: boolean) => void; + const preflightCheckMonitor = new Promise((resolve) => (ready = resolve)); + + preflightCheck.stderr.on('data', (ev_buffer) => preflightCheckPrinter(ev_buffer, outputChannel)); + preflightCheck.stdout.on('data', (ev_buffer) => preflightCheckPrinter(ev_buffer, outputChannel)); + preflightCheck.on('data', (ev_buffer) => preflightCheckPrinter(ev_buffer, outputChannel)); + preflightCheck.on('exit', async (code) => { + if (code !== 0) { + outputChannel.appendLine(`Exited with code ${code}`); + } + ready(code == 0); + }); + + if (!(await preflightCheckMonitor)) { + outputChannel.show(); + throw new Error(`Error launching debugger. Please inspect the Output pane for more details.`); + } else { + outputChannel.appendLine(`Starting debugger session...`); + } + + return config; + } +} + +/** + * Takes stderr or stdout output from the Nargo's DAP + * preflight check and formats it in an Output pane friendly way, + * by removing all special characters. + * + * Note: VS Code's output panes only support plain text. + * + */ +function preflightCheckPrinter(buffer: Buffer, output: OutputChannel) { + const formattedOutput = buffer + .toString() + // eslint-disable-next-line no-control-regex + .replace(/\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '') + .replace(/[^ -~\n\t]/g, ''); + + output.appendLine(formattedOutput); +} diff --git a/src/extension.ts b/src/extension.ts index f5a334b..c920365 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -17,6 +17,7 @@ */ import { + window, workspace, commands, ExtensionContext, @@ -33,11 +34,12 @@ import { TaskPanelKind, TaskGroup, ProcessExecution, - window, ProgressLocation, } from 'vscode'; import os from 'os'; +import { activateDebugger } from './debugger'; + import { languageId } from './constants'; import Client from './client'; import findNargo, { findNargoBinaries } from './find-nargo'; @@ -362,6 +364,8 @@ export async function activate(context: ExtensionContext): Promise { const disposable = await didOpenTextDocument(doc); context.subscriptions.push(disposable); } + + activateDebugger(context); } export async function deactivate(): Promise { diff --git a/src/find-nearest-package.ts b/src/find-nearest-package.ts new file mode 100644 index 0000000..46f14ea --- /dev/null +++ b/src/find-nearest-package.ts @@ -0,0 +1,44 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; + +/** + * Given a program file path, walk up the file system until + * finding the nearest a Nargo.toml in a directory that contains + * the program. + * + * To reduce the odds of accidentally choosing the wrong Nargo package, + * end the walk at the root of the current VS Code open files. + */ +export default function findNearestPackageFolder(program: string, outputChannel: vscode.OutputChannel): string { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + throw new Error(`No workspace is currently open in VS Code.`); + } + + const workspaceRoots = workspaceFolders.map((wf) => wf.uri.fsPath); + + let currentFolder = path.dirname(program); + + try { + while (currentFolder !== path.dirname(currentFolder)) { + const maybeNargoProject = path.join(currentFolder, 'Nargo.toml'); + + if (fs.existsSync(maybeNargoProject)) { + return currentFolder; + } + + if (workspaceRoots.includes(currentFolder)) { + break; + } + + currentFolder = path.dirname(currentFolder); + } + } catch (error) { + outputChannel.appendLine(`Error looking for Nargo.toml: {error.message}`); + outputChannel.show(); + throw new Error(`Could not find a Nargo package associated to this file.`); + } + + throw new Error(`Could not find a Nargo package associated to this file.`); +}