diff --git a/npm/package.json b/npm/package.json index 175d54d41b..0fdd93b9fe 100644 --- a/npm/package.json +++ b/npm/package.json @@ -17,7 +17,8 @@ "node": "./dist/main.js", "default": "./dist/browser.js" }, - "./worker": "./dist/compiler/worker-browser.js" + "./compiler-worker": "./dist/compiler/worker-browser.js", + "./language-service-worker": "./dist/language-service/worker-browser.js" }, "scripts": { "build": "npm run generate && npm run build:tsc", diff --git a/npm/src/browser.ts b/npm/src/browser.ts index 9afa0b97ea..2c1365e028 100644 --- a/npm/src/browser.ts +++ b/npm/src/browser.ts @@ -122,3 +122,5 @@ export { default as samples } from "./samples.generated.js"; export { type VSDiagnostic } from "./vsdiagnostic.js"; export { log, type LogLevel }; export type { ICompilerWorker }; +export type { ILanguageServiceWorker, ILanguageService }; +export { type LanguageServiceEvent } from "./language-service/language-service.js"; diff --git a/npm/src/compiler/compiler.ts b/npm/src/compiler/compiler.ts index 9e8a035c7d..13be7f919b 100644 --- a/npm/src/compiler/compiler.ts +++ b/npm/src/compiler/compiler.ts @@ -1,12 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { IDiagnostic, ICompletionList } from "../../lib/node/qsc_wasm.cjs"; +import { IDiagnostic } from "../../lib/node/qsc_wasm.cjs"; import { log } from "../log.js"; +import { VSDiagnostic, mapDiagnostics } from "../vsdiagnostic.js"; +import { IServiceProxy, ServiceState } from "../worker-proxy.js"; import { eventStringToMsg } from "./common.js"; -import { mapDiagnostics, VSDiagnostic } from "../vsdiagnostic.js"; import { IQscEventTarget, QscEvents, makeEvent } from "./events.js"; -import { IServiceProxy, ServiceState } from "../worker-proxy.js"; // The wasm types generated for the node.js bundle are just the exported APIs, // so use those as the set used by the shared compiler @@ -15,9 +15,11 @@ type Wasm = typeof import("../../lib/node/qsc_wasm.cjs"); // These need to be async/promise results for when communicating across a WebWorker, however // for running the compiler in the same thread the result will be synchronous (a resolved promise). export interface ICompiler { + /** + * @deprecated use the language service for errors and other editor features. + */ checkCode(code: string): Promise; getHir(code: string): Promise; - getCompletions(): Promise; run( code: string, expr: string, @@ -63,9 +65,10 @@ export class Compiler implements ICompiler { globalThis.qscGitHash = this.wasm.git_hash(); } + /** + * @deprecated use the language service for errors and other editor features. + */ async checkCode(code: string): Promise { - // Temporary implementation until we have the language - // service notifications properly wired up to the editor. let diags: IDiagnostic[] = []; const languageService = new this.wasm.LanguageService( (uri: string, version: number, errors: IDiagnostic[]) => { @@ -80,15 +83,6 @@ export class Compiler implements ICompiler { return this.wasm.get_hir(code); } - async getCompletions(): Promise { - // Temporary implementation until we have the language - // service properly wired up to the editor. - // eslint-disable-next-line @typescript-eslint/no-empty-function - const languageService = new this.wasm.LanguageService(() => {}); - languageService.update_document("code", 1, ""); - return languageService.get_completions("code", 1); - } - async run( code: string, expr: string, diff --git a/npm/src/compiler/worker-proxy.ts b/npm/src/compiler/worker-proxy.ts index 23a50b3e24..e55a633263 100644 --- a/npm/src/compiler/worker-proxy.ts +++ b/npm/src/compiler/worker-proxy.ts @@ -15,7 +15,6 @@ import { QscEventData } from "./events.js"; const requests: MethodMap = { checkCode: "request", getHir: "request", - getCompletions: "request", run: "requestWithProgress", runKata: "requestWithProgress", }; diff --git a/npm/test/basics.js b/npm/test/basics.js index 1dc1f1e9aa..8e2f7f1a42 100644 --- a/npm/test/basics.js +++ b/npm/test/basics.js @@ -66,38 +66,6 @@ namespace Test { assert(result.result === "Zero"); }); -test("one syntax error", async () => { - const compiler = getCompiler(); - - const diags = await compiler.checkCode("namespace Foo []"); - assert.equal(diags.length, 1); - assert.equal(diags[0].start_pos, 14); - assert.equal(diags[0].end_pos, 15); -}); - -test("error with newlines", async () => { - const compiler = getCompiler(); - - const diags = await compiler.checkCode( - "namespace input { operation Foo(a) : Unit {} }" - ); - assert.equal(diags.length, 1); - assert.equal(diags[0].start_pos, 32); - assert.equal(diags[0].end_pos, 33); - assert.equal( - diags[0].message, - "type error: missing type in item signature\n\nhelp: types cannot be inferred for global declarations" - ); -}); - -test("completions include CNOT", async () => { - const compiler = getCompiler(); - - let results = await compiler.getCompletions(); - let cnot = results.items.find((x) => x.label === "CNOT"); - assert.ok(cnot); -}); - test("dump and message output", async () => { let code = `namespace Test { function Answer() : Int { @@ -117,27 +85,6 @@ test("dump and message output", async () => { assert(result.events[1].message == "hello, qsharp"); }); -test("type error", async () => { - let code = `namespace Sample { - operation main() : Result[] { - use q1 = Qubit(); - Ry(q1); - let m1 = M(q1); - return [m1]; - } - }`; - const compiler = getCompiler(); - let result = await compiler.checkCode(code); - - assert.equal(result.length, 1); - assert.equal(result[0].start_pos, 99); - assert.equal(result[0].end_pos, 105); - assert.equal( - result[0].message, - "type error: expected (Double, Qubit), found Qubit" - ); -}); - test("kata success", async () => { const evtTarget = new QscEventTarget(true); const compiler = getCompiler(); @@ -208,28 +155,6 @@ namespace Kata { assert.equal(results[0].result.message, "Error: syntax error"); }); -test("worker check", async () => { - let code = `namespace Sample { - operation main() : Result[] { - use q1 = Qubit(); - Ry(q1); - let m1 = M(q1); - return [m1]; - } - }`; - const compiler = getCompilerWorker(); - let result = await compiler.checkCode(code); - compiler.terminate(); - - assert.equal(result.length, 1); - assert.equal(result[0].start_pos, 99); - assert.equal(result[0].end_pos, 105); - assert.equal( - result[0].message, - "type error: expected (Double, Qubit), found Qubit" - ); -}); - test("worker 100 shots", async () => { let code = `namespace Test { function Answer() : Int { @@ -316,7 +241,7 @@ test("cancel worker", () => { compiler.run(code, "", 10, resultsHandler).catch((err) => { cancelledArray.push(err); }); - compiler.checkCode(code).catch((err) => { + compiler.getHir(code).catch((err) => { cancelledArray.push(err); }); @@ -327,11 +252,11 @@ test("cancel worker", () => { // Start a new compiler and ensure that works fine const compiler2 = getCompilerWorker(); - const result = await compiler2.checkCode(code); + const result = await compiler2.getHir(code); compiler2.terminate(); - // New 'check' result is good - assert(Array.isArray(result) && result.length === 0); + // getHir should have worked + assert(typeof result === "string" && result.length > 0); // Old requests were cancelled assert(cancelledArray.length === 2); @@ -342,6 +267,15 @@ test("cancel worker", () => { }); }); +test("check code", async () => { + const compiler = getCompiler(); + + const diags = await compiler.checkCode("namespace Foo []"); + assert.equal(diags.length, 1); + assert.equal(diags[0].start_pos, 14); + assert.equal(diags[0].end_pos, 15); +}); + test("language service diagnostics", async () => { const languageService = getLanguageService(); let gotDiagnostics = false; diff --git a/playground/build.js b/playground/build.js index e03cda8bd9..67ae0ac9c7 100644 --- a/playground/build.js +++ b/playground/build.js @@ -18,7 +18,11 @@ const outdir = join(thisDir, "public/libs"); /** @type {import("esbuild").BuildOptions} */ const buildOptions = { - entryPoints: [join(thisDir, "src/main.tsx"), join(thisDir, "src/worker.ts")], + entryPoints: [ + join(thisDir, "src/main.tsx"), + join(thisDir, "src/compiler-worker.ts"), + join(thisDir, "src/language-service-worker.ts"), + ], outdir, bundle: true, target: ["es2020", "chrome64", "edge79", "firefox62", "safari11.1"], diff --git a/playground/src/compiler-worker.ts b/playground/src/compiler-worker.ts new file mode 100644 index 0000000000..55c9ec83ab --- /dev/null +++ b/playground/src/compiler-worker.ts @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { messageHandler } from "qsharp/compiler-worker"; + +self.onmessage = messageHandler; diff --git a/playground/src/editor.tsx b/playground/src/editor.tsx index 6ae6e6e52a..626547a54d 100644 --- a/playground/src/editor.tsx +++ b/playground/src/editor.tsx @@ -7,6 +7,8 @@ import { useEffect, useRef, useState } from "preact/hooks"; import { CompilerState, ICompilerWorker, + ILanguageServiceWorker, + LanguageServiceEvent, QscEventTarget, VSDiagnostic, log, @@ -24,10 +26,23 @@ function VSDiagsToMarkers( srcModel: monaco.editor.ITextModel ): monaco.editor.IMarkerData[] { return errors.map((err) => { + let severity = monaco.MarkerSeverity.Error; + switch (err.severity) { + case "error": + severity = monaco.MarkerSeverity.Error; + break; + case "warning": + severity = monaco.MarkerSeverity.Warning; + break; + case "info": + severity = monaco.MarkerSeverity.Info; + break; + } + const startPos = srcModel.getPositionAt(err.start_pos); const endPos = srcModel.getPositionAt(err.end_pos); const marker: monaco.editor.IMarkerData = { - severity: monaco.MarkerSeverity.Error, + severity, message: err.message, startLineNumber: startPos.lineNumber, startColumn: startPos.column, @@ -52,13 +67,14 @@ export function Editor(props: { showShots: boolean; setHir: (hir: string) => void; activeTab: ActiveTab; + languageService: ILanguageServiceWorker; }) { const editor = useRef(null); const errMarks = useRef({ checkDiags: [], shotDiags: [] }); const editorDiv = useRef(null); - // Maintain a ref to the latest check function, as it closes over a bunch of stuff - const checkRef = useRef(async () => { + // Maintain a ref to the latest getHir function, as it closes over a bunch of stuff + const hirRef = useRef(async () => { return; }); @@ -69,10 +85,15 @@ export function Editor(props: { ); const [hasCheckErrors, setHasCheckErrors] = useState(false); - function markErrors() { + function markErrors(version?: number) { const model = editor.current?.getModel(); if (!model) return; + if (version != null && version !== model.getVersionId()) { + // Diagnostics event received for an outdated model + return; + } + const errs = [ ...errMarks.current.checkDiags, ...errMarks.current.shotDiags, @@ -88,16 +109,13 @@ export function Editor(props: { setErrors(errList); } - checkRef.current = async function onCheck() { + hirRef.current = async function updateHir() { const code = editor.current?.getValue(); if (code == null) return; - const diags = await props.compiler.checkCode(code); + if (props.activeTab === "hir-tab") { props.setHir(await props.compiler.getHir(code)); } - errMarks.current.checkDiags = diags; - markErrors(); - setHasCheckErrors(diags.length > 0); }; async function onRun() { @@ -132,7 +150,19 @@ export function Editor(props: { editor.current = newEditor; const srcModel = monaco.editor.createModel(props.code, "qsharp"); newEditor.setModel(srcModel); - srcModel.onDidChangeContent(() => checkRef.current()); + srcModel.onDidChangeContent(() => hirRef.current()); + + // TODO: If the language service ever changes, this callback + // will be invalid as it captures the *original* props.languageService + // and not the updated one. Not a problem currently since the language + // service is never updated, but not correct either. + srcModel.onDidChangeContent(async () => { + await props.languageService.updateDocument( + srcModel.uri.toString(), + srcModel.getVersionId(), + srcModel.getValue() + ); + }); function onResize() { newEditor.layout(); @@ -147,6 +177,22 @@ export function Editor(props: { }; }, []); + useEffect(() => { + function onDiagnostics(evt: LanguageServiceEvent) { + const diagnostics = evt.detail.diagnostics; + errMarks.current.checkDiags = diagnostics; + markErrors(evt.detail.version); + setHasCheckErrors(diagnostics.length > 0); + } + + props.languageService.addEventListener("diagnostics", onDiagnostics); + + return () => { + log.info("Removing diagnostics listener"); + props.languageService.removeEventListener("diagnostics", onDiagnostics); + }; + }, [props.languageService]); + useEffect(() => { const theEditor = editor.current; if (!theEditor) return; @@ -164,7 +210,7 @@ export function Editor(props: { useEffect(() => { // Whenever the active tab changes, run check again. - checkRef.current(); + hirRef.current(); }, [props.activeTab]); // On reset, reload the initial code diff --git a/playground/src/kata.tsx b/playground/src/kata.tsx index 9d3d546aea..394e813905 100644 --- a/playground/src/kata.tsx +++ b/playground/src/kata.tsx @@ -2,7 +2,13 @@ // Licensed under the MIT License. import { useEffect, useRef } from "preact/hooks"; -import { CompilerState, ICompilerWorker, Kata, QscEventTarget } from "qsharp"; +import { + CompilerState, + ICompilerWorker, + ILanguageServiceWorker, + Kata, + QscEventTarget, +} from "qsharp"; import { Editor } from "./editor.js"; import { OutputTabs } from "./tabs.js"; @@ -11,6 +17,7 @@ export function Kata(props: { compiler: ICompilerWorker; compilerState: CompilerState; onRestartCompiler: () => void; + languageService: ILanguageServiceWorker; }) { const kataContent = useRef(null); const itemContent = useRef<(HTMLDivElement | null)[]>([]); @@ -81,6 +88,7 @@ export function Kata(props: { key={item.id} setHir={() => ({})} activeTab="results-tab" + languageService={props.languageService} > void }; @@ -41,7 +44,7 @@ const wasmPromise = loadWasmModule(modulePath); // Start loading but don't wait function createCompiler(onStateChange: (val: CompilerState) => void) { log.info("In createCompiler"); - const compiler = getCompilerWorker(workerPath); + const compiler = getCompilerWorker(compilerWorkerPath); compiler.onstatechange = onStateChange; return compiler; } @@ -51,7 +54,13 @@ function App(props: { katas: Kata[]; linkedCode?: string }) { const [compiler, setCompiler] = useState(() => createCompiler(setCompilerState) ); - const [evtTarget] = useState(new QscEventTarget(true)); + const [evtTarget] = useState(() => new QscEventTarget(true)); + + const [languageService] = useState(() => { + const languageService = getLanguageServiceWorker(languageServiceWorkerPath); + registerMonacoLanguageServiceProviders(languageService); + return languageService; + }); const [currentNavItem, setCurrentNavItem] = useState( props.linkedCode ? "linked" : "Minimal" @@ -123,6 +132,7 @@ function App(props: { katas: Kata[]; linkedCode?: string }) { shotError={shotError} setHir={setHir} activeTab={activeTab} + languageService={languageService} > )} @@ -168,6 +179,105 @@ async function loaded() { render(, document.body); } +function registerMonacoLanguageServiceProviders( + languageService: ILanguageService +) { + monaco.languages.registerCompletionItemProvider("qsharp", { + // @ts-expect-error - Monaco's types expect range to be defined, + // but it's actually optional and the default behavior is better + provideCompletionItems: async ( + model: monaco.editor.ITextModel, + position: monaco.Position + ) => { + const completions = await languageService.getCompletions( + model.uri.toString(), + model.getOffsetAt(position) + ); + return { + suggestions: completions.items.map((i) => { + let kind; + switch (i.kind) { + case "function": + kind = monaco.languages.CompletionItemKind.Function; + break; + case "module": + kind = monaco.languages.CompletionItemKind.Module; + break; + case "keyword": + kind = monaco.languages.CompletionItemKind.Keyword; + break; + case "issue": + kind = monaco.languages.CompletionItemKind.Issue; + break; + } + return { + label: i.label, + kind: kind, + insertText: i.label, + range: undefined, + }; + }), + }; + }, + }); + + monaco.languages.registerHoverProvider("qsharp", { + provideHover: async ( + model: monaco.editor.ITextModel, + position: monaco.Position + ) => { + const hover = await languageService.getHover( + model.uri.toString(), + model.getOffsetAt(position) + ); + + if (hover) { + const start = model.getPositionAt(hover.span.start); + const end = model.getPositionAt(hover.span.end); + + return { + contents: [{ value: hover.contents }], + range: { + startLineNumber: start.lineNumber, + startColumn: start.column, + endLineNumber: end.lineNumber, + endColumn: end.column, + }, + }; + } + return null; + }, + }); + + monaco.languages.registerDefinitionProvider("qsharp", { + provideDefinition: async ( + model: monaco.editor.ITextModel, + position: monaco.Position + ) => { + const definition = await languageService.getDefinition( + model.uri.toString(), + model.getOffsetAt(position) + ); + + if (!definition) return null; + const uri = monaco.Uri.parse(definition.source); + const definitionPosition = + uri.toString() === model.uri.toString() + ? model.getPositionAt(definition.offset) + : { lineNumber: 1, column: 1 }; + return { + uri, + range: { + startLineNumber: definitionPosition.lineNumber, + startColumn: definitionPosition.column, + endLineNumber: definitionPosition.lineNumber, + endColumn: definitionPosition.column + 1, + }, + }; + }, + }); +} + // Monaco provides the 'require' global for loading modules. declare const require: { config: (settings: object) => void; diff --git a/playground/src/worker.ts b/playground/src/worker.ts deleted file mode 100644 index c7c7e50a19..0000000000 --- a/playground/src/worker.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { messageHandler } from "qsharp/worker"; - -self.onmessage = messageHandler; diff --git a/vscode/src/completion.ts b/vscode/src/completion.ts new file mode 100644 index 0000000000..d0449201b5 --- /dev/null +++ b/vscode/src/completion.ts @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ILanguageService } from "qsharp"; +import * as vscode from "vscode"; +import { CompletionItem } from "vscode"; + +export function createCompletionItemProvider( + languageService: ILanguageService +) { + return new QSharpCompletionItemProvider(languageService); +} + +class QSharpCompletionItemProvider implements vscode.CompletionItemProvider { + constructor(public languageService: ILanguageService) {} + + async provideCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + token: vscode.CancellationToken, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + context: vscode.CompletionContext + ) { + const completions = await this.languageService.getCompletions( + document.uri.toString(), + document.offsetAt(position) + ); + return completions.items.map((c) => { + let kind; + switch (c.kind) { + case "function": + kind = vscode.CompletionItemKind.Function; + break; + case "module": + kind = vscode.CompletionItemKind.Module; + break; + case "keyword": + kind = vscode.CompletionItemKind.Keyword; + break; + case "issue": + kind = vscode.CompletionItemKind.Issue; + break; + } + return new CompletionItem(c.label, kind); + }); + } +} diff --git a/vscode/src/definition.ts b/vscode/src/definition.ts new file mode 100644 index 0000000000..68dadfff18 --- /dev/null +++ b/vscode/src/definition.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ILanguageService } from "qsharp"; +import * as vscode from "vscode"; + +export function createDefinitionProvider(languageService: ILanguageService) { + return new QSharpDefinitionProvider(languageService); +} + +class QSharpDefinitionProvider implements vscode.DefinitionProvider { + constructor(public languageService: ILanguageService) {} + + async provideDefinition( + document: vscode.TextDocument, + position: vscode.Position, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + token: vscode.CancellationToken + ) { + const definition = await this.languageService.getDefinition( + document.uri.toString(), + document.offsetAt(position) + ); + if (!definition) return null; + const uri = vscode.Uri.parse(definition.source); + // We have to do this to map the position :( + const definitionPosition = ( + await vscode.workspace.openTextDocument(uri) + ).positionAt(definition.offset); + return new vscode.Location(uri, definitionPosition); + } +} diff --git a/vscode/src/diagnostics.ts b/vscode/src/diagnostics.ts index 3b624c8ac7..4cebdd7321 100644 --- a/vscode/src/diagnostics.ts +++ b/vscode/src/diagnostics.ts @@ -1,83 +1,63 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { type getCompiler } from "qsharp"; +import { ILanguageService, VSDiagnostic } from "qsharp"; import * as vscode from "vscode"; import { qsharpLanguageId } from "./common.js"; -/** - * Checks the currently open Q# documents and publishes diagnostics. - * Then subscribes to document updates to check code and update the diagnostics. - */ -export async function startCheckingQSharp( - compiler: Awaited> -) { +export function startCheckingQSharp(languageService: ILanguageService) { const diagCollection = vscode.languages.createDiagnosticCollection(qsharpLanguageId); - // This here is OK, but be aware that async functions in map() are not - // necessarily executed sequentially - await Promise.all( - vscode.workspace.textDocuments.map(async (document) => { - await updateDiagnosticsIfQSharp(document); - }) - ); - - const subscriptions = []; - - subscriptions.push( - vscode.workspace.onDidOpenTextDocument((document) => { - updateDiagnosticsIfQSharp(document); - }) - ); - - subscriptions.push( - vscode.workspace.onDidChangeTextDocument((evt) => { - updateDiagnosticsIfQSharp(evt.document); - }) - ); - - subscriptions.push( - vscode.workspace.onDidCloseTextDocument((document) => { - if (vscode.languages.match(qsharpLanguageId, document)) { - // Clear diagnostics for a closed document - diagCollection.delete(document.uri); - } - }) - ); - - async function updateDiagnosticsIfQSharp(document: vscode.TextDocument) { - if (vscode.languages.match(qsharpLanguageId, document)) { - const diags = await compiler.checkCode(document.getText()); - - const getPosition = (offset: number) => { - return document.positionAt(offset); - }; - - diagCollection.set( - document.uri, - diags.map((d) => { - let severity; - switch (d.severity) { - case "error": - severity = vscode.DiagnosticSeverity.Error; - break; - case "warning": - severity = vscode.DiagnosticSeverity.Warning; - break; - case "info": - severity = vscode.DiagnosticSeverity.Information; - break; - } - return new vscode.Diagnostic( - new vscode.Range(getPosition(d.start_pos), getPosition(d.end_pos)), - d.message, - severity - ); - }) - ); - } + function onDiagnostics(evt: { + detail: { + uri: string; + version: number; + diagnostics: VSDiagnostic[]; + }; + }) { + const diagnostics = evt.detail; + + const getPosition = (offset: number) => { + // We need the document here to be able to map offsets to line/column positions. + // The document may not be available if this event is to clear diagnostics + // for an already-closed document from the problems list. + // Note: This mapping will break down if we ever send diagnostics for closed files. + const document = vscode.workspace.textDocuments.filter( + (doc) => doc.uri.toString() === diagnostics.uri + )[0]; + return document.positionAt(offset); + }; + + diagCollection.set( + vscode.Uri.parse(evt.detail.uri), + diagnostics.diagnostics.map((d) => { + let severity; + switch (d.severity) { + case "error": + severity = vscode.DiagnosticSeverity.Error; + break; + case "warning": + severity = vscode.DiagnosticSeverity.Warning; + break; + case "info": + severity = vscode.DiagnosticSeverity.Information; + break; + } + return new vscode.Diagnostic( + new vscode.Range(getPosition(d.start_pos), getPosition(d.end_pos)), + d.message, + severity + ); + }) + ); } - return subscriptions; + languageService.addEventListener("diagnostics", onDiagnostics); + + return { + dispose: () => { + languageService.removeEventListener("diagnostics", onDiagnostics); + }, + }; } diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 46722858f0..e17c513e1b 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -1,29 +1,111 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { getCompiler, loadWasmModule } from "qsharp"; +import { ILanguageService, getLanguageService, loadWasmModule } from "qsharp"; import * as vscode from "vscode"; +import { createCompletionItemProvider } from "./completion.js"; +import { createDefinitionProvider } from "./definition.js"; import { startCheckingQSharp } from "./diagnostics.js"; +import { createHoverProvider } from "./hover.js"; import { registerQSharpNotebookHandlers } from "./notebook.js"; export async function activate(context: vscode.ExtensionContext) { + initializeLogger(); + + const languageService = await loadLanguageService(context.extensionUri); + + context.subscriptions.push( + ...registerDocumentUpdateHandlers(languageService) + ); + + context.subscriptions.push(...registerQSharpNotebookHandlers()); + + context.subscriptions.push(startCheckingQSharp(languageService)); + + // completions + context.subscriptions.push( + vscode.languages.registerCompletionItemProvider( + "qsharp", + createCompletionItemProvider(languageService), + "." + ) + ); + + // hover + context.subscriptions.push( + vscode.languages.registerHoverProvider( + "qsharp", + createHoverProvider(languageService) + ) + ); + + // go to def + context.subscriptions.push( + vscode.languages.registerDefinitionProvider( + "qsharp", + createDefinitionProvider(languageService) + ) + ); +} + +function initializeLogger() { + const output = vscode.window.createOutputChannel("Q#", { log: true }); + + // Override the global logger with functions that write to the output channel + global.qscLog.error = output.error; + global.qscLog.warn = output.warn; + global.qscLog.info = output.info; + global.qscLog.debug = output.debug; + global.qscLog.trace = output.trace; + + global.qscLog.info("Q# extension activated."); +} + +function registerDocumentUpdateHandlers(languageService: ILanguageService) { + vscode.workspace.textDocuments.forEach((document) => { + updateIfQsharpDocument(document); + }); + const subscriptions = []; + subscriptions.push( + vscode.workspace.onDidOpenTextDocument((document) => { + updateIfQsharpDocument(document); + }) + ); - const compiler = await loadCompiler(context.extensionUri); + subscriptions.push( + vscode.workspace.onDidChangeTextDocument((evt) => { + updateIfQsharpDocument(evt.document); + }) + ); - subscriptions.push(...(await startCheckingQSharp(compiler))); + subscriptions.push( + vscode.workspace.onDidCloseTextDocument((document) => { + if (vscode.languages.match("qsharp", document)) { + languageService.closeDocument(document.uri.toString()); + } + }) + ); - subscriptions.push(...registerQSharpNotebookHandlers()); + function updateIfQsharpDocument(document: vscode.TextDocument) { + if (vscode.languages.match("qsharp", document)) { + languageService.updateDocument( + document.uri.toString(), + document.version, + document.getText() + ); + } + } - context.subscriptions.push(...subscriptions); + return subscriptions; } /** * Loads the Q# compiler including the WASM module */ -async function loadCompiler(baseUri: vscode.Uri) { +async function loadLanguageService(baseUri: vscode.Uri) { const wasmUri = vscode.Uri.joinPath(baseUri, "./wasm/qsc_wasm_bg.wasm"); const wasmBytes = await vscode.workspace.fs.readFile(wasmUri); await loadWasmModule(wasmBytes); - return await getCompiler(); + return await getLanguageService(); } diff --git a/vscode/src/hover.ts b/vscode/src/hover.ts new file mode 100644 index 0000000000..da0f80ab66 --- /dev/null +++ b/vscode/src/hover.ts @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ILanguageService } from "qsharp"; +import * as vscode from "vscode"; + +export function createHoverProvider(languageService: ILanguageService) { + return new QSharpHoverProvider(languageService); +} + +class QSharpHoverProvider implements vscode.HoverProvider { + constructor(public languageService: ILanguageService) {} + + async provideHover( + document: vscode.TextDocument, + position: vscode.Position, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + token: vscode.CancellationToken + ) { + const hover = await this.languageService.getHover( + document.uri.toString(), + document.offsetAt(position) + ); + return ( + hover && + new vscode.Hover( + new vscode.MarkdownString(hover.contents), + new vscode.Range( + document.positionAt(hover.span.start), + document.positionAt(hover.span.end) + ) + ) + ); + } +}