diff --git a/packages/playground/definitions/index.d.ts b/packages/playground/definitions/index.d.ts index 05bfab01a76..affda601578 100644 --- a/packages/playground/definitions/index.d.ts +++ b/packages/playground/definitions/index.d.ts @@ -1,2 +1,15 @@ declare module "*.module.css"; + +declare module "monaco-editor/esm/vs/editor/editor.worker.js" { + interface IWorkerContext { + host: Record; + getMirrorModels(): Array<{ + readonly uri: import("monaco-editor").Uri; + readonly version: number; + getValue(): string; + }>; + } + + export function initialize(callback: (ctx: IWorkerContext, createData: unknown) => object): void; +} declare module "*.css"; diff --git a/packages/playground/package.json b/packages/playground/package.json index 3ed494f4433..4bd33380bee 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -47,6 +47,14 @@ "types": "./dist/src/react/viewers/index.d.ts", "default": "./dist/react/viewers/index.js" }, + "./worker": { + "types": "./dist/src/worker/index.d.ts", + "default": "./dist/worker/index.js" + }, + "./worker/playground-worker": { + "types": "./dist/src/worker/playground-worker.d.ts", + "default": "./dist/worker/playground-worker.js" + }, "./style.css": "./dist/style.css", "./styles.css": "./dist/style.css" }, diff --git a/packages/playground/src/monaco-worker.ts b/packages/playground/src/monaco-worker.ts index 34b4dae5d99..642e71a3f39 100644 --- a/packages/playground/src/monaco-worker.ts +++ b/packages/playground/src/monaco-worker.ts @@ -8,6 +8,12 @@ export function registerMonacoDefaultWorkersForVite() { ); return jsonWorker(); } + case "typespec": { + const { default: typespecWorker } = await import( + "@typespec/playground/worker/playground-worker?worker" as any + ); + return typespecWorker(); + } default: { const { default: editorWorker } = await import( "monaco-editor/esm/vs/editor/editor.worker?worker" as any diff --git a/packages/playground/src/react/types.ts b/packages/playground/src/react/types.ts index 876c218e2a1..fade83b370a 100644 --- a/packages/playground/src/react/types.ts +++ b/packages/playground/src/react/types.ts @@ -1,22 +1,35 @@ import type { Program } from "@typespec/compiler"; import type { ReactNode } from "react"; +import type { SerializedDiagnostic } from "../worker/protocol.js"; export type CompilationCrashed = { readonly internalCompilerError: any; }; export type CompileResult = { - readonly program: Program; - readonly outputFiles: string[]; + /** + * The compiler Program object. Only available in main-thread compilation mode. + * Not available in worker mode since Program cannot cross the worker boundary. + */ + readonly program?: Program; + + /** Diagnostics from the compilation, serialized for display. */ + readonly diagnostics: readonly SerializedDiagnostic[]; + + /** Map from relative output path to file content. */ + readonly outputFiles: Record; }; export type CompilationState = CompileResult | CompilationCrashed; export type EmitterOptions = Record>; export interface OutputViewerProps { - readonly program: Program; - /** Files emitted */ - readonly outputFiles: string[]; + /** + * The compiler Program object. May be undefined in worker mode. + */ + readonly program?: Program; + /** Map from relative output path to file content. */ + readonly outputFiles: Record; /** Current viewer state (for viewers that have internal state) */ readonly viewerState?: Record; /** Callback to update viewer state */ diff --git a/packages/playground/src/worker-client.ts b/packages/playground/src/worker-client.ts new file mode 100644 index 00000000000..fe2cc4e0c0e --- /dev/null +++ b/packages/playground/src/worker-client.ts @@ -0,0 +1,41 @@ +import * as monaco from "monaco-editor"; +import type { LibraryImportOptions } from "./core.js"; +import type { TypeSpecWorkerApi, WorkerInitResult } from "./worker/protocol.js"; + +export interface TypeSpecWorkerClient { + /** The proxy to call worker methods from the main thread. */ + readonly proxy: TypeSpecWorkerApi; + /** Capabilities returned by the worker after initialization. */ + readonly initResult: WorkerInitResult; + /** Dispose the worker. */ + dispose(): void; +} + +/** + * Create a TypeSpec web worker using Monaco's `createWebWorker` infrastructure. + * + * The worker runs compilation and language services off the main thread. + * Callers must ensure `MonacoEnvironment.getWorker` handles `label: "typespec"`. + */ +export async function createTypeSpecWorker(params: { + libraries: readonly string[]; + importConfig?: LibraryImportOptions; +}): Promise { + const worker = monaco.editor.createWebWorker({ + moduleId: "typespec-playground-worker", + label: "typespec", + keepIdleModels: true, + }); + + const proxy = await worker.getProxy(); + const initResult = await proxy.initialize({ + libraries: params.libraries, + importConfig: params.importConfig, + }); + + return { + proxy, + initResult, + dispose: () => worker.dispose(), + }; +} diff --git a/packages/playground/src/worker-services.ts b/packages/playground/src/worker-services.ts new file mode 100644 index 00000000000..ecce150fc2c --- /dev/null +++ b/packages/playground/src/worker-services.ts @@ -0,0 +1,383 @@ +/** + * Registers Monaco language providers for TypeSpec that delegate to a web worker. + * + * This is the worker-based equivalent of `registerMonacoLanguage()` in `services.ts`. + * All LSP operations are proxied to the worker via Monaco's `createWebWorker` infrastructure. + */ +import { TypeSpecLanguageConfiguration } from "@typespec/compiler"; +import * as monaco from "monaco-editor"; +import { DocumentHighlightKind, type WorkspaceEdit } from "vscode-languageserver"; +import { LspToMonaco } from "./lsp/lsp-to-monaco.js"; +import type { + SerializedCodeFixRef, + SerializedDiagnostic, + TypeSpecWorkerApi, + WorkerInitResult, +} from "./worker/protocol.js"; + +/** Diagnostics from the last worker compilation, for the code action provider. */ +let _workerDiagnostics: readonly SerializedDiagnostic[] = []; +let _workerProxy: TypeSpecWorkerApi | undefined; + +/** + * Update the diagnostics used by the worker-mode code action provider. + * Call this after each compilation. + */ +export function updateWorkerDiagnostics( + proxy: TypeSpecWorkerApi, + diagnostics: readonly SerializedDiagnostic[], +) { + _workerDiagnostics = diagnostics; + _workerProxy = proxy; +} + +function getIndentAction( + value: "none" | "indent" | "indentOutdent" | "outdent", +): monaco.languages.IndentAction { + switch (value) { + case "none": + return monaco.languages.IndentAction.None; + case "indent": + return monaco.languages.IndentAction.Indent; + case "indentOutdent": + return monaco.languages.IndentAction.IndentOutdent; + case "outdent": + return monaco.languages.IndentAction.Outdent; + } +} + +function getTypeSpecLanguageConfiguration(): monaco.languages.LanguageConfiguration { + return { + ...(TypeSpecLanguageConfiguration as any), + onEnterRules: TypeSpecLanguageConfiguration.onEnterRules.map((rule) => { + return { + beforeText: new RegExp(rule.beforeText.pattern), + previousLineText: + "previousLineText" in rule ? new RegExp(rule.previousLineText.pattern) : undefined, + action: { + indentAction: getIndentAction(rule.action.indent), + appendText: "appendText" in rule.action ? rule.action.appendText : undefined, + removeText: "removeText" in rule.action ? rule.action.removeText : undefined, + }, + }; + }), + }; +} + +function lspPosition(pos: monaco.Position) { + return { line: pos.lineNumber - 1, character: pos.column - 1 }; +} + +function lspDocumentArgs(model: monaco.editor.ITextModel) { + return { + textDocument: { uri: model.uri.toString() }, + }; +} + +function lspArgs(model: monaco.editor.ITextModel, pos: monaco.Position) { + return { ...lspDocumentArgs(model), position: lspPosition(pos) }; +} + +function monacoLocation(loc: { uri: string; range: any }): monaco.languages.Location { + return { + uri: monaco.Uri.parse(loc.uri), + range: LspToMonaco.range(loc.range), + }; +} + +function monacoDocumentHighlightKind(kind: number | undefined) { + switch (kind) { + case DocumentHighlightKind.Text: + return monaco.languages.DocumentHighlightKind.Text; + case DocumentHighlightKind.Read: + return monaco.languages.DocumentHighlightKind.Read; + case DocumentHighlightKind.Write: + return monaco.languages.DocumentHighlightKind.Write; + default: + return undefined; + } +} + +function monacoWorkspaceEdit(edit: WorkspaceEdit): monaco.languages.WorkspaceEdit { + const edits: monaco.languages.IWorkspaceTextEdit[] = []; + for (const [uri, changes] of Object.entries(edit.changes ?? {})) { + const resource = monaco.Uri.parse(uri); + for (const change of changes) { + edits.push({ resource, textEdit: LspToMonaco.textEdit(change), versionId: undefined }); + } + } + return { edits }; +} + +/** + * Resolve a {@link SerializedCodeFixRef} into Monaco workspace edits by calling the worker. + */ +async function resolveWorkerCodeFix( + proxy: TypeSpecWorkerApi, + fixRef: SerializedCodeFixRef, + model: monaco.editor.ITextModel, +): Promise { + const resolved = await proxy.resolveCodeFix(fixRef.index); + return resolved.edits + .filter((edit) => edit.file === "/test/main.tsp") + .map((edit) => ({ + resource: model.uri, + textEdit: { + range: { + startLineNumber: edit.range.start.line + 1, + startColumn: edit.range.start.character + 1, + endLineNumber: edit.range.end.line + 1, + endColumn: edit.range.end.character + 1, + }, + text: edit.newText, + }, + versionId: undefined, + })); +} + +/** + * Convert a serialized diagnostic location to a Monaco range. + */ +function serializedLocationToRange(loc: SerializedDiagnostic["location"]): monaco.IRange { + if (!loc) { + return { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }; + } + return { + startLineNumber: loc.startLine + 1, + startColumn: loc.startCharacter + 1, + endLineNumber: loc.endLine + 1, + endColumn: loc.endCharacter + 1, + }; +} + +function rangesOverlap(a: monaco.IRange, b: monaco.IRange): boolean { + if (a.endLineNumber < b.startLineNumber || b.endLineNumber < a.startLineNumber) return false; + if (a.endLineNumber === b.startLineNumber && a.endColumn < b.startColumn) return false; + if (b.endLineNumber === a.startLineNumber && b.endColumn < a.startColumn) return false; + return true; +} + +/** + * Register Monaco language providers for TypeSpec, delegating all LSP operations + * to the given worker proxy. + * + * Call this once after the worker has been initialized. + */ +export function registerMonacoLanguageWorker( + proxy: TypeSpecWorkerApi, + initResult: WorkerInitResult, +) { + monaco.languages.register({ id: "typespec", extensions: [".tsp"] }); + monaco.languages.setLanguageConfiguration("typespec", getTypeSpecLanguageConfiguration()); + + // Avoid double-registration + if ((window as any).registeredServices) return; + (window as any).registeredServices = true; + + // -- Completion -- + monaco.languages.registerCompletionItemProvider("typespec", { + triggerCharacters: initResult.completionTriggerCharacters, + async provideCompletionItems(model, position) { + const result = await proxy.doComplete(lspArgs(model, position) as any); + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + + const suggestions: monaco.languages.CompletionItem[] = []; + for (const item of result.items) { + let itemRange: monaco.IRange = range; + let insertText = item.insertText ?? item.label; + if (item.textEdit && "range" in item.textEdit) { + itemRange = LspToMonaco.range(item.textEdit.range); + insertText = item.textEdit.newText; + } + suggestions.push({ + label: item.label, + kind: item.kind as any, + documentation: item.documentation, + insertText, + range: itemRange, + commitCharacters: item.commitCharacters ?? initResult.completionCommitCharacters, + tags: item.tags, + }); + } + return { suggestions }; + }, + }); + + // -- Hover -- + monaco.languages.registerHoverProvider("typespec", { + async provideHover(model, position) { + const hover = await proxy.doHover(lspArgs(model, position) as any); + if (!hover) return null; + // eslint-disable-next-line @typescript-eslint/no-deprecated + if (Array.isArray(hover.contents) || typeof hover.contents === "string") { + throw new Error("MarkedString (deprecated) not supported."); + } + return { + contents: [{ value: (hover.contents as any).value }], + range: hover.range ? LspToMonaco.range(hover.range) : undefined, + }; + }, + }); + + // -- Definition -- + monaco.languages.registerDefinitionProvider("typespec", { + async provideDefinition(model, position) { + const results = await proxy.doDefinition(lspArgs(model, position) as any); + return results.map(monacoLocation); + }, + }); + + // -- References -- + monaco.languages.registerReferenceProvider("typespec", { + async provideReferences(model, position, context) { + const results = await proxy.doReferences({ + ...(lspArgs(model, position) as any), + context, + }); + return results.map(monacoLocation); + }, + }); + + // -- Rename -- + monaco.languages.registerRenameProvider("typespec", { + async resolveRenameLocation(model, position): Promise { + const result = await proxy.doPrepareRename(lspArgs(model, position) as any); + if (!result) throw new Error("This element can't be renamed."); + const text = model.getWordAtPosition(position)?.word; + if (!text) throw new Error("Failed to obtain word at position."); + return { text, range: LspToMonaco.range(result) }; + }, + async provideRenameEdits(model, position, newName) { + const result = await proxy.doRename({ ...(lspArgs(model, position) as any), newName }); + return monacoWorkspaceEdit(result); + }, + }); + + // -- Folding -- + monaco.languages.registerFoldingRangeProvider("typespec", { + async provideFoldingRanges(model) { + const ranges = await proxy.doFoldingRanges(lspDocumentArgs(model) as any); + return ranges.map(LspToMonaco.foldingRange); + }, + }); + + // -- Formatting -- + monaco.languages.registerDocumentFormattingEditProvider("typespec", { + async provideDocumentFormattingEdits(model, options) { + const edits = await proxy.doFormat({ + ...(lspDocumentArgs(model) as any), + options: { tabSize: options.tabSize, insertSpaces: options.insertSpaces }, + }); + return LspToMonaco.textEdits(edits); + }, + }); + + // -- Document Highlights -- + monaco.languages.registerDocumentHighlightProvider("typespec", { + async provideDocumentHighlights(model, position) { + const highlights = await proxy.doDocumentHighlight(lspArgs(model, position) as any); + return highlights.map((h: any) => ({ + range: LspToMonaco.range(h.range), + kind: monacoDocumentHighlightKind(h.kind), + })); + }, + }); + + // -- Signature Help -- + monaco.languages.registerSignatureHelpProvider("typespec", { + signatureHelpTriggerCharacters: ["(", ",", "<"], + signatureHelpRetriggerCharacters: [")"], + async provideSignatureHelp(model, position) { + const help = await proxy.doSignatureHelp(lspArgs(model, position) as any); + return { value: LspToMonaco.signatureHelp(help), dispose: () => {} }; + }, + }); + + // -- Semantic Tokens -- + monaco.languages.registerDocumentSemanticTokensProvider("typespec", { + getLegend() { + // Use a standard legend; the worker returns raw token data. + return { + tokenModifiers: [], + tokenTypes: [ + "comment", + "keyword", + "string", + "number", + "regexp", + "operator", + "type", + "variable", + "function", + "macro", + "parameter", + "property", + "label", + "plainKeyword", + "docCommentTag", + ], + }; + }, + async provideDocumentSemanticTokens(model) { + const result = await proxy.doSemanticTokens(lspDocumentArgs(model) as any); + return { + resultId: result.resultId, + data: new Uint32Array(result.data), + }; + }, + releaseDocumentSemanticTokens() {}, + }); + + // -- Code Actions (codefixes from compilation diagnostics) -- + monaco.languages.registerCodeActionProvider("typespec", { + async provideCodeActions(model, range) { + if (!_workerProxy) return { actions: [], dispose: () => {} }; + const actions: monaco.languages.CodeAction[] = []; + + for (const diag of _workerDiagnostics) { + if (!diag.codefixes?.length) continue; + if (!diag.location || diag.location.file !== "/test/main.tsp") continue; + const diagRange = serializedLocationToRange(diag.location); + if (!rangesOverlap(diagRange, range)) continue; + + for (const fixRef of diag.codefixes) { + const edits = await resolveWorkerCodeFix(_workerProxy, fixRef, model); + if (edits.length > 0) { + actions.push({ + title: fixRef.label, + kind: "quickfix", + edit: { edits }, + }); + } + } + } + return { actions, dispose: () => {} }; + }, + }); + + // -- Themes -- + monaco.editor.defineTheme("typespec", { + base: "vs", + inherit: true, + colors: {}, + rules: [ + { token: "macro", foreground: "#800000" }, + { token: "function", foreground: "#795E26" }, + ], + }); + monaco.editor.defineTheme("typespec-dark", { + base: "vs-dark", + inherit: true, + colors: {}, + rules: [ + { token: "macro", foreground: "#E06C75" }, + { token: "function", foreground: "#E06C75" }, + ], + }); +} diff --git a/packages/playground/src/worker/index.ts b/packages/playground/src/worker/index.ts new file mode 100644 index 00000000000..d80129dd462 --- /dev/null +++ b/packages/playground/src/worker/index.ts @@ -0,0 +1,12 @@ +export type { + CompileParams, + CompileResponse, + ResolvedCodeFixEdits, + SerializedCodeFixRef, + SerializedCompileResult, + SerializedDiagnostic, + SerializedSourceLocation, + TypeSpecWorkerApi, + WorkerInitParams, + WorkerInitResult, +} from "./protocol.js"; diff --git a/packages/playground/src/worker/playground-worker.ts b/packages/playground/src/worker/playground-worker.ts new file mode 100644 index 00000000000..1248c1b05b2 --- /dev/null +++ b/packages/playground/src/worker/playground-worker.ts @@ -0,0 +1,335 @@ +/** + * Web Worker entry point for the TypeSpec playground. + * + * Uses Monaco's built-in worker infrastructure (`initialize` from + * `monaco-editor/esm/vs/editor/editor.worker`). Monaco automatically: + * - syncs editor models to the worker (accessible via `ctx.getMirrorModels()`) + * - proxies all method calls on the returned object through postMessage + * - handles request/response matching and serialization + * + * The main thread creates this worker with `monaco.editor.createWebWorker(…)`. + */ + +import type { Diagnostic, ServerHost } from "@typespec/compiler"; +import { type IWorkerContext, initialize } from "monaco-editor/esm/vs/editor/editor.worker.js"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { createBrowserHost, resolveVirtualPath } from "../browser-host.js"; +import type { BrowserHost } from "../types.js"; +import type { + CompileParams, + CompileResponse, + ResolvedCodeFixEdits, + SerializedCodeFixRef, + SerializedCompileResult, + SerializedDiagnostic, + SerializedSourceLocation, + TypeSpecWorkerApi, + WorkerInitParams, + WorkerInitResult, +} from "./protocol.js"; + +// --------------------------------------------------------------------------- +// Worker service — the proxy target for Monaco's createWebWorker +// --------------------------------------------------------------------------- + +/** + * Implements {@link TypeSpecWorkerApi}. An instance of this class is returned + * from the Monaco `initialize()` factory and all its public methods become + * available to the main thread via the `MonacoWebWorker` proxy. + */ +class TypeSpecWorkerService implements TypeSpecWorkerApi { + #ctx: IWorkerContext; + #host: BrowserHost | undefined; + #server: import("@typespec/compiler").Server | undefined; + + /** Documents known to the language server, keyed by URI string. */ + #documents = new Map(); + /** Last-seen version per URI, used to detect changes in mirror models. */ + #documentVersions = new Map(); + + /** Code-fixes from the most recent compilation (kept in worker memory). */ + #lastCodefixes: Array<{ fix: any; compiler: typeof import("@typespec/compiler") }> = []; + + constructor(ctx: IWorkerContext) { + this.#ctx = ctx; + } + + // -- Lifecycle ----------------------------------------------------------- + + async initialize(params: WorkerInitParams): Promise { + this.#host = await createBrowserHost(params.libraries, params.importConfig); + + const serverHost: ServerHost = { + compilerHost: this.#host, + getOpenDocumentByURL: (url: string) => this.#documents.get(url), + sendDiagnostics() {}, + log(log) { + // eslint-disable-next-line no-console + if (log.level === "error") console.error(log); + // eslint-disable-next-line no-console + else if (log.level === "warning") console.warn(log); + }, + applyEdit() { + return Promise.resolve({ applied: false }); + }, + }; + + const { createServer } = this.#host.compiler; + this.#server = createServer(serverHost); + + const lsConfig = await this.#server.initialize({ + capabilities: {}, + processId: 1, + workspaceFolders: [], + rootUri: "inmemory://", + }); + this.#server.initialized({}); + + return { + completionTriggerCharacters: + lsConfig.capabilities.completionProvider?.triggerCharacters ?? [], + completionCommitCharacters: + lsConfig.capabilities.completionProvider?.allCommitCharacters ?? [], + }; + } + + // -- Compilation --------------------------------------------------------- + + async compile(params: CompileParams): Promise { + if (!this.#host) throw new Error("Worker not initialized"); + this.#lastCodefixes = []; + + const { content, emitter, options } = params; + await this.#host.writeFile("main.tsp", content); + await this.#emptyOutputDir(); + + try { + const typespecCompiler = this.#host.compiler; + const outputDir = resolveVirtualPath("tsp-output"); + const program = await typespecCompiler.compile(this.#host, resolveVirtualPath("main.tsp"), { + ...options, + options: { + ...options.options, + [emitter]: { + ...options.options?.[emitter], + "emitter-output-dir": outputDir, + }, + }, + outputDir, + emit: emitter ? [emitter] : [], + }); + + const diagnostics = program.diagnostics.map((d) => + this.#serializeDiagnostic(typespecCompiler, d), + ); + const outputFiles = await this.#readOutputFiles(); + return { diagnostics, outputFiles } satisfies SerializedCompileResult; + } catch (error: any) { + // eslint-disable-next-line no-console + console.error("Internal compiler error", error); + return { internalCompilerError: error?.message ?? String(error) }; + } + } + + async resolveCodeFix(codefixIndex: number): Promise { + const entry = this.#lastCodefixes[codefixIndex]; + if (!entry) throw new Error(`No codefix at index ${codefixIndex}`); + + const edits = await entry.compiler.resolveCodeFix(entry.fix); + return { + edits: edits.map((edit) => { + const start = edit.file.getLineAndCharacterOfPosition(edit.pos); + if (edit.kind === "insert-text") { + return { + range: { + start: { line: start.line, character: start.character }, + end: { line: start.line, character: start.character }, + }, + newText: edit.text, + file: edit.file.path, + }; + } else { + const end = edit.file.getLineAndCharacterOfPosition(edit.end); + return { + range: { + start: { line: start.line, character: start.character }, + end: { line: end.line, character: end.character }, + }, + newText: edit.text, + file: edit.file.path, + }; + } + }), + }; + } + + // -- LSP methods --------------------------------------------------------- + + async doComplete(params: any) { + this.#syncDocuments(); + return this.#server!.complete(params); + } + + async doHover(params: any) { + this.#syncDocuments(); + return this.#server!.getHover(params); + } + + async doDefinition(params: any) { + this.#syncDocuments(); + return this.#server!.gotoDefinition(params); + } + + async doReferences(params: any) { + this.#syncDocuments(); + return this.#server!.findReferences(params); + } + + async doPrepareRename(params: any) { + this.#syncDocuments(); + return this.#server!.prepareRename(params); + } + + async doRename(params: any) { + this.#syncDocuments(); + return this.#server!.rename(params); + } + + async doFormat(params: any) { + this.#syncDocuments(); + return this.#server!.formatDocument(params); + } + + async doFoldingRanges(params: any) { + this.#syncDocuments(); + return this.#server!.getFoldingRanges(params); + } + + async doDocumentHighlight(params: any) { + this.#syncDocuments(); + return this.#server!.findDocumentHighlight(params); + } + + async doSignatureHelp(params: any) { + this.#syncDocuments(); + return this.#server!.getSignatureHelp(params); + } + + async doSemanticTokens(params: any) { + this.#syncDocuments(); + return this.#server!.buildSemanticTokens(params); + } + + // -- Private helpers ----------------------------------------------------- + + /** + * Sync Monaco mirror models → language server. + * + * Monaco automatically mirrors editor models to the worker. Before each LSP + * call we check for new or changed models and notify the language server. + */ + #syncDocuments(): void { + for (const model of this.#ctx.getMirrorModels()) { + const uri = model.uri.toString(); + const version = model.version; + const lastVersion = this.#documentVersions.get(uri); + + if (lastVersion !== version) { + const doc = TextDocument.create(uri, "typespec", version, model.getValue()); + this.#documents.set(uri, doc); + this.#documentVersions.set(uri, version); + + if (this.#server) { + if (lastVersion === undefined) { + this.#server.documentOpened({ document: doc }); + } else { + this.#server.checkChange({ document: doc }); + } + } + } + } + } + + #serializeDiagnostic( + compiler: typeof import("@typespec/compiler"), + diag: Diagnostic, + ): SerializedDiagnostic { + let location: SerializedSourceLocation | undefined; + const loc = compiler.getSourceLocation(diag.target, { locateId: true }); + if (loc) { + const start = loc.file.getLineAndCharacterOfPosition(loc.pos); + const end = loc.file.getLineAndCharacterOfPosition(loc.end); + location = { + file: loc.file.path, + startLine: start.line, + startCharacter: start.character, + endLine: end.line, + endCharacter: end.character, + }; + } + + let codefixes: SerializedCodeFixRef[] | undefined; + if (diag.codefixes?.length) { + codefixes = diag.codefixes.map((fix) => { + const cfIndex = this.#lastCodefixes.length; + this.#lastCodefixes.push({ fix, compiler }); + return { index: cfIndex, label: fix.label }; + }); + } + + return { + severity: diag.severity, + code: typeof diag.code === "string" ? diag.code : undefined, + message: diag.message, + location, + codefixes, + }; + } + + async #emptyOutputDir(): Promise { + if (!this.#host) return; + try { + const dirs = await this.#host.readDir("./tsp-output"); + for (const file of dirs) { + await this.#host.rm("./tsp-output/" + file, { recursive: true }); + } + } catch { + // Directory may not exist yet + } + } + + async #readOutputFiles(): Promise> { + if (!this.#host) return {}; + const outputDir = resolveVirtualPath("tsp-output"); + const files: Record = {}; + + const addFiles = async (dir: string) => { + const items = await this.#host!.readDir(outputDir + dir); + for (const item of items) { + const itemPath = `${dir}/${item}`; + if ((await this.#host!.stat(outputDir + itemPath)).isDirectory()) { + await addFiles(itemPath); + } else { + const relativePath = dir === "" ? item : `${dir}/${item}`; + const sf = await this.#host!.readFile(outputDir + itemPath); + files[relativePath] = sf.text; + } + } + }; + + try { + await addFiles(""); + } catch { + // Output dir may not exist + } + return files; + } +} + +// --------------------------------------------------------------------------- +// Monaco worker bootstrap +// --------------------------------------------------------------------------- + +initialize((ctx: IWorkerContext) => { + return new TypeSpecWorkerService(ctx); +}); diff --git a/packages/playground/src/worker/protocol.ts b/packages/playground/src/worker/protocol.ts new file mode 100644 index 00000000000..6647f5f4e68 --- /dev/null +++ b/packages/playground/src/worker/protocol.ts @@ -0,0 +1,127 @@ +import type { CompilerOptions } from "@typespec/compiler"; +import type { + CompletionList, + CompletionParams, + DefinitionParams, + DocumentFormattingParams, + DocumentHighlight, + DocumentHighlightParams, + FoldingRange, + FoldingRangeParams, + Hover, + HoverParams, + Location, + PrepareRenameParams, + Range, + ReferenceParams, + RenameParams, + SemanticTokens, + SemanticTokensParams, + SignatureHelp, + SignatureHelpParams, + TextEdit, + WorkspaceEdit, +} from "vscode-languageserver"; +import type { LibraryImportOptions } from "../core.js"; + +// --------------------------------------------------------------------------- +// Serialized types – safe to cross the worker boundary via postMessage +// --------------------------------------------------------------------------- + +/** Source location serialized as plain numbers (no AST node references). */ +export interface SerializedSourceLocation { + file: string; + /** 0-based line */ + startLine: number; + /** 0-based character offset */ + startCharacter: number; + /** 0-based line */ + endLine: number; + /** 0-based character offset */ + endCharacter: number; +} + +/** A reference to a code-fix that lives in the worker. The main thread can + * request resolution by sending the `index` back. */ +export interface SerializedCodeFixRef { + index: number; + label: string; +} + +/** Diagnostic stripped of all non-serializable compiler internals. */ +export interface SerializedDiagnostic { + severity: "error" | "warning"; + code?: string; + message: string; + location?: SerializedSourceLocation; + codefixes?: SerializedCodeFixRef[]; +} + +/** Result of a successful compilation. */ +export interface SerializedCompileResult { + readonly diagnostics: SerializedDiagnostic[]; + /** Map from relative output path to file content. */ + readonly outputFiles: Record; +} + +/** Union returned from a compile request. */ +export type CompileResponse = SerializedCompileResult | { internalCompilerError: string }; + +// --------------------------------------------------------------------------- +// Worker API — interface of the object proxied by Monaco's createWebWorker +// --------------------------------------------------------------------------- + +/** Initialization parameters passed to the worker. */ +export interface WorkerInitParams { + libraries: readonly string[]; + importConfig?: LibraryImportOptions; +} + +/** Capabilities returned after initialization. */ +export interface WorkerInitResult { + completionTriggerCharacters?: string[]; + completionCommitCharacters?: string[]; +} + +/** Compile request parameters. */ +export interface CompileParams { + content: string; + emitter: string; + options: CompilerOptions; +} + +/** Resolved code-fix edits. */ +export interface ResolvedCodeFixEdits { + edits: Array<{ range: Range; newText: string; file: string }>; +} + +/** + * The public API surface exposed by the TypeSpec worker. + * + * Monaco's `createWebWorker` transparently proxies all method calls on this + * interface through postMessage, handling serialization and request/response + * matching automatically. + * + * All parameters and return values **must** be structured-cloneable. + */ +export interface TypeSpecWorkerApi { + // -- Lifecycle ---------------------------------------------------------- + initialize(params: WorkerInitParams): Promise; + + // -- Compilation -------------------------------------------------------- + compile(params: CompileParams): Promise; + resolveCodeFix(codefixIndex: number): Promise; + + // -- LSP methods -------------------------------------------------------- + doComplete(params: CompletionParams): Promise; + doHover(params: HoverParams): Promise; + doDefinition(params: DefinitionParams): Promise; + doReferences(params: ReferenceParams): Promise; + doPrepareRename(params: PrepareRenameParams): Promise; + doRename(params: RenameParams): Promise; + doFormat(params: DocumentFormattingParams): Promise; + doFoldingRanges(params: FoldingRangeParams): Promise; + doDocumentHighlight(params: DocumentHighlightParams): Promise; + doSignatureHelp(params: SignatureHelpParams): Promise; + doSemanticTokens(params: SemanticTokensParams): Promise; +} diff --git a/packages/playground/vite.config.ts b/packages/playground/vite.config.ts index 0d25404b079..3d31c627314 100644 --- a/packages/playground/vite.config.ts +++ b/packages/playground/vite.config.ts @@ -33,6 +33,8 @@ export default defineConfig({ "state-storage": "src/state-storage.ts", "react/index": "src/react/index.ts", "react/viewers/index": "src/react/viewers/index.tsx", + "worker/index": "src/worker/index.ts", + "worker/playground-worker": "src/worker/playground-worker.ts", "tooling/index": "src/tooling/index.ts", "vite/index": "src/vite/index.ts", },