diff --git a/packages/vscode-typescript/l10n/bundle.l10n.json b/packages/vscode-typescript/l10n/bundle.l10n.json index d33a2666e490f..416e09129368d 100644 --- a/packages/vscode-typescript/l10n/bundle.l10n.json +++ b/packages/vscode-typescript/l10n/bundle.l10n.json @@ -65,6 +65,46 @@ "{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}", + "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..a5ab6e53276b2 100644 --- a/packages/vscode-typescript/package.json +++ b/packages/vscode-typescript/package.json @@ -224,6 +224,16 @@ } } ], + "views": { + "explorer": [ + { + "id": "typescript.native-preview.contentMapperDiagnosticDirectives", + "name": "%native-preview.contentMapperDiagnosticDirectives.name%", + "icon": "$(list-tree)", + "when": "typescript.native-preview.serverRunning && config.js/ts.showDebugInfo" + } + ] + }, "commands": [ { "command": "typescript.native-preview.enable", @@ -267,6 +277,12 @@ "enablement": "typescript.native-preview.serverRunning", "category": "TypeScript" }, + { + "command": "typescript.native-preview.showContentMapperVirtualDocuments", + "title": "%native-preview.showContentMapperVirtualDocuments.title%", + "enablement": "typescript.native-preview.serverRunning && config.js/ts.showDebugInfo && typescript.native-preview.activeEditorIsContentMapped", + "category": "TypeScript" + }, { "command": "typescript.native-preview.sortImports", "title": "%native-preview.sortImports.title%", @@ -339,6 +355,10 @@ "command": "typescript.native-preview.goToSourceDefinition", "when": "typescript.native-preview.serverRunning && tsSupportsSourceDefinition" }, + { + "command": "typescript.native-preview.showContentMapperVirtualDocuments", + "when": "typescript.native-preview.serverRunning && config.js/ts.showDebugInfo && 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 +374,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 && config.js/ts.showDebugInfo && 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..17fcfe0d7bf66 --- /dev/null +++ b/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts @@ -0,0 +1,707 @@ +import * as path from "node:path"; +import * as vscode from "vscode"; + +import type { + ContentMapperTextRange, + ContentMapperVirtualSpan, + MappedOutput, +} from "./contentMapperVirtualFiles"; +import { + type DiagnosticDirectiveNode, + DiagnosticDirectivesView, +} from "./diagnosticDirectivesView"; + +const virtualDocumentScheme = "typescript-content-mapper"; +const activeEditorIsContentMappedContext = "typescript.native-preview.activeEditorIsContentMapped"; +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 inspectionTimer: 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); + } + }), + vscode.workspace.onDidSaveTextDocument(document => this.scheduleRefresh(document.uri)), + vscode.languages.onDidChangeDiagnostics(event => { + for (const uri of event.uris) { + this.scheduleRefresh(uri); + } + }), + vscode.window.onDidChangeActiveTextEditor(editor => { + this.updateActiveEditorContext(editor); + if (editor?.document.uri.scheme === virtualDocumentScheme) { + const entry = this.entries.get(editor.document.uri.toString()); + if (entry) { + this.refreshSource(entry.sourceUri); + } + } + this.scheduleInspection(); + }), + vscode.window.onDidChangeTextEditorSelection(event => { + if (event.textEditor === vscode.window.activeTextEditor) { + this.scheduleInspection(); + } + }), + vscode.window.onDidChangeVisibleTextEditors(() => this.scheduleInspection()), + 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.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); + for (const timer of this.refreshTimers.values()) { + clearTimeout(timer); + } + this.refreshTimers.clear(); + if (this.inspectionTimer) { + clearTimeout(this.inspectionTimer); + this.inspectionTimer = undefined; + } + 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.remember(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 previousUris = this.sourceToVirtualUris.get(sourceUri.toString()) ?? []; + const previousEntries = new Map(previousUris.map( + uri => [uri.toString(), this.entries.get(uri.toString())] as const, + )); + const virtualUris = this.remember(sourceUri, outputs); + this.diagnosticDirectivesView.show(sourceUri, outputs); + const nextKeys = new Set(virtualUris.map(uri => uri.toString())); + const changes: vscode.FileChangeEvent[] = []; + for (const uri of previousUris) { + if (!nextKeys.has(uri.toString())) { + changes.push({ type: vscode.FileChangeType.Deleted, uri }); + } + } + for (const uri of virtualUris) { + const previous = previousEntries.get(uri.toString()); + const entry = this.entries.get(uri.toString())!; + if (!previous) { + changes.push({ type: vscode.FileChangeType.Created, uri }); + } + else if (previous.output.identity !== entry.output.identity) { + entry.mtime = Math.max(Date.now(), previous.mtime + 1); + changes.push({ type: vscode.FileChangeType.Changed, uri }); + } + } + 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 updateActiveEditorContext(editor: vscode.TextEditor | undefined): void { + 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 async updateActiveEditorContextNow(editor: vscode.TextEditor | undefined, version: number): Promise { + let isContentMapped = false; + if (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 remember(sourceUri: vscode.Uri, outputs: readonly MappedOutput[]): readonly vscode.Uri[] { + 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())); + + for (const previousUri of previousUris) { + if (!nextKeys.has(previousUri.toString())) { + this.entries.delete(previousUri.toString()); + } + } + + outputs.forEach((mappedOutput, index) => { + const uri = nextUris[index]!; + const existing = this.entries.get(uri.toString()); + this.entries.set(uri.toString(), { + sourceUri, + output: mappedOutput, + mtime: existing?.mtime ?? Date.now(), + }); + }); + this.sourceToVirtualUris.set(sourceKey, nextUris); + return nextUris; + } + + 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); + if (outputs.length === 0) { + this.clearInspection(); + this.diagnosticDirectivesView.refresh(sourceUri, undefined); + this.sourceToVirtualUris.delete(sourceKey); + const changes = previousUris.map(uri => ({ type: vscode.FileChangeType.Deleted, uri })); + for (const uri of previousUris) { + this.entries.delete(uri.toString()); + } + this.changeEmitter.fire(changes); + return; + } + + const previousEntries = new Map(previousUris.map(uri => [uri.toString(), this.entries.get(uri.toString())])); + const nextUris = this.remember(sourceUri, outputs); + this.diagnosticDirectivesView.refresh(sourceUri, outputs); + const nextKeys = new Set(nextUris.map(uri => uri.toString())); + const changes: vscode.FileChangeEvent[] = []; + + for (const uri of previousUris) { + if (!nextKeys.has(uri.toString())) { + changes.push({ type: vscode.FileChangeType.Deleted, uri }); + } + } + for (const uri of nextUris) { + const entry = this.entries.get(uri.toString())!; + const previous = previousEntries.get(uri.toString()); + if (!previous) { + changes.push({ type: vscode.FileChangeType.Created, uri }); + } + else if (previous.output.identity !== entry.output.identity) { + entry.mtime = Math.max(Date.now(), previous.mtime + 1); + changes.push({ type: vscode.FileChangeType.Changed, uri }); + } + } + if (changes.length !== 0) { + this.changeEmitter.fire(changes); + } + this.scheduleInspection(); + } + + private async revealDiagnosticDirective(node: DiagnosticDirectiveNode): Promise { + const sourceUri = this.sourceUriForOutput(node.output); + if (!sourceUri) { + return; + } + 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 sourceDocument = await vscode.workspace.openTextDocument(sourceUri); + const sourceRange = rangeFromTextRange(sourceDocument, node.directive.originalRange); + await vscode.window.showTextDocument(sourceDocument, { + preserveFocus: true, + preview: false, + selection: sourceRange, + }); + + 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 virtualEditor = await vscode.window.showTextDocument(virtualDocument, { + preserveFocus: false, + preview: false, + selection: virtualRange, + viewColumn: vscode.ViewColumn.Beside, + }); + virtualEditor.revealRange(virtualRange, vscode.TextEditorRevealType.InCenterIfOutsideViewport); + this.scheduleInspection(); + } + + private sourceUriForOutput(output: MappedOutput): vscode.Uri | undefined { + for (const entry of this.entries.values()) { + if (entry.output === output) { + return entry.sourceUri; + } + } + return undefined; + } + + 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 sourceEditor = vscode.window.visibleTextEditors.find( + candidate => candidate.document.uri.toString() === entry.sourceUri.toString(), + ); + if (!sourceEditor) { + this.clearInspection(); + return; + } + const offset = virtualEditor.document.offsetAt(virtualEditor.selection.active); + 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); + 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 virtualRanges: vscode.Range[][] = [[], [], []]; + for (const mapping of mappings) { + const kind = normalizedMappingKind(mapping.kind); + sourceRanges[kind]!.push(rangeFromOffsets(sourceEditor.document, mapping.originalStart, mapping.originalLength)); + virtualRanges[kind]!.push(rangeFromOffsets(virtualEditor.document, mapping.generatedStart, mapping.generatedLength)); + } + for (let kind = 0; kind < this.mappingDecorations.length; kind++) { + virtualEditor.setDecorations(this.mappingDecorations[kind]!, virtualRanges[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 sourceRanges: vscode.Range[][] = [[], [], []]; + const virtualRanges: vscode.Range[][] = [[], [], []]; + for (const mapping of mappings) { + const kind = normalizedMappingKind(mapping.kind); + sourceRanges[kind]!.push(rangeFromOffsets(sourceEditor.document, mapping.originalStart, mapping.originalLength)); + virtualRanges[kind]!.push(rangeFromOffsets(virtualEditor.document, mapping.generatedStart, mapping.generatedLength)); + } + for (let kind = 0; kind < this.mappingDecorations.length; kind++) { + sourceEditor.setDecorations(this.mappingDecorations[kind]!, sourceRanges[kind]!); + virtualEditor.setDecorations(this.mappingDecorations[kind]!, virtualRanges[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 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); + } +} + +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..d17038759499d --- /dev/null +++ b/packages/vscode-typescript/src/contentMapperVirtualFiles.ts @@ -0,0 +1,44 @@ +import { createHash } from "node:crypto"; + +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 text: string; + readonly originalText: string; + readonly scriptKind: number; + readonly mappings: readonly ContentMapperVirtualSpan[]; + readonly diagnosticDirectives: readonly ContentMapperDiagnosticDirective[]; +} + +export interface MappedOutput extends ContentMapperVirtualFile { + readonly key: string; + readonly identity: string; +} + +export function toMappedOutputs(files: readonly ContentMapperVirtualFile[]): readonly MappedOutput[] { + return files.map((file, index) => ({ + ...file, + key: String(index), + identity: createHash("sha256").update(JSON.stringify(file)).digest("hex"), + })); +} diff --git a/packages/vscode-typescript/src/diagnosticDirectivesView.ts b/packages/vscode-typescript/src/diagnosticDirectivesView.ts new file mode 100644 index 0000000000000..6d5d932b92c56 --- /dev/null +++ b/packages/vscode-typescript/src/diagnosticDirectivesView.ts @@ -0,0 +1,172 @@ +import * as path from "node:path"; +import * as vscode from "vscode"; + +import type { + ContentMapperDiagnosticDirective, + ContentMapperTextRange, + MappedOutput, +} from "./contentMapperVirtualFiles"; + +const revealDiagnosticDirectiveCommand = "typescript.native-preview.revealContentMapperDiagnosticDirective"; +const diagnosticDirectivesViewId = "typescript.native-preview.contentMapperDiagnosticDirectives"; + +interface OutputNode { + readonly kind: "output"; + readonly output: MappedOutput; +} + +export interface DiagnosticDirectiveNode { + readonly kind: "directive"; + 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 outputs: readonly MappedOutput[] = []; + + 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.outputs = outputs; + this.updateMessage(); + this.changeEmitter.fire(undefined); + } + + refresh(sourceUri: vscode.Uri, outputs: readonly MappedOutput[] | undefined): void { + if (sourceUri.toString() !== this.sourceUri?.toString()) { + return; + } + this.outputs = outputs ?? []; + this.updateMessage(); + 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 original = positionAt(output.originalText, directive.originalRange.pos); + const virtual = positionAt(output.text, directive.virtualRange.pos); + const item = new vscode.TreeItem(policy, vscode.TreeItemCollapsibleState.None); + item.description = `${formatPosition(original)} \u2192 ${formatPosition(virtual)}`; + item.iconPath = new vscode.ThemeIcon(directive.policy === 1 ? "error" : "eye"); + 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.outputs + .filter(output => output.diagnosticDirectives.length !== 0) + .map(output => ({ kind: "output", output })); + } + if (node.kind === "output") { + return node.output.diagnosticDirectives.map(directive => ({ + kind: "directive", + output: node.output, + directive, + })); + } + return []; + } + + dispose(): void { + for (const disposable of this.disposables.splice(0)) { + disposable.dispose(); + } + } + + private updateMessage(): void { + const directiveCount = this.outputs.reduce( + (count, output) => count + output.diagnosticDirectives.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; + } +} + +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)}\``); + 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))}`); + 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 formatPosition(position: vscode.Position): string { + return `${position.line + 1}:${position.character + 1}`; +} + +function positionAt(text: string, offset: number): vscode.Position { + const limit = Math.min(Math.max(offset, 0), text.length); + let line = 0; + let lineStart = 0; + for (let index = 0; index < limit; index++) { + if (text.charCodeAt(index) === 10) { + line++; + lineStart = index + 1; + } + } + return new vscode.Position(line, limit - lineStart); +} 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..2d6eaa6678f33 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,17 @@ export class SessionManager implements vscode.Disposable { return result.pipe; } + getContentMapperVirtualFiles(uri: vscode.Uri): Promise { + if (!this.currentSession) { + 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 +164,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..8e49f3b01b7d2 --- /dev/null +++ b/packages/vscode-typescript/test/contentMapperVirtualFiles.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { toMappedOutputs } from "../src/contentMapperVirtualFiles"; + +test("adds stable output keys and identities to content mapper virtual files", () => { + const files = [{ + fileName: "/component.vue.ts", + text: "export {}", + originalText: "` + const box = `// 💥 +// @box-expect-error: unused +const café = 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, } var mu sync.Mutex @@ -156,14 +165,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 +203,52 @@ 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.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)]))) + assert.NilError(t, fs.WriteFile("/home/project/tsconfig.json", `{ "compilerOptions": { "target": "es2020", "module": "esnext", "moduleResolution": "bundler", "strict": true } }`)) @@ -196,6 +261,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 +320,11 @@ 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}, + }) +} + +func utf16Length(text string) int { + return len(utf16.Encode([]rune(text))) }