From 0371102859c812524ea08f9527de3091185834c8 Mon Sep 17 00:00:00 2001 From: X Date: Mon, 5 Feb 2024 02:32:25 +0800 Subject: [PATCH] esm-monaco: Refactor typescript work for supporting types from CDN --- packages/esm-monaco/src/editor.ts | 2 + packages/esm-monaco/src/import-map.ts | 111 +++ packages/esm-monaco/src/lsp/json/schemas.ts | 43 +- .../src/lsp/typescript/language-features.ts | 142 ++-- .../esm-monaco/src/lsp/typescript/setup.ts | 223 ++++-- .../esm-monaco/src/lsp/typescript/worker.ts | 714 +++++++++++------- packages/esm-monaco/src/shiki.ts | 25 +- packages/esm-monaco/src/vfs.ts | 218 ++++-- packages/esm-monaco/test/index.html | 6 +- packages/esm-monaco/test/serve.mjs | 55 +- packages/esm-monaco/types/vfs.d.ts | 10 +- 11 files changed, 1073 insertions(+), 476 deletions(-) create mode 100644 packages/esm-monaco/src/import-map.ts diff --git a/packages/esm-monaco/src/editor.ts b/packages/esm-monaco/src/editor.ts index eb7b4a34..16aed17e 100644 --- a/packages/esm-monaco/src/editor.ts +++ b/packages/esm-monaco/src/editor.ts @@ -49,6 +49,7 @@ export async function init(options: InitOptions = {}) { } if (options.vfs) { + options.vfs.bindMonaco(monaco); Reflect.set(monaco.editor, "vfs", options.vfs); try { const list = await options.vfs.list(); @@ -120,6 +121,7 @@ export async function init(options: InitOptions = {}) { "trimAutoWhitespace", "wordWrap", "wordWrapColumn", + "wrappingIndent", ]; for (const attrName of this.getAttributeNames()) { const key = optionKeys.find((k) => k.toLowerCase() === attrName); diff --git a/packages/esm-monaco/src/import-map.ts b/packages/esm-monaco/src/import-map.ts new file mode 100644 index 00000000..858dc3c1 --- /dev/null +++ b/packages/esm-monaco/src/import-map.ts @@ -0,0 +1,111 @@ +export interface ImportMap { + $src?: string; + $support?: boolean; + $baseURL: string; + imports: Record; + scopes: Record; +} + +export function blankImportMap(): ImportMap { + return { + $baseURL: "file:///", + imports: {}, + scopes: {}, + }; +} + +export function isBlank(importMap: ImportMap) { + return ( + Object.keys(importMap.imports).length === 0 && + Object.keys(importMap.scopes).length === 0 + ); +} + +function matchImports(specifier: string, imports: ImportMap["imports"]) { + if (specifier in imports) { + return imports[specifier]; + } + for (const [k, v] of Object.entries(imports)) { + if (k.endsWith("/") && specifier.startsWith(k)) { + return v + specifier.slice(k.length); + } + } + return null; +} + +export function resolve( + importMap: ImportMap, + specifier: string, + scriptUrlRaw: string, +) { + const { $baseURL, imports, scopes } = importMap; + const scriptUrl = new URL(scriptUrlRaw); + const sameOriginScopes = Object.entries(scopes) + .map(([scope, imports]) => [new URL(scope, $baseURL), imports] as const) + .filter(([scopeUrl]) => scopeUrl.origin === scriptUrl.origin) + .sort(([a], [b]) => + b.pathname.split("/").length - a.pathname.split("/").length + ); + if (sameOriginScopes.length > 0) { + for (const [scopeUrl, scopeImports] of sameOriginScopes) { + if (scriptUrl.pathname.startsWith(scopeUrl.pathname)) { + const match = matchImports(specifier, scopeImports); + if (match) { + return new URL(match, scopeUrl); + } + } + } + } + const match = matchImports(specifier, imports); + if (match) { + return new URL(match, scriptUrl); + } + return new URL(specifier, scriptUrl); +} + +export function parseImportMapFromJson( + json: string, + baseURL?: string, +): ImportMap { + const importMap: ImportMap = { + $support: globalThis.HTMLScriptElement?.supports?.("importmap"), + $baseURL: new URL(baseURL ?? ".", "file:///").href, + imports: {}, + scopes: {}, + }; + const v = JSON.parse(json); + if (isObject(v)) { + const { imports, scopes } = v; + if (isObject(imports)) { + validateImports(imports); + importMap.imports = imports as ImportMap["imports"]; + } + if (isObject(scopes)) { + validateScopes(scopes); + importMap.scopes = scopes as ImportMap["scopes"]; + } + } + return importMap; +} + +function validateImports(imports: Record) { + for (const [k, v] of Object.entries(imports)) { + if (!v || typeof v !== "string") { + delete imports[k]; + } + } +} + +function validateScopes(imports: Record) { + for (const [k, v] of Object.entries(imports)) { + if (isObject(v)) { + validateImports(v); + } else { + delete imports[k]; + } + } +} + +function isObject(v: unknown): v is Record { + return v && typeof v === "object" && !Array.isArray(v); +} diff --git a/packages/esm-monaco/src/lsp/json/schemas.ts b/packages/esm-monaco/src/lsp/json/schemas.ts index ec0891a4..186f954e 100644 --- a/packages/esm-monaco/src/lsp/json/schemas.ts +++ b/packages/esm-monaco/src/lsp/json/schemas.ts @@ -2,14 +2,53 @@ import type { SchemaConfiguration } from "vscode-json-languageservice"; export const schemas: SchemaConfiguration[] = [ { - uri: - "https://raw.githubusercontent.com/denoland/vscode_deno/main/schemas/import_map.schema.json", + uri: "//", fileMatch: [ "import_map.json", "import-map.json", "importmap.json", "importMap.json", ], + schema: { + $schema: "http://json-schema.org/draft-07/schema#", + $id: + "https://github.com/denoland/vscode_deno/blob/main/schemas/import_map.schema.json", + title: "An Import Map", + description: + "An import map which is used to remap imports when modules are loaded.", + type: "object", + properties: { + imports: { + description: "A map of specifiers to their remapped specifiers.", + type: "object", + properties: { + "@jsxImportSource": { + description: "The key is the specifier for JSX runtime.", + type: "string", + }, + }, + additionalProperties: { + description: + "The key is the specifier or partial specifier to match, with a value that represents the target specifier.", + type: "string", + }, + }, + scopes: { + description: + "Define a scope which remaps a specifier in only a specified scope", + type: "object", + additionalProperties: { + description: "A definition of a scoped remapping.", + type: "object", + additionalProperties: { + description: + "The key is the specifier or partial specifier to match within the referring scope, with a value that represents the target specifier.", + type: "string", + }, + }, + }, + }, + }, }, { uri: "https://json.schemastore.org/tsconfig", diff --git a/packages/esm-monaco/src/lsp/typescript/language-features.ts b/packages/esm-monaco/src/lsp/typescript/language-features.ts index ef273a7d..e4bfbec3 100644 --- a/packages/esm-monaco/src/lsp/typescript/language-features.ts +++ b/packages/esm-monaco/src/lsp/typescript/language-features.ts @@ -68,8 +68,13 @@ export class LibFiles { } public setExtraLibs(extraLibs: Record) { - const entries = Object.entries(extraLibs); - for (const [filePath, content] of entries) { + const toRemove = Object.keys(this._extraLibs).filter( + (key) => !extraLibs[key], + ); + for (const key of toRemove) { + this.removeExtraLib(key); + } + for (const [filePath, content] of Object.entries(extraLibs)) { this.addExtraLib(content, filePath); } } @@ -240,12 +245,12 @@ interface IInternalEditorModel extends editor.IModel { } export class DiagnosticsAdapter extends Adapter { - private _disposables: IDisposable[] = []; - private _listener: { [uri: string]: IDisposable } = Object.create(null); + // private _disposables: IDisposable[] = []; + private _listeners: { [uri: string]: IDisposable } = Object.create(null); constructor( - private readonly _diagnosticsOptions: DiagnosticsOptions, - private readonly _onRefreshDiagnostic: IEvent, + private _diagnosticsOptions: DiagnosticsOptions, + onRefreshDiagnostic: IEvent, private _selector: string, worker: (...uris: Uri[]) => Promise, ) { @@ -257,8 +262,8 @@ export class DiagnosticsAdapter extends Adapter { return; } + const { onlyVisible } = this._diagnosticsOptions; const maybeValidate = () => { - const { onlyVisible } = this._diagnosticsOptions; if (onlyVisible) { if (model.isAttachedToEditor()) { this._doValidate(model); @@ -268,85 +273,75 @@ export class DiagnosticsAdapter extends Adapter { } }; - let handle: number; - const changeSubscription = model.onDidChangeContent(() => { - clearTimeout(handle); - handle = window.setTimeout(maybeValidate, 500); - }); + let timer: number | null = null; + const disposes = [ + model.onDidChangeContent(() => { + if (timer !== null) { + return; + } + timer = setTimeout(() => { + timer = null; + maybeValidate(); + }, 500); + }), + ]; - const visibleSubscription = model.onDidChangeAttached(() => { - const { onlyVisible } = this._diagnosticsOptions; - if (onlyVisible) { + if (onlyVisible) { + disposes.push(model.onDidChangeAttached(() => { if (model.isAttachedToEditor()) { // this model is now attached to an editor // => compute diagnostics - maybeValidate(); + this._doValidate(model); } else { // this model is no longer attached to an editor // => clear existing diagnostics editor.setModelMarkers(model, this._selector, []); } - } - }); + })); + } - this._listener[model.uri.toString()] = { + this._listeners[model.uri.toString()] = { dispose() { - changeSubscription.dispose(); - visibleSubscription.dispose(); - clearTimeout(handle); + timer = null; + disposes.forEach((d) => d.dispose()); }, }; maybeValidate(); }; - const onModelRemoved = (model: editor.IModel): void => { - editor.setModelMarkers(model, this._selector, []); const key = model.uri.toString(); - if (this._listener[key]) { - this._listener[key].dispose(); - delete this._listener[key]; + if (this._listeners[key]) { + this._listeners[key].dispose(); + delete this._listeners[key]; } + editor.setModelMarkers(model, this._selector, []); }; - this._disposables.push( - editor.onDidCreateModel((model) => - onModelAdd( model) - ), - ); - this._disposables.push(editor.onWillDisposeModel(onModelRemoved)); - this._disposables.push( - editor.onDidChangeModelLanguage((event) => { - onModelRemoved(event.model); - onModelAdd( event.model); - }), + editor.onDidCreateModel((model) => + onModelAdd( model) ); - - this._disposables.push({ - dispose() { - for (const model of editor.getModels()) { - onModelRemoved(model); - } - }, + editor.onWillDisposeModel(onModelRemoved); + editor.onDidChangeModelLanguage((event) => { + onModelRemoved(event.model); + onModelAdd( event.model); }); - - const refreshDiagostics = () => { - // redo diagnostics when options change + onRefreshDiagnostic(() => { for (const model of editor.getModels()) { onModelRemoved(model); onModelAdd( model); } - }; - this._disposables.push(_onRefreshDiagnostic(refreshDiagostics)); + }); + editor.getModels().forEach((model) => onModelAdd( model) ); } - public dispose(): void { - this._disposables.forEach((d) => d && d.dispose()); - this._disposables = []; - } + // public dispose(): void { + // this._disposables.forEach((d) => d && d.dispose()); + // this._disposables = []; + // } private async _doValidate(model: editor.ITextModel): Promise { const editor = M.editor; @@ -397,11 +392,11 @@ export class DiagnosticsAdapter extends Adapter { editor.setModelMarkers( model, this._selector, - diagnostics.map((d) => this._convertDiagnostics(model, d)), + diagnostics.map((d) => DiagnosticsAdapter._convertDiagnostics(model, d)), ); } - private _convertDiagnostics( + private static _convertDiagnostics( model: editor.ITextModel, diag: Diagnostic, ): editor.IMarkerData { @@ -423,7 +418,9 @@ export class DiagnosticsAdapter extends Adapter { } return { - severity: this._tsDiagnosticCategoryToMarkerSeverity(diag.category), + severity: DiagnosticsAdapter._tsDiagnosticCategoryToMarkerSeverity( + diag.category, + ), startLineNumber, startColumn, endLineNumber, @@ -431,14 +428,14 @@ export class DiagnosticsAdapter extends Adapter { message: flattenDiagnosticMessageText(diag.messageText, "\n"), code: diag.code.toString(), tags, - relatedInformation: this._convertRelatedInformation( + relatedInformation: DiagnosticsAdapter._convertRelatedInformation( model, diag.relatedInformation, ), }; } - private _convertRelatedInformation( + private static _convertRelatedInformation( model: editor.ITextModel, relatedInformation?: DiagnosticRelatedInformation[], ): editor.IRelatedInformation[] { @@ -477,7 +474,7 @@ export class DiagnosticsAdapter extends Adapter { return result; } - private _tsDiagnosticCategoryToMarkerSeverity( + private static _tsDiagnosticCategoryToMarkerSeverity( category: ts.DiagnosticCategory, ): MarkerSeverity { const MarkerSeverity = M.MarkerSeverity; @@ -502,6 +499,7 @@ interface MyCompletionItem extends languages.CompletionItem { uri: Uri; position: Position; offset: number; + data?: any; } export class SuggestAdapter extends Adapter @@ -568,6 +566,7 @@ export class SuggestAdapter extends Adapter insertText: entry.name, sortText: entry.sortText, kind: SuggestAdapter.convertKind(entry.kind), + data: entry.data, tags, }; }); @@ -591,16 +590,34 @@ export class SuggestAdapter extends Adapter resource.toString(), offset, myItem.label, + myItem.data, ); if (!details) { return myItem; } + let additionalTextEdits: languages.TextEdit[] = []; + if (details.codeActions) { + const model = M.editor.getModel(resource); + if (model) { + details.codeActions.forEach((action) => + action.changes.forEach((change) => + change.textChanges.forEach(({ span, newText }) => { + additionalTextEdits.push({ + range: this._textSpanToRange(model, span), + text: newText, + }); + }) + ) + ); + } + } return { uri: resource, position: position, label: details.name, kind: SuggestAdapter.convertKind(details.kind), detail: displayPartsToString(details.displayParts), + additionalTextEdits, documentation: { value: SuggestAdapter.createDocumentationString(details), }, @@ -636,6 +653,8 @@ export class SuggestAdapter extends Adapter return languages.CompletionItemKind.Interface; case Kind.warning: return languages.CompletionItemKind.File; + case Kind.externalSymbol: + return languages.CompletionItemKind.Event; } return languages.CompletionItemKind.Property; @@ -661,7 +680,7 @@ function tagToString(tag: ts.JSDocTagInfo): string { tagLabel += `\`${paramName.text}\``; if (rest.length > 0) tagLabel += ` — ${rest.map((r) => r.text).join(" ")}`; } else if (Array.isArray(tag.text)) { - tagLabel += ` — ${tag.text.map((r) => r.text).join(" ")}`; + tagLabel += ` — ${tag.text.map((r) => r.text).join("")}`; } else if (tag.text) { tagLabel += ` — ${tag.text}`; } @@ -1038,6 +1057,7 @@ export class Kind { public static const: string = "const"; public static let: string = "let"; public static warning: string = "warning"; + public static externalSymbol = "external symbol"; } // --- formatting ---- diff --git a/packages/esm-monaco/src/lsp/typescript/setup.ts b/packages/esm-monaco/src/lsp/typescript/setup.ts index 879cd242..03eea0a9 100644 --- a/packages/esm-monaco/src/lsp/typescript/setup.ts +++ b/packages/esm-monaco/src/lsp/typescript/setup.ts @@ -1,14 +1,14 @@ import type * as monacoNS from "monaco-editor-core"; import type ts from "typescript"; +import { blankImportMap, parseImportMapFromJson } from "../../import-map"; import type { VFS } from "../../vfs"; -import type { CreateData, Host, ImportMap, TypeScriptWorker } from "./worker"; +import type { CreateData, Host, TypeScriptWorker } from "./worker"; import * as lf from "./language-features"; +type TSWorker = monacoNS.editor.MonacoWebWorker; + // javascript and typescript share the same worker -let worker: - | monacoNS.editor.MonacoWebWorker - | Promise> - | null = null; +let worker: TSWorker | Promise | null = null; let refreshDiagnosticEventEmitter: EventTrigger | null = null; class EventTrigger { @@ -32,8 +32,101 @@ class EventTrigger { } } +/** Convert string to URL. */ +function toUrl(name: string | URL) { + return typeof name === "string" ? new URL(name, "file:///") : name; +} + + +/** Load compiler options from tsconfig.json in VFS if exists. */ +async function loadCompilerOptions(vfs: VFS) { + const compilerOptions: ts.CompilerOptions = {}; + try { + const tconfigjson = await vfs.readTextFile("tsconfig.json"); + const tconfig = JSON.parse(tconfigjson); + const types = tconfig.compilerOptions.types; + delete tconfig.compilerOptions.types; + Array.isArray(types) && await Promise.all(types.map(async (type) => { + if (/^https?:\/\//.test(type)) { + const res = await vfs.fetch(type); + const dtsUrl = res.headers.get("x-typescript-types"); + if (dtsUrl) { + res.body.cancel?.(); + const res2 = await vfs.fetch(dtsUrl); + if (res2.ok) { + return [dtsUrl, await res2.text()]; + } else { + console.error( + `Failed to fetch "${dtsUrl}": ` + await res2.text(), + ); + } + } else if (res.ok) { + return [type, await res.text()]; + } else { + console.error( + `Failed to fetch "${dtsUrl}": ` + await res.text(), + ); + } + } else if (typeof type === "string") { + const dtsUrl = toUrl(type.replace(/\.d\.ts$/, "") + ".d.ts"); + try { + return [dtsUrl.href, await vfs.readTextFile(dtsUrl)]; + } catch (error) { + console.error( + `Failed to read "${dtsUrl.href}": ` + error.message, + ); + } + } + return null; + })).then((entries) => { + compilerOptions.$types = entries.map(([url]) => url).filter((url) => + url.startsWith("file://") + ); + lf.libFiles.setExtraLibs(Object.fromEntries(entries.filter(Boolean))); + }); + compilerOptions.$src = toUrl("tsconfig.json").href; + Object.assign(compilerOptions, tconfig.compilerOptions); + } catch (error) { + if (error instanceof vfs.ErrorNotFound) { + // ignore + } else { + console.error(error); + } + } + return compilerOptions; +} + +/** Load import maps from the root index.html or external json file. */ +async function loadImportMap(vfs: VFS) { + try { + const indexHtml = await vfs.readTextFile("index.html"); + const tplEl = document.createElement("template"); + tplEl.innerHTML = indexHtml; + const scriptEl: HTMLScriptElement = tplEl.content.querySelector( + 'script[type="importmap"]', + ); + if (scriptEl) { + const im = parseImportMapFromJson( + scriptEl.src + ? await vfs.readTextFile(scriptEl.src) + : scriptEl.textContent, + ); + im.$src = toUrl(scriptEl.src ? scriptEl.src : "index.html").href; + return im; + } + } catch (error) { + if (error instanceof vfs.ErrorNotFound) { + // ignore + } else { + console.error(error); + } + } + return blankImportMap(); +} + +/** Create the typescript worker. */ async function createWorker(monaco: typeof monacoNS) { - const compilerOptions: ts.CompilerOptions = { + const defaultCompilerOptions: ts.CompilerOptions = { allowImportingTsExtensions: true, allowJs: true, module: 99, // ModuleKind.ESNext, @@ -41,51 +134,33 @@ async function createWorker(monaco: typeof monacoNS) { target: 99, // ScriptTarget.ESNext, noEmit: true, }; - const importMap: ImportMap = {}; const vfs = Reflect.get(monaco.editor, "vfs") as VFS | undefined; - const libsPromise = import("./libs.js").then((m) => m.default); + const promises = [import("./libs.js").then((m) => m.default)]; + + let compilerOptions: ts.CompilerOptions = { ...defaultCompilerOptions }; + let importMap = blankImportMap(); if (vfs) { - try { - const tconfigjson = await vfs.readTextFile("tsconfig.json"); - const tconfig = JSON.parse(tconfigjson); - const types = tconfig.compilerOptions.types; - delete tconfig.compilerOptions.types; - if (Array.isArray(types)) { - for (const type of types) { - // TODO: support type from http - try { - const dts = await vfs.readTextFile(type); - lf.libFiles.addExtraLib(dts, type); - } catch (error) { - if (error instanceof vfs.ErrorNotFound) { - // ignore - } else { - console.error(error); - } - } - } - } - Object.assign(compilerOptions, tconfig.compilerOptions); - } catch (error) { - if (error instanceof vfs.ErrorNotFound) { - // ignore - } else { - console.error(error); - } - } + promises.push( + loadCompilerOptions(vfs).then((options) => { + compilerOptions = { ...defaultCompilerOptions, ...options }; + }), + loadImportMap(vfs).then((im) => { + importMap = im; + }), + ); } - // todo: watch tsconfig.json - const libs = await libsPromise; + const [libs] = await Promise.all(promises); + lf.libFiles.setLibs(libs); + const createData: CreateData = { compilerOptions, libs, extraLibs: lf.libFiles.extraLibs, importMap, }; - lf.libFiles.setLibs(libs); - return monaco.editor.createWebWorker({ + const worker = monaco.editor.createWebWorker({ moduleId: "lsp/typescript/worker", label: "typescript", keepIdleModels: true, @@ -110,6 +185,71 @@ async function createWorker(monaco: typeof monacoNS) { }, } satisfies Host, }); + + const updateCompilerOptions: TypeScriptWorker["updateCompilerOptions"] = + async (options) => { + const proxy = await worker.getProxy(); + await proxy.updateCompilerOptions(options); + refreshDiagnosticEventEmitter?.fire(); + }; + + if (vfs) { + const watchTypes = () => + (compilerOptions.$types as string[] ?? []).map((url) => + vfs.watch(url, async (e) => { + if (e.kind === "remove") { + lf.libFiles.removeExtraLib(url); + } else { + const content = await vfs.readTextFile(url); + lf.libFiles.addExtraLib(content, url); + } + updateCompilerOptions({ extraLibs: lf.libFiles.extraLibs }); + }) + ); + const watchImportMap = () => { + const { $src } = importMap; + if ($src && $src !== "file:///index.html") { + return vfs.watch($src, async (e) => { + if (e.kind === "remove") { + importMap = blankImportMap(); + } else { + const content = await vfs.readTextFile($src); + const im = parseImportMapFromJson(content); + importMap = im; + } + updateCompilerOptions({ importMap }); + }); + } + }; + let disposes = watchTypes(); + let dispose = watchImportMap(); + vfs.watch("tsconfig.json", async (e) => { + disposes.forEach((dispose) => dispose()); + loadCompilerOptions(vfs).then((options) => { + const newOptions = { ...defaultCompilerOptions, ...options }; + if (JSON.stringify(newOptions) !== JSON.stringify(compilerOptions)) { + compilerOptions = newOptions; + updateCompilerOptions({ + compilerOptions, + extraLibs: lf.libFiles.extraLibs, + }); + } + disposes = watchTypes(); + }); + }); + vfs.watch("index.html", async (e) => { + dispose?.(); + loadImportMap(vfs).then((im) => { + if (JSON.stringify(im) !== JSON.stringify(importMap)) { + importMap = im; + updateCompilerOptions({ importMap }); + } + dispose = watchImportMap(); + }); + }); + } + + return worker; } export async function setup(languageId: string, monaco: typeof monacoNS) { @@ -129,8 +269,7 @@ export async function setup(languageId: string, monaco: typeof monacoNS) { const workerWithResources = ( ...uris: monacoNS.Uri[] ): Promise => { - return (worker as monacoNS.editor.MonacoWebWorker) - .withSyncedResources(uris); + return (worker as TSWorker).withSyncedResources(uris); }; // register language features diff --git a/packages/esm-monaco/src/lsp/typescript/worker.ts b/packages/esm-monaco/src/lsp/typescript/worker.ts index 0f3a7d86..67ddc8bc 100644 --- a/packages/esm-monaco/src/lsp/typescript/worker.ts +++ b/packages/esm-monaco/src/lsp/typescript/worker.ts @@ -5,7 +5,9 @@ import ts from "typescript"; import type * as monacoNS from "monaco-editor-core"; -import * as worker from "monaco-editor-core/esm/vs/editor/editor.worker"; +import { initialize } from "monaco-editor-core/esm/vs/editor/editor.worker"; +import { type ImportMap, isBlank, resolve } from "../../import-map"; +import { vfetch } from "../../vfs"; export interface Host { tryOpenModel(uri: string): Promise; @@ -17,11 +19,6 @@ export interface ExtraLib { version: number; } -export interface ImportMap { - imports?: Record; - scopes?: Record>; -} - export interface CreateData { compilerOptions: ts.CompilerOptions; libs: Record; @@ -48,93 +45,44 @@ export class TypeScriptWorker implements ts.LanguageServiceHost { private _ctx: monacoNS.worker.IWorkerContext; private _compilerOptions: ts.CompilerOptions; private _importMap: ImportMap; + private _blankImportMap: boolean; + private _importMapVersion: number; private _libs: Record; - private _extraLibs: Record = Object.create(null); + private _extraLibs: Record; private _inlayHintsOptions?: ts.UserPreferences; private _languageService = ts.createLanguageService(this); - private _badModuleNames = new Set(); - - constructor(ctx: worker.IWorkerContext, createData: CreateData) { + private _httpLibs = new Map(); + private _httpModules = new Map(); + private _dtsMap = new Map(); + private _badHttpRequests = new Set(); + private _fetchPromises = new Map>(); + + constructor( + ctx: monacoNS.worker.IWorkerContext, + createData: CreateData, + ) { this._ctx = ctx; this._compilerOptions = createData.compilerOptions; + this._importMap = createData.importMap; + this._blankImportMap = isBlank(createData.importMap); + this._importMapVersion = 0; this._libs = createData.libs; this._extraLibs = createData.extraLibs; this._inlayHintsOptions = createData.inlayHintsOptions; } - resolveModuleNameLiterals( - moduleLiterals: readonly ts.StringLiteralLike[], - containingFile: string, - redirectedReference: ts.ResolvedProjectReference | undefined, - options: ts.CompilerOptions, - containingSourceFile: ts.SourceFile, - reusedNames: readonly ts.StringLiteralLike[] | undefined, - ): readonly ts.ResolvedModuleWithFailedLookupLocations[] { - return moduleLiterals.map((literal) => { - if ( - literal.text.startsWith("file:///") || literal.text.startsWith("/") || - literal.text.startsWith(".") - ) { - const url = new URL(literal.text, containingFile); - const isFileProtocol = url.protocol === "file:"; - if (isFileProtocol) { - for (const model of this._ctx.getMirrorModels()) { - if (url.href === model.uri.toString()) { - return { - resolvedModule: { - resolvedFileName: url.toString(), - extension: TypeScriptWorker.getFileExtension(url.pathname), - }, - } satisfies ts.ResolvedModuleWithFailedLookupLocations; - } - } - } - if (isFileProtocol && !this._badModuleNames.has(url.href)) { - this._ctx.host.tryOpenModel(url.href).then((ok) => { - if (ok) { - this._ctx.host.refreshDiagnostics(); - } else { - // file not found, don't try to reopen it - this._badModuleNames.add(url.href); - } - }); - } - } - return { resolvedModule: undefined }; - }); - } + /*** language service host ***/ - static getFileExtension(fileName: string): ts.Extension { - const suffix = fileName.substring(fileName.lastIndexOf(".") + 1); - switch (suffix) { - case "ts": - if (fileName.endsWith(".d.ts")) { - return ts.Extension.Dts; - } - return ts.Extension.Ts; - case "mts": - if (fileName.endsWith(".d.mts")) { - return ts.Extension.Dts; + getCompilationSettings(): ts.CompilerOptions { + if (!this._compilerOptions.jsxImportSource) { + const jsxImportSource = this._importMap.imports["@jsxImportSource"]; + if (jsxImportSource) { + this._compilerOptions.jsxImportSource = jsxImportSource; + if (!this._compilerOptions.jsx) { + this._compilerOptions.jsx = ts.JsxEmit.ReactJSX; } - return ts.Extension.Mts; - case "tsx": - return ts.Extension.Tsx; - case "js": - return ts.Extension.Js; - case "mjs": - return ts.Extension.Mjs; - case "jsx": - return ts.Extension.Jsx; - case "json": - return ts.Extension.Json; - default: - return ts.Extension.Ts; + } } - } - - // --- language service host --------------- - - getCompilationSettings(): ts.CompilerOptions { return this._compilerOptions; } @@ -142,73 +90,36 @@ export class TypeScriptWorker implements ts.LanguageServiceHost { return this._languageService; } - getExtraLibs(): Record { - return this._extraLibs; - } - getScriptFileNames(): string[] { - const allModels = this._ctx.getMirrorModels().map((model) => model.uri); - const models = allModels.filter((uri) => !this._fileNameIsLib(uri)).map(( - uri, - ) => uri.toString()); - return models.concat(Object.keys(this._extraLibs)); - } - - private _getModel(fileName: string): worker.IMirrorModel | null { - let models = this._ctx.getMirrorModels(); - for (let i = 0; i < models.length; i++) { - const uri = models[i].uri; - if (uri.toString() === fileName || uri.toString(true) === fileName) { - return models[i]; - } - } - return null; + return this._ctx.getMirrorModels() + .map((model) => model.uri.toString()) + .concat( + Object.keys(this._extraLibs), + [...this._httpLibs.keys()], + [...this._httpModules.keys()], + ); } getScriptVersion(fileName: string): string { + if (fileName in this._extraLibs) { + return String(this._extraLibs[fileName].version); + } let model = this._getModel(fileName); if (model) { - return model.version.toString(); - } else if (this.isDefaultLibFileName(fileName)) { - // default lib is static - return "1"; - } else if (fileName in this._extraLibs) { - return String(this._extraLibs[fileName].version); + return model.version + "." + this._importMapVersion; } - return ""; + return "1"; // default lib is static } async getScriptText(fileName: string): Promise { return this._getScriptText(fileName); } - _getScriptText(fileName: string): string | undefined { - let text: string; - let model = this._getModel(fileName); - const libizedFileName = "lib." + fileName + ".d.ts"; - if (model) { - // a true editor model - text = model.getValue(); - } else if (fileName in this._libs) { - text = this._libs[fileName]; - } else if (libizedFileName in this._libs) { - text = this._libs[libizedFileName]; - } else if (fileName in this._extraLibs) { - // extra lib - text = this._extraLibs[fileName].content; - } else { - return; - } - - return text; - } - getScriptSnapshot(fileName: string): ts.IScriptSnapshot | undefined { const text = this._getScriptText(fileName); if (text === undefined) { return; } - return { getText: (start, end) => text.substring(start, end), getLength: () => text.length, @@ -217,8 +128,23 @@ export class TypeScriptWorker implements ts.LanguageServiceHost { } getScriptKind(fileName: string): ts.ScriptKind { - const suffix = fileName.substring(fileName.lastIndexOf(".") + 1); - switch (suffix) { + if ( + fileName in this._libs || fileName in this._extraLibs || + this._httpLibs.has(fileName) + ) { + return ts.ScriptKind.TS; + } + if (this._httpModules.has(fileName)) { + return ts.ScriptKind.JS; + } + const { pathname } = new URL(fileName, "file:///"); + const basename = pathname.substring(pathname.lastIndexOf("/") + 1); + const dotIndex = basename.lastIndexOf("."); + if (dotIndex === -1) { + return ts.ScriptKind.JS; + } + const ext = basename.substring(dotIndex + 1); + switch (ext) { case "ts": return ts.ScriptKind.TS; case "tsx": @@ -227,13 +153,15 @@ export class TypeScriptWorker implements ts.LanguageServiceHost { return ts.ScriptKind.JS; case "jsx": return ts.ScriptKind.JSX; + case "json": + return ts.ScriptKind.JSON; default: - return ts.ScriptKind.TS; + return ts.ScriptKind.JS; } } getCurrentDirectory(): string { - return ""; + return "/"; } getDefaultLibFileName(options: ts.CompilerOptions): string { @@ -265,74 +193,160 @@ export class TypeScriptWorker implements ts.LanguageServiceHost { } } - isDefaultLibFileName(fileName: string): boolean { - return fileName === this.getDefaultLibFileName(this._compilerOptions); - } - - readFile(path: string): string | undefined { - return this._getScriptText(path); + readFile(filename: string): string | undefined { + return this._getScriptText(filename); } - fileExists(path: string): boolean { - return this._getScriptText(path) !== undefined; + fileExists(filename: string): boolean { + return this._fileExists(filename); } async getLibFiles(): Promise> { return this._libs; } - // --- language features - - private static clearFiles(tsDiagnostics: ts.Diagnostic[]): Diagnostic[] { - // Clear the `file` field, which cannot be JSON'yfied because it - // contains cyclic data structures, except for the `fileName` - // property. - // Do a deep clone so we don't mutate the ts.Diagnostic object (see https://github.com/microsoft/monaco-editor/issues/2392) - const diagnostics: Diagnostic[] = []; - for (const tsDiagnostic of tsDiagnostics) { - const diagnostic: Diagnostic = { - ...tsDiagnostic, - file: tsDiagnostic.file - ? { fileName: tsDiagnostic.file.fileName } - : undefined, - }; - if (tsDiagnostic.relatedInformation) { - diagnostic.relatedInformation = []; - for (const tsRelatedDiagnostic of tsDiagnostic.relatedInformation) { - const relatedDiagnostic: DiagnosticRelatedInformation = { - ...tsRelatedDiagnostic, + resolveModuleNameLiterals( + moduleLiterals: readonly ts.StringLiteralLike[], + containingFile: string, + redirectedReference: ts.ResolvedProjectReference | undefined, + options: ts.CompilerOptions, + containingSourceFile: ts.SourceFile, + reusedNames: readonly ts.StringLiteralLike[] | undefined, + ): readonly ts.ResolvedModuleWithFailedLookupLocations[] { + return moduleLiterals.map(( + literal, + ): ts.ResolvedModuleWithFailedLookupLocations["resolvedModule"] => { + let moduleName = literal.text; + if (!this._blankImportMap) { + const resolved = resolve(this._importMap, moduleName, containingFile); + moduleName = resolved.href; + } + if (TypeScriptWorker.getScriptExtension(moduleName, null) === null) { + // use the extension of the containing file which is a dts file + const ext = TypeScriptWorker.getScriptExtension(containingFile, null); + if (ext === ".d.ts" || ext === ".d.mts" || ext === ".d.cts") { + moduleName += ext; + } + } + const moduleUrl = new URL(moduleName, containingFile); + if (this._httpModules.has(containingFile)) { + // ignore dependencies of http js modules + return { + resolvedFileName: moduleUrl.href, + extension: ".js", + }; + } + if (moduleUrl.protocol === "file:") { + const moduleHref = moduleUrl.href; + for (const model of this._ctx.getMirrorModels()) { + if (moduleHref === model.uri.toString()) { + return { + resolvedFileName: moduleHref, + extension: TypeScriptWorker.getScriptExtension(moduleUrl), + }; + } + } + this._ctx.host.tryOpenModel(moduleHref).then((ok) => { + if (ok) { + this._ctx.host.refreshDiagnostics(); + } + }); + } else if ( + ( + moduleUrl.protocol === "http:" || + moduleUrl.protocol === "https:" + ) && + moduleUrl.pathname !== "/" && + !/[@./-]$/.test(moduleUrl.pathname) + ) { + const moduleHref = moduleUrl.href; + if (this._dtsMap.has(moduleHref)) { + return { + resolvedFileName: this._dtsMap.get(moduleHref), + extension: ".d.ts", + }; + } + if (this._httpLibs.has(moduleHref)) { + return { + resolvedFileName: moduleHref, + extension: ".d.ts", + }; + } + if (this._httpModules.has(moduleHref)) { + return { + resolvedFileName: moduleHref, + extension: ".js", }; - relatedDiagnostic.file = relatedDiagnostic.file - ? { fileName: relatedDiagnostic.file.fileName } - : undefined; - diagnostic.relatedInformation.push(relatedDiagnostic); + } + if ( + !this._fetchPromises.has(moduleHref) && + !this._badHttpRequests.has(moduleHref) + ) { + this._fetchPromises.set( + moduleHref, + vfetch(moduleUrl).then(async (res) => { + if (res.ok) { + const contentType = res.headers.get("content-type"); + const dts = res.headers.get("x-typescript-types"); + if (dts) { + const dtsRes = await vfetch(dts); + if (dtsRes.ok) { + res.body?.cancel(); + this._httpLibs.set(dts, await dtsRes.text()); + this._dtsMap.set(moduleHref, dts); + } else if (dtsRes.status >= 400 && dtsRes.status < 500) { + this._httpModules.set(moduleHref, await res.text()); + } else { + res.body?.cancel(); + } + } else if ( + /\.(c|m)?tsx?$/.test(moduleUrl.pathname) || + /^(application|text)\/typescript/.test(contentType) + ) { + this._httpLibs.set(moduleHref, await res.text()); + } else if ( + /\.(c|m)?jsx?$/.test(moduleUrl.pathname) || + /^(application|text)\/javascript/.test(contentType) + ) { + this._httpModules.set(moduleHref, await res.text()); + } else { + // not a typescript or javascript file + res.body?.cancel(); + this._badHttpRequests.add(moduleHref); + } + } else { + res.body?.cancel(); + if (res.status >= 400 && res.status < 500) { + this._badHttpRequests.add(moduleHref); + } + } + this._ctx.host.refreshDiagnostics(); + }).finally(() => { + this._fetchPromises.delete(moduleHref); + }), + ); } } - diagnostics.push(diagnostic); - } - return diagnostics; + return { + resolvedFileName: moduleName, + extension: TypeScriptWorker.getScriptExtension(moduleName), + }; + }).map((resolvedModule) => ({ resolvedModule })); } + /*** language features ***/ + async getSyntacticDiagnostics(fileName: string): Promise { - if (this._fileNameIsLib(fileName)) { - return []; - } const diagnostics = this._languageService.getSyntacticDiagnostics(fileName); return TypeScriptWorker.clearFiles(diagnostics); } async getSemanticDiagnostics(fileName: string): Promise { - if (this._fileNameIsLib(fileName)) { - return []; - } const diagnostics = this._languageService.getSemanticDiagnostics(fileName); return TypeScriptWorker.clearFiles(diagnostics); } async getSuggestionDiagnostics(fileName: string): Promise { - if (this._fileNameIsLib(fileName)) { - return []; - } const diagnostics = this._languageService.getSuggestionDiagnostics( fileName, ); @@ -342,9 +356,6 @@ export class TypeScriptWorker implements ts.LanguageServiceHost { async getCompilerOptionsDiagnostics( fileName: string, ): Promise { - if (this._fileNameIsLib(fileName)) { - return []; - } const diagnostics = this._languageService.getCompilerOptionsDiagnostics(); return TypeScriptWorker.clearFiles(diagnostics); } @@ -353,30 +364,50 @@ export class TypeScriptWorker implements ts.LanguageServiceHost { fileName: string, position: number, ): Promise { - if (this._fileNameIsLib(fileName)) { - return undefined; - } - return this._languageService.getCompletionsAtPosition( + const completions = this._languageService.getCompletionsAtPosition( fileName, position, - undefined, + { + includeCompletionsForModuleExports: true, + organizeImportsIgnoreCase: false, + importModuleSpecifierPreference: "shortest", + importModuleSpecifierEnding: "js", + includePackageJsonAutoImports: "off", + allowRenameOfImportPath: true, + }, ); + if (completions) { + // filter auto-import suggestions from a types module + completions.entries = completions.entries.filter(({ data }) => + !data || !TypeScriptWorker.isDts(data.fileName) || + !data.fileName.toLocaleLowerCase().startsWith(data.moduleSpecifier) + ); + } + return completions; } async getCompletionEntryDetails( fileName: string, position: number, - entry: string, + entryName: string, + data?: ts.CompletionEntryData, ): Promise { - return this._languageService.getCompletionEntryDetails( - fileName, - position, - entry, - undefined, - undefined, - undefined, - undefined, - ); + try { + return this._languageService.getCompletionEntryDetails( + fileName, + position, + entryName, + { + insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets: true, + semicolons: ts.SemicolonPreference.Insert, + }, + undefined, + { includeCompletionsForModuleExports: true }, + data, + ); + } catch (error) { + return; + } } async getSignatureHelpItems( @@ -384,9 +415,6 @@ export class TypeScriptWorker implements ts.LanguageServiceHost { position: number, options: ts.SignatureHelpItemsOptions | undefined, ): Promise { - if (this._fileNameIsLib(fileName)) { - return undefined; - } return this._languageService.getSignatureHelpItems( fileName, position, @@ -398,10 +426,74 @@ export class TypeScriptWorker implements ts.LanguageServiceHost { fileName: string, position: number, ): Promise { - if (this._fileNameIsLib(fileName)) { - return undefined; + const info = this._languageService.getQuickInfoAtPosition( + fileName, + position, + ); + if (!info) { + return; } - return this._languageService.getQuickInfoAtPosition(fileName, position); + + // pettier display for module specifiers + const { kind, kindModifiers, displayParts, textSpan } = info; + if ( + kind === ts.ScriptElementKind.moduleElement && + displayParts?.length === 3 + ) { + const moduleName = displayParts[2].text; + if ( + // show full path for `file:` specifiers + moduleName.startsWith('"file:') && fileName.startsWith("file:") + ) { + const model = this._getModel(fileName); + const literalText = model.getValue().substring( + textSpan.start, + textSpan.start + textSpan.length, + ); + const specifier = JSON.parse(literalText); + info.displayParts[2].text = '"' + + new URL(specifier, fileName).pathname + '"'; + } else if ( + // show module url for `http:` specifiers instead of the types url + kindModifiers === "declare" && moduleName.startsWith('"http') + ) { + const specifier = JSON.parse(moduleName); + for (const [url, dts] of this._dtsMap) { + if (specifier + ".d.ts" === dts) { + info.displayParts[2].text = '"' + url + '"'; + info.tags = [{ + name: "types", + text: [{ kind: "text", text: dts }], + }]; + if (url.startsWith("https://esm.sh/")) { + const { pathname } = new URL(url); + const pathSegments = pathname.split("/").slice(1); + if (/^v\d$/.test(pathSegments[0])) { + pathSegments.shift(); + } + let scope = ""; + let pkgName = pathSegments.shift(); + if (pkgName?.startsWith("@")) { + scope = pkgName; + pkgName = pathSegments.shift(); + } + if (!pkgName) { + continue; + } + const npmPkgId = [scope, pkgName.split("@")[0]].filter(Boolean) + .join("/"); + const npmPkgUrl = `https://www.npmjs.com/package/${npmPkgId}`; + info.tags.unshift({ + name: "npm", + text: [{ kind: "text", text: `[${npmPkgId}](${npmPkgUrl})` }], + }); + } + break; + } + } + } + } + return info; } async getDocumentHighlights( @@ -409,9 +501,6 @@ export class TypeScriptWorker implements ts.LanguageServiceHost { position: number, filesToSearch: string[], ): Promise | undefined> { - if (this._fileNameIsLib(fileName)) { - return undefined; - } return this._languageService.getDocumentHighlights( fileName, position, @@ -423,9 +512,6 @@ export class TypeScriptWorker implements ts.LanguageServiceHost { fileName: string, position: number, ): Promise | undefined> { - if (this._fileNameIsLib(fileName)) { - return undefined; - } return this._languageService.getDefinitionAtPosition(fileName, position); } @@ -433,18 +519,12 @@ export class TypeScriptWorker implements ts.LanguageServiceHost { fileName: string, position: number, ): Promise { - if (this._fileNameIsLib(fileName)) { - return undefined; - } return this._languageService.getReferencesAtPosition(fileName, position); } async getNavigationTree( fileName: string, ): Promise { - if (this._fileNameIsLib(fileName)) { - return undefined; - } return this._languageService.getNavigationTree(fileName); } @@ -452,9 +532,6 @@ export class TypeScriptWorker implements ts.LanguageServiceHost { fileName: string, options: ts.FormatCodeSettings, ): Promise { - if (this._fileNameIsLib(fileName)) { - return []; - } return this._languageService.getFormattingEditsForDocument( fileName, options, @@ -467,9 +544,6 @@ export class TypeScriptWorker implements ts.LanguageServiceHost { end: number, options: ts.FormatCodeSettings, ): Promise { - if (this._fileNameIsLib(fileName)) { - return []; - } return this._languageService.getFormattingEditsForRange( fileName, start, @@ -484,9 +558,6 @@ export class TypeScriptWorker implements ts.LanguageServiceHost { ch: string, options: ts.FormatCodeSettings, ): Promise { - if (this._fileNameIsLib(fileName)) { - return []; - } return this._languageService.getFormattingEditsAfterKeystroke( fileName, postion, @@ -502,9 +573,6 @@ export class TypeScriptWorker implements ts.LanguageServiceHost { findInComments: boolean, providePrefixAndSuffixTextForRename: boolean, ): Promise { - if (this._fileNameIsLib(fileName)) { - return undefined; - } return this._languageService.findRenameLocations( fileName, position, @@ -519,19 +587,10 @@ export class TypeScriptWorker implements ts.LanguageServiceHost { position: number, options: ts.UserPreferences, ): Promise { - if (this._fileNameIsLib(fileName)) { - return { - canRename: false, - localizedErrorMessage: "Cannot rename in lib file", - }; - } return this._languageService.getRenameInfo(fileName, position, options); } async getEmitOutput(fileName: string): Promise { - if (this._fileNameIsLib(fileName)) { - return { outputFiles: [], emitSkipped: true }; - } return this._languageService.getEmitOutput(fileName); } @@ -542,9 +601,6 @@ export class TypeScriptWorker implements ts.LanguageServiceHost { errorCodes: number[], formatOptions: ts.FormatCodeSettings, ): Promise> { - if (this._fileNameIsLib(fileName)) { - return []; - } const preferences = {}; try { return this._languageService.getCodeFixesAtPosition( @@ -560,30 +616,13 @@ export class TypeScriptWorker implements ts.LanguageServiceHost { } } - async updateCompilerOptions( - compilerOptions: ts.CompilerOptions, - extraLibs: Record, - importMap: ImportMap, - ): Promise { - this._compilerOptions = compilerOptions; - this._extraLibs = extraLibs; - this._importMap = importMap; - } - async provideInlayHints( fileName: string, start: number, end: number, ): Promise { - if (this._fileNameIsLib(fileName)) { - return []; - } const preferences: ts.UserPreferences = this._inlayHintsOptions ?? {}; - const span: ts.TextSpan = { - start, - length: end - start, - }; - + const span: ts.TextSpan = { start, length: end - start }; try { return this._languageService.provideInlayHints( fileName, @@ -595,31 +634,166 @@ export class TypeScriptWorker implements ts.LanguageServiceHost { } } - /** - * Loading a default lib as a source file will mess up TS completely. - * So our strategy is to hide such a text model from TS. - * See https://github.com/microsoft/monaco-editor/issues/2182 - */ - _fileNameIsLib(resource: monacoNS.Uri | string): boolean { - if (typeof resource === "string") { - if (resource.startsWith("file:///")) { - return resource.substring(8) in this._libs; + async organizeImports( + fileName: string, + formatOptions: ts.FormatCodeSettings, + ): Promise { + try { + return this._languageService.organizeImports( + { + type: "file", + fileName, + mode: ts.OrganizeImportsMode.SortAndCombine, + }, + formatOptions, + undefined, + ); + } catch { + return []; + } + } + + async updateCompilerOptions({ + compilerOptions, + importMap, + extraLibs, + }: { + compilerOptions?: ts.CompilerOptions; + importMap?: ImportMap; + extraLibs?: Record; + }): Promise { + if (compilerOptions) { + this._compilerOptions = compilerOptions; + } + if (importMap) { + this._importMap = importMap; + this._blankImportMap = isBlank(importMap); + this._importMapVersion++; + } + if (extraLibs) { + this._extraLibs = extraLibs; + } + } + + private static getScriptExtension( + url: URL | string, + defaultExt = ".js", + ): string | null { + const pathname = typeof url === "string" + ? new URL(url, "file:///").pathname + : url.pathname; + const fileName = pathname.substring(pathname.lastIndexOf("/") + 1); + const dotIndex = fileName.lastIndexOf("."); + if (dotIndex === -1) { + return defaultExt ?? null; + } + const ext = fileName.substring(dotIndex + 1); + switch (ext) { + case "ts": + return fileName.endsWith(".d.ts") ? ".d.ts" : ".ts"; + case "mts": + return fileName.endsWith(".d.mts") ? ".d.mts" : ".mts"; + case "cts": + return fileName.endsWith(".d.cts") ? ".d.cts" : ".cts"; + case "tsx": + return ".tsx"; + case "js": + return ".js"; + case "mjs": + return ".mjs"; + case "cjs": + return ".cjs"; + case "jsx": + return ".jsx"; + case "json": + return ".json"; + default: + return ".js"; + } + } + + private static isDts(fileName: string): boolean { + return fileName.endsWith(".d.ts") || + fileName.endsWith(".d.mts") || + fileName.endsWith(".d.cts"); + } + + private static clearFiles(tsDiagnostics: ts.Diagnostic[]): Diagnostic[] { + // Clear the `file` field, which cannot be JSON stringified because it + // contains cyclic data structures, except for the `fileName` + // property. + // Do a deep clone so we don't mutate the ts.Diagnostic object (see https://github.com/microsoft/monaco-editor/issues/2392) + const diagnostics: Diagnostic[] = []; + for (const tsDiagnostic of tsDiagnostics) { + const diagnostic: Diagnostic = { + ...tsDiagnostic, + file: tsDiagnostic.file + ? { fileName: tsDiagnostic.file.fileName } + : undefined, + }; + if (tsDiagnostic.relatedInformation) { + diagnostic.relatedInformation = []; + for (const tsRelatedDiagnostic of tsDiagnostic.relatedInformation) { + const relatedDiagnostic: DiagnosticRelatedInformation = { + ...tsRelatedDiagnostic, + }; + relatedDiagnostic.file = relatedDiagnostic.file + ? { fileName: relatedDiagnostic.file.fileName } + : undefined; + diagnostic.relatedInformation.push(relatedDiagnostic); + } } - return false; + diagnostics.push(diagnostic); } - if (resource.path.startsWith("/lib.")) { - return resource.path.slice(1) in this._libs; + return diagnostics; + } + + private _fileExists(fileName: string): boolean { + let models = this._ctx.getMirrorModels(); + for (let i = 0; i < models.length; i++) { + const uri = models[i].uri; + if (uri.toString() === fileName || uri.toString(true) === fileName) { + return true; + } } - return false; + return ( + fileName in this._libs || + `lib.${fileName}.d.ts` in this._libs || + fileName in this._extraLibs || + this._httpLibs.has(fileName) || + this._httpModules.has(fileName) + ); + } + + private _getScriptText(fileName: string): string | undefined { + let model = this._getModel(fileName); + if (model) { + return model.getValue(); + } + return this._libs[fileName] ?? + this._libs[`lib.${fileName}.d.ts`] ?? + this._extraLibs[fileName]?.content ?? + this._httpLibs.get(fileName) ?? + this._httpModules.get(fileName); + } + + private _getModel(fileName: string): monacoNS.worker.IMirrorModel | null { + let models = this._ctx.getMirrorModels(); + for (let i = 0; i < models.length; i++) { + const uri = models[i].uri; + if (uri.toString() === fileName || uri.toString(true) === fileName) { + return models[i]; + } + } + return null; } } globalThis.onmessage = () => { // ignore the first message - worker.initialize((ctx, createData) => { + initialize((ctx, createData) => { return new TypeScriptWorker(ctx, createData); }); }; -// export TS for html embeded script export { ts as TS }; diff --git a/packages/esm-monaco/src/shiki.ts b/packages/esm-monaco/src/shiki.ts index 534c9caf..bd4cbfa2 100644 --- a/packages/esm-monaco/src/shiki.ts +++ b/packages/esm-monaco/src/shiki.ts @@ -26,6 +26,19 @@ export async function initShiki( ) { const themes: ThemeInput[] = []; const langs: LanguageInput = []; + const fetcher = Reflect.get(monaco.editor, "vfs")?.fetch ?? globalThis.fetch + + function loadTMTheme(theme: string) { + return fetcher( + `https://esm.sh/tm-themes@${tmThemesVersion}/themes/${theme}.json`, + ).then((res) => res.json()); + } + + function loadTMGrammer(id: string) { + return fetcher( + `https://esm.sh/tm-grammars@${tmGrammersVersion}/grammars/${id}.json`, + ).then((res) => res.json()); + } if (options.preloadGrammars) { const preloadGrammars = new Set(options.preloadGrammars); @@ -87,18 +100,6 @@ export async function initShiki( shikiToMonaco(highlighter, monaco); } -function loadTMTheme(theme: string) { - return fetch( - `https://esm.sh/tm-themes@${tmThemesVersion}/themes/${theme}.json`, - ).then((res) => res.json()); -} - -function loadTMGrammer(lang: string) { - return fetch( - `https://esm.sh/tm-grammars@${tmGrammersVersion}/grammars/${lang}.json`, - ).then((res) => res.json()); -} - // add some aliases for javascript and typescript const javascriptGrammar = allGrammars.find((g) => g.name === "javascript"); const typescriptGrammar = allGrammars.find((g) => g.name === "typescript"); diff --git a/packages/esm-monaco/src/vfs.ts b/packages/esm-monaco/src/vfs.ts index 818489ac..474c2622 100644 --- a/packages/esm-monaco/src/vfs.ts +++ b/packages/esm-monaco/src/vfs.ts @@ -1,12 +1,11 @@ -import * as monaco from "monaco-editor-core"; +import type * as monacoNS from "monaco-editor-core"; const enc = new TextEncoder(); const dec = new TextDecoder(); -const idbVer = 1; export interface VFSInterface { readonly ErrorNotFound: typeof ErrorNotFound; - openModel(name: string | URL): Promise; + openModel(name: string | URL): Promise; exists(name: string | URL): Promise; list(): Promise; readFile(name: string | URL): Promise; @@ -17,16 +16,21 @@ export interface VFSInterface { version?: number, ): Promise; removeFile(name: string | URL): Promise; - watchFile?( - name: string | URL, - handler: (evt: { kind: string; path: string }) => void, - ): () => void; + watch(name: string | URL, handler: (evt: WatchEvent) => void): () => void; +} + +interface WatchEvent { + kind: "create" | "modify" | "remove"; + path: string; } -interface VFSItem { +interface VFile { url: string; version: number; content: string | Uint8Array; + ctime: number; + mtime: number; + headers?: [string, string][]; } interface VFSOptions { @@ -34,7 +38,10 @@ interface VFSOptions { initial?: Record; } +/** Virtual file system class for monaco editor. */ +// TODO: use lz-string to compress text content export class VFS implements VFSInterface { + #monaco: typeof monacoNS; #db: Promise | IDBDatabase; #watchHandlers = new Map< string, @@ -42,28 +49,26 @@ export class VFS implements VFSInterface { >(); constructor(options: VFSOptions) { - const req = indexedDB.open( - "vfs:esm-monaco/" + (options.scope ?? "main"), - idbVer, - ); - req.onupgradeneeded = async () => { - const db = req.result; - if (!db.objectStoreNames.contains("files")) { - const store = db.createObjectStore("files", { keyPath: "url" }); + const req = openDB( + "vfs:monaco-app/" + (options.scope ?? "main"), + async (store) => { for (const [name, data] of Object.entries(options.initial ?? {})) { - const url = new URL(name, "file:///"); - const item: VFSItem = { + const url = toUrl(name); + const now = Date.now(); + const item: VFile = { url: url.href, - version: 0, + version: 1, content: Array.isArray(data) && !(data instanceof Uint8Array) ? data.join("\n") : data, + ctime: now, + mtime: now, }; await waitIDBRequest(store.add(item)); } - } - }; - this.#db = waitIDBRequest(req).then((db) => this.#db = db); + }, + ); + this.#db = req.then((db) => this.#db = db); } get ErrorNotFound() { @@ -71,17 +76,14 @@ export class VFS implements VFSInterface { } async #begin(readonly = false) { - let db = this.#db; - if (db instanceof Promise) { - db = await db; - } - + const db = await this.#db; return db.transaction("files", readonly ? "readonly" : "readwrite") .objectStore("files"); } async openModel(name: string | URL) { - const url = new URL(name, "file:///"); + const monaco = this.#monaco; + const url = toUrl(name); const uri = monaco.Uri.parse(url.href); const { content, version } = await this.#read(url); let model = monaco.editor.getModel(uri); @@ -107,9 +109,9 @@ export class VFS implements VFSInterface { } async exists(name: string | URL): Promise { - const url = new URL(name, "file:///").href; + const url = toUrl(name); const db = await this.#begin(true); - return waitIDBRequest(db.getKey(url)).then((key) => !!key); + return waitIDBRequest(db.getKey(url.href)).then((key) => !!key); } async list() { @@ -119,11 +121,9 @@ export class VFS implements VFSInterface { } async #read(name: string | URL) { - const url = new URL(name, "file:///"); + const url = toUrl(name); const db = await this.#begin(true); - const ret = await waitIDBRequest( - db.get(url.href), - ); + const ret = await waitIDBRequest(db.get(url.href)); if (!ret) { throw new ErrorNotFound(name); } @@ -145,35 +145,53 @@ export class VFS implements VFSInterface { content: string | Uint8Array, version?: number, ) { - const { pathname, href } = new URL(name, "file:///"); + const { pathname, href: url } = toUrl(name); const db = await this.#begin(); - const item: VFSItem = { url: href, version: version ?? 0, content }; - await waitIDBRequest(db.put(item)); - const handlers = this.#watchHandlers.get(href); - if (handlers) { - for (const handler of handlers) { - handler({ kind: "createOrModify", path: pathname }); + const old = await waitIDBRequest( + db.get(url), + ); + const now = Date.now(); + const file: VFile = { + url, + version: version ?? (1 + (old?.version ?? 0)), + content, + ctime: old?.ctime ?? now, + mtime: now, + }; + await waitIDBRequest(db.put(file)); + setTimeout(() => { + for (const key of [url, "*"]) { + const handlers = this.#watchHandlers.get(key); + if (handlers) { + for (const handler of handlers) { + handler({ kind: old ? "modify" : "create", path: pathname }); + } + } } - } + }, 0); } async removeFile(name: string | URL): Promise { - const { pathname, href } = new URL(name, "file:///"); + const { pathname, href } = toUrl(name); const db = await this.#begin(); await waitIDBRequest(db.delete(href)); - const handlers = this.#watchHandlers.get(href); - if (handlers) { - for (const handler of handlers) { - handler({ kind: "remove", path: pathname }); + setTimeout(() => { + for (const key of [href, "*"]) { + const handlers = this.#watchHandlers.get(key); + if (handlers) { + for (const handler of handlers) { + handler({ kind: "remove", path: pathname }); + } + } } - } + }, 0); } - watchFile( + watch( name: string | URL, - handler: (evt: { kind: string; path: string }) => void, + handler: (evt: WatchEvent) => void, ): () => void { - const url = new URL(name, "file:///").href; + const url = name == "*" ? name : toUrl(name).href; let handlers = this.#watchHandlers.get(url); if (!handlers) { handlers = new Set(); @@ -184,14 +202,104 @@ export class VFS implements VFSInterface { handlers!.delete(handler); }; } + + fetch(url: string | URL) { + return vfetch(url); + } + + bindMonaco(monaco: typeof monacoNS) { + this.#monaco = monaco; + } } +/** Error for file not found. */ export class ErrorNotFound extends Error { constructor(name: string | URL) { super("file not found: " + name.toString()); } } +/** Open the given IndexedDB database. */ +export function openDB( + name: string, + onStoreCreate?: (store: IDBObjectStore) => void | Promise, +) { + const req = indexedDB.open(name, 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains("files")) { + const store = db.createObjectStore("files", { keyPath: "url" }); + onStoreCreate?.(store); + } + }; + return waitIDBRequest(req); +} + +/** wait for the given IDBRequest. */ +export function waitIDBRequest(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +/** The cache storage in IndexedDB. */ +let cacheDb: Promise | IDBDatabase | null = null; + +/** Fetch with vfs cache. */ +export async function vfetch(url: string | URL): Promise { + const db = await (cacheDb ?? (cacheDb = openDB("vfs:monaco-cache"))); + const tx = db.transaction("files", "readonly").objectStore("files"); + const caceUrl = toUrl(url).href; + const ret = await waitIDBRequest(tx.get(caceUrl)); + if (ret && ret.headers) { + const headers = new Headers(ret.headers); + const cc = headers.get("cache-control"); + let hit = false; + if (cc) { + if (cc.includes("immutable")) { + hit = true; + } else { + const m = cc.match(/max-age=(\d+)/); + if (m) { + const maxAgeMs = Number(m[1]) * 1000; + hit = ret.mtime + maxAgeMs > Date.now(); + } + } + } + if (hit) { + return new Response(ret.content, { headers }); + } + } + const res = await fetch(url); + const cc = res.headers.get("cache-control"); + if (res.ok && cc && (cc.includes("max-age=") || cc.includes("immutable"))) { + const content = new Uint8Array(await res.arrayBuffer()); + const headers = [...res.headers.entries()].filter(([k]) => + ["content-type", "content-length", "cache-control", "x-typescript-types"] + .includes(k) + ); + const now = Date.now(); + const file: VFile = { + url: caceUrl, + version: 1, + content, + headers, + ctime: now, + mtime: now, + }; + const tx = db.transaction("files", "readwrite").objectStore("files"); + await waitIDBRequest(tx.put(file)); + return new Response(content, { headers }); + } + return res; +} + +/** Convert string to URL. */ +function toUrl(name: string | URL) { + return typeof name === "string" ? new URL(name, "file:///") : name; +} + /** Convert string to Uint8Array. */ function toUint8Array(data: string | Uint8Array): Uint8Array { return typeof data === "string" ? enc.encode(data) : data; @@ -201,11 +309,3 @@ function toUint8Array(data: string | Uint8Array): Uint8Array { function toString(data: string | Uint8Array) { return data instanceof Uint8Array ? dec.decode(data) : data; } - -/** wait for the given IDBRequest. */ -function waitIDBRequest(req: IDBRequest): Promise { - return new Promise((resolve, reject) => { - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); -} diff --git a/packages/esm-monaco/test/index.html b/packages/esm-monaco/test/index.html index 2855aa3a..6f8401a4 100644 --- a/packages/esm-monaco/test/index.html +++ b/packages/esm-monaco/test/index.html @@ -24,7 +24,7 @@ - + @@ -62,11 +62,13 @@ '}' ], 'App.tsx': [ + 'import confetti from "https://esm.sh/canvas-confetti@1.6.0"', 'import { useEffect } from "react"', 'import { message } from "./greeting.ts"', '', 'export default function App() {', ' useEffect(() => {', + ' confetti()', ' log(message)', ' }, [])', ' return

{message}

;', @@ -88,7 +90,7 @@ }, null, 2), 'tsconfig.json': JSON.stringify({ compilerOptions: { - types: ["log.d.ts"], + types: ["log.d.ts", "https://raw.githubusercontent.com/vitejs/vite/main/packages/vite/types/importMeta.d.ts"], } }, null, 2) } diff --git a/packages/esm-monaco/test/serve.mjs b/packages/esm-monaco/test/serve.mjs index 0ea778dd..fb90aa5d 100644 --- a/packages/esm-monaco/test/serve.mjs +++ b/packages/esm-monaco/test/serve.mjs @@ -12,43 +12,50 @@ Deno.serve(async (req) => { }, ); } - const ext = url.pathname.split(".").pop(); try { - let body = - (await Deno.open(new URL("../dist" + url.pathname, import.meta.url))) - .readable; + const fileUrl = new URL("../dist" + url.pathname, import.meta.url); + let body = (await Deno.open(fileUrl)).readable; if (url.pathname === "/lsp/typescript/worker.js") { - const ts = new TransformStream({ - transform: async (chunk, controller) => { - const text = new TextDecoder().decode(chunk); - if (/from"typescript"/.test(text)) { - controller.enqueue( - new TextEncoder().encode( - text.replace( - /from"typescript"/, - 'from"https://esm.sh/typescript@5.3.3?bundle"', + body = body.pipeThrough( + new TransformStream({ + transform: async (chunk, controller) => { + const text = new TextDecoder().decode(chunk); + if (/from"typescript"/.test(text)) { + controller.enqueue( + new TextEncoder().encode( + text.replace( + /from"typescript"/, + 'from"https://esm.sh/typescript@5.3.3?bundle"', + ), ), - ), - ); - } else { - controller.enqueue(chunk); - } - }, - }); - body = body.pipeThrough(ts); + ); + } else { + controller.enqueue(chunk); + } + }, + }), + ); } return new Response( body, { headers: new Headers({ - "content-type": ext === "js" ? "application/javascript" : "text/css", + "transfer-encoding": "chunked", + "content-type": fileUrl.pathname.endsWith(".css") + ? "text/css" + : "application/javascript", "cache-control": "public, max-age=0, revalidate", }), }, ); } catch (e) { - return new Response("Not found", { - status: 404, + if (e instanceof Deno.errors.NotFound) { + return new Response("Not found", { + status: 404, + }); + } + return new Response(e.message, { + status: 500, }); } }); diff --git a/packages/esm-monaco/types/vfs.d.ts b/packages/esm-monaco/types/vfs.d.ts index 59b5500c..b5768a71 100644 --- a/packages/esm-monaco/types/vfs.d.ts +++ b/packages/esm-monaco/types/vfs.d.ts @@ -17,8 +17,10 @@ export class VFS { version?: number, ): Promise; removeFile(name: string | URL): Promise; - watchFile?( - name: string | URL, - handler: (evt: { kind: string; path: string }) => void, - ): () => void; + watch(name: string | URL, handler: (evt: WatchEvent) => void): () => void; +} + +interface WatchEvent { + kind: "create" | "modify" | "remove"; + path: string; }