diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 75254f589ae..b28a0fbb187 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -346,7 +346,11 @@ 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: "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: "SpanMapPurpose", goPrefix: "Purpose", 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" }, // String enum: Go stores internal names with a "\xFE" sentinel prefix, but the escaped diff --git a/_extension/package.json b/_extension/package.json index fd0f5df26cb..d6c628ade08 100644 --- a/_extension/package.json +++ b/_extension/package.json @@ -254,6 +254,10 @@ "title": "%native-preview.codeLens.showLocations.title%", "enablement": "false" }, + { + "command": "typescript.native-preview.discoverContentMappers", + "title": "%native-preview.discoverContentMappers.title%" + }, { "command": "typescript.native-preview.dev.runGC", "title": "%native-preview.dev.runGC.title%", @@ -293,6 +297,10 @@ ], "menus": { "commandPalette": [ + { + "command": "typescript.native-preview.discoverContentMappers", + "when": "false" + }, { "command": "typescript.native-preview.restart", "when": "typescript.native-preview.serverRunning" diff --git a/_extension/package.nls.json b/_extension/package.nls.json index 135fccd2849..02e1c4174ba 100644 --- a/_extension/package.nls.json +++ b/_extension/package.nls.json @@ -25,6 +25,7 @@ "native-preview.sortImports.title": "Sort Imports", "native-preview.removeUnusedImports.title": "Remove Unused Imports", "native-preview.codeLens.showLocations.title": "Show References of CodeLens", + "native-preview.discoverContentMappers.title": "Discover TypeScript Content Mappers", "native-preview.dev.runGC.title": "TypeScript: Run Garbage Collection", "native-preview.dev.saveHeapProfile.title": "TypeScript: Save Heap Profile", "native-preview.dev.saveAllocProfile.title": "TypeScript: Save Allocation Profile", diff --git a/_extension/src/client.ts b/_extension/src/client.ts index cbc937e06c3..00d38d14886 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, @@ -37,6 +38,24 @@ import { } from "./util"; import { getLanguageForUri } from "./util"; +// 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 foreign 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; @@ -51,6 +70,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; @@ -84,6 +111,8 @@ export class Client implements vscode.Disposable { codeLensShowLocationsCommandName, enableTelemetry: true, logVerbosity: this.outputChannel.logLevel, + // Content mappers run external plugin processes, so they are only enabled in a trusted workspace. + loadExternalPlugins: vscode.workspace.isTrusted, }, errorHandler: this.errorHandler, middleware: { @@ -98,6 +127,18 @@ export class Client implements vscode.Disposable { }, sendNotification: sendNotificationMiddleware, 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: { @@ -133,10 +174,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; @@ -190,9 +237,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.loadExternalPlugins = vscode.workspace.isTrusted; this.client = new NativePreviewLanguageClient( "js/ts", @@ -257,11 +304,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 { @@ -270,6 +383,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(); @@ -282,6 +399,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(); @@ -391,6 +512,23 @@ export class Client implements vscode.Disposable { textDocument: { uri }, }, token); } + + async discoverContentMappers(uris: readonly vscode.Uri[], extensions: readonly string[]): Promise { + if (!this.client || !this.isInitialized || !vscode.workspace.isTrusted) { + return []; + } + try { + const result = await this.client.sendRequest<{ extensions: string[]; }>("custom/discoverContentMappers", { + textDocuments: uris.map(uri => ({ uri: uri.toString() })), + extensions: [...extensions], + }); + return result.extensions; + } + catch (error) { + this.outputChannel.warn(`Content mapper discovery failed: ${String(error)}`); + return []; + } + } } // Returns true when running on a VS Code Insiders build. diff --git a/_extension/src/commands.ts b/_extension/src/commands.ts index de015f116bd..2de6768792d 100644 --- a/_extension/src/commands.ts +++ b/_extension/src/commands.ts @@ -26,6 +26,36 @@ export function registerEnablementCommands(context: vscode.ExtensionContext, tel })); } +export const discoverContentMappersCommandName = "typescript.native-preview.discoverContentMappers"; + +export interface DiscoverContentMappersCommandArgs { + readonly uris: readonly vscode.Uri[]; + readonly extensions: readonly string[]; +} + +export function registerDiscoverContentMappersCommand( + context: vscode.ExtensionContext, + discover: (uris: readonly vscode.Uri[], extensions: readonly string[]) => Promise, +): void { + context.subscriptions.push(vscode.commands.registerCommand(discoverContentMappersCommandName, async (args: unknown): Promise => { + if (!isDiscoverContentMappersCommandArgs(args)) { + return []; + } + return discover(args.uris, args.extensions); + })); +} + +function isDiscoverContentMappersCommandArgs(value: unknown): value is DiscoverContentMappersCommandArgs { + if (typeof value !== "object" || value === null) { + return false; + } + const args = value as Partial; + return Array.isArray(args.uris) + && args.uris.every(uri => uri instanceof vscode.Uri) + && Array.isArray(args.extensions) + && args.extensions.every(extension => typeof extension === "string"); +} + /** * Updates the TypeScript 7 setting and reloads extension host. * Handles both `js/ts.experimental.useTsgo` and `typescript.experimental.useTsgo`. diff --git a/_extension/src/extension.ts b/_extension/src/extension.ts index d776ce76066..b5df2c7d391 100644 --- a/_extension/src/extension.ts +++ b/_extension/src/extension.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import { + registerDiscoverContentMappersCommand, registerEnablementCommands, updateUseTsgoSetting, } from "./commands"; @@ -51,6 +52,7 @@ export async function activate(context: vscode.ExtensionContext): Promise sessionManager.stop()); + registerDiscoverContentMappersCommand(context, (uris, extensions) => sessionManager.discoverContentMappers(uris, extensions)); let pluginWarningShown = false; const onDidChangeExtensions = vscode.extensions.onDidChange(() => { diff --git a/_extension/src/session.ts b/_extension/src/session.ts index 24d3e323ffc..70dee9ff8db 100644 --- a/_extension/src/session.ts +++ b/_extension/src/session.ts @@ -42,6 +42,10 @@ export class SessionManager implements vscode.Disposable { this.outputChannel = outputChannel; this.telemetryReporter = telemetryReporter; this.initializedEventEmitter = initializedEventEmitter; + + // Workspace trust gates content mappers (external plugin processes), which are passed to the + // server as an initialization option. Restart when trust is granted so they become active. + this.disposables.push(vscode.workspace.onDidGrantWorkspaceTrust(() => this.restart(context))); } start(context: vscode.ExtensionContext): Promise { @@ -72,6 +76,10 @@ export class SessionManager implements vscode.Disposable { return result.pipe; } + async discoverContentMappers(uris: readonly vscode.Uri[], extensions: readonly string[]): Promise { + return this.currentSession?.client.discoverContentMappers(uris, extensions) ?? []; + } + async dispose(): Promise { await this.currentSession?.dispose(); await Promise.all(this.disposables.map(d => d.dispose())); diff --git a/_packages/native-preview/src/api/async/client.ts b/_packages/native-preview/src/api/async/client.ts index b4a123d537a..c9251230587 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..6176f3c903e 100644 --- a/_packages/native-preview/src/api/node/encoder.ts +++ b/_packages/native-preview/src/api/node/encoder.ts @@ -139,7 +139,7 @@ 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); + 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); } 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..9cc0f2ccb3d 100644 --- a/_packages/native-preview/src/api/node/node.ts +++ b/_packages/native-preview/src/api/node/node.ts @@ -5,6 +5,9 @@ import { type Node, NodeFlags, type Path, + SpanMap, + SpanMapKind, + SpanMapPurpose, SyntaxKind, TokenFlags, } from "../../ast/index.ts"; @@ -59,6 +62,8 @@ 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; constructor(data: Uint8Array, decoder: TextDecoder, timing?: TimingCollector) { const view = new DataView(data.buffer, data.byteOffset, data.byteLength); @@ -232,6 +237,42 @@ export class RemoteSourceFile extends RemoteNode implements SourceFileInfo { return this.getOrCreateNodeAtIndex(nodeIndex) as Node; } + get originalText(): string { + const stringIndex = this.view.getUint32(this.extendedDataOffset + 48, 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 + 52, 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 generatedStart = reader.readUint(); + const generatedLength = reader.readUint(); + const originalStart = reader.readUint(); + const originalLength = reader.readUint(); + const kind = reader.readUint(); + const purpose = tupleLength === 6 ? reader.readUint() as SpanMapPurpose : SpanMapPurpose.All; + if (kind !== SpanMapKind.Verbatim && kind !== SpanMapKind.Atom && kind !== SpanMapKind.Alias) throw new Error(`Invalid span map kind: ${kind}`); + segments[i] = { + generatedStart, + generatedEnd: generatedStart + generatedLength, + originalStart, + originalEnd: originalStart + originalLength, + kind, + purpose, + }; + } + return this._cachedSpanMap = new SpanMap(segments); + } + 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..dc28e0fa444 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 = 6; 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..6d54ba6a70f 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. */ + loadExternalPlugins?: 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.loadExternalPlugins) args.push("--loadExternalPlugins"); + 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 e160f6797df..0ee9c67be93 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..5c66d6bb296 100644 --- a/_packages/native-preview/src/ast/ast.ts +++ b/_packages/native-preview/src/ast/ast.ts @@ -49,6 +49,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"; @@ -118,6 +119,8 @@ export interface SourceFile extends Node { readonly statements: NodeArray; readonly endOfFileToken: EndOfFile; readonly text: string; + readonly originalText: string; + readonly spanMap: SpanMap | undefined; 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 f29cfc3d107..594c6232c66 100644 --- a/_packages/native-preview/src/ast/factory.generated.ts +++ b/_packages/native-preview/src/ast/factory.generated.ts @@ -526,6 +526,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; } @@ -571,6 +574,9 @@ export class NodeObject { get scriptKind(): any { return this._data?.scriptKind; } + get spanMap(): any { + return this._data?.spanMap; + } get statement(): any { return this._data?.statement; } @@ -3789,6 +3795,8 @@ export function createSourceFile(statements: readonly Statement[], endOfFileToke statements: createNodeArray(statements), endOfFileToken, text, + originalText: text, + spanMap: 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..a1ddba2baba 100644 --- a/_packages/native-preview/src/ast/index.ts +++ b/_packages/native-preview/src/ast/index.ts @@ -7,6 +7,9 @@ export { NodeFlags } from "#enums/nodeFlags"; export { RegularExpressionFlags } from "#enums/regularExpressionFlags"; export { ScriptKind } from "#enums/scriptKind"; export { ScriptTarget } from "#enums/scriptTarget"; +export { SpanMapFidelity } from "#enums/spanMapFidelity"; +export { SpanMapKind } from "#enums/spanMapKind"; +export { SpanMapPurpose } from "#enums/spanMapPurpose"; export { SyntaxKind } from "#enums/syntaxKind"; export { TokenFlags } from "#enums/tokenFlags"; export * from "./ast.ts"; @@ -15,5 +18,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..5ea5126aee9 --- /dev/null +++ b/_packages/native-preview/src/ast/spanMap.ts @@ -0,0 +1,337 @@ +import { SpanMapFidelity } from "#enums/spanMapFidelity"; +import { SpanMapKind } from "#enums/spanMapKind"; +import { SpanMapPurpose } from "#enums/spanMapPurpose"; +import type { ReadonlyTextRange } from "./ast.ts"; + +export { SpanMapFidelity, SpanMapKind, SpanMapPurpose }; + +// Keep this in sync with spanmap.go + +/** Maps one half-open generated range to one half-open original range. */ +export interface SpanMapSegment { + readonly generatedStart: number; + readonly generatedEnd: number; + readonly originalStart: number; + readonly originalEnd: number; + readonly kind: SpanMapKind; + readonly purpose?: SpanMapPurpose; +} + +/** Internal segment representation after an omitted purpose has been normalized to `All`. */ +type NormalizedSpanMapSegment = SpanMapSegment & { readonly purpose: SpanMapPurpose; }; + +/** One generated projection of an original position and its mapping fidelity. */ +export interface MappedPosition { + readonly position: number; + readonly fidelity: SpanMapFidelity; +} + +/** One generated projection of an original range and its mapping fidelity. */ +export interface MappedRange { + readonly range: ReadonlyTextRange; + readonly fidelity: SpanMapFidelity; +} + +/** Provides bidirectional span-aware mapping between generated and original text. */ +export class SpanMap { + readonly segments: readonly NormalizedSpanMapSegment[]; + private originalSegments: readonly NormalizedSpanMapSegment[] | undefined; + + /** Copies and sorts segments by generated start, normalizing omitted purposes to `All`. */ + constructor(segments: readonly SpanMapSegment[]) { + this.segments = segments + .map(segment => ({ ...segment, purpose: segment.purpose ?? SpanMapPurpose.All })) + .sort((left, right) => left.generatedStart - right.generatedStart); + } + + /** 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 generated range to original text. Gaps map to insertion points with `None` fidelity, + * and ranges crossing segment boundaries map their endpoints with `Approximate` fidelity. + */ + generatedToOriginalSpan(range: ReadonlyTextRange): MappedRange { + return this.mapRange(range, this.segments, false); + } + + /** Maps a generated position to original text, using `None` fidelity for synthesized gaps. */ + generatedToOriginalPosition(position: number): MappedPosition { + return this.mapPoint(position, this.segments, false); + } + + /** + * Returns every generated projection of an original position whose segment supports `purpose`. + * Results are ordered by generated start; uncovered or disabled positions produce no results. + */ + originalToGeneratedPositions(position: number, purpose: SpanMapPurpose): readonly MappedPosition[] { + const segments = originalSegmentsAt(this.getOriginalSegments(), position); + if (!segments) return []; + return segments + .filter(segment => supportsPurpose(segment, purpose)) + .map(segment => + segment.kind === SpanMapKind.Verbatim + ? { position: mapVerbatimPosition(segment, position, true), fidelity: SpanMapFidelity.Exact } + : { position: segment.generatedStart, fidelity: SpanMapFidelity.Atom } + ); + } + + /** + * Returns every purpose-compatible generated 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 generated ranges. For + * example, suppose two original segments are each copied twice into the generated text: + * + * ```text + * original: [ A ][ B ] + * [---) range from inside A to inside B + * + * generated: [ 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. + */ + originalToGeneratedSpans(range: ReadonlyTextRange, purpose: SpanMapPurpose): 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 = originalSegmentsAt(originalSegments, start); + const endSegments = originalSegmentsAt(originalSegments, lastCharacter); + if (!startSegments || !endSegments) return []; + if (sameOriginalRange(startSegments[0], endSegments[0])) { + return originalToGeneratedSpansInGroup(startSegments, start, end, purpose); + } + const starts = originalStartProjections(startSegments, start, purpose); + const ends = originalEndProjections(endSegments, end, purpose); + if (starts.length === 0 || ends.length === 0) return []; + return starts.flatMap((generatedStart, index) => { + const generatedEnd = ends.find(end => end >= generatedStart); + return generatedEnd === undefined || index + 1 < starts.length && starts[index + 1] <= generatedEnd + ? [] + : [{ range: { pos: generatedStart, end: generatedEnd }, 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.generatedStart : 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.generatedStart - right.generatedStart + ); + } +} + +/** + * Maps the inclusive start of an original range through every matching segment. Verbatim segments preserve + * the offset within the segment; atoms map to their generated start. + * + * ```text + * original: [---------) + * ^ start + * + * generated: [---------) [---------) + * ^ ^ + * result result + * ``` + */ +function originalStartProjections(segments: readonly NormalizedSpanMapSegment[], start: number, purpose: SpanMapPurpose): readonly number[] { + return segments + .filter(segment => supportsPurpose(segment, purpose)) + .map(segment => + segment.kind === SpanMapKind.Verbatim + ? mapVerbatimPosition(segment, start, true) + : segment.generatedStart + ); +} + +/** + * 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 + * + * generated: [---------) [---------) + * ^ ^ + * result result + * ``` + */ +function originalEndProjections(segments: readonly NormalizedSpanMapSegment[], end: number, purpose: SpanMapPurpose): readonly number[] { + return segments + .filter(segment => supportsPurpose(segment, purpose)) + .map(segment => + segment.kind === SpanMapKind.Verbatim + ? mapVerbatimPosition(segment, end, true) + : segment.generatedEnd + ); +} + +/** Maps a range whose boundaries are known to lie in one duplicate group. */ +function originalToGeneratedSpansInGroup(segments: readonly NormalizedSpanMapSegment[], start: number, end: number, purpose: SpanMapPurpose): readonly MappedRange[] { + return segments + .filter(segment => supportsPurpose(segment, purpose)) + .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.generatedStart, end: segment.generatedEnd }, 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 containing `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 generated start. + */ +function originalSegmentsAt(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); +} + +/** Reports whether a segment participates in an original-to-generated query for `purpose`. */ +function supportsPurpose(segment: NormalizedSpanMapSegment, purpose: SpanMapPurpose): boolean { + return (segment.purpose & purpose) !== 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 generated 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].generatedStart; + if (start < position) low = middle + 1; + else high = middle; + } + if (low < segments.length && (reverse ? segments[low].originalStart : segments[low].generatedStart) === position) { + return [low, true]; + } + const previous = low - 1; + if (previous >= 0) { + const end = reverse ? segments[previous].originalEnd : segments[previous].generatedEnd; + if (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].generatedEnd : 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.generatedStart; + const targetStart = reverse ? segment.generatedStart : segment.originalStart; + const targetEnd = reverse ? segment.generatedEnd : 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.generatedEnd : segment.generatedStart; + 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.generatedStart, end: segment.generatedEnd } + : { 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..3a7e254cd45 100644 --- a/_packages/native-preview/src/ast/utils.ts +++ b/_packages/native-preview/src/ast/utils.ts @@ -76,6 +76,8 @@ 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, 6, "protocol version should be 6"); // Verify we can decode it const decoded = decode(encoded); @@ -174,11 +174,17 @@ describe("Encoder", () => { assert.strictEqual(rootKind, SyntaxKind.IfStatement); }); - test("protocol version is 5", () => { + test("protocol version is 6", () => { 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, 6); + }); + + 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); }); 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..9374e12ddde --- /dev/null +++ b/_packages/native-preview/test/spanMap.test.ts @@ -0,0 +1,117 @@ +import { + SpanMap, + SpanMapFidelity, + SpanMapKind, + SpanMapPurpose, +} from "@typescript/native-preview/unstable/ast"; +import assert from "node:assert"; +import { + describe, + test, +} from "node:test"; + +describe("SpanMap", () => { + const map = new SpanMap([ + { generatedStart: 2, generatedEnd: 6, originalStart: 10, originalEnd: 14, kind: SpanMapKind.Verbatim }, + { generatedStart: 8, generatedEnd: 11, originalStart: 20, originalEnd: 27, kind: SpanMapKind.Atom }, + { generatedStart: 14, generatedEnd: 18, originalStart: 30, originalEnd: 34, kind: SpanMapKind.Verbatim }, + ]); + + test("maps generated positions and ranges to original", () => { + assert.equal(map.segments[0].purpose, SpanMapPurpose.All); + assert.deepEqual(map.generatedToOriginalPosition(4), { position: 12, fidelity: SpanMapFidelity.Exact }); + assert.deepEqual(map.generatedToOriginalSpan({ pos: 3, end: 5 }), { range: { pos: 11, end: 13 }, fidelity: SpanMapFidelity.Exact }); + assert.deepEqual(map.generatedToOriginalPosition(9), { position: 20, fidelity: SpanMapFidelity.Atom }); + assert.deepEqual(map.generatedToOriginalSpan({ pos: 8, end: 10 }), { range: { pos: 20, end: 27 }, fidelity: SpanMapFidelity.Atom }); + assert.deepEqual(map.generatedToOriginalSpan({ pos: 5, end: 15 }), { range: { pos: 13, end: 31 }, fidelity: SpanMapFidelity.Approximate }); + }); + + test("maps aliases with atom geometry", () => { + const alias = new SpanMap([ + { generatedStart: 0, generatedEnd: 3, originalStart: 0, originalEnd: 1, kind: SpanMapKind.Alias }, + ]); + assert.deepEqual(alias.generatedToOriginalSpan({ pos: 0, end: 3 }), { + range: { pos: 0, end: 1 }, + fidelity: SpanMapFidelity.Atom, + }); + }); + + test("maps synthesized gaps to insertion points", () => { + assert.deepEqual(map.generatedToOriginalPosition(0), { position: 0, fidelity: SpanMapFidelity.None }); + assert.deepEqual(map.generatedToOriginalSpan({ pos: 6, end: 8 }), { range: { pos: 14, end: 14 }, fidelity: SpanMapFidelity.None }); + assert.deepEqual(map.generatedToOriginalPosition(19), { position: 34, fidelity: SpanMapFidelity.None }); + }); + + test("maps original positions and ranges to generated", () => { + assert.deepEqual(map.originalToGeneratedPositions(12, SpanMapPurpose.All), [{ position: 4, fidelity: SpanMapFidelity.Exact }]); + assert.deepEqual(map.originalToGeneratedSpans({ pos: 21, end: 25 }, SpanMapPurpose.All), [{ range: { pos: 8, end: 11 }, fidelity: SpanMapFidelity.Atom }]); + assert.deepEqual(map.originalToGeneratedSpans({ pos: 13, end: 31 }, SpanMapPurpose.All), [{ range: { pos: 5, end: 15 }, fidelity: SpanMapFidelity.Approximate }]); + assert.deepEqual(map.originalToGeneratedPositions(15, SpanMapPurpose.All), []); + }); + + test("sorts generated and original indexes independently", () => { + const reordered = new SpanMap([ + { generatedStart: 0, generatedEnd: 2, originalStart: 10, originalEnd: 12, kind: SpanMapKind.Verbatim }, + { generatedStart: 2, generatedEnd: 4, originalStart: 0, originalEnd: 2, kind: SpanMapKind.Verbatim }, + ]); + assert.deepEqual(reordered.generatedToOriginalPosition(3), { position: 1, fidelity: SpanMapFidelity.Exact }); + assert.deepEqual(reordered.originalToGeneratedPositions(1, SpanMapPurpose.All), [{ position: 3, fidelity: SpanMapFidelity.Exact }]); + }); + + test("an empty map describes fully synthesized output", () => { + const empty = new SpanMap([]); + assert.deepEqual(empty.generatedToOriginalPosition(5), { position: 0, fidelity: SpanMapFidelity.None }); + assert.deepEqual(empty.generatedToOriginalSpan({ pos: 2, end: 7 }), { range: { pos: 0, end: 0 }, fidelity: SpanMapFidelity.None }); + assert.deepEqual(empty.originalToGeneratedPositions(5, SpanMapPurpose.All), []); + }); + + test("maps duplicate groups by purpose", () => { + const duplicates = new SpanMap([ + { generatedStart: 0, generatedEnd: 3, originalStart: 10, originalEnd: 13, kind: SpanMapKind.Verbatim, purpose: SpanMapPurpose.Navigation }, + { generatedStart: 10, generatedEnd: 13, originalStart: 10, originalEnd: 13, kind: SpanMapKind.Verbatim, purpose: SpanMapPurpose.Semantic }, + { generatedStart: 14, generatedEnd: 17, originalStart: 10, originalEnd: 13, kind: SpanMapKind.Verbatim, purpose: SpanMapPurpose.Semantic }, + { generatedStart: 20, generatedEnd: 25, originalStart: 10, originalEnd: 13, kind: SpanMapKind.Atom, purpose: SpanMapPurpose.Navigation }, + ]); + + assert.deepEqual(duplicates.originalToGeneratedPositions(11, SpanMapPurpose.Semantic), [ + { position: 11, fidelity: SpanMapFidelity.Exact }, + { position: 15, fidelity: SpanMapFidelity.Exact }, + ]); + assert.deepEqual(duplicates.originalToGeneratedPositions(11, SpanMapPurpose.Navigation), [ + { position: 1, fidelity: SpanMapFidelity.Exact }, + { position: 20, fidelity: SpanMapFidelity.Atom }, + ]); + }); + + test("maps minimal cross-group projections", () => { + const projections = new SpanMap([ + { generatedStart: 0, generatedEnd: 2, originalStart: 0, originalEnd: 2, kind: SpanMapKind.Verbatim, purpose: SpanMapPurpose.Semantic }, + { generatedStart: 2, generatedEnd: 4, originalStart: 2, originalEnd: 4, kind: SpanMapKind.Verbatim, purpose: SpanMapPurpose.Semantic }, + { generatedStart: 10, generatedEnd: 12, originalStart: 0, originalEnd: 2, kind: SpanMapKind.Verbatim, purpose: SpanMapPurpose.Semantic }, + { generatedStart: 12, generatedEnd: 14, originalStart: 2, originalEnd: 4, kind: SpanMapKind.Verbatim, purpose: SpanMapPurpose.Semantic }, + ]); + + assert.deepEqual(projections.originalToGeneratedSpans({ pos: 1, end: 3 }, SpanMapPurpose.Semantic), [ + { range: { pos: 1, end: 3 }, fidelity: SpanMapFidelity.Approximate }, + { range: { pos: 11, end: 13 }, fidelity: SpanMapFidelity.Approximate }, + ]); + }); + + test("explicit zero purpose disables original-to-generated mapping", () => { + const disabled = new SpanMap([ + { generatedStart: 0, generatedEnd: 3, originalStart: 10, originalEnd: 13, kind: SpanMapKind.Verbatim, purpose: SpanMapPurpose.None }, + ]); + + assert.deepEqual(disabled.originalToGeneratedPositions(11, SpanMapPurpose.Semantic), []); + assert.deepEqual(disabled.originalToGeneratedPositions(11, SpanMapPurpose.Navigation), []); + assert.deepEqual(disabled.originalToGeneratedSpans({ pos: 10, end: 13 }, SpanMapPurpose.Semantic), []); + }); + + 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 4aeec139e90..0bd790c9055 100644 --- a/_packages/native-preview/test/sync/ast.test.ts +++ b/_packages/native-preview/test/sync/ast.test.ts @@ -786,6 +786,8 @@ 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); } finally { api.close(); diff --git a/_scripts/generate-ts-ast.ts b/_scripts/generate-ts-ast.ts index 17ff07631b4..15017535ce7 100644 --- a/_scripts/generate-ts-ast.ts +++ b/_scripts/generate-ts-ast.ts @@ -580,9 +580,11 @@ function generateFactory(): string { for ( const name of [ "fileName", + "originalText", "path", "languageVariant", "scriptKind", + "spanMap", "isDeclarationFile", "referencedFiles", "typeReferenceDirectives", @@ -1171,6 +1173,8 @@ function generateFactory(): string { out.push(` statements: createNodeArray(statements),`); out.push(` endOfFileToken,`); out.push(` text,`); + out.push(` originalText: text,`); + out.push(` spanMap: 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..33747850513 100644 --- a/cmd/tsgo/api.go +++ b/cmd/tsgo/api.go @@ -21,6 +21,7 @@ func runAPI(args []string) int { 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") + loadExternalPlugins := flag.Bool("loadExternalPlugins", false, "allow projects to execute configured external plugins") if err := flag.Parse(args); err != nil { return 2 } @@ -34,12 +35,14 @@ func runAPI(args []string) int { } options := &api.StdioServerOptions{ - Err: os.Stderr, - Cwd: *cwd, - DefaultLibraryPath: defaultLibraryPath, - Callbacks: callbacksList, - Async: *async, - CollectTiming: *timing, + Err: os.Stderr, + Cwd: *cwd, + DefaultLibraryPath: defaultLibraryPath, + Callbacks: callbacksList, + Async: *async, + CollectTiming: *timing, + LoadExternalPlugins: *loadExternalPlugins, + ContentMapperSpawner: newSystem(), } if *pipePath != "" { options.PipePath = *pipePath diff --git a/cmd/tsgo/lsp.go b/cmd/tsgo/lsp.go index bc1a63b6d9a..539be151901 100644 --- a/cmd/tsgo/lsp.go +++ b/cmd/tsgo/lsp.go @@ -60,6 +60,7 @@ func runLSP(args []string) int { cmd.Dir = cwd return cmd.Output() }, + Spawn: spawnProcess, ProgressDelay: 250 * time.Millisecond, SetParentProcessID: newParentProcessWatchdog(ctx, stop), }) diff --git a/cmd/tsgo/sys.go b/cmd/tsgo/sys.go index e5b2b8d569a..235055e2766 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" @@ -59,6 +61,54 @@ func (s *osSys) GetEnvironmentVariable(name string) string { return os.Getenv(name) } +func (s *osSys) Spawn(command []string, dir string) (io.ReadWriteCloser, error) { + return spawnProcess(command, dir) +} + +// 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) (io.ReadWriteCloser, error) { + cmd := exec.Command(command[0], command[1:]...) + cmd.Dir = dir + cmd.Stderr = os.Stderr + 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.ReadCloser +} + +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) Close() error { + // Kill guarantees the process is gone even if it is ignoring stdin's EOF; Wait then reaps it and + // closes the stdio pipes it created. Kill is best-effort because Wait reports the real outcome, and a + // "signal: killed" ExitError is the expected result of that kill, so only an unexpected Wait error is + // surfaced. + _ = p.cmd.Process.Kill() + err := p.cmd.Wait() + if _, ok := errors.AsType[*exec.ExitError](err); ok { + return nil + } + return err +} + func newSystem() *osSys { cwd, err := os.Getwd() if err != nil { diff --git a/internal/api/callbackfs.go b/internal/api/callbackfs.go index daf170e7181..df69ae73e9f 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 } @@ -67,7 +68,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..666d0c13762 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 = 6 ) // Source File Binary Format @@ -148,6 +149,8 @@ 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 | // // Structured data (variable) // -------------------------- @@ -161,6 +164,10 @@ 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: +// +// [generatedStart: uint, generatedLength: uint, originalStart: uint, originalLength: uint, kind: uint, purpose?: uint] +// // An offset of 0xFFFFFFFF indicates no data (empty array). // // Nodes (28 bytes per node) @@ -645,14 +652,22 @@ 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) + } // 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) } func recordExtendedData_TemplateHead(node *ast.Node, strs *stringTable, positionMap *ast.PositionMap, extendedData *[]byte, structuredData *[]byte) { @@ -753,6 +768,35 @@ func encodeStringArray(strs []string, buf *[]byte) uint32 { return offset } +func encodeSpanMap(m *spanmap.SpanMap, generatedPositions *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.Purpose != spanmap.PurposeAll { + tupleLength = 6 + } + *buf = msgpackWriteArrayHeader(*buf, tupleLength) + generatedStart := generatedPositions.UTF8ToUTF16(int(segment.GenStart)) + generatedEnd := generatedPositions.UTF8ToUTF16(int(segment.GenEnd)) + originalStart := originalPositions.UTF8ToUTF16(int(segment.OrigStart)) + originalEnd := originalPositions.UTF8ToUTF16(int(segment.OrigEnd)) + *buf = msgpackWriteUint(*buf, uint32(generatedStart)) + *buf = msgpackWriteUint(*buf, uint32(generatedEnd-generatedStart)) + *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.Purpose)) + } + } + return offset +} + // Minimal msgpack writers for the structured data section. func msgpackWriteArrayHeader(buf []byte, length int) []byte { 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..1ac3b71ba2a 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 + // LoadExternalPlugins allows configured content mappers to execute. + LoadExternalPlugins 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 } @@ -83,11 +88,13 @@ func (s *StdioServer) Run(ctx context.Context) error { Logger: nil, // TODO: Add logging support FS: fs, Options: &project.SessionOptions{ - CurrentDirectory: s.options.Cwd, - DefaultLibraryPath: s.options.DefaultLibraryPath, - PositionEncoding: lsproto.PositionEncodingKindUTF8, - LoggingEnabled: false, + CurrentDirectory: s.options.Cwd, + DefaultLibraryPath: s.options.DefaultLibraryPath, + PositionEncoding: lsproto.PositionEncodingKindUTF8, + LoggingEnabled: false, + LoadExternalPlugins: s.options.LoadExternalPlugins, }, + 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 8eb9aa9738f..41d308a3d37 100644 --- a/internal/api/session.go +++ b/internal/api/session.go @@ -17,6 +17,7 @@ import ( "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/compiler" "github.com/microsoft/typescript-go/internal/core" + "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" @@ -26,6 +27,7 @@ import ( "github.com/microsoft/typescript-go/internal/pprof" "github.com/microsoft/typescript-go/internal/printer" "github.com/microsoft/typescript-go/internal/project" + "github.com/microsoft/typescript-go/internal/spanmap" "github.com/microsoft/typescript-go/internal/tsoptions" "github.com/microsoft/typescript-go/internal/tspath" ) @@ -406,7 +408,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 { @@ -1119,7 +1121,6 @@ func (s *Session) handleParseConfigFile(ctx context.Context, params *ParseConfig nil, /*existingOptionsRaw*/ configFileName, nil, /*resolutionStack*/ - nil, /*extraFileExtensions*/ nil, /*extendedConfigCache*/ ) @@ -1743,11 +1744,14 @@ func toAPITextEdits(sourceFile *ast.SourceFile, converters *lsconv.Converters, e positionMap := sourceFile.GetPositionMap() result := make([]*TextEdit, len(edits)) for i, edit := range edits { - start := converters.LineAndCharacterToPosition(sourceFile, edit.Range.Start) - end := converters.LineAndCharacterToPosition(sourceFile, edit.Range.End) + starts := converters.FromLSPPosition(sourceFile, edit.Range.Start, spanmap.PurposeAll) + ends := converters.FromLSPPosition(sourceFile, edit.Range.End, spanmap.PurposeAll) + if len(starts) != 1 || len(ends) != 1 { + return nil + } result[i] = &TextEdit{ - Pos: positionMap.UTF8ToUTF16(int(start)), - End: positionMap.UTF8ToUTF16(int(end)), + Pos: positionMap.UTF8ToUTF16(int(starts[0].Position)), + End: positionMap.UTF8ToUTF16(int(ends[0].Position)), NewText: edit.NewText, } } diff --git a/internal/ast/ast.go b/internal/ast/ast.go index 6dcd606b57e..a1f4b07064c 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/stringutil" "github.com/microsoft/typescript-go/internal/tspath" "github.com/zeebo/xxh3" @@ -2471,8 +2472,11 @@ type SourceFile struct { fileName string // For debugging convenience parseOptions SourceFileParseOptions text string - Statements *NodeList // NodeList[*Statement] - EndOfFileToken *TokenNode // TokenNode[*EndOfFileToken] + originalText string // For content-mapped files, the untransformed source text. + spanMap *spanmap.SpanMap // For content-mapped files, maps transformed positions back to the original text. + contentMapper string // For content-mapped files, the identity of the mapper that produced this file. + Statements *NodeList // NodeList[*Statement] + EndOfFileToken *TokenNode // TokenNode[*EndOfFileToken] // Fields for lazily-computed data owned by packages outside ast. dataMu sync.Mutex @@ -2565,6 +2569,50 @@ 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.originalText + } + return node.text +} + +// SetOriginalText records the untransformed source text of a content-mapped file, whose Text() holds +// the transformed TypeScript. +func (node *SourceFile) SetOriginalText(text string) { + node.originalText = text +} + +// 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 { + return node.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 { + return node.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 +} + +// SetContentMapper records the identity of the content mapper that produced this file. +func (node *SourceFile) SetContentMapper(identity string) { + node.contentMapper = identity +} + +// SetSpanMap records the span map that maps positions in this file's transformed Text() back to its +// original, untransformed content. +func (node *SourceFile) SetSpanMap(spanMap *spanmap.SpanMap) { + node.spanMap = spanMap +} + func (node *SourceFile) FileName() string { return node.parseOptions.FileName } @@ -2658,6 +2706,9 @@ func (node *SourceFile) IsJS() bool { func (node *SourceFile) copyFrom(other *SourceFile) { // Do not copy fields set by NewSourceFile (Text, FileName, Path, or Statements) + node.originalText = other.originalText + node.spanMap = other.spanMap + node.contentMapper = other.contentMapper 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 8eadefd4b20..5e386a9da93 100644 --- a/internal/ast/diagnostic.go +++ b/internal/ast/diagnostic.go @@ -35,8 +35,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 @@ -54,6 +61,7 @@ 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) MessageKey() diagnostics.Key { return d.messageKey } func (d *Diagnostic) MessageArgs() []string { return d.messageArgs } func (d *Diagnostic) MessageChain() []*Diagnostic { return d.messageChain } @@ -99,12 +107,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 generated 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().AliasForGeneratedSpan(d.loc) + if !ok { + return d.messageArgs + } + generatedText := d.file.Text() + originalText := d.file.OriginalText() + if segment.GenStart < 0 || segment.GenEnd > core.TextPos(len(generatedText)) || + segment.OrigStart < 0 || segment.OrigEnd > core.TextPos(len(originalText)) { + return d.messageArgs + } + generatedName := generatedText[segment.GenStart:segment.GenEnd] + originalName := originalText[segment.OrigStart:segment.OrigEnd] + var result []string + for i, arg := range d.messageArgs { + if arg != generatedName { + continue + } + if result == nil { + result = slices.Clone(d.messageArgs) + } + result[i] = originalName + } + if result != nil { + return result + } + return d.messageArgs } func NewDiagnosticFromSerialized( @@ -160,6 +208,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 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..098b1f33337 --- /dev/null +++ b/internal/compiler/contentmapper_test.go @@ -0,0 +1,177 @@ +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/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 (fakeContentMapperHost) Acquire(mappers []*contentmapper.Mapper) func() { + return func() {} +} + +func (r fakeContentMapperHost) Transform(mapper *contentmapper.Mapper, request contentmapper.Request) (contentmapper.Result, error) { + return r.transform(request.FileName, request.Content) +} + +func (fakeContentMapperHost) Close() error { return nil } + +func newContentMapperProgram(t *testing.T, contentMapperHost contentmapper.Host, files map[string]string, rootFiles []string) *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: &core.CompilerOptions{ + SkipLibCheck: core.TSTrue, + Module: core.ModuleKindESNext, + ModuleResolution: core.ModuleResolutionKindBundler, + }, + 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, contentMapperHost), + // Load files on the calling goroutine for deterministic diagnostics ordering. + SingleThreaded: core.TSTrue, + }) +} + +func collectContentMapperDiagnostics(program *compiler.Program) []*ast.Diagnostic { + ctx := context.Background() + return slices.Concat( + program.GetSyntacticDiagnostics(ctx, nil), + program.GetSemanticDiagnostics(ctx, nil), + program.GetProgramDiagnostics(), + ) +} + +func TestContentMapperInvalidMappings(t *testing.T) { + t.Parallel() + + const transformed = "export const x = 1;\n" + const original = "\n" + + atomAll := func(origEnd int) *spanmap.SpanMap { + return spanmap.New([]spanmap.Segment{{ + GenStart: 0, GenEnd: core.TextPos(len(transformed)), + OrigStart: 0, OrigEnd: core.TextPos(origEnd), Kind: spanmap.KindAtom, + }}) + } + + testCases := []struct { + name string + mappings *spanmap.SpanMap + wantCode int32 + }{ + { + "overlap", + spanmap.New([]spanmap.Segment{ + {GenStart: 0, GenEnd: 10, OrigStart: 0, OrigEnd: 0, Kind: spanmap.KindAtom}, + {GenStart: 5, GenEnd: core.TextPos(len(transformed)), OrigStart: 0, OrigEnd: 0, Kind: spanmap.KindAtom}, + }), + 100038, + }, + { + "outOfBounds", + atomAll(len(original) + 50), + 100029, + }, + { + // A verbatim segment whose original text differs from the transformed text. + "verbatimMismatch", + spanmap.New([]spanmap.Segment{{ + GenStart: 0, GenEnd: core.TextPos(len(transformed)), + OrigStart: 0, OrigEnd: core.TextPos(len(transformed)), Kind: spanmap.KindVerbatim, + }}), + 100030, + }, + { + "invalidKind", + spanmap.New([]spanmap.Segment{{ + GenStart: 0, GenEnd: core.TextPos(len(transformed)), + OrigStart: 0, OrigEnd: core.TextPos(len(original)), Kind: 3, + }}), + 100041, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + files := map[string]string{ + "/src/app.ts": `import "./Component.vue";`, + "/src/Component.vue": original, + } + contentMapperHost := fakeContentMapperHost{ + transform: func(fileName string, content string) (contentmapper.Result, error) { + return contentmapper.Result{ + Text: transformed, + ScriptKind: core.ScriptKindTS, + Mappings: tc.mappings, + }, nil + }, + } + program := newContentMapperProgram(t, contentMapperHost, files, []string{"/src/app.ts"}) + diags := collectContentMapperDiagnostics(program) + found := slices.ContainsFunc(diags, func(d *ast.Diagnostic) bool { return d.Code() == tc.wantCode }) + assert.Assert(t, found, "expected diagnostic TS%d attributing the invalid mapping, got: %v", tc.wantCode, diags) + }) + } +} + +func TestContentMapperSourceFileState(t *testing.T) { + t.Parallel() + + t.Run("successful synthesized empty file", func(t *testing.T) { + t.Parallel() + program := newContentMapperProgram(t, fakeContentMapperHost{ + transform: func(fileName string, content string) (contentmapper.Result, error) { + return contentmapper.Result{Text: "export {};", ScriptKind: core.ScriptKindTS, Mappings: spanmap.New(nil)}, nil + }, + }, map[string]string{"/src/empty.vue": ""}, []string{"/src/empty.vue"}) + file := program.GetSourceFile("/src/empty.vue") + assert.Assert(t, file != nil) + assert.Equal(t, file.OriginalText(), "") + assert.Equal(t, file.ContentMapper(), "vue-mapper@1.0.0") + assert.Assert(t, !file.IsContentMapperFailureStub()) + }) + + t.Run("failed transform", func(t *testing.T) { + t.Parallel() + program := newContentMapperProgram(t, fakeContentMapperHost{ + transform: func(fileName string, content string) (contentmapper.Result, error) { + return contentmapper.Result{}, errors.New("failed") + }, + }, map[string]string{"/src/fail.vue": "original"}, []string{"/src/fail.vue"}) + file := program.GetSourceFile("/src/fail.vue") + assert.Assert(t, file != nil) + assert.Equal(t, file.OriginalText(), "original") + assert.Equal(t, file.ContentMapper(), "vue-mapper@1.0.0") + assert.Assert(t, file.IsContentMapperFailureStub()) + }) +} diff --git a/internal/compiler/emitHost.go b/internal/compiler/emitHost.go index 0215b4d5e1d..400a7bb599a 100644 --- a/internal/compiler/emitHost.go +++ b/internal/compiler/emitHost.go @@ -109,6 +109,10 @@ func (host *emitHost) SourceFiles() []*ast.SourceFile { return host.program.Sour func (host *emitHost) GetCurrentDirectory() string { return host.program.GetCurrentDirectory() } func (host *emitHost) CommonSourceDirectory() string { return host.program.CommonSourceDirectory() } +func (host *emitHost) ContentMapperExtensions() []string { + return host.program.ContentMapperExtensions() +} + func (host *emitHost) UseCaseSensitiveFileNames() bool { return host.program.UseCaseSensitiveFileNames() } diff --git a/internal/compiler/emit_test.go b/internal/compiler/emit_test.go index 9d0740b915e..48962acf6d2 100644 --- a/internal/compiler/emit_test.go +++ b/internal/compiler/emit_test.go @@ -51,11 +51,11 @@ func BenchmarkEmitLongLines(b *testing.B) { OutDir: "/dev/out", } - host := compiler.NewCompilerHost("/dev/src", fs, bundled.LibPath(), nil, nil) + host := compiler.NewCompilerHost("/dev/src", fs, bundled.LibPath(), nil, nil, nil) p := compiler.NewProgram(compiler.ProgramOptions{ Config: &tsoptions.ParsedCommandLine{ - ParsedConfig: &core.ParsedOptions{ + ParsedConfig: &tsoptions.ParsedOptions{ FileNames: []string{"/dev/src/index.ts"}, CompilerOptions: &opts, }, @@ -106,11 +106,11 @@ func BenchmarkEmitManyFiles(b *testing.B) { OutDir: "/dev/out", } - host := compiler.NewCompilerHost("/dev/src", fs, bundled.LibPath(), nil, nil) + host := compiler.NewCompilerHost("/dev/src", fs, bundled.LibPath(), nil, nil, nil) p := compiler.NewProgram(compiler.ProgramOptions{ Config: &tsoptions.ParsedCommandLine{ - ParsedConfig: &core.ParsedOptions{ + ParsedConfig: &tsoptions.ParsedOptions{ FileNames: fileNames, CompilerOptions: &opts, }, @@ -164,11 +164,11 @@ func BenchmarkEmitLongLinesWithLineBreaks(b *testing.B) { OutDir: "/dev/out", } - host := compiler.NewCompilerHost("/dev/src", fs, bundled.LibPath(), nil, nil) + host := compiler.NewCompilerHost("/dev/src", fs, bundled.LibPath(), nil, nil, nil) p := compiler.NewProgram(compiler.ProgramOptions{ Config: &tsoptions.ParsedCommandLine{ - ParsedConfig: &core.ParsedOptions{ + ParsedConfig: &tsoptions.ParsedOptions{ FileNames: []string{"/dev/src/index.ts"}, CompilerOptions: &opts, }, diff --git a/internal/compiler/emitter.go b/internal/compiler/emitter.go index 81fa16ad4d6..e7325b61bba 100644 --- a/internal/compiler/emitter.go +++ b/internal/compiler/emitter.go @@ -215,6 +215,10 @@ func (e *emitter) emitDeclarationFile(sourceFile *ast.SourceFile, declarationFil if sourceFile == nil || e.emitOnly == EmitOnlyJs || len(declarationFilePath) == 0 { return } + // Declaration files for content-mapped files don't get source maps because the mapped positions would point into + // transformed TS content that exists only in-memory during the build. As a future improvement, it may be possible + // to double-map the positions using the content-mapped file's spanmap. + emitDeclarationMap := e.emitOnly != EmitOnlyForcedDts && options.DeclarationMap.IsTrue() && sourceFile.ContentMapper() == "" if e.tr != nil { defer e.tr.Push(tracing.PhaseEmit, "emitDeclarationFileOrBundle", map[string]any{"declarationFilePath": declarationFilePath}, true)() @@ -247,7 +251,7 @@ func (e *emitter) emitDeclarationFile(sourceFile *ast.SourceFile, declarationFil // Module: options.Module, // NYI // ModuleResolution: options.ModuleResolution, // NYI Target: options.GetEmitScriptTarget(), - SourceMap: e.emitOnly != EmitOnlyForcedDts && options.DeclarationMap.IsTrue(), + SourceMap: emitDeclarationMap, InlineSourceMap: options.InlineSourceMap.IsTrue(), // InlineSources: options.InlineSources.IsTrue(), // ignored, per strada // ExtendedDiagnostics: options.ExtendedDiagnostics.IsTrue(), // NYI @@ -261,7 +265,7 @@ func (e *emitter) emitDeclarationFile(sourceFile *ast.SourceFile, declarationFil }, emitContext) declarationMapOptions := &core.CompilerOptions{ - SourceMap: core.IfElse(e.emitOnly != EmitOnlyForcedDts && options.DeclarationMap.IsTrue(), core.TSTrue, core.TSFalse), + SourceMap: core.IfElse(emitDeclarationMap, core.TSTrue, core.TSFalse), SourceRoot: options.SourceRoot, MapRoot: options.MapRoot, // Explicitly do not pass through either inline option. @@ -461,6 +465,12 @@ func sourceFileMayBeEmitted(sourceFile *ast.SourceFile, host SourceFileMayBeEmit return false } + // Runtime output for content-mapped files is owned by the external content mapper or build tool. Only + // include them in the emit set when their transformed TypeScript can produce declarations. + if sourceFile.ContentMapper() != "" && !forceDtsEmit && !options.GetEmitDeclarations() { + return false + } + // Source file from node_modules are not emitted if host.IsSourceFileFromExternalLibrary(sourceFile) { return false diff --git a/internal/compiler/fileloader.go b/internal/compiler/fileloader.go index 8af0966f8fc..8d4684e68a9 100644 --- a/internal/compiler/fileloader.go +++ b/internal/compiler/fileloader.go @@ -2,6 +2,7 @@ package compiler import ( "cmp" + "errors" "slices" "strings" "sync" @@ -9,9 +10,12 @@ import ( "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/collections" + "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/module" + "github.com/microsoft/typescript-go/internal/parser" + "github.com/microsoft/typescript-go/internal/spanmap" "github.com/microsoft/typescript-go/internal/tracing" "github.com/microsoft/typescript-go/internal/tsoptions" "github.com/microsoft/typescript-go/internal/tspath" @@ -24,6 +28,10 @@ type libResolution struct { trace []module.DiagAndArgs } +// maxContentMapperFailures is the number of transform failures a single content mapper may accumulate +// before it is disabled for the rest of the program. +const maxContentMapperFailures = 5 + type LibFile struct { Name string path string @@ -42,6 +50,7 @@ type fileLoader struct { comparePathsOptions tspath.ComparePathsOptions supportedExtensions [][]string supportedExtensionsWithJsonIfResolveJsonModule [][]string + contentMapperExtensions []string filesParser *filesParser rootTasks []*parseTask @@ -57,6 +66,13 @@ type fileLoader struct { pathForLibFileCache collections.SyncMap[string, *LibFile] pathForLibFileResolutions collections.SyncMap[tspath.Path, *libResolution] + + // contentMapperMu guards the content-mapper bookkeeping below, which is written concurrently as + // content-mapped files are parsed across worker goroutines. + contentMapperMu sync.Mutex + contentMapperForFile map[tspath.Path]*contentmapper.Mapper + contentMapperFailures map[*contentmapper.Mapper]int + contentMapperDiagnostics []*ast.Diagnostic } type redirectsFile struct { @@ -71,6 +87,12 @@ type DuplicateSourceFile struct { ParseOptions ast.SourceFileParseOptions Hash xxh3.Uint128 ScriptKind core.ScriptKind + // ContentMapper is the identity of the content mapper that produced this file, + // or "" if the file is not content-mapped. + ContentMapper string + // IsContentMapperFailureStub reports whether the file is an empty placeholder + // from a failed transform. + IsContentMapperFailureStub bool } var _ ast.HasFileName = (*redirectsFile)(nil) @@ -111,7 +133,11 @@ type processedFiles struct { redirectTargetsMap map[tspath.Path][]string // filesByPath for redirect files redirectFilesByPath map[tspath.Path]*redirectsFile - finishedProcessing bool + // Association from a content-mapped source file path to the content mapper that produced it. + contentMapperForFile map[tspath.Path]*contentmapper.Mapper + // Program-level diagnostics reported when a content mapper fails fatally (reported once per mapper). + contentMapperDiagnostics []*ast.Diagnostic + finishedProcessing bool } type jsxRuntimeImportSpecifier struct { @@ -125,7 +151,7 @@ func processAllProgramFiles( ) processedFiles { compilerOptions := opts.Config.CompilerOptions() rootFiles := opts.Config.FileNames() - supportedExtensions := tsoptions.GetSupportedExtensions(compilerOptions, nil /*extraFileExtensions*/) + supportedExtensions := tsoptions.GetSupportedExtensions(compilerOptions, opts.Config.ContentMapperExtensions()) supportedExtensionsWithJsonIfResolveJsonModule := tsoptions.GetSupportedExtensionsWithJsonIfResolveJsonModule(compilerOptions, supportedExtensions) var maxNodeModuleJsDepth int if p := opts.Config.CompilerOptions().MaxNodeModuleJsDepth; p != nil { @@ -145,9 +171,10 @@ func processAllProgramFiles( rootTasks: make([]*parseTask, 0, len(rootFiles)+len(compilerOptions.Lib)), supportedExtensions: supportedExtensions, supportedExtensionsWithJsonIfResolveJsonModule: supportedExtensionsWithJsonIfResolveJsonModule, + contentMapperExtensions: opts.Config.ContentMapperExtensions(), } loader.addProjectReferenceTasks(singleThreaded) - loader.resolver = module.NewResolver(loader.projectReferenceFileMapper.host, compilerOptions, opts.TypingsLocation, opts.ProjectName) + loader.resolver = module.NewResolver(loader.projectReferenceFileMapper.host, compilerOptions, opts.TypingsLocation, opts.ProjectName, opts.Config.ContentMapperExtensions()) if opts.Tracing != nil { defer opts.Tracing.Push(tracing.PhaseProgram, "processRootFiles", map[string]any{"count": len(rootFiles)}, false)() } @@ -367,14 +394,172 @@ func (p *fileLoader) parseSourceFile(t *parseTask) *ast.SourceFile { } path := p.toPath(t.normalizedFilePath) options := p.projectReferenceFileMapper.getCompilerOptionsForFile(t) - sourceFile := p.opts.Host.GetSourceFile(ast.SourceFileParseOptions{ + parseOptions := ast.SourceFileParseOptions{ FileName: t.normalizedFilePath, Path: path, ExternalModuleIndicatorOptions: ast.GetExternalModuleIndicatorOptions(t.normalizedFilePath, options, t.metadata), - }) + } + if tspath.FileExtensionIsOneOf(t.normalizedFilePath, p.contentMapperExtensions) { + return p.parseContentMappedFile(parseOptions) + } + return p.opts.Host.GetSourceFile(parseOptions) +} + +// parseContentMappedFile produces a foreign file's TypeScript source file via the host's content +// mapper, preserving the original file name and retaining the untransformed text on the +// source file. Content mapper extensions only reach the parser when content mappers are configured. +// +// When the host reports a failure the file is added with empty content and a per-file diagnostic +// describing the failure. To avoid drowning the output when a mapper is systematically broken (e.g. a +// version mismatch that makes it fail on every file), a mapper is disabled after maxContentMapperFailures +// failures: at that point a single program diagnostic reports that the mapper was disabled, and every +// subsequent file it would have handled is silently substituted with an empty file. It returns nil only +// if the file cannot be read. +func (p *fileLoader) parseContentMappedFile(opts ast.SourceFileParseOptions) *ast.SourceFile { + mapper := p.opts.Config.GetContentMapperForFileName(opts.FileName) + p.recordContentMapper(opts.Path, mapper) + label := mapper.Name + if p.contentMapperDisabled(mapper) { + // The mapper already exceeded its failure budget; add the file empty without re-reporting. + return p.emptyContentMappedFile(opts, mapper.Identity()) + } + sourceFile, err := p.opts.Host.GetContentMappedSourceFile(opts, mapper, p.opts.Config.CompilerOptions()) + if err != nil { + sourceFile := p.emptyContentMappedFile(opts, mapper.Identity()) + if p.recordContentMapperFailure(mapper, label) { + var diagnostic *ast.Diagnostic + if problem, ok := errors.AsType[*spanmap.MappingError](err); ok { + diagnostic = contentMapperMappingDiagnostic(sourceFile, label, problem) + } else { + diagnostic = contentMapperTransformDiagnostic(sourceFile, label, err) + } + sourceFile.SetDiagnostics(append(sourceFile.Diagnostics(), diagnostic)) + } + return sourceFile + } + return sourceFile +} + +func contentMapperTransformDiagnostic(file *ast.SourceFile, label string, err error) *ast.Diagnostic { + if transformError, ok := errors.AsType[*contentmapper.TransformError](err); ok { + switch transformError.Kind { + case contentmapper.TransformErrorKindInitialize: + if initializeError, ok := errors.AsType[*contentmapper.InitializeError](transformError); ok { + switch initializeError.Kind { + case contentmapper.InitializeErrorKindProtocolVersion: + return contentMapperTransformDiagnosticChain(file, label, diagnostics.The_content_mapper_uses_unsupported_protocol_version_0_expected_version_1, initializeError.ProtocolVersion, contentmapper.ProtocolVersion) + case contentmapper.InitializeErrorKindPositionEncoding: + return contentMapperTransformDiagnosticChain(file, label, diagnostics.The_content_mapper_selected_unsupported_position_encoding_0, initializeError.PositionEncoding) + case contentmapper.InitializeErrorKindEmptyDiagnosticSource: + return contentMapperTransformDiagnosticChain(file, label, diagnostics.The_content_mapper_diagnostic_source_must_not_be_empty) + case contentmapper.InitializeErrorKindReservedDiagnosticSource: + return contentMapperTransformDiagnosticChain(file, label, diagnostics.The_content_mapper_diagnostic_source_0_is_reserved_by_TypeScript, initializeError.DiagnosticSource) + } + } + return contentMapperTransformDiagnosticChain(file, label, diagnostics.The_content_mapper_process_could_not_be_started_or_initialized) + case contentmapper.TransformErrorKindCompilerOptions: + return contentMapperTransformDiagnosticChain(file, label, diagnostics.The_compiler_options_requested_by_the_content_mapper_could_not_be_prepared) + case contentmapper.TransformErrorKindRequest: + return contentMapperTransformDiagnosticChain(file, label, diagnostics.The_content_mapper_process_failed_while_handling_the_transform_request) + case contentmapper.TransformErrorKindResponse: + return contentMapperTransformDiagnosticChain(file, label, diagnostics.The_content_mapper_returned_an_invalid_transform_response) + case contentmapper.TransformErrorKindMappings: + return ast.NewDiagnostic(file, core.NewTextRange(0, 0), diagnostics.The_content_mapper_0_did_not_provide_the_required_position_mappings, label) + } + } + return ast.NewDiagnostic(file, core.NewTextRange(0, 0), diagnostics.The_content_mapper_0_failed_to_transform_this_file, label) +} + +func contentMapperTransformDiagnosticChain(file *ast.SourceFile, label string, message *diagnostics.Message, args ...any) *ast.Diagnostic { + return ast.NewDiagnostic( + file, + core.NewTextRange(0, 0), + diagnostics.The_content_mapper_0_failed_to_transform_this_file, + label, + ).AddMessageChain(ast.NewCompilerDiagnostic(message, args...)) +} + +// contentMapperMappingDiagnostic builds the diagnostic reported against a mapper that produced an +// invalid span map, including the offsets involved so the mapper's author can locate the problem. +func contentMapperMappingDiagnostic(file *ast.SourceFile, label string, problem *spanmap.MappingError) *ast.Diagnostic { + loc := core.NewTextRange(0, 0) + switch problem.Kind { + case spanmap.MappingErrorKindOverlap: + return ast.NewDiagnostic(file, loc, diagnostics.The_content_mapper_0_produced_overlapping_or_out_of_order_position_mappings_near_output_offset_1, label, int(problem.GenPos)) + case spanmap.MappingErrorKindOutOfBounds: + return ast.NewDiagnostic(file, loc, diagnostics.The_content_mapper_0_produced_a_position_mapping_that_points_outside_the_original_content_original_offset_1, label, int(problem.OrigPos)) + case spanmap.MappingErrorKindVerbatimMismatch: + return ast.NewDiagnostic(file, loc, diagnostics.The_content_mapper_0_produced_a_verbatim_mapping_that_does_not_match_the_original_content_output_offset_1_original_offset_2, label, int(problem.GenPos), int(problem.OrigPos)) + case spanmap.MappingErrorKindKind: + return ast.NewDiagnostic(file, loc, diagnostics.The_content_mapper_0_produced_a_position_mapping_with_an_invalid_kind_near_output_offset_1, label, int(problem.GenPos)) + case spanmap.MappingErrorKindOriginalOverlap: + return ast.NewDiagnostic(file, loc, diagnostics.The_content_mapper_0_produced_overlapping_original_position_mappings_that_are_not_identical_near_original_offset_1, label, int(problem.OrigPos)) + case spanmap.MappingErrorKindPurpose: + return ast.NewDiagnostic(file, loc, diagnostics.The_content_mapper_0_produced_invalid_mapping_purposes_near_original_offset_1, label, int(problem.OrigPos)) + default: + return ast.NewDiagnostic(file, loc, diagnostics.The_content_mapper_0_did_not_provide_the_required_position_mappings, label) + } +} + +// emptyContentMappedFile produces an empty TypeScript source file for a content-mapped file whose +// transform could not be used, retaining the original content for diagnostics. Importers see it as an +// empty module rather than triggering a "cannot find module" error. It is still marked as content-mapped +// so it is excluded from emit like a successfully mapped file. +func (p *fileLoader) emptyContentMappedFile(opts ast.SourceFileParseOptions, mapperIdentity string) *ast.SourceFile { + content, _ := p.opts.Host.FS().ReadFile(opts.FileName) + sourceFile := parser.ParseSourceFile(opts, "", core.ScriptKindTS) + sourceFile.SetOriginalText(content) + sourceFile.SetContentMapper(mapperIdentity) return sourceFile } +// recordContentMapper associates a content-mapped file with the mapper that produced it, so the +// program can report the mapping without re-matching extensions. +func (p *fileLoader) recordContentMapper(path tspath.Path, mapper *contentmapper.Mapper) { + if mapper == nil { + return + } + p.contentMapperMu.Lock() + defer p.contentMapperMu.Unlock() + if p.contentMapperForFile == nil { + p.contentMapperForFile = make(map[tspath.Path]*contentmapper.Mapper) + } + p.contentMapperForFile[path] = mapper +} + +// contentMapperDisabled reports whether mapper has exceeded its failure budget and been disabled. +func (p *fileLoader) contentMapperDisabled(mapper *contentmapper.Mapper) bool { + if mapper == nil { + return false + } + p.contentMapperMu.Lock() + defer p.contentMapperMu.Unlock() + return p.contentMapperFailures[mapper] >= maxContentMapperFailures +} + +// recordContentMapperFailure counts a transform failure for mapper. It returns whether the failure +// should be reported for this file (false once the mapper is already disabled). On the failure that +// reaches maxContentMapperFailures it appends a single program diagnostic disabling the mapper. +func (p *fileLoader) recordContentMapperFailure(mapper *contentmapper.Mapper, label string) bool { + p.contentMapperMu.Lock() + defer p.contentMapperMu.Unlock() + if p.contentMapperFailures == nil { + p.contentMapperFailures = make(map[*contentmapper.Mapper]int) + } + if p.contentMapperFailures[mapper] >= maxContentMapperFailures { + return false + } + p.contentMapperFailures[mapper]++ + if p.contentMapperFailures[mapper] >= maxContentMapperFailures { + p.contentMapperDiagnostics = append(p.contentMapperDiagnostics, ast.NewCompilerDiagnostic( + diagnostics.The_content_mapper_0_failed_1_times_and_will_not_be_used, + label, + maxContentMapperFailures, + )) + } + return true +} + func (p *fileLoader) isSupportedExtension(canonicalFileName string) bool { for _, group := range p.supportedExtensionsWithJsonIfResolveJsonModule { if tspath.FileExtensionIsOneOf(canonicalFileName, group) { @@ -591,7 +776,7 @@ func (p *fileLoader) resolveImportsAndModuleAugmentations(t *parseTask) { resolvedFileName := resolvedModule.ResolvedFileName isFromNodeModulesSearch := resolvedModule.IsExternalLibraryImport // Don't treat redirected files as JS files. - isJsFile := !tspath.FileExtensionIsOneOf(resolvedFileName, tspath.SupportedTSExtensionsWithJsonFlat) && p.projectReferenceFileMapper.getRedirectParsedCommandLineForResolution(ast.NewHasFileName(resolvedFileName, p.toPath(resolvedFileName))) == nil + isJsFile := !resolvedModule.ResolvedUsingExtraExtensions && !tspath.FileExtensionIsOneOf(resolvedFileName, tspath.SupportedTSExtensionsWithJsonFlat) && p.projectReferenceFileMapper.getRedirectParsedCommandLineForResolution(ast.NewHasFileName(resolvedFileName, p.toPath(resolvedFileName))) == nil isJsFileFromNodeModules := isFromNodeModulesSearch && isJsFile && strings.Contains(resolvedFileName, "/node_modules/") // add file to program only if: diff --git a/internal/compiler/filesparser.go b/internal/compiler/filesparser.go index 5817fc6bb86..8a7d672270d 100644 --- a/internal/compiler/filesparser.go +++ b/internal/compiler/filesparser.go @@ -382,9 +382,11 @@ func (w *filesParser) getProcessedFiles(loader *fileLoader) processedFiles { } if dups.AddIfAbsent(task.normalizedFilePath) { duplicateSourceFiles = append(duplicateSourceFiles, &DuplicateSourceFile{ - ParseOptions: task.file.ParseOptions(), - Hash: task.file.Hash, - ScriptKind: task.file.ScriptKind, + ParseOptions: task.file.ParseOptions(), + Hash: task.file.Hash, + ScriptKind: task.file.ScriptKind, + ContentMapper: task.file.ContentMapper(), + IsContentMapperFailureStub: task.file.IsContentMapperFailureStub(), }) } } @@ -425,9 +427,11 @@ func (w *filesParser) getProcessedFiles(loader *fileLoader) processedFiles { // program, but we still parsed this file and acquired it through // the host, so snapshot disposal must release that extra owner. duplicateSourceFiles = append(duplicateSourceFiles, &DuplicateSourceFile{ - ParseOptions: file.ParseOptions(), - Hash: file.Hash, - ScriptKind: file.ScriptKind, + ParseOptions: file.ParseOptions(), + Hash: file.Hash, + ScriptKind: file.ScriptKind, + ContentMapper: file.ContentMapper(), + IsContentMapperFailureStub: file.IsContentMapperFailureStub(), }) } redirectTargetsMap[packageIdFile.Path()] = append(redirectTargetsMap[packageIdFile.Path()], task.normalizedFilePath) @@ -551,6 +555,8 @@ func (w *filesParser) getProcessedFiles(loader *fileLoader) processedFiles { outputFileToProjectReferenceSource: outputFileToProjectReferenceSource, redirectTargetsMap: redirectTargetsMap, redirectFilesByPath: redirectFilesByPath, + contentMapperForFile: loader.contentMapperForFile, + contentMapperDiagnostics: loader.contentMapperDiagnostics, } } diff --git a/internal/compiler/host.go b/internal/compiler/host.go index 17751307fe6..e2d7b218b24 100644 --- a/internal/compiler/host.go +++ b/internal/compiler/host.go @@ -2,6 +2,7 @@ package compiler import ( "github.com/microsoft/typescript-go/internal/ast" + "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/parser" @@ -17,6 +18,12 @@ type CompilerHost interface { GetCurrentDirectory() string Trace(msg *diagnostics.Message, args ...any) GetSourceFile(opts ast.SourceFileParseOptions) *ast.SourceFile + // GetContentMappedSourceFile produces the source file for a content-mapped (foreign) file by running + // the given mapper's transform on the file's content. The caller resolves the mapper (and owns the + // failure accounting), so implementations must use it as-is. It returns nil if the file cannot be read, + // or an error if the transform fails or the mapper produces invalid position mappings. Implementations + // may cache successful results. + GetContentMappedSourceFile(parseOptions ast.SourceFileParseOptions, mapper *contentmapper.Mapper, options *core.CompilerOptions) (*ast.SourceFile, error) GetResolvedProjectReference(fileName string, path tspath.Path) *tsoptions.ParsedCommandLine } @@ -28,6 +35,7 @@ type compilerHost struct { defaultLibraryPath string extendedConfigCache tsoptions.ExtendedConfigCache trace func(msg *diagnostics.Message, args ...any) + contentMapperHost contentmapper.Host } func NewCachedFSCompilerHost( @@ -36,8 +44,9 @@ func NewCachedFSCompilerHost( defaultLibraryPath string, extendedConfigCache tsoptions.ExtendedConfigCache, trace func(msg *diagnostics.Message, args ...any), + contentMapperHost contentmapper.Host, ) CompilerHost { - return NewCompilerHost(currentDirectory, cachedvfs.From(fs), defaultLibraryPath, extendedConfigCache, trace) + return NewCompilerHost(currentDirectory, cachedvfs.From(fs), defaultLibraryPath, extendedConfigCache, trace, contentMapperHost) } func NewCompilerHost( @@ -46,6 +55,7 @@ func NewCompilerHost( defaultLibraryPath string, extendedConfigCache tsoptions.ExtendedConfigCache, trace func(msg *diagnostics.Message, args ...any), + contentMapperHost contentmapper.Host, ) CompilerHost { if trace == nil { trace = func(msg *diagnostics.Message, args ...any) {} @@ -56,6 +66,7 @@ func NewCompilerHost( defaultLibraryPath: defaultLibraryPath, extendedConfigCache: extendedConfigCache, trace: trace, + contentMapperHost: contentMapperHost, } } @@ -83,6 +94,14 @@ func (h *compilerHost) GetSourceFile(opts ast.SourceFileParseOptions) *ast.Sourc return parser.ParseSourceFile(opts, text, core.GetScriptKindFromFileName(opts.FileName)) } +func (h *compilerHost) GetContentMappedSourceFile(parseOptions ast.SourceFileParseOptions, mapper *contentmapper.Mapper, options *core.CompilerOptions) (*ast.SourceFile, error) { + content, ok := h.FS().ReadFile(parseOptions.FileName) + if !ok { + return nil, nil + } + return contentmapper.TransformAndParse(parseOptions, content, mapper, options, h.contentMapperHost) +} + func (h *compilerHost) GetResolvedProjectReference(fileName string, path tspath.Path) *tsoptions.ParsedCommandLine { commandLine, _ := tsoptions.GetParsedCommandLineOfConfigFilePath(fileName, path, nil, nil /*optionsRaw*/, h, h.extendedConfigCache) return commandLine diff --git a/internal/compiler/program.go b/internal/compiler/program.go index 74b2504d9ec..086a376c120 100644 --- a/internal/compiler/program.go +++ b/internal/compiler/program.go @@ -14,6 +14,7 @@ import ( "github.com/microsoft/typescript-go/internal/binder" "github.com/microsoft/typescript-go/internal/checker" "github.com/microsoft/typescript-go/internal/collections" + "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/json" @@ -234,7 +235,7 @@ func (p *Program) GetSourceFileFromReference(origin *ast.SourceFile, ref *ast.Fi // Still, without the failed lookup reporting that only the loader does, this isn't terribly complicated fileName := tspath.ResolvePath(tspath.GetDirectoryPath(origin.FileName()), ref.FileName) - supportedExtensionsBase := tsoptions.GetSupportedExtensions(p.Options(), nil /*extraFileExtensions*/) + supportedExtensionsBase := tsoptions.GetSupportedExtensions(p.Options(), p.CommandLine().ContentMapperExtensions()) supportedExtensions := tsoptions.GetSupportedExtensionsWithJsonIfResolveJsonModule(p.Options(), supportedExtensionsBase) allowNonTsExtensions := p.Options().AllowNonTsExtensions.IsTrue() if tspath.HasExtension(fileName) { @@ -298,7 +299,20 @@ func (p *Program) UpdateProgram(changedFilePath tspath.Path, newHost CompilerHos } oldFile := p.filesByPath[changedFilePath] - newFile := newHost.GetSourceFile(oldFile.ParseOptions()) + var newFile *ast.SourceFile + if oldFile.ContentMapper() != "" { + // Content-mapped files are produced by running an external transform, which a plain reparse can't + // reproduce. Re-run the transform through the host; any failure (or a missing file) falls back to + // a full rebuild so the file loader's failure policy runs. + mapper := newOpts.Config.GetContentMapperForFileName(oldFile.FileName()) + var err error + newFile, err = newHost.GetContentMappedSourceFile(oldFile.ParseOptions(), mapper, newOpts.Config.CompilerOptions()) + if err != nil { + return NewProgram(newOpts), nil, false + } + } else { + newFile = newHost.GetSourceFile(oldFile.ParseOptions()) + } // If this file is part of a package redirect group (same package installed in multiple // node_modules locations), we need to rebuild the program because the redirect targets @@ -402,9 +416,17 @@ func equalCheckJSDirectives(d1 *ast.CheckJsDirective, d2 *ast.CheckJsDirective) func (p *Program) SourceFiles() []*ast.SourceFile { return p.files } func (p *Program) DuplicateSourceFiles() []*DuplicateSourceFile { return p.duplicateSourceFiles } func (p *Program) Options() *core.CompilerOptions { return p.opts.Config.CompilerOptions() } -func (p *Program) CommandLine() *tsoptions.ParsedCommandLine { return p.opts.Config } -func (p *Program) Host() CompilerHost { return p.opts.Host } -func (p *Program) Tracing() *tracing.Tracing { return p.opts.Tracing } + +// GetContentMapper returns the content mapper that produced the given source file, or nil if the +// file was not produced by a content mapper. +func (p *Program) GetContentMapper(file *ast.SourceFile) *contentmapper.Mapper { + return p.contentMapperForFile[file.Path()] +} + +func (p *Program) ContentMapperExtensions() []string { return p.opts.Config.ContentMapperExtensions() } +func (p *Program) CommandLine() *tsoptions.ParsedCommandLine { return p.opts.Config } +func (p *Program) Host() CompilerHost { return p.opts.Host } +func (p *Program) Tracing() *tracing.Tracing { return p.opts.Tracing } func (p *Program) GetConfigFileParsingDiagnostics() []*ast.Diagnostic { return slices.Clip(p.opts.Config.GetConfigFileParsingDiagnostics()) } @@ -674,8 +696,9 @@ func (p *Program) GetSuggestionDiagnostics(ctx context.Context, sourceFile *ast. } func (p *Program) GetProgramDiagnostics() []*ast.Diagnostic { - return SortAndDeduplicateDiagnostics(core.Concatenate( + return SortAndDeduplicateDiagnostics(slices.Concat( p.programDiagnostics, + p.contentMapperDiagnostics, p.includeProcessor.getDiagnostics(p).GetGlobalDiagnostics(), )) } @@ -701,7 +724,7 @@ func (p *Program) canIncludeBindAndCheckDiagnostics(sourceFile *ast.SourceFile) return false } - if sourceFile.ScriptKind == core.ScriptKindTS || sourceFile.ScriptKind == core.ScriptKindTSX || sourceFile.ScriptKind == core.ScriptKindExternal { + if sourceFile.ScriptKind == core.ScriptKindTS || sourceFile.ScriptKind == core.ScriptKindTSX { return true } @@ -709,11 +732,10 @@ func (p *Program) canIncludeBindAndCheckDiagnostics(sourceFile *ast.SourceFile) isCheckJS := isJS && ast.IsCheckJSEnabledForFile(sourceFile, p.Options()) isPlainJS := ast.IsPlainJSFile(sourceFile, p.Options().CheckJs) - // By default, only type-check .ts, .tsx, Deferred, plain JS, checked JS and External + // By default, only type-check .ts, .tsx, plain JS, and checked JS // - plain JS: .js files with no // ts-check and checkJs: undefined // - check JS: .js files with either // ts-check or checkJs: true - // - external: files that are added by plugins - return isPlainJS || isCheckJS || sourceFile.ScriptKind == core.ScriptKindDeferred + return isPlainJS || isCheckJS } func (p *Program) getSourceFilesToEmit(targetSourceFile *ast.SourceFile, forceDtsEmit bool) []*ast.SourceFile { @@ -1763,7 +1785,13 @@ func GetDiagnosticsOfAnyProgram( allDiagnostics := slices.Clip(program.GetConfigFileParsingDiagnostics()) configFileParsingDiagnosticsLength := len(allDiagnostics) - allDiagnostics = append(allDiagnostics, program.GetSyntacticDiagnostics(ctx, file)...) + syntacticDiagnostics := program.GetSyntacticDiagnostics(ctx, file) + if len(syntacticDiagnostics) > 0 { + // Per-file content mapper failures are syntactic diagnostics, but the locationless diagnostic + // that disables a repeatedly failing mapper must still be reported. + allDiagnostics = append(allDiagnostics, program.Program().contentMapperDiagnostics...) + } + allDiagnostics = append(allDiagnostics, syntacticDiagnostics...) // If we didn't have any syntactic errors, then also try getting the program (options), // global and semantic errors. diff --git a/internal/compiler/program_test.go b/internal/compiler/program_test.go index 90a26d08c21..b337062c62d 100644 --- a/internal/compiler/program_test.go +++ b/internal/compiler/program_test.go @@ -246,12 +246,12 @@ func TestProgram(t *testing.T) { program := compiler.NewProgram(compiler.ProgramOptions{ Config: &tsoptions.ParsedCommandLine{ - ParsedConfig: &core.ParsedOptions{ + ParsedConfig: &tsoptions.ParsedOptions{ FileNames: []string{"c:/dev/src/index.ts"}, CompilerOptions: &opts, }, }, - Host: compiler.NewCompilerHost("c:/dev/src", fs, bundled.LibPath(), nil, nil), + Host: compiler.NewCompilerHost("c:/dev/src", fs, bundled.LibPath(), nil, nil, nil), }) actualFiles := []string{} @@ -286,12 +286,12 @@ func TestIncludeProcessorDiagnosticsWithMissingFileCasing(t *testing.T) { // to load because it does not exist on the case-sensitive filesystem. program := compiler.NewProgram(compiler.ProgramOptions{ Config: &tsoptions.ParsedCommandLine{ - ParsedConfig: &core.ParsedOptions{ + ParsedConfig: &tsoptions.ParsedOptions{ FileNames: []string{"/src/MyFile.ts", "/src/myFile.ts"}, CompilerOptions: &opts, }, }, - Host: compiler.NewCompilerHost("/", fs, bundled.LibPath(), nil, nil), + Host: compiler.NewCompilerHost("/", fs, bundled.LibPath(), nil, nil, nil), }) // GetProgramDiagnostics triggers getDiagnostics which processes all @@ -328,12 +328,12 @@ func BenchmarkNewProgram(b *testing.B) { opts := core.CompilerOptions{Target: testCase.target} programOpts := compiler.ProgramOptions{ Config: &tsoptions.ParsedCommandLine{ - ParsedConfig: &core.ParsedOptions{ + ParsedConfig: &tsoptions.ParsedOptions{ FileNames: []string{"c:/dev/src/index.ts"}, CompilerOptions: &opts, }, }, - Host: compiler.NewCompilerHost("c:/dev/src", fs, bundled.LibPath(), nil, nil), + Host: compiler.NewCompilerHost("c:/dev/src", fs, bundled.LibPath(), nil, nil, nil), } for b.Loop() { @@ -350,7 +350,7 @@ func BenchmarkNewProgram(b *testing.B) { fs := osvfs.FS() fs = bundled.WrapFS(fs) - 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"), nil, nil, host, nil) assert.Equal(b, len(errors), 0, "Expected no errors in parsed command line") diff --git a/internal/contentmapper/contentmapper.go b/internal/contentmapper/contentmapper.go new file mode 100644 index 00000000000..5cf5e130694 --- /dev/null +++ b/internal/contentmapper/contentmapper.go @@ -0,0 +1,118 @@ +// Package contentmapper defines the types describing an external content mapper: a plugin that +// transforms foreign file content (e.g. .vue) into TypeScript during program construction. +// +// A mapper is declared in tsconfig (Definition), its implementation is described by fields in its npm +// package's package.json (Manifest), and the two are combined once the package is resolved (Mapper). +// Resolution itself lives in the tsoptions package (it needs node module resolution). +// +// The package also drives the configured content mappers at build time (Host): it spawns each mapper's +// package as a child process and talks to it over a JSON-RPC connection (reusing internal/ipc), turning +// foreign file content into TypeScript. Processes are consolidated by mapper identity, so many projects +// that use the same mapper version share a single process. +package contentmapper + +import ( + "reflect" + "strings" + "sync" + + "github.com/microsoft/typescript-go/internal/collections" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/json" + "github.com/zeebo/xxh3" +) + +// Definition is a content mapper as declared in a tsconfig's "contentMappers": the npm package that +// implements the mapper and the foreign file extensions it registers. +type Definition struct { + Package string `json:"package"` + Extensions []string `json:"extensions"` +} + +// Manifest is the content-mapper information read from a package's package.json: its name and version +// (which form the mapper's identity), the argv used to run it, and the compiler options it declares it +// depends on. +type Manifest struct { + Name string + Version string + Exec []string + CompilerOptions []string +} + +// Mapper is a resolved content mapper: its tsconfig Definition combined with the Manifest resolved from +// the package's package.json, plus the package directory used as the mapper's working directory. +type Mapper struct { + Definition + Manifest `json:"-"` + PackageDirectory string `json:"-"` +} + +// Identity returns the mapper's "name@version" identity, or just the name when it declares no version, +// or an empty string when the mapper has not been resolved to a name. +func (m *Mapper) Identity() string { + switch { + case m.Name == "": + return "" + case m.Version == "": + return m.Name + default: + return m.Name + "@" + m.Version + } +} + +// TransformIdentity returns a fingerprint of everything besides a file's content that determines the +// output of transforming it with this mapper under the given options: the mapper's identity and the +// values of the compiler options it declared it depends on. Folding it into a cache key means a change to +// the mapper version or a relevant compiler option invalidates cached results. It is a pure function of +// the mapper and options — the declared options come from the manifest, so it never starts the mapper +// process. +func (m *Mapper) TransformIdentity(options *core.CompilerOptions) xxh3.Uint128 { + declared, _ := m.MarshalDeclaredOptions(options) + optionsJSON, _ := json.Marshal(declared) + buf := make([]byte, 0, len(m.Identity())+1+len(optionsJSON)) + buf = append(buf, m.Identity()...) + buf = append(buf, 0) + buf = append(buf, optionsJSON...) + return xxh3.Hash128(buf) +} + +// MarshalDeclaredOptions marshals just the compiler options this mapper declared it depends on, in the +// declared order, skipping any that are unset. Marshaling only the declared fields avoids serializing the +// whole CompilerOptions when a mapper depends on few options (or none). +func (m *Mapper) MarshalDeclaredOptions(options *core.CompilerOptions) (*collections.OrderedMap[string, json.Value], error) { + out := collections.NewOrderedMapWithSizeHint[string, json.Value](len(m.CompilerOptions)) + if options == nil || len(m.CompilerOptions) == 0 { + return out, nil + } + fields := compilerOptionFields() + v := reflect.ValueOf(options).Elem() + for _, name := range m.CompilerOptions { + i, ok := fields[name] + if !ok { + continue + } + field := v.Field(i) + if field.IsZero() { + continue + } + raw, err := json.Marshal(field.Interface()) + if err != nil { + return nil, err + } + out.Set(name, json.Value(raw)) + } + return out, nil +} + +// compilerOptionFields maps each CompilerOptions option name (its json tag) to its struct field index. +var compilerOptionFields = sync.OnceValue(func() map[string]int { + t := reflect.TypeFor[core.CompilerOptions]() + fields := make(map[string]int, t.NumField()) + for i := range t.NumField() { + name, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ",") + if name != "" && name != "-" { + fields[name] = i + } + } + return fields +}) diff --git a/internal/contentmapper/host.go b/internal/contentmapper/host.go new file mode 100644 index 00000000000..82ab0290ab1 --- /dev/null +++ b/internal/contentmapper/host.go @@ -0,0 +1,106 @@ +package contentmapper + +import ( + "fmt" + + "github.com/microsoft/typescript-go/internal/ast" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/spanmap" +) + +type TransformErrorKind uint8 + +const ( + TransformErrorKindUnknown TransformErrorKind = iota + TransformErrorKindInitialize + TransformErrorKindCompilerOptions + TransformErrorKindRequest + TransformErrorKindResponse + TransformErrorKindMappings +) + +type TransformError struct { + Kind TransformErrorKind + err error +} + +func NewTransformError(kind TransformErrorKind, err error) *TransformError { + return &TransformError{Kind: kind, err: err} +} + +func (e *TransformError) Error() string { + return fmt.Sprintf("content mapper transform failed: %v", e.err) +} + +func (e *TransformError) Unwrap() error { return e.err } + +type InitializeErrorKind uint8 + +const ( + InitializeErrorKindProtocolVersion InitializeErrorKind = iota + InitializeErrorKindPositionEncoding + InitializeErrorKindEmptyDiagnosticSource + InitializeErrorKindReservedDiagnosticSource +) + +type InitializeError struct { + Kind InitializeErrorKind + ProtocolVersion int + PositionEncoding PositionEncoding + DiagnosticSource string +} + +func (e *InitializeError) Error() string { + switch e.Kind { + case InitializeErrorKindProtocolVersion: + return fmt.Sprintf("unsupported protocol version %d (expected %d)", e.ProtocolVersion, ProtocolVersion) + case InitializeErrorKindPositionEncoding: + return fmt.Sprintf("unsupported position encoding %q", e.PositionEncoding) + case InitializeErrorKindEmptyDiagnosticSource: + return "diagnostic source must not be empty" + case InitializeErrorKindReservedDiagnosticSource: + return fmt.Sprintf("diagnostic source %q is reserved by TypeScript", e.DiagnosticSource) + default: + return "content mapper initialization failed" + } +} + +// Result is the outcome of transforming a foreign file's content into TypeScript. +type Result struct { + // Text is the transformed TypeScript source text that is parsed into the program. + Text string + // ScriptKind is how Text should be parsed. + ScriptKind core.ScriptKind + // Diagnostics are syntax errors in the original content. + Diagnostics []*ast.Diagnostic + // Mappings maps positions in Text back to the original content, so that diagnostics the compiler + // produces against the transformed text can be reported at their original locations. A successful + // transform must return a non-nil map; an empty map describes fully synthesized output. + Mappings *spanmap.SpanMap +} + +// Request carries the inputs for transforming one foreign file. +type Request struct { + // FileName is the foreign file being transformed. + FileName string + // Content is the foreign file's text. + Content string + // CompilerOptions is the project's compiler options. The host marshals and forwards only the subset + // each mapper declared it depends on; a mapper that declares none receives an empty object. + CompilerOptions *core.CompilerOptions +} + +// Host transforms foreign file content into TypeScript during program construction, by driving the +// configured content mappers. Create one with NewHost; Close tears down every mapper it spawned. +type Host interface { + // Acquire retains the processes for the given mapper identities until the returned lease is released. + // Acquiring a mapper does not start its process; processes remain lazy until Transform is called. + Acquire(mappers []*Mapper) (release func()) + // Transform maps a foreign file's content to TypeScript using the given content mapper. + // + // A non-nil error indicates the mapper itself failed to produce a result — for example the + // host hit a broken pipe, a process crash, or could not deserialize the mapper's response. + Transform(mapper *Mapper, request Request) (result Result, err error) + // Close shuts down every mapper process the host spawned. + Close() error +} diff --git a/internal/contentmapper/host_test.go b/internal/contentmapper/host_test.go new file mode 100644 index 00000000000..f62e9ec5b87 --- /dev/null +++ b/internal/contentmapper/host_test.go @@ -0,0 +1,392 @@ +package contentmapper_test + +import ( + "context" + "fmt" + "io" + "net" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/microsoft/typescript-go/internal/contentmapper" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/ipc" + "github.com/microsoft/typescript-go/internal/json" + "github.com/microsoft/typescript-go/internal/locale" + "github.com/microsoft/typescript-go/internal/spanmap" + "gotest.tools/v3/assert" +) + +// fakeMapper is an in-process mapper that transforms content verbatim and reports one diagnostic. +type fakeMapper struct{} + +func (fakeMapper) HandleRequest(ctx context.Context, method string, params json.Value) (any, error) { + switch method { + case contentmapper.MethodInitialize: + return contentmapper.InitializeResult{ProtocolVersion: contentmapper.ProtocolVersion, PositionEncoding: contentmapper.PositionEncodingUTF8, DiagnosticSource: "vue"}, nil + case contentmapper.MethodTransform: + var p contentmapper.TransformParams + if err := json.Unmarshal(params, &p); err != nil { + return nil, err + } + mappings, err := spanmap.New([]spanmap.Segment{{ + GenEnd: core.TextPos(len(p.Content)), + OrigEnd: core.TextPos(len(p.Content)), + Kind: spanmap.KindVerbatim, + }}).Marshal() + if err != nil { + return nil, err + } + return contentmapper.TransformResult{ + Text: p.Content, + Mappings: json.Value(mappings), + Diagnostics: []contentmapper.Diagnostic{{ + MessageText: "boom", + Start: 0, + Length: min(3, len(p.Content)), + Code: 9999, + }}, + }, nil + default: + return nil, fmt.Errorf("unexpected method %s", method) + } +} + +type unicodeMapper struct { + encoding contentmapper.PositionEncoding + source *string +} + +func (m unicodeMapper) HandleRequest(ctx context.Context, method string, params json.Value) (any, error) { + switch method { + case contentmapper.MethodInitialize: + var p contentmapper.InitializeParams + if err := json.Unmarshal(params, &p); err != nil { + return nil, err + } + offered := slices.Contains(p.PositionEncodings, m.encoding) + if !offered && (m.encoding == contentmapper.PositionEncodingUTF8 || m.encoding == contentmapper.PositionEncodingUTF16) { + return nil, fmt.Errorf("position encoding %q was not offered", m.encoding) + } + source := "mapper" + if m.source != nil { + source = *m.source + } + return contentmapper.InitializeResult{ProtocolVersion: contentmapper.ProtocolVersion, PositionEncoding: m.encoding, DiagnosticSource: source}, nil + case contentmapper.MethodTransform: + var p contentmapper.TransformParams + if err := json.Unmarshal(params, &p); err != nil { + return nil, err + } + var emojiLength, textLength int + switch m.encoding { + case contentmapper.PositionEncodingUTF8: + emojiLength, textLength = 2, 3 + case contentmapper.PositionEncodingUTF16: + emojiLength, textLength = 1, 2 + default: + return contentmapper.TransformResult{Text: p.Content}, nil + } + mappings, err := json.Marshal([][5]int{ + {0, emojiLength, 0, emojiLength, int(spanmap.KindVerbatim)}, + {emojiLength, textLength - emojiLength, emojiLength, textLength - emojiLength, int(spanmap.KindVerbatim)}, + }) + if err != nil { + return nil, err + } + return contentmapper.TransformResult{ + Text: p.Content, + Mappings: mappings, + Diagnostics: []contentmapper.Diagnostic{{ + MessageText: "after non-ASCII character", + Start: emojiLength, + Length: textLength - emojiLength, + }}, + }, nil + default: + return nil, fmt.Errorf("unexpected method %s", method) + } +} + +func (unicodeMapper) HandleNotification(ctx context.Context, method string, params json.Value) error { + return nil +} + +type invalidDiagnosticMapper struct { + encoding contentmapper.PositionEncoding +} + +func (m invalidDiagnosticMapper) HandleRequest(ctx context.Context, method string, params json.Value) (any, error) { + switch method { + case contentmapper.MethodInitialize: + return contentmapper.InitializeResult{ProtocolVersion: contentmapper.ProtocolVersion, PositionEncoding: m.encoding, DiagnosticSource: "mapper"}, nil + case contentmapper.MethodTransform: + return contentmapper.TransformResult{ + Text: "", + Diagnostics: []contentmapper.Diagnostic{{ + MessageText: "invalid boundary", + Start: 1, + }}, + }, nil + default: + return nil, fmt.Errorf("unexpected method %s", method) + } +} + +func (invalidDiagnosticMapper) HandleNotification(ctx context.Context, method string, params json.Value) error { + return nil +} + +func (fakeMapper) HandleNotification(ctx context.Context, method string, params json.Value) error { + return nil +} + +// fakeSpawner serves each spawn request with an in-process mapper over a net.Pipe, counting spawns so +// tests can assert process consolidation. When handler is nil it serves a fakeMapper. +type fakeSpawner struct { + spawns atomic.Int32 + closes atomic.Int32 + handler ipc.Handler +} + +func (s *fakeSpawner) Spawn(command []string, dir string) (io.ReadWriteCloser, error) { + s.spawns.Add(1) + handler := s.handler + if handler == nil { + handler = fakeMapper{} + } + client, server := net.Pipe() + go func() { _ = ipc.NewAsyncConn(server, handler).Run(context.Background()) }() + return &countingReadWriteCloser{ReadWriteCloser: client, closes: &s.closes}, nil +} + +type countingReadWriteCloser struct { + io.ReadWriteCloser + closes *atomic.Int32 + once sync.Once +} + +func (c *countingReadWriteCloser) Close() error { + c.once.Do(func() { c.closes.Add(1) }) + return c.ReadWriteCloser.Close() +} + +func TestRunnerTransform(t *testing.T) { + t.Parallel() + r := contentmapper.NewHost(t.Context(), &fakeSpawner{}, locale.Default) + defer r.Close() + + mapper := &contentmapper.Mapper{Manifest: contentmapper.Manifest{Name: "vue", Version: "1.0.0", Exec: []string{"vue-mapper"}}} + result, err := r.Transform(mapper, contentmapper.Request{FileName: "/a.vue", Content: "export const x = 1;"}) + assert.NilError(t, err) + assert.Equal(t, result.Text, "export const x = 1;") + assert.Equal(t, result.ScriptKind, core.ScriptKindTS) + assert.Assert(t, result.Mappings != nil) + assert.Equal(t, len(result.Diagnostics), 1) + assert.Equal(t, result.Diagnostics[0].Code(), int32(9999)) + assert.Equal(t, result.Diagnostics[0].Source(), "vue") +} + +func TestRunnerPositionEncodings(t *testing.T) { + t.Parallel() + for _, encoding := range []contentmapper.PositionEncoding{ + contentmapper.PositionEncodingUTF8, + contentmapper.PositionEncodingUTF16, + } { + t.Run(string(encoding), func(t *testing.T) { + t.Parallel() + r := contentmapper.NewHost(t.Context(), &fakeSpawner{handler: unicodeMapper{encoding: encoding}}, locale.Default) + defer r.Close() + mapper := &contentmapper.Mapper{Manifest: contentmapper.Manifest{Name: string(encoding), Exec: []string{"mapper"}}} + result, err := r.Transform(mapper, contentmapper.Request{FileName: "/a.vue", Content: "éx"}) + assert.NilError(t, err) + segments := result.Mappings.Segments() + assert.Equal(t, len(segments), 2) + assert.Equal(t, int(segments[0].GenEnd), 2) + assert.Equal(t, int(segments[0].OrigEnd), 2) + assert.Equal(t, int(segments[1].GenStart), 2) + assert.Equal(t, int(segments[1].OrigStart), 2) + assert.Equal(t, result.Text, "éx") + problem := result.Mappings.Validate(result.Text, "éx") + assert.Assert(t, problem == nil, "%v", problem) + mapped, fidelity := result.Mappings.GeneratedToOriginalPosition(2) + assert.Equal(t, int(mapped), 2) + assert.Equal(t, fidelity, spanmap.FidelityExact) + assert.Equal(t, result.Diagnostics[0].Pos(), 2) + assert.Equal(t, result.Diagnostics[0].End(), 3) + }) + } +} + +func TestRunnerRejectsUnsupportedPositionEncoding(t *testing.T) { + t.Parallel() + r := contentmapper.NewHost(t.Context(), &fakeSpawner{handler: unicodeMapper{encoding: "utf-32"}}, locale.Default) + defer r.Close() + mapper := &contentmapper.Mapper{Manifest: contentmapper.Manifest{Name: "invalid", Exec: []string{"mapper"}}} + _, err := r.Transform(mapper, contentmapper.Request{FileName: "/a.vue", Content: "x"}) + assert.ErrorContains(t, err, "unsupported position encoding") +} + +func TestRunnerRejectsInvalidDiagnosticSource(t *testing.T) { + t.Parallel() + for _, source := range []string{"", " ", "ts", "TS", "d.ts", "json", "typescript", "TypeScript", "tsc", "TSC"} { + t.Run(source, func(t *testing.T) { + t.Parallel() + handler := unicodeMapper{encoding: contentmapper.PositionEncodingUTF8, source: &source} + r := contentmapper.NewHost(t.Context(), &fakeSpawner{handler: handler}, locale.Default) + defer r.Close() + mapper := &contentmapper.Mapper{Manifest: contentmapper.Manifest{Name: "invalid", Exec: []string{"mapper"}}} + _, err := r.Transform(mapper, contentmapper.Request{FileName: "/a.vue", Content: "x"}) + if strings.TrimSpace(source) == "" { + assert.ErrorContains(t, err, "diagnostic source must not be empty") + } else { + assert.ErrorContains(t, err, "is reserved by TypeScript") + } + }) + } +} + +func TestRunnerRejectsPositionsInsideUnicodeCharacters(t *testing.T) { + t.Parallel() + for _, test := range []struct { + encoding contentmapper.PositionEncoding + content string + }{ + {encoding: contentmapper.PositionEncodingUTF8, content: "é"}, + {encoding: contentmapper.PositionEncodingUTF16, content: "😀"}, + } { + t.Run(string(test.encoding), func(t *testing.T) { + t.Parallel() + r := contentmapper.NewHost(t.Context(), &fakeSpawner{handler: invalidDiagnosticMapper{encoding: test.encoding}}, locale.Default) + defer r.Close() + mapper := &contentmapper.Mapper{Manifest: contentmapper.Manifest{Name: string(test.encoding), Exec: []string{"mapper"}}} + _, err := r.Transform(mapper, contentmapper.Request{FileName: "/a.vue", Content: test.content}) + assert.ErrorContains(t, err, "splits a Unicode code point") + }) + } +} + +func TestRunnerConsolidatesByIdentity(t *testing.T) { + t.Parallel() + var spawner fakeSpawner + r := contentmapper.NewHost(t.Context(), &spawner, locale.Default) + defer r.Close() + + // Two logically-separate mappers with the same identity share one process. + vueA := &contentmapper.Mapper{Definition: contentmapper.Definition{Package: "a"}, Manifest: contentmapper.Manifest{Name: "vue", Version: "1.0.0", Exec: []string{"vue-mapper"}}} + vueB := &contentmapper.Mapper{Definition: contentmapper.Definition{Package: "b"}, Manifest: contentmapper.Manifest{Name: "vue", Version: "1.0.0", Exec: []string{"vue-mapper"}}} + svelte := &contentmapper.Mapper{Manifest: contentmapper.Manifest{Name: "svelte", Version: "2.0.0", Exec: []string{"svelte-mapper"}}} + + for _, m := range []*contentmapper.Mapper{vueA, vueB, vueA, svelte} { + _, err := r.Transform(m, contentmapper.Request{FileName: "/x", Content: "y"}) + assert.NilError(t, err) + } + assert.Equal(t, spawner.spawns.Load(), int32(2), "expected one process per identity") +} + +func TestRunnerLeaseLifecycle(t *testing.T) { + t.Parallel() + var spawner fakeSpawner + r := contentmapper.NewHost(t.Context(), &spawner, locale.Default) + defer r.Close() + + vueA := &contentmapper.Mapper{Definition: contentmapper.Definition{Package: "a"}, Manifest: contentmapper.Manifest{Name: "vue", Version: "1.0.0", Exec: []string{"vue-mapper"}}} + vueB := &contentmapper.Mapper{Definition: contentmapper.Definition{Package: "b"}, Manifest: contentmapper.Manifest{Name: "vue", Version: "1.0.0", Exec: []string{"vue-mapper"}}} + svelte := &contentmapper.Mapper{Manifest: contentmapper.Manifest{Name: "svelte", Version: "2.0.0", Exec: []string{"svelte-mapper"}}} + + releaseVueA := r.Acquire([]*contentmapper.Mapper{vueA, vueA}) + releaseVueB := r.Acquire([]*contentmapper.Mapper{vueB}) + releaseSvelte := r.Acquire([]*contentmapper.Mapper{svelte}) + for _, mapper := range []*contentmapper.Mapper{vueA, svelte} { + _, err := r.Transform(mapper, contentmapper.Request{FileName: "/x", Content: "y"}) + assert.NilError(t, err) + } + assert.Equal(t, spawner.spawns.Load(), int32(2)) + + releaseVueA() + assert.Equal(t, spawner.closes.Load(), int32(0), "shared vue process should remain owned") + releaseSvelte() + assert.Equal(t, spawner.closes.Load(), int32(1), "final release should close the process") + releaseVueB() + releaseVueB() + assert.Equal(t, spawner.closes.Load(), int32(2), "final vue owner should close once") + + releaseNew := r.Acquire([]*contentmapper.Mapper{vueA}) + _, err := r.Transform(vueA, contentmapper.Request{FileName: "/x", Content: "y"}) + assert.NilError(t, err) + assert.Equal(t, spawner.spawns.Load(), int32(3), "reacquiring should spawn a fresh process lazily") + releaseNew() + assert.Equal(t, spawner.closes.Load(), int32(3)) +} + +// recordingMapper captures (as JSON) the options it receives on transform so a test can assert the host +// forwarded only the declared subset, in order. +type recordingMapper struct { + mu sync.Mutex + received string + receivedLocale string +} + +func (m *recordingMapper) HandleRequest(ctx context.Context, method string, params json.Value) (any, error) { + switch method { + case contentmapper.MethodInitialize: + var p contentmapper.InitializeParams + if err := json.Unmarshal(params, &p); err != nil { + return nil, err + } + m.mu.Lock() + m.receivedLocale = p.Locale + m.mu.Unlock() + return contentmapper.InitializeResult{ProtocolVersion: contentmapper.ProtocolVersion, PositionEncoding: contentmapper.PositionEncodingUTF8, DiagnosticSource: "mapper"}, nil + case contentmapper.MethodTransform: + var p contentmapper.TransformParams + if err := json.Unmarshal(params, &p); err != nil { + return nil, err + } + raw, err := json.Marshal(p.CompilerOptions) + if err != nil { + return nil, err + } + m.mu.Lock() + m.received = string(raw) + m.mu.Unlock() + return contentmapper.TransformResult{Text: p.Content}, nil + default: + return nil, fmt.Errorf("unexpected method %s", method) + } +} + +func (m *recordingMapper) HandleNotification(ctx context.Context, method string, params json.Value) error { + return nil +} + +func TestRunnerForwardsDeclaredOptions(t *testing.T) { + t.Parallel() + mapper := &recordingMapper{} + diagnosticLocale, ok := locale.Parse("cs-CZ") + assert.Assert(t, ok) + r := contentmapper.NewHost(t.Context(), &fakeSpawner{handler: mapper}, diagnosticLocale) + defer r.Close() + + // target is declared and set (forwarded); jsx is declared but unset (omitted); strict is set but + // undeclared (excluded). + _, err := r.Transform( + &contentmapper.Mapper{Manifest: contentmapper.Manifest{Name: "vue", Version: "1.0.0", Exec: []string{"vue-mapper"}, CompilerOptions: []string{"target", "jsx"}}}, + contentmapper.Request{ + FileName: "/a.vue", + Content: "x", + CompilerOptions: &core.CompilerOptions{Target: core.ScriptTargetES2020, Strict: core.TSTrue}, + }, + ) + assert.NilError(t, err) + + want, err := json.Marshal(core.ScriptTargetES2020) + assert.NilError(t, err) + mapper.mu.Lock() + defer mapper.mu.Unlock() + assert.Equal(t, mapper.received, fmt.Sprintf(`{"target":%s}`, want)) + assert.Equal(t, mapper.receivedLocale, "cs-CZ") +} diff --git a/internal/contentmapper/hostimpl.go b/internal/contentmapper/hostimpl.go new file mode 100644 index 00000000000..6f0f008279b --- /dev/null +++ b/internal/contentmapper/hostimpl.go @@ -0,0 +1,461 @@ +package contentmapper + +import ( + "context" + "errors" + "fmt" + "io" + "slices" + "strings" + "sync" + "unicode/utf8" + + "github.com/microsoft/typescript-go/internal/ast" + "github.com/microsoft/typescript-go/internal/collections" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/diagnostics" + "github.com/microsoft/typescript-go/internal/ipc" + "github.com/microsoft/typescript-go/internal/json" + "github.com/microsoft/typescript-go/internal/locale" + "github.com/microsoft/typescript-go/internal/spanmap" + "github.com/microsoft/typescript-go/internal/tspath" +) + +// ProtocolVersion is the content mapper protocol version this host speaks. +const ProtocolVersion = 1 + +const ( + MethodInitialize = "initialize" + MethodTransform = "transform" +) + +// InitializeParams is the parameter object for the initialize request. +type InitializeParams struct { + ProtocolVersion int `json:"protocolVersion"` + // Locale is the BCP 47 locale to use for mapper-authored diagnostic messages, when configured. + Locale string `json:"locale,omitempty"` + // PositionEncodings lists the coordinate spaces the host accepts. + PositionEncodings []PositionEncoding `json:"positionEncodings"` +} + +// InitializeResult is the mapper's response to the initialize request. +type InitializeResult struct { + ProtocolVersion int `json:"protocolVersion"` + // PositionEncoding selects the coordinate space for all mappings and diagnostics. + PositionEncoding PositionEncoding `json:"positionEncoding"` + // DiagnosticSource is the prefix used for every mapper-authored diagnostic code. + DiagnosticSource string `json:"diagnosticSource"` +} + +// PositionEncoding is the coordinate space a mapper uses for mappings and diagnostics. +type PositionEncoding string + +const ( + PositionEncodingUTF8 PositionEncoding = "utf-8" + PositionEncodingUTF16 PositionEncoding = "utf-16" +) + +// TransformParams is the parameter object for the transform request. +type TransformParams struct { + FileName string `json:"fileName"` + Content string `json:"content"` + // CompilerOptions holds the values of the options the mapper declared in initialize, keyed by option + // name and ordered by the mapper's declaration. It is an empty object when the mapper declared none. + CompilerOptions *collections.OrderedMap[string, json.Value] `json:"compilerOptions"` +} + +// TransformResult is the mapper's response to a transform request. +type TransformResult struct { + Text string `json:"text"` + ScriptKind core.ScriptKind `json:"scriptKind,omitempty"` + Diagnostics []Diagnostic `json:"diagnostics,omitempty"` + // Mappings is the span map's tuple-array JSON (see spanmap.Marshal), expressed in the selected + // position encoding. Absent or empty means the output is fully synthesized. + Mappings json.Value `json:"mappings,omitempty"` +} + +// Diagnostic is an error reported by a mapper. +type Diagnostic struct { + MessageText string `json:"messageText"` + // Start and Length locate the diagnostic in the original content using the selected position encoding. + Start int `json:"start"` + Length int `json:"length"` + Code int32 `json:"code,omitempty"` +} + +// dialFunc establishes a running connection to a mapper. In production it spawns the mapper's process; +// tests substitute an in-memory connection. It returns the connection and a closer that tears it down. +type dialFunc func(ctx context.Context, mapper *Mapper) (ipc.Conn, io.Closer, PositionEncoding, string, error) + +// host manages one child process per mapper identity. It is the production implementation of Host. +type host struct { + ctx context.Context + cancel context.CancelFunc + stop func() bool + dial dialFunc + + mu sync.Mutex + conns map[string]*mapperConn +} + +type mapperConn struct { + conn ipc.Conn + closer io.Closer + // err, when non-nil, records that this mapper failed to start; it is cached so we do not repeatedly + // try (and fail) to spawn a broken mapper. + err error + positionEncoding PositionEncoding + diagnosticSource string + // refs is the number of active Acquire calls retaining this identity. + refs int +} + +var _ Host = (*host)(nil) + +// Spawner starts a child process, returning its stdio as an io.ReadWriteCloser (Read is the +// process's stdout, Write is its stdin) whose Close tears the process down. This seam keeps os/exec out +// of this package: production hosts spawn a real process, tests supply an in-process pipe. +type Spawner interface { + Spawn(command []string, dir string) (io.ReadWriteCloser, error) +} + +// SpawnerFunc adapts a spawn function to the Spawner interface. +type SpawnerFunc func(command []string, dir string) (io.ReadWriteCloser, error) + +func (f SpawnerFunc) Spawn(command []string, dir string) (io.ReadWriteCloser, error) { + return f(command, dir) +} + +// NewHost creates a Host that spawns each mapper's process via the given spawner and drives it over a +// JSON-RPC connection. The host's lifetime is bound to ctx: cancelling it (e.g. the CLI's signal context +// on SIGINT, or a build/watch session ending) tears every mapper process down, so owners of a session +// context need not close the host explicitly. Close does the same synchronously. +func NewHost(ctx context.Context, spawner Spawner, diagnosticLocale locale.Locale) Host { + return newWithDial(ctx, func(ctx context.Context, mapper *Mapper) (ipc.Conn, io.Closer, PositionEncoding, string, error) { + if len(mapper.Exec) == 0 { + return nil, nil, "", "", fmt.Errorf("content mapper %q declares no command to run", mapper.Package) + } + rwc, err := spawner.Spawn(mapper.Exec, mapper.PackageDirectory) + if err != nil { + return nil, nil, "", "", err + } + conn := ipc.NewAsyncConn(rwc, rejectHandler{}) + go func() { _ = conn.Run(ctx) }() + positionEncoding, diagnosticSource, err := handshake(ctx, conn, diagnosticLocale) + if err != nil { + _ = rwc.Close() + return nil, nil, "", "", fmt.Errorf("content mapper %q failed to initialize: %w", mapper.Package, err) + } + return conn, rwc, positionEncoding, diagnosticSource, nil + }) +} + +func newWithDial(ctx context.Context, dial dialFunc) *host { + hostCtx, cancel := context.WithCancel(ctx) + h := &host{ctx: hostCtx, cancel: cancel, dial: dial, conns: make(map[string]*mapperConn)} + h.stop = context.AfterFunc(ctx, func() { _ = h.Close() }) + return h +} + +func (h *host) Acquire(mappers []*Mapper) func() { + seen := make(map[string]struct{}, len(mappers)) + identities := make([]string, 0, len(mappers)) + h.mu.Lock() + if h.conns != nil { + for _, mapper := range mappers { + identity := mapper.Identity() + if _, ok := seen[identity]; ok { + continue + } + seen[identity] = struct{}{} + identities = append(identities, identity) + entry := h.conns[identity] + if entry == nil { + entry = &mapperConn{} + h.conns[identity] = entry + } + entry.refs++ + } + } + h.mu.Unlock() + return sync.OnceFunc(func() { h.release(identities) }) +} + +// Transform sends the file's content to the mapper's process and decodes the transformed result. The +// mapper receives the subset of the project's compiler options it declared in its manifest (an empty +// object if it declared none). +func (h *host) Transform(mapper *Mapper, request Request) (Result, error) { + conn, positionEncoding, diagnosticSource, err := h.connFor(mapper) + if err != nil { + return Result{}, NewTransformError(TransformErrorKindInitialize, err) + } + options, err := mapper.MarshalDeclaredOptions(request.CompilerOptions) + if err != nil { + return Result{}, NewTransformError(TransformErrorKindCompilerOptions, err) + } + raw, err := conn.Call(h.ctx, MethodTransform, TransformParams{ + FileName: request.FileName, + Content: request.Content, + CompilerOptions: options, + }) + if err != nil { + return Result{}, NewTransformError(TransformErrorKindRequest, err) + } + result, err := decodeTransformResult(raw, request.Content, positionEncoding, diagnosticSource) + if err != nil { + return Result{}, NewTransformError(TransformErrorKindResponse, err) + } + return result, nil +} + +// Close shuts down every mapper process. It is safe to call more than once and is invoked automatically +// when the context passed to New is cancelled. +func (h *host) Close() error { + h.stop() + h.cancel() + h.mu.Lock() + var closers []io.Closer + for _, mc := range h.conns { + if mc.closer != nil { + closers = append(closers, mc.closer) + } + } + h.conns = nil + h.mu.Unlock() + var errs []error + for _, closer := range closers { + if err := closer.Close(); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +// connFor returns the connection for a mapper's identity, spawning its process on first use. Mappers +// sharing an identity share a single process. +func (h *host) connFor(mapper *Mapper) (ipc.Conn, PositionEncoding, string, error) { + h.mu.Lock() + defer h.mu.Unlock() + if h.conns == nil { + return nil, "", "", errors.New("content mapper host is closed") + } + identity := mapper.Identity() + entry := h.conns[identity] + if entry == nil { + entry = &mapperConn{} + h.conns[identity] = entry + } + if entry.conn != nil || entry.err != nil { + return entry.conn, entry.positionEncoding, entry.diagnosticSource, entry.err + } + conn, closer, positionEncoding, diagnosticSource, err := h.dial(h.ctx, mapper) + entry.conn = conn + entry.closer = closer + entry.err = err + entry.positionEncoding = positionEncoding + entry.diagnosticSource = diagnosticSource + return conn, positionEncoding, diagnosticSource, err +} + +func (h *host) release(identities []string) { + var closers []io.Closer + h.mu.Lock() + if h.conns != nil { + for _, identity := range identities { + entry := h.conns[identity] + if entry == nil { + continue + } + entry.refs-- + if entry.refs == 0 { + delete(h.conns, identity) + if entry.closer != nil { + closers = append(closers, entry.closer) + } + } + } + } + h.mu.Unlock() + for _, closer := range closers { + _ = closer.Close() + } +} + +func handshake(ctx context.Context, conn ipc.Conn, diagnosticLocale locale.Locale) (PositionEncoding, string, error) { + raw, err := conn.Call(ctx, MethodInitialize, InitializeParams{ + ProtocolVersion: ProtocolVersion, + Locale: diagnosticLocale.String(), + PositionEncodings: []PositionEncoding{PositionEncodingUTF8, PositionEncodingUTF16}, + }) + if err != nil { + return "", "", err + } + var res InitializeResult + if err := json.Unmarshal(raw, &res); err != nil { + return "", "", err + } + if res.ProtocolVersion != ProtocolVersion { + return "", "", &InitializeError{Kind: InitializeErrorKindProtocolVersion, ProtocolVersion: res.ProtocolVersion} + } + if res.PositionEncoding != PositionEncodingUTF8 && res.PositionEncoding != PositionEncodingUTF16 { + return "", "", &InitializeError{Kind: InitializeErrorKindPositionEncoding, PositionEncoding: res.PositionEncoding} + } + if strings.TrimSpace(res.DiagnosticSource) == "" { + return "", "", &InitializeError{Kind: InitializeErrorKindEmptyDiagnosticSource} + } + if strings.EqualFold(res.DiagnosticSource, "typescript") || strings.EqualFold(res.DiagnosticSource, "tsc") { + return "", "", &InitializeError{Kind: InitializeErrorKindReservedDiagnosticSource, DiagnosticSource: res.DiagnosticSource} + } + nativeExtensions := core.Flatten(tspath.AllSupportedExtensionsWithJson) + if slices.ContainsFunc(nativeExtensions, func(extension string) bool { + return strings.EqualFold(res.DiagnosticSource, strings.TrimPrefix(extension, ".")) + }) { + return "", "", &InitializeError{Kind: InitializeErrorKindReservedDiagnosticSource, DiagnosticSource: res.DiagnosticSource} + } + return res.PositionEncoding, res.DiagnosticSource, nil +} + +func decodeTransformResult(raw json.Value, originalText string, positionEncoding PositionEncoding, diagnosticSource string) (Result, error) { + var res TransformResult + if err := json.Unmarshal(raw, &res); err != nil { + return Result{}, err + } + // Any script kind the mapper does not produce a valid, non-Unknown value for defaults to a .ts file. + scriptKind := core.ScriptKindTS + switch res.ScriptKind { + case core.ScriptKindJS, core.ScriptKindJSX, core.ScriptKindTS, core.ScriptKindTSX, core.ScriptKindJSON: + scriptKind = res.ScriptKind + } + result := Result{ + Text: res.Text, + ScriptKind: scriptKind, + } + generatedPositions, err := newPositionNormalizer(res.Text, positionEncoding) + if err != nil { + return Result{}, err + } + originalPositions, err := newPositionNormalizer(originalText, positionEncoding) + if err != nil { + return Result{}, err + } + // A successful transform always carries a span map. Absent or empty mappings describe fully + // synthesized output (no segment corresponds to the original), so decode to an empty map rather than + // nil, which would mean "not content-mapped". + if len(res.Mappings) > 0 { + mappings, err := spanmap.Unmarshal(res.Mappings) + if err != nil { + return Result{}, err + } + result.Mappings, err = normalizeMappings(mappings, generatedPositions, originalPositions) + if err != nil { + return Result{}, err + } + } else { + result.Mappings = spanmap.New(nil) + } + for _, d := range res.Diagnostics { + if d.Start < 0 || d.Length < 0 || d.Start > int(^uint(0)>>1)-d.Length { + return Result{}, fmt.Errorf("invalid content mapper diagnostic range [%d, %d)", d.Start, d.Start+d.Length) + } + start, err := originalPositions.normalize(d.Start) + if err != nil { + return Result{}, fmt.Errorf("invalid content mapper diagnostic start: %w", err) + } + end, err := originalPositions.normalize(d.Start + d.Length) + if err != nil { + return Result{}, fmt.Errorf("invalid content mapper diagnostic end: %w", err) + } + result.Diagnostics = append(result.Diagnostics, ast.NewExternalDiagnostic( + nil, + core.NewTextRange(start, end), + diagnosticSource, + diagnostics.CategoryError, + d.Code, + d.MessageText, + )) + } + return result, nil +} + +func normalizeMappings(mappings *spanmap.SpanMap, generatedPositions *positionNormalizer, originalPositions *positionNormalizer) (*spanmap.SpanMap, error) { + segments := mappings.Segments() + for i := range segments { + segment := &segments[i] + var err error + segment.GenStart, err = generatedPositions.normalizeTextPos(segment.GenStart) + if err != nil { + return nil, fmt.Errorf("invalid content mapper mapping %d generated start: %w", i, err) + } + segment.GenEnd, err = generatedPositions.normalizeTextPos(segment.GenEnd) + if err != nil { + return nil, fmt.Errorf("invalid content mapper mapping %d generated end: %w", i, err) + } + segment.OrigStart, err = originalPositions.normalizeTextPos(segment.OrigStart) + if err != nil { + return nil, fmt.Errorf("invalid content mapper mapping %d original start: %w", i, err) + } + segment.OrigEnd, err = originalPositions.normalizeTextPos(segment.OrigEnd) + if err != nil { + return nil, fmt.Errorf("invalid content mapper mapping %d original end: %w", i, err) + } + } + return spanmap.New(segments), nil +} + +type positionNormalizer struct { + text string + encoding PositionEncoding + positionMap *ast.PositionMap + length int +} + +func newPositionNormalizer(text string, encoding PositionEncoding) (*positionNormalizer, error) { + normalizer := &positionNormalizer{text: text, encoding: encoding} + switch encoding { + case PositionEncodingUTF8: + normalizer.length = len(text) + case PositionEncodingUTF16: + normalizer.positionMap = ast.ComputePositionMap(text) + normalizer.length = normalizer.positionMap.UTF8ToUTF16(len(text)) + default: + return nil, fmt.Errorf("unsupported position encoding %q", encoding) + } + return normalizer, nil +} + +func (n *positionNormalizer) normalizeTextPos(position core.TextPos) (core.TextPos, error) { + normalized, err := n.normalize(int(position)) + return core.TextPos(normalized), err +} + +func (n *positionNormalizer) normalize(position int) (int, error) { + if position < 0 { + return 0, fmt.Errorf("position %d is negative", position) + } + if position > n.length { + return 0, fmt.Errorf("position %d exceeds %s length %d", position, n.encoding, n.length) + } + var bytePosition int + switch n.encoding { + case PositionEncodingUTF8: + bytePosition = position + case PositionEncodingUTF16: + bytePosition = n.positionMap.UTF16ToUTF8(position) + } + if bytePosition < len(n.text) && !utf8.RuneStart(n.text[bytePosition]) { + return 0, fmt.Errorf("position %d splits a Unicode code point", position) + } + return bytePosition, nil +} + +// rejectHandler rejects any request initiated by the mapper. The content mapper protocol is currently +// parent-driven only; a request from the child is a protocol violation. +type rejectHandler struct{} + +func (rejectHandler) HandleRequest(ctx context.Context, method string, params json.Value) (any, error) { + return nil, fmt.Errorf("content mapper sent an unexpected request: %s", method) +} + +func (rejectHandler) HandleNotification(ctx context.Context, method string, params json.Value) error { + return nil +} diff --git a/internal/contentmapper/transform.go b/internal/contentmapper/transform.go new file mode 100644 index 00000000000..2c7c9b5c1b7 --- /dev/null +++ b/internal/contentmapper/transform.go @@ -0,0 +1,55 @@ +package contentmapper + +import ( + "fmt" + + "github.com/microsoft/typescript-go/internal/ast" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/parser" +) + +// TransformAndParse runs the given content mapper's transform for a foreign file and +// parses the resulting TypeScript, preserving the original file name and retaining the untransformed text +// on the source file. The mapper is supplied by the caller (which also owns the failure accounting) so it +// is neither re-resolved nor substituted here. It returns an error if the transform fails or the mapper +// produces invalid position mappings (a *spanmap.MappingError); the caller decides how to report the failure +// and what placeholder file to substitute. It is the shared implementation behind +// CompilerHost.GetContentMappedSourceFile. +func TransformAndParse( + parseOptions ast.SourceFileParseOptions, + content string, + mapper *Mapper, + compilerOptions *core.CompilerOptions, + host Host, +) (*ast.SourceFile, error) { + if host == nil { + panic(fmt.Sprintf("content mapper host is required to load content-mapped file %q", parseOptions.FileName)) + } + result, err := host.Transform(mapper, Request{ + FileName: parseOptions.FileName, + Content: content, + CompilerOptions: compilerOptions, + }) + if err != nil { + return nil, err + } + if result.Mappings == nil { + return nil, NewTransformError(TransformErrorKindMappings, nil) + } + if problem := result.Mappings.Validate(result.Text, content); problem != nil { + return nil, problem + } + sourceFile := parser.ParseSourceFile(parseOptions, result.Text, result.ScriptKind) + sourceFile.SetOriginalText(content) + sourceFile.SetSpanMap(result.Mappings) + sourceFile.SetContentMapper(mapper.Identity()) + if len(result.Diagnostics) > 0 { + // The runner produces diagnostics without a source file (it doesn't have one yet); associate + // them with the file now so they are reported against it. + for _, diagnostic := range result.Diagnostics { + diagnostic.SetFile(sourceFile) + } + sourceFile.SetDiagnostics(append(sourceFile.Diagnostics(), result.Diagnostics...)) + } + return sourceFile, nil +} diff --git a/internal/core/compileroptions.go b/internal/core/compileroptions.go index b079097c540..e9efce16634 100644 --- a/internal/core/compileroptions.go +++ b/internal/core/compileroptions.go @@ -153,6 +153,7 @@ type CompilerOptions struct { Build Tristate `json:"build,omitzero"` Help Tristate `json:"help,omitzero"` All Tristate `json:"all,omitzero"` + LoadExternalPlugins Tristate `json:"loadExternalPlugins,omitzero"` PprofDir string `json:"pprofDir,omitzero"` SingleThreaded Tristate `json:"singleThreaded,omitzero"` diff --git a/internal/core/parsedoptions.go b/internal/core/parsedoptions.go deleted file mode 100644 index 3aba067df0d..00000000000 --- a/internal/core/parsedoptions.go +++ /dev/null @@ -1,10 +0,0 @@ -package core - -type ParsedOptions struct { - CompilerOptions *CompilerOptions `json:"compilerOptions"` - WatchOptions *WatchOptions `json:"watchOptions"` - TypeAcquisition *TypeAcquisition `json:"typeAcquisition"` - - FileNames []string `json:"fileNames"` - ProjectReferences []*ProjectReference `json:"projectReferences"` -} diff --git a/internal/core/scriptkind.go b/internal/core/scriptkind.go index e7066ae2240..2d98bd4c503 100644 --- a/internal/core/scriptkind.go +++ b/internal/core/scriptkind.go @@ -6,16 +6,12 @@ package core type ScriptKind int32 const ( - ScriptKindUnknown ScriptKind = iota - ScriptKindJS - ScriptKindJSX - ScriptKindTS - ScriptKindTSX - ScriptKindExternal - ScriptKindJSON - /** - * Used on extensions that doesn't define the ScriptKind but the content defines it. - * Deferred extensions are going to be included in all project contexts. - */ - ScriptKindDeferred + ScriptKindUnknown ScriptKind = 0 + ScriptKindJS ScriptKind = 1 + ScriptKindJSX ScriptKind = 2 + ScriptKindTS ScriptKind = 3 + ScriptKindTSX ScriptKind = 4 + // Value 5 is reserved (formerly ScriptKindExternal). + ScriptKindJSON ScriptKind = 6 + // Value 7 is reserved (formerly ScriptKindDeferred). ) diff --git a/internal/core/scriptkind_stringer_generated.go b/internal/core/scriptkind_stringer_generated.go index 97de80e4ca4..e7d32cd5c6c 100644 --- a/internal/core/scriptkind_stringer_generated.go +++ b/internal/core/scriptkind_stringer_generated.go @@ -13,19 +13,23 @@ func _() { _ = x[ScriptKindJSX-2] _ = x[ScriptKindTS-3] _ = x[ScriptKindTSX-4] - _ = x[ScriptKindExternal-5] _ = x[ScriptKindJSON-6] - _ = x[ScriptKindDeferred-7] } -const _ScriptKind_name = "ScriptKindUnknownScriptKindJSScriptKindJSXScriptKindTSScriptKindTSXScriptKindExternalScriptKindJSONScriptKindDeferred" +const ( + _ScriptKind_name_0 = "ScriptKindUnknownScriptKindJSScriptKindJSXScriptKindTSScriptKindTSX" + _ScriptKind_name_1 = "ScriptKindJSON" +) -var _ScriptKind_index = [...]uint8{0, 17, 29, 42, 54, 67, 85, 99, 117} +var _ScriptKind_index_0 = [...]uint8{0, 17, 29, 42, 54, 67} func (i ScriptKind) String() string { - idx := int(i) - 0 - if i < 0 || idx >= len(_ScriptKind_index)-1 { + switch { + case 0 <= i && i <= 4: + return _ScriptKind_name_0[_ScriptKind_index_0[i]:_ScriptKind_index_0[i+1]] + case i == 6: + return _ScriptKind_name_1 + default: return "ScriptKind(" + strconv.FormatInt(int64(i), 10) + ")" } - return _ScriptKind_name[_ScriptKind_index[idx]:_ScriptKind_index[idx+1]] } diff --git a/internal/diagnostics/diagnostics_generated.go b/internal/diagnostics/diagnostics_generated.go index 86ffdcea417..1331d5b185d 100644 --- a/internal/diagnostics/diagnostics_generated.go +++ b/internal/diagnostics/diagnostics_generated.go @@ -4310,6 +4310,64 @@ var Sort_Imports = &Message{code: 100018, category: CategoryMessage, key: "Sort_ var JSDoc_comment = &Message{code: 100019, category: CategoryMessage, key: "JSDoc_comment_100019", text: "JSDoc comment"} +var Content_mapper_file_extension_0_must_begin_with_a = &Message{code: 100020, category: CategoryError, key: "Content_mapper_file_extension_0_must_begin_with_a_100020", text: "Content mapper file extension '{0}' must begin with a '.'."} + +var Content_mapper_file_extension_0_is_a_built_in_extension_and_cannot_be_registered_by_a_content_mapper = &Message{code: 100021, category: CategoryError, key: "Content_mapper_file_extension_0_is_a_built_in_extension_and_cannot_be_registered_by_a_content_mapper_100021", text: "Content mapper file extension '{0}' is a built-in extension and cannot be registered by a content mapper."} + +var Content_mapper_file_extension_0_is_registered_by_more_than_one_content_mapper = &Message{code: 100022, category: CategoryError, key: "Content_mapper_file_extension_0_is_registered_by_more_than_one_content_mapper_100022", text: "Content mapper file extension '{0}' is registered by more than one content mapper."} + +var Allow_loading_external_content_mapper_plugins_that_execute_code_during_compilation = &Message{code: 100023, category: CategoryMessage, key: "Allow_loading_external_content_mapper_plugins_that_execute_code_during_compilation_100023", text: "Allow loading external content mapper plugins that execute code during compilation."} + +var Content_mappers_require_the_loadExternalPlugins_command_line_flag_to_be_enabled = &Message{code: 100024, category: CategoryError, key: "Content_mappers_require_the_loadExternalPlugins_command_line_flag_to_be_enabled_100024", text: "Content mappers require the '--loadExternalPlugins' command line flag to be enabled."} + +var The_content_mapper_0_failed_to_transform_this_file = &Message{code: 100025, category: CategoryError, key: "The_content_mapper_0_failed_to_transform_this_file_100025", text: "The content mapper '{0}' failed to transform this file."} + +var The_content_mapper_0_failed_1_times_and_will_not_be_used = &Message{code: 100026, category: CategoryError, key: "The_content_mapper_0_failed_1_times_and_will_not_be_used_100026", text: "The content mapper '{0}' failed {1} times and will not be used."} + +var The_content_mapper_0_did_not_provide_the_required_position_mappings = &Message{code: 100027, category: CategoryError, key: "The_content_mapper_0_did_not_provide_the_required_position_mappings_100027", text: "The content mapper '{0}' did not provide the required position mappings."} + +var The_content_mapper_0_produced_a_position_mapping_that_points_outside_the_original_content_original_offset_1 = &Message{code: 100029, category: CategoryError, key: "The_content_mapper_0_produced_a_position_mapping_that_points_outside_the_original_content_original_o_100029", text: "The content mapper '{0}' produced a position mapping that points outside the original content (original offset {1})."} + +var The_content_mapper_0_produced_a_verbatim_mapping_that_does_not_match_the_original_content_output_offset_1_original_offset_2 = &Message{code: 100030, category: CategoryError, key: "The_content_mapper_0_produced_a_verbatim_mapping_that_does_not_match_the_original_content_output_off_100030", text: "The content mapper '{0}' produced a verbatim mapping that does not match the original content (output offset {1}, original offset {2})."} + +var This_location_is_in_code_generated_by_the_content_mapper_0_and_has_no_corresponding_location_in_the_original_file = &Message{code: 100031, category: CategoryMessage, key: "This_location_is_in_code_generated_by_the_content_mapper_0_and_has_no_corresponding_location_in_the__100031", text: "This location is in code generated by the content mapper '{0}' and has no corresponding location in the original file."} + +var The_content_mapper_package_0_could_not_be_resolved = &Message{code: 100032, category: CategoryError, key: "The_content_mapper_package_0_could_not_be_resolved_100032", text: "The content mapper package '{0}' could not be resolved."} + +var The_package_json_of_the_content_mapper_package_0_could_not_be_parsed = &Message{code: 100033, category: CategoryError, key: "The_package_json_of_the_content_mapper_package_0_could_not_be_parsed_100033", text: "The 'package.json' of the content mapper package '{0}' could not be parsed."} + +var The_package_json_of_the_content_mapper_package_0_does_not_specify_a_name = &Message{code: 100034, category: CategoryError, key: "The_package_json_of_the_content_mapper_package_0_does_not_specify_a_name_100034", text: "The 'package.json' of the content mapper package '{0}' does not specify a 'name'."} + +var The_package_json_of_the_content_mapper_package_0_does_not_declare_a_tsContentMapper_object = &Message{code: 100035, category: CategoryError, key: "The_package_json_of_the_content_mapper_package_0_does_not_declare_a_tsContentMapper_object_100035", text: "The 'package.json' of the content mapper package '{0}' does not declare a 'tsContentMapper' object."} + +var The_tsContentMapper_exec_of_the_content_mapper_package_0_must_be_a_non_empty_array_of_strings = &Message{code: 100036, category: CategoryError, key: "The_tsContentMapper_exec_of_the_content_mapper_package_0_must_be_a_non_empty_array_of_strings_100036", text: "The 'tsContentMapper.exec' of the content mapper package '{0}' must be a non-empty array of strings."} + +var Code_generated_by_the_content_mapper_0_has_problems_with_no_corresponding_location_in_this_file = &Message{code: 100037, category: CategoryError, key: "Code_generated_by_the_content_mapper_0_has_problems_with_no_corresponding_location_in_this_file_100037", text: "Code generated by the content mapper '{0}' has problems with no corresponding location in this file."} + +var The_content_mapper_0_produced_overlapping_or_out_of_order_position_mappings_near_output_offset_1 = &Message{code: 100038, category: CategoryError, key: "The_content_mapper_0_produced_overlapping_or_out_of_order_position_mappings_near_output_offset_1_100038", text: "The content mapper '{0}' produced overlapping or out-of-order position mappings (near output offset {1})."} + +var The_content_mapper_0_produced_overlapping_original_position_mappings_that_are_not_identical_near_original_offset_1 = &Message{code: 100039, category: CategoryError, key: "The_content_mapper_0_produced_overlapping_original_position_mappings_that_are_not_identical_near_ori_100039", text: "The content mapper '{0}' produced overlapping original position mappings that are not identical (near original offset {1})."} + +var The_content_mapper_0_produced_invalid_mapping_purposes_near_original_offset_1 = &Message{code: 100040, category: CategoryError, key: "The_content_mapper_0_produced_invalid_mapping_purposes_near_original_offset_1_100040", text: "The content mapper '{0}' produced invalid mapping purposes near original offset {1}."} + +var The_content_mapper_0_produced_a_position_mapping_with_an_invalid_kind_near_output_offset_1 = &Message{code: 100041, category: CategoryError, key: "The_content_mapper_0_produced_a_position_mapping_with_an_invalid_kind_near_output_offset_1_100041", text: "The content mapper '{0}' produced a position mapping with an invalid kind (near output offset {1})."} + +var The_content_mapper_process_could_not_be_started_or_initialized = &Message{code: 100042, category: CategoryMessage, key: "The_content_mapper_process_could_not_be_started_or_initialized_100042", text: "The content mapper process could not be started or initialized."} + +var The_compiler_options_requested_by_the_content_mapper_could_not_be_prepared = &Message{code: 100043, category: CategoryMessage, key: "The_compiler_options_requested_by_the_content_mapper_could_not_be_prepared_100043", text: "The compiler options requested by the content mapper could not be prepared."} + +var The_content_mapper_process_failed_while_handling_the_transform_request = &Message{code: 100044, category: CategoryMessage, key: "The_content_mapper_process_failed_while_handling_the_transform_request_100044", text: "The content mapper process failed while handling the transform request."} + +var The_content_mapper_returned_an_invalid_transform_response = &Message{code: 100045, category: CategoryMessage, key: "The_content_mapper_returned_an_invalid_transform_response_100045", text: "The content mapper returned an invalid transform response."} + +var The_content_mapper_uses_unsupported_protocol_version_0_expected_version_1 = &Message{code: 100047, category: CategoryMessage, key: "The_content_mapper_uses_unsupported_protocol_version_0_expected_version_1_100047", text: "The content mapper uses unsupported protocol version {0}; expected version {1}."} + +var The_content_mapper_selected_unsupported_position_encoding_0 = &Message{code: 100048, category: CategoryMessage, key: "The_content_mapper_selected_unsupported_position_encoding_0_100048", text: "The content mapper selected unsupported position encoding '{0}'."} + +var The_content_mapper_diagnostic_source_must_not_be_empty = &Message{code: 100049, category: CategoryMessage, key: "The_content_mapper_diagnostic_source_must_not_be_empty_100049", text: "The content mapper diagnostic source must not be empty."} + +var The_content_mapper_diagnostic_source_0_is_reserved_by_TypeScript = &Message{code: 100050, category: CategoryMessage, key: "The_content_mapper_diagnostic_source_0_is_reserved_by_TypeScript_100050", text: "The content mapper diagnostic source '{0}' is reserved by TypeScript."} + func keyToMessage(key Key) *Message { switch key { case "Unterminated_string_literal_1002": @@ -8620,6 +8678,64 @@ func keyToMessage(key Key) *Message { return Sort_Imports case "JSDoc_comment_100019": return JSDoc_comment + case "Content_mapper_file_extension_0_must_begin_with_a_100020": + return Content_mapper_file_extension_0_must_begin_with_a + case "Content_mapper_file_extension_0_is_a_built_in_extension_and_cannot_be_registered_by_a_content_mapper_100021": + return Content_mapper_file_extension_0_is_a_built_in_extension_and_cannot_be_registered_by_a_content_mapper + case "Content_mapper_file_extension_0_is_registered_by_more_than_one_content_mapper_100022": + return Content_mapper_file_extension_0_is_registered_by_more_than_one_content_mapper + case "Allow_loading_external_content_mapper_plugins_that_execute_code_during_compilation_100023": + return Allow_loading_external_content_mapper_plugins_that_execute_code_during_compilation + case "Content_mappers_require_the_loadExternalPlugins_command_line_flag_to_be_enabled_100024": + return Content_mappers_require_the_loadExternalPlugins_command_line_flag_to_be_enabled + case "The_content_mapper_0_failed_to_transform_this_file_100025": + return The_content_mapper_0_failed_to_transform_this_file + case "The_content_mapper_0_failed_1_times_and_will_not_be_used_100026": + return The_content_mapper_0_failed_1_times_and_will_not_be_used + case "The_content_mapper_0_did_not_provide_the_required_position_mappings_100027": + return The_content_mapper_0_did_not_provide_the_required_position_mappings + case "The_content_mapper_0_produced_a_position_mapping_that_points_outside_the_original_content_original_o_100029": + return The_content_mapper_0_produced_a_position_mapping_that_points_outside_the_original_content_original_offset_1 + case "The_content_mapper_0_produced_a_verbatim_mapping_that_does_not_match_the_original_content_output_off_100030": + return The_content_mapper_0_produced_a_verbatim_mapping_that_does_not_match_the_original_content_output_offset_1_original_offset_2 + case "This_location_is_in_code_generated_by_the_content_mapper_0_and_has_no_corresponding_location_in_the__100031": + return This_location_is_in_code_generated_by_the_content_mapper_0_and_has_no_corresponding_location_in_the_original_file + case "The_content_mapper_package_0_could_not_be_resolved_100032": + return The_content_mapper_package_0_could_not_be_resolved + case "The_package_json_of_the_content_mapper_package_0_could_not_be_parsed_100033": + return The_package_json_of_the_content_mapper_package_0_could_not_be_parsed + case "The_package_json_of_the_content_mapper_package_0_does_not_specify_a_name_100034": + return The_package_json_of_the_content_mapper_package_0_does_not_specify_a_name + case "The_package_json_of_the_content_mapper_package_0_does_not_declare_a_tsContentMapper_object_100035": + return The_package_json_of_the_content_mapper_package_0_does_not_declare_a_tsContentMapper_object + case "The_tsContentMapper_exec_of_the_content_mapper_package_0_must_be_a_non_empty_array_of_strings_100036": + return The_tsContentMapper_exec_of_the_content_mapper_package_0_must_be_a_non_empty_array_of_strings + case "Code_generated_by_the_content_mapper_0_has_problems_with_no_corresponding_location_in_this_file_100037": + return Code_generated_by_the_content_mapper_0_has_problems_with_no_corresponding_location_in_this_file + case "The_content_mapper_0_produced_overlapping_or_out_of_order_position_mappings_near_output_offset_1_100038": + return The_content_mapper_0_produced_overlapping_or_out_of_order_position_mappings_near_output_offset_1 + case "The_content_mapper_0_produced_overlapping_original_position_mappings_that_are_not_identical_near_ori_100039": + return The_content_mapper_0_produced_overlapping_original_position_mappings_that_are_not_identical_near_original_offset_1 + case "The_content_mapper_0_produced_invalid_mapping_purposes_near_original_offset_1_100040": + return The_content_mapper_0_produced_invalid_mapping_purposes_near_original_offset_1 + case "The_content_mapper_0_produced_a_position_mapping_with_an_invalid_kind_near_output_offset_1_100041": + return The_content_mapper_0_produced_a_position_mapping_with_an_invalid_kind_near_output_offset_1 + case "The_content_mapper_process_could_not_be_started_or_initialized_100042": + return The_content_mapper_process_could_not_be_started_or_initialized + case "The_compiler_options_requested_by_the_content_mapper_could_not_be_prepared_100043": + return The_compiler_options_requested_by_the_content_mapper_could_not_be_prepared + case "The_content_mapper_process_failed_while_handling_the_transform_request_100044": + return The_content_mapper_process_failed_while_handling_the_transform_request + case "The_content_mapper_returned_an_invalid_transform_response_100045": + return The_content_mapper_returned_an_invalid_transform_response + case "The_content_mapper_uses_unsupported_protocol_version_0_expected_version_1_100047": + return The_content_mapper_uses_unsupported_protocol_version_0_expected_version_1 + case "The_content_mapper_selected_unsupported_position_encoding_0_100048": + return The_content_mapper_selected_unsupported_position_encoding_0 + case "The_content_mapper_diagnostic_source_must_not_be_empty_100049": + return The_content_mapper_diagnostic_source_must_not_be_empty + case "The_content_mapper_diagnostic_source_0_is_reserved_by_TypeScript_100050": + return The_content_mapper_diagnostic_source_0_is_reserved_by_TypeScript default: return nil } diff --git a/internal/diagnostics/extraDiagnosticMessages.json b/internal/diagnostics/extraDiagnosticMessages.json index 10ea4408af6..965d0e038f8 100644 --- a/internal/diagnostics/extraDiagnosticMessages.json +++ b/internal/diagnostics/extraDiagnosticMessages.json @@ -130,5 +130,121 @@ "JSDoc comment": { "category": "Message", "code": 100019 + }, + "Content mapper file extension '{0}' must begin with a '.'.": { + "category": "Error", + "code": 100020 + }, + "Content mapper file extension '{0}' is a built-in extension and cannot be registered by a content mapper.": { + "category": "Error", + "code": 100021 + }, + "Content mapper file extension '{0}' is registered by more than one content mapper.": { + "category": "Error", + "code": 100022 + }, + "Allow loading external content mapper plugins that execute code during compilation.": { + "category": "Message", + "code": 100023 + }, + "Content mappers require the '--loadExternalPlugins' command line flag to be enabled.": { + "category": "Error", + "code": 100024 + }, + "The content mapper '{0}' failed to transform this file.": { + "category": "Error", + "code": 100025 + }, + "The content mapper '{0}' failed {1} times and will not be used.": { + "category": "Error", + "code": 100026 + }, + "The content mapper '{0}' did not provide the required position mappings.": { + "category": "Error", + "code": 100027 + }, + "The content mapper '{0}' produced a position mapping that points outside the original content (original offset {1}).": { + "category": "Error", + "code": 100029 + }, + "The content mapper '{0}' produced a verbatim mapping that does not match the original content (output offset {1}, original offset {2}).": { + "category": "Error", + "code": 100030 + }, + "This location is in code generated by the content mapper '{0}' and has no corresponding location in the original file.": { + "category": "Message", + "code": 100031 + }, + "The content mapper package '{0}' could not be resolved.": { + "category": "Error", + "code": 100032 + }, + "The 'package.json' of the content mapper package '{0}' could not be parsed.": { + "category": "Error", + "code": 100033 + }, + "The 'package.json' of the content mapper package '{0}' does not specify a 'name'.": { + "category": "Error", + "code": 100034 + }, + "The 'package.json' of the content mapper package '{0}' does not declare a 'tsContentMapper' object.": { + "category": "Error", + "code": 100035 + }, + "The 'tsContentMapper.exec' of the content mapper package '{0}' must be a non-empty array of strings.": { + "category": "Error", + "code": 100036 + }, + "Code generated by the content mapper '{0}' has problems with no corresponding location in this file.": { + "category": "Error", + "code": 100037 + }, + "The content mapper '{0}' produced overlapping or out-of-order position mappings (near output offset {1}).": { + "category": "Error", + "code": 100038 + }, + "The content mapper '{0}' produced overlapping original position mappings that are not identical (near original offset {1}).": { + "category": "Error", + "code": 100039 + }, + "The content mapper '{0}' produced invalid mapping purposes near original offset {1}.": { + "category": "Error", + "code": 100040 + }, + "The content mapper '{0}' produced a position mapping with an invalid kind (near output offset {1}).": { + "category": "Error", + "code": 100041 + }, + "The content mapper process could not be started or initialized.": { + "category": "Message", + "code": 100042 + }, + "The compiler options requested by the content mapper could not be prepared.": { + "category": "Message", + "code": 100043 + }, + "The content mapper process failed while handling the transform request.": { + "category": "Message", + "code": 100044 + }, + "The content mapper returned an invalid transform response.": { + "category": "Message", + "code": 100045 + }, + "The content mapper uses unsupported protocol version {0}; expected version {1}.": { + "category": "Message", + "code": 100047 + }, + "The content mapper selected unsupported position encoding '{0}'.": { + "category": "Message", + "code": 100048 + }, + "The content mapper diagnostic source must not be empty.": { + "category": "Message", + "code": 100049 + }, + "The content mapper diagnostic source '{0}' is reserved by TypeScript.": { + "category": "Message", + "code": 100050 } } diff --git a/internal/diagnosticwriter/diagnosticwriter.go b/internal/diagnosticwriter/diagnosticwriter.go index f038badc6dc..98128b4bb82 100644 --- a/internal/diagnosticwriter/diagnosticwriter.go +++ b/internal/diagnosticwriter/diagnosticwriter.go @@ -14,6 +14,7 @@ import ( "github.com/microsoft/typescript-go/internal/diagnostics" "github.com/microsoft/typescript-go/internal/locale" "github.com/microsoft/typescript-go/internal/scanner" + "github.com/microsoft/typescript-go/internal/spanmap" "github.com/microsoft/typescript-go/internal/tspath" ) @@ -31,6 +32,9 @@ type Diagnostic interface { Len() int Code() int32 Category() diagnostics.Category + // Source is a custom prefix shown before the code instead of "TS" (empty means "TS"). A non-empty + // value marks the diagnostic as coming from an external source (e.g. a content mapper). + Source() string Localize(locale locale.Locale) string MessageChain() []Diagnostic RelatedInformation() []Diagnostic @@ -51,17 +55,92 @@ func (d *ASTDiagnostic) RelatedInformation() []Diagnostic { } func (d *ASTDiagnostic) File() FileLike { - if file := d.Diagnostic.File(); file != nil { - return file + file := d.Diagnostic.File() + if file == nil { + return nil + } + if d.resolve().useOriginal { + // The mapper's own diagnostics (Source != "") already carry original ranges; compiler + // diagnostics have their transformed ranges mapped back. Both render against the original, + // untransformed text. Diagnostics in generated code (see resolve) keep the transformed text. + return newOriginalTextFile(file) + } + return file +} + +func (d *ASTDiagnostic) Source() string { + return d.Diagnostic.Source() +} + +func (d *ASTDiagnostic) Pos() int { return d.resolve().loc.Pos() } +func (d *ASTDiagnostic) End() int { return d.resolve().loc.End() } +func (d *ASTDiagnostic) Len() int { return d.resolve().loc.Len() } + +// resolvedLocation describes how a diagnostic on a content-mapped file should be reported. +type resolvedLocation struct { + loc core.TextRange + useOriginal bool // render against the file's original, untransformed text + generated bool // the range is in generated code with no corresponding original location +} + +// resolve determines where and against which text a diagnostic should be reported. A content mapper's +// own diagnostics already carry original ranges. A compiler diagnostic on a content-mapped file has its +// transformed range mapped back to the original; if it falls entirely within generated code, there is no +// original location, so it is shown against the transformed text and flagged as generated. +func (d *ASTDiagnostic) resolve() resolvedLocation { + loc := d.Diagnostic.Loc() + file := d.Diagnostic.File() + switch { + case file == nil: + return resolvedLocation{loc: loc} + case d.Diagnostic.Source() != "": + return resolvedLocation{loc: loc, useOriginal: true} + case file.SpanMap() != nil: + mapped, fidelity := file.SpanMap().GeneratedToOriginalSpan(loc) + if fidelity == spanmap.FidelityNone { + return resolvedLocation{loc: loc, generated: true} + } + return resolvedLocation{loc: mapped, useOriginal: true} + default: + return resolvedLocation{loc: loc} + } +} + +// originalTextFile presents a source file's original (untransformed) text as a FileLike, so that +// diagnostics whose ranges point into that text render at the correct locations. +type originalTextFile struct { + fileName string + text string + lineMap []core.TextPos +} + +func newOriginalTextFile(file *ast.SourceFile) *originalTextFile { + text := file.OriginalText() + return &originalTextFile{ + fileName: file.FileName(), + text: text, + lineMap: []core.TextPos(core.ComputeECMALineStarts(text)), } - return nil } +func (f *originalTextFile) FileName() string { return f.fileName } +func (f *originalTextFile) Text() string { return f.text } +func (f *originalTextFile) ECMALineMap() []core.TextPos { return f.lineMap } + func (d *ASTDiagnostic) MessageChain() []Diagnostic { chain := d.Diagnostic.MessageChain() - result := make([]Diagnostic, len(chain)) - for i, c := range chain { - result[i] = &ASTDiagnostic{c} + result := make([]Diagnostic, 0, len(chain)+1) + for _, c := range chain { + result = append(result, &ASTDiagnostic{c}) + } + if d.resolve().generated { + // The diagnostic points into generated code; make clear the shown location is not in the + // original file, and which content mapper generated it. + note := ast.NewCompilerDiagnostic( + diagnostics.This_location_is_in_code_generated_by_the_content_mapper_0_and_has_no_corresponding_location_in_the_original_file, + d.Diagnostic.File().ContentMapper(), + ) + result = append(result, WrapASTDiagnostic(note)) } return result } @@ -140,7 +219,7 @@ func FormatDiagnosticWithColorAndContext(output io.Writer, diagnostic Diagnostic } writeWithStyleAndReset(output, diagnostic.Category().Name(), getCategoryFormat(diagnostic.Category())) - fmt.Fprintf(output, "%s TS%d: %s", foregroundColorEscapeGrey, diagnostic.Code(), resetEscapeSequence) + fmt.Fprintf(output, "%s %s%d: %s", foregroundColorEscapeGrey, diagnosticPrefix(diagnostic), diagnostic.Code(), resetEscapeSequence) WriteFlattenedDiagnosticMessage(output, diagnostic, formatOpts.NewLine, formatOpts.Locale) if diagnostic.File() != nil && diagnostic.Code() != diagnostics.File_appears_to_be_binary.Code() { @@ -280,6 +359,15 @@ func flattenDiagnosticMessageChain(writer io.Writer, chain Diagnostic, newLine s } } +// diagnosticPrefix returns the prefix shown before a diagnostic's code, e.g. "TS" for compiler +// diagnostics or a content mapper's custom source for its diagnostics. +func diagnosticPrefix(diagnostic Diagnostic) string { + if source := diagnostic.Source(); source != "" { + return source + } + return "TS" +} + func getCategoryFormat(category diagnostics.Category) string { switch category { case diagnostics.CategoryError: @@ -472,7 +560,7 @@ func WriteFormatDiagnostic(output io.Writer, diagnostic Diagnostic, formatOpts * fmt.Fprintf(output, "%s(%d,%d): ", relativeFileName, line+1, int(character)+1) } - fmt.Fprintf(output, "%s TS%d: ", diagnostic.Category().Name(), diagnostic.Code()) + fmt.Fprintf(output, "%s %s%d: ", diagnostic.Category().Name(), diagnosticPrefix(diagnostic), diagnostic.Code()) WriteFlattenedDiagnosticMessage(output, diagnostic, formatOpts.NewLine, formatOpts.Locale) fmt.Fprint(output, formatOpts.NewLine) } diff --git a/internal/execute/build/buildtask.go b/internal/execute/build/buildtask.go index 97381244caa..2e7ff1afbb8 100644 --- a/internal/execute/build/buildtask.go +++ b/internal/execute/build/buildtask.go @@ -343,6 +343,11 @@ func (t *BuildTask) getUpToDateStatus(orchestrator *Orchestrator, configPath tsp return &upToDateStatus{kind: upToDateStatusTypeTsVersionOutputOfDate, data: buildInfo.Version} } + // If a configured content mapper's identity has changed, files it produced may be stale. + if !buildInfo.ContentMapperIdentitiesMatch(incremental.ContentMapperIdentities(t.resolved)) { + return &upToDateStatus{kind: upToDateStatusTypeOutOfDateOptions, data: buildInfoPath} + } + // Report errors if build info indicates errors if buildInfo.Errors || // Errors that need to be reported irrespective of "--noCheck" (!t.resolved.CompilerOptions().NoCheck.IsTrue() && (buildInfo.SemanticErrors || buildInfo.CheckPending)) { // Errors without --noCheck diff --git a/internal/execute/build/compilerHost.go b/internal/execute/build/compilerHost.go index 3004c4cca70..7e54d5270ec 100644 --- a/internal/execute/build/compilerHost.go +++ b/internal/execute/build/compilerHost.go @@ -3,6 +3,8 @@ package build import ( "github.com/microsoft/typescript-go/internal/ast" "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/tsoptions" "github.com/microsoft/typescript-go/internal/tspath" @@ -36,6 +38,10 @@ func (h *compilerHost) GetSourceFile(opts ast.SourceFileParseOptions) *ast.Sourc return h.host.GetSourceFile(opts) } +func (h *compilerHost) GetContentMappedSourceFile(parseOptions ast.SourceFileParseOptions, mapper *contentmapper.Mapper, options *core.CompilerOptions) (*ast.SourceFile, error) { + return h.host.GetContentMappedSourceFile(parseOptions, mapper, options) +} + func (h *compilerHost) GetResolvedProjectReference(fileName string, path tspath.Path) *tsoptions.ParsedCommandLine { return h.host.GetResolvedProjectReference(fileName, path) } diff --git a/internal/execute/build/host.go b/internal/execute/build/host.go index 99563863bdb..b2419630fb4 100644 --- a/internal/execute/build/host.go +++ b/internal/execute/build/host.go @@ -6,6 +6,8 @@ import ( "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/collections" "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/execute/incremental" "github.com/microsoft/typescript-go/internal/execute/tsc" @@ -58,6 +60,14 @@ func (h *host) GetSourceFile(opts ast.SourceFileParseOptions) *ast.SourceFile { return h.host.GetSourceFile(opts) } +func (h *host) GetContentMappedSourceFile(parseOptions ast.SourceFileParseOptions, mapper *contentmapper.Mapper, options *core.CompilerOptions) (*ast.SourceFile, error) { + content, ok := h.FS().ReadFile(parseOptions.FileName) + if !ok { + return nil, nil + } + return contentmapper.TransformAndParse(parseOptions, content, mapper, options, h.orchestrator.contentMapperHost) +} + func (h *host) GetResolvedProjectReference(fileName string, path tspath.Path) *tsoptions.ParsedCommandLine { return h.resolvedReferences.loadOrStore(path, func(path tspath.Path) *tsoptions.ParsedCommandLine { configStart := h.orchestrator.opts.Sys.Now() diff --git a/internal/execute/build/orchestrator.go b/internal/execute/build/orchestrator.go index 8adac69a991..357dcbe10b9 100644 --- a/internal/execute/build/orchestrator.go +++ b/internal/execute/build/orchestrator.go @@ -12,6 +12,7 @@ import ( "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/collections" "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/execute/incremental" @@ -64,6 +65,15 @@ type Orchestrator struct { comparePathsOptions tspath.ComparePathsOptions host *host + // ctx bounds the whole build session (the CLI signal context); it is set by Start and used to bound + // resources that outlive a single project build, such as content mapper processes. + ctx context.Context + // contentMapperHost transforms content-mapped files; it is created once per build session (when + // enabled) and shared across all projects so mapper processes are consolidated. It closes itself when + // ctx is cancelled (see contentmapper.New). + contentMapperHost contentmapper.Host + releaseContentMappers func() + // order generation result tasks *collections.SyncMap[tspath.Path, *BuildTask] order []string @@ -222,9 +232,32 @@ func (o *Orchestrator) GenerateGraph(oldTasks *collections.SyncMap[tspath.Path, for _, project := range projects { o.setupBuildTask(project, nil, false, &completed, &analyzing, circularityStack) } + o.replaceContentMapperLease() +} + +func (o *Orchestrator) replaceContentMapperLease() { + if o.contentMapperHost == nil || !o.opts.Command.CompilerOptions.Watch.IsTrue() { + return + } + var mappers []*contentmapper.Mapper + for _, config := range o.order { + if task := o.getTask(o.toPath(config)); task.resolved != nil { + mappers = append(mappers, task.resolved.ContentMappers()...) + } + } + release := o.contentMapperHost.Acquire(mappers) + if o.releaseContentMappers != nil { + o.releaseContentMappers() + } + o.releaseContentMappers = release } func (o *Orchestrator) Start(ctx context.Context) tsc.CommandLineResult { + o.ctx = ctx + o.contentMapperHost = tsc.NewContentMapperHost(ctx, o.opts.Sys, o.opts.Command.CompilerOptions) + if o.contentMapperHost != nil && !o.opts.Command.CompilerOptions.Watch.IsTrue() { + defer o.contentMapperHost.Close() + } if o.opts.Command.CompilerOptions.Watch.IsTrue() { o.watchStatusReporter(ast.NewCompilerDiagnostic(diagnostics.Starting_compilation_in_watch_mode)) } @@ -698,6 +731,7 @@ func NewOrchestrator(opts Options) *Orchestrator { orchestrator.opts.Sys.DefaultLibraryPath(), nil, nil, + nil, ), mTimes: &collections.SyncMap[tspath.Path, time.Time]{}, } diff --git a/internal/execute/incremental/buildInfo.go b/internal/execute/incremental/buildInfo.go index 19a308064b7..a44825f6b1a 100644 --- a/internal/execute/incremental/buildInfo.go +++ b/internal/execute/incremental/buildInfo.go @@ -1,8 +1,10 @@ package incremental import ( + "encoding/hex" "fmt" "iter" + "slices" "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/collections" @@ -463,11 +465,12 @@ type BuildInfo struct { Version string `json:"version,omitzero"` // Common between incremental and tsc -b buildinfo for non incremental programs - Errors bool `json:"errors,omitzero"` - CheckPending bool `json:"checkPending,omitzero"` - Root []*BuildInfoRoot `json:"root,omitzero"` - PackageJsons []string `json:"packageJsons,omitzero"` - MissingPackageJsons []string `json:"missingPackageJsons,omitzero"` + Errors bool `json:"errors,omitzero"` + CheckPending bool `json:"checkPending,omitzero"` + Root []*BuildInfoRoot `json:"root,omitzero"` + PackageJsons []string `json:"packageJsons,omitzero"` + MissingPackageJsons []string `json:"missingPackageJsons,omitzero"` + ContentMapperIdentities []string `json:"contentMapperIdentities,omitzero"` // IncrementalProgram info FileNames []string `json:"fileNames,omitzero"` @@ -491,6 +494,32 @@ func (b *BuildInfo) IsValidVersion() bool { return b.Version == core.Version() } +// ContentMapperIdentities returns sorted fingerprints of the content mappers configured in config. Each +// fingerprint includes the mapper identity and values of its declared compiler options, so changing either +// invalidates build info. Mappers with no resolved name are omitted. Returns nil when no mapper has an identity. +func ContentMapperIdentities(config *tsoptions.ParsedCommandLine) []string { + mappers := config.ContentMappers() + identities := make([]string, 0, len(mappers)) + for _, mapper := range mappers { + if identity := mapper.Identity(); identity != "" { + hash := mapper.TransformIdentity(config.CompilerOptions()) + bytes := hash.Bytes() + identities = append(identities, identity+":"+hex.EncodeToString(bytes[:])) + } + } + if len(identities) == 0 { + return nil + } + slices.Sort(identities) + return identities +} + +// ContentMapperIdentitiesMatch reports whether the content mapper identities recorded in this build info +// match the given current identities (as produced by ContentMapperIdentities). +func (b *BuildInfo) ContentMapperIdentitiesMatch(current []string) bool { + return slices.Equal(b.ContentMapperIdentities, current) +} + func (b *BuildInfo) IsIncremental() bool { return b != nil && len(b.FileNames) != 0 } diff --git a/internal/execute/incremental/buildinfo_contentmapper_test.go b/internal/execute/incremental/buildinfo_contentmapper_test.go new file mode 100644 index 00000000000..93d1b027db5 --- /dev/null +++ b/internal/execute/incremental/buildinfo_contentmapper_test.go @@ -0,0 +1,88 @@ +package incremental_test + +import ( + "slices" + "strings" + "testing" + + "github.com/microsoft/typescript-go/internal/contentmapper" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/execute/incremental" + "github.com/microsoft/typescript-go/internal/tsoptions" + "gotest.tools/v3/assert" +) + +func configWithMappers(mappers ...*contentmapper.Mapper) *tsoptions.ParsedCommandLine { + return &tsoptions.ParsedCommandLine{ + ParsedConfig: &tsoptions.ParsedOptions{ + CompilerOptions: &core.CompilerOptions{}, + ContentMappers: mappers, + }, + } +} + +func TestContentMapperIdentities(t *testing.T) { + t.Parallel() + + assert.Assert(t, incremental.ContentMapperIdentities(configWithMappers()) == nil) + + // Identities come from the mappers' resolved name/version; a mapper with no name is omitted, and the + // result is sorted so reordering content mappers in tsconfig does not change it. + config := configWithMappers( + &contentmapper.Mapper{Definition: contentmapper.Definition{Package: "vue"}, Manifest: contentmapper.Manifest{Name: "vue", Version: "2.0.0"}}, + &contentmapper.Mapper{Definition: contentmapper.Definition{Package: "svelte"}, Manifest: contentmapper.Manifest{Name: "svelte", Version: "3.0.0"}}, + &contentmapper.Mapper{Definition: contentmapper.Definition{Package: "anon"}}, + ) + identities := incremental.ContentMapperIdentities(config) + assert.Equal(t, len(identities), 2) + assert.Assert(t, strings.HasPrefix(identities[0], "svelte@3.0.0:")) + assert.Assert(t, strings.HasPrefix(identities[1], "vue@2.0.0:")) + + jsxMapper := &contentmapper.Mapper{ + Definition: contentmapper.Definition{Package: "jsx"}, + Manifest: contentmapper.Manifest{Name: "jsx", Version: "1.0.0", CompilerOptions: []string{"jsx"}}, + } + jsxPreserve := configWithMappers(jsxMapper) + jsxPreserve.ParsedConfig.CompilerOptions.Jsx = core.JsxEmitPreserve + jsxReact := configWithMappers(jsxMapper) + jsxReact.ParsedConfig.CompilerOptions.Jsx = core.JsxEmitReact + assert.Assert(t, !slices.Equal(incremental.ContentMapperIdentities(jsxPreserve), incremental.ContentMapperIdentities(jsxReact))) +} + +func TestContentMapperIdentitiesMatch(t *testing.T) { + t.Parallel() + + buildInfo := &incremental.BuildInfo{ContentMapperIdentities: []string{"vue@1.0.0"}} + assert.Assert(t, buildInfo.ContentMapperIdentitiesMatch([]string{"vue@1.0.0"})) + assert.Assert(t, !buildInfo.ContentMapperIdentitiesMatch([]string{"vue@2.0.0"})) + assert.Assert(t, !buildInfo.ContentMapperIdentitiesMatch(nil)) + + empty := &incremental.BuildInfo{} + assert.Assert(t, empty.ContentMapperIdentitiesMatch(nil)) + assert.Assert(t, !empty.ContentMapperIdentitiesMatch([]string{"vue@1.0.0"})) +} + +type fakeBuildInfoReader struct { + buildInfo *incremental.BuildInfo +} + +func (r fakeBuildInfoReader) ReadBuildInfo(*tsoptions.ParsedCommandLine) *incremental.BuildInfo { + return r.buildInfo +} + +func TestReadBuildInfoProgramContentMapperIdentityMismatch(t *testing.T) { + t.Parallel() + + // An otherwise-valid, incremental build info whose recorded mapper identity differs from the one the + // config now resolves to cannot be reused: the old program is discarded (nil) so the project is + // rebuilt. The host is never touched because we bail before reconstructing the snapshot. + buildInfo := &incremental.BuildInfo{ + Version: core.Version(), + FileNames: []string{"/src/a.ts"}, + ContentMapperIdentities: []string{"vue@1.0.0"}, + } + config := configWithMappers(&contentmapper.Mapper{Definition: contentmapper.Definition{Package: "vue", Extensions: []string{".vue"}}, Manifest: contentmapper.Manifest{Name: "vue", Version: "2.0.0"}}) + + program := incremental.ReadBuildInfoProgram(config, fakeBuildInfoReader{buildInfo}, nil) + assert.Assert(t, program == nil, "expected the old program to be discarded when the mapper identity changed") +} diff --git a/internal/execute/incremental/incremental.go b/internal/execute/incremental/incremental.go index fc29c318487..804ab1ec792 100644 --- a/internal/execute/incremental/incremental.go +++ b/internal/execute/incremental/incremental.go @@ -47,6 +47,11 @@ func ReadBuildInfoProgram(config *tsoptions.ParsedCommandLine, reader BuildInfoR if buildInfo == nil || !buildInfo.IsValidVersion() || !buildInfo.IsIncremental() { return nil } + // If any configured content mapper's identity has changed, files it produced may be stale, so the + // old program cannot be reused. + if !buildInfo.ContentMapperIdentitiesMatch(ContentMapperIdentities(config)) { + return nil + } // Convert to information that can be used to create incremental program incrementalProgram := &Program{ diff --git a/internal/execute/incremental/programtosnapshot.go b/internal/execute/incremental/programtosnapshot.go index 44b9b4cbc65..9ac35f63d3e 100644 --- a/internal/execute/incremental/programtosnapshot.go +++ b/internal/execute/incremental/programtosnapshot.go @@ -90,7 +90,11 @@ func (t *toProgramSnapshot) computeProgramFileChanges() { wg := core.NewWorkGroup(t.program.SingleThreaded()) for _, file := range files { wg.Queue(func() { - version := t.snapshot.computeHash(file.Text()) + versionText := file.Text() + if file.ContentMapper() != "" { + versionText = file.OriginalText() + } + version := t.snapshot.computeHash(versionText) impliedNodeFormat := t.program.GetSourceFileMetaData(file.Path()).ImpliedNodeFormat affectsGlobalScope := fileAffectsGlobalScope(file) var signature string diff --git a/internal/execute/incremental/snapshottobuildinfo.go b/internal/execute/incremental/snapshottobuildinfo.go index 3b2c3c7736d..f4f0eec9fa5 100644 --- a/internal/execute/incremental/snapshottobuildinfo.go +++ b/internal/execute/incremental/snapshottobuildinfo.go @@ -52,6 +52,7 @@ func snapshotToBuildInfo(snapshot *snapshot, program *compiler.Program, buildInf buildInfo.Errors = snapshot.hasErrors.IsTrue() buildInfo.SemanticErrors = snapshot.hasSemanticErrors buildInfo.CheckPending = snapshot.checkPending + buildInfo.ContentMapperIdentities = ContentMapperIdentities(program.CommandLine()) to.setPackageJsons() return buildInfo } diff --git a/internal/execute/tsc.go b/internal/execute/tsc.go index 17baf4e51c5..5645af38765 100644 --- a/internal/execute/tsc.go +++ b/internal/execute/tsc.go @@ -243,6 +243,7 @@ func tscCompilation(ctx context.Context, sys tsc.System, commandLine *tsoptions. return tsc.CommandLineResult{Status: tsc.ExitStatusSuccess, Watcher: watcher} } else if configForCompilation.CompilerOptions().IsIncremental() { return performIncrementalCompilation( + ctx, sys, configForCompilation, reportDiagnostic, @@ -253,6 +254,7 @@ func tscCompilation(ctx context.Context, sys tsc.System, commandLine *tsoptions. ) } return performCompilation( + ctx, sys, configForCompilation, reportDiagnostic, @@ -282,6 +284,7 @@ func getTraceFromSys(sys tsc.System, locale locale.Locale, testing tsc.CommandLi } func performIncrementalCompilation( + ctx context.Context, sys tsc.System, config *tsoptions.ParsedCommandLine, reportDiagnostic tsc.DiagnosticReporter, @@ -290,7 +293,11 @@ func performIncrementalCompilation( compileTimes *tsc.CompileTimes, testing tsc.CommandLineTesting, ) tsc.CommandLineResult { - host := compiler.NewCachedFSCompilerHost(sys.GetCurrentDirectory(), sys.FS(), sys.DefaultLibraryPath(), extendedConfigCache, getTraceFromSys(sys, config.Locale(), testing)) + contentMapperHost := tsc.NewContentMapperHost(ctx, sys, config.CompilerOptions()) + if contentMapperHost != nil { + defer contentMapperHost.Acquire(config.ContentMappers())() + } + host := compiler.NewCachedFSCompilerHost(sys.GetCurrentDirectory(), sys.FS(), sys.DefaultLibraryPath(), extendedConfigCache, getTraceFromSys(sys, config.Locale(), testing), contentMapperHost) buildInfoReadStart := sys.Now() oldProgram := incremental.ReadBuildInfoProgram(config, incremental.NewBuildInfoReader(host), host) compileTimes.BuildInfoReadTime = sys.Now().Sub(buildInfoReadStart) @@ -331,6 +338,7 @@ func performIncrementalCompilation( } func performCompilation( + ctx context.Context, sys tsc.System, config *tsoptions.ParsedCommandLine, reportDiagnostic tsc.DiagnosticReporter, @@ -339,7 +347,11 @@ func performCompilation( compileTimes *tsc.CompileTimes, testing tsc.CommandLineTesting, ) tsc.CommandLineResult { - host := compiler.NewCachedFSCompilerHost(sys.GetCurrentDirectory(), sys.FS(), sys.DefaultLibraryPath(), extendedConfigCache, getTraceFromSys(sys, config.Locale(), testing)) + contentMapperHost := tsc.NewContentMapperHost(ctx, sys, config.CompilerOptions()) + if contentMapperHost != nil { + defer contentMapperHost.Acquire(config.ContentMappers())() + } + host := compiler.NewCachedFSCompilerHost(sys.GetCurrentDirectory(), sys.FS(), sys.DefaultLibraryPath(), extendedConfigCache, getTraceFromSys(sys, config.Locale(), testing), contentMapperHost) tr := startTracingIfNeeded(sys, config, testing) diff --git a/internal/execute/tsc/compile.go b/internal/execute/tsc/compile.go index 4f69345efaa..3c519b91df2 100644 --- a/internal/execute/tsc/compile.go +++ b/internal/execute/tsc/compile.go @@ -1,12 +1,15 @@ package tsc import ( + "context" "io" "time" "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/collections" "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/execute/incremental" "github.com/microsoft/typescript-go/internal/locale" @@ -22,6 +25,7 @@ type System interface { WriteOutputIsTTY() bool GetWidthOfTerminal() int GetEnvironmentVariable(name string) string + Spawn(command []string, dir string) (io.ReadWriteCloser, error) Now() time.Time SinceStart() time.Duration @@ -62,6 +66,18 @@ type CommandLineTesting interface { OnProgram(program *incremental.Program) } +// NewContentMapperHost creates a content mapper host when content mappers are enabled via the +// --loadExternalPlugins flag, spawning mapper processes through the system's Spawn. It returns +// nil otherwise, in which case no content-mapped files can be loaded. The caller owns the host and must +// Close it when the compilation session ends. +func NewContentMapperHost(ctx context.Context, sys System, options *core.CompilerOptions) contentmapper.Host { + if !options.LoadExternalPlugins.IsTrue() { + return nil + } + diagnosticLocale, _ := locale.Parse(options.Locale) + return contentmapper.NewHost(ctx, sys, diagnosticLocale) +} + type CompileTimes struct { ConfigTime time.Duration ParseTime time.Duration diff --git a/internal/execute/tsctests/contentmapper_watch_test.go b/internal/execute/tsctests/contentmapper_watch_test.go new file mode 100644 index 00000000000..786388f9edc --- /dev/null +++ b/internal/execute/tsctests/contentmapper_watch_test.go @@ -0,0 +1,167 @@ +package tsctests + +import ( + "context" + "io" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/microsoft/typescript-go/internal/contentmapper" + "github.com/microsoft/typescript-go/internal/execute" + "github.com/microsoft/typescript-go/internal/fswatch" + "github.com/microsoft/typescript-go/internal/testutil/contentmappertest" + "gotest.tools/v3/assert" +) + +type recordingContentMapperSystem struct { + *TestSys + spawner *recordingContentMapperSpawner +} + +func (s *recordingContentMapperSystem) Spawn(command []string, dir string) (io.ReadWriteCloser, error) { + return s.spawner.Spawn(command, dir) +} + +type recordingContentMapperSpawner struct { + inner contentmapper.Spawner + spawns atomic.Int32 + closes atomic.Int32 +} + +func (s *recordingContentMapperSpawner) Spawn(command []string, dir string) (io.ReadWriteCloser, error) { + process, err := s.inner.Spawn(command, dir) + if err != nil { + return nil, err + } + s.spawns.Add(1) + return &recordingContentMapperProcess{ReadWriteCloser: process, closes: &s.closes}, nil +} + +type recordingContentMapperProcess struct { + io.ReadWriteCloser + closes *atomic.Int32 + once sync.Once +} + +func (p *recordingContentMapperProcess) Close() error { + var err error + p.once.Do(func() { + p.closes.Add(1) + err = p.ReadWriteCloser.Close() + }) + return err +} + +func TestContentMapperBuildLifecycle(t *testing.T) { + t.Parallel() + input := &tscInput{files: FileMap{ + "/home/src/workspaces/project/tsconfig.json": `{ + "compilerOptions": { "composite": true }, + "contentMappers": [{ "package": "mapper", "extensions": [".vue"] }] + }`, + "/home/src/workspaces/project/app.vue": `export const app = 1;`, + "/home/src/workspaces/project/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.VerbatimMapper), + }} + testSys := newTestSys(input, false) + spawner := &recordingContentMapperSpawner{inner: contentmappertest.NewSpawner()} + sys := &recordingContentMapperSystem{TestSys: testSys, spawner: spawner} + + result := execute.CommandLine(t.Context(), sys, []string{"--build", "--loadExternalPlugins"}, testSys) + assert.Assert(t, result.Watcher == nil) + assert.Equal(t, spawner.spawns.Load(), int32(1)) + assert.Equal(t, spawner.closes.Load(), int32(1)) +} + +func TestContentMapperWatchLifecycle(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + args []string + }{ + {name: "watch", args: []string{"--watch", "--loadExternalPlugins"}}, + {name: "build watch", args: []string{"--build", "--watch", "--loadExternalPlugins"}}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + const configFileName = "/home/src/workspaces/project/tsconfig.json" + input := &tscInput{files: FileMap{ + configFileName: `{ + "compilerOptions": { "composite": true }, + "contentMappers": [{ "package": "mapper-a", "extensions": [".vue"] }] + }`, + "/home/src/workspaces/project/app.vue": `export const app = 1;`, + "/home/src/workspaces/project/node_modules/mapper-a/package.json": contentmappertest.PackageJSON(contentmappertest.VerbatimMapper), + "/home/src/workspaces/project/node_modules/mapper-b/package.json": strings.Replace(contentmappertest.PackageJSON(contentmappertest.VerbatimMapper), `"version": "1.0.0"`, `"version": "2.0.0"`, 1), + }} + testSys := newTestSys(input, false) + spawner := &recordingContentMapperSpawner{inner: contentmappertest.NewSpawner()} + sys := &recordingContentMapperSystem{TestSys: testSys, spawner: spawner} + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + result := execute.CommandLine(ctx, sys, test.args, testSys) + assert.Assert(t, result.Watcher != nil) + assert.Equal(t, spawner.spawns.Load(), int32(1)) + assert.Equal(t, spawner.closes.Load(), int32(0)) + + testSys.writeFileNoError(configFileName, `{ + "compilerOptions": { "composite": true }, + "contentMappers": [{ "package": "mapper-b", "extensions": [".vue"] }] + }`) + testSys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventUpdate, Path: configFileName}}) + result.Watcher.DoCycle() + + assert.Equal(t, spawner.spawns.Load(), int32(2)) + assert.Equal(t, spawner.closes.Load(), int32(1)) + + testSys.writeFileNoError(configFileName, `{ "compilerOptions": { "composite": true } }`) + testSys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventUpdate, Path: configFileName}}) + result.Watcher.DoCycle() + + assert.Equal(t, spawner.closes.Load(), int32(2)) + }) + } +} + +func TestContentMapperBuildWatchSharedLifecycle(t *testing.T) { + t.Parallel() + const mapperConfig = `{ + "compilerOptions": { "composite": true }, + "contentMappers": [{ "package": "mapper", "extensions": [".vue"] }] + }` + input := &tscInput{files: FileMap{ + "/home/src/workspaces/project/tsconfig.json": `{ + "files": [], + "references": [{ "path": "a" }, { "path": "b" }] + }`, + "/home/src/workspaces/project/a/tsconfig.json": mapperConfig, + "/home/src/workspaces/project/a/app.vue": `export const a = 1;`, + "/home/src/workspaces/project/b/tsconfig.json": mapperConfig, + "/home/src/workspaces/project/b/app.vue": `export const b = 1;`, + "/home/src/workspaces/project/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.VerbatimMapper), + }} + testSys := newTestSys(input, false) + spawner := &recordingContentMapperSpawner{inner: contentmappertest.NewSpawner()} + sys := &recordingContentMapperSystem{TestSys: testSys, spawner: spawner} + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + result := execute.CommandLine(ctx, sys, []string{"--build", "--watch", "--loadExternalPlugins"}, testSys) + assert.Assert(t, result.Watcher != nil) + assert.Equal(t, spawner.spawns.Load(), int32(1)) + assert.Equal(t, spawner.closes.Load(), int32(0)) + + for _, project := range []string{"a", "b"} { + configFileName := "/home/src/workspaces/project/" + project + "/tsconfig.json" + testSys.writeFileNoError(configFileName, `{ "compilerOptions": { "composite": true } }`) + testSys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventUpdate, Path: configFileName}}) + result.Watcher.DoCycle() + if project == "a" { + assert.Equal(t, spawner.closes.Load(), int32(0)) + } else { + assert.Equal(t, spawner.closes.Load(), int32(1)) + } + } +} diff --git a/internal/execute/tsctests/runner.go b/internal/execute/tsctests/runner.go index 38c7e9c2089..c4a52e6c0bb 100644 --- a/internal/execute/tsctests/runner.go +++ b/internal/execute/tsctests/runner.go @@ -41,9 +41,9 @@ type tscInput struct { windowsStyleRoot string } -func (test *tscInput) executeCommand(sys *TestSys, baselineBuilder *strings.Builder, commandLineArgs []string) tsc.CommandLineResult { +func (test *tscInput) executeCommand(ctx context.Context, sys *TestSys, baselineBuilder *strings.Builder, commandLineArgs []string) tsc.CommandLineResult { fmt.Fprint(baselineBuilder, "tsgo ", strings.Join(commandLineArgs, " "), "\n") - result := execute.CommandLine(context.Background(), sys, commandLineArgs, sys) + result := execute.CommandLine(ctx, sys, commandLineArgs, sys) switch result.Status { case tsc.ExitStatusSuccess: baselineBuilder.WriteString("ExitStatus:: Success") @@ -67,6 +67,8 @@ func (test *tscInput) run(t *testing.T, scenario string) { t.Helper() t.Run(test.getBaselineSubFolder()+"/"+test.subScenario, func(t *testing.T) { t.Parallel() + // ctx is cancelled when the subtest ends, tearing down any content mapper host created during the run. + ctx := t.Context() // initial test tsc compile baselineBuilder := &strings.Builder{} sys := newTestSys(test, false) @@ -79,7 +81,7 @@ func (test *tscInput) run(t *testing.T, scenario string) { "\nInput::\n", ) sys.baselineFSwithDiff(baselineBuilder) - result := test.executeCommand(sys, baselineBuilder, test.commandLineArgs) + result := test.executeCommand(ctx, sys, baselineBuilder, test.commandLineArgs) sys.serializeState(baselineBuilder) if result.Watcher != nil && sys.mockWatchBackend.HasWatches() { baselineBuilder.WriteString(sys.mockWatchBackend.WatchState()) @@ -101,7 +103,7 @@ func (test *tscInput) run(t *testing.T, scenario string) { sys.baselineFSwithDiff(baselineBuilder) if result.Watcher == nil { - test.executeCommand(sys, baselineBuilder, commandLineArgs) + test.executeCommand(ctx, sys, baselineBuilder, commandLineArgs) } else { sys.mockWatchBackend.SendChangedPaths(changedPaths) result.Watcher.DoCycle() @@ -120,7 +122,7 @@ func (test *tscInput) run(t *testing.T, scenario string) { test.edits[i].edit(nonIncrementalSys) } } - execute.CommandLine(context.Background(), nonIncrementalSys, commandLineArgs, nonIncrementalSys) + execute.CommandLine(ctx, nonIncrementalSys, commandLineArgs, nonIncrementalSys) }) wg.RunAndWait() diff --git a/internal/execute/tsctests/sys.go b/internal/execute/tsctests/sys.go index 2d75a36d4ca..09408c3b2f9 100644 --- a/internal/execute/tsctests/sys.go +++ b/internal/execute/tsctests/sys.go @@ -19,6 +19,7 @@ import ( "github.com/microsoft/typescript-go/internal/execute/tsc" "github.com/microsoft/typescript-go/internal/execute/watchmanager" "github.com/microsoft/typescript-go/internal/locale" + "github.com/microsoft/typescript-go/internal/testutil/contentmappertest" "github.com/microsoft/typescript-go/internal/testutil/fsbaselineutil" "github.com/microsoft/typescript-go/internal/testutil/harnessutil" "github.com/microsoft/typescript-go/internal/testutil/stringtestutil" @@ -234,6 +235,13 @@ func (s *TestSys) GetEnvironmentVariable(name string) string { return s.env[name] } +// Spawn serves the fake content mappers in-process, selecting the implementation by the exec command the +// mapper package declares (see internal/testutil/contentmappertest), so tests exercise the full IPC stack +// without spawning a subprocess. +func (s *TestSys) Spawn(command []string, dir string) (io.ReadWriteCloser, error) { + return contentmappertest.NewSpawner().Spawn(command, dir) +} + func (s *TestSys) OnEmittedFiles(result *compiler.EmitResult, mTimesCache *collections.SyncMap[tspath.Path, time.Time]) { if result != nil { for _, file := range result.EmittedFiles { diff --git a/internal/execute/tsctests/tsc_test.go b/internal/execute/tsctests/tsc_test.go index 29cba7ba4e2..9cda13b1290 100644 --- a/internal/execute/tsctests/tsc_test.go +++ b/internal/execute/tsctests/tsc_test.go @@ -4756,3 +4756,113 @@ func TestGenerateTrace(t *testing.T) { c.run(t, "generateTrace") } } + +func TestTscContentMapperEmit(t *testing.T) { + t.Parallel() + (&tscInput{ + subScenario: "content-mapped files are not emitted", + files: FileMap{ + "/home/src/workspaces/project/tsconfig.json": stringtestutil.Dedent(` + { + "compilerOptions": { + "outDir": "./dist" + }, + "contentMappers": [ + { "package": "vue-ts-mapper", "extensions": [".vue"] } + ] + }`), + "/home/src/workspaces/project/index.ts": `export const local = 1;`, + "/home/src/workspaces/project/app.vue": `export const app = 1;`, + "/home/src/workspaces/project/node_modules/vue-ts-mapper/package.json": stringtestutil.Dedent(` + { + "name": "vue-ts-mapper", + "version": "1.0.0", + "tsContentMapper": { "exec": ["verbatim-mapper"] } + }`), + }, + commandLineArgs: []string{"--loadExternalPlugins"}, + }).run(t, "contentMapperEmit") +} + +func TestTscContentMapperFailures(t *testing.T) { + t.Parallel() + failMapperPackageJSON := stringtestutil.Dedent(` + { + "name": "fail", + "version": "1.0.0", + "tsContentMapper": { "exec": ["failing-mapper"] } + }`) + failMapperTSConfig := stringtestutil.Dedent(` + { + "contentMappers": [ + { "package": "fail", "extensions": [".vue"] } + ] + }`) + testCases := []*tscInput{ + { + subScenario: "transform failure reports a per-file error", + files: FileMap{ + "/home/src/workspaces/project/tsconfig.json": failMapperTSConfig, + "/home/src/workspaces/project/index.ts": `import "./app.vue";`, + "/home/src/workspaces/project/app.vue": ``, + "/home/src/workspaces/project/node_modules/fail/package.json": failMapperPackageJSON, + }, + commandLineArgs: []string{"--loadExternalPlugins"}, + }, + { + subScenario: "mapper is disabled after repeated failures", + files: FileMap{ + "/home/src/workspaces/project/tsconfig.json": failMapperTSConfig, + "/home/src/workspaces/project/index.ts": stringtestutil.Dedent(` + import "./a.vue"; + import "./b.vue"; + import "./c.vue"; + import "./d.vue"; + import "./e.vue"; + import "./f.vue"; + import "./g.vue";`), + "/home/src/workspaces/project/a.vue": ``, + "/home/src/workspaces/project/b.vue": ``, + "/home/src/workspaces/project/c.vue": ``, + "/home/src/workspaces/project/d.vue": ``, + "/home/src/workspaces/project/e.vue": ``, + "/home/src/workspaces/project/f.vue": ``, + "/home/src/workspaces/project/g.vue": ``, + "/home/src/workspaces/project/node_modules/fail/package.json": failMapperPackageJSON, + }, + // --singleThreaded makes file loading order deterministic so the same files exceed the failure + // threshold on every run. + commandLineArgs: []string{"--loadExternalPlugins", "--singleThreaded"}, + }, + } + for _, test := range testCases { + test.run(t, "contentMapperFailures") + } +} + +func TestTscContentMapperSynthesized(t *testing.T) { + t.Parallel() + (&tscInput{ + subScenario: "diagnostics in synthesized code render on the generated text", + files: FileMap{ + "/home/src/workspaces/project/tsconfig.json": stringtestutil.Dedent(` + { + "contentMappers": [ + { "package": "synth", "extensions": [".vue"] } + ] + }`), + "/home/src/workspaces/project/index.ts": `import "./app.vue";`, + "/home/src/workspaces/project/app.vue": stringtestutil.Dedent(` + `), + "/home/src/workspaces/project/node_modules/synth/package.json": stringtestutil.Dedent(` + { + "name": "synth", + "version": "1.0.0", + "tsContentMapper": { "exec": ["synthesizing-mapper"] } + }`), + }, + commandLineArgs: []string{"--loadExternalPlugins"}, + }).run(t, "contentMapperSynthesized") +} diff --git a/internal/execute/tsctests/tscbuild_test.go b/internal/execute/tsctests/tscbuild_test.go index 9c964d2e7a1..b0393e113f1 100644 --- a/internal/execute/tsctests/tscbuild_test.go +++ b/internal/execute/tsctests/tscbuild_test.go @@ -297,6 +297,52 @@ console.log(tsconfig);`, } } +func TestBuildContentMapperIdentity(t *testing.T) { + t.Parallel() + testCases := []*tscInput{ + { + subScenario: "content mapper identity change forces rebuild", + files: FileMap{ + "/home/src/workspaces/project/tsconfig.json": stringtestutil.Dedent(` + { + "compilerOptions": { + "incremental": true + }, + "contentMappers": [ + { "package": "vue-ts-mapper", "extensions": [".vue"] } + ] + }`), + "/home/src/workspaces/project/index.ts": `export const local = 1;`, + "/home/src/workspaces/project/app.vue": `export const app = 1;`, + "/home/src/workspaces/project/node_modules/vue-ts-mapper/package.json": stringtestutil.Dedent(` + { + "name": "vue-ts-mapper", + "version": "1.0.0", + "tsContentMapper": { "exec": ["verbatim-mapper"] } + }`), + }, + commandLineArgs: []string{"--build", "--verbose", "--loadExternalPlugins"}, + edits: []*tscEdit{ + noChange, + { + caption: "upgrade the content mapper package to a new version", + edit: func(sys *TestSys) { + sys.replaceFileText( + "/home/src/workspaces/project/node_modules/vue-ts-mapper/package.json", + `"version": "1.0.0"`, + `"version": "2.0.0"`, + ) + }, + }, + noChange, + }, + }, + } + for _, test := range testCases { + test.run(t, "contentMapperIdentity") + } +} + func TestBuildClean(t *testing.T) { t.Parallel() testCases := []*tscInput{ diff --git a/internal/execute/watcher.go b/internal/execute/watcher.go index 9b11c2a4dd4..88f4c51b682 100644 --- a/internal/execute/watcher.go +++ b/internal/execute/watcher.go @@ -9,6 +9,7 @@ import ( "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/collections" "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/execute/incremental" @@ -65,6 +66,14 @@ type Watcher struct { reportWatchStatus tsc.DiagnosticReporter testing tsc.CommandLineTesting + // ctx bounds the whole watch session (the CLI signal context); it is set by start and used to bound + // resources that outlive a single cycle, such as content mapper processes. + ctx context.Context + // contentMapperHost transforms content-mapped files; it is created once per watch session (when + // enabled) and reused across cycles. It closes itself when ctx is cancelled (see contentmapper.New). + contentMapperHost contentmapper.Host + releaseContentMappers func() + program *incremental.Program extendedConfigCache *tsc.ExtendedConfigCache configModified bool @@ -112,9 +121,12 @@ func createWatcher( } func (w *Watcher) start(ctx context.Context) { + w.ctx = ctx + w.contentMapperHost = tsc.NewContentMapperHost(ctx, w.sys, w.config.CompilerOptions()) + w.replaceContentMapperLease(w.config.ContentMappers()) w.wm.Lock() w.extendedConfigCache = &tsc.ExtendedConfigCache{} - host := compiler.NewCompilerHost(w.sys.GetCurrentDirectory(), w.sys.FS(), w.sys.DefaultLibraryPath(), w.extendedConfigCache, getTraceFromSys(w.sys, w.config.Locale(), w.testing)) + host := compiler.NewCompilerHost(w.sys.GetCurrentDirectory(), w.sys.FS(), w.sys.DefaultLibraryPath(), w.extendedConfigCache, getTraceFromSys(w.sys, w.config.Locale(), w.testing), nil) w.program = incremental.ReadBuildInfoProgram(w.config, incremental.NewBuildInfoReader(host), host) if w.configFileName != "" { @@ -136,10 +148,22 @@ func (w *Watcher) start(ctx context.Context) { w.wm.Unlock() if w.testing == nil { + // The content mapper host closes itself when ctx is cancelled (see contentmapper.New). w.wm.RunLoop(ctx, w.DoCycle) } } +func (w *Watcher) replaceContentMapperLease(mappers []*contentmapper.Mapper) { + if w.contentMapperHost == nil { + return + } + release := w.contentMapperHost.Acquire(mappers) + if w.releaseContentMappers != nil { + w.releaseContentMappers() + } + w.releaseContentMappers = release +} + func (w *Watcher) computeDesiredWatches(seenFilePaths []string) map[string]bool { cwd := w.sys.GetCurrentDirectory() @@ -286,7 +310,7 @@ func (w *Watcher) doBuild() error { cached := cachedvfs.From(w.sys.FS()) tfs := &trackingvfs.FS{Inner: cached} - innerHost := compiler.NewCompilerHost(w.sys.GetCurrentDirectory(), tfs, w.sys.DefaultLibraryPath(), w.extendedConfigCache, getTraceFromSys(w.sys, w.config.Locale(), w.testing)) + innerHost := compiler.NewCompilerHost(w.sys.GetCurrentDirectory(), tfs, w.sys.DefaultLibraryPath(), w.extendedConfigCache, getTraceFromSys(w.sys, w.config.Locale(), w.testing), w.contentMapperHost) host := &watchCompilerHost{CompilerHost: innerHost, cache: w.sourceFileCache} var wildcardDirs map[string]bool @@ -418,6 +442,7 @@ func (w *Watcher) recheckTsConfig() bool { if !reflect.DeepEqual(w.config.ParsedConfig, configParseResult.ParsedConfig) { w.configModified = true } + w.replaceContentMapperLease(configParseResult.ContentMappers()) w.config = configParseResult return false } diff --git a/internal/fourslash/_scripts/failingTests.txt b/internal/fourslash/_scripts/failingTests.txt index 5254408fde1..3971f88c0d1 100644 --- a/internal/fourslash/_scripts/failingTests.txt +++ b/internal/fourslash/_scripts/failingTests.txt @@ -112,7 +112,6 @@ TestCompletionForStringLiteral_quotePreference8 TestCompletionForStringLiteral4 TestCompletionImportMeta TestCompletionImportMetaWithGlobalDeclaration -TestCompletionImportModuleSpecifierEndingUnsupportedExtension TestCompletionInChecks1 TestCompletionInFunctionLikeBody_includesPrimitiveTypes TestCompletionInUncheckedJSFile @@ -338,12 +337,9 @@ TestMemberListOfExportedClass TestMultiModuleFundule TestNgProxy1 TestNgProxy4 -TestNoQuickInfoForLabel -TestNoQuickInfoInWhitespace TestOverloadQuickInfo TestProtoVarVisibleWithOuterScopeUnderscoreProto TestQuickfixImplementInterfaceUnreachableTypeUsesRelativeImport -TestQuickInfo_notInsideComment TestQuickinfo01 TestQuickInfoBindingPatternInJsdocNoCrash1 TestQuickInfoCloduleWithRecursiveReference @@ -351,7 +347,6 @@ TestQuickInfoContextuallyTypedSignatureOptionalParameterFromIntersection1 TestQuickInfoContextualTyping TestQuickInfoDisplayPartsIife TestQuickInfoElementAccessDeclaration -TestQuickInfoForConstTypeReference TestQuickInfoForContextuallyTypedArrowFunctionInSuperCall TestQuickInfoForGenericConstraints1 TestQuickInfoForGenericTaggedTemplateExpression @@ -382,7 +377,6 @@ TestQuickInfoModuleVariables TestQuickInfoNarrowedTypeOfAliasSymbol TestQuickInfoOnArgumentsInsideFunction TestQuickInfoOnCatchVariable -TestQuickInfoOnClosingJsx TestQuickInfoOnElementAccessInWriteLocation4 TestQuickInfoOnElementAccessInWriteLocation5 TestQuickInfoOnExpandoLikePropertyWithSetterDeclarationJs1 @@ -411,7 +405,6 @@ TestQuickInfoUnionOfNamespaces TestQuickInfoUntypedModuleImport TestQuickinfoWrongComment TestRecursiveInternalModuleImport -TestRegexDetection TestSelfReferencedExternalModule TestSignatureHelpCallExpressionJs TestStringCompletionsImportOrExportSpecifier diff --git a/internal/fourslash/baselineutil.go b/internal/fourslash/baselineutil.go index 4b8c091e7eb..f607edb047a 100644 --- a/internal/fourslash/baselineutil.go +++ b/internal/fourslash/baselineutil.go @@ -15,6 +15,7 @@ import ( "github.com/microsoft/typescript-go/internal/debug" "github.com/microsoft/typescript-go/internal/ls/lsconv" "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/spanmap" "github.com/microsoft/typescript-go/internal/stringutil" "github.com/microsoft/typescript-go/internal/testutil/baseline" "github.com/microsoft/typescript-go/internal/vfs" @@ -925,7 +926,7 @@ type textWithContext struct { fileName string content string // content of the original file lineStarts *lsconv.LSPLineMap - converters *lsconv.Converters + converters *testConverters // posLineInfo posInfo *lsproto.Position @@ -942,6 +943,12 @@ func (t *textWithContext) Text() string { return t.content } +// implements lsconv.Script +func (t *textWithContext) OriginalText() string { return t.content } + +// implements lsconv.Script +func (t *textWithContext) SpanMap() *spanmap.SpanMap { return nil } + func newTextWithContext(fileName string, content string) *textWithContext { t := &textWithContext{ nLinesContext: 4, @@ -956,9 +963,9 @@ func newTextWithContext(fileName string, content string) *textWithContext { lineStarts: lsconv.ComputeLSPLineStarts(content), } - t.converters = lsconv.NewConverters(lsproto.PositionEncodingKindUTF8, func(_ string) *lsconv.LSPLineMap { + t.converters = newTestConverters(lsconv.NewConverters(lsproto.PositionEncodingKindUTF8, func(_ string) *lsconv.LSPLineMap { return t.lineStarts - }) + })) t.readableContents.WriteString("// === ") t.readableContents.WriteString(fileName) t.readableContents.WriteString(" ===") diff --git a/internal/fourslash/fourslash.go b/internal/fourslash/fourslash.go index 39dfac4fa65..10bbbf293b1 100644 --- a/internal/fourslash/fourslash.go +++ b/internal/fourslash/fourslash.go @@ -15,7 +15,9 @@ import ( "github.com/google/go-cmp/cmp" "github.com/microsoft/typescript-go/internal/bundled" "github.com/microsoft/typescript-go/internal/collections" + "github.com/microsoft/typescript-go/internal/contentmapper" "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/debug" "github.com/microsoft/typescript-go/internal/diagnostics" "github.com/microsoft/typescript-go/internal/diagnosticwriter" "github.com/microsoft/typescript-go/internal/execute/tsctests" @@ -29,6 +31,7 @@ import ( "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/project" "github.com/microsoft/typescript-go/internal/repo" + "github.com/microsoft/typescript-go/internal/spanmap" "github.com/microsoft/typescript-go/internal/stringutil" "github.com/microsoft/typescript-go/internal/testutil/baseline" "github.com/microsoft/typescript-go/internal/testutil/harnessutil" @@ -52,7 +55,7 @@ type FourslashTest struct { stateBaseline *stateBaseline scriptInfos map[string]*scriptInfo - converters *lsconv.Converters + converters *testConverters stateEnableFormatting bool reportFormatOnTypeCrash bool @@ -77,6 +80,25 @@ type scriptInfo struct { version int32 } +type testConverters struct { + *lsconv.Converters +} + +func newTestConverters(converters *lsconv.Converters) *testConverters { + return &testConverters{Converters: converters} +} + +func (c *testConverters) PositionToLineAndCharacter(script lsconv.Script, position core.TextPos) lsproto.Position { + lspPosition, _ := c.ToLSPPosition(script, position) + return lspPosition +} + +func (c *testConverters) LineAndCharacterToPosition(script lsconv.Script, position lsproto.Position) core.TextPos { + positions := c.FromLSPPosition(script, position, spanmap.PurposeAll) + debug.Assert(len(positions) == 1, "fourslash script must have exactly one position projection") + return positions[0].Position +} + type textEditSpan struct { start int end int @@ -102,6 +124,10 @@ func (s *scriptInfo) Text() string { return s.content } +func (s *scriptInfo) OriginalText() string { return s.content } + +func (s *scriptInfo) SpanMap() *spanmap.SpanMap { return nil } + func (s *scriptInfo) FileName() string { return s.fileName } @@ -131,6 +157,25 @@ var parseCache = project.NewParseCache( ) func NewFourslash(t *testing.T, capabilities *lsproto.ClientCapabilities, content string) (*FourslashTest, func()) { + _, testPath, _, _ := runtime.Caller(1) + return newFourslash(t, content, &FourslashOptions{Capabilities: capabilities}, testPath) +} + +type FourslashOptions struct { + Capabilities *lsproto.ClientCapabilities + ContentMapperSpawner contentmapper.Spawner + LoadExternalPlugins bool +} + +func NewFourslashWithOptions(t *testing.T, content string, options *FourslashOptions) (*FourslashTest, func()) { + _, testPath, _, _ := runtime.Caller(1) + return newFourslash(t, content, options, testPath) +} + +func newFourslash(t *testing.T, content string, options *FourslashOptions, testPath string) (*FourslashTest, func()) { + if options == nil { + options = &FourslashOptions{} + } repo.SkipIfNoTypeScriptSubmodule(t) if !bundled.Embedded { // Without embedding, we'd need to read all of the lib files out from disk into the MapFS. @@ -184,14 +229,17 @@ func NewFourslash(t *testing.T, capabilities *lsproto.ClientCapabilities, conten ParseCache: parseCache, } + if options.ContentMapperSpawner != nil { + serverOpts.Spawn = options.ContentMapperSpawner.Spawn + } - converters := lsconv.NewConverters(lsproto.PositionEncodingKindUTF8, func(fileName string) *lsconv.LSPLineMap { + converters := newTestConverters(lsconv.NewConverters(lsproto.PositionEncodingKindUTF8, func(fileName string) *lsconv.LSPLineMap { scriptInfo, ok := scriptInfos[fileName] if !ok { return nil } return scriptInfo.lineMap - }) + })) f := &FourslashTest{ testData: &testData, @@ -212,19 +260,20 @@ func NewFourslash(t *testing.T, capabilities *lsproto.ClientCapabilities, conten // !!! temporary; remove when we have `handleDidChangeConfiguration`/implicit project config support // !!! replace with a proper request *after initialize* client.SetCompilerOptionsForInferredProjects(compilerOptions) - f.initialize(t, capabilities) + f.initialize(t, options.Capabilities, options.LoadExternalPlugins) if testData.isStateBaseliningEnabled() { // Single baseline, so initialize project state baseline too f.stateBaseline = newStateBaseline(fsFromMap.(iovfs.FsWithSys)) } else { for _, file := range testData.Files { - f.openFile(t, file.fileName) + if file.open { + f.openFile(t, file.fileName) + } } f.activeFilename = f.testData.Files[0].fileName } - _, testPath, _, _ := runtime.Caller(1) return f, func() { t.Helper() err := closeClient() @@ -316,13 +365,17 @@ func getBaseFileNameFromTest(t *testing.T) string { const showCodeLensLocationsCommandName = "typescript.showCodeLensLocations" -func (f *FourslashTest) initialize(t *testing.T, capabilities *lsproto.ClientCapabilities) { +func (f *FourslashTest) initialize(t *testing.T, capabilities *lsproto.ClientCapabilities, loadExternalPlugins bool) { + initializationOptions := &lsproto.InitializationOptions{ + CodeLensShowLocationsCommandName: new(showCodeLensLocationsCommandName), + } + if loadExternalPlugins { + initializationOptions.LoadExternalPlugins = new(true) + } params := &lsproto.InitializeParams{ Locale: new("en-US"), InitializationOptions: &lsproto.InitializationOptionsOrNull{ - InitializationOptions: &lsproto.InitializationOptions{ - CodeLensShowLocationsCommandName: new(showCodeLensLocationsCommandName), - }, + InitializationOptions: initializationOptions, }, } params.Capabilities = getCapabilitiesWithDefaults(capabilities) @@ -1264,7 +1317,7 @@ func (f *FourslashTest) verifyCompletionsResult( } func isEmptyExpectedList(expected *CompletionsExpectedList) bool { - return expected == nil || (len(expected.Items.Exact) == 0 && len(expected.Items.Includes) == 0 && len(expected.Items.Excludes) == 0) + return expected == nil || (len(expected.Items.Exact) == 0 && len(expected.Items.Includes) == 0 && len(expected.Items.Excludes) == 0 && len(expected.Items.Unsorted) == 0) } func verifyCompletionsItemDefaults(t *testing.T, actual *lsproto.CompletionItemDefaults, expected *CompletionsExpectedItemDefaults, prefix string) { @@ -2543,11 +2596,16 @@ func (f *FourslashTest) VerifyBaselineCodeLens(t *testing.T, preferences *lsutil locations = locs } + ranges := f.converters.FromLSPRange(f.getScriptInfo(openFile), resolvedCodeLens.Range, spanmap.PurposeAll) + if len(ranges) != 1 { + continue + } + codeLensRange := ranges[0].Span f.addResultToBaseline(t, codeLensesCmd, f.getBaselineForLocationsWithFileContents(locations, baselineFourslashLocationsOptions{ marker: &RangeMarker{ fileName: openFile, LSRange: resolvedCodeLens.Range, - Range: f.converters.FromLSPRange(f.getScriptInfo(openFile), resolvedCodeLens.Range), + Range: codeLensRange, }, markerName: "/*CODELENS: " + resolvedCodeLens.Command.Title + "*/", })) @@ -3983,7 +4041,7 @@ func (f *FourslashTest) editScriptAndUpdateMarkersWorker(t *testing.T, fileName start := updatePosition(rangeMarker.Range.Pos(), editStart, editEnd, change.NewText) end := updatePosition(rangeMarker.Range.End(), editStart, editEnd, change.NewText) rangeMarker.Range = core.NewTextRange(start, end) - rangeMarker.LSRange = f.converters.ToLSPRange(script, rangeMarker.Range) + rangeMarker.LSRange, _ = f.converters.ToLSPRange(script, rangeMarker.Range) } } } @@ -4001,13 +4059,20 @@ func updatePosition(pos int, editStart int, editEnd int, newText string) int { return pos + len(newText) - (editEnd - editStart) } +func (f *FourslashTest) fromLSPRange(script *scriptInfo, r lsproto.Range) core.TextRange { + ranges := f.converters.FromLSPRange(script, r, spanmap.PurposeAll) + if len(ranges) != 1 { + return core.TextRange{} + } + return ranges[0].Span +} + func (f *FourslashTest) editScript(t *testing.T, fileName string, change core.TextChange) *scriptInfo { script := f.getOrLoadScriptInfo(fileName) if script == nil { panic(fmt.Sprintf("Script info for file %s not found", fileName)) } - - changeRange := f.converters.ToLSPRange(script, core.NewTextRange(change.Pos(), change.End())) + changeRange, _ := f.converters.ToLSPRange(script, core.NewTextRange(change.Pos(), change.End())) script.editContent(change) if err := f.vfs.WriteFile(fileName, script.content); err != nil { t.Fatalf("failed to write to VFS for %s: %v", fileName, err) @@ -4047,6 +4112,9 @@ func (f *FourslashTest) getOrLoadScriptInfo(fileName string) *scriptInfo { func (f *FourslashTest) VerifyQuickInfoAt(t *testing.T, marker string, expectedText string, expectedDocumentation string) { f.GoToMarker(t, marker) hover := f.getQuickInfoAtCurrentPosition(t) + if hover == nil { + t.Fatalf("Expected hover result at marker '%s' but got nil", *f.lastKnownMarkerName) + } f.verifyHoverContent(t, hover.Contents, expectedText, expectedDocumentation, f.getCurrentPositionPrefix()) } @@ -4058,9 +4126,6 @@ func (f *FourslashTest) getQuickInfoAtCurrentPosition(t *testing.T) *lsproto.Hov Position: f.currentCaretPosition, } result := sendRequest(t, f, lsproto.TextDocumentHoverInfo, params) - if result.Hover == nil { - t.Fatalf("Expected hover result at marker '%s' but got nil", *f.lastKnownMarkerName) - } return result.Hover } @@ -4561,9 +4626,9 @@ func (f *FourslashTest) BaselineAutoImportsCompletions(t *testing.T, markerNames )) currentFile := newScriptInfo(f.activeFilename, fileContent) - converters := lsconv.NewConverters(lsproto.PositionEncodingKindUTF8, func(_ string) *lsconv.LSPLineMap { + converters := newTestConverters(lsconv.NewConverters(lsproto.PositionEncodingKindUTF8, func(_ string) *lsconv.LSPLineMap { return currentFile.lineMap - }) + })) var list []*lsproto.CompletionItem if result.Items == nil || len(*result.Items) == 0 { if result.List == nil || result.List.Items == nil || len(result.List.Items) == 0 { @@ -4773,7 +4838,7 @@ func (f *FourslashTest) RenameAtCaret(t *testing.T, newName string) lsproto.Rena script := f.getOrLoadScriptInfo(fileName) changes := core.Map(edits, func(edit *lsproto.TextEdit) core.TextChange { return core.TextChange{ - TextRange: f.converters.FromLSPRange(script, edit.Range), + TextRange: f.fromLSPRange(script, edit.Range), NewText: edit.NewText, } }) @@ -4790,7 +4855,7 @@ func (f *FourslashTest) RenameAtCaret(t *testing.T, newName string) lsproto.Rena changes := core.Map(docChange.TextDocumentEdit.Edits, func(edit lsproto.TextEditOrAnnotatedTextEditOrSnippetTextEdit) core.TextChange { textEdit := edit.TextEdit return core.TextChange{ - TextRange: f.converters.FromLSPRange(script, textEdit.Range), + TextRange: f.fromLSPRange(script, textEdit.Range), NewText: textEdit.NewText, } }) @@ -4852,7 +4917,7 @@ func (f *FourslashTest) willRenameFilesWorker(t *testing.T, files ...*lsproto.Fi script := f.getOrLoadScriptInfo(fileName) changes := core.Map(edits, func(edit *lsproto.TextEdit) core.TextChange { return core.TextChange{ - TextRange: f.converters.FromLSPRange(script, edit.Range), + TextRange: f.fromLSPRange(script, edit.Range), NewText: edit.NewText, } }) @@ -4869,7 +4934,7 @@ func (f *FourslashTest) willRenameFilesWorker(t *testing.T, files ...*lsproto.Fi changes := core.Map(docChange.TextDocumentEdit.Edits, func(edit lsproto.TextEditOrAnnotatedTextEditOrSnippetTextEdit) core.TextChange { textEdit := edit.TextEdit return core.TextChange{ - TextRange: f.converters.FromLSPRange(script, textEdit.Range), + TextRange: f.fromLSPRange(script, textEdit.Range), NewText: textEdit.NewText, } }) @@ -5125,7 +5190,7 @@ func (f *FourslashTest) VerifyBaselineInlayHints( fileName := f.activeFilename var lspRange lsproto.Range if span == nil { - lspRange = f.converters.ToLSPRange(f.getScriptInfo(fileName), core.NewTextRange(0, len(f.scriptInfos[fileName].content))) + lspRange, _ = f.converters.ToLSPRange(f.getScriptInfo(fileName), core.NewTextRange(0, len(f.scriptInfos[fileName].content))) } else { lspRange = *span } @@ -5388,6 +5453,10 @@ func (f *fourslashDiagnosticFile) Text() string { return f.file.Content } +func (f *fourslashDiagnosticFile) OriginalText() string { return f.file.Content } + +func (f *fourslashDiagnosticFile) SpanMap() *spanmap.SpanMap { return nil } + func (f *fourslashDiagnosticFile) ECMALineMap() []core.TextPos { if f.ecmaLineMap == nil { f.ecmaLineMap = core.ComputeECMALineStarts(f.file.Content) @@ -5421,6 +5490,10 @@ func (d *fourslashDiagnostic) Category() diagnostics.Category { return d.category } +func (d *fourslashDiagnostic) Source() string { + return "" +} + func (d *fourslashDiagnostic) Localize(locale locale.Locale) string { return d.message } @@ -5462,7 +5535,7 @@ func (f *FourslashTest) toDiagnostic(scriptInfo *scriptInfo, lspDiagnostic *lspr } relatedDiagnostic := &fourslashDiagnostic{ file: &fourslashDiagnosticFile{file: &harnessutil.TestFile{UnitName: relatedScriptInfo.fileName, Content: relatedScriptInfo.content}}, - loc: f.converters.FromLSPRange(relatedScriptInfo, info.Location.Range), + loc: f.fromLSPRange(relatedScriptInfo, info.Location.Range), code: code, category: category, message: info.Message, @@ -5478,7 +5551,7 @@ func (f *FourslashTest) toDiagnostic(scriptInfo *scriptInfo, lspDiagnostic *lspr Content: scriptInfo.content, }, }, - loc: f.converters.FromLSPRange(scriptInfo, lspDiagnostic.Range), + loc: f.fromLSPRange(scriptInfo, lspDiagnostic.Range), code: code, category: category, message: lspDiagnostic.Message.AsString(), diff --git a/internal/fourslash/semantictokens.go b/internal/fourslash/semantictokens.go index bb69d03682f..8967aa11dd9 100644 --- a/internal/fourslash/semantictokens.go +++ b/internal/fourslash/semantictokens.go @@ -58,9 +58,9 @@ func decodeSemanticTokens(f *FourslashTest, data []uint32, tokenTypes, tokenModi } scriptInfo := f.scriptInfos[f.activeFilename] - converters := lsconv.NewConverters(lsproto.PositionEncodingKindUTF8, func(_ string) *lsconv.LSPLineMap { + converters := newTestConverters(lsconv.NewConverters(lsproto.PositionEncodingKindUTF8, func(_ string) *lsconv.LSPLineMap { return scriptInfo.lineMap - }) + })) var tokens []SemanticToken prevLine := uint32(0) diff --git a/internal/fourslash/test_parser.go b/internal/fourslash/test_parser.go index 015e4f3c564..a9ea97150f8 100644 --- a/internal/fourslash/test_parser.go +++ b/internal/fourslash/test_parser.go @@ -11,6 +11,7 @@ import ( "github.com/microsoft/typescript-go/internal/json" "github.com/microsoft/typescript-go/internal/ls/lsconv" "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/spanmap" "github.com/microsoft/typescript-go/internal/stringutil" "github.com/microsoft/typescript-go/internal/testrunner" "github.com/microsoft/typescript-go/internal/tspath" @@ -201,6 +202,7 @@ type TestFileInfo struct { // The contents of the file (with markers, etc stripped out) Content string emit bool + open bool } // FileName implements lsconv.Script. @@ -213,9 +215,18 @@ func (t *TestFileInfo) Text() string { return t.Content } +// OriginalText implements lsconv.Script. +func (t *TestFileInfo) OriginalText() string { return t.Content } + +// SpanMap implements lsconv.Script. +func (t *TestFileInfo) SpanMap() *spanmap.SpanMap { return nil } + var _ lsconv.Script = (*TestFileInfo)(nil) -const emitThisFileOption = "emitthisfile" +const ( + emitThisFileOption = "emitthisfile" + noOpenFileOption = "noopen" +) type parserState int @@ -414,9 +425,9 @@ func parseFileContent(fileName string, content string, fileOptions map[string]st outputString := output.String() // Set LS positions for markers lineMap := lsconv.ComputeLSPLineStarts(outputString) - converters := lsconv.NewConverters(lsproto.PositionEncodingKindUTF8, func(_ string) *lsconv.LSPLineMap { + converters := newTestConverters(lsconv.NewConverters(lsproto.PositionEncodingKindUTF8, func(_ string) *lsconv.LSPLineMap { return lineMap - }) + })) emit := fileOptions[emitThisFileOption] == "true" @@ -424,6 +435,7 @@ func parseFileContent(fileName string, content string, fileOptions map[string]st fileName: fileName, Content: outputString, emit: emit, + open: fileOptions[noOpenFileOption] != "true", } slices.SortStableFunc(rangeMarkers, func(a, b *RangeMarker) int { diff --git a/internal/fourslash/tests/contentMapperAutoImports_test.go b/internal/fourslash/tests/contentMapperAutoImports_test.go new file mode 100644 index 00000000000..b6e51716ed6 --- /dev/null +++ b/internal/fourslash/tests/contentMapperAutoImports_test.go @@ -0,0 +1,162 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/fourslash" + . "github.com/microsoft/typescript-go/internal/fourslash/tests/util" + "github.com/microsoft/typescript-go/internal/ls/lsutil" + "github.com/microsoft/typescript-go/internal/testutil" + "github.com/microsoft/typescript-go/internal/testutil/contentmappertest" +) + +func TestContentMapperAutoImports(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := newContentMapperFourslash(t, `// @Filename: /ProfileCard.vue + + + +// @Filename: /main.ts +profileTi/**/ +`, contentmappertest.ComponentMapper, ".vue") + defer done() + + f.VerifyCompletions(t, "", &fourslash.CompletionsExpectedList{ + UserPreferences: &lsutil.UserPreferences{ + IncludeCompletionsForModuleExports: core.TSTrue, + IncludeCompletionsForImportStatements: core.TSTrue, + }, + IsIncomplete: false, + ItemDefaults: &fourslash.CompletionsExpectedItemDefaults{ + CommitCharacters: &DefaultCommitCharacters, + EditRange: Ignored, + }, + Items: &fourslash.CompletionsExpectedItems{ + Includes: []fourslash.CompletionsExpectedItem{"profileTitle"}, + }, + }) + f.BaselineAutoImportsCompletions(t, []string{""}) +} + +func TestContentMapperAnonymousDefaultAutoImportName(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := newContentMapperFourslash(t, `// @Filename: /Component.vue + + +// @Filename: /main.ts +Comp/**/ +`, contentmappertest.ComponentMapper, ".vue") + defer done() + + f.VerifyCompletions(t, "", &fourslash.CompletionsExpectedList{ + UserPreferences: &lsutil.UserPreferences{ + IncludeCompletionsForModuleExports: core.TSTrue, + IncludeCompletionsForImportStatements: core.TSTrue, + }, + IsIncomplete: false, + ItemDefaults: &fourslash.CompletionsExpectedItemDefaults{ + CommitCharacters: &DefaultCommitCharacters, + EditRange: Ignored, + }, + Items: &fourslash.CompletionsExpectedItems{ + Includes: []fourslash.CompletionsExpectedItem{"Component"}, + Excludes: []string{"ComponentVue"}, + }, + }) + f.VerifyApplyCodeActionFromCompletion(t, new(""), &fourslash.ApplyCodeActionFromCompletionOptions{ + Name: "Component", + Source: "./Component.vue", + Description: "Add import from \"./Component.vue\"", + NewFileContent: new(`import Component from "./Component.vue"; + +Comp +`), + }) +} + +func TestContentMapperAutoImportsIntoMappedFile(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := newContentMapperFourslash(t, `// @Filename: /dep.ts +export const existing = 1; +export const helper = 2; + +// @Filename: /ProfileCard.vue + + +`, contentmappertest.ComponentMapper, ".vue") + defer done() + + f.VerifyCompletions(t, "", &fourslash.CompletionsExpectedList{ + UserPreferences: &lsutil.UserPreferences{ + IncludeCompletionsForModuleExports: core.TSTrue, + IncludeCompletionsForImportStatements: core.TSTrue, + }, + IsIncomplete: false, + ItemDefaults: &fourslash.CompletionsExpectedItemDefaults{ + CommitCharacters: &DefaultCommitCharacters, + EditRange: Ignored, + }, + Items: &fourslash.CompletionsExpectedItems{ + Includes: []fourslash.CompletionsExpectedItem{"helper"}, + }, + }) + f.BaselineAutoImportsCompletions(t, []string{""}) +} + +func TestContentMapperNodeModulesAutoImports(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := newContentMapperFourslash(t, `// @Filename: /package.json +{ "dependencies": { "profile-package": "1.0.0" } } + +// @Filename: /node_modules/profile-package/package.json +{ "name": "profile-package", "version": "1.0.0" } + +// @Filename: /node_modules/profile-package/ProfileCard.vue + + + +// @Filename: /node_modules/profile-package/HiddenCard.vue + + + +// @Filename: /load.ts +import "profile-package/ProfileCard.vue"; + +// @Filename: /main.ts +profileTi/**/ +`, contentmappertest.ComponentMapper, ".vue") + defer done() + + f.VerifyCompletions(t, "", &fourslash.CompletionsExpectedList{ + UserPreferences: &lsutil.UserPreferences{ + IncludeCompletionsForModuleExports: core.TSTrue, + IncludeCompletionsForImportStatements: core.TSTrue, + }, + IsIncomplete: false, + ItemDefaults: &fourslash.CompletionsExpectedItemDefaults{ + CommitCharacters: &DefaultCommitCharacters, + EditRange: Ignored, + }, + Items: &fourslash.CompletionsExpectedItems{ + Includes: []fourslash.CompletionsExpectedItem{"profileTitle"}, + Excludes: []string{"hiddenTitle"}, + }, + }) + f.BaselineAutoImportsCompletions(t, []string{""}) +} diff --git a/internal/fourslash/tests/contentMapperCompletions_test.go b/internal/fourslash/tests/contentMapperCompletions_test.go new file mode 100644 index 00000000000..3ee88ff416d --- /dev/null +++ b/internal/fourslash/tests/contentMapperCompletions_test.go @@ -0,0 +1,122 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + . "github.com/microsoft/typescript-go/internal/fourslash/tests/util" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/testutil" + "github.com/microsoft/typescript-go/internal/testutil/contentmappertest" +) + +func TestContentMapperCompletions(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := newContentMapperFourslash(t, `// @Filename: /settings.ts +export const settings = { color: "blue", size: 2 }; + +// @Filename: /ProfileCard.vue + + + + +// @Filename: /main.ts +import { card } from "./ProfileCard.vue"; +card.se/*incoming*/ttings; +`, contentmappertest.ComponentMapper, ".vue") + defer done() + + propertyCompletions := func(items ...fourslash.CompletionsExpectedItem) *fourslash.CompletionsExpectedList { + return &fourslash.CompletionsExpectedList{ + ItemDefaults: &fourslash.CompletionsExpectedItemDefaults{CommitCharacters: &DefaultCommitCharacters, EditRange: Ignored}, + Items: &fourslash.CompletionsExpectedItems{Includes: items}, + } + } + f.VerifyCompletions(t, "exact", propertyCompletions("title")) + f.VerifyCompletions(t, "outgoing", propertyCompletions("color")) + f.VerifyCompletions(t, "incoming", propertyCompletions("settings")) + f.VerifyCompletions(t, "atom", nil) + f.VerifyCompletions(t, "markup", nil) +} + +func TestContentMapperModulePathCompletions(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + content := `// @Filename: /tsconfig.json +{ + "compilerOptions": { + "target": "es2020", + "module": "esnext", + "moduleResolution": "bundler", + "strict": true + }, + "contentMappers": [ + { "package": "mapper", "extensions": [".vue"] } + ], + "files": ["main.ts", "Loaded.vue"] +} + +// @Filename: /node_modules/mapper/package.json +` + contentmappertest.PackageJSON(contentmappertest.ComponentMapper) + ` + +// @Filename: /Loaded.vue + + + +// @Filename: /Unloaded.vue +// @noOpen: true + + + +// @Filename: /main.ts +import { loaded } from "./Loaded.vue"; +import {} from "./[|/*loadedPath*/Lo|]"; +import {} from "./[|/*unloadedPath*/Un|]"; +` + f, done := fourslash.NewFourslashWithOptions(t, content, &fourslash.FourslashOptions{ + ContentMapperSpawner: contentmappertest.NewSpawner(), + LoadExternalPlugins: true, + }) + defer done() + + pathCompletion := func(label string, rangeIndex int) *fourslash.CompletionsExpectedList { + return &fourslash.CompletionsExpectedList{ + IsIncomplete: false, + ItemDefaults: &fourslash.CompletionsExpectedItemDefaults{ + CommitCharacters: &[]string{}, + EditRange: Ignored, + }, + Items: &fourslash.CompletionsExpectedItems{ + Includes: []fourslash.CompletionsExpectedItem{ + &lsproto.CompletionItem{ + Label: label, + Detail: new(label), + Kind: new(lsproto.CompletionItemKindFile), + TextEdit: &lsproto.TextEditOrInsertReplaceEdit{ + TextEdit: &lsproto.TextEdit{ + NewText: label, + Range: f.Ranges()[rangeIndex].LSRange, + }, + }, + }, + }, + }, + } + } + f.VerifyCompletions(t, "loadedPath", pathCompletion("Loaded.vue", 0)) + f.VerifyCompletions(t, "unloadedPath", pathCompletion("Unloaded.vue", 1)) +} diff --git a/internal/fourslash/tests/contentMapperDefinition_test.go b/internal/fourslash/tests/contentMapperDefinition_test.go new file mode 100644 index 00000000000..5cb4ee2fc1d --- /dev/null +++ b/internal/fourslash/tests/contentMapperDefinition_test.go @@ -0,0 +1,46 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/testutil" + "github.com/microsoft/typescript-go/internal/testutil/contentmappertest" +) + +func TestContentMapperDefinition(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := newContentMapperFourslash(t, `// @Filename: /format.ts +export function [|format|](value: string): string { return value.toUpperCase(); } + +// @Filename: /ProfileCard.vue + + + + +// @Filename: /main.ts +import DefaultCard, { ProfileCard, title } from "./ProfileCard.vue"; +export const pageTitle = ti/*incoming*/tle; +export const component = Profile/*atomTarget*/Card; +export const fallback = Default/*fallback*/Card; +`, contentmappertest.ComponentMapper, ".vue") + defer done() + + f.VerifyBaselineGoToDefinition(t, false, /*includeOriginalSelectionRange*/ + "within", + "template", + "incoming", + "outgoing", + "atomTarget", + "fallback", + "markup", + ) +} diff --git a/internal/fourslash/tests/contentMapperDiagnostics_test.go b/internal/fourslash/tests/contentMapperDiagnostics_test.go new file mode 100644 index 00000000000..c05d1ef7ec9 --- /dev/null +++ b/internal/fourslash/tests/contentMapperDiagnostics_test.go @@ -0,0 +1,61 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/testutil" + "github.com/microsoft/typescript-go/internal/testutil/contentmappertest" +) + +func TestContentMapperDiagnostics(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := newContentMapperFourslash(t, `// @Filename: /ProfileCard.vue + + + +`, contentmappertest.ComponentMapper, ".vue") + defer done() + + f.VerifyBaselineNonSuggestionDiagnostics(t) +} + +func TestContentMapperSynthesizedDiagnostics(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := newContentMapperFourslash(t, `// @Filename: /ProfileCard.vue + +`, contentmappertest.SynthesizingMapper, ".vue") + defer done() + + f.VerifyBaselineNonSuggestionDiagnostics(t) +} + +func TestContentMapperTransformFailureDiagnostics(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := newContentMapperFourslash(t, `// @Filename: /app.vue +[||] +`, contentmappertest.FailingMapper, ".vue") + defer done() + f.GoToFile(t, "/app.vue") + + f.VerifyNonSuggestionDiagnostics(t, []*lsproto.Diagnostic{ + { + Code: &lsproto.IntegerOrString{Integer: new(int32(100025))}, + Message: lsproto.StringOrMarkupContent{String: new( + "The content mapper 'mapper' failed to transform this file.\n The content mapper process failed while handling the transform request.", + )}, + Range: f.Ranges()[0].LSRange, + }, + }) +} diff --git a/internal/fourslash/tests/contentMapperDocumentHighlights_test.go b/internal/fourslash/tests/contentMapperDocumentHighlights_test.go new file mode 100644 index 00000000000..757517ec4de --- /dev/null +++ b/internal/fourslash/tests/contentMapperDocumentHighlights_test.go @@ -0,0 +1,27 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/testutil" + "github.com/microsoft/typescript-go/internal/testutil/contentmappertest" +) + +func TestContentMapperDocumentHighlights(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := newContentMapperFourslash(t, `// @Filename: /ProfileCard.vue + + + +`, contentmappertest.ComponentMapper, ".vue") + defer done() + + f.VerifyBaselineDocumentHighlights(t, nil /*preferences*/, "script", "template", "markup") +} diff --git a/internal/fourslash/tests/contentMapperDuplicateMappings_test.go b/internal/fourslash/tests/contentMapperDuplicateMappings_test.go new file mode 100644 index 00000000000..f7ccd468ad9 --- /dev/null +++ b/internal/fourslash/tests/contentMapperDuplicateMappings_test.go @@ -0,0 +1,35 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/testutil" + "github.com/microsoft/typescript-go/internal/testutil/contentmappertest" +) + +func TestContentMapperDuplicateMappings(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := newContentMapperFourslash(t, `// @Filename: /value.dup +[|val/*query*/ue|] +`, contentmappertest.DuplicateMapper, ".dup") + defer done() + + f.VerifyQuickInfoAt(t, "query", "const value: 1", "") + f.VerifyBaselineGoToDefinition(t, false, "query") + f.VerifyBaselineFindAllReferences(t, "query") +} + +func TestContentMapperDisabledPurposes(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := newContentMapperFourslash(t, `// @Filename: /disabled.dup +val/*query*/ue +`, contentmappertest.DuplicateMapper, ".dup") + defer done() + + f.GoToMarker(t, "query") + f.VerifyNotQuickInfoExists(t) + f.VerifyBaselineGoToDefinition(t, false, "query") + f.VerifyBaselineFindAllReferences(t, "query") +} diff --git a/internal/fourslash/tests/contentMapperHover_test.go b/internal/fourslash/tests/contentMapperHover_test.go new file mode 100644 index 00000000000..d73fa2264c6 --- /dev/null +++ b/internal/fourslash/tests/contentMapperHover_test.go @@ -0,0 +1,40 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/testutil" + "github.com/microsoft/typescript-go/internal/testutil/contentmappertest" +) + +func TestContentMapperHover(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := newContentMapperFourslash(t, `// @Filename: /format.ts +export function format(value: string): string { return value.toUpperCase(); } + +// @Filename: /ProfileCard.vue + + + + +// @Filename: /main.ts +import { title } from "./ProfileCard.vue"; +export const pageTitle = ti/*incoming*/tle; +`, contentmappertest.ComponentMapper, ".vue") + defer done() + + f.VerifyQuickInfoAt(t, "scriptTitle", "const title: string", "") + f.VerifyQuickInfoAt(t, "templateTitle", "const title: string", "") + f.VerifyQuickInfoAt(t, "outgoing", "(alias) function format(value: string): string", "") + f.VerifyQuickInfoAt(t, "incoming", "(alias) const title: string", "") + f.GoToMarker(t, "markup") + f.VerifyNotQuickInfoExists(t) +} diff --git a/internal/fourslash/tests/contentMapperImplementation_test.go b/internal/fourslash/tests/contentMapperImplementation_test.go new file mode 100644 index 00000000000..060d0e8742f --- /dev/null +++ b/internal/fourslash/tests/contentMapperImplementation_test.go @@ -0,0 +1,38 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/testutil" + "github.com/microsoft/typescript-go/internal/testutil/contentmappertest" +) + +func TestContentMapperImplementation(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := newContentMapperFourslash(t, `// @Filename: /models.ts +export class ExternalShape { area = 2 } + +// @Filename: /ProfileCard.vue + + + + +// @Filename: /main.ts +import { LocalShape } from "./ProfileCard.vue"; +let shape: Local/*incoming*/Shape; +`, contentmappertest.ComponentMapper, ".vue") + defer done() + + f.VerifyBaselineGoToImplementation(t, "within", "template", "incoming", "outgoing", "markup") +} diff --git a/internal/fourslash/tests/contentMapperReferences_test.go b/internal/fourslash/tests/contentMapperReferences_test.go new file mode 100644 index 00000000000..a75c355da42 --- /dev/null +++ b/internal/fourslash/tests/contentMapperReferences_test.go @@ -0,0 +1,45 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/testutil" + "github.com/microsoft/typescript-go/internal/testutil/contentmappertest" +) + +func TestContentMapperReferences(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := newContentMapperFourslash(t, `// @Filename: /format.ts +export function [|format|](value: string): string { return value; } + +// @Filename: /ProfileCard.vue + + + + +// @Filename: /main.ts +import DefaultCard, { ProfileCard, title } from "./ProfileCard.vue"; +export const pageTitle = [|ti/*incoming*/tle|]; +export const component = [|Profile/*atomResult*/Card|]; +export const fallback = [|Default/*synthesized*/Card|]; +`, contentmappertest.ComponentMapper, ".vue") + defer done() + + f.VerifyBaselineFindAllReferences(t, + "script", + "template", + "incoming", + "outgoing", + "atomResult", + "synthesized", + "markup", + ) +} diff --git a/internal/fourslash/tests/contentMapperRename_test.go b/internal/fourslash/tests/contentMapperRename_test.go new file mode 100644 index 00000000000..40ada4e6e91 --- /dev/null +++ b/internal/fourslash/tests/contentMapperRename_test.go @@ -0,0 +1,91 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/testutil" + "github.com/microsoft/typescript-go/internal/testutil/contentmappertest" +) + +func TestContentMapperRename(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := newContentMapperFourslash(t, `// @Filename: /ProfileCard.vue + + + + +// @Filename: /main.ts +import { title } from "./ProfileCard.vue"; +export const pageTitle = title; +`, contentmappertest.ComponentMapper, ".vue") + defer done() + + f.VerifyRename(t, "rename", "newTitle", map[string]string{ + "/ProfileCard.vue": ` + + +`, + "/main.ts": `import { newTitle } from "./ProfileCard.vue"; +export const pageTitle = newTitle; +`, + }) +} + +func TestContentMapperRenameRejectsAtomAndUnmappedOrigins(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := newContentMapperFourslash(t, `// @Filename: /ProfileCard.vue + + + +`, contentmappertest.ComponentMapper, ".vue") + defer done() + + f.GoToMarker(t, "atom") + f.VerifyRenameFailed(t, nil) + f.GoToMarker(t, "markup") + f.VerifyRenameFailed(t, nil) +} + +func TestContentMapperRenameOutgoing(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := newContentMapperFourslash(t, `// @Filename: /format.ts +export function format(value: string): string { return value; } + +// @Filename: /ProfileCard.vue + + + +`, contentmappertest.ComponentMapper, ".vue") + defer done() + + f.VerifyRename(t, "rename", "render", map[string]string{ + "/format.ts": `export function render(value: string): string { return value; } +`, + "/ProfileCard.vue": ` + + +`, + }) +} diff --git a/internal/fourslash/tests/contentMapperSignatureHelp_test.go b/internal/fourslash/tests/contentMapperSignatureHelp_test.go new file mode 100644 index 00000000000..a86dc251dec --- /dev/null +++ b/internal/fourslash/tests/contentMapperSignatureHelp_test.go @@ -0,0 +1,56 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" + "github.com/microsoft/typescript-go/internal/testutil/contentmappertest" +) + +func TestContentMapperSignatureHelp(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := newContentMapperFourslash(t, `// @Filename: /format.ts +export function format(value: string, uppercase?: boolean): string { return value; } + +// @Filename: /ProfileCard.vue + + + + +// @Filename: /main.ts +import { greet } from "./ProfileCard.vue"; +greet("hello", /*incomingCall*/2); +`, contentmappertest.ComponentMapper, ".vue") + defer done() + + f.GoToMarker(t, "scriptCall") + f.VerifySignatureHelp(t, fourslash.VerifySignatureHelpOptions{ + Text: "format(value: string, uppercase?: boolean): string", + ParameterName: "uppercase?", + ParameterSpan: "uppercase?: boolean", + }) + f.GoToMarker(t, "templateCall") + f.VerifySignatureHelp(t, fourslash.VerifySignatureHelpOptions{ + Text: "format(value: string, uppercase?: boolean): string", + ParameterName: "value", + ParameterSpan: "value: string", + }) + f.GoToMarker(t, "incomingCall") + f.VerifySignatureHelp(t, fourslash.VerifySignatureHelpOptions{ + Text: "greet(name: string, count: number): string", + ParameterName: "count", + ParameterSpan: "count: number", + }) + f.GoToMarker(t, "markup") + f.VerifyNoSignatureHelp(t) +} diff --git a/internal/fourslash/tests/contentMapperTypeDefinition_test.go b/internal/fourslash/tests/contentMapperTypeDefinition_test.go new file mode 100644 index 00000000000..2ec48b9a8c9 --- /dev/null +++ b/internal/fourslash/tests/contentMapperTypeDefinition_test.go @@ -0,0 +1,39 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/testutil" + "github.com/microsoft/typescript-go/internal/testutil/contentmappertest" +) + +func TestContentMapperTypeDefinition(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := newContentMapperFourslash(t, `// @Filename: /models.ts +export interface [|ExternalShape|] { area: number } + +// @Filename: /ProfileCard.vue + + + + +// @Filename: /main.ts +import { ProfileCard, localShape } from "./ProfileCard.vue"; +local/*incoming*/Shape; +Profile/*atomTarget*/Card; +`, contentmappertest.ComponentMapper, ".vue") + defer done() + + f.VerifyBaselineGoToTypeDefinition(t, "within", "template", "incoming", "outgoing", "atomTarget", "markup") +} diff --git a/internal/fourslash/tests/contentMapper_test.go b/internal/fourslash/tests/contentMapper_test.go new file mode 100644 index 00000000000..0cf6223c0d5 --- /dev/null +++ b/internal/fourslash/tests/contentMapper_test.go @@ -0,0 +1,39 @@ +package fourslash_test + +import ( + "strconv" + "strings" + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil/contentmappertest" +) + +func newContentMapperFourslash(t *testing.T, content, mapper string, extensions ...string) (*fourslash.FourslashTest, func()) { + t.Helper() + quotedExtensions := make([]string, len(extensions)) + for i, extension := range extensions { + quotedExtensions[i] = strconv.Quote(extension) + } + content = `// @Filename: /tsconfig.json +{ + "compilerOptions": { + "target": "es2020", + "module": "esnext", + "moduleResolution": "bundler", + "strict": true + }, + "contentMappers": [ + { "package": "mapper", "extensions": [` + strings.Join(quotedExtensions, ", ") + `] } + ] +} + +// @Filename: /node_modules/mapper/package.json +` + contentmappertest.PackageJSON(mapper) + ` + +` + content + return fourslash.NewFourslashWithOptions(t, content, &fourslash.FourslashOptions{ + ContentMapperSpawner: contentmappertest.NewSpawner(), + LoadExternalPlugins: true, + }) +} diff --git a/internal/fourslash/tests/manual/completionForStringLiteralExport_test.go b/internal/fourslash/tests/manual/completionForStringLiteralExport_test.go index 82ef1c2d6dc..7af9d400eeb 100644 --- a/internal/fourslash/tests/manual/completionForStringLiteralExport_test.go +++ b/internal/fourslash/tests/manual/completionForStringLiteralExport_test.go @@ -12,7 +12,7 @@ import ( func TestCompletionForStringLiteralExport(t *testing.T) { t.Parallel() defer testutil.RecoverAndFail(t, "Panic on fourslash test") - const content = `// @typeRoots: my_typings + const content = `// @typeRoots: fourslash/my_typings // @Filename: fourslash/test.ts export * from "./some/*0*/ export * from "./sub/some/*1*/"; @@ -62,7 +62,8 @@ export var x = 9;` Items: &fourslash.CompletionsExpectedItems{ Unsorted: []fourslash.CompletionsExpectedItem{ &lsproto.CompletionItem{ - Label: "some-module", + Label: "some-module", + Detail: new("some-module"), TextEdit: &lsproto.TextEditOrInsertReplaceEdit{ TextEdit: &lsproto.TextEdit{ NewText: "some-module", diff --git a/internal/fourslash/tests/manual/completionForStringLiteralImport1_test.go b/internal/fourslash/tests/manual/completionForStringLiteralImport1_test.go index ea614d080fc..76887a2bb01 100644 --- a/internal/fourslash/tests/manual/completionForStringLiteralImport1_test.go +++ b/internal/fourslash/tests/manual/completionForStringLiteralImport1_test.go @@ -12,7 +12,7 @@ import ( func TestCompletionForStringLiteralImport1(t *testing.T) { t.Parallel() defer testutil.RecoverAndFail(t, "Panic on fourslash test") - const content = `// @typeRoots: my_typings + const content = `// @typeRoots: fourslash/my_typings // @Filename: fourslash/test.ts import * as foo0 from "./some/*0*/ import * as foo1 from "./sub/some/*1*/ @@ -61,7 +61,8 @@ export var x = 9;` Items: &fourslash.CompletionsExpectedItems{ Unsorted: []fourslash.CompletionsExpectedItem{ &lsproto.CompletionItem{ - Label: "some-module", + Label: "some-module", + Detail: new("some-module"), TextEdit: &lsproto.TextEditOrInsertReplaceEdit{ TextEdit: &lsproto.TextEdit{ NewText: "some-module", diff --git a/internal/fourslash/tests/manual/completionForStringLiteralWithDynamicImport_test.go b/internal/fourslash/tests/manual/completionForStringLiteralWithDynamicImport_test.go index dfd9127e857..bfc1f186b99 100644 --- a/internal/fourslash/tests/manual/completionForStringLiteralWithDynamicImport_test.go +++ b/internal/fourslash/tests/manual/completionForStringLiteralWithDynamicImport_test.go @@ -12,7 +12,7 @@ import ( func TestCompletionForStringLiteralWithDynamicImport(t *testing.T) { t.Parallel() defer testutil.RecoverAndFail(t, "Panic on fourslash test") - const content = `// @typeRoots: my_typings + const content = `// @typeRoots: fourslash/my_typings // @Filename: fourslash/test.ts const a = import("./some/*0*/ const a = import("./sub/some/*1*/"); @@ -61,7 +61,8 @@ export var x = 9;` Items: &fourslash.CompletionsExpectedItems{ Unsorted: []fourslash.CompletionsExpectedItem{ &lsproto.CompletionItem{ - Label: "some-module", + Label: "some-module", + Detail: new("some-module"), TextEdit: &lsproto.TextEditOrInsertReplaceEdit{ TextEdit: &lsproto.TextEdit{ NewText: "some-module", diff --git a/internal/api/conn.go b/internal/ipc/conn.go similarity index 91% rename from internal/api/conn.go rename to internal/ipc/conn.go index 091a169be35..ced2f75f2c9 100644 --- a/internal/api/conn.go +++ b/internal/ipc/conn.go @@ -1,4 +1,4 @@ -package api +package ipc import ( "context" @@ -8,8 +8,8 @@ import ( ) var ( - ErrConnClosed = errors.New("api: connection closed") - ErrRequestTimeout = errors.New("api: request timeout") + ErrConnClosed = errors.New("ipc: connection closed") + ErrRequestTimeout = errors.New("ipc: request timeout") ) // Handler processes incoming API requests and notifications. diff --git a/internal/api/conn_async.go b/internal/ipc/conn_async.go similarity index 88% rename from internal/api/conn_async.go rename to internal/ipc/conn_async.go index a55965b4350..5ad636ca5ce 100644 --- a/internal/api/conn_async.go +++ b/internal/ipc/conn_async.go @@ -1,4 +1,4 @@ -package api +package ipc import ( "context" @@ -63,6 +63,7 @@ func (c *AsyncConn) SetCollectTiming(enabled bool) { // Run starts processing messages on the connection. // It blocks until the context is cancelled or an error occurs. func (c *AsyncConn) Run(ctx context.Context) error { + defer c.closePendingCalls() for { if ctx.Err() != nil { return ctx.Err() @@ -86,6 +87,16 @@ func (c *AsyncConn) Run(ctx context.Context) error { } } +// closePendingCalls unblocks requests waiting for a response when the connection read loop exits. +func (c *AsyncConn) closePendingCalls() { + c.pendingMu.Lock() + defer c.pendingMu.Unlock() + for id, ch := range c.pending { + close(ch) + delete(c.pending, id) + } +} + // handleResponse matches a response to a pending request. func (c *AsyncConn) handleResponse(msg *Message) { c.pendingMu.Lock() @@ -111,7 +122,7 @@ func (c *AsyncConn) handleRequest(ctx context.Context, msg *Message) { writeErr := c.protocol.WriteResponse(msg.ID, serverTimingSnapshot(c.timing)) c.writeMu.Unlock() if writeErr != nil { - panic(fmt.Sprintf("api: failed to write server timing response: %v", writeErr)) + panic(fmt.Sprintf("ipc: failed to write server timing response: %v", writeErr)) } return case string(MethodResetServerTiming): @@ -122,7 +133,7 @@ func (c *AsyncConn) handleRequest(ctx context.Context, msg *Message) { writeErr := c.protocol.WriteResponse(msg.ID, nil) c.writeMu.Unlock() if writeErr != nil { - panic(fmt.Sprintf("api: failed to write reset server timing response: %v", writeErr)) + panic(fmt.Sprintf("ipc: failed to write reset server timing response: %v", writeErr)) } return } @@ -149,7 +160,7 @@ func (c *AsyncConn) handleRequest(ctx context.Context, msg *Message) { c.writeMu.Unlock() if writeErr != nil { - panic(fmt.Sprintf("api: failed to write panic error response: %v (original panic: %v)", writeErr, r)) + panic(fmt.Sprintf("ipc: failed to write panic error response: %v (original panic: %v)", writeErr, r)) } } }() @@ -174,7 +185,7 @@ func (c *AsyncConn) handleRequest(ctx context.Context, msg *Message) { } if writeErr != nil { - panic(fmt.Sprintf("api: failed to write response: %v", writeErr)) + panic(fmt.Sprintf("ipc: failed to write response: %v", writeErr)) } } @@ -215,9 +226,12 @@ func (c *AsyncConn) Call(ctx context.Context, method string, params any) (json.V select { case <-ctx.Done(): return nil, ctx.Err() - case resp := <-responseChan: + case resp, ok := <-responseChan: + if !ok { + return nil, errors.New("ipc: connection closed before response") + } if resp.Error != nil { - return nil, fmt.Errorf("api: remote error [%d]: %s", resp.Error.Code, resp.Error.Message) + return nil, fmt.Errorf("ipc: remote error [%d]: %s", resp.Error.Code, resp.Error.Message) } return resp.Result, nil } diff --git a/internal/ipc/conn_async_test.go b/internal/ipc/conn_async_test.go new file mode 100644 index 00000000000..a226d350fac --- /dev/null +++ b/internal/ipc/conn_async_test.go @@ -0,0 +1,43 @@ +package ipc_test + +import ( + "context" + "net" + "testing" + + "github.com/microsoft/typescript-go/internal/ipc" + "github.com/microsoft/typescript-go/internal/json" + "gotest.tools/v3/assert" +) + +type noOpHandler struct{} + +func (noOpHandler) HandleRequest(context.Context, string, json.Value) (any, error) { + return nil, nil +} + +func (noOpHandler) HandleNotification(context.Context, string, json.Value) error { + return nil +} + +func TestAsyncConnCallReturnsWhenPeerCloses(t *testing.T) { + t.Parallel() + client, server := net.Pipe() + conn := ipc.NewAsyncConn(client, noOpHandler{}) + runDone := make(chan error, 1) + go func() { runDone <- conn.Run(t.Context()) }() + + callDone := make(chan error, 1) + go func() { + _, err := conn.Call(t.Context(), "transform", nil) + callDone <- err + }() + + buffer := make([]byte, 1024) + _, err := server.Read(buffer) + assert.NilError(t, err) + assert.NilError(t, server.Close()) + assert.NilError(t, <-runDone) + assert.ErrorContains(t, <-callDone, "connection closed before response") + assert.NilError(t, client.Close()) +} diff --git a/internal/api/conn_sync.go b/internal/ipc/conn_sync.go similarity index 92% rename from internal/api/conn_sync.go rename to internal/ipc/conn_sync.go index 30209603b2e..eadaa5569d7 100644 --- a/internal/api/conn_sync.go +++ b/internal/ipc/conn_sync.go @@ -1,4 +1,4 @@ -package api +package ipc import ( "context" @@ -75,7 +75,7 @@ func (c *SyncConn) Run(ctx context.Context) error { c.handleNotification(ctx, msg) } else { // Responses are not expected in the main loop - they are read inline by Call(). - return errors.New("api: unexpected response message in sync connection") + return errors.New("ipc: unexpected response message in sync connection") } } } @@ -90,7 +90,7 @@ func (c *SyncConn) handleRequest(ctx context.Context, msg *Message) { writeErr := c.protocol.WriteResponse(msg.ID, serverTimingSnapshot(c.timing)) c.mu.Unlock() if writeErr != nil { - panic(fmt.Sprintf("api: failed to write server timing response: %v", writeErr)) + panic(fmt.Sprintf("ipc: failed to write server timing response: %v", writeErr)) } return case string(MethodResetServerTiming): @@ -101,7 +101,7 @@ func (c *SyncConn) handleRequest(ctx context.Context, msg *Message) { writeErr := c.protocol.WriteResponse(msg.ID, nil) c.mu.Unlock() if writeErr != nil { - panic(fmt.Sprintf("api: failed to write reset server timing response: %v", writeErr)) + panic(fmt.Sprintf("ipc: failed to write reset server timing response: %v", writeErr)) } return } @@ -128,7 +128,7 @@ func (c *SyncConn) handleRequest(ctx context.Context, msg *Message) { c.mu.Unlock() if writeErr != nil { - panic(fmt.Sprintf("api: failed to write panic error response: %v (original panic: %v)", writeErr, r)) + panic(fmt.Sprintf("ipc: failed to write panic error response: %v (original panic: %v)", writeErr, r)) } } }() @@ -153,7 +153,7 @@ func (c *SyncConn) handleRequest(ctx context.Context, msg *Message) { } if writeErr != nil { - panic(fmt.Sprintf("api: failed to write response: %v", writeErr)) + panic(fmt.Sprintf("ipc: failed to write response: %v", writeErr)) } } @@ -191,13 +191,13 @@ func (c *SyncConn) Call(ctx context.Context, method string, params any) (json.Va if msg.IsResponse() && msg.ID != nil && msg.ID.String() == method { if msg.Error != nil { - return nil, fmt.Errorf("api: remote error [%d]: %s", msg.Error.Code, msg.Error.Message) + return nil, fmt.Errorf("ipc: remote error [%d]: %s", msg.Error.Code, msg.Error.Message) } return msg.Result, nil } // Unexpected message while waiting for response - return nil, fmt.Errorf("api: unexpected message while waiting for %q response", method) + return nil, fmt.Errorf("ipc: unexpected message while waiting for %q response", method) } // Notify sends a notification to the client (no response expected). diff --git a/internal/api/protocol.go b/internal/ipc/protocol.go similarity index 98% rename from internal/api/protocol.go rename to internal/ipc/protocol.go index 060b8a36a15..492c7c22394 100644 --- a/internal/api/protocol.go +++ b/internal/ipc/protocol.go @@ -1,4 +1,4 @@ -package api +package ipc import ( "github.com/microsoft/typescript-go/internal/jsonrpc" diff --git a/internal/api/protocol_jsonrpc.go b/internal/ipc/protocol_jsonrpc.go similarity index 99% rename from internal/api/protocol_jsonrpc.go rename to internal/ipc/protocol_jsonrpc.go index ee0ded5dcd9..7960b09a4fc 100644 --- a/internal/api/protocol_jsonrpc.go +++ b/internal/ipc/protocol_jsonrpc.go @@ -1,4 +1,4 @@ -package api +package ipc import ( "io" diff --git a/internal/api/timing.go b/internal/ipc/timing.go similarity index 93% rename from internal/api/timing.go rename to internal/ipc/timing.go index b1a3512c8e2..4114a548eae 100644 --- a/internal/api/timing.go +++ b/internal/ipc/timing.go @@ -1,10 +1,17 @@ -package api +package ipc import ( "sync" "time" ) +// Method names for the generic connection-level timing feature, handled by the connection itself rather +// than the handler when timing collection is enabled. +const ( + MethodGetServerTiming = "getServerTiming" + MethodResetServerTiming = "resetServerTiming" +) + // serverRecentRequestCapacity is the number of most-recent requests retained in // the server-side timing ring buffer. const serverRecentRequestCapacity = 5 diff --git a/internal/api/timing_test.go b/internal/ipc/timing_test.go similarity index 99% rename from internal/api/timing_test.go rename to internal/ipc/timing_test.go index 4ca22ba242c..7ecdc53c7c5 100644 --- a/internal/api/timing_test.go +++ b/internal/ipc/timing_test.go @@ -1,4 +1,4 @@ -package api +package ipc import ( "testing" diff --git a/internal/api/transport.go b/internal/ipc/transport.go similarity index 99% rename from internal/api/transport.go rename to internal/ipc/transport.go index 4eb3d26ad31..b912f7c0502 100644 --- a/internal/api/transport.go +++ b/internal/ipc/transport.go @@ -1,4 +1,4 @@ -package api +package ipc import ( "io" diff --git a/internal/api/transport_unix.go b/internal/ipc/transport_unix.go similarity index 97% rename from internal/api/transport_unix.go rename to internal/ipc/transport_unix.go index 667ed7c0d06..952cefb9e74 100644 --- a/internal/api/transport_unix.go +++ b/internal/ipc/transport_unix.go @@ -1,6 +1,6 @@ //go:build !windows -package api +package ipc import ( "net" diff --git a/internal/api/transport_windows.go b/internal/ipc/transport_windows.go similarity index 96% rename from internal/api/transport_windows.go rename to internal/ipc/transport_windows.go index c54967f4171..6e105a8033e 100644 --- a/internal/api/transport_windows.go +++ b/internal/ipc/transport_windows.go @@ -1,6 +1,6 @@ //go:build windows -package api +package ipc import ( "net" diff --git a/internal/locale/locale.go b/internal/locale/locale.go index 20d6e14a3de..10a8fcf91ef 100644 --- a/internal/locale/locale.go +++ b/internal/locale/locale.go @@ -12,6 +12,13 @@ type Locale language.Tag var Default Locale +func (l Locale) String() string { + if l == Default { + return "" + } + return language.Tag(l).String() +} + func WithLocale(ctx context.Context, locale Locale) context.Context { return context.WithValue(ctx, contextKey(0), locale) } diff --git a/internal/ls/autoimport/aliasresolver.go b/internal/ls/autoimport/aliasresolver.go index 9da2eeb4ec9..f7af5feb934 100644 --- a/internal/ls/autoimport/aliasresolver.go +++ b/internal/ls/autoimport/aliasresolver.go @@ -154,6 +154,11 @@ func (r *aliasResolver) CommonSourceDirectory() string { panic("unimplemented") } +// ContentMapperExtensions implements checker.Program. +func (r *aliasResolver) ContentMapperExtensions() []string { + return nil +} + // FileExists implements checker.Program. func (r *aliasResolver) FileExists(fileName string) bool { panic("unimplemented") diff --git a/internal/ls/autoimport/aliasresolver_crash_test.go b/internal/ls/autoimport/aliasresolver_crash_test.go index 897261c15d2..22a48bd1e8a 100644 --- a/internal/ls/autoimport/aliasresolver_crash_test.go +++ b/internal/ls/autoimport/aliasresolver_crash_test.go @@ -56,7 +56,7 @@ func TestAliasResolverGetDiagnosticsDoesNotPanic(t *testing.T) { }, text, core.ScriptKindTS) binder.BindSourceFile(sourceFile) - resolver := module.NewResolver(host, core.EmptyCompilerOptions, "", "") + resolver := module.NewResolver(host, core.EmptyCompilerOptions, "", "", nil) r := newAliasResolver( []*ast.SourceFile{sourceFile}, nil, diff --git a/internal/ls/autoimport/fix.go b/internal/ls/autoimport/fix.go index 4103cdc72b9..99b57891e88 100644 --- a/internal/ls/autoimport/fix.go +++ b/internal/ls/autoimport/fix.go @@ -50,6 +50,9 @@ type addToExistingImportFix struct { namedImport *newImportBinding } +// Edits produces the text edits and a human-readable description for the fix. The returned bool is false +// when the fix targets a content-mapped file and any edit could not be placed within a single verbatim +// span, meaning it cannot be safely applied to the original text and the caller should discard it. func (f *Fix) Edits( ctx context.Context, file *ast.SourceFile, @@ -57,20 +60,22 @@ func (f *Fix) Edits( formatOptions lsutil.FormatCodeSettings, converters *lsconv.Converters, preferences lsutil.UserPreferences, -) ([]*lsproto.TextEdit, string) { +) ([]*lsproto.TextEdit, string, bool) { locale := locale.FromContext(ctx) tracker := change.NewTracker(ctx, compilerOptions, formatOptions, converters) switch f.Kind { case lsproto.AutoImportFixKindUseNamespace: description := addNamespaceQualifier(f, tracker, file, locale) - return tracker.GetChanges()[file.FileName()], description + edits, safe := fileEdits(tracker, file) + return edits, description, safe case lsproto.AutoImportFixKindAddToExisting: if len(file.Imports()) <= int(f.ImportIndex) { panic("import index out of range") } existingFix := getAddToExistingImportFix(file, f) addToExistingImport(tracker, file, existingFix.importClauseOrBindingPattern, existingFix.defaultImport, core.SingleElementSlice(existingFix.namedImport), preferences) - return tracker.GetChanges()[file.FileName()], diagnostics.Update_import_from_0.Localize(locale, f.ModuleSpecifier) + edits, safe := fileEdits(tracker, file) + return edits, diagnostics.Update_import_from_0.Localize(locale, f.ModuleSpecifier), safe case lsproto.AutoImportFixKindAddNew: var declarations []*ast.Statement defaultImport := core.IfElse(f.ImportKind == lsproto.ImportKindDefault, &newImportBinding{name: f.Name, addAsTypeOnly: f.AddAsTypeOnly}, nil) @@ -101,23 +106,35 @@ func (f *Fix) Edits( // if qualification != nil { // addNamespaceQualifier(tracker, file, qualification) // } - return tracker.GetChanges()[file.FileName()], diagnostics.Add_import_from_0.Localize(locale, f.ModuleSpecifier) + edits, safe := fileEdits(tracker, file) + return edits, diagnostics.Add_import_from_0.Localize(locale, f.ModuleSpecifier), safe case lsproto.AutoImportFixKindPromoteTypeOnly: promotedDeclaration := promoteFromTypeOnly(tracker, f.TypeOnlyAliasDeclaration, compilerOptions, file, preferences) if promotedDeclaration.Kind == ast.KindImportSpecifier { moduleSpec := getModuleSpecifierText(promotedDeclaration.Parent.Parent) - return tracker.GetChanges()[file.FileName()], diagnostics.Remove_type_from_import_of_0_from_1.Localize(locale, f.Name, moduleSpec) + edits, safe := fileEdits(tracker, file) + return edits, diagnostics.Remove_type_from_import_of_0_from_1.Localize(locale, f.Name, moduleSpec), safe } moduleSpec := getModuleSpecifierText(promotedDeclaration) - return tracker.GetChanges()[file.FileName()], diagnostics.Remove_type_from_import_declaration_from_0.Localize(locale, moduleSpec) + edits, safe := fileEdits(tracker, file) + return edits, diagnostics.Remove_type_from_import_declaration_from_0.Localize(locale, moduleSpec), safe case lsproto.AutoImportFixKindJsdocTypeImport: description := addImportType(f, file, preferences, tracker, locale) - return tracker.GetChanges()[file.FileName()], description + edits, safe := fileEdits(tracker, file) + return edits, description, safe default: panic("unimplemented fix edit") } } +// fileEdits returns the edits recorded for file, along with whether they are safe to apply. GetChanges +// drops the edits of any content-mapped file that cannot be faithfully mapped back to the original text, +// so an empty result with safe == false means the fix could not be represented and must be discarded. +func fileEdits(tracker *change.Tracker, file *ast.SourceFile) (edits []*lsproto.TextEdit, safe bool) { + changes, unmappable := tracker.GetChanges() + return changes[file.FileName()], len(unmappable) == 0 +} + func addImportType(f *Fix, file *ast.SourceFile, preferences lsutil.UserPreferences, tracker *change.Tracker, locale locale.Locale) string { if f.UsagePosition == nil { panic("UsagePosition must be set for JSDoc type import fix") diff --git a/internal/ls/autoimport/import_adder.go b/internal/ls/autoimport/import_adder.go index ab0603d65be..005be12276e 100644 --- a/internal/ls/autoimport/import_adder.go +++ b/internal/ls/autoimport/import_adder.go @@ -169,7 +169,10 @@ func (adder *importAdder) Edits() []*lsproto.TextEdit { insertImports(tracker, adder.view.importingFile, newDeclarations, true /*blankLineBetween*/, adder.preferences) } - return tracker.GetChanges()[adder.view.importingFile.FileName()] + // Unmappable files are dropped by GetChanges, so a content-mapped importing file that cannot be + // faithfully rewritten yields no edits rather than a corrupting one. + changes, _ := tracker.GetChanges() + return changes[adder.view.importingFile.FileName()] } func sortedNamedImports(m map[string]*newImportBinding) []*newImportBinding { diff --git a/internal/ls/autoimport/registry.go b/internal/ls/autoimport/registry.go index 513da69e389..92ad1e1d317 100644 --- a/internal/ls/autoimport/registry.go +++ b/internal/ls/autoimport/registry.go @@ -1241,14 +1241,10 @@ func (b *registryBuilder) buildProjectBucket( skippedFileCount++ continue } - // Skip all node_modules files - they are always handled by node_modules buckets. - // This simplifies the logic and ensures exports are indexed consistently. - if strings.Contains(file.FileName(), "/node_modules/") { - continue - } - // Skip files that are realpaths of symlinks in node_modules. - // These files will be indexed via their symlinked path in node_modules buckets. - if hasSymlinkToNodeModules(file.Path(), symlinkCache) { + // Ordinary node_modules files are owned by node_modules buckets. Content-mapped files are not + // discovered by those buckets, but files already transformed in the Program can be indexed here. + if file.ContentMapper() == "" && + (strings.Contains(file.FileName(), "/node_modules/") || hasSymlinkToNodeModules(file.Path(), symlinkCache)) { continue } wg.Go(func() { diff --git a/internal/ls/autoimport/registry_test.go b/internal/ls/autoimport/registry_test.go index 8ecf415eb3f..d5c9bd0a768 100644 --- a/internal/ls/autoimport/registry_test.go +++ b/internal/ls/autoimport/registry_test.go @@ -15,6 +15,7 @@ import ( "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/project" "github.com/microsoft/typescript-go/internal/testutil/autoimporttestutil" + "github.com/microsoft/typescript-go/internal/testutil/contentmappertest" "github.com/microsoft/typescript-go/internal/testutil/projecttestutil" "github.com/microsoft/typescript-go/internal/tspath" "github.com/microsoft/typescript-go/internal/vfs/vfstest" @@ -895,6 +896,53 @@ export declare const otherValue: string;`, }) } +func TestContentMappedNodeModulesFileUsesProjectBucket(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + + files := map[string]any{ + "/home/project/tsconfig.json": `{ + "compilerOptions": { "module": "esnext", "moduleResolution": "bundler", "strict": true, "skipLibCheck": true }, + "contentMappers": [ { "package": "mapper", "extensions": [".vue"] } ] + }`, + "/home/project/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.ComponentMapper), + "/home/project/node_modules/profile-package/ProfileCard.vue": ` +`, + "/home/project/node_modules/profile-package/HiddenCard.vue": ` +`, + "/home/project/node_modules/profile-package/ordinary.ts": `export const ordinary = true;`, + "/home/project/load.ts": `import "profile-package/ProfileCard.vue"; +import "profile-package/ordinary";`, + "/home/project/main.ts": `profileTitle;`, + } + init, _ := projecttestutil.GetSessionInitOptions(files, &project.SessionOptions{ + CurrentDirectory: "/home/project", + DefaultLibraryPath: bundled.LibPath(), + TypingsLocation: projecttestutil.TestTypingsLocation, + PositionEncoding: lsproto.PositionEncodingKindUTF8, + LoadExternalPlugins: true, + }, nil) + init.Spawner = contentmappertest.NewSpawner() + session := project.NewSession(init) + defer session.Close() + + ctx := context.Background() + mainURI := lsproto.DocumentUri("file:///home/project/main.ts") + session.DidOpenFile(ctx, mainURI, 1, files["/home/project/main.ts"].(string), lsproto.LanguageKindTypeScript) + _, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, mainURI) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + + projectBucket := singleBucket(t, autoImportStats(t, session).ProjectBuckets) + assert.Equal(t, projectBucket.FileCount, 3, "expected the two project roots and referenced mapped package file") +} + func TestHiddenDirectoriesInNodeModules(t *testing.T) { t.Parallel() t.Run("deep import through subdirectory package.json in hidden store", func(t *testing.T) { diff --git a/internal/ls/autoinsert.go b/internal/ls/autoinsert.go index d4b88b06c14..1bbf4d95cf7 100644 --- a/internal/ls/autoinsert.go +++ b/internal/ls/autoinsert.go @@ -7,6 +7,7 @@ import ( "github.com/microsoft/typescript-go/internal/astnav" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/scanner" + "github.com/microsoft/typescript-go/internal/spanmap" ) func (l *LanguageService) ProvideOnAutoInsert(ctx context.Context, params *lsproto.VSOnAutoInsertParams) (lsproto.VSOnAutoInsertResponse, error) { @@ -18,7 +19,11 @@ func (l *LanguageService) ProvideOnAutoInsert(ctx context.Context, params *lspro } _, sourceFile := l.getProgramAndFile(params.VSTextDocument.Uri) - position := l.converters.LineAndCharacterToPosition(sourceFile, params.VSPosition) + positions := l.converters.FromLSPPosition(sourceFile, params.VSPosition, spanmap.PurposeAll) + if len(positions) != 1 || !positions[0].Fidelity.IsExact() { + return lsproto.VSOnAutoInsertResponse{}, nil + } + position := positions[0].Position token := astnav.FindPrecedingToken(sourceFile, int(position)) if token == nil { diff --git a/internal/ls/callhierarchy.go b/internal/ls/callhierarchy.go index 376adf0a08d..397cb477133 100644 --- a/internal/ls/callhierarchy.go +++ b/internal/ls/callhierarchy.go @@ -9,6 +9,7 @@ import ( "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/astnav" "github.com/microsoft/typescript-go/internal/checker" + "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/compiler" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/debug" @@ -16,6 +17,7 @@ import ( "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/printer" "github.com/microsoft/typescript-go/internal/scanner" + "github.com/microsoft/typescript-go/internal/spanmap" ) type CallHierarchyDeclaration = *ast.Node @@ -505,8 +507,14 @@ func (l *LanguageService) createCallHierarchyItem(program *compiler.Program, nod fullStart := scanner.SkipTriviaEx(sourceFile.Text(), node.Pos(), &scanner.SkipTriviaOptions{StopAtComments: true}) script := l.getScript(sourceFile.FileName()) - span := l.converters.ToLSPRange(script, core.NewTextRange(fullStart, node.End())) - selectionSpan := l.converters.ToLSPRange(script, core.NewTextRange(namePos, nameEnd)) + span, spanFidelity := l.converters.ToLSPRange(script, core.NewTextRange(fullStart, node.End())) + selectionSpan, selectionFidelity := l.converters.ToLSPRange(script, core.NewTextRange(namePos, nameEnd)) + if !selectionFidelity.IsSingleSegment() { + return nil + } + if spanFidelity.IsNone() { + span = selectionSpan + } item := &lsproto.CallHierarchyItem{ Name: nameText, @@ -563,16 +571,22 @@ func getCallSiteGroupKey(site *callSite) ast.NodeId { } func (l *LanguageService) convertCallSiteGroupToIncomingCall(program *compiler.Program, entries []*callSite) *lsproto.CallHierarchyIncomingCall { - fromRanges := make([]lsproto.Range, len(entries)) - for i, entry := range entries { + fromRanges := make([]lsproto.Range, 0, len(entries)) + for _, entry := range entries { script := l.getScript(entry.sourceFile.AsSourceFile().FileName()) - fromRanges[i] = l.converters.ToLSPRange(script, entry.textRange) + if lspRange, fidelity := l.converters.ToLSPRange(script, entry.textRange); !fidelity.IsNone() { + fromRanges = append(fromRanges, lspRange) + } + } + from := l.createCallHierarchyItem(program, entries[0].declaration) + if from == nil || len(fromRanges) == 0 { + return nil } slices.SortFunc(fromRanges, lsproto.CompareRanges) return &lsproto.CallHierarchyIncomingCall{ - From: l.createCallHierarchyItem(program, entries[0].declaration), + From: from, FromRanges: fromRanges, } } @@ -610,7 +624,7 @@ func (d *incomingEntry) TextDocumentURI() lsproto.DocumentUri { func (d *incomingEntry) TextDocumentPosition() lsproto.Position { d.positionOnce.Do(func() { start := scanner.GetTokenPosOfNode(d.node, d.getSourceFile(), false /*includeJsDoc*/) - d.position = d.ls.createLspPosition(start, d.getSourceFile()) + d.position, _ = d.ls.createLspPosition(start, d.getSourceFile()) }) return d.position } @@ -626,6 +640,11 @@ func (l *LanguageService) getIncomingCalls(ctx context.Context, program *compile if location == nil { return lsproto.CallHierarchyIncomingCallsOrNull{}, nil } + locationFile := ast.GetSourceFileOfNode(location) + locationStart := scanner.GetTokenPosOfNode(location, locationFile, false /*includeJsDoc*/) + if _, fidelity := l.converters.ToLSPPosition(locationFile, core.TextPos(locationStart)); fidelity.IsNone() { + return lsproto.CallHierarchyIncomingCallsOrNull{}, nil + } incomingEntry := &incomingEntry{ ls: l, @@ -683,7 +702,9 @@ func (l *LanguageService) symbolAndEntriesToIncomingCalls(ctx context.Context, p var result []*lsproto.CallHierarchyIncomingCall for _, sites := range grouped { - result = append(result, l.convertCallSiteGroupToIncomingCall(program, sites)) + if incomingCall := l.convertCallSiteGroupToIncomingCall(program, sites); incomingCall != nil { + result = append(result, incomingCall) + } } return lsproto.CallHierarchyIncomingCallsOrNull{CallHierarchyIncomingCalls: &result}, nil } @@ -920,16 +941,22 @@ func collectCallSites(program *compiler.Program, c *checker.Checker, node *ast.N } func (l *LanguageService) convertCallSiteGroupToOutgoingCall(program *compiler.Program, entries []*callSite) *lsproto.CallHierarchyOutgoingCall { - fromRanges := make([]lsproto.Range, len(entries)) - for i, entry := range entries { + fromRanges := make([]lsproto.Range, 0, len(entries)) + for _, entry := range entries { script := l.getScript(entry.sourceFile.AsSourceFile().FileName()) - fromRanges[i] = l.converters.ToLSPRange(script, entry.textRange) + if lspRange, fidelity := l.converters.ToLSPRange(script, entry.textRange); !fidelity.IsNone() { + fromRanges = append(fromRanges, lspRange) + } + } + to := l.createCallHierarchyItem(program, entries[0].declaration) + if to == nil || len(fromRanges) == 0 { + return nil } slices.SortFunc(fromRanges, lsproto.CompareRanges) return &lsproto.CallHierarchyOutgoingCall{ - To: l.createCallHierarchyItem(program, entries[0].declaration), + To: to, FromRanges: fromRanges, } } @@ -957,7 +984,9 @@ func (l *LanguageService) getOutgoingCalls(program *compiler.Program, declaratio var result []*lsproto.CallHierarchyOutgoingCall for _, sites := range grouped { - result = append(result, l.convertCallSiteGroupToOutgoingCall(program, sites)) + if outgoingCall := l.convertCallSiteGroupToOutgoingCall(program, sites); outgoingCall != nil { + result = append(result, outgoingCall) + } } slices.SortFunc(result, func(a, b *lsproto.CallHierarchyOutgoingCall) int { @@ -979,25 +1008,15 @@ func (l *LanguageService) ProvidePrepareCallHierarchy( position lsproto.Position, ) (lsproto.CallHierarchyPrepareResponse, error) { program, file := l.getProgramAndFile(documentURI) - node := astnav.GetTouchingPropertyName(file, int(l.converters.LineAndCharacterToPosition(file, position))) - - if node.Kind == ast.KindSourceFile { - return lsproto.CallHierarchyItemsOrNull{}, nil - } - - declaration := resolveCallHierarchyDeclaration(program, node) - if declaration == nil { - return lsproto.CallHierarchyItemsOrNull{}, nil - } - + declarations := l.callHierarchyDeclarations(file, position, program, false) var items []*lsproto.CallHierarchyItem - switch decl := declaration.(type) { - case *ast.Node: - items = []*lsproto.CallHierarchyItem{l.createCallHierarchyItem(program, decl)} - case []*ast.Node: - items = make([]*lsproto.CallHierarchyItem, len(decl)) - for i, d := range decl { - items[i] = l.createCallHierarchyItem(program, d) + var seen collections.Set[lsproto.Location] + for _, declaration := range declarations { + if item := l.createCallHierarchyItem(program, declaration); item != nil { + location := lsproto.Location{Uri: item.Uri, Range: item.SelectionRange} + if seen.AddIfAbsent(location) { + items = append(items, item) + } } } @@ -1019,38 +1038,34 @@ func (l *LanguageService) ProvideCallHierarchyIncomingCalls( return lsproto.CallHierarchyIncomingCallsOrNull{}, nil } - pos := int(l.converters.LineAndCharacterToPosition(file, item.SelectionRange.Start)) - var node *ast.Node - if pos == 0 { - node = file.AsNode() - } else { - node = astnav.GetTouchingPropertyName(file, pos) - } - - if node == nil { - return lsproto.CallHierarchyIncomingCallsOrNull{}, nil - } - - declaration := resolveCallHierarchyDeclaration(program, node) - if declaration == nil { - return lsproto.CallHierarchyIncomingCallsOrNull{}, nil - } - - var decl *ast.Node - switch d := declaration.(type) { - case *ast.Node: - decl = d - case []*ast.Node: - if len(d) > 0 { - decl = d[0] + declarations := l.callHierarchyDeclarations(file, item.SelectionRange.Start, program, true) + var calls []*lsproto.CallHierarchyIncomingCall + seen := make(map[lsproto.Location]*lsproto.CallHierarchyIncomingCall) + for _, declaration := range declarations { + response, err := l.getIncomingCalls(ctx, program, declaration, orchestrator) + if err != nil { + return lsproto.CallHierarchyIncomingCallsOrNull{}, err + } + if response.CallHierarchyIncomingCalls != nil { + for _, call := range *response.CallHierarchyIncomingCalls { + location := lsproto.Location{Uri: call.From.Uri, Range: call.From.SelectionRange} + if existing := seen[location]; existing != nil { + for _, fromRange := range call.FromRanges { + if !slices.Contains(existing.FromRanges, fromRange) { + existing.FromRanges = append(existing.FromRanges, fromRange) + } + } + } else { + seen[location] = call + calls = append(calls, call) + } + } } } - - if decl == nil { + if len(calls) == 0 { return lsproto.CallHierarchyIncomingCallsOrNull{}, nil } - - return l.getIncomingCalls(ctx, program, decl, orchestrator) + return lsproto.CallHierarchyIncomingCallsOrNull{CallHierarchyIncomingCalls: &calls}, nil } func (l *LanguageService) ProvideCallHierarchyOutgoingCalls( @@ -1064,40 +1079,58 @@ func (l *LanguageService) ProvideCallHierarchyOutgoingCalls( return lsproto.CallHierarchyOutgoingCallsOrNull{}, nil } - pos := int(l.converters.LineAndCharacterToPosition(file, item.SelectionRange.Start)) - var node *ast.Node - if pos == 0 { - node = file.AsNode() - } else { - node = astnav.GetTouchingPropertyName(file, pos) - } - - if node == nil { - return lsproto.CallHierarchyOutgoingCallsOrNull{}, nil - } - - declaration := resolveCallHierarchyDeclaration(program, node) - if declaration == nil { - return lsproto.CallHierarchyOutgoingCallsOrNull{}, nil - } - - var decl *ast.Node - switch d := declaration.(type) { - case *ast.Node: - decl = d - case []*ast.Node: - if len(d) > 0 { - decl = d[0] + declarations := l.callHierarchyDeclarations(file, item.SelectionRange.Start, program, true) + var calls []*lsproto.CallHierarchyOutgoingCall + seen := make(map[lsproto.Location]*lsproto.CallHierarchyOutgoingCall) + for _, declaration := range declarations { + for _, call := range l.getOutgoingCalls(program, declaration) { + location := lsproto.Location{Uri: call.To.Uri, Range: call.To.SelectionRange} + if existing := seen[location]; existing != nil { + for _, fromRange := range call.FromRanges { + if !slices.Contains(existing.FromRanges, fromRange) { + existing.FromRanges = append(existing.FromRanges, fromRange) + } + } + } else { + seen[location] = call + calls = append(calls, call) + } } } - - if decl == nil { + if len(calls) == 0 { return lsproto.CallHierarchyOutgoingCallsOrNull{}, nil } + return lsproto.CallHierarchyOutgoingCallsOrNull{CallHierarchyOutgoingCalls: &calls}, nil +} - calls := l.getOutgoingCalls(program, decl) - if calls == nil { - return lsproto.CallHierarchyOutgoingCallsOrNull{}, nil +func (l *LanguageService) callHierarchyDeclarations(file *ast.SourceFile, position lsproto.Position, program *compiler.Program, allowSourceFile bool) []*ast.Node { + positions := l.converters.FromLSPPosition(file, position, spanmap.PurposeNavigation) + var declarations []*ast.Node + var seen collections.Set[*ast.Node] + for _, mapped := range positions { + if !mapped.Fidelity.IsSingleSegment() { + continue + } + pos := int(mapped.Position) + node := file.AsNode() + if pos != 0 { + node = astnav.GetTouchingPropertyName(file, pos) + } + if node == nil || !allowSourceFile && node.Kind == ast.KindSourceFile { + continue + } + switch declaration := resolveCallHierarchyDeclaration(program, node).(type) { + case *ast.Node: + if seen.AddIfAbsent(declaration) { + declarations = append(declarations, declaration) + } + case []*ast.Node: + for _, declaration := range declaration { + if seen.AddIfAbsent(declaration) { + declarations = append(declarations, declaration) + } + } + } } - return lsproto.CallHierarchyOutgoingCallsOrNull{CallHierarchyOutgoingCalls: &calls}, nil + return declarations } diff --git a/internal/ls/change/delete.go b/internal/ls/change/delete.go index e4af6b4ebb6..c78b021eb05 100644 --- a/internal/ls/change/delete.go +++ b/internal/ls/change/delete.go @@ -8,7 +8,6 @@ import ( "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/debug" "github.com/microsoft/typescript-go/internal/format" - "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/scanner" "github.com/microsoft/typescript-go/internal/stringutil" ) @@ -115,9 +114,7 @@ func deleteDefaultImport(t *Tracker, sourceFile *ast.SourceFile, importClause *a if nextToken != nil && nextToken.Kind == ast.KindCommaToken { // shift first non-whitespace position after comma to the start position of the node end := scanner.SkipTriviaEx(sourceFile.Text(), nextToken.End(), &scanner.SkipTriviaOptions{StopAfterLineBreak: false, StopAtComments: true}) - startPos := t.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(start)) - endPos := t.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(end)) - t.ReplaceRangeWithText(sourceFile, lsproto.Range{Start: startPos, End: endPos}, "") + t.ReplaceRangeWithText(sourceFile, t.toLSPEditRange(sourceFile, core.NewTextRange(start, end)), "") } else { deleteNode(t, sourceFile, name, LeadingTriviaOptionIncludeAll, TrailingTriviaOptionInclude) } @@ -132,9 +129,8 @@ func deleteImportBinding(t *Tracker, sourceFile *ast.SourceFile, node *ast.Node) // import d|, { a }| from './file' previousToken := astnav.GetTokenAtPosition(sourceFile, node.Pos()-1) debug.Assert(previousToken != nil, "previousToken should not be nil") - startPos := t.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(astnav.GetStartOfNode(previousToken, sourceFile, false))) - endPos := t.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(node.End())) - t.ReplaceRangeWithText(sourceFile, lsproto.Range{Start: startPos, End: endPos}, "") + start := astnav.GetStartOfNode(previousToken, sourceFile, false) + t.ReplaceRangeWithText(sourceFile, t.toLSPEditRange(sourceFile, core.NewTextRange(start, node.End())), "") } else { // Delete the entire import declaration // |import * as ns from './file'| @@ -187,9 +183,7 @@ func deleteVariableDeclaration(t *Tracker, deletedNodesInLists map[*ast.Node]boo func deleteNode(t *Tracker, sourceFile *ast.SourceFile, node *ast.Node, leadingTrivia LeadingTriviaOption, trailingTrivia TrailingTriviaOption) { startPosition := t.getAdjustedStartPosition(sourceFile, node, leadingTrivia, false) endPosition := t.getAdjustedEndPosition(sourceFile, node, trailingTrivia) - startPos := t.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(startPosition)) - endPos := t.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(endPosition)) - t.ReplaceRangeWithText(sourceFile, lsproto.Range{Start: startPos, End: endPos}, "") + t.ReplaceRangeWithText(sourceFile, t.toLSPEditRange(sourceFile, core.NewTextRange(startPosition, endPosition)), "") } func deleteNodeInList(t *Tracker, deletedNodesInLists map[*ast.Node]bool, sourceFile *ast.SourceFile, node *ast.Node) { @@ -220,9 +214,7 @@ func deleteNodeInList(t *Tracker, deletedNodesInLists map[*ast.Node]bool, source endPos = t.endPositionToDeleteNodeInList(sourceFile, node, prevNode, containingList.Nodes[index+1]) } - startLSPos := t.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(startPos)) - endLSPos := t.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(endPos)) - t.ReplaceRangeWithText(sourceFile, lsproto.Range{Start: startLSPos, End: endLSPos}, "") + t.ReplaceRangeWithText(sourceFile, t.toLSPEditRange(sourceFile, core.NewTextRange(startPos, endPos)), "") } // startPositionToDeleteNodeInList finds the first non-whitespace position in the leading trivia of the node diff --git a/internal/ls/change/tracker.go b/internal/ls/change/tracker.go index b84201bf85a..a437ee96643 100644 --- a/internal/ls/change/tracker.go +++ b/internal/ls/change/tracker.go @@ -93,6 +93,11 @@ type Tracker struct { deletedNodes []deletedNode nodesWithInsertionsAtStart map[*ast.Node]*nodesInsertedAtStartState + // unmappableFiles collects the files for which an edit could not be represented within a single + // verbatim span of the original text. GetChanges drops their edits so a partial, corrupting change is + // never emitted for a content-mapped file. + unmappableFiles collections.Set[string] + // created during call to getChanges writer *printer.ChangeTrackerWriter // printer @@ -119,14 +124,48 @@ func NewTracker(ctx context.Context, compilerOptions *core.CompilerOptions, form } } -// GetChanges returns the accumulated text edits. +// GetChanges returns the accumulated text edits grouped by file name. Any file whose edits could not be +// faithfully mapped back onto content-mapped original text is omitted from the returned map, and its name +// is included in the returned slice. Dropping the whole file (rather than the individual edit) keeps a +// logical change atomic, and returning the result inline means a caller cannot forget to check it or +// accidentally emit a partial, corrupting change. // Note: after calling this, the Tracker object must be discarded! -func (t *Tracker) GetChanges() map[string][]*lsproto.TextEdit { +func (t *Tracker) GetChanges() (map[string][]*lsproto.TextEdit, []string) { t.finishDeleteDeclarations() t.finishNodesWithInsertionsAtStart() changes := t.getTextChangesFromChanges() // !!! changes for new files - return changes + if t.unmappableFiles.Len() == 0 { + return changes, nil + } + unmappable := make([]string, 0, t.unmappableFiles.Len()) + for fileName := range t.unmappableFiles.Keys() { + delete(changes, fileName) + unmappable = append(unmappable, fileName) + } + slices.Sort(unmappable) + return changes, unmappable +} + +// toLSPEditRange converts a transformed-text range to an LSP range for an edit, mapping through the +// content mapper's span map when the file is content-mapped. If the range does not fall entirely within a +// single verbatim span the edit cannot be represented safely in the original text: the file is recorded so +// GetChanges drops its edits, and a best-effort range is returned so the accumulated edits stay well-formed. +func (t *Tracker) toLSPEditRange(sourceFile *ast.SourceFile, textRange core.TextRange) lsproto.Range { + r, fidelity := t.converters.ToLSPRange(sourceFile, textRange) + if !fidelity.IsExact() { + // The range does not map into a single verbatim span, so the edit cannot be represented safely in + // the original text. Record the file so GetChanges drops its edits, keeping the best-effort range so + // the accumulated edits stay well-formed. + t.unmappableFiles.Add(sourceFile.FileName()) + } + return r +} + +// toLSPEditPos converts a transformed-text offset to an LSP position for a zero-length edit (an insertion), +// applying the same content-mapping safety check as toLSPEditRange. +func (t *Tracker) toLSPEditPos(sourceFile *ast.SourceFile, pos core.TextPos) lsproto.Position { + return t.toLSPEditRange(sourceFile, core.NewTextRange(int(pos), int(pos))).Start } func (t *Tracker) ReplaceNode(sourceFile *ast.SourceFile, oldNode *ast.Node, newNode *ast.Node, options *NodeOptions) { @@ -158,6 +197,13 @@ func (t *Tracker) ReplaceRangeWithText(sourceFile *ast.SourceFile, lsprotoRange t.changes.Add(sourceFile, &trackerEdit{kind: trackerEditKindText, Range: lsprotoRange, NewText: text}) } +// ReplaceTextRangeWithText replaces textRange (in transformed-text coordinates) with text, mapping the +// range through the content-mapping guard so an edit that cannot be represented in the original text marks +// the file unmappable and is dropped by GetChanges. +func (t *Tracker) ReplaceTextRangeWithText(sourceFile *ast.SourceFile, textRange core.TextRange, text string) { + t.ReplaceRangeWithText(sourceFile, t.toLSPEditRange(sourceFile, textRange), text) +} + func (t *Tracker) ReplaceRangeWithNodes(sourceFile *ast.SourceFile, lsprotoRange lsproto.Range, newNodes []*ast.Node, options NodeOptions) { if len(newNodes) == 1 { t.ReplaceRange(sourceFile, lsprotoRange, newNodes[0], options) @@ -171,12 +217,12 @@ func (t *Tracker) InsertText(sourceFile *ast.SourceFile, pos lsproto.Position, t } func (t *Tracker) InsertNodeAt(sourceFile *ast.SourceFile, pos core.TextPos, newNode *ast.Node, options NodeOptions) { - lsPos := t.converters.PositionToLineAndCharacter(sourceFile, pos) + lsPos := t.toLSPEditPos(sourceFile, pos) t.ReplaceRange(sourceFile, lsproto.Range{Start: lsPos, End: lsPos}, newNode, options) } func (t *Tracker) InsertNodesAt(sourceFile *ast.SourceFile, pos core.TextPos, newNodes []*ast.Node, options NodeOptions) { - lsPos := t.converters.PositionToLineAndCharacter(sourceFile, pos) + lsPos := t.toLSPEditPos(sourceFile, pos) t.ReplaceRangeWithNodes(sourceFile, lsproto.Range{Start: lsPos, End: lsPos}, newNodes, options) } @@ -247,8 +293,8 @@ func (t *Tracker) ParenthesizeArrowParameters(sourceFile *ast.SourceFile, arrowF firstParam := params[0] lastParam := params[len(params)-1] startPos := astnav.GetStartOfNode(firstParam, sourceFile, false) - t.InsertText(sourceFile, t.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(startPos)), "(") - t.InsertText(sourceFile, t.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(lastParam.End())), ")") + t.InsertText(sourceFile, t.toLSPEditPos(sourceFile, core.TextPos(startPos)), "(") + t.InsertText(sourceFile, t.toLSPEditPos(sourceFile, core.TextPos(lastParam.End())), ")") } // InsertModifierBefore inserts a modifier token (like 'type') before a node with a trailing space. @@ -268,7 +314,7 @@ func (t *Tracker) Delete(sourceFile *ast.SourceFile, node *ast.Node) { // DeleteRange deletes a text range from the source file. func (t *Tracker) DeleteRange(sourceFile *ast.SourceFile, textRange core.TextRange) { - lspRange := t.converters.ToLSPRange(sourceFile, textRange) + lspRange := t.toLSPEditRange(sourceFile, textRange) t.ReplaceRangeWithText(sourceFile, lspRange, "") } @@ -283,9 +329,7 @@ func (t *Tracker) DeleteNode(sourceFile *ast.SourceFile, node *ast.Node, leading func (t *Tracker) DeleteNodeRange(sourceFile *ast.SourceFile, startNode *ast.Node, endNode *ast.Node, leadingTrivia LeadingTriviaOption, trailingTrivia TrailingTriviaOption) { startPosition := t.getAdjustedStartPosition(sourceFile, startNode, leadingTrivia, false) endPosition := t.getAdjustedEndPosition(sourceFile, endNode, trailingTrivia) - startPos := t.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(startPosition)) - endPos := t.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(endPosition)) - t.ReplaceRangeWithText(sourceFile, lsproto.Range{Start: startPos, End: endPos}, "") + t.ReplaceRangeWithText(sourceFile, t.toLSPEditRange(sourceFile, core.NewTextRange(startPosition, endPosition)), "") } // finishDeleteDeclarations processes all queued deletions with smart handling for lists and trailing commas. @@ -326,9 +370,9 @@ func (t *Tracker) finishDeleteDeclarations() { } if lastNonDeletedIndex != -1 { - startPos := t.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(list.Nodes[lastNonDeletedIndex].End())) - endPos := t.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(t.startPositionToDeleteNodeInList(sourceFile, list.Nodes[lastNonDeletedIndex+1]))) - t.ReplaceRangeWithText(sourceFile, lsproto.Range{Start: startPos, End: endPos}, "") + start := list.Nodes[lastNonDeletedIndex].End() + end := t.startPositionToDeleteNodeInList(sourceFile, list.Nodes[lastNonDeletedIndex+1]) + t.ReplaceRangeWithText(sourceFile, t.toLSPEditRange(sourceFile, core.NewTextRange(start, end)), "") } } } @@ -337,7 +381,7 @@ func (t *Tracker) endPosForInsertNodeAfter(sourceFile *ast.SourceFile, after *as if needSemicolonBetween(after, newNode) && (rune(sourceFile.Text()[after.End()-1]) != ';') { // check if previous statement ends with semicolon // if not - insert semicolon to preserve the code from changing the meaning due to ASI - endPos := t.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(after.End())) + endPos := t.toLSPEditPos(sourceFile, core.TextPos(after.End())) semicolon := t.NewToken(ast.KindSemicolonToken) semicolon.Loc = core.NewTextRange(after.End(), after.End()) semicolon.Parent = after.Parent @@ -430,7 +474,7 @@ func (t *Tracker) InsertNodeInListAfter(sourceFile *ast.SourceFile, after *ast.N separatorString := scanner.TokenToString(separator) separatorToken.Loc = core.NewTextRange(end, end+len(separatorString)) separatorToken.Parent = after.Parent - endPos := t.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(end)) + endPos := t.toLSPEditPos(sourceFile, core.TextPos(end)) t.ReplaceRange(sourceFile, lsproto.Range{Start: endPos, End: endPos}, separatorToken, NodeOptions{}) // use the same indentation as 'after' item indentation := format.FindFirstNonWhitespaceColumn(afterStartLinePosition, afterStart, sourceFile, t.formatSettings) @@ -440,7 +484,7 @@ func (t *Tracker) InsertNodeInListAfter(sourceFile *ast.SourceFile, after *ast.N for insertPos != end && stringutil.IsLineBreak(rune(sourceFile.Text()[insertPos-1])) { insertPos-- } - insertLSPos := t.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(insertPos)) + insertLSPos := t.toLSPEditPos(sourceFile, core.TextPos(insertPos)) t.ReplaceRange( sourceFile, lsproto.Range{Start: insertLSPos, End: insertLSPos}, @@ -452,7 +496,7 @@ func (t *Tracker) InsertNodeInListAfter(sourceFile *ast.SourceFile, after *ast.N ) } else { separatorString := scanner.TokenToString(separator) - endPos := t.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(end)) + endPos := t.toLSPEditPos(sourceFile, core.TextPos(end)) t.ReplaceRange(sourceFile, lsproto.Range{Start: endPos, End: endPos}, newNode, NodeOptions{Prefix: separatorString + " "}) } } @@ -704,7 +748,7 @@ func (t *Tracker) finishNodesWithInsertionsAtStart() { } if isSingleLine { - t.InsertText(state.sourceFile, t.converters.PositionToLineAndCharacter(state.sourceFile, core.TextPos(closeBrace.End()-1)), t.newLine) + t.InsertText(state.sourceFile, t.toLSPEditPos(state.sourceFile, core.TextPos(closeBrace.End()-1)), t.newLine) } } } diff --git a/internal/ls/change/trackerimpl.go b/internal/ls/change/trackerimpl.go index e9e0ae717e2..1de2ec561f6 100644 --- a/internal/ls/change/trackerimpl.go +++ b/internal/ls/change/trackerimpl.go @@ -15,12 +15,16 @@ import ( "github.com/microsoft/typescript-go/internal/parser" "github.com/microsoft/typescript-go/internal/printer" "github.com/microsoft/typescript-go/internal/scanner" + "github.com/microsoft/typescript-go/internal/spanmap" "github.com/microsoft/typescript-go/internal/stringutil" ) func (t *Tracker) getTextChangesFromChanges() map[string][]*lsproto.TextEdit { changes := map[string][]*lsproto.TextEdit{} for sourceFile, changesInFile := range t.changes.M { + if t.unmappableFiles.Has(sourceFile.FileName()) { + continue + } // order changes by start position // If the start position is the same, put the shorter range first, since an empty range (x, x) may precede (x, y) but not vice-versa. slices.SortStableFunc(changesInFile, func(a, b *trackerEdit) int { return lsproto.CompareRanges(a.Range, b.Range) }) @@ -62,30 +66,50 @@ func (t *Tracker) computeNewText(change *trackerEdit, targetSourceFile *ast.Sour return change.NewText } - pos := int(t.converters.LineAndCharacterToPosition(sourceFile, change.Range.Start)) - formatNode := func(n *ast.Node) string { - return t.getFormattedTextOfNode(n, targetSourceFile, sourceFile, pos, change.options) - } - - var text string - switch change.kind { + positions := t.converters.FromLSPPosition(sourceFile, change.Range.Start, spanmap.PurposeAll) + var result string + found := false + // The original range may have multiple verbatim copies; it is safe to lose their identity only when + // formatting at every exact projection produces the same edit. + for _, mapped := range positions { + if !mapped.Fidelity.IsExact() { + continue + } + pos := int(mapped.Position) + formatNode := func(n *ast.Node) string { + return t.getFormattedTextOfNode(n, targetSourceFile, sourceFile, pos, change.options) + } - case trackerEditKindReplaceWithMultipleNodes: - if change.options.joiner == "" { - change.options.joiner = t.newLine + var text string + switch change.kind { + case trackerEditKindReplaceWithMultipleNodes: + joiner := change.options.joiner + if joiner == "" { + joiner = t.newLine + } + text = strings.Join(core.Map(change.nodes, func(n *ast.Node) string { return strings.TrimSuffix(formatNode(n), t.newLine) }), joiner) + case trackerEditKindReplaceWithSingleNode: + text = formatNode(change.Node) + default: + panic(fmt.Sprintf("change kind %d should have been handled earlier", change.kind)) + } + // Strip initial indentation if text will be inserted in the middle of the line. + noIndent := text + if !(change.options.indentation != nil || format.GetLineStartPositionForPosition(pos, targetSourceFile) == pos) { + noIndent = strings.TrimLeftFunc(text, unicode.IsSpace) + } + candidate := change.options.Prefix + noIndent + core.IfElse(strings.HasSuffix(noIndent, change.options.Suffix), "", change.options.Suffix) + if found && candidate != result { + t.unmappableFiles.Add(sourceFile.FileName()) + return "" } - text = strings.Join(core.Map(change.nodes, func(n *ast.Node) string { return strings.TrimSuffix(formatNode(n), t.newLine) }), change.options.joiner) - case trackerEditKindReplaceWithSingleNode: - text = formatNode(change.Node) - default: - panic(fmt.Sprintf("change kind %d should have been handled earlier", change.kind)) + result = candidate + found = true } - // strip initial indentation (spaces or tabs) if text will be inserted in the middle of the line - noIndent := text - if !(change.options.indentation != nil || format.GetLineStartPositionForPosition(pos, targetSourceFile) == pos) { - noIndent = strings.TrimLeftFunc(text, unicode.IsSpace) + if !found { + t.unmappableFiles.Add(sourceFile.FileName()) } - return change.options.Prefix + noIndent + core.IfElse(strings.HasSuffix(noIndent, change.options.Suffix), "", change.options.Suffix) + return result } /** Note: this may mutate `nodeIn`. */ @@ -159,7 +183,7 @@ func (t *Tracker) getNonformattedText(node *ast.Node, sourceFile *ast.SourceFile // method on the changeTracker because use of converters // GetAdjustedRange computes the adjusted range for a node in a source file, accounting for trivia. func (t *Tracker) GetAdjustedRange(sourceFile *ast.SourceFile, startNode *ast.Node, endNode *ast.Node, leadingOption LeadingTriviaOption, trailingOption TrailingTriviaOption) lsproto.Range { - return t.converters.ToLSPRange( + return t.toLSPEditRange( sourceFile, core.NewTextRange( t.getAdjustedStartPosition(sourceFile, startNode, leadingOption, false), diff --git a/internal/ls/codeactions.go b/internal/ls/codeactions.go index d04f64c73d0..772743f339d 100644 --- a/internal/ls/codeactions.go +++ b/internal/ls/codeactions.go @@ -14,6 +14,7 @@ import ( "github.com/microsoft/typescript-go/internal/locale" "github.com/microsoft/typescript-go/internal/ls/lsconv" "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/spanmap" ) // CodeFixProvider represents a provider for a specific type of code fix @@ -117,31 +118,31 @@ func (l *LanguageService) ProvideCodeActions(ctx context.Context, params *lsprot continue } - position := l.converters.LineAndCharacterToPosition(file, diag.Range.Start) - endPosition := l.converters.LineAndCharacterToPosition(file, diag.Range.End) - fixContext := &CodeFixContext{ - SourceFile: file, - Span: core.NewTextRange(int(position), int(endPosition)), - ErrorCode: errorCode, - Program: program, - LS: l, - Diagnostic: diag, - Params: params, - } + for _, mapped := range l.converters.FromLSPRange(file, diag.Range, spanmap.PurposeSemantic) { + fixContext := &CodeFixContext{ + SourceFile: file, + Span: mapped.Span, + ErrorCode: errorCode, + Program: program, + LS: l, + Diagnostic: diag, + Params: params, + } - providerActions, err := provider.GetCodeActions(ctx, fixContext) - if err != nil { - return lsproto.CodeActionResponse{}, err - } - for _, action := range providerActions { - i, found := slices.BinarySearchFunc(seen, action, (*CodeAction).Compare) - if found { - continue + providerActions, err := provider.GetCodeActions(ctx, fixContext) + if err != nil { + return lsproto.CodeActionResponse{}, err } - seen = slices.Insert(seen, i, action) - actions = append(actions, convertToLSPCodeAction(action, diag, params.TextDocument.Uri)) - if action.FixID != "" { - fixIdSeen[action.FixID] = provider + for _, action := range providerActions { + i, found := slices.BinarySearchFunc(seen, action, (*CodeAction).Compare) + if found { + continue + } + seen = slices.Insert(seen, i, action) + actions = append(actions, convertToLSPCodeAction(action, diag, params.TextDocument.Uri)) + if action.FixID != "" { + fixIdSeen[action.FixID] = provider + } } } } diff --git a/internal/ls/codeactions_fixclassincorrectlyimplementsinterface.go b/internal/ls/codeactions_fixclassincorrectlyimplementsinterface.go index 756b1b340a0..d8be5b0f6b6 100644 --- a/internal/ls/codeactions_fixclassincorrectlyimplementsinterface.go +++ b/internal/ls/codeactions_fixclassincorrectlyimplementsinterface.go @@ -134,7 +134,8 @@ func addChanges(context context.Context, fixContext *CodeFixContext, changeTrack } func getChanges(changeTracker *change.Tracker, importAdder autoimport.ImportAdder, sourceFile *ast.SourceFile) []*lsproto.TextEdit { - fileChanges := changeTracker.GetChanges()[sourceFile.FileName()] + changes, _ := changeTracker.GetChanges() + fileChanges := changes[sourceFile.FileName()] if importAdder != nil && importAdder.HasFixes() { fileChanges = append(fileChanges, importAdder.Edits()...) } diff --git a/internal/ls/codeactions_fixmissingtypeannotation.go b/internal/ls/codeactions_fixmissingtypeannotation.go index b218d270771..ba724b15165 100644 --- a/internal/ls/codeactions_fixmissingtypeannotation.go +++ b/internal/ls/codeactions_fixmissingtypeannotation.go @@ -153,7 +153,7 @@ func getAllIsolatedDeclarationsCodeActions(ctx context.Context, fixContext *Code fixer.addSymbolToExistingImport(sym) } - changes := changeTracker.GetChanges() + changes, _ := changeTracker.GetChanges() fileChanges := changes[fixContext.SourceFile.FileName()] if len(fileChanges) == 0 { return nil, nil @@ -192,7 +192,7 @@ func tryCodeAction(ctx context.Context, fixContext *CodeFixContext, ch *checker. fixer.addSymbolToExistingImport(sym) } - changes := changeTracker.GetChanges() + changes, _ := changeTracker.GetChanges() fileChanges := changes[fixContext.SourceFile.FileName()] // Add import edits if import adder has fixes diff --git a/internal/ls/codeactions_importfixes.go b/internal/ls/codeactions_importfixes.go index 894510b8aca..237feb2afb3 100644 --- a/internal/ls/codeactions_importfixes.go +++ b/internal/ls/codeactions_importfixes.go @@ -72,7 +72,7 @@ func getImportCodeActions(ctx context.Context, fixContext *CodeFixContext) ([]*C var actions []*CodeAction for _, fixInfo := range info { - edits, description := fixInfo.fix.Edits( + edits, description, ok := fixInfo.fix.Edits( ctx, fixContext.SourceFile, fixContext.Program.Options(), @@ -81,12 +81,14 @@ func getImportCodeActions(ctx context.Context, fixContext *CodeFixContext) ([]*C fixContext.LS.UserPreferences(), ) - actions = append(actions, &CodeAction{ - Description: description, - Changes: edits, - FixID: importFixID, - FixAllDescription: diagnostics.Add_all_missing_imports.Localize(locale.FromContext(ctx)), - }) + if ok { + actions = append(actions, &CodeAction{ + Description: description, + Changes: edits, + FixID: importFixID, + FixAllDescription: diagnostics.Add_all_missing_imports.Localize(locale.FromContext(ctx)), + }) + } } return actions, nil } @@ -310,7 +312,10 @@ func getFixesInfoForNonUMDImport(ctx context.Context, fixContext *CodeFixContext var allInfo []*fixInfo // Compute usage position for JSDoc import type fixes - usagePosition := fixContext.LS.converters.PositionToLineAndCharacter(fixContext.SourceFile, core.TextPos(scanner.GetTokenPosOfNode(symbolToken, fixContext.SourceFile, false))) + usagePosition, fidelity := fixContext.LS.converters.ToLSPPosition(fixContext.SourceFile, core.TextPos(scanner.GetTokenPosOfNode(symbolToken, fixContext.SourceFile, false))) + if !fidelity.IsExact() { + return nil + } for _, sn := range symbolNames { // Type-only imports are handled by the promotion code path, not the auto-import path. diff --git a/internal/ls/codelens.go b/internal/ls/codelens.go index 1067e561daa..c8018da7991 100644 --- a/internal/ls/codelens.go +++ b/internal/ls/codelens.go @@ -33,11 +33,15 @@ func (l *LanguageService) ProvideCodeLenses(ctx context.Context, documentURI lsp lastSymbol = currentSymbol if userPrefs.ReferencesCodeLensEnabled.IsTrue() && isValidReferenceLensNode(node, userPrefs) { - result = append(result, l.newCodeLensForNode(documentURI, file, node, lsproto.CodeLensKindReferences)) + if codeLens := l.newCodeLensForNode(documentURI, file, node, lsproto.CodeLensKindReferences); codeLens != nil { + result = append(result, codeLens) + } } if userPrefs.ImplementationsCodeLensEnabled.IsTrue() && isValidImplementationsCodeLensNode(node, userPrefs) { - result = append(result, l.newCodeLensForNode(documentURI, file, node, lsproto.CodeLensKindImplementations)) + if codeLens := l.newCodeLensForNode(documentURI, file, node, lsproto.CodeLensKindImplementations); codeLens != nil { + result = append(result, codeLens) + } } } @@ -136,12 +140,13 @@ func (l *LanguageService) newCodeLensForNode(fileUri lsproto.DocumentUri, file * nodeForRange = nodeName } pos := scanner.SkipTrivia(file.Text(), nodeForRange.Pos()) + lspRange, fidelity := l.converters.ToLSPRange(file, core.NewTextRange(pos, node.End())) + if fidelity.IsNone() { + return nil + } return &lsproto.CodeLens{ - Range: lsproto.Range{ - Start: l.converters.PositionToLineAndCharacter(file, core.TextPos(pos)), - End: l.converters.PositionToLineAndCharacter(file, core.TextPos(node.End())), - }, + Range: lspRange, Data: &lsproto.CodeLensData{ Kind: kind, Uri: fileUri, diff --git a/internal/ls/completions.go b/internal/ls/completions.go index e6659cec1a2..4b014731122 100644 --- a/internal/ls/completions.go +++ b/internal/ls/completions.go @@ -25,6 +25,7 @@ import ( "github.com/microsoft/typescript-go/internal/nodebuilder" "github.com/microsoft/typescript-go/internal/printer" "github.com/microsoft/typescript-go/internal/scanner" + "github.com/microsoft/typescript-go/internal/spanmap" "github.com/microsoft/typescript-go/internal/stringutil" "github.com/microsoft/typescript-go/internal/tspath" ) @@ -37,13 +38,19 @@ func (l *LanguageService) ProvideCompletion( LSPPosition lsproto.Position, context *lsproto.CompletionContext, ) (lsproto.CompletionResponse, error) { - _, file := l.getProgramAndFile(documentURI) + program, file := l.getProgramAndFile(documentURI) var triggerCharacter *string if context != nil { triggerCharacter = context.TriggerCharacter } ctx = format.WithFormatCodeSettings(ctx, l.FormatOptions(), l.FormatOptions().NewLineCharacter) - position := int(l.converters.LineAndCharacterToPosition(file, LSPPosition)) + positions := l.converters.FromLSPPosition(file, LSPPosition, spanmap.PurposeSemantic) + if len(positions) == 0 || !positions[0].Fidelity.IsExact() { + // In a content-mapped file the cursor is outside a verbatim span, so any completion committed here + // could not be applied to the original text. Offer nothing rather than edits at a bogus location. + return lsproto.CompletionItemsOrListOrNull{}, nil + } + position := int(positions[0].Position) completionListInternal, err := l.getCompletionsAtPosition( ctx, file, @@ -55,9 +62,44 @@ func (l *LanguageService) ProvideCompletion( return lsproto.CompletionItemsOrListOrNull{}, err } completionList := ensureItemData(file.FileName(), position, completionListInternal.toLSP()) + if file.SpanMap() != nil { + l.filterContentMappedAutoImports(ctx, program, file, completionList) + } return lsproto.CompletionItemsOrListOrNull{List: completionList}, nil } +// filterContentMappedAutoImports eagerly resolves auto-import edits for a content-mapped file and drops any +// completion whose import edit cannot be placed entirely within verbatim spans (it would otherwise insert +// an import into generated code with no counterpart in the original file). Surviving auto-imports carry +// their additional edits directly so the client applies correct original-text positions on commit. +func (l *LanguageService) filterContentMappedAutoImports(ctx context.Context, program *compiler.Program, file *ast.SourceFile, list *lsproto.CompletionList) { + if list == nil { + return + } + filtered := list.Items[:0] + for _, item := range list.Items { + if item.Data == nil || item.Data.AutoImport == nil { + filtered = append(filtered, item) + continue + } + edits, description, ok := (&autoimport.Fix{AutoImportFix: item.Data.AutoImport}).Edits( + ctx, + file, + program.Options(), + l.FormatOptions(), + l.converters, + l.UserPreferences(), + ) + if !ok { + continue + } + item.AdditionalTextEdits = &edits + item.Detail = strPtrTo(description) + filtered = append(filtered, item) + } + list.Items = filtered +} + func (l *LanguageService) GetCompletionsAtPosition(ctx context.Context, file *ast.SourceFile, position int, triggerCharacter *string, includeSymbols bool) (*CompletionList, error) { return l.getCompletionsAtPosition(ctx, file, position, triggerCharacter, includeSymbols) } @@ -186,7 +228,7 @@ const ( CompletionKindString ) -var TriggerCharacters = []string{".", `"`, "'", "`", "/", "@", "<", "#", " ", "*"} +var CompletionTriggerCharacters = []string{".", `"`, "'", "`", "/", "@", "<", "#", " ", "*"} // All commit characters, valid when `isNewIdentifierLocation` is false. var allCommitCharacters = []string{".", ",", ";"} @@ -1145,9 +1187,15 @@ func (l *LanguageService) getCompletionData( // import { type | -> token text should be blank var lowerCaseTokenText string - usagePosition := l.createLspPosition(position, file) + usagePosition, fidelity := l.createLspPosition(position, file) + if !fidelity.IsExact() { + return nil + } if previousToken != nil && ast.IsIdentifier(previousToken) { - usagePosition = l.createLspPosition(scanner.GetTokenPosOfNode(previousToken, file, false /*includeJSDoc*/), file) + usagePosition, fidelity = l.createLspPosition(scanner.GetTokenPosOfNode(previousToken, file, false /*includeJSDoc*/), file) + if !fidelity.IsExact() { + return nil + } if !(previousToken == contextToken && importStatementCompletion != nil) { lowerCaseTokenText = strings.ToLower(previousToken.Text()) } @@ -2107,7 +2155,11 @@ func (l *LanguageService) createCompletionItem( } else { end = dot.End() } - replacementSpan = new(l.createLspRangeFromBounds(astnav.GetStartOfNode(dot, file, false /*includeJSDoc*/), end, file)) + lspRange, fidelity := l.createLspRangeFromBounds(astnav.GetStartOfNode(dot, file, false /*includeJSDoc*/), end, file) + if !fidelity.IsExact() { + return nil + } + replacementSpan = &lspRange } if data.jsxInitializer.isInitializer { @@ -2116,7 +2168,11 @@ func (l *LanguageService) createCompletionItem( } insertText = fmt.Sprintf("{%s}", insertText) if data.jsxInitializer.initializer != nil { - replacementSpan = new(l.createLspRangeFromNode(data.jsxInitializer.initializer, file)) + lspRange, fidelity := l.createLspRangeFromNode(data.jsxInitializer.initializer, file) + if !fidelity.IsExact() { + return nil + } + replacementSpan = &lspRange } } @@ -2143,11 +2199,15 @@ func (l *LanguageService) createCompletionItem( data.propertyAccessToConvert.Parent, data.propertyAccessToConvert.Expression(), ) - replacementSpan = new(l.createLspRangeFromBounds( + lspRange, fidelity := l.createLspRangeFromBounds( astnav.GetStartOfNode(wrapNode, file, false /*includeJSDoc*/), data.propertyAccessToConvert.End(), file, - )) + ) + if !fidelity.IsExact() { + return nil + } + replacementSpan = &lspRange } if originIsTypeOnlyAlias(origin) { @@ -3103,7 +3163,11 @@ func (l *LanguageService) getReplacementRangeForContextToken(file *ast.SourceFil case ast.KindStringLiteral, ast.KindNoSubstitutionTemplateLiteral: return l.createRangeFromStringLiteralLikeContent(file, contextToken, position) default: - return new(l.createLspRangeFromNode(contextToken, file)) + lspRange, fidelity := l.createLspRangeFromNode(contextToken, file) + if !fidelity.IsExact() { + return nil + } + return &lspRange } } @@ -3117,7 +3181,11 @@ func (l *LanguageService) createRangeFromStringLiteralLikeContent(file *ast.Sour } replacementEnd = min(position, node.End()) } - return new(l.createLspRangeFromBounds(nodeStart+1, replacementEnd, file)) + lspRange, fidelity := l.createLspRangeFromBounds(nodeStart+1, replacementEnd, file) + if !fidelity.IsExact() { + return nil + } + return &lspRange } func quotePropertyName(file *ast.SourceFile, preferences lsutil.UserPreferences, name string) string { @@ -3472,7 +3540,10 @@ func (l *LanguageService) getOptionalReplacementSpan(location *ast.Node, file *a // StringLiteralLike locations are handled separately in stringCompletions.ts if location != nil && (location.Kind == ast.KindIdentifier || location.Kind == ast.KindPrivateIdentifier) { start := astnav.GetStartOfNode(location, file, false /*includeJSDoc*/) - return new(l.createLspRangeFromBounds(start, location.End(), file)) + lspRange, fidelity := l.createLspRangeFromBounds(start, location.End(), file) + if fidelity.IsExact() { + return &lspRange + } } return nil } @@ -4273,9 +4344,13 @@ func (l *LanguageService) setItemDefaults( } if optionalReplacementSpan != nil { // Ported from vscode ts extension. + end, fidelity := l.createLspPosition(position, file) + if !fidelity.IsExact() { + return itemDefaults + } insertRange := lsproto.Range{ Start: optionalReplacementSpan.Start, - End: l.createLspPosition(position, file), + End: end, } if clientSupportsDefaultEditRange(ctx) { itemDefaults = core.OrElse(itemDefaults, &lsproto.CompletionItemDefaults{}) @@ -4379,7 +4454,10 @@ func (l *LanguageService) getJsxClosingTagCompletion( tagName := jsxClosingElement.Parent.AsJsxElement().OpeningElement.TagName() closingTag := scanner.GetTextOfNode(tagName) fullClosingTag := closingTag + core.IfElse(hasClosingAngleBracket, "", ">") - optionalReplacementSpan := new(l.createLspRangeFromNode(jsxClosingElement.TagName(), file)) + optionalReplacementSpan, fidelity := l.createLspRangeFromNode(jsxClosingElement.TagName(), file) + if !fidelity.IsExact() { + return nil + } defaultCommitCharacters := getDefaultCommitCharacters(false /*isNewIdentifierLocation*/) lspItem := l.createLSPCompletionItem( @@ -4413,7 +4491,7 @@ func (l *LanguageService) getJsxClosingTagCompletion( file, items, &defaultCommitCharacters, - optionalReplacementSpan, + &optionalReplacementSpan, ) return &CompletionList{ @@ -4933,7 +5011,10 @@ func (l *LanguageService) getCompletionItemDetails( } if data.AutoImport != nil { - edits, description := (&autoimport.Fix{AutoImportFix: data.AutoImport}).Edits(ctx, file, program.Options(), l.FormatOptions(), l.converters, l.UserPreferences()) + // Auto-imports in content-mapped files are evaluated eagerly so edits outside + // of verbatim spans can cause the completion item to be filtered out entirely. + // Only real files take this code path, so the final Edits() is guaranteed ok. + edits, description, _ := (&autoimport.Fix{AutoImportFix: data.AutoImport}).Edits(ctx, file, program.Options(), l.FormatOptions(), l.converters, l.UserPreferences()) item.AdditionalTextEdits = &edits item.Detail = strPtrTo(description) return item @@ -5216,7 +5297,11 @@ func (l *LanguageService) getSingleLineReplacementSpanForImportCompletionNode(no // Use token position (excluding JSDoc/trivia) instead of node.Pos() to avoid including JSDoc comments tokenPos := scanner.GetTokenPosOfNode(node, sourceFile, false /*includeJSDoc*/) if printer.GetLinesBetweenPositions(sourceFile, tokenPos, node.End()) == 0 { - return new(l.createLspRangeFromNode(node, sourceFile)) + lspRange, fidelity := l.createLspRangeFromNode(node, sourceFile) + if !fidelity.IsExact() { + return nil + } + return &lspRange } if node.Kind == ast.KindImportKeyword || node.Kind == ast.KindImportSpecifier { @@ -5252,7 +5337,11 @@ func (l *LanguageService) getSingleLineReplacementSpanForImportCompletionNode(no // assume that the "module specifier" is actually just another statement, and return // the single-line range of the import excluding that probable statement. if printer.GetLinesBetweenPositions(sourceFile, withoutModuleSpecifier.Pos(), withoutModuleSpecifier.End()) == 0 { - return new(l.createLspRangeFromBounds(withoutModuleSpecifier.Pos(), withoutModuleSpecifier.End(), sourceFile)) + lspRange, fidelity := l.createLspRangeFromBounds(withoutModuleSpecifier.Pos(), withoutModuleSpecifier.End(), sourceFile) + if !fidelity.IsExact() { + return nil + } + return &lspRange } return nil } diff --git a/internal/ls/definition.go b/internal/ls/definition.go index b2647222a08..f5ab88c63e0 100644 --- a/internal/ls/definition.go +++ b/internal/ls/definition.go @@ -8,10 +8,12 @@ import ( "github.com/microsoft/typescript-go/internal/astnav" "github.com/microsoft/typescript-go/internal/checker" "github.com/microsoft/typescript-go/internal/collections" + "github.com/microsoft/typescript-go/internal/compiler" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/ls/lsconv" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/scanner" + "github.com/microsoft/typescript-go/internal/spanmap" ) func (l *LanguageService) ProvideDefinition( @@ -34,17 +36,28 @@ func (l *LanguageService) provideDefinitionWorker( clientSupportsLink := caps.TextDocument.Definition.LinkSupport program, file := l.getProgramAndFile(documentURI) - pos := int(l.converters.LineAndCharacterToPosition(file, position)) + positions := l.converters.FromLSPPosition(file, position, spanmap.PurposeNavigation) + var results []lsproto.DefinitionResponse + for _, mapped := range positions { + if mapped.Fidelity.IsSingleSegment() { + results = append(results, l.provideDefinitionAtPosition(ctx, program, file, mapped.Position, clientSupportsLink)) + } + } + return combineDefinitionResponses(results, clientSupportsLink), nil +} + +func (l *LanguageService) provideDefinitionAtPosition(ctx context.Context, program *compiler.Program, file *ast.SourceFile, textPos core.TextPos, clientSupportsLink bool) lsproto.DefinitionResponse { + pos := int(textPos) node := astnav.GetTouchingPropertyName(file, pos) reference := getReferenceAtPosition(file, pos, program) if node.Kind == ast.KindSourceFile { - return lsproto.LocationOrLocationsOrDefinitionLinksOrNull{}, nil + return lsproto.LocationOrLocationsOrDefinitionLinksOrNull{} } - originSelectionRange := l.createLspRangeFromNode(node, file) + originSelectionRange, _ := l.createLspRangeFromNode(node, file) if reference != nil && reference.file != nil { - return l.createDefinitionLocations(originSelectionRange, clientSupportsLink, []*ast.Node{}, reference), nil + return l.createDefinitionLocations(originSelectionRange, clientSupportsLink, []*ast.Node{}, reference) } c, done := program.GetTypeCheckerForFile(ctx, file) @@ -52,26 +65,26 @@ func (l *LanguageService) provideDefinitionWorker( if node.Kind == ast.KindOverrideKeyword { if sym := getSymbolForOverriddenMember(c, node); sym != nil { - return l.createDefinitionLocations(originSelectionRange, clientSupportsLink, sym.Declarations, nil /*reference*/), nil + return l.createDefinitionLocations(originSelectionRange, clientSupportsLink, sym.Declarations, nil /*reference*/) } } if ast.IsJumpStatementTarget(node) { if label := getTargetLabel(node.Parent, node.Text()); label != nil { - return l.createDefinitionLocations(originSelectionRange, clientSupportsLink, []*ast.Node{label}, nil /*reference*/), nil + return l.createDefinitionLocations(originSelectionRange, clientSupportsLink, []*ast.Node{label}, nil /*reference*/) } } if node.Kind == ast.KindCaseKeyword || node.Kind == ast.KindDefaultKeyword && ast.IsDefaultClause(node.Parent) { if stmt := ast.FindAncestor(node.Parent, ast.IsSwitchStatement); stmt != nil { file := ast.GetSourceFileOfNode(stmt) - return l.createLocationFromFileAndRange(file, scanner.GetRangeOfTokenAtPosition(file, stmt.Pos())), nil + return l.createLocationFromFileAndRange(file, scanner.GetRangeOfTokenAtPosition(file, stmt.Pos())) } } if node.Kind == ast.KindReturnKeyword || node.Kind == ast.KindYieldKeyword || node.Kind == ast.KindAwaitKeyword { if fn := ast.FindAncestor(node, ast.IsFunctionLikeDeclaration); fn != nil { - return l.createDefinitionLocations(originSelectionRange, clientSupportsLink, []*ast.Node{fn}, nil /*reference*/), nil + return l.createDefinitionLocations(originSelectionRange, clientSupportsLink, []*ast.Node{fn}, nil /*reference*/) } } @@ -94,7 +107,7 @@ func (l *LanguageService) provideDefinitionWorker( } declarations = append(declarations, calledDeclaration) } - return l.createDefinitionLocations(originSelectionRange, clientSupportsLink, declarations, reference), nil + return l.createDefinitionLocations(originSelectionRange, clientSupportsLink, declarations, reference) } func (l *LanguageService) ProvideTypeDefinition( @@ -106,11 +119,23 @@ func (l *LanguageService) ProvideTypeDefinition( clientSupportsLink := caps.TextDocument.TypeDefinition.LinkSupport program, file := l.getProgramAndFile(documentURI) - node := astnav.GetTouchingPropertyName(file, int(l.converters.LineAndCharacterToPosition(file, position))) + positions := l.converters.FromLSPPosition(file, position, spanmap.PurposeNavigation) + var results []lsproto.TypeDefinitionResponse + for _, mapped := range positions { + if mapped.Fidelity.IsSingleSegment() { + results = append(results, l.provideTypeDefinitionAtPosition(ctx, program, file, mapped.Position, clientSupportsLink)) + } + } + return combineDefinitionResponses(results, clientSupportsLink), nil +} + +func (l *LanguageService) provideTypeDefinitionAtPosition(ctx context.Context, program *compiler.Program, file *ast.SourceFile, textPos core.TextPos, clientSupportsLink bool) lsproto.TypeDefinitionResponse { + pos := int(textPos) + node := astnav.GetTouchingPropertyName(file, pos) if node.Kind == ast.KindSourceFile { - return lsproto.LocationOrLocationsOrDefinitionLinksOrNull{}, nil + return lsproto.LocationOrLocationsOrDefinitionLinksOrNull{} } - originSelectionRange := l.createLspRangeFromNode(node, file) + originSelectionRange, _ := l.createLspRangeFromNode(node, file) c, done := program.GetTypeCheckerForFile(ctx, file) defer done() @@ -124,14 +149,47 @@ func (l *LanguageService) ProvideTypeDefinition( declarations = core.Concatenate(getDeclarationsFromType(typeArgument), declarations) } if len(declarations) != 0 { - return l.createDefinitionLocations(originSelectionRange, clientSupportsLink, declarations, nil /*reference*/), nil + return l.createDefinitionLocations(originSelectionRange, clientSupportsLink, declarations, nil /*reference*/) } if symbol.Flags&ast.SymbolFlagsValue == 0 && symbol.Flags&ast.SymbolFlagsType != 0 { - return l.createDefinitionLocations(originSelectionRange, clientSupportsLink, symbol.Declarations, nil /*reference*/), nil + return l.createDefinitionLocations(originSelectionRange, clientSupportsLink, symbol.Declarations, nil /*reference*/) } } - return lsproto.LocationOrLocationsOrDefinitionLinksOrNull{}, nil + return lsproto.LocationOrLocationsOrDefinitionLinksOrNull{} +} + +func combineDefinitionResponses(results []lsproto.DefinitionResponse, links bool) lsproto.DefinitionResponse { + var locations []lsproto.Location + var definitionLinks []*lsproto.LocationLink + var seen collections.Set[lsproto.Location] + for _, result := range results { + if result.DefinitionLinks != nil { + for _, link := range *result.DefinitionLinks { + location := lsproto.Location{Uri: link.TargetUri, Range: link.TargetSelectionRange} + if seen.AddIfAbsent(location) { + definitionLinks = append(definitionLinks, link) + locations = append(locations, location) + } + } + } + if result.Location != nil && seen.AddIfAbsent(*result.Location) { + locations = append(locations, *result.Location) + definitionLinks = append(definitionLinks, &lsproto.LocationLink{TargetUri: result.Location.Uri, TargetRange: result.Location.Range, TargetSelectionRange: result.Location.Range}) + } + if result.Locations != nil { + for _, location := range *result.Locations { + if seen.AddIfAbsent(location) { + locations = append(locations, location) + definitionLinks = append(definitionLinks, &lsproto.LocationLink{TargetUri: location.Uri, TargetRange: location.Range, TargetSelectionRange: location.Range}) + } + } + } + } + if links { + return lsproto.LocationOrLocationsOrDefinitionLinksOrNull{DefinitionLinks: &definitionLinks} + } + return lsproto.LocationOrLocationsOrDefinitionLinksOrNull{Locations: &locations} } func getDeclarationNameForKeyword(node *ast.Node) *ast.Node { @@ -160,6 +218,8 @@ func (l *LanguageService) createDefinitionLocations( ) lsproto.DefinitionResponse { locations := make([]*lsproto.LocationLink, 0) locationRanges := collections.Set[fileRange]{} + concreteTargets := collections.Set[lsproto.DocumentUri]{} + var fileFallbacks []*lsproto.LocationLink if reference != nil { targetRange := lsproto.Range{ @@ -193,8 +253,26 @@ func (l *LanguageService) createDefinitionLocations( if locationRanges.AddIfAbsent(fileRange{fileName, nameRange}) { contextNode := core.OrElse(getContextNode(decl), decl) contextRange := core.OrElse(toContextRange(&nameRange, file, contextNode), &nameRange) - targetSelectionLoc := l.getMappedLocation(fileName, nameRange) - targetLoc := l.getMappedLocation(fileName, *contextRange) + if !nameRange.ContainedBy(*contextRange) { + enclosingRange := core.NewTextRange(min(nameRange.Pos(), contextRange.Pos()), max(nameRange.End(), contextRange.End())) + contextRange = &enclosingRange + } + targetSelectionLoc, selectionFidelity := l.getMappedLocation(fileName, nameRange) + if !selectionFidelity.IsSingleSegment() { + zeroRange := lsproto.Range{} + fileFallbacks = append(fileFallbacks, &lsproto.LocationLink{ + OriginSelectionRange: &originSelectionRange, + TargetSelectionRange: zeroRange, + TargetUri: targetSelectionLoc.Uri, + TargetRange: zeroRange, + }) + continue + } + targetLoc, contextFidelity := l.getMappedLocation(fileName, *contextRange) + if contextFidelity.IsNone() || targetLoc.Uri != targetSelectionLoc.Uri || !lspRangeContains(targetLoc.Range, targetSelectionLoc.Range) { + targetLoc = targetSelectionLoc + } + concreteTargets.Add(targetSelectionLoc.Uri) locations = append(locations, &lsproto.LocationLink{ OriginSelectionRange: &originSelectionRange, TargetSelectionRange: targetSelectionLoc.Range, @@ -203,6 +281,12 @@ func (l *LanguageService) createDefinitionLocations( }) } } + for _, fallback := range fileFallbacks { + if !concreteTargets.Has(fallback.TargetUri) { + concreteTargets.Add(fallback.TargetUri) + locations = append(locations, fallback) + } + } if clientSupportsLink { return lsproto.LocationOrLocationsOrDefinitionLinksOrNull{DefinitionLinks: &locations} @@ -210,6 +294,11 @@ func (l *LanguageService) createDefinitionLocations( return createLocationsFromLinks(locations) } +func lspRangeContains(outer, inner lsproto.Range) bool { + return lsproto.ComparePositions(outer.Start, inner.Start) <= 0 && + lsproto.ComparePositions(inner.End, outer.End) <= 0 +} + func createLocationsFromLinks(links []*lsproto.LocationLink) lsproto.DefinitionResponse { locations := core.Map(links, func(link *lsproto.LocationLink) lsproto.Location { return lsproto.Location{ @@ -221,7 +310,10 @@ func createLocationsFromLinks(links []*lsproto.LocationLink) lsproto.DefinitionR } func (l *LanguageService) createLocationFromFileAndRange(file *ast.SourceFile, textRange core.TextRange) lsproto.DefinitionResponse { - mappedLocation := l.getMappedLocation(file.FileName(), textRange) + mappedLocation, fidelity := l.getMappedLocation(file.FileName(), textRange) + if fidelity.IsNone() { + mappedLocation.Range = lsproto.Range{} + } return lsproto.LocationOrLocationsOrDefinitionLinksOrNull{ Location: &mappedLocation, } diff --git a/internal/ls/diagnostics.go b/internal/ls/diagnostics.go index 27c35e49c6d..87cc1829a90 100644 --- a/internal/ls/diagnostics.go +++ b/internal/ls/diagnostics.go @@ -4,9 +4,13 @@ import ( "context" "github.com/microsoft/typescript-go/internal/ast" + "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/compiler" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/diagnostics" "github.com/microsoft/typescript-go/internal/ls/lsconv" "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/spanmap" ) // getAllDiagnostics collects all diagnostics for a file: syntactic, semantic, @@ -44,15 +48,70 @@ func (l *LanguageService) ProvideDiagnostics(ctx context.Context, uri lsproto.Do } func (l *LanguageService) toLSPDiagnostics(ctx context.Context, diagnostics ...[]*ast.Diagnostic) []*lsproto.Diagnostic { + reportStyleChecksAsWarnings := l.UserPreferences().ReportStyleChecksAsWarnings.IsTrue() size := 0 for _, diagSlice := range diagnostics { size += len(diagSlice) } lspDiagnostics := make([]*lsproto.Diagnostic, 0, size) + // Compiler diagnostics located entirely in a content-mapped file's synthesized code have no location + // in the original file. Collect them per file and surface them through a single aggregate at the top + // of the file (with the real messages as related information) rather than dropping them or scattering + // them at position 0. + var synthesizedByFile collections.OrderedMap[*ast.SourceFile, []*ast.Diagnostic] for _, diagSlice := range diagnostics { for _, diag := range diagSlice { - lspDiagnostics = append(lspDiagnostics, lsconv.DiagnosticToLSPPull(ctx, l.converters, diag, l.UserPreferences().ReportStyleChecksAsWarnings.IsTrue())) + if isSynthesizedContentMappedDiagnostic(diag) { + synthesizedByFile.Set(diag.File(), append(synthesizedByFile.GetOrZero(diag.File()), diag)) + continue + } + lspDiagnostics = append(lspDiagnostics, lsconv.DiagnosticToLSPPull(ctx, l.converters, diag, reportStyleChecksAsWarnings)) } } + for file, diags := range synthesizedByFile.Entries() { + aggregate := aggregateSynthesizedDiagnostics(file, diags) + lspDiagnostics = append(lspDiagnostics, lsconv.DiagnosticToLSPPull(ctx, l.converters, aggregate, reportStyleChecksAsWarnings)) + } return lspDiagnostics } + +// isSynthesizedContentMappedDiagnostic reports whether diag is a compiler diagnostic on a content-mapped +// file whose location lies entirely in synthesized (generated) code with no counterpart in the original +// file, and so has no meaningful position to report against the original file. +func isSynthesizedContentMappedDiagnostic(diag *ast.Diagnostic) bool { + file := diag.File() + if file == nil || file.SpanMap() == nil || diag.Source() != "" { + return false + } + _, fidelity := file.SpanMap().GeneratedToOriginalSpan(diag.Loc()) + return fidelity == spanmap.FidelityNone +} + +// aggregateSynthesizedDiagnostics builds a single diagnostic at the top of a content-mapped file standing +// in for compiler diagnostics located in synthesized code with no original location. The originals are +// attached as related information so their messages are surfaced rather than silently dropped. (A later +// change will point the related locations at a read-only view of the file's generated TypeScript.) +func aggregateSynthesizedDiagnostics(file *ast.SourceFile, diags []*ast.Diagnostic) *ast.Diagnostic { + aggregate := ast.NewDiagnostic( + file, + core.NewTextRange(0, 0), + diagnostics.Code_generated_by_the_content_mapper_0_has_problems_with_no_corresponding_location_in_this_file, + file.ContentMapper(), + ) + aggregate.SetRelatedInfo(diags) + aggregate.SetCategory(worstCategory(diags)) + return aggregate +} + +func worstCategory(diags []*ast.Diagnostic) diagnostics.Category { + worst := diags[0].Category() + for _, diag := range diags { + switch diag.Category() { + case diagnostics.CategoryError: + return diagnostics.CategoryError + case diagnostics.CategoryWarning: + worst = diagnostics.CategoryWarning + } + } + return worst +} diff --git a/internal/ls/documenthighlights.go b/internal/ls/documenthighlights.go index 7494ee707ea..aa20745ce9c 100644 --- a/internal/ls/documenthighlights.go +++ b/internal/ls/documenthighlights.go @@ -10,6 +10,7 @@ import ( "github.com/microsoft/typescript-go/internal/ls/lsconv" "github.com/microsoft/typescript-go/internal/ls/lsutil" "github.com/microsoft/typescript-go/internal/scanner" + "github.com/microsoft/typescript-go/internal/spanmap" "github.com/microsoft/typescript-go/internal/stringutil" "github.com/microsoft/typescript-go/internal/lsp/lsproto" @@ -38,7 +39,17 @@ func (l *LanguageService) ProvideMultiDocumentHighlights(ctx context.Context, do func (l *LanguageService) provideDocumentHighlightsWorker(ctx context.Context, documentUri lsproto.DocumentUri, documentPosition lsproto.Position, filesToSearch []lsproto.DocumentUri) (lsproto.MultiDocumentHighlightsOrNull, error) { program, sourceFile := l.getProgramAndFile(documentUri) - position := int(l.converters.LineAndCharacterToPosition(sourceFile, documentPosition)) + positions := l.converters.FromLSPPosition(sourceFile, documentPosition, spanmap.PurposeNavigation) + var results []lsproto.MultiDocumentHighlightsOrNull + for _, mapped := range positions { + if mapped.Fidelity.IsSingleSegment() { + results = append(results, l.provideDocumentHighlightsAtPosition(ctx, documentUri, int(mapped.Position), program, sourceFile, filesToSearch)) + } + } + return combineMultiDocumentHighlights(results), nil +} + +func (l *LanguageService) provideDocumentHighlightsAtPosition(ctx context.Context, documentUri lsproto.DocumentUri, position int, program *compiler.Program, sourceFile *ast.SourceFile, filesToSearch []lsproto.DocumentUri) lsproto.MultiDocumentHighlightsOrNull { node := astnav.GetTouchingPropertyName(sourceFile, position) // Cheap JSX check before resolving files to search. @@ -51,23 +62,21 @@ func (l *LanguageService) provideDocumentHighlightsWorker(ctx context.Context, d var highlights []*lsproto.DocumentHighlight kind := lsproto.DocumentHighlightKindRead if openingElement != nil { - highlights = append(highlights, &lsproto.DocumentHighlight{ - Range: l.createLspRangeFromNode(openingElement, sourceFile), - Kind: &kind, - }) + if lspRange, fidelity := l.createLspRangeFromNode(openingElement, sourceFile); !fidelity.IsNone() { + highlights = append(highlights, &lsproto.DocumentHighlight{Range: lspRange, Kind: &kind}) + } } if closingElement != nil { - highlights = append(highlights, &lsproto.DocumentHighlight{ - Range: l.createLspRangeFromNode(closingElement, sourceFile), - Kind: &kind, - }) + if lspRange, fidelity := l.createLspRangeFromNode(closingElement, sourceFile); !fidelity.IsNone() { + highlights = append(highlights, &lsproto.DocumentHighlight{Range: lspRange, Kind: &kind}) + } } multiHighlights := []*lsproto.MultiDocumentHighlight{ {Uri: documentUri, Highlights: highlights}, } return lsproto.MultiDocumentHighlightsOrNull{ MultiDocumentHighlights: &multiHighlights, - }, nil + } } // Resolve the source files to search, deduplicating by file name. @@ -96,7 +105,34 @@ func (l *LanguageService) provideDocumentHighlightsWorker(ctx context.Context, d } } } - return lsproto.MultiDocumentHighlightsOrNull{MultiDocumentHighlights: &multiHighlights}, nil + return lsproto.MultiDocumentHighlightsOrNull{MultiDocumentHighlights: &multiHighlights} +} + +func combineMultiDocumentHighlights(results []lsproto.MultiDocumentHighlightsOrNull) lsproto.MultiDocumentHighlightsOrNull { + byURI := make(map[lsproto.DocumentUri]*lsproto.MultiDocumentHighlight) + seen := make(map[lsproto.DocumentUri]collections.Set[lsproto.Range]) + var combinedDocuments []*lsproto.MultiDocumentHighlight + for _, result := range results { + if result.MultiDocumentHighlights == nil { + continue + } + for _, document := range *result.MultiDocumentHighlights { + combinedDocument := byURI[document.Uri] + if combinedDocument == nil { + combinedDocument = &lsproto.MultiDocumentHighlight{Uri: document.Uri} + byURI[document.Uri] = combinedDocument + combinedDocuments = append(combinedDocuments, combinedDocument) + } + ranges := seen[document.Uri] + for _, highlight := range document.Highlights { + if ranges.AddIfAbsent(highlight.Range) { + combinedDocument.Highlights = append(combinedDocument.Highlights, highlight) + } + } + seen[document.Uri] = ranges + } + } + return lsproto.MultiDocumentHighlightsOrNull{MultiDocumentHighlights: &combinedDocuments} } func (l *LanguageService) getSemanticDocumentHighlights(ctx context.Context, position int, node *ast.Node, program *compiler.Program, sourceFiles []*ast.SourceFile) []*lsproto.MultiDocumentHighlight { @@ -218,10 +254,9 @@ func (l *LanguageService) highlightSpans(nodes []*ast.Node, sourceFile *ast.Sour kind := lsproto.DocumentHighlightKindRead for _, node := range nodes { if node != nil { - highlights = append(highlights, &lsproto.DocumentHighlight{ - Range: l.createLspRangeFromNode(node, sourceFile), - Kind: &kind, - }) + if lspRange, fidelity := l.createLspRangeFromNode(node, sourceFile); !fidelity.IsNone() { + highlights = append(highlights, &lsproto.DocumentHighlight{Range: lspRange, Kind: &kind}) + } } } return highlights @@ -276,19 +311,18 @@ func (l *LanguageService) getIfElseOccurrences(ifStatement *ast.IfStatement, sou } } if shouldCombine { - highlights = append(highlights, &lsproto.DocumentHighlight{ - Range: l.createLspRangeFromBounds(scanner.SkipTrivia(sourceFile.Text(), elseKeyword.Pos()), ifKeyword.End(), sourceFile), - Kind: &kind, - }) + lspRange, fidelity := l.createLspRangeFromBounds(scanner.SkipTrivia(sourceFile.Text(), elseKeyword.Pos()), ifKeyword.End(), sourceFile) + if !fidelity.IsNone() { + highlights = append(highlights, &lsproto.DocumentHighlight{Range: lspRange, Kind: &kind}) + } i++ // skip the next keyword continue } } // Ordinary case: just highlight the keyword. - highlights = append(highlights, &lsproto.DocumentHighlight{ - Range: l.createLspRangeFromNode(keywords[i], sourceFile), - Kind: &kind, - }) + if lspRange, fidelity := l.createLspRangeFromNode(keywords[i], sourceFile); !fidelity.IsNone() { + highlights = append(highlights, &lsproto.DocumentHighlight{Range: lspRange, Kind: &kind}) + } } return highlights } diff --git a/internal/ls/file_rename.go b/internal/ls/file_rename.go index cd36bb074fb..079e4dcc38b 100644 --- a/internal/ls/file_rename.go +++ b/internal/ls/file_rename.go @@ -8,6 +8,7 @@ import ( "github.com/microsoft/typescript-go/internal/checker" "github.com/microsoft/typescript-go/internal/compiler" "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/debug" "github.com/microsoft/typescript-go/internal/ls/change" "github.com/microsoft/typescript-go/internal/ls/lsconv" "github.com/microsoft/typescript-go/internal/lsp/lsproto" @@ -59,7 +60,8 @@ func (l *LanguageService) GetEditsForFileRename(ctx context.Context, oldURI lspr } } - for fileName, edits := range changeTracker.GetChanges() { + changes, _ := changeTracker.GetChanges() + for fileName, edits := range changes { uri := lsconv.FileNameToDocumentURI(fileName) lspEdits := make([]lsproto.TextEditOrAnnotatedTextEditOrSnippetTextEdit, 0, len(edits)) for _, edit := range edits { @@ -180,10 +182,10 @@ func tryUpdateConfigString(configFile *ast.SourceFile, configDir string, element return false } - changeTracker.ReplaceRangeWithText(configFile, lsproto.Range{ - Start: converters.PositionToLineAndCharacter(configFile, core.TextPos(scanner.GetTokenPosOfNode(element, configFile, false)+1)), - End: converters.PositionToLineAndCharacter(configFile, core.TextPos(element.End()-1)), - }, relativePathFromDirectory(configDir, updated, useCaseSensitiveFileNames)) + textRange := core.NewTextRange(scanner.GetTokenPosOfNode(element, configFile, false)+1, element.End()-1) + lspRange, fidelity := converters.ToLSPRange(configFile, textRange) + debug.Assert(fidelity.IsExact(), "config files are not content-mapped") + changeTracker.ReplaceRangeWithText(configFile, lspRange, relativePathFromDirectory(configDir, updated, useCaseSensitiveFileNames)) return true } @@ -216,14 +218,14 @@ func (l *LanguageService) updateImportsForFileRename(program *compiler.Program, } updated := l.updateRelativePath(oldToNew, oldFileName, newImportFromPath, ref.FileName) if updated != ref.FileName { - changeTracker.ReplaceRangeWithText(sourceFile, l.converters.ToLSPRange(sourceFile, ref.TextRange), updated) + changeTracker.ReplaceTextRangeWithText(sourceFile, ref.TextRange, updated) } } for _, importStringLiteral := range sourceFile.Imports() { updated := l.getUpdatedImportSpecifier(program, checker, sourceFile, importStringLiteral, oldToNew, newImportFromPath, fileMoved, moduleSpecifierPreferences) if updated != "" && updated != importStringLiteral.Text() { - changeTracker.ReplaceRangeWithText(sourceFile, l.converters.ToLSPRange(sourceFile, createStringTextRange(sourceFile, importStringLiteral)), updated) + changeTracker.ReplaceTextRangeWithText(sourceFile, createStringTextRange(sourceFile, importStringLiteral), updated) } } } diff --git a/internal/ls/findallreferences.go b/internal/ls/findallreferences.go index 28043f859dd..3e970a0b8e7 100644 --- a/internal/ls/findallreferences.go +++ b/internal/ls/findallreferences.go @@ -20,6 +20,7 @@ import ( "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/printer" "github.com/microsoft/typescript-go/internal/scanner" + "github.com/microsoft/typescript-go/internal/spanmap" "github.com/microsoft/typescript-go/internal/stringutil" "github.com/microsoft/typescript-go/internal/tspath" @@ -102,12 +103,13 @@ const ( ) type ReferenceEntry struct { - kind entryKind - node *ast.Node - context *ast.Node // !!! ContextWithStartAndEndNode, optional - fileName string - textRange *core.TextRange - lspRange *lsproto.Location + kind entryKind + node *ast.Node + context *ast.Node // !!! ContextWithStartAndEndNode, optional + fileName string + textRange *core.TextRange + lspRange *lsproto.Location + unmappable bool } // Node returns the AST node for this reference entry. @@ -172,8 +174,9 @@ func (l *LanguageService) getFileNameOfEntry(entry *ReferenceEntry) lsproto.Docu return l.resolveEntry(entry).lspRange.Uri } -func (l *LanguageService) getLocationOfEntry(entry *ReferenceEntry) lsproto.Location { - return *l.resolveEntry(entry).lspRange +func (l *LanguageService) getLocationOfEntry(entry *ReferenceEntry) (lsproto.Location, bool) { + resolved := l.resolveEntry(entry) + return *resolved.lspRange, !resolved.unmappable } func (l *LanguageService) resolveEntry(entry *ReferenceEntry) *ReferenceEntry { @@ -184,8 +187,9 @@ func (l *LanguageService) resolveEntry(entry *ReferenceEntry) *ReferenceEntry { entry.fileName = sourceFile.FileName() } if entry.lspRange == nil { - location := l.getMappedLocation(entry.fileName, *entry.textRange) + location, fidelity := l.getMappedLocation(entry.fileName, *entry.textRange) entry.lspRange = &location + entry.unmappable = !fidelity.IsSingleSegment() } return entry } @@ -317,15 +321,6 @@ func getContextNode(node *ast.Node) *ast.Node { } } -// utils -func (l *LanguageService) getLspRangeOfNode(node *ast.Node, sourceFile *ast.SourceFile, endNode *ast.Node) lsproto.Range { - if sourceFile == nil { - sourceFile = ast.GetSourceFileOfNode(node) - } - textRange := getRangeOfNode(node, sourceFile, endNode) - return l.createLspRangeFromBounds(textRange.Pos(), textRange.End(), sourceFile) -} - func getRangeOfNode(node *ast.Node, sourceFile *ast.SourceFile, endNode *ast.Node) core.TextRange { if sourceFile == nil { sourceFile = ast.GetSourceFileOfNode(node) @@ -508,17 +503,25 @@ func (l *LanguageService) getNonLocalDefinition(ctx context.Context, entry *Symb if isDefinitionVisible(emitResolver, d) { file, startPos := getFileAndStartPosFromDeclaration(d) fileName := file.FileName() + lspPosition, fidelity := l.converters.ToLSPPosition(file, startPos) + if fidelity.IsNone() { + continue + } return &nonLocalDefinition{ position: position{ uri: lsconv.FileNameToDocumentURI(fileName), - pos: l.converters.PositionToLineAndCharacter(file, startPos), + pos: lspPosition, }, GetSourcePosition: sync.OnceValue(func() lsproto.HasTextDocumentPosition { mapped := l.tryGetSourcePosition(fileName, startPos) if mapped != nil { + mappedPosition, mappedFidelity := l.converters.ToLSPPosition(l.getScript(mapped.FileName), core.TextPos(mapped.Pos)) + if mappedFidelity.IsNone() { + return nil + } return &position{ uri: lsconv.FileNameToDocumentURI(mapped.FileName), - pos: l.converters.PositionToLineAndCharacter(l.getScript(mapped.FileName), core.TextPos(mapped.Pos)), + pos: mappedPosition, } } return nil @@ -526,9 +529,13 @@ func (l *LanguageService) getNonLocalDefinition(ctx context.Context, entry *Symb GetGeneratedPosition: sync.OnceValue(func() lsproto.HasTextDocumentPosition { mapped := l.tryGetGeneratedPosition(fileName, startPos) if mapped != nil { + mappedPosition, mappedFidelity := l.converters.ToLSPPosition(l.getScript(mapped.FileName), core.TextPos(mapped.Pos)) + if mappedFidelity.IsNone() { + return nil + } return &position{ uri: lsconv.FileNameToDocumentURI(mapped.FileName), - pos: l.converters.PositionToLineAndCharacter(l.getScript(mapped.FileName), core.TextPos(mapped.Pos)), + pos: mappedPosition, } } return nil @@ -598,16 +605,16 @@ func (l *LanguageService) forEachOriginalDefinitionLocation( // Map to ts position mapped := l.tryGetSourcePosition(file.FileName(), startPos) if mapped != nil { - cb( - lsconv.FileNameToDocumentURI(mapped.FileName), - l.converters.PositionToLineAndCharacter(l.getScript(mapped.FileName), core.TextPos(mapped.Pos)), - ) + lspPosition, fidelity := l.converters.ToLSPPosition(l.getScript(mapped.FileName), core.TextPos(mapped.Pos)) + if !fidelity.IsNone() { + cb(lsconv.FileNameToDocumentURI(mapped.FileName), lspPosition) + } } } else if program.IsSourceFromProjectReference(l.toPath(fileName)) { - cb( - lsconv.FileNameToDocumentURI(fileName), - l.converters.PositionToLineAndCharacter(file, startPos), - ) + lspPosition, fidelity := l.converters.ToLSPPosition(file, startPos) + if !fidelity.IsNone() { + cb(lsconv.FileNameToDocumentURI(fileName), lspPosition) + } } } } @@ -628,8 +635,31 @@ type SymbolAndEntriesData struct { func (l *LanguageService) provideSymbolsAndEntries(ctx context.Context, uri lsproto.DocumentUri, documentPosition lsproto.Position, isRename bool, implementations bool) (SymbolAndEntriesData, bool) { // `findReferencedSymbols` except only computes the information needed to return reference locations program, sourceFile := l.getProgramAndFile(uri) - position := int(l.converters.LineAndCharacterToPosition(sourceFile, documentPosition)) + positions := l.converters.FromLSPPosition(sourceFile, documentPosition, spanmap.PurposeNavigation) + if len(positions) == 0 { + return SymbolAndEntriesData{}, false + } + var combined SymbolAndEntriesData + var ok bool + for _, mapped := range positions { + if !mapped.Fidelity.IsSingleSegment() { + continue + } + data, found := l.provideSymbolsAndEntriesAtPosition(ctx, program, sourceFile, int(mapped.Position), isRename, implementations) + if !found { + continue + } + if !ok { + combined.OriginalNode = data.OriginalNode + combined.Position = data.Position + ok = true + } + combined.SymbolsAndEntries = append(combined.SymbolsAndEntries, data.SymbolsAndEntries...) + } + return combined, ok +} +func (l *LanguageService) provideSymbolsAndEntriesAtPosition(ctx context.Context, program *compiler.Program, sourceFile *ast.SourceFile, position int, isRename bool, implementations bool) (SymbolAndEntriesData, bool) { node := astnav.GetTouchingPropertyName(sourceFile, position) if isRename { // Adjust modifier/keyword nodes to the declaration name, matching Strada's findRenameLocations. @@ -721,9 +751,12 @@ func (l *LanguageService) ProvideVSReferences(ctx context.Context, params *lspro func (l *LanguageService) symbolAndEntriesToReferences(ctx context.Context, params *lsproto.ReferenceParams, data SymbolAndEntriesData, options symbolEntryTransformOptions) (lsproto.ReferencesResponse, error) { // `findReferencedSymbols` except only computes the information needed to return reference locations - locations := core.FlatMap(data.SymbolsAndEntries, func(s *SymbolAndEntries) []lsproto.Location { - return l.convertSymbolAndEntriesToLocations(s, params.Context.IncludeDeclaration) - }) + var locations []lsproto.Location + var seenLocations collections.Set[lsproto.Location] + for _, symbol := range data.SymbolsAndEntries { + symbolLocations := l.convertSymbolAndEntriesToLocations(symbol, params.Context.IncludeDeclaration) + locations = combineLocationArray(locations, &symbolLocations, &seenLocations) + } return lsproto.LocationsOrNull{Locations: &locations}, nil } @@ -766,7 +799,10 @@ func (l *LanguageService) symbolAndEntriesToVSReferences(ctx context.Context, pa continue } - refLocation := l.getLocationOfEntry(ref) + refLocation, ok := l.getLocationOfEntry(ref) + if !ok { + continue + } // Determine read/write kind kind := lsproto.VSReferenceKindRead @@ -816,7 +852,10 @@ func (l *LanguageService) definitionToReferencedSymbolDefinitionInfo(ctx context node = originalNode } - loc := l.getLocationOfEntry(&ReferenceEntry{kind: entryKindNode, node: node}) + loc, ok := l.getLocationOfEntry(&ReferenceEntry{kind: entryKindNode, node: node}) + if !ok { + return nil + } return &referencedSymbolDefinitionInfo{ node: node, location: loc, @@ -828,7 +867,10 @@ func (l *LanguageService) definitionToReferencedSymbolDefinitionInfo(ctx context if node == nil { return nil } - loc := l.getLocationOfEntry(&ReferenceEntry{kind: entryKindNode, node: node}) + loc, ok := l.getLocationOfEntry(&ReferenceEntry{kind: entryKindNode, node: node}) + if !ok { + return nil + } return &referencedSymbolDefinitionInfo{ node: node, location: loc, @@ -843,7 +885,10 @@ func (l *LanguageService) definitionToReferencedSymbolDefinitionInfo(ctx context return nil } name := scanner.TokenToString(node.Kind) - loc := l.getLocationOfEntry(&ReferenceEntry{kind: entryKindNode, node: node}) + loc, ok := l.getLocationOfEntry(&ReferenceEntry{kind: entryKindNode, node: node}) + if !ok { + return nil + } return &referencedSymbolDefinitionInfo{ node: node, location: loc, @@ -862,7 +907,10 @@ func (l *LanguageService) definitionToReferencedSymbolDefinitionInfo(ctx context return nil } element := l.getDefinitionKindAndDisplayParts(ctx, symbol, node, vsCapability) - loc := l.getLocationOfEntry(&ReferenceEntry{kind: entryKindNode, node: node}) + loc, ok := l.getLocationOfEntry(&ReferenceEntry{kind: entryKindNode, node: node}) + if !ok { + return nil + } return &referencedSymbolDefinitionInfo{ node: node, location: loc, @@ -874,7 +922,10 @@ func (l *LanguageService) definitionToReferencedSymbolDefinitionInfo(ctx context if node == nil { return nil } - loc := l.getLocationOfEntry(&ReferenceEntry{kind: entryKindNode, node: node}) + loc, ok := l.getLocationOfEntry(&ReferenceEntry{kind: entryKindNode, node: node}) + if !ok { + return nil + } return &referencedSymbolDefinitionInfo{ node: node, location: loc, @@ -888,7 +939,10 @@ func (l *LanguageService) definitionToReferencedSymbolDefinitionInfo(ctx context return nil } node := def.tripleSlashFileRef.file.AsNode() - loc := l.getLocationOfEntry(&ReferenceEntry{kind: entryKindNode, node: node}) + loc, ok := l.getLocationOfEntry(&ReferenceEntry{kind: entryKindNode, node: node}) + if !ok { + return nil + } return &referencedSymbolDefinitionInfo{ node: node, location: loc, @@ -955,7 +1009,7 @@ func (l *LanguageService) symbolAndEntriesToImplementations(ctx context.Context, links := l.convertEntriesToLocationLinks(entries) return lsproto.LocationOrLocationsOrDefinitionLinksOrNull{DefinitionLinks: &links}, nil } - locations := l.convertEntriesToLocations(entries) + locations := l.convertEntriesToLocations(entries, nil /*definitionSymbol*/) return lsproto.LocationOrLocationsOrDefinitionLinksOrNull{Locations: &locations}, nil } @@ -970,7 +1024,11 @@ func (l *LanguageService) convertSymbolAndEntriesToLocations(s *SymbolAndEntries }) } - return l.convertEntriesToLocations(references) + var definitionSymbol *ast.Symbol + if includeDeclarations && s.definition != nil { + definitionSymbol = s.definition.symbol + } + return l.convertEntriesToLocations(references, definitionSymbol) } func isDeclarationOfSymbol(node *ast.Node, target *ast.Symbol) bool { @@ -997,20 +1055,39 @@ func isDeclarationOfSymbol(node *ast.Node, target *ast.Symbol) bool { }) } -func (l *LanguageService) convertEntriesToLocations(entries []*ReferenceEntry) []lsproto.Location { - locations := make([]lsproto.Location, len(entries)) - for i, entry := range entries { - locations[i] = l.getLocationOfEntry(entry) +func (l *LanguageService) convertEntriesToLocations(entries []*ReferenceEntry, definitionSymbol *ast.Symbol) []lsproto.Location { + // A synthesized declaration has no source span, but it still represents the symbol's definition in + // that file. Mirror go-to-definition's file-level fallback while continuing to omit synthesized uses. + concreteFiles := collections.Set[lsproto.DocumentUri]{} + for _, entry := range entries { + if location, ok := l.getLocationOfEntry(entry); ok { + concreteFiles.Add(location.Uri) + } + } + + locations := make([]lsproto.Location, 0, len(entries)) + for _, entry := range entries { + location, ok := l.getLocationOfEntry(entry) + if ok { + locations = append(locations, location) + } else if isDeclarationOfSymbol(entry.node, definitionSymbol) && !concreteFiles.Has(location.Uri) { + location.Range = lsproto.Range{} + locations = append(locations, location) + concreteFiles.Add(location.Uri) + } } return locations } func (l *LanguageService) convertEntriesToLocationLinks(entries []*ReferenceEntry) []*lsproto.LocationLink { - links := make([]*lsproto.LocationLink, len(entries)) - for i, entry := range entries { + links := make([]*lsproto.LocationLink, 0, len(entries)) + for _, entry := range entries { // Get the selection range (the actual reference) - loc := l.getLocationOfEntry(entry) + loc, ok := l.getLocationOfEntry(entry) + if !ok { + continue + } targetSelectionRange := loc.Range targetRange := targetSelectionRange @@ -1019,16 +1096,18 @@ func (l *LanguageService) convertEntriesToLocationLinks(entries []*ReferenceEntr // Get the context range (broader scope including declaration context) contextTextRange := toContextRange(entry.textRange, l.program.GetSourceFile(entry.fileName), entry.context) if contextTextRange != nil { - contextLocation := l.getMappedLocation(entry.fileName, *contextTextRange) - targetRange = contextLocation.Range + contextLocation, fidelity := l.getMappedLocation(entry.fileName, *contextTextRange) + if !fidelity.IsNone() && contextLocation.Uri == loc.Uri { + targetRange = contextLocation.Range + } } } - links[i] = &lsproto.LocationLink{ + links = append(links, &lsproto.LocationLink{ TargetUri: lsconv.FileNameToDocumentURI(entry.fileName), TargetRange: targetRange, TargetSelectionRange: targetSelectionRange, - } + }) } return links } diff --git a/internal/ls/folding.go b/internal/ls/folding.go index fbd41a6513f..a7a1a5af77d 100644 --- a/internal/ls/folding.go +++ b/internal/ls/folding.go @@ -13,6 +13,7 @@ import ( "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/printer" "github.com/microsoft/typescript-go/internal/scanner" + "github.com/microsoft/typescript-go/internal/spanmap" ) func (l *LanguageService) ProvideFoldingRange(ctx context.Context, documentURI lsproto.DocumentUri) (lsproto.FoldingRangeResponse, error) { @@ -40,12 +41,13 @@ func (l *LanguageService) adjustFoldingEnd(ranges []*lsproto.FoldingRange, sourc result := make([]*lsproto.FoldingRange, 0, len(ranges)) for _, r := range ranges { if r.EndCharacter != nil && *r.EndCharacter > 0 { - endOffset := int(l.converters.LineAndCharacterToPosition(sourceFile, lsproto.Position{ + positions := l.converters.FromLSPPosition(sourceFile, lsproto.Position{ Line: r.EndLine, Character: *r.EndCharacter, - })) - if endOffset > 0 && endOffset <= len(sourceText) { - foldEndChar := sourceText[endOffset-1] + }, spanmap.PurposeAll) + if len(positions) == 1 && !positions[0].Fidelity.IsNone() && positions[0].Position > 0 && int(positions[0].Position) <= len(sourceText) { + endOffset := positions[0].Position + foldEndChar := sourceText[int(endOffset)-1] if foldEndChar == '}' || foldEndChar == ']' || foldEndChar == ')' || foldEndChar == '`' || foldEndChar == '>' { if r.EndLine > r.StartLine { r.EndLine-- @@ -111,7 +113,10 @@ func (l *LanguageService) addRegionOutliningSpans(ctx context.Context, sourceFil } if result.isStart { - commentStart := l.createLspPosition(strings.Index(sourceFile.Text()[currentLineStart:lineEnd], "//")+int(currentLineStart), sourceFile) + commentStart, fidelity := l.createLspPosition(strings.Index(sourceFile.Text()[currentLineStart:lineEnd], "//")+int(currentLineStart), sourceFile) + if fidelity.IsNone() { + continue + } foldingRangeKindRegion := lsproto.FoldingRangeKindRegion region := &lsproto.FoldingRange{ StartLine: commentStart.Line, @@ -133,7 +138,10 @@ func (l *LanguageService) addRegionOutliningSpans(ctx context.Context, sourceFil if len(regions) > 0 { region := regions[len(regions)-1] regions = regions[:len(regions)-1] - endingPosition := l.createLspPosition(lineEnd, sourceFile) + endingPosition, fidelity := l.createLspPosition(lineEnd, sourceFile) + if fidelity.IsNone() { + continue + } region.EndLine = endingPosition.Line region.EndCharacter = &endingPosition.Character out = append(out, region) @@ -365,7 +373,11 @@ func getOutliningSpanForNode(ctx context.Context, n *ast.Node, sourceFile *ast.S default: // Block was a standalone block. In this case we want to only collapse // the span of the block, independent of any parent span. - return createFoldingRange(ctx, l.createLspRangeFromNode(n, sourceFile), "", "") + textRange, fidelity := l.createLspRangeFromNode(n, sourceFile) + if fidelity.IsNone() { + return nil + } + return createFoldingRange(ctx, textRange, "", "") } case ast.KindModuleBlock: return spanForNode(ctx, n, ast.KindOpenBraceToken, true /*useFullStart*/, sourceFile, l) @@ -425,7 +437,10 @@ func spanForParenthesizedExpression(ctx context.Context, node *ast.Node, sourceF if printer.PositionsAreOnSameLine(start, node.End(), sourceFile) { return nil } - textRange := l.createLspRangeFromBounds(start, node.End(), sourceFile) + textRange, fidelity := l.createLspRangeFromBounds(start, node.End(), sourceFile) + if fidelity.IsNone() { + return nil + } return createFoldingRange(ctx, textRange, "", "") } @@ -447,7 +462,10 @@ func spanForArrowFunction(ctx context.Context, node *ast.Node, sourceFile *ast.S if ast.IsBlock(arrowFunctionNode.Body) || ast.IsParenthesizedExpression(arrowFunctionNode.Body) || printer.PositionsAreOnSameLine(arrowFunctionNode.Body.Pos(), arrowFunctionNode.Body.End(), sourceFile) { return nil } - textRange := l.createLspRangeFromBounds(arrowFunctionNode.Body.Pos(), arrowFunctionNode.Body.End(), sourceFile) + textRange, fidelity := l.createLspRangeFromBounds(arrowFunctionNode.Body.Pos(), arrowFunctionNode.Body.End(), sourceFile) + if fidelity.IsNone() { + return nil + } return createFoldingRange(ctx, textRange, "", "") } @@ -461,14 +479,20 @@ func spanForTemplateLiteral(ctx context.Context, node *ast.Node, sourceFile *ast func spanForJSXElement(ctx context.Context, node *ast.Node, sourceFile *ast.SourceFile, l *LanguageService) *lsproto.FoldingRange { if node.Kind == ast.KindJsxElement { jsxElement := node.AsJsxElement() - textRange := l.createLspRangeFromBounds(astnav.GetStartOfNode(jsxElement.OpeningElement, sourceFile, false /*includeJSDoc*/), jsxElement.ClosingElement.End(), sourceFile) + textRange, fidelity := l.createLspRangeFromBounds(astnav.GetStartOfNode(jsxElement.OpeningElement, sourceFile, false /*includeJSDoc*/), jsxElement.ClosingElement.End(), sourceFile) + if fidelity.IsNone() { + return nil + } tagName := scanner.GetTextOfNode(jsxElement.OpeningElement.TagName()) bannerText := "<" + tagName + ">..." return createFoldingRange(ctx, textRange, "", bannerText) } // JsxFragment jsxFragment := node.AsJsxFragment() - textRange := l.createLspRangeFromBounds(astnav.GetStartOfNode(jsxFragment.OpeningFragment, sourceFile, false /*includeJSDoc*/), jsxFragment.ClosingFragment.End(), sourceFile) + textRange, fidelity := l.createLspRangeFromBounds(astnav.GetStartOfNode(jsxFragment.OpeningFragment, sourceFile, false /*includeJSDoc*/), jsxFragment.ClosingFragment.End(), sourceFile) + if fidelity.IsNone() { + return nil + } return createFoldingRange(ctx, textRange, "", "<>...") } @@ -487,7 +511,11 @@ func spanForJSXAttributes(ctx context.Context, node *ast.Node, sourceFile *ast.S func spanForNodeArray(ctx context.Context, statements *ast.NodeList, sourceFile *ast.SourceFile, l *LanguageService) *lsproto.FoldingRange { if statements != nil && len(statements.Nodes) != 0 { - return createFoldingRange(ctx, l.createLspRangeFromBounds(statements.Pos(), statements.End(), sourceFile), "", "") + textRange, fidelity := l.createLspRangeFromBounds(statements.Pos(), statements.End(), sourceFile) + if fidelity.IsNone() { + return nil + } + return createFoldingRange(ctx, textRange, "", "") } return nil } @@ -507,10 +535,14 @@ func spanForNode(ctx context.Context, node *ast.Node, open ast.Kind, useFullStar func rangeBetweenTokens(ctx context.Context, openToken *ast.Node, closeToken *ast.Node, sourceFile *ast.SourceFile, useFullStart bool, l *LanguageService) *lsproto.FoldingRange { var textRange lsproto.Range + var fidelity spanmap.Fidelity if useFullStart { - textRange = l.createLspRangeFromBounds(openToken.Pos(), closeToken.End(), sourceFile) + textRange, fidelity = l.createLspRangeFromBounds(openToken.Pos(), closeToken.End(), sourceFile) } else { - textRange = l.createLspRangeFromBounds(astnav.GetStartOfNode(openToken, sourceFile, false /*includeJSDoc*/), closeToken.End(), sourceFile) + textRange, fidelity = l.createLspRangeFromBounds(astnav.GetStartOfNode(openToken, sourceFile, false /*includeJSDoc*/), closeToken.End(), sourceFile) + } + if fidelity.IsNone() { + return nil } return createFoldingRange(ctx, textRange, "", "") } @@ -538,7 +570,11 @@ func createFoldingRange(ctx context.Context, textRange lsproto.Range, foldingRan } func createFoldingRangeFromBounds(ctx context.Context, pos int, end int, foldingRangeKind lsproto.FoldingRangeKind, sourceFile *ast.SourceFile, l *LanguageService) *lsproto.FoldingRange { - return createFoldingRange(ctx, l.createLspRangeFromBounds(pos, end, sourceFile), foldingRangeKind, "") + textRange, fidelity := l.createLspRangeFromBounds(pos, end, sourceFile) + if fidelity.IsNone() { + return nil + } + return createFoldingRange(ctx, textRange, foldingRangeKind, "") } func functionSpan(ctx context.Context, node *ast.Node, body *ast.Node, sourceFile *ast.SourceFile, l *LanguageService) *lsproto.FoldingRange { diff --git a/internal/ls/format.go b/internal/ls/format.go index cb8159f2b72..93dad94726a 100644 --- a/internal/ls/format.go +++ b/internal/ls/format.go @@ -11,14 +11,19 @@ import ( "github.com/microsoft/typescript-go/internal/ls/lsutil" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/scanner" + "github.com/microsoft/typescript-go/internal/spanmap" ) func (l *LanguageService) toLSProtoTextEdits(file *ast.SourceFile, changes []core.TextChange) []*lsproto.TextEdit { result := make([]*lsproto.TextEdit, 0, len(changes)) for _, c := range changes { + lspRange, fidelity := l.converters.ToLSPRange(file, core.NewTextRange(c.Pos(), c.End())) + if !fidelity.IsExact() { + return nil + } result = append(result, &lsproto.TextEdit{ NewText: c.NewText, - Range: l.createLspRangeFromBounds(c.Pos(), c.End(), file), + Range: lspRange, }) } return result @@ -53,11 +58,15 @@ func (l *LanguageService) ProvideFormatDocumentRange( } _, file := l.getProgramAndFile(documentURI) formatOpts := lsutil.FromLSFormatOptions(l.FormatOptions(), options) + ranges := l.converters.FromLSPRange(file, r, spanmap.PurposeAll) + if len(ranges) != 1 || !ranges[0].Fidelity.IsExact() { + return lsproto.TextEditsOrNull{}, nil + } edits := l.toLSProtoTextEdits(file, l.getFormattingEditsForRange( ctx, file, formatOpts, - l.converters.FromLSPRange(file, r), + ranges[0].Span, )) return lsproto.TextEditsOrNull{TextEdits: &edits}, nil } @@ -74,11 +83,15 @@ func (l *LanguageService) ProvideFormatDocumentOnType( } _, file := l.getProgramAndFile(documentURI) formatOpts := lsutil.FromLSFormatOptions(l.FormatOptions(), options) + positions := l.converters.FromLSPPosition(file, position, spanmap.PurposeAll) + if len(positions) != 1 || !positions[0].Fidelity.IsExact() { + return lsproto.TextEditsOrNull{}, nil + } edits := l.toLSProtoTextEdits(file, l.getFormattingEditsAfterKeystroke( ctx, file, formatOpts, - int(l.converters.LineAndCharacterToPosition(file, position)), + int(positions[0].Position), character, )) return lsproto.TextEditsOrNull{TextEdits: &edits}, nil diff --git a/internal/ls/hover.go b/internal/ls/hover.go index 22d2e93982f..2193401d0bc 100644 --- a/internal/ls/hover.go +++ b/internal/ls/hover.go @@ -16,6 +16,7 @@ import ( "github.com/microsoft/typescript-go/internal/nodebuilder" "github.com/microsoft/typescript-go/internal/printer" "github.com/microsoft/typescript-go/internal/scanner" + "github.com/microsoft/typescript-go/internal/spanmap" ) const ( @@ -33,7 +34,11 @@ func (l *LanguageService) ProvideHover(ctx context.Context, params *lsproto.Hove } program, file := l.getProgramAndFile(params.TextDocument.Uri) - position := int(l.converters.LineAndCharacterToPosition(file, params.Position)) + positions := l.converters.FromLSPPosition(file, params.Position, spanmap.PurposeSemantic) + if len(positions) == 0 || !positions[0].Fidelity.IsSingleSegment() { + return lsproto.HoverOrNull{}, nil + } + position := int(positions[0].Position) node := astnav.GetTouchingPropertyName(file, position) if ast.IsSourceFile(node) || ast.IsPropertyAccessOrQualifiedName(node) && isInComment(file, position, node) == nil { // Avoid giving quickInfo for the sourceFile as a whole or inside the comment of a/**/.b @@ -61,7 +66,9 @@ func (l *LanguageService) ProvideHover(ctx context.Context, params *lsproto.Hove if quickInfo == "" { return lsproto.HoverOrNull{}, nil } - hoverRange := l.getLspRangeOfNode(rangeNode, nil, nil) + rangeFile := ast.GetSourceFileOfNode(rangeNode) + textRange := getRangeOfNode(rangeNode, rangeFile, nil /*endNode*/) + hoverRange, hoverFidelity := l.createLspRangeFromBounds(textRange.Pos(), textRange.End(), rangeFile) var content string if contentFormat == lsproto.MarkupKindMarkdown { @@ -77,7 +84,9 @@ func (l *LanguageService) ProvideHover(ctx context.Context, params *lsproto.Hove Value: content, }, }, - Range: &hoverRange, + } + if hoverFidelity.IsSingleSegment() { + hover.Range = &hoverRange } if caps.Experimental.HoverVerbosityLevel { @@ -1113,13 +1122,13 @@ func (l *LanguageService) writeNameLink(b *strings.Builder, c *checker.Checker, declaration := declarations[0] file := ast.GetSourceFileOfNode(declaration) node := core.OrElse(ast.GetNameOfDeclaration(declaration), declaration) - loc := l.getMappedLocation(file.FileName(), createRangeFromNode(node, file)) + loc, fidelity := l.getMappedLocation(file.FileName(), createRangeFromNode(node, file)) prefixLen := core.IfElse(strings.HasPrefix(text, "()"), 2, 0) linkText := trimCommentPrefix(text[prefixLen:]) if linkText == "" { linkText = getEntityNameString(name) + text[:prefixLen] } - if isMarkdown { + if isMarkdown && fidelity.IsSingleSegment() { linkUri := fmt.Sprintf("%s#%d,%d-%d,%d", loc.Uri, loc.Range.Start.Line+1, loc.Range.Start.Character+1, loc.Range.End.Line+1, loc.Range.End.Character+1) writeMarkdownLink(b, linkText, linkUri, quote) } else { diff --git a/internal/ls/inlay_hints.go b/internal/ls/inlay_hints.go index a1923826cb5..de38f30dfab 100644 --- a/internal/ls/inlay_hints.go +++ b/internal/ls/inlay_hints.go @@ -18,6 +18,7 @@ import ( "github.com/microsoft/typescript-go/internal/nodebuilder" "github.com/microsoft/typescript-go/internal/printer" "github.com/microsoft/typescript-go/internal/scanner" + "github.com/microsoft/typescript-go/internal/spanmap" "github.com/microsoft/typescript-go/internal/stringutil" ) @@ -36,17 +37,21 @@ func (l *LanguageService) ProvideInlayHint( checker, done := program.GetTypeCheckerForFile(ctx, file) defer done() - inlayHintState := &inlayHintState{ - ctx: ctx, - span: l.converters.FromLSPRange(file, params.Range), - preferences: inlayHintPreferences, - quotePreference: quotePreference, - file: file, - checker: checker, - converters: l.converters, - } - inlayHintState.visit(file.AsNode()) - return lsproto.InlayHintsOrNull{InlayHints: &inlayHintState.result}, nil + var result []*lsproto.InlayHint + for _, mapped := range l.converters.FromLSPRange(file, params.Range, spanmap.PurposeSemantic) { + inlayHintState := &inlayHintState{ + ctx: ctx, + span: mapped.Span, + preferences: inlayHintPreferences, + quotePreference: quotePreference, + file: file, + checker: checker, + converters: l.converters, + } + inlayHintState.visit(file.AsNode()) + result = append(result, inlayHintState.result...) + } + return lsproto.InlayHintsOrNull{InlayHints: &result}, nil } type inlayHintState struct { @@ -333,6 +338,10 @@ func (s *inlayHintState) typePredicateToInlayHintParts(typePredicate *checker.Ty } func (s *inlayHintState) addTypeHints(hint lsproto.StringOrInlayHintLabelParts, position int) { + lspPosition, fidelity := s.converters.ToLSPPosition(s.file, core.TextPos(position)) + if fidelity.IsNone() { + return + } if hint.String != nil { hint.String = new(": " + *hint.String) } else { @@ -340,23 +349,31 @@ func (s *inlayHintState) addTypeHints(hint lsproto.StringOrInlayHintLabelParts, } s.result = append(s.result, &lsproto.InlayHint{ Label: hint, - Position: s.converters.PositionToLineAndCharacter(s.file, core.TextPos(position)), + Position: lspPosition, Kind: new(lsproto.InlayHintKindType), PaddingLeft: new(true), }) } func (s *inlayHintState) addEnumMemberValueHints(text string, position int) { + lspPosition, fidelity := s.converters.ToLSPPosition(s.file, core.TextPos(position)) + if fidelity.IsNone() { + return + } s.result = append(s.result, &lsproto.InlayHint{ Label: lsproto.StringOrInlayHintLabelParts{ String: new("= " + text), }, - Position: s.converters.PositionToLineAndCharacter(s.file, core.TextPos(position)), + Position: lspPosition, PaddingLeft: new(true), }) } func (s *inlayHintState) addParameterHints(text string, parameter *ast.IdentifierNode, position int, isFirstVariadicArgument bool) { + lspPosition, fidelity := s.converters.ToLSPPosition(s.file, core.TextPos(position)) + if fidelity.IsNone() { + return + } hintText := core.IfElse(isFirstVariadicArgument, "...", "") + text displayParts := []*lsproto.InlayHintLabelPart{ s.getNodeDisplayPart(hintText, parameter), @@ -368,7 +385,7 @@ func (s *inlayHintState) addParameterHints(text string, parameter *ast.Identifie s.result = append(s.result, &lsproto.InlayHint{ Label: labelParts, - Position: s.converters.PositionToLineAndCharacter(s.file, core.TextPos(position)), + Position: lspPosition, Kind: new(lsproto.InlayHintKindParameter), PaddingRight: new(true), }) @@ -769,13 +786,17 @@ func (s *inlayHintState) getNodeDisplayPart(text string, node *ast.Node) *lsprot file := ast.GetSourceFileOfNode(node) pos := astnav.GetStartOfNode(node, file, false /*includeJSDoc*/) end := node.End() - return &lsproto.InlayHintLabelPart{ - Value: text, - Location: &lsproto.Location{ + part := &lsproto.InlayHintLabelPart{Value: text} + // The location is an optional go-to target for the name. Only attach it when the name maps back to a + // single concrete span in the original text; an approximate or synthesized mapping would point the + // user somewhere wrong, so it is better to omit the target than to fabricate one. + if lspRange, fidelity := s.converters.ToLSPRange(file, core.NewTextRange(pos, end)); fidelity.IsSingleSegment() { + part.Location = &lsproto.Location{ Uri: lsconv.FileNameToDocumentURI(file.FileName()), - Range: s.converters.ToLSPRange(file, core.NewTextRange(pos, end)), - }, + Range: lspRange, + } } + return part } func (s *inlayHintState) getLiteralText(node *ast.LiteralLikeNode) string { diff --git a/internal/ls/jsdoc_snippet.go b/internal/ls/jsdoc_snippet.go index 159bb14a7d6..6510edb5df9 100644 --- a/internal/ls/jsdoc_snippet.go +++ b/internal/ls/jsdoc_snippet.go @@ -100,7 +100,10 @@ func (l *LanguageService) getJSDocSnippetCompletionRange(ctx context.Context, fi end += suffixEnd } - replacementRange := l.createLspRangeFromBounds(start, end, file) + replacementRange, fidelity := l.createLspRangeFromBounds(start, end, file) + if !fidelity.IsExact() { + return nil + } if clientSupportsItemInsertReplace(ctx) { return &lsproto.TextEditOrInsertReplaceEdit{ InsertReplaceEdit: &lsproto.InsertReplaceEdit{ diff --git a/internal/ls/linkedediting.go b/internal/ls/linkedediting.go index 8368317c73c..627307e197d 100644 --- a/internal/ls/linkedediting.go +++ b/internal/ls/linkedediting.go @@ -9,6 +9,7 @@ import ( "github.com/microsoft/typescript-go/internal/debug" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/scanner" + "github.com/microsoft/typescript-go/internal/spanmap" ) // allow the client to match more than valid tag names. This allows linked editing when typing is in progress or tag name is incomplete @@ -16,7 +17,11 @@ var jsxTagWordPattern = new("[a-zA-Z0-9:\\-\\._$]*") func (l *LanguageService) ProvideLinkedEditingRange(ctx context.Context, params *lsproto.LinkedEditingRangeParams) (lsproto.LinkedEditingRangeResponse, error) { _, sourceFile := l.getProgramAndFile(params.TextDocument.Uri) - position := l.converters.LineAndCharacterToPosition(sourceFile, params.Position) + positions := l.converters.FromLSPPosition(sourceFile, params.Position, spanmap.PurposeAll) + if len(positions) != 1 || !positions[0].Fidelity.IsExact() { + return lsproto.LinkedEditingRangeResponse{}, nil + } + position := positions[0].Position token := astnav.FindPrecedingToken(sourceFile, int(position)) if token == nil || token.Parent.Kind == ast.KindSourceFile { @@ -39,8 +44,11 @@ func (l *LanguageService) ProvideLinkedEditingRange(ctx context.Context, params return lsproto.LinkedEditingRangeResponse{}, nil } - openLineChar := l.converters.PositionToLineAndCharacter(sourceFile, openPos) - closeLineChar := l.converters.PositionToLineAndCharacter(sourceFile, closePos) + openLineChar, openFidelity := l.converters.ToLSPPosition(sourceFile, openPos) + closeLineChar, closeFidelity := l.converters.ToLSPPosition(sourceFile, closePos) + if !openFidelity.IsExact() || !closeFidelity.IsExact() { + return lsproto.LinkedEditingRangeResponse{}, nil + } return lsproto.LinkedEditingRangeResponse{ LinkedEditingRanges: &lsproto.LinkedEditingRanges{ Ranges: []lsproto.Range{ @@ -88,17 +96,17 @@ func (l *LanguageService) ProvideLinkedEditingRange(ctx context.Context, params return lsproto.LinkedEditingRangeResponse{}, nil } + openRange, openFidelity := l.converters.ToLSPRange(sourceFile, core.NewTextRange(openTagNameStart, openTagNameEnd)) + closeRange, closeFidelity := l.converters.ToLSPRange(sourceFile, core.NewTextRange(closeTagNameStart, closeTagNameEnd)) + if !openFidelity.IsExact() || !closeFidelity.IsExact() { + return lsproto.LinkedEditingRangeResponse{}, nil + } + return lsproto.LinkedEditingRangeResponse{ LinkedEditingRanges: &lsproto.LinkedEditingRanges{ Ranges: []lsproto.Range{ - { - Start: l.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(openTagNameStart)), - End: l.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(openTagNameEnd)), - }, - { - Start: l.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(closeTagNameStart)), - End: l.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(closeTagNameEnd)), - }, + openRange, + closeRange, }, WordPattern: jsxTagWordPattern, }, diff --git a/internal/ls/lsconv/converters.go b/internal/ls/lsconv/converters.go index 0ff39bea16a..0bc4d1a934a 100644 --- a/internal/ls/lsconv/converters.go +++ b/internal/ls/lsconv/converters.go @@ -13,10 +13,12 @@ import ( "github.com/microsoft/typescript-go/internal/bundled" "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/debug" "github.com/microsoft/typescript-go/internal/diagnostics" "github.com/microsoft/typescript-go/internal/diagnosticwriter" "github.com/microsoft/typescript-go/internal/locale" "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/spanmap" "github.com/microsoft/typescript-go/internal/tspath" ) @@ -25,9 +27,15 @@ type Converters struct { positionEncoding lsproto.PositionEncodingKind } +// Script is a source text the converters operate over. For a content-mapped file, Text() is the content +// mapper's transformed output and SpanMap() returns the map from that output back to the original text +// (OriginalText()); output ranges are then automatically converted to original coordinates (see +// ToLSPRange). For an ordinary file SpanMap() is nil and OriginalText() equals Text(). type Script interface { FileName() string Text() string + SpanMap() *spanmap.SpanMap + OriginalText() string } func NewConverters(positionEncoding lsproto.PositionEncodingKind, getLineMap func(fileName string) *LSPLineMap) *Converters { @@ -37,32 +45,85 @@ func NewConverters(positionEncoding lsproto.PositionEncodingKind, getLineMap fun } } -func (c *Converters) ToLSPRange(script Script, textRange core.TextRange) lsproto.Range { +// The To*/From* conversions map between an editor's coordinates and the coordinates the language service +// operates over. A content-mapped Script (SpanMap() != nil) is mapped through its span map; any other +// Script passes through unchanged. + +func (c *Converters) ToLSPRange(script Script, textRange core.TextRange) (lsproto.Range, spanmap.Fidelity) { + script, textRange, fidelity := mapOutputToOriginal(script, textRange) return lsproto.Range{ - Start: c.PositionToLineAndCharacter(script, core.TextPos(textRange.Pos())), - End: c.PositionToLineAndCharacter(script, core.TextPos(textRange.End())), - } + Start: c.positionToLineAndCharacter(script, core.TextPos(textRange.Pos())), + End: c.positionToLineAndCharacter(script, core.TextPos(textRange.End())), + }, fidelity +} + +// ToLSPPosition is the single-position analog of ToLSPRange. +func (c *Converters) ToLSPPosition(script Script, position core.TextPos) (lsproto.Position, spanmap.Fidelity) { + script, position, fidelity := mapOutputPositionToOriginal(script, position) + return c.positionToLineAndCharacter(script, position), fidelity +} + +func (c *Converters) ToLSPLocation(script Script, rng core.TextRange) (lsproto.Location, spanmap.Fidelity) { + lspRange, fidelity := c.ToLSPRange(script, rng) + return lsproto.Location{ + Uri: FileNameToDocumentURI(script.FileName()), + Range: lspRange, + }, fidelity } -func (c *Converters) FromLSPRange(script Script, textRange lsproto.Range) core.TextRange { - return core.NewTextRange( - int(c.LineAndCharacterToPosition(script, textRange.Start)), - int(c.LineAndCharacterToPosition(script, textRange.End)), +// FromLSPRange converts an incoming LSP range to the offset range the language service operates over, +// mapping a content-mapped file's original text forward into its transformed text; it is the input analog +// of ToLSPRange. +func (c *Converters) FromLSPRange(script Script, textRange lsproto.Range, purpose spanmap.Purpose) []spanmap.MappedSpan { + spans := script.SpanMap() + if spans == nil { + return []spanmap.MappedSpan{{ + Span: core.NewTextRange( + int(c.lineAndCharacterToPosition(script, textRange.Start)), + int(c.lineAndCharacterToPosition(script, textRange.End)), + ), + Fidelity: spanmap.FidelityExact, + }} + } + // A content-mapped script's line map is its original text's, so convert against that text and then map + // the resulting original range forward into the transformed text. + original := originalTextScript{fileName: script.FileName(), text: script.OriginalText()} + origRange := core.NewTextRange( + int(c.lineAndCharacterToPosition(original, textRange.Start)), + int(c.lineAndCharacterToPosition(original, textRange.End)), ) + return spans.OriginalToGeneratedSpans(origRange, purpose) } -func (c *Converters) FromLSPTextChange(script Script, change *lsproto.TextDocumentContentChangePartial) core.TextChange { - return core.TextChange{ - TextRange: c.FromLSPRange(script, change.Range), - NewText: change.Text, +// FromLSPPosition maps one incoming LSP position to every generated projection supporting purpose. +func (c *Converters) FromLSPPosition(script Script, position lsproto.Position, purpose spanmap.Purpose) []spanmap.MappedPosition { + spans := script.SpanMap() + if spans == nil { + return []spanmap.MappedPosition{{Position: c.lineAndCharacterToPosition(script, position), Fidelity: spanmap.FidelityExact}} } + original := originalTextScript{fileName: script.FileName(), text: script.OriginalText()} + origOffset := c.lineAndCharacterToPosition(original, position) + return spans.OriginalToGeneratedPositions(origOffset, purpose) } -func (c *Converters) ToLSPLocation(script Script, rng core.TextRange) lsproto.Location { - return lsproto.Location{ - Uri: FileNameToDocumentURI(script.FileName()), - Range: c.ToLSPRange(script, rng), +// mapOutputToOriginal maps a range in a content mapper's transformed output back to its original text, +// returning a Script over that original text and the mapping fidelity. Scripts that carry no span map are +// returned unchanged with FidelityExact. +func mapOutputToOriginal(script Script, textRange core.TextRange) (Script, core.TextRange, spanmap.Fidelity) { + if script.SpanMap() == nil { + return script, textRange, spanmap.FidelityExact } + mapped, fidelity := script.SpanMap().GeneratedToOriginalSpan(textRange) + return originalTextScript{fileName: script.FileName(), text: script.OriginalText()}, mapped, fidelity +} + +// mapOutputPositionToOriginal is the single-position analog of mapOutputToOriginal. +func mapOutputPositionToOriginal(script Script, position core.TextPos) (Script, core.TextPos, spanmap.Fidelity) { + if script.SpanMap() == nil { + return script, position, spanmap.FidelityExact + } + mapped, fidelity := script.SpanMap().GeneratedToOriginalPosition(position) + return originalTextScript{fileName: script.FileName(), text: script.OriginalText()}, mapped, fidelity } func LanguageKindToScriptKind(languageID lsproto.LanguageKind) core.ScriptKind { @@ -141,8 +202,9 @@ func FileNameToDocumentURI(fileName string) lsproto.DocumentUri { return lsproto.DocumentUri("file://" + volume + strings.Join(parts, "/")) } -func (c *Converters) LineAndCharacterToPosition(script Script, lineAndCharacter lsproto.Position) core.TextPos { +func (c *Converters) lineAndCharacterToPosition(script Script, lineAndCharacter lsproto.Position) core.TextPos { // UTF-8/16 0-indexed line and character to UTF-8 offset + debug.Assert(script.SpanMap() == nil, "raw coordinate conversion requires a non-content-mapped script") lineMap := c.getLineMap(script.FileName()) @@ -191,8 +253,9 @@ func (c *Converters) LineAndCharacterToPosition(script Script, lineAndCharacter return core.TextPos(pos) } -func (c *Converters) PositionToLineAndCharacter(script Script, position core.TextPos) lsproto.Position { +func (c *Converters) positionToLineAndCharacter(script Script, position core.TextPos) lsproto.Position { // UTF-8 offset to UTF-8/16 0-indexed line and character + debug.Assert(script.SpanMap() == nil, "raw coordinate conversion requires a non-content-mapped script") position = max(0, min(position, core.TextPos(len(script.Text())))) @@ -268,17 +331,7 @@ var styleCheckDiagnostics = collections.NewSetFromItems( func diagnosticToLSP(ctx context.Context, converters *Converters, diagnostic *ast.Diagnostic, opts diagnosticOptions) *lsproto.Diagnostic { locale := locale.FromContext(ctx) - var severity lsproto.DiagnosticSeverity - switch diagnostic.Category() { - case diagnostics.CategorySuggestion: - severity = lsproto.DiagnosticSeverityHint - case diagnostics.CategoryMessage: - severity = lsproto.DiagnosticSeverityInformation - case diagnostics.CategoryWarning: - severity = lsproto.DiagnosticSeverityWarning - default: - severity = lsproto.DiagnosticSeverityError - } + severity := diagnosticSeverity(diagnostic.Category()) if opts.reportStyleChecksAsWarnings && severity == lsproto.DiagnosticSeverityError && styleCheckDiagnostics.Has(diagnostic.Code()) { severity = lsproto.DiagnosticSeverityWarning @@ -288,10 +341,17 @@ func diagnosticToLSP(ctx context.Context, converters *Converters, diagnostic *as if opts.relatedInformation { relatedInformation = make([]*lsproto.DiagnosticRelatedInformation, 0, len(diagnostic.RelatedInformation())) for _, related := range diagnostic.RelatedInformation() { + script, loc := diagnosticScriptAndRange(related.File(), related.Loc(), related.Source()) + relatedRange, fidelity := converters.ToLSPRange(script, loc) + if fidelity.IsNone() { + // Related diagnostic information cannot omit its location. Use an explicit file-level + // location instead of presenting the synthesized span's insertion point as related source. + relatedRange = lsproto.Range{} + } relatedInformation = append(relatedInformation, &lsproto.DiagnosticRelatedInformation{ Location: lsproto.Location{ Uri: FileNameToDocumentURI(related.File().FileName()), - Range: converters.ToLSPRange(related.File(), related.Loc()), + Range: relatedRange, }, Message: related.Localize(locale), }) @@ -312,11 +372,21 @@ func diagnosticToLSP(ctx context.Context, converters *Converters, diagnostic *as // For diagnostics without a file (e.g., program diagnostics), use a zero range var lspRange lsproto.Range if diagnostic.File() != nil { - lspRange = converters.ToLSPRange(diagnostic.File(), diagnostic.Loc()) + script, loc := diagnosticScriptAndRange(diagnostic.File(), diagnostic.Loc(), diagnostic.Source()) + var fidelity spanmap.Fidelity + lspRange, fidelity = converters.ToLSPRange(script, loc) + if fidelity.IsNone() { + // Diagnostics must carry a range. A zero range honestly means "this file" when the + // diagnostic arose entirely in synthesized code and has no original source span. + lspRange = lsproto.Range{} + } } var code *lsproto.IntegerOrString - var source *string + sourceText := diagnostic.Source() + if sourceText == "" { + sourceText = "ts" + } if opts.visualStudio { code = &lsproto.IntegerOrString{ String: new(fmt.Sprintf("TS%d", diagnostic.Code())), @@ -325,7 +395,6 @@ func diagnosticToLSP(ctx context.Context, converters *Converters, diagnostic *as code = &lsproto.IntegerOrString{ Integer: new(diagnostic.Code()), } - source = new("ts") } return &lsproto.Diagnostic{ @@ -333,12 +402,60 @@ func diagnosticToLSP(ctx context.Context, converters *Converters, diagnostic *as Code: code, Severity: &severity, Message: lsproto.StringOrMarkupContent{String: new(messageChainToString(diagnostic, locale))}, - Source: source, + Source: &sourceText, RelatedInformation: ptrToSliceIfNonEmpty(relatedInformation), Tags: ptrToSliceIfNonEmpty(tags), } } +// diagnosticScriptAndRange resolves the text basis and range to report a diagnostic against. For a +// content-mapped file it maps the diagnostic's transformed range back to the original text so +// the range lines up with what the editor shows; the original text's line map is already what +// getLineMap returns for the file. A range in synthesized code has no original counterpart, so it is +// surfaced at the top of the file. Non-mapped files are returned unchanged. +func diagnosticScriptAndRange(file *ast.SourceFile, loc core.TextRange, source string) (Script, core.TextRange) { + if file == nil || file.SpanMap() == nil { + return file, loc + } + original := originalTextScript{fileName: file.FileName(), text: file.OriginalText()} + if source != "" { + // A content mapper's own diagnostics already carry original-text ranges. + return original, loc + } + mapped, fidelity := file.SpanMap().GeneratedToOriginalSpan(loc) + if fidelity == spanmap.FidelityNone { + // Entirely synthesized code has no original location; surface it at the top of the file. + return original, core.NewTextRange(0, 0) + } + return original, mapped +} + +// originalTextScript presents a content-mapped file's original (untransformed) text as a Script, so that +// ranges already mapped into that text convert to the correct line/character positions. +type originalTextScript struct { + fileName string + text string +} + +func (s originalTextScript) FileName() string { return s.fileName } +func (s originalTextScript) Text() string { return s.text } +func (s originalTextScript) OriginalText() string { return s.text } +func (originalTextScript) SpanMap() *spanmap.SpanMap { return nil } + +// diagnosticSeverity maps a diagnostic category to its LSP severity. +func diagnosticSeverity(category diagnostics.Category) lsproto.DiagnosticSeverity { + switch category { + case diagnostics.CategorySuggestion: + return lsproto.DiagnosticSeverityHint + case diagnostics.CategoryMessage: + return lsproto.DiagnosticSeverityInformation + case diagnostics.CategoryWarning: + return lsproto.DiagnosticSeverityWarning + default: + return lsproto.DiagnosticSeverityError + } +} + func messageChainToString(diagnostic *ast.Diagnostic, locale locale.Locale) string { if len(diagnostic.MessageChain()) == 0 { return diagnostic.Localize(locale) diff --git a/internal/ls/lsconv/converters_test.go b/internal/ls/lsconv/converters_test.go index 9c7587d2bcf..ffa33dc537e 100644 --- a/internal/ls/lsconv/converters_test.go +++ b/internal/ls/lsconv/converters_test.go @@ -11,6 +11,7 @@ import ( "github.com/microsoft/typescript-go/internal/json" "github.com/microsoft/typescript-go/internal/ls/lsconv" "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/spanmap" "gotest.tools/v3/assert" ) @@ -93,8 +94,10 @@ type testScript struct { text string } -func (s *testScript) FileName() string { return s.name } -func (s *testScript) Text() string { return s.text } +func (s *testScript) FileName() string { return s.name } +func (s *testScript) Text() string { return s.text } +func (s *testScript) OriginalText() string { return s.text } +func (s *testScript) SpanMap() *spanmap.SpanMap { return nil } func newTestConverters(text string) (*lsconv.Converters, *testScript) { script := &testScript{name: "test.ts", text: text} @@ -134,17 +137,21 @@ func TestConvertersInvalidUTF8(t *testing.T) { } for _, m := range mappings { lc := lsproto.Position{Line: m.line, Character: m.char} - assert.Equal(t, conv.LineAndCharacterToPosition(script, lc), m.bytePos, + positions := conv.FromLSPPosition(script, lc, spanmap.PurposeAll) + assert.Equal(t, len(positions), 1) + assert.Equal(t, positions[0].Position, m.bytePos, fmt.Sprintf("LineAndCharacterToPosition(%d,%d)", m.line, m.char)) - assert.Equal(t, conv.PositionToLineAndCharacter(script, m.bytePos), lc, + lspPosition, _ := conv.ToLSPPosition(script, m.bytePos) + assert.Equal(t, lspPosition, lc, fmt.Sprintf("PositionToLineAndCharacter(%d)", m.bytePos)) } // Byte-by-byte round-trip across the entire text. for bytePos := core.TextPos(0); bytePos <= core.TextPos(len(text)); bytePos++ { - lc := conv.PositionToLineAndCharacter(script, bytePos) - rt := conv.LineAndCharacterToPosition(script, lc) - assert.Equal(t, rt, bytePos, fmt.Sprintf("round-trip byte %d", bytePos)) + lc, _ := conv.ToLSPPosition(script, bytePos) + positions := conv.FromLSPPosition(script, lc, spanmap.PurposeAll) + assert.Equal(t, len(positions), 1) + assert.Equal(t, positions[0].Position, bytePos, fmt.Sprintf("round-trip byte %d", bytePos)) } } @@ -316,12 +323,13 @@ func TestConvertersAgainstJSReference(t *testing.T) { bytePos := core.TextPos(tup.BytePos) expectedLC := lsproto.Position{Line: uint32(tup.Line), Character: uint32(tup.Char)} - gotLC := conv.PositionToLineAndCharacter(script, bytePos) + gotLC, _ := conv.ToLSPPosition(script, bytePos) assert.Equal(t, gotLC, expectedLC, fmt.Sprintf("PositionToLineAndCharacter(%d) mismatch in %q", bytePos, c.text)) - gotPos := conv.LineAndCharacterToPosition(script, expectedLC) - assert.Equal(t, gotPos, bytePos, + positions := conv.FromLSPPosition(script, expectedLC, spanmap.PurposeAll) + assert.Equal(t, len(positions), 1) + assert.Equal(t, positions[0].Position, bytePos, fmt.Sprintf("LineAndCharacterToPosition(%d,%d) mismatch in %q", tup.Line, tup.Char, c.text)) } }) diff --git a/internal/ls/lsutil/utilities.go b/internal/ls/lsutil/utilities.go index 745d3a7221c..b02fa7a6394 100644 --- a/internal/ls/lsutil/utilities.go +++ b/internal/ls/lsutil/utilities.go @@ -118,7 +118,7 @@ func ModuleSymbolToValidIdentifier(moduleSymbol *ast.Symbol, forceCapitalize boo } func ModuleSpecifierToValidIdentifier(moduleSpecifier string, forceCapitalize bool) string { - baseName := tspath.GetBaseFileName(strings.TrimSuffix(tspath.RemoveFileExtension(moduleSpecifier), "/index")) + baseName := tspath.GetBaseFileName(strings.TrimSuffix(tspath.RemoveAnyFileExtension(moduleSpecifier), "/index")) res := []rune{} lastCharWasValid := true baseNameRunes := []rune(baseName) diff --git a/internal/ls/organizeimports.go b/internal/ls/organizeimports.go index ddea88bffd1..1231798bd18 100644 --- a/internal/ls/organizeimports.go +++ b/internal/ls/organizeimports.go @@ -111,7 +111,10 @@ func (l *LanguageService) OrganizeImports( } } - return changeTracker.GetChanges() + // Unmappable files are dropped by GetChanges, so a content-mapped file whose imports cannot be + // faithfully rewritten yields no edits rather than a corrupting one. + changes, _ := changeTracker.GetChanges() + return changes } type organizeImportsComparerSettings struct { diff --git a/internal/ls/rename.go b/internal/ls/rename.go index b1ef362666c..9218ecdb6f0 100644 --- a/internal/ls/rename.go +++ b/internal/ls/rename.go @@ -16,6 +16,7 @@ import ( "github.com/microsoft/typescript-go/internal/ls/lsutil" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/module" + "github.com/microsoft/typescript-go/internal/spanmap" "github.com/microsoft/typescript-go/internal/tspath" ) @@ -46,14 +47,17 @@ func (l *LanguageService) ProvideRename(ctx context.Context, params *lsproto.Ren func (l *LanguageService) GetRenameInfo(ctx context.Context, newName string, documentURI lsproto.DocumentUri, position lsproto.Position) RenameInfo { program, sourceFile := l.getProgramAndFile(documentURI) - pos := int(l.converters.LineAndCharacterToPosition(sourceFile, position)) - - node := astnav.GetTouchingPropertyName(sourceFile, pos) - node = getAdjustedLocation(node, true /*forRename*/, sourceFile) - - if nodeIsEligibleForRename(node) { - if renameInfo, ok := l.getRenameInfoForNode(ctx, newName, node, sourceFile, program); ok { - return renameInfo + positions := l.converters.FromLSPPosition(sourceFile, position, spanmap.PurposeNavigation) + for _, mapped := range positions { + if !mapped.Fidelity.IsExact() { + continue + } + node := astnav.GetTouchingPropertyName(sourceFile, int(mapped.Position)) + node = getAdjustedLocation(node, true /*forRename*/, sourceFile) + if nodeIsEligibleForRename(node) { + if renameInfo, ok := l.getRenameInfoForNode(ctx, newName, node, sourceFile, program); ok { + return renameInfo + } } } return getRenameInfoError(ctx, diagnostics.You_cannot_rename_this_element) @@ -87,8 +91,14 @@ func (l *LanguageService) symbolAndEntriesToRename(ctx context.Context, params * if l.UserPreferences().AllowRenameOfImportPath != core.TSTrue && entry.node != nil && ast.IsStringLiteralLike(entry.node) && ast.TryGetImportFromModuleSpecifier(entry.node) != nil { continue } + rng, ok := l.renameEditRange(entry) + if !ok { + // The occurrence lies outside a verbatim span of a content-mapped file, so it cannot be + // written back to the original text. Skip it and keep renaming the remaining occurrences. + continue + } textEdit := &lsproto.TextEdit{ - Range: l.getRangeOfEntry(entry), + Range: rng, NewText: l.getTextForRename(data.OriginalNode, entry, params.NewName, ch, quotePreference, useAliasesForRename), } changes[uri] = append(changes[uri], textEdit) @@ -100,6 +110,24 @@ func (l *LanguageService) symbolAndEntriesToRename(ctx context.Context, params * }, nil } +// renameEditRange returns the LSP range at which a rename occurrence should be edited. For occurrences in +// content-mapped files it maps the transformed range strictly, returning ok=false when the occurrence is +// not fully within a single verbatim span, so the caller can skip an edit that cannot be applied to the +// original text. +func (l *LanguageService) renameEditRange(entry *ReferenceEntry) (lsproto.Range, bool) { + l.resolveEntry(entry) + if entry.node == nil { + location, fidelity := l.getMappedLocation(entry.fileName, *entry.textRange) + return location.Range, fidelity.IsExact() + } + sourceFile := ast.GetSourceFileOfNode(entry.node) + if sourceFile == nil || sourceFile.SpanMap() == nil { + return l.getRangeOfEntry(entry), true + } + lspRange, fidelity := l.converters.ToLSPRange(sourceFile, *entry.textRange) + return lspRange, fidelity.IsExact() +} + // getRenameInfoForNode performs detailed validation for a rename operation on a specific node. func (l *LanguageService) getRenameInfoForNode(ctx context.Context, newName string, node *ast.Node, sourceFile *ast.SourceFile, program *compiler.Program) (RenameInfo, bool) { ch, done := program.GetTypeChecker(ctx) @@ -143,6 +171,9 @@ func (l *LanguageService) getRenameInfoForNode(ctx context.Context, newName stri } func nodeIsEligibleForRename(node *ast.Node) bool { + if node == nil { + return false + } switch node.Kind { case ast.KindIdentifier, ast.KindPrivateIdentifier, @@ -266,10 +297,14 @@ func (l *LanguageService) getRenameInfoForModule(ctx context.Context, newName st start := specifier.Pos() + 1 + indexAfterLastSlash length := len(specifier.Text()) - indexAfterLastSlash + triggerSpan, fidelity := l.converters.ToLSPRange(sourceFile, core.NewTextRange(start, start+length)) + if !fidelity.IsExact() { + return RenameInfo{}, false + } return RenameInfo{ CanRename: true, DisplayName: specifier.Text()[indexAfterLastSlash:], - TriggerSpan: l.converters.ToLSPRange(sourceFile, core.NewTextRange(start, start+length)), + TriggerSpan: triggerSpan, FileToRename: displayName, NewFileName: newFileName, }, true @@ -371,9 +406,13 @@ func getRenameInfoSuccess(node *ast.Node, sourceFile *ast.SourceFile, displayNam start++ end-- } + triggerSpan, fidelity := converters.ToLSPRange(sourceFile, core.NewTextRange(start, end)) + if !fidelity.IsExact() { + return RenameInfo{CanRename: false} + } return RenameInfo{ CanRename: true, DisplayName: displayName, - TriggerSpan: converters.ToLSPRange(sourceFile, core.NewTextRange(start, end)), + TriggerSpan: triggerSpan, } } diff --git a/internal/ls/selectionranges.go b/internal/ls/selectionranges.go index 834f250fbb0..f553061e4d0 100644 --- a/internal/ls/selectionranges.go +++ b/internal/ls/selectionranges.go @@ -8,6 +8,7 @@ import ( "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/scanner" + "github.com/microsoft/typescript-go/internal/spanmap" ) func (l *LanguageService) ProvideSelectionRanges(ctx context.Context, params *lsproto.SelectionRangeParams) (lsproto.SelectionRangeResponse, error) { @@ -18,8 +19,11 @@ func (l *LanguageService) ProvideSelectionRanges(ctx context.Context, params *ls var results []*lsproto.SelectionRange for _, position := range params.Positions { - pos := l.converters.LineAndCharacterToPosition(sourceFile, position) - selectionRange := getSmartSelectionRange(l, sourceFile, int(pos)) + positions := l.converters.FromLSPPosition(sourceFile, position, spanmap.PurposeAll) + if len(positions) != 1 || !positions[0].Fidelity.IsSingleSegment() { + return lsproto.SelectionRangesOrNull{}, nil + } + selectionRange := getSmartSelectionRange(l, sourceFile, int(positions[0].Position)) if selectionRange != nil { results = append(results, selectionRange) } @@ -176,7 +180,10 @@ func getSmartSelectionRange(l *LanguageService, sourceFile *ast.SourceFile, pos return current } - lspRange := l.converters.ToLSPRange(sourceFile, core.NewTextRange(start, end)) + lspRange, fidelity := l.converters.ToLSPRange(sourceFile, core.NewTextRange(start, end)) + if fidelity.IsNone() { + return current + } if current != nil && current.Range == lspRange { return current @@ -205,9 +212,8 @@ func getSmartSelectionRange(l *LanguageService, sourceFile *ast.SourceFile, pos if pos1 == pos2 { return true } - lspPos1 := l.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(pos1)) - lspPos2 := l.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(pos2)) - return lspPos1.Line == lspPos2.Line + lineStarts := sourceFile.ECMALineMap() + return scanner.ComputeLineOfPosition(lineStarts, pos1) == scanner.ComputeLineOfPosition(lineStarts, pos2) } shouldSkipNode := func(node *ast.Node, parent *ast.Node) bool { @@ -238,7 +244,10 @@ func getSmartSelectionRange(l *LanguageService, sourceFile *ast.SourceFile, pos return false } - fullRange := l.converters.ToLSPRange(sourceFile, core.NewTextRange(sourceFile.Pos(), sourceFile.End())) + fullRange, fidelity := l.converters.ToLSPRange(sourceFile, core.NewTextRange(sourceFile.Pos(), sourceFile.End())) + if fidelity.IsNone() { + return nil + } result := &lsproto.SelectionRange{ Range: fullRange, } diff --git a/internal/ls/semantictokens.go b/internal/ls/semantictokens.go index ff1c78a30bc..1e4e08a9a94 100644 --- a/internal/ls/semantictokens.go +++ b/internal/ls/semantictokens.go @@ -1,17 +1,20 @@ package ls import ( + "cmp" "context" "fmt" "slices" "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/checker" + "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/compiler" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/ls/lsconv" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/scanner" + "github.com/microsoft/typescript-go/internal/spanmap" ) // tokenTypes defines the order of token types for encoding @@ -150,10 +153,16 @@ func (l *LanguageService) ProvideSemanticTokensRange(ctx context.Context, docume c, done := program.GetTypeCheckerForFile(ctx, file) defer done() - start := int(l.converters.LineAndCharacterToPosition(file, rng.Start)) - end := int(l.converters.LineAndCharacterToPosition(file, rng.End)) - - tokens := l.collectSemanticTokensInRange(ctx, c, file, program, start, end) + var tokens []semanticToken + var seen collections.Set[semanticToken] + for _, mapped := range l.converters.FromLSPRange(file, rng, spanmap.PurposeSemantic) { + for _, token := range l.collectSemanticTokensInRange(ctx, c, file, program, mapped.Span.Pos(), mapped.Span.End()) { + if seen.AddIfAbsent(token) { + tokens = append(tokens, token) + } + } + } + slices.SortFunc(tokens, func(a, b semanticToken) int { return cmp.Compare(a.node.Pos(), b.node.Pos()) }) if len(tokens) == 0 { return lsproto.SemanticTokensOrNull{}, nil @@ -527,9 +536,14 @@ func encodeSemanticTokens(ctx context.Context, tokens []semanticToken, file *ast tokenStart := scanner.GetTokenPosOfNode(token.node, file, false) tokenEnd := token.node.End() - // Convert both start and end positions to LSP coordinates, then compute length - startPos := converters.PositionToLineAndCharacter(file, core.TextPos(tokenStart)) - endPos := converters.PositionToLineAndCharacter(file, core.TextPos(tokenEnd)) + // Semantic tokens must describe one concrete source segment; synthesized and cross-segment + // tokens do not identify a coherent token in the original text. + lspRange, fidelity := converters.ToLSPRange(file, core.NewTextRange(tokenStart, tokenEnd)) + if !fidelity.IsExact() { + continue + } + startPos := lspRange.Start + endPos := lspRange.End // Length is the character difference when on the same line var tokenLength uint32 diff --git a/internal/ls/signaturehelp.go b/internal/ls/signaturehelp.go index c837656666e..f845d84a0cb 100644 --- a/internal/ls/signaturehelp.go +++ b/internal/ls/signaturehelp.go @@ -15,6 +15,15 @@ import ( "github.com/microsoft/typescript-go/internal/nodebuilder" "github.com/microsoft/typescript-go/internal/printer" "github.com/microsoft/typescript-go/internal/scanner" + "github.com/microsoft/typescript-go/internal/spanmap" +) + +// SignatureHelpTriggerCharacters and SignatureHelpRetriggerCharacters are the characters that trigger and +// re-trigger signature help. They are advertised both in the static server capabilities and in the dynamic +// content-mapper registration, so they live here to keep those two declarations in sync. +var ( + SignatureHelpTriggerCharacters = []string{"(", ",", "<"} + SignatureHelpRetriggerCharacters = []string{")"} ) type callInvocation struct { @@ -44,9 +53,14 @@ func (l *LanguageService) ProvideSignatureHelp( context *lsproto.SignatureHelpContext, ) (lsproto.SignatureHelpResponse, error) { program, sourceFile := l.getProgramAndFile(documentURI) + positions := l.converters.FromLSPPosition(sourceFile, position, spanmap.PurposeSemantic) + if len(positions) == 0 || !positions[0].Fidelity.IsSingleSegment() { + return lsproto.SignatureHelpOrNull{}, nil + } + pos := int(positions[0].Position) items := l.GetSignatureHelpItems( ctx, - int(l.converters.LineAndCharacterToPosition(sourceFile, position)), + pos, program, sourceFile, context, diff --git a/internal/ls/source_map.go b/internal/ls/source_map.go index cae74c72122..8774551362b 100644 --- a/internal/ls/source_map.go +++ b/internal/ls/source_map.go @@ -6,17 +6,18 @@ import ( "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/outputpaths" "github.com/microsoft/typescript-go/internal/sourcemap" + "github.com/microsoft/typescript-go/internal/spanmap" "github.com/microsoft/typescript-go/internal/tspath" ) -func (l *LanguageService) getMappedLocation(fileName string, fileRange core.TextRange) lsproto.Location { +func (l *LanguageService) getMappedLocation(fileName string, fileRange core.TextRange) (lsproto.Location, spanmap.Fidelity) { startPos := l.tryGetSourcePosition(fileName, core.TextPos(fileRange.Pos())) if startPos == nil { - lspRange := l.createLspRangeFromRange(fileRange, l.getScript(fileName)) + lspRange, fidelity := l.createLspRangeFromRange(fileRange, l.getScript(fileName)) return lsproto.Location{ Uri: lsconv.FileNameToDocumentURI(fileName), Range: lspRange, - } + }, fidelity } endPos := l.tryGetSourcePosition(fileName, core.TextPos(fileRange.End())) if endPos == nil || endPos.FileName != startPos.FileName || endPos.Pos < startPos.Pos { @@ -29,11 +30,11 @@ func (l *LanguageService) getMappedLocation(fileName string, fileRange core.Text } } newRange := core.NewTextRange(startPos.Pos, endPos.Pos) - lspRange := l.createLspRangeFromRange(newRange, l.getScript(startPos.FileName)) + lspRange, fidelity := l.createLspRangeFromRange(newRange, l.getScript(startPos.FileName)) return lsproto.Location{ Uri: lsconv.FileNameToDocumentURI(startPos.FileName), Range: lspRange, - } + }, fidelity } type script struct { @@ -49,7 +50,22 @@ func (s *script) Text() string { return s.text } -func (l *LanguageService) getScript(fileName string) *script { +// SpanMap and OriginalText satisfy lsconv.Script for a plain (non-content-mapped) file: it carries no +// span map and its original text is its own text. +func (s *script) SpanMap() *spanmap.SpanMap { return nil } + +func (s *script) OriginalText() string { return s.text } + +func (l *LanguageService) getScript(fileName string) lsconv.Script { + if program := l.GetProgram(); program != nil { + if file := program.GetSourceFile(fileName); file != nil { + // Use a program lookup first so we get mappable scripts for content-mapped files + return file + } + } + // Fall back to getting the plain text from the file system. This happens when fileName + // is the result of a .d.ts.map mapping back to a source file whose declaration file is + // part of the program instead of the source. text, ok := l.host.ReadFile(fileName) if !ok { return nil diff --git a/internal/ls/sourcedefinition.go b/internal/ls/sourcedefinition.go index 023e6da1a59..b7930d866a4 100644 --- a/internal/ls/sourcedefinition.go +++ b/internal/ls/sourcedefinition.go @@ -17,6 +17,7 @@ import ( "github.com/microsoft/typescript-go/internal/module" "github.com/microsoft/typescript-go/internal/modulespecifiers" "github.com/microsoft/typescript-go/internal/parser" + "github.com/microsoft/typescript-go/internal/spanmap" "github.com/microsoft/typescript-go/internal/tspath" "github.com/microsoft/typescript-go/internal/vfs" ) @@ -25,12 +26,32 @@ func (l *LanguageService) ProvideSourceDefinition( ctx context.Context, documentURI lsproto.DocumentUri, position lsproto.Position, +) (lsproto.DefinitionResponse, error) { + _, file := l.getProgramAndFile(documentURI) + positions := l.converters.FromLSPPosition(file, position, spanmap.PurposeNavigation) + var results []lsproto.DefinitionResponse + for _, mapped := range positions { + if mapped.Fidelity.IsSingleSegment() { + result, err := l.provideSourceDefinitionAtPosition(ctx, documentURI, mapped.Position) + if err != nil { + return lsproto.DefinitionResponse{}, err + } + results = append(results, result) + } + } + return combineDefinitionResponses(results, lsproto.GetClientCapabilities(ctx).TextDocument.Definition.LinkSupport), nil +} + +func (l *LanguageService) provideSourceDefinitionAtPosition( + ctx context.Context, + documentURI lsproto.DocumentUri, + textPos core.TextPos, ) (lsproto.DefinitionResponse, error) { caps := lsproto.GetClientCapabilities(ctx) clientSupportsLink := caps.TextDocument.Definition.LinkSupport program, file := l.getProgramAndFile(documentURI) - pos := int(l.converters.LineAndCharacterToPosition(file, position)) + pos := int(textPos) resolver := l.newSourceDefResolver(program, file.FileName()) node := astnav.GetTouchingPropertyName(file, pos) @@ -38,13 +59,13 @@ func (l *LanguageService) ProvideSourceDefinition( // Triple-slash directives are comments, not AST nodes, so // GetTouchingPropertyName returns the SourceFile node. if declarations, ref := resolver.resolveTripleSlashReference(file, pos, program); len(declarations) != 0 { - originSelectionRange := l.createLspRangeFromBounds(ref.Pos(), ref.End(), file) + originSelectionRange, _ := l.createLspRangeFromBounds(ref.Pos(), ref.End(), file) return l.createDefinitionLocations(originSelectionRange, clientSupportsLink, declarations, nil /*reference*/), nil } return lsproto.LocationOrLocationsOrDefinitionLinksOrNull{}, nil } - originSelectionRange := l.createLspRangeFromNode(node, file) + originSelectionRange, _ := l.createLspRangeFromNode(node, file) // If the cursor is directly on a module specifier string, resolve to the // implementation file's entry point. @@ -56,7 +77,7 @@ func (l *LanguageService) ProvideSourceDefinition( return l.createDefinitionLocations(originSelectionRange, clientSupportsLink, getSourceDefinitionEntryDeclarations(sourceFile), nil), nil } } - return l.provideDefinitionWorker(ctx, documentURI, position) + return l.provideDefinitionAtPosition(ctx, program, file, textPos, clientSupportsLink), nil } // Phase 1: Syntactic fast path — when the cursor is inside an @@ -98,7 +119,7 @@ func (l *LanguageService) ProvideSourceDefinition( return l.createDefinitionLocations(originSelectionRange, clientSupportsLink, getSourceDefinitionEntryDeclarations(sourceFile), nil), nil } } - return l.provideDefinitionWorker(ctx, documentURI, position) + return l.provideDefinitionAtPosition(ctx, program, file, textPos, clientSupportsLink), nil } return l.createDefinitionLocations(originSelectionRange, clientSupportsLink, declarations, nil /*reference*/), nil } @@ -131,7 +152,7 @@ func (l *LanguageService) newSourceDefResolver( options: options, getSourceFile: program.GetSourceFile, resolveFrom: resolveFrom, - resolver: module.NewResolver(program.Host(), noDtsOptions, program.GetGlobalTypingsCacheLocation(), ""), + resolver: module.NewResolver(program.Host(), noDtsOptions, program.GetGlobalTypingsCacheLocation(), "", program.CommandLine().ContentMapperExtensions()), } } diff --git a/internal/ls/string_completions.go b/internal/ls/string_completions.go index f5240bcf329..34e6ccc6eb4 100644 --- a/internal/ls/string_completions.go +++ b/internal/ls/string_completions.go @@ -41,13 +41,17 @@ type pathCompletion struct { name string kind lsutil.ScriptElementKind extension string - textRange *core.TextRange +} + +type pathCompletions struct { + entries []*pathCompletion + replacementSpan *lsproto.Range } type stringLiteralCompletions struct { fromTypes *completionsFromTypes fromProperties *completionsFromProperties - fromPaths []*pathCompletion + fromPaths *pathCompletions } func (l *LanguageService) getStringLiteralCompletions( @@ -60,8 +64,8 @@ func (l *LanguageService) getStringLiteralCompletions( includeSymbols bool, ) *CompletionList { if isInReferenceComment(file, position) { - entries := l.getTripleSlashReferenceCompletions(file, position, l.GetProgram(), checker) - return l.convertPathCompletions(ctx, entries, file, position) + completion := l.getTripleSlashReferenceCompletions(file, position, l.GetProgram(), checker) + return l.convertPathCompletions(ctx, completion, file, position) } if IsInString(file, position, contextToken) { if contextToken == nil || !ast.IsStringLiteralLike(contextToken) { @@ -105,8 +109,7 @@ func (l *LanguageService) convertStringLiteralCompletions( optionalReplacementRange := l.createRangeFromStringLiteralLikeContent(file, contextToken, position) switch { case completion.fromPaths != nil: - completion := completion.fromPaths - return l.convertPathCompletions(ctx, completion, file, position) + return l.convertPathCompletions(ctx, completion.fromPaths, file, position) case completion.fromProperties != nil: completion := completion.fromProperties data := &completionDataData{ @@ -198,17 +201,16 @@ func (l *LanguageService) convertStringLiteralCompletions( func (l *LanguageService) convertPathCompletions( ctx context.Context, - pathCompletions []*pathCompletion, + completion *pathCompletions, file *ast.SourceFile, position int, ) *CompletionList { + if completion == nil { + return nil + } isNewIdentifierLocation := true // The user may type in a path that doesn't yet exist, creating a "new identifier" with respect to the collection of identifiers the server is aware of. defaultCommitCharacters := getDefaultCommitCharacters(isNewIdentifierLocation) - items := core.Map(pathCompletions, func(pathCompletion *pathCompletion) *CompletionItem { - var replacementSpan *lsproto.Range - if pathCompletion.textRange != nil { - replacementSpan = new(l.createLspRangeFromBounds(pathCompletion.textRange.Pos(), pathCompletion.textRange.End(), file)) - } + items := core.Map(completion.entries, func(pathCompletion *pathCompletion) *CompletionItem { detail := pathCompletion.name if !strings.HasSuffix(pathCompletion.name, pathCompletion.extension) { detail += pathCompletion.extension @@ -221,7 +223,7 @@ func (l *LanguageService) convertPathCompletions( SortTextLocationPriority, pathCompletion.kind, kindModifiersFromExtension(pathCompletion.extension), - replacementSpan, + completion.replacementSpan, nil, /*commitCharacters*/ nil, /*labelDetails*/ file, @@ -583,30 +585,46 @@ func (l *LanguageService) getStringLiteralCompletionsFromModuleNames( program *compiler.Program, checker *checker.Checker, ) *stringLiteralCompletions { + textStart := astnav.GetStartOfNode(node, file, false /*includeJSDoc*/) + 1 + replacementSpan, ok := l.pathCompletionReplacementSpan(file, getDirectoryFragmentRange(node.Text(), textStart)) + if !ok { + return nil + } nameAndKinds := l.getStringLiteralCompletionsFromModuleNamesWorker( file, node, program, checker, ) - textStart := astnav.GetStartOfNode(node, file, false /*includeJSDoc*/) + 1 return &stringLiteralCompletions{ - fromPaths: addReplacementSpans(node.Text(), textStart, nameAndKinds), + fromPaths: &pathCompletions{ + entries: toPathCompletions(nameAndKinds), + replacementSpan: replacementSpan, + }, } } -func addReplacementSpans(text string, textStart int, names []moduleCompletionNameAndKind) []*pathCompletion { - textRange := getDirectoryFragmentRange(text, textStart) +func toPathCompletions(names []moduleCompletionNameAndKind) []*pathCompletion { return core.Map(names, func(nameAndKind moduleCompletionNameAndKind) *pathCompletion { return &pathCompletion{ name: nameAndKind.name, kind: moduletToScriptElementKind(nameAndKind.kind), extension: nameAndKind.extension, - textRange: textRange, } }) } +func (l *LanguageService) pathCompletionReplacementSpan(file *ast.SourceFile, textRange *core.TextRange) (*lsproto.Range, bool) { + if textRange == nil { + return nil, true + } + lspRange, fidelity := l.createLspRangeFromBounds(textRange.Pos(), textRange.End(), file) + if !fidelity.IsExact() { + return nil, false + } + return &lspRange, true +} + func moduletToScriptElementKind(kind moduleCompletionKind) lsutil.ScriptElementKind { switch kind { case moduleCompletionKindDirectory: @@ -1007,7 +1025,7 @@ func (l *LanguageService) getExtensionOptions( mode core.ResolutionMode, checker *checker.Checker, ) *extensionOptions { - extensionsToSearch := getSupportedExtensionsForModuleResolution(options, checker) + extensionsToSearch := getSupportedExtensionsForModuleResolution(options, l.GetProgram().CommandLine().ContentMapperExtensions(), checker) return &extensionOptions{ extensionsToSearch: extensionsToSearch, @@ -1018,7 +1036,7 @@ func (l *LanguageService) getExtensionOptions( } } -func getSupportedExtensionsForModuleResolution(options *core.CompilerOptions, checker *checker.Checker) []string { +func getSupportedExtensionsForModuleResolution(options *core.CompilerOptions, extraExtensions []string, checker *checker.Checker) []string { /** file extensions from ambient modules declarations e.g. *.css */ var extensions []string if checker != nil { @@ -1031,7 +1049,7 @@ func getSupportedExtensionsForModuleResolution(options *core.CompilerOptions, ch extensions = append(extensions, name[1:]) } } - supportedExtensions := tsoptions.GetSupportedExtensions(options, nil /*extraFileExtensions*/) + supportedExtensions := tsoptions.GetSupportedExtensions(options, extraExtensions) for _, ext := range supportedExtensions { extensions = append(extensions, ext...) } @@ -2161,7 +2179,7 @@ func (l *LanguageService) getTripleSlashReferenceCompletions( position int, program *compiler.Program, checker *checker.Checker, -) []*pathCompletion { +) *pathCompletions { compilerOptions := program.Options() token := astnav.GetTokenAtPosition(file, position) commentRanges := slices.Collect(scanner.GetLeadingCommentRanges(&ast.NodeFactory{}, file.Text(), token.Pos())) @@ -2183,6 +2201,10 @@ func (l *LanguageService) getTripleSlashReferenceCompletions( if !ok { return nil } + replacementSpan, ok := l.pathCompletionReplacementSpan(file, getDirectoryFragmentRange(toComplete, foundRange.Pos()+len(prefix))) + if !ok { + return nil + } scriptPath := tspath.GetDirectoryPath(string(file.Path())) @@ -2207,5 +2229,8 @@ func (l *LanguageService) getTripleSlashReferenceCompletions( names = slices.Collect(maps.Values(result.names)) } - return addReplacementSpans(toComplete, foundRange.Pos()+len(prefix), names) + return &pathCompletions{ + entries: toPathCompletions(names), + replacementSpan: replacementSpan, + } } diff --git a/internal/ls/symbols.go b/internal/ls/symbols.go index 634d478b53d..7e4102acc10 100644 --- a/internal/ls/symbols.go +++ b/internal/ls/symbols.go @@ -307,14 +307,16 @@ func (l *LanguageService) newDocumentSymbol(node *ast.Node, name *ast.Node, chil } result.Name = text result.Kind = getSymbolKindFromNode(node) - result.Range = lsproto.Range{ - Start: l.converters.PositionToLineAndCharacter(file, core.TextPos(nodeStartPos)), - End: l.converters.PositionToLineAndCharacter(file, core.TextPos(node.End())), + selectionRange, selectionFidelity := l.converters.ToLSPRange(file, core.NewTextRange(nameStartPos, nameEndPos)) + if !selectionFidelity.IsSingleSegment() { + return nil } - result.SelectionRange = lsproto.Range{ - Start: l.converters.PositionToLineAndCharacter(file, core.TextPos(nameStartPos)), - End: l.converters.PositionToLineAndCharacter(file, core.TextPos(nameEndPos)), + symbolRange, rangeFidelity := l.converters.ToLSPRange(file, core.NewTextRange(nodeStartPos, node.End())) + if rangeFidelity.IsNone() { + symbolRange = selectionRange } + result.Range = symbolRange + result.SelectionRange = selectionRange if children == nil { children = []*lsproto.DocumentSymbol{} } @@ -556,8 +558,8 @@ func ProvideWorkspaceSymbols( // Sort the DeclarationInfos and return the top 256 matches. slices.SortFunc(infos, compareDeclarationInfos) count := min(len(infos), 256) - symbols := make([]*lsproto.SymbolInformation, count) - for i, info := range infos[0:count] { + symbols := make([]*lsproto.SymbolInformation, 0, count) + for _, info := range infos[0:count] { node := info.declaration sourceFile := ast.GetSourceFileOfNode(node) container := getContainerNode(info.declaration) @@ -572,12 +574,17 @@ func ProvideWorkspaceSymbols( nameNode := ast.GetNameOfDeclaration(node) nameStart := astnav.GetStartOfNode(nameNode, sourceFile, false /*includeJsDoc*/) nameRange := core.NewTextRange(nameStart, nameNode.End()) + location, fidelity := converters.ToLSPLocation(sourceFile, nameRange) + if !fidelity.IsSingleSegment() { + // The name has no counterpart in the original text, so there is nothing to navigate to. + continue + } var symbol lsproto.SymbolInformation symbol.Name = info.name symbol.Kind = getSymbolKindFromNode(info.declaration) - symbol.Location = converters.ToLSPLocation(sourceFile, nameRange) + symbol.Location = location symbol.ContainerName = containerName - symbols[i] = &symbol + symbols = append(symbols, &symbol) } return lsproto.SymbolInformationsOrWorkspaceSymbolsOrNull{SymbolInformations: &symbols}, nil diff --git a/internal/ls/utilities.go b/internal/ls/utilities.go index 8dab6bce9b8..81912f0dc91 100644 --- a/internal/ls/utilities.go +++ b/internal/ls/utilities.go @@ -18,6 +18,7 @@ import ( "github.com/microsoft/typescript-go/internal/ls/lsutil" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/scanner" + "github.com/microsoft/typescript-go/internal/spanmap" "github.com/microsoft/typescript-go/internal/stringutil" "github.com/microsoft/typescript-go/internal/tspath" ) @@ -284,7 +285,7 @@ func isInRightSideOfInternalImportEqualsDeclaration(node *ast.Node) bool { return ast.IsInternalModuleImportEqualsDeclaration(node.Parent) && node.Parent.AsImportEqualsDeclaration().ModuleReference == node } -func (l *LanguageService) createLspRangeFromNode(node *ast.Node, file *ast.SourceFile) lsproto.Range { +func (l *LanguageService) createLspRangeFromNode(node *ast.Node, file *ast.SourceFile) (lsproto.Range, spanmap.Fidelity) { return l.createLspRangeFromBounds(scanner.GetTokenPosOfNode(node, file, false /*includeJSDoc*/), node.End(), file) } @@ -292,16 +293,16 @@ func createRangeFromNode(node *ast.Node, file *ast.SourceFile) core.TextRange { return core.NewTextRange(scanner.GetTokenPosOfNode(node, file, false /*includeJSDoc*/), node.End()) } -func (l *LanguageService) createLspRangeFromBounds(start, end int, file *ast.SourceFile) lsproto.Range { +func (l *LanguageService) createLspRangeFromBounds(start, end int, file *ast.SourceFile) (lsproto.Range, spanmap.Fidelity) { return l.converters.ToLSPRange(file, core.NewTextRange(start, end)) } -func (l *LanguageService) createLspRangeFromRange(textRange core.TextRange, script lsconv.Script) lsproto.Range { +func (l *LanguageService) createLspRangeFromRange(textRange core.TextRange, script lsconv.Script) (lsproto.Range, spanmap.Fidelity) { return l.converters.ToLSPRange(script, textRange) } -func (l *LanguageService) createLspPosition(position int, file *ast.SourceFile) lsproto.Position { - return l.converters.PositionToLineAndCharacter(file, core.TextPos(position)) +func (l *LanguageService) createLspPosition(position int, file *ast.SourceFile) (lsproto.Position, spanmap.Fidelity) { + return l.converters.ToLSPPosition(file, core.TextPos(position)) } func quote(file *ast.SourceFile, preferences lsutil.UserPreferences, text string) string { diff --git a/internal/lsp/lsproto/_generate/generate.mts b/internal/lsp/lsproto/_generate/generate.mts index 4c12a08823c..4a0b7d2dd0b 100755 --- a/internal/lsp/lsproto/_generate/generate.mts +++ b/internal/lsp/lsproto/_generate/generate.mts @@ -68,6 +68,12 @@ const customStructures: Structure[] = [ optional: true, documentation: "The initial log verbosity level, matching the client's output channel log level at startup. Subsequent changes are sent via custom/setLogVerbosity.", }, + { + name: "loadExternalPlugins", + type: { kind: "base", name: "boolean" }, + optional: true, + documentation: "LoadExternalPlugins allows configured content mappers to launch external plugin processes. The client should set this only for trusted workspaces. It mirrors the --loadExternalPlugins CLI flag.", + }, ], documentation: "InitializationOptions contains user-provided initialization options.", }, @@ -410,6 +416,33 @@ const customStructures: Structure[] = [ ], documentation: "Result for the custom/projectInfo request.", }, + { + name: "DiscoverContentMappersParams", + properties: [ + { + name: "textDocuments", + type: { kind: "array", element: { kind: "reference", name: "TextDocumentIdentifier" } }, + documentation: "Open foreign documents whose configured projects should be checked for content mappers.", + }, + { + name: "extensions", + type: { kind: "array", element: { kind: "base", name: "string" } }, + documentation: "Candidate foreign file extensions, including the leading dot.", + }, + ], + documentation: "Parameters for the custom/discoverContentMappers request.", + }, + { + name: "DiscoverContentMappersResult", + properties: [ + { + name: "extensions", + type: { kind: "array", element: { kind: "base", name: "string" } }, + documentation: "Requested extensions provided by content mappers in discovered configured projects.", + }, + ], + documentation: "Result for the custom/discoverContentMappers request.", + }, { name: "SetLogVerbosityParams", properties: [ @@ -853,6 +886,14 @@ const customRequests: Request[] = [ messageDirection: "clientToServer", documentation: "Returns project information (e.g. the tsconfig.json path) for a given text document.", }, + { + method: "custom/discoverContentMappers", + typeName: "CustomDiscoverContentMappersRequest", + params: { kind: "reference", name: "DiscoverContentMappersParams" }, + result: { kind: "reference", name: "DiscoverContentMappersResult" }, + messageDirection: "clientToServer", + documentation: "Discovers content mappers from configured projects governing the supplied foreign documents.", + }, { method: "custom/textDocument/sourceDefinition", typeName: "CustomTextDocumentSourceDefinitionRequest", diff --git a/internal/lsp/lsproto/lsp_generated.go b/internal/lsp/lsproto/lsp_generated.go index 7d568afdc92..7136a5c3d9f 100644 --- a/internal/lsp/lsproto/lsp_generated.go +++ b/internal/lsp/lsproto/lsp_generated.go @@ -8790,6 +8790,9 @@ type InitializationOptions struct { // The initial log verbosity level, matching the client's output channel log level at startup. Subsequent changes are sent via custom/setLogVerbosity. LogVerbosity *LogVerbosity `json:"logVerbosity,omitzero"` + + // LoadExternalPlugins allows configured content mappers to launch external plugin processes. The client should set this only for trusted workspaces. It mirrors the --loadExternalPlugins CLI flag. + LoadExternalPlugins *bool `json:"loadExternalPlugins,omitzero"` } var _ json.UnmarshalerFrom = (*InitializationOptions)(nil) @@ -9090,6 +9093,33 @@ func (s *ProjectInfoResult) UnmarshalJSONFrom(dec *json.Decoder) error { return unmarshalStruct(s, dec) } +// Parameters for the custom/discoverContentMappers request. +type DiscoverContentMappersParams struct { + // Open foreign documents whose configured projects should be checked for content mappers. + TextDocuments []TextDocumentIdentifier `json:"textDocuments" lsp:"required"` + + // Candidate foreign file extensions, including the leading dot. + Extensions []string `json:"extensions" lsp:"required"` +} + +var _ json.UnmarshalerFrom = (*DiscoverContentMappersParams)(nil) + +func (s *DiscoverContentMappersParams) UnmarshalJSONFrom(dec *json.Decoder) error { + return unmarshalStruct(s, dec) +} + +// Result for the custom/discoverContentMappers request. +type DiscoverContentMappersResult struct { + // Requested extensions provided by content mappers in discovered configured projects. + Extensions []string `json:"extensions" lsp:"required"` +} + +var _ json.UnmarshalerFrom = (*DiscoverContentMappersResult)(nil) + +func (s *DiscoverContentMappersResult) UnmarshalJSONFrom(dec *json.Decoder) error { + return unmarshalStruct(s, dec) +} + // Parameters for the custom/setLogVerbosity notification. type SetLogVerbosityParams struct { // The log verbosity level. @@ -10991,6 +11021,8 @@ const ( MethodCustomInitializeAPISession Method = "custom/initializeAPISession" // Returns project information (e.g. the tsconfig.json path) for a given text document. MethodCustomProjectInfo Method = "custom/projectInfo" + // Discovers content mappers from configured projects governing the supplied foreign documents. + MethodCustomDiscoverContentMappers Method = "custom/discoverContentMappers" // Request to get source definitions for a position. MethodCustomTextDocumentSourceDefinition Method = "custom/textDocument/sourceDefinition" // Request to get document highlights across multiple files. @@ -11540,6 +11572,12 @@ type CustomProjectInfoResponse = *ProjectInfoResult // Type mapping info for `custom/projectInfo` var CustomProjectInfoInfo = RequestInfo[*ProjectInfoParams, CustomProjectInfoResponse]{Method: MethodCustomProjectInfo} +// Response type for `custom/discoverContentMappers` +type CustomDiscoverContentMappersResponse = *DiscoverContentMappersResult + +// Type mapping info for `custom/discoverContentMappers` +var CustomDiscoverContentMappersInfo = RequestInfo[*DiscoverContentMappersParams, CustomDiscoverContentMappersResponse]{Method: MethodCustomDiscoverContentMappers} + // Response type for `custom/textDocument/sourceDefinition` type CustomTextDocumentSourceDefinitionResponse = *LocationOrLocationsOrDefinitionLinksOrNull diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 8a0b7214c9d..9fa5b2661dc 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -16,9 +16,11 @@ import ( "github.com/microsoft/typescript-go/internal/api" "github.com/microsoft/typescript-go/internal/collections" + "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/fswatch" + "github.com/microsoft/typescript-go/internal/ipc" "github.com/microsoft/typescript-go/internal/json" "github.com/microsoft/typescript-go/internal/jsonrpc" "github.com/microsoft/typescript-go/internal/locale" @@ -46,6 +48,9 @@ type ServerOptions struct { TypingsLocation string ParseCache *project.ParseCache NpmInstall func(cwd string, args []string) ([]byte, error) + // Spawn launches a child process, returning its stdio as an io.ReadWriteCloser (Read is its stdout, + // Write is its stdin). It is nil when the host cannot spawn processes. Currently used for content mappers. + Spawn func(command []string, dir string) (io.ReadWriteCloser, error) ProgressDelay time.Duration // delay before showing progress UI; 0 means no delay SetParentProcessID func(parentPID int) } @@ -69,6 +74,7 @@ func NewServer(opts *ServerOptions) *Server { typingsLocation: opts.TypingsLocation, parseCache: opts.ParseCache, npmInstall: opts.NpmInstall, + spawn: opts.Spawn, startWatchdog: opts.SetParentProcessID, initComplete: make(chan struct{}), progressDelay: opts.ProgressDelay, @@ -182,6 +188,14 @@ type Server struct { telemetryEnabled bool watcherID atomic.Uint32 watchers collections.SyncSet[project.WatcherID] + + // contentMapperRegistrationMu serializes RegisterContentMapperExtensions so the method is correctly + // synchronized on its own rather than relying on callers to serialize it. It guards + // contentMapperExtensionsRegistered and the ordered unregister/register requests the method sends. + contentMapperRegistrationMu sync.Mutex + // contentMapperExtensionsRegistered records whether a content mapper text document sync + // registration is currently active with the client, so it can be replaced or removed. + contentMapperExtensionsRegistered bool // builtinWatcher is non-nil when the server is running its own // in-process file watcher instead of using LSP-based watching. It // is enabled when the client lacks DynamicRegistration for @@ -210,6 +224,7 @@ type Server struct { parseCache *project.ParseCache npmInstall func(cwd string, args []string) ([]byte, error) + spawn func(command []string, dir string) (io.ReadWriteCloser, error) cpuProfiler pprof.CPUProfiler @@ -287,6 +302,218 @@ func (s *Server) UnwatchFiles(ctx context.Context, id project.WatcherID) error { return fmt.Errorf("no file watcher exists with ID %s", id) } +const ( + contentMapperDidOpenRegistrationID = "content-mapper-did-open" + contentMapperDidChangeRegistrationID = "content-mapper-did-change" + contentMapperDidCloseRegistrationID = "content-mapper-did-close" + contentMapperDiagnosticRegistrationID = "content-mapper-diagnostic" + contentMapperHoverRegistrationID = "content-mapper-hover" + contentMapperSignatureHelpRegistrationID = "content-mapper-signature-help" + contentMapperDefinitionRegistrationID = "content-mapper-definition" + contentMapperTypeDefinitionRegistrationID = "content-mapper-type-definition" + contentMapperImplementationRegistrationID = "content-mapper-implementation" + contentMapperReferencesRegistrationID = "content-mapper-references" + contentMapperDocumentHighlightRegistrationID = "content-mapper-document-highlight" + contentMapperCompletionRegistrationID = "content-mapper-completion" + contentMapperRenameRegistrationID = "content-mapper-rename" +) + +func (s *Server) supportsContentMapperRegistration(id string) bool { + switch id { + case contentMapperDidOpenRegistrationID, contentMapperDidChangeRegistrationID, contentMapperDidCloseRegistrationID: + return s.clientCapabilities.TextDocument.Synchronization.DynamicRegistration + case contentMapperDiagnosticRegistrationID: + return s.clientCapabilities.TextDocument.Diagnostic.DynamicRegistration + case contentMapperHoverRegistrationID: + return s.clientCapabilities.TextDocument.Hover.DynamicRegistration + case contentMapperSignatureHelpRegistrationID: + return s.clientCapabilities.TextDocument.SignatureHelp.DynamicRegistration + case contentMapperDefinitionRegistrationID: + return s.clientCapabilities.TextDocument.Definition.DynamicRegistration + case contentMapperTypeDefinitionRegistrationID: + return s.clientCapabilities.TextDocument.TypeDefinition.DynamicRegistration + case contentMapperImplementationRegistrationID: + return s.clientCapabilities.TextDocument.Implementation.DynamicRegistration + case contentMapperReferencesRegistrationID: + return s.clientCapabilities.TextDocument.References.DynamicRegistration + case contentMapperDocumentHighlightRegistrationID: + return s.clientCapabilities.TextDocument.DocumentHighlight.DynamicRegistration + case contentMapperCompletionRegistrationID: + return s.clientCapabilities.TextDocument.Completion.DynamicRegistration + case contentMapperRenameRegistrationID: + return s.clientCapabilities.TextDocument.Rename.DynamicRegistration + default: + return false + } +} + +// RegisterContentMapperExtensions implements project.Client. It dynamically registers text document +// synchronization and pull diagnostics for the given foreign file extensions so the editor forwards their +// open/change/close notifications to the server and requests diagnostics for them. It is called with the +// full desired set each time it changes; an empty slice removes any prior registration. +func (s *Server) RegisterContentMapperExtensions(ctx context.Context, extensions []string) error { + if !s.clientCapabilities.TextDocument.Synchronization.DynamicRegistration { + return nil + } + + s.contentMapperRegistrationMu.Lock() + defer s.contentMapperRegistrationMu.Unlock() + + if s.contentMapperExtensionsRegistered { + unregistrations := []*lsproto.Unregistration{ + {Id: contentMapperDidOpenRegistrationID, Method: string(lsproto.MethodTextDocumentDidOpen)}, + {Id: contentMapperDidChangeRegistrationID, Method: string(lsproto.MethodTextDocumentDidChange)}, + {Id: contentMapperDidCloseRegistrationID, Method: string(lsproto.MethodTextDocumentDidClose)}, + {Id: contentMapperDiagnosticRegistrationID, Method: string(lsproto.MethodTextDocumentDiagnostic)}, + {Id: contentMapperHoverRegistrationID, Method: string(lsproto.MethodTextDocumentHover)}, + {Id: contentMapperSignatureHelpRegistrationID, Method: string(lsproto.MethodTextDocumentSignatureHelp)}, + {Id: contentMapperDefinitionRegistrationID, Method: string(lsproto.MethodTextDocumentDefinition)}, + {Id: contentMapperTypeDefinitionRegistrationID, Method: string(lsproto.MethodTextDocumentTypeDefinition)}, + {Id: contentMapperImplementationRegistrationID, Method: string(lsproto.MethodTextDocumentImplementation)}, + {Id: contentMapperReferencesRegistrationID, Method: string(lsproto.MethodTextDocumentReferences)}, + {Id: contentMapperDocumentHighlightRegistrationID, Method: string(lsproto.MethodTextDocumentDocumentHighlight)}, + {Id: contentMapperCompletionRegistrationID, Method: string(lsproto.MethodTextDocumentCompletion)}, + {Id: contentMapperRenameRegistrationID, Method: string(lsproto.MethodTextDocumentRename)}, + } + unregistrations = slices.DeleteFunc(unregistrations, func(registration *lsproto.Unregistration) bool { + return !s.supportsContentMapperRegistration(registration.Id) + }) + if _, err := sendClientRequest(ctx, s, lsproto.ClientUnregisterCapabilityInfo, &lsproto.UnregistrationParams{ + Unregisterations: unregistrations, + }); err != nil { + return fmt.Errorf("failed to unregister content mapper text document sync: %w", err) + } + s.contentMapperExtensionsRegistered = false + } + + if len(extensions) == 0 { + return nil + } + + filters := make([]lsproto.TextDocumentFilterLanguageOrSchemeOrPattern, 0, len(extensions)) + for _, ext := range extensions { + filters = append(filters, lsproto.TextDocumentFilterLanguageOrSchemeOrPattern{ + Pattern: &lsproto.TextDocumentFilterPattern{ + Pattern: lsproto.PatternOrRelativePattern{Pattern: new("**/*" + ext)}, + }, + }) + } + selector := lsproto.DocumentSelectorOrNull{DocumentSelector: &filters} + + registrations := []*lsproto.Registration{ + { + Id: contentMapperDidOpenRegistrationID, + RegisterOptions: &lsproto.RegisterOptions{ + TextDocumentDidOpen: &lsproto.TextDocumentRegistrationOptions{DocumentSelector: selector}, + }, + }, + { + Id: contentMapperDidChangeRegistrationID, + RegisterOptions: &lsproto.RegisterOptions{ + TextDocumentDidChange: &lsproto.TextDocumentChangeRegistrationOptions{ + DocumentSelector: selector, + SyncKind: lsproto.TextDocumentSyncKindIncremental, + }, + }, + }, + { + Id: contentMapperDidCloseRegistrationID, + RegisterOptions: &lsproto.RegisterOptions{ + TextDocumentDidClose: &lsproto.TextDocumentRegistrationOptions{DocumentSelector: selector}, + }, + }, + { + Id: contentMapperDiagnosticRegistrationID, + RegisterOptions: &lsproto.RegisterOptions{ + TextDocumentDiagnostic: &lsproto.DiagnosticRegistrationOptions{ + DocumentSelector: selector, + Identifier: new("typescript"), + InterFileDependencies: true, + }, + }, + }, + { + Id: contentMapperHoverRegistrationID, + RegisterOptions: &lsproto.RegisterOptions{ + TextDocumentHover: &lsproto.HoverRegistrationOptions{DocumentSelector: selector}, + }, + }, + { + Id: contentMapperSignatureHelpRegistrationID, + RegisterOptions: &lsproto.RegisterOptions{ + TextDocumentSignatureHelp: &lsproto.SignatureHelpRegistrationOptions{ + DocumentSelector: selector, + TriggerCharacters: &ls.SignatureHelpTriggerCharacters, + RetriggerCharacters: &ls.SignatureHelpRetriggerCharacters, + }, + }, + }, + { + Id: contentMapperDefinitionRegistrationID, + RegisterOptions: &lsproto.RegisterOptions{ + TextDocumentDefinition: &lsproto.DefinitionRegistrationOptions{DocumentSelector: selector}, + }, + }, + { + Id: contentMapperTypeDefinitionRegistrationID, + RegisterOptions: &lsproto.RegisterOptions{ + TextDocumentTypeDefinition: &lsproto.TypeDefinitionRegistrationOptions{DocumentSelector: selector}, + }, + }, + { + Id: contentMapperImplementationRegistrationID, + RegisterOptions: &lsproto.RegisterOptions{ + TextDocumentImplementation: &lsproto.ImplementationRegistrationOptions{DocumentSelector: selector}, + }, + }, + { + Id: contentMapperReferencesRegistrationID, + RegisterOptions: &lsproto.RegisterOptions{ + TextDocumentReferences: &lsproto.ReferenceRegistrationOptions{DocumentSelector: selector}, + }, + }, + { + Id: contentMapperDocumentHighlightRegistrationID, + RegisterOptions: &lsproto.RegisterOptions{ + TextDocumentDocumentHighlight: &lsproto.DocumentHighlightRegistrationOptions{DocumentSelector: selector}, + }, + }, + { + Id: contentMapperCompletionRegistrationID, + RegisterOptions: &lsproto.RegisterOptions{ + TextDocumentCompletion: &lsproto.CompletionRegistrationOptions{ + DocumentSelector: selector, + TriggerCharacters: &ls.CompletionTriggerCharacters, + ResolveProvider: new(true), + CompletionItem: &lsproto.ServerCompletionItemOptions{ + LabelDetailsSupport: new(true), + }, + }, + }, + }, + { + Id: contentMapperRenameRegistrationID, + RegisterOptions: &lsproto.RegisterOptions{ + TextDocumentRename: &lsproto.RenameRegistrationOptions{ + DocumentSelector: selector, + PrepareProvider: new(true), + }, + }, + }, + } + registrations = slices.DeleteFunc(registrations, func(registration *lsproto.Registration) bool { + return !s.supportsContentMapperRegistration(registration.Id) + }) + if _, err := sendClientRequest(ctx, s, lsproto.ClientRegisterCapabilityInfo, &lsproto.RegistrationParams{ + Registrations: registrations, + }); err != nil { + return fmt.Errorf("failed to register content mapper text document sync: %w", err) + } + + s.contentMapperExtensionsRegistered = true + return nil +} + // RefreshDiagnostics implements project.Client. func (s *Server) RefreshDiagnostics(ctx context.Context) error { if !s.clientCapabilities.Workspace.Diagnostics.RefreshSupport { @@ -706,6 +933,12 @@ func (s *Server) handleRequestOrNotification(ctx context.Context, req *lsproto.R idStr = " (" + req.ID.String() + ")" } if err != nil { + if resp, ok := contentMapperFallbackResponse(req.Method, err); ok { + if !s.logger.IsTracing() { + s.logger.Info("handled method '", req.Method, "'", idStr, " in ", time.Since(start)) + } + return nil, s.sendResult(req.ID, resp) + } if _, ok := errors.AsType[userFacingRequestFailedError](err); !ok { s.logger.Error("error handling method '", req.Method, "'", idStr, ": ", err) } else if !s.logger.IsTracing() { @@ -739,6 +972,36 @@ func (s *Server) handleRequestOrNotification(ctx context.Context, req *lsproto.R return nil, nil } +// contentMapperFallbackResponse returns an empty response for requests made for +// unknown file types not handled by any content mapper. This typically serves a +// short window in time between when the server has unregistered content mapper +// extensions and when the client has stopped sending requests for those file types. +func contentMapperFallbackResponse(method lsproto.Method, err error) (any, bool) { + if !errors.Is(err, project.ErrNoProjectForUnknownScriptKind) { + return nil, false + } + switch method { + case lsproto.MethodTextDocumentDiagnostic: + return lsproto.DocumentDiagnosticResponse{ + FullDocumentDiagnosticReport: &lsproto.RelatedFullDocumentDiagnosticReport{ + Items: []*lsproto.Diagnostic{}, + }, + }, true + case lsproto.MethodTextDocumentHover, + lsproto.MethodTextDocumentSignatureHelp, + lsproto.MethodTextDocumentDefinition, + lsproto.MethodTextDocumentTypeDefinition, + lsproto.MethodTextDocumentImplementation, + lsproto.MethodTextDocumentReferences, + lsproto.MethodTextDocumentDocumentHighlight, + lsproto.MethodTextDocumentCompletion, + lsproto.MethodTextDocumentRename: + return lsproto.Null{}, true + default: + return nil, false + } +} + // handlerMap maps LSP method to a handler function. The handler function executes any work that must be done synchronously // before other requests/notifications can be processed, and returns any additional work as a function to be executed // asynchronously after the synchronous work is complete. @@ -811,6 +1074,7 @@ var handlers = sync.OnceValue(func() handlerMap { registerRequestHandler(handlers, lsproto.CustomInitializeAPISessionInfo, (*Server).handleInitializeAPISession) registerRequestHandler(handlers, lsproto.CustomProjectInfoInfo, (*Server).handleProjectInfo) + registerRequestHandler(handlers, lsproto.CustomDiscoverContentMappersInfo, (*Server).handleDiscoverContentMappers) return handlers }) @@ -1100,15 +1364,15 @@ func (s *Server) handleInitialize(ctx context.Context, params *lsproto.Initializ }, }, CompletionProvider: &lsproto.CompletionOptions{ - TriggerCharacters: &ls.TriggerCharacters, + TriggerCharacters: &ls.CompletionTriggerCharacters, ResolveProvider: new(true), CompletionItem: &lsproto.ServerCompletionItemOptions{ LabelDetailsSupport: new(true), }, }, SignatureHelpProvider: &lsproto.SignatureHelpOptions{ - TriggerCharacters: &[]string{"(", ",", "<"}, - RetriggerCharacters: &[]string{")"}, + TriggerCharacters: &ls.SignatureHelpTriggerCharacters, + RetriggerCharacters: &ls.SignatureHelpRetriggerCharacters, }, DocumentFormattingProvider: &lsproto.BooleanOrDocumentFormattingOptions{ Boolean: new(true), @@ -1204,6 +1468,10 @@ func (s *Server) handleInitialized(ctx context.Context, params *lsproto.Initiali if s.initializationOptions.EnableTelemetry != nil { enableTelemetry = *s.initializationOptions.EnableTelemetry } + var loadExternalPlugins bool + if s.initializationOptions.LoadExternalPlugins != nil { + loadExternalPlugins = *s.initializationOptions.LoadExternalPlugins + } hasDynamicWatchRegistration := s.clientCapabilities.Workspace.DidChangeWatchedFiles.DynamicRegistration if hasDynamicWatchRegistration { s.logger.Logf("file watching: using LSP client-side watching (client supports dynamic registration)") @@ -1255,11 +1523,13 @@ func (s *Server) handleInitialized(ctx context.Context, params *lsproto.Initiali DebounceDelay: 500 * time.Millisecond, PushDiagnosticsEnabled: !disablePushDiagnostics, Locale: s.locale, + LoadExternalPlugins: loadExternalPlugins, }, FS: s.fs, Logger: s.logger, Client: s, NpmExecutor: s, + Spawner: s.contentMapperSpawner(), ParseCache: s.parseCache, }) @@ -1361,9 +1631,9 @@ func (s *Server) handleSetLogVerbosity(_ context.Context, params *lsproto.SetLog return nil } -func (s *Server) handleDocumentDiagnostic(ctx context.Context, ls *ls.LanguageService, params *lsproto.DocumentDiagnosticParams) (lsproto.DocumentDiagnosticResponse, error) { +func (s *Server) handleDocumentDiagnostic(ctx context.Context, languageService *ls.LanguageService, params *lsproto.DocumentDiagnosticParams) (lsproto.DocumentDiagnosticResponse, error) { ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeDiagnostics) - return ls.ProvideDiagnostics(ctx, params.TextDocument.Uri) + return languageService.ProvideDiagnostics(ctx, params.TextDocument.Uri) } func (s *Server) handleHover(ctx context.Context, ls *ls.LanguageService, params *lsproto.HoverParams) (lsproto.HoverResponse, error) { @@ -1748,7 +2018,7 @@ func (s *Server) handleInitializeAPISession(ctx context.Context, params *lsproto pipePath = s.generateAPIPipePath() } - transport, err := api.NewPipeTransport(pipePath) + transport, err := ipc.NewPipeTransport(pipePath) if err != nil { return nil, fmt.Errorf("failed to create API transport: %w", err) } @@ -1783,7 +2053,7 @@ func (s *Server) handleInitializeAPISession(ctx context.Context, params *lsproto } }() - conn := api.NewAsyncConn(rwc, apiSession) + conn := ipc.NewAsyncConn(rwc, apiSession) if apiErr := conn.Run(apiCtx); apiErr != nil { s.logger.Errorf("API session %s: %v", apiSession.ID(), apiErr) } @@ -1801,7 +2071,7 @@ func (s *Server) generateAPIPipePath() string { // Generate a high-entropy path using time and random source now := time.Now().UnixNano() rnd := rand.Uint64() - return api.GeneratePipePath(fmt.Sprintf("tsgo-api-%x-%x", now, rnd)) + return ipc.GeneratePipePath(fmt.Sprintf("tsgo-api-%x-%x", now, rnd)) } func (s *Server) removeAPISession(id string) { @@ -1823,6 +2093,15 @@ func (s *Server) NpmInstall(cwd string, args []string) ([]byte, error) { return s.npmInstall(cwd, args) } +// contentMapperSpawner adapts the server's spawn callback to a content mapper spawner, or returns nil when +// the server cannot spawn processes. +func (s *Server) contentMapperSpawner() contentmapper.Spawner { + if s.spawn == nil { + return nil + } + return contentmapper.SpawnerFunc(s.spawn) +} + // Developer/debugging command handlers func (s *Server) handleRunGC(_ context.Context, _ lsproto.NoParams, _ *lsproto.RequestMessage) (lsproto.RunGCResponse, error) { @@ -1881,3 +2160,12 @@ func (s *Server) handleProjectInfo(ctx context.Context, params *lsproto.ProjectI ConfigFilePath: configFilePath, }, nil } + +func (s *Server) handleDiscoverContentMappers(ctx context.Context, params *lsproto.DiscoverContentMappersParams, _ *lsproto.RequestMessage) (lsproto.CustomDiscoverContentMappersResponse, error) { + textDocuments := make([]lsproto.DocumentUri, len(params.TextDocuments)) + for i, document := range params.TextDocuments { + textDocuments[i] = document.Uri + } + extensions := s.session.DiscoverContentMapperExtensions(ctx, textDocuments, params.Extensions) + return &lsproto.DiscoverContentMappersResult{Extensions: extensions}, nil +} diff --git a/internal/lsp/server_contentmapper_test.go b/internal/lsp/server_contentmapper_test.go new file mode 100644 index 00000000000..e38d9eeb0f9 --- /dev/null +++ b/internal/lsp/server_contentmapper_test.go @@ -0,0 +1,185 @@ +package lsp_test + +import ( + "context" + "io" + "strings" + "sync" + "testing" + + "github.com/microsoft/typescript-go/internal/bundled" + "github.com/microsoft/typescript-go/internal/lsp" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/testutil/contentmappertest" + "github.com/microsoft/typescript-go/internal/testutil/lsptestutil" + "github.com/microsoft/typescript-go/internal/vfs/vfstest" + "gotest.tools/v3/assert" +) + +func TestDiscoverContentMappersBeforeDidOpen(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + + const component = ` + +` + files := map[string]string{ + "/home/project/tsconfig.json": `{ + "compilerOptions": { "target": "es2020", "module": "esnext", "moduleResolution": "bundler", "strict": true }, + "contentMappers": [ { "package": "mapper", "extensions": [".vue"] } ] + }`, + "/home/project/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.ComponentMapper), + "/home/project/ProfileCard.vue": component, + } + + var mu sync.Mutex + var registrations []*lsproto.Registration + var unregistrations []*lsproto.Unregistration + unregisteredSignal := make(chan struct{}, 1) + onServerRequest := func(_ context.Context, req *lsproto.RequestMessage) *lsproto.ResponseMessage { + switch req.Method { + case lsproto.MethodWorkspaceConfiguration: + return &lsproto.ResponseMessage{ID: req.ID, JSONRPC: req.JSONRPC, Result: []any{nil, nil, nil, nil}} + case lsproto.MethodClientRegisterCapability: + params, err := lsproto.UnmarshalParams[*lsproto.RegistrationParams](req) + assert.NilError(t, err) + mu.Lock() + registrations = append(registrations, params.Registrations...) + mu.Unlock() + return &lsproto.ResponseMessage{ID: req.ID, JSONRPC: req.JSONRPC, Result: lsproto.Null{}} + case lsproto.MethodClientUnregisterCapability: + params, err := lsproto.UnmarshalParams[*lsproto.UnregistrationParams](req) + assert.NilError(t, err) + mu.Lock() + unregistrations = append(unregistrations, params.Unregisterations...) + mu.Unlock() + unregisteredSignal <- struct{}{} + return &lsproto.ResponseMessage{ID: req.ID, JSONRPC: req.JSONRPC, Result: lsproto.Null{}} + default: + return nil + } + } + + fs := bundled.WrapFS(vfstest.FromMap(files, false)) + client, closeClient := lsptestutil.NewLSPClient(t, lsp.ServerOptions{ + Err: io.Discard, + Cwd: "/home/project", + FS: fs, + DefaultLibraryPath: bundled.LibPath(), + Spawn: contentmappertest.NewSpawner().Spawn, + }, onServerRequest) + t.Cleanup(func() { _ = closeClient() }) + + caps := &lsproto.ClientCapabilities{TextDocument: &lsproto.TextDocumentClientCapabilities{ + Synchronization: &lsproto.TextDocumentSyncClientCapabilities{DynamicRegistration: new(true)}, + }} + initMsg, _, ok := lsptestutil.SendRequest(t, client, lsproto.InitializeInfo, &lsproto.InitializeParams{ + Capabilities: caps, + InitializationOptions: &lsproto.InitializationOptionsOrNull{InitializationOptions: &lsproto.InitializationOptions{ + LoadExternalPlugins: new(true), + }}, + }) + assert.Assert(t, ok && initMsg.AsResponse().Error == nil, "initialize failed") + lsptestutil.SendNotification(t, client, lsproto.InitializedInfo, &lsproto.InitializedParams{}) + <-client.Server.InitComplete() + + uri := lsproto.DocumentUri("file:///home/project/ProfileCard.vue") + msg, result, ok := lsptestutil.SendRequest(t, client, lsproto.CustomDiscoverContentMappersInfo, &lsproto.DiscoverContentMappersParams{ + TextDocuments: []lsproto.TextDocumentIdentifier{{Uri: uri}}, + Extensions: []string{".vue", ".svelte"}, + }) + assert.Assert(t, ok && msg.AsResponse().Error == nil) + assert.DeepEqual(t, result.Extensions, []string{".vue"}) + + mu.Lock() + registered := append([]*lsproto.Registration(nil), registrations...) + mu.Unlock() + assert.Assert(t, len(registered) > 0, "expected dynamic registrations") + var foundDidOpen bool + for _, registration := range registered { + if registration.Id != "content-mapper-did-open" && registration.Id != "content-mapper-did-change" && registration.Id != "content-mapper-did-close" { + assert.Assert(t, !strings.HasPrefix(registration.Id, "content-mapper-"), "unexpected unsupported content mapper registration %q", registration.Id) + } + if registration.Id == "content-mapper-did-open" { + foundDidOpen = true + assert.Assert(t, registration.RegisterOptions != nil && registration.RegisterOptions.TextDocumentDidOpen != nil) + selector := registration.RegisterOptions.TextDocumentDidOpen.DocumentSelector.DocumentSelector + assert.Assert(t, selector != nil && len(*selector) == 1) + assert.Equal(t, *(*selector)[0].Pattern.Pattern.Pattern, "**/*.vue") + } + } + assert.Assert(t, foundDidOpen, "expected didOpen registration for .vue") + + lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{ + TextDocument: &lsproto.TextDocumentItem{Uri: uri, LanguageId: "vue", Version: 1, Text: component}, + }) + hoverMsg, hover, ok := lsptestutil.SendRequest(t, client, lsproto.TextDocumentHoverInfo, &lsproto.HoverParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: uri}, + Position: lsproto.Position{Line: 3, Character: 15}, + }) + assert.Assert(t, ok && hoverMsg.AsResponse().Error == nil) + assert.Assert(t, hover.Hover != nil, "expected hover after first foreign didOpen") + + assert.NilError(t, fs.WriteFile("/home/project/tsconfig.json", `{ + "compilerOptions": { "target": "es2020", "module": "esnext", "moduleResolution": "bundler", "strict": true } + }`)) + lsptestutil.SendNotification(t, client, lsproto.WorkspaceDidChangeWatchedFilesInfo, &lsproto.DidChangeWatchedFilesParams{ + Changes: []*lsproto.FileEvent{{Uri: "file:///home/project/tsconfig.json", Type: lsproto.FileChangeTypeChanged}}, + }) + hoverMsg, hover, _ = lsptestutil.SendRequest(t, client, lsproto.TextDocumentHoverInfo, &lsproto.HoverParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: uri}, + Position: lsproto.Position{Line: 3, Character: 15}, + }) + assert.Assert(t, hoverMsg != nil && hoverMsg.AsResponse().Error == nil, "request before didClose should return a null result") + assert.Assert(t, hover.Hover == nil) + diagnosticMsg, diagnostics, ok := lsptestutil.SendRequest(t, client, lsproto.TextDocumentDiagnosticInfo, &lsproto.DocumentDiagnosticParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: uri}, + }) + assert.Assert(t, ok && diagnosticMsg.AsResponse().Error == nil, "diagnostics before didClose should return an empty report") + assert.Assert(t, diagnostics.FullDocumentDiagnosticReport != nil) + assert.Equal(t, len(diagnostics.FullDocumentDiagnosticReport.Items), 0) + completionMsg, completion, ok := lsptestutil.SendRequest(t, client, lsproto.TextDocumentCompletionInfo, &lsproto.CompletionParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: uri}, + Position: lsproto.Position{Line: 3, Character: 15}, + }) + assert.Assert(t, ok && completionMsg.AsResponse().Error == nil) + assert.Assert(t, completion.Items == nil && completion.List == nil) + referencesMsg, references, ok := lsptestutil.SendRequest(t, client, lsproto.TextDocumentReferencesInfo, &lsproto.ReferenceParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: uri}, + Position: lsproto.Position{Line: 3, Character: 15}, + Context: &lsproto.ReferenceContext{IncludeDeclaration: true}, + }) + assert.Assert(t, ok && referencesMsg.AsResponse().Error == nil) + assert.Assert(t, references.Locations == nil) + renameMsg, rename, ok := lsptestutil.SendRequest(t, client, lsproto.TextDocumentRenameInfo, &lsproto.RenameParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: uri}, + Position: lsproto.Position{Line: 3, Character: 15}, + NewName: "renamed", + }) + assert.Assert(t, ok && renameMsg.AsResponse().Error == nil) + assert.Assert(t, rename.WorkspaceEdit == nil) + <-unregisteredSignal + + mu.Lock() + unregistered := append([]*lsproto.Unregistration(nil), unregistrations...) + mu.Unlock() + assert.Assert(t, len(unregistered) > 0, "expected dynamic unregistration") + var foundDidClose bool + for _, unregistration := range unregistered { + if unregistration.Id != "content-mapper-did-open" && unregistration.Id != "content-mapper-did-change" && unregistration.Id != "content-mapper-did-close" { + assert.Assert(t, !strings.HasPrefix(unregistration.Id, "content-mapper-"), "unexpected unsupported content mapper unregistration %q", unregistration.Id) + } + if unregistration.Id == "content-mapper-did-close" { + foundDidClose = true + } + } + assert.Assert(t, foundDidClose, "expected didClose unregistration") + + lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidCloseInfo, &lsproto.DidCloseTextDocumentParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: uri}, + }) +} diff --git a/internal/module/resolver.go b/internal/module/resolver.go index 52400dc2b98..3809ae4a5c9 100644 --- a/internal/module/resolver.go +++ b/internal/module/resolver.go @@ -18,11 +18,12 @@ import ( ) type resolved struct { - path string - extension string - packageId PackageId - originalPath string - resolvedUsingTsExtension bool + path string + extension string + packageId PackageId + originalPath string + resolvedUsingTsExtension bool + resolvedUsingExtraExtensions bool } func (r *resolved) shouldContinueSearching() bool { @@ -158,6 +159,7 @@ type Resolver struct { compilerOptions *core.CompilerOptions typingsLocation string projectName string + extraExtensions []string // reportDiagnostic: DiagnosticReporter } @@ -170,6 +172,7 @@ func NewResolver( options *core.CompilerOptions, typingsLocation string, projectName string, + extraExtensions []string, ) *Resolver { return &Resolver{ host: host, @@ -177,6 +180,7 @@ func NewResolver( compilerOptions: options, typingsLocation: typingsLocation, projectName: projectName, + extraExtensions: extraExtensions, } } @@ -1178,6 +1182,7 @@ func (r *resolutionState) createResolvedModule(resolved *resolved, isExternalLib resolvedModule.OriginalPath = resolved.originalPath resolvedModule.IsExternalLibraryImport = isExternalLibraryImport resolvedModule.ResolvedUsingTsExtension = resolved.resolvedUsingTsExtension + resolvedModule.ResolvedUsingExtraExtensions = resolved.resolvedUsingExtraExtensions resolvedModule.Extension = resolved.extension resolvedModule.PackageId = resolved.packageId } @@ -1440,7 +1445,11 @@ func (r *resolutionState) loadModuleFromFileNoImplicitExtensions(extensions exte extensionless := tspath.RemoveFileExtension(candidate) if extensionless == candidate { // Once TS native extensions are handled, handle arbitrary extensions for declaration file mapping - extensionless = candidate[:strings.LastIndex(candidate, ".")] + extension := tspath.GetLongestExtensionFromPath(candidate, r.resolver.extraExtensions, false) + if extension == "" { + extension = candidate[strings.LastIndex(candidate, "."):] + } + extensionless = tspath.RemoveExtension(candidate, extension) } extension := candidate[len(extensionless):] @@ -1558,6 +1567,13 @@ func (r *resolutionState) tryAddingExtensions(extensionless string, extensions e } return continueSearching() default: + if slices.Contains(r.resolver.extraExtensions, originalExtension) { + // A fully specified import of an extraExtension resolves directly to the file. + if resolved := r.tryExtension(originalExtension, extensionless, false); !resolved.shouldContinueSearching() { + resolved.resolvedUsingExtraExtensions = true + return resolved + } + } if extensions&extensionsDeclaration != 0 && !tspath.IsDeclarationFileName(extensionless+originalExtension) { if resolved := r.tryExtension(".d"+originalExtension+".ts", extensionless, false); !resolved.shouldContinueSearching() { return resolved @@ -2075,7 +2091,7 @@ func extensionIsOk(extensions extensions, extension string) bool { } func ResolveConfig(moduleName string, containingFile string, host ResolutionHost) *ResolvedModule { - resolver := NewResolver(host, &core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindNodeNext}, "", "") + resolver := NewResolver(host, &core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindNodeNext}, "", "", nil) return resolver.resolveConfig(moduleName, containingFile) } diff --git a/internal/module/resolver_test.go b/internal/module/resolver_test.go index c15209769ad..9902713f15d 100644 --- a/internal/module/resolver_test.go +++ b/internal/module/resolver_test.go @@ -39,7 +39,7 @@ func TestResolveModuleNameTrailingSlash(t *testing.T) { Module: core.ModuleKindESNext, Target: core.ScriptTargetESNext, } - resolver := module.NewResolver(host, opts, "", "") + resolver := module.NewResolver(host, opts, "", "", nil) for _, name := range []string{"pkg", "pkg/"} { r, _ := resolver.ResolveModuleName(name, "/repo/src/file.ts", core.ModuleKindESNext, nil) @@ -168,7 +168,7 @@ func TestResolveModuleNameTrailingSlashRace(t *testing.T) { Module: core.ModuleKindESNext, Target: core.ScriptTargetESNext, } - resolver := module.NewResolver(host, opts, "", "") + resolver := module.NewResolver(host, opts, "", "", nil) type resolutionResult struct { name string @@ -240,7 +240,7 @@ func TestResolveSubpathNilContentsRace(t *testing.T) { Module: core.ModuleKindESNext, Target: core.ScriptTargetESNext, } - resolver := module.NewResolver(host, opts, "", "") + resolver := module.NewResolver(host, opts, "", "", nil) var panicked atomic.Bool type resolutionResult struct { @@ -363,7 +363,7 @@ func TestResolvePeerDependencyNilContentsRace(t *testing.T) { Module: core.ModuleKindESNext, Target: core.ScriptTargetESNext, } - resolver := module.NewResolver(host, opts, "", "") + resolver := module.NewResolver(host, opts, "", "", nil) var panicked atomic.Bool type resolutionResult struct { diff --git a/internal/module/types.go b/internal/module/types.go index 32d18530295..0662959bc1c 100644 --- a/internal/module/types.go +++ b/internal/module/types.go @@ -63,14 +63,15 @@ func (p *PackageId) PackageName() string { } type ResolvedModule struct { - ResolutionDiagnostics []*ast.Diagnostic - ResolvedFileName string - OriginalPath string - Extension string - ResolvedUsingTsExtension bool - PackageId PackageId - IsExternalLibraryImport bool - AlternateResult string + ResolutionDiagnostics []*ast.Diagnostic + ResolvedFileName string + OriginalPath string + Extension string + ResolvedUsingTsExtension bool + ResolvedUsingExtraExtensions bool + PackageId PackageId + IsExternalLibraryImport bool + AlternateResult string } func (r *ResolvedModule) IsResolved() bool { diff --git a/internal/module/util.go b/internal/module/util.go index bb48dedcb11..79f1f6489fa 100644 --- a/internal/module/util.go +++ b/internal/module/util.go @@ -151,6 +151,10 @@ func GetResolutionDiagnostic(options *core.CompilerOptions, resolvedModule *Reso return diagnostics.Module_0_was_resolved_to_1_but_allowArbitraryExtensions_is_not_set } + if resolvedModule.ResolvedUsingExtraExtensions { + return nil + } + switch resolvedModule.Extension { case tspath.ExtensionTs, tspath.ExtensionDts, tspath.ExtensionMts, tspath.ExtensionDmts, diff --git a/internal/modulespecifiers/specifiers_test.go b/internal/modulespecifiers/specifiers_test.go index 201ce408e20..62177712dc8 100644 --- a/internal/modulespecifiers/specifiers_test.go +++ b/internal/modulespecifiers/specifiers_test.go @@ -15,6 +15,7 @@ import ( // Mock host for testing type mockModuleSpecifierGenerationHost struct { currentDir string + contentMapperExtensions []string useCaseSensitiveFileNames bool symlinkCache *symlinks.KnownSymlinks } @@ -43,6 +44,10 @@ func (h *mockModuleSpecifierGenerationHost) CommonSourceDirectory() string { return h.currentDir } +func (h *mockModuleSpecifierGenerationHost) ContentMapperExtensions() []string { + return h.contentMapperExtensions +} + func (h *mockModuleSpecifierGenerationHost) GetProjectReferenceFromSource(path tspath.Path) *tsoptions.SourceOutputAndProjectReference { return nil } diff --git a/internal/modulespecifiers/types.go b/internal/modulespecifiers/types.go index e164cc15da3..2fb9b6104c4 100644 --- a/internal/modulespecifiers/types.go +++ b/internal/modulespecifiers/types.go @@ -44,6 +44,7 @@ type ModuleSpecifierGenerationHost interface { GetSymlinkCache() *symlinks.KnownSymlinks // GetFileIncludeReasons() any // !!! TODO: adapt new resolution cache model CommonSourceDirectory() string + ContentMapperExtensions() []string GetGlobalTypingsCacheLocation() string UseCaseSensitiveFileNames() bool GetCurrentDirectory() string diff --git a/internal/modulespecifiers/util.go b/internal/modulespecifiers/util.go index a765e88429e..27230b42756 100644 --- a/internal/modulespecifiers/util.go +++ b/internal/modulespecifiers/util.go @@ -198,18 +198,7 @@ func tryGetAnyFileFromPath(host ModuleSpecifierGenerationHost, path string) bool &core.CompilerOptions{ AllowJs: core.TSTrue, }, - []tsoptions.FileExtensionInfo{ - { - Extension: "node", - IsMixedContent: false, - ScriptKind: core.ScriptKindExternal, - }, - { - Extension: "json", - IsMixedContent: false, - ScriptKind: core.ScriptKindJSON, - }, - }, + []string{".node", ".json"}, ) for _, exts := range extGroups { for _, e := range exts { diff --git a/internal/outputpaths/outputpaths.go b/internal/outputpaths/outputpaths.go index 09d977e6f59..9968da3c097 100644 --- a/internal/outputpaths/outputpaths.go +++ b/internal/outputpaths/outputpaths.go @@ -10,6 +10,7 @@ import ( type OutputPathsHost interface { CommonSourceDirectory() string + ContentMapperExtensions() []string GetCurrentDirectory() string UseCaseSensitiveFileNames() bool } @@ -49,7 +50,7 @@ func GetOutputPathsFor(sourceFile *ast.SourceFile, options *core.CompilerOptions UseCaseSensitiveFileNames: host.UseCaseSensitiveFileNames(), }) == 0 paths := &OutputPaths{} - if options.EmitDeclarationOnly != core.TSTrue && !isJsonEmittedToSameLocation { + if sourceFile.ContentMapper() == "" && options.EmitDeclarationOnly != core.TSTrue && !isJsonEmittedToSameLocation { paths.jsFilePath = ownOutputFilePath if !ast.IsJsonSourceFile(sourceFile) { paths.sourceMapFilePath = GetSourceMapFilePath(paths.jsFilePath, options) @@ -57,7 +58,7 @@ func GetOutputPathsFor(sourceFile *ast.SourceFile, options *core.CompilerOptions } if forceDtsEmit || options.GetEmitDeclarations() && !isJsonFile { paths.declarationFilePath = GetDeclarationEmitOutputFilePath(sourceFile.FileName(), options, host) - if options.GetAreDeclarationMapsEnabled() { + if sourceFile.ContentMapper() == "" && options.GetAreDeclarationMapsEnabled() { paths.declarationMapPath = paths.declarationFilePath + ".map" } } @@ -100,10 +101,7 @@ func GetOutputDeclarationFileNameWorker(inputFileName string, options *core.Comp if len(dir) == 0 { dir = options.OutDir } - return tspath.ChangeExtension( - getOutputPathWithoutChangingExtension(inputFileName, dir, host), - tspath.GetDeclarationEmitExtensionForPath(inputFileName), - ) + return ChangeToDeclarationExtension(getOutputPathWithoutChangingExtension(inputFileName, dir, host), host) } func GetOutputExtension(fileName string, jsx core.JsxEmit) string { @@ -135,8 +133,20 @@ func GetDeclarationEmitOutputFilePath(file string, options *core.CompilerOptions } else { path = file } - declarationExtension := tspath.GetDeclarationEmitExtensionForPath(path) - return tspath.RemoveFileExtension(path) + declarationExtension + return ChangeToDeclarationExtension(path, host) +} + +func ChangeToDeclarationExtension(path string, host OutputPathsHost) string { + if extension := tspath.GetLongestExtensionFromPath(path, host.ContentMapperExtensions(), false); extension != "" { + return tspath.RemoveExtension(path, extension) + ".d" + extension + ".ts" + } + pathWithoutExtension := tspath.RemoveFileExtension(path) + if pathWithoutExtension == path { + if extension := tspath.GetAnyExtensionFromPath(path, nil, false); extension != "" { + pathWithoutExtension = tspath.RemoveExtension(path, extension) + } + } + return pathWithoutExtension + tspath.GetDeclarationEmitExtensionForPath(path) } func GetSourceFilePathInNewDir(fileName string, newDirPath string, currentDirectory string, commonSourceDirectory string, useCaseSensitiveFileNames bool) string { diff --git a/internal/packagejson/packagejson.go b/internal/packagejson/packagejson.go index d9ff92879a9..70094c24d3b 100644 --- a/internal/packagejson/packagejson.go +++ b/internal/packagejson/packagejson.go @@ -111,6 +111,12 @@ type Fields struct { HeaderFields PathFields DependencyFields + ContentMapper Expected[ContentMapperFields] `json:"tsContentMapper"` +} + +type ContentMapperFields struct { + Exec Expected[[]string] `json:"exec"` + CompilerOptions Expected[[]string] `json:"compilerOptions"` } func Parse(data []byte) (Fields, error) { diff --git a/internal/packagejson/packagejson_test.go b/internal/packagejson/packagejson_test.go index 832ec5221a7..4af6fa5af90 100644 --- a/internal/packagejson/packagejson_test.go +++ b/internal/packagejson/packagejson_test.go @@ -96,6 +96,8 @@ func TestParse(t *testing.T) { packagejson.HeaderFields{}, packagejson.Expected[string]{}, packagejson.Expected[map[string]string]{}, + packagejson.Expected[[]string]{}, + packagejson.Expected[packagejson.ContentMapperFields]{}, packagejson.ExportsOrImports{}, )) }) diff --git a/internal/project/ata/ata.go b/internal/project/ata/ata.go index f877f718d47..7018d96c63f 100644 --- a/internal/project/ata/ata.go +++ b/internal/project/ata/ata.go @@ -186,7 +186,7 @@ func (ti *TypingsInstaller) installTypings( if packageNames, ok := ti.installWorker(projectID, requestID, scopedTypings, logger); ok { logger.Log(fmt.Sprintf("ATA:: Installed typings %v", packageNames)) var installedTypingFiles []string - resolver := module.NewResolver(ti.host, &core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindNodeNext}, "", "") + resolver := module.NewResolver(ti.host, &core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindNodeNext}, "", "", nil) for _, packageName := range filteredTypings { typingFile := ti.typingToFileName(resolver, packageName) if typingFile == "" { @@ -416,7 +416,7 @@ func (ti *TypingsInstaller) processCacheLocation(projectID string, fs vfs.FS, lo logger.Log("ATA:: Loaded content of " + packageLockJson + ": " + npmLockContents) // !!! sheetal strada uses Node10 - resolver := module.NewResolver(ti.host, &core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindNodeNext}, "", "") + resolver := module.NewResolver(ti.host, &core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindNodeNext}, "", "", nil) if npmConfig.DevDependencies != nil && (npmLock.Packages != nil || npmLock.Dependencies != nil) { for key := range npmConfig.DevDependencies { npmLockValue, npmLockValueExists := npmLock.Packages["node_modules/"+key] diff --git a/internal/project/client.go b/internal/project/client.go index 98ff57b6c3d..e4f6f081fd5 100644 --- a/internal/project/client.go +++ b/internal/project/client.go @@ -10,6 +10,7 @@ import ( type Client interface { WatchFiles(ctx context.Context, id WatcherID, watchers []*lsproto.FileSystemWatcher) error UnwatchFiles(ctx context.Context, id WatcherID) error + RegisterContentMapperExtensions(ctx context.Context, extensions []string) error RefreshDiagnostics(ctx context.Context) error PublishDiagnostics(ctx context.Context, params *lsproto.PublishDiagnosticsParams) error RefreshInlayHints(ctx context.Context) error diff --git a/internal/project/compilerhost.go b/internal/project/compilerhost.go index 212601d4b88..106a73f0117 100644 --- a/internal/project/compilerhost.go +++ b/internal/project/compilerhost.go @@ -3,6 +3,8 @@ package project import ( "github.com/microsoft/typescript-go/internal/ast" "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/project/logging" @@ -102,6 +104,24 @@ func (c *compilerHost) GetSourceFile(opts ast.SourceFileParseOptions) *ast.Sourc return nil } +// GetContentMappedSourceFile implements compiler.CompilerHost. +func (c *compilerHost) GetContentMappedSourceFile(parseOptions ast.SourceFileParseOptions, mapper *contentmapper.Mapper, options *core.CompilerOptions) (*ast.SourceFile, error) { + c.ensureAlive() + fh := c.sourceFS.GetFileByPath(parseOptions.FileName, parseOptions.Path) + if fh == nil { + return nil, nil + } + key := contentMappedParseCacheKey(parseOptions, fh.Hash(), mapper.TransformIdentity(options)) + return c.builder.parseCache.AcquireOrError(key, func() (*ast.SourceFile, error) { + file, err := contentmapper.TransformAndParse(parseOptions, fh.Content(), mapper, options, c.builder.contentMapperHost) + if err != nil { + return nil, err + } + file.Hash = key.Hash + return file, nil + }) +} + // Trace implements compiler.CompilerHost. func (c *compilerHost) Trace(msg *diagnostics.Message, args ...any) { c.logger.Log(msg.Localize(locale.Default, args...)) diff --git a/internal/project/configfileregistry.go b/internal/project/configfileregistry.go index 011dcf3481c..624d20c72e8 100644 --- a/internal/project/configfileregistry.go +++ b/internal/project/configfileregistry.go @@ -3,7 +3,10 @@ package project import ( "iter" "maps" + "slices" + "github.com/microsoft/typescript-go/internal/collections" + "github.com/microsoft/typescript-go/internal/contentmapper" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/tsoptions" @@ -19,7 +22,47 @@ type ConfigFileRegistry struct { configFileNames map[tspath.Path]*configFileNames // customConfigFileName is the custom config file name preference that was // used when building this registry's configFileNames cache. - customConfigFileName string + customConfigFileName string + allConfiguredContentMappers *configuredContentMappers +} + +type configuredContentMappers struct { + extensions []string + mappers []*contentmapper.Mapper +} + +func collectConfiguredContentMappers(commandLines []*tsoptions.ParsedCommandLine) *configuredContentMappers { + var seenExtensions collections.Set[string] + var seenMappers collections.Set[*contentmapper.Mapper] + var extensions []string + var mappers []*contentmapper.Mapper + for _, commandLine := range commandLines { + for _, mapper := range commandLine.ContentMappers() { + if seenMappers.AddIfAbsent(mapper) { + mappers = append(mappers, mapper) + } + for _, extension := range mapper.Extensions { + if seenExtensions.AddIfAbsent(extension) { + extensions = append(extensions, extension) + } + } + } + } + slices.Sort(extensions) + return &configuredContentMappers{extensions: extensions, mappers: mappers} +} + +func (c *ConfigFileRegistry) contentMappers() *configuredContentMappers { + if c.allConfiguredContentMappers != nil { + return c.allConfiguredContentMappers + } + var commandLines []*tsoptions.ParsedCommandLine + for _, entry := range c.configs { + if entry.commandLine != nil { + commandLines = append(commandLines, entry.commandLine) + } + } + return collectConfiguredContentMappers(commandLines) } type configFileEntry struct { @@ -108,9 +151,10 @@ func (c *ConfigFileRegistry) GetAncestorConfigFileName(path tspath.Path, higherT // clone creates a shallow copy of the configFileRegistry. func (c *ConfigFileRegistry) clone() *ConfigFileRegistry { return &ConfigFileRegistry{ - configs: maps.Clone(c.configs), - configFileNames: maps.Clone(c.configFileNames), - customConfigFileName: c.customConfigFileName, + configs: maps.Clone(c.configs), + configFileNames: maps.Clone(c.configFileNames), + customConfigFileName: c.customConfigFileName, + allConfiguredContentMappers: c.allConfiguredContentMappers, } } diff --git a/internal/project/configfileregistrybuilder.go b/internal/project/configfileregistrybuilder.go index 1d62a11880a..0e4ed6d3363 100644 --- a/internal/project/configfileregistrybuilder.go +++ b/internal/project/configfileregistrybuilder.go @@ -5,6 +5,7 @@ import ( "maps" "slices" "strings" + "sync" "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/core" @@ -36,6 +37,8 @@ type configFileRegistryBuilder struct { configs *dirty.SyncMap[tspath.Path, *configFileEntry] configFileNames *dirty.Map[tspath.Path, *configFileNames] customConfigFileNameChanged bool + contentMappersMu sync.Mutex + allConfiguredContentMappers *configuredContentMappers } func newConfigFileRegistryBuilder( @@ -58,6 +61,7 @@ func newConfigFileRegistryBuilder( snapshotID: snapshotID, customConfigFileName: customConfigFileName, customConfigFileNameChanged: customConfigFileName != oldConfigFileRegistry.customConfigFileName, + allConfiguredContentMappers: oldConfigFileRegistry.contentMappers(), configs: dirty.NewSyncMap(oldConfigFileRegistry.configs), configFileNames: dirty.NewMap(oldConfigFileRegistry.configFileNames), @@ -79,6 +83,7 @@ func (c *configFileRegistryBuilder) Finalize() *ConfigFileRegistry { if configs, changedConfigs := c.configs.Finalize(); changedConfigs { ensureCloned() newRegistry.configs = configs + newRegistry.allConfiguredContentMappers = c.contentMappers() } if configFileNames, changedNames := c.configFileNames.Finalize(); changedNames { @@ -94,6 +99,28 @@ func (c *configFileRegistryBuilder) Finalize() *ConfigFileRegistry { return newRegistry } +func (c *configFileRegistryBuilder) contentMappers() *configuredContentMappers { + c.contentMappersMu.Lock() + defer c.contentMappersMu.Unlock() + if c.allConfiguredContentMappers == nil { + var commandLines []*tsoptions.ParsedCommandLine + c.configs.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *configFileEntry]) bool { + if commandLine := entry.Value().commandLine; commandLine != nil { + commandLines = append(commandLines, commandLine) + } + return true + }) + c.allConfiguredContentMappers = collectConfiguredContentMappers(commandLines) + } + return c.allConfiguredContentMappers +} + +func (c *configFileRegistryBuilder) invalidateContentMappers() { + c.contentMappersMu.Lock() + c.allConfiguredContentMappers = nil + c.contentMappersMu.Unlock() +} + func (c *configFileRegistryBuilder) findOrAcquireConfigForFile( configFileName string, configFilePath tspath.Path, @@ -117,22 +144,29 @@ func (c *configFileRegistryBuilder) findOrAcquireConfigForFile( // reloadIfNeeded updates the command line of the config file entry based on its // pending reload state. This function should only be called from within the // Change() method of a dirty map entry. -func (c *configFileRegistryBuilder) reloadIfNeeded(entry *configFileEntry, fileName string, path tspath.Path, logger *logging.LogTree) { +func (c *configFileRegistryBuilder) reloadIfNeeded(entry *configFileEntry, fileName string, path tspath.Path, logger *logging.LogTree) bool { + oldCommandLine := entry.commandLine switch entry.pendingReload { case PendingReloadFileNames: logger.Log("Reloading file names for config: " + fileName) entry.commandLine = entry.commandLine.ReloadFileNamesOfParsedCommandLine(c.fs) case PendingReloadFull: logger.Log("Loading config file: " + fileName) - oldCommandLine := entry.commandLine - entry.commandLine, _ = tsoptions.GetParsedCommandLineOfConfigFilePath(fileName, path, nil, nil /*optionsRaw*/, c, c) + // When the workspace is trusted, enable external content mappers so a config's contentMappers pass + // the loadExternalPlugins gate and register, as they would with the CLI flag. + var existingOptions *core.CompilerOptions + if c.sessionOptions.LoadExternalPlugins { + existingOptions = &core.CompilerOptions{LoadExternalPlugins: core.TSTrue} + } + entry.commandLine, _ = tsoptions.GetParsedCommandLineOfConfigFilePath(fileName, path, existingOptions, nil /*optionsRaw*/, c, c) c.updateExtendingConfigs(path, entry.commandLine, oldCommandLine) c.updateRootFilesWatch(fileName, entry) logger.Log("Finished loading config file") default: - return + return false } entry.pendingReload = PendingReloadNone + return oldCommandLine != entry.commandLine } func (c *configFileRegistryBuilder) updateExtendingConfigs(extendingConfigPath tspath.Path, newCommandLine *tsoptions.ParsedCommandLine, oldCommandLine *tsoptions.ParsedCommandLine) { @@ -248,6 +282,7 @@ func (c *configFileRegistryBuilder) updateRootFilesWatch(fileName string, entry func (c *configFileRegistryBuilder) acquireConfigForProject(fileName string, path tspath.Path, project *Project, logger *logging.LogTree) *tsoptions.ParsedCommandLine { entry, _ := c.configs.LoadOrStore(path, newConfigFileEntry(c.hasRelativePatternCapability, fileName)) var needsRetainProject bool + var contentMappersChanged bool entry.ChangeIf( func(config *configFileEntry) bool { _, alreadyRetaining := config.retainingProjects[project.configFilePath] @@ -261,9 +296,12 @@ func (c *configFileRegistryBuilder) acquireConfigForProject(fileName string, pat } config.retainingProjects[project.configFilePath] = struct{}{} } - c.reloadIfNeeded(config, fileName, path, logger) + contentMappersChanged = c.reloadIfNeeded(config, fileName, path, logger) }, ) + if contentMappersChanged { + c.invalidateContentMappers() + } return entry.Value().commandLine } @@ -274,6 +312,7 @@ func (c *configFileRegistryBuilder) acquireConfigForProject(fileName string, pat func (c *configFileRegistryBuilder) acquireConfigForFile(configFileName string, configFilePath tspath.Path, filePath tspath.Path, logger *logging.LogTree) *tsoptions.ParsedCommandLine { entry, _ := c.configs.LoadOrStore(configFilePath, newConfigFileEntry(c.hasRelativePatternCapability, configFileName)) var needsRetainOpenFile bool + var contentMappersChanged bool entry.ChangeIf( func(config *configFileEntry) bool { if c.isOpenFile(filePath) { @@ -289,9 +328,12 @@ func (c *configFileRegistryBuilder) acquireConfigForFile(configFileName string, } config.retainingOpenFiles[filePath] = struct{}{} } - c.reloadIfNeeded(config, configFileName, configFilePath, logger) + contentMappersChanged = c.reloadIfNeeded(config, configFileName, configFilePath, logger) }, ) + if contentMappersChanged { + c.invalidateContentMappers() + } return entry.Value().commandLine } @@ -724,10 +766,16 @@ func (c *configFileRegistryBuilder) GetExtendedConfig(fileName string, path tspa } func (c *configFileRegistryBuilder) Cleanup() { + changed := false c.configs.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *configFileEntry]) bool { entry.DeleteIf(func(value *configFileEntry) bool { - return len(value.retainingProjects) == 0 && len(value.retainingOpenFiles) == 0 && len(value.retainingConfigs) == 0 + shouldDelete := len(value.retainingProjects) == 0 && len(value.retainingOpenFiles) == 0 && len(value.retainingConfigs) == 0 + changed = changed || shouldDelete + return shouldDelete }) return true }) + if changed { + c.invalidateContentMappers() + } } diff --git a/internal/project/contentmapper_test.go b/internal/project/contentmapper_test.go new file mode 100644 index 00000000000..f82016248c0 --- /dev/null +++ b/internal/project/contentmapper_test.go @@ -0,0 +1,703 @@ +package project_test + +import ( + "context" + "errors" + "fmt" + "io" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/microsoft/typescript-go/internal/bundled" + "github.com/microsoft/typescript-go/internal/contentmapper" + "github.com/microsoft/typescript-go/internal/ls" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/project" + "github.com/microsoft/typescript-go/internal/testutil/contentmappertest" + "github.com/microsoft/typescript-go/internal/testutil/projecttestutil" + "gotest.tools/v3/assert" +) + +type recordingContentMapperSpawner struct { + inner contentmapper.Spawner + spawns atomic.Int32 + closes atomic.Int32 +} + +func (s *recordingContentMapperSpawner) Spawn(command []string, dir string) (io.ReadWriteCloser, error) { + process, err := s.inner.Spawn(command, dir) + if err != nil { + return nil, err + } + s.spawns.Add(1) + return &recordingContentMapperProcess{ReadWriteCloser: process, closes: &s.closes}, nil +} + +type recordingContentMapperProcess struct { + io.ReadWriteCloser + closes *atomic.Int32 + once sync.Once +} + +func (p *recordingContentMapperProcess) Close() error { + p.once.Do(func() { p.closes.Add(1) }) + return p.ReadWriteCloser.Close() +} + +func TestContentMapperInProject(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/home/project/tsconfig.json": `{ + "compilerOptions": { "target": "es2020", "module": "esnext", "moduleResolution": "bundler", "strict": true }, + "contentMappers": [ { "package": "mapper", "extensions": [".box"] } ] + }`, + "/home/project/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.TransformingMapper), + "/home/project/app.box": "export const version = #{target};\n", + "/home/project/main.ts": "import { version } from \"./app.box\";\nexport const twice: number = version * 2;\n", + } + + newSession := func(trusted bool) (*project.Session, *projecttestutil.SessionUtils) { + init, utils := projecttestutil.GetSessionInitOptions(files, &project.SessionOptions{ + CurrentDirectory: "/home/project", + DefaultLibraryPath: bundled.LibPath(), + TypingsLocation: projecttestutil.TestTypingsLocation, + PositionEncoding: lsproto.PositionEncodingKindUTF8, + LoggingEnabled: true, + LoadExternalPlugins: trusted, + }, nil) + init.Spawner = contentmappertest.NewSpawner() + return project.NewSession(init), utils + } + + t.Run("trusted workspace transforms the content-mapped file", func(t *testing.T) { + t.Parallel() + session, utils := newSession(true) + defer session.Close() + + session.DidOpenFile(context.Background(), "file:///home/project/main.ts", 1, files["/home/project/main.ts"].(string), lsproto.LanguageKindTypeScript) + ls, err := session.GetLanguageService(context.Background(), "file:///home/project/main.ts") + assert.NilError(t, err) + + boxFile := ls.GetProgram().GetSourceFile("/home/project/app.box") + assert.Assert(t, boxFile != nil, "expected app.box to be loaded into the program") + // The #{target} token was substituted with the es2020 target value (7) by the content mapper. + assert.Assert(t, strings.Contains(boxFile.Text(), "export const version = 7;"), "app.box was not transformed: %q", boxFile.Text()) + + // The config's .box mapper should have been registered for text document synchronization. + session.WaitForBackgroundTasks() + calls := utils.Client().RegisterContentMapperExtensionsCalls() + assert.Assert(t, len(calls) > 0, "expected RegisterContentMapperExtensions to be called") + assert.DeepEqual(t, calls[len(calls)-1].Extensions, []string{".box"}) + }) + + t.Run("untrusted workspace does not run the content mapper", func(t *testing.T) { + t.Parallel() + session, utils := newSession(false) + defer session.Close() + + session.DidOpenFile(context.Background(), "file:///home/project/main.ts", 1, files["/home/project/main.ts"].(string), lsproto.LanguageKindTypeScript) + ls, err := session.GetLanguageService(context.Background(), "file:///home/project/main.ts") + assert.NilError(t, err) + + // Without workspace trust, the content mapper gate drops the mappers, so .box is not a recognized + // extension and app.box never enters the program. + boxFile := ls.GetProgram().GetSourceFile("/home/project/app.box") + assert.Assert(t, boxFile == nil, "app.box should not be loaded without trust") + + // No content mapper extensions should be registered without trust. + session.WaitForBackgroundTasks() + for _, call := range utils.Client().RegisterContentMapperExtensionsCalls() { + assert.Equal(t, len(call.Extensions), 0, "expected no content mapper extensions to be registered without trust") + } + }) + + t.Run("editing an open content-mapped file reparses it through the mapper", func(t *testing.T) { + t.Parallel() + session, _ := newSession(true) + defer session.Close() + + ctx := context.Background() + session.DidOpenFile(ctx, "file:///home/project/main.ts", 1, files["/home/project/main.ts"].(string), lsproto.LanguageKindTypeScript) + // Open the .box with a foreign language id so its overlay script kind is Unknown, matching how an + // editor opens a content-mapped file. This is what made the incremental reparse panic. + session.DidOpenFile(ctx, "file:///home/project/app.box", 1, files["/home/project/app.box"].(string), lsproto.LanguageKind("box")) + _, err := session.GetLanguageService(ctx, "file:///home/project/main.ts") + assert.NilError(t, err) + + // Editing the open .box file drives the single-file incremental reparse path + // (Program.UpdateProgram), which must re-run the content mapper transform rather than parse the + // raw foreign text. + session.DidChangeFile(ctx, "file:///home/project/app.box", 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{ + {WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: "export const version = #{target};\nexport const extra = 1;\n"}}, + }) + ls, err := session.GetLanguageService(ctx, "file:///home/project/main.ts") + assert.NilError(t, err) + + boxFile := ls.GetProgram().GetSourceFile("/home/project/app.box") + assert.Assert(t, boxFile != nil, "expected app.box to be loaded") + assert.Assert(t, strings.Contains(boxFile.Text(), "export const version = 7;"), "reparsed app.box was not transformed: %q", boxFile.Text()) + assert.Assert(t, strings.Contains(boxFile.Text(), "export const extra = 1;"), "reparsed app.box missing the edit: %q", boxFile.Text()) + }) + + t.Run("watch change to a content-mapped file updates the program", func(t *testing.T) { + t.Parallel() + session, utils := newSession(true) + defer session.Close() + + ctx := context.Background() + mainURI := lsproto.DocumentUri("file:///home/project/main.ts") + session.DidOpenFile(ctx, mainURI, 1, files["/home/project/main.ts"].(string), lsproto.LanguageKindTypeScript) + languageService, err := session.GetLanguageService(ctx, mainURI) + assert.NilError(t, err) + original := languageService.GetProgram().GetSourceFile("/home/project/app.box") + assert.Assert(t, original != nil, "expected app.box to be loaded") + + // Wait until the configured extension set has been published; watch filtering uses the set captured + // when the snapshot change is created. + session.WaitForBackgroundTasks() + updatedContent := "export const version = #{target};\nexport const watched = true;\n" + assert.NilError(t, utils.FS().WriteFile("/home/project/app.box", updatedContent)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: "file:///home/project/app.box", + Type: lsproto.FileChangeTypeChanged, + }}) + + languageService, err = session.GetLanguageService(ctx, mainURI) + assert.NilError(t, err) + updatedSnapshot := session.Snapshot() + configuredProject := updatedSnapshot.GetDefaultProject(mainURI) + assert.Assert(t, configuredProject != nil, "expected configured project") + assert.Equal(t, configuredProject.ProgramUpdateKind, project.ProgramUpdateKindCloned) + assert.Equal(t, configuredProject.ProgramLastUpdate, updatedSnapshot.ID()) + updated := languageService.GetProgram().GetSourceFile("/home/project/app.box") + assert.Assert(t, updated != nil, "expected app.box to remain loaded") + assert.Assert(t, updated != original, "expected the watched content-mapped file to be reparsed") + assert.Assert(t, strings.Contains(updated.Text(), "export const version = 7;"), "updated app.box was not transformed: %q", updated.Text()) + assert.Assert(t, strings.Contains(updated.Text(), "export const watched = true;"), "updated app.box missing watched change: %q", updated.Text()) + }) + + t.Run("unchanged content-mapped file is reused from the cache across a full rebuild", func(t *testing.T) { + t.Parallel() + session, utils := newSession(true) + defer session.Close() + + ctx := context.Background() + session.DidOpenFile(ctx, "file:///home/project/main.ts", 1, files["/home/project/main.ts"].(string), lsproto.LanguageKindTypeScript) + ls, err := session.GetLanguageService(ctx, "file:///home/project/main.ts") + assert.NilError(t, err) + boxFile := ls.GetProgram().GetSourceFile("/home/project/app.box") + assert.Assert(t, boxFile != nil, "expected app.box to be loaded") + assert.Assert(t, strings.Contains(boxFile.Text(), "export const version = 7;"), "app.box was not transformed: %q", boxFile.Text()) + + // Changing a compiler option the mapper does not depend on (strict) forces a full program + // rebuild while leaving app.box's content and the mapper's transform identity unchanged, so the + // transformed file must be served from the parse cache rather than re-transformed. + err = utils.FS().WriteFile("/home/project/tsconfig.json", `{ + "compilerOptions": { "target": "es2020", "module": "esnext", "moduleResolution": "bundler", "strict": false }, + "contentMappers": [ { "package": "mapper", "extensions": [".box"] } ] + }`) + assert.NilError(t, err) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{Uri: "file:///home/project/tsconfig.json", Type: lsproto.FileChangeTypeChanged}}) + + ls, err = session.GetLanguageService(ctx, "file:///home/project/main.ts") + assert.NilError(t, err) + rebuiltBox := ls.GetProgram().GetSourceFile("/home/project/app.box") + assert.Assert(t, rebuiltBox == boxFile, "expected the unchanged content-mapped file to be reused from the parse cache, not re-transformed") + }) +} + +func TestContentMappersInParallelProjectReferences(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/home/project/tsconfig.json": `{ + "files": ["src/index.d.ts"], + "references": [{ "path": "./a" }, { "path": "./b" }] + }`, + "/home/project/src/index.d.ts": "export {};", + "/home/project/a/tsconfig.json": `{ + "compilerOptions": { "composite": true }, + "files": ["../src/index.d.ts"], + "contentMappers": [{ "package": "mapper", "extensions": [".vue"] }] + }`, + "/home/project/b/tsconfig.json": `{ + "compilerOptions": { "composite": true }, + "files": ["../src/index.d.ts"], + "contentMappers": [{ "package": "mapper", "extensions": [".svelte"] }] + }`, + "/home/project/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.TransformingMapper), + } + init, utils := projecttestutil.GetSessionInitOptions(files, &project.SessionOptions{ + CurrentDirectory: "/home/project", + DefaultLibraryPath: bundled.LibPath(), + TypingsLocation: projecttestutil.TestTypingsLocation, + PositionEncoding: lsproto.PositionEncodingKindUTF8, + LoadExternalPlugins: true, + }, nil) + init.Spawner = contentmappertest.NewSpawner() + session := project.NewSession(init) + defer session.Close() + + uri := lsproto.DocumentUri("file:///home/project/src/index.d.ts") + session.DidOpenFile(context.Background(), uri, 1, files["/home/project/src/index.d.ts"].(string), lsproto.LanguageKindTypeScript) + session.WaitForBackgroundTasks() + calls := utils.Client().RegisterContentMapperExtensionsCalls() + assert.Assert(t, len(calls) > 0) + extensions := slices.Clone(calls[len(calls)-1].Extensions) + slices.Sort(extensions) + assert.DeepEqual(t, extensions, []string{".svelte", ".vue"}) +} + +func TestContentMapperOpenFileExcludedByConfigChange(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/home/project/tsconfig.json": `{ + "compilerOptions": { "target": "es2020", "module": "esnext", "moduleResolution": "bundler", "strict": true }, + "include": ["src"], + "contentMappers": [ { "package": "mapper", "extensions": [".box"] } ] + }`, + "/home/project/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.TransformingMapper), + "/home/project/src/app.box": "export const version = #{target};\n", + "/home/project/src/main.ts": "export const main = true;\n", + } + init, utils := projecttestutil.GetSessionInitOptions(files, &project.SessionOptions{ + CurrentDirectory: "/home/project", + DefaultLibraryPath: bundled.LibPath(), + TypingsLocation: projecttestutil.TestTypingsLocation, + PositionEncoding: lsproto.PositionEncodingKindUTF8, + LoadExternalPlugins: true, + }, nil) + init.Spawner = contentmappertest.NewSpawner() + session := project.NewSession(init) + defer session.Close() + + ctx := context.Background() + boxURI := lsproto.DocumentUri("file:///home/project/src/app.box") + session.DidOpenFile(ctx, boxURI, 1, files["/home/project/src/app.box"].(string), lsproto.LanguageKind("box")) + languageService, err := session.GetLanguageService(ctx, boxURI) + assert.NilError(t, err) + assert.Assert(t, languageService.GetProgram().GetSourceFile("/home/project/src/app.box") != nil) + + assert.NilError(t, utils.FS().WriteFile("/home/project/tsconfig.json", `{ + "compilerOptions": { "target": "es2020", "module": "esnext", "moduleResolution": "bundler", "strict": true }, + "include": ["src/**/*.ts"], + "contentMappers": [ { "package": "mapper", "extensions": [".box"] } ] + }`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: "file:///home/project/tsconfig.json", + Type: lsproto.FileChangeTypeChanged, + }}) + + languageService, err = session.GetLanguageService(ctx, boxURI) + assert.NilError(t, err) + defaultProject := session.Snapshot().GetDefaultProject(boxURI) + assert.Assert(t, defaultProject != nil, "expected a default project for the open app.box") + assert.Equal(t, defaultProject.Kind, project.KindInferred) + boxFile := languageService.GetProgram().GetSourceFile("/home/project/src/app.box") + assert.Assert(t, boxFile != nil, "expected the open app.box in the inferred project") + assert.Assert(t, boxFile.ContentMapper() != "", "expected app.box to retain its content mapper") + assert.Assert(t, !strings.Contains(boxFile.Text(), "#{target}"), "expected app.box to be transformed: %q", boxFile.Text()) +} + +func TestContentMapperRemovalWithOpenFile(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/home/project/tsconfig.json": `{ + "compilerOptions": { "target": "es2020", "module": "esnext", "moduleResolution": "bundler" }, + "contentMappers": [ { "package": "mapper", "extensions": [".box"] } ] + }`, + "/home/project/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.TransformingMapper), + "/home/project/app.box": "export const version = #{target};\n", + } + init, utils := projecttestutil.GetSessionInitOptions(files, &project.SessionOptions{ + CurrentDirectory: "/home/project", + DefaultLibraryPath: bundled.LibPath(), + TypingsLocation: projecttestutil.TestTypingsLocation, + PositionEncoding: lsproto.PositionEncodingKindUTF8, + LoadExternalPlugins: true, + }, nil) + spawner := &recordingContentMapperSpawner{inner: contentmappertest.NewSpawner()} + init.Spawner = spawner + session := project.NewSession(init) + defer session.Close() + + ctx := context.Background() + boxURI := lsproto.DocumentUri("file:///home/project/app.box") + session.DidOpenFile(ctx, boxURI, 1, files["/home/project/app.box"].(string), lsproto.LanguageKind("box")) + languageService, err := session.GetLanguageService(ctx, boxURI) + assert.NilError(t, err) + assert.Assert(t, languageService.GetProgram().GetSourceFile("/home/project/app.box").ContentMapper() != "") + assert.Equal(t, spawner.spawns.Load(), int32(1)) + assert.Equal(t, spawner.closes.Load(), int32(0)) + for version := int32(2); version <= 4; version++ { + session.DidChangeFile(ctx, boxURI, version, []lsproto.TextDocumentContentChangePartialOrWholeDocument{ + {WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: fmt.Sprintf("export const version = %d;\n", version)}}, + }) + _, err = session.GetLanguageService(ctx, boxURI) + assert.NilError(t, err) + assert.Equal(t, spawner.spawns.Load(), int32(1), "snapshot clone should reuse the mapper process") + assert.Equal(t, spawner.closes.Load(), int32(0), "snapshot clone should preserve overlapping ownership") + } + releaseOldSnapshot, err := session.WithLanguageServiceAndSnapshot(ctx, boxURI, func(_ *ls.LanguageService, _ *project.Snapshot) (func() error, error) { + return func() error { return nil }, nil + }) + assert.NilError(t, err) + + assert.NilError(t, utils.FS().WriteFile("/home/project/tsconfig.json", `{ + "compilerOptions": { "target": "es2020", "module": "esnext", "moduleResolution": "bundler" } + }`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: "file:///home/project/tsconfig.json", + Type: lsproto.FileChangeTypeChanged, + }}) + + _, err = session.GetLanguageService(ctx, boxURI) + assert.ErrorContains(t, err, "no project found") + assert.Assert(t, session.Snapshot().GetFile("/home/project/app.box") != nil, "overlay should remain until didClose") + assert.Assert(t, session.Snapshot().GetDefaultProject(boxURI) == nil, "unsupported foreign file should not be in a project") + + session.WaitForBackgroundTasks() + assert.Equal(t, spawner.closes.Load(), int32(0), "live old snapshot should retain the mapper process") + assert.NilError(t, releaseOldSnapshot()) + assert.Equal(t, spawner.closes.Load(), int32(1), "process should close after the final live snapshot is released") + calls := utils.Client().RegisterContentMapperExtensionsCalls() + assert.Assert(t, len(calls) > 0, "expected content mapper registration updates") + assert.Equal(t, len(calls[len(calls)-1].Extensions), 0, "expected content mapper extensions to be unregistered") + + session.DidCloseFile(ctx, boxURI) + _, err = session.GetLanguageService(ctx, boxURI) + assert.ErrorContains(t, err, "no project found") +} + +func TestContentMapperProcessSharedAcrossProjects(t *testing.T) { + t.Parallel() + config := func(extension string) string { + return fmt.Sprintf(`{ + "compilerOptions": { "target": "es2020", "module": "esnext", "moduleResolution": "bundler" }, + "contentMappers": [ { "package": "mapper", "extensions": [%q] } ] + }`, extension) + } + files := map[string]any{ + "/home/a/tsconfig.json": config(".box"), + "/home/a/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.TransformingMapper), + "/home/a/app.box": "export const a = 1;\n", + "/home/b/tsconfig.json": config(".panel"), + "/home/b/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.TransformingMapper), + "/home/b/app.panel": "export const b = 1;\n", + } + init, utils := projecttestutil.GetSessionInitOptions(files, &project.SessionOptions{ + CurrentDirectory: "/home", + DefaultLibraryPath: bundled.LibPath(), + TypingsLocation: projecttestutil.TestTypingsLocation, + PositionEncoding: lsproto.PositionEncodingKindUTF8, + LoadExternalPlugins: true, + }, nil) + spawner := &recordingContentMapperSpawner{inner: contentmappertest.NewSpawner()} + init.Spawner = spawner + session := project.NewSession(init) + defer session.Close() + + ctx := context.Background() + aURI := lsproto.DocumentUri("file:///home/a/app.box") + bURI := lsproto.DocumentUri("file:///home/b/app.panel") + session.DidOpenFile(ctx, aURI, 1, files["/home/a/app.box"].(string), lsproto.LanguageKind("box")) + _, err := session.GetLanguageService(ctx, aURI) + assert.NilError(t, err) + session.DidOpenFile(ctx, bURI, 1, files["/home/b/app.panel"].(string), lsproto.LanguageKind("panel")) + _, err = session.GetLanguageService(ctx, bURI) + assert.NilError(t, err) + assert.Equal(t, spawner.spawns.Load(), int32(1), "same mapper identity should share one process") + + assert.NilError(t, utils.FS().WriteFile("/home/a/tsconfig.json", `{}`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{Uri: "file:///home/a/tsconfig.json", Type: lsproto.FileChangeTypeChanged}}) + _, err = session.GetLanguageService(ctx, aURI) + assert.ErrorContains(t, err, "no project found") + assert.Equal(t, spawner.closes.Load(), int32(0), "second project still owns the shared process") + + assert.NilError(t, utils.FS().WriteFile("/home/b/tsconfig.json", `{}`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{Uri: "file:///home/b/tsconfig.json", Type: lsproto.FileChangeTypeChanged}}) + _, err = session.GetLanguageService(ctx, bURI) + assert.ErrorContains(t, err, "no project found") + assert.Equal(t, spawner.closes.Load(), int32(1), "final project owner should close the shared process") +} + +func TestContentMapperInferredProjectUsesSessionMappers(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/home/configured/tsconfig.json": `{ + "compilerOptions": { "target": "es2020", "module": "esnext", "moduleResolution": "bundler" }, + "contentMappers": [ { "package": "mapper", "extensions": [".box"] } ] + }`, + "/home/configured/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.TransformingMapper), + "/home/configured/main.ts": "export const main = true;\n", + "/home/loose/app.box": "export const version = #{target};\n", + } + init, _ := projecttestutil.GetSessionInitOptions(files, &project.SessionOptions{ + CurrentDirectory: "/home", + DefaultLibraryPath: bundled.LibPath(), + TypingsLocation: projecttestutil.TestTypingsLocation, + PositionEncoding: lsproto.PositionEncodingKindUTF8, + LoadExternalPlugins: true, + }, nil) + init.Spawner = contentmappertest.NewSpawner() + session := project.NewSession(init) + defer session.Close() + + ctx := context.Background() + configuredURI := lsproto.DocumentUri("file:///home/configured/main.ts") + session.DidOpenFile(ctx, configuredURI, 1, files["/home/configured/main.ts"].(string), lsproto.LanguageKindTypeScript) + _, err := session.GetLanguageService(ctx, configuredURI) + assert.NilError(t, err) + + boxURI := lsproto.DocumentUri("file:///home/loose/app.box") + session.DidOpenFile(ctx, boxURI, 1, files["/home/loose/app.box"].(string), lsproto.LanguageKind("box")) + languageService, err := session.GetLanguageService(ctx, boxURI) + assert.NilError(t, err) + defaultProject := session.Snapshot().GetDefaultProject(boxURI) + assert.Assert(t, defaultProject != nil, "expected a default project for the loose app.box") + assert.Equal(t, defaultProject.Kind, project.KindInferred) + boxFile := languageService.GetProgram().GetSourceFile("/home/loose/app.box") + assert.Assert(t, boxFile != nil, "expected loose app.box in the inferred project") + assert.Assert(t, boxFile.ContentMapper() != "", "expected loose app.box to use the session mapper union") + assert.Assert(t, !strings.Contains(boxFile.Text(), "#{target}"), "expected loose app.box to be transformed: %q", boxFile.Text()) +} + +func TestDiscoverContentMapperExtensions(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/home/project/tsconfig.json": `{ + "compilerOptions": { "target": "es2020", "module": "esnext", "moduleResolution": "bundler", "strict": true }, + "contentMappers": [ { "package": "mapper", "extensions": [".vue"] } ] + }`, + "/home/project/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.ComponentMapper), + "/home/project/ProfileCard.vue": ``, + "/home/other/tsconfig.json": `{ + "compilerOptions": { "target": "es2020", "module": "esnext", "moduleResolution": "bundler" }, + "contentMappers": [ { "package": "mapper", "extensions": [".svelte"] } ] + }`, + "/home/other/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.ComponentMapper), + "/home/other/Widget.svelte": ``, + } + init, utils := projecttestutil.GetSessionInitOptions(files, &project.SessionOptions{ + CurrentDirectory: "/home/project", + DefaultLibraryPath: bundled.LibPath(), + TypingsLocation: projecttestutil.TestTypingsLocation, + PositionEncoding: lsproto.PositionEncodingKindUTF8, + LoadExternalPlugins: true, + }, nil) + init.Spawner = contentmappertest.NewSpawner() + session := project.NewSession(init) + defer session.Close() + + ctx := context.Background() + assert.DeepEqual(t, session.DiscoverContentMapperExtensions(ctx, []lsproto.DocumentUri{ + "file:///home/other/Widget.svelte", + }, []string{".svelte"}), []string{".svelte"}) + + matched := session.DiscoverContentMapperExtensions(ctx, []lsproto.DocumentUri{ + "file:///home/project/ProfileCard.vue", + "file:///home/project/ignored.svelte", + }, []string{".vue", ".svelte", ".vue", "vue", "../bad"}) + assert.DeepEqual(t, matched, []string{".vue"}) + + calls := utils.Client().RegisterContentMapperExtensionsCalls() + assert.Assert(t, len(calls) > 0, "expected content mapper extensions to be registered") + assert.DeepEqual(t, calls[len(calls)-1].Extensions, []string{".svelte", ".vue"}) + + snapshot := session.Snapshot() + assert.Assert(t, snapshot.GetDefaultProject("file:///home/project/ProfileCard.vue") != nil, "expected the configured project to be discovered") + assert.Assert(t, snapshot.GetDefaultProject("file:///home/project/ignored.svelte") == nil, "unmatched extensions should not load a project") + + untrustedInit, untrustedUtils := projecttestutil.GetSessionInitOptions(files, &project.SessionOptions{ + CurrentDirectory: "/home/project", + DefaultLibraryPath: bundled.LibPath(), + TypingsLocation: projecttestutil.TestTypingsLocation, + PositionEncoding: lsproto.PositionEncodingKindUTF8, + LoadExternalPlugins: false, + }, nil) + untrustedInit.Spawner = contentmappertest.NewSpawner() + untrusted := project.NewSession(untrustedInit) + defer untrusted.Close() + assert.Equal(t, len(untrusted.DiscoverContentMapperExtensions(ctx, []lsproto.DocumentUri{"file:///home/project/ProfileCard.vue"}, []string{".vue"})), 0) + assert.Equal(t, len(untrustedUtils.Client().RegisterContentMapperExtensionsCalls()), 0) +} + +func TestContentMapperSynthesizedDocumentSymbols(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + files := map[string]any{ + "/home/project/tsconfig.json": `{ + "compilerOptions": { "target": "es2020", "module": "esnext", "moduleResolution": "bundler", "strict": true, "skipLibCheck": true }, + "contentMappers": [ { "package": "mapper", "extensions": [".box"] } ] + }`, + "/home/project/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.SynthesizingMapper), + "/home/project/app.box": "component source with no direct TypeScript span\n", + "/home/project/main.ts": "import { el } from \"./app.box\";\nexport const value = el;\n", + } + + init, _ := projecttestutil.GetSessionInitOptions(files, &project.SessionOptions{ + CurrentDirectory: "/home/project", + DefaultLibraryPath: bundled.LibPath(), + TypingsLocation: projecttestutil.TestTypingsLocation, + PositionEncoding: lsproto.PositionEncodingKindUTF8, + LoadExternalPlugins: true, + }, nil) + init.Spawner = contentmappertest.NewSpawner() + session := project.NewSession(init) + defer session.Close() + + ctx := context.Background() + session.DidOpenFile(ctx, "file:///home/project/main.ts", 1, files["/home/project/main.ts"].(string), lsproto.LanguageKindTypeScript) + ls, err := session.GetLanguageService(ctx, "file:///home/project/main.ts") + assert.NilError(t, err) + + symbols, err := ls.ProvideDocumentSymbols(ctx, "file:///home/project/app.box") + assert.NilError(t, err) + assert.Assert(t, symbols.SymbolInformations != nil) + assert.Equal(t, len(*symbols.SymbolInformations), 0, "synthesized declarations should not appear as symbols at unrelated source ranges") +} + +// TestContentMapperCompletions verifies that completions are offered inside verbatim spans of a +// content-mapped file (mapped to the original position) but declined outside them, and that auto-import +// completions whose import edit would land in generated code are filtered out of the list. +func TestContentMapperCompletions(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + + newSession := func(t *testing.T, files map[string]any) *project.Session { + init, _ := projecttestutil.GetSessionInitOptions(files, &project.SessionOptions{ + CurrentDirectory: "/home/project", + DefaultLibraryPath: bundled.LibPath(), + TypingsLocation: projecttestutil.TestTypingsLocation, + PositionEncoding: lsproto.PositionEncodingKindUTF8, + LoadExternalPlugins: true, + }, nil) + init.Spawner = contentmappertest.NewSpawner() + session := project.NewSession(init) + ctx := context.Background() + session.DidOpenFile(ctx, "file:///home/project/main.ts", 1, files["/home/project/main.ts"].(string), lsproto.LanguageKindTypeScript) + session.DidOpenFile(ctx, "file:///home/project/app.box", 1, files["/home/project/app.box"].(string), lsproto.LanguageKind("box")) + return session + } + + baseFiles := func(boxContent string) map[string]any { + return map[string]any{ + "/home/project/tsconfig.json": `{ + "compilerOptions": { "target": "es2020", "module": "esnext", "moduleResolution": "bundler", "strict": true, "skipLibCheck": true }, + "contentMappers": [ { "package": "mapper", "extensions": [".box"] } ] + }`, + "/home/project/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.TransformingMapper), + "/home/project/app.box": boxContent, + "/home/project/main.ts": "import \"./app.box\";\n", + } + } + + t.Run("offers completions in a verbatim span", func(t *testing.T) { + t.Parallel() + session := newSession(t, baseFiles("export const foo = 1;\nfoo\n")) + defer session.Close() + + // Complete at the end of `foo` on original line 1 (a verbatim span). + resp := completeContentMapped(t, session, "file:///home/project/app.box", lsproto.Position{Line: 1, Character: 3}) + assert.Assert(t, resp.List != nil, "expected a completion list in a verbatim span") + var sawFoo bool + for _, item := range resp.List.Items { + if item.Label == "foo" { + sawFoo = true + } + } + assert.Assert(t, sawFoo, "expected `foo` to be offered as a completion") + + // The edit range must land on the original cursor line (1). If it were left in transformed + // coordinates (shifted by the synthesized preamble), it would not contain the cursor and VS Code + // would silently filter out every item. + assert.Assert(t, resp.List.ItemDefaults != nil && resp.List.ItemDefaults.EditRange != nil, "expected an edit range default") + insert := resp.List.ItemDefaults.EditRange.EditRangeWithInsertReplace.Insert + assert.Equal(t, insert.End.Line, uint32(1), "the edit range must map back to the original cursor line") + assert.Equal(t, insert.End.Character, uint32(3), "the edit range must end at the original cursor character") + }) + + t.Run("declines completions outside a verbatim span", func(t *testing.T) { + t.Parallel() + session := newSession(t, baseFiles("export const version = #{target};\n")) + defer session.Close() + + // The `#{target}` token maps to an atom span (a mapper substitution), which is not editable. + resp := completeContentMapped(t, session, "file:///home/project/app.box", lsproto.Position{Line: 0, Character: 25}) + assert.Assert(t, resp.List == nil && resp.Items == nil, "expected no completions inside an atom span") + }) + + t.Run("filters auto-imports that would edit generated code", func(t *testing.T) { + t.Parallel() + files := baseFiles("export const version = #{target};\nconst x = help;\n") + files["/home/project/dep.ts"] = "export const helper = 1;\n" + session := newSession(t, files) + defer session.Close() + + // `help` on original line 1 would suggest auto-importing `helper` from ./dep, but the new import + // statement would be inserted into the synthesized preamble, so the item must be dropped. + resp := completeContentMapped(t, session, "file:///home/project/app.box", lsproto.Position{Line: 1, Character: 14}) + assert.Assert(t, resp.List != nil, "expected a completion list") + for _, item := range resp.List.Items { + if item.Data != nil && item.Data.AutoImport != nil { + t.Fatalf("auto-import completion %q should have been filtered out (edit lands in generated code)", item.Label) + } + } + }) + + t.Run("keeps auto-imports whose edit lands in a verbatim span", func(t *testing.T) { + t.Parallel() + files := baseFiles("import { a } from \"./dep\";\nconst x = help;\n") + files["/home/project/dep.ts"] = "export const a = 1;\nexport const helper = 2;\n" + session := newSession(t, files) + defer session.Close() + + // `./dep` is already imported in the verbatim body, so completing `help` adds `helper` to the + // existing import on original line 0 — an edit fully inside a verbatim span, which must be kept. + resp := completeContentMapped(t, session, "file:///home/project/app.box", lsproto.Position{Line: 1, Character: 14}) + assert.Assert(t, resp.List != nil, "expected a completion list") + var helper *lsproto.CompletionItem + for _, item := range resp.List.Items { + if item.Label == "helper" && item.Data != nil && item.Data.AutoImport != nil { + helper = item + } + } + assert.Assert(t, helper != nil, "expected the `helper` auto-import to be offered") + assert.Assert(t, helper.AdditionalTextEdits != nil, "expected the import edit to be attached eagerly") + for _, edit := range *helper.AdditionalTextEdits { + assert.Equal(t, edit.Range.Start.Line, uint32(0), "the import edit should map to original line 0") + } + }) +} + +// completeContentMapped issues a completion request, mirroring the server's two-phase auto-import protocol +// (retrying with an auto-import-prepared language service when the first attempt reports ErrNeedsAutoImports). +// It advertises editRange/insertReplace support so the response carries edit ranges, as VS Code does. +func completeContentMapped(t *testing.T, session *project.Session, uri lsproto.DocumentUri, pos lsproto.Position) lsproto.CompletionResponse { + t.Helper() + caps := &lsproto.ResolvedClientCapabilities{} + caps.TextDocument.Completion.CompletionList.ItemDefaults = []string{"editRange", "commitCharacters"} + caps.TextDocument.Completion.CompletionItem.InsertReplaceSupport = true + ctx := lsproto.WithClientCapabilities(context.Background(), caps) + var resp lsproto.CompletionResponse + _, err := session.WithLanguageServiceAndSnapshot(ctx, uri, func(languageService *ls.LanguageService, snapshot *project.Snapshot) (func() error, error) { + r, e := languageService.ProvideCompletion(ctx, uri, pos, &lsproto.CompletionContext{}) + if errors.Is(e, ls.ErrNeedsAutoImports) { + languageService, e = session.GetLanguageServiceWithAutoImports(ctx, snapshot, uri) + if e != nil { + return nil, e + } + r, e = languageService.ProvideCompletion(ctx, uri, pos, &lsproto.CompletionContext{}) + } + resp = r + return nil, e + }) + assert.NilError(t, err) + return resp +} diff --git a/internal/project/extendedconfigcache_test.go b/internal/project/extendedconfigcache_test.go index dd4a7a1493e..82806b825de 100644 --- a/internal/project/extendedconfigcache_test.go +++ b/internal/project/extendedconfigcache_test.go @@ -24,6 +24,10 @@ func (noopClient) WatchFiles(ctx context.Context, id WatcherID, watchers []*lspr func (noopClient) UnwatchFiles(ctx context.Context, id WatcherID) error { return nil } +func (noopClient) RegisterContentMapperExtensions(ctx context.Context, extensions []string) error { + return nil +} + func (noopClient) RefreshDiagnostics(ctx context.Context) error { return nil } func (noopClient) PublishDiagnostics(ctx context.Context, params *lsproto.PublishDiagnosticsParams) error { diff --git a/internal/project/overlayfs.go b/internal/project/overlayfs.go index 1b300a01312..151df3ba7fa 100644 --- a/internal/project/overlayfs.go +++ b/internal/project/overlayfs.go @@ -6,9 +6,11 @@ import ( "sync" "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/debug" "github.com/microsoft/typescript-go/internal/ls/lsconv" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/sourcemap" + "github.com/microsoft/typescript-go/internal/spanmap" "github.com/microsoft/typescript-go/internal/tspath" "github.com/microsoft/typescript-go/internal/vfs" "github.com/zeebo/xxh3" @@ -142,6 +144,13 @@ func (o *Overlay) Text() string { return o.content } +// SpanMap and OriginalText satisfy lsconv.Script. An overlay holds the editor's raw text (for a +// content-mapped file, that is the original foreign text, not the transformed output), so it never +// carries a span map and its original text is its own text. +func (o *Overlay) SpanMap() *spanmap.SpanMap { return nil } + +func (o *Overlay) OriginalText() string { return o.content } + // MatchesDiskText may return false negatives, but never false positives. func (o *Overlay) MatchesDiskText() bool { return o.matchesDiskText @@ -355,7 +364,10 @@ func (fs *overlayFS) processChanges(changes []FileChange) (FileChangeSummary, ma }) for _, textChange := range change.Changes { if partialChange := textChange.Partial; partialChange != nil { - newContent := converters.FromLSPTextChange(o, partialChange).ApplyTo(o.content) + ranges := converters.FromLSPRange(o, partialChange.Range, spanmap.PurposeAll) + debug.Assert(len(ranges) == 1, "expected exactly one range for partial change") + textChange := core.TextChange{TextRange: ranges[0].Span, NewText: partialChange.Text} + newContent := textChange.ApplyTo(o.content) o = newOverlay(o.fileName, newContent, change.Version, o.kind) } else if wholeChange := textChange.WholeDocument; wholeChange != nil { o = newOverlay(o.fileName, wholeChange.Text, change.Version, o.kind) diff --git a/internal/project/parsecache.go b/internal/project/parsecache.go index 6ef1cdf629b..440ba1450b3 100644 --- a/internal/project/parsecache.go +++ b/internal/project/parsecache.go @@ -1,7 +1,10 @@ package project import ( + "encoding/binary" + "github.com/microsoft/typescript-go/internal/ast" + "github.com/microsoft/typescript-go/internal/compiler" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/parser" "github.com/zeebo/xxh3" @@ -25,6 +28,42 @@ func NewParseCacheKey( } } +// contentMappedParseCacheKey builds the parse-cache key for a content-mapped file. The transform output +// (text and script kind) is fully determined by the file's raw content and the mapper's transform +// identity, so the key folds both into the hash and uses a fixed placeholder script kind. That lets the +// key be reconstructed from the produced source file (see parseCacheKeyForFile) without knowing the +// output script kind up front. +func contentMappedParseCacheKey(options ast.SourceFileParseOptions, rawHash, transformIdentity xxh3.Uint128) ParseCacheKey { + var buf [32]byte + binary.LittleEndian.PutUint64(buf[0:8], rawHash.Hi) + binary.LittleEndian.PutUint64(buf[8:16], rawHash.Lo) + binary.LittleEndian.PutUint64(buf[16:24], transformIdentity.Hi) + binary.LittleEndian.PutUint64(buf[24:32], transformIdentity.Lo) + return NewParseCacheKey(options, xxh3.Hash128(buf[:]), core.ScriptKindUnknown) +} + +// parseCacheKeyForFile reconstructs the parse-cache key for a source file held by a program. Content- +// mapped files store a composite hash (raw content + transform identity) on their Hash field and are +// keyed by a placeholder script kind, matching contentMapperParseCacheKey; all other files key on their +// own hash and script kind. +func parseCacheKeyForFile(file *ast.SourceFile) ParseCacheKey { + scriptKind := file.ScriptKind + if file.ContentMapper() != "" { + scriptKind = core.ScriptKindUnknown + } + return NewParseCacheKey(file.ParseOptions(), file.Hash, scriptKind) +} + +// parseCacheKeyForDuplicate reconstructs the parse-cache key for a deduplicated source file, mirroring +// parseCacheKeyForFile. +func parseCacheKeyForDuplicate(file *compiler.DuplicateSourceFile) ParseCacheKey { + scriptKind := file.ScriptKind + if file.ContentMapper != "" { + scriptKind = core.ScriptKindUnknown + } + return NewParseCacheKey(file.ParseOptions, file.Hash, scriptKind) +} + type ParseCache = RefCountCache[ParseCacheKey, *ast.SourceFile, FileHandle] func NewParseCache(options RefCountCacheOptions) *ParseCache { diff --git a/internal/project/project.go b/internal/project/project.go index 39c77aa78df..7ee3aa54097 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -10,6 +10,7 @@ import ( "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/collections" "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/ls" "github.com/microsoft/typescript-go/internal/lsp/lsproto" @@ -102,6 +103,7 @@ func NewInferredProject( currentDirectory string, compilerOptions *core.CompilerOptions, rootFileNames []string, + contentMappers []*contentmapper.Mapper, builder *ProjectCollectionBuilder, logger *logging.LogTree, ) *Project { @@ -121,9 +123,10 @@ func NewInferredProject( ResolveJsonModule: core.TSTrue, } } - p.CommandLine = tsoptions.NewParsedCommandLine( + p.CommandLine = newInferredProjectCommandLine( compilerOptions, rootFileNames, + contentMappers, tspath.ComparePathsOptions{ UseCaseSensitiveFileNames: builder.fs.fs.UseCaseSensitiveFileNames(), CurrentDirectory: currentDirectory, @@ -132,6 +135,17 @@ func NewInferredProject( return p } +func newInferredProjectCommandLine( + compilerOptions *core.CompilerOptions, + rootFileNames []string, + contentMappers []*contentmapper.Mapper, + comparePathsOptions tspath.ComparePathsOptions, +) *tsoptions.ParsedCommandLine { + commandLine := tsoptions.NewParsedCommandLine(compilerOptions, rootFileNames, comparePathsOptions) + commandLine.ParsedConfig.ContentMappers = contentMappers + return commandLine +} + func NewProject( configFileName string, kind Kind, @@ -372,19 +386,21 @@ func (p *Project) CreateProgram() CreateProgramResult { for _, file := range newProgram.SourceFiles() { // Use pointer identity: dirtyFile is the exact instance UpdateProgram acquired, // and it is the only file whose refcount is already accounted for. - if file != dirtyFile { + if file != dirtyFile && !file.IsContentMapperFailureStub() { // UpdateProgram acquired the changed file only, so we need to ref everything else - p.host.builder.parseCache.Ref(NewParseCacheKey(file.ParseOptions(), file.Hash, file.ScriptKind)) + p.host.builder.parseCache.Ref(parseCacheKeyForFile(file)) } } for _, file := range newProgram.DuplicateSourceFiles() { - p.host.builder.parseCache.Ref(NewParseCacheKey(file.ParseOptions, file.Hash, file.ScriptKind)) + if !file.IsContentMapperFailureStub { + p.host.builder.parseCache.Ref(parseCacheKeyForDuplicate(file)) + } } } else if dirtyFile != nil { // UpdateProgram always acquires the dirty file before deciding whether it can // reuse the old program. If it falls back to a full rebuild, release that // speculative acquire so the rebuilt program is the only remaining owner. - p.host.builder.parseCache.Deref(NewParseCacheKey(dirtyFile.ParseOptions(), dirtyFile.Hash, dirtyFile.ScriptKind)) + p.host.builder.parseCache.Deref(parseCacheKeyForFile(dirtyFile)) } } else { var typingsLocation string diff --git a/internal/project/projectcollectionbuilder.go b/internal/project/projectcollectionbuilder.go index 3355b68878f..a46001e0331 100644 --- a/internal/project/projectcollectionbuilder.go +++ b/internal/project/projectcollectionbuilder.go @@ -9,6 +9,7 @@ import ( "github.com/microsoft/typescript-go/internal/collections" "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/lsp/lsproto" @@ -31,6 +32,7 @@ type ProjectCollectionBuilder struct { sessionOptions *SessionOptions parseCache *ParseCache extendedConfigCache *ExtendedConfigCache + contentMapperHost contentmapper.Host toPath func(fileName string) tspath.Path ctx context.Context @@ -65,6 +67,7 @@ func newProjectCollectionBuilder( customConfigFileName string, parseCache *ParseCache, extendedConfigCache *ExtendedConfigCache, + contentMapperHost contentmapper.Host, client Client, ) *ProjectCollectionBuilder { return &ProjectCollectionBuilder{ @@ -75,6 +78,7 @@ func newProjectCollectionBuilder( sessionOptions: sessionOptions, parseCache: parseCache, extendedConfigCache: extendedConfigCache, + contentMapperHost: contentMapperHost, base: oldProjectCollection, configFileRegistryBuilder: newConfigFileRegistryBuilder(lsproto.GetClientCapabilities(ctx).Workspace.DidChangeWatchedFiles.RelativePatternSupport, fs, oldConfigFileRegistry, extendedConfigCache, newSnapshotID, sessionOptions, customConfigFileName, nil), newSnapshotID: newSnapshotID, @@ -1041,6 +1045,7 @@ func (b *ProjectCollectionBuilder) findOrCreateProject( } func (b *ProjectCollectionBuilder) updateInferredProjectRoots(rootFileNames []string, logger *logging.LogTree) bool { + rootFileNames = core.Filter(rootFileNames, b.isSupportedInInferredProject) if len(rootFileNames) == 0 { if b.inferredProject.Value() != nil { if logger != nil { @@ -1053,20 +1058,22 @@ func (b *ProjectCollectionBuilder) updateInferredProjectRoots(rootFileNames []st } slices.Sort(rootFileNames) + contentMappers := b.configFileRegistryBuilder.contentMappers().mappers if b.inferredProject.Value() == nil { - b.inferredProject.Set(NewInferredProject(b.sessionOptions.CurrentDirectory, b.compilerOptionsForInferredProjects, rootFileNames, b, logger)) + b.inferredProject.Set(NewInferredProject(b.sessionOptions.CurrentDirectory, b.compilerOptionsForInferredProjects, rootFileNames, contentMappers, b, logger)) } else { newCompilerOptions := b.inferredProject.Value().CommandLine.CompilerOptions() if b.compilerOptionsForInferredProjects != nil { newCompilerOptions = b.compilerOptionsForInferredProjects } - newCommandLine := tsoptions.NewParsedCommandLine(newCompilerOptions, rootFileNames, tspath.ComparePathsOptions{ + newCommandLine := newInferredProjectCommandLine(newCompilerOptions, rootFileNames, contentMappers, tspath.ComparePathsOptions{ UseCaseSensitiveFileNames: b.fs.fs.UseCaseSensitiveFileNames(), CurrentDirectory: b.sessionOptions.CurrentDirectory, }) changed := b.inferredProject.ChangeIf( func(p *Project) bool { - return !maps.Equal(p.CommandLine.FileNamesByPath(), newCommandLine.FileNamesByPath()) + return !maps.Equal(p.CommandLine.FileNamesByPath(), newCommandLine.FileNamesByPath()) || + !slices.Equal(p.CommandLine.ContentMappers(), newCommandLine.ContentMappers()) }, func(p *Project) { if logger != nil { @@ -1082,6 +1089,13 @@ func (b *ProjectCollectionBuilder) updateInferredProjectRoots(rootFileNames []st return true } +func (b *ProjectCollectionBuilder) isSupportedInInferredProject(fileName string) bool { + if tspath.IsDynamicFileName(fileName) || core.GetScriptKindFromFileName(fileName) != core.ScriptKindUnknown { + return true + } + return tspath.FileExtensionIsOneOf(fileName, b.configFileRegistryBuilder.contentMappers().extensions) +} + // updateProgram updates the program for the given project entry if necessary. It returns // a boolean indicating whether the update could have caused any structure-affecting changes. func (b *ProjectCollectionBuilder) updateProgram(entry dirty.Value[*Project], logger *logging.LogTree) bool { diff --git a/internal/project/refcountcache.go b/internal/project/refcountcache.go index 909b5aa4b1b..c70e1d4ea90 100644 --- a/internal/project/refcountcache.go +++ b/internal/project/refcountcache.go @@ -57,6 +57,29 @@ func (c *RefCountCache[K, V, AcquireArgs]) Has(identity K) bool { return ok } +// AcquireOrError retrieves an existing entry (incrementing its refcount) or produces a new one via +// produce. If produce returns an error, no entry is stored and the error is returned, so callers can +// cache only successful results. produce runs while holding the new entry's lock, so concurrent +// acquisitions of the same identity that miss serialize on it. +// +// The caller is responsible for calling Deref when a value is returned without error. +func (c *RefCountCache[K, V, AcquireArgs]) AcquireOrError(identity K, produce func() (V, error)) (V, error) { + entry, loaded := c.loadOrStoreNewLockedEntry(identity) + defer entry.mu.Unlock() + if loaded { + return entry.value, nil + } + value, err := produce() + if err != nil { + // Undo the speculative entry so failures are not cached. + entry.refCount = 0 + c.entries.Delete(identity) + return value, err + } + entry.value = value + return value, nil +} + // Ref increments the reference count for an existing entry. // Panics if the entry does not exist. func (c *RefCountCache[K, V, AcquireArgs]) Ref(identity K) { diff --git a/internal/project/session.go b/internal/project/session.go index d8776aea586..4305615cd30 100644 --- a/internal/project/session.go +++ b/internal/project/session.go @@ -2,7 +2,9 @@ package project import ( "context" + "errors" "fmt" + "maps" "math" "runtime" gometrics "runtime/metrics" @@ -15,6 +17,7 @@ import ( osmemory "github.com/mackerelio/go-osstat/memory" "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/collections" + "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/json" @@ -32,6 +35,9 @@ import ( type UpdateReason int +// ErrNoProjectForUnknownScriptKind identifies requests for unsupported foreign files. +var ErrNoProjectForUnknownScriptKind = errors.New("no project for unknown script kind") + const ( UpdateReasonUnknown UpdateReason = iota UpdateReasonDidOpenFile @@ -61,9 +67,12 @@ type SessionOptions struct { LoggingEnabled bool TelemetryEnabled bool PushDiagnosticsEnabled bool - DebounceDelay time.Duration - Locale locale.Locale - CheckerPoolOptions CheckerPoolOptions + // LoadExternalPlugins allows configured content mappers to run their (external) processes, + // gated on workspace trust by the client. It corresponds to the --loadExternalPlugins CLI flag. + LoadExternalPlugins bool + DebounceDelay time.Duration + Locale locale.Locale + CheckerPoolOptions CheckerPoolOptions } type SessionInit struct { @@ -73,7 +82,9 @@ type SessionInit struct { Client Client Logger logging.Logger NpmExecutor ata.NpmExecutor - ParseCache *ParseCache + // Spawner launches content mapper processes. It is nil when the host cannot spawn processes. + Spawner contentmapper.Spawner + ParseCache *ParseCache } // Session manages the state of an LSP session. It receives textDocument @@ -90,7 +101,19 @@ type Session struct { client Client logger logging.Logger npmExecutor ata.NpmExecutor - fs *overlayFS + // contentMapperHost drives configured content mappers for all projects in the session. It is nil unless + // the workspace is trusted (LoadExternalPlugins) and a spawner is available. It is shared so + // projects that use the same mapper share a single process, and is closed when the session ends. + contentMapperHost contentmapper.Host + fs *overlayFS + + // registeredContentMapperSnapshotID is the ID of the newest snapshot whose registration has been + // applied. Registration runs from background tasks that may finish out of order, so + // contentMapperRegistrationMu serializes updates and the snapshot ID keeps a stale task from + // overwriting a newer snapshot's registration. + registeredContentMapperExtensions []string + registeredContentMapperSnapshotID uint64 + contentMapperRegistrationMu sync.Mutex // parseCache is the ref-counted cache of source files used when // creating programs during snapshot cloning. @@ -177,6 +200,16 @@ type Session struct { globalDiagPublishPending atomic.Bool } +// newContentMapperHost creates the session's shared content mapper host when the workspace is trusted and +// a spawner is available; otherwise it returns nil, and configured content mappers are rejected by the +// config-file gate. +func newContentMapperHost(init *SessionInit) contentmapper.Host { + if !init.Options.LoadExternalPlugins || init.Spawner == nil { + return nil + } + return contentmapper.NewHost(init.BackgroundCtx, init.Spawner, init.Options.Locale) +} + func NewSession(init *SessionInit) *Session { currentDirectory := init.Options.CurrentDirectory useCaseSensitiveFileNames := init.FS.UseCaseSensitiveFileNames() @@ -201,6 +234,7 @@ func NewSession(init *SessionInit) *Session { client: init.Client, logger: sessionLogger, npmExecutor: init.NpmExecutor, + contentMapperHost: newContentMapperHost(init), fs: overlayFS, parseCache: parseCache, extendedConfigCache: extendedConfigCache, @@ -330,17 +364,37 @@ func (s *Session) DidCloseFile(ctx context.Context, uri lsproto.DocumentUri) { } func (s *Session) DidChangeFile(ctx context.Context, uri lsproto.DocumentUri, version int32, changes []lsproto.TextDocumentContentChangePartialOrWholeDocument) { - s.cancelDiagnosticsRefresh() s.cancelWarmAutoImportCache() s.scheduleIdleCacheClean() s.pendingFileChangesMu.Lock() - defer s.pendingFileChangesMu.Unlock() s.pendingFileChanges = append(s.pendingFileChanges, FileChange{ Kind: FileChangeKindChange, URI: uri, Version: version, Changes: changes, }) + s.pendingFileChangesMu.Unlock() + + // Editing a content-mapped file changes the program like any source edit, but the client's + // pull-diagnostics machinery won't re-request diagnostics for dependent files: the foreign file is not + // in the diagnostic provider's document selector, so a change to it never triggers the client's + // inter-file re-pull. Prompt a workspace refresh so dependents update. We skip the debounce here so the + // edit doesn't feel sluggish (normal source edits are pulled per-keystroke client-side); the refresh is + // still coalesced. Ordinary source files are handled entirely client-side, so we cancel any pending + // refresh for them as before. + if s.isContentMapperFile(uri) { + s.scheduleDiagnosticsRefresh(0) + } else { + s.cancelDiagnosticsRefresh() + } +} + +// isContentMapperFile reports whether uri is a foreign file handled by a configured content mapper, based +// on the extensions currently registered with the client for text document synchronization. +func (s *Session) isContentMapperFile(uri lsproto.DocumentUri) bool { + contentMappers := s.Snapshot().ConfigFileRegistry.contentMappers() + return contentMappers != nil && len(contentMappers.extensions) > 0 && + tspath.FileExtensionIsOneOf(uri.FileName(), contentMappers.extensions) } func (s *Session) DidSaveFile(ctx context.Context, uri lsproto.DocumentUri) { @@ -392,6 +446,13 @@ func (s *Session) DidChangeCompilerOptionsForInferredProjects(ctx context.Contex } func (s *Session) ScheduleDiagnosticsRefresh() { + s.scheduleDiagnosticsRefresh(s.options.DebounceDelay) +} + +// scheduleDiagnosticsRefresh schedules a coalesced workspace diagnostics refresh after delay. A delay of +// 0 refreshes as soon as the background queue runs the task; it is used for interactive edits (e.g. a +// content-mapped file) where the debounce would make dependent-file diagnostics feel sluggish. +func (s *Session) scheduleDiagnosticsRefresh(delay time.Duration) { s.diagnosticsRefreshMu.Lock() defer s.diagnosticsRefreshMu.Unlock() @@ -409,15 +470,19 @@ func (s *Session) ScheduleDiagnosticsRefresh() { generation := s.diagnosticsRefreshGeneration s.diagnosticsRefreshCancel = cancel - // Enqueue the debounced diagnostics refresh + // Enqueue the (optionally debounced) diagnostics refresh s.backgroundQueue.Enqueue(debounceCtx, func(ctx context.Context) { defer cancel() - // Sleep for the debounce delay - select { - case <-time.After(s.options.DebounceDelay): - // Delay completed, proceed with refresh - case <-ctx.Done(): - // Context was cancelled, newer events arrived + if delay > 0 { + // Wait out the debounce window; a newer event cancels this one. + select { + case <-time.After(delay): + // Delay completed, proceed with refresh + case <-ctx.Done(): + // Context was cancelled, newer events arrived + return + } + } else if ctx.Err() != nil { return } @@ -980,6 +1045,9 @@ func (s *Session) getSnapshotAndDefaultProject(ctx context.Context, uri lsproto. ) project := snapshot.GetDefaultProject(uri) if project == nil { + if file := snapshot.GetFile(uri.FileName()); file != nil && file.Kind() == core.ScriptKindUnknown { + return nil, nil, nil, fmt.Errorf("%w: no project found for URI %s", ErrNoProjectForUnknownScriptKind, uri) + } return nil, nil, nil, fmt.Errorf("no project found for URI %s", uri) } return snapshot, project, ls.NewLanguageService(project.configFilePath, project.GetProgram(), snapshot, uri.FileName()), nil @@ -1015,6 +1083,79 @@ func (s *Session) GetProjectsForFile(ctx context.Context, uri lsproto.DocumentUr return allProjects, nil } +// DiscoverContentMapperExtensions loads configured projects for the supplied foreign documents without +// creating inferred projects, publishes any newly discovered content-mapper registrations, and returns +// the requested extensions provided by those configs. +func (s *Session) DiscoverContentMapperExtensions(ctx context.Context, documentURIs []lsproto.DocumentUri, candidateExtensions []string) []string { + caseSensitive := s.Snapshot().UseCaseSensitiveFileNames() + canonicalize := func(value string) string { return tspath.GetCanonicalFileName(value, caseSensitive) } + + candidateKeys := collections.NewSetWithSizeHint[string](len(candidateExtensions)) + candidates := make(map[string]string, len(candidateExtensions)) + for _, extension := range candidateExtensions { + if len(extension) <= 1 || extension[0] != '.' || tspath.GetAnyExtensionFromPath("file"+extension, nil, false) != extension { + continue + } + key := canonicalize(extension) + if candidateKeys.AddIfAbsent(key) { + candidates[key] = extension + } + } + if candidateKeys.Len() == 0 { + return nil + } + + documents := make([]lsproto.DocumentUri, 0, len(documentURIs)) + seenDocuments := collections.NewSetWithSizeHint[string](len(documentURIs)) + candidateExtensionsCanonical := slices.Collect(maps.Keys(candidateKeys.Keys())) + for _, uri := range documentURIs { + fileName := uri.FileName() + if fileName == "" { + continue + } + if tspath.GetAnyExtensionFromPath(canonicalize(fileName), candidateExtensionsCanonical, false) == "" { + continue + } + key := canonicalize(string(uri)) + if !seenDocuments.AddIfAbsent(key) { + continue + } + documents = append(documents, uri) + } + if len(documents) == 0 { + return nil + } + + snapshot := s.getSnapshot(ctx, ResourceRequest{ConfiguredProjectDocuments: documents}, false /*callerRef*/) + if err := s.updateContentMapperRegistrations(ctx, snapshot); err != nil { + return nil + } + + var discovered collections.Set[string] + for _, uri := range documents { + project := snapshot.GetDefaultProject(uri) + if project == nil || project.Kind != KindConfigured { + continue + } + commandLine := snapshot.ConfigFileRegistry.GetConfig(project.configFilePath) + if commandLine == nil { + continue + } + for _, extension := range commandLine.ContentMapperExtensions() { + discovered.Add(canonicalize(extension)) + } + } + matched := make([]string, 0, candidateKeys.Len()) + for key := range candidateKeys.Keys() { + if discovered.Has(key) { + extension := candidates[key] + matched = append(matched, extension) + } + } + slices.Sort(matched) + return matched +} + func (s *Session) GetLanguageServicesForDocuments(ctx context.Context, uris []lsproto.DocumentUri) []*ls.LanguageService { snapshot := s.getSnapshot( ctx, @@ -1186,12 +1327,12 @@ func (s *Session) adoptSnapshotChange(baseSnapshot, newSnapshot *Snapshot) { // Session hasn't moved on; adopt the new snapshot. The clone's initial // ref is transferred to become the session's ref for its current snapshot. s.snapshot = newSnapshot + oldSnapshot.Deref(s) s.snapshotMu.Unlock() if s.options.LoggingEnabled { s.logger.Logf("Adopted snapshot %d (parent %d) as current session snapshot (replacing %d)", newSnapshot.id, newSnapshot.parentId, oldSnapshot.id) s.logger.Log(newSnapshot.builderLogs.String()) } - oldSnapshot.Deref(s) } else { // Session has moved on to a newer snapshot; discard this one. // Release the clone's initial ref. If a handler is still using @@ -1257,6 +1398,7 @@ func (s *Session) updateSnapshot(ctx context.Context, overlays map[tspath.Path]* s.logger.Log(err) } } + _ = s.updateContentMapperRegistrations(ctx, newSnapshot) s.publishProgramDiagnostics(oldSnapshot, newSnapshot) s.sendProjectInfoTelemetryForNewProjects(oldSnapshot, newSnapshot) s.warmAutoImportCache(ctx, change, oldSnapshot, newSnapshot) @@ -1352,6 +1494,40 @@ func updateWatch[T any](ctx context.Context, session *Session, logger logging.Lo return errors } +// updateContentMapperRegistrations computes the union of content mapper extensions across all loaded +// configs in the new snapshot and, when the set changes, asks the client to synchronize text documents +// with those extensions. This is how a foreign file (e.g. a `.vue`) begins flowing to the server once a +// config that maps it is discovered. +func (s *Session) updateContentMapperRegistrations(ctx context.Context, snapshot *Snapshot) error { + contentMappers := snapshot.ConfigFileRegistry.contentMappers() + extensions := contentMappers.extensions + + s.contentMapperRegistrationMu.Lock() + defer s.contentMapperRegistrationMu.Unlock() + // Background tasks may finish out of order; never let an older snapshot's task overwrite the + // registration derived from a newer one. + if snapshot.ID() <= s.registeredContentMapperSnapshotID { + return nil + } + if slices.Equal(extensions, s.registeredContentMapperExtensions) { + s.registeredContentMapperSnapshotID = snapshot.ID() + return nil + } + // RegisterContentMapperExtensions replaces the prior registration wholesale (unregistering extensions + // that are no longer mapped and registering the current set), so an empty set removes the registration + // once the last mapping config unloads. On failure we leave the state unadvanced so the next snapshot + // update retries. + if err := s.client.RegisterContentMapperExtensions(ctx, extensions); err != nil { + if s.options.LoggingEnabled { + s.logger.Log(err) + } + return err + } + s.registeredContentMapperExtensions = extensions + s.registeredContentMapperSnapshotID = snapshot.ID() + return nil +} + func (s *Session) updateWatches(oldSnapshot *Snapshot, newSnapshot *Snapshot) error { var errors []error start := time.Now() @@ -1440,6 +1616,9 @@ func (s *Session) Close() { // Cancel periodic performance telemetry s.stopPerformanceTelemetry() s.backgroundQueue.Close() + if s.contentMapperHost != nil { + _ = s.contentMapperHost.Close() + } } func (s *Session) flushChanges(ctx context.Context) (FileChangeSummary, map[tspath.Path]*Overlay, map[tspath.Path]*ATAStateChange, *lsutil.UserPreferences) { diff --git a/internal/project/snapshot.go b/internal/project/snapshot.go index b49e5405dd8..dcffaa0e29c 100644 --- a/internal/project/snapshot.go +++ b/internal/project/snapshot.go @@ -42,6 +42,7 @@ type Snapshot struct { autoImportsWatch *WatchedFiles[map[tspath.Path]string] compilerOptionsForInferredProjects *core.CompilerOptions userPreferences lsutil.UserPreferences + releaseContentMappers func() builderLogs *logging.LogTree apiError error @@ -232,7 +233,12 @@ type ATAStateChange struct { Logs *logging.LogTree } -func (s *Snapshot) Clone(ctx context.Context, change SnapshotChange, overlays map[tspath.Path]*Overlay, session *Session) *Snapshot { +func (s *Snapshot) Clone( + ctx context.Context, + change SnapshotChange, + overlays map[tspath.Path]*Overlay, + session *Session, +) *Snapshot { var logger *logging.LogTree // Print in-progress logs immediately if cloning fails @@ -286,6 +292,7 @@ func (s *Snapshot) Clone(ctx context.Context, change SnapshotChange, overlays ma } start := time.Now() + contentMappers := s.ConfigFileRegistry.contentMappers() fs := newSnapshotFSBuilder(session.fs.fs, s.fs.overlays, overlays, s.fs.diskFiles, s.fs.diskDirectories, s.fs.nodeModulesRealpathAliases, session.options.PositionEncoding, s.toPath) if change.fileChanges.HasExcessiveWatchEvents() { invalidateStart := time.Now() @@ -304,7 +311,11 @@ func (s *Snapshot) Clone(ctx context.Context, change SnapshotChange, overlays ma logger.Logf("npm install detected, invalidated node_modules cache in %v", time.Since(invalidateStart)) } } else { - change.fileChanges = fs.expandAndFilterWatchEvents(change.fileChanges) + var contentMapperExtensions []string + if contentMappers != nil { + contentMapperExtensions = contentMappers.extensions + } + change.fileChanges = fs.expandAndFilterWatchEvents(change.fileChanges, contentMapperExtensions) change.fileChanges = s.fs.expandRealpathAliases(change.fileChanges) change.fileChanges = fs.markDirtyFiles(change.fileChanges) change.fileChanges = fs.convertOpenAndCloseToChanges(change.fileChanges) @@ -335,6 +346,7 @@ func (s *Snapshot) Clone(ctx context.Context, change SnapshotChange, overlays ma customConfigFileName, session.parseCache, session.extendedConfigCache, + session.contentMapperHost, session.client, ) @@ -460,6 +472,9 @@ func (s *Snapshot) Clone(ctx context.Context, change SnapshotChange, overlays ma newSnapshot.parentId = s.id newSnapshot.ProjectCollection = projectCollection newSnapshot.ConfigFileRegistry = configFileRegistry + if session.contentMapperHost != nil { + newSnapshot.releaseContentMappers = session.contentMapperHost.Acquire(newSnapshot.ConfigFileRegistry.contentMappers().mappers) + } newSnapshot.builderLogs = logger newSnapshot.apiError = apiError @@ -535,6 +550,9 @@ func (s *Snapshot) Deref(session *Session) { } func (s *Snapshot) dispose(session *Session) { + if s.releaseContentMappers != nil { + defer s.releaseContentMappers() + } for _, project := range s.ProjectCollection.Projects() { if project.Program != nil && session.programCounter.Deref(project.Program) { // This program is no longer referenced by any snapshot. @@ -545,10 +563,14 @@ func (s *Snapshot) dispose(session *Session) { project.checkerPool.Discard() } for _, file := range project.Program.SourceFiles() { - session.parseCache.Deref(NewParseCacheKey(file.ParseOptions(), file.Hash, file.ScriptKind)) + if !file.IsContentMapperFailureStub() { + session.parseCache.Deref(parseCacheKeyForFile(file)) + } } for _, file := range project.Program.DuplicateSourceFiles() { - session.parseCache.Deref(NewParseCacheKey(file.ParseOptions, file.Hash, file.ScriptKind)) + if !file.IsContentMapperFailureStub { + session.parseCache.Deref(parseCacheKeyForDuplicate(file)) + } } } } diff --git a/internal/project/snapshotfs.go b/internal/project/snapshotfs.go index 70bc4a932cb..5f47e51cd5b 100644 --- a/internal/project/snapshotfs.go +++ b/internal/project/snapshotfs.go @@ -587,10 +587,13 @@ func (s *SnapshotFS) expandRealpathAliases(change FileChangeSummary) FileChangeS } // isRelevantFileName returns true if the given URI refers to a file that -// could affect the project: it has a TypeScript-relevant extension, is a -// dynamic (e.g. untitled) file, or is currently open as an overlay. -func (s *snapshotFSBuilder) isRelevantFileName(uri lsproto.DocumentUri) bool { +// could affect the project: it has a TypeScript-relevant or configured content-mapper extension, +// is a dynamic (e.g. untitled) file, or is currently open as an overlay. +func (s *snapshotFSBuilder) isRelevantFileName(uri lsproto.DocumentUri, contentMapperExtensions []string) bool { fileName := uri.FileName() + if tspath.FileExtensionIsOneOf(fileName, contentMapperExtensions) { + return true + } if tspath.IsDynamicFileName(fileName) { return true } @@ -613,14 +616,14 @@ func (s *snapshotFSBuilder) isRelevantFileName(uri lsproto.DocumentUri) bool { // file deletion URIs using the cached directory structure, and filters out // watch events for paths that are neither known directories nor have relevant // file extensions. -func (s *snapshotFSBuilder) expandAndFilterWatchEvents(change FileChangeSummary) FileChangeSummary { +func (s *snapshotFSBuilder) expandAndFilterWatchEvents(change FileChangeSummary, contentMapperExtensions []string) FileChangeSummary { if change.Deleted.Len() > 0 { var filteredDeleted collections.Set[lsproto.DocumentUri] for uri := range change.Deleted.Keys() { path := s.toPath(uri.FileName()) if _, ok := s.diskDirectories.Get(path); ok { s.collectFilesRecursive(path, &filteredDeleted) - } else if s.isRelevantFileName(uri) || isNodeModulesPath(path) { + } else if s.isRelevantFileName(uri, contentMapperExtensions) || isNodeModulesPath(path) { // node_modules deletions must always be preserved for auto-import registry change handlers. // They won't be in diskDirectories since the registry doesn't use the snapshotFSBuilder for // its file system, since we don't want to retain files read there. @@ -633,7 +636,7 @@ func (s *snapshotFSBuilder) expandAndFilterWatchEvents(change FileChangeSummary) if change.Changed.Len() > 0 { var filteredChanged collections.Set[lsproto.DocumentUri] for uri := range change.Changed.Keys() { - if s.isRelevantFileName(uri) { + if s.isRelevantFileName(uri, contentMapperExtensions) { filteredChanged.Add(uri) } } diff --git a/internal/project/snapshotfs_test.go b/internal/project/snapshotfs_test.go index 63358a96089..e36f4314eca 100644 --- a/internal/project/snapshotfs_test.go +++ b/internal/project/snapshotfs_test.go @@ -1463,7 +1463,7 @@ func TestExpandAndFilterWatchEvents(t *testing.T) { change := FileChangeSummary{} change.Deleted.Add("file:///project/node_modules") - expanded := builder.expandAndFilterWatchEvents(change) + expanded := builder.expandAndFilterWatchEvents(change, nil) assert.Assert(t, expanded.Deleted.Has("file:///project/node_modules"), "bare node_modules directory deletion should be preserved") }) @@ -1477,7 +1477,7 @@ func TestExpandAndFilterWatchEvents(t *testing.T) { change := FileChangeSummary{} change.Deleted.Add("file:///project/node_modules/@scope/pkg") - expanded := builder.expandAndFilterWatchEvents(change) + expanded := builder.expandAndFilterWatchEvents(change, nil) assert.Assert(t, expanded.Deleted.Has("file:///project/node_modules/@scope/pkg"), "package directory deletion inside node_modules should be preserved") }) @@ -1491,7 +1491,7 @@ func TestExpandAndFilterWatchEvents(t *testing.T) { change := FileChangeSummary{} change.Deleted.Add("file:///project/build") - expanded := builder.expandAndFilterWatchEvents(change) + expanded := builder.expandAndFilterWatchEvents(change, nil) assert.Equal(t, expanded.Deleted.Len(), 0, "untracked non-node_modules directory deletion should be dropped") }) @@ -1519,7 +1519,7 @@ func TestExpandAndFilterWatchEvents(t *testing.T) { change := FileChangeSummary{} change.Deleted.Add("file:///src") - expanded := builder.expandAndFilterWatchEvents(change) + expanded := builder.expandAndFilterWatchEvents(change, nil) assert.Assert(t, expanded.Deleted.Has("file:///src/foo.ts"), "tracked directory deletion should expand to contained file deletions") assert.Assert(t, !expanded.Deleted.Has("file:///src"), diff --git a/internal/spanmap/spanmap.go b/internal/spanmap/spanmap.go new file mode 100644 index 00000000000..2329cf0540f --- /dev/null +++ b/internal/spanmap/spanmap.go @@ -0,0 +1,616 @@ +// Package spanmap provides bidirectional span-aware mapping between a content mapper's transformed +// output and its original, untransformed source. Unlike a source map, which records +// point correspondences and leaves spans and "no origin" implicit, a SpanMap records explicit segments +// for the parts of the generated text that correspond to the original; positions not covered by any +// segment are synthesized (generated content with no original counterpart). All positions are absolute +// offsets (core.TextPos), matching the compiler's TextRange model. +package spanmap + +// Keep this in sync with spanMap.ts + +import ( + "fmt" + "slices" + "sync" + + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/json" +) + +// Kind describes how positions inside a segment relate the generated span to the original span. +type Kind int32 + +const ( + // KindVerbatim segments are length-preserving: the generated and original spans have the same + // length and interior positions map 1:1 (origPos = pos - GenStart + OrigStart). A generated span + // fully within a verbatim segment maps to an exact original span. + KindVerbatim Kind = iota + // KindAtom segments map a generated span to an original span as a whole; interior positions are not + // interpolatable (the lengths may differ), so positions within clamp to the segment's endpoints. + // Used for renamed identifiers or short expressions. + KindAtom + // KindAlias has atom geometry, but additionally asserts that the generated and original texts are + // names for the same logical entity. Diagnostic presentation may substitute the original name. + KindAlias +) + +// Purpose selects which generated projection should receive an original-to-generated language-service query. +// It does not affect generated-to-original result mapping. +type Purpose int32 + +const ( + // PurposeNone prevents original-to-generated language-service queries from selecting the segment. + PurposeNone Purpose = 0 + // PurposeSemantic selects segments used by type-information features such as hover and completion. + PurposeSemantic Purpose = 1 << 0 + // PurposeNavigation selects segments used by symbol-location features such as definitions and references. + PurposeNavigation Purpose = 1 << 1 + // PurposeAll selects segments for both semantic and navigation queries. + PurposeAll Purpose = PurposeSemantic | PurposeNavigation +) + +const purposeMask = PurposeAll + +// Fidelity describes how faithfully a mapped span reflects the original. +type Fidelity int32 + +const ( + // FidelityExact means the span fell entirely within a single verbatim segment and maps precisely. + FidelityExact Fidelity = iota + // FidelityAtom means the span fell within a single atom segment and maps to that atom's span. + FidelityAtom + // FidelityApproximate means the span crossed segment boundaries; its endpoints were mapped and clamped. + FidelityApproximate + // FidelityNone means the span had no original counterpart (it was entirely synthesized). + FidelityNone +) + +// IsExact reports whether the mapping was fully faithful — the input fell within a single verbatim span — +// so the result maps 1:1 and can host a text edit written back to the original. +func (f Fidelity) IsExact() bool { + return f == FidelityExact +} + +// IsSingleSegment reports whether the input fell within one segment, verbatim or atom, so the result is a +// concrete location rather than a best-effort approximation across boundaries or a synthesized gap. +func (f Fidelity) IsSingleSegment() bool { + return f == FidelityExact || f == FidelityAtom +} + +// IsNone reports whether the input had no original counterpart, meaning the mapped result is a synthesized +// gap that does not correspond to any location in the original text. +func (f Fidelity) IsNone() bool { + return f == FidelityNone +} + +// Segment maps the half-open generated range [GenStart, GenEnd) to the half-open original range +// [OrigStart, OrigEnd). Purpose is consulted only for original-to-generated queries. +type Segment struct { + GenStart core.TextPos + GenEnd core.TextPos + OrigStart core.TextPos + OrigEnd core.TextPos + Kind Kind + Purpose Purpose +} + +// MappedPosition is one generated projection of an original position and its mapping fidelity. +type MappedPosition struct { + Position core.TextPos + Fidelity Fidelity +} + +// MappedSpan is one generated projection of an original range and its mapping fidelity. +type MappedSpan struct { + Span core.TextRange + Fidelity Fidelity +} + +// SpanMap is a sparse, ordered set of segments over a content mapper's generated text. Segments do not +// need to cover the whole output: any generated position not inside a segment is synthesized (it has no +// original counterpart). An empty SpanMap therefore describes fully synthesized output. +type SpanMap struct { + segments []Segment + + // origOnce guards lazy construction of origSorted, the segments ordered by OrigStart, used for + // original-to-generated lookups. + origOnce sync.Once + origSorted []Segment +} + +// Validation failures. A content mapper is required to provide a valid span map; these describe the +// ways a map can be malformed, so the compiler can attribute the failure to the mapper precisely and +// point the mapper's author at the offending location. +type MappingErrorKind int + +const ( + // MappingErrorKindOverlap means the segments overlap, run backwards, or extend past the end of the + // transformed text (they must be ordered and disjoint in generated space). + MappingErrorKindOverlap MappingErrorKind = iota + // MappingErrorKindOutOfBounds means a segment's original span lies outside the original text. + MappingErrorKindOutOfBounds + // MappingErrorKindVerbatimMismatch means a verbatim segment's generated and original text differ. + MappingErrorKindVerbatimMismatch + // MappingErrorKindKind means a segment uses an unsupported mapping kind. + MappingErrorKindKind + // MappingErrorKindOriginalOverlap means original spans partially overlap or contain one another. + MappingErrorKindOriginalOverlap + // MappingErrorKindPurpose means a purpose annotation contains unsupported flags. + MappingErrorKindPurpose +) + +// MappingError describes a single span map validation failure, including the offsets involved so the mapper's +// author can locate it. GenPos is an offset into the transformed output; OrigPos is an offset into the +// original content. Either may be unused (zero) depending on Kind. +type MappingError struct { + Kind MappingErrorKind + GenPos core.TextPos + OrigPos core.TextPos +} + +// Error describes the invalid mapping and the coordinate at which it was detected. +func (p *MappingError) Error() string { + switch p.Kind { + case MappingErrorKindOverlap: + return fmt.Sprintf("content mapper position mappings overlap or are out of order near output offset %d", p.GenPos) + case MappingErrorKindOutOfBounds: + return fmt.Sprintf("content mapper position mapping points outside the original content at original offset %d", p.OrigPos) + case MappingErrorKindVerbatimMismatch: + return fmt.Sprintf("content mapper verbatim mapping does not match the original content at output offset %d, original offset %d", p.GenPos, p.OrigPos) + case MappingErrorKindKind: + return fmt.Sprintf("content mapper position mapping has an invalid kind at output offset %d", p.GenPos) + case MappingErrorKindOriginalOverlap: + return fmt.Sprintf("content mapper position mappings partially overlap in the original content near offset %d", p.OrigPos) + case MappingErrorKindPurpose: + return fmt.Sprintf("content mapper position mappings have invalid purposes near original offset %d", p.OrigPos) + default: + return "content mapper produced an invalid position mapping" + } +} + +// Validate enforces the content-mapper span map contract against the transformed and original text: the +// segments must be ordered and disjoint in generated space and stay within the transformed text, every +// original span must lie within the original text, and every verbatim segment's text must match the +// original exactly. Gaps are allowed (they map as synthesized) and an empty map is valid. It returns the +// first violation found, or nil if the map is valid. +func (m *SpanMap) Validate(transformed, original string) *MappingError { + if m == nil { + return nil + } + genLen := core.TextPos(len(transformed)) + origLen := core.TextPos(len(original)) + var prevGenEnd core.TextPos + for i := range m.segments { + s := &m.segments[i] + if s.GenStart < prevGenEnd || s.GenEnd < s.GenStart || s.GenEnd > genLen { + return &MappingError{Kind: MappingErrorKindOverlap, GenPos: s.GenStart} + } + prevGenEnd = s.GenEnd + if s.OrigStart < 0 || s.OrigEnd < s.OrigStart || s.OrigEnd > origLen { + return &MappingError{Kind: MappingErrorKindOutOfBounds, GenPos: s.GenStart, OrigPos: s.OrigEnd} + } + if s.Kind != KindVerbatim && s.Kind != KindAtom && s.Kind != KindAlias { + return &MappingError{Kind: MappingErrorKindKind, GenPos: s.GenStart, OrigPos: s.OrigStart} + } + if s.Kind == KindVerbatim { + if s.GenEnd-s.GenStart != s.OrigEnd-s.OrigStart || + transformed[s.GenStart:s.GenEnd] != original[s.OrigStart:s.OrigEnd] { + return &MappingError{Kind: MappingErrorKindVerbatimMismatch, GenPos: s.GenStart, OrigPos: s.OrigStart} + } + } + if s.Purpose&^purposeMask != 0 { + return &MappingError{Kind: MappingErrorKindPurpose, GenPos: s.GenStart, OrigPos: s.OrigStart} + } + } + originalSegments := m.origIndex() + for i := 0; i < len(originalSegments); { + groupEnd := i + 1 + for groupEnd < len(originalSegments) && originalSegments[groupEnd].OrigStart == originalSegments[i].OrigStart && originalSegments[groupEnd].OrigEnd == originalSegments[i].OrigEnd { + groupEnd++ + } + if i > 0 && originalSegments[i].OrigStart < originalSegments[i-1].OrigEnd { + return &MappingError{Kind: MappingErrorKindOriginalOverlap, GenPos: originalSegments[i].GenStart, OrigPos: originalSegments[i].OrigStart} + } + i = groupEnd + } + return nil +} + +// New builds a SpanMap from segments, sorted by generated start. Segments describe only the parts of the +// generated text that correspond to the original; anything not covered maps as synthesized. +func New(segments []Segment) *SpanMap { + sorted := slices.Clone(segments) + slices.SortFunc(sorted, func(a, b Segment) int { + return int(a.GenStart - b.GenStart) + }) + return &SpanMap{segments: sorted} +} + +// Segments returns the map's segments ordered by generated start. +func (m *SpanMap) Segments() []Segment { + if m == nil { + return nil + } + return slices.Clone(m.segments) +} + +// GeneratedToOriginalSpan maps a generated range to an original range, along with the fidelity of the result. A generated +// range that lies entirely in a gap between segments (or in an empty map) is synthesized: it maps to the +// insertion point in the original with FidelityNone. A nil SpanMap maps identically. +func (m *SpanMap) GeneratedToOriginalSpan(r core.TextRange) (core.TextRange, Fidelity) { + if m == nil { + return r, FidelityExact + } + genStart := core.TextPos(r.Pos()) + genEnd := max(core.TextPos(r.End()), genStart) + + startIdx, startIn := m.segmentIndexAt(genStart) + endProbe := genEnd + if genEnd > genStart { + endProbe = genEnd - 1 + } + endIdx, endIn := m.segmentIndexAt(endProbe) + + if startIdx == endIdx && startIn == endIn { + if startIn { + seg := &m.segments[startIdx] + if seg.Kind == KindVerbatim { + origStart := clamp(seg.OrigStart+(genStart-seg.GenStart), seg.OrigStart, seg.OrigEnd) + origEnd := clamp(seg.OrigStart+(genEnd-seg.GenStart), origStart, seg.OrigEnd) + return core.NewTextRange(int(origStart), int(origEnd)), FidelityExact + } + return core.NewTextRange(int(seg.OrigStart), int(seg.OrigEnd)), FidelityAtom + } + // Entirely within a single synthesized gap. + pos := m.insertionPoint(startIdx) + return core.NewTextRange(int(pos), int(pos)), FidelityNone + } + + origStart := m.mapLow(genStart, startIdx, startIn) + origEnd := max(m.mapHigh(genEnd, endIdx, endIn), origStart) + return core.NewTextRange(int(origStart), int(origEnd)), FidelityApproximate +} + +// GeneratedToOriginalPosition maps a single generated position to the corresponding original position, along with the +// fidelity of the result. It is the single-position analog of GeneratedToOriginalSpan: a position in a gap (or in an empty +// map) is synthesized and maps to the insertion point with FidelityNone. A nil SpanMap maps identically. +func (m *SpanMap) GeneratedToOriginalPosition(pos core.TextPos) (core.TextPos, Fidelity) { + if m == nil { + return pos, FidelityExact + } + idx, in := m.segmentIndexAt(pos) + if !in { + return m.insertionPoint(idx), FidelityNone + } + seg := &m.segments[idx] + if seg.Kind == KindVerbatim { + return clamp(seg.OrigStart+(pos-seg.GenStart), seg.OrigStart, seg.OrigEnd), FidelityExact + } + return seg.OrigStart, FidelityAtom +} + +// AliasForGeneratedSpan returns the alias segment exactly covering r. Partial overlap does not qualify: +// diagnostic text may be substituted only when the diagnostic identifies the complete generated alias. +func (m *SpanMap) AliasForGeneratedSpan(r core.TextRange) (Segment, bool) { + if m == nil { + return Segment{}, false + } + index, inside := m.segmentIndexAt(core.TextPos(r.Pos())) + if !inside { + return Segment{}, false + } + segment := m.segments[index] + return segment, segment.Kind == KindAlias && r.Pos() == int(segment.GenStart) && r.End() == int(segment.GenEnd) +} + +// segmentIndexAt returns the index of the segment containing pos and true, or, when pos lies in a gap, +// the index of the segment immediately before pos (-1 if none) and false. +func (m *SpanMap) segmentIndexAt(pos core.TextPos) (int, bool) { + idx, found := slices.BinarySearchFunc(m.segments, pos, func(s Segment, p core.TextPos) int { + return int(s.GenStart - p) + }) + if found { + return idx, true + } + prev := idx - 1 + if prev >= 0 && pos < m.segments[prev].GenEnd { + return prev, true + } + return prev, false +} + +// insertionPoint returns the original offset where synthesized content following segment prev sits: the +// original end of that segment, or 0 before the first segment. +func (m *SpanMap) insertionPoint(prev int) core.TextPos { + if prev < 0 { + return 0 + } + return m.segments[prev].OrigEnd +} + +// mapLow maps a generated lower range boundary to original coordinates. A boundary in a synthesized +// gap uses that gap's insertion point; an atom uses its original start. +func (m *SpanMap) mapLow(pos core.TextPos, idx int, in bool) core.TextPos { + if !in { + return m.insertionPoint(idx) + } + seg := &m.segments[idx] + if seg.Kind == KindVerbatim { + return clamp(seg.OrigStart+(pos-seg.GenStart), seg.OrigStart, seg.OrigEnd) + } + return seg.OrigStart +} + +// mapHigh maps a generated upper range boundary to original coordinates. A boundary in a synthesized +// gap uses that gap's insertion point; an atom uses its original end. +func (m *SpanMap) mapHigh(pos core.TextPos, idx int, in bool) core.TextPos { + if !in { + return m.insertionPoint(idx) + } + seg := &m.segments[idx] + if seg.Kind == KindVerbatim { + return clamp(seg.OrigStart+(pos-seg.GenStart), seg.OrigStart, seg.OrigEnd) + } + return seg.OrigEnd +} + +// OriginalToGeneratedPositions returns every generated projection of an original position whose segment +// supports purpose. Results are ordered by generated start. It returns no results for an uncovered position +// or when all covering segments reject purpose. A nil SpanMap maps identically. +func (m *SpanMap) OriginalToGeneratedPositions(pos core.TextPos, purpose Purpose) []MappedPosition { + if m == nil { + return []MappedPosition{{Position: pos, Fidelity: FidelityExact}} + } + segments, inside := originalSegmentsAt(m.origIndex(), pos) + if !inside { + return nil + } + results := make([]MappedPosition, 0, len(segments)) + for _, segment := range segments { + if !supportsPurpose(segment, purpose) { + continue + } + if segment.Kind == KindVerbatim { + results = append(results, MappedPosition{ + Position: clamp(segment.GenStart+(pos-segment.OrigStart), segment.GenStart, segment.GenEnd), + Fidelity: FidelityExact, + }) + } else { + results = append(results, MappedPosition{Position: segment.GenStart, Fidelity: FidelityAtom}) + } + } + return results +} + +// OriginalToGeneratedSpans returns every purpose-compatible generated 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 generated ranges. For +// example, suppose two original segments are each copied twice into the generated text: +// +// original: [ A ][ B ] +// [---) range from inside A to inside B +// +// generated: [ 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. +// If either boundary is uncovered or disabled for purpose, there are no results. A nil SpanMap maps identically. +func (m *SpanMap) OriginalToGeneratedSpans(r core.TextRange, purpose Purpose) []MappedSpan { + if m == nil { + return []MappedSpan{{Span: r, Fidelity: FidelityExact}} + } + start := core.TextPos(r.Pos()) + end := max(core.TextPos(r.End()), start) + lastCharacter := end + if end > start { + lastCharacter-- + } + originalSegments := m.origIndex() + startSegments, startInside := originalSegmentsAt(originalSegments, start) + endSegments, endInside := originalSegmentsAt(originalSegments, lastCharacter) + if !startInside || !endInside { + return nil + } + if sameOriginalRange(startSegments[0], endSegments[0]) { + return originalToGeneratedSpansInGroup(startSegments, start, end, purpose) + } + starts := originalStartProjections(startSegments, start, purpose) + ends := originalEndProjections(endSegments, end, purpose) + if len(starts) == 0 || len(ends) == 0 { + return nil + } + results := make([]MappedSpan, 0, min(len(starts), len(ends))) + for i, genStart := range starts { + endIndex, _ := slices.BinarySearch(ends, genStart) + if endIndex == len(ends) || i+1 < len(starts) && starts[i+1] <= ends[endIndex] { + continue + } + results = append(results, MappedSpan{ + Span: core.NewTextRange(int(genStart), int(ends[endIndex])), + Fidelity: FidelityApproximate, + }) + } + return results +} + +// originalStartProjections maps the inclusive start of an original range through every matching segment. +// Verbatim segments preserve the offset within the segment; atoms map to their generated start. +// +// For duplicate verbatim segments, the start keeps the same relative offset in every copy: +// +// original: [---------) +// ^ start +// +// generated: [---------) [---------) +// ^ ^ +// result result +func originalStartProjections(segments []Segment, start core.TextPos, purpose Purpose) []core.TextPos { + results := make([]core.TextPos, 0, len(segments)) + for _, segment := range segments { + if !supportsPurpose(segment, purpose) { + continue + } + if segment.Kind == KindVerbatim { + results = append(results, clamp(segment.GenStart+(start-segment.OrigStart), segment.GenStart, segment.GenEnd)) + } else { + results = append(results, segment.GenStart) + } + } + return results +} + +// originalEndProjections 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. Verbatim segments preserve that boundary; atoms map to their generated end. +// +// The lookup uses end-1 so an end at a segment boundary selects the segment on its left, not the next one: +// +// original: [---------)[ next segment ) +// ^`-- end +// `--- end-1 +// +// generated: [---------) [---------) +// ^ ^ +// result result +func originalEndProjections(segments []Segment, end core.TextPos, purpose Purpose) []core.TextPos { + results := make([]core.TextPos, 0, len(segments)) + for _, segment := range segments { + if !supportsPurpose(segment, purpose) { + continue + } + if segment.Kind == KindVerbatim { + results = append(results, clamp(segment.GenStart+(end-segment.OrigStart), segment.GenStart, segment.GenEnd)) + } else { + results = append(results, segment.GenEnd) + } + } + return results +} + +// originalToGeneratedSpansInGroup maps a range whose boundaries are known to lie in segments. +func originalToGeneratedSpansInGroup(segments []Segment, start core.TextPos, end core.TextPos, purpose Purpose) []MappedSpan { + results := make([]MappedSpan, 0, len(segments)) + for _, segment := range segments { + if !supportsPurpose(segment, purpose) { + continue + } + if segment.Kind == KindVerbatim { + genStart := clamp(segment.GenStart+(start-segment.OrigStart), segment.GenStart, segment.GenEnd) + genEnd := clamp(segment.GenStart+(end-segment.OrigStart), genStart, segment.GenEnd) + results = append(results, MappedSpan{Span: core.NewTextRange(int(genStart), int(genEnd)), Fidelity: FidelityExact}) + } else { + results = append(results, MappedSpan{Span: core.NewTextRange(int(segment.GenStart), int(segment.GenEnd)), Fidelity: FidelityAtom}) + } + } + return results +} + +// sameOriginalRange reports whether two segments belong to the same duplicate group. +func sameOriginalRange(left Segment, right Segment) bool { + return left.OrigStart == right.OrigStart && left.OrigEnd == right.OrigEnd +} + +// origIndex returns the segments ordered by OrigStart, building it once on first use. +func (m *SpanMap) origIndex() []Segment { + m.origOnce.Do(func() { + m.origSorted = slices.Clone(m.segments) + slices.SortFunc(m.origSorted, func(a, b Segment) int { + if c := int(a.OrigStart - b.OrigStart); c != 0 { + return c + } + if c := int(a.OrigEnd - b.OrigEnd); c != 0 { + return c + } + return int(a.GenStart - b.GenStart) + }) + }) + return m.origSorted +} + +// originalSegmentsAt returns the complete duplicate group containing pos from a slice ordered by original +// start, original end, and generated start. Segment ends are exclusive; a segment start, including a zero-length +// segment, is considered contained. It finds a candidate in O(log n), then scans only the duplicate group. +// The boolean reports whether any group contains pos. +func originalSegmentsAt(segments []Segment, pos core.TextPos) ([]Segment, bool) { + index, found := slices.BinarySearchFunc(segments, pos, func(segment Segment, position core.TextPos) int { + return int(segment.OrigStart - position) + }) + if !found { + index-- + } + if index < 0 || !(segments[index].OrigStart == pos || pos < segments[index].OrigEnd) { + return nil, false + } + start := index + for start > 0 && sameOriginalRange(segments[start-1], segments[index]) { + start-- + } + end := start + 1 + for end < len(segments) && sameOriginalRange(segments[end], segments[start]) { + end++ + } + return segments[start:end], true +} + +// supportsPurpose reports whether segment participates in an original-to-generated query for purpose. +func supportsPurpose(segment Segment, purpose Purpose) bool { + return segment.Purpose&purpose != 0 +} + +// clamp confines v to the inclusive interval [lo, hi]. +func clamp(v, lo, hi core.TextPos) core.TextPos { + return max(lo, min(v, hi)) +} + +// Unmarshal decodes a SpanMap from the JSON tuple form produced by an out-of-process content mapper. +// Five-element tuples omit purpose and are normalized to PurposeAll; six-element tuples preserve the +// explicit purpose, including PurposeNone. +func Unmarshal(data []byte) (*SpanMap, error) { + var tuples [][]int32 + if err := json.Unmarshal(data, &tuples); err != nil { + return nil, err + } + segments := make([]Segment, len(tuples)) + for i, t := range tuples { + if len(t) != 5 && len(t) != 6 { + return nil, fmt.Errorf("span map segment %d: expected 5 or 6 values, got %d", i, len(t)) + } + segments[i] = Segment{ + GenStart: core.TextPos(t[0]), + GenEnd: core.TextPos(t[0] + t[1]), + OrigStart: core.TextPos(t[2]), + OrigEnd: core.TextPos(t[2] + t[3]), + Kind: Kind(t[4]), + Purpose: PurposeAll, + } + if len(t) == 6 { + segments[i].Purpose = Purpose(t[5]) + } + } + return New(segments), nil +} + +// Marshal encodes a SpanMap into the JSON tuple form. PurposeAll uses the backward-compatible five-element +// tuple; every other purpose is emitted as a sixth element. +func (m *SpanMap) Marshal() ([]byte, error) { + tuples := make([][]int32, len(m.segments)) + for i, s := range m.segments { + tuples[i] = []int32{ + int32(s.GenStart), + int32(s.GenEnd - s.GenStart), + int32(s.OrigStart), + int32(s.OrigEnd - s.OrigStart), + int32(s.Kind), + } + if s.Purpose != PurposeAll { + tuples[i] = append(tuples[i], int32(s.Purpose)) + } + } + return json.Marshal(tuples) +} diff --git a/internal/spanmap/spanmap_test.go b/internal/spanmap/spanmap_test.go new file mode 100644 index 00000000000..c8942c2dfcd --- /dev/null +++ b/internal/spanmap/spanmap_test.go @@ -0,0 +1,513 @@ +package spanmap_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/spanmap" + "gotest.tools/v3/assert" +) + +func TestGeneratedToOriginalSpanVerbatim(t *testing.T) { + t.Parallel() + + // Generated [0,10) is a verbatim copy of original [100,110). + m := spanmap.New([]spanmap.Segment{ + {GenStart: 0, GenEnd: 10, OrigStart: 100, OrigEnd: 110, Kind: spanmap.KindVerbatim, Purpose: spanmap.PurposeAll}, + }) + + got, fidelity := m.GeneratedToOriginalSpan(core.NewTextRange(3, 7)) + assert.Equal(t, got.Pos(), 103) + assert.Equal(t, got.End(), 107) + assert.Equal(t, fidelity, spanmap.FidelityExact) +} + +func TestGeneratedToOriginalSpanAtom(t *testing.T) { + t.Parallel() + + // Generated [0,3) is a synthesized gap; [3,14) ("MyComponent") is an atom of the original [60,71). + m := spanmap.New([]spanmap.Segment{ + {GenStart: 3, GenEnd: 14, OrigStart: 60, OrigEnd: 71, Kind: spanmap.KindAtom, Purpose: spanmap.PurposeAll}, + }) + + // A span inside the atom maps to the whole atom span. + got, fidelity := m.GeneratedToOriginalSpan(core.NewTextRange(5, 9)) + assert.Equal(t, got.Pos(), 60) + assert.Equal(t, got.End(), 71) + assert.Equal(t, fidelity, spanmap.FidelityAtom) +} + +func TestGeneratedAlias(t *testing.T) { + t.Parallel() + + m := spanmap.New([]spanmap.Segment{ + {GenStart: 3, GenEnd: 6, OrigStart: 10, OrigEnd: 11, Kind: spanmap.KindAlias, Purpose: spanmap.PurposeAll}, + }) + + got, fidelity := m.GeneratedToOriginalSpan(core.NewTextRange(3, 6)) + assert.Equal(t, got, core.NewTextRange(10, 11)) + assert.Equal(t, fidelity, spanmap.FidelityAtom) + alias, ok := m.AliasForGeneratedSpan(core.NewTextRange(3, 6)) + assert.Assert(t, ok) + assert.Equal(t, alias.Kind, spanmap.KindAlias) + _, partial := m.AliasForGeneratedSpan(core.NewTextRange(4, 6)) + assert.Assert(t, !partial) + + data, err := m.Marshal() + assert.NilError(t, err) + decoded, err := spanmap.Unmarshal(data) + assert.NilError(t, err) + assert.Equal(t, decoded.Segments()[0].Kind, spanmap.KindAlias) +} + +func TestGeneratedToOriginalSpanSynthesizedGap(t *testing.T) { + t.Parallel() + + // A gap between two verbatim segments is synthesized: it maps to the insertion point (the preceding + // segment's original end) with no fidelity. + m := spanmap.New([]spanmap.Segment{ + {GenStart: 0, GenEnd: 10, OrigStart: 100, OrigEnd: 110, Kind: spanmap.KindVerbatim, Purpose: spanmap.PurposeAll}, + {GenStart: 20, GenEnd: 30, OrigStart: 200, OrigEnd: 210, Kind: spanmap.KindVerbatim, Purpose: spanmap.PurposeAll}, + }) + + got, fidelity := m.GeneratedToOriginalSpan(core.NewTextRange(12, 15)) + assert.Equal(t, got.Pos(), 110) + assert.Equal(t, got.End(), 110) + assert.Equal(t, fidelity, spanmap.FidelityNone) +} + +func TestGeneratedToOriginalSpanEmptyIsSynthesized(t *testing.T) { + t.Parallel() + + // An empty map describes fully synthesized output: everything maps to the start with no fidelity. + m := spanmap.New(nil) + got, fidelity := m.GeneratedToOriginalSpan(core.NewTextRange(5, 10)) + assert.Equal(t, got.Pos(), 0) + assert.Equal(t, got.End(), 0) + assert.Equal(t, fidelity, spanmap.FidelityNone) +} + +func TestGeneratedToOriginalSpanCrossingSegments(t *testing.T) { + t.Parallel() + + m := spanmap.New([]spanmap.Segment{ + {GenStart: 0, GenEnd: 10, OrigStart: 100, OrigEnd: 110, Kind: spanmap.KindVerbatim}, + {GenStart: 10, GenEnd: 20, OrigStart: 200, OrigEnd: 210, Kind: spanmap.KindVerbatim}, + }) + + got, fidelity := m.GeneratedToOriginalSpan(core.NewTextRange(5, 15)) + assert.Equal(t, got.Pos(), 105) + assert.Equal(t, got.End(), 205) + assert.Equal(t, fidelity, spanmap.FidelityApproximate) +} + +func TestGeneratedToOriginalSpanNilIdentity(t *testing.T) { + t.Parallel() + + var m *spanmap.SpanMap + got, fidelity := m.GeneratedToOriginalSpan(core.NewTextRange(3, 7)) + assert.Equal(t, got.Pos(), 3) + assert.Equal(t, got.End(), 7) + assert.Equal(t, fidelity, spanmap.FidelityExact) +} + +func TestGeneratedToOriginalPosition(t *testing.T) { + t.Parallel() + + // Generated [0,10) is a verbatim copy of original [100,110); [10,20) is an atom of original [200,210). + m := spanmap.New([]spanmap.Segment{ + {GenStart: 0, GenEnd: 10, OrigStart: 100, OrigEnd: 110, Kind: spanmap.KindVerbatim, Purpose: spanmap.PurposeAll}, + {GenStart: 20, GenEnd: 30, OrigStart: 200, OrigEnd: 210, Kind: spanmap.KindAtom, Purpose: spanmap.PurposeAll}, + }) + + testCases := []struct { + name string + pos core.TextPos + want core.TextPos + fidelity spanmap.Fidelity + }{ + {"verbatim interpolates", 3, 103, spanmap.FidelityExact}, + {"atom maps to its start", 25, 200, spanmap.FidelityAtom}, + {"gap maps to insertion point", 15, 110, spanmap.FidelityNone}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, fidelity := m.GeneratedToOriginalPosition(tc.pos) + assert.Equal(t, got, tc.want) + assert.Equal(t, fidelity, tc.fidelity) + // GeneratedToOriginalPosition must agree with GeneratedToOriginalSpan on a zero-length range. + span, spanFidelity := m.GeneratedToOriginalSpan(core.NewTextRange(int(tc.pos), int(tc.pos))) + assert.Equal(t, got, core.TextPos(span.Pos())) + assert.Equal(t, fidelity, spanFidelity) + }) + } +} + +func TestMapPositionNilIdentity(t *testing.T) { + t.Parallel() + + var m *spanmap.SpanMap + got, fidelity := m.GeneratedToOriginalPosition(7) + assert.Equal(t, got, core.TextPos(7)) + assert.Equal(t, fidelity, spanmap.FidelityExact) +} + +func TestOriginalToGeneratedSpanVerbatim(t *testing.T) { + t.Parallel() + + // Generated [0,10) is a verbatim copy of original [100,110). + m := spanmap.New([]spanmap.Segment{ + {GenStart: 0, GenEnd: 10, OrigStart: 100, OrigEnd: 110, Kind: spanmap.KindVerbatim, Purpose: spanmap.PurposeAll}, + }) + + results := m.OriginalToGeneratedSpans(core.NewTextRange(103, 107), spanmap.PurposeAll) + assert.Equal(t, len(results), 1) + assert.Equal(t, results[0].Span.Pos(), 3) + assert.Equal(t, results[0].Span.End(), 7) + assert.Equal(t, results[0].Fidelity, spanmap.FidelityExact) +} + +func TestOriginalToGeneratedSpanAtom(t *testing.T) { + t.Parallel() + + // Generated [3,14) is an atom of the original [60,71). + m := spanmap.New([]spanmap.Segment{ + {GenStart: 3, GenEnd: 14, OrigStart: 60, OrigEnd: 71, Kind: spanmap.KindAtom, Purpose: spanmap.PurposeAll}, + }) + + // A span inside the original atom maps to the whole generated span. + results := m.OriginalToGeneratedSpans(core.NewTextRange(63, 67), spanmap.PurposeAll) + assert.Equal(t, len(results), 1) + assert.Equal(t, results[0].Span.Pos(), 3) + assert.Equal(t, results[0].Span.End(), 14) + assert.Equal(t, results[0].Fidelity, spanmap.FidelityAtom) +} + +func TestOriginalToGeneratedSpanGap(t *testing.T) { + t.Parallel() + + // An original range with no covering segment has no generated counterpart. + m := spanmap.New([]spanmap.Segment{ + {GenStart: 0, GenEnd: 10, OrigStart: 100, OrigEnd: 110, Kind: spanmap.KindVerbatim, Purpose: spanmap.PurposeAll}, + {GenStart: 20, GenEnd: 30, OrigStart: 200, OrigEnd: 210, Kind: spanmap.KindVerbatim, Purpose: spanmap.PurposeAll}, + }) + + assert.Equal(t, len(m.OriginalToGeneratedSpans(core.NewTextRange(150, 160), spanmap.PurposeAll)), 0) +} + +func TestOriginalToGeneratedSpanNilIdentity(t *testing.T) { + t.Parallel() + + var m *spanmap.SpanMap + results := m.OriginalToGeneratedSpans(core.NewTextRange(3, 7), spanmap.PurposeAll) + assert.Equal(t, len(results), 1) + assert.Equal(t, results[0].Span.Pos(), 3) + assert.Equal(t, results[0].Span.End(), 7) + assert.Equal(t, results[0].Fidelity, spanmap.FidelityExact) +} + +func TestOriginalToGeneratedPositions(t *testing.T) { + t.Parallel() + + // Original [100,110) is a verbatim copy of generated [0,10); [200,210) is an atom of generated [20,30). + m := spanmap.New([]spanmap.Segment{ + {GenStart: 0, GenEnd: 10, OrigStart: 100, OrigEnd: 110, Kind: spanmap.KindVerbatim, Purpose: spanmap.PurposeAll}, + {GenStart: 20, GenEnd: 30, OrigStart: 200, OrigEnd: 210, Kind: spanmap.KindAtom, Purpose: spanmap.PurposeAll}, + }) + + testCases := []struct { + name string + pos core.TextPos + want core.TextPos + fidelity spanmap.Fidelity + }{ + {"verbatim interpolates", 103, 3, spanmap.FidelityExact}, + {"atom maps to its start", 205, 20, spanmap.FidelityAtom}, + {"gap has no projection", 150, 0, spanmap.FidelityNone}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + positions := m.OriginalToGeneratedPositions(tc.pos, spanmap.PurposeAll) + spans := m.OriginalToGeneratedSpans(core.NewTextRange(int(tc.pos), int(tc.pos)), spanmap.PurposeAll) + if tc.fidelity.IsNone() { + assert.Equal(t, len(positions), 0) + assert.Equal(t, len(spans), 0) + return + } + assert.Equal(t, len(positions), 1) + assert.Equal(t, positions[0].Position, tc.want) + assert.Equal(t, positions[0].Fidelity, tc.fidelity) + assert.Equal(t, len(spans), 1) + assert.Equal(t, core.TextPos(spans[0].Span.Pos()), tc.want) + assert.Equal(t, spans[0].Fidelity, tc.fidelity) + }) + } +} + +func TestOriginalToGeneratedDuplicateGroup(t *testing.T) { + t.Parallel() + + m := spanmap.New([]spanmap.Segment{ + {GenStart: 0, GenEnd: 3, OrigStart: 10, OrigEnd: 13, Kind: spanmap.KindVerbatim, Purpose: spanmap.PurposeNavigation}, + {GenStart: 10, GenEnd: 13, OrigStart: 10, OrigEnd: 13, Kind: spanmap.KindVerbatim, Purpose: spanmap.PurposeSemantic}, + {GenStart: 20, GenEnd: 25, OrigStart: 10, OrigEnd: 13, Kind: spanmap.KindAtom, Purpose: spanmap.PurposeNavigation}, + }) + + semantic := m.OriginalToGeneratedPositions(11, spanmap.PurposeSemantic) + assert.Equal(t, len(semantic), 1) + assert.Equal(t, semantic[0].Position, core.TextPos(11)) + assert.Equal(t, semantic[0].Fidelity, spanmap.FidelityExact) + + navigation := m.OriginalToGeneratedPositions(11, spanmap.PurposeNavigation) + assert.Equal(t, len(navigation), 2) + assert.Equal(t, navigation[0].Position, core.TextPos(1)) + assert.Equal(t, navigation[0].Fidelity, spanmap.FidelityExact) + assert.Equal(t, navigation[1].Position, core.TextPos(20)) + assert.Equal(t, navigation[1].Fidelity, spanmap.FidelityAtom) + + spans := m.OriginalToGeneratedSpans(core.NewTextRange(10, 13), spanmap.PurposeNavigation) + assert.Equal(t, len(spans), 2) + assert.Equal(t, spans[0].Span.Pos(), 0) + assert.Equal(t, spans[0].Span.End(), 3) + assert.Equal(t, spans[1].Span.Pos(), 20) + assert.Equal(t, spans[1].Span.End(), 25) +} + +func TestOriginalToGeneratedCrossGroupProjections(t *testing.T) { + t.Parallel() + + m := spanmap.New([]spanmap.Segment{ + {GenStart: 0, GenEnd: 2, OrigStart: 0, OrigEnd: 2, Kind: spanmap.KindVerbatim, Purpose: spanmap.PurposeSemantic}, + {GenStart: 2, GenEnd: 4, OrigStart: 2, OrigEnd: 4, Kind: spanmap.KindVerbatim, Purpose: spanmap.PurposeSemantic}, + {GenStart: 10, GenEnd: 12, OrigStart: 0, OrigEnd: 2, Kind: spanmap.KindVerbatim, Purpose: spanmap.PurposeSemantic}, + {GenStart: 12, GenEnd: 14, OrigStart: 2, OrigEnd: 4, Kind: spanmap.KindVerbatim, Purpose: spanmap.PurposeSemantic}, + }) + + spans := m.OriginalToGeneratedSpans(core.NewTextRange(1, 3), spanmap.PurposeSemantic) + assert.Equal(t, len(spans), 2) + assert.Equal(t, spans[0].Span, core.NewTextRange(1, 3)) + assert.Equal(t, spans[1].Span, core.NewTextRange(11, 13)) + for _, mapped := range spans { + assert.Equal(t, mapped.Fidelity, spanmap.FidelityApproximate) + } +} + +func TestOriginalToGeneratedExplicitZeroPurpose(t *testing.T) { + t.Parallel() + + m := spanmap.New([]spanmap.Segment{ + {GenStart: 0, GenEnd: 3, OrigStart: 10, OrigEnd: 13, Kind: spanmap.KindVerbatim, Purpose: spanmap.PurposeNone}, + }) + + assert.Equal(t, len(m.OriginalToGeneratedPositions(11, spanmap.PurposeSemantic)), 0) + assert.Equal(t, len(m.OriginalToGeneratedPositions(11, spanmap.PurposeNavigation)), 0) + assert.Equal(t, len(m.OriginalToGeneratedSpans(core.NewTextRange(10, 13), spanmap.PurposeSemantic)), 0) + + data, err := m.Marshal() + assert.NilError(t, err) + assert.Equal(t, string(data), "[[0,3,10,3,0,0]]") + decoded, err := spanmap.Unmarshal(data) + assert.NilError(t, err) + segments := decoded.Segments() + assert.Equal(t, segments[0].Purpose, spanmap.PurposeNone) + + legacy, err := spanmap.Unmarshal([]byte("[[0,3,10,3,0]]")) + assert.NilError(t, err) + assert.Equal(t, legacy.Segments()[0].Purpose, spanmap.PurposeAll) + assert.Equal(t, len(legacy.OriginalToGeneratedPositions(11, spanmap.PurposeSemantic)), 1) +} + +func TestOriginalToGeneratedSpanRoundTrip(t *testing.T) { + t.Parallel() + + // Original spans are out of order relative to generated spans, exercising the reverse index. + m := spanmap.New([]spanmap.Segment{ + {GenStart: 0, GenEnd: 10, OrigStart: 200, OrigEnd: 210, Kind: spanmap.KindVerbatim, Purpose: spanmap.PurposeAll}, + {GenStart: 10, GenEnd: 20, OrigStart: 100, OrigEnd: 110, Kind: spanmap.KindVerbatim, Purpose: spanmap.PurposeAll}, + }) + + for _, r := range []core.TextRange{core.NewTextRange(2, 8), core.NewTextRange(12, 18)} { + orig, fidelity := m.GeneratedToOriginalSpan(r) + assert.Equal(t, fidelity, spanmap.FidelityExact) + back := m.OriginalToGeneratedSpans(orig, spanmap.PurposeAll) + assert.Equal(t, len(back), 1) + assert.Equal(t, back[0].Fidelity, spanmap.FidelityExact) + assert.Equal(t, back[0].Span.Pos(), r.Pos()) + assert.Equal(t, back[0].Span.End(), r.End()) + } +} + +func TestMarshalRoundTrip(t *testing.T) { + t.Parallel() + + original := spanmap.New([]spanmap.Segment{ + {GenStart: 3, GenEnd: 14, OrigStart: 60, OrigEnd: 71, Kind: spanmap.KindAtom}, + {GenStart: 14, GenEnd: 24, OrigStart: 71, OrigEnd: 81, Kind: spanmap.KindVerbatim}, + }) + + data, err := original.Marshal() + assert.NilError(t, err) + decoded, err := spanmap.Unmarshal(data) + assert.NilError(t, err) + + for _, r := range []core.TextRange{core.NewTextRange(1, 2), core.NewTextRange(4, 10), core.NewTextRange(16, 20)} { + wantRange, wantFidelity := original.GeneratedToOriginalSpan(r) + gotRange, gotFidelity := decoded.GeneratedToOriginalSpan(r) + assert.Equal(t, gotRange, wantRange) + assert.Equal(t, gotFidelity, wantFidelity) + } +} + +func TestValidate(t *testing.T) { + t.Parallel() + + const transformed = "const greeting = 1;\n" + const original = "const greeting = 1;\n" + scriptStart := 3 // index of "const" in original + + testCases := []struct { + name string + segs []spanmap.Segment + wantKind spanmap.MappingErrorKind + wantOK bool + }{ + { + name: "valid verbatim", + segs: []spanmap.Segment{{GenStart: 0, GenEnd: core.TextPos(len(transformed)), OrigStart: core.TextPos(scriptStart), OrigEnd: core.TextPos(scriptStart + len(transformed)), Kind: spanmap.KindVerbatim}}, + wantOK: true, + }, + { + name: "empty is valid", + segs: nil, + wantOK: true, + }, + { + name: "gap is allowed", + segs: []spanmap.Segment{{GenStart: 3, GenEnd: core.TextPos(len(transformed)), OrigStart: 0, OrigEnd: 0, Kind: spanmap.KindAtom}}, + wantOK: true, + }, + { + name: "overlap", + segs: []spanmap.Segment{ + {GenStart: 0, GenEnd: 10, OrigStart: 0, OrigEnd: 0, Kind: spanmap.KindAtom}, + {GenStart: 5, GenEnd: core.TextPos(len(transformed)), OrigStart: 0, OrigEnd: 0, Kind: spanmap.KindAtom}, + }, + wantKind: spanmap.MappingErrorKindOverlap, + }, + { + name: "original out of bounds", + segs: []spanmap.Segment{{GenStart: 0, GenEnd: core.TextPos(len(transformed)), OrigStart: 0, OrigEnd: core.TextPos(len(original) + 10), Kind: spanmap.KindAtom}}, + wantKind: spanmap.MappingErrorKindOutOfBounds, + }, + { + name: "verbatim text mismatch", + segs: []spanmap.Segment{{GenStart: 0, GenEnd: core.TextPos(len(transformed)), OrigStart: 0, OrigEnd: core.TextPos(len(transformed)), Kind: spanmap.KindVerbatim}}, + wantKind: spanmap.MappingErrorKindVerbatimMismatch, + }, + { + name: "unknown kind", + segs: []spanmap.Segment{{GenStart: 0, GenEnd: 1, OrigStart: 0, OrigEnd: 1, Kind: 3}}, + wantKind: spanmap.MappingErrorKindKind, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + problem := spanmap.New(tc.segs).Validate(transformed, original) + if tc.wantOK { + assert.Assert(t, problem == nil, "expected valid, got %+v", problem) + return + } + assert.Assert(t, problem != nil, "expected a problem") + assert.Equal(t, problem.Kind, tc.wantKind) + }) + } +} + +func TestValidateOriginalOverlapAndPurposes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + segments []spanmap.Segment + wantKind spanmap.MappingErrorKind + valid bool + }{ + { + name: "identical duplicate group", + segments: []spanmap.Segment{ + {GenStart: 0, GenEnd: 3, OrigStart: 0, OrigEnd: 3, Kind: spanmap.KindVerbatim, Purpose: spanmap.PurposeNavigation}, + {GenStart: 3, GenEnd: 6, OrigStart: 0, OrigEnd: 3, Kind: spanmap.KindVerbatim, Purpose: spanmap.PurposeSemantic}, + }, + valid: true, + }, + { + name: "partial original overlap", + segments: []spanmap.Segment{ + {GenStart: 0, GenEnd: 3, OrigStart: 0, OrigEnd: 3, Kind: spanmap.KindAtom}, + {GenStart: 3, GenEnd: 6, OrigStart: 2, OrigEnd: 5, Kind: spanmap.KindAtom}, + }, + wantKind: spanmap.MappingErrorKindOriginalOverlap, + }, + { + name: "nested original overlap", + segments: []spanmap.Segment{ + {GenStart: 0, GenEnd: 5, OrigStart: 0, OrigEnd: 5, Kind: spanmap.KindAtom}, + {GenStart: 5, GenEnd: 6, OrigStart: 1, OrigEnd: 4, Kind: spanmap.KindAtom}, + }, + wantKind: spanmap.MappingErrorKindOriginalOverlap, + }, + { + name: "duplicate without explicit purpose is tolerant", + segments: []spanmap.Segment{ + {GenStart: 0, GenEnd: 3, OrigStart: 0, OrigEnd: 3, Kind: spanmap.KindAtom}, + {GenStart: 3, GenEnd: 6, OrigStart: 0, OrigEnd: 3, Kind: spanmap.KindAtom, Purpose: spanmap.PurposeNavigation}, + }, + valid: true, + }, + { + name: "duplicate with two semantic members is tolerant", + segments: []spanmap.Segment{ + {GenStart: 0, GenEnd: 3, OrigStart: 0, OrigEnd: 3, Kind: spanmap.KindAtom, Purpose: spanmap.PurposeSemantic}, + {GenStart: 3, GenEnd: 6, OrigStart: 0, OrigEnd: 3, Kind: spanmap.KindAtom, Purpose: spanmap.PurposeSemantic | spanmap.PurposeNavigation}, + }, + valid: true, + }, + { + name: "purpose on sole cover is valid", + segments: []spanmap.Segment{ + {GenStart: 0, GenEnd: 3, OrigStart: 0, OrigEnd: 3, Kind: spanmap.KindAtom, Purpose: spanmap.PurposeNavigation}, + }, + valid: true, + }, + { + name: "unknown purpose flag", + segments: []spanmap.Segment{ + {GenStart: 0, GenEnd: 3, OrigStart: 0, OrigEnd: 3, Kind: spanmap.KindAtom, Purpose: 4}, + }, + wantKind: spanmap.MappingErrorKindPurpose, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + problem := spanmap.New(test.segments).Validate("abcabc", "abcdef") + if test.valid { + assert.Assert(t, problem == nil, "expected valid, got %+v", problem) + return + } + assert.Assert(t, problem != nil) + assert.Equal(t, problem.Kind, test.wantKind) + }) + } +} + +func TestValidateNilIsValid(t *testing.T) { + t.Parallel() + var m *spanmap.SpanMap + assert.Assert(t, m.Validate("abc", "abc") == nil) +} diff --git a/internal/testrunner/compiler_runner.go b/internal/testrunner/compiler_runner.go index 3db15567425..3f7927c2477 100644 --- a/internal/testrunner/compiler_runner.go +++ b/internal/testrunner/compiler_runner.go @@ -213,6 +213,7 @@ func (r *CompilerBaselineRunner) runSingleConfigTest(t *testing.T, testName stri harnessutil.SkipUnsupportedCompilerOptions(t, compilerTest.options) compilerTest.verifyDiagnostics(t, r.testSuitName, r.isSubmodule) + compilerTest.verifyContentMapper(t, r.testSuitName, r.isSubmodule) compilerTest.verifyJavaScriptOutput(t, r.testSuitName, r.isSubmodule) compilerTest.verifySourceMapOutput(t, r.testSuitName, r.isSubmodule) compilerTest.verifySourceMapRecord(t, r.testSuitName, r.isSubmodule) @@ -243,17 +244,18 @@ func getCompilerFileBasedTest(t *testing.T, filename string) *compilerFileBasedT } type compilerTest struct { - testName string - filename string - basename string - configuredName string // name with configuration description, e.g. `file` - options *core.CompilerOptions - harnessOptions *harnessutil.HarnessOptions - result *harnessutil.CompilationResult - tsConfigFiles []*harnessutil.TestFile - toBeCompiled []*harnessutil.TestFile // equivalent to the files that will be passed on the command line - otherFiles []*harnessutil.TestFile // equivalent to other files on the file system not directly passed to the compiler (ie things that are referenced by other files) - hasNonDtsFiles bool + testName string + filename string + basename string + configuredName string // name with configuration description, e.g. `file` + currentDirectory string + options *core.CompilerOptions + harnessOptions *harnessutil.HarnessOptions + result *harnessutil.CompilationResult + tsConfigFiles []*harnessutil.TestFile + toBeCompiled []*harnessutil.TestFile // equivalent to the files that will be passed on the command line + otherFiles []*harnessutil.TestFile // equivalent to other files on the file system not directly passed to the compiler (ie things that are referenced by other files) + hasNonDtsFiles bool } type testCaseContentWithConfig struct { @@ -345,18 +347,28 @@ func newCompilerTest( testCaseContentWithConfig.symlinks, ) + // Content-mapped files are transformed during program construction; the transformed text is what the + // compiler actually parses and reports positions against. Baseline that text (rather than the original + // foreign source) so the type, symbol, and error baselines line up with the compiler's positions. + for _, file := range core.Concatenate(toBeCompiled, otherFiles) { + if sf := result.Program.GetSourceFile(file.UnitName); sf != nil && sf.ContentMapper() != "" { + file.Content = sf.Text() + } + } + return &compilerTest{ - testName: testName, - filename: filename, - basename: basename, - configuredName: configuredName, - options: result.Options, - harnessOptions: result.HarnessOptions, - result: result, - tsConfigFiles: tsConfigFiles, - toBeCompiled: toBeCompiled, - otherFiles: otherFiles, - hasNonDtsFiles: hasNonDtsFiles, + testName: testName, + filename: filename, + basename: basename, + configuredName: configuredName, + currentDirectory: currentDirectory, + options: result.Options, + harnessOptions: result.HarnessOptions, + result: result, + tsConfigFiles: tsConfigFiles, + toBeCompiled: toBeCompiled, + otherFiles: otherFiles, + hasNonDtsFiles: hasNonDtsFiles, } } @@ -364,7 +376,18 @@ func (c *compilerTest) verifyDiagnostics(t *testing.T, suiteName string, isSubmo t.Run("error", func(t *testing.T) { defer testutil.RecoverAndFail(t, "Panic on creating error baseline for test "+c.filename) files := core.Concatenate(c.tsConfigFiles, core.Concatenate(c.toBeCompiled, c.otherFiles)) - tsbaseline.DoErrorBaseline(t, c.configuredName, files, c.result.Diagnostics, c.result.Options.Pretty.IsTrue(), baseline.Options{ + diagnostics := c.result.Diagnostics + // Content-mapped files' diagnostics are baselined separately (see verifyContentMapper), where they can + // be rendered against the correct text; the squiggle renderer here assumes a single coordinate space. + if contentMapped := c.contentMappedFileNames(); len(contentMapped) > 0 { + files = core.Filter(files, func(f *harnessutil.TestFile) bool { + return !contentMapped[tspath.GetNormalizedAbsolutePath(f.UnitName, c.currentDirectory)] + }) + diagnostics = core.Filter(diagnostics, func(d *ast.Diagnostic) bool { + return d.File() == nil || !contentMapped[d.File().FileName()] + }) + } + tsbaseline.DoErrorBaseline(t, c.configuredName, files, diagnostics, c.result.Options.Pretty.IsTrue(), baseline.Options{ Subfolder: suiteName, IsSubmodule: isSubmodule, DiffFixupOld: func(old string) string { @@ -390,6 +413,31 @@ func (c *compilerTest) verifyDiagnostics(t *testing.T, suiteName string, isSubmo }) } +func (c *compilerTest) verifyContentMapper(t *testing.T, suiteName string, isSubmodule bool) { + t.Run("content mapper", func(t *testing.T) { + defer testutil.RecoverAndFail(t, "Panic on creating content mapper baseline for test "+c.filename) + tsbaseline.DoContentMapperBaseline(t, c.configuredName, c.result.Program, c.result.Diagnostics, baseline.Options{ + Subfolder: suiteName, + IsSubmodule: isSubmodule, + }) + }) +} + +// contentMappedFileNames returns the set of absolute file names that were produced by a content mapper. +func (c *compilerTest) contentMappedFileNames() map[string]bool { + program := c.result.Program.Program() + var mapped map[string]bool + for _, file := range c.result.Program.GetSourceFiles() { + if program.GetContentMapper(file) != nil { + if mapped == nil { + mapped = make(map[string]bool) + } + mapped[file.FileName()] = true + } + } + return mapped +} + var skippedEmitTests = map[string]string{ "filesEmittingIntoSameOutput.ts": "Output order nondeterministic due to collision on filename during parallel emit.", "jsFileCompilationWithJsEmitPathSameAsInput.ts": "Output order nondeterministic due to collision on filename during parallel emit.", diff --git a/internal/testrunner/test_case_parser.go b/internal/testrunner/test_case_parser.go index 1baec4087e0..6c4bd50ff18 100644 --- a/internal/testrunner/test_case_parser.go +++ b/internal/testrunner/test_case_parser.go @@ -44,12 +44,12 @@ var optionRegex = regexp.MustCompile(`(?m)^\/{2}\s*@(\w+)\s*:\s*([^\r\n]*)`) var linkRegex = regexp.MustCompile(`(?m)^\/{2}\s*@link\s*:\s*([^\r\n]*)\s*->\s*([^\r\n]*)`) // File-specific directives used by fourslash tests -var fourslashDirectives = []string{"emitthisfile"} +var fourslashDirectives = []string{"emitthisfile", "noopen"} // Given a test file containing // @FileName directives, // return an array of named units of code to be added to an existing compiler instance. func makeUnitsFromTest(code string, fileName string) testCaseContent { - testUnits, symlinks, currentDirectory, _, _ := ParseTestFilesAndSymlinks( + testUnits, symlinks, currentDirectory, globalOptions, _ := ParseTestFilesAndSymlinks( code, fileName, func(filename string, content string, fileOptions map[string]string) (*testUnit, error) { @@ -66,7 +66,15 @@ func makeUnitsFromTest(code string, fileName string) testCaseContent { for _, data := range testUnits { allFiles[tspath.GetNormalizedAbsolutePath(data.name, currentDirectory)] = data.content } - parseConfigHost := tsoptionstest.NewVFSParseConfigHost(allFiles, currentDirectory, true /*useCaseSensitiveFileNames*/) + parseConfigHost := tsoptionstest.NewVFSParseConfigHostWithSymlinks(allFiles, symlinks, currentDirectory, true /*useCaseSensitiveFileNames*/) + + // Content mappers are gated behind --loadExternalPlugins, a command-line-only option. A test + // opts in with a top-level `// @loadExternalPlugins: true`, which we surface to the config + // parse as an existing option so the gate passes and the mappers register. + var existingOptions *core.CompilerOptions + if globalOptions["loadexternalplugins"] == "true" { + existingOptions = &core.CompilerOptions{LoadExternalPlugins: core.TSTrue} + } // check if project has tsconfig.json in the list of files var tsConfig *tsoptions.ParsedCommandLine @@ -87,11 +95,10 @@ func makeUnitsFromTest(code string, fileName string) testCaseContent { tsConfigSourceFile, parseConfigHost, configDir, - nil, /*existingOptions*/ + existingOptions, nil, /*existingOptionsRaw*/ configFileName, nil, /*resolutionStack*/ - nil, /*extraFileExtensions*/ nil, /*extendedConfigCache*/ ) tsConfigFileUnitData = data diff --git a/internal/testutil/contentmappertest/mapper.go b/internal/testutil/contentmappertest/mapper.go new file mode 100644 index 00000000000..4191d0c48cb --- /dev/null +++ b/internal/testutil/contentmappertest/mapper.go @@ -0,0 +1,549 @@ +// Package contentmappertest provides a small but realistic content mapper used by tests. Unlike the +// verbatim mapper baked into the tsc test harness, this mapper performs a genuine transform: it injects +// synthesized glue, copies the body verbatim, and substitutes compiler-option-driven tokens, producing a +// span map that exercises every span kind (verbatim, atom, synthesized). The same handler can be driven +// in-process over a net.Pipe (via NewSpawner) or out-of-process over stdio (via Serve), so the identical +// mapper code backs both the hermetic compiler-test path and the occasional real-subprocess e2e test. +package contentmappertest + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "strings" + + "github.com/microsoft/typescript-go/internal/collections" + "github.com/microsoft/typescript-go/internal/contentmapper" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/ipc" + "github.com/microsoft/typescript-go/internal/json" + "github.com/microsoft/typescript-go/internal/spanmap" +) + +const ( + // TransformingMapper selects the full transforming mapper (synthesized preamble + verbatim body + option-token + // atoms). This is the "realistic" mapper used by most content-mapper tests. + TransformingMapper = "compiler-test-mapper" + // VerbatimMapper selects a mapper that copies its input through unchanged with an identity span map. The + // input is expected to already be valid TypeScript. + VerbatimMapper = "verbatim-mapper" + // FailingMapper selects a mapper that initializes but fails every transform request, exercising the + // per-file failure and mapper-disabled paths. + FailingMapper = "failing-mapper" + // SynthesizingMapper selects a mapper that emits synthesized TypeScript with no original counterpart, so + // compiler diagnostics in it are reported against the generated text. + SynthesizingMapper = "synthesizing-mapper" + // ComponentMapper selects a Vue-like mapper that extracts ") + if closeRel < 0 { + return "", nil, errors.New("contentmappertest: missing tag") + } + scriptEnd := scriptStart + closeRel + writeMapped(content[scriptStart:scriptEnd], scriptStart, scriptEnd, spanmap.KindVerbatim) + } + + writeSynthesized("\nfunction __render() {\n") + for searchStart := 0; searchStart < len(content); { + openRel := strings.Index(content[searchStart:], "{{") + if openRel < 0 { + break + } + exprStart := searchStart + openRel + len("{{") + closeRel := strings.Index(content[exprStart:], "}}") + if closeRel < 0 { + return "", nil, errors.New("contentmappertest: unclosed template expression") + } + exprEnd := exprStart + closeRel + writeSynthesized(" void (") + for pos := exprStart; pos < exprEnd; { + if !isIdentifierStart(content[pos]) { + writeSynthesized(content[pos : pos+1]) + pos++ + continue + } + end := pos + 1 + for end < exprEnd && isIdentifierPart(content[end]) { + end++ + } + writeMapped(content[pos:end], pos, end, spanmap.KindAtom) + pos = end + } + writeSynthesized(");\n") + searchStart = exprEnd + len("}}") + } + writeSynthesized("}\n") + if nameStart, nameEnd, ok := componentNameRange(content); ok { + writeSynthesized("export class ") + writeMapped(content[nameStart:nameEnd], nameStart, nameEnd, spanmap.KindAtom) + writeSynthesized(" {}\n") + } + writeSynthesized("export default {};\n") + + mappings, err := spanmap.New(segments).Marshal() + if err != nil { + return "", nil, err + } + return gen.String(), json.Value(mappings), nil +} + +func componentNameRange(content string) (start, end int, ok bool) { + componentStart := strings.Index(content, "') + if tagEndRel < 0 { + return 0, 0, false + } + tag := content[componentStart : componentStart+tagEndRel] + nameRel := strings.Index(tag, `name="`) + if nameRel < 0 { + return 0, 0, false + } + start = componentStart + nameRel + len(`name="`) + endRel := strings.IndexByte(content[start:], '"') + if endRel < 0 { + return 0, 0, false + } + return start, start + endRel, true +} + +func isIdentifierStart(ch byte) bool { + return ch == '_' || ch == '$' || ch >= 'A' && ch <= 'Z' || ch >= 'a' && ch <= 'z' +} + +func isIdentifierPart(ch byte) bool { + return isIdentifierStart(ch) || ch >= '0' && ch <= '9' +} + +// handlerForMapper selects the mapper implementation named by a package.json's tsContentMapper.exec command. +func handlerForMapper(command []string) (ipc.Handler, error) { + if len(command) == 0 { + return nil, errors.New("contentmappertest: empty mapper command") + } + switch command[0] { + case TransformingMapper: + return Handler{}, nil + case VerbatimMapper: + return verbatimHandler{}, nil + case FailingMapper: + return failingHandler{}, nil + case SynthesizingMapper: + return synthesizingHandler{}, nil + case ComponentMapper: + return componentHandler{}, nil + case DuplicateMapper: + return duplicateHandler{}, nil + case LispMapper: + return lispHandler{}, nil + default: + return nil, fmt.Errorf("contentmappertest: unknown mapper command %v", command) + } +} + +// NewSpawner returns a contentmapper.Spawner that serves the fake mappers in-process. It selects the +// implementation named by the mapper package, standing it up +// over a net.Pipe so tests exercise the full IPC stack without spawning a real subprocess. +func NewSpawner() contentmapper.Spawner { + return spawner{} +} + +type spawner struct{} + +func (spawner) Spawn(command []string, dir string) (io.ReadWriteCloser, error) { + handler, err := handlerForMapper(command) + if err != nil { + return nil, err + } + client, server := net.Pipe() + go func() { _ = ipc.NewAsyncConn(server, handler).Run(context.Background()) }() + return client, nil +} + +// PackageJSON returns the contents of a package.json that selects the given mapper via its +// tsContentMapper.exec command, for use as a node_modules fixture in tests. TransformingMapper declares +// the compiler options it depends on; the others declare none. +func PackageJSON(mapper string) string { + compilerOptions := "" + if mapper == TransformingMapper { + compilerOptions = `, "compilerOptions": ["target", "jsx"]` + } + return fmt.Sprintf(`{ + "name": %q, + "version": "1.0.0", + "tsContentMapper": { "exec": [%q]%s } +}`, PackageName, mapper, compilerOptions) +} diff --git a/internal/testutil/contentmappertest/mapper_test.go b/internal/testutil/contentmappertest/mapper_test.go new file mode 100644 index 00000000000..d02b0fd6eef --- /dev/null +++ b/internal/testutil/contentmappertest/mapper_test.go @@ -0,0 +1,216 @@ +package contentmappertest_test + +import ( + "context" + "io" + "os" + "os/exec" + "strings" + "testing" + + "github.com/microsoft/typescript-go/internal/contentmapper" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/locale" + "github.com/microsoft/typescript-go/internal/spanmap" + "github.com/microsoft/typescript-go/internal/testutil/contentmappertest" + "gotest.tools/v3/assert" +) + +// helperEnv, when set, makes the test binary act as the mapper subprocess instead of running tests. This +// lets the out-of-process test spawn a real subprocess (itself) that speaks the mapper protocol over +// stdio, exercising the same handler code that the in-process spawner runs over a pipe. +const helperEnv = "TSGO_CONTENT_MAPPER_HELPER" + +func TestMain(m *testing.M) { + if os.Getenv(helperEnv) == "1" { + _ = contentmappertest.Serve(context.Background(), stdio{}) + os.Exit(0) + } + os.Exit(m.Run()) +} + +// stdio adapts the process's stdin/stdout to an io.ReadWriteCloser for the mapper server. +type stdio struct{} + +func (stdio) Read(p []byte) (int, error) { return os.Stdin.Read(p) } +func (stdio) Write(p []byte) (int, error) { return os.Stdout.Write(p) } +func (stdio) Close() error { return nil } + +func testMapper() *contentmapper.Mapper { + return &contentmapper.Mapper{ + Definition: contentmapper.Definition{ + Package: contentmappertest.PackageName, + Extensions: []string{".box"}, + }, + Manifest: contentmapper.Manifest{ + Name: contentmappertest.PackageName, + Version: "1.0.0", + Exec: []string{contentmappertest.TransformingMapper}, + CompilerOptions: contentmappertest.DeclaredOptions, + }, + PackageDirectory: "/node_modules/" + contentmappertest.PackageName, + } +} + +func transformRequest() contentmapper.Request { + return contentmapper.Request{ + FileName: "/app.box", + Content: "export const version = #{target};\n", + CompilerOptions: &core.CompilerOptions{Target: core.ScriptTargetES2020}, + } +} + +// TestInProcessSpanKinds drives the mapper in-process and verifies the transform produces a span map that +// exercises all three span kinds: the synthesized preamble, the verbatim body, and the atom substitution +// of a compiler-option token. +func TestInProcessSpanKinds(t *testing.T) { + t.Parallel() + host := contentmapper.NewHost(t.Context(), contentmappertest.NewSpawner(), locale.Default) + defer host.Close() + + result, err := host.Transform(testMapper(), transformRequest()) + assert.NilError(t, err) + assert.Equal(t, result.ScriptKind, core.ScriptKindTS) + // The #{target} token was replaced by the es2020 target value (7). + assert.Assert(t, strings.Contains(result.Text, "export const version = 7;"), "got %q", result.Text) + + text := result.Text + + // Synthesized: __VERSION appears only in the injected preamble, which has no original counterpart. + synthStart := strings.Index(text, "__VERSION") + assert.Assert(t, synthStart >= 0) + _, synthFidelity := result.Mappings.GeneratedToOriginalSpan(core.NewTextRange(synthStart, synthStart+len("__VERSION"))) + assert.Equal(t, synthFidelity, spanmap.FidelityNone) + + // Verbatim: "export const version" is copied through, so its generated span maps exactly onto the + // identical span in the original. + verbatimStart := strings.Index(text, "export const version") + assert.Assert(t, verbatimStart >= 0) + verbatimRange, verbatimFidelity := result.Mappings.GeneratedToOriginalSpan(core.NewTextRange(verbatimStart, verbatimStart+len("export"))) + assert.Equal(t, verbatimFidelity, spanmap.FidelityExact) + original := transformRequest().Content + assert.Equal(t, original[verbatimRange.Pos():verbatimRange.End()], "export") + + // Atom: the substituted "7" maps as a whole back to the original #{target} token span. + atomStart := strings.Index(text, "= 7;") + len("= ") + atomRange, atomFidelity := result.Mappings.GeneratedToOriginalSpan(core.NewTextRange(atomStart, atomStart+len("7"))) + assert.Equal(t, atomFidelity, spanmap.FidelityAtom) + assert.Equal(t, original[atomRange.Pos():atomRange.End()], "#{target}") +} + +func TestPurposePresenceRoundTrip(t *testing.T) { + t.Parallel() + transform := func(fileName string) []spanmap.Segment { + host := contentmapper.NewHost(t.Context(), contentmappertest.NewSpawner(), locale.Default) + defer host.Close() + mapper := testMapper() + mapper.Manifest.Exec = []string{contentmappertest.DuplicateMapper} + result, err := host.Transform(mapper, contentmapper.Request{FileName: fileName, Content: "value"}) + assert.NilError(t, err) + return result.Mappings.Segments() + } + + duplicates := transform("/value.dup") + assert.Equal(t, duplicates[0].Purpose, spanmap.PurposeSemantic) + assert.Equal(t, duplicates[1].Purpose, spanmap.PurposeNavigation) + disabled := transform("/disabled.dup") + assert.Equal(t, disabled[0].Purpose, spanmap.PurposeNone) +} + +func TestComponentMapperSpanKinds(t *testing.T) { + t.Parallel() + host := contentmapper.NewHost(t.Context(), contentmappertest.NewSpawner(), locale.Default) + defer host.Close() + + mapper := testMapper() + mapper.Manifest.Exec = []string{contentmappertest.ComponentMapper} + mapper.Manifest.CompilerOptions = nil + content := ` + +` + result, err := host.Transform(mapper, contentmapper.Request{FileName: "/app.box", Content: content}) + assert.NilError(t, err) + + scriptStart := strings.Index(result.Text, "export const title") + _, exact := result.Mappings.GeneratedToOriginalSpan(core.NewTextRange(scriptStart, scriptStart+len("export"))) + assert.Equal(t, exact, spanmap.FidelityExact) + + templateTitle := strings.LastIndex(result.Text, "title") + _, atom := result.Mappings.GeneratedToOriginalSpan(core.NewTextRange(templateTitle, templateTitle+len("title"))) + assert.Equal(t, atom, spanmap.FidelityAtom) + + expressionStart := strings.Index(result.Text, "title + suffix") + _, approximate := result.Mappings.GeneratedToOriginalSpan(core.NewTextRange(expressionStart, expressionStart+len("title + suffix"))) + assert.Equal(t, approximate, spanmap.FidelityApproximate) + + renderStart := strings.Index(result.Text, "__render") + _, none := result.Mappings.GeneratedToOriginalSpan(core.NewTextRange(renderStart, renderStart+len("__render"))) + assert.Equal(t, none, spanmap.FidelityNone) + + markupStart := strings.Index(content, "

") + assert.Equal(t, len(result.Mappings.OriginalToGeneratedPositions(core.TextPos(markupStart), spanmap.PurposeAll)), 0) + + componentName := strings.Index(result.Text, "ProfileCard") + _, componentNameFidelity := result.Mappings.GeneratedToOriginalSpan(core.NewTextRange(componentName, componentName+len("ProfileCard"))) + assert.Equal(t, componentNameFidelity, spanmap.FidelityAtom) + componentDeclaration := strings.Index(result.Text, "export class ProfileCard") + _, componentDeclarationFidelity := result.Mappings.GeneratedToOriginalSpan(core.NewTextRange(componentDeclaration, componentDeclaration+len("export class ProfileCard {}"))) + assert.Equal(t, componentDeclarationFidelity, spanmap.FidelityApproximate) +} + +// TestOutOfProcess exercises the real out-of-process IPC path: it spawns the test binary as a mapper +// subprocess and drives it over stdio through the production content mapper host. +func TestOutOfProcess(t *testing.T) { + t.Parallel() + host := contentmapper.NewHost(t.Context(), execSpawner{}, locale.Default) + defer host.Close() + + result, err := host.Transform(testMapper(), transformRequest()) + assert.NilError(t, err) + assert.Equal(t, result.ScriptKind, core.ScriptKindTS) + assert.Assert(t, strings.Contains(result.Text, "export const version = 7;"), "got %q", result.Text) + assert.Assert(t, result.Mappings != nil) +} + +// execSpawner spawns the test binary itself as the mapper subprocess (guarded by helperEnv), so the test +// talks to a genuinely separate process over real pipes. +type execSpawner struct{} + +func (execSpawner) Spawn(command []string, dir string) (io.ReadWriteCloser, error) { + cmd := exec.Command(os.Args[0]) + cmd.Env = append(os.Environ(), helperEnv+"=1") + cmd.Stderr = os.Stderr + 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 &process{cmd: cmd, stdin: stdin, stdout: stdout}, nil +} + +// process adapts a spawned subprocess's stdio to an io.ReadWriteCloser: reads come from its stdout, writes +// go to its stdin, and Close tears the process down. +type process struct { + cmd *exec.Cmd + stdin io.WriteCloser + stdout io.ReadCloser +} + +func (p *process) Read(b []byte) (int, error) { return p.stdout.Read(b) } +func (p *process) Write(b []byte) (int, error) { return p.stdin.Write(b) } + +func (p *process) Close() error { + _ = p.stdin.Close() + _ = p.cmd.Process.Kill() + _ = p.cmd.Wait() + return nil +} diff --git a/internal/testutil/harnessutil/harnessutil.go b/internal/testutil/harnessutil/harnessutil.go index a571b4ce7ee..a96de32fc92 100644 --- a/internal/testutil/harnessutil/harnessutil.go +++ b/internal/testutil/harnessutil/harnessutil.go @@ -20,6 +20,7 @@ import ( "github.com/microsoft/typescript-go/internal/bundled" "github.com/microsoft/typescript-go/internal/collections" "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/execute/incremental" @@ -29,6 +30,7 @@ import ( "github.com/microsoft/typescript-go/internal/repo" "github.com/microsoft/typescript-go/internal/sourcemap" "github.com/microsoft/typescript-go/internal/testutil" + "github.com/microsoft/typescript-go/internal/testutil/contentmappertest" "github.com/microsoft/typescript-go/internal/tsoptions" "github.com/microsoft/typescript-go/internal/tspath" "github.com/microsoft/typescript-go/internal/vfs" @@ -184,6 +186,11 @@ func CompileFilesEx( compilerOptions.TypeRoots[i] = tspath.GetNormalizedAbsolutePath(typeRoot, currentDirectory) } + var contentMappers []*contentmapper.Mapper + if tsconfig != nil && tsconfig.ParsedConfig != nil { + contentMappers = tsconfig.ParsedConfig.ContentMappers + } + // Create fake FS for testing testfs := map[string]any{} for _, file := range inputFiles { @@ -212,7 +219,15 @@ func CompileFilesEx( fs = bundled.WrapFS(fs) fs = NewOutputRecorderFS(fs) - host := createCompilerHost(fs, bundled.LibPath(), currentDirectory) + // Content mappers, when trusted, are served in-process by the test mapper (see contentmappertest). + // The host is shared by the pre- and post-emit programs and torn down when this compilation finishes. + var contentMapperHost contentmapper.Host + if compilerOptions.LoadExternalPlugins.IsTrue() && len(contentMappers) > 0 { + contentMapperHost = contentmapper.NewHost(context.Background(), contentmappertest.NewSpawner(), locale.Default) + defer contentMapperHost.Close() + } + + host := createCompilerHost(fs, bundled.LibPath(), currentDirectory, contentMapperHost) var configFile *tsoptions.TsConfigSourceFile var errors []*ast.Diagnostic if tsconfig != nil { @@ -220,9 +235,10 @@ func CompileFilesEx( errors = tsconfig.Errors } result := compileFilesWithHost(host, &tsoptions.ParsedCommandLine{ - ParsedConfig: &core.ParsedOptions{ + ParsedConfig: &tsoptions.ParsedOptions{ CompilerOptions: compilerOptions, FileNames: programFileNames, + ContentMappers: contentMappers, }, ConfigFile: configFile, Errors: errors, @@ -587,13 +603,13 @@ func (t *TracerForBaselining) Reset() { t.packageJsonCache = make(map[tspath.Path]bool) } -func createCompilerHost(fs vfs.FS, defaultLibraryPath string, currentDirectory string) *cachedCompilerHost { +func createCompilerHost(fs vfs.FS, defaultLibraryPath string, currentDirectory string, contentMapperHost contentmapper.Host) *cachedCompilerHost { tracer := NewTracerForBaselining(tspath.ComparePathsOptions{ UseCaseSensitiveFileNames: fs.UseCaseSensitiveFileNames(), CurrentDirectory: currentDirectory, }, &strings.Builder{}) return &cachedCompilerHost{ - CompilerHost: compiler.NewCompilerHost(currentDirectory, fs, defaultLibraryPath, nil, tracer.Trace), + CompilerHost: compiler.NewCompilerHost(currentDirectory, fs, defaultLibraryPath, nil, tracer.Trace, contentMapperHost), tracer: tracer, } } @@ -619,13 +635,15 @@ func compileFilesWithHost( // } ctx := context.Background() + var preErrors []*ast.Diagnostic preCompilerOptions := config.CompilerOptions().Clone() preCompilerOptions.TraceResolution = core.TSFalse preConfig := &tsoptions.ParsedCommandLine{ - ParsedConfig: &core.ParsedOptions{ + ParsedConfig: &tsoptions.ParsedOptions{ CompilerOptions: preCompilerOptions, FileNames: config.FileNames(), + ContentMappers: config.ContentMappers(), }, ConfigFile: config.ConfigFile, Errors: config.Errors, @@ -827,6 +845,9 @@ func (c *CompilationResult) getOutputPath(path string, ext string) string { path = tspath.CombinePaths(tspath.ResolvePath(c.Host.GetCurrentDirectory(), c.Options.OutDir), path) } } + if ext == tspath.GetDeclarationEmitExtensionForPath(path) { + return outputpaths.ChangeToDeclarationExtension(path, c.Program.Program()) + } return tspath.ChangeExtension(path, ext) } diff --git a/internal/testutil/projecttestutil/clientmock_generated.go b/internal/testutil/projecttestutil/clientmock_generated.go index 7a1b97c0365..3d852ddcd2f 100644 --- a/internal/testutil/projecttestutil/clientmock_generated.go +++ b/internal/testutil/projecttestutil/clientmock_generated.go @@ -43,6 +43,9 @@ var _ project.Client = &ClientMock{} // RefreshInlayHintsFunc: func(ctx context.Context) error { // panic("mock out the RefreshInlayHints method") // }, +// RegisterContentMapperExtensionsFunc: func(ctx context.Context, extensions []string) error { +// panic("mock out the RegisterContentMapperExtensions method") +// }, // SendTelemetryFunc: func(ctx context.Context, telemetry lsproto.TelemetryEvent) error { // panic("mock out the SendTelemetry method") // }, @@ -80,6 +83,9 @@ type ClientMock struct { // RefreshInlayHintsFunc mocks the RefreshInlayHints method. RefreshInlayHintsFunc func(ctx context.Context) error + // RegisterContentMapperExtensionsFunc mocks the RegisterContentMapperExtensions method. + RegisterContentMapperExtensionsFunc func(ctx context.Context, extensions []string) error + // SendTelemetryFunc mocks the SendTelemetry method. SendTelemetryFunc func(ctx context.Context, telemetry lsproto.TelemetryEvent) error @@ -129,6 +135,13 @@ type ClientMock struct { // Ctx is the ctx argument value. Ctx context.Context } + // RegisterContentMapperExtensions holds details about calls to the RegisterContentMapperExtensions method. + RegisterContentMapperExtensions []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Extensions is the extensions argument value. + Extensions []string + } // SendTelemetry holds details about calls to the SendTelemetry method. SendTelemetry []struct { // Ctx is the ctx argument value. @@ -153,16 +166,17 @@ type ClientMock struct { Watchers []*lsproto.FileSystemWatcher } } - lockIsActive sync.RWMutex - lockProgressFinish sync.RWMutex - lockProgressStart sync.RWMutex - lockPublishDiagnostics sync.RWMutex - lockRefreshCodeLens sync.RWMutex - lockRefreshDiagnostics sync.RWMutex - lockRefreshInlayHints sync.RWMutex - lockSendTelemetry sync.RWMutex - lockUnwatchFiles sync.RWMutex - lockWatchFiles sync.RWMutex + lockIsActive sync.RWMutex + lockProgressFinish sync.RWMutex + lockProgressStart sync.RWMutex + lockPublishDiagnostics sync.RWMutex + lockRefreshCodeLens sync.RWMutex + lockRefreshDiagnostics sync.RWMutex + lockRefreshInlayHints sync.RWMutex + lockRegisterContentMapperExtensions sync.RWMutex + lockSendTelemetry sync.RWMutex + lockUnwatchFiles sync.RWMutex + lockWatchFiles sync.RWMutex } // IsActive calls IsActiveFunc. @@ -398,6 +412,43 @@ func (mock *ClientMock) RefreshInlayHintsCalls() []struct { return calls } +// RegisterContentMapperExtensions calls RegisterContentMapperExtensionsFunc. +func (mock *ClientMock) RegisterContentMapperExtensions(ctx context.Context, extensions []string) error { + callInfo := struct { + Ctx context.Context + Extensions []string + }{ + Ctx: ctx, + Extensions: extensions, + } + mock.lockRegisterContentMapperExtensions.Lock() + mock.calls.RegisterContentMapperExtensions = append(mock.calls.RegisterContentMapperExtensions, callInfo) + mock.lockRegisterContentMapperExtensions.Unlock() + if mock.RegisterContentMapperExtensionsFunc == nil { + var errOut error + return errOut + } + return mock.RegisterContentMapperExtensionsFunc(ctx, extensions) +} + +// RegisterContentMapperExtensionsCalls gets all the calls that were made to RegisterContentMapperExtensions. +// Check the length with: +// +// len(mockedClient.RegisterContentMapperExtensionsCalls()) +func (mock *ClientMock) RegisterContentMapperExtensionsCalls() []struct { + Ctx context.Context + Extensions []string +} { + var calls []struct { + Ctx context.Context + Extensions []string + } + mock.lockRegisterContentMapperExtensions.RLock() + calls = mock.calls.RegisterContentMapperExtensions + mock.lockRegisterContentMapperExtensions.RUnlock() + return calls +} + // SendTelemetry calls SendTelemetryFunc. func (mock *ClientMock) SendTelemetry(ctx context.Context, telemetry lsproto.TelemetryEvent) error { callInfo := struct { diff --git a/internal/testutil/tsbaseline/contentmapper_baseline.go b/internal/testutil/tsbaseline/contentmapper_baseline.go new file mode 100644 index 00000000000..bfd839992e0 --- /dev/null +++ b/internal/testutil/tsbaseline/contentmapper_baseline.go @@ -0,0 +1,92 @@ +package tsbaseline + +import ( + "fmt" + "regexp" + "strings" + "testing" + + "github.com/microsoft/typescript-go/internal/ast" + "github.com/microsoft/typescript-go/internal/compiler" + "github.com/microsoft/typescript-go/internal/contentmapper" + "github.com/microsoft/typescript-go/internal/diagnosticwriter" + "github.com/microsoft/typescript-go/internal/testutil/baseline" +) + +var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m") + +var contentMapperFormatOpts = &diagnosticwriter.FormattingOptions{NewLine: "\n"} + +// DoContentMapperBaseline writes a baseline for content-mapped files that shows the original source, the +// transformed source the compiler actually checks, and the file's diagnostics. Diagnostics are rendered +// with the standard diagnostic writer, which maps each one to the text it belongs to: mapper-produced +// diagnostics render against the original source, compiler diagnostics on mappable code render against the +// original source, and compiler diagnostics on synthesized code render against the transformed source. If +// the program has no content-mapped files, no baseline is written. +func DoContentMapperBaseline( + t *testing.T, + baselinePath string, + program compiler.ProgramLike, + diagnostics []*ast.Diagnostic, + opts baseline.Options, +) { + content := getContentMapperBaseline(program, diagnostics) + if content == "" { + return + } + baseline.Run(t, tsExtension.ReplaceAllString(baselinePath, ".contentmapper"), content, opts) +} + +func getContentMapperBaseline(program compiler.ProgramLike, diagnostics []*ast.Diagnostic) string { + prog := program.Program() + mapped := make(map[string]*contentmapper.Mapper) + var files []*ast.SourceFile + for _, file := range program.GetSourceFiles() { + if mapper := prog.GetContentMapper(file); mapper != nil { + mapped[file.FileName()] = mapper + files = append(files, file) + } + } + if len(files) == 0 { + return "" + } + + var b strings.Builder + for _, file := range files { + mapper := mapped[file.FileName()] + fmt.Fprintf(&b, "//// [%s] (ScriptKind: %s, ContentMapper: %v)\n", removeTestPathPrefixes(file.FileName(), false), file.ScriptKind, mapper.Extensions) + b.WriteString("--- Original ---\n") + b.WriteString(ensureTrailingNewline(file.OriginalText())) + b.WriteString("--- Transformed ---\n") + b.WriteString(ensureTrailingNewline(file.Text())) + b.WriteString("\n") + } + + var fileDiagnostics []*ast.Diagnostic + for _, d := range diagnostics { + if d.File() != nil && mapped[d.File().FileName()] != nil { + fileDiagnostics = append(fileDiagnostics, d) + } + } + + b.WriteString("=== Diagnostics ===\n\n") + if len(fileDiagnostics) == 0 { + b.WriteString(baseline.NoContent + "\n") + return b.String() + } + var rendered strings.Builder + diagnosticwriter.FormatDiagnosticsWithColorAndContext( + &rendered, + diagnosticwriter.ToDiagnostics(diagnosticwriter.WrapASTDiagnostics(fileDiagnostics)), + contentMapperFormatOpts, + ) + b.WriteString(removeTestPathPrefixes(ansiEscape.ReplaceAllString(rendered.String(), ""), false)) + return b.String() +} + +func ensureTrailingNewline(s string) string { + if s == "" || strings.HasSuffix(s, "\n") { + return s + } + return s + "\n" +} diff --git a/internal/testutil/tsbaseline/js_emit_baseline.go b/internal/testutil/tsbaseline/js_emit_baseline.go index c55a94f8887..87091afb0c1 100644 --- a/internal/testutil/tsbaseline/js_emit_baseline.go +++ b/internal/testutil/tsbaseline/js_emit_baseline.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/diagnosticwriter" + "github.com/microsoft/typescript-go/internal/outputpaths" "github.com/microsoft/typescript-go/internal/parser" "github.com/microsoft/typescript-go/internal/testutil/baseline" "github.com/microsoft/typescript-go/internal/testutil/harnessutil" @@ -161,7 +162,7 @@ type declarationCompilationContext struct { harnessSettings *harnessutil.HarnessOptions options *core.CompilerOptions currentDirectory string - configFile *tsoptions.TsConfigSourceFile + config *tsoptions.ParsedCommandLine } func prepareDeclarationCompilationContext( @@ -181,7 +182,8 @@ func prepareDeclarationCompilationContext( if result.DTS.Size() == 0 && !options.NoEmit.IsTrue() { panic("Expected at least one declaration file to be emitted when emitDeclarationOnly:true and no errors were generated") } - } else if result.DTS.Size() != result.GetNumberOfJSFiles(false /*includeJson*/) { + } else if !core.Some(result.Program.GetSourceFiles(), func(file *ast.SourceFile) bool { return file.ContentMapper() != "" }) && + result.DTS.Size() != result.GetNumberOfJSFiles(false /*includeJson*/) { panic("There were no errors and declFiles generated did not match number of js files generated") } } @@ -214,14 +216,15 @@ func prepareDeclarationCompilationContext( sourceFileName = sourceFile.FileName() } - dTsFileName := tspath.RemoveFileExtension(sourceFileName) + tspath.GetDeclarationEmitExtensionForPath(sourceFileName) + dTsFileName := outputpaths.ChangeToDeclarationExtension(sourceFileName, result.Program.Program()) return result.DTS.GetOrZero(dTsFileName) } addDtsFile := func(file *harnessutil.TestFile, dtsFiles []*harnessutil.TestFile) []*harnessutil.TestFile { if tspath.IsDeclarationFileName(file.UnitName) || tspath.HasJSONFileExtension(file.UnitName) { dtsFiles = append(dtsFiles, file) - } else if tspath.HasTSFileExtension(file.UnitName) || (tspath.HasJSFileExtension(file.UnitName) && options.GetAllowJS()) { + } else if sourceFile := result.Program.GetSourceFile(file.UnitName); sourceFile != nil && + (tspath.HasTSFileExtension(file.UnitName) || (tspath.HasJSFileExtension(file.UnitName) && options.GetAllowJS()) || sourceFile.ContentMapper() != "") { declFile := findResultCodeFile(file.UnitName) if declFile != nil && findUnit(declFile.UnitName, declInputFiles) == nil && findUnit(declFile.UnitName, declOtherFiles) == nil { dtsFiles = append(dtsFiles, &harnessutil.TestFile{ @@ -247,7 +250,7 @@ func prepareDeclarationCompilationContext( harnessSettings: harnessSettings, options: options, currentDirectory: core.IfElse(len(currentDirectory) > 0, currentDirectory, harnessSettings.CurrentDirectory), - configFile: result.Program.Program().CommandLine().ConfigFile, + config: result.Program.Program().CommandLine(), } } return nil @@ -264,9 +267,12 @@ func compileDeclarationFiles(t *testing.T, context *declarationCompilationContex return nil } var tsconfig *tsoptions.ParsedCommandLine - if context.configFile != nil { + if context.config != nil && context.config.ConfigFile != nil { tsconfig = &tsoptions.ParsedCommandLine{ - ConfigFile: context.configFile, + ParsedConfig: &tsoptions.ParsedOptions{ + ContentMappers: context.config.ContentMappers(), + }, + ConfigFile: context.config.ConfigFile, } } declFileCompilationResult := harnessutil.CompileFilesEx(t, diff --git a/internal/transformers/tstransforms/importelision_test.go b/internal/transformers/tstransforms/importelision_test.go index e15945c2c44..8917cadc9e8 100644 --- a/internal/transformers/tstransforms/importelision_test.go +++ b/internal/transformers/tstransforms/importelision_test.go @@ -50,6 +50,10 @@ func (p *fakeProgram) CommonSourceDirectory() string { panic("unimplemented") } +func (p *fakeProgram) ContentMapperExtensions() []string { + return nil +} + func (p *fakeProgram) GetResolvedModuleFromModuleSpecifier(file ast.HasFileName, moduleSpecifier *ast.StringLiteralLike) *module.ResolvedModule { panic("unimplemented") } diff --git a/internal/tsoptions/commandlineoption.go b/internal/tsoptions/commandlineoption.go index de7c8c723ec..baa2b35c6a0 100644 --- a/internal/tsoptions/commandlineoption.go +++ b/internal/tsoptions/commandlineoption.go @@ -139,6 +139,10 @@ var commandLineOptionElements = map[string]*CommandLineOption{ Name: "references", Kind: CommandLineOptionTypeObject, }, + "contentMappers": { + Name: "contentMappers", + Kind: CommandLineOptionTypeObject, + }, "files": { Name: "files", Kind: CommandLineOptionTypeString, diff --git a/internal/tsoptions/contentmappers.go b/internal/tsoptions/contentmappers.go new file mode 100644 index 00000000000..b6b0f6f2f5c --- /dev/null +++ b/internal/tsoptions/contentmappers.go @@ -0,0 +1,52 @@ +package tsoptions + +import ( + "github.com/microsoft/typescript-go/internal/ast" + "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/module" + "github.com/microsoft/typescript-go/internal/packagejson" + "github.com/microsoft/typescript-go/internal/tspath" +) + +// resolveContentMapperManifest locates packageName in node_modules (walking up from the directory of +// containingFile via node module resolution) and reads its package.json to produce the mapper's manifest +// and package directory. It never executes the package. On failure it returns a diagnostic describing why +// the mapper could not be resolved; on success the diagnostic is nil. +func resolveContentMapperManifest(host ParseConfigHost, containingFile string, packageName string) (contentmapper.Manifest, string, *ast.Diagnostic) { + resolver := module.NewResolver(host, &core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindBundler}, "", "", nil) + resolved := resolver.ResolvePackageDirectory(packageName, containingFile, core.ResolutionModeNone, nil) + if resolved == nil || resolved.ResolvedFileName == "" { + return contentmapper.Manifest{}, "", ast.NewCompilerDiagnostic(diagnostics.The_content_mapper_package_0_could_not_be_resolved, packageName) + } + packageDirectory := resolved.ResolvedFileName + + packageJsonPath := tspath.CombinePaths(packageDirectory, "package.json") + contents, ok := host.FS().ReadFile(packageJsonPath) + if !ok { + return contentmapper.Manifest{}, "", ast.NewCompilerDiagnostic(diagnostics.The_content_mapper_package_0_could_not_be_resolved, packageName) + } + fields, err := packagejson.Parse([]byte(contents)) + if err != nil { + return contentmapper.Manifest{}, "", ast.NewCompilerDiagnostic(diagnostics.The_package_json_of_the_content_mapper_package_0_could_not_be_parsed, packageName) + } + name, _ := fields.Name.GetValue() + if name == "" { + return contentmapper.Manifest{}, "", ast.NewCompilerDiagnostic(diagnostics.The_package_json_of_the_content_mapper_package_0_does_not_specify_a_name, packageName) + } + version, _ := fields.Version.GetValue() + + // A content mapper package must declare how to run it: a "tsContentMapper" object with a non-empty + // "exec" array of strings. + cm, ok := fields.ContentMapper.GetValue() + if !ok { + return contentmapper.Manifest{}, "", ast.NewCompilerDiagnostic(diagnostics.The_package_json_of_the_content_mapper_package_0_does_not_declare_a_tsContentMapper_object, packageName) + } + exec, ok := cm.Exec.GetValue() + if !ok || len(exec) == 0 { + return contentmapper.Manifest{}, "", ast.NewCompilerDiagnostic(diagnostics.The_tsContentMapper_exec_of_the_content_mapper_package_0_must_be_a_non_empty_array_of_strings, packageName) + } + compilerOptions, _ := cm.CompilerOptions.GetValue() + return contentmapper.Manifest{Name: name, Version: version, Exec: exec, CompilerOptions: compilerOptions}, packageDirectory, nil +} diff --git a/internal/tsoptions/contentmappers_test.go b/internal/tsoptions/contentmappers_test.go new file mode 100644 index 00000000000..3d60d816b3d --- /dev/null +++ b/internal/tsoptions/contentmappers_test.go @@ -0,0 +1,94 @@ +package tsoptions + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/contentmapper" + "github.com/microsoft/typescript-go/internal/diagnostics" + "github.com/microsoft/typescript-go/internal/vfs" + "github.com/microsoft/typescript-go/internal/vfs/vfstest" + "gotest.tools/v3/assert" +) + +type resolveContentMapperHost struct { + fs vfs.FS +} + +func TestGetContentMapperForFileNameUsesLongestExtension(t *testing.T) { + t.Parallel() + zMapper := &contentmapper.Mapper{Definition: contentmapper.Definition{Package: "z", Extensions: []string{".z"}}} + yzMapper := &contentmapper.Mapper{Definition: contentmapper.Definition{Package: "yz", Extensions: []string{".y.z"}}} + commandLine := &ParsedCommandLine{ParsedConfig: &ParsedOptions{ContentMappers: []*contentmapper.Mapper{zMapper, yzMapper}}} + + assert.Equal(t, commandLine.GetContentMapperForFileName("/src/Component.y.z"), yzMapper) + assert.Equal(t, commandLine.GetContentMapperForFileName("/src/Component.z"), zMapper) +} +func (h resolveContentMapperHost) FS() vfs.FS { return h.fs } +func (h resolveContentMapperHost) GetCurrentDirectory() string { return "/home/project" } + +func TestResolveContentMapperManifest(t *testing.T) { + t.Parallel() + + host := resolveContentMapperHost{fs: vfstest.FromMap(map[string]string{ + "/home/project/node_modules/vue-ts-mapper/package.json": `{ + "name": "vue-ts-mapper", + "version": "1.2.3", + "tsContentMapper": { "exec": ["node", "./dist/mapper.js"], "compilerOptions": ["target", "jsx"] } + }`, + "/home/node_modules/@scope/noversion/package.json": `{ + "name": "@scope/noversion", + "tsContentMapper": { "exec": ["run"] } + }`, + "/home/project/node_modules/no-name/package.json": `{ + "version": "1.0.0" + }`, + "/home/project/node_modules/no-manifest/package.json": `{ + "name": "no-manifest" + }`, + "/home/project/node_modules/no-exec/package.json": `{ + "name": "no-exec", + "tsContentMapper": {} + }`, + "/home/project/node_modules/bad-exec/package.json": `{ + "name": "bad-exec", + "tsContentMapper": { "exec": "node ./mapper.js" } + }`, + }, true /*useCaseSensitiveFileNames*/)} + + // Name, version, and the verbatim exec argv are preserved. + manifest, packageDirectory, diagnostic := resolveContentMapperManifest(host, "/home/project/tsconfig.json", "vue-ts-mapper") + assert.Assert(t, diagnostic == nil) + assert.Equal(t, manifest.Name, "vue-ts-mapper") + assert.Equal(t, manifest.Version, "1.2.3") + assert.Equal(t, packageDirectory, "/home/project/node_modules/vue-ts-mapper") + assert.DeepEqual(t, manifest.Exec, []string{"node", "./dist/mapper.js"}) + assert.DeepEqual(t, manifest.CompilerOptions, []string{"target", "jsx"}) + + // Resolution walks up node_modules; a package with no version resolves to a name and empty version. + manifest, _, diagnostic = resolveContentMapperManifest(host, "/home/project/src/tsconfig.json", "@scope/noversion") + assert.Assert(t, diagnostic == nil) + assert.Equal(t, manifest.Name, "@scope/noversion") + assert.Equal(t, manifest.Version, "") + + // A package that is not installed reports a resolution diagnostic. + _, _, diagnostic = resolveContentMapperManifest(host, "/home/project/tsconfig.json", "missing-mapper") + assert.Assert(t, diagnostic != nil) + assert.Equal(t, diagnostic.Code(), diagnostics.The_content_mapper_package_0_could_not_be_resolved.Code()) + + // A package whose package.json has no name reports a diagnostic. + _, _, diagnostic = resolveContentMapperManifest(host, "/home/project/tsconfig.json", "no-name") + assert.Assert(t, diagnostic != nil) + assert.Equal(t, diagnostic.Code(), diagnostics.The_package_json_of_the_content_mapper_package_0_does_not_specify_a_name.Code()) + + // A package that does not declare a "tsContentMapper" object reports a diagnostic. + _, _, diagnostic = resolveContentMapperManifest(host, "/home/project/tsconfig.json", "no-manifest") + assert.Assert(t, diagnostic != nil) + assert.Equal(t, diagnostic.Code(), diagnostics.The_package_json_of_the_content_mapper_package_0_does_not_declare_a_tsContentMapper_object.Code()) + + // A "tsContentMapper" with no "exec", or an "exec" of the wrong type, reports a diagnostic. + for _, pkg := range []string{"no-exec", "bad-exec"} { + _, _, diagnostic = resolveContentMapperManifest(host, "/home/project/tsconfig.json", pkg) + assert.Assert(t, diagnostic != nil, "expected a diagnostic for %s", pkg) + assert.Equal(t, diagnostic.Code(), diagnostics.The_tsContentMapper_exec_of_the_content_mapper_package_0_must_be_a_non_empty_array_of_strings.Code()) + } +} diff --git a/internal/tsoptions/declscompiler.go b/internal/tsoptions/declscompiler.go index cde4e2556bc..3d676b06982 100644 --- a/internal/tsoptions/declscompiler.go +++ b/internal/tsoptions/declscompiler.go @@ -250,6 +250,14 @@ var commonOptionsWithBuild = []*CommandLineOption{ DefaultValueDescription: diagnostics.X_4_unless_singleThreaded_is_passed, minValue: 1, }, + { + Name: "loadExternalPlugins", + Kind: CommandLineOptionTypeBoolean, + Category: diagnostics.Command_line_Options, + IsCommandLineOnly: true, + Description: diagnostics.Allow_loading_external_content_mapper_plugins_that_execute_code_during_compilation, + DefaultValueDescription: false, + }, } var optionsForCompiler = []*CommandLineOption{ diff --git a/internal/tsoptions/parsedcommandline.go b/internal/tsoptions/parsedcommandline.go index 9f0229165bc..509947da9e9 100644 --- a/internal/tsoptions/parsedcommandline.go +++ b/internal/tsoptions/parsedcommandline.go @@ -8,6 +8,7 @@ import ( "sync" "github.com/microsoft/typescript-go/internal/ast" + "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/glob" @@ -25,7 +26,7 @@ const ( ) type ParsedCommandLine struct { - ParsedConfig *core.ParsedOptions `json:"parsedConfig"` + ParsedConfig *ParsedOptions `json:"parsedConfig"` ConfigFile *TsConfigSourceFile `json:"configFile"` // TsConfigSourceFile, used in Program and ExecuteCommandLine Errors []*ast.Diagnostic `json:"errors"` @@ -37,7 +38,6 @@ type ParsedCommandLine struct { wildcardDirectories map[string]bool includeGlobsOnce sync.Once includeGlobs []*glob.Glob - extraFileExtensions []FileExtensionInfo sourceAndOutputMapsOnce sync.Once sourceToProjectReference map[tspath.Path]*SourceOutputAndProjectReference @@ -63,7 +63,7 @@ func NewParsedCommandLine( comparePathsOptions tspath.ComparePathsOptions, ) *ParsedCommandLine { return &ParsedCommandLine{ - ParsedConfig: &core.ParsedOptions{ + ParsedConfig: &ParsedOptions{ CompilerOptions: compilerOptions, FileNames: rootFileNames, }, @@ -267,7 +267,7 @@ func (p *ParsedCommandLine) LiteralFileNames() []string { return nil } -func (p *ParsedCommandLine) SetParsedOptions(o *core.ParsedOptions) { +func (p *ParsedCommandLine) SetParsedOptions(o *ParsedOptions) { p.ParsedConfig = o } @@ -310,6 +310,33 @@ func (p *ParsedCommandLine) ProjectReferences() []*core.ProjectReference { return p.ParsedConfig.ProjectReferences } +func (p *ParsedCommandLine) ContentMappers() []*contentmapper.Mapper { + if p == nil || p.ParsedConfig == nil { + return nil + } + return p.ParsedConfig.ContentMappers +} + +// ContentMapperExtensions returns the flattened list of file extensions registered by the +// config's content mappers. +func (p *ParsedCommandLine) ContentMapperExtensions() []string { + return core.FlatMap(p.ContentMappers(), func(m *contentmapper.Mapper) []string { + return m.Extensions + }) +} + +// GetContentMapperForFileName returns the configured content mapper whose extensions include fileName, +// or nil if no content mapper is registered for the file's extension. +func (p *ParsedCommandLine) GetContentMapperForFileName(fileName string) *contentmapper.Mapper { + extension := tspath.GetLongestExtensionFromPath(fileName, p.ContentMapperExtensions(), false) + for _, mapper := range p.ContentMappers() { + if slices.Contains(mapper.Extensions, extension) { + return mapper + } + } + return nil +} + func (p *ParsedCommandLine) ResolvedProjectReferencePaths() []string { p.resolvedProjectReferencePathsOnce.Do(func() { p.resolvedProjectReferencePaths = core.Map(p.ParsedConfig.ProjectReferences, core.ResolveProjectReferencePath) @@ -397,7 +424,7 @@ func (p *ParsedCommandLine) ReloadFileNamesOfParsedCommandLine(fs vfs.FS) *Parse p.GetCurrentDirectory(), p.CompilerOptions(), fs, - p.extraFileExtensions, + p.ContentMapperExtensions(), ) parsedConfig.FileNames = fileNames parsedCommandLine := ParsedCommandLine{ @@ -409,7 +436,6 @@ func (p *ParsedCommandLine) ReloadFileNamesOfParsedCommandLine(fs vfs.FS) *Parse comparePathsOptions: p.comparePathsOptions, wildcardDirectories: p.wildcardDirectories, includeGlobs: p.includeGlobs, - extraFileExtensions: p.extraFileExtensions, literalFileNamesLen: literalFileNamesLen, } return &parsedCommandLine diff --git a/internal/tsoptions/parsedoptions.go b/internal/tsoptions/parsedoptions.go new file mode 100644 index 00000000000..688b276e9e6 --- /dev/null +++ b/internal/tsoptions/parsedoptions.go @@ -0,0 +1,16 @@ +package tsoptions + +import ( + "github.com/microsoft/typescript-go/internal/contentmapper" + "github.com/microsoft/typescript-go/internal/core" +) + +type ParsedOptions struct { + CompilerOptions *core.CompilerOptions `json:"compilerOptions"` + WatchOptions *core.WatchOptions `json:"watchOptions"` + TypeAcquisition *core.TypeAcquisition `json:"typeAcquisition"` + + FileNames []string `json:"fileNames"` + ProjectReferences []*core.ProjectReference `json:"projectReferences"` + ContentMappers []*contentmapper.Mapper `json:"contentMappers"` +} diff --git a/internal/tsoptions/parsinghelpers.go b/internal/tsoptions/parsinghelpers.go index 2e6fb29e1cf..8414719641f 100644 --- a/internal/tsoptions/parsinghelpers.go +++ b/internal/tsoptions/parsinghelpers.go @@ -6,6 +6,7 @@ import ( "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/collections" + "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/tspath" @@ -100,6 +101,55 @@ func parseProjectReference(json any) *projectReferenceParseResult { return nil } +func parseContentMapper(json any) (*contentmapper.Mapper, []*ast.Diagnostic) { + v, ok := json.(*collections.OrderedMap[string, any]) + if !ok { + return nil, nil + } + var errors []*ast.Diagnostic + mapper := &contentmapper.Mapper{} + if pkg, ok := v.Get("package"); ok { + if str, isString := pkg.(string); isString && str != "" { + mapper.Package = str + } else { + errors = append(errors, ast.NewCompilerDiagnostic(diagnostics.Compiler_option_0_requires_a_value_of_type_1, "contentMapper.package", "string")) + } + } else { + errors = append(errors, ast.NewCompilerDiagnostic(diagnostics.Compiler_option_0_requires_a_value_of_type_1, "contentMapper.package", "string")) + } + if extensions, ok := v.Get("extensions"); ok { + if strs, isStringArray := parseStringArrayStrict(extensions); isStringArray { + mapper.Extensions = strs + } else { + errors = append(errors, ast.NewCompilerDiagnostic(diagnostics.Compiler_option_0_requires_a_value_of_type_1, "contentMapper.extensions", "string[]")) + } + } else { + errors = append(errors, ast.NewCompilerDiagnostic(diagnostics.Compiler_option_0_requires_a_value_of_type_1, "contentMapper.extensions", "string[]")) + } + if len(errors) != 0 { + return nil, errors + } + return mapper, errors +} + +// parseStringArrayStrict returns the string slice and true only if value is an array whose +// elements are all strings. A missing element or wrong element type yields false. +func parseStringArrayStrict(value any) ([]string, bool) { + arr, ok := value.([]any) + if !ok { + return nil, false + } + result := make([]string, 0, len(arr)) + for _, v := range arr { + str, ok := v.(string) + if !ok { + return nil, false + } + result = append(result, str) + } + return result, true +} + func parseJsonToStringKey(json any) *collections.OrderedMap[string, any] { result := collections.NewOrderedMapWithSizeHint[string, any](6) if m, ok := json.(*collections.OrderedMap[string, any]); ok { @@ -115,6 +165,9 @@ func parseJsonToStringKey(json any) *collections.OrderedMap[string, any] { if v, ok := m.Get("references"); ok { result.Set("references", v) } + if v, ok := m.Get("contentMappers"); ok { + result.Set("contentMappers", v) + } if v, ok := m.Get("extends"); ok { if str, ok := v.(string); ok { result.Set("extends", []any{str}) @@ -487,6 +540,8 @@ func parseCompilerOptions(key string, value any, allOptions *core.CompilerOption allOptions.Quiet = ParseTristate(value) case "checkers": allOptions.Checkers = parseNumber(value) + case "loadExternalPlugins": + allOptions.LoadExternalPlugins = ParseTristate(value) default: // different than any key above return false diff --git a/internal/tsoptions/tsconfigparsing.go b/internal/tsoptions/tsconfigparsing.go index b1e1955b1f9..f2fb34f84cb 100644 --- a/internal/tsoptions/tsconfigparsing.go +++ b/internal/tsoptions/tsconfigparsing.go @@ -8,6 +8,7 @@ import ( "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/collections" + "github.com/microsoft/typescript-go/internal/contentmapper" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/debug" "github.com/microsoft/typescript-go/internal/diagnostics" @@ -15,6 +16,7 @@ import ( "github.com/microsoft/typescript-go/internal/locale" "github.com/microsoft/typescript-go/internal/module" "github.com/microsoft/typescript-go/internal/parser" + "github.com/microsoft/typescript-go/internal/scanner" "github.com/microsoft/typescript-go/internal/tspath" "github.com/microsoft/typescript-go/internal/vfs" "github.com/microsoft/typescript-go/internal/vfs/vfsmatch" @@ -65,6 +67,10 @@ var tsconfigRootOptionsMap = &CommandLineOption{ Kind: CommandLineOptionTypeList, // should be a list of projectReference // Category: diagnostics.Projects, }, + { + Name: "contentMappers", + Kind: CommandLineOptionTypeList, // list of content mapper objects + }, { Name: "files", Kind: CommandLineOptionTypeList, @@ -145,12 +151,6 @@ func (c *configFileSpecs) getMatchedFileSpec(fileName string, comparePathsOption return "" } -type FileExtensionInfo struct { - Extension string - IsMixedContent bool - ScriptKind core.ScriptKind -} - type ExtendedConfigCache interface { GetExtendedConfig(fileName string, path tspath.Path, resolutionStack []tspath.Path, host ParseConfigHost) *ExtendedConfigCacheEntry } @@ -731,11 +731,10 @@ func ParseJsonSourceFileConfigFileContent( existingOptionsRaw *collections.OrderedMap[string, any], configFileName string, resolutionStack []tspath.Path, - extraFileExtensions []FileExtensionInfo, extendedConfigCache ExtendedConfigCache, ) *ParsedCommandLine { // tracing?.push(tracing.Phase.Parse, "parseJsonSourceFileConfigFileContent", { path: sourceFile.fileName }); - result := parseJsonConfigFileContentWorker(nil /*json*/, sourceFile, host, basePath, existingOptions, existingOptionsRaw, configFileName, resolutionStack, extraFileExtensions, extendedConfigCache) + result := parseJsonConfigFileContentWorker(nil /*json*/, sourceFile, host, basePath, existingOptions, existingOptionsRaw, configFileName, resolutionStack, extendedConfigCache) // tracing?.pop(); return result } @@ -870,8 +869,8 @@ func convertPropertyValueToJson(sourceFile *ast.SourceFile, valueExpression *ast // jsonNode: The contents of the config file to parse // host: Instance of ParseConfigHost used to enumerate files in folder. // basePath: A root directory to resolve relative path entries in the config file to. e.g. outDir -func ParseJsonConfigFileContent(json any, host ParseConfigHost, basePath string, existingOptions *core.CompilerOptions, configFileName string, resolutionStack []tspath.Path, extraFileExtensions []FileExtensionInfo, extendedConfigCache ExtendedConfigCache) *ParsedCommandLine { - result := parseJsonConfigFileContentWorker(parseJsonToStringKey(json), nil /*sourceFile*/, host, basePath, existingOptions, nil /*existingOptionsRaw*/, configFileName, resolutionStack, extraFileExtensions, extendedConfigCache) +func ParseJsonConfigFileContent(json any, host ParseConfigHost, basePath string, existingOptions *core.CompilerOptions, configFileName string, resolutionStack []tspath.Path, extendedConfigCache ExtendedConfigCache) *ParsedCommandLine { + result := parseJsonConfigFileContentWorker(parseJsonToStringKey(json), nil /*sourceFile*/, host, basePath, existingOptions, nil /*existingOptionsRaw*/, configFileName, resolutionStack, extendedConfigCache) return result } @@ -1192,7 +1191,6 @@ func parseJsonConfigFileContentWorker( existingOptionsRaw *collections.OrderedMap[string, any], configFileName string, resolutionStack []tspath.Path, - extraFileExtensions []FileExtensionInfo, extendedConfigCache ExtendedConfigCache, ) *ParsedCommandLine { debug.Assert((json == nil && sourceFile != nil) || (json != nil && sourceFile == nil)) @@ -1326,9 +1324,78 @@ func parseJsonConfigFileContentWorker( sourceFile.configFileSpecs = &configFileSpecs } + var contentMapperSourceFile *ast.SourceFile + if sourceFile != nil { + contentMapperSourceFile = sourceFile.SourceFile + } + var contentMappers []*contentmapper.Mapper + var contentMapperIndices []int + contentMappersOfRaw := getPropFromRaw("contentMappers", func(element any) bool { return reflect.TypeOf(element) == orderedMapType }, "object") + for i, element := range contentMappersOfRaw.sliceValue { + mapper, mapperErrors := parseContentMapper(element) + for _, mapperError := range mapperErrors { + errors = append(errors, setContentMapperDiagnosticLocation(mapperError, contentMapperSourceFile, getContentMapperSyntax(contentMapperSourceFile, i, ""))) + } + if mapper != nil { + contentMappers = append(contentMappers, mapper) + contentMapperIndices = append(contentMapperIndices, i) + } + } + totalContentMapperExtensions := 0 + for _, mapper := range contentMappers { + totalContentMapperExtensions += len(mapper.Extensions) + } + seenContentMapperExtensions := make(map[string]struct{}, totalContentMapperExtensions) + contentMapperExtensions := make([]string, 0, totalContentMapperExtensions) + nativeExtensions := core.Flatten(tspath.AllSupportedExtensionsWithJson) + for j, mapper := range contentMappers { + validExtensions := make([]string, 0, len(mapper.Extensions)) + for _, ext := range mapper.Extensions { + extNode := getContentMapperExtensionSyntax(contentMapperSourceFile, contentMapperIndices[j], ext) + switch { + case !strings.HasPrefix(ext, "."): + errors = append(errors, setContentMapperDiagnosticLocation(ast.NewCompilerDiagnostic(diagnostics.Content_mapper_file_extension_0_must_begin_with_a, ext), contentMapperSourceFile, extNode)) + case slices.Contains(nativeExtensions, ext): + errors = append(errors, setContentMapperDiagnosticLocation(ast.NewCompilerDiagnostic(diagnostics.Content_mapper_file_extension_0_is_a_built_in_extension_and_cannot_be_registered_by_a_content_mapper, ext), contentMapperSourceFile, extNode)) + default: + if _, seen := seenContentMapperExtensions[ext]; seen { + errors = append(errors, setContentMapperDiagnosticLocation(ast.NewCompilerDiagnostic(diagnostics.Content_mapper_file_extension_0_is_registered_by_more_than_one_content_mapper, ext), contentMapperSourceFile, extNode)) + } else { + seenContentMapperExtensions[ext] = struct{}{} + contentMapperExtensions = append(contentMapperExtensions, ext) + validExtensions = append(validExtensions, ext) + } + } + } + mapper.Extensions = validExtensions + } + if len(contentMappers) > 0 && !(parsedConfig.options != nil && parsedConfig.options.LoadExternalPlugins.IsTrue()) { + errors = append(errors, setContentMapperDiagnosticLocation(ast.NewCompilerDiagnostic(diagnostics.Content_mappers_require_the_loadExternalPlugins_command_line_flag_to_be_enabled), contentMapperSourceFile, getContentMappersKeySyntax(contentMapperSourceFile))) + // Without the flag the mappers are not trusted to run, so drop them entirely: their extensions are + // not registered and their files are not intercepted (they are treated as unknown foreign files). + contentMappers = nil + contentMapperExtensions = nil + } else if len(contentMappers) > 0 { + // Resolve each mapper's package.json now so its name, version, and run command are available to + // everything downstream (diagnostics, build-info staleness) without executing anything. + containingFile := configFileName + if containingFile == "" { + containingFile = tspath.CombinePaths(basePathForFileNames, "tsconfig.json") + } + for j, mapper := range contentMappers { + manifest, packageDirectory, diagnostic := resolveContentMapperManifest(host, containingFile, mapper.Package) + if diagnostic != nil { + errors = append(errors, setContentMapperDiagnosticLocation(diagnostic, contentMapperSourceFile, getContentMapperSyntax(contentMapperSourceFile, contentMapperIndices[j], "package"))) + continue + } + mapper.Manifest = manifest + mapper.PackageDirectory = packageDirectory + } + } + getFileNames := func(basePath string) ([]string, int) { parsedConfigOptions := parsedConfig.options - fileNames, literalFileNamesLen := getFileNamesFromConfigSpecs(configFileSpecs, basePath, parsedConfigOptions, host.FS(), extraFileExtensions) + fileNames, literalFileNamesLen := getFileNamesFromConfigSpecs(configFileSpecs, basePath, parsedConfigOptions, host.FS(), contentMapperExtensions) if shouldReportNoInputFiles(fileNames, canJsonReportNoInputFiles(rawConfig), resolutionStack) { includeSpecs := configFileSpecs.includeSpecs excludeSpecs := configFileSpecs.excludeSpecs @@ -1376,18 +1443,18 @@ func parseJsonConfigFileContentWorker( fileNames, literalFileNamesLen := getFileNames(basePathForFileNames) return &ParsedCommandLine{ - ParsedConfig: &core.ParsedOptions{ + ParsedConfig: &ParsedOptions{ CompilerOptions: parsedConfig.options, TypeAcquisition: parsedConfig.typeAcquisition, // WatchOptions: nil, FileNames: fileNames, ProjectReferences: getProjectReferences(basePathForFileNames), + ContentMappers: contentMappers, }, ConfigFile: sourceFile, Raw: parsedConfig.raw, Errors: errors, - extraFileExtensions: extraFileExtensions, comparePathsOptions: tspath.ComparePathsOptions{ UseCaseSensitiveFileNames: host.FS().UseCaseSensitiveFileNames(), CurrentDirectory: basePathForFileNames, @@ -1537,6 +1604,70 @@ func GetOptionsSyntaxByArrayElementValue(objectLiteral *ast.ObjectLiteralExpress return ForEachPropertyAssignment(objectLiteral, propKey, GetCallbackForFindingPropertyAssignmentByValue(elementValue)) } +// getContentMapperSyntax returns the tsconfig JSON node to attribute a diagnostic about the content +// mapper at index to: the value of subKey within that mapper's object (when subKey is non-empty), +// falling back to the mapper element, then to the "contentMappers" array. An index outside the array +// (e.g. -1) yields the array itself. Returns nil when there is no source file (JSON API). +func getContentMapperSyntax(sourceFile *ast.SourceFile, index int, subKey string) *ast.Node { + if sourceFile == nil { + return nil + } + return ForEachTsConfigPropArray(sourceFile, "contentMappers", func(property *ast.PropertyAssignment) *ast.Node { + if !ast.IsArrayLiteralExpression(property.Initializer) { + return property.Initializer + } + elements := property.Initializer.Elements() + if index < 0 || index >= len(elements) { + return property.Initializer + } + element := elements[index] + if subKey != "" && ast.IsObjectLiteralExpression(element) { + if node := ForEachPropertyAssignment(element.AsObjectLiteralExpression(), subKey, func(property *ast.PropertyAssignment) *ast.Node { + return property.Initializer + }); node != nil { + return node + } + } + return element + }) +} + +// getContentMappersKeySyntax returns the "contentMappers" property key node, used to attribute a +// diagnostic about the setting as a whole rather than a specific mapper. +func getContentMappersKeySyntax(sourceFile *ast.SourceFile) *ast.Node { + if sourceFile == nil { + return nil + } + return ForEachTsConfigPropArray(sourceFile, "contentMappers", func(property *ast.PropertyAssignment) *ast.Node { + return property.Name() + }) +} + +// getContentMapperExtensionSyntax returns the node for a specific extension string within the content +// mapper at index, falling back to the "extensions" array or the mapper element. +func getContentMapperExtensionSyntax(sourceFile *ast.SourceFile, index int, ext string) *ast.Node { + node := getContentMapperSyntax(sourceFile, index, "extensions") + if node != nil && ast.IsArrayLiteralExpression(node) { + if element := core.Find(node.Elements(), func(element *ast.Node) bool { + return ast.IsStringLiteral(element) && element.Text() == ext + }); element != nil { + return element + } + } + return node +} + +// setContentMapperDiagnosticLocation attaches a source location to a content mapper diagnostic when a +// tsconfig source file and node are available (the jsonSourceFile API), leaving it as a location-less +// compiler diagnostic otherwise (the JSON API). +func setContentMapperDiagnosticLocation(diagnostic *ast.Diagnostic, sourceFile *ast.SourceFile, node *ast.Node) *ast.Diagnostic { + if sourceFile != nil && node != nil { + diagnostic.SetFile(sourceFile) + diagnostic.SetLocation(core.NewTextRange(scanner.SkipTrivia(sourceFile.Text(), node.Pos()), node.End())) + } + return diagnostic +} + func ForEachPropertyAssignment[T any](objectLiteral *ast.ObjectLiteralExpression, key string, callback func(property *ast.PropertyAssignment) *T, key2 ...string) *T { if objectLiteral != nil { for _, property := range objectLiteral.Properties.Nodes { @@ -1693,15 +1824,14 @@ func removeWildcardFilesWithLowerPriorityExtension(file string, wildcardFiles *c // basePath is the base path for any relative file specifications. // options is the Compiler options. // host is the host used to resolve files and directories. -// extraFileExtensions optionally file extra file extension information from host +// extraExtensions are additional file extensions (e.g. from content mappers) to treat as supported. func getFileNamesFromConfigSpecs( configFileSpecs configFileSpecs, basePath string, // considering this is the current directory options *core.CompilerOptions, host vfs.FS, - extraFileExtensions []FileExtensionInfo, + extraExtensions []string, ) ([]string, int) { - extraFileExtensions = []FileExtensionInfo{} basePath = tspath.NormalizePath(basePath) keyMappper := func(value string) string { return tspath.GetCanonicalFileName(value, host.UseCaseSensitiveFileNames()) } // Literal file names (provided via the "files" array in tsconfig.json) are stored in a @@ -1721,7 +1851,7 @@ func getFileNamesFromConfigSpecs( validatedExcludeSpecs := configFileSpecs.validatedExcludeSpecs // Rather than re-query this for each file and filespec, we query the supported extensions // once and store it on the expansion context. - supportedExtensions := GetSupportedExtensions(options, extraFileExtensions) + supportedExtensions := GetSupportedExtensions(options, extraExtensions) supportedExtensionsWithJsonIfResolveJsonModule := GetSupportedExtensionsWithJsonIfResolveJsonModule(options, supportedExtensions) // Literal files are always included verbatim. An "include" or "exclude" specification cannot // remove a literal file. @@ -1787,30 +1917,28 @@ func getFileNamesFromConfigSpecs( return files, literalFileMap.Size() } -func GetSupportedExtensions(compilerOptions *core.CompilerOptions, extraFileExtensions []FileExtensionInfo) [][]string { +func GetSupportedExtensions(compilerOptions *core.CompilerOptions, extraExtensions []string) [][]string { needJSExtensions := compilerOptions.GetAllowJS() - if len(extraFileExtensions) == 0 { - if needJSExtensions { - return tspath.AllSupportedExtensions - } else { - return tspath.SupportedTSExtensions - } - } var builtins [][]string if needJSExtensions { builtins = tspath.AllSupportedExtensions } else { builtins = tspath.SupportedTSExtensions } + if len(extraExtensions) == 0 { + return builtins + } flatBuiltins := core.Flatten(builtins) var result [][]string - for _, x := range extraFileExtensions { - if x.ScriptKind == core.ScriptKindDeferred || (needJSExtensions && (x.ScriptKind == core.ScriptKindJS || x.ScriptKind == core.ScriptKindJSX)) && !slices.Contains(flatBuiltins, x.Extension) { - result = append(result, []string{x.Extension}) + for _, ext := range extraExtensions { + if !slices.Contains(flatBuiltins, ext) { + result = append(result, []string{ext}) } } - extensions := slices.Concat(builtins, result) - return extensions + if len(result) == 0 { + return builtins + } + return slices.Concat(builtins, result) } func GetSupportedExtensionsWithJsonIfResolveJsonModule(compilerOptions *core.CompilerOptions, supportedExtensions [][]string) [][]string { @@ -1864,7 +1992,6 @@ func GetParsedCommandLineOfConfigFilePath( optionsRaw, configFileName, nil, - nil, extendedConfigCache, ), nil } diff --git a/internal/tsoptions/tsconfigparsing_test.go b/internal/tsoptions/tsconfigparsing_test.go index a53b3a66d27..788f9ceeb89 100644 --- a/internal/tsoptions/tsconfigparsing_test.go +++ b/internal/tsoptions/tsconfigparsing_test.go @@ -6,6 +6,7 @@ import ( "io/fs" "maps" "path/filepath" + "slices" "strings" "testing" @@ -29,10 +30,11 @@ import ( ) type testConfig struct { - jsonText string - configFileName string - basePath string - allFileList map[string]string + jsonText string + configFileName string + basePath string + allFileList map[string]string + existingOptions *core.CompilerOptions } var parseConfigFileTextToJsonTests = []struct { @@ -823,10 +825,9 @@ func getParsedWithJsonApi(config testConfig, host tsoptions.ParseConfigHost, bas parsed, host, basePath, - nil, + config.existingOptions, configFileName, /*resolutionStack*/ nil, - /*extraFileExtensions*/ nil, /*extendedConfigCache*/ nil, ) } @@ -869,7 +870,6 @@ func TestParseJsonSourceFileConfigFileContentReportsInvalidExtendedConfig(t *tes configFileName, nil, nil, - nil, ) parseErrors := core.Filter(parsed.Errors, func(diagnostic *ast.Diagnostic) bool { @@ -914,7 +914,6 @@ func TestParseJsonSourceFileConfigFileContentWithEmptyExtendedConfig(t *testing. configFileName, nil, nil, - nil, ) assert.Assert(t, parsed != nil) @@ -1006,6 +1005,187 @@ func TestParseNullEnumCompilerOptions(t *testing.T) { } } +func TestContentMappers(t *testing.T) { + t.Parallel() + + config := testConfig{ + jsonText: `{ + "contentMappers": [ + { "package": "vue-mapper", "extensions": [".vue"] } + ], + "include": ["src"] + }`, + configFileName: "tsconfig.json", + basePath: "/", + allFileList: map[string]string{ + "/src/app.ts": "export {}", + "/src/Component.vue": "", + "/node_modules/vue-mapper/package.json": `{ "name": "vue-mapper", "version": "1.2.3", "tsContentMapper": { "exec": ["node", "./mapper.js"] } }`, + }, + existingOptions: &core.CompilerOptions{LoadExternalPlugins: core.TSTrue}, + } + for name, getParsed := range map[string]func(testConfig, tsoptions.ParseConfigHost, string) *tsoptions.ParsedCommandLine{ + "json api": getParsedWithJsonApi, + "jsonSourceFile api": getParsedWithJsonSourceFileApi, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + allFileLists := make(map[string]string, len(config.allFileList)+1) + maps.Copy(allFileLists, config.allFileList) + allFileLists["/tsconfig.json"] = config.jsonText + host := tsoptionstest.NewVFSParseConfigHost(allFileLists, config.basePath, true /*useCaseSensitiveFileNames*/) + parsed := getParsed(config, host, config.basePath) + + assert.Equal(t, len(parsed.Errors), 0) + + mappers := parsed.ContentMappers() + assert.Equal(t, len(mappers), 1) + assert.Equal(t, mappers[0].Package, "vue-mapper") + assert.DeepEqual(t, mappers[0].Extensions, []string{".vue"}) + assert.DeepEqual(t, parsed.ContentMapperExtensions(), []string{".vue"}) + + // The package.json is resolved during parsing, populating name, version, and exec. + assert.Equal(t, mappers[0].Name, "vue-mapper") + assert.Equal(t, mappers[0].Version, "1.2.3") + assert.DeepEqual(t, mappers[0].Exec, []string{"node", "./mapper.js"}) + assert.Equal(t, mappers[0].PackageDirectory, "/node_modules/vue-mapper") + + // The .vue file is picked up by the include glob because its extension is registered. + assert.Assert(t, slices.Contains(parsed.FileNames(), "/src/Component.vue"), "expected /src/Component.vue in %v", parsed.FileNames()) + assert.Assert(t, slices.Contains(parsed.FileNames(), "/src/app.ts"), "expected /src/app.ts in %v", parsed.FileNames()) + }) + } +} + +func TestContentMappersRequireFlag(t *testing.T) { + t.Parallel() + + config := testConfig{ + jsonText: `{ "contentMappers": [{ "package": "vue-mapper", "extensions": [".vue"] }] }`, + configFileName: "tsconfig.json", + basePath: "/", + allFileList: map[string]string{"/app.ts": "export {}"}, + // existingOptions omitted: --loadExternalPlugins is not set. + } + expectedCode := diagnostics.Content_mappers_require_the_loadExternalPlugins_command_line_flag_to_be_enabled.Code() + for name, getParsed := range map[string]func(testConfig, tsoptions.ParseConfigHost, string) *tsoptions.ParsedCommandLine{ + "json api": getParsedWithJsonApi, + "jsonSourceFile api": getParsedWithJsonSourceFileApi, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + allFileLists := map[string]string{"/tsconfig.json": config.jsonText} + maps.Copy(allFileLists, config.allFileList) + host := tsoptionstest.NewVFSParseConfigHost(allFileLists, config.basePath, true /*useCaseSensitiveFileNames*/) + parsed := getParsed(config, host, config.basePath) + found := slices.ContainsFunc(parsed.Errors, func(d *ast.Diagnostic) bool { + return d.Code() == expectedCode + }) + assert.Assert(t, found, "expected diagnostic %d, got errors: %v", expectedCode, parsed.Errors) + }) + } +} + +func TestContentMappersValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + contentMappers string + expectedCode int32 + }{ + { + name: "extension without leading dot", + contentMappers: `[{ "package": "vue-mapper", "extensions": ["vue"] }]`, + expectedCode: diagnostics.Content_mapper_file_extension_0_must_begin_with_a.Code(), + }, + { + name: "built-in extension", + contentMappers: `[{ "package": "x", "extensions": [".ts"] }]`, + expectedCode: diagnostics.Content_mapper_file_extension_0_is_a_built_in_extension_and_cannot_be_registered_by_a_content_mapper.Code(), + }, + { + name: "missing extensions", + contentMappers: `[{ "package": "x" }]`, + expectedCode: diagnostics.Compiler_option_0_requires_a_value_of_type_1.Code(), + }, + { + name: "duplicate extension across mappers", + contentMappers: `[{ "package": "a", "extensions": [".vue"] }, { "package": "b", "extensions": [".vue"] }]`, + expectedCode: diagnostics.Content_mapper_file_extension_0_is_registered_by_more_than_one_content_mapper.Code(), + }, + { + name: "extensions is not an array", + contentMappers: `[{ "package": "x", "extensions": ".vue" }]`, + expectedCode: diagnostics.Compiler_option_0_requires_a_value_of_type_1.Code(), + }, + { + name: "extensions contains a non-string", + contentMappers: `[{ "package": "x", "extensions": [".vue", 1] }]`, + expectedCode: diagnostics.Compiler_option_0_requires_a_value_of_type_1.Code(), + }, + { + name: "package is not a string", + contentMappers: `[{ "package": ["x"], "extensions": [".vue"] }]`, + expectedCode: diagnostics.Compiler_option_0_requires_a_value_of_type_1.Code(), + }, + { + name: "missing package", + contentMappers: `[{ "extensions": [".vue"] }]`, + expectedCode: diagnostics.Compiler_option_0_requires_a_value_of_type_1.Code(), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + config := testConfig{ + jsonText: `{ "contentMappers": ` + test.contentMappers + ` }`, + configFileName: "tsconfig.json", + basePath: "/", + allFileList: map[string]string{"/app.ts": "export {}"}, + existingOptions: &core.CompilerOptions{LoadExternalPlugins: core.TSTrue}, + } + for apiName, getParsed := range map[string]func(testConfig, tsoptions.ParseConfigHost, string) *tsoptions.ParsedCommandLine{ + "json api": getParsedWithJsonApi, + "jsonSourceFile api": getParsedWithJsonSourceFileApi, + } { + t.Run(apiName, func(t *testing.T) { + t.Parallel() + allFileLists := map[string]string{"/tsconfig.json": config.jsonText} + maps.Copy(allFileLists, config.allFileList) + host := tsoptionstest.NewVFSParseConfigHost(allFileLists, config.basePath, true /*useCaseSensitiveFileNames*/) + parsed := getParsed(config, host, config.basePath) + diagnostic := core.Find(parsed.Errors, func(d *ast.Diagnostic) bool { + return d.Code() == test.expectedCode + }) + assert.Assert(t, diagnostic != nil, "expected diagnostic %d, got errors: %v", test.expectedCode, parsed.Errors) + switch test.name { + case "built-in extension": + assert.Equal(t, len(parsed.ContentMappers()), 1) + assert.Equal(t, len(parsed.ContentMappers()[0].Extensions), 0) + assert.Equal(t, len(parsed.ContentMapperExtensions()), 0) + case "duplicate extension across mappers": + assert.Equal(t, len(parsed.ContentMappers()), 2) + assert.DeepEqual(t, parsed.ContentMappers()[0].Extensions, []string{".vue"}) + assert.Equal(t, len(parsed.ContentMappers()[1].Extensions), 0) + assert.DeepEqual(t, parsed.ContentMapperExtensions(), []string{".vue"}) + case "missing extensions", "extensions is not an array", "extensions contains a non-string", "package is not a string", "missing package": + assert.Equal(t, len(parsed.ContentMappers()), 0) + } + + // With the jsonSourceFile API the diagnostic is located at the offending tsconfig syntax. + if apiName == "jsonSourceFile api" { + assert.Assert(t, diagnostic.File() != nil, "expected diagnostic %d to have a source file", test.expectedCode) + assert.Assert(t, diagnostic.Len() > 0, "expected diagnostic %d to have a non-empty location", test.expectedCode) + } + }) + } + }) + } +} + func getParsedWithJsonSourceFileApi(config testConfig, host tsoptions.ParseConfigHost, basePath string) *tsoptions.ParsedCommandLine { configFileName := tspath.GetNormalizedAbsolutePath(config.configFileName, basePath) path := tspath.ToPath(config.configFileName, basePath, host.FS().UseCaseSensitiveFileNames()) @@ -1020,11 +1200,10 @@ func getParsedWithJsonSourceFileApi(config testConfig, host tsoptions.ParseConfi tsConfigSourceFile, host, host.GetCurrentDirectory(), - nil, + config.existingOptions, nil, configFileName, /*resolutionStack*/ nil, - /*extraFileExtensions*/ nil, /*extendedConfigCache*/ nil, ) } @@ -1248,7 +1427,6 @@ func TestParseSrcCompiler(t *testing.T) { nil, tsconfigFileName, /*resolutionStack*/ nil, - /*extraFileExtensions*/ nil, /*extendedConfigCache*/ nil, ) @@ -1413,7 +1591,6 @@ func BenchmarkParseSrcCompiler(b *testing.B) { nil, tsconfigFileName, /*resolutionStack*/ nil, - /*extraFileExtensions*/ nil, /*extendedConfigCache*/ nil, ) } @@ -1474,7 +1651,6 @@ func TestExtendedConfigErrorsAppearOnCacheHit(t *testing.T) { nil, configFileName, nil, - nil, cache, ) } @@ -1519,7 +1695,6 @@ func TestExtendedConfigErrorsAppearOnCacheHit(t *testing.T) { nil, configFileName, nil, - nil, cache, ) } diff --git a/internal/tsoptions/tsoptionstest/parsedcommandline.go b/internal/tsoptions/tsoptionstest/parsedcommandline.go index c5dc7b1c203..02f5c99b845 100644 --- a/internal/tsoptions/tsoptionstest/parsedcommandline.go +++ b/internal/tsoptions/tsoptionstest/parsedcommandline.go @@ -10,5 +10,5 @@ func GetParsedCommandLine(t assert.TestingT, jsonText string, files map[string]s host := NewVFSParseConfigHost(files, currentDirectory, useCaseSensitiveFileNames) configFileName := tspath.CombinePaths(currentDirectory, "tsconfig.json") tsconfigSourceFile := tsoptions.NewTsconfigSourceFileFromFilePath(configFileName, tspath.ToPath(configFileName, currentDirectory, useCaseSensitiveFileNames), jsonText) - return tsoptions.ParseJsonSourceFileConfigFileContent(tsconfigSourceFile, host, currentDirectory, nil, nil, configFileName, nil, nil, nil) + return tsoptions.ParseJsonSourceFileConfigFileContent(tsconfigSourceFile, host, currentDirectory, nil, nil, configFileName, nil, nil) } diff --git a/internal/tsoptions/tsoptionstest/vfsparseconfighost.go b/internal/tsoptions/tsoptionstest/vfsparseconfighost.go index 249617083ef..362f4679511 100644 --- a/internal/tsoptions/tsoptionstest/vfsparseconfighost.go +++ b/internal/tsoptions/tsoptionstest/vfsparseconfighost.go @@ -39,3 +39,22 @@ func NewVFSParseConfigHost(files map[string]string, currentDirectory string, use CurrentDirectory: currentDirectory, } } + +// NewVFSParseConfigHostWithSymlinks builds a parse-config host whose vfs also contains the given symlinks +// (link path -> target path), so config parsing resolves packages through symlinks as it would on disk. +func NewVFSParseConfigHostWithSymlinks(files map[string]string, symlinks map[string]string, currentDirectory string, useCaseSensitiveFileNames bool) *VfsParseConfigHost { + if len(symlinks) == 0 { + return NewVFSParseConfigHost(files, currentDirectory, useCaseSensitiveFileNames) + } + entries := make(map[string]any, len(files)+len(symlinks)) + for name, content := range files { + entries[name] = content + } + for link, target := range symlinks { + entries[tspath.GetNormalizedAbsolutePath(link, currentDirectory)] = vfstest.Symlink(tspath.GetNormalizedAbsolutePath(target, currentDirectory)) + } + return &VfsParseConfigHost{ + Vfs: vfstest.FromMap(entries, useCaseSensitiveFileNames), + CurrentDirectory: currentDirectory, + } +} diff --git a/internal/tspath/extension.go b/internal/tspath/extension.go index 8e78809fc25..1c17fa4b0be 100644 --- a/internal/tspath/extension.go +++ b/internal/tspath/extension.go @@ -53,6 +53,16 @@ func RemoveFileExtension(path string) string { return path } +func RemoveAnyFileExtension(path string) string { + if withoutExtension := RemoveFileExtension(path); withoutExtension != path { + return withoutExtension + } + if extension := GetAnyExtensionFromPath(path, nil, false); extension != "" { + return RemoveExtension(path, extension) + } + return path +} + func TryGetExtensionFromPath(p string) string { for _, ext := range extensionsToRemove { if FileExtensionIs(p, ext) { diff --git a/internal/tspath/path.go b/internal/tspath/path.go index e128d54d8f5..1150f155b77 100644 --- a/internal/tspath/path.go +++ b/internal/tspath/path.go @@ -878,6 +878,20 @@ func GetAnyExtensionFromPath(path string, extensions []string, ignoreCase bool) return "" } +func GetLongestExtensionFromPath(path string, extensions []string, ignoreCase bool) string { + path = RemoveTrailingDirectorySeparator(path) + comparer := stringutil.GetStringEqualityComparer(ignoreCase) + longest := "" + for _, extension := range extensions { + if len(extension) > len(longest) { + if matched := tryGetExtensionFromPath(path, extension, comparer); matched != "" { + longest = matched + } + } + } + return longest +} + func getAnyExtensionFromPathWorker(path string, extensions []string, stringEqualityComparer func(a, b string) bool) string { for _, extension := range extensions { result := tryGetExtensionFromPath(path, extension, stringEqualityComparer) diff --git a/internal/tspath/path_test.go b/internal/tspath/path_test.go index 711030a1cd7..13057d37da7 100644 --- a/internal/tspath/path_test.go +++ b/internal/tspath/path_test.go @@ -168,6 +168,22 @@ func TestGetDirectoryPath(t *testing.T) { assert.Equal(t, GetDirectoryPath("http://server/path/"), "http://server/") } +func TestGetLongestExtensionFromPath(t *testing.T) { + t.Parallel() + extensions := []string{".z", ".y.z", ".other"} + assert.Equal(t, GetLongestExtensionFromPath("/src/Component.y.z", extensions, false), ".y.z") + assert.Equal(t, GetLongestExtensionFromPath("/src/Component.z", extensions, false), ".z") + assert.Equal(t, GetLongestExtensionFromPath("/src/Component.y.Z", extensions, false), "") + assert.Equal(t, GetLongestExtensionFromPath("/src/Component.y.Z", extensions, true), ".y.Z") +} + +func TestRemoveAnyFileExtension(t *testing.T) { + t.Parallel() + assert.Equal(t, RemoveAnyFileExtension("/src/Component.vue"), "/src/Component") + assert.Equal(t, RemoveAnyFileExtension("/src/Component.d.ts"), "/src/Component") + assert.Equal(t, RemoveAnyFileExtension("/src/Component"), "/src/Component") +} + // !!! // getBaseFileName // getAnyExtensionFromPath diff --git a/testdata/baselines/reference/compiler/contentMapperAliasDiagnostic.contentmapper b/testdata/baselines/reference/compiler/contentMapperAliasDiagnostic.contentmapper new file mode 100644 index 00000000000..ea0be64ea15 --- /dev/null +++ b/testdata/baselines/reference/compiler/contentMapperAliasDiagnostic.contentmapper @@ -0,0 +1,12 @@ +//// [/expression.lisp] (ScriptKind: ScriptKindTS, ContentMapper: [.lisp]) +--- Original --- +(+ 1 2 "oops") +--- Transformed --- +add(1, 2, "oops"); + +=== Diagnostics === + +/expression.lisp:1:2 - error TS2304: Cannot find name '+'. + +1 (+ 1 2 "oops") + ~ diff --git a/testdata/baselines/reference/compiler/contentMapperAliasDiagnostic.symbols b/testdata/baselines/reference/compiler/contentMapperAliasDiagnostic.symbols new file mode 100644 index 00000000000..cb5b4c5147f --- /dev/null +++ b/testdata/baselines/reference/compiler/contentMapperAliasDiagnostic.symbols @@ -0,0 +1,5 @@ +//// [tests/cases/compiler/contentMapperAliasDiagnostic.ts] //// + +=== /expression.lisp === + +add(1, 2, "oops"); diff --git a/testdata/baselines/reference/compiler/contentMapperAliasDiagnostic.types b/testdata/baselines/reference/compiler/contentMapperAliasDiagnostic.types new file mode 100644 index 00000000000..71e264e2307 --- /dev/null +++ b/testdata/baselines/reference/compiler/contentMapperAliasDiagnostic.types @@ -0,0 +1,10 @@ +//// [tests/cases/compiler/contentMapperAliasDiagnostic.ts] //// + +=== /expression.lisp === +add(1, 2, "oops"); +>add(1, 2, "oops") : any +>add : any +>1 : 1 +>2 : 2 +>"oops" : "oops" + diff --git a/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.contentmapper b/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.contentmapper new file mode 100644 index 00000000000..8e9f527ead7 --- /dev/null +++ b/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.contentmapper @@ -0,0 +1,16 @@ +//// [/component.y.z] (ScriptKind: ScriptKindTS, ContentMapper: [.y.z]) +--- Original --- +export interface ComponentProps { + label: string; +} +export declare const component: ComponentProps; +--- Transformed --- +const __VERSION = "1.0.0"; +export interface ComponentProps { + label: string; +} +export declare const component: ComponentProps; + +=== Diagnostics === + + diff --git a/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.js b/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.js new file mode 100644 index 00000000000..1c59ebc6a0a --- /dev/null +++ b/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.js @@ -0,0 +1,31 @@ +//// [tests/cases/compiler/contentMapperDeclarationEmit.ts] //// + +//// [package.json] +{ + "name": "mapper", + "version": "1.0.0", + "tsContentMapper": { "exec": ["compiler-test-mapper"] } +} + +//// [component.y.z] +const __VERSION = "1.0.0"; +export interface ComponentProps { + label: string; +} +export declare const component: ComponentProps; + +//// [main.ts] +export { component } from "./component.y.z"; + +//// [main.js] +export { component } from "./component.y.z"; + + +//// [component.d.y.z.ts] +export interface ComponentProps { + label: string; +} +export declare const component: ComponentProps; +//// [main.d.ts] +export { component } from "./component.y.z"; +//# sourceMappingURL=main.d.ts.map \ No newline at end of file diff --git a/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.js.map b/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.js.map new file mode 100644 index 00000000000..e41883c691d --- /dev/null +++ b/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.js.map @@ -0,0 +1,3 @@ +//// [main.d.ts.map] +{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["main.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC"} +//// https://sokra.github.io/source-map-visualization#base64,ZXhwb3J0IHsgY29tcG9uZW50IH0gZnJvbSAiLi9jb21wb25lbnQueS56IjsNCi8vIyBzb3VyY2VNYXBwaW5nVVJMPW1haW4uZC50cy5tYXA=,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibWFpbi5kLnRzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsibWFpbi50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEVBQUUsU0FBUyxFQUFFLE1BQU0saUJBQWlCLENBQUMifQ==,ZXhwb3J0IHsgY29tcG9uZW50IH0gZnJvbSAiLi9jb21wb25lbnQueS56Ijs= diff --git a/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.sourcemap.txt b/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.sourcemap.txt new file mode 100644 index 00000000000..1e303fdec37 --- /dev/null +++ b/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.sourcemap.txt @@ -0,0 +1,37 @@ +=================================================================== +JsFile: main.d.ts +mapUrl: main.d.ts.map +sourceRoot: +sources: main.ts +=================================================================== +------------------------------------------------------------------- +emittedFile:/main.d.ts +sourceFile:main.ts +------------------------------------------------------------------- +>>>export { component } from "./component.y.z"; +1 > +2 >^^^^^^^ +3 > ^^ +4 > ^^^^^^^^^ +5 > ^^ +6 > ^^^^^^ +7 > ^^^^^^^^^^^^^^^^^ +8 > ^ +1 > +2 >export +3 > { +4 > component +5 > } +6 > from +7 > "./component.y.z" +8 > ; +1 >Emitted(1, 1) Source(1, 1) + SourceIndex(0) +2 >Emitted(1, 8) Source(1, 8) + SourceIndex(0) +3 >Emitted(1, 10) Source(1, 10) + SourceIndex(0) +4 >Emitted(1, 19) Source(1, 19) + SourceIndex(0) +5 >Emitted(1, 21) Source(1, 21) + SourceIndex(0) +6 >Emitted(1, 27) Source(1, 27) + SourceIndex(0) +7 >Emitted(1, 44) Source(1, 44) + SourceIndex(0) +8 >Emitted(1, 45) Source(1, 45) + SourceIndex(0) +--- +>>>//# sourceMappingURL=main.d.ts.map \ No newline at end of file diff --git a/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.symbols b/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.symbols new file mode 100644 index 00000000000..e5a15c7e69b --- /dev/null +++ b/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.symbols @@ -0,0 +1,20 @@ +//// [tests/cases/compiler/contentMapperDeclarationEmit.ts] //// + +=== /component.y.z === +const __VERSION = "1.0.0"; +>__VERSION : Symbol(__VERSION, Decl(component.y.z, 0, 5)) + +export interface ComponentProps { +>ComponentProps : Symbol(ComponentProps, Decl(component.y.z, 0, 26)) + + label: string; +>label : Symbol(ComponentProps.label, Decl(component.y.z, 1, 33)) +} +export declare const component: ComponentProps; +>component : Symbol(component, Decl(component.y.z, 4, 20)) +>ComponentProps : Symbol(ComponentProps, Decl(component.y.z, 0, 26)) + +=== /main.ts === +export { component } from "./component.y.z"; +>component : Symbol(component, Decl(main.ts, 0, 8)) + diff --git a/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.types b/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.types new file mode 100644 index 00000000000..f6676f44ba9 --- /dev/null +++ b/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.types @@ -0,0 +1,18 @@ +//// [tests/cases/compiler/contentMapperDeclarationEmit.ts] //// + +=== /component.y.z === +const __VERSION = "1.0.0"; +>__VERSION : "1.0.0" +>"1.0.0" : "1.0.0" + +export interface ComponentProps { + label: string; +>label : string +} +export declare const component: ComponentProps; +>component : ComponentProps + +=== /main.ts === +export { component } from "./component.y.z"; +>component : import("./component.y.z").ComponentProps + diff --git a/testdata/baselines/reference/compiler/contentMapperDiagnostics.contentmapper b/testdata/baselines/reference/compiler/contentMapperDiagnostics.contentmapper new file mode 100644 index 00000000000..223287609cb --- /dev/null +++ b/testdata/baselines/reference/compiler/contentMapperDiagnostics.contentmapper @@ -0,0 +1,27 @@ +//// [/widget.box] (ScriptKind: ScriptKindTS, ContentMapper: [.box]) +--- Original --- +export const count: number = "not a number"; +export const label: string = #{target}; +export const broken = #{unterminated; +--- Transformed --- +const __VERSION = "1.0.0"; +export const count: number = "not a number"; +export const label: string = 7; +export const broken = undefined + +=== Diagnostics === + +/widget.box:1:14 - error TS2322: Type 'string' is not assignable to type 'number'. + +1 export const count: number = "not a number"; + ~~~~~ + +/widget.box:2:14 - error TS2322: Type 'number' is not assignable to type 'string'. + +2 export const label: string = #{target}; + ~~~~~ + +/widget.box:3:23 - error box1000: Unclosed interpolation. + +3 export const broken = #{unterminated; + ~~~~~~~~~~~~~~~ diff --git a/testdata/baselines/reference/compiler/contentMapperDiagnostics.symbols b/testdata/baselines/reference/compiler/contentMapperDiagnostics.symbols new file mode 100644 index 00000000000..a4532015b05 --- /dev/null +++ b/testdata/baselines/reference/compiler/contentMapperDiagnostics.symbols @@ -0,0 +1,16 @@ +//// [tests/cases/compiler/contentMapperDiagnostics.ts] //// + +=== /widget.box === +const __VERSION = "1.0.0"; +>__VERSION : Symbol(__VERSION, Decl(widget.box, 0, 5)) + +export const count: number = "not a number"; +>count : Symbol(count, Decl(widget.box, 1, 12)) + +export const label: string = 7; +>label : Symbol(label, Decl(widget.box, 2, 12)) + +export const broken = undefined +>broken : Symbol(broken, Decl(widget.box, 3, 12)) +>undefined : Symbol(undefined) + diff --git a/testdata/baselines/reference/compiler/contentMapperDiagnostics.types b/testdata/baselines/reference/compiler/contentMapperDiagnostics.types new file mode 100644 index 00000000000..55ae7085e5d --- /dev/null +++ b/testdata/baselines/reference/compiler/contentMapperDiagnostics.types @@ -0,0 +1,19 @@ +//// [tests/cases/compiler/contentMapperDiagnostics.ts] //// + +=== /widget.box === +const __VERSION = "1.0.0"; +>__VERSION : "1.0.0" +>"1.0.0" : "1.0.0" + +export const count: number = "not a number"; +>count : number +>"not a number" : "not a number" + +export const label: string = 7; +>label : string +>7 : 7 + +export const broken = undefined +>broken : undefined +>undefined : undefined + diff --git a/testdata/baselines/reference/compiler/contentMapperSymlink.contentmapper b/testdata/baselines/reference/compiler/contentMapperSymlink.contentmapper new file mode 100644 index 00000000000..63883c1f097 --- /dev/null +++ b/testdata/baselines/reference/compiler/contentMapperSymlink.contentmapper @@ -0,0 +1,10 @@ +//// [/app.box] (ScriptKind: ScriptKindTS, ContentMapper: [.box]) +--- Original --- +export const version = #{target}; +--- Transformed --- +const __VERSION = "1.0.0"; +export const version = 7; + +=== Diagnostics === + + diff --git a/testdata/baselines/reference/compiler/contentMapperSymlink.js b/testdata/baselines/reference/compiler/contentMapperSymlink.js new file mode 100644 index 00000000000..d21ef3eb399 --- /dev/null +++ b/testdata/baselines/reference/compiler/contentMapperSymlink.js @@ -0,0 +1,22 @@ +//// [tests/cases/compiler/contentMapperSymlink.ts] //// + +//// [package.json] +{ + "name": "mapper", + "version": "1.0.0", + "tsContentMapper": { "exec": ["compiler-test-mapper"], "compilerOptions": ["target", "jsx"] } +} + +//// [app.box] +const __VERSION = "1.0.0"; +export const version = 7; + +//// [main.ts] +import { version } from "./app.box"; + +export const twice: number = version * 2; + + +//// [main.js] +import { version } from "./app.box"; +export const twice = version * 2; diff --git a/testdata/baselines/reference/compiler/contentMapperSymlink.symbols b/testdata/baselines/reference/compiler/contentMapperSymlink.symbols new file mode 100644 index 00000000000..d42d8b50270 --- /dev/null +++ b/testdata/baselines/reference/compiler/contentMapperSymlink.symbols @@ -0,0 +1,17 @@ +//// [tests/cases/compiler/contentMapperSymlink.ts] //// + +=== /app.box === +const __VERSION = "1.0.0"; +>__VERSION : Symbol(__VERSION, Decl(app.box, 0, 5)) + +export const version = 7; +>version : Symbol(version, Decl(app.box, 1, 12)) + +=== /main.ts === +import { version } from "./app.box"; +>version : Symbol(version, Decl(main.ts, 0, 8)) + +export const twice: number = version * 2; +>twice : Symbol(twice, Decl(main.ts, 2, 12)) +>version : Symbol(version, Decl(main.ts, 0, 8)) + diff --git a/testdata/baselines/reference/compiler/contentMapperSymlink.types b/testdata/baselines/reference/compiler/contentMapperSymlink.types new file mode 100644 index 00000000000..67dd735c7e6 --- /dev/null +++ b/testdata/baselines/reference/compiler/contentMapperSymlink.types @@ -0,0 +1,21 @@ +//// [tests/cases/compiler/contentMapperSymlink.ts] //// + +=== /app.box === +const __VERSION = "1.0.0"; +>__VERSION : "1.0.0" +>"1.0.0" : "1.0.0" + +export const version = 7; +>version : 7 +>7 : 7 + +=== /main.ts === +import { version } from "./app.box"; +>version : 7 + +export const twice: number = version * 2; +>twice : number +>version * 2 : number +>version : 7 +>2 : 2 + diff --git a/testdata/baselines/reference/compiler/contentMapperTransform.contentmapper b/testdata/baselines/reference/compiler/contentMapperTransform.contentmapper new file mode 100644 index 00000000000..5285a71ce57 --- /dev/null +++ b/testdata/baselines/reference/compiler/contentMapperTransform.contentmapper @@ -0,0 +1,14 @@ +//// [/app.box] (ScriptKind: ScriptKindTS, ContentMapper: [.box]) +--- Original --- +export const label: string = "widget"; +export const version = #{target}; +export const flavor = #{jsx}; +--- Transformed --- +const __VERSION = "1.0.0"; +export const label: string = "widget"; +export const version = 7; +export const flavor = undefined; + +=== Diagnostics === + + diff --git a/testdata/baselines/reference/compiler/contentMapperTransform.js b/testdata/baselines/reference/compiler/contentMapperTransform.js new file mode 100644 index 00000000000..cf90133072a --- /dev/null +++ b/testdata/baselines/reference/compiler/contentMapperTransform.js @@ -0,0 +1,28 @@ +//// [tests/cases/compiler/contentMapperTransform.ts] //// + +//// [package.json] +{ + "name": "mapper", + "version": "1.0.0", + "tsContentMapper": { "exec": ["compiler-test-mapper"], "compilerOptions": ["target", "jsx"] } +} + +//// [app.box] +const __VERSION = "1.0.0"; +export const label: string = "widget"; +export const version = 7; +export const flavor = undefined; + +//// [main.ts] +import { label, version, flavor } from "./app.box"; + +export const upper: string = label; +export const twice: number = version * 2; +export const maybe: undefined = flavor; + + +//// [main.js] +import { label, version, flavor } from "./app.box"; +export const upper = label; +export const twice = version * 2; +export const maybe = flavor; diff --git a/testdata/baselines/reference/compiler/contentMapperTransform.symbols b/testdata/baselines/reference/compiler/contentMapperTransform.symbols new file mode 100644 index 00000000000..9601f3cf26e --- /dev/null +++ b/testdata/baselines/reference/compiler/contentMapperTransform.symbols @@ -0,0 +1,34 @@ +//// [tests/cases/compiler/contentMapperTransform.ts] //// + +=== /app.box === +const __VERSION = "1.0.0"; +>__VERSION : Symbol(__VERSION, Decl(app.box, 0, 5)) + +export const label: string = "widget"; +>label : Symbol(label, Decl(app.box, 1, 12)) + +export const version = 7; +>version : Symbol(version, Decl(app.box, 2, 12)) + +export const flavor = undefined; +>flavor : Symbol(flavor, Decl(app.box, 3, 12)) +>undefined : Symbol(undefined) + +=== /main.ts === +import { label, version, flavor } from "./app.box"; +>label : Symbol(label, Decl(main.ts, 0, 8)) +>version : Symbol(version, Decl(main.ts, 0, 15)) +>flavor : Symbol(flavor, Decl(main.ts, 0, 24)) + +export const upper: string = label; +>upper : Symbol(upper, Decl(main.ts, 2, 12)) +>label : Symbol(label, Decl(main.ts, 0, 8)) + +export const twice: number = version * 2; +>twice : Symbol(twice, Decl(main.ts, 3, 12)) +>version : Symbol(version, Decl(main.ts, 0, 15)) + +export const maybe: undefined = flavor; +>maybe : Symbol(maybe, Decl(main.ts, 4, 12)) +>flavor : Symbol(flavor, Decl(main.ts, 0, 24)) + diff --git a/testdata/baselines/reference/compiler/contentMapperTransform.types b/testdata/baselines/reference/compiler/contentMapperTransform.types new file mode 100644 index 00000000000..16979403aa6 --- /dev/null +++ b/testdata/baselines/reference/compiler/contentMapperTransform.types @@ -0,0 +1,39 @@ +//// [tests/cases/compiler/contentMapperTransform.ts] //// + +=== /app.box === +const __VERSION = "1.0.0"; +>__VERSION : "1.0.0" +>"1.0.0" : "1.0.0" + +export const label: string = "widget"; +>label : string +>"widget" : "widget" + +export const version = 7; +>version : 7 +>7 : 7 + +export const flavor = undefined; +>flavor : undefined +>undefined : undefined + +=== /main.ts === +import { label, version, flavor } from "./app.box"; +>label : string +>version : 7 +>flavor : undefined + +export const upper: string = label; +>upper : string +>label : string + +export const twice: number = version * 2; +>twice : number +>version * 2 : number +>version : 7 +>2 : 2 + +export const maybe: undefined = flavor; +>maybe : undefined +>flavor : undefined + diff --git a/testdata/baselines/reference/fourslash/autoImports/contentMapperAutoImports.baseline.md b/testdata/baselines/reference/fourslash/autoImports/contentMapperAutoImports.baseline.md new file mode 100644 index 00000000000..1b0550736ce --- /dev/null +++ b/testdata/baselines/reference/fourslash/autoImports/contentMapperAutoImports.baseline.md @@ -0,0 +1,12 @@ +// === Auto Imports === +```ts +// @FileName: /main.ts +profileTi/**/ + +``````ts +import { profileTitle } from "./ProfileCard.vue"; + +profileTi + +``` + diff --git a/testdata/baselines/reference/fourslash/autoImports/contentMapperAutoImportsIntoMappedFile.baseline.md b/testdata/baselines/reference/fourslash/autoImports/contentMapperAutoImportsIntoMappedFile.baseline.md new file mode 100644 index 00000000000..498a946789f --- /dev/null +++ b/testdata/baselines/reference/fourslash/autoImports/contentMapperAutoImportsIntoMappedFile.baseline.md @@ -0,0 +1,18 @@ +// === Auto Imports === +```vue +// @FileName: /ProfileCard.vue + + + +``````vue + + + +``` + diff --git a/testdata/baselines/reference/fourslash/autoImports/contentMapperNodeModulesAutoImports.baseline.md b/testdata/baselines/reference/fourslash/autoImports/contentMapperNodeModulesAutoImports.baseline.md new file mode 100644 index 00000000000..711188c9c0b --- /dev/null +++ b/testdata/baselines/reference/fourslash/autoImports/contentMapperNodeModulesAutoImports.baseline.md @@ -0,0 +1,12 @@ +// === Auto Imports === +```ts +// @FileName: /main.ts +profileTi/**/ + +``````ts +import { profileTitle } from "profile-package/ProfileCard.vue"; + +profileTi + +``` + diff --git a/testdata/baselines/reference/fourslash/documentHighlights/contentMapperDocumentHighlights.baseline.jsonc b/testdata/baselines/reference/fourslash/documentHighlights/contentMapperDocumentHighlights.baseline.jsonc new file mode 100644 index 00000000000..3865ac52d15 --- /dev/null +++ b/testdata/baselines/reference/fourslash/documentHighlights/contentMapperDocumentHighlights.baseline.jsonc @@ -0,0 +1,40 @@ +// === documentHighlights === +// === /ProfileCard.vue === +// +// +// +// + + + +// === documentHighlights === +// === /ProfileCard.vue === +// +// +// +// + + + +// === documentHighlights === +// === /ProfileCard.vue === +// +// +// +// + +// === /main.ts === +// import DefaultCard, { ProfileCard, [|title|] } from "./ProfileCard.vue"; +// export const pageTitle = [|title|]; +// export const component = ProfileCard; +// export const fallback = DefaultCard; +// + + + +// === findAllReferences === +// === /ProfileCard.vue === +// +// +// +// + +// === /main.ts === +// import DefaultCard, { ProfileCard, [|title|] } from "./ProfileCard.vue"; +// export const pageTitle = [|title|]; +// export const component = ProfileCard; +// export const fallback = DefaultCard; +// + + + +// === findAllReferences === +// === /ProfileCard.vue === +// +// +// +// + +// === /main.ts === +// import DefaultCard, { ProfileCard, [|title|] } from "./ProfileCard.vue"; +// export const pageTitle = [|ti/*FIND ALL REFS*/tle|]; +// export const component = ProfileCard; +// export const fallback = DefaultCard; +// + + + +// === findAllReferences === +// === /ProfileCard.vue === +// --- (line: 3) skipped --- +//

Profile

+// +// +// + +// === /format.ts === +// export function [|format|](value: string): string { return value; } +// + + + +// === findAllReferences === +// === /ProfileCard.vue === +// +//