diff --git a/packages/vscode-typescript/l10n/bundle.l10n.json b/packages/vscode-typescript/l10n/bundle.l10n.json index d33a2666e490f..a3d8227d3f30c 100644 --- a/packages/vscode-typescript/l10n/bundle.l10n.json +++ b/packages/vscode-typescript/l10n/bundle.l10n.json @@ -65,6 +65,47 @@ "{0} extensions contribute TypeScript server plugins that will not be loaded because TypeScript 7 is enabled globally: {1}": "{0} extensions contribute TypeScript server plugins that will not be loaded because TypeScript 7 is enabled globally: {1}", "Disable Native Preview in Workspace": "Disable Native Preview in Workspace", "Don't Show Again": "Don't Show Again", + "Open virtual documents to inspect diagnostic directives.": "Open virtual documents to inspect diagnostic directives.", + "{0} directive": "{0} directive", + "{0} directives": "{0} directives", + "Reveal Diagnostic Directive": "Reveal Diagnostic Directive", + "The current content-mapped file has no diagnostic directives.": "The current content-mapped file has no diagnostic directives.", + "Original range: {0}": "Original range: {0}", + "Virtual range: {0}": "Virtual range: {0}", + "Preview:": "Preview:", + "Unused diagnostic code: {0}": "Unused diagnostic code: {0}", + "Expect": "Expect", + "Unknown ({0})": "Unknown ({0})", + "Could not reveal diagnostic directive: {0}": "Could not reveal diagnostic directive: {0}", + "Open a content-mapped source file to show its virtual TypeScript documents.": "Open a content-mapped source file to show its virtual TypeScript documents.", + "The active file is not transformed by a TypeScript content mapper.": "The active file is not transformed by a TypeScript content mapper.", + "Content mapper inspector is unavailable: {0}": "Content mapper inspector is unavailable: {0}", + "Span kind: {0}": "Span kind: {0}", + "Features: {0}": "Features: {0}", + "None": "None", + "Verbatim": "Verbatim", + "Atom": "Atom", + "Alias": "Alias", + "Hover": "Hover", + "Signature Help": "Signature Help", + "Completion": "Completion", + "Definition": "Definition", + "Type Definition": "Type Definition", + "Implementation": "Implementation", + "References": "References", + "Document Highlights": "Document Highlights", + "Rename": "Rename", + "Call Hierarchy": "Call Hierarchy", + "Code Actions": "Code Actions", + "Formatting": "Formatting", + "Inlay Hints": "Inlay Hints", + "Semantic Tokens": "Semantic Tokens", + "Folding Ranges": "Folding Ranges", + "Selection Ranges": "Selection Ranges", + "Linked Editing": "Linked Editing", + "Auto Insert": "Auto Insert", + "Document Symbols": "Document Symbols", + "CodeLens": "CodeLens", "Unexpected number of arguments.": "Unexpected number of arguments.", "Starting language server...": "Starting language server...", "Language client is not initialized": "Language client is not initialized", diff --git a/packages/vscode-typescript/package.json b/packages/vscode-typescript/package.json index 3be3c59bd7b72..814511b79c938 100644 --- a/packages/vscode-typescript/package.json +++ b/packages/vscode-typescript/package.json @@ -32,7 +32,8 @@ "onLanguage:javascript", "onLanguage:javascriptreact", "onLanguage:typescript", - "onLanguage:typescriptreact" + "onLanguage:typescriptreact", + "onFileSystem:typescript-content-mapper" ], "contributes": { "configuration": [ @@ -224,6 +225,16 @@ } } ], + "views": { + "explorer": [ + { + "id": "typescript.native-preview.contentMapperDiagnosticDirectives", + "name": "%native-preview.contentMapperDiagnosticDirectives.name%", + "icon": "$(list-tree)", + "when": "typescript.native-preview.serverRunning && typescript.native-preview.contentMapperInspectorEnabled" + } + ] + }, "commands": [ { "command": "typescript.native-preview.enable", @@ -267,6 +278,12 @@ "enablement": "typescript.native-preview.serverRunning", "category": "TypeScript" }, + { + "command": "typescript.native-preview.showContentMapperVirtualDocuments", + "title": "%native-preview.showContentMapperVirtualDocuments.title%", + "enablement": "typescript.native-preview.serverRunning && typescript.native-preview.activeEditorIsContentMapped", + "category": "TypeScript" + }, { "command": "typescript.native-preview.sortImports", "title": "%native-preview.sortImports.title%", @@ -339,6 +356,10 @@ "command": "typescript.native-preview.goToSourceDefinition", "when": "typescript.native-preview.serverRunning && tsSupportsSourceDefinition" }, + { + "command": "typescript.native-preview.showContentMapperVirtualDocuments", + "when": "typescript.native-preview.serverRunning && typescript.native-preview.activeEditorIsContentMapped" + }, { "command": "typescript.native-preview.sortImports", "when": "typescript.native-preview.serverRunning && supportedCodeAction =~ /(\\s|^)source\\.sortImports\\b/ && editorLangId =~ /^(typescript|javascript)(react)?$/" @@ -354,6 +375,13 @@ "when": "typescript.native-preview.serverRunning && tsSupportsSourceDefinition && (resourceLangId == typescript || resourceLangId == typescriptreact || resourceLangId == javascript || resourceLangId == javascriptreact)", "group": "navigation@1.41" } + ], + "editor/title/context": [ + { + "command": "typescript.native-preview.showContentMapperVirtualDocuments", + "when": "typescript.native-preview.serverRunning && typescript.native-preview.activeEditorIsContentMapped", + "group": "navigation@1.5" + } ] } }, diff --git a/packages/vscode-typescript/package.nls.json b/packages/vscode-typescript/package.nls.json index 437a36410004f..b777e403a6261 100644 --- a/packages/vscode-typescript/package.nls.json +++ b/packages/vscode-typescript/package.nls.json @@ -23,6 +23,8 @@ "native-preview.reportIssue.title": "Report Issue", "native-preview.selectVersion.title": "Select TypeScript Version...", "native-preview.goToSourceDefinition.title": "Go to Source Definition", + "native-preview.showContentMapperVirtualDocuments.title": "Show Content Mapper Virtual Documents", + "native-preview.contentMapperDiagnosticDirectives.name": "Content Mapper Diagnostic Directives", "native-preview.sortImports.title": "Sort Imports", "native-preview.removeUnusedImports.title": "Remove Unused Imports", "native-preview.codeLens.showLocations.title": "Show References of CodeLens", diff --git a/packages/vscode-typescript/src/client.ts b/packages/vscode-typescript/src/client.ts index b8bd17df8540a..447c5082b9dd5 100644 --- a/packages/vscode-typescript/src/client.ts +++ b/packages/vscode-typescript/src/client.ts @@ -25,6 +25,11 @@ import { sendNotificationMiddleware, } from "./configurationMiddleware"; import type { SerializedContentMapperContribution } from "./contentMapperContributions"; +import { + type ContentMapperVirtualFile, + type MappedOutput, + toMappedOutputs, +} from "./contentMapperVirtualFiles"; import { registerMultiDocumentHighlightFeature } from "./languageFeatures/documentHighlight"; import { registerHoverFeature } from "./languageFeatures/hover"; import { registerOnAutoInsertFeature } from "./languageFeatures/onAutoInsert"; @@ -436,6 +441,28 @@ export class Client implements vscode.Disposable { return this.client.sendRequest<{ sessionId: string; pipe: string; }>("custom/initializeAPISession", { pipe }); } + async getContentMapperVirtualFiles(uri: vscode.Uri): Promise { + if (!this.client) { + throw new Error(vscode.l10n.t("Language client is not initialized")); + } + const result = await this.client.sendRequest<{ files: ContentMapperVirtualFile[]; }>( + "custom/contentMapperVirtualFiles", + { textDocument: { uri: uri.toString() } }, + ); + return toMappedOutputs(result.files); + } + + async isContentMapped(uri: vscode.Uri): Promise { + if (!this.client) { + throw new Error(vscode.l10n.t("Language client is not initialized")); + } + const result = await this.client.sendRequest<{ isContentMapped: boolean; }>( + "custom/isContentMapped", + { textDocument: { uri: uri.toString() } }, + ); + return result.isContentMapped; + } + /** * Restart the language server if the executable path has not changed. * Returns true if a restart was performed. diff --git a/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts b/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts new file mode 100644 index 0000000000000..535e9788763c5 --- /dev/null +++ b/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts @@ -0,0 +1,843 @@ +import * as path from "node:path"; +import * as vscode from "vscode"; + +import type { + ContentMapperTextRange, + ContentMapperVirtualSpan, + MappedOutput, +} from "./contentMapperVirtualFiles"; +import { + type DiagnosticDirectiveNode, + DiagnosticDirectivesView, +} from "./diagnosticDirectivesView"; +import { readNativePreviewConfig } from "./util"; + +const virtualDocumentScheme = "typescript-content-mapper"; +const activeEditorIsContentMappedContext = "typescript.native-preview.activeEditorIsContentMapped"; +const contentMapperInspectorEnabledContext = "typescript.native-preview.contentMapperInspectorEnabled"; +export const showVirtualDocumentsCommand = "typescript.native-preview.showContentMapperVirtualDocuments"; + +export interface ContentMapperVirtualFilesProvider { + readonly onDidInitializeLanguageServer: vscode.Event; + readonly onDidSynchronizeContentMapperContributions: vscode.Event; + getContentMapperVirtualFiles(uri: vscode.Uri): Promise; + isContentMapped(uri: vscode.Uri): Promise; +} + +interface VirtualDocumentEntry { + readonly sourceUri: vscode.Uri; + output: MappedOutput; + mtime: number; +} + +export function registerContentMapperVirtualDocumentProvider( + provider: ContentMapperVirtualFilesProvider, + output: vscode.LogOutputChannel, +): vscode.Disposable { + return new ContentMapperVirtualDocumentProvider(provider, output); +} + +class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, vscode.Disposable { + private readonly changeEmitter = new vscode.EventEmitter(); + private readonly entries = new Map(); + private readonly sourceToVirtualUris = new Map(); + private readonly refreshTimers = new Map(); + private readonly evictionTimers = new Map(); + private inspectionTimer: NodeJS.Timeout | undefined; + private activeEditorContextTimer: NodeJS.Timeout | undefined; + private activeEditorContextVersion = 0; + private readonly mappingDecorations = [ + vscode.window.createTextEditorDecorationType({ + backgroundColor: "rgba(70, 180, 90, 0.18)", + border: "1px solid rgba(70, 180, 90, 0.8)", + }), + vscode.window.createTextEditorDecorationType({ + backgroundColor: "rgba(230, 165, 35, 0.18)", + border: "1px solid rgba(230, 165, 35, 0.85)", + }), + vscode.window.createTextEditorDecorationType({ + backgroundColor: "rgba(65, 145, 235, 0.18)", + border: "1px solid rgba(65, 145, 235, 0.85)", + }), + ]; + private readonly decoratedEditors = new Set(); + private readonly highlightedMappings = new Map(); + private readonly diagnosticDirectivesView: DiagnosticDirectivesView; + private readonly disposables: vscode.Disposable[]; + + readonly onDidChangeFile = this.changeEmitter.event; + + constructor( + private readonly provider: ContentMapperVirtualFilesProvider, + private readonly output: vscode.LogOutputChannel, + ) { + this.diagnosticDirectivesView = new DiagnosticDirectivesView(node => { + void this.revealDiagnosticDirective(node).catch(error => { + this.output.error(`Could not reveal diagnostic directive: ${String(error)}`); + void vscode.window.showErrorMessage(vscode.l10n.t("Could not reveal diagnostic directive: {0}", errorMessage(error))); + }); + }); + this.disposables = [ + this.changeEmitter, + this.diagnosticDirectivesView, + ...this.mappingDecorations, + vscode.workspace.registerFileSystemProvider(virtualDocumentScheme, this, { + isCaseSensitive: true, + isReadonly: true, + }), + vscode.languages.registerHoverProvider( + { scheme: virtualDocumentScheme }, + { provideHover: (document, position) => this.provideMappingHover(document, position) }, + ), + vscode.commands.registerCommand(showVirtualDocumentsCommand, () => this.showActiveDocument()), + vscode.workspace.onDidChangeTextDocument(event => { + if (event.document.uri.scheme === virtualDocumentScheme) { + this.scheduleInspection(); + } + else { + this.scheduleRefresh(event.document.uri); + if (event.document === vscode.window.activeTextEditor?.document) { + this.scheduleActiveEditorContextUpdate(event.document); + } + } + }), + vscode.workspace.onDidSaveTextDocument(document => this.scheduleRefresh(document.uri)), + vscode.workspace.onDidCloseTextDocument(document => { + if (document.uri.scheme === virtualDocumentScheme) { + this.scheduleSourceEviction(document.uri); + } + }), + vscode.languages.onDidChangeDiagnostics(event => { + for (const uri of event.uris) { + this.scheduleRefresh(uri); + } + }), + vscode.window.onDidChangeActiveTextEditor(editor => { + this.updateActiveEditorContext(editor); + const inspectedSource = this.showDiagnosticDirectivesForEditor(editor); + if (inspectedSource) { + this.refreshSource(inspectedSource); + } + this.scheduleInspection(); + }), + vscode.window.onDidChangeTextEditorSelection(event => { + if (event.textEditor === vscode.window.activeTextEditor) { + this.scheduleInspection(); + } + }), + vscode.window.onDidChangeVisibleTextEditors(() => this.scheduleInspection()), + vscode.workspace.onDidChangeConfiguration(event => { + if ( + event.affectsConfiguration("js/ts.showDebugInfo") + || event.affectsConfiguration("typescript.native-preview.showDebugInfo") + ) { + this.updateInspectorEnabledContext(); + this.updateActiveEditorContext(vscode.window.activeTextEditor); + } + }), + provider.onDidInitializeLanguageServer(() => { + this.updateActiveEditorContext(vscode.window.activeTextEditor); + for (const source of this.sourceToVirtualUris.keys()) { + this.refreshSource(vscode.Uri.parse(source)); + } + }), + provider.onDidSynchronizeContentMapperContributions(() => { + this.updateActiveEditorContext(vscode.window.activeTextEditor); + }), + ]; + this.updateInspectorEnabledContext(); + this.updateActiveEditorContext(vscode.window.activeTextEditor); + } + + watch(): vscode.Disposable { + return new vscode.Disposable(() => {}); + } + + async stat(uri: vscode.Uri): Promise { + const entry = await this.getOrCreateEntry(uri); + if (!entry) { + throw vscode.FileSystemError.FileNotFound(uri); + } + return { + type: vscode.FileType.File, + ctime: 0, + mtime: entry.mtime, + size: Buffer.byteLength(entry.output.text), + permissions: vscode.FilePermission.Readonly, + }; + } + + readDirectory(): [string, vscode.FileType][] { + return []; + } + + createDirectory(uri: vscode.Uri): void { + throw vscode.FileSystemError.NoPermissions(uri); + } + + async readFile(uri: vscode.Uri): Promise { + const entry = await this.getOrCreateEntry(uri); + if (!entry) { + throw vscode.FileSystemError.FileNotFound(uri); + } + return Buffer.from(entry.output.text); + } + + writeFile(uri: vscode.Uri): void { + throw vscode.FileSystemError.NoPermissions(uri); + } + + delete(uri: vscode.Uri): void { + throw vscode.FileSystemError.NoPermissions(uri); + } + + rename(oldUri: vscode.Uri): void { + throw vscode.FileSystemError.NoPermissions(oldUri); + } + + dispose(): void { + this.activeEditorContextVersion++; + void vscode.commands.executeCommand("setContext", activeEditorIsContentMappedContext, false); + void vscode.commands.executeCommand("setContext", contentMapperInspectorEnabledContext, false); + for (const timer of this.refreshTimers.values()) { + clearTimeout(timer); + } + this.refreshTimers.clear(); + for (const timer of this.evictionTimers.values()) { + clearTimeout(timer); + } + this.evictionTimers.clear(); + if (this.inspectionTimer) { + clearTimeout(this.inspectionTimer); + this.inspectionTimer = undefined; + } + this.clearActiveEditorContextTimer(); + for (const disposable of this.disposables.splice(0)) { + disposable.dispose(); + } + } + + private async getOrCreateEntry(uri: vscode.Uri): Promise { + const existing = this.entries.get(uri.toString()); + if (existing) { + return existing; + } + const parsed = parseVirtualUri(uri); + if (!parsed) { + return undefined; + } + const outputs = await this.loadOutputs(parsed.sourceUri); + this.storeOutputs(parsed.sourceUri, outputs); + this.diagnosticDirectivesView.show(parsed.sourceUri, outputs); + return this.entries.get(uri.toString()); + } + + private async showActiveDocument(): Promise { + const sourceEditor = vscode.window.activeTextEditor; + const sourceUri = sourceEditor?.document.uri; + if (!sourceEditor || !sourceUri || sourceUri.scheme === virtualDocumentScheme) { + void vscode.window.showInformationMessage(vscode.l10n.t("Open a content-mapped source file to show its virtual TypeScript documents.")); + return; + } + + try { + const outputs = await this.loadOutputs(sourceUri); + if (outputs.length === 0) { + this.diagnosticDirectivesView.show(sourceUri, []); + void vscode.window.showInformationMessage(vscode.l10n.t("The active file is not transformed by a TypeScript content mapper.")); + return; + } + const { virtualUris, changes } = this.storeOutputs(sourceUri, outputs); + this.diagnosticDirectivesView.show(sourceUri, outputs); + if (changes.length !== 0) { + this.changeEmitter.fire(changes); + } + const targetColumn = sourceEditor.viewColumn === undefined + ? vscode.ViewColumn.Beside + : sourceEditor.viewColumn + 1; + + for (let index = virtualUris.length - 1; index >= 0; index--) { + const uri = virtualUris[index]!; + const entry = this.entries.get(uri.toString())!; + let document = await vscode.workspace.openTextDocument(uri); + document = await vscode.languages.setTextDocumentLanguage(document, languageIdForScriptKind(entry.output.scriptKind)); + await vscode.window.showTextDocument(document, { + preview: false, + preserveFocus: index !== 0, + viewColumn: targetColumn, + }); + } + this.scheduleInspection(); + } + catch (error) { + this.output.error(`Could not show content mapper virtual documents: ${String(error)}`); + void vscode.window.showInformationMessage(vscode.l10n.t("Content mapper inspector is unavailable: {0}", errorMessage(error))); + } + } + + private loadOutputs(sourceUri: vscode.Uri): Promise { + return this.provider.getContentMapperVirtualFiles(sourceUri); + } + + private updateInspectorEnabledContext(): void { + void vscode.commands.executeCommand( + "setContext", + contentMapperInspectorEnabledContext, + readNativePreviewConfig("showDebugInfo", false), + ); + } + + private updateActiveEditorContext(editor: vscode.TextEditor | undefined): void { + this.clearActiveEditorContextTimer(); + const version = ++this.activeEditorContextVersion; + void this.updateActiveEditorContextNow(editor, version).catch(error => { + this.output.error(`Could not update the active content mapper context: ${String(error)}`); + }); + } + + private scheduleActiveEditorContextUpdate(document: vscode.TextDocument): void { + this.clearActiveEditorContextTimer(); + this.activeEditorContextTimer = setTimeout(() => { + this.activeEditorContextTimer = undefined; + const editor = vscode.window.activeTextEditor; + if (editor?.document === document) { + this.updateActiveEditorContext(editor); + } + }, 100); + } + + private clearActiveEditorContextTimer(): void { + if (this.activeEditorContextTimer) { + clearTimeout(this.activeEditorContextTimer); + this.activeEditorContextTimer = undefined; + } + } + + private async updateActiveEditorContextNow(editor: vscode.TextEditor | undefined, version: number): Promise { + let isContentMapped = false; + if (readNativePreviewConfig("showDebugInfo", false) && editor?.document.uri.scheme === "file") { + try { + isContentMapped = await this.provider.isContentMapped(editor.document.uri); + } + catch (error) { + this.output.debug(`Could not determine whether ${editor.document.uri.toString()} is content-mapped: ${String(error)}`); + } + } + if (version === this.activeEditorContextVersion) { + await vscode.commands.executeCommand("setContext", activeEditorIsContentMappedContext, isContentMapped); + } + } + + private storeOutputs( + sourceUri: vscode.Uri, + outputs: readonly MappedOutput[], + ): { readonly virtualUris: readonly vscode.Uri[]; readonly changes: vscode.FileChangeEvent[]; } { + const sourceKey = sourceUri.toString(); + const previousUris = this.sourceToVirtualUris.get(sourceKey) ?? []; + const nextUris = outputs.map(output => virtualUriForOutput(sourceUri, output)); + const nextKeys = new Set(nextUris.map(uri => uri.toString())); + const changes: vscode.FileChangeEvent[] = []; + + for (const previousUri of previousUris) { + if (!nextKeys.has(previousUri.toString())) { + this.entries.delete(previousUri.toString()); + this.highlightedMappings.delete(previousUri.toString()); + changes.push({ type: vscode.FileChangeType.Deleted, uri: previousUri }); + } + } + + outputs.forEach((mappedOutput, index) => { + const uri = nextUris[index]!; + const existing = this.entries.get(uri.toString()); + let mtime = existing?.mtime ?? Date.now(); + if (existing && existing.output.hash !== mappedOutput.hash) { + mtime = Math.max(Date.now(), existing.mtime + 1); + } + this.entries.set(uri.toString(), { + sourceUri, + output: mappedOutput, + mtime, + }); + if (!existing) { + changes.push({ type: vscode.FileChangeType.Created, uri }); + } + else if (existing.output.hash !== mappedOutput.hash) { + changes.push({ type: vscode.FileChangeType.Changed, uri }); + } + }); + this.sourceToVirtualUris.set(sourceKey, nextUris); + return { virtualUris: nextUris, changes }; + } + + private showDiagnosticDirectivesForEditor(editor: vscode.TextEditor | undefined): vscode.Uri | undefined { + const sourceUri = editor && this.inspectedSourceUri(editor); + if (!sourceUri) { + this.diagnosticDirectivesView.clear(); + return undefined; + } + const outputs = (this.sourceToVirtualUris.get(sourceUri.toString()) ?? []) + .map(uri => this.entries.get(uri.toString())?.output) + .filter(output => output !== undefined); + this.diagnosticDirectivesView.show(sourceUri, outputs); + return sourceUri; + } + + private inspectedSourceUri(editor: vscode.TextEditor): vscode.Uri | undefined { + if (editor.document.uri.scheme === virtualDocumentScheme) { + return this.entries.get(editor.document.uri.toString())?.sourceUri; + } + return this.sourceToVirtualUris.has(editor.document.uri.toString()) ? editor.document.uri : undefined; + } + + private scheduleSourceEviction(closedVirtualUri: vscode.Uri): void { + const entry = this.entries.get(closedVirtualUri.toString()); + const parsed = entry ? undefined : parseVirtualUri(closedVirtualUri); + const sourceUri = entry?.sourceUri ?? parsed?.sourceUri; + if (!sourceUri) { + return; + } + const sourceKey = sourceUri.toString(); + const existing = this.evictionTimers.get(sourceKey); + if (existing) { + clearTimeout(existing); + } + // setTextDocumentLanguage emits a close/open pair, so wait a turn before deciding the URI is unused. + this.evictionTimers.set( + sourceKey, + setTimeout(() => { + this.evictionTimers.delete(sourceKey); + this.evictSourceIfUnused(sourceUri); + }, 0), + ); + } + + private evictSourceIfUnused(sourceUri: vscode.Uri): void { + const sourceKey = sourceUri.toString(); + const virtualUris = this.sourceToVirtualUris.get(sourceKey); + if (!virtualUris) { + return; + } + const virtualKeys = new Set(virtualUris.map(uri => uri.toString())); + const hasOpenVirtualDocument = vscode.workspace.textDocuments.some(document => virtualKeys.has(document.uri.toString())); + if (hasOpenVirtualDocument) { + return; + } + const refreshTimer = this.refreshTimers.get(sourceKey); + if (refreshTimer) { + clearTimeout(refreshTimer); + this.refreshTimers.delete(sourceKey); + } + this.deleteCachedSource(sourceUri); + this.diagnosticDirectivesView.clear(sourceUri); + this.scheduleInspection(); + } + + private deleteCachedSource(sourceUri: vscode.Uri): readonly vscode.Uri[] { + const sourceKey = sourceUri.toString(); + const virtualUris = this.sourceToVirtualUris.get(sourceKey) ?? []; + this.sourceToVirtualUris.delete(sourceKey); + for (const uri of virtualUris) { + const key = uri.toString(); + this.entries.delete(key); + this.highlightedMappings.delete(key); + } + return virtualUris; + } + + private scheduleRefresh(sourceUri: vscode.Uri): void { + const sourceKey = sourceUri.toString(); + if (!this.sourceToVirtualUris.has(sourceKey)) { + return; + } + const existing = this.refreshTimers.get(sourceKey); + if (existing) { + clearTimeout(existing); + } + this.refreshTimers.set( + sourceKey, + setTimeout(() => { + this.refreshTimers.delete(sourceKey); + this.refreshSource(sourceUri); + }, 100), + ); + } + + private refreshSource(sourceUri: vscode.Uri): void { + void this.refresh(sourceUri).catch(error => { + this.output.warn(`Could not refresh ${sourceUri.toString()}: ${String(error)}`); + }); + } + + private async refresh(sourceUri: vscode.Uri): Promise { + const sourceKey = sourceUri.toString(); + const previousUris = this.sourceToVirtualUris.get(sourceKey); + if (!previousUris) { + return; + } + + const outputs = await this.loadOutputs(sourceUri); + // Ignore a response if the source was evicted or replaced while the request was in flight. + if (this.sourceToVirtualUris.get(sourceKey) !== previousUris) { + return; + } + if (outputs.length === 0) { + this.diagnosticDirectivesView.refresh(sourceUri, undefined); + const changes = this.deleteCachedSource(sourceUri) + .map(uri => ({ type: vscode.FileChangeType.Deleted, uri })); + this.changeEmitter.fire(changes); + this.scheduleInspection(); + return; + } + + const { changes } = this.storeOutputs(sourceUri, outputs); + this.diagnosticDirectivesView.refresh(sourceUri, outputs); + if (changes.length !== 0) { + this.changeEmitter.fire(changes); + } + this.scheduleInspection(); + } + + private async revealDiagnosticDirective(node: DiagnosticDirectiveNode): Promise { + const sourceUri = node.sourceUri; + const virtualUri = virtualUriForOutput(sourceUri, node.output); + const entry = await this.getOrCreateEntry(virtualUri); + if (!entry) { + throw new Error(`Could not load virtual document "${node.output.fileName}".`); + } + + const sourceEditor = vscode.window.visibleTextEditors.find( + editor => editor.document.uri.toString() === sourceUri.toString(), + ); + const virtualEditor = vscode.window.visibleTextEditors.find( + editor => editor.document.uri.toString() === virtualUri.toString(), + ); + const inspectorEditor = virtualEditor ?? vscode.window.visibleTextEditors.find(editor => { + if (editor.document.uri.scheme !== virtualDocumentScheme) { + return false; + } + return parseVirtualUri(editor.document.uri)?.sourceUri.toString() === sourceUri.toString(); + }); + const targetColumn = inspectorEditor?.viewColumn + ?? (sourceEditor?.viewColumn === undefined ? vscode.ViewColumn.Beside : sourceEditor.viewColumn + 1); + + try { + const sourceDocument = sourceEditor?.document ?? await vscode.workspace.openTextDocument(sourceUri); + const sourceRange = rangeFromTextRange(sourceDocument, node.directive.originalRange); + await vscode.window.showTextDocument(sourceDocument, { + preserveFocus: true, + preview: false, + selection: sourceRange.isEmpty ? undefined : sourceRange, + viewColumn: sourceEditor?.viewColumn, + }); + } + catch (error) { + this.output.debug(`Could not reveal the diagnostic directive in ${sourceUri.toString()}: ${String(error)}`); + } + + let virtualDocument = await vscode.workspace.openTextDocument(virtualUri); + virtualDocument = await vscode.languages.setTextDocumentLanguage( + virtualDocument, + languageIdForScriptKind(entry.output.scriptKind), + ); + const virtualRange = rangeFromTextRange(virtualDocument, node.directive.virtualRange); + const revealedVirtualEditor = await vscode.window.showTextDocument(virtualDocument, { + preserveFocus: false, + preview: false, + selection: virtualRange.isEmpty ? undefined : virtualRange, + viewColumn: targetColumn, + }); + if (virtualRange.isEmpty) { + this.clearInspection(); + } + else { + revealedVirtualEditor.revealRange(virtualRange, vscode.TextEditorRevealType.InCenterIfOutsideViewport); + this.scheduleInspection(); + } + } + + private scheduleInspection(): void { + if (this.inspectionTimer) { + clearTimeout(this.inspectionTimer); + } + this.inspectionTimer = setTimeout(() => { + this.inspectionTimer = undefined; + this.inspectActiveSelection(); + }, 0); + } + + private inspectActiveSelection(): void { + const editor = vscode.window.activeTextEditor; + if (editor) { + this.inspectSelection(editor); + } + else { + this.clearInspection(); + } + } + + private inspectSelection(editor: vscode.TextEditor): void { + if (editor.document.uri.scheme === virtualDocumentScheme) { + this.inspectVirtualSelection(editor); + } + else { + this.inspectSourceSelection(editor); + } + } + + private inspectVirtualSelection(virtualEditor: vscode.TextEditor): void { + const entry = this.entries.get(virtualEditor.document.uri.toString()); + if (!entry) { + this.clearInspection(); + return; + } + const offset = virtualEditor.document.offsetAt(virtualEditor.selection.active); + void this.diagnosticDirectivesView.revealVirtualRange(entry.sourceUri, entry.output, offset).catch(error => { + this.output.debug(`Could not reveal the selected virtual diagnostic directive: ${String(error)}`); + }); + const sourceEditor = vscode.window.visibleTextEditors.find( + candidate => candidate.document.uri.toString() === entry.sourceUri.toString(), + ); + if (!sourceEditor) { + this.clearInspection(); + return; + } + const mappings = entry.output.mappings.filter( + mapping => containsOffset(mapping.generatedStart, mapping.generatedLength, offset), + ); + this.clearInspection(); + this.decorateMappingPair(sourceEditor, virtualEditor, mappings); + } + + private inspectSourceSelection(sourceEditor: vscode.TextEditor): void { + const virtualUris = this.sourceToVirtualUris.get(sourceEditor.document.uri.toString()); + if (!virtualUris) { + this.clearInspection(); + return; + } + const offset = sourceEditor.document.offsetAt(sourceEditor.selection.active); + void this.diagnosticDirectivesView.revealOriginalRange(sourceEditor.document.uri, offset).catch(error => { + this.output.debug(`Could not reveal the selected original diagnostic directive: ${String(error)}`); + }); + this.clearInspection(); + const sourceRanges: vscode.Range[][] = [[], [], []]; + for (const virtualUri of virtualUris) { + const entry = this.entries.get(virtualUri.toString()); + const virtualEditor = vscode.window.visibleTextEditors.find( + candidate => candidate.document.uri.toString() === virtualUri.toString(), + ); + if (!entry || !virtualEditor) { + continue; + } + const mappings = entry.output.mappings.filter( + mapping => containsOffset(mapping.originalStart, mapping.originalLength, offset), + ); + if (mappings.length === 0) { + continue; + } + const ranges = mappingDecorationRanges(sourceEditor.document, virtualEditor.document, mappings); + for (let kind = 0; kind < this.mappingDecorations.length; kind++) { + sourceRanges[kind]!.push(...ranges.source[kind]!); + virtualEditor.setDecorations(this.mappingDecorations[kind]!, ranges.virtual[kind]!); + } + this.decoratedEditors.add(virtualEditor); + this.highlightedMappings.set(virtualEditor.document.uri.toString(), mappings); + } + for (let kind = 0; kind < this.mappingDecorations.length; kind++) { + sourceEditor.setDecorations(this.mappingDecorations[kind]!, sourceRanges[kind]!); + } + if (sourceRanges.some(ranges => ranges.length !== 0)) { + this.decoratedEditors.add(sourceEditor); + } + } + + private decorateMappingPair( + sourceEditor: vscode.TextEditor, + virtualEditor: vscode.TextEditor, + mappings: readonly ContentMapperVirtualSpan[], + ): void { + if (mappings.length === 0) { + return; + } + + const ranges = mappingDecorationRanges(sourceEditor.document, virtualEditor.document, mappings); + for (let kind = 0; kind < this.mappingDecorations.length; kind++) { + sourceEditor.setDecorations(this.mappingDecorations[kind]!, ranges.source[kind]!); + virtualEditor.setDecorations(this.mappingDecorations[kind]!, ranges.virtual[kind]!); + } + this.decoratedEditors.add(sourceEditor); + this.decoratedEditors.add(virtualEditor); + this.highlightedMappings.set(virtualEditor.document.uri.toString(), mappings); + } + + private clearInspection(): void { + for (const editor of this.decoratedEditors) { + for (const decoration of this.mappingDecorations) { + editor.setDecorations(decoration, []); + } + } + this.decoratedEditors.clear(); + this.highlightedMappings.clear(); + } + + private provideMappingHover(document: vscode.TextDocument, position: vscode.Position): vscode.Hover | undefined { + const highlighted = this.highlightedMappings.get(document.uri.toString()); + if (!highlighted) { + return undefined; + } + const offset = document.offsetAt(position); + const mappings = highlighted.filter(mapping => containsOffset(mapping.generatedStart, mapping.generatedLength, offset)); + if (mappings.length === 0) { + return undefined; + } + + const contents = new vscode.MarkdownString(); + mappings.forEach((mapping, index) => { + if (index !== 0) { + contents.appendMarkdown("\n\n---\n\n"); + } + contents.appendMarkdown(`**${vscode.l10n.t("Span kind: {0}", mappingKindName(mapping.kind))}**`); + const features = featureNames(mapping.features); + contents.appendMarkdown(`\n\n${vscode.l10n.t("Features: {0}", features.join(", ") || vscode.l10n.t("None"))}`); + }); + const first = mappings[0]!; + return new vscode.Hover(contents, rangeFromOffsets(document, first.generatedStart, first.generatedLength)); + } +} + +function containsOffset(start: number, length: number, offset: number): boolean { + return length === 0 ? offset === start : start <= offset && offset < start + length; +} + +function rangeFromOffsets(document: vscode.TextDocument, start: number, length: number): vscode.Range { + return new vscode.Range(document.positionAt(start), document.positionAt(start + length)); +} + +function rangeFromTextRange(document: vscode.TextDocument, range: ContentMapperTextRange): vscode.Range { + return new vscode.Range(document.positionAt(range.pos), document.positionAt(range.end)); +} + +function mappingDecorationRanges( + source: vscode.TextDocument, + virtual: vscode.TextDocument, + mappings: readonly ContentMapperVirtualSpan[], +): { readonly source: vscode.Range[][]; readonly virtual: vscode.Range[][]; } { + const ranges = { + source: [[], [], []] as vscode.Range[][], + virtual: [[], [], []] as vscode.Range[][], + }; + for (const mapping of mappings) { + const kind = normalizedMappingKind(mapping.kind); + if (mapping.originalLength !== 0) { + ranges.source[kind]!.push(rangeFromOffsets(source, mapping.originalStart, mapping.originalLength)); + } + if (mapping.generatedLength !== 0) { + ranges.virtual[kind]!.push(rangeFromOffsets(virtual, mapping.generatedStart, mapping.generatedLength)); + } + } + return ranges; +} + +function normalizedMappingKind(kind: number): number { + return kind >= 0 && kind <= 2 ? kind : 1; +} + +function mappingKindName(kind: number): string { + switch (kind) { + case 0: + return vscode.l10n.t("Verbatim"); + case 1: + return vscode.l10n.t("Atom"); + case 2: + return vscode.l10n.t("Alias"); + default: + return vscode.l10n.t("Unknown ({0})", kind); + } +} + +// Keep this in the same bit order as spanmap.Feature in the server. +const featureLabels = [ + () => vscode.l10n.t("Hover"), + () => vscode.l10n.t("Signature Help"), + () => vscode.l10n.t("Completion"), + () => vscode.l10n.t("Definition"), + () => vscode.l10n.t("Type Definition"), + () => vscode.l10n.t("Implementation"), + () => vscode.l10n.t("References"), + () => vscode.l10n.t("Document Highlights"), + () => vscode.l10n.t("Rename"), + () => vscode.l10n.t("Call Hierarchy"), + () => vscode.l10n.t("Code Actions"), + () => vscode.l10n.t("Formatting"), + () => vscode.l10n.t("Inlay Hints"), + () => vscode.l10n.t("Semantic Tokens"), + () => vscode.l10n.t("Folding Ranges"), + () => vscode.l10n.t("Selection Ranges"), + () => vscode.l10n.t("Linked Editing"), + () => vscode.l10n.t("Auto Insert"), + () => vscode.l10n.t("Document Symbols"), + () => vscode.l10n.t("CodeLens"), +] as const; + +function featureNames(features: number): string[] { + return featureLabels + .filter((_, index) => (features & (1 << index)) !== 0) + .map(label => label()); +} + +function virtualUriForOutput(sourceUri: vscode.Uri, output: MappedOutput): vscode.Uri { + return vscode.Uri.from({ + scheme: virtualDocumentScheme, + path: `/${virtualFileName(output)}`, + query: new URLSearchParams({ + source: sourceUri.toString(), + output: output.key, + }).toString(), + }); +} + +function virtualFileName(output: MappedOutput): string { + const fileName = path.basename(output.fileName); + const extension = extensionForScriptKind(output.scriptKind); + return fileName.toLowerCase().endsWith(extension) ? fileName : fileName + extension; +} + +function parseVirtualUri(uri: vscode.Uri): { readonly sourceUri: vscode.Uri; readonly outputKey: string; } | undefined { + const params = new URLSearchParams(uri.query); + const source = params.get("source"); + const outputKey = params.get("output"); + return source && outputKey ? { sourceUri: vscode.Uri.parse(source), outputKey } : undefined; +} + +function extensionForScriptKind(scriptKind: number): string { + switch (scriptKind) { + case 1: + return ".js"; + case 2: + return ".jsx"; + case 4: + return ".tsx"; + case 6: + return ".json"; + default: + return ".ts"; + } +} + +function languageIdForScriptKind(scriptKind: number): string { + switch (scriptKind) { + case 1: + return "javascript"; + case 2: + return "javascriptreact"; + case 4: + return "typescriptreact"; + case 6: + return "json"; + default: + return "typescript"; + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/vscode-typescript/src/contentMapperVirtualFiles.ts b/packages/vscode-typescript/src/contentMapperVirtualFiles.ts new file mode 100644 index 0000000000000..ea92bc5a35df5 --- /dev/null +++ b/packages/vscode-typescript/src/contentMapperVirtualFiles.ts @@ -0,0 +1,75 @@ +export interface ContentMapperTextRange { + readonly pos: number; + readonly end: number; +} + +export interface ContentMapperDiagnosticDirective { + readonly originalRange: ContentMapperTextRange; + readonly virtualRange: ContentMapperTextRange; + readonly policy: number; + readonly unusedCode: number; +} + +export interface ContentMapperVirtualSpan { + readonly generatedStart: number; + readonly generatedLength: number; + readonly originalStart: number; + readonly originalLength: number; + readonly kind: number; + readonly features: number; +} + +export interface ContentMapperVirtualFile { + readonly fileName: string; + readonly hash: string; + readonly text: string; + readonly originalText: string; + readonly scriptKind: number; + readonly mappings: readonly ContentMapperVirtualSpan[]; + readonly diagnosticDirectives: readonly ContentMapperDiagnosticDirective[]; +} + +export interface MappedOutput extends ContentMapperVirtualFile { + readonly key: string; +} + +export function containsNonEmptyTextRange(range: ContentMapperTextRange, offset: number): boolean { + return range.pos < range.end && range.pos <= offset && offset < range.end; +} + +export function textRangePreview(text: string, range: ContentMapperTextRange, maxLength = 80): string { + const preview = text.slice(range.pos, range.end).trim().replace(/\s+/g, " "); + const characters = [...preview]; + if (characters.length <= maxLength) { + return preview; + } + return `${characters.slice(0, Math.max(0, maxLength - 3)).join("")}...`; +} + +export function textPositionAt(text: string, offset: number): { readonly line: number; readonly character: number; } { + const limit = Math.min(Math.max(offset, 0), text.length); + let line = 0; + let lineStart = 0; + for (let index = 0; index < limit; index++) { + const character = text.charCodeAt(index); + if (character === 13) { + if (index + 1 < limit && text.charCodeAt(index + 1) === 10) { + index++; + } + line++; + lineStart = index + 1; + } + else if (character === 10) { + line++; + lineStart = index + 1; + } + } + return { line, character: limit - lineStart }; +} + +export function toMappedOutputs(files: readonly ContentMapperVirtualFile[]): readonly MappedOutput[] { + return files.map((file, index) => ({ + ...file, + key: String(index), + })); +} diff --git a/packages/vscode-typescript/src/diagnosticDirectivesView.ts b/packages/vscode-typescript/src/diagnosticDirectivesView.ts new file mode 100644 index 0000000000000..131752f661c86 --- /dev/null +++ b/packages/vscode-typescript/src/diagnosticDirectivesView.ts @@ -0,0 +1,253 @@ +import * as path from "node:path"; +import * as vscode from "vscode"; + +import type { + ContentMapperDiagnosticDirective, + ContentMapperTextRange, + MappedOutput, +} from "./contentMapperVirtualFiles"; +import { + containsNonEmptyTextRange, + textPositionAt, + textRangePreview, +} from "./contentMapperVirtualFiles"; + +const revealDiagnosticDirectiveCommand = "typescript.native-preview.revealContentMapperDiagnosticDirective"; +const diagnosticDirectivesViewId = "typescript.native-preview.contentMapperDiagnosticDirectives"; + +interface OutputNode { + readonly kind: "output"; + readonly output: MappedOutput; + readonly directives: readonly DiagnosticDirectiveNode[]; +} + +export interface DiagnosticDirectiveNode { + readonly kind: "directive"; + readonly sourceUri: vscode.Uri; + readonly output: MappedOutput; + readonly directive: ContentMapperDiagnosticDirective; +} + +type DirectiveTreeNode = OutputNode | DiagnosticDirectiveNode; + +export class DiagnosticDirectivesView implements vscode.TreeDataProvider, vscode.Disposable { + private readonly changeEmitter = new vscode.EventEmitter(); + private readonly treeView: vscode.TreeView; + private readonly disposables: vscode.Disposable[] = []; + private sourceUri: vscode.Uri | undefined; + private outputNodes: readonly OutputNode[] = []; + private selectedDirective: DiagnosticDirectiveNode | undefined; + + readonly onDidChangeTreeData = this.changeEmitter.event; + + constructor(reveal: (node: DiagnosticDirectiveNode) => void | Promise) { + this.treeView = vscode.window.createTreeView(diagnosticDirectivesViewId, { + treeDataProvider: this, + showCollapseAll: true, + }); + this.treeView.message = vscode.l10n.t("Open virtual documents to inspect diagnostic directives."); + const revealCommand = vscode.commands.registerCommand(revealDiagnosticDirectiveCommand, reveal); + this.treeView.onDidChangeVisibility(event => { + if (event.visible && this.sourceUri) { + this.changeEmitter.fire(undefined); + } + }); + this.disposables.push(this.changeEmitter, this.treeView, revealCommand); + } + + show(sourceUri: vscode.Uri, outputs: readonly MappedOutput[]): void { + this.sourceUri = sourceUri; + this.setOutputs(sourceUri, outputs); + this.updateMessage(); + this.changeEmitter.fire(undefined); + } + + refresh(sourceUri: vscode.Uri, outputs: readonly MappedOutput[] | undefined): void { + if (sourceUri.toString() !== this.sourceUri?.toString()) { + return; + } + this.setOutputs(sourceUri, outputs ?? []); + this.updateMessage(); + this.changeEmitter.fire(undefined); + } + + clear(sourceUri?: vscode.Uri): void { + if (sourceUri && sourceUri.toString() !== this.sourceUri?.toString()) { + return; + } + this.sourceUri = undefined; + this.outputNodes = []; + this.selectedDirective = undefined; + this.treeView.description = undefined; + this.treeView.message = vscode.l10n.t("Open virtual documents to inspect diagnostic directives."); + this.changeEmitter.fire(undefined); + } + + getTreeItem(node: DirectiveTreeNode): vscode.TreeItem { + if (node.kind === "output") { + const count = node.output.diagnosticDirectives.length; + const item = new vscode.TreeItem( + path.basename(node.output.fileName), + vscode.TreeItemCollapsibleState.Expanded, + ); + item.description = count === 1 + ? vscode.l10n.t("{0} directive", count) + : vscode.l10n.t("{0} directives", count); + item.iconPath = new vscode.ThemeIcon("file-code"); + return item; + } + + const { directive, output } = node; + const policy = diagnosticDirectivePolicyName(directive.policy); + const preview = textRangePreview(output.text, directive.virtualRange); + const item = new vscode.TreeItem(policy, vscode.TreeItemCollapsibleState.None); + const positions = []; + if (!isEmptyRange(directive.originalRange)) { + positions.push(formatPosition(positionAt(output.originalText, directive.originalRange.pos))); + } + positions.push(formatPosition(positionAt(output.text, directive.virtualRange.pos))); + item.description = [positions.join(" \u2192 "), preview].filter(Boolean).join(" "); + item.command = { + command: revealDiagnosticDirectiveCommand, + title: vscode.l10n.t("Reveal Diagnostic Directive"), + arguments: [node], + }; + item.tooltip = directiveTooltip(node); + return item; + } + + getChildren(node?: DirectiveTreeNode): DirectiveTreeNode[] { + if (!node) { + return [...this.outputNodes]; + } + if (node.kind === "output") { + return [...node.directives]; + } + return []; + } + + getParent(node: DirectiveTreeNode): OutputNode | undefined { + if (node.kind === "directive") { + return this.outputNodes.find(outputNode => outputNode.output === node.output); + } + return undefined; + } + + revealOriginalRange(sourceUri: vscode.Uri, offset: number): Promise { + if (sourceUri.toString() !== this.sourceUri?.toString()) { + return Promise.resolve(); + } + return this.revealMatchingDirective( + this.outputNodes.flatMap(node => node.directives), + node => containsNonEmptyTextRange(node.directive.originalRange, offset), + ); + } + + revealVirtualRange(sourceUri: vscode.Uri, output: MappedOutput, offset: number): Promise { + if (sourceUri.toString() !== this.sourceUri?.toString()) { + return Promise.resolve(); + } + return this.revealMatchingDirective( + this.outputNodes.find(node => node.output === output)?.directives ?? [], + node => containsNonEmptyTextRange(node.directive.virtualRange, offset), + ); + } + + dispose(): void { + for (const disposable of this.disposables.splice(0)) { + disposable.dispose(); + } + } + + private updateMessage(): void { + const directiveCount = this.outputNodes.reduce( + (count, output) => count + output.directives.length, + 0, + ); + this.treeView.description = directiveCount === 0 ? undefined : String(directiveCount); + this.treeView.message = directiveCount === 0 + ? vscode.l10n.t("The current content-mapped file has no diagnostic directives.") + : undefined; + } + + private setOutputs(sourceUri: vscode.Uri, outputs: readonly MappedOutput[]): void { + // TreeView.reveal requires stable element instances and parent links. + this.outputNodes = outputs + .filter(output => output.diagnosticDirectives.length !== 0) + .map(output => ({ + kind: "output", + output, + directives: output.diagnosticDirectives.map(directive => ({ + kind: "directive", + sourceUri, + output, + directive, + })), + })); + this.selectedDirective = undefined; + } + + private revealMatchingDirective( + nodes: readonly DiagnosticDirectiveNode[], + matches: (node: DiagnosticDirectiveNode) => boolean, + ): Promise { + const node = nodes.find(matches); + if (!node || node === this.selectedDirective) { + this.selectedDirective = node; + return Promise.resolve(); + } + this.selectedDirective = node; + return Promise.resolve(this.treeView.reveal(node, { + select: true, + focus: false, + expand: true, + })); + } +} + +function directiveTooltip(node: DiagnosticDirectiveNode): vscode.MarkdownString { + const { directive, output } = node; + const tooltip = new vscode.MarkdownString(); + tooltip.appendMarkdown(`**${diagnosticDirectivePolicyName(directive.policy)}** in \`${path.basename(output.fileName)}\``); + if (!isEmptyRange(directive.originalRange)) { + tooltip.appendMarkdown(`\n\n${vscode.l10n.t("Original range: {0}", formatRange(output.originalText, directive.originalRange))}`); + } + tooltip.appendMarkdown(`\n\n${vscode.l10n.t("Virtual range: {0}", formatRange(output.text, directive.virtualRange))}`); + const preview = textRangePreview(output.text, directive.virtualRange); + if (preview) { + tooltip.appendMarkdown(`\n\n${vscode.l10n.t("Preview:")}`); + tooltip.appendCodeblock(preview); + } + if (directive.policy === 1) { + tooltip.appendMarkdown(`\n\n${vscode.l10n.t("Unused diagnostic code: {0}", directive.unusedCode)}`); + } + return tooltip; +} + +function diagnosticDirectivePolicyName(policy: number): string { + switch (policy) { + case 0: + return vscode.l10n.t("Ignore"); + case 1: + return vscode.l10n.t("Expect"); + default: + return vscode.l10n.t("Unknown ({0})", policy); + } +} + +function formatRange(text: string, range: ContentMapperTextRange): string { + return `${formatPosition(positionAt(text, range.pos))}\u2013${formatPosition(positionAt(text, range.end))}`; +} + +function isEmptyRange(range: ContentMapperTextRange): boolean { + return range.pos === range.end; +} + +function formatPosition(position: vscode.Position): string { + return `${position.line + 1}:${position.character + 1}`; +} + +function positionAt(text: string, offset: number): vscode.Position { + const position = textPositionAt(text, offset); + return new vscode.Position(position.line, position.character); +} diff --git a/packages/vscode-typescript/src/extension.ts b/packages/vscode-typescript/src/extension.ts index 7bff6514e4397..c4fb72e226020 100644 --- a/packages/vscode-typescript/src/extension.ts +++ b/packages/vscode-typescript/src/extension.ts @@ -5,6 +5,7 @@ import { updateUseTsgoSetting, } from "./commands"; import type { ContentMapperContribution } from "./contentMapperContributions"; +import { registerContentMapperVirtualDocumentProvider } from "./contentMapperVirtualDocuments"; import { aiConnectionString, getExplicitConfigTarget, @@ -51,7 +52,10 @@ export async function activate(context: vscode.ExtensionContext): Promise sessionManager.stop()); let pluginWarningShown = false; diff --git a/packages/vscode-typescript/src/session.ts b/packages/vscode-typescript/src/session.ts index 46303606b836f..17182d0859188 100644 --- a/packages/vscode-typescript/src/session.ts +++ b/packages/vscode-typescript/src/session.ts @@ -12,6 +12,7 @@ import { serializeContentMapperContributions, validateContentMapperRegistration, } from "./contentMapperContributions"; +import type { MappedOutput } from "./contentMapperVirtualFiles"; import { ProjectStatus } from "./projectStatus"; import { setupStatusBar } from "./statusBar"; import { TelemetryReporter } from "./telemetryReporting"; @@ -37,11 +38,15 @@ export class SessionManager implements vscode.Disposable { private disposables: vscode.Disposable[] = []; private outputChannel: vscode.LogOutputChannel; private initializedEventEmitter: vscode.EventEmitter; + private contentMapperContributionsSynchronizedEmitter = new vscode.EventEmitter(); private telemetryReporter: TelemetryReporter; private readonly contentMapperRegistrations = new Map(); private lifecycleOperation = Promise.resolve(); private contentMapperSyncOperation = Promise.resolve(); + readonly onDidInitializeLanguageServer: vscode.Event; + readonly onDidSynchronizeContentMapperContributions = this.contentMapperContributionsSynchronizedEmitter.event; + constructor( context: vscode.ExtensionContext, outputChannel: vscode.LogOutputChannel, @@ -51,6 +56,7 @@ export class SessionManager implements vscode.Disposable { this.outputChannel = outputChannel; this.telemetryReporter = telemetryReporter; this.initializedEventEmitter = initializedEventEmitter; + this.onDidInitializeLanguageServer = initializedEventEmitter.event; this.disposables.push(vscode.workspace.onDidChangeConfiguration(event => { if (this.currentSession && event.affectsConfiguration("js/ts.contentMappers.enabled")) { @@ -59,6 +65,7 @@ export class SessionManager implements vscode.Disposable { }); } })); + this.disposables.push(this.contentMapperContributionsSynchronizedEmitter); this.disposables.push(vscode.workspace.onDidOpenTextDocument(document => { if (documentMatchesContentMapperContributions(document, this.contentMapperRegistrations)) { void this.syncContentMapperContributions(); @@ -114,6 +121,19 @@ export class SessionManager implements vscode.Disposable { return result.pipe; } + async getContentMapperVirtualFiles(uri: vscode.Uri): Promise { + // Restored virtual documents can be requested while extension activation is still starting the server. + await this.lifecycleOperation; + if (!this.currentSession?.client.isInitialized) { + throw new Error(vscode.l10n.t("Language server is not running.")); + } + return this.currentSession.client.getContentMapperVirtualFiles(uri); + } + + isContentMapped(uri: vscode.Uri): Promise { + return this.currentSession?.client.isContentMapped(uri) ?? Promise.resolve(false); + } + registerContentMappers(contributorId: string, contributions: readonly ContentMapperContribution[]): vscode.Disposable { validateContentMapperRegistration(contributorId, contributions); if (this.contentMapperRegistrations.has(contributorId)) { @@ -146,6 +166,7 @@ export class SessionManager implements vscode.Disposable { serializeContentMapperContributions(this.contentMapperRegistrations), openDocuments, ); + this.contentMapperContributionsSynchronizedEmitter.fire(); } catch (error) { this.outputChannel.warn(`Content mapper contribution synchronization failed: ${String(error)}`); diff --git a/packages/vscode-typescript/test/contentMapperVirtualFiles.test.ts b/packages/vscode-typescript/test/contentMapperVirtualFiles.test.ts new file mode 100644 index 0000000000000..8caa5ea13d8e0 --- /dev/null +++ b/packages/vscode-typescript/test/contentMapperVirtualFiles.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + containsNonEmptyTextRange, + textPositionAt, + textRangePreview, + toMappedOutputs, +} from "../src/contentMapperVirtualFiles"; + +test("matches offsets only inside non-empty text ranges", () => { + assert.equal(containsNonEmptyTextRange({ pos: 2, end: 5 }, 2), true); + assert.equal(containsNonEmptyTextRange({ pos: 2, end: 5 }, 4), true); + assert.equal(containsNonEmptyTextRange({ pos: 2, end: 5 }, 5), false); + assert.equal(containsNonEmptyTextRange({ pos: 2, end: 2 }, 2), false); +}); + +test("previews virtual ranges with normalized whitespace and safe truncation", () => { + assert.equal( + textRangePreview("before first\n second after", { pos: 6, end: 23 }), + "first second", + ); + assert.equal(textRangePreview("A😀BCDE", { pos: 0, end: 7 }, 5), "A😀..."); +}); + +test("computes positions for LF, CRLF, and lone CR line endings", () => { + const text = "a\rb\r\nc\nd"; + assert.deepEqual(textPositionAt(text, 2), { line: 1, character: 0 }); + assert.deepEqual(textPositionAt(text, 5), { line: 2, character: 0 }); + assert.deepEqual(textPositionAt(text, 7), { line: 3, character: 0 }); +}); + +test("adds stable output keys to content mapper virtual files", () => { + const files = [{ + fileName: "/component.vue.ts", + hash: "1234", + text: "export {}", + originalText: "` + const box = `// 💥 +// @box-expect-error: unused +const café = 1;` + const missingOriginalRange = `// @box-invalid-directive: original-range-out-of-bounds +const value = 1;` files := map[string]string{ "/home/project/tsconfig.json": `{ "compilerOptions": { "target": "es2020", "module": "esnext", "moduleResolution": "bundler", "strict": true }, - "contentMappers": [ { "package": "mapper", "extensions": [".vue"] } ] + "contentMappers": [ + { "package": "mapper", "extensions": [".vue"] }, + { "package": "box-mapper", "extensions": [".box"] } + ] }`, - "/home/project/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.ComponentMapper), - "/home/project/ProfileCard.vue": component, + "/home/project/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.ComponentMapper), + "/home/project/node_modules/box-mapper/package.json": strings.Replace(contentmappertest.PackageJSON(contentmappertest.TransformingMapper), `"name": "mapper"`, `"name": "box-mapper"`, 1), + "/home/project/ProfileCard.vue": component, + "/home/project/example.box": box, + "/home/project/missing-original.box": missingOriginalRange, } var mu sync.Mutex @@ -156,14 +168,24 @@ export const title = "Profile"; if registration.Id == "content-mapper-did-open" { assert.Assert(t, registration.RegisterOptions != nil && registration.RegisterOptions.TextDocumentDidOpen != nil) selector := registration.RegisterOptions.TextDocumentDidOpen.DocumentSelector.DocumentSelector - assert.Assert(t, selector != nil && len(*selector) == 1) - assert.Equal(t, *(*selector)[0].Pattern.Pattern.Pattern, "**/*.vue") + assert.Assert(t, selector != nil && len(*selector) == 2) + patterns := map[string]bool{} + for _, filter := range *selector { + patterns[*filter.Pattern.Pattern.Pattern] = true + } + assert.Assert(t, patterns["**/*.vue"]) + assert.Assert(t, patterns["**/*.box"]) } if registration.Id == "content-mapper-semantic-tokens" { assert.Assert(t, registration.RegisterOptions != nil && registration.RegisterOptions.TextDocumentSemanticTokens != nil) selector := registration.RegisterOptions.TextDocumentSemanticTokens.DocumentSelector.DocumentSelector - assert.Assert(t, selector != nil && len(*selector) == 1) - assert.Equal(t, *(*selector)[0].Pattern.Pattern.Pattern, "**/*.vue") + assert.Assert(t, selector != nil && len(*selector) == 2) + patterns := map[string]bool{} + for _, filter := range *selector { + patterns[*filter.Pattern.Pattern.Pattern] = true + } + assert.Assert(t, patterns["**/*.vue"]) + assert.Assert(t, patterns["**/*.box"]) } if registration.Id == "content-mapper-code-action" { assert.Assert(t, registration.RegisterOptions != nil && registration.RegisterOptions.TextDocumentCodeAction != nil) @@ -184,6 +206,66 @@ export const title = "Profile"; assert.Assert(t, ok && hoverMsg.AsResponse().Error == nil) assert.Assert(t, hover.Hover != nil, "expected hover after first foreign didOpen") + isContentMappedMsg, isContentMapped, ok := lsptestutil.SendRequest(t, client, lsproto.CustomIsContentMappedInfo, &lsproto.IsContentMappedParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: uri}, + }) + assert.Assert(t, ok && isContentMappedMsg.AsResponse().Error == nil) + assert.Assert(t, isContentMapped.IsContentMapped) + + virtualFilesMsg, virtualFiles, ok := lsptestutil.SendRequest(t, client, lsproto.CustomContentMapperVirtualFilesInfo, &lsproto.ContentMapperVirtualFilesParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: uri}, + }) + assert.Assert(t, ok && virtualFilesMsg.AsResponse().Error == nil) + assert.Equal(t, len(virtualFiles.Files), 1) + assert.Equal(t, virtualFiles.Files[0].FileName, "/home/project/ProfileCard.vue.ts") + assert.Assert(t, virtualFiles.Files[0].Hash != "") + assert.Equal(t, virtualFiles.Files[0].ScriptKind, int32(3)) + assert.Assert(t, strings.Contains(virtualFiles.Files[0].Text, `export const title = "Profile";`)) + assert.Assert(t, len(virtualFiles.Files[0].Mappings) > 0) + assert.Equal(t, virtualFiles.Files[0].Mappings[0].Kind, int32(0)) + assert.Equal(t, virtualFiles.Files[0].Mappings[0].Features, int32((1<<20)-1)) + + boxURI := lsproto.DocumentUri("file:///home/project/example.box") + lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{ + TextDocument: &lsproto.TextDocumentItem{Uri: boxURI, LanguageId: "box", Version: 1, Text: box}, + }) + _, boxVirtualFiles, ok := lsptestutil.SendRequest(t, client, lsproto.CustomContentMapperVirtualFilesInfo, &lsproto.ContentMapperVirtualFilesParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: boxURI}, + }) + assert.Assert(t, ok) + assert.Equal(t, len(boxVirtualFiles.Files), 1) + boxVirtualFile := boxVirtualFiles.Files[0] + assert.Equal(t, len(boxVirtualFile.Mappings), 1) + mappedTextStart := strings.Index(boxVirtualFile.Text, "// 💥") + assert.Assert(t, mappedTextStart >= 0) + assert.Equal(t, boxVirtualFile.Mappings[0].GeneratedStart, int32(utf16Length(boxVirtualFile.Text[:mappedTextStart]))) + assert.Equal(t, boxVirtualFile.Mappings[0].GeneratedLength, int32(utf16Length(box))) + assert.Equal(t, boxVirtualFile.Mappings[0].OriginalLength, int32(utf16Length(box))) + assert.Equal(t, len(boxVirtualFile.DiagnosticDirectives), 1) + directive := boxVirtualFile.DiagnosticDirectives[0] + directiveStart := strings.Index(box, "// @box-expect-error") + directiveEnd := directiveStart + strings.IndexByte(box[directiveStart:], '\n') + assert.Equal(t, directive.OriginalRange.Pos, int32(utf16Length(box[:directiveStart]))) + assert.Equal(t, directive.OriginalRange.End, int32(utf16Length(box[:directiveEnd]))) + affectedText := "const café = 1;" + affectedStart := strings.Index(boxVirtualFile.Text, affectedText) + assert.Assert(t, affectedStart >= 0) + assert.Equal(t, directive.VirtualRange.Pos, int32(utf16Length(boxVirtualFile.Text[:affectedStart]))) + assert.Equal(t, directive.VirtualRange.End, int32(utf16Length(boxVirtualFile.Text[:affectedStart+len(affectedText)]))) + + missingOriginalURI := lsproto.DocumentUri("file:///home/project/missing-original.box") + lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{ + TextDocument: &lsproto.TextDocumentItem{Uri: missingOriginalURI, LanguageId: "box", Version: 1, Text: missingOriginalRange}, + }) + _, missingOriginalVirtualFiles, ok := lsptestutil.SendRequest(t, client, lsproto.CustomContentMapperVirtualFilesInfo, &lsproto.ContentMapperVirtualFilesParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: missingOriginalURI}, + }) + assert.Assert(t, ok) + assert.Equal(t, len(missingOriginalVirtualFiles.Files), 1) + assert.Equal(t, len(missingOriginalVirtualFiles.Files[0].DiagnosticDirectives), 1) + assert.Equal(t, missingOriginalVirtualFiles.Files[0].DiagnosticDirectives[0].OriginalRange.Pos, int32(0)) + assert.Equal(t, missingOriginalVirtualFiles.Files[0].DiagnosticDirectives[0].OriginalRange.End, int32(0)) + assert.NilError(t, fs.WriteFile("/home/project/tsconfig.json", `{ "compilerOptions": { "target": "es2020", "module": "esnext", "moduleResolution": "bundler", "strict": true } }`)) @@ -196,6 +278,16 @@ export const title = "Profile"; }) assert.Assert(t, hoverMsg != nil && hoverMsg.AsResponse().Error == nil, "request before didClose should return a null result") assert.Assert(t, hover.Hover == nil) + isContentMappedMsg, isContentMapped, ok = lsptestutil.SendRequest(t, client, lsproto.CustomIsContentMappedInfo, &lsproto.IsContentMappedParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: uri}, + }) + assert.Assert(t, ok && isContentMappedMsg.AsResponse().Error == nil) + assert.Assert(t, !isContentMapped.IsContentMapped) + virtualFilesMsg, virtualFiles, ok = lsptestutil.SendRequest(t, client, lsproto.CustomContentMapperVirtualFilesInfo, &lsproto.ContentMapperVirtualFilesParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: uri}, + }) + assert.Assert(t, ok && virtualFilesMsg.AsResponse().Error == nil) + assert.Equal(t, len(virtualFiles.Files), 0) diagnosticMsg, diagnostics, ok := lsptestutil.SendRequest(t, client, lsproto.TextDocumentDiagnosticInfo, &lsproto.DocumentDiagnosticParams{ TextDocument: lsproto.TextDocumentIdentifier{Uri: uri}, }) @@ -245,4 +337,14 @@ export const title = "Profile"; lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidCloseInfo, &lsproto.DidCloseTextDocumentParams{ TextDocument: lsproto.TextDocumentIdentifier{Uri: uri}, }) + lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidCloseInfo, &lsproto.DidCloseTextDocumentParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: boxURI}, + }) + lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidCloseInfo, &lsproto.DidCloseTextDocumentParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: missingOriginalURI}, + }) +} + +func utf16Length(text string) int { + return len(utf16.Encode([]rune(text))) }