diff --git a/compiler/qsc/src/interpret.rs b/compiler/qsc/src/interpret.rs index 2a0a489f39..ff653fc12b 100644 --- a/compiler/qsc/src/interpret.rs +++ b/compiler/qsc/src/interpret.rs @@ -20,6 +20,7 @@ pub use qsc_eval::{ use crate::{ error::{self, WithStack}, incremental::Compiler, + location::Location, }; use debug::format_call_stack; use miette::Diagnostic; @@ -437,25 +438,15 @@ impl Debugger { Global::Udt => "udt".into(), }; - let hir_package = self - .interpreter - .compiler - .package_store() - .get(map_fir_package_to_hir(frame.id.package)) - .expect("package should exist"); - let source = hir_package - .sources - .find_by_offset(frame.span.lo) - .expect("frame should have a source"); - let path = source.name.to_string(); StackFrame { name, functor, - path, - range: Range::from_span( + location: Location::from( + frame.span, + map_fir_package_to_hir(frame.id.package), + self.interpreter.compiler.package_store(), + map_fir_package_to_hir(self.interpreter.source_package), self.position_encoding, - &source.contents, - &(frame.span - source.offset), ), } }) @@ -541,10 +532,8 @@ pub struct StackFrame { pub name: String, /// The functor of the callable. pub functor: String, - /// The path of the source file. - pub path: String, - /// The source range of the call site. - pub range: Range, + /// The source location of the call site. + pub location: Location, } #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] diff --git a/vscode/src/common.ts b/vscode/src/common.ts index 6baf9294b4..29217adc69 100644 --- a/vscode/src/common.ts +++ b/vscode/src/common.ts @@ -21,14 +21,6 @@ export function isQsharpNotebookCell(document: TextDocument): boolean { export const qsharpExtensionId = "qsharp-vscode"; -export interface FileAccessor { - normalizePath(path: string): string; - convertToWindowsPathSeparator(path: string): string; - resolvePathToUri(path: string): Uri; - openPath(path: string): Promise; - openUri(uri: Uri): Promise; -} - export function basename(path: string): string | undefined { return path.replace(/\/+$/, "").split("/").pop(); } diff --git a/vscode/src/debugger/activate.ts b/vscode/src/debugger/activate.ts index f1e291c3fd..f9177a00f0 100644 --- a/vscode/src/debugger/activate.ts +++ b/vscode/src/debugger/activate.ts @@ -3,8 +3,8 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { IDebugServiceWorker, getDebugServiceWorker } from "qsharp-lang"; -import { qsharpExtensionId, isQsharpDocument, FileAccessor } from "../common"; +import { IDebugServiceWorker, getDebugServiceWorker, log } from "qsharp-lang"; +import { qsharpExtensionId, isQsharpDocument } from "../common"; import { QscDebugSession } from "./session"; import { getRandomGuid } from "../utils"; @@ -49,13 +49,18 @@ function registerCommands(context: vscode.ExtensionContext) { } if (targetResource) { + // We'll omit config.program and let the configuration + // resolver fill it in with the currently open editor's URI. + // This will also let us correctly handle untitled files + // where the save prompt pops up before the debugger is launched, + // potentially causing the active editor URI to change if + // the file is saved with a different name. vscode.debug.startDebugging( undefined, { type: "qsharp", name: "Run Q# File", request: "launch", - program: targetResource.toString(), shots: 1, stopOnEntry: false, }, @@ -73,11 +78,16 @@ function registerCommands(context: vscode.ExtensionContext) { } if (targetResource) { + // We'll omit config.program and let the configuration + // resolver fill it in with the currently open editor's URI. + // This will also let us correctly handle untitled files + // where the save prompt pops up before the debugger is launched, + // potentially causing the active editor URI to change if + // the file is saved with a different name. vscode.debug.startDebugging(undefined, { type: "qsharp", name: "Debug Q# File", request: "launch", - program: targetResource.toString(), shots: 1, stopOnEntry: true, noDebug: false, @@ -101,29 +111,51 @@ class QsDebugConfigProvider implements vscode.DebugConfigurationProvider { config.type = "qsharp"; config.name = "Launch"; config.request = "launch"; - config.program = editor.document.uri.toString(); + config.programUri = editor.document.uri.toString(); config.shots = 1; config.noDebug = "noDebug" in config ? config.noDebug : false; config.stopOnEntry = !config.noDebug; } + } else if (config.program && folder) { + // A program is specified in launch.json. + // + // Variable substitution is a bit odd in VS Code. Variables such as + // ${file} and ${workspaceFolder} are expanded to absolute filesystem + // paths with platform-specific separators. To correctly convert them + // back to a URI, we need to use the vscode.Uri.file constructor. + // + // However, this gives us the URI scheme file:// , which is not correct + // when the workspace uses a virtual filesystem such as qsharp-vfs:// + // or vscode-test-web://. So now we also need the workspace folder URI + // to use as the basis for our file URI. + // + // Examples of program paths that can come through variable substitution: + // C:\foo\bar.qs + // \foo\bar.qs + // /foo/bar.qs + const fileUri = vscode.Uri.file(config.program); + config.programUri = folder.uri + .with({ + path: fileUri.path, + }) + .toString(); } else { - // we have a launch config, resolve the program path - - // ensure we have the program uri correctly formatted - // this is a user specified path. - if (config.program) { - const uri = workspaceFileAccessor.resolvePathToUri(config.program); - config.program = uri.toString(); - } else { - // Use the active editor if no program or ${file} is specified. - const editor = vscode.window.activeTextEditor; - if (editor && isQsharpDocument(editor.document)) { - config.program = editor.document.uri.toString(); - } + // Use the active editor if no program is specified. + const editor = vscode.window.activeTextEditor; + if (editor && isQsharpDocument(editor.document)) { + config.programUri = editor.document.uri.toString(); } } - if (!config.program) { + log.trace( + `resolveDebugConfigurationWithSubstitutedVariables config.program=${ + config.program + } folder.uri=${folder?.uri.toString()} config.programUri=${ + config.programUri + }`, + ); + + if (!config.programUri) { // abort launch return vscode.window .showInformationMessage("Cannot find a Q# program to debug") @@ -157,35 +189,6 @@ class QsDebugConfigProvider implements vscode.DebugConfigurationProvider { } } -// The path normalization, fallbacks, and uri resolution are necessary -// due to https://github.com/microsoft/vscode-debugadapter-node/issues/298 -// We can't specify that the debug adapter should use Uri for paths and can't -// use the DebugSession conversion functions because they don't work in the web. -export const workspaceFileAccessor: FileAccessor = { - normalizePath(path: string): string { - return path.replace(/\\/g, "/"); - }, - convertToWindowsPathSeparator(path: string): string { - return path.replace(/\//g, "\\"); - }, - resolvePathToUri(path: string): vscode.Uri { - const normalizedPath = this.normalizePath(path); - return vscode.Uri.parse(normalizedPath, false); - }, - async openPath(path: string): Promise { - const uri: vscode.Uri = this.resolvePathToUri(path); - return this.openUri(uri); - }, - async openUri(uri: vscode.Uri): Promise { - try { - return await vscode.workspace.openTextDocument(uri); - } catch { - const path = this.convertToWindowsPathSeparator(uri.toString()); - return await vscode.workspace.openTextDocument(vscode.Uri.file(path)); - } - }, -}; - class InlineDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory { @@ -194,12 +197,9 @@ class InlineDebugAdapterFactory _executable: vscode.DebugAdapterExecutable | undefined, ): Promise { const worker = debugServiceWorkerFactory(); - const uri = workspaceFileAccessor.resolvePathToUri( - session.configuration.program, - ); + const uri = vscode.Uri.parse(session.configuration.programUri); const project = await loadProject(uri); const qscSession = new QscDebugSession( - workspaceFileAccessor, worker, session.configuration, project.sources, diff --git a/vscode/src/debugger/session.ts b/vscode/src/debugger/session.ts index 11dc2b8bc0..89e5adee92 100644 --- a/vscode/src/debugger/session.ts +++ b/vscode/src/debugger/session.ts @@ -21,12 +21,7 @@ import { Handles, Scope, } from "@vscode/debugadapter"; -import { - FileAccessor, - basename, - isQsharpDocument, - toVscodeRange, -} from "../common"; +import { basename, isQsharpDocument, toVscodeRange } from "../common"; import { DebugProtocol } from "@vscode/debugprotocol"; import { IDebugServiceWorker, @@ -34,7 +29,6 @@ import { StepResultId, IStructStepResult, QscEventTarget, - qsharpLibraryUriScheme, } from "qsharp-lang"; import { createDebugConsoleEventTarget } from "./output"; import { ILaunchRequestArguments } from "./types"; @@ -68,6 +62,8 @@ interface IBreakpointLocationData { export class QscDebugSession extends LoggingDebugSession { private static threadID = 1; + private readonly knownPaths = new Map(); + private breakpointLocations: Map; private breakpoints: Map; private variableHandles = new Handles<"locals" | "quantum">(); @@ -76,7 +72,6 @@ export class QscDebugSession extends LoggingDebugSession { private supportsVariableType = false; public constructor( - private fileAccessor: FileAccessor, private debugService: IDebugServiceWorker, private config: vscode.DebugConfiguration, private sources: [string, string][], @@ -93,7 +88,19 @@ export class QscDebugSession extends LoggingDebugSession { this.breakpoints = new Map(); this.setDebuggerLinesStartAt1(false); this.setDebuggerColumnsStartAt1(false); - this.languageFeatures = languageFeatures; + + for (const source of sources) { + const uri = vscode.Uri.parse(source[0], true); + + // In Debug Protocol requests, the VS Code debug adapter client + // will strip file URIs to just the filesystem path. + // Keep track of the filesystem paths we know about so that + // we can resolve them back to the original URI when handling requests. + // See `asUri()` for more details. + if (uri.scheme === "file") { + this.knownPaths.set(uri.fsPath, uri.toString()); + } + } } public async init(associationId: string): Promise { @@ -468,27 +475,15 @@ export class QscDebugSession extends LoggingDebugSession { breakpoints: [], }; - const file = await this.fileAccessor - .openPath(args.source.path ?? "") - .catch((e) => { - log.trace(`Failed to open file: ${e}`); - const fileUri = this.fileAccessor.resolvePathToUri( - args.source.path ?? "", - ); - log.trace( - "breakpointLocationsRequest, target file: " + fileUri.toString(), - ); - return undefined; - }); + const doc = await this.tryLoadSource(args.source); + log.trace( + `breakpointLocationsRequest: path=${args.source.path} resolved to ${doc?.uri}`, + ); - // If we couldn't find the file, or it wasn't a Q# file, or - // the range is longer than the file, just return + // If we couldn't find the document, or if + // the range is longer than the document, just return const targetLineNumber = this.convertClientLineToDebugger(args.line); - if ( - !file || - !isQsharpDocument(file) || - targetLineNumber >= file.lineCount - ) { + if (!doc || targetLineNumber >= doc.lineCount) { log.trace(`setBreakPointsResponse: %O`, response); this.sendResponse(response); return; @@ -498,7 +493,7 @@ export class QscDebugSession extends LoggingDebugSession { // everything from `file` is 0 based, everything from `args` is 1 based // so we have to convert anything from `args` to 0 based - const line = file.lineAt(targetLineNumber); + const line = doc.lineAt(targetLineNumber); const lineRange = line.range; // If the column isn't specified, it is a line breakpoint so that we // use the whole line's range for breakpoint finding. @@ -534,7 +529,7 @@ export class QscDebugSession extends LoggingDebugSession { // column offset, so we need to check if the startOffset is within range. const bps = this.breakpointLocations - .get(file.uri.toString()) + .get(doc.uri.toString()) ?.filter((bp) => isLineBreakpoint ? bp.range.start.line == requestRange.start.line @@ -563,31 +558,25 @@ export class QscDebugSession extends LoggingDebugSession { ): Promise { log.trace(`setBreakPointsRequest: %O`, args); - const file = await this.fileAccessor - .openPath(args.source.path ?? "") - .catch((e) => { - log.trace(`setBreakPointsRequest - Failed to open file: ${e}`); - const fileUri = this.fileAccessor.resolvePathToUri( - args.source.path ?? "", - ); - log.trace("setBreakPointsRequest, target file: " + fileUri.toString()); - return undefined; - }); + const doc = await this.tryLoadSource(args.source); + log.trace( + `setBreakPointsRequest: path=${args.source.path} resolved to ${doc?.uri}`, + ); - // If we couldn't find the file, or it wasn't a Q# file, just return - if (!file || !isQsharpDocument(file)) { + // If we couldn't find the document, just return + if (!doc) { log.trace(`setBreakPointsResponse: %O`, response); this.sendResponse(response); return; } log.trace(`setBreakPointsRequest: looking`); - this.breakpoints.set(file.uri.toString(), []); + this.breakpoints.set(doc.uri.toString(), []); log.trace( `setBreakPointsRequest: files in cache %O`, this.breakpointLocations.keys(), ); - const locations = this.breakpointLocations.get(file.uri.toString()) ?? []; + const locations = this.breakpointLocations.get(doc.uri.toString()) ?? []; log.trace(`setBreakPointsRequest: got locations %O`, locations); const desiredBpOffsets: { range: vscode.Range; @@ -597,12 +586,12 @@ export class QscDebugSession extends LoggingDebugSession { .filter( (sourceBreakpoint) => this.convertClientLineToDebugger(sourceBreakpoint.line) < - file.lineCount, + doc.lineCount, ) .map((sourceBreakpoint) => { const isLineBreakpoint = !sourceBreakpoint.column; const line = this.convertClientLineToDebugger(sourceBreakpoint.line); - const lineRange = file.lineAt(line).range; + const lineRange = doc.lineAt(line).range; const startCol = sourceBreakpoint.column ? this.convertClientColumnToDebugger(sourceBreakpoint.column) : lineRange.start.character; @@ -644,7 +633,7 @@ export class QscDebugSession extends LoggingDebugSession { } // Update our breakpoint list for the given file - this.breakpoints.set(file.uri.toString(), bps); + this.breakpoints.set(doc.uri.toString(), bps); response.body = { breakpoints: bps, @@ -675,79 +664,29 @@ export class QscDebugSession extends LoggingDebugSession { const mappedStackFrames = await Promise.all( debuggerStackFrames .map(async (f, id) => { - log.trace(`frames: path %O`, f.path); - - const file = await this.fileAccessor - .openPath(f.path ?? "") - .catch((e) => { - log.error(`stackTraceRequest - Failed to open file: ${e}`); - const fileUri = this.fileAccessor.resolvePathToUri(f.path ?? ""); - log.trace( - "stackTraceRequest, target file: " + fileUri.toString(), - ); - }); - if (file) { - log.trace(`frames: file %O`, file); - const sf: DebugProtocol.StackFrame = new StackFrame( - id, - f.name, - new Source( - basename(f.path) ?? f.path, - file.uri.toString(true), - undefined, - undefined, - "qsharp-adapter-data", - ), - this.convertDebuggerLineToClient(f.range.start.line), - this.convertDebuggerColumnToClient(f.range.start.character), - ); - sf.endLine = this.convertDebuggerLineToClient(f.range.end.line); - sf.endColumn = this.convertDebuggerColumnToClient( - f.range.end.character, - ); - return sf; - } else { - try { - // This file isn't part of the workspace, so we'll - // create a URI which can try to load it from the core and std lib - // There is a custom content provider subscribed to this scheme. - // Opening the text document by that uri will use the content - // provider to look for the source code. - const uri = vscode.Uri.from({ - scheme: qsharpLibraryUriScheme, - path: f.path, - }); - const source = new Source( - basename(f.path) ?? f.path, - uri.toString(), - 0, - "internal core/std library", - "qsharp-adapter-data", - ) as DebugProtocol.Source; - const sf = new StackFrame( - id, - f.name, - source as Source, - this.convertDebuggerLineToClient(f.range.start.line), - this.convertDebuggerColumnToClient(f.range.start.character), - ); - sf.endLine = this.convertDebuggerLineToClient(f.range.end.line); - sf.endColumn = this.convertDebuggerColumnToClient( - f.range.end.character, - ); - - return sf as DebugProtocol.StackFrame; - } catch (e: any) { - log.warn(e.message); - return new StackFrame( - id, - f.name, - undefined, - undefined, - undefined, - ); - } - } + log.trace(`frames: location %O`, f.location); + + const uri = f.location.source; + const sf: DebugProtocol.StackFrame = new StackFrame( + id, + f.name, + new Source( + basename(vscode.Uri.parse(uri).path) ?? uri, + uri, + undefined, + undefined, + "qsharp-adapter-data", + ), + this.convertDebuggerLineToClient(f.location.span.start.line), + this.convertDebuggerColumnToClient(f.location.span.start.character), + ); + sf.endLine = this.convertDebuggerLineToClient( + f.location.span.end.line, + ); + sf.endColumn = this.convertDebuggerColumnToClient( + f.location.span.end.character, + ); + return sf; }) .filter(filterUndefined), ); @@ -883,4 +822,65 @@ export class QscDebugSession extends LoggingDebugSession { ); this.sendEvent(evt); } + + /** + * Attempts to find the Source in the current session and returns the + * TextDocument if it exists. + * + * This method *may* return a valid result even when the requested + * path does not belong in the current program (e.g. another Q# file + * in the workspace). + */ + async tryLoadSource(source: DebugProtocol.Source) { + if (!source.path) { + return; + } + + const uri = this.asUri(source.path); + if (!uri) { + return; + } + + try { + const doc = await vscode.workspace.openTextDocument(uri); + if (!isQsharpDocument(doc)) { + return; + } + return doc; + } catch (e) { + log.trace(`Failed to open ${uri}: ${e}`); + } + } + + /** + * Attemps to resolve a DebugProtocol.Source.path to a URI. + * + * In Debug Protocol requests, the VS Code debug adapter client + * will strip file URIs to just the filesystem path part. + * But for non-file URIs, the full URI is sent. + * + * See: https://github.com/microsoft/vscode/blob/3246d63177e1e5ae211029e7ab0021c33342a3c7/src/vs/workbench/contrib/debug/common/debugSource.ts#L90 + * + * Here, we need the original URI, but we don't know if we're + * dealing with a filesystem path or URI. We cannot determine + * which one it is based on the input alone (the syntax is ambiguous). + * But we do have a set of *known* filesystem paths that we + * constructed at initialization, and we can use that to resolve + * any known fileystem paths back to the original URI. + * + * Filesystem paths we don't know about *won't* be resolved, + * and that's ok in this use case. + * + * If the path was originally constructed from a URI, it won't + * be in our known paths map, so we'll treat the string as a URI. + */ + asUri(pathOrUri: string): vscode.Uri | undefined { + pathOrUri = this.knownPaths.get(pathOrUri) || pathOrUri; + + try { + return vscode.Uri.parse(pathOrUri); + } catch (e) { + log.trace(`Could not resolve path ${pathOrUri}`); + } + } } diff --git a/vscode/test/runTests.mjs b/vscode/test/runTests.mjs index 210c38527d..51f044ca8d 100644 --- a/vscode/test/runTests.mjs +++ b/vscode/test/runTests.mjs @@ -39,7 +39,7 @@ try { // Debugger tests await runSuite( join(thisDir, "out", "debugger", "index"), - join(thisDir, "..", "..", "samples"), + join(thisDir, "suites", "debugger", "test-workspace"), ); } catch (err) { console.error("Failed to run tests", err); diff --git a/vscode/test/suites/debugger/debugger.test.ts b/vscode/test/suites/debugger/debugger.test.ts new file mode 100644 index 0000000000..2a832845b6 --- /dev/null +++ b/vscode/test/suites/debugger/debugger.test.ts @@ -0,0 +1,567 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as vscode from "vscode"; +import { assert } from "chai"; +import { activateExtension, waitForCondition } from "../extensionUtils"; +import { DebugProtocol } from "@vscode/debugprotocol"; + +/** + * Set to true to log Debug Adapter Protocol messages to the console. + * This is useful for debugging test failures. + */ +const logDebugAdapterActivity = false; + +suite("Q# Debugger Tests", function suite() { + const workspaceFolder = + vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders[0]; + assert(workspaceFolder, "Expecting an open folder"); + const fooUri = vscode.Uri.joinPath(workspaceFolder.uri, "src", "foo.qs"); + const barUri = vscode.Uri.joinPath(workspaceFolder.uri, "src", "bar.qs"); + + let tracker: Tracker | undefined; + let disposable; + + this.beforeAll(async () => { + await activateExtension(); + }); + + this.beforeEach(async () => { + tracker = new Tracker(); + disposable = vscode.debug.registerDebugAdapterTrackerFactory("qsharp", { + createDebugAdapterTracker(): vscode.ProviderResult { + return tracker; + }, + }); + }); + + this.afterEach(async () => { + disposable.dispose(); + tracker = undefined; + await terminateSession(); + vscode.commands.executeCommand("workbench.action.closeAllEditors"); + vscode.debug.removeBreakpoints(vscode.debug.breakpoints); + }); + + test("Launch with debugEditorContents command", async () => { + await vscode.window.showTextDocument(fooUri); + + // launch debugger + await vscode.commands.executeCommand("qsharp-vscode.debugEditorContents"); + + await waitUntilPaused([ + { + id: 0, + source: { + name: "foo.qs", + path: "vscode-test-web://mount/src/foo.qs", + sourceReference: 0, + adapterData: "qsharp-adapter-data", + }, + line: 5, + column: 9, + name: "Foo ", + endLine: 5, + endColumn: 15, + }, + { id: 0, line: 0, column: 0, name: "entry", source: undefined }, + ]); + }); + + test("Launch with launch.json configuration - workspaceFolder substitution", async () => { + // The DebugConfiguration object is what would go in launch.json, + // pass it in directly here + await vscode.debug.startDebugging(workspaceFolder, { + name: "Launch foo.qs", + type: "qsharp", + request: "launch", + program: "${workspaceFolder}src/foo.qs", + }); + + await waitUntilPaused([ + { + id: 0, + source: { + name: "foo.qs", + path: "vscode-test-web://mount/src/foo.qs", + sourceReference: 0, + adapterData: "qsharp-adapter-data", + }, + line: 5, + column: 9, + name: "Foo ", + endLine: 5, + endColumn: 15, + }, + { id: 0, line: 0, column: 0, name: "entry", source: undefined }, + ]); + }); + + test("Launch with launch.json configuration - file substitution", async () => { + await vscode.window.showTextDocument(fooUri); + + // ${file} will expand to the filesystem path of the currently opened file + await vscode.debug.startDebugging(workspaceFolder, { + name: "Launch foo.qs", + type: "qsharp", + request: "launch", + program: "${file}", + }); + + await waitUntilPaused([ + { + id: 0, + source: { + name: "foo.qs", + path: "vscode-test-web://mount/src/foo.qs", + sourceReference: 0, + adapterData: "qsharp-adapter-data", + }, + line: 5, + column: 9, + name: "Foo ", + endLine: 5, + endColumn: 15, + }, + { id: 0, line: 0, column: 0, name: "entry", source: undefined }, + ]); + }); + + test("Run until completion", async () => { + // launch debugger + await vscode.debug.startDebugging(workspaceFolder, { + name: "Launch foo.qs", + type: "qsharp", + request: "launch", + program: "${workspaceFolder}src/foo.qs", + stopOnEntry: true, + }); + + // should hit the breakpoint we set above + await waitUntilPaused([ + { + id: 0, + source: { + name: "foo.qs", + path: "vscode-test-web://mount/src/foo.qs", + sourceReference: 0, + adapterData: "qsharp-adapter-data", + }, + line: 5, + column: 9, + name: "Foo ", + endLine: 5, + endColumn: 15, + }, + { id: 0, line: 0, column: 0, name: "entry", source: undefined }, + ]); + + vscode.commands.executeCommand("workbench.action.debug.continue"); + + // wait until there's no longer an active debug session + await waitForCondition( + () => !vscode.debug.activeDebugSession, + vscode.debug.onDidChangeActiveDebugSession, + 2000, + "timed out waiting for the debugger to be terminated", + ); + }); + + test("Set breakpoint in main file", async () => { + // Set a breakpoint on line 6 of foo.qs (5 when 0-indexed) + await vscode.debug.addBreakpoints([ + new vscode.SourceBreakpoint( + new vscode.Location(fooUri, new vscode.Position(5, 0)), + ), + ]); + + // launch debugger + await vscode.debug.startDebugging(workspaceFolder, { + name: "Launch foo.qs", + type: "qsharp", + request: "launch", + program: "${workspaceFolder}src/foo.qs", + stopOnEntry: false, + }); + + // should hit the breakpoint we set above + await waitUntilPaused([ + { + id: 0, + source: { + name: "foo.qs", + path: "vscode-test-web://mount/src/foo.qs", + sourceReference: 0, + adapterData: "qsharp-adapter-data", + }, + line: 6, + column: 9, + name: "Foo ", + endLine: 6, + endColumn: 15, + }, + { id: 0, line: 0, column: 0, name: "entry", source: undefined }, + ]); + }); + + test("Set breakpoint in other file", async () => { + // Set a breakpoint on line 3 of bar.qs (2 when 0-indexed) + await vscode.debug.addBreakpoints([ + new vscode.SourceBreakpoint( + new vscode.Location(barUri, new vscode.Position(2, 0)), + ), + ]); + + // launch debugger + await vscode.debug.startDebugging(workspaceFolder, { + name: "Launch foo.qs", + type: "qsharp", + request: "launch", + program: "${workspaceFolder}src/foo.qs", + stopOnEntry: false, + }); + + // should hit the breakpoint we set above + await waitUntilPaused([ + { + id: 1, + source: { + name: "bar.qs", + path: "vscode-test-web://mount/src/bar.qs", + sourceReference: 0, + adapterData: "qsharp-adapter-data", + }, + line: 3, + column: 9, + name: "Bar ", + endLine: 3, + endColumn: 26, + }, + { + id: 0, + source: { + name: "foo.qs", + path: "vscode-test-web://mount/src/foo.qs", + sourceReference: 0, + adapterData: "qsharp-adapter-data", + }, + line: 5, + column: 12, + name: "Foo ", + endLine: 5, + endColumn: 14, + }, + { id: 0, line: 0, column: 0, name: "entry", source: undefined }, + ]); + + // text editor should now be open on bar.qs + await waitForTextEditorOn(barUri); + }); + + test("Step into other file", async () => { + // launch debugger + await vscode.debug.startDebugging(workspaceFolder, { + name: "Launch foo.qs", + type: "qsharp", + request: "launch", + program: "${workspaceFolder}src/foo.qs", + stopOnEntry: true, + }); + + // should break on entry (per debug config above) + await waitUntilPaused([ + { + id: 0, + source: { + name: "foo.qs", + path: "vscode-test-web://mount/src/foo.qs", + sourceReference: 0, + adapterData: "qsharp-adapter-data", + }, + line: 5, + column: 9, + name: "Foo ", + endLine: 5, + endColumn: 15, + }, + { id: 0, line: 0, column: 0, name: "entry", source: undefined }, + ]); + + // step into call (will be a call into bar.qs) + await vscode.commands.executeCommand("workbench.action.debug.stepInto"); + + await waitUntilPaused([ + { + id: 1, + source: { + name: "bar.qs", + path: "vscode-test-web://mount/src/bar.qs", + sourceReference: 0, + adapterData: "qsharp-adapter-data", + }, + line: 3, + column: 9, + name: "Bar ", + endLine: 3, + endColumn: 26, + }, + { + id: 0, + source: { + name: "foo.qs", + path: "vscode-test-web://mount/src/foo.qs", + sourceReference: 0, + adapterData: "qsharp-adapter-data", + }, + line: 5, + column: 12, + name: "Foo ", + endLine: 5, + endColumn: 14, + }, + { id: 0, line: 0, column: 0, name: "entry", source: undefined }, + ]); + + // text editor should now be open on bar.qs + await waitForTextEditorOn(barUri); + }); + + test("Step into standard lib", async () => { + // Set a breakpoint on line 8 of foo.qs (7 when 0-indexed) + // This will be a call into stdlib + await vscode.debug.addBreakpoints([ + new vscode.SourceBreakpoint( + new vscode.Location(fooUri, new vscode.Position(7, 0)), + ), + ]); + + // launch debugger + await vscode.debug.startDebugging(workspaceFolder, { + name: "Launch foo.qs", + type: "qsharp", + request: "launch", + program: "${workspaceFolder}src/foo.qs", + stopOnEntry: false, + }); + + // should hit the breakpoint we set above + await waitUntilPaused([ + { + id: 0, + source: { + name: "foo.qs", + path: "vscode-test-web://mount/src/foo.qs", + sourceReference: 0, + adapterData: "qsharp-adapter-data", + }, + line: 8, + column: 9, + name: "Foo ", + endLine: 8, + endColumn: 14, + }, + { id: 0, line: 0, column: 0, name: "entry", source: undefined }, + ]); + + // step into call (will be a call into intrinsic.qs) + await vscode.commands.executeCommand("workbench.action.debug.stepInto"); + + await waitUntilPaused([ + { + id: 1, + source: { + name: "intrinsic.qs", + path: "qsharp-library-source:intrinsic.qs", + sourceReference: 0, + adapterData: "qsharp-adapter-data", + }, + line: 165, + column: 13, + name: "H ", + endLine: 165, + endColumn: 44, + }, + { + id: 0, + source: { + name: "foo.qs", + path: "vscode-test-web://mount/src/foo.qs", + sourceReference: 0, + adapterData: "qsharp-adapter-data", + }, + line: 8, + column: 11, + name: "Foo ", + endLine: 8, + endColumn: 12, + }, + { id: 0, line: 0, column: 0, name: "entry", source: undefined }, + ]); + + // text editor should now be open on intrinsic.qs + await waitForTextEditorOn( + vscode.Uri.parse("qsharp-library-source:intrinsic.qs"), + ); + }); + + /** + * Wait until the debugger has entered the paused state. + * + * @param expectedStackTrace assert that the stack trace matches this value + */ + function waitUntilPaused(expectedStackTrace: DebugProtocol.StackFrame[]) { + return tracker!.waitUntilPaused(expectedStackTrace); + } +}); + +/** + * Terminate the active debug session and wait for it to end. + */ +async function terminateSession() { + vscode.commands.executeCommand("workbench.action.debug.stop"); + await waitForCondition( + () => !vscode.debug.activeDebugSession, + vscode.debug.onDidChangeActiveDebugSession, + 2000, + "timed out waiting for the debugger to be terminated", + ); +} + +/** + * Wait for the active text editor to be open to the given document URI. + */ +async function waitForTextEditorOn(uri: vscode.Uri) { + await waitForCondition( + () => + vscode.window.activeTextEditor?.document.uri.toString() === + uri.toString(), + vscode.window.onDidChangeActiveTextEditor, + 500, + `timed out waiting for the text editor to open to ${uri}.\nactive text editor is ${vscode.window.activeTextEditor?.document.uri}`, + ); +} + +/** + * This class will listen to the communication between VS Code and the debug adapter (our code). + * + * VS Code does not provide an easy way to hook into debug session state for our tests. But there + * is a predictable pattern of Debug Adapter Protocol messages we can listen to, + * to figure out when the debugger has entered the paused state (as a result of a breakpoint, step, breaking on entry, etc.). + * + * 1. a "stopped" event coming from the debug adapter. + * 2. a response to a "stackTrace" request. + * 3. a response to a "variables" request. + * + * The "variables" request is the last thing VS Code sends to the debug adapter, and thus we can + * use that event to reasonably determine we're ready to move on to the next test command. + * + * This pattern is based on the debug tests in the VS Code repo: + * https://github.com/microsoft/vscode/blob/13e49a698cf441f82984b357f09ed095779751b8/extensions/vscode-api-tests/src/singlefolder-tests/debug.test.ts#L52 + */ +class Tracker implements vscode.DebugAdapterTracker { + private stoppedCount = 0; + private stackTrace; + private variables; + private onVariablesResponse: ((e: any) => void) | undefined; + + /** + * Wait until the debugger has entered the paused state by waiting for the + * appropriate sequence of messages in the debug adapter. + * + * @param expectedStackTrace assert that the stack trace matches this value + */ + async waitUntilPaused(expectedStackTrace: DebugProtocol.StackFrame[]) { + const start = performance.now(); + + await waitForCondition( + () => this.stoppedCount === 1 && this.stackTrace && this.variables, + (listener: (e: any) => void) => { + this.onVariablesResponse = listener; + return { + dispose() { + this.onVariablesResponse = undefined; + }, + }; + }, + 1800, + "timed out waiting for the debugger to stop", + ); + + assert.deepEqual( + this.stackTrace, + expectedStackTrace, + // print copy-pastable stack trace + `actual stack trace:\n${JSON.stringify(this.stackTrace)}\n`, + ); + + this.stoppedCount = 0; + this.stackTrace = undefined; + this.variables = undefined; + + const stepMs = performance.now() - start; + if (stepMs > 700) { + // Not much we can control here if the debugger is taking too long, + // but log a warning so that we see it in the test log if we get + // close to hitting test timeouts. + // The default mocha test timeout is 2000ms. + console.log(`qsharp-tests: debugger took ${stepMs}ms to stop`); + } + if (logDebugAdapterActivity) { + console.log(`qsharp-tests: debugger paused`); + } + } + + onWillReceiveMessage(message: any): void { + if (logDebugAdapterActivity) { + console.log(`qsharp-tests: -> ${JSON.stringify(message)}`); + } + } + + onDidSendMessage(message: any): void { + if (logDebugAdapterActivity) { + if (message.type === "response") { + console.log(`qsharp-tests: <- ${JSON.stringify(message)}`); + } else { + // message.type === "event" + console.log(`qsharp-tests: <-* ${JSON.stringify(message)}`); + } + } + + if (message.type === "event") { + if (message.event === "stopped") { + this.stoppedCount++; + } + } else if (message.type === "response") { + if (message.command === "variables") { + this.variables = message.body.variables; + this.onVariablesResponse?.(undefined); + } else if (message.command === "stackTrace") { + this.stackTrace = message.body.stackFrames; + } + } + } + + onWillStartSession(): void { + if (logDebugAdapterActivity) { + console.log(`qsharp-tests: starting debug session`); + } + } + + onWillStopSession(): void { + if (logDebugAdapterActivity) { + console.log(`qsharp-tests: stopping debug session`); + } + } + + onError(error: Error): void { + console.log(`qsharp-tests: [error] error in debug session: ${error}`); + } + + onExit(code: number, signal: string): void { + if (logDebugAdapterActivity) { + console.log( + `qsharp-tests: debug session exited with code ${code} and signal ${signal}`, + ); + } + } +} diff --git a/vscode/test/suites/debugger/extension.test.ts b/vscode/test/suites/debugger/extension.test.ts deleted file mode 100644 index e3b28a73ba..0000000000 --- a/vscode/test/suites/debugger/extension.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import * as vscode from "vscode"; -import { assert } from "chai"; - -suite("Q# Debugger Tests", () => { - const workspaceFolder = - vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders[0]; - assert(workspaceFolder, "Expecting an open folder"); - - test("Placeholder - success", async () => { - assert.equal(1, 1); - }); -}); diff --git a/vscode/test/suites/debugger/index.ts b/vscode/test/suites/debugger/index.ts index 82a441be4a..e6ca2e261d 100644 --- a/vscode/test/suites/debugger/index.ts +++ b/vscode/test/suites/debugger/index.ts @@ -8,6 +8,6 @@ export function run(): Promise { // We can't use any wildcards or dynamically discovered // paths here since ESBuild needs these modules to be // real paths on disk at bundling time. - require("./extension.test"); + require("./debugger.test"); }); } diff --git a/vscode/test/suites/debugger/test-workspace/qsharp.json b/vscode/test/suites/debugger/test-workspace/qsharp.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/vscode/test/suites/debugger/test-workspace/qsharp.json @@ -0,0 +1 @@ +{} diff --git a/vscode/test/suites/debugger/test-workspace/src/bar.qs b/vscode/test/suites/debugger/test-workspace/src/bar.qs new file mode 100644 index 0000000000..d1e0eae0d9 --- /dev/null +++ b/vscode/test/suites/debugger/test-workspace/src/bar.qs @@ -0,0 +1,5 @@ +namespace Bar { + operation Bar() : Unit { + Message("hello"); + } +} diff --git a/vscode/test/suites/debugger/test-workspace/src/foo.qs b/vscode/test/suites/debugger/test-workspace/src/foo.qs new file mode 100644 index 0000000000..b354cf1c78 --- /dev/null +++ b/vscode/test/suites/debugger/test-workspace/src/foo.qs @@ -0,0 +1,11 @@ +namespace Foo { + open Bar; + @EntryPoint() + operation Foo() : Unit { + Bar(); + Bar(); + use q = Qubit(); + H(q); + Reset(q); + } +} diff --git a/wasm/src/debug_service.rs b/wasm/src/debug_service.rs index 27143cb412..2fd30c0f38 100644 --- a/wasm/src/debug_service.rs +++ b/wasm/src/debug_service.rs @@ -8,7 +8,7 @@ use qsc::interpret::{Debugger, Error, StepAction, StepResult}; use qsc::line_column::Encoding; use qsc::{fmt_complex, target::Profile, LanguageFeatures}; -use crate::line_column::Range; +use crate::line_column::{Location, Range}; use crate::{get_source_map, serializable_type, CallbackReceiver}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -70,11 +70,10 @@ impl DebugService { StackFrameList { frames: frames - .iter() + .into_iter() .map(|s| StackFrame { name: format!("{} {}", s.name, s.functor), - path: s.path.clone(), - range: s.range.into(), + location: s.location.into(), }) .collect(), } @@ -326,13 +325,11 @@ serializable_type! { StackFrame, { pub name: String, - pub path: String, - pub range: Range + pub location: Location }, r#"export interface IStackFrame { name: string; - path: string; - range: IRange; + location: ILocation }"# }