diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 98f30fd5569..eb170f03554 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -352,7 +352,12 @@ const enumDefs = [ { name: "ModuleDetectionKind", goPrefix: "ModuleDetectionKind", goFile: "internal/core/compileroptions.go", outDir: "_packages/native-preview/src/enums" }, { name: "NewLineKind", goPrefix: "NewLineKind", goFile: "internal/core/compileroptions.go", outDir: "_packages/native-preview/src/enums" }, { name: "JsxEmit", goPrefix: "JsxEmit", goFile: "internal/core/compileroptions.go", outDir: "_packages/native-preview/src/enums" }, + { name: "ScriptKind", goPrefix: "ScriptKind", goFile: "internal/core/scriptkind.go", outDir: "_packages/native-preview/src/enums" }, { name: "TokenFlags", goPrefix: "TokenFlags", goFile: "internal/ast/tokenflags.go", outDir: "_packages/native-preview/src/enums" }, + { name: "DiagnosticDirectivePolicy", goPrefix: "MappedDiagnosticDirectivePolicy", goFile: "internal/ast/ast.go", outDir: "_packages/native-preview/src/enums" }, + { name: "SpanMapKind", goPrefix: "Kind", goFile: "internal/spanmap/spanmap.go", outDir: "_packages/native-preview/src/enums" }, + { name: "SpanMapFidelity", goPrefix: "Fidelity", goFile: "internal/spanmap/spanmap.go", outDir: "_packages/native-preview/src/enums" }, + { name: "SpanMapFeature", goPrefix: "Feature", goFile: "internal/spanmap/spanmap.go", outDir: "_packages/native-preview/src/enums" }, { name: "NodeBuilderFlags", goPrefix: "Flags", goFile: "internal/nodebuilder/types.go", outDir: "_packages/native-preview/src/enums" }, { name: "CompletionItemKind", goPrefix: "CompletionItemKind", goFile: "internal/lsp/lsproto/lsp_generated.go", outDir: "_packages/native-preview/src/enums" }, { name: "EmitOnly", goPrefix: "Emit", goFile: "internal/compiler/emitter.go", outDir: "_packages/native-preview/src/enums", excludeMembers: ["OnlyBuilderSignature"] }, @@ -370,7 +375,7 @@ function parseGoConstBlock(block, def) { const prefix = def.goPrefix; const members = []; let iotaCounter = 0; - let hasIota = false; + let iotaExpression; for (const rawLine of block.split("\n")) { const line = rawLine.replace(/\/\/.*$/, "").trim(); @@ -379,7 +384,7 @@ function parseGoConstBlock(block, def) { // Match: PrefixName Type = value or PrefixName = value const fullMatch = line.match(new RegExp(`^(${prefix}\\w+)\\s+(?:\\S+\\s*)?=\\s*(.+)$`)); // Match bare iota continuation: just PrefixName - const bareMatch = !fullMatch && hasIota + const bareMatch = !fullMatch && iotaExpression !== undefined ? line.match(new RegExp(`^(${prefix}\\w+)$`)) : null; @@ -393,12 +398,12 @@ function parseGoConstBlock(block, def) { if (def.stringEnum) { tsValue = parseGoStringValue(goValue, def.valueReplacements ?? {}); } - else if (goValue === "iota") { - tsValue = String(iotaCounter); - hasIota = true; + else if (goValue.includes("iota")) { + iotaExpression = goValue; + tsValue = goValue.replace(/\biota\b/g, String(iotaCounter)); } - else if (hasIota && goValue === "") { - tsValue = String(iotaCounter); + else if (iotaExpression !== undefined && goValue === "") { + tsValue = iotaExpression.replace(/\biota\b/g, String(iotaCounter)); } else { // Replace Go bitwise NOT (^) with TypeScript (~) diff --git a/_extension/package.json b/_extension/package.json index b27035bc6dc..1c1684385ce 100644 --- a/_extension/package.json +++ b/_extension/package.json @@ -86,6 +86,15 @@ "experimental" ] }, + "js/ts.contentMappers.enabled": { + "type": "boolean", + "default": true, + "description": "%native-preview.contentMappers.enabled.description%", + "scope": "window", + "tags": [ + "experimental" + ] + }, "js/ts.server.goMemLimit": { "type": "string", "description": "%native-preview.goMemLimit.description%", diff --git a/_extension/package.nls.json b/_extension/package.nls.json index 5a3c0c6d712..5e51b0e49f2 100644 --- a/_extension/package.nls.json +++ b/_extension/package.nls.json @@ -6,6 +6,7 @@ "native-preview.tsserver.tsdk.description": "Path to the @typescript/native-preview package or tsgo binary directory. If not specified, the extension will look for it in the default location.", "native-preview.additionalTsdkLocations.description": "Additional paths to tsgo binary directories or @typescript/native-preview packages that should appear in the version selector.", "native-preview.showDebugInfo.description": "Show debug information (PID, executable path) in the server menu.", + "native-preview.contentMappers.enabled.description": "Enable external content mappers that transform otherwise unsupported file types for TypeScript language features. Content mappers can be configured in tsconfig.json/jsconfig.json files or provided by VS Code extensions.", "native-preview.goMemLimit.description": "Set GOMEMLIMIT for the language server (e.g., '2048MiB', '4GiB'). See https://pkg.go.dev/runtime#hdr-Environment_Variables for more information.", "native-preview.goMemLimit.error": "Must be a valid memory limit (e.g., '2048MiB', '4GiB').", "native-preview.customConfigFileName.deprecation": "This setting has moved to js/ts.customConfigFileName.", diff --git a/_extension/src/client.ts b/_extension/src/client.ts index 50a995a3450..a6be0265da9 100644 --- a/_extension/src/client.ts +++ b/_extension/src/client.ts @@ -1,5 +1,6 @@ import * as vscode from "vscode"; +import { CancellationToken } from "vscode-languageclient"; import { ClientCapabilities, CloseAction, @@ -23,12 +24,14 @@ import { configurationMiddleware, sendNotificationMiddleware, } from "./configurationMiddleware"; +import type { SerializedContentMapperContribution } from "./contentMapperContributions"; import { registerMultiDocumentHighlightFeature } from "./languageFeatures/documentHighlight"; import { registerHoverFeature } from "./languageFeatures/hover"; import { registerOnAutoInsertFeature } from "./languageFeatures/onAutoInsert"; import { registerSourceDefinitionFeature } from "./languageFeatures/sourceDefinition"; import * as tr from "./telemetryReporting"; import { + contentMappersEnabled, ExeInfo, getExe, jsTsLanguageModes, @@ -38,6 +41,24 @@ import { import { getLanguageForUri } from "./util"; import { workspaceSymbolSendRequestMiddleware } from "./workspaceSymbolMiddleware"; +// Registration IDs the server uses for content mapper capabilities all share this prefix (see +// RegisterContentMapperExtensions in internal/lsp/server.go). The extension watches for these dynamic +// registrations to learn which file extensions are content-mapped. +const contentMapperRegistrationPrefix = "content-mapper-"; + +// ContentMapperRegisterOptions is the subset of a dynamic registration's options that our server sends +// for content mapper capabilities: a document selector of simple glob-pattern filters (see +// RegisterContentMapperExtensions in internal/lsp/server.go). +interface ContentMapperRegisterOptions { + documentSelector?: ReadonlyArray<{ pattern: string; }>; +} + +// extractPatternFilters reuses the server's glob-pattern filters so the extension's custom providers match +// the same content-mapped files. +function extractPatternFilters(registerOptions: ContentMapperRegisterOptions | undefined): vscode.DocumentFilter[] { + return (registerOptions?.documentSelector ?? []).map(({ pattern }) => ({ pattern })); +} + export class Client implements vscode.Disposable { private outputChannel: vscode.LogOutputChannel; private initializedEventEmitter: vscode.EventEmitter; @@ -52,6 +73,14 @@ export class Client implements vscode.Disposable { private disposables: vscode.Disposable[] = []; isInitialized = false; + // Document filters for content-mapped file extensions, keyed by the server's registration ID. These + // augment the static jsTs document selector so the extension's custom language-feature providers + // (hover, multi-document highlight, on-auto-insert) also cover content-mapped files. + private contentMapperFiltersById = new Map(); + // Disposables for the custom providers registered against the current (selector-scoped) document set. + // Re-registered whenever the set of content-mapped extensions changes. + private selectorScopedFeatures: vscode.Disposable[] = []; + private exe: ExeInfo | undefined; private errorHandler: ReportingErrorHandler; @@ -85,6 +114,7 @@ export class Client implements vscode.Disposable { codeLensShowLocationsCommandName, enableTelemetry: true, logVerbosity: this.outputChannel.logLevel, + runExternalCode: contentMappersEnabled(), }, errorHandler: this.errorHandler, middleware: { @@ -100,6 +130,18 @@ export class Client implements vscode.Disposable { sendNotification: sendNotificationMiddleware, sendRequest: workspaceSymbolSendRequestMiddleware, provideHover: () => undefined, + handleRegisterCapability: async (params, next) => { + await next(params, CancellationToken.None); + if (this.trackContentMapperRegistrations(params.registrations)) { + this.registerSelectorScopedFeatures(); + } + }, + handleUnregisterCapability: async (params, next) => { + await next(params, CancellationToken.None); + if (this.trackContentMapperUnregistrations(params.unregisterations)) { + this.registerSelectorScopedFeatures(); + } + }, }, diagnosticCollectionName: "typescript-push", diagnosticPullOptions: { @@ -135,10 +177,16 @@ export class Client implements vscode.Disposable { } if (selector.pattern !== undefined) { - // VS Code's glob matcher is not available via the API; - // see: https://github.com/microsoft/vscode/issues/237304 - // But, we're only called on selectors passed above, so just ignore this for now. - throw new Error("Not implemented"); + // VS Code's full glob matcher is not available via the API + // (microsoft/vscode#237304), but content mapper registrations only ever + // use simple "**/*" patterns, so match those by suffix. Any other + // pattern falls through to the next selector. + const glob = typeof selector.pattern === "string" ? selector.pattern : selector.pattern.pattern; + const globPrefix = "**/*"; + if (glob.startsWith(globPrefix) && resource.path.endsWith(glob.slice(globPrefix.length))) { + return true; + } + continue; } return true; @@ -197,9 +245,9 @@ export class Client implements vscode.Disposable { }, }; - // Refresh the initial log verbosity in case the output channel's log - // level changed between construction and start. + // Refresh options in case they changed between construction and start. this.clientOptions.initializationOptions.logVerbosity = this.outputChannel.logLevel; + this.clientOptions.initializationOptions.runExternalCode = contentMappersEnabled(); this.clientOptions.initializationOptions.trackFlakyDiagnostics = effectiveflakesFlag !== "never" ? (effectiveflakesFlag === "panic" ? 2 : 1) : 0; this.client = new NativePreviewLanguageClient( @@ -265,11 +313,77 @@ export class Client implements vscode.Disposable { this.disposables.push( logLevelListener, serverTelemetryListener, - registerMultiDocumentHighlightFeature(this.documentSelector, this.client), registerSourceDefinitionFeature(this.client), - registerHoverFeature(this.documentSelector, this.client), registerOnAutoInsertFeature(this.documentSelector, this.client), ); + // Register the selector-scoped custom providers (hover, multi-document highlight). These start + // scoped to the static jsTs selector and expand as content-mapped extensions register. + this.registerSelectorScopedFeatures(); + } + + // trackContentMapperRegistrations records the content-mapped document filters carried by the server's + // dynamic capability registrations. Returns whether the known set of content-mapper filters changed. + private trackContentMapperRegistrations(registrations: ReadonlyArray<{ id: string; registerOptions?: ContentMapperRegisterOptions; }>): boolean { + let changed = false; + for (const registration of registrations) { + if (!registration.id.startsWith(contentMapperRegistrationPrefix)) { + continue; + } + const filters = extractPatternFilters(registration.registerOptions); + if (filters.length > 0) { + this.contentMapperFiltersById.set(registration.id, filters); + changed = true; + } + else if (this.contentMapperFiltersById.delete(registration.id)) { + changed = true; + } + } + return changed; + } + + // trackContentMapperUnregistrations forgets the filters for any content-mapper registration the server + // has removed. Returns whether the known set of content-mapper filters changed. + private trackContentMapperUnregistrations(unregistrations: ReadonlyArray<{ id: string; }>): boolean { + let changed = false; + for (const unregistration of unregistrations) { + if (this.contentMapperFiltersById.delete(unregistration.id)) { + changed = true; + } + } + return changed; + } + + // selectorScopedDocumentSelector is the static jsTs selector plus every active content-mapper filter, + // deduplicated. The custom providers are registered against it so they also cover content-mapped files. + private selectorScopedDocumentSelector(): vscode.DocumentSelector { + const seen = new Set(); + const filters: vscode.DocumentFilter[] = []; + for (const list of this.contentMapperFiltersById.values()) { + for (const filter of list) { + const key = typeof filter.pattern === "string" ? filter.pattern : JSON.stringify(filter.pattern); + if (!seen.has(key)) { + seen.add(key); + filters.push(filter); + } + } + } + return [...this.documentSelector, ...filters]; + } + + // registerSelectorScopedFeatures (re)registers the custom language-feature providers whose scope must + // track the set of content-mapped extensions. Existing registrations are disposed first. + private registerSelectorScopedFeatures(): void { + for (const disposable of this.selectorScopedFeatures.splice(0)) { + disposable.dispose(); + } + if (!this.client || this.isStopping || this.isDisposed) { + return; + } + const selector = this.selectorScopedDocumentSelector(); + this.selectorScopedFeatures.push( + registerMultiDocumentHighlightFeature(selector, this.client), + registerHoverFeature(selector, this.client), + ); } async stop(): Promise { @@ -278,6 +392,10 @@ export class Client implements vscode.Disposable { } this.isStopping = true; this.isInitialized = false; + for (const disposable of this.selectorScopedFeatures.splice(0)) { + disposable.dispose(); + } + this.contentMapperFiltersById.clear(); const disposables = this.disposables.splice(0); await Promise.all(disposables.map(d => d.dispose())); await this.client?.stop(); @@ -290,6 +408,10 @@ export class Client implements vscode.Disposable { this.isDisposed = true; this.isStopping = true; this.isInitialized = false; + for (const disposable of this.selectorScopedFeatures.splice(0)) { + disposable.dispose(); + } + this.contentMapperFiltersById.clear(); const disposables = this.disposables.splice(0); await Promise.all(disposables.map(d => d.dispose())); await this.client?.dispose(); @@ -399,6 +521,16 @@ export class Client implements vscode.Disposable { textDocument: { uri }, }, token); } + + async setContentMapperContributions(contributions: readonly SerializedContentMapperContribution[], openDocuments: readonly vscode.Uri[]): Promise { + if (!this.client || !this.isInitialized) { + return; + } + await this.client.sendRequest("custom/setContentMapperContributions", { + contributions: contentMappersEnabled() ? contributions : [], + openDocuments: openDocuments.map(uri => ({ uri: uri.toString() })), + }); + } } // Returns true when running on a VS Code Insiders build. diff --git a/_extension/src/contentMapperContributions.ts b/_extension/src/contentMapperContributions.ts new file mode 100644 index 00000000000..44fe298cfe0 --- /dev/null +++ b/_extension/src/contentMapperContributions.ts @@ -0,0 +1,93 @@ +import * as vscode from "vscode"; + +export interface ContentMapperManifest { + readonly name: string; + readonly version?: string; + readonly exec: readonly string[]; + readonly cwd?: vscode.Uri; + readonly compilerOptions?: readonly string[]; + readonly dynamicConfig?: boolean; +} + +export interface ContentMapperContribution { + readonly extensions: readonly string[]; + readonly inferredProject?: { + readonly options?: Readonly>; + readonly manifest: ContentMapperManifest; + }; +} + +export interface SerializedContentMapperContribution { + readonly contributorId: string; + readonly extensions: readonly string[]; + readonly inferredProjectContribution?: { + readonly options?: Readonly>; + readonly manifest: { + readonly name: string; + readonly version?: string; + readonly exec: readonly string[]; + readonly cwd?: string; + readonly compilerOptions?: readonly string[]; + readonly dynamicConfig?: boolean; + }; + }; +} + +export function serializeContentMapperContributions( + registrations: ReadonlyMap, +): readonly SerializedContentMapperContribution[] { + const result: SerializedContentMapperContribution[] = []; + for (const [contributorId, contributions] of registrations) { + contributions.forEach(contribution => { + result.push({ + contributorId, + extensions: [...contribution.extensions], + inferredProjectContribution: contribution.inferredProject && { + options: contribution.inferredProject.options, + manifest: { + ...contribution.inferredProject.manifest, + exec: [...contribution.inferredProject.manifest.exec], + cwd: contribution.inferredProject.manifest.cwd?.fsPath, + compilerOptions: contribution.inferredProject.manifest.compilerOptions && [...contribution.inferredProject.manifest.compilerOptions], + }, + }, + }); + }); + } + return result; +} + +export function validateContentMapperRegistration(contributorId: string, contributions: readonly ContentMapperContribution[]): void { + if (!contributorId) { + throw new TypeError("Content mapper contributor ID must not be empty."); + } + for (const contribution of contributions) { + if (contribution.extensions.length === 0 || contribution.extensions.some(extension => !extension.startsWith(".") || extension.length === 1)) { + throw new TypeError("Content mapper contributions require non-empty extensions beginning with '.'."); + } + const inferredProject = contribution.inferredProject; + if (inferredProject?.options === null || Array.isArray(inferredProject?.options) || inferredProject?.options !== undefined && typeof inferredProject.options !== "object") { + throw new TypeError("Content mapper contribution options must be an object."); + } + if (inferredProject && (!inferredProject.manifest.name || inferredProject.manifest.exec.length === 0)) { + throw new TypeError("Content mapper contribution manifests require a name and non-empty exec."); + } + if (inferredProject?.manifest.cwd && inferredProject.manifest.cwd.scheme !== "file") { + throw new TypeError("Content mapper contribution cwd must be a file URI."); + } + } +} + +export function documentMatchesContentMapperContributions( + document: vscode.TextDocument, + registrations: ReadonlyMap, +): boolean { + for (const contributions of registrations.values()) { + for (const contribution of contributions) { + if (contribution.extensions.some(extension => document.uri.path.endsWith(extension))) { + return true; + } + } + } + return false; +} diff --git a/_extension/src/extension.ts b/_extension/src/extension.ts index d776ce76066..7bff6514e43 100644 --- a/_extension/src/extension.ts +++ b/_extension/src/extension.ts @@ -4,6 +4,7 @@ import { registerEnablementCommands, updateUseTsgoSetting, } from "./commands"; +import type { ContentMapperContribution } from "./contentMapperContributions"; import { aiConnectionString, getExplicitConfigTarget, @@ -26,6 +27,7 @@ import assert from "node:assert"; export interface ExtensionAPI { onLanguageServerInitialized: vscode.Event; initializeAPIConnection(pipe?: string): Promise; + registerContentMappers(contributorId: string, contributions: readonly ContentMapperContribution[]): vscode.Disposable; } export async function activate(context: vscode.ExtensionContext): Promise { @@ -80,6 +82,9 @@ export async function activate(context: vscode.ExtensionContext): Promise { return sessionManager.initializeAPIConnection(pipe); }, + registerContentMappers(contributorId, contributions): vscode.Disposable { + return sessionManager.registerContentMappers(contributorId, contributions); + }, }; let configChangeTimeout: ReturnType | undefined; @@ -130,7 +135,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { diff --git a/_extension/src/session.ts b/_extension/src/session.ts index 24d3e323ffc..d86fa7f0066 100644 --- a/_extension/src/session.ts +++ b/_extension/src/session.ts @@ -6,6 +6,12 @@ import { registerCodeLensShowLocationsCommand, updateWorkspaceUseTsgoSetting, } from "./commands"; +import { + type ContentMapperContribution, + documentMatchesContentMapperContributions, + serializeContentMapperContributions, + validateContentMapperRegistration, +} from "./contentMapperContributions"; import { ProjectStatus } from "./projectStatus"; import { setupStatusBar } from "./statusBar"; import { TelemetryReporter } from "./telemetryReporting"; @@ -32,6 +38,9 @@ export class SessionManager implements vscode.Disposable { private outputChannel: vscode.LogOutputChannel; private initializedEventEmitter: vscode.EventEmitter; private telemetryReporter: TelemetryReporter; + private readonly contentMapperRegistrations = new Map(); + private lifecycleOperation = Promise.resolve(); + private contentMapperSyncOperation = Promise.resolve(); constructor( context: vscode.ExtensionContext, @@ -42,26 +51,59 @@ export class SessionManager implements vscode.Disposable { this.outputChannel = outputChannel; this.telemetryReporter = telemetryReporter; this.initializedEventEmitter = initializedEventEmitter; + + this.disposables.push(vscode.workspace.onDidChangeConfiguration(event => { + if (this.currentSession && event.affectsConfiguration("js/ts.contentMappers.enabled")) { + void this.restart(context).catch(error => { + this.outputChannel.error(`TypeScript language server restart failed: ${String(error)}`); + }); + } + })); + this.disposables.push(vscode.workspace.onDidOpenTextDocument(document => { + if (documentMatchesContentMapperContributions(document, this.contentMapperRegistrations)) { + void this.syncContentMapperContributions(); + } + })); + this.disposables.push(initializedEventEmitter.event(() => { + void this.syncContentMapperContributions(); + })); } start(context: vscode.ExtensionContext): Promise { return this.restart(context); } - async restart(context: vscode.ExtensionContext): Promise { + restart(context: vscode.ExtensionContext): Promise { + return this.enqueueLifecycleOperation(() => this.restartNow(context)); + } + + private async restartNow(context: vscode.ExtensionContext): Promise { if (this.currentSession) { this.outputChannel.appendLine("Restarting TypeScript language server..."); await this.currentSession.stop(); } - this.currentSession = new Session(context, this.outputChannel, this.initializedEventEmitter, this.telemetryReporter, () => this.stop(), () => this.restart(context)); - return this.currentSession.start(context); + const session = new Session(context, this.outputChannel, this.initializedEventEmitter, this.telemetryReporter, () => this.stop(), () => this.restart(context)); + this.currentSession = session; + try { + await session.start(context); + await this.syncContentMapperContributions(); + } + catch (error) { + if (this.currentSession === session) { + this.currentSession = undefined; + } + await session.dispose(); + throw error; + } } - async stop(): Promise { - if (this.currentSession) { - await this.currentSession.stop(); - this.currentSession = undefined; - } + stop(): Promise { + return this.enqueueLifecycleOperation(async () => { + if (this.currentSession) { + await this.currentSession.stop(); + this.currentSession = undefined; + } + }); } async initializeAPIConnection(pipe?: string): Promise { @@ -72,9 +114,56 @@ export class SessionManager implements vscode.Disposable { return result.pipe; } - async dispose(): Promise { - await this.currentSession?.dispose(); - await Promise.all(this.disposables.map(d => d.dispose())); + registerContentMappers(contributorId: string, contributions: readonly ContentMapperContribution[]): vscode.Disposable { + validateContentMapperRegistration(contributorId, contributions); + if (this.contentMapperRegistrations.has(contributorId)) { + throw new Error(`Content mapper contributor '${contributorId}' is already registered.`); + } + this.contentMapperRegistrations.set(contributorId, contributions); + void this.syncContentMapperContributions(); + let disposed = false; + return new vscode.Disposable(() => { + if (disposed) return; + disposed = true; + this.contentMapperRegistrations.delete(contributorId); + void this.syncContentMapperContributions(); + }); + } + + private syncContentMapperContributions(): Promise { + const operation = this.contentMapperSyncOperation.then(() => this.syncContentMapperContributionsNow()); + this.contentMapperSyncOperation = operation.catch(() => {}); + return operation; + } + + private async syncContentMapperContributionsNow(): Promise { + try { + if (!this.currentSession?.client.isInitialized) return; + const openDocuments = vscode.workspace.textDocuments + .filter(document => documentMatchesContentMapperContributions(document, this.contentMapperRegistrations)) + .map(document => document.uri); + await this.currentSession.client.setContentMapperContributions( + serializeContentMapperContributions(this.contentMapperRegistrations), + openDocuments, + ); + } + catch (error) { + this.outputChannel.warn(`Content mapper contribution synchronization failed: ${String(error)}`); + } + } + + private enqueueLifecycleOperation(operation: () => Promise): Promise { + const result = this.lifecycleOperation.then(operation); + this.lifecycleOperation = result.catch(() => {}); + return result; + } + + dispose(): Promise { + return this.enqueueLifecycleOperation(async () => { + await this.currentSession?.dispose(); + this.currentSession = undefined; + await Promise.all(this.disposables.splice(0).map(d => d.dispose())); + }); } } diff --git a/_extension/src/util.ts b/_extension/src/util.ts index 767d6874984..13cfc7f2485 100644 --- a/_extension/src/util.ts +++ b/_extension/src/util.ts @@ -519,6 +519,10 @@ export function readUnifiedConfig( return vscode.workspace.getConfiguration(fallbackSection, scope).get(fallbackKey, defaultValue); } +export function contentMappersEnabled(): boolean { + return vscode.workspace.getConfiguration("js/ts").get("contentMappers.enabled", true); +} + export interface PackageInfo { name: string; version: string; diff --git a/_packages/native-preview/src/api/async/client.ts b/_packages/native-preview/src/api/async/client.ts index 1b301007134..cdcc2343f8e 100644 --- a/_packages/native-preview/src/api/async/client.ts +++ b/_packages/native-preview/src/api/async/client.ts @@ -17,6 +17,7 @@ import { type ClientOptions, type ClientSocketOptions, type ClientSpawnOptions, + getAPIProcessArgs, isSpawnOptions, resolveExePath, } from "../options.ts"; @@ -65,16 +66,7 @@ export class Client { const { spawn } = await import("node:child_process"); return new Promise((resolve, reject) => { - const args = [ - "--api", - "--async", - "--cwd", - options.cwd ?? process.cwd(), - ]; - - if (options.collectTiming) { - args.push("--timing"); - } + const args = getAPIProcessArgs(options, true); // Enable virtual FS callbacks for each provided FS function const enabledCallbacks: string[] = []; diff --git a/_packages/native-preview/src/api/node/encoder.ts b/_packages/native-preview/src/api/node/encoder.ts index d026565071a..8f1acc5e439 100644 --- a/_packages/native-preview/src/api/node/encoder.ts +++ b/_packages/native-preview/src/api/node/encoder.ts @@ -139,7 +139,8 @@ function recordExtendedData(node: Node, strs: StringTable, extendedData: number[ const referencedFilesOffset = encodeFileReferences(sf.referencedFiles, structuredWriter); const typeRefDirectivesOffset = encodeFileReferences(sf.typeReferenceDirectives, structuredWriter); const libRefDirectivesOffset = encodeFileReferences(sf.libReferenceDirectives, structuredWriter); - extendedData.push(textIndex, fileNameIndex, pathIndex, sf.languageVariant, sf.scriptKind, referencedFilesOffset, typeRefDirectivesOffset, libRefDirectivesOffset, NO_STRUCTURED_DATA, NO_STRUCTURED_DATA, NO_STRUCTURED_DATA, 0); + // Content-mapper metadata is program-owned and supplied only by the Go encoder. + extendedData.push(textIndex, fileNameIndex, pathIndex, sf.languageVariant, sf.scriptKind, referencedFilesOffset, typeRefDirectivesOffset, libRefDirectivesOffset, NO_STRUCTURED_DATA, NO_STRUCTURED_DATA, NO_STRUCTURED_DATA, 0, textIndex, NO_STRUCTURED_DATA, NO_STRUCTURED_DATA, NO_STRUCTURED_DATA, NO_STRUCTURED_DATA, NO_STRUCTURED_DATA, NO_STRUCTURED_DATA); } else if ( node.kind === SyntaxKind.TemplateHead || diff --git a/_packages/native-preview/src/api/node/node.ts b/_packages/native-preview/src/api/node/node.ts index edea7d5ca4a..65247c6d267 100644 --- a/_packages/native-preview/src/api/node/node.ts +++ b/_packages/native-preview/src/api/node/node.ts @@ -2,9 +2,13 @@ import { computeLineStarts, type FileReference, type LineAndCharacter, + type MappedDiagnosticDirective, type Node, NodeFlags, type Path, + SpanMap, + SpanMapFeature, + SpanMapKind, SyntaxKind, TokenFlags, } from "../../ast/index.ts"; @@ -36,6 +40,34 @@ import { Wtf8Decoder } from "./wtf8.ts"; export { RemoteNode, RemoteNodeList } from "./node.generated.ts"; export { readParseOptionsKey, readSourceFileHash, RemoteNodeBase } from "./node.infrastructure.ts"; +const sourceFileExtendedDataOffsets = { + Text: 0, + FileName: 4, + Path: 8, + LanguageVariant: 12, + ScriptKind: 16, + ReferencedFiles: 20, + TypeReferenceDirectives: 24, + LibReferenceDirectives: 28, + Imports: 32, + ModuleAugmentations: 36, + AmbientModuleNames: 40, + ExternalModuleIndicator: 44, + OriginalText: 48, + SpanMap: 52, + SupplementalSourceFileNames: 56, + CanonicalSourceFileName: 60, + ContentMapper: 64, + VirtualFileName: 68, + DiagnosticDirectives: 72, +} as const; + +for (const [index, offset] of Object.values(sourceFileExtendedDataOffsets).entries()) { + if (offset !== index * Uint32Array.BYTES_PER_ELEMENT) { + throw new Error(`Invalid SourceFile extended data offset ${offset} at index ${index}`); + } +} + // ═══════════════════════════════════════════════════════════════════════════ // RemoteSourceFile // ═══════════════════════════════════════════════════════════════════════════ @@ -59,6 +91,11 @@ export class RemoteSourceFile extends RemoteNode implements SourceFileInfo { private _cachedImports: readonly Node[] | undefined; private _cachedModuleAugmentations: readonly Node[] | undefined; private _cachedAmbientModuleNames: readonly string[] | undefined; + private _cachedSpanMap: SpanMap | undefined; + private _spanMapRead = false; + private _cachedSupplementalSourceFileNames: readonly string[] | undefined; + private _cachedDiagnosticDirectives: readonly MappedDiagnosticDirective[] | undefined; + private _diagnosticDirectivesRead = false; constructor(data: Uint8Array, decoder: TextDecoder, timing?: TimingCollector) { const view = new DataView(data.buffer, data.byteOffset, data.byteLength); @@ -160,26 +197,26 @@ export class RemoteSourceFile extends RemoteNode implements SourceFileInfo { } get fileName(): string { - const stringIndex = this.view.getUint32(this.extendedDataOffset + 4, true); + const stringIndex = this.view.getUint32(this.extendedDataOffset + sourceFileExtendedDataOffsets.FileName, true); return this.getString(stringIndex); } get path(): string { - const stringIndex = this.view.getUint32(this.extendedDataOffset + 8, true); + const stringIndex = this.view.getUint32(this.extendedDataOffset + sourceFileExtendedDataOffsets.Path, true); return this.getString(stringIndex); } get languageVariant(): number { - return this.view.getUint32(this.extendedDataOffset + 12, true); + return this.view.getUint32(this.extendedDataOffset + sourceFileExtendedDataOffsets.LanguageVariant, true); } get scriptKind(): number { - return this.view.getUint32(this.extendedDataOffset + 16, true); + return this.view.getUint32(this.extendedDataOffset + sourceFileExtendedDataOffsets.ScriptKind, true); } get referencedFiles(): readonly FileReference[] { if (this._cachedReferencedFiles !== undefined) return this._cachedReferencedFiles; - const offset = this.view.getUint32(this.extendedDataOffset + 20, true); + const offset = this.view.getUint32(this.extendedDataOffset + sourceFileExtendedDataOffsets.ReferencedFiles, true); const files = this.readFileReferences(offset); this._cachedReferencedFiles = files; return files; @@ -187,7 +224,7 @@ export class RemoteSourceFile extends RemoteNode implements SourceFileInfo { get typeReferenceDirectives(): readonly FileReference[] { if (this._cachedTypeReferenceDirectives !== undefined) return this._cachedTypeReferenceDirectives; - const offset = this.view.getUint32(this.extendedDataOffset + 24, true); + const offset = this.view.getUint32(this.extendedDataOffset + sourceFileExtendedDataOffsets.TypeReferenceDirectives, true); const directives = this.readFileReferences(offset); this._cachedTypeReferenceDirectives = directives; return directives; @@ -195,7 +232,7 @@ export class RemoteSourceFile extends RemoteNode implements SourceFileInfo { get libReferenceDirectives(): readonly FileReference[] { if (this._cachedLibReferenceDirectives !== undefined) return this._cachedLibReferenceDirectives; - const offset = this.view.getUint32(this.extendedDataOffset + 28, true); + const offset = this.view.getUint32(this.extendedDataOffset + sourceFileExtendedDataOffsets.LibReferenceDirectives, true); const directives = this.readFileReferences(offset); this._cachedLibReferenceDirectives = directives; return directives; @@ -203,7 +240,7 @@ export class RemoteSourceFile extends RemoteNode implements SourceFileInfo { get imports(): readonly Node[] { if (this._cachedImports !== undefined) return this._cachedImports; - const offset = this.view.getUint32(this.extendedDataOffset + 32, true); + const offset = this.view.getUint32(this.extendedDataOffset + sourceFileExtendedDataOffsets.Imports, true); const imports = this.readNodeIndexArray(offset); this._cachedImports = imports; return imports; @@ -211,7 +248,7 @@ export class RemoteSourceFile extends RemoteNode implements SourceFileInfo { get moduleAugmentations(): readonly Node[] { if (this._cachedModuleAugmentations !== undefined) return this._cachedModuleAugmentations; - const offset = this.view.getUint32(this.extendedDataOffset + 36, true); + const offset = this.view.getUint32(this.extendedDataOffset + sourceFileExtendedDataOffsets.ModuleAugmentations, true); const moduleAugmentations = this.readNodeIndexArray(offset); this._cachedModuleAugmentations = moduleAugmentations; return moduleAugmentations; @@ -219,19 +256,102 @@ export class RemoteSourceFile extends RemoteNode implements SourceFileInfo { get ambientModuleNames(): readonly string[] { if (this._cachedAmbientModuleNames !== undefined) return this._cachedAmbientModuleNames; - const offset = this.view.getUint32(this.extendedDataOffset + 40, true); + const offset = this.view.getUint32(this.extendedDataOffset + sourceFileExtendedDataOffsets.AmbientModuleNames, true); const names = this.readStringArray(offset); this._cachedAmbientModuleNames = names; return names; } get externalModuleIndicator(): Node | true | undefined { - const nodeIndex = this.view.getUint32(this.extendedDataOffset + 44, true); + const nodeIndex = this.view.getUint32(this.extendedDataOffset + sourceFileExtendedDataOffsets.ExternalModuleIndicator, true); if (nodeIndex === 0) return undefined; if (nodeIndex === this.index) return true; return this.getOrCreateNodeAtIndex(nodeIndex) as Node; } + get originalText(): string { + const stringIndex = this.view.getUint32(this.extendedDataOffset + sourceFileExtendedDataOffsets.OriginalText, true); + return this.getString(stringIndex); + } + + get spanMap(): SpanMap | undefined { + if (this._spanMapRead) return this._cachedSpanMap; + this._spanMapRead = true; + const offset = this.view.getUint32(this.extendedDataOffset + sourceFileExtendedDataOffsets.SpanMap, true); + if (offset === NO_STRUCTURED_DATA) return undefined; + const buf = new Uint8Array(this.view.buffer, this.view.byteOffset, this.view.byteLength); + const reader = new MsgpackReader(buf, this._offsetStructuredData + offset); + const count = reader.readArrayHeader(); + const segments = Array(count); + for (let i = 0; i < count; i++) { + const tupleLength = reader.readArrayHeader(); + if (tupleLength !== 5 && tupleLength !== 6) throw new Error("Invalid span map segment"); + const virtualStart = reader.readUint(); + const virtualLength = reader.readUint(); + const originalStart = reader.readUint(); + const originalLength = reader.readUint(); + const kind = reader.readUint(); + const features = tupleLength === 6 ? reader.readUint() as SpanMapFeature : SpanMapFeature.All; + if (kind !== SpanMapKind.Verbatim && kind !== SpanMapKind.Atom && kind !== SpanMapKind.Alias) throw new Error(`Invalid span map kind: ${kind}`); + segments[i] = { + virtualStart, + virtualEnd: virtualStart + virtualLength, + originalStart, + originalEnd: originalStart + originalLength, + kind, + features, + }; + } + return this._cachedSpanMap = new SpanMap(segments); + } + + get supplementalSourceFileNames(): readonly string[] | undefined { + if (this._cachedSupplementalSourceFileNames !== undefined) return this._cachedSupplementalSourceFileNames; + const offset = this.view.getUint32(this.extendedDataOffset + sourceFileExtendedDataOffsets.SupplementalSourceFileNames, true); + if (offset === NO_STRUCTURED_DATA) return undefined; + return this._cachedSupplementalSourceFileNames = this.readStringArray(offset); + } + + get canonicalSourceFileName(): string | undefined { + const stringIndex = this.view.getUint32(this.extendedDataOffset + sourceFileExtendedDataOffsets.CanonicalSourceFileName, true); + return stringIndex === NO_STRUCTURED_DATA ? undefined : this.getString(stringIndex); + } + + get contentMapper(): string | undefined { + const stringIndex = this.view.getUint32(this.extendedDataOffset + sourceFileExtendedDataOffsets.ContentMapper, true); + return stringIndex === NO_STRUCTURED_DATA ? undefined : this.getString(stringIndex); + } + + get virtualFileName(): string | undefined { + const stringIndex = this.view.getUint32(this.extendedDataOffset + sourceFileExtendedDataOffsets.VirtualFileName, true); + return stringIndex === NO_STRUCTURED_DATA ? undefined : this.getString(stringIndex); + } + + get diagnosticDirectives(): readonly MappedDiagnosticDirective[] | undefined { + if (this._diagnosticDirectivesRead) return this._cachedDiagnosticDirectives; + this._diagnosticDirectivesRead = true; + const offset = this.view.getUint32(this.extendedDataOffset + sourceFileExtendedDataOffsets.DiagnosticDirectives, true); + if (offset === NO_STRUCTURED_DATA) return undefined; + const buf = new Uint8Array(this.view.buffer, this.view.byteOffset, this.view.byteLength); + const reader = new MsgpackReader(buf, this._offsetStructuredData + offset); + const count = reader.readArrayHeader(); + const directives = Array(count); + for (let i = 0; i < count; i++) { + if (reader.readArrayHeader() !== 6) throw new Error("Invalid diagnostic directive"); + const originalStart = reader.readUint(); + const originalLength = reader.readUint(); + const virtualStart = reader.readUint(); + const virtualLength = reader.readUint(); + directives[i] = { + originalRange: { pos: originalStart, end: originalStart + originalLength }, + virtualRange: { pos: virtualStart, end: virtualStart + virtualLength }, + policy: reader.readUint(), + unusedCode: reader.readUint(), + }; + } + return this._cachedDiagnosticDirectives = directives; + } + get isDeclarationFile(): boolean { return (this.flags & NodeFlags.Ambient) !== 0; } diff --git a/_packages/native-preview/src/api/node/protocol.ts b/_packages/native-preview/src/api/node/protocol.ts index b21098cc2d4..2f98f3abdbb 100644 --- a/_packages/native-preview/src/api/node/protocol.ts +++ b/_packages/native-preview/src/api/node/protocol.ts @@ -1,4 +1,4 @@ -export const PROTOCOL_VERSION = 5; +export const PROTOCOL_VERSION = 7; export const HEADER_OFFSET_METADATA = 0; export const HEADER_OFFSET_HASH_LO0 = 4; diff --git a/_packages/native-preview/src/api/options.ts b/_packages/native-preview/src/api/options.ts index 630893cea5d..dbeda1248bb 100644 --- a/_packages/native-preview/src/api/options.ts +++ b/_packages/native-preview/src/api/options.ts @@ -17,6 +17,8 @@ export interface ClientSpawnOptions { cwd?: string; /** Virtual filesystem callbacks */ fs?: FileSystem; + /** Allow trusted projects to execute configured external content mapper processes. */ + runExternalCode?: boolean; /** * When true, collect timing information for each request. The client * measures round-trip latency and bytes sent/received, and the server @@ -37,6 +39,15 @@ export function resolveExePath(options: ClientSpawnOptions): string { return options.tsserverPath ?? getExePath(); } +export function getAPIProcessArgs(options: ClientSpawnOptions, async: boolean): string[] { + const args = ["--api"]; + if (async) args.push("--async"); + args.push("--cwd", options.cwd ?? process.cwd()); + if (options.runExternalCode) args.push("--runExternalCode"); + if (options.collectTiming) args.push("--timing"); + return args; +} + export interface LSPConnectionOptions extends ClientSocketOptions { } diff --git a/_packages/native-preview/src/api/sync/client.ts b/_packages/native-preview/src/api/sync/client.ts index 81548106dac..615fc299fa7 100644 --- a/_packages/native-preview/src/api/sync/client.ts +++ b/_packages/native-preview/src/api/sync/client.ts @@ -3,6 +3,7 @@ import { type ClientOptions, type ClientSocketOptions, type ClientSpawnOptions, + getAPIProcessArgs, isSpawnOptions, resolveExePath, } from "../options.ts"; @@ -27,12 +28,7 @@ export class Client { throw new Error("Socket connections are not yet supported in the sync client"); } - const cwd = options.cwd ?? process.cwd(); - const args = [ - "--api", - "--cwd", - cwd, - ]; + const args = getAPIProcessArgs(options, false); // Enable virtual FS callbacks for each provided FS function const enabledCallbacks: (typeof fsCallbackNames[number])[] = []; @@ -49,7 +45,6 @@ export class Client { const collectTiming = options.collectTiming ?? false; if (collectTiming) { - args.push("--timing"); this.timing = new TimingCollector(); } diff --git a/_packages/native-preview/src/ast/ast.ts b/_packages/native-preview/src/ast/ast.ts index 04b273c8042..804f0c0c6a3 100644 --- a/_packages/native-preview/src/ast/ast.ts +++ b/_packages/native-preview/src/ast/ast.ts @@ -1,6 +1,7 @@ // ast.ts — Hand-written AST type definitions // Generated types are in ast.generated.ts +import type { DiagnosticDirectivePolicy } from "#enums/diagnosticDirectivePolicy"; import type { InternalSymbolName } from "#enums/internalSymbolName"; import type { LanguageVariant } from "#enums/languageVariant"; import type { NodeFlags } from "#enums/nodeFlags"; @@ -49,6 +50,7 @@ import type { WhileStatement, WithStatement, } from "./ast.generated.ts"; +import type { SpanMap } from "./spanMap.ts"; export { SyntaxKind } from "#enums/syntaxKind"; export { TokenFlags } from "#enums/tokenFlags"; @@ -113,11 +115,30 @@ export interface LineAndCharacter { readonly character: number; } +export interface MappedDiagnosticDirective { + readonly originalRange: ReadonlyTextRange; + readonly virtualRange: ReadonlyTextRange; + readonly policy: DiagnosticDirectivePolicy; + readonly unusedCode: number; +} + export interface SourceFile extends Node { readonly kind: SyntaxKind.SourceFile; readonly statements: NodeArray; readonly endOfFileToken: EndOfFile; readonly text: string; + readonly originalText: string; + readonly spanMap: SpanMap | undefined; + /** Identity of the content mapper that produced this source file. */ + readonly contentMapper?: string; + /** Filename used to determine the syntax and module semantics of the transformed content. */ + readonly virtualFileName?: string; + /** Framework-specific diagnostic directives applied to the transformed content. */ + readonly diagnosticDirectives?: readonly MappedDiagnosticDirective[]; + /** Compiler-assigned filenames of supplemental outputs associated with this canonical source file. */ + readonly supplementalSourceFileNames?: readonly string[]; + /** Canonical source filename associated with this supplemental output, if this is supplemental. */ + readonly canonicalSourceFileName?: string; readonly fileName: string; readonly path: Path; readonly languageVariant: LanguageVariant; diff --git a/_packages/native-preview/src/ast/factory.generated.ts b/_packages/native-preview/src/ast/factory.generated.ts index f8c1ca889f2..b2283698646 100644 --- a/_packages/native-preview/src/ast/factory.generated.ts +++ b/_packages/native-preview/src/ast/factory.generated.ts @@ -308,6 +308,9 @@ export class NodeObject { get body(): any { return this._data?.body; } + get canonicalSourceFileName(): any { + return this._data?.canonicalSourceFileName; + } get caseBlock(): any { return this._data?.caseBlock; } @@ -347,6 +350,9 @@ export class NodeObject { get containsOnlyTriviaWhiteSpaces(): any { return this._data?.containsOnlyTriviaWhiteSpaces; } + get contentMapper(): any { + return this._data?.contentMapper; + } get declarationList(): any { return this._data?.declarationList; } @@ -356,6 +362,9 @@ export class NodeObject { get defaultType(): any { return this._data?.defaultType; } + get diagnosticDirectives(): any { + return this._data?.diagnosticDirectives; + } get dotDotDotToken(): any { return this._data?.dotDotDotToken; } @@ -527,6 +536,9 @@ export class NodeObject { get operatorToken(): any { return this._data?.operatorToken; } + get originalText(): any { + return this._data?.originalText; + } get parameterName(): any { return this._data?.parameterName; } @@ -572,12 +584,18 @@ export class NodeObject { get scriptKind(): any { return this._data?.scriptKind; } + get spanMap(): any { + return this._data?.spanMap; + } get statement(): any { return this._data?.statement; } get statements(): any { return this._data?.statements; } + get supplementalSourceFileNames(): any { + return this._data?.supplementalSourceFileNames; + } get tag(): any { return this._data?.tag; } @@ -653,6 +671,9 @@ export class NodeObject { get variableDeclaration(): any { return this._data?.variableDeclaration; } + get virtualFileName(): any { + return this._data?.virtualFileName; + } get whenFalse(): any { return this._data?.whenFalse; } @@ -3790,6 +3811,11 @@ export function createSourceFile(statements: readonly Statement[], endOfFileToke statements: createNodeArray(statements), endOfFileToken, text, + originalText: text, + spanMap: undefined, + contentMapper: undefined, + virtualFileName: undefined, + diagnosticDirectives: undefined, fileName, path, }) as unknown as SourceFile; diff --git a/_packages/native-preview/src/ast/index.ts b/_packages/native-preview/src/ast/index.ts index a7079c98b0f..fcef01b579d 100644 --- a/_packages/native-preview/src/ast/index.ts +++ b/_packages/native-preview/src/ast/index.ts @@ -1,5 +1,6 @@ export { CharacterCodes } from "#enums/characterCodes"; export { CommentDirectiveType } from "#enums/commentDirectiveType"; +export { DiagnosticDirectivePolicy } from "#enums/diagnosticDirectivePolicy"; export { InternalSymbolName } from "#enums/internalSymbolName"; export { LanguageVariant } from "#enums/languageVariant"; export { ModifierFlags } from "#enums/modifierFlags"; @@ -7,6 +8,9 @@ export { NodeFlags } from "#enums/nodeFlags"; export { RegularExpressionFlags } from "#enums/regularExpressionFlags"; export { ScriptKind } from "#enums/scriptKind"; export { ScriptTarget } from "#enums/scriptTarget"; +export { SpanMapFeature } from "#enums/spanMapFeature"; +export { SpanMapFidelity } from "#enums/spanMapFidelity"; +export { SpanMapKind } from "#enums/spanMapKind"; export { SyntaxKind } from "#enums/syntaxKind"; export { TokenFlags } from "#enums/tokenFlags"; export * from "./ast.ts"; @@ -15,5 +19,6 @@ export * from "./clone.ts"; export * from "./is.ts"; export * from "./jsdoc.ts"; export * from "./scanner.ts"; +export * from "./spanMap.ts"; export * from "./utils.ts"; export * from "./visitor.ts"; diff --git a/_packages/native-preview/src/ast/spanMap.ts b/_packages/native-preview/src/ast/spanMap.ts new file mode 100644 index 00000000000..4ee2030008e --- /dev/null +++ b/_packages/native-preview/src/ast/spanMap.ts @@ -0,0 +1,425 @@ +import { SpanMapFeature } from "#enums/spanMapFeature"; +import { SpanMapFidelity } from "#enums/spanMapFidelity"; +import { SpanMapKind } from "#enums/spanMapKind"; +import type { ReadonlyTextRange } from "./ast.ts"; + +export { SpanMapFeature, SpanMapFidelity, SpanMapKind }; + +// Keep this in sync with spanmap.go + +/** Maps one half-open virtual range to one half-open original range. */ +export interface SpanMapSegment { + readonly virtualStart: number; + readonly virtualEnd: number; + readonly originalStart: number; + readonly originalEnd: number; + readonly kind: SpanMapKind; + readonly features?: SpanMapFeature; +} + +/** Internal segment representation after omitted features have been normalized to `All`. */ +type NormalizedSpanMapSegment = SpanMapSegment & { readonly features: SpanMapFeature; }; + +/** One virtual projection of an original position and its mapping fidelity. */ +export interface MappedPosition { + readonly position: number; + readonly fidelity: SpanMapFidelity; +} + +/** One virtual projection of an original range and its mapping fidelity. */ +export interface MappedRange { + readonly range: ReadonlyTextRange; + readonly fidelity: SpanMapFidelity; +} + +/** Provides bidirectional span-aware mapping between virtual and original text. */ +export class SpanMap { + readonly segments: readonly NormalizedSpanMapSegment[]; + private originalSegments: readonly NormalizedSpanMapSegment[] | undefined; + + /** Copies and sorts segments by virtual start, normalizing omitted features to `All`. */ + constructor(segments: readonly SpanMapSegment[]) { + this.segments = segments + .map(segment => ({ ...segment, features: segment.features ?? SpanMapFeature.All })) + .sort((left, right) => left.virtualStart - right.virtualStart); + } + + /** Reports whether a mapping is a precise, edit-safe projection through one verbatim segment. */ + static isExact(fidelity: SpanMapFidelity): boolean { + return fidelity === SpanMapFidelity.Exact; + } + + /** Reports whether a mapping lies in one verbatim or atom segment. */ + static isSingleSegment(fidelity: SpanMapFidelity): boolean { + return fidelity === SpanMapFidelity.Exact || fidelity === SpanMapFidelity.Atom; + } + + /** Reports whether the input had no counterpart in the target text. */ + static isNone(fidelity: SpanMapFidelity): boolean { + return fidelity === SpanMapFidelity.None; + } + + /** + * Maps a virtual range to original text. Gaps map to insertion points with `None` fidelity, + * and ranges crossing segment boundaries map their endpoints with `Approximate` fidelity. + */ + virtualToOriginalSpan(range: ReadonlyTextRange): MappedRange { + return this.mapRange(range, this.segments, false); + } + + /** Maps a visible LS result only when every covered segment participates in `feature`. */ + virtualToOriginalSpanForFeature(range: ReadonlyTextRange, feature: SpanMapFeature): MappedRange { + const mapped = this.virtualToOriginalSpan(range); + return this.virtualRangeSupportsFeature(range, feature) ? mapped : { ...mapped, fidelity: SpanMapFidelity.None }; + } + + /** Maps a virtual position to original text, using `None` fidelity for synthesized gaps. */ + virtualToOriginalPosition(position: number): MappedPosition { + return this.mapPoint(position, this.segments, false); + } + + virtualToOriginalPositionForFeature(position: number, feature: SpanMapFeature): MappedPosition { + const mapped = this.virtualToOriginalPosition(position); + const [index, inside] = segmentIndexAt(this.segments, position, false); + return inside && supportsFeature(this.segments[index], feature) ? mapped : { ...mapped, fidelity: SpanMapFidelity.None }; + } + + /** + * Returns every virtual projection of an original position whose segment participates in `feature`. + * Segment ends are inclusive for point mapping, so adjacent spans may both produce projections. + * Results are ordered by virtual position; uncovered or disabled positions produce no results. + */ + originalToVirtualPositions(position: number, feature: SpanMapFeature): readonly MappedPosition[] { + const groups = segmentGroupsAtOriginalPosition(this.getOriginalSegments(), position); + const results: MappedPosition[] = []; + for (const group of groups) { + for (const segment of group.segments) { + if (!supportsFeature(segment, feature)) continue; + const mapped = segment.kind === SpanMapKind.Verbatim + ? { position: mapVerbatimPosition(segment, position, true), fidelity: SpanMapFidelity.Exact } + : { position: group.atEnd ? segment.virtualEnd : segment.virtualStart, fidelity: SpanMapFidelity.Atom }; + if (!results.some(result => result.position === mapped.position && result.fidelity === mapped.fidelity)) { + results.push(mapped); + } + } + } + return results.sort((left, right) => left.position - right.position); + } + + /** + * Returns every feature-compatible virtual projection of an original range. + * A range contained by one duplicate group produces one exact or atom result per matching group member. + * + * A range that starts in one group and ends in another can have several possible virtual ranges. For + * example, suppose two original segments are each copied twice into the virtual text: + * + * ```text + * original: [ A ][ B ] + * [---) range from inside A to inside B + * + * virtual: [ A ][ B ] [ A ][ B ] + * ^ ^ ^ ^ + * start end start end + * 1 3 11 13 + * ``` + * + * The map says that the range may start at 1 or 11 and end at 3 or 13, but it does not say which copy of A + * belongs with which copy of B. We choose the smallest range around each possible location, producing [1,3) + * and [11,13). We do not return [1,13), because it contains both smaller candidates and would include code + * that may be unrelated to the original range. These cross-group results have approximate fidelity. + */ + originalToVirtualSpans(range: ReadonlyTextRange, feature: SpanMapFeature): readonly MappedRange[] { + const start = range.pos; + const end = Math.max(range.end, start); + const lastCharacter = end > start ? end - 1 : end; + const originalSegments = this.getOriginalSegments(); + const startSegments = segmentsAtOriginalPosition(originalSegments, start); + const endSegments = segmentsAtOriginalPosition(originalSegments, lastCharacter); + if (!startSegments || !endSegments) return []; + if (sameOriginalRange(startSegments[0], endSegments[0])) { + return originalToVirtualSpansInGroup(startSegments, start, end, feature); + } + const starts = originalStartProjections(startSegments, start, feature); + const ends = originalEndProjections(endSegments, end, feature); + if (starts.length === 0 || ends.length === 0) return []; + return starts.flatMap((virtualStart, index) => { + const virtualEnd = ends.find(end => end >= virtualStart); + return virtualEnd === undefined || index + 1 < starts.length && starts[index + 1] <= virtualEnd + ? [] + : [{ range: { pos: virtualStart, end: virtualEnd }, fidelity: SpanMapFidelity.Approximate }]; + }); + } + + /** Maps one range through an ordered segment index in the direction selected by `reverse`. */ + private mapRange(range: ReadonlyTextRange, segments: readonly SpanMapSegment[], reverse: boolean): MappedRange { + const start = range.pos; + const end = Math.max(range.end, start); + const [startIndex, startInside] = segmentIndexAt(segments, start, reverse); + const endProbe = end > start ? end - 1 : end; + const [endIndex, endInside] = segmentIndexAt(segments, endProbe, reverse); + + if (startIndex === endIndex && startInside === endInside) { + if (startInside) { + const segment = segments[startIndex]; + if (segment.kind === SpanMapKind.Verbatim) { + const mappedStart = mapVerbatimPosition(segment, start, reverse); + const mappedEnd = Math.max(mappedStart, mapVerbatimPosition(segment, end, reverse)); + return { range: { pos: mappedStart, end: mappedEnd }, fidelity: SpanMapFidelity.Exact }; + } + return { range: targetRange(segment, reverse), fidelity: SpanMapFidelity.Atom }; + } + const position = insertionPoint(segments, startIndex, reverse); + return { range: { pos: position, end: position }, fidelity: SpanMapFidelity.None }; + } + + const mappedStart = mapBoundary(segments, start, startIndex, startInside, reverse, false); + const mappedEnd = Math.max(mappedStart, mapBoundary(segments, end, endIndex, endInside, reverse, true)); + return { range: { pos: mappedStart, end: mappedEnd }, fidelity: SpanMapFidelity.Approximate }; + } + + /** Maps one position through an ordered segment index in the direction selected by `reverse`. */ + private mapPoint(position: number, segments: readonly SpanMapSegment[], reverse: boolean): MappedPosition { + const [index, inside] = segmentIndexAt(segments, position, reverse); + if (!inside) { + return { position: insertionPoint(segments, index, reverse), fidelity: SpanMapFidelity.None }; + } + const segment = segments[index]; + if (segment.kind === SpanMapKind.Verbatim) { + return { position: mapVerbatimPosition(segment, position, reverse), fidelity: SpanMapFidelity.Exact }; + } + return { + position: reverse ? segment.virtualStart : segment.originalStart, + fidelity: SpanMapFidelity.Atom, + }; + } + + /** Returns the lazily built segment index ordered by original start. */ + private getOriginalSegments(): readonly NormalizedSpanMapSegment[] { + return this.originalSegments ??= [...this.segments].sort((left, right) => + left.originalStart - right.originalStart + || left.originalEnd - right.originalEnd + || left.virtualStart - right.virtualStart + ); + } + + private virtualRangeSupportsFeature(range: ReadonlyTextRange, feature: SpanMapFeature): boolean { + const start = range.pos; + const end = Math.max(range.end, start); + if (start === end) { + const [index, inside] = segmentIndexAt(this.segments, start, false); + return inside && supportsFeature(this.segments[index], feature); + } + let [index, inside] = segmentIndexAt(this.segments, start, false); + if (!inside) return false; + let coveredThrough = start; + while (index < this.segments.length && coveredThrough < end) { + const segment = this.segments[index]; + if (segment.virtualStart > coveredThrough || segment.virtualEnd <= coveredThrough || !supportsFeature(segment, feature)) return false; + coveredThrough = segment.virtualEnd; + index++; + } + return coveredThrough >= end; + } +} + +/** + * Maps the inclusive start of an original range through every matching segment. Verbatim segments preserve + * the offset within the segment; atoms map to their virtual start. + * + * ```text + * original: [---------) + * ^ start + * + * virtual: [---------) [---------) + * ^ ^ + * result result + * ``` + */ +function originalStartProjections(segments: readonly NormalizedSpanMapSegment[], start: number, feature: SpanMapFeature): readonly number[] { + return segments + .filter(segment => supportsFeature(segment, feature)) + .map(segment => + segment.kind === SpanMapKind.Verbatim + ? mapVerbatimPosition(segment, start, true) + : segment.virtualStart + ); +} + +/** + * Maps the exclusive end of an original range through every matching segment. The caller uses `end - 1` + * to find the segment containing the final character, while this helper maps the `end` boundary itself. + * + * ```text + * original: [---------)[ next segment ) + * ^`-- end + * `--- end - 1 + * + * virtual: [---------) [---------) + * ^ ^ + * result result + * ``` + */ +function originalEndProjections(segments: readonly NormalizedSpanMapSegment[], end: number, feature: SpanMapFeature): readonly number[] { + return segments + .filter(segment => supportsFeature(segment, feature)) + .map(segment => + segment.kind === SpanMapKind.Verbatim + ? mapVerbatimPosition(segment, end, true) + : segment.virtualEnd + ); +} + +/** Maps a range whose boundaries are known to lie in one duplicate group. */ +function originalToVirtualSpansInGroup(segments: readonly NormalizedSpanMapSegment[], start: number, end: number, feature: SpanMapFeature): readonly MappedRange[] { + return segments + .filter(segment => supportsFeature(segment, feature)) + .map(segment => { + if (segment.kind === SpanMapKind.Verbatim) { + const mappedStart = mapVerbatimPosition(segment, start, true); + const mappedEnd = Math.max(mappedStart, mapVerbatimPosition(segment, end, true)); + return { range: { pos: mappedStart, end: mappedEnd }, fidelity: SpanMapFidelity.Exact }; + } + return { range: { pos: segment.virtualStart, end: segment.virtualEnd }, fidelity: SpanMapFidelity.Atom }; + }); +} + +/** Reports whether two segments belong to the same duplicate group. */ +function sameOriginalRange(left: SpanMapSegment, right: SpanMapSegment): boolean { + return left.originalStart === right.originalStart && left.originalEnd === right.originalEnd; +} + +/** + * Returns the complete duplicate group of mapping segments containing the original-text `position`. + * Segment ends are exclusive; starts, including zero-length segment starts, are included. It finds a candidate + * in O(log n), then scans only the duplicate group. `segments` must be ordered by original start, original end, + * and virtual start. + */ +function segmentsAtOriginalPosition(segments: readonly NormalizedSpanMapSegment[], position: number): readonly NormalizedSpanMapSegment[] | undefined { + let low = 0; + let high = segments.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (segments[middle].originalStart < position) low = middle + 1; + else high = middle; + } + let index = low < segments.length && segments[low].originalStart === position ? low : low - 1; + if ( + index < 0 || !( + segments[index].originalStart === position + || position < segments[index].originalEnd + ) + ) return undefined; + while (index > 0 && sameOriginalRange(segments[index - 1], segments[index])) index--; + let end = index + 1; + while (end < segments.length && sameOriginalRange(segments[end], segments[index])) end++; + return segments.slice(index, end); +} + +interface SegmentGroupAtOriginalPosition { + readonly segments: readonly NormalizedSpanMapSegment[]; + readonly atEnd: boolean; +} + +/** + * Returns groups of mapping segments containing or touching the original-text `position`. + * At a shared boundary, segments ending at the point and segments starting there form separate groups: + * + * ```text + * original: [--- A ---)[--- B ---) + * ^ position + * + * virtual: [ A1 ) [ A2 ) [ B1 ) [ B2 ) + * left group right group + * atEnd: true atEnd: false + * ``` + */ +function segmentGroupsAtOriginalPosition(segments: readonly NormalizedSpanMapSegment[], position: number): readonly SegmentGroupAtOriginalPosition[] { + let low = 0; + let high = segments.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (segments[middle].originalStart < position) low = middle + 1; + else high = middle; + } + if (low < segments.length && segments[low].originalStart === position) { + const right = segmentsAtOriginalPosition(segments, position)!; + const groups: SegmentGroupAtOriginalPosition[] = []; + if (low > 0 && segments[low - 1].originalEnd === position) { + let leftStart = low - 1; + while (leftStart > 0 && sameOriginalRange(segments[leftStart - 1], segments[low - 1])) leftStart--; + groups.push({ segments: segments.slice(leftStart, low), atEnd: true }); + } + groups.push({ segments: right, atEnd: false }); + return groups; + } + if (low === 0) return []; + const left = segments[low - 1]; + if (position > left.originalEnd) return []; + let start = low - 1; + while (start > 0 && sameOriginalRange(segments[start - 1], left)) start--; + return [{ segments: segments.slice(start, low), atEnd: position === left.originalEnd }]; +} + +/** Reports whether a segment participates in an original-to-virtual query for `features`. */ +function supportsFeature(segment: NormalizedSpanMapSegment, feature: SpanMapFeature): boolean { + return (segment.features & feature) !== 0; +} + +/** + * Finds the segment containing `position`, or the preceding segment when `position` is in a gap. + * The boolean distinguishes containment from a gap; `reverse` selects original rather than virtual coordinates. + */ +function segmentIndexAt(segments: readonly SpanMapSegment[], position: number, reverse: boolean): [number, boolean] { + let low = 0; + let high = segments.length; + while (low < high) { + const middle = (low + high) >>> 1; + const start = reverse ? segments[middle].originalStart : segments[middle].virtualStart; + if (start < position) low = middle + 1; + else high = middle; + } + if (low < segments.length && (reverse ? segments[low].originalStart : segments[low].virtualStart) === position) { + return [low, true]; + } + const previous = low - 1; + if (previous >= 0) { + const end = reverse ? segments[previous].originalEnd : segments[previous].virtualEnd; + if (position < end || previous === segments.length - 1 && position === end) return [previous, true]; + } + return [previous, false]; +} + +/** Returns the target insertion point for a gap following `previous`, or zero before the first segment. */ +function insertionPoint(segments: readonly SpanMapSegment[], previous: number, reverse: boolean): number { + if (previous < 0) return 0; + return reverse ? segments[previous].virtualEnd : segments[previous].originalEnd; +} + +/** Linearly maps and clamps a position within a length-preserving verbatim segment. */ +function mapVerbatimPosition(segment: SpanMapSegment, position: number, reverse: boolean): number { + const sourceStart = reverse ? segment.originalStart : segment.virtualStart; + const targetStart = reverse ? segment.virtualStart : segment.originalStart; + const targetEnd = reverse ? segment.virtualEnd : segment.originalEnd; + return clamp(targetStart + position - sourceStart, targetStart, targetEnd); +} + +/** Maps a range boundary, using insertion points for gaps and the selected endpoint for atoms. */ +function mapBoundary(segments: readonly SpanMapSegment[], position: number, index: number, inside: boolean, reverse: boolean, high: boolean): number { + if (!inside) return insertionPoint(segments, index, reverse); + const segment = segments[index]; + if (segment.kind === SpanMapKind.Verbatim) return mapVerbatimPosition(segment, position, reverse); + if (reverse) return high ? segment.virtualEnd : segment.virtualStart; + return high ? segment.originalEnd : segment.originalStart; +} + +/** Returns the complete target range of a segment in the selected direction. */ +function targetRange(segment: SpanMapSegment, reverse: boolean): ReadonlyTextRange { + return reverse + ? { pos: segment.virtualStart, end: segment.virtualEnd } + : { pos: segment.originalStart, end: segment.originalEnd }; +} + +/** Confines `value` to the inclusive interval [`low`, `high`]. */ +function clamp(value: number, low: number, high: number): number { + return Math.max(low, Math.min(value, high)); +} diff --git a/_packages/native-preview/src/ast/utils.ts b/_packages/native-preview/src/ast/utils.ts index 0e158659ab8..f3018566aa4 100644 --- a/_packages/native-preview/src/ast/utils.ts +++ b/_packages/native-preview/src/ast/utils.ts @@ -76,6 +76,13 @@ export function cloneSourceFileData(sourceFile: SourceFile): Record { // Verify header const view = new DataView(encoded.buffer, encoded.byteOffset, encoded.byteLength); const metadata = view.getUint32(0, true); - assert.strictEqual(metadata >>> 24, 5, "protocol version should be 5"); + assert.strictEqual(metadata >>> 24, 7, "protocol version should be 7"); // Verify we can decode it const decoded = decode(encoded); @@ -75,6 +75,11 @@ describe("Encoder", () => { assert.strictEqual(decoded.fileName, "/test.ts"); assert.strictEqual(decoded.path, "/test.ts"); assert.strictEqual(decoded.text, ""); + assert.strictEqual(decoded.contentMapper, undefined); + assert.strictEqual(decoded.virtualFileName, undefined); + assert.strictEqual(decoded.diagnosticDirectives, undefined); + assert.strictEqual(decoded.supplementalSourceFileNames, undefined); + assert.strictEqual(decoded.canonicalSourceFileName, undefined); }); test("encodes source file with identifier", () => { @@ -174,11 +179,20 @@ describe("Encoder", () => { assert.strictEqual(rootKind, SyntaxKind.IfStatement); }); - test("protocol version is 5", () => { + test("protocol version is 7", () => { const sf = makeSF("", "/test.ts", []); const encoded = encodeSourceFile(sf); const view = new DataView(encoded.buffer, encoded.byteOffset, encoded.byteLength); - assert.strictEqual(view.getUint32(0, true) >>> 24, 5); + assert.strictEqual(view.getUint32(0, true) >>> 24, 7); + }); + + test("encodes source files without content mapping metadata", () => { + const ordinary = decode(encodeSourceFile(makeSF("text", "/test.ts", []))); + assert.equal(ordinary.originalText, ordinary.text); + assert.equal(ordinary.spanMap, undefined); + assert.equal(ordinary.contentMapper, undefined); + assert.equal(ordinary.virtualFileName, undefined); + assert.equal(ordinary.diagnosticDirectives, undefined); }); test("boolean properties are encoded", () => { diff --git a/_packages/native-preview/test/spanMap.test.ts b/_packages/native-preview/test/spanMap.test.ts new file mode 100644 index 00000000000..996f0c80d09 --- /dev/null +++ b/_packages/native-preview/test/spanMap.test.ts @@ -0,0 +1,144 @@ +import { + SpanMap, + SpanMapFeature, + SpanMapFidelity, + SpanMapKind, +} from "@typescript/native-preview/unstable/ast"; +import assert from "node:assert"; +import { + describe, + test, +} from "node:test"; + +describe("SpanMap", () => { + const map = new SpanMap([ + { virtualStart: 2, virtualEnd: 6, originalStart: 10, originalEnd: 14, kind: SpanMapKind.Verbatim }, + { virtualStart: 8, virtualEnd: 11, originalStart: 20, originalEnd: 27, kind: SpanMapKind.Atom }, + { virtualStart: 14, virtualEnd: 18, originalStart: 30, originalEnd: 34, kind: SpanMapKind.Verbatim }, + ]); + + test("maps virtual positions and ranges to original", () => { + assert.equal(map.segments[0].features, SpanMapFeature.All); + assert.deepEqual(map.virtualToOriginalPosition(4), { position: 12, fidelity: SpanMapFidelity.Exact }); + assert.deepEqual(map.virtualToOriginalSpan({ pos: 3, end: 5 }), { range: { pos: 11, end: 13 }, fidelity: SpanMapFidelity.Exact }); + assert.deepEqual(map.virtualToOriginalPosition(9), { position: 20, fidelity: SpanMapFidelity.Atom }); + assert.deepEqual(map.virtualToOriginalSpan({ pos: 8, end: 10 }), { range: { pos: 20, end: 27 }, fidelity: SpanMapFidelity.Atom }); + assert.deepEqual(map.virtualToOriginalSpan({ pos: 5, end: 15 }), { range: { pos: 13, end: 31 }, fidelity: SpanMapFidelity.Approximate }); + }); + + test("maps aliases with atom geometry", () => { + const alias = new SpanMap([ + { virtualStart: 0, virtualEnd: 3, originalStart: 0, originalEnd: 1, kind: SpanMapKind.Alias }, + ]); + assert.deepEqual(alias.virtualToOriginalSpan({ pos: 0, end: 3 }), { + range: { pos: 0, end: 1 }, + fidelity: SpanMapFidelity.Atom, + }); + }); + + test("maps synthesized gaps to insertion points", () => { + assert.deepEqual(map.virtualToOriginalPosition(0), { position: 0, fidelity: SpanMapFidelity.None }); + assert.deepEqual(map.virtualToOriginalSpan({ pos: 6, end: 8 }), { range: { pos: 14, end: 14 }, fidelity: SpanMapFidelity.None }); + assert.deepEqual(map.virtualToOriginalPosition(19), { position: 34, fidelity: SpanMapFidelity.None }); + }); + + test("maps original positions and ranges to virtual", () => { + assert.deepEqual(map.originalToVirtualPositions(12, SpanMapFeature.All), [{ position: 4, fidelity: SpanMapFidelity.Exact }]); + assert.deepEqual(map.originalToVirtualSpans({ pos: 21, end: 25 }, SpanMapFeature.All), [{ range: { pos: 8, end: 11 }, fidelity: SpanMapFidelity.Atom }]); + assert.deepEqual(map.originalToVirtualSpans({ pos: 13, end: 31 }, SpanMapFeature.All), [{ range: { pos: 5, end: 15 }, fidelity: SpanMapFidelity.Approximate }]); + assert.deepEqual(map.originalToVirtualPositions(15, SpanMapFeature.All), []); + }); + + test("maps segment endpoints", () => { + assert.deepEqual(map.virtualToOriginalPosition(18), { position: 34, fidelity: SpanMapFidelity.Exact }); + assert.deepEqual(map.originalToVirtualPositions(34, SpanMapFeature.All), [{ position: 18, fidelity: SpanMapFidelity.Exact }]); + assert.deepEqual(map.virtualToOriginalPosition(6), { position: 14, fidelity: SpanMapFidelity.None }); + assert.deepEqual(map.originalToVirtualPositions(14, SpanMapFeature.All), [{ position: 6, fidelity: SpanMapFidelity.Exact }]); + + const adjacent = new SpanMap([ + { virtualStart: 20, virtualEnd: 23, originalStart: 10, originalEnd: 13, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover }, + { virtualStart: 2, virtualEnd: 5, originalStart: 13, originalEnd: 16, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Completion }, + { virtualStart: 30, virtualEnd: 33, originalStart: 20, originalEnd: 25, kind: SpanMapKind.Atom }, + ]); + assert.deepEqual(adjacent.originalToVirtualPositions(13, SpanMapFeature.All), [ + { position: 2, fidelity: SpanMapFidelity.Exact }, + { position: 23, fidelity: SpanMapFidelity.Exact }, + ]); + assert.deepEqual(adjacent.originalToVirtualPositions(13, SpanMapFeature.Completion), [ + { position: 2, fidelity: SpanMapFidelity.Exact }, + ]); + assert.deepEqual(adjacent.originalToVirtualPositions(25, SpanMapFeature.All), [ + { position: 33, fidelity: SpanMapFidelity.Atom }, + ]); + }); + + test("sorts virtual and original indexes independently", () => { + const reordered = new SpanMap([ + { virtualStart: 0, virtualEnd: 2, originalStart: 10, originalEnd: 12, kind: SpanMapKind.Verbatim }, + { virtualStart: 2, virtualEnd: 4, originalStart: 0, originalEnd: 2, kind: SpanMapKind.Verbatim }, + ]); + assert.deepEqual(reordered.virtualToOriginalPosition(3), { position: 1, fidelity: SpanMapFidelity.Exact }); + assert.deepEqual(reordered.originalToVirtualPositions(1, SpanMapFeature.All), [{ position: 3, fidelity: SpanMapFidelity.Exact }]); + }); + + test("an empty map describes fully synthesized output", () => { + const empty = new SpanMap([]); + assert.deepEqual(empty.virtualToOriginalPosition(5), { position: 0, fidelity: SpanMapFidelity.None }); + assert.deepEqual(empty.virtualToOriginalSpan({ pos: 2, end: 7 }), { range: { pos: 0, end: 0 }, fidelity: SpanMapFidelity.None }); + assert.deepEqual(empty.originalToVirtualPositions(5, SpanMapFeature.All), []); + }); + + test("maps duplicate groups by features", () => { + const duplicates = new SpanMap([ + { virtualStart: 0, virtualEnd: 3, originalStart: 10, originalEnd: 13, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Definition }, + { virtualStart: 10, virtualEnd: 13, originalStart: 10, originalEnd: 13, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover }, + { virtualStart: 14, virtualEnd: 17, originalStart: 10, originalEnd: 13, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover }, + { virtualStart: 20, virtualEnd: 25, originalStart: 10, originalEnd: 13, kind: SpanMapKind.Atom, features: SpanMapFeature.Definition }, + ]); + + assert.deepEqual(duplicates.originalToVirtualPositions(11, SpanMapFeature.Hover), [ + { position: 11, fidelity: SpanMapFidelity.Exact }, + { position: 15, fidelity: SpanMapFidelity.Exact }, + ]); + assert.deepEqual(duplicates.originalToVirtualPositions(11, SpanMapFeature.Definition), [ + { position: 1, fidelity: SpanMapFidelity.Exact }, + { position: 20, fidelity: SpanMapFidelity.Atom }, + ]); + assert.deepEqual(duplicates.originalToVirtualPositions(13, SpanMapFeature.Hover), [ + { position: 13, fidelity: SpanMapFidelity.Exact }, + { position: 17, fidelity: SpanMapFidelity.Exact }, + ]); + }); + + test("maps minimal cross-group projections", () => { + const projections = new SpanMap([ + { virtualStart: 0, virtualEnd: 2, originalStart: 0, originalEnd: 2, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover }, + { virtualStart: 2, virtualEnd: 4, originalStart: 2, originalEnd: 4, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover }, + { virtualStart: 10, virtualEnd: 12, originalStart: 0, originalEnd: 2, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover }, + { virtualStart: 12, virtualEnd: 14, originalStart: 2, originalEnd: 4, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover }, + ]); + + assert.deepEqual(projections.originalToVirtualSpans({ pos: 1, end: 3 }, SpanMapFeature.Hover), [ + { range: { pos: 1, end: 3 }, fidelity: SpanMapFidelity.Approximate }, + { range: { pos: 11, end: 13 }, fidelity: SpanMapFidelity.Approximate }, + ]); + }); + + test("explicit zero features disables original-to-virtual mapping", () => { + const disabled = new SpanMap([ + { virtualStart: 0, virtualEnd: 3, originalStart: 10, originalEnd: 13, kind: SpanMapKind.Verbatim, features: SpanMapFeature.None }, + ]); + + assert.deepEqual(disabled.originalToVirtualPositions(11, SpanMapFeature.Hover), []); + assert.deepEqual(disabled.originalToVirtualPositions(11, SpanMapFeature.Definition), []); + assert.deepEqual(disabled.originalToVirtualSpans({ pos: 10, end: 13 }, SpanMapFeature.Hover), []); + }); + + test("exposes fidelity predicates", () => { + assert.equal(SpanMap.isExact(SpanMapFidelity.Exact), true); + assert.equal(SpanMap.isSingleSegment(SpanMapFidelity.Exact), true); + assert.equal(SpanMap.isSingleSegment(SpanMapFidelity.Atom), true); + assert.equal(SpanMap.isSingleSegment(SpanMapFidelity.Approximate), false); + assert.equal(SpanMap.isNone(SpanMapFidelity.None), true); + }); +}); diff --git a/_packages/native-preview/test/sync/ast.test.ts b/_packages/native-preview/test/sync/ast.test.ts index ecab77f4e67..b7ec9e47b52 100644 --- a/_packages/native-preview/test/sync/ast.test.ts +++ b/_packages/native-preview/test/sync/ast.test.ts @@ -811,6 +811,13 @@ describe("RemoteNode + getSynthesizedDeepClone", () => { assert.strictEqual(clone.moduleAugmentations, moduleAugmentations); assert.strictEqual(clone.ambientModuleNames, ambientModuleNames); assert.strictEqual(clone.externalModuleIndicator, sf.externalModuleIndicator); + assert.strictEqual(clone.originalText, sf.originalText); + assert.strictEqual(clone.spanMap, sf.spanMap); + assert.strictEqual(clone.contentMapper, sf.contentMapper); + assert.strictEqual(clone.virtualFileName, sf.virtualFileName); + assert.strictEqual(clone.diagnosticDirectives, sf.diagnosticDirectives); + assert.strictEqual(clone.supplementalSourceFileNames, sf.supplementalSourceFileNames); + assert.strictEqual(clone.canonicalSourceFileName, sf.canonicalSourceFileName); } finally { api.close(); diff --git a/_scripts/generate-ts-ast.ts b/_scripts/generate-ts-ast.ts index eed6f9b862f..24b511ad2fe 100644 --- a/_scripts/generate-ts-ast.ts +++ b/_scripts/generate-ts-ast.ts @@ -580,9 +580,16 @@ function generateFactory(): string { for ( const name of [ "fileName", + "originalText", + "contentMapper", + "virtualFileName", + "diagnosticDirectives", + "supplementalSourceFileNames", + "canonicalSourceFileName", "path", "languageVariant", "scriptKind", + "spanMap", "isDeclarationFile", "referencedFiles", "typeReferenceDirectives", @@ -1171,6 +1178,11 @@ function generateFactory(): string { out.push(` statements: createNodeArray(statements),`); out.push(` endOfFileToken,`); out.push(` text,`); + out.push(` originalText: text,`); + out.push(` spanMap: undefined,`); + out.push(` contentMapper: undefined,`); + out.push(` virtualFileName: undefined,`); + out.push(` diagnosticDirectives: undefined,`); out.push(` fileName,`); out.push(` path,`); out.push(` }) as unknown as SourceFile;`); diff --git a/cmd/tsgo/api.go b/cmd/tsgo/api.go index fa47eb9b0d4..f23332a8c1c 100644 --- a/cmd/tsgo/api.go +++ b/cmd/tsgo/api.go @@ -14,14 +14,33 @@ import ( "github.com/microsoft/typescript-go/internal/core" ) +type apiFlags struct { + cwd string + pipePath string + callbacks string + async bool + timing bool + runExternalCode bool +} + +func parseAPIFlags(args []string) (apiFlags, error) { + flags := flag.NewFlagSet("api", flag.ContinueOnError) + result := apiFlags{} + flags.StringVar(&result.cwd, "cwd", core.Must(os.Getwd()), "current working directory") + flags.StringVar(&result.pipePath, "pipe", "", "use named pipe or Unix domain socket for communication instead of stdio") + flags.StringVar(&result.callbacks, "callbacks", "", "comma-separated list of FS callbacks to enable (readFile,fileExists,directoryExists,getAccessibleEntries,realpath)") + flags.BoolVar(&result.async, "async", false, "use JSON-RPC protocol instead of MessagePack (for async API)") + flags.BoolVar(&result.timing, "timing", false, "collect per-request server processing time, folded into the client's timing snapshot") + flags.BoolVar(&result.runExternalCode, "runExternalCode", false, "allow projects to execute configured external plugins") + if err := flags.Parse(args); err != nil { + return apiFlags{}, err + } + return result, nil +} + func runAPI(args []string) int { - flag := flag.NewFlagSet("api", flag.ContinueOnError) - cwd := flag.String("cwd", core.Must(os.Getwd()), "current working directory") - pipePath := flag.String("pipe", "", "use named pipe or Unix domain socket for communication instead of stdio") - callbacks := flag.String("callbacks", "", "comma-separated list of FS callbacks to enable (readFile,fileExists,directoryExists,getAccessibleEntries,realpath)") - async := flag.Bool("async", false, "use JSON-RPC protocol instead of MessagePack (for async API)") - timing := flag.Bool("timing", false, "collect per-request server processing time, folded into the client's timing snapshot") - if err := flag.Parse(args); err != nil { + flags, err := parseAPIFlags(args) + if err != nil { return 2 } @@ -29,20 +48,22 @@ func runAPI(args []string) int { // Parse callbacks list var callbacksList []string - if *callbacks != "" { - callbacksList = strings.Split(*callbacks, ",") + if flags.callbacks != "" { + callbacksList = strings.Split(flags.callbacks, ",") } options := &api.StdioServerOptions{ - Err: os.Stderr, - Cwd: *cwd, - DefaultLibraryPath: defaultLibraryPath, - Callbacks: callbacksList, - Async: *async, - CollectTiming: *timing, + Err: os.Stderr, + Cwd: flags.cwd, + DefaultLibraryPath: defaultLibraryPath, + Callbacks: callbacksList, + Async: flags.async, + CollectTiming: flags.timing, + RunExternalCode: flags.runExternalCode, + ContentMapperSpawner: newSystem(), } - if *pipePath != "" { - options.PipePath = *pipePath + if flags.pipePath != "" { + options.PipePath = flags.pipePath } else { options.In = os.Stdin options.Out = os.Stdout diff --git a/cmd/tsgo/lsp.go b/cmd/tsgo/lsp.go index e5a99a78eeb..9105bd330d3 100644 --- a/cmd/tsgo/lsp.go +++ b/cmd/tsgo/lsp.go @@ -61,6 +61,7 @@ func runLSP(args []string) int { cmd.Dir = cwd return cmd.Output() }, + Spawn: spawnProcess, ProgressDelay: 250 * time.Millisecond, SetParentProcessID: newParentProcessWatchdog(ctx, stop, *clientProcessID), }) diff --git a/cmd/tsgo/sys.go b/cmd/tsgo/sys.go index e5b2b8d569a..12fe54224bf 100644 --- a/cmd/tsgo/sys.go +++ b/cmd/tsgo/sys.go @@ -1,9 +1,11 @@ package main import ( + "errors" "fmt" "io" "os" + "os/exec" "time" "github.com/microsoft/typescript-go/internal/bundled" @@ -46,6 +48,10 @@ func (s *osSys) Writer() io.Writer { return s.writer } +func (s *osSys) ErrorWriter() io.Writer { + return os.Stderr +} + func (s *osSys) WriteOutputIsTTY() bool { return term.IsTerminal(int(os.Stdout.Fd())) } @@ -59,6 +65,62 @@ func (s *osSys) GetEnvironmentVariable(name string) string { return os.Getenv(name) } +func (s *osSys) Spawn(command []string, dir string, stderr io.Writer) (io.ReadWriteCloser, error) { + return spawnProcess(command, dir, stderr) +} + +// spawnProcess launches a process and adapts its stdio to an io.ReadWriteCloser (Read is its stdout, +// Write is its stdin). +func spawnProcess(command []string, dir string, stderr io.Writer) (io.ReadWriteCloser, error) { + cmd := exec.Command(command[0], command[1:]...) + cmd.Dir = dir + cmd.Stderr = stderr + cmd.WaitDelay = time.Second + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, err + } + return &childProcess{cmd: cmd, stdin: stdin, stdout: stdout}, nil +} + +// childProcess adapts a spawned process's stdout (read) and stdin (write) into one io.ReadWriteCloser. +// Close kills and reaps the process. +type childProcess struct { + cmd *exec.Cmd + stdin io.WriteCloser + stdout io.Reader +} + +func (p *childProcess) Read(b []byte) (int, error) { return p.stdout.Read(b) } +func (p *childProcess) Write(b []byte) (int, error) { return p.stdin.Write(b) } + +func (p *childProcess) ExitCode() (int, bool) { + if p.cmd.ProcessState == nil { + return 0, false + } + return p.cmd.ProcessState.ExitCode(), true +} + +func (p *childProcess) Close() error { + _ = p.stdin.Close() + _ = p.cmd.Process.Kill() + err := p.cmd.Wait() + if _, ok := errors.AsType[*exec.ExitError](err); ok { + return nil + } + if errors.Is(err, exec.ErrWaitDelay) { + return nil + } + return err +} + func newSystem() *osSys { cwd, err := os.Getwd() if err != nil { diff --git a/cmd/tsgo/sys_unix_test.go b/cmd/tsgo/sys_unix_test.go new file mode 100644 index 00000000000..4192f1acce6 --- /dev/null +++ b/cmd/tsgo/sys_unix_test.go @@ -0,0 +1,42 @@ +//go:build unix + +package main + +import ( + "bufio" + "bytes" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "gotest.tools/v3/assert" +) + +func TestChildProcessCloseDoesNotWaitForLauncherDescendants(t *testing.T) { + t.Parallel() + process, err := spawnProcess([]string{"sh", "-c", "nohup sleep 60 & echo $!; wait"}, "", &bytes.Buffer{}) + assert.NilError(t, err) + pidText, err := bufio.NewReader(process).ReadString('\n') + assert.NilError(t, err) + descendantPID, err := strconv.Atoi(strings.TrimSpace(pidText)) + assert.NilError(t, err) + done := make(chan error, 1) + go func() { done <- process.Close() }() + + completed := false + select { + case err := <-done: + assert.NilError(t, err) + completed = true + case <-time.After(2 * time.Second): + _ = syscall.Kill(descendantPID, syscall.SIGKILL) + <-done + completed = false + } + assert.Assert(t, completed, "child process shutdown waited for a launcher descendant") + if isProcessAlive(descendantPID) { + _ = syscall.Kill(descendantPID, syscall.SIGKILL) + } +} diff --git a/internal/api/callbackfs.go b/internal/api/callbackfs.go index 1f6dc0a1bed..bbf85033dd8 100644 --- a/internal/api/callbackfs.go +++ b/internal/api/callbackfs.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "github.com/microsoft/typescript-go/internal/ipc" "github.com/microsoft/typescript-go/internal/json" "github.com/microsoft/typescript-go/internal/vfs" ) @@ -21,7 +22,7 @@ type callbackFS struct { enabledCallbacks map[string]bool // conn and ctx are set after connection is established - conn Conn + conn ipc.Conn ctx context.Context } @@ -69,7 +70,7 @@ func newCallbackFS(base vfs.FS, callbacks []string) *callbackFS { // SetConnection sets the RPC connection for callbacks. // This must be called after the transport connection is established // but before any filesystem operations that need callbacks. -func (fs *callbackFS) SetConnection(ctx context.Context, conn Conn) { +func (fs *callbackFS) SetConnection(ctx context.Context, conn ipc.Conn) { fs.ctx = ctx fs.conn = conn } diff --git a/internal/api/encoder/encoder.go b/internal/api/encoder/encoder.go index dd93a2ea3ba..d75659893e6 100644 --- a/internal/api/encoder/encoder.go +++ b/internal/api/encoder/encoder.go @@ -9,6 +9,7 @@ import ( "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/spanmap" "github.com/zeebo/xxh3" ) @@ -62,7 +63,7 @@ const ( ) const ( - ProtocolVersion uint8 = 5 + ProtocolVersion uint8 = 7 ) // Source File Binary Format @@ -148,6 +149,13 @@ const ( // | 36-40 | uint32 | Byte offset of `moduleAugmentations` node index array | // | 40-44 | uint32 | Byte offset of `ambientModuleNames` string array | // | 44-48 | uint32 | Node index of `externalModuleIndicator` (0 = nil) | +// | 48-52 | uint32 | Index of `originalText` in the string offsets section | +// | 52-56 | uint32 | Byte offset of `spanMap` in structured data | +// | 56-60 | uint32 | Byte offset of `supplementalSourceFileNames` in structured data | +// | 60-64 | uint32 | Index of `canonicalSourceFileName`, or noStructuredData | +// | 64-68 | uint32 | Index of `contentMapper`, or noStructuredData | +// | 68-72 | uint32 | Index of `virtualFileName`, or noStructuredData | +// | 72-76 | uint32 | Byte offset of `diagnosticDirectives` in structured data | // // Structured data (variable) // -------------------------- @@ -161,6 +169,14 @@ const ( // value is a node index into the nodes section. String arrays (ambientModuleNames) are msgpack // arrays of string values. // +// Span maps are msgpack arrays of tuples in UTF-16 coordinates: +// +// [virtualStart: uint, virtualLength: uint, originalStart: uint, originalLength: uint, kind: uint, features?: uint] +// +// Diagnostic directives are msgpack arrays of normalized tuples in UTF-16 coordinates: +// +// [originalStart: uint, originalLength: uint, virtualStart: uint, virtualLength: uint, policy: uint, unusedCode: uint] +// // An offset of 0xFFFFFFFF indicates no data (empty array). // // Nodes (28 bytes per node) @@ -645,14 +661,37 @@ const noStructuredData = 0xFFFFFFFF func recordExtendedData_SourceFile(node *ast.Node, strs *stringTable, positionMap *ast.PositionMap, extendedData *[]byte, structuredData *[]byte) { sf := node.AsSourceFile() textIndex := strs.add(sf.Text(), sf.Kind, sf.Pos(), sf.End()) + originalTextIndex := textIndex + if sf.OriginalText() != sf.Text() { + originalTextIndex = strs.add(sf.OriginalText(), 0, 0, 0) + } fileNameIndex := strs.add(sf.FileName(), 0, 0, 0) pathIndex := strs.add(string(sf.Path()), 0, 0, 0) referencedFilesOffset := encodeFileReferences(sf.ReferencedFiles, positionMap, structuredData) typeRefDirectivesOffset := encodeFileReferences(sf.TypeReferenceDirectives, positionMap, structuredData) libRefDirectivesOffset := encodeFileReferences(sf.LibReferenceDirectives, positionMap, structuredData) + spanMapOffset := uint32(noStructuredData) + if spanMap := sf.SpanMap(); spanMap != nil { + spanMapOffset = encodeSpanMap(spanMap, positionMap, ast.ComputePositionMap(sf.OriginalText()), structuredData) + } + supplementalFileNames := core.Map(sf.SupplementalSourceFiles(), func(file *ast.SourceFile) string { return file.FileName() }) + supplementalFileNamesOffset := encodeStringArray(supplementalFileNames, structuredData) + canonicalFileNameIndex := uint32(noStructuredData) + if canonical := sf.CanonicalSourceFile(); canonical != nil { + canonicalFileNameIndex = strs.add(canonical.FileName(), 0, 0, 0) + } + contentMapperIndex := uint32(noStructuredData) + if contentMapper := sf.ContentMapper(); contentMapper != "" { + contentMapperIndex = strs.add(contentMapper, 0, 0, 0) + } + virtualFileNameIndex := uint32(noStructuredData) + if virtualFileName := sf.VirtualFileName(); virtualFileName != "" { + virtualFileNameIndex = strs.add(virtualFileName, 0, 0, 0) + } + diagnosticDirectivesOffset := encodeDiagnosticDirectives(sf.DiagnosticDirectives(), positionMap, ast.ComputePositionMap(sf.OriginalText()), structuredData) // imports, moduleAugmentations, ambientModuleNames offsets are placeholders; // they will be patched after the tree walk when node indices are known. - *extendedData = appendUint32s(*extendedData, textIndex, fileNameIndex, pathIndex, uint32(sf.LanguageVariant), uint32(sf.ScriptKind), referencedFilesOffset, typeRefDirectivesOffset, libRefDirectivesOffset, noStructuredData, noStructuredData, noStructuredData, 0) + *extendedData = appendUint32s(*extendedData, textIndex, fileNameIndex, pathIndex, uint32(sf.LanguageVariant), uint32(sf.ScriptKind), referencedFilesOffset, typeRefDirectivesOffset, libRefDirectivesOffset, noStructuredData, noStructuredData, noStructuredData, 0, originalTextIndex, spanMapOffset, supplementalFileNamesOffset, canonicalFileNameIndex, contentMapperIndex, virtualFileNameIndex, diagnosticDirectivesOffset) } func recordExtendedData_TemplateHead(node *ast.Node, strs *stringTable, positionMap *ast.PositionMap, extendedData *[]byte, structuredData *[]byte) { @@ -753,6 +792,57 @@ func encodeStringArray(strs []string, buf *[]byte) uint32 { return offset } +func encodeSpanMap(m *spanmap.SpanMap, virtualPositions *ast.PositionMap, originalPositions *ast.PositionMap, buf *[]byte) uint32 { + if m == nil { + return noStructuredData + } + segments := m.Segments() + offset := uint32(len(*buf)) + *buf = msgpackWriteArrayHeader(*buf, len(segments)) + for _, segment := range segments { + tupleLength := 5 + if segment.Features != spanmap.FeatureAll { + tupleLength = 6 + } + *buf = msgpackWriteArrayHeader(*buf, tupleLength) + virtualStart := virtualPositions.UTF8ToUTF16(int(segment.VirtualStart)) + virtualEnd := virtualPositions.UTF8ToUTF16(int(segment.VirtualEnd)) + originalStart := originalPositions.UTF8ToUTF16(int(segment.OriginalStart)) + originalEnd := originalPositions.UTF8ToUTF16(int(segment.OriginalEnd)) + *buf = msgpackWriteUint(*buf, uint32(virtualStart)) + *buf = msgpackWriteUint(*buf, uint32(virtualEnd-virtualStart)) + *buf = msgpackWriteUint(*buf, uint32(originalStart)) + *buf = msgpackWriteUint(*buf, uint32(originalEnd-originalStart)) + *buf = msgpackWriteUint(*buf, uint32(segment.Kind)) + if tupleLength == 6 { + *buf = msgpackWriteUint(*buf, uint32(segment.Features)) + } + } + return offset +} + +func encodeDiagnosticDirectives(directives []ast.MappedDiagnosticDirective, virtualPositions *ast.PositionMap, originalPositions *ast.PositionMap, buf *[]byte) uint32 { + if len(directives) == 0 { + return noStructuredData + } + offset := uint32(len(*buf)) + *buf = msgpackWriteArrayHeader(*buf, len(directives)) + for _, directive := range directives { + *buf = msgpackWriteArrayHeader(*buf, 6) + originalStart := originalPositions.UTF8ToUTF16(directive.OriginalRange.Pos()) + originalEnd := originalPositions.UTF8ToUTF16(directive.OriginalRange.End()) + virtualStart := virtualPositions.UTF8ToUTF16(directive.VirtualRange.Pos()) + virtualEnd := virtualPositions.UTF8ToUTF16(directive.VirtualRange.End()) + *buf = msgpackWriteUint(*buf, uint32(originalStart)) + *buf = msgpackWriteUint(*buf, uint32(originalEnd-originalStart)) + *buf = msgpackWriteUint(*buf, uint32(virtualStart)) + *buf = msgpackWriteUint(*buf, uint32(virtualEnd-virtualStart)) + *buf = msgpackWriteUint(*buf, uint32(directive.Policy)) + *buf = msgpackWriteUint(*buf, uint32(directive.UnusedCode)) + } + return offset +} + // Minimal msgpack writers for the structured data section. func msgpackWriteArrayHeader(buf []byte, length int) []byte { diff --git a/internal/api/encoder/encoder_test.go b/internal/api/encoder/encoder_test.go index de774c74e98..b822abcfc6c 100644 --- a/internal/api/encoder/encoder_test.go +++ b/internal/api/encoder/encoder_test.go @@ -35,6 +35,62 @@ func TestEncodeSourceFile(t *testing.T) { }) } +func TestEncodeContentMapperSourceFileMetadata(t *testing.T) { + t.Parallel() + if encoder.ProtocolVersion != 7 { + t.Fatalf("protocol version = %d, want 7", encoder.ProtocolVersion) + } + sourceFile := parser.ParseSourceFile(ast.SourceFileParseOptions{ + FileName: "/component.vue", + Path: "/component.vue", + }, "😀virtual", core.ScriptKindTS) + sourceFile.SetContentMapperInfo(ast.ContentMapperSourceFileInfo{ + OriginalText: "😀original", + ContentMapper: "mapper@1.0.0", + VirtualFileName: "/component.vue.ts", + DiagnosticDirectives: []ast.MappedDiagnosticDirective{{ + OriginalRange: core.NewTextRange(4, 5), + VirtualRange: core.NewTextRange(4, 11), + Policy: ast.MappedDiagnosticDirectivePolicyExpect, + UnusedCode: 2578, + UnusedMessageText: "Unused framework directive.", + Source: "mapper", + }}, + }) + + buf, _, err := encoder.EncodeSourceFile(sourceFile) + assert.NilError(t, err) + nodesOffset := readUint32(buf, encoder.HeaderOffsetNodes) + rootData := readUint32(buf, int(nodesOffset)+encoder.NodeSize+encoder.NodeOffsetData) + extendedOffset := readUint32(buf, encoder.HeaderOffsetExtendedData) + (rootData & encoder.NodeDataStringIndexMask) + if int(extendedOffset)+76 > len(buf) { + t.Fatalf("invalid extended offset %d (nodes=%d rootData=%#x extendedData=%d len=%d)", extendedOffset, nodesOffset, rootData, readUint32(buf, encoder.HeaderOffsetExtendedData), len(buf)) + } + contentMapperIndex := readUint32(buf, int(extendedOffset)+64) + virtualFileNameIndex := readUint32(buf, int(extendedOffset)+68) + diagnosticDirectivesOffset := readUint32(buf, int(extendedOffset)+72) + assert.Equal(t, encodedString(buf, contentMapperIndex), "mapper@1.0.0") + assert.Equal(t, encodedString(buf, virtualFileNameIndex), "/component.vue.ts") + structuredDataOffset := readUint32(buf, encoder.HeaderOffsetStructuredData) + directiveOffset := structuredDataOffset + diagnosticDirectivesOffset + assert.DeepEqual(t, buf[directiveOffset:directiveOffset+10], []byte{ + 0x91, // one directive + 0x96, // six-element tuple + 2, 1, // original range [2, 3) in UTF-16 + 2, 7, // virtual range [2, 9) in UTF-16 + 1, // expect policy + 0xcd, 10, 18, // unused diagnostic code 2578 + }) +} + +func encodedString(buf []byte, index uint32) string { + stringOffsets := readUint32(buf, encoder.HeaderOffsetStringOffsets) + stringData := readUint32(buf, encoder.HeaderOffsetStringData) + start := readUint32(buf, int(stringOffsets+index*4)) + end := readUint32(buf, int(stringOffsets+index*4+4)) + return string(buf[stringData+start : stringData+end]) +} + func TestEncodeSourceFileWithUnicodeEscapes(t *testing.T) { t.Parallel() sourceFile := parser.ParseSourceFile(ast.SourceFileParseOptions{ diff --git a/internal/api/protocol_msgpack.go b/internal/api/protocol_msgpack.go index b8b9d87d66b..a4d5a73f5e5 100644 --- a/internal/api/protocol_msgpack.go +++ b/internal/api/protocol_msgpack.go @@ -6,6 +6,7 @@ import ( "fmt" "io" + "github.com/microsoft/typescript-go/internal/ipc" "github.com/microsoft/typescript-go/internal/json" "github.com/microsoft/typescript-go/internal/jsonrpc" ) @@ -43,7 +44,7 @@ type MessagePackProtocol struct { w *bufio.Writer } -var _ Protocol = (*MessagePackProtocol)(nil) +var _ ipc.Protocol = (*MessagePackProtocol)(nil) // NewMessagePackProtocol creates a new msgpack protocol handler. func NewMessagePackProtocol(rw io.ReadWriter) *MessagePackProtocol { @@ -54,14 +55,14 @@ func NewMessagePackProtocol(rw io.ReadWriter) *MessagePackProtocol { } // ReadMessage implements Protocol. -func (p *MessagePackProtocol) ReadMessage() (*Message, error) { +func (p *MessagePackProtocol) ReadMessage() (*ipc.Message, error) { msgType, method, payload, err := p.readTuple() if err != nil { return nil, err } // Convert msgpack message type to JSON-RPC message - msg := &Message{} + msg := &ipc.Message{} switch msgType { case MessageTypeRequest: diff --git a/internal/api/server.go b/internal/api/server.go index ef5ca2a99ee..4ff65896206 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -6,6 +6,8 @@ import ( "io" "github.com/microsoft/typescript-go/internal/bundled" + "github.com/microsoft/typescript-go/internal/contentmapper" + "github.com/microsoft/typescript-go/internal/ipc" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/project" "github.com/microsoft/typescript-go/internal/vfs/osvfs" @@ -33,6 +35,9 @@ type StdioServerOptions struct { // left unchanged; the client folds this data into its own timing snapshot // on demand via getServerTiming / resetServerTiming requests. CollectTiming bool + // RunExternalCode allows configured content mappers to execute. + RunExternalCode bool + ContentMapperSpawner contentmapper.Spawner } // StdioServer runs an API session over STDIO using MessagePack protocol. @@ -55,16 +60,16 @@ func NewStdioServer(options *StdioServerOptions) *StdioServer { // Run starts the server and blocks until the connection closes. func (s *StdioServer) Run(ctx context.Context) error { - var transport Transport + var transport ipc.Transport if s.options.PipePath != "" { - t, err := NewPipeTransport(s.options.PipePath) + t, err := ipc.NewPipeTransport(s.options.PipePath) if err != nil { return fmt.Errorf("failed to create pipe transport: %w", err) } defer t.Close() transport = t } else { - t := NewStdioTransport(s.options.In, s.options.Out) + t := ipc.NewStdioTransport(s.options.In, s.options.Out) defer t.Close() transport = t } @@ -87,7 +92,9 @@ func (s *StdioServer) Run(ctx context.Context) error { DefaultLibraryPath: s.options.DefaultLibraryPath, PositionEncoding: lsproto.PositionEncodingKindUTF8, LoggingEnabled: false, + RunExternalCode: s.options.RunExternalCode, }, + Spawner: s.options.ContentMapperSpawner, }) session := NewSession(projectSession, &SessionOptions{ @@ -102,15 +109,15 @@ func (s *StdioServer) Run(ctx context.Context) error { } // Create protocol and connection based on async mode - var conn Conn + var conn ipc.Conn if s.options.Async { - protocol := NewJSONRPCProtocol(rwc) - asyncConn := NewAsyncConnWithProtocol(rwc, protocol, session) + protocol := ipc.NewJSONRPCProtocol(rwc) + asyncConn := ipc.NewAsyncConnWithProtocol(rwc, protocol, session) asyncConn.SetCollectTiming(s.options.CollectTiming) conn = asyncConn } else { protocol := NewMessagePackProtocol(rwc) - syncConn := NewSyncConn(rwc, protocol, session) + syncConn := ipc.NewSyncConn(rwc, protocol, session) syncConn.SetCollectTiming(s.options.CollectTiming) conn = syncConn } diff --git a/internal/api/session.go b/internal/api/session.go index 1a8d8b4f22d..3bb0670ebee 100644 --- a/internal/api/session.go +++ b/internal/api/session.go @@ -20,6 +20,7 @@ import ( "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/diagnostics" "github.com/microsoft/typescript-go/internal/format" + "github.com/microsoft/typescript-go/internal/ipc" "github.com/microsoft/typescript-go/internal/json" "github.com/microsoft/typescript-go/internal/ls" "github.com/microsoft/typescript-go/internal/ls/autoimport" @@ -410,7 +411,7 @@ type Session struct { } // Ensure Session implements Handler -var _ Handler = (*Session)(nil) +var _ ipc.Handler = (*Session)(nil) // SessionOptions configures an API session. type SessionOptions struct { @@ -1205,7 +1206,6 @@ func (s *Session) handleParseJsonConfigFileContent(ctx context.Context, params * nil, /*existingOptions*/ configFileName, nil, /*resolutionStack*/ - nil, /*extraFileExtensions*/ nil, /*extendedConfigCache*/ ) return NewConfigFileResponse(parsedCommandLine), nil @@ -1233,7 +1233,6 @@ func (s *Session) handleParseConfigFile(ctx context.Context, params *ParseConfig nil, /*existingOptionsRaw*/ configFileName, nil, /*resolutionStack*/ - nil, /*extraFileExtensions*/ nil, /*extendedConfigCache*/ ) return NewConfigFileResponse(parsedCommandLine), nil @@ -2010,24 +2009,44 @@ func (s *Session) handleGetImportAdderEdits(ctx context.Context, params *GetImpo if !importAdder.HasFixes() { return []*TextEdit{}, nil } - return toAPITextEdits(sourceFile, workingSnapshot.Converters(), importAdder.Edits()), nil + return toAPITextEdits(sourceFile, importAdder.Edits()), nil } -func toAPITextEdits(sourceFile *ast.SourceFile, converters *lsconv.Converters, edits []*lsproto.TextEdit) []*TextEdit { - positionMap := sourceFile.GetPositionMap() +func toAPITextEdits(sourceFile *ast.SourceFile, edits []*lsproto.TextEdit) []*TextEdit { + originalText := sourceFile.OriginalText() + lineMap := lsconv.ComputeLSPLineStarts(originalText) + positionMap := ast.ComputePositionMap(originalText) result := make([]*TextEdit, len(edits)) for i, edit := range edits { - start := converters.LineAndCharacterToPosition(sourceFile, edit.Range.Start) - end := converters.LineAndCharacterToPosition(sourceFile, edit.Range.End) + start, ok := originalTextOffset(lineMap, edit.Range.Start, len(originalText)) + if !ok { + return nil + } + end, ok := originalTextOffset(lineMap, edit.Range.End, len(originalText)) + if !ok { + return nil + } result[i] = &TextEdit{ - Pos: positionMap.UTF8ToUTF16(int(start)), - End: positionMap.UTF8ToUTF16(int(end)), + Pos: positionMap.UTF8ToUTF16(start), + End: positionMap.UTF8ToUTF16(end), NewText: edit.NewText, } } return result } +func originalTextOffset(lineMap *lsconv.LSPLineMap, position lsproto.Position, textLength int) (int, bool) { + line := int(position.Line) + if line < 0 || line >= len(lineMap.LineStarts) { + return 0, false + } + offset := int(lineMap.LineStarts[line]) + int(position.Character) + if offset < int(lineMap.LineStarts[line]) || offset > textLength { + return 0, false + } + return offset, true +} + // resolveTypePropertyOfType resolves a type property of type `Type` and returns a type response. func (s *Session) resolveTypePropertyOfType(params *GetTypePropertyParams, getter func(*checker.Type) *checker.Type) (*TypeResponse, error) { sd, err := s.getSnapshotData(params.Snapshot) diff --git a/internal/api/session_textedit_test.go b/internal/api/session_textedit_test.go new file mode 100644 index 00000000000..c9091e128f6 --- /dev/null +++ b/internal/api/session_textedit_test.go @@ -0,0 +1,39 @@ +package api + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/ast" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/parser" + "github.com/microsoft/typescript-go/internal/tspath" + "gotest.tools/v3/assert" +) + +func TestToAPITextEditsUsesOriginalCoordinates(t *testing.T) { + t.Parallel() + + sourceFile := parser.ParseSourceFile(ast.SourceFileParseOptions{ + FileName: "/app.vue", + Path: tspath.Path("/app.vue"), + }, "const transformed = true;", core.ScriptKindTS) + sourceFile.SetContentMapperInfo(ast.ContentMapperSourceFileInfo{ + OriginalText: "😀\nabc", + ContentMapper: "mapper", + }) + + edits := toAPITextEdits(sourceFile, []*lsproto.TextEdit{{ + Range: lsproto.Range{ + Start: lsproto.Position{Line: 1, Character: 1}, + End: lsproto.Position{Line: 1, Character: 2}, + }, + NewText: "x", + }}) + + assert.DeepEqual(t, edits, []*TextEdit{{ + Pos: 4, + End: 5, + NewText: "x", + }}) +} diff --git a/internal/ast/ast.go b/internal/ast/ast.go index fad3777fef0..4eff0ef1fa5 100644 --- a/internal/ast/ast.go +++ b/internal/ast/ast.go @@ -9,6 +9,7 @@ import ( "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/spanmap" "github.com/microsoft/typescript-go/internal/tspath" "github.com/zeebo/xxh3" ) @@ -2462,11 +2463,12 @@ type SourceFile struct { CompositeBase // Fields set by NewSourceFile - fileName string // For debugging convenience - parseOptions SourceFileParseOptions - text string - Statements *NodeList // NodeList[*Statement] - EndOfFileToken *TokenNode // TokenNode[*EndOfFileToken] + fileName string // For debugging convenience + parseOptions SourceFileParseOptions + text string + contentMapperInfo *ContentMapperSourceFileInfo + Statements *NodeList // NodeList[*Statement] + EndOfFileToken *TokenNode // TokenNode[*EndOfFileToken] // Fields for lazily-computed data owned by packages outside ast. dataMu sync.Mutex @@ -2555,6 +2557,133 @@ func (node *SourceFile) Text() string { return node.text } +// OriginalText returns the untransformed source text for content-mapped files, or Text() otherwise. +func (node *SourceFile) OriginalText() string { + if node.ContentMapper() != "" { + return node.contentMapperInfo.OriginalText + } + return node.text +} + +// OriginalFileName returns the canonical filename associated with a supplemental source file, or FileName() otherwise. +func (node *SourceFile) OriginalFileName() string { + if canonical := node.CanonicalSourceFile(); canonical != nil { + return canonical.FileName() + } + return node.FileName() +} + +// SpanMap returns the span map that maps positions in this file's transformed Text() back to its +// original, untransformed content, or nil if the file is not content-mapped (or is a failure stub). +// The returned map is nil-safe: a nil map maps positions identically. +func (node *SourceFile) SpanMap() *spanmap.SpanMap { + if node.contentMapperInfo == nil { + return nil + } + return node.contentMapperInfo.SpanMap +} + +// ContentMapper returns the identity of the content mapper that produced this file, or "" if the file +// was not produced by a content mapper (or the mapper did not identify itself). +func (node *SourceFile) ContentMapper() string { + if node.contentMapperInfo == nil { + return "" + } + return node.contentMapperInfo.ContentMapper +} + +// IsContentMapperFailureStub reports whether this file is the empty placeholder produced when a content +// mapper's transform failed. +func (node *SourceFile) IsContentMapperFailureStub() bool { + return node.ContentMapper() != "" && node.SpanMap() == nil +} + +func (node *SourceFile) ContentMapperTransformIdentity() string { + if node.contentMapperInfo == nil { + return "" + } + return node.contentMapperInfo.TransformIdentity +} + +func (node *SourceFile) VirtualFileName() string { + if node.contentMapperInfo == nil { + return "" + } + return node.contentMapperInfo.VirtualFileName +} + +type MappedDiagnosticDirectivePolicy uint8 + +const ( + MappedDiagnosticDirectivePolicyIgnore MappedDiagnosticDirectivePolicy = iota + MappedDiagnosticDirectivePolicyExpect +) + +type MappedDiagnosticDirective struct { + OriginalRange core.TextRange + VirtualRange core.TextRange + Policy MappedDiagnosticDirectivePolicy + UnusedCode int32 + UnusedMessageText string + Source string +} + +type ContentMapperSourceFileInfo struct { + ContentMapper string + TransformIdentity string + ParseOptions SourceFileParseOptions + VirtualFileName string + OriginalText string + SpanMap *spanmap.SpanMap + DiagnosticDirectives []MappedDiagnosticDirective + SupplementalSourceFiles []*SourceFile + CanonicalSourceFile *SourceFile +} + +// ContentMapperParseOptions returns the parse options used to acquire this file from the mapped parse cache. +func (node *SourceFile) ContentMapperParseOptions() SourceFileParseOptions { + if node.contentMapperInfo == nil { + return SourceFileParseOptions{} + } + return node.contentMapperInfo.ParseOptions +} + +// SetContentMapperInfo initializes all content-mapper metadata before the source file is published. +func (node *SourceFile) SetContentMapperInfo(info ContentMapperSourceFileInfo) { + if node.contentMapperInfo != nil { + panic("content mapper source file info already set") + } + node.contentMapperInfo = &info +} + +func (node *SourceFile) DiagnosticDirectives() []MappedDiagnosticDirective { + if node.contentMapperInfo == nil { + return nil + } + return node.contentMapperInfo.DiagnosticDirectives +} + +// SupplementalSourceFiles returns the additional outputs produced from this canonical source file. +func (node *SourceFile) SupplementalSourceFiles() []*SourceFile { + if node.contentMapperInfo == nil { + return nil + } + return node.contentMapperInfo.SupplementalSourceFiles +} + +// CanonicalSourceFile returns the canonical output associated with this supplemental source file. +func (node *SourceFile) CanonicalSourceFile() *SourceFile { + if node.contentMapperInfo == nil { + return nil + } + return node.contentMapperInfo.CanonicalSourceFile +} + +// IsContentMapperSupplemental reports whether this is an unnamed supplemental mapper output. +func (node *SourceFile) IsContentMapperSupplemental() bool { + return node.CanonicalSourceFile() != nil +} + func (file *SourceFile) HasIdentifier(name string) bool { file.identifiersOnce.Do(func() { file.identifiers = collectIdentifiersForSourceFile(file) @@ -2675,6 +2804,9 @@ func (node *SourceFile) IsJS() bool { func (node *SourceFile) copyFrom(other *SourceFile) { // Do not copy fields set by NewSourceFile (Text, FileName, Path, or Statements) + if other.contentMapperInfo != nil { + node.SetContentMapperInfo(*other.contentMapperInfo) + } node.LanguageVariant = other.LanguageVariant node.ScriptKind = other.ScriptKind node.IsDeclarationFile = other.IsDeclarationFile diff --git a/internal/ast/diagnostic.go b/internal/ast/diagnostic.go index 8002d6c6530..7c8f7ee591b 100644 --- a/internal/ast/diagnostic.go +++ b/internal/ast/diagnostic.go @@ -36,8 +36,15 @@ type Diagnostic struct { loc core.TextRange code int32 category diagnostics.Category + // source, when non-empty, is a custom prefix (e.g. a content mapper's name) shown instead of "TS" + // before the code. It marks the diagnostic as coming from an external source whose ranges point + // into the file's original, untransformed text. + source string // Original message; may be nil. - message *diagnostics.Message + message *diagnostics.Message + // messageText is an already-localized message used when message is nil, e.g. a diagnostic + // deserialized from an external process that owns its own localization. + messageText string messageKey diagnostics.Key messageArgs []string messageChain []*Diagnostic @@ -55,6 +62,8 @@ func (d *Diagnostic) Len() int { return d.loc.L func (d *Diagnostic) Loc() core.TextRange { return d.loc } func (d *Diagnostic) Code() int32 { return d.code } func (d *Diagnostic) Category() diagnostics.Category { return d.category } +func (d *Diagnostic) Source() string { return d.source } +func (d *Diagnostic) MessageText() string { return d.messageText } func (d *Diagnostic) MessageKey() diagnostics.Key { return d.messageKey } func (d *Diagnostic) MessageArgs() []string { return d.messageArgs } func (d *Diagnostic) MessageChain() []*Diagnostic { return d.messageChain } @@ -70,6 +79,12 @@ func (d *Diagnostic) SetCategory(category diagnostics.Category) { d.categ func (d *Diagnostic) SetSkippedOnNoEmit() { d.skippedOnNoEmit = true } func (d *Diagnostic) SetRepopulateInfo(info *RepopulateDiagnosticInfo) { d.repopulateInfo = info } +func (d *Diagnostic) SetExternalData(source string, messageText string) *Diagnostic { + d.source = source + d.messageText = messageText + return d +} + func (d *Diagnostic) SetMessageChain(messageChain []*Diagnostic) *Diagnostic { d.messageChain = messageChain return d @@ -100,12 +115,52 @@ func (d *Diagnostic) Clone() *Diagnostic { } func (d *Diagnostic) Localize(locale locale.Locale) string { - return diagnostics.Localize(locale, d.message, d.messageKey, d.messageArgs...) + if d.message == nil && d.messageText != "" { + return d.messageText + } + return diagnostics.Localize(locale, d.message, d.messageKey, d.displayMessageArgs()...) } // For debugging only. func (d *Diagnostic) String() string { - return diagnostics.Localize(locale.Default, d.message, d.messageKey, d.messageArgs...) + if d.message == nil && d.messageText != "" { + return d.messageText + } + return diagnostics.Localize(locale.Default, d.message, d.messageKey, d.displayMessageArgs()...) +} + +// displayMessageArgs substitutes the original text for a complete alias span when a diagnostic argument +// exactly matches the virtual alias. Stored arguments remain unchanged for code fixes and serialization. +func (d *Diagnostic) displayMessageArgs() []string { + if d.file == nil || d.source != "" { + return d.messageArgs + } + segment, ok := d.file.SpanMap().AliasForVirtualSpan(d.loc) + if !ok { + return d.messageArgs + } + virtualText := d.file.Text() + originalText := d.file.OriginalText() + if segment.VirtualStart < 0 || segment.VirtualEnd > core.TextPos(len(virtualText)) || + segment.OriginalStart < 0 || segment.OriginalEnd > core.TextPos(len(originalText)) { + return d.messageArgs + } + virtualName := virtualText[segment.VirtualStart:segment.VirtualEnd] + originalName := originalText[segment.OriginalStart:segment.OriginalEnd] + var result []string + for i, arg := range d.messageArgs { + if arg != virtualName { + continue + } + if result == nil { + result = slices.Clone(d.messageArgs) + } + result[i] = originalName + } + if result != nil { + return result + } + return d.messageArgs } func NewDiagnosticFromSerialized( @@ -161,6 +216,21 @@ func NewCompilerDiagnostic(message *diagnostics.Message, args ...any) *Diagnosti return NewDiagnostic(nil, core.UndefinedTextRange(), message, args...) } +// NewExternalDiagnostic creates a diagnostic reported by an external source such as a content mapper. +// The message text is already localized (the external source owns localization) and the code is shown +// with the given source prefix (e.g. "vue") instead of "TS". The location refers to the file's original, +// untransformed content. +func NewExternalDiagnostic(file *SourceFile, loc core.TextRange, source string, category diagnostics.Category, code int32, messageText string) *Diagnostic { + return &Diagnostic{ + file: file, + loc: loc, + code: code, + category: category, + source: source, + messageText: messageText, + } +} + type DiagnosticsCollection struct { mu sync.Mutex count int @@ -315,12 +385,17 @@ func EqualDiagnosticsNoRelatedInfo(d1, d2 *Diagnostic) bool { return getDiagnosticPath(d1) == getDiagnosticPath(d2) && d1.Loc() == d2.Loc() && d1.Code() == d2.Code() && + d1.Category() == d2.Category() && + d1.Source() == d2.Source() && getDiagnosticMessageIdentity(d1) == getDiagnosticMessageIdentity(d2) && slices.Equal(d1.MessageArgs(), d2.MessageArgs()) && slices.EqualFunc(d1.MessageChain(), d2.MessageChain(), equalMessageChain) } func getDiagnosticMessageIdentity(diagnostic *Diagnostic) string { + if diagnostic.MessageText() != "" { + return diagnostic.MessageText() + } if diagnostic.message != nil && diagnostic.Code() == -1 { return diagnostic.message.String() } @@ -400,6 +475,14 @@ func CompareDiagnostics(d1, d2 *Diagnostic) int { if c != 0 { return c } + c = int(d1.Category()) - int(d2.Category()) + if c != 0 { + return c + } + c = strings.Compare(d1.Source(), d2.Source()) + if c != 0 { + return c + } c = strings.Compare(getDiagnosticMessageIdentity(d1), getDiagnosticMessageIdentity(d2)) if c != 0 { return c diff --git a/internal/ast/diagnostic_test.go b/internal/ast/diagnostic_test.go index b35751651f6..3a7af19c328 100644 --- a/internal/ast/diagnostic_test.go +++ b/internal/ast/diagnostic_test.go @@ -6,6 +6,7 @@ import ( "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/diagnostics" "github.com/microsoft/typescript-go/internal/tspath" + "gotest.tools/v3/assert" ) func TestDiagnosticsCollectionDeduplicatesExactDiagnosticsOnAdd(t *testing.T) { @@ -75,3 +76,24 @@ func TestDiagnosticsCollectionGetsDiagnosticsForEquivalentSourceFile(t *testing. t.Fatalf("GetDiagnosticsForFile() returned %v, want diagnostic for equivalent source file", collected) } } + +func TestExternalDiagnosticIdentity(t *testing.T) { + t.Parallel() + file := &SourceFile{parseOptions: SourceFileParseOptions{FileName: "/src/file.vue", Path: "/src/file.vue"}} + loc := core.NewTextRange(1, 2) + first := NewExternalDiagnostic(file, loc, "mapper-a", diagnostics.CategoryError, 0, "first") + diagnostics := []*Diagnostic{ + first, + NewExternalDiagnostic(file, loc, "mapper-a", diagnostics.CategoryError, 0, "second"), + NewExternalDiagnostic(file, loc, "mapper-b", diagnostics.CategoryError, 0, "first"), + NewExternalDiagnostic(file, loc, "mapper-a", diagnostics.CategoryWarning, 0, "first"), + } + + var collection DiagnosticsCollection + for _, diagnostic := range diagnostics { + assert.Assert(t, !EqualDiagnosticsNoRelatedInfo(first, diagnostic) || diagnostic == first) + assert.Assert(t, CompareDiagnostics(first, diagnostic) != 0 || diagnostic == first) + collection.Add(diagnostic) + } + assert.Equal(t, len(collection.GetDiagnostics()), len(diagnostics)) +} diff --git a/internal/checker/checker_test.go b/internal/checker/checker_test.go index 00d93a65f2c..2b195e76d5d 100644 --- a/internal/checker/checker_test.go +++ b/internal/checker/checker_test.go @@ -36,7 +36,7 @@ foo.bar;` fs = bundled.WrapFS(fs) cd := "/" - host := compiler.NewCompilerHost(cd, fs, bundled.LibPath(), nil, nil) + host := compiler.NewCompilerHost(cd, fs, bundled.LibPath(), nil, nil, nil) parsed, errors := tsoptions.GetParsedCommandLineOfConfigFile("/tsconfig.json", &core.CompilerOptions{}, nil, host, nil) assert.Equal(t, len(errors), 0, "Expected no errors in parsed command line") @@ -68,7 +68,7 @@ func BenchmarkNewChecker(b *testing.B) { rootPath := tspath.CombinePaths(tspath.NormalizeSlashes(repo.TypeScriptSubmodulePath()), "src", "compiler") - host := compiler.NewCompilerHost(rootPath, fs, bundled.LibPath(), nil, nil) + host := compiler.NewCompilerHost(rootPath, fs, bundled.LibPath(), nil, nil, nil) parsed, errors := tsoptions.GetParsedCommandLineOfConfigFile(tspath.CombinePaths(rootPath, "tsconfig.json"), &core.CompilerOptions{}, nil, host, nil) assert.Equal(b, len(errors), 0, "Expected no errors in parsed command line") p := compiler.NewProgram(compiler.ProgramOptions{ diff --git a/internal/compiler/contentmapper_test.go b/internal/compiler/contentmapper_test.go new file mode 100644 index 00000000000..080f063a27d --- /dev/null +++ b/internal/compiler/contentmapper_test.go @@ -0,0 +1,210 @@ +package compiler_test + +import ( + "context" + "errors" + "slices" + "testing" + + "github.com/microsoft/typescript-go/internal/ast" + "github.com/microsoft/typescript-go/internal/bundled" + "github.com/microsoft/typescript-go/internal/compiler" + "github.com/microsoft/typescript-go/internal/contentmapper" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/diagnostics" + "github.com/microsoft/typescript-go/internal/locale" + "github.com/microsoft/typescript-go/internal/spanmap" + "github.com/microsoft/typescript-go/internal/tsoptions" + "github.com/microsoft/typescript-go/internal/vfs/vfstest" + "gotest.tools/v3/assert" +) + +type fakeContentMapperHost struct { + transform func(fileName string, content string) (contentmapper.Result, error) +} + +func (r fakeContentMapperHost) Refresh() error { return nil } +func (r fakeContentMapperHost) Identities() ([]string, error) { return nil, nil } +func (r fakeContentMapperHost) Identity(*contentmapper.Mapper) (string, error) { return "test", nil } +func (r fakeContentMapperHost) WatchedFiles() ([]string, error) { return nil, nil } +func (r fakeContentMapperHost) Diagnostics() []contentmapper.OptionDiagnostic { + return nil +} +func (r fakeContentMapperHost) Close() error { return nil } + +func (r fakeContentMapperHost) Transform(mapper *contentmapper.Mapper, request contentmapper.Request) (contentmapper.Result, error) { + return r.transform(request.FileName, request.Content) +} + +func newContentMapperProgram(t *testing.T, contentMapperProject contentmapper.Project, files map[string]string, rootFiles []string) *compiler.Program { + return newContentMapperProgramWithOptions(t, contentMapperProject, files, rootFiles, &core.CompilerOptions{ + SkipLibCheck: core.TSTrue, + Module: core.ModuleKindESNext, + ModuleResolution: core.ModuleResolutionKindBundler, + }) +} + +func newContentMapperProgramWithOptions(t *testing.T, contentMapperProject contentmapper.Project, files map[string]string, rootFiles []string, options *core.CompilerOptions) *compiler.Program { + t.Helper() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + fs := vfstest.FromMap[any](nil, false /*useCaseSensitiveFileNames*/) + fs = bundled.WrapFS(fs) + for name, content := range files { + _ = fs.WriteFile(name, content) + } + + config := &tsoptions.ParsedCommandLine{ + ParsedConfig: &tsoptions.ParsedOptions{ + FileNames: rootFiles, + CompilerOptions: options, + ContentMappers: []*contentmapper.Mapper{{Definition: contentmapper.Definition{Package: "vue", Extensions: []string{".vue"}}, Manifest: contentmapper.Manifest{Name: "vue-mapper", Version: "1.0.0"}}}, + }, + } + return compiler.NewProgram(compiler.ProgramOptions{ + Config: config, + Host: compiler.NewCompilerHost("/src", fs, bundled.LibPath(), nil, nil, contentMapperProject), + // Load files on the calling goroutine for deterministic diagnostics ordering. + SingleThreaded: core.TSTrue, + }) +} + +func TestContentMapperVirtualExtensionSetsImpliedNodeFormat(t *testing.T) { + t.Parallel() + program := newContentMapperProgramWithOptions( + t, + fakeContentMapperHost{transform: func(fileName string, content string) (contentmapper.Result, error) { + return contentmapper.Result{Text: "export {};", VirtualExtension: ".mts", Mappings: spanmap.New(nil)}, nil + }}, + map[string]string{"/src/Component.vue": "