From 67e730079e7ccdd09ede4cf8f9b26237ff14bddb Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Thu, 7 May 2026 15:51:17 -0700 Subject: [PATCH 01/87] expose circuit-to-qsharp function to TS --- source/npm/qsharp/src/compiler/compiler.ts | 18 +++++++ source/npm/qsharp/test/basics.js | 57 ++++++++++++++++++++++ source/wasm/src/lib.rs | 13 +++++ 3 files changed, 88 insertions(+) diff --git a/source/npm/qsharp/src/compiler/compiler.ts b/source/npm/qsharp/src/compiler/compiler.ts index c2be4ef82c9..fa2480402dd 100644 --- a/source/npm/qsharp/src/compiler/compiler.ts +++ b/source/npm/qsharp/src/compiler/compiler.ts @@ -73,6 +73,14 @@ export interface ICompiler { operation?: IOperationInfo, ): Promise; + /** + * Generate Q# source code that implements the given circuit. + * + * @param fileName Used to derive the operation name(s) in the generated Q# code. + * @param circuits The circuit (or circuit group) to convert. + */ + circuitsToQsharp(fileName: string, circuits: CircuitData): Promise; + getDocumentation(additionalProgram?: ProgramConfig): Promise; getLibrarySummaries(): Promise; @@ -238,6 +246,15 @@ export class Compiler implements ICompiler { }; } + async circuitsToQsharp( + fileName: string, + circuits: CircuitData, + ): Promise { + return callAndTransformExceptions(async () => + this.wasm.circuits_to_qsharp(fileName, JSON.stringify(circuits)), + ); + } + // Returns all autogenerated documentation files for the standard library // and loaded project (if requested). This include file names and metadata, // including specially formatted table of content file. @@ -352,6 +369,7 @@ export const compilerProtocol: ServiceProtocol = { getQir: "request", getEstimates: "request", getCircuit: "request", + circuitsToQsharp: "request", getDocumentation: "request", getLibrarySummaries: "request", run: "requestWithProgress", diff --git a/source/npm/qsharp/test/basics.js b/source/npm/qsharp/test/basics.js index 21127dcf210..86fb3a6d8bb 100644 --- a/source/npm/qsharp/test/basics.js +++ b/source/npm/qsharp/test/basics.js @@ -136,6 +136,63 @@ test("basic eval", async () => { assert.equal(result.result, "42"); }); +test("circuitsToQsharp generates Q# from a CircuitGroup", async () => { + const compiler = await getCompiler(); + + /** @type {import("../dist/data-structures/circuit.js").CircuitGroup} */ + const circuits = { + version: 1, + circuits: [ + { + qubits: [{ id: 0 }, { id: 1 }], + componentGrid: [ + { + components: [ + { + kind: "unitary", + gate: "H", + targets: [{ qubit: 0 }], + }, + ], + }, + { + components: [ + { + kind: "unitary", + gate: "X", + targets: [{ qubit: 1 }], + controls: [{ qubit: 0 }], + }, + ], + }, + ], + }, + ], + }; + + const qsharp = await compiler.circuitsToQsharp("Bell", circuits); + + assert( + qsharp.includes("operation Bell"), + `expected generated Q# to define operation 'Bell'; got: ${qsharp}`, + ); + assert(qsharp.includes("H("), "expected H gate call"); + assert(qsharp.includes("Controlled X("), "expected Controlled X gate call"); +}); + +test("circuitsToQsharp surfaces errors for invalid JSON shape", async () => { + const compiler = await getCompiler(); + + // Missing the required `version` and `circuits` fields. + await assert.rejects(() => + compiler.circuitsToQsharp( + "Bad", + // @ts-expect-error - intentionally invalid + { not: "a circuit group" }, + ), + ); +}); + test("EntryPoint only", async () => { const code = ` namespace Test { diff --git a/source/wasm/src/lib.rs b/source/wasm/src/lib.rs index 252dfb3884e..2fc94e38397 100644 --- a/source/wasm/src/lib.rs +++ b/source/wasm/src/lib.rs @@ -240,6 +240,19 @@ fn compile_errors_into_qsharp_errors_json(errs: Vec) -> Str interpret_errors_into_qsharp_errors_json(errs.into_iter().map(Into::into).collect()) } +/// Generates a Q# operation that implements the given circuit. +/// +/// `circuits_json` is a JSON-serialized `CircuitGroup` (see the TypeScript +/// `CircuitGroup` type). `file_name` is used to derive the operation name(s) +/// in the generated Q# code. +/// +/// Returns the generated Q# source on success, or an error message on failure +/// (e.g. when the JSON cannot be parsed as a `CircuitGroup`). +#[wasm_bindgen] +pub fn circuits_to_qsharp(file_name: &str, circuits_json: &str) -> Result { + qsc::circuit::circuits_to_qsharp(file_name, circuits_json) +} + #[wasm_bindgen] #[must_use] pub fn get_library_source_content(name: &str) -> Option { From 794983ca4370f666d20e34c99cd29f78a6028096 Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Thu, 7 May 2026 16:12:00 -0700 Subject: [PATCH 02/87] circuit code preview --- source/vscode/src/circuitPreview.ts | 138 ++++++++++++++++++++++++++++ source/vscode/src/extension.ts | 2 + 2 files changed, 140 insertions(+) create mode 100644 source/vscode/src/circuitPreview.ts diff --git a/source/vscode/src/circuitPreview.ts b/source/vscode/src/circuitPreview.ts new file mode 100644 index 00000000000..33446929806 --- /dev/null +++ b/source/vscode/src/circuitPreview.ts @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as vscode from "vscode"; + +/** + * URI scheme used for read-only Q# previews of circuit files. + * + * Documents under this scheme are served by `CircuitPreviewProvider` and + * appear to VS Code as regular Q# documents, so they automatically pick up + * Q# syntax highlighting, the language service, the editor theme, etc. + * + * The corresponding circuit document's URI is carried in the preview URI's + * `query`, which keeps the preview URI stable across edits without baking + * the (possibly long, possibly platform-specific) source path into the + * preview's display path. + */ +export const qsharpCircuitPreviewScheme = "qsharp-circuit-preview"; + +/** + * Build the deterministic preview URI for a given circuit document. + * + * The path is purely cosmetic (it controls the editor tab label and is what + * the language service sees as the document name). The query carries the + * full original circuit URI so the provider can map back when needed. + */ +export function circuitPreviewUriFor(circuitUri: vscode.Uri): vscode.Uri { + // Use the basename for the display path, with a `.qs` suffix so VS Code + // selects the Q# language for the editor. + const basename = circuitUri.path.split(/[\\/]/).pop() ?? "circuit"; + return vscode.Uri.from({ + scheme: qsharpCircuitPreviewScheme, + // Leading slash so the URI parses cleanly across platforms. + path: `/${basename}.qs`, + query: circuitUri.toString(), + }); +} + +/** + * Read-only content provider that serves Q# code generated from a circuit. + * + * The provider does not compute the Q# itself; callers (the circuit editor, + * primarily) push the latest generated code in via `setContent`. This keeps + * the provider free of any compiler / wasm dependency and lets the circuit + * editor own debouncing, error handling, and lifecycle of the preview. + * + * Content is keyed by the preview URI (as a string), not the source circuit + * URI, so two circuits with the same basename in different folders never + * collide even though their tab labels are identical. + */ +export class CircuitPreviewProvider + implements vscode.TextDocumentContentProvider, vscode.Disposable +{ + private readonly _onDidChange = new vscode.EventEmitter(); + private readonly _content = new Map(); + + readonly onDidChange = this._onDidChange.event; + + /** + * Update the cached Q# content for a preview URI. + * + * Fires `onDidChange` so any open editor showing the preview re-fetches + * via `provideTextDocumentContent`. + */ + setContent(uri: vscode.Uri, content: string): void { + this._content.set(uri.toString(), content); + this._onDidChange.fire(uri); + } + + /** + * Drop cached content for a preview URI. Subsequent fetches will fall + * back to the placeholder text. + */ + clearContent(uri: vscode.Uri): void { + if (this._content.delete(uri.toString())) { + this._onDidChange.fire(uri); + } + } + + provideTextDocumentContent(uri: vscode.Uri): string { + const cached = this._content.get(uri.toString()); + if (cached !== undefined) return cached; + // Placeholder shown before the circuit editor has produced any content + // (e.g. immediately after the preview is opened). Q# comments so syntax + // highlighting still renders sensibly. + return "// Q# preview will appear here as you edit the circuit.\n"; + } + + dispose(): void { + this._content.clear(); + this._onDidChange.dispose(); + } +} + +/** + * Singleton instance of the preview provider. + * + * Created and registered with VS Code from `extension.ts` activation. + * Held here so other modules (notably `CircuitEditorProvider`) can push + * generated Q# into it without having to thread the provider through + * many layers of constructors. + */ +let _provider: CircuitPreviewProvider | undefined; + +/** + * Register the circuit preview content provider with VS Code. + * + * Returns a Disposable suitable for `context.subscriptions.push(...)`. + * Calling this more than once is a programming error. + */ +export function registerCircuitPreviewProvider(): vscode.Disposable { + if (_provider !== undefined) { + throw new Error("Circuit preview provider has already been registered."); + } + _provider = new CircuitPreviewProvider(); + const registration = vscode.workspace.registerTextDocumentContentProvider( + qsharpCircuitPreviewScheme, + _provider, + ); + return vscode.Disposable.from(registration, _provider, { + dispose: () => { + _provider = undefined; + }, + }); +} + +/** + * Get the registered preview provider, if any. + * + * Returns `undefined` before activation has registered it (or after + * deactivation), in which case callers should silently skip preview + * updates rather than fail. + */ +export function getCircuitPreviewProvider(): + | CircuitPreviewProvider + | undefined { + return _provider; +} diff --git a/source/vscode/src/extension.ts b/source/vscode/src/extension.ts index dd6991d705f..8245fab277d 100644 --- a/source/vscode/src/extension.ts +++ b/source/vscode/src/extension.ts @@ -11,6 +11,7 @@ import { import * as vscode from "vscode"; import { initAzureWorkspaces } from "./azure/commands.js"; import { CircuitEditorProvider } from "./circuitEditor.js"; +import { registerCircuitPreviewProvider } from "./circuitPreview.js"; import { initProjectCreator } from "./createProject.js"; import { activateDebugger } from "./debugger/activate.js"; import { startOtherQSharpDiagnostics } from "./diagnostics.js"; @@ -83,6 +84,7 @@ export async function activate( context.subscriptions.push(...(await activateLanguageService(context))); context.subscriptions.push(...startOtherQSharpDiagnostics()); context.subscriptions.push(...registerQSharpNotebookHandlers()); + context.subscriptions.push(registerCircuitPreviewProvider()); context.subscriptions.push(CircuitEditorProvider.register(context)); context.subscriptions.push(...registerChangelogCommand(context)); From 2a654ce0f95313d344d42a0b443355bdf29943b0 Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Thu, 7 May 2026 16:24:04 -0700 Subject: [PATCH 03/87] Show read-only live code panel --- source/vscode/src/circuitEditor.ts | 184 +++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) diff --git a/source/vscode/src/circuitEditor.ts b/source/vscode/src/circuitEditor.ts index 6d6e1eb58e0..4b81401c1d6 100644 --- a/source/vscode/src/circuitEditor.ts +++ b/source/vscode/src/circuitEditor.ts @@ -1,9 +1,24 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import type { ICompilerWorker } from "qsharp-lang"; import * as vscode from "vscode"; +import { + circuitPreviewUriFor, + getCircuitPreviewProvider, +} from "./circuitPreview"; +import { loadCompilerWorker } from "./common"; import { runProgramInTerminal } from "./run"; +/** + * Debounce window between circuit edits and recomputing the Q# preview. + * + * Short enough that the preview feels live during typical interactions + * (drag, drop, parameter edits), long enough to coalesce the rapid bursts + * of edits that happen during a drag. + */ +const PREVIEW_DEBOUNCE_MS = 200; + export class CircuitEditorProvider implements vscode.CustomTextEditorProvider { private static readonly viewType = "qsharp-webview.circuit"; updatingDocument: boolean = false; @@ -30,6 +45,91 @@ export class CircuitEditorProvider implements vscode.CustomTextEditorProvider { }; webviewPanel.webview.html = this.getHtmlForWebview(webviewPanel.webview); + // Per-editor preview state. Held in closure variables (rather than on + // `this`) because a single CircuitEditorProvider services all open + // circuit editors and would otherwise need a Map keyed by document URI. + const previewUri = circuitPreviewUriFor(document.uri); + let previewWorker: ICompilerWorker | undefined; + let previewTimer: ReturnType | undefined; + // Monotonically increasing request ID so out-of-order async results from + // the compiler worker can be discarded (latest edit wins). + let previewRequestId = 0; + let lastAppliedRequestId = -1; + + const getPreviewWorker = (): ICompilerWorker => { + if (!previewWorker) { + previewWorker = loadCompilerWorker(this.context.extensionUri); + } + return previewWorker; + }; + + /** + * Recompute the Q# preview for the current document content and push it + * to the preview provider. Errors are surfaced inline as Q# comments so + * the preview surface remains a valid Q# document at all times. + */ + const refreshPreviewNow = async () => { + const provider = getCircuitPreviewProvider(); + if (!provider) return; + + const requestId = ++previewRequestId; + const text = document.getText(); + const operationName = previewOperationNameFor(document.uri); + + let qsharp: string; + if (text.trim().length === 0) { + qsharp = `// ${operationName} is empty. Add gates to the circuit to generate Q#.\n`; + } else { + try { + // Validate the JSON shape on the host first so an unparseable + // file produces a friendly comment rather than a wasm panic. + JSON.parse(text); + } catch (err: any) { + if (requestId > lastAppliedRequestId) { + lastAppliedRequestId = requestId; + provider.setContent( + previewUri, + previewErrorComment( + `Circuit file is not valid JSON: ${err?.message ?? err}`, + ), + ); + } + return; + } + + try { + const worker = getPreviewWorker(); + const circuits = JSON.parse(text); + qsharp = await worker.circuitsToQsharp(operationName, circuits); + } catch (err: any) { + if (requestId > lastAppliedRequestId) { + lastAppliedRequestId = requestId; + provider.setContent( + previewUri, + previewErrorComment( + `Could not generate Q#: ${err?.message ?? err}`, + ), + ); + } + return; + } + } + + // Drop stale results so a slow generation can't overwrite a newer one. + if (requestId <= lastAppliedRequestId) return; + lastAppliedRequestId = requestId; + provider.setContent(previewUri, qsharp); + }; + + const schedulePreviewRefresh = () => { + if (previewTimer) clearTimeout(previewTimer); + previewTimer = setTimeout(() => { + previewTimer = undefined; + // Fire-and-forget; errors are already surfaced into the preview text. + void refreshPreviewNow(); + }, PREVIEW_DEBOUNCE_MS); + }; + webviewPanel.webview.onDidReceiveMessage(async (e) => { switch (e.command) { case "update": @@ -92,6 +192,12 @@ export class CircuitEditorProvider implements vscode.CustomTextEditorProvider { // Update the webview with the new document content updateWebview(); } + // Refresh the preview for any change (including ones initiated by + // the webview itself), so external edits and webview edits stay + // in sync with the side-by-side Q# preview. + if (event.contentChanges.length > 0) { + schedulePreviewRefresh(); + } } }, ); @@ -99,7 +205,24 @@ export class CircuitEditorProvider implements vscode.CustomTextEditorProvider { // Dispose of the event listener when the webview is closed webviewPanel.onDidDispose(() => { changeDocumentSubscription.dispose(); + if (previewTimer) { + clearTimeout(previewTimer); + previewTimer = undefined; + } + if (previewWorker) { + previewWorker.terminate(); + previewWorker = undefined; + } + // Drop cached preview content for this document so a subsequent open + // doesn't briefly show stale Q# before the first refresh completes. + getCircuitPreviewProvider()?.clearContent(previewUri); }); + + // Generate the initial preview content and open the preview tab beside + // the circuit. Both are fire-and-forget: failures only affect the side + // panel, never the circuit editor itself. + void refreshPreviewNow(); + void openPreviewBeside(previewUri); } private getHtmlForWebview(webview: vscode.Webview): string { @@ -241,3 +364,64 @@ export async function generateQubitCircuitExpression( ); } } + +/** + * Derive the Q# operation name shown in the preview from the circuit URI. + * + * Mirrors the convention used by `CircuitEditorProvider.updateWebview`, which + * derives the title from the basename minus extension. Falls back to a safe + * default for URIs without a recognizable basename. + */ +function previewOperationNameFor(circuitUri: vscode.Uri): string { + const basename = circuitUri.path.split(/[\\/]/).pop() ?? ""; + const name = basename.replace(/\.[^/.]+$/, ""); + return name.length > 0 ? name : "Circuit"; +} + +/** + * Format an error message as a Q# comment block so the preview tab keeps + * rendering as valid Q# even when generation fails. + */ +function previewErrorComment(message: string): string { + const lines = String(message).split(/\r?\n/); + return [ + "// Q# preview unavailable for the current circuit:", + ...lines.map((line) => `// ${line}`), + "", + ].join("\n"); +} + +/** + * Reveal (or open) the Q# preview document in the editor group beside the + * circuit. Best-effort: if VS Code rejects the open (e.g. no editor group is + * available), the preview simply isn't shown and the circuit editor is + * unaffected. + */ +async function openPreviewBeside(previewUri: vscode.Uri): Promise { + try { + // If the preview is already showing in some tab, reveal that one instead + // of opening a duplicate. This handles the common case of toggling back + // to a circuit whose preview was opened earlier in this session. + for (const group of vscode.window.tabGroups.all) { + for (const tab of group.tabs) { + const input = tab.input as { uri?: vscode.Uri } | undefined; + if (input?.uri?.toString() === previewUri.toString()) { + await vscode.window.showTextDocument(previewUri, { + viewColumn: group.viewColumn, + preserveFocus: true, + preview: false, + }); + return; + } + } + } + + await vscode.window.showTextDocument(previewUri, { + viewColumn: vscode.ViewColumn.Beside, + preserveFocus: true, + preview: false, + }); + } catch { + // Best-effort; the circuit editor itself works without the preview. + } +} From 24aba3663d327e232c5e230d0decb6c6bd30e49b Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Thu, 7 May 2026 16:39:57 -0700 Subject: [PATCH 04/87] Clean up and command for showing preview --- source/vscode/package.json | 22 ++++ source/vscode/src/circuitEditor.ts | 180 +++++++++++++++++++++-------- source/vscode/src/extension.ts | 44 ++++++- 3 files changed, 199 insertions(+), 47 deletions(-) diff --git a/source/vscode/package.json b/source/vscode/package.json index 58c31448a2e..ee106a5b496 100644 --- a/source/vscode/package.json +++ b/source/vscode/package.json @@ -141,6 +141,11 @@ } } }, + "Q#.circuits.showCodePreview": { + "type": "boolean", + "default": true, + "markdownDescription": "Automatically open a live, read-only Q# preview alongside circuit (`.qsc`) files. The preview updates as the circuit is edited." + }, "Q#.simulation.pauliNoise": { "markdownDescription": "The Pauli noise to apply when running multiple shots via the Histogram command. This is applied for every gate or measurement on all qubits referenced.\n\nProbability values are in the range [0, 1].", "type": "object", @@ -221,6 +226,13 @@ "group": "navigation@2" } ], + "editor/title": [ + { + "command": "qsharp-vscode.showCircuitCodePreview", + "when": "activeCustomEditorId == qsharp-webview.circuit", + "group": "navigation@1" + } + ], "commandPalette": [ { "command": "qsharp-vscode.runProgram", @@ -286,6 +298,10 @@ "command": "qsharp-vscode.showCircuit", "when": "resourceLangId == qsharp || resourceLangId == openqasm" }, + { + "command": "qsharp-vscode.showCircuitCodePreview", + "when": "activeCustomEditorId == qsharp-webview.circuit" + }, { "command": "qsharp-vscode.showDocumentation", "when": "resourceLangId == qsharp" @@ -457,6 +473,12 @@ "title": "Show circuit", "category": "QDK" }, + { + "command": "qsharp-vscode.showCircuitCodePreview", + "title": "Show Q# preview for circuit", + "category": "QDK", + "icon": "$(open-preview)" + }, { "command": "qsharp-vscode.showDocumentation", "title": "Show API documentation", diff --git a/source/vscode/src/circuitEditor.ts b/source/vscode/src/circuitEditor.ts index 4b81401c1d6..8103527bc42 100644 --- a/source/vscode/src/circuitEditor.ts +++ b/source/vscode/src/circuitEditor.ts @@ -19,6 +19,40 @@ import { runProgramInTerminal } from "./run"; */ const PREVIEW_DEBOUNCE_MS = 200; +/** + * Settings key for the auto-open preview behaviour. Read on editor open and + * watched for changes so the user can flip it without restarting. + */ +const PREVIEW_SETTING_SECTION = "Q#"; +const PREVIEW_SETTING_KEY = "circuits.showCodePreview"; + +function previewAutoOpenEnabled(): boolean { + return vscode.workspace + .getConfiguration(PREVIEW_SETTING_SECTION) + .get(PREVIEW_SETTING_KEY, true); +} + +/** + * Per-open-circuit hooks exposed to the showCircuitCodePreview command so it + * can request the preview for whichever circuit is currently active without + * the command needing to know about the editor's internal plumbing. + */ +interface CircuitPreviewController { + show: () => Promise; +} + +const previewControllers = new Map(); + +/** + * Look up the controller for a circuit document URI, if one is currently open. + * Used by the showCircuitCodePreview command. + */ +export function getCircuitPreviewController( + circuitUri: vscode.Uri, +): CircuitPreviewController | undefined { + return previewControllers.get(circuitUri.toString()); +} + export class CircuitEditorProvider implements vscode.CustomTextEditorProvider { private static readonly viewType = "qsharp-webview.circuit"; updatingDocument: boolean = false; @@ -55,6 +89,10 @@ export class CircuitEditorProvider implements vscode.CustomTextEditorProvider { // the compiler worker can be discarded (latest edit wins). let previewRequestId = 0; let lastAppliedRequestId = -1; + // Cache of the last content we pushed to the preview provider. Used to + // suppress redundant updates (notably during error storms when every + // keystroke produces the same "invalid JSON" comment). + let lastAppliedContent: string | undefined; const getPreviewWorker = (): ICompilerWorker => { if (!previewWorker) { @@ -63,6 +101,18 @@ export class CircuitEditorProvider implements vscode.CustomTextEditorProvider { return previewWorker; }; + const applyPreviewContent = (content: string, requestId: number) => { + if (requestId <= lastAppliedRequestId) return; + lastAppliedRequestId = requestId; + // Avoid no-op updates: TextDocumentContentProvider.onDidChange would + // otherwise force VS Code to re-tokenize and refresh the editor for + // identical content (common when the same JSON-parse error repeats + // on every keystroke). + if (content === lastAppliedContent) return; + lastAppliedContent = content; + getCircuitPreviewProvider()?.setContent(previewUri, content); + }; + /** * Recompute the Q# preview for the current document content and push it * to the preview provider. Errors are surfaced inline as Q# comments so @@ -76,49 +126,46 @@ export class CircuitEditorProvider implements vscode.CustomTextEditorProvider { const text = document.getText(); const operationName = previewOperationNameFor(document.uri); - let qsharp: string; if (text.trim().length === 0) { - qsharp = `// ${operationName} is empty. Add gates to the circuit to generate Q#.\n`; - } else { - try { - // Validate the JSON shape on the host first so an unparseable - // file produces a friendly comment rather than a wasm panic. - JSON.parse(text); - } catch (err: any) { - if (requestId > lastAppliedRequestId) { - lastAppliedRequestId = requestId; - provider.setContent( - previewUri, - previewErrorComment( - `Circuit file is not valid JSON: ${err?.message ?? err}`, - ), - ); - } - return; - } + applyPreviewContent( + `// Q# preview — empty circuit\n// Add gates to ${operationName} to generate Q#.\n`, + requestId, + ); + return; + } - try { - const worker = getPreviewWorker(); - const circuits = JSON.parse(text); - qsharp = await worker.circuitsToQsharp(operationName, circuits); - } catch (err: any) { - if (requestId > lastAppliedRequestId) { - lastAppliedRequestId = requestId; - provider.setContent( - previewUri, - previewErrorComment( - `Could not generate Q#: ${err?.message ?? err}`, - ), - ); - } - return; - } + try { + // Validate the JSON shape on the host first so an unparseable + // file produces a friendly comment rather than a wasm panic. + JSON.parse(text); + } catch (err: any) { + applyPreviewContent( + previewErrorComment( + "invalid JSON", + `Circuit file is not valid JSON: ${err?.message ?? err}`, + ), + requestId, + ); + return; } - // Drop stale results so a slow generation can't overwrite a newer one. - if (requestId <= lastAppliedRequestId) return; - lastAppliedRequestId = requestId; - provider.setContent(previewUri, qsharp); + let qsharp: string; + try { + const worker = getPreviewWorker(); + const circuits = JSON.parse(text); + qsharp = await worker.circuitsToQsharp(operationName, circuits); + } catch (err: any) { + applyPreviewContent( + previewErrorComment( + "generation failed", + `Could not generate Q#: ${err?.message ?? err}`, + ), + requestId, + ); + return; + } + + applyPreviewContent(qsharp, requestId); }; const schedulePreviewRefresh = () => { @@ -130,6 +177,20 @@ export class CircuitEditorProvider implements vscode.CustomTextEditorProvider { }, PREVIEW_DEBOUNCE_MS); }; + /** + * Force the preview to be visible and current. Used by the + * showCircuitCodePreview command and by the config-change listener when + * the user flips the auto-open setting on while a circuit is open. + */ + const showPreview = async () => { + await refreshPreviewNow(); + await openPreviewBeside(previewUri); + }; + + // Make this editor's preview reachable from the showCircuitCodePreview + // command. Keyed by the original circuit URI (not the preview URI). + previewControllers.set(document.uri.toString(), { show: showPreview }); + webviewPanel.webview.onDidReceiveMessage(async (e) => { switch (e.command) { case "update": @@ -202,9 +263,32 @@ export class CircuitEditorProvider implements vscode.CustomTextEditorProvider { }, ); + // React to the user toggling the auto-open setting on. Toggling off + // intentionally leaves any already-open preview tab in place — the user + // can close it manually — but stops further auto-opens for new circuits. + let lastAutoOpenEnabled = previewAutoOpenEnabled(); + const configSubscription = vscode.workspace.onDidChangeConfiguration( + (e) => { + if ( + !e.affectsConfiguration( + `${PREVIEW_SETTING_SECTION}.${PREVIEW_SETTING_KEY}`, + ) + ) { + return; + } + const enabled = previewAutoOpenEnabled(); + if (enabled && !lastAutoOpenEnabled) { + void showPreview(); + } + lastAutoOpenEnabled = enabled; + }, + ); + // Dispose of the event listener when the webview is closed webviewPanel.onDidDispose(() => { changeDocumentSubscription.dispose(); + configSubscription.dispose(); + previewControllers.delete(document.uri.toString()); if (previewTimer) { clearTimeout(previewTimer); previewTimer = undefined; @@ -218,11 +302,13 @@ export class CircuitEditorProvider implements vscode.CustomTextEditorProvider { getCircuitPreviewProvider()?.clearContent(previewUri); }); - // Generate the initial preview content and open the preview tab beside - // the circuit. Both are fire-and-forget: failures only affect the side - // panel, never the circuit editor itself. + // Generate the initial preview content unconditionally so the command + // (or a later setting flip) gets an instant response. Only auto-open + // the side panel if the setting allows it. void refreshPreviewNow(); - void openPreviewBeside(previewUri); + if (lastAutoOpenEnabled) { + void openPreviewBeside(previewUri); + } } private getHtmlForWebview(webview: vscode.Webview): string { @@ -380,12 +466,14 @@ function previewOperationNameFor(circuitUri: vscode.Uri): string { /** * Format an error message as a Q# comment block so the preview tab keeps - * rendering as valid Q# even when generation fails. + * rendering as valid Q# even when generation fails. The `kind` shows up in + * the header so users can tell at a glance whether the issue is a malformed + * file vs. a compiler problem. */ -function previewErrorComment(message: string): string { +function previewErrorComment(kind: string, message: string): string { const lines = String(message).split(/\r?\n/); return [ - "// Q# preview unavailable for the current circuit:", + `// Q# preview unavailable — ${kind}`, ...lines.map((line) => `// ${line}`), "", ].join("\n"); diff --git a/source/vscode/src/extension.ts b/source/vscode/src/extension.ts index 8245fab277d..03b1de7c534 100644 --- a/source/vscode/src/extension.ts +++ b/source/vscode/src/extension.ts @@ -10,7 +10,10 @@ import { } from "qsharp-lang"; import * as vscode from "vscode"; import { initAzureWorkspaces } from "./azure/commands.js"; -import { CircuitEditorProvider } from "./circuitEditor.js"; +import { + CircuitEditorProvider, + getCircuitPreviewController, +} from "./circuitEditor.js"; import { registerCircuitPreviewProvider } from "./circuitPreview.js"; import { initProjectCreator } from "./createProject.js"; import { activateDebugger } from "./debugger/activate.js"; @@ -86,6 +89,30 @@ export async function activate( context.subscriptions.push(...registerQSharpNotebookHandlers()); context.subscriptions.push(registerCircuitPreviewProvider()); context.subscriptions.push(CircuitEditorProvider.register(context)); + context.subscriptions.push( + vscode.commands.registerCommand( + "qsharp-vscode.showCircuitCodePreview", + async (resource?: vscode.Uri) => { + const target = resource ?? activeCircuitDocumentUri(); + if (!target) { + void vscode.window.showInformationMessage( + "Open a circuit (.qsc) file to use the Q# preview.", + ); + return; + } + const controller = getCircuitPreviewController(target); + if (!controller) { + // Custom editor for this URI isn't currently resolved (e.g. the + // user invoked the command from the explorer for a circuit that + // isn't open). Open it first; the editor will register itself and + // auto-open the preview if the setting allows it. + await vscode.commands.executeCommand("vscode.open", target); + return; + } + await controller.show(); + }, + ), + ); context.subscriptions.push(...registerChangelogCommand(context)); /// Handle incoming workspace connection URIs. The URI will be in the format: @@ -193,6 +220,21 @@ function getViewColumnForLocations( return undefined; } +/** + * Find the URI of the circuit document backing the currently-active custom + * editor tab, if any. Returns undefined when the active tab isn't a circuit. + */ +function activeCircuitDocumentUri(): vscode.Uri | undefined { + const tab = vscode.window.tabGroups.activeTabGroup.activeTab; + if ( + tab?.input instanceof vscode.TabInputCustom && + tab.input.viewType === "qsharp-webview.circuit" + ) { + return tab.input.uri; + } + return undefined; +} + function parseLocations(locations: ILocation[]): vscode.Location[] { const uris = []; for (const loc of locations) { From 23fc1cd918cd01fac9e2b2acdfaa1055344b055d Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Fri, 8 May 2026 14:31:55 -0700 Subject: [PATCH 05/87] deep emitter with simple loop and cond handling --- .../qsc_circuit/src/circuit_to_qsharp.rs | 249 ++++++- .../src/circuit_to_qsharp/tests.rs | 632 ++++++++++++++++++ 2 files changed, 851 insertions(+), 30 deletions(-) diff --git a/source/compiler/qsc_circuit/src/circuit_to_qsharp.rs b/source/compiler/qsc_circuit/src/circuit_to_qsharp.rs index 15831f78ed5..5639abdcd99 100644 --- a/source/compiler/qsc_circuit/src/circuit_to_qsharp.rs +++ b/source/compiler/qsc_circuit/src/circuit_to_qsharp.rs @@ -10,7 +10,7 @@ use std::fmt::Write; use crate::{ Circuit, Operation, - circuit::{Ket, Measurement, Unitary}, + circuit::{ComponentGrid, Ket, Measurement, Unitary}, json_to_circuit::json_to_circuits, }; @@ -58,12 +58,12 @@ fn build_operation_def(circuit_name: &str, circuit: &Circuit) -> String { _ => "Result[]", }; - // Check if all operations are Unitary - let is_ctl_adj = !circuit.component_grid.iter().any(|col| { - col.components - .iter() - .any(|op| !matches!(op, Operation::Unitary(_))) - }); + // Check if all operations (recursively) are unitaries — only then can the + // emitted operation declare `is Ctl + Adj`. We have to descend into + // structural groups (loops, conditionals) because a measurement nested + // inside a loop disqualifies the operation just as much as a measurement + // at the top level. + let is_ctl_adj = grid_is_all_unitary(&circuit.component_grid); let characteristics = if is_ctl_adj { "is Ctl + Adj " } else { "" }; let summary = if qubits.is_empty() { @@ -100,47 +100,236 @@ fn build_operation_def(circuit_name: &str, circuit: &Circuit) -> String { let mut body_str = String::new(); let mut should_add_pi = false; - // Note: In the future we may want to add support for children operations - for col in &circuit.component_grid { + // The trace-derived form of a circuit wraps the entire body in a single + // outer call to the entry-point operation (e.g. `Main` with the whole + // body in `children`). Calling that name here would emit a call to a + // non-existent operation and skip the body entirely, so we unwrap one + // level when we see that shape. Editor-authored circuits never produce + // this shape — custom-gate calls don't carry their body as children. + let body_grid = match unwrap_entry_point_wrapper(&circuit.component_grid) { + Some(inner) => inner, + None => &circuit.component_grid, + }; + + process_components( + body_grid, + &qubits, + indentation_level, + &mut measure_results, + &mut should_add_pi, + &mut body_str, + ); + + if should_add_pi { + // Add the π constant + writeln!(qsharp_str, "{indent}let π = Std.Math.PI();") + .expect("could not write to qsharp_str"); + } + + qsharp_str.push_str(body_str.as_str()); + qsharp_str.push_str(&generate_return_statement(&mut measure_results, &indent)); + qsharp_str.push_str("}\n\n"); + qsharp_str +} + +/// Recursively emits Q# for the given grid of components into `out`. +/// +/// Most operations emit a single call. The exception is structural groups +/// (loops, conditionals, anonymous scopes, loop-iteration wrappers) — these +/// don't correspond to real Q# operations, so calling them by name would +/// produce code that doesn't compile. Instead we recurse into their children +/// and surface the structure as Q# comments. As the editor learns to author +/// these constructs natively (loops, conditionals, …), each case here will +/// graduate from a `// loop: …` comment to a real `for` / `if` block. +/// +/// Custom-gate groups (e.g. `Foo` with a `children` expansion of its body) +/// are *not* treated as structural — the call to `Foo` is what we want to +/// preserve, and the user's project supplies the body. +fn process_components( + grid: &ComponentGrid, + qubits: &FxHashMap, + indentation_level: usize, + measure_results: &mut Vec<(String, (usize, usize))>, + should_add_pi: &mut bool, + out: &mut String, +) { + let indent = " ".repeat(indentation_level); + for col in grid { for op in &col.components { + // Structural groups are inlined rather than emitted as a call. + if let Operation::Unitary(u) = op + && !u.children.is_empty() + && let Some(kind) = structural_group_kind(&u.gate) + { + emit_structural_group( + kind, + &u.gate, + &u.children, + qubits, + indentation_level, + measure_results, + should_add_pi, + out, + ); + continue; + } + match &op { Operation::Measurement(measurement) => { - body_str.push_str( - generate_measurement_call( - measurement, - &qubits, - &indent, - &mut measure_results, - ) - .as_str(), - ); + out.push_str(&generate_measurement_call( + measurement, + qubits, + &indent, + measure_results, + )); } Operation::Unitary(unitary) => { - body_str.push_str(generate_unitary_call(unitary, &qubits, &indent).as_str()); + out.push_str(&generate_unitary_call(unitary, qubits, &indent)); } Operation::Ket(ket) => { - body_str.push_str(generate_ket_call(ket, &qubits, &indent).as_str()); + out.push_str(&generate_ket_call(ket, qubits, &indent)); } } // Look for a "π" in the args let args = op.args(); - if !should_add_pi && !args.is_empty() { - should_add_pi = args.iter().any(|arg| arg.contains("π")); + if !*should_add_pi && !args.is_empty() { + *should_add_pi = args.iter().any(|arg| arg.contains("π")); } } } +} - if should_add_pi { - // Add the π constant - writeln!(qsharp_str, "{indent}let π = Std.Math.PI();") - .expect("could not write to qsharp_str"); +/// Categorization of structural group names produced by the circuit tracer. +/// Any variant other than [`StructuralGroupKind::Iteration`] gets a +/// human-readable comment header in the emitted Q#. +#[derive(Clone, Copy)] +enum StructuralGroupKind { + Loop, + Conditional, + /// A loop-iteration wrapper such as `(0)`, `(1)`. Its children are the + /// iteration body and we recurse silently — adding visible markers for + /// every iteration would dwarf the actual Q#. + Iteration, + /// ``, ``, or any other compiler-synthesized scope label + /// that doesn't map to a callable. + AnonymousScope, +} + +fn structural_group_kind(name: &str) -> Option { + if name.starts_with("loop:") { + Some(StructuralGroupKind::Loop) + } else if name.starts_with("if:") { + Some(StructuralGroupKind::Conditional) + } else if is_iteration_marker(name) { + Some(StructuralGroupKind::Iteration) + } else if name == "" || name == "" { + Some(StructuralGroupKind::AnonymousScope) + } else { + None } +} - qsharp_str.push_str(body_str.as_str()); - qsharp_str.push_str(&generate_return_statement(&mut measure_results, &indent)); - qsharp_str.push_str("}\n\n"); - qsharp_str +/// Matches a loop-iteration wrapper name like `(0)`, `(12)`. We deliberately +/// require ASCII digits and the literal parens so we don't accidentally +/// classify a custom gate named e.g. `(Reset)` as an iteration wrapper. +fn is_iteration_marker(name: &str) -> bool { + let bytes = name.as_bytes(); + bytes.len() >= 3 + && bytes[0] == b'(' + && bytes[bytes.len() - 1] == b')' + && bytes[1..bytes.len() - 1].iter().all(u8::is_ascii_digit) +} + +#[allow(clippy::too_many_arguments)] +fn emit_structural_group( + kind: StructuralGroupKind, + name: &str, + children: &ComponentGrid, + qubits: &FxHashMap, + indentation_level: usize, + measure_results: &mut Vec<(String, (usize, usize))>, + should_add_pi: &mut bool, + out: &mut String, +) { + let indent = " ".repeat(indentation_level); + + // Iteration markers emit a single header comment with no closing + // marker — the next iteration (or the enclosing `// end loop`) closes + // the visual scope. Keeping them visible (rather than transparent) is + // important when loop iterations are structurally different: without + // these markers there's no way for the reader to tell which gates + // belong to which iteration. + if matches!(kind, StructuralGroupKind::Iteration) { + writeln!(out, "{indent}// iteration {name}").expect("could not write to out"); + process_components( + children, + qubits, + indentation_level, + measure_results, + should_add_pi, + out, + ); + return; + } + + let footer = match kind { + StructuralGroupKind::Loop => "// end loop", + StructuralGroupKind::Conditional => "// end if", + StructuralGroupKind::AnonymousScope => "// end scope", + StructuralGroupKind::Iteration => unreachable!("handled above"), + }; + writeln!(out, "{indent}// {name}").expect("could not write to out"); + process_components( + children, + qubits, + indentation_level, + measure_results, + should_add_pi, + out, + ); + writeln!(out, "{indent}{footer}").expect("could not write to out"); +} + +/// Returns true iff every operation in `grid` (and recursively in any +/// children) is a [`Operation::Unitary`]. Used to decide whether the emitted +/// operation can declare `is Ctl + Adj`. +fn grid_is_all_unitary(grid: &ComponentGrid) -> bool { + grid.iter().all(|col| { + col.components + .iter() + .all(|op| matches!(op, Operation::Unitary(_)) && grid_is_all_unitary(op.children())) + }) +} + +/// Detects the trace-derived "entry-point wrapper" shape: a top-level grid +/// containing exactly one column with exactly one unitary that has children +/// AND whose name does *not* identify a structural group (loops, +/// conditionals, scopes — those are real circuit structure that we must +/// preserve, not wrappers to unwrap). The wrapper's name is the entry-point +/// operation that was traced (e.g. `Main`). Emitting it as a call would +/// point at an operation that doesn't exist in the user's preview and would +/// skip the body entirely. +/// +/// Returns the inner grid to use as the body, or `None` if the grid is not +/// in entry-point-wrapper shape. +fn unwrap_entry_point_wrapper(grid: &ComponentGrid) -> Option<&ComponentGrid> { + if grid.len() != 1 { + return None; + } + let col = grid.first()?; + if col.components.len() != 1 { + return None; + } + let only = col.components.first()?; + match only { + Operation::Unitary(u) + if !u.children.is_empty() && structural_group_kind(&u.gate).is_none() => + { + Some(&u.children) + } + _ => None, + } } fn generate_qubit_validation( diff --git a/source/compiler/qsc_circuit/src/circuit_to_qsharp/tests.rs b/source/compiler/qsc_circuit/src/circuit_to_qsharp/tests.rs index 429ba4cc109..164e0315ab3 100644 --- a/source/compiler/qsc_circuit/src/circuit_to_qsharp/tests.rs +++ b/source/compiler/qsc_circuit/src/circuit_to_qsharp/tests.rs @@ -665,3 +665,635 @@ fn circuit_with_ctrl_adj_sqrt_x_gate() { "#]], ); } + +// --------------------------------------------------------------------------- +// Recursive emission of structural groups (loops, conditionals, scopes, +// loop-iteration wrappers). These groups are produced by the circuit tracer +// and don't correspond to real callable operations — emitting them as a call +// to e.g. `loop: 0..3(qs)` would be nonsense. Instead the emitter recurses +// into their children and surfaces the structure as Q# comments. +// +// Custom-gate groups (a Unitary whose name *does* refer to a real operation +// in the user's project) are deliberately preserved as a call so the emitted +// preview keeps the user's abstraction. +// --------------------------------------------------------------------------- + +#[test] +fn custom_gate_with_children_emits_call_not_inline() { + // A custom gate `Foo` carries its body in `children` for visualization + // purposes. We must keep emitting `Foo(...)` — inlining would duplicate + // code that the user has already defined elsewhere in the project. + // + // The test uses two top-level components so the entry-point-wrapper + // unwrap heuristic doesn't apply (which is reserved for the case where + // the trace wraps the entire body in a single non-existent operation). + check( + r#" +{ + "componentGrid": [ + { + "components": [ + { + "kind": "unitary", + "gate": "Foo", + "targets": [{ "qubit": 0 }, { "qubit": 1 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + }, + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 1 }] } + ] + } + ] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "Foo", + "targets": [{ "qubit": 0 }, { "qubit": 1 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + } + ] + } + ] + } + ], + "qubits": [{ "id": 0 }, { "id": 1 }] +}"#, + &expect![[r#" + /// Expects a qubit register of at least 2 qubits. + operation Test(qs : Qubit[]) : Unit is Ctl + Adj { + if Length(qs) < 2 { + fail "Invalid number of qubits. Operation Test expects a qubit register of at least 2 qubits."; + } + Foo(qs[0], qs[1]); + Foo(qs[0], qs[1]); + } + + "#]], + ); +} + +#[test] +fn loop_group_inlines_with_comment_header() { + // The `loop: 0..3` outer group becomes a `// loop:` … `// end loop` + // pair; each `(N)` iteration wrapper becomes a single-line + // `// iteration (N)` marker so the reader can see iteration + // boundaries even when iteration bodies differ structurally. + check( + r#" +{ + "componentGrid": [ + { + "components": [ + { + "kind": "unitary", + "gate": "loop: 0..3", + "targets": [{ "qubit": 0 }, { "qubit": 1 }], + "children": [ + { + "components": [ + { + "kind": "unitary", + "gate": "(0)", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + } + ] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "(1)", + "targets": [{ "qubit": 1 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 1 }] } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "qubits": [{ "id": 0 }, { "id": 1 }] +}"#, + &expect![[r#" + /// Expects a qubit register of at least 2 qubits. + operation Test(qs : Qubit[]) : Unit is Ctl + Adj { + if Length(qs) < 2 { + fail "Invalid number of qubits. Operation Test expects a qubit register of at least 2 qubits."; + } + // loop: 0..3 + // iteration (0) + H(qs[0]); + // iteration (1) + H(qs[1]); + // end loop + } + + "#]], + ); +} + +#[test] +fn conditional_group_inlines_with_comment_header() { + // The `if:` group's header becomes a comment; its body inlines. + // The inner H carries no classical controls itself (those live on the + // outer group), so it emits as a plain `H(qs[0])` — exactly what we + // want when the outer conditional is rendered as a comment. + check( + r#" +{ + "componentGrid": [ + { + "components": [ + { "kind": "measurement", "gate": "M", "qubits": [{ "qubit": 0 }], "results": [{ "qubit": 0, "result": 0 }] } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "if: c_0 == One", + "targets": [{ "qubit": 0 }, { "qubit": 0, "result": 0 }], + "controls": [{ "qubit": 0, "result": 0 }], + "isConditional": true, + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + } + ] + } + ] + } + ], + "qubits": [{ "id": 0, "numResults": 1 }] +}"#, + &expect![[r#" + /// Expects a qubit register of at least 1 qubits. + operation Test(qs : Qubit[]) : Result { + if Length(qs) < 1 { + fail "Invalid number of qubits. Operation Test expects a qubit register of at least 1 qubits."; + } + let c0_0 = M(qs[0]); + // if: c_0 == One + H(qs[0]); + // end if + return c0_0; + } + + "#]], + ); +} + +#[test] +fn anonymous_scope_inlines_with_comment_header() { + // `` and `` are compiler-synthesized labels for groupings + // that have no callable name. They emit as anonymous scope comments. + check( + r#" +{ + "componentGrid": [ + { + "components": [ + { + "kind": "unitary", + "gate": "", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "X", "targets": [{ "qubit": 0 }] } + ] + } + ] + } + ] + } + ], + "qubits": [{ "id": 0 }] +}"#, + &expect![[r#" + /// Expects a qubit register of at least 1 qubits. + operation Test(qs : Qubit[]) : Unit is Ctl + Adj { + if Length(qs) < 1 { + fail "Invalid number of qubits. Operation Test expects a qubit register of at least 1 qubits."; + } + // + X(qs[0]); + // end scope + } + + "#]], + ); +} + +#[test] +fn measurement_nested_in_loop_disqualifies_ctl_adj() { + // The grid_is_all_unitary check must descend into structural groups — + // a measurement inside a loop body is just as much a non-unitary as + // one at the top level, and the emitted operation must NOT declare + // `is Ctl + Adj`. + check( + r#" +{ + "componentGrid": [ + { + "components": [ + { + "kind": "unitary", + "gate": "loop: 0..1", + "targets": [{ "qubit": 0 }, { "qubit": 0, "result": 0 }], + "children": [ + { + "components": [ + { + "kind": "unitary", + "gate": "(0)", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { "kind": "measurement", "gate": "M", "qubits": [{ "qubit": 0 }], "results": [{ "qubit": 0, "result": 0 }] } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "qubits": [{ "id": 0, "numResults": 1 }] +}"#, + &expect![[r#" + /// Expects a qubit register of at least 1 qubits. + operation Test(qs : Qubit[]) : Result { + if Length(qs) < 1 { + fail "Invalid number of qubits. Operation Test expects a qubit register of at least 1 qubits."; + } + // loop: 0..1 + // iteration (0) + let c0_0 = M(qs[0]); + // end loop + return c0_0; + } + + "#]], + ); +} + +#[test] +fn nested_structural_groups_compose() { + // A loop with two iterations, the second of which contains an `if:` + // group. Verifies that nested structural groups compose cleanly and + // that the inner conditional's children are reachable. + check( + r#" +{ + "componentGrid": [ + { + "components": [ + { + "kind": "unitary", + "gate": "loop: 0..1", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { + "kind": "unitary", + "gate": "(0)", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + } + ] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "(1)", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { + "kind": "unitary", + "gate": "if: c_0 == One", + "targets": [{ "qubit": 0 }], + "controls": [{ "qubit": 0, "result": 0 }], + "isConditional": true, + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "X", "targets": [{ "qubit": 0 }] } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "qubits": [{ "id": 0 }] +}"#, + &expect![[r#" + /// Expects a qubit register of at least 1 qubits. + operation Test(qs : Qubit[]) : Unit is Ctl + Adj { + if Length(qs) < 1 { + fail "Invalid number of qubits. Operation Test expects a qubit register of at least 1 qubits."; + } + // loop: 0..1 + // iteration (0) + H(qs[0]); + // iteration (1) + // if: c_0 == One + X(qs[0]); + // end if + // end loop + } + + "#]], + ); +} + +#[test] +fn bare_iteration_marker_emits_visible_header() { + // A bare `(0)` wrapper is rare — the tracer normally produces them + // only inside a `loop:` group — but if one shows up at the top level + // we still want it visible. The header is a single-line marker (no + // closing comment), since iteration boundaries are implicitly closed + // by the next iteration or the enclosing loop's `// end loop`. + // + // (Two top-level components keep the entry-point-wrapper unwrap from + // hiding the marker.) + check( + r#" +{ + "componentGrid": [ + { + "components": [ + { + "kind": "unitary", + "gate": "(0)", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + } + ] + } + ] + }, + { + "components": [ + { "kind": "unitary", "gate": "X", "targets": [{ "qubit": 0 }] } + ] + } + ], + "qubits": [{ "id": 0 }] +}"#, + &expect![[r#" + /// Expects a qubit register of at least 1 qubits. + operation Test(qs : Qubit[]) : Unit is Ctl + Adj { + if Length(qs) < 1 { + fail "Invalid number of qubits. Operation Test expects a qubit register of at least 1 qubits."; + } + // iteration (0) + H(qs[0]); + X(qs[0]); + } + + "#]], + ); +} + +#[test] +#[allow(clippy::too_many_lines)] // long because the inline JSON mirrors a real trace +fn group_splitting_test_shape_emits_loop_with_asymmetric_iterations() { + // Trims a real trace produced by samples/circuit_integration/GroupSplittingTest.qs + // (entry-point wrapper, an outer `loop: 0..3` with structurally + // different iterations, including a conditional that only appears in + // later iterations and a custom-gate call to `Foo`). The point of + // this test is to lock down what the user sees when they open such a + // trace as a `.qsc` file: a flat sequence of gates inside `// loop` + // and `// if` markers, with custom gates preserved as calls. + // + // This test intentionally lives alongside the unit tests rather than + // in an integration harness so it stays in sync with the emitter + // output as we extend the structural-group handling. If it breaks + // because we taught the emitter real `for` / `if` syntax, the new + // expectation is the contract going forward. + check( + r#" +{ + "componentGrid": [ + { + "components": [ + { + "kind": "unitary", + "gate": "Main", + "targets": [{ "qubit": 0 }, { "qubit": 1 }, { "qubit": 2 }, { "qubit": 3 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "X", "targets": [{ "qubit": 1 }] }, + { "kind": "unitary", "gate": "X", "targets": [{ "qubit": 2 }] } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "loop: 0..3", + "targets": [{ "qubit": 0 }, { "qubit": 2 }, { "qubit": 3 }], + "children": [ + { + "components": [ + { + "kind": "unitary", + "gate": "(1)", + "targets": [{ "qubit": 0 }, { "qubit": 2 }, { "qubit": 3 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "X", "targets": [{ "qubit": 3 }] }, + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + }, + { + "components": [ + { "kind": "measurement", "gate": "M", "qubits": [{ "qubit": 0 }], "results": [{ "qubit": 0, "result": 0 }] } + ] + }, + { + "components": [ + { "kind": "ket", "gate": "0", "targets": [{ "qubit": 0 }] } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "Foo", + "targets": [{ "qubit": 0 }, { "qubit": 2 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] }, + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 2 }] } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "(2)", + "targets": [{ "qubit": 0 }, { "qubit": 2 }, { "qubit": 3 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "X", "targets": [{ "qubit": 3 }] }, + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "if: (f(c_0)) > (2)", + "targets": [{ "qubit": 0 }, { "qubit": 0, "result": 0 }], + "controls": [{ "qubit": 0, "result": 0 }], + "isConditional": true, + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + } + ] + } + ] + }, + { + "components": [ + { "kind": "measurement", "gate": "M", "qubits": [{ "qubit": 0 }], "results": [{ "qubit": 0, "result": 1 }] } + ] + }, + { + "components": [ + { "kind": "ket", "gate": "0", "targets": [{ "qubit": 0 }] } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "Foo", + "targets": [{ "qubit": 0 }, { "qubit": 2 }] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "qubits": [ + { "id": 0, "numResults": 2 }, + { "id": 1 }, + { "id": 2 }, + { "id": 3 } + ] +}"#, + &expect![[r#" + /// Expects a qubit register of at least 4 qubits. + operation Test(qs : Qubit[]) : Result[] { + if Length(qs) < 4 { + fail "Invalid number of qubits. Operation Test expects a qubit register of at least 4 qubits."; + } + X(qs[1]); + X(qs[2]); + // loop: 0..3 + // iteration (1) + X(qs[3]); + H(qs[0]); + let c0_0 = M(qs[0]); + Reset(qs[0]); + Foo(qs[0], qs[2]); + // iteration (2) + X(qs[3]); + H(qs[0]); + // if: (f(c_0)) > (2) + H(qs[0]); + // end if + let c0_1 = M(qs[0]); + Reset(qs[0]); + Foo(qs[0], qs[2]); + // end loop + return [c0_0, c0_1]; + } + + "#]], + ); +} From 790007a6f4644f9da3accd7a184f2b26423c917f Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Fri, 8 May 2026 15:08:55 -0700 Subject: [PATCH 06/87] add divergence banner --- .../qsc_circuit/src/circuit_to_qsharp.rs | 217 ++++++++- .../src/circuit_to_qsharp/tests.rs | 457 ++++++++++++++++++ 2 files changed, 672 insertions(+), 2 deletions(-) diff --git a/source/compiler/qsc_circuit/src/circuit_to_qsharp.rs b/source/compiler/qsc_circuit/src/circuit_to_qsharp.rs index 5639abdcd99..62c91754009 100644 --- a/source/compiler/qsc_circuit/src/circuit_to_qsharp.rs +++ b/source/compiler/qsc_circuit/src/circuit_to_qsharp.rs @@ -10,7 +10,7 @@ use std::fmt::Write; use crate::{ Circuit, Operation, - circuit::{ComponentGrid, Ket, Measurement, Unitary}, + circuit::{ComponentGrid, Ket, Measurement, SourceLocation, Unitary}, json_to_circuit::json_to_circuits, }; @@ -111,6 +111,14 @@ fn build_operation_def(circuit_name: &str, circuit: &Circuit) -> String { None => &circuit.component_grid, }; + // Scan the body for trace-only patterns the emitter can't faithfully + // recreate as Q# (e.g. loops with structurally divergent iterations, + // conditionals with opaque expressions). Any findings become a banner + // above the operation declaration so the reader knows the preview is + // approximate. Editor-authored circuits never trigger this banner + // because they only contain shapes the emitter can recreate exactly. + let divergence_banner = format_divergence_banner(&detect_divergences(body_grid)); + process_components( body_grid, &qubits, @@ -129,7 +137,14 @@ fn build_operation_def(circuit_name: &str, circuit: &Circuit) -> String { qsharp_str.push_str(body_str.as_str()); qsharp_str.push_str(&generate_return_statement(&mut measure_results, &indent)); qsharp_str.push_str("}\n\n"); - qsharp_str + // Prepend the divergence banner (if any) so it sits above the doc-comment + // and operation declaration. Computed earlier from the same body grid we + // emitted, so its line references stay consistent with what the user sees. + if divergence_banner.is_empty() { + qsharp_str + } else { + format!("{divergence_banner}{qsharp_str}") + } } /// Recursively emits Q# for the given grid of components into `out`. @@ -332,6 +347,204 @@ fn unwrap_entry_point_wrapper(grid: &ComponentGrid) -> Option<&ComponentGrid> { } } +// --------------------------------------------------------------------------- +// Trace-divergence detection +// +// The trace-derived form of a circuit can contain shapes that the editor +// (and therefore the emitter) can't recreate exactly as Q#: +// +// * `loop:` groups whose iterations are structurally different — produced +// when partial evaluation specialises iterations differently (e.g. an +// `if` body gets eliminated in iteration 0 but appears in iteration 2). +// A `for` loop has one body, so no `for` we emit could reproduce these +// iterations as-is. The recursive emitter already prints them as +// unrolled `// iteration (N)` blocks; the banner just calibrates the +// reader's expectation. +// +// * `if:` groups whose label is an opaque expression (e.g. +// `(f(c_0, c_1)) > (2)`) rather than a literal `c_N == One` / `Zero` +// comparison. The trace summarises arbitrary classical conditions +// opaquely; the original Q# expression is lost. +// +// Detection runs after `unwrap_entry_point_wrapper` so it walks the same +// grid the emitter prints. Findings are surfaced as a single banner above +// the operation declaration, naming each issue and (when available) its +// source line. +// --------------------------------------------------------------------------- + +/// One actionable finding from the divergence detector. Each becomes a +/// bullet line in the banner above the emitted operation. +struct DivergenceFinding { + kind: DivergenceKind, + /// The structural group's label as written in the circuit (e.g. + /// `"loop: 0..3"`, `"if: (f(c_0)) > (2)"`). Surfaced verbatim so the + /// reader can correlate it with the inline `// loop: ...` / `// if: ...` + /// comments in the body. + label: String, + location: Option, +} + +#[derive(Clone, Copy)] +enum DivergenceKind { + DivergentLoopIterations, + OpaqueConditional, +} + +fn detect_divergences(grid: &ComponentGrid) -> Vec { + let mut findings = vec![]; + walk_for_divergences(grid, &mut findings); + findings +} + +fn walk_for_divergences(grid: &ComponentGrid, findings: &mut Vec) { + for col in grid { + for op in &col.components { + if let Operation::Unitary(u) = op + && !u.children.is_empty() + { + match structural_group_kind(&u.gate) { + Some(StructuralGroupKind::Loop) => check_loop(u, findings), + Some(StructuralGroupKind::Conditional) => check_conditional(u, findings), + _ => {} + } + } + // Recurse into all children — divergences can be nested arbitrarily, + // and we want to surface every one in the banner. + walk_for_divergences(op.children(), findings); + } + } +} + +fn check_loop(loop_op: &Unitary, findings: &mut Vec) { + let iterations: Vec<&Unitary> = loop_op + .children + .iter() + .flat_map(|col| col.components.iter()) + .filter_map(|op| match op { + Operation::Unitary(u) if is_iteration_marker(&u.gate) => Some(u), + _ => None, + }) + .collect(); + + if iterations.len() < 2 { + return; + } + + let first_body = &iterations[0].children; + let all_equiv = iterations + .iter() + .skip(1) + .all(|it| grids_skeleton_equal(&it.children, first_body)); + + if !all_equiv { + findings.push(DivergenceFinding { + kind: DivergenceKind::DivergentLoopIterations, + label: loop_op.gate.clone(), + location: location_from_metadata(loop_op), + }); + } +} + +fn check_conditional(if_op: &Unitary, findings: &mut Vec) { + let label = if_op.gate.trim_start_matches("if:").trim(); + if !is_simple_conditional(label) { + findings.push(DivergenceFinding { + kind: DivergenceKind::OpaqueConditional, + label: if_op.gate.clone(), + location: location_from_metadata(if_op), + }); + } +} + +/// True iff `label` is a comparison the emitter could reproduce literally: +/// ` == One` or ` == Zero`. Anything more complex +/// (function calls, numeric comparisons, conjunctions) is opaque. +fn is_simple_conditional(label: &str) -> bool { + let Some((lhs, rhs)) = label.split_once("==") else { + return false; + }; + let lhs = lhs.trim(); + let rhs = rhs.trim(); + let lhs_is_ident = + !lhs.is_empty() && lhs.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'); + let rhs_is_result = rhs == "One" || rhs == "Zero"; + lhs_is_ident && rhs_is_result +} + +/// Two grids are skeleton-equal if their structure matches when register +/// indices are ignored. This is the equivalence we care about for loop +/// iterations: a uniform `for i in 0..N { H(qs[i]); }` produces N +/// iterations whose bodies share the same shape but reference different +/// qubits, and we must consider those equivalent. A divergent loop changes +/// the *shape* — different gates, an extra `if:` group, missing operations. +fn grids_skeleton_equal(a: &ComponentGrid, b: &ComponentGrid) -> bool { + if a.len() != b.len() { + return false; + } + a.iter().zip(b.iter()).all(|(ca, cb)| { + ca.components.len() == cb.components.len() + && ca + .components + .iter() + .zip(cb.components.iter()) + .all(|(oa, ob)| operations_skeleton_equal(oa, ob)) + }) +} + +fn operations_skeleton_equal(a: &Operation, b: &Operation) -> bool { + let same_kind = matches!( + (a, b), + (Operation::Measurement(_), Operation::Measurement(_)) + | (Operation::Unitary(_), Operation::Unitary(_)) + | (Operation::Ket(_), Operation::Ket(_)) + ); + if !same_kind { + return false; + } + if a.gate() != b.gate() { + return false; + } + grids_skeleton_equal(a.children(), b.children()) +} + +/// Pulls the most useful source location off a structural group's metadata. +/// Prefers `scope_location` (the `for`/`if` keyword) over `source` (often +/// the first gate inside the body). +fn location_from_metadata(u: &Unitary) -> Option { + let md = u.metadata.as_ref()?; + md.scope_location.clone().or_else(|| md.source.clone()) +} + +fn format_divergence_banner(findings: &[DivergenceFinding]) -> String { + if findings.is_empty() { + return String::new(); + } + + let mut out = String::new(); + out.push_str( + "// NOTE: This Q# preview was reconstructed from a circuit trace and is approximate.\n", + ); + out.push_str("// The original Q# source is the authoritative version.\n"); + + for finding in findings { + let location_suffix = finding + .location + .as_ref() + // Source locations are 0-indexed; editors display 1-indexed. + .map(|loc| format!(" (line {})", loc.line + 1)) + .unwrap_or_default(); + let descr = match finding.kind { + DivergenceKind::DivergentLoopIterations => { + "loop has structurally divergent iterations" + } + DivergenceKind::OpaqueConditional => "conditional uses an opaque expression", + }; + let _ = writeln!(out, "// - {descr}{location_suffix}: {}", finding.label); + } + + out +} + fn generate_qubit_validation( circuit_name: &str, qubits: &FxHashMap, diff --git a/source/compiler/qsc_circuit/src/circuit_to_qsharp/tests.rs b/source/compiler/qsc_circuit/src/circuit_to_qsharp/tests.rs index 164e0315ab3..daa4b884a2e 100644 --- a/source/compiler/qsc_circuit/src/circuit_to_qsharp/tests.rs +++ b/source/compiler/qsc_circuit/src/circuit_to_qsharp/tests.rs @@ -1040,6 +1040,9 @@ fn nested_structural_groups_compose() { "qubits": [{ "id": 0 }] }"#, &expect![[r#" + // NOTE: This Q# preview was reconstructed from a circuit trace and is approximate. + // The original Q# source is the authoritative version. + // - loop has structurally divergent iterations: loop: 0..1 /// Expects a qubit register of at least 1 qubits. operation Test(qs : Qubit[]) : Unit is Ctl + Adj { if Length(qs) < 1 { @@ -1267,6 +1270,10 @@ fn group_splitting_test_shape_emits_loop_with_asymmetric_iterations() { ] }"#, &expect![[r#" + // NOTE: This Q# preview was reconstructed from a circuit trace and is approximate. + // The original Q# source is the authoritative version. + // - loop has structurally divergent iterations: loop: 0..3 + // - conditional uses an opaque expression: if: (f(c_0)) > (2) /// Expects a qubit register of at least 4 qubits. operation Test(qs : Qubit[]) : Result[] { if Length(qs) < 4 { @@ -1297,3 +1304,453 @@ fn group_splitting_test_shape_emits_loop_with_asymmetric_iterations() { "#]], ); } + +// --------------------------------------------------------------------------- +// Trace-divergence detection +// +// These tests exercise the banner that appears above the operation when the +// circuit contains shapes the emitter can't faithfully recreate as Q#. +// Editor-authored circuits should never trigger the banner; trace-derived +// circuits with non-uniform loops or opaque conditionals should. +// --------------------------------------------------------------------------- + +#[test] +fn uniform_loop_emits_no_banner() { + // Two iterations whose bodies share the same shape (only the qubit + // index differs) — the canonical `for i in 0..1 { H(qs[i]); }` pattern. + // The detector must treat this as uniform and *not* emit a banner. + check( + r#" +{ + "componentGrid": [ + { + "components": [ + { + "kind": "unitary", + "gate": "loop: 0..1", + "targets": [{ "qubit": 0 }, { "qubit": 1 }], + "children": [ + { + "components": [ + { + "kind": "unitary", + "gate": "(0)", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + } + ] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "(1)", + "targets": [{ "qubit": 1 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 1 }] } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "qubits": [{ "id": 0 }, { "id": 1 }] +}"#, + &expect![[r#" + /// Expects a qubit register of at least 2 qubits. + operation Test(qs : Qubit[]) : Unit is Ctl + Adj { + if Length(qs) < 2 { + fail "Invalid number of qubits. Operation Test expects a qubit register of at least 2 qubits."; + } + // loop: 0..1 + // iteration (0) + H(qs[0]); + // iteration (1) + H(qs[1]); + // end loop + } + + "#]], + ); +} + +#[test] +fn divergent_loop_emits_banner_with_label() { + // Iteration (0) is just `H`; iteration (1) is `H` followed by `X`. + // The shapes don't match — divergence finding expected. + check( + r#" +{ + "componentGrid": [ + { + "components": [ + { + "kind": "unitary", + "gate": "loop: 0..1", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { + "kind": "unitary", + "gate": "(0)", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + } + ] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "(1)", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] }, + { "kind": "unitary", "gate": "X", "targets": [{ "qubit": 0 }] } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "qubits": [{ "id": 0 }] +}"#, + &expect![[r#" + // NOTE: This Q# preview was reconstructed from a circuit trace and is approximate. + // The original Q# source is the authoritative version. + // - loop has structurally divergent iterations: loop: 0..1 + /// Expects a qubit register of at least 1 qubits. + operation Test(qs : Qubit[]) : Unit is Ctl + Adj { + if Length(qs) < 1 { + fail "Invalid number of qubits. Operation Test expects a qubit register of at least 1 qubits."; + } + // loop: 0..1 + // iteration (0) + H(qs[0]); + // iteration (1) + H(qs[0]); + X(qs[0]); + // end loop + } + + "#]], + ); +} + +#[test] +fn simple_conditional_emits_no_banner() { + // `if: c_0 == One` is a label the emitter could reproduce literally as + // `if c_0 == One { ... }`, so no opaque-conditional finding is expected. + check( + r#" +{ + "componentGrid": [ + { + "components": [ + { "kind": "measurement", "gate": "M", "qubits": [{ "qubit": 0 }], "results": [{ "qubit": 0, "result": 0 }] } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "if: c_0 == One", + "targets": [{ "qubit": 0 }, { "qubit": 0, "result": 0 }], + "controls": [{ "qubit": 0, "result": 0 }], + "isConditional": true, + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + } + ] + } + ] + } + ], + "qubits": [{ "id": 0, "numResults": 1 }] +}"#, + &expect![[r#" + /// Expects a qubit register of at least 1 qubits. + operation Test(qs : Qubit[]) : Result { + if Length(qs) < 1 { + fail "Invalid number of qubits. Operation Test expects a qubit register of at least 1 qubits."; + } + let c0_0 = M(qs[0]); + // if: c_0 == One + H(qs[0]); + // end if + return c0_0; + } + + "#]], + ); +} + +#[test] +fn opaque_conditional_emits_banner_with_label() { + // Function-call / inequality labels can't be reproduced literally + // because the trace lost the original Q# expression. Expect a banner. + check( + r#" +{ + "componentGrid": [ + { + "components": [ + { "kind": "measurement", "gate": "M", "qubits": [{ "qubit": 0 }], "results": [{ "qubit": 0, "result": 0 }] } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "if: (f(c_0)) > (2)", + "targets": [{ "qubit": 0 }, { "qubit": 0, "result": 0 }], + "controls": [{ "qubit": 0, "result": 0 }], + "isConditional": true, + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + } + ] + } + ] + } + ], + "qubits": [{ "id": 0, "numResults": 1 }] +}"#, + &expect![[r#" + // NOTE: This Q# preview was reconstructed from a circuit trace and is approximate. + // The original Q# source is the authoritative version. + // - conditional uses an opaque expression: if: (f(c_0)) > (2) + /// Expects a qubit register of at least 1 qubits. + operation Test(qs : Qubit[]) : Result { + if Length(qs) < 1 { + fail "Invalid number of qubits. Operation Test expects a qubit register of at least 1 qubits."; + } + let c0_0 = M(qs[0]); + // if: (f(c_0)) > (2) + H(qs[0]); + // end if + return c0_0; + } + + "#]], + ); +} + +#[test] +fn divergence_banner_includes_source_line_when_available() { + // When the structural group carries a `scopeLocation` in its metadata, + // the banner surfaces the (1-indexed) line number so the reader can + // jump to the construct in the original `.qs` source. The trace stores + // line numbers 0-indexed, so a `"line": 5` should display as `line 6`. + check( + r#" +{ + "componentGrid": [ + { + "components": [ + { + "kind": "unitary", + "gate": "if: (a + b) > 0", + "targets": [{ "qubit": 0 }], + "metadata": { + "scopeLocation": { "file": "test.qs", "line": 5, "column": 4 }, + "controlResultIds": [] + }, + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + } + ] + } + ] + }, + { + "components": [ + { "kind": "unitary", "gate": "X", "targets": [{ "qubit": 0 }] } + ] + } + ], + "qubits": [{ "id": 0 }] +}"#, + &expect![[r#" + // NOTE: This Q# preview was reconstructed from a circuit trace and is approximate. + // The original Q# source is the authoritative version. + // - conditional uses an opaque expression (line 6): if: (a + b) > 0 + /// Expects a qubit register of at least 1 qubits. + operation Test(qs : Qubit[]) : Unit is Ctl + Adj { + if Length(qs) < 1 { + fail "Invalid number of qubits. Operation Test expects a qubit register of at least 1 qubits."; + } + // if: (a + b) > 0 + H(qs[0]); + // end if + X(qs[0]); + } + + "#]], + ); +} + +#[test] +#[allow(clippy::too_many_lines)] // long because the inline JSON describes two distinct loops +fn divergence_banner_is_per_finding_not_global() { + // Two divergent loops at different points produce two findings, each + // named individually. This confirms the banner doesn't collapse to a + // single generic "something is wrong" line. + check( + r#" +{ + "componentGrid": [ + { + "components": [ + { + "kind": "unitary", + "gate": "loop: 0..1", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { + "kind": "unitary", + "gate": "(0)", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + } + ] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "(1)", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "X", "targets": [{ "qubit": 0 }] } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "loop: 0..1", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { + "kind": "unitary", + "gate": "(0)", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "Y", "targets": [{ "qubit": 0 }] } + ] + } + ] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "(1)", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "Z", "targets": [{ "qubit": 0 }] }, + { "kind": "unitary", "gate": "S", "targets": [{ "qubit": 0 }] } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "qubits": [{ "id": 0 }] +}"#, + &expect![[r#" + // NOTE: This Q# preview was reconstructed from a circuit trace and is approximate. + // The original Q# source is the authoritative version. + // - loop has structurally divergent iterations: loop: 0..1 + // - loop has structurally divergent iterations: loop: 0..1 + /// Expects a qubit register of at least 1 qubits. + operation Test(qs : Qubit[]) : Unit is Ctl + Adj { + if Length(qs) < 1 { + fail "Invalid number of qubits. Operation Test expects a qubit register of at least 1 qubits."; + } + // loop: 0..1 + // iteration (0) + H(qs[0]); + // iteration (1) + X(qs[0]); + // end loop + // loop: 0..1 + // iteration (0) + Y(qs[0]); + // iteration (1) + Z(qs[0]); + S(qs[0]); + // end loop + } + + "#]], + ); +} From 14c750816a940fb40a28b621c2f7a46fb7520c46 Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Fri, 8 May 2026 15:48:40 -0700 Subject: [PATCH 07/87] bridge to export from circuit visual --- source/compiler/qsc_circuit/src/circuit.rs | 1 + .../src/circuit_to_qsharp/tests.rs | 57 ++++++ source/npm/qsharp/ux/circuit.tsx | 25 +++ source/npm/qsharp/ux/data.ts | 7 + source/vscode/src/circuit.ts | 184 +++++++++++++++++- source/vscode/src/webview/webview.tsx | 37 +++- source/vscode/src/webviewPanel.ts | 14 +- 7 files changed, 318 insertions(+), 7 deletions(-) diff --git a/source/compiler/qsc_circuit/src/circuit.rs b/source/compiler/qsc_circuit/src/circuit.rs index 3ef9e96ff52..8dd0b6a0a7a 100644 --- a/source/compiler/qsc_circuit/src/circuit.rs +++ b/source/compiler/qsc_circuit/src/circuit.rs @@ -355,6 +355,7 @@ pub struct Metadata { /// Only populated if this operation represents a scope group. pub scope_location: Option, #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(default)] /// Map from Register to "result ID" which will be used to display labels in UI pub control_result_ids: Vec<(Register, usize)>, } diff --git a/source/compiler/qsc_circuit/src/circuit_to_qsharp/tests.rs b/source/compiler/qsc_circuit/src/circuit_to_qsharp/tests.rs index daa4b884a2e..95782e5845d 100644 --- a/source/compiler/qsc_circuit/src/circuit_to_qsharp/tests.rs +++ b/source/compiler/qsc_circuit/src/circuit_to_qsharp/tests.rs @@ -1754,3 +1754,60 @@ fn divergence_banner_is_per_finding_not_global() { "#]], ); } + +#[test] +fn metadata_omitted_entirely_still_parses_and_emits_banner() { + // When a circuit is saved to a .qsc, the host strips `metadata` per the + // schema's documented intent. Regression-guard for two things at once: + // (1) deserialization succeeds without `controlResultIds` (it used to + // fail with "missing field controlResultIds" because `Vec` had + // `skip_serializing_if` but no matching `#[serde(default)]`). + // (2) the divergence detector still produces a banner from the gate + // label alone, just without the `(line N)` suffix that depends on + // `metadata.scopeLocation`. + check( + r#" +{ + "componentGrid": [ + { + "components": [ + { + "kind": "unitary", + "gate": "if: (a + b) > 0", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + } + ] + } + ] + }, + { + "components": [ + { "kind": "unitary", "gate": "X", "targets": [{ "qubit": 0 }] } + ] + } + ], + "qubits": [{ "id": 0 }] +}"#, + &expect![[r#" + // NOTE: This Q# preview was reconstructed from a circuit trace and is approximate. + // The original Q# source is the authoritative version. + // - conditional uses an opaque expression: if: (a + b) > 0 + /// Expects a qubit register of at least 1 qubits. + operation Test(qs : Qubit[]) : Unit is Ctl + Adj { + if Length(qs) < 1 { + fail "Invalid number of qubits. Operation Test expects a qubit register of at least 1 qubits."; + } + // if: (a + b) > 0 + H(qs[0]); + // end if + X(qs[0]); + } + + "#]], + ); +} diff --git a/source/npm/qsharp/ux/circuit.tsx b/source/npm/qsharp/ux/circuit.tsx index 1ed57b219f8..0fb8c505cd4 100644 --- a/source/npm/qsharp/ux/circuit.tsx +++ b/source/npm/qsharp/ux/circuit.tsx @@ -205,6 +205,16 @@ export function CircuitPanel(props: CircuitProps) { ) : null; + // Only offer the "Save as .qsc" affordance when the host wired up a + // handler AND we actually have a circuit to save. We deliberately keep + // the button hidden while calculating to avoid racing the underlying + // payload, and hidden in editor mode (the .qsc *is* the source there). + const canSave = + !!props.onSaveAsCircuit && + !!props.circuit && + !props.calculating && + !isEditable; + return (
@@ -220,6 +230,21 @@ export function CircuitPanel(props: CircuitProps) { and may change from run to run.

)} + {canSave && ( +

+ +

+ )}

Learn more at{" "} {isEditable ? ( diff --git a/source/npm/qsharp/ux/data.ts b/source/npm/qsharp/ux/data.ts index 659dfba0f26..9262320d79e 100644 --- a/source/npm/qsharp/ux/data.ts +++ b/source/npm/qsharp/ux/data.ts @@ -76,6 +76,13 @@ export type CircuitProps = { // Editor-specific handlers (callbacks, state-viz host offload, etc.). // When omitted, the circuit is rendered read-only. editor?: EditorHandlers; + /** + * Optional handler invoked when the user clicks "Save as Circuit (.qsc)…" + * in the panel toolbar. When provided, a button is rendered alongside the + * circuit; when omitted, no button appears (preserving the read-only + * panel UX for hosts that can't save, such as the Python widget). + */ + onSaveAsCircuit?: () => void; }; export type EditorHandlers = import("./circuit-vis/index.js").EditorHandlers; diff --git a/source/vscode/src/circuit.ts b/source/vscode/src/circuit.ts index b4d111403c0..76265191b67 100644 --- a/source/vscode/src/circuit.ts +++ b/source/vscode/src/circuit.ts @@ -10,7 +10,8 @@ import { QdkDiagnostics, log, } from "qsharp-lang"; -import { Uri, workspace } from "vscode"; +import { Uri, window, workspace } from "vscode"; +import * as vscode from "vscode"; import { getTargetFriendlyName } from "./config"; import { clearCommandDiagnostics } from "./diagnostics"; import { FullProgramConfig, getActiveProgram } from "./programConfig"; @@ -466,6 +467,21 @@ export function updateCircuitPanel( const target = `Target profile: ${getTargetFriendlyName(targetProfile)} `; + // Stash the latest circuit alongside a sensible default file name so the + // "Save as Circuit (.qsc)…" button on the webview can post a message + // back here without having to round-trip the entire payload again. + if (params.circuit) { + generatedCircuitCache.set(panelId, { + circuit: params.circuit, + suggestedFileName: title || projectName || panelId || "Circuit", + simulated: params.simulated || false, + }); + } else if (params.errorHtml || params.calculating) { + // The cached circuit no longer reflects what's on screen — drop it so + // the button stays hidden until the next successful generation. + generatedCircuitCache.delete(panelId); + } + const props = { title, targetProfile: target, @@ -473,6 +489,12 @@ export function updateCircuitPanel( calculating: params?.calculating || false, circuit: params?.circuit, errorHtml: params?.errorHtml, + // The webview swaps this flag for an actual `onSaveAsCircuit` callback + // before handing props to the React tree (see webview.tsx). We send a + // boolean rather than a function because postMessage can't ferry + // closures across the worker boundary. + canSaveAsCircuit: + !!params.circuit && !params.calculating && !params.errorHtml, }; const message = { @@ -481,6 +503,166 @@ export function updateCircuitPanel( sendMessageToPanel({ panelType: "circuit", id: panelId }, reveal, message); } +/** + * Per-panel cache of the most recent successfully generated circuit. Keyed + * by panelId (the same value the webview echoes back in its messages) so + * `handleSaveGeneratedCircuit` can locate the right payload without the + * webview having to round-trip the full circuit on every click. + */ +const generatedCircuitCache = new Map< + string, + { + circuit: CircuitData; + suggestedFileName: string; + simulated: boolean; + } +>(); + +/** + * Sanitize a string into a filesystem-safe basename. Strips path separators, + * Windows-reserved characters, and trims to a reasonable length so the + * default for the save dialog is always a valid file name. + */ +function sanitizeFileName(name: string): string { + const cleaned = name + .replace(/[\\/:*?"<>|]/g, "_") + .replace(/\s+/g, "_") + .replace(/^\.+|\.+$/g, "") + .slice(0, 80); + return cleaned.length > 0 ? cleaned : "Circuit"; +} + +/** + * Choose the directory the save dialog should default to. Prefers the + * folder of the currently active text editor (so the .qsc lands beside + * the .qs that produced it), then the first workspace folder, then no + * default at all. + */ +function defaultSaveDirectory(): Uri | undefined { + const active = window.activeTextEditor?.document.uri; + if (active && active.scheme === "file") { + return Uri.joinPath(active, ".."); + } + const folder = workspace.workspaceFolders?.[0]?.uri; + if (folder) return folder; + return undefined; +} + +/** + * Handler for the "Save as Circuit (.qsc)…" button on the QDK Circuit + * panel. Writes the cached circuit JSON to a user-chosen .qsc file and + * opens it with the Circuit Editor, which in turn brings up the live Q# + * preview alongside (per the existing setting). + * + * Best-effort: any failure surfaces as a notification rather than + * propagating, so a missing cache or denied write doesn't take down the + * webview. + */ +export async function handleSaveGeneratedCircuit(panelId: string) { + const cached = generatedCircuitCache.get(panelId); + if (!cached) { + void window.showWarningMessage( + "No circuit available to save yet. Generate the circuit first, then try again.", + ); + return; + } + + const baseName = sanitizeFileName(cached.suggestedFileName); + const baseDir = defaultSaveDirectory(); + const defaultUri = baseDir + ? Uri.joinPath(baseDir, `${baseName}.qsc`) + : Uri.file(`${baseName}.qsc`); + + const target = await window.showSaveDialog({ + defaultUri, + filters: { "Quantum Circuit": ["qsc"] }, + saveLabel: "Save Circuit", + title: "Save generated circuit as .qsc", + }); + if (!target) return; + + // The Circuit Editor and the panel's `Circuit` component both accept the + // CircuitGroup shape `{ version, circuits: [...] }` directly, which is + // exactly what wasm's getCircuit returns. Strip transient `metadata` (per + // the schema doc comment, those fields are not meant to be persisted in + // a .qsc file — they reference the originating .qs by absolute path and + // can also gain new required-on-read fields between releases). Pretty- + // print so users can inspect / hand-edit the JSON if they ever crack it + // open in a text editor. + const persistable = stripTransientMetadata(cached.circuit); + const json = JSON.stringify(persistable, null, 2); + try { + await workspace.fs.writeFile(target, new TextEncoder().encode(json)); + } catch (err: any) { + log.error("Failed to write generated circuit to .qsc", err); + void window.showErrorMessage( + `Could not save circuit: ${err?.message ?? err}`, + ); + return; + } + + try { + // openWith honours the customEditor registration for *.qsc, which + // routes through CircuitEditorProvider and (per the user's setting) + // opens the live Q# preview to the side automatically. + await vscode.commands.executeCommand( + "vscode.openWith", + target, + "qsharp-webview.circuit", + ); + } catch (err: any) { + // Fall back to a plain document open if the custom editor refused for + // some reason — the file is on disk either way. + log.warn("openWith(qsharp-webview.circuit) failed, opening as text", err); + await vscode.commands.executeCommand("vscode.open", target); + } + + // Trace-derived snapshots are necessarily approximate — surface that to + // the user once, where they can act on it (the divergence banner inside + // the live preview will reinforce the same point in code form). + if (cached.simulated) { + void window.showInformationMessage( + "Saved a trace snapshot. The live Q# preview will mark any non-uniform loops or opaque conditionals as approximate.", + ); + } +} + +/** + * Return a structural copy of `circuit` with all `metadata` fields removed. + * + * The Rust `Metadata` struct's doc comment is explicit: "the schema of + * Metadata may change and its contents are never meant to be persisted in a + * .qsc file." It also uses `skip_serializing_if = "Vec::is_empty"` on some + * Vec fields without matching `#[serde(default)]`, which historically + * produced asymmetric round-trips ("missing field `controlResultIds`") + * when the trace happened to leave those fields empty. + * + * Stripping at the host boundary is the most robust fix: it isolates + * future Metadata-shape changes from on-disk .qsc files, and means a + * snapshot saved today will continue to load on a future QDK build that + * adds new required Metadata fields. + */ +function stripTransientMetadata(circuit: T): T { + const seen = new WeakSet(); + const visit = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(visit); + if (value && typeof value === "object") { + if (seen.has(value as object)) return value; + seen.add(value as object); + const out: Record = {}; + for (const [key, child] of Object.entries( + value as Record, + )) { + if (key === "metadata") continue; // <-- the strip + out[key] = visit(child); + } + return out; + } + return value; + }; + return visit(circuit) as T; +} + /** * If the input is a URI, turns it into a document open link. * Otherwise returns the HTML-escaped input diff --git a/source/vscode/src/webview/webview.tsx b/source/vscode/src/webview/webview.tsx index 23b8ed3ced4..61b89081c70 100644 --- a/source/vscode/src/webview/webview.tsx +++ b/source/vscode/src/webview/webview.tsx @@ -53,7 +53,11 @@ type EstimatesState = { type CircuitState = { viewType: "circuit"; panelId: string; - props: CircuitProps; + // Stored shape carries the host's flag rather than a callback so that + // `vscodeApi.setState(state)` (structured-cloned) doesn't choke on a + // non-serializable function. The real `onSaveAsCircuit` callback is + // attached freshly at render time. + props: CircuitProps & { canSaveAsCircuit?: boolean }; }; type DocumentationState = { @@ -139,10 +143,18 @@ function onMessage(event: any) { state = helpState; break; case "circuit": + // Stash the host's payload verbatim, including the boolean save + // capability flag. The actual `onSaveAsCircuit` callback is wired + // up at render time so we never persist a function into the state + // object that gets structured-cloned by `vscodeApi.setState`. { + const incomingProps = (message.props ?? {}) as CircuitProps & { + canSaveAsCircuit?: boolean; + }; state = { viewType: "circuit", - ...message, + panelId: message.panelId, + props: incomingProps, }; } break; @@ -223,8 +235,25 @@ function App({ state }: { state: State }) { runNames={[]} /> ); - case "circuit": - return ; + case "circuit": // Hydrate the boolean `canSaveAsCircuit` capability flag into a real + // postMessage callback. Done at render time (not in state) so we + // never persist a function via `vscodeApi.setState`. + { + const { canSaveAsCircuit, ...rest } = state.props; + const onSaveAsCircuit = canSaveAsCircuit + ? () => + vscodeApi.postMessage({ + command: "saveGeneratedCircuit", + panelId: state.panelId, + }) + : undefined; + return ( + + ); + } case "help": return ; case "documentation": diff --git a/source/vscode/src/webviewPanel.ts b/source/vscode/src/webviewPanel.ts index e7f4c199378..a72733f8add 100644 --- a/source/vscode/src/webviewPanel.ts +++ b/source/vscode/src/webviewPanel.ts @@ -13,7 +13,7 @@ import { commands, window, } from "vscode"; -import { showCircuitCommand } from "./circuit"; +import { showCircuitCommand, handleSaveGeneratedCircuit } from "./circuit"; import { clearCommandDiagnostics } from "./diagnostics"; import { showDocumentationCommand } from "./documentation"; import { getActiveProgram } from "./programConfig"; @@ -379,9 +379,19 @@ export class QSharpWebViewPanel { this.panel.webview.postMessage(message), ); this._queuedMessages = []; + return; + } + + if (message.command === "saveGeneratedCircuit") { + // Posted by the "Save as Circuit (.qsc)…" button on a circuit + // panel. The webview only echoes the panel id; the host already + // has the circuit cached so we don't need to ferry it back. + const id = + typeof message.panelId === "string" ? message.panelId : this.id; + void handleSaveGeneratedCircuit(id); + return; } - // No messages are currently sent from the webview console.log("Message for webview received", message); }); } From 061135590cb92c42250e5cba9dd24abbbce18f39 Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Fri, 8 May 2026 18:04:14 -0700 Subject: [PATCH 08/87] Fix some cod view errors from Q# generation --- .../qsc_circuit/src/circuit_to_qsharp.rs | 527 +++++++++++-- .../src/circuit_to_qsharp/tests.rs | 695 ++++++++++++++++-- source/vscode/src/circuitPreview.ts | 296 +++++++- source/vscode/src/extension.ts | 9 +- 4 files changed, 1403 insertions(+), 124 deletions(-) diff --git a/source/compiler/qsc_circuit/src/circuit_to_qsharp.rs b/source/compiler/qsc_circuit/src/circuit_to_qsharp.rs index 62c91754009..3e5306bd4ad 100644 --- a/source/compiler/qsc_circuit/src/circuit_to_qsharp.rs +++ b/source/compiler/qsc_circuit/src/circuit_to_qsharp.rs @@ -5,34 +5,426 @@ mod tests; use regex_lite::{Captures, Regex}; -use rustc_hash::FxHashMap; +use rustc_hash::{FxHashMap, FxHashSet}; use std::fmt::Write; use crate::{ Circuit, Operation, - circuit::{ComponentGrid, Ket, Measurement, SourceLocation, Unitary}, + circuit::{ComponentGrid, Ket, Measurement, Qubit, Register, SourceLocation, Unitary}, json_to_circuit::json_to_circuits, }; pub fn circuits_to_qsharp(file_name: &str, circuits_json: &str) -> Result { - json_to_circuits(circuits_json).map(|circuits| build_circuits(file_name, &circuits.circuits)) + let safe_name = sanitize_qsharp_identifier(file_name); + json_to_circuits(circuits_json).map(|circuits| build_circuits(&safe_name, &circuits.circuits)) +} + +/// Coerce an arbitrary string (typically a `.qsc` file basename) into a +/// valid Q# identifier suitable for use as an operation name. +/// +/// The caller is the host's circuit-to-Q# bridge, which in normal use +/// passes a stripped file basename. Filenames are essentially unconstrained +/// — they can contain `.`, spaces, leading digits, unicode, etc. — but the +/// emitter uses this string verbatim as the operation name (`operation +/// (qs : Qubit[]) : ...`), and Q# requires a valid identifier there. +/// Without sanitization, perfectly reasonable filenames like +/// `GroupSplittingTest.Main.qsc` blow up downstream with a syntax error. +/// +/// Rules applied (deliberately conservative — preserves ASCII letters, +/// digits and underscore as-is, replaces everything else with `_`): +/// * Each char is kept if it is ASCII alphanumeric or `_`, otherwise `_`. +/// * If the result is empty or starts with a digit, a `_` is prefixed. +/// +/// Stable across calls so two circuits that map to the same sanitized name +/// will collide in the same way every time, which is the right behaviour +/// for a deterministic emitter — the alternative (hashing the original +/// name into the result) would silently change `operation` names every +/// release if our hash impl ever changed. +fn sanitize_qsharp_identifier(raw: &str) -> String { + let mut out: String = raw + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' { + c + } else { + '_' + } + }) + .collect(); + if out.is_empty() || out.chars().next().is_some_and(|c| c.is_ascii_digit()) { + out.insert(0, '_'); + } + out } fn build_circuits(file_name: &str, circuits: &[Circuit]) -> String { + // Names of operations the caller already plans to emit. We must not let + // an extracted custom-gate definition collide with one of these — the + // user's `Foo` circuit and an extracted `Foo` body would each compile + // to a duplicate definition. + let mut emitted_names: FxHashSet = FxHashSet::default(); + + // Queue of custom-gate definitions discovered while walking the bodies. + // First-occurrence wins; duplicates are silently dropped via the + // companion `seen` set so even cyclic-looking trace data terminates. + let mut extraction_queue: Vec = vec![]; + let mut seen_extractions: FxHashSet = FxHashSet::default(); + + let mut qsharp_str = String::new(); + + // Pre-compute the global set of custom-gate names. Every call site of + // these names (anywhere in any circuit, including transitively inside + // extracted bodies) must use the array-wrap calling convention because + // every extracted operation has signature `(qs : Qubit[])`. We compute + // this set ONCE up front so `operation_call` has consistent information + // when emitting the very first body — by the time we drain the + // extraction queue, the top-level body has already been emitted, so a + // progressively-built set wouldn't help calls in the main op that + // refer to gates only discovered later in the walk. + let mut custom_gates: FxHashSet = FxHashSet::default(); + if circuits.len() == 1 { - build_operation_def(file_name, &circuits[0]) + emitted_names.insert(file_name.to_string()); + // Reserve the main name *before* walking so an in-body call to a + // gate with the same name (rare, but legal in tests/snapshots) + // doesn't try to extract the main op as one of its own callees. + seen_extractions.insert(file_name.to_string()); + let working = unwrap_trace_entry_point_wrapper(&circuits[0]); + collect_custom_gate_names(&working.component_grid, &mut custom_gates); + qsharp_str.push_str(&build_operation_def(file_name, &working, &custom_gates)); + collect_custom_gate_defs( + &working.component_grid, + &mut extraction_queue, + &mut seen_extractions, + ); } else { - let mut qsharp_str = String::new(); - for (index, circuit) in circuits.iter().enumerate() { + for index in 0..circuits.len() { + let name = format!("{file_name}{index}"); + emitted_names.insert(name.clone()); + seen_extractions.insert(name); + } + // Walk every circuit first to assemble the union of all custom + // gates. Only then do we start emitting — by that point the set + // covers every name `operation_call` could encounter. + let workings: Vec = circuits + .iter() + .map(unwrap_trace_entry_point_wrapper) + .collect(); + for working in &workings { + collect_custom_gate_names(&working.component_grid, &mut custom_gates); + } + for (index, working) in workings.iter().enumerate() { let circuit_name = format!("{file_name}{index}"); - let circuit_str = build_operation_def(&circuit_name, circuit); - qsharp_str.push_str(&circuit_str); + qsharp_str.push_str(&build_operation_def(&circuit_name, working, &custom_gates)); + collect_custom_gate_defs( + &working.component_grid, + &mut extraction_queue, + &mut seen_extractions, + ); + } + } + + // Drain the queue. Each extracted definition's own body is walked for + // further custom-gate calls (transitive closure: if Foo calls Bar, both + // Foo and Bar end up emitted exactly once). The `seen_extractions` set + // doubles as the dedup guard, so this terminates in O(distinct gates). + let mut i = 0; + while i < extraction_queue.len() { + let def = &extraction_queue[i]; + let name = def.name.clone(); + let synth = synthesize_circuit_for_extraction(&def.children, &def.targets); + + // The synthesized body may itself reference custom gates we haven't + // seen yet (the trace recorded them only inside this extracted + // body, never at the top level). Fold them into the global set + // before emitting so the resulting Q# uses array-wrap calls + // uniformly. + collect_custom_gate_names(&synth.component_grid, &mut custom_gates); + + // Note: build_operation_def does its own divergence-banner pass on + // the synthesized body, so any opaque conditionals or non-uniform + // loops *inside* a custom gate get their own banner above its + // declaration. + qsharp_str.push_str(&build_operation_def(&name, &synth, &custom_gates)); + emitted_names.insert(name); + + // Walk this extracted body for further nested gates. We pass the + // outgoing queue/seen set so any new finds are appended to the same + // queue and processed in subsequent loop iterations. + collect_custom_gate_defs( + &synth.component_grid, + &mut extraction_queue, + &mut seen_extractions, + ); + + i += 1; + } + + let _ = emitted_names; // currently informational; reserved for future collision diagnostics + qsharp_str +} + +/// Strip the trace-derived "entry-point wrapper" if present, returning a +/// `Circuit` whose top-level grid is the wrapper's body. +/// +/// A trace always wraps the entire body in a single outer `Unitary` call to +/// the entry-point operation (e.g. `Main`). That wrapper is a trace artifact +/// — there's no Q# operation by that name in the user's saved file — so +/// emitting it as a call would produce a reference to a non-existent +/// operation and skip the body entirely. Editor-authored circuits never +/// produce this shape (custom-gate calls in editor circuits don't carry +/// their body as `children`). +/// +/// This unwrap fires *exactly once* at the top of `build_circuits`, before +/// custom-gate extraction. Crucially, it does NOT recurse into extracted +/// callees: when extraction synthesizes `operation Foo` from a custom-gate +/// call's `children`, that body is real user code (the operation's actual +/// body as recorded by the trace) and must not be unwrapped further. If we +/// applied this heuristic recursively, a `Foo` whose body is just a single +/// call to `Bar` would have its `Bar` call eaten — a real bug we'd hit +/// every time the user defines a thin wrapper operation. +/// +/// Detection: a top-level grid with exactly one column, exactly one +/// component, where that component is a `Unitary` with non-empty `children` +/// AND a name that does NOT identify a structural group (loops, +/// conditionals, anonymous scopes — those are real circuit structure that +/// must be preserved). Any deviation from this exact shape and we leave +/// the circuit untouched. +fn unwrap_trace_entry_point_wrapper(circuit: &Circuit) -> Circuit { + let grid = &circuit.component_grid; + let single_col = grid.len() == 1; + let only = single_col + .then(|| grid.first().and_then(|c| c.components.first())) + .flatten(); + + let inner = only.and_then(|op| match op { + Operation::Unitary(u) + if grid.first().map(|c| c.components.len()) == Some(1) + && !u.children.is_empty() + && structural_group_kind(&u.gate).is_none() => + { + Some(u.children.clone()) + } + _ => None, + }); + + match inner { + Some(component_grid) => Circuit { + component_grid, + qubits: circuit.qubits.clone(), + }, + None => circuit.clone(), + } +} + +/// One custom-gate definition harvested from a `Unitary` op's `children`. +/// Owns its data so the queue can be drained without juggling lifetimes +/// against the original `Circuit`. +struct ExtractedDef { + name: String, + /// The body of the custom gate, exactly as it appeared in the trace. + children: ComponentGrid, + /// The targets of the *call* — these define the parameter order of the + /// extracted operation. Within `children`, qubit IDs equal to + /// `targets[i].qubit` map to local parameter slot `i`. + targets: Vec, +} + +/// Walk `grid` and append any custom-gate definitions it carries to +/// `queue`. `seen` deduplicates by gate name (first occurrence wins) and +/// also lets the caller pre-reserve names that should never be extracted +/// (e.g. the main operation's name). +/// +/// We recurse into every `Unitary`'s children regardless of whether we +/// already emitted that gate, so a custom `Foo` whose body calls `Bar` is +/// guaranteed to surface `Bar` even if `Foo` itself is being skipped. +fn collect_custom_gate_defs( + grid: &ComponentGrid, + queue: &mut Vec, + seen: &mut FxHashSet, +) { + for col in grid { + for op in &col.components { + // Only `Unitary` ops can carry a `children` body that represents + // a Q# operation definition. Measurements with children are + // structural (e.g. measurement-based scopes), not user gates. + let Operation::Unitary(u) = op else { continue }; + let has_body = !u.children.is_empty(); + if !has_body { + continue; + } + // Structural groups (`loop:`, `if:`, `(N)`, ``, + // ``) are not user-defined operations — their children + // get inlined by the emitter. Don't try to extract them; do + // recurse to find any user gates buried inside. + let is_structural = structural_group_kind(&u.gate).is_some(); + if !is_structural && seen.insert(u.gate.clone()) { + queue.push(ExtractedDef { + name: u.gate.clone(), + children: u.children.clone(), + targets: u.targets.clone(), + }); + } + collect_custom_gate_defs(&u.children, queue, seen); + } + } +} + +/// Walk `grid` and insert into `names` every gate name that appears as a +/// custom-operation definition site (i.e. a `Unitary` carrying a non-empty +/// `children` body whose name is not a structural-group marker). Recurses +/// into all child grids unconditionally so transitively-nested custom +/// gates are captured. +/// +/// Used by `build_circuits` to assemble — *before any emission* — the +/// global set of gate names that `operation_call` must render with the +/// array-wrap calling convention. Every extracted operation has signature +/// `(qs : Qubit[])`, so any call to such a name (including bare references +/// without children, e.g. the second iteration of a loop) must wrap its +/// targets in a single array literal. +fn collect_custom_gate_names(grid: &ComponentGrid, names: &mut FxHashSet) { + for col in grid { + for op in &col.components { + match op { + Operation::Unitary(u) => { + if !u.children.is_empty() && structural_group_kind(&u.gate).is_none() { + names.insert(u.gate.clone()); + } + collect_custom_gate_names(&u.children, names); + } + Operation::Measurement(m) => { + collect_custom_gate_names(&m.children, names); + } + Operation::Ket(k) => { + collect_custom_gate_names(&k.children, names); + } + } } - qsharp_str } } -fn build_operation_def(circuit_name: &str, circuit: &Circuit) -> String { +/// Build a synthetic `Circuit` suitable for handing to `build_operation_def` +/// when emitting an extracted custom-gate definition. +/// +/// Qubit list = `call_targets` first (so positional parameter order matches +/// the call site `Foo(qs[0], qs[1], …)`), followed by any other qubit IDs +/// the body references but the call didn't pass as a target (controls, +/// internally-allocated qubits captured by the trace, …). Adding those +/// extras keeps `get_qubit_name` from panicking on out-of-range IDs at the +/// cost of a slightly inflated parameter list — acceptable because the +/// alternative would be a hard panic in the live preview. +/// +/// `num_results` per qubit is computed by counting `Measurement.results` +/// entries attached to that qubit in the body, so the synthesized +/// operation's return type matches what its body actually produces. +fn synthesize_circuit_for_extraction( + children: &ComponentGrid, + call_targets: &[Register], +) -> Circuit { + let mut qubit_ids: Vec = vec![]; + let mut seen: FxHashSet = FxHashSet::default(); + + for r in call_targets { + if seen.insert(r.qubit) { + qubit_ids.push(r.qubit); + } + } + + collect_qubit_ids(children, &mut qubit_ids, &mut seen); + + let mut result_counts: FxHashMap = FxHashMap::default(); + count_results_per_qubit(children, &mut result_counts); + + let qubits = qubit_ids + .into_iter() + .map(|id| Qubit { + id, + num_results: result_counts.get(&id).copied().unwrap_or(0), + declarations: vec![], + }) + .collect(); + + Circuit { + qubits, + component_grid: children.clone(), + } +} + +/// Recursively gather every distinct qubit ID referenced anywhere in `grid` +/// (targets, controls, measurement qubits, measurement-result wires). +fn collect_qubit_ids(grid: &ComponentGrid, ids: &mut Vec, seen: &mut FxHashSet) { + for col in grid { + for op in &col.components { + match op { + Operation::Measurement(m) => { + for r in &m.qubits { + if seen.insert(r.qubit) { + ids.push(r.qubit); + } + } + for r in &m.results { + if seen.insert(r.qubit) { + ids.push(r.qubit); + } + } + collect_qubit_ids(&m.children, ids, seen); + } + Operation::Unitary(u) => { + for r in &u.targets { + if seen.insert(r.qubit) { + ids.push(r.qubit); + } + } + for r in &u.controls { + if seen.insert(r.qubit) { + ids.push(r.qubit); + } + } + collect_qubit_ids(&u.children, ids, seen); + } + Operation::Ket(k) => { + for r in &k.targets { + if seen.insert(r.qubit) { + ids.push(r.qubit); + } + } + collect_qubit_ids(&k.children, ids, seen); + } + } + } + } +} + +/// Count measurement-result wires attached to each qubit. Used to size the +/// `num_results` field of synthesized qubits so the extracted operation's +/// return type (`Unit` / `Result` / `Result[]`) is consistent with what +/// `process_components` actually emits inside the body. +fn count_results_per_qubit(grid: &ComponentGrid, counts: &mut FxHashMap) { + for col in grid { + for op in &col.components { + match op { + Operation::Measurement(m) => { + for r in &m.results { + *counts.entry(r.qubit).or_insert(0) += 1; + } + count_results_per_qubit(&m.children, counts); + } + Operation::Unitary(u) => { + count_results_per_qubit(&u.children, counts); + } + Operation::Ket(k) => { + count_results_per_qubit(&k.children, counts); + } + } + } + } +} + +fn build_operation_def( + circuit_name: &str, + circuit: &Circuit, + custom_gates: &FxHashSet, +) -> String { let mut indentation_level = 0; let qubits = circuit .qubits @@ -100,16 +492,7 @@ fn build_operation_def(circuit_name: &str, circuit: &Circuit) -> String { let mut body_str = String::new(); let mut should_add_pi = false; - // The trace-derived form of a circuit wraps the entire body in a single - // outer call to the entry-point operation (e.g. `Main` with the whole - // body in `children`). Calling that name here would emit a call to a - // non-existent operation and skip the body entirely, so we unwrap one - // level when we see that shape. Editor-authored circuits never produce - // this shape — custom-gate calls don't carry their body as children. - let body_grid = match unwrap_entry_point_wrapper(&circuit.component_grid) { - Some(inner) => inner, - None => &circuit.component_grid, - }; + let body_grid = &circuit.component_grid; // Scan the body for trace-only patterns the emitter can't faithfully // recreate as Q# (e.g. loops with structurally divergent iterations, @@ -126,6 +509,7 @@ fn build_operation_def(circuit_name: &str, circuit: &Circuit) -> String { &mut measure_results, &mut should_add_pi, &mut body_str, + custom_gates, ); if should_add_pi { @@ -160,6 +544,7 @@ fn build_operation_def(circuit_name: &str, circuit: &Circuit) -> String { /// Custom-gate groups (e.g. `Foo` with a `children` expansion of its body) /// are *not* treated as structural — the call to `Foo` is what we want to /// preserve, and the user's project supplies the body. +#[allow(clippy::too_many_arguments)] fn process_components( grid: &ComponentGrid, qubits: &FxHashMap, @@ -167,6 +552,7 @@ fn process_components( measure_results: &mut Vec<(String, (usize, usize))>, should_add_pi: &mut bool, out: &mut String, + custom_gates: &FxHashSet, ) { let indent = " ".repeat(indentation_level); for col in grid { @@ -185,6 +571,7 @@ fn process_components( measure_results, should_add_pi, out, + custom_gates, ); continue; } @@ -199,7 +586,12 @@ fn process_components( )); } Operation::Unitary(unitary) => { - out.push_str(&generate_unitary_call(unitary, qubits, &indent)); + out.push_str(&generate_unitary_call( + unitary, + qubits, + &indent, + custom_gates, + )); } Operation::Ket(ket) => { out.push_str(&generate_ket_call(ket, qubits, &indent)); @@ -266,6 +658,7 @@ fn emit_structural_group( measure_results: &mut Vec<(String, (usize, usize))>, should_add_pi: &mut bool, out: &mut String, + custom_gates: &FxHashSet, ) { let indent = " ".repeat(indentation_level); @@ -284,6 +677,7 @@ fn emit_structural_group( measure_results, should_add_pi, out, + custom_gates, ); return; } @@ -302,6 +696,7 @@ fn emit_structural_group( measure_results, should_add_pi, out, + custom_gates, ); writeln!(out, "{indent}{footer}").expect("could not write to out"); } @@ -317,36 +712,6 @@ fn grid_is_all_unitary(grid: &ComponentGrid) -> bool { }) } -/// Detects the trace-derived "entry-point wrapper" shape: a top-level grid -/// containing exactly one column with exactly one unitary that has children -/// AND whose name does *not* identify a structural group (loops, -/// conditionals, scopes — those are real circuit structure that we must -/// preserve, not wrappers to unwrap). The wrapper's name is the entry-point -/// operation that was traced (e.g. `Main`). Emitting it as a call would -/// point at an operation that doesn't exist in the user's preview and would -/// skip the body entirely. -/// -/// Returns the inner grid to use as the body, or `None` if the grid is not -/// in entry-point-wrapper shape. -fn unwrap_entry_point_wrapper(grid: &ComponentGrid) -> Option<&ComponentGrid> { - if grid.len() != 1 { - return None; - } - let col = grid.first()?; - if col.components.len() != 1 { - return None; - } - let only = col.components.first()?; - match only { - Operation::Unitary(u) - if !u.children.is_empty() && structural_group_kind(&u.gate).is_none() => - { - Some(&u.children) - } - _ => None, - } -} - // --------------------------------------------------------------------------- // Trace-divergence detection // @@ -366,10 +731,9 @@ fn unwrap_entry_point_wrapper(grid: &ComponentGrid) -> Option<&ComponentGrid> { // comparison. The trace summarises arbitrary classical conditions // opaquely; the original Q# expression is lost. // -// Detection runs after `unwrap_entry_point_wrapper` so it walks the same -// grid the emitter prints. Findings are surfaced as a single banner above -// the operation declaration, naming each issue and (when available) its -// source line. +// Detection runs on the same grid the emitter prints. Findings are +// surfaced as a single banner above the operation declaration, naming each +// issue and (when available) its source line. // --------------------------------------------------------------------------- /// One actionable finding from the divergence detector. Each becomes a @@ -408,9 +772,22 @@ fn walk_for_divergences(grid: &ComponentGrid, findings: &mut Vec {} } } - // Recurse into all children — divergences can be nested arbitrarily, - // and we want to surface every one in the banner. - walk_for_divergences(op.children(), findings); + // Recurse into structural-group children (loops, conditionals, + // anonymous scopes, iteration markers) and into measurement/ket + // children — those get inlined into the current operation's + // body, so any divergences in them belong on this operation's + // banner. + // + // Skip recursion into non-structural Unitary children (custom + // gate calls). Those get extracted into their own operation by + // `collect_custom_gate_defs`, and `build_operation_def` will + // run divergence detection on the extracted body separately. + // Recursing here would double-report divergences on both the + // caller and the callee. + match op { + Operation::Unitary(u) if structural_group_kind(&u.gate).is_none() => {} + _ => walk_for_divergences(op.children(), findings), + } } } } @@ -534,9 +911,7 @@ fn format_divergence_banner(findings: &[DivergenceFinding]) -> String { .map(|loc| format!(" (line {})", loc.line + 1)) .unwrap_or_default(); let descr = match finding.kind { - DivergenceKind::DivergentLoopIterations => { - "loop has structurally divergent iterations" - } + DivergenceKind::DivergentLoopIterations => "loop has structurally divergent iterations", DivergenceKind::OpaqueConditional => "conditional uses an opaque expression", }; let _ = writeln!(out, "// - {descr}{location_suffix}: {}", finding.label); @@ -606,8 +981,9 @@ fn generate_unitary_call( unitary: &Unitary, qubits: &FxHashMap, indent: &str, + custom_gates: &FxHashSet, ) -> String { - let operation_str = operation_call(unitary, qubits); + let operation_str = operation_call(unitary, qubits, custom_gates); format!("{indent}{operation_str};\n") } @@ -683,10 +1059,24 @@ fn ket_call(ket: &Ket, qubits: &FxHashMap) -> String { format!("Reset({args})") } -fn operation_call(unitary: &Unitary, qubits: &FxHashMap) -> String { +fn operation_call( + unitary: &Unitary, + qubits: &FxHashMap, + custom_gates: &FxHashSet, +) -> String { let gate = unitary.gate.as_str(); let is_controlled = !unitary.controls.is_empty(); + // A "custom" gate is one we extracted (or will extract) into its own + // operation definition. Every such operation has the canonical + // signature `(qs : Qubit[])`, so its call sites must wrap targets in + // a single array literal — `Foo([qs[0], qs[1]])`, not the per-qubit + // positional form `Foo(qs[0], qs[1])` we use for built-in gates like + // `H` / `X` whose signatures take individual qubits. The set is + // computed up front in `build_circuits` so that even a bare reference + // (e.g. the second iteration of a loop, where the trace dropped the + // body) renders with the array convention. + let is_custom = custom_gates.contains(gate); let functors = if is_controlled && unitary.is_adjoint { "Controlled Adjoint " @@ -725,7 +1115,16 @@ fn operation_call(unitary: &Unitary, qubits: &FxHashMap) -> Strin .iter() .map(|t| get_qubit_name(qubits, t.qubit)) .collect::>(); - args.extend(targets); + if is_custom { + // Single Qubit[] arg — even an empty target list emits `[]` so the + // arity matches the extracted operation's `(qs : Qubit[])` + // signature. The controlled branch below sees this as a single + // arg and won't add a stray tuple wrap, yielding the desired + // `Controlled Foo([c], [qs[0], qs[1]])`. + args.push(format!("[{}]", targets.join(", "))); + } else { + args.extend(targets); + } if is_controlled { let controls = unitary diff --git a/source/compiler/qsc_circuit/src/circuit_to_qsharp/tests.rs b/source/compiler/qsc_circuit/src/circuit_to_qsharp/tests.rs index 95782e5845d..3462b09698a 100644 --- a/source/compiler/qsc_circuit/src/circuit_to_qsharp/tests.rs +++ b/source/compiler/qsc_circuit/src/circuit_to_qsharp/tests.rs @@ -5,8 +5,16 @@ use super::*; use expect_test::{Expect, expect}; fn check(contents: &str, expect: &Expect) { + // The `check` helper exercises `build_operation_def` in isolation — no + // global pre-walk for custom gates. Tests that rely on this path are + // verifying basic emission of built-in gates (where the array-wrap + // convention doesn't apply), so an empty `custom_gates` set is the + // right default. Tests that need custom-gate behaviour use + // `check_circuit_group`, which routes through `circuits_to_qsharp` + // and computes the set itself. + let custom_gates = FxHashSet::default(); let actual = match serde_json::from_str::(contents) { - Ok(circuit) => build_operation_def("Test", &circuit), + Ok(circuit) => build_operation_def("Test", &circuit, &custom_gates), Err(e) => format!("Error: {e}"), }; expect.assert_eq(&actual); @@ -687,49 +695,59 @@ fn custom_gate_with_children_emits_call_not_inline() { // The test uses two top-level components so the entry-point-wrapper // unwrap heuristic doesn't apply (which is reserved for the case where // the trace wraps the entire body in a single non-existent operation). - check( + // + // Routes through `circuits_to_qsharp` so the global custom-gate set is + // populated; the call sites accordingly use the array-wrap convention + // (`Foo([qs[0], qs[1]])`) matching the extracted operation's + // `(qs : Qubit[])` signature. + check_circuit_group( r#" { - "componentGrid": [ + "version": 1, + "circuits": [ { - "components": [ + "componentGrid": [ { - "kind": "unitary", - "gate": "Foo", - "targets": [{ "qubit": 0 }, { "qubit": 1 }], - "children": [ - { - "components": [ - { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } - ] - }, + "components": [ { - "components": [ - { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 1 }] } + "kind": "unitary", + "gate": "Foo", + "targets": [{ "qubit": 0 }, { "qubit": 1 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + }, + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 1 }] } + ] + } ] } ] - } - ] - }, - { - "components": [ + }, { - "kind": "unitary", - "gate": "Foo", - "targets": [{ "qubit": 0 }, { "qubit": 1 }], - "children": [ + "components": [ { - "components": [ - { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + "kind": "unitary", + "gate": "Foo", + "targets": [{ "qubit": 0 }, { "qubit": 1 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + } ] } ] } - ] + ], + "qubits": [{ "id": 0 }, { "id": 1 }] } - ], - "qubits": [{ "id": 0 }, { "id": 1 }] + ] }"#, &expect![[r#" /// Expects a qubit register of at least 2 qubits. @@ -737,8 +755,17 @@ fn custom_gate_with_children_emits_call_not_inline() { if Length(qs) < 2 { fail "Invalid number of qubits. Operation Test expects a qubit register of at least 2 qubits."; } - Foo(qs[0], qs[1]); - Foo(qs[0], qs[1]); + Foo([qs[0], qs[1]]); + Foo([qs[0], qs[1]]); + } + + /// Expects a qubit register of at least 2 qubits. + operation Foo(qs : Qubit[]) : Unit is Ctl + Adj { + if Length(qs) < 2 { + fail "Invalid number of qubits. Operation Foo expects a qubit register of at least 2 qubits."; + } + H(qs[0]); + H(qs[1]); } "#]], @@ -1123,18 +1150,25 @@ fn group_splitting_test_shape_emits_loop_with_asymmetric_iterations() { // different iterations, including a conditional that only appears in // later iterations and a custom-gate call to `Foo`). The point of // this test is to lock down what the user sees when they open such a - // trace as a `.qsc` file: a flat sequence of gates inside `// loop` - // and `// if` markers, with custom gates preserved as calls. + // trace as a `.qsc` file: the `Main` trace-wrapper is unwrapped at + // the top so the body inlines into `Test`, and the `Foo` custom gate + // is extracted into its own appended operation definition. // // This test intentionally lives alongside the unit tests rather than // in an integration harness so it stays in sync with the emitter // output as we extend the structural-group handling. If it breaks // because we taught the emitter real `for` / `if` syntax, the new // expectation is the contract going forward. - check( + // + // Uses `check_circuit_group` (not `check`) so the full pipeline runs: + // wrapper unwrap, divergence detection, and custom-gate extraction. + check_circuit_group( r#" { - "componentGrid": [ + "version": 1, + "circuits": [ + { + "componentGrid": [ { "components": [ { @@ -1262,11 +1296,13 @@ fn group_splitting_test_shape_emits_loop_with_asymmetric_iterations() { ] } ], - "qubits": [ - { "id": 0, "numResults": 2 }, - { "id": 1 }, - { "id": 2 }, - { "id": 3 } + "qubits": [ + { "id": 0, "numResults": 2 }, + { "id": 1 }, + { "id": 2 }, + { "id": 3 } + ] + } ] }"#, &expect![[r#" @@ -1287,7 +1323,7 @@ fn group_splitting_test_shape_emits_loop_with_asymmetric_iterations() { H(qs[0]); let c0_0 = M(qs[0]); Reset(qs[0]); - Foo(qs[0], qs[2]); + Foo([qs[0], qs[2]]); // iteration (2) X(qs[3]); H(qs[0]); @@ -1296,11 +1332,20 @@ fn group_splitting_test_shape_emits_loop_with_asymmetric_iterations() { // end if let c0_1 = M(qs[0]); Reset(qs[0]); - Foo(qs[0], qs[2]); + Foo([qs[0], qs[2]]); // end loop return [c0_0, c0_1]; } + /// Expects a qubit register of at least 2 qubits. + operation Foo(qs : Qubit[]) : Unit is Ctl + Adj { + if Length(qs) < 2 { + fail "Invalid number of qubits. Operation Foo expects a qubit register of at least 2 qubits."; + } + H(qs[0]); + H(qs[1]); + } + "#]], ); } @@ -1811,3 +1856,569 @@ fn metadata_omitted_entirely_still_parses_and_emits_banner() { "#]], ); } + +// --------------------------------------------------------------------------- +// Custom-gate extraction +// +// Saved trace snapshots live in standalone .qsc files, disconnected from +// the .qs project that defined the user's custom gates. The trace stores a +// custom gate's body in the call's `children`, so the emitter can use that +// to synthesize a definition alongside the call. Without this, a snapshot +// of any program that calls a user-defined operation would generate Q# +// that doesn't compile. +// +// All tests in this section drive the public `circuits_to_qsharp` entry +// point so they exercise the full extraction pipeline (including the +// transitive walk and dedup), not just `build_operation_def`. +// --------------------------------------------------------------------------- + +#[test] +fn custom_gate_extraction_emits_separate_definition() { + // Single call to `Foo` with a 2-qubit body. Expect both `Test` and an + // appended `Foo` definition. The call site keeps the existing + // call-not-inline behaviour. + // + // The trailing `X` adds a sibling component at the top level so the + // trace-wrapper heuristic in `unwrap_trace_entry_point_wrapper` does + // not mistake `Foo` for the trace's entry-point wrapper. Real saved + // .qsc files almost always have multiple top-level columns, so this + // shape is more representative than a bare single-call body. + check_circuit_group( + r#" +{ + "version": 1, + "circuits": [ + { + "componentGrid": [ + { + "components": [ + { + "kind": "unitary", + "gate": "Foo", + "targets": [{ "qubit": 0 }, { "qubit": 1 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + }, + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 1 }] } + ] + } + ] + } + ] + }, + { + "components": [ + { "kind": "unitary", "gate": "X", "targets": [{ "qubit": 0 }] } + ] + } + ], + "qubits": [{ "id": 0 }, { "id": 1 }] + } + ] +}"#, + &expect![[r#" + /// Expects a qubit register of at least 2 qubits. + operation Test(qs : Qubit[]) : Unit is Ctl + Adj { + if Length(qs) < 2 { + fail "Invalid number of qubits. Operation Test expects a qubit register of at least 2 qubits."; + } + Foo([qs[0], qs[1]]); + X(qs[0]); + } + + /// Expects a qubit register of at least 2 qubits. + operation Foo(qs : Qubit[]) : Unit is Ctl + Adj { + if Length(qs) < 2 { + fail "Invalid number of qubits. Operation Foo expects a qubit register of at least 2 qubits."; + } + H(qs[0]); + H(qs[1]); + } + + "#]], + ); +} + +#[test] +fn custom_gate_extracted_only_once_when_called_multiple_times() { + // Two calls to `Foo` should produce one extracted `Foo` definition. + // The first occurrence's children win (silently — divergent bodies + // are a separate concern handled by the divergence detector in the + // future). + check_circuit_group( + r#" +{ + "version": 1, + "circuits": [ + { + "componentGrid": [ + { + "components": [ + { + "kind": "unitary", + "gate": "Foo", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + } + ] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "Foo", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + } + ] + } + ] + } + ], + "qubits": [{ "id": 0 }] + } + ] +}"#, + &expect![[r#" + /// Expects a qubit register of at least 1 qubits. + operation Test(qs : Qubit[]) : Unit is Ctl + Adj { + if Length(qs) < 1 { + fail "Invalid number of qubits. Operation Test expects a qubit register of at least 1 qubits."; + } + Foo([qs[0]]); + Foo([qs[0]]); + } + + /// Expects a qubit register of at least 1 qubits. + operation Foo(qs : Qubit[]) : Unit is Ctl + Adj { + if Length(qs) < 1 { + fail "Invalid number of qubits. Operation Foo expects a qubit register of at least 1 qubits."; + } + H(qs[0]); + } + + "#]], + ); +} + +#[test] +fn nested_custom_gates_are_transitively_extracted() { + // `Foo` calls `Bar`. The single walk should surface `Foo` immediately, + // then walk `Foo`'s body to surface `Bar` — both end up as appended + // operations after `Test`. + // + // The trailing `X` keeps the trace-wrapper heuristic from eating the + // top-level `Foo` call (see `custom_gate_extraction_emits_separate_definition` + // for the rationale). + check_circuit_group( + r#" +{ + "version": 1, + "circuits": [ + { + "componentGrid": [ + { + "components": [ + { + "kind": "unitary", + "gate": "Foo", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { + "kind": "unitary", + "gate": "Bar", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "components": [ + { "kind": "unitary", "gate": "X", "targets": [{ "qubit": 0 }] } + ] + } + ], + "qubits": [{ "id": 0 }] + } + ] +}"#, + &expect![[r#" + /// Expects a qubit register of at least 1 qubits. + operation Test(qs : Qubit[]) : Unit is Ctl + Adj { + if Length(qs) < 1 { + fail "Invalid number of qubits. Operation Test expects a qubit register of at least 1 qubits."; + } + Foo([qs[0]]); + X(qs[0]); + } + + /// Expects a qubit register of at least 1 qubits. + operation Foo(qs : Qubit[]) : Unit is Ctl + Adj { + if Length(qs) < 1 { + fail "Invalid number of qubits. Operation Foo expects a qubit register of at least 1 qubits."; + } + Bar([qs[0]]); + } + + /// Expects a qubit register of at least 1 qubits. + operation Bar(qs : Qubit[]) : Unit is Ctl + Adj { + if Length(qs) < 1 { + fail "Invalid number of qubits. Operation Bar expects a qubit register of at least 1 qubits."; + } + H(qs[0]); + } + + "#]], + ); +} + +#[test] +fn custom_gate_inside_structural_group_is_still_extracted() { + // The body of a `loop:` group contains a call to `Foo`. Even though + // the loop itself is inlined as comments rather than emitted as a + // call, we must still walk into its children to discover and extract + // `Foo` — otherwise the loop's call site would reference an undefined + // operation in the preview. + check_circuit_group( + r#" +{ + "version": 1, + "circuits": [ + { + "componentGrid": [ + { + "components": [ + { + "kind": "unitary", + "gate": "loop: 0..1", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { + "kind": "unitary", + "gate": "(0)", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { + "kind": "unitary", + "gate": "Foo", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "qubits": [{ "id": 0 }] + } + ] +}"#, + &expect![[r#" + /// Expects a qubit register of at least 1 qubits. + operation Test(qs : Qubit[]) : Unit is Ctl + Adj { + if Length(qs) < 1 { + fail "Invalid number of qubits. Operation Test expects a qubit register of at least 1 qubits."; + } + // loop: 0..1 + // iteration (0) + Foo([qs[0]]); + // end loop + } + + /// Expects a qubit register of at least 1 qubits. + operation Foo(qs : Qubit[]) : Unit is Ctl + Adj { + if Length(qs) < 1 { + fail "Invalid number of qubits. Operation Foo expects a qubit register of at least 1 qubits."; + } + H(qs[0]); + } + + "#]], + ); +} + +#[test] +fn custom_gate_with_internal_measurement_returns_result() { + // `Foo`'s body measures its qubit. The synthesized definition's + // `num_results` should reflect this so the return type becomes + // `Result` instead of `Unit` — and the body should both bind and + // return that measurement. + // + // The trailing `X` keeps the trace-wrapper heuristic from eating the + // top-level `Foo` call. + check_circuit_group( + r#" +{ + "version": 1, + "circuits": [ + { + "componentGrid": [ + { + "components": [ + { + "kind": "unitary", + "gate": "Foo", + "targets": [{ "qubit": 0 }], + "children": [ + { + "components": [ + { + "kind": "measurement", + "gate": "M", + "qubits": [{ "qubit": 0 }], + "results": [{ "qubit": 0, "result": 0 }] + } + ] + } + ] + } + ] + }, + { + "components": [ + { "kind": "unitary", "gate": "X", "targets": [{ "qubit": 0 }] } + ] + } + ], + "qubits": [{ "id": 0 }] + } + ] +}"#, + &expect![[r#" + /// Expects a qubit register of at least 1 qubits. + operation Test(qs : Qubit[]) : Unit { + if Length(qs) < 1 { + fail "Invalid number of qubits. Operation Test expects a qubit register of at least 1 qubits."; + } + Foo([qs[0]]); + X(qs[0]); + } + + /// Expects a qubit register of at least 1 qubits. + operation Foo(qs : Qubit[]) : Result { + if Length(qs) < 1 { + fail "Invalid number of qubits. Operation Foo expects a qubit register of at least 1 qubits."; + } + let c0_0 = M(qs[0]); + return c0_0; + } + + "#]], + ); +} + +// --------------------------------------------------------------------------- +// File-name sanitization +// +// `circuits_to_qsharp` is the only entry point that ever sees a raw +// filename. The Rust emitter pastes the name verbatim into `operation +// (...)`, and downstream the language service derives an implicit +// namespace from the same string when compiling the preview as a single- +// file project. Both consumers require a valid Q# identifier, but real +// `.qsc` filenames can contain `.`, spaces, leading digits, and so on +// (most commonly `..qsc` for circuits exported from a +// `.qs` file). Sanitization at the entry point keeps every downstream +// caller — preview, full project compile, future tooling — working on +// any reasonable filename without each one having to re-implement the +// same character rules. +// --------------------------------------------------------------------------- + +#[test] +fn file_name_with_embedded_dot_is_sanitized() { + // `GroupSplittingTest.Main.qsc` is the canonical "exported from a + // .qs file" shape. The basename passed in is the file stem + // (`GroupSplittingTest.Main`). Without sanitization, the emitted + // `operation GroupSplittingTest.Main(...)` is a syntax error. + check_circuit_group_with_name( + "GroupSplittingTest.Main", + r#" +{ + "version": 1, + "circuits": [ + { + "componentGrid": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + } + ], + "qubits": [{ "id": 0 }] + } + ] +}"#, + &expect![[r#" + /// Expects a qubit register of at least 1 qubits. + operation GroupSplittingTest_Main(qs : Qubit[]) : Unit is Ctl + Adj { + if Length(qs) < 1 { + fail "Invalid number of qubits. Operation GroupSplittingTest_Main expects a qubit register of at least 1 qubits."; + } + H(qs[0]); + } + + "#]], + ); +} + +#[test] +fn file_name_with_spaces_and_unicode_is_sanitized() { + // Spaces, dashes, and non-ASCII letters all map to `_` so the + // generated operation parses on every platform. We don't try to be + // clever about transliteration — predictable replacement keeps the + // emitter deterministic and easy to reason about. + check_circuit_group_with_name( + "my circuit-\u{00e9}", + r#" +{ + "version": 1, + "circuits": [ + { + "componentGrid": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + } + ], + "qubits": [{ "id": 0 }] + } + ] +}"#, + &expect![[r#" + /// Expects a qubit register of at least 1 qubits. + operation my_circuit__(qs : Qubit[]) : Unit is Ctl + Adj { + if Length(qs) < 1 { + fail "Invalid number of qubits. Operation my_circuit__ expects a qubit register of at least 1 qubits."; + } + H(qs[0]); + } + + "#]], + ); +} + +#[test] +fn file_name_starting_with_digit_gets_underscore_prefix() { + // Q# identifiers can't start with a digit; the sanitizer prepends + // `_` rather than dropping the digit so the user can still + // recognize their original filename in the generated code. + check_circuit_group_with_name( + "1_qubit_circuit", + r#" +{ + "version": 1, + "circuits": [ + { + "componentGrid": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + } + ], + "qubits": [{ "id": 0 }] + } + ] +}"#, + &expect![[r#" + /// Expects a qubit register of at least 1 qubits. + operation _1_qubit_circuit(qs : Qubit[]) : Unit is Ctl + Adj { + if Length(qs) < 1 { + fail "Invalid number of qubits. Operation _1_qubit_circuit expects a qubit register of at least 1 qubits."; + } + H(qs[0]); + } + + "#]], + ); +} + +#[test] +fn empty_file_name_becomes_underscore() { + // Defensive: callers should never hand us an empty name, but if + // they do, fall back to a one-character placeholder that at least + // parses. Better than panicking inside the emitter. + check_circuit_group_with_name( + "", + r#" +{ + "version": 1, + "circuits": [ + { + "componentGrid": [ + { + "components": [ + { "kind": "unitary", "gate": "H", "targets": [{ "qubit": 0 }] } + ] + } + ], + "qubits": [{ "id": 0 }] + } + ] +}"#, + &expect![[r#" + /// Expects a qubit register of at least 1 qubits. + operation _(qs : Qubit[]) : Unit is Ctl + Adj { + if Length(qs) < 1 { + fail "Invalid number of qubits. Operation _ expects a qubit register of at least 1 qubits."; + } + H(qs[0]); + } + + "#]], + ); +} + +/// Like `check_circuit_group`, but threads the operation name through so +/// tests can exercise sanitization paths. Other tests pin the name to +/// `"Test"` (already a valid identifier) and don't need this variant. +fn check_circuit_group_with_name(name: &str, contents: &str, expect: &Expect) { + let actual = match circuits_to_qsharp(name, contents) { + Ok(circuit) => circuit, + Err(e) => e, + }; + expect.assert_eq(&actual); +} diff --git a/source/vscode/src/circuitPreview.ts b/source/vscode/src/circuitPreview.ts index 33446929806..61337f16375 100644 --- a/source/vscode/src/circuitPreview.ts +++ b/source/vscode/src/circuitPreview.ts @@ -1,7 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import { type ICompilerWorker, log } from "qsharp-lang"; import * as vscode from "vscode"; +import { loadCompilerWorker } from "./common"; /** * URI scheme used for read-only Q# previews of circuit files. @@ -21,28 +23,152 @@ export const qsharpCircuitPreviewScheme = "qsharp-circuit-preview"; * Build the deterministic preview URI for a given circuit document. * * The path is purely cosmetic (it controls the editor tab label and is what - * the language service sees as the document name). The query carries the - * full original circuit URI so the provider can map back when needed. + * the language service sees as the document name). The original circuit + * URI is **not** encoded in the URI itself — it's stashed in a side-channel + * map keyed by preview-URI-as-string. We used to put the source URI in the + * preview URI's `query`, but `URI.toString()` includes the query verbatim, + * which means downstream consumers that treat the URI string as a path — + * notably `Path::extension()` in `qsc::packages::convert_circuit_sources` + * — would see the `.qsc` extension at the tail of the query and try to + * parse the preview Q# text as circuit JSON, surfacing a spurious + * `Error: expected value at line 1 column 1` diagnostic on every preview. + * + * Keying the side-channel map by the *encoded* basename (the same + * basename that ends up in the URI path) keeps the mapping deterministic + * across re-opens of the same document while still allowing two documents + * with the same basename in different folders to coexist (we disambiguate + * by appending the source URI's hash to the path when there's a + * collision). */ export function circuitPreviewUriFor(circuitUri: vscode.Uri): vscode.Uri { - // Use the basename for the display path, with a `.qs` suffix so VS Code - // selects the Q# language for the editor. - const basename = circuitUri.path.split(/[\\/]/).pop() ?? "circuit"; + // The preview path becomes the implicit Q# namespace when the language + // service compiles the preview as a single-file project, and the stem + // becomes the operation name in the generated Q#. Both must be valid + // Q# identifiers — see `sanitizeQsharpIdentifier` for the rules and the + // motivating bug (a file named `GroupSplittingTest.Main.qsc` would + // otherwise produce `operation GroupSplittingTest.Main(...)` and a + // namespace name with an embedded `.`, both syntax errors). + // + // Strip the source extension first so we don't sanitize the trailing + // `.qsc` into `_qsc` — a `Foo.qsc` source should preview as `Foo.qs`, + // not `Foo_qsc.qs`. + const rawBasename = circuitUri.path.split(/[\\/]/).pop() ?? "circuit"; + const rawStem = rawBasename.replace(/\.[^.]+$/, "") || rawBasename; + const stem = sanitizeQsharpIdentifier(rawStem); + // If two open circuits share a sanitized stem (either because their + // basenames are identical or because sanitization mapped two distinct + // inputs to the same identifier), give them distinct preview paths so + // VS Code treats them as separate documents — otherwise the first one + // to register would win and the second would appear blank. The hash + // is short, deterministic per source URI, and identifier-safe. + const existing = _sourceLookup.get(stem); + let path: string; + if (!existing || existing.toString() === circuitUri.toString()) { + path = `/${stem}.qs`; + _sourceLookup.set(stem, circuitUri); + } else { + const suffix = shortHash(circuitUri.toString()); + const disambiguated = `${stem}_${suffix}`; + path = `/${disambiguated}.qs`; + _sourceLookup.set(disambiguated, circuitUri); + } return vscode.Uri.from({ scheme: qsharpCircuitPreviewScheme, - // Leading slash so the URI parses cleanly across platforms. - path: `/${basename}.qs`, - query: circuitUri.toString(), + path, }); } +/** + * Coerce an arbitrary string (typically a `.qsc` file basename) into a + * valid Q# identifier suitable for use as an implicit-namespace component. + * + * Mirrors `sanitize_qsharp_identifier` in + * `compiler/qsc_circuit/src/circuit_to_qsharp.rs`; both layers must agree + * so the preview URI's namespace and the generated `operation` name line + * up. Rules: + * + * * Keep each char if it is ASCII alphanumeric or `_`; otherwise replace + * it with `_`. + * * If the result is empty or starts with a digit, prefix with `_`. + */ +function sanitizeQsharpIdentifier(raw: string): string { + let out = ""; + for (const ch of raw) { + if (/[A-Za-z0-9_]/.test(ch)) { + out += ch; + } else { + out += "_"; + } + } + if (out.length === 0 || /^[0-9]/.test(out)) { + out = `_${out}`; + } + return out; +} + +/** + * Side-channel mapping from preview URI key (the basename used as the + * preview path) to the original circuit URI. Populated by + * `circuitPreviewUriFor` and consumed by `sourceUriFromPreviewUri`. + * + * Lives at module scope so any caller of `circuitPreviewUriFor` and any + * subsequent `provideTextDocumentContent` see the same mapping. Entries + * are never removed — the basenames are short and the worst-case memory + * impact is bounded by the number of distinct circuit files the user + * opens in a session. + */ +const _sourceLookup = new Map(); + +/** + * Compact, stable hash of the source URI used to disambiguate same-basename + * collisions. Not a security primitive — just needs to be deterministic and + * collision-resistant enough for "more than one Foo.qsc open at once". + */ +function shortHash(input: string): string { + // FNV-1a 32-bit; tiny, deterministic, no deps. + let h = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + h ^= input.charCodeAt(i); + h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; + } + return h.toString(16).padStart(8, "0"); +} + +/** + * Recover the source `.qsc` URI for a preview URI from the side-channel + * map. Returns `undefined` if no entry is registered yet — most commonly + * when VS Code restored a preview tab from a previous session and the + * corresponding `.qsc` custom editor hasn't activated yet to populate the + * map. The lazy-regen path treats `undefined` as "wait for the editor", + * which is correct: when the `.qsc` editor activates it will call + * `setContent` and fire `onDidChange`, refreshing the placeholder. + */ +function sourceUriFromPreviewUri( + previewUri: vscode.Uri, +): vscode.Uri | undefined { + // Strip the leading `/` and the trailing `.qs` to recover the lookup key. + const key = previewUri.path.replace(/^\//, "").replace(/\.qs$/, ""); + return _sourceLookup.get(key); +} + /** * Read-only content provider that serves Q# code generated from a circuit. * - * The provider does not compute the Q# itself; callers (the circuit editor, - * primarily) push the latest generated code in via `setContent`. This keeps - * the provider free of any compiler / wasm dependency and lets the circuit - * editor own debouncing, error handling, and lifecycle of the preview. + * The fast path: the circuit editor calls `setContent` whenever the user + * edits the circuit, and `provideTextDocumentContent` returns whatever was + * pushed last. This keeps the preview live during editing without the + * provider having to know anything about wasm or the compiler. + * + * The slow path: when VS Code restores a preview tab from a previous + * session, the corresponding `.qsc` custom editor may not have activated + * yet (custom editors are lazy-loaded by VS Code). In that case nothing + * has called `setContent`, so the provider would normally show its + * placeholder forever. To avoid that, the provider can also asynchronously + * generate content directly from the source `.qsc` file: on a cache miss + * it returns the placeholder synchronously and kicks off a regeneration + * that calls `setContent` when done, which fires `onDidChange` and makes + * VS Code refresh the open tab. This recovers the preview without + * depending on the custom editor running. * * Content is keyed by the preview URI (as a string), not the source circuit * URI, so two circuits with the same basename in different folders never @@ -53,9 +179,19 @@ export class CircuitPreviewProvider { private readonly _onDidChange = new vscode.EventEmitter(); private readonly _content = new Map(); + /** + * URIs we've already kicked off a lazy regeneration for. Prevents + * stampedes when VS Code re-fetches the same restored tab multiple times + * before the first regeneration finishes (which it does, especially + * during initial activation). + */ + private readonly _regenerating = new Set(); + private _worker: ICompilerWorker | undefined; readonly onDidChange = this._onDidChange.event; + constructor(private readonly extensionUri: vscode.Uri) {} + /** * Update the cached Q# content for a preview URI. * @@ -69,7 +205,7 @@ export class CircuitPreviewProvider /** * Drop cached content for a preview URI. Subsequent fetches will fall - * back to the placeholder text. + * back to the placeholder text (and may trigger a lazy regeneration). */ clearContent(uri: vscode.Uri): void { if (this._content.delete(uri.toString())) { @@ -80,14 +216,110 @@ export class CircuitPreviewProvider provideTextDocumentContent(uri: vscode.Uri): string { const cached = this._content.get(uri.toString()); if (cached !== undefined) return cached; - // Placeholder shown before the circuit editor has produced any content - // (e.g. immediately after the preview is opened). Q# comments so syntax + + // Cache miss. Most likely VS Code restored this preview tab before the + // custom editor for the source .qsc file activated. Kick off a lazy + // regeneration directly from the source file so the user doesn't see + // the placeholder indefinitely. Fire-and-forget; if the source file is + // unreadable or the generator fails, we surface that as an error + // comment by writing it back through `setContent`. + void this.regenerateFromSource(uri); + + // Placeholder shown while regeneration is in flight (and as the final + // value if the source file no longer exists). Q# comments so syntax // highlighting still renders sensibly. return "// Q# preview will appear here as you edit the circuit.\n"; } + /** + * Read the source `.qsc` file from disk and regenerate the preview Q#. + * Idempotent per-URI: a regeneration already in flight is not duplicated. + * + * On success, the regenerated content is pushed via `setContent`, which + * fires `onDidChange` so VS Code refreshes the open editor for `uri`. + */ + private async regenerateFromSource(uri: vscode.Uri): Promise { + const key = uri.toString(); + if (this._regenerating.has(key)) return; + this._regenerating.add(key); + try { + const sourceUri = sourceUriFromPreviewUri(uri); + if (!sourceUri) return; + + let text: string; + try { + const bytes = await vscode.workspace.fs.readFile(sourceUri); + text = new TextDecoder().decode(bytes); + } catch (err) { + log.debug( + "circuit preview regen: failed to read source", + sourceUri.toString(), + err, + ); + return; + } + + const operationName = previewOperationNameFor(sourceUri); + + if (text.trim().length === 0) { + this.setContent( + uri, + `// Q# preview \u2014 empty circuit\n// Add gates to ${operationName} to generate Q#.\n`, + ); + return; + } + + // Validate JSON on the host first so a corrupt file produces a + // friendly comment instead of a wasm panic. + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (err: any) { + this.setContent( + uri, + previewErrorComment( + "invalid JSON", + `Circuit file is not valid JSON: ${err?.message ?? err}`, + ), + ); + return; + } + + let qsharp: string; + try { + const worker = this.getWorker(); + qsharp = await worker.circuitsToQsharp(operationName, parsed as any); + } catch (err: any) { + this.setContent( + uri, + previewErrorComment( + "generation failed", + `Could not generate Q#: ${err?.message ?? err}`, + ), + ); + return; + } + + this.setContent(uri, qsharp); + } finally { + this._regenerating.delete(key); + } + } + + private getWorker(): ICompilerWorker { + if (!this._worker) { + this._worker = loadCompilerWorker(this.extensionUri); + } + return this._worker; + } + dispose(): void { this._content.clear(); + this._regenerating.clear(); + if (this._worker) { + this._worker.terminate(); + this._worker = undefined; + } this._onDidChange.dispose(); } } @@ -108,11 +340,13 @@ let _provider: CircuitPreviewProvider | undefined; * Returns a Disposable suitable for `context.subscriptions.push(...)`. * Calling this more than once is a programming error. */ -export function registerCircuitPreviewProvider(): vscode.Disposable { +export function registerCircuitPreviewProvider( + extensionUri: vscode.Uri, +): vscode.Disposable { if (_provider !== undefined) { throw new Error("Circuit preview provider has already been registered."); } - _provider = new CircuitPreviewProvider(); + _provider = new CircuitPreviewProvider(extensionUri); const registration = vscode.workspace.registerTextDocumentContentProvider( qsharpCircuitPreviewScheme, _provider, @@ -136,3 +370,31 @@ export function getCircuitPreviewProvider(): | undefined { return _provider; } + +/** + * Derive the Q# operation name shown in the preview from the source URI. + * + * Mirrors the convention used by the circuit editor: basename minus + * extension. Falls back to a safe default for URIs without a recognizable + * basename. + */ +function previewOperationNameFor(circuitUri: vscode.Uri): string { + const basename = circuitUri.path.split(/[\\/]/).pop() ?? ""; + const name = basename.replace(/\.[^/.]+$/, ""); + return name.length > 0 ? name : "Circuit"; +} + +/** + * Format an error message as a Q# comment block so the preview tab keeps + * rendering as valid Q# even when generation fails. The `kind` shows up in + * the header so users can tell at a glance whether the issue is a malformed + * file vs. a compiler problem. + */ +function previewErrorComment(kind: string, message: string): string { + const lines = String(message).split(/\r?\n/); + return [ + `// Q# preview unavailable \u2014 ${kind}`, + ...lines.map((line) => `// ${line}`), + "", + ].join("\n"); +} diff --git a/source/vscode/src/extension.ts b/source/vscode/src/extension.ts index 03b1de7c534..ff352287d23 100644 --- a/source/vscode/src/extension.ts +++ b/source/vscode/src/extension.ts @@ -87,8 +87,15 @@ export async function activate( context.subscriptions.push(...(await activateLanguageService(context))); context.subscriptions.push(...startOtherQSharpDiagnostics()); context.subscriptions.push(...registerQSharpNotebookHandlers()); - context.subscriptions.push(registerCircuitPreviewProvider()); + context.subscriptions.push( + registerCircuitPreviewProvider(context.extensionUri), + ); context.subscriptions.push(CircuitEditorProvider.register(context)); + + // Note: previews restored from a previous session are repopulated + // lazily by `CircuitPreviewProvider.regenerateFromSource`, which + // reads the source `.qsc` file and regenerates Q# without depending + // on the custom editor having activated. See `circuitPreview.ts`. context.subscriptions.push( vscode.commands.registerCommand( "qsharp-vscode.showCircuitCodePreview", From 8b0fb077c232acba93ac6b09b6f1e369bfd1d66d Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Sat, 9 May 2026 16:03:01 -0700 Subject: [PATCH 09/87] Major CE internal re-architecting --- .../npm/qsharp/test/circuitActions.test.mjs | 199 +++ .../circuits-cases/empty.qsc.snapshot.html | 70 +- .../gate-and-measure.qsc.snapshot.html | 210 ++-- source/npm/qsharp/test/dropzones.test.mjs | 607 +++++++++ .../qsharp/test/interactionActions.test.mjs | 180 +++ .../qsharp/test/keyboardController.test.mjs | 164 +++ source/npm/qsharp/test/location.test.mjs | 109 ++ .../ux/circuit-vis/CIRCUIT_EDITOR_TODO.md | 1118 +++++++++++++++++ .../circuitActions.ts} | 369 +++--- .../circuit-vis/actions/interactionActions.ts | 129 ++ .../circuit-vis/actions/interactionState.ts | 106 ++ .../ux/circuit-vis/{ => data}/circuit.ts | 6 +- .../ux/circuit-vis/data/circuitModel.ts | 130 ++ .../qsharp/ux/circuit-vis/data/location.ts | 175 +++ .../ux/circuit-vis/{ => data}/register.ts | 2 +- source/npm/qsharp/ux/circuit-vis/draggable.ts | 531 -------- .../circuit-vis/{ => editor}/contextMenu.ts | 12 +- .../ux/circuit-vis/editor/dragController.ts | 571 +++++++++ .../qsharp/ux/circuit-vis/editor/draggable.ts | 693 ++++++++++ .../qsharp/ux/circuit-vis/editor/events.ts | 218 ++++ .../circuit-vis/editor/interactionContext.ts | 57 + .../circuit-vis/editor/keyboardController.ts | 58 + .../qsharp/ux/circuit-vis/editor/prompts.ts | 81 ++ .../ux/circuit-vis/editor/qubitController.ts | 149 +++ .../ux/circuit-vis/editor/scrollController.ts | 89 ++ .../circuit-vis/editor/selectionController.ts | 60 + source/npm/qsharp/ux/circuit-vis/events.ts | 1095 ---------------- source/npm/qsharp/ux/circuit-vis/index.ts | 4 +- .../circuit-vis/{ => renderer}/constants.ts | 0 .../{ => renderer}/formatters/formatUtils.ts | 0 .../formatters/gateFormatter.ts | 2 +- .../formatters/inputFormatter.ts | 6 +- .../formatters/registerFormatter.ts | 2 +- .../{ => renderer}/gateRenderData.ts | 20 +- .../ux/circuit-vis/renderer/layoutMap.ts | 87 ++ .../ux/circuit-vis/{ => renderer}/panel.ts | 10 +- .../ux/circuit-vis/{ => renderer}/process.ts | 123 +- source/npm/qsharp/ux/circuit-vis/sqore.ts | 96 +- .../state-viz/stateVizController.ts | 4 +- .../ux/circuit-vis/state-viz/worker/index.ts | 2 +- .../state-viz/worker/stateCompute.ts | 2 +- source/npm/qsharp/ux/circuit-vis/utils.ts | 128 +- source/npm/qsharp/ux/circuit.tsx | 2 +- source/npm/qsharp/ux/data.ts | 4 +- source/vscode/src/webview/webview.tsx | 3 +- 45 files changed, 5621 insertions(+), 2062 deletions(-) create mode 100644 source/npm/qsharp/test/circuitActions.test.mjs create mode 100644 source/npm/qsharp/test/dropzones.test.mjs create mode 100644 source/npm/qsharp/test/interactionActions.test.mjs create mode 100644 source/npm/qsharp/test/keyboardController.test.mjs create mode 100644 source/npm/qsharp/test/location.test.mjs create mode 100644 source/npm/qsharp/ux/circuit-vis/CIRCUIT_EDITOR_TODO.md rename source/npm/qsharp/ux/circuit-vis/{circuitManipulation.ts => actions/circuitActions.ts} (65%) create mode 100644 source/npm/qsharp/ux/circuit-vis/actions/interactionActions.ts create mode 100644 source/npm/qsharp/ux/circuit-vis/actions/interactionState.ts rename source/npm/qsharp/ux/circuit-vis/{ => data}/circuit.ts (60%) create mode 100644 source/npm/qsharp/ux/circuit-vis/data/circuitModel.ts create mode 100644 source/npm/qsharp/ux/circuit-vis/data/location.ts rename source/npm/qsharp/ux/circuit-vis/{ => data}/register.ts (76%) delete mode 100644 source/npm/qsharp/ux/circuit-vis/draggable.ts rename source/npm/qsharp/ux/circuit-vis/{ => editor}/contextMenu.ts (96%) create mode 100644 source/npm/qsharp/ux/circuit-vis/editor/dragController.ts create mode 100644 source/npm/qsharp/ux/circuit-vis/editor/draggable.ts create mode 100644 source/npm/qsharp/ux/circuit-vis/editor/events.ts create mode 100644 source/npm/qsharp/ux/circuit-vis/editor/interactionContext.ts create mode 100644 source/npm/qsharp/ux/circuit-vis/editor/keyboardController.ts create mode 100644 source/npm/qsharp/ux/circuit-vis/editor/prompts.ts create mode 100644 source/npm/qsharp/ux/circuit-vis/editor/qubitController.ts create mode 100644 source/npm/qsharp/ux/circuit-vis/editor/scrollController.ts create mode 100644 source/npm/qsharp/ux/circuit-vis/editor/selectionController.ts delete mode 100644 source/npm/qsharp/ux/circuit-vis/events.ts rename source/npm/qsharp/ux/circuit-vis/{ => renderer}/constants.ts (100%) rename source/npm/qsharp/ux/circuit-vis/{ => renderer}/formatters/formatUtils.ts (100%) rename source/npm/qsharp/ux/circuit-vis/{ => renderer}/formatters/gateFormatter.ts (99%) rename source/npm/qsharp/ux/circuit-vis/{ => renderer}/formatters/inputFormatter.ts (98%) rename source/npm/qsharp/ux/circuit-vis/{ => renderer}/formatters/registerFormatter.ts (98%) rename source/npm/qsharp/ux/circuit-vis/{ => renderer}/gateRenderData.ts (74%) create mode 100644 source/npm/qsharp/ux/circuit-vis/renderer/layoutMap.ts rename source/npm/qsharp/ux/circuit-vis/{ => renderer}/panel.ts (97%) rename source/npm/qsharp/ux/circuit-vis/{ => renderer}/process.ts (78%) diff --git a/source/npm/qsharp/test/circuitActions.test.mjs b/source/npm/qsharp/test/circuitActions.test.mjs new file mode 100644 index 00000000000..ef67507855b --- /dev/null +++ b/source/npm/qsharp/test/circuitActions.test.mjs @@ -0,0 +1,199 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// circuitActions tests: exercises the Action-layer of the circuit +// editor (`ux/circuit-vis/circuitActions.ts`) directly against a +// `CircuitModel` (Data layer), with **no JSDOM and no `CircuitEvents` +// stub**. That direct testability is the main point of the +// Data/Action/View split landed in R3 — see +// `ux/circuit-vis/CIRCUIT_EDITOR_TODO.md`. +// +// Tests cover the small mutation contracts each action promises: +// componentGrid layout, qubitUseCounts bookkeeping, and the trailing- +// wire trim that several actions trigger as a side effect. + +// @ts-check + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { CircuitModel } from "../dist/ux/circuit-vis/data/circuitModel.js"; +import { + addControl, + addOperation, + findAndRemoveOperations, + moveQubit, + removeControl, + removeOperation, + removeQubit, +} from "../dist/ux/circuit-vis/actions/circuitActions.js"; + +/** + * Build a fresh empty Circuit with `n` qubits and no operations. + * @param {number} n + * @returns {import("../dist/ux/circuit-vis/index.js").Circuit} + */ +function emptyCircuit(n) { + return { + qubits: Array.from({ length: n }, (_, id) => ({ id })), + componentGrid: [], + }; +} + +/** + * Build a unitary-gate template (the shape `addOperation` deep-copies). + * @param {string} gate + */ +function unitary(gate) { + return { kind: "unitary", gate, targets: [{ qubit: 0 }] }; +} + +test("CircuitModel constructor seeds qubitUseCounts from the existing grid", () => { + const circuit = { + qubits: [{ id: 0 }, { id: 1 }, { id: 2 }], + componentGrid: [ + { + components: [ + { + kind: "unitary", + gate: "X", + targets: [{ qubit: 0 }], + controls: [{ qubit: 1 }], + }, + ], + }, + ], + }; + + const model = new CircuitModel(/** @type {any} */ (circuit)); + + assert.deepEqual(model.qubitUseCounts, [1, 1, 0]); +}); + +test("addOperation appends to the target column and bumps qubitUseCounts", () => { + const model = new CircuitModel(emptyCircuit(2)); + + const added = addOperation(model, unitary("H"), "0,0", 0); + + assert.ok(added, "addOperation should return the new operation"); + assert.equal(model.componentGrid.length, 1); + assert.equal(model.componentGrid[0].components.length, 1); + assert.equal(model.componentGrid[0].components[0].gate, "H"); + // The op the action returns is the same reference it inserted into + // the grid — the deep-copy is taken from the input template, not + // the stored op. + assert.equal(added, model.componentGrid[0].components[0]); + assert.deepEqual(model.qubitUseCounts, [1, 0]); +}); + +test("removeOperation drops the op and decrements qubitUseCounts", () => { + const model = new CircuitModel(emptyCircuit(2)); + addOperation(model, unitary("H"), "0,0", 0); + addOperation(model, unitary("X"), "1,0", 1); + // Each addOperation appends a fresh column → grid is now [[H@0], [X@1]]. + assert.equal(model.componentGrid.length, 2); + assert.deepEqual(model.qubitUseCounts, [1, 1]); + + // Remove the X (column 1). + removeOperation(model, "1,0"); + + assert.equal(model.componentGrid.length, 1); + assert.equal(model.componentGrid[0].components[0].gate, "H"); + // Wire 1 went to 0 uses → trailing-wire trim drops it. + assert.deepEqual(model.qubitUseCounts, [1]); + assert.equal(model.qubits.length, 1); +}); + +test("addControl/removeControl maintain qubitUseCounts and trim trailing wires", () => { + const model = new CircuitModel(emptyCircuit(1)); + addOperation(model, unitary("X"), "0,0", 0); + assert.deepEqual(model.qubitUseCounts, [1]); + + // Add a control on a brand-new wire. The action should grow the + // qubit list, bump the use count, and never shrink it back behind + // wire 1. + const op = /** @type {any} */ (model.componentGrid[0].components[0]); + const ok = addControl(model, op, 1); + assert.equal(ok, true); + assert.equal(model.qubits.length, 2); + assert.deepEqual(model.qubitUseCounts, [1, 1]); + + // Adding the same control again is a no-op. + assert.equal(addControl(model, op, 1), false); + assert.deepEqual(model.qubitUseCounts, [1, 1]); + + // Removing the control on the trailing wire should also trim it. + assert.equal(removeControl(model, op, 1), true); + assert.equal(model.qubits.length, 1); + assert.deepEqual(model.qubitUseCounts, [1]); +}); + +test("findAndRemoveOperations decrements qubitUseCounts and prunes empty columns", () => { + const model = new CircuitModel(emptyCircuit(2)); + addOperation(model, unitary("H"), "0,0", 0); + addOperation(model, unitary("X"), "1,0", 1); + // Grid: [[H@0], [X@1]]. + assert.deepEqual(model.qubitUseCounts, [1, 1]); + + findAndRemoveOperations(model, (/** @type {any} */ op) => op.gate === "X"); + + assert.equal(model.componentGrid.length, 1); + assert.equal(model.componentGrid[0].components[0].gate, "H"); + // findAndRemoveOperations only decrements counts — it does NOT trim + // trailing wires (callers do that explicitly when they need to). + assert.deepEqual(model.qubitUseCounts, [1, 0]); +}); + +test("moveQubit swaps register references and reorders ops within a column", () => { + const circuit = { + qubits: [{ id: 0 }, { id: 1 }], + componentGrid: [ + { + components: [ + { kind: "unitary", gate: "X", targets: [{ qubit: 0 }] }, + { kind: "unitary", gate: "H", targets: [{ qubit: 1 }] }, + ], + }, + ], + }; + const model = new CircuitModel(/** @type {any} */ (circuit)); + + moveQubit( + model, + /* sourceWire */ 0, + /* targetWire */ 1, + /* isBetween */ false, + ); + + // After the swap, X targets wire 1 and H targets wire 0; column is + // re-sorted so H (lowest reg = 0) comes first. + const ops = model.componentGrid[0].components; + assert.equal(ops[0].gate, "H"); + assert.equal(/** @type {any} */ (ops[0]).targets[0].qubit, 0); + assert.equal(ops[1].gate, "X"); + assert.equal(/** @type {any} */ (ops[1]).targets[0].qubit, 1); + // Qubit ids are renumbered to match positions. + assert.equal(model.qubits[0].id, 0); + assert.equal(model.qubits[1].id, 1); +}); + +test("removeQubit shifts higher wire indices down by one", () => { + const circuit = { + qubits: [{ id: 0 }, { id: 1 }, { id: 2 }], + componentGrid: [ + { + components: [{ kind: "unitary", gate: "X", targets: [{ qubit: 2 }] }], + }, + ], + }; + const model = new CircuitModel(/** @type {any} */ (circuit)); + assert.deepEqual(model.qubitUseCounts, [0, 0, 1]); + + removeQubit(model, 1); + + assert.equal(model.qubits.length, 2); + // Wire 2's reference shifts to wire 1 (since wire 1 was deleted). + const op = /** @type {any} */ (model.componentGrid[0].components[0]); + assert.equal(op.targets[0].qubit, 1); + // qubitUseCounts unchanged at the removed index, only the slot is gone. + assert.deepEqual(model.qubitUseCounts, [0, 1]); +}); diff --git a/source/npm/qsharp/test/circuits-cases/empty.qsc.snapshot.html b/source/npm/qsharp/test/circuits-cases/empty.qsc.snapshot.html index c02ba32aa0b..1d2a606f28c 100644 --- a/source/npm/qsharp/test/circuits-cases/empty.qsc.snapshot.html +++ b/source/npm/qsharp/test/circuits-cases/empty.qsc.snapshot.html @@ -265,40 +265,42 @@

Toolbox

- - - - | - ψ - 0 - ⟩ - - -