From ce4dd1de27bb58670156da97740b2280df60513d Mon Sep 17 00:00:00 2001 From: Mike DelGaudio <43451174+mikedelgaudio@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:10:59 -0700 Subject: [PATCH] Emit declaration source maps for generated typings Generated typings such as .resx and .scss declarations are merged into the source tree via "rootDirs", so the TypeScript language service only ever sees the generated .d.ts. Alt-clicking a localized string therefore navigates to the generated declaration rather than the file that declares it. Add opt-in declaration source map generation to TypingsGenerator. The generator already composes its output line by line, so it knows the exact position of every emitted declaration and does not need to parse its own output. The map is serialized per output folder so that the relative path back to the source is correct for secondary folders as well. StringValuesTypingsGenerator records those positions from the new optional IStringValueTyping.sourcePosition, parseResx populates it from the xmldoc element, and heft-localization-typings-plugin exposes a generateDeclarationMaps option. Parsers that do not supply positions are unaffected, and no map is emitted unless the feature is enabled and positions are available. --- ...-declaration-maps_2026-07-28-20-47-38.json | 11 + ...-declaration-maps_2026-07-28-20-47-38.json | 11 + ...-declaration-maps_2026-07-28-20-47-38.json | 11 + .../reviews/api/localization-utilities.api.md | 2 + common/reviews/api/typings-generator.api.md | 32 ++- .../src/LocalizationTypingsPlugin.ts | 9 + ...ft-localization-typings-plugin.schema.json | 5 + .../src/TypingsGenerator.ts | 3 +- .../localization-utilities/src/interfaces.ts | 9 + .../src/parsers/parseResx.ts | 5 +- .../test/__snapshots__/parseResx.test.ts.snap | 44 ++++ .../typings-generator/src/DeclarationMap.ts | 155 ++++++++++++ .../src/StringValuesTypingsGenerator.ts | 41 +++- .../typings-generator/src/TypingsGenerator.ts | 87 ++++++- libraries/typings-generator/src/index.ts | 3 + .../src/test/DeclarationMap.test.ts | 225 ++++++++++++++++++ 16 files changed, 631 insertions(+), 22 deletions(-) create mode 100644 common/changes/@rushstack/heft-localization-typings-plugin/feature-typings-generator-declaration-maps_2026-07-28-20-47-38.json create mode 100644 common/changes/@rushstack/localization-utilities/feature-typings-generator-declaration-maps_2026-07-28-20-47-38.json create mode 100644 common/changes/@rushstack/typings-generator/feature-typings-generator-declaration-maps_2026-07-28-20-47-38.json create mode 100644 libraries/typings-generator/src/DeclarationMap.ts create mode 100644 libraries/typings-generator/src/test/DeclarationMap.test.ts diff --git a/common/changes/@rushstack/heft-localization-typings-plugin/feature-typings-generator-declaration-maps_2026-07-28-20-47-38.json b/common/changes/@rushstack/heft-localization-typings-plugin/feature-typings-generator-declaration-maps_2026-07-28-20-47-38.json new file mode 100644 index 00000000000..26d30d5b590 --- /dev/null +++ b/common/changes/@rushstack/heft-localization-typings-plugin/feature-typings-generator-declaration-maps_2026-07-28-20-47-38.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Add opt-in declaration source map generation so editors can resolve go-to-definition from generated typings to the original source file.", + "type": "minor", + "packageName": "@rushstack/heft-localization-typings-plugin" + } + ], + "packageName": "@rushstack/heft-localization-typings-plugin", + "email": "mdelgaudio_microsoft@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/localization-utilities/feature-typings-generator-declaration-maps_2026-07-28-20-47-38.json b/common/changes/@rushstack/localization-utilities/feature-typings-generator-declaration-maps_2026-07-28-20-47-38.json new file mode 100644 index 00000000000..12d18c25703 --- /dev/null +++ b/common/changes/@rushstack/localization-utilities/feature-typings-generator-declaration-maps_2026-07-28-20-47-38.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Add opt-in declaration source map generation so editors can resolve go-to-definition from generated typings to the original source file.", + "type": "minor", + "packageName": "@rushstack/localization-utilities" + } + ], + "packageName": "@rushstack/localization-utilities", + "email": "mdelgaudio_microsoft@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/typings-generator/feature-typings-generator-declaration-maps_2026-07-28-20-47-38.json b/common/changes/@rushstack/typings-generator/feature-typings-generator-declaration-maps_2026-07-28-20-47-38.json new file mode 100644 index 00000000000..a851e04f7a4 --- /dev/null +++ b/common/changes/@rushstack/typings-generator/feature-typings-generator-declaration-maps_2026-07-28-20-47-38.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Add opt-in declaration source map generation so editors can resolve go-to-definition from generated typings to the original source file.", + "type": "minor", + "packageName": "@rushstack/typings-generator" + } + ], + "packageName": "@rushstack/typings-generator", + "email": "mdelgaudio_microsoft@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/reviews/api/localization-utilities.api.md b/common/reviews/api/localization-utilities.api.md index 762259c0837..535f11d6373 100644 --- a/common/reviews/api/localization-utilities.api.md +++ b/common/reviews/api/localization-utilities.api.md @@ -5,6 +5,7 @@ ```ts import { IExportAsDefaultOptions } from '@rushstack/typings-generator'; +import type { ISourcePosition } from '@rushstack/typings-generator'; import type { ITerminal } from '@rushstack/terminal'; import { ITypingsGeneratorBaseOptions } from '@rushstack/typings-generator'; import { NewlineKind } from '@rushstack/node-core-library'; @@ -31,6 +32,7 @@ export interface ILocalizationFile { export interface ILocalizedString { // (undocumented) comment?: string; + sourcePosition?: ISourcePosition; // (undocumented) value: string; } diff --git a/common/reviews/api/typings-generator.api.md b/common/reviews/api/typings-generator.api.md index a942f38b474..ca5acb0922a 100644 --- a/common/reviews/api/typings-generator.api.md +++ b/common/reviews/api/typings-generator.api.md @@ -6,6 +6,13 @@ import { ITerminal } from '@rushstack/terminal'; +// @public +export interface IDeclarationMapping { + generatedColumn: number; + generatedLine: number; + sourcePosition: ISourcePosition; +} + // @public (undocumented) export interface IExportAsDefaultOptions { // @deprecated (undocumented) @@ -15,6 +22,20 @@ export interface IExportAsDefaultOptions { valueDocumentationComment?: string; } +// @public +export interface IGeneratedTypings { + declarationMappings?: readonly IDeclarationMapping[]; + typingsData: string; +} + +// @public +export interface ISourcePosition { + // (undocumented) + column: number; + // (undocumented) + line: number; +} + // @public (undocumented) export interface IStringValuesTypingsGeneratorBaseOptions { exportAsDefault?: boolean | IExportAsDefaultOptions; @@ -36,6 +57,7 @@ export interface IStringValueTyping { comment?: string; // (undocumented) exportName: string; + sourcePosition?: ISourcePosition; } // @public (undocumented) @@ -47,6 +69,7 @@ export interface IStringValueTypings { // @public (undocumented) export interface ITypingsGeneratorBaseOptions { + generateDeclarationMaps?: boolean; // (undocumented) generatedTsFolder: string; // (undocumented) @@ -84,6 +107,9 @@ export interface ITypingsGeneratorOptionsWithoutReadFile = (filePath: string, relativePath: string) => Promise | TFileContents; +// @public +export function serializeDeclarationMap(mappings: readonly IDeclarationMapping[], generatedFileName: string, sourcePath: string, generatedLineOffset: number): string; + // @public export class StringValuesTypingsGenerator extends TypingsGenerator { constructor(options: TFileContents extends string ? IStringValuesTypingsGeneratorOptions : never); @@ -92,15 +118,15 @@ export class StringValuesTypingsGenerator extends Typing // @public export class TypingsGenerator { - constructor(options: TFileContents extends string ? ITypingsGeneratorOptions : never); - constructor(options: ITypingsGeneratorOptionsWithCustomReadFile); + constructor(options: TFileContents extends string ? ITypingsGeneratorOptions : never); + constructor(options: ITypingsGeneratorOptionsWithCustomReadFile); generateTypingsAsync(relativeFilePaths?: string[]): Promise; // (undocumented) getOutputFilePaths(relativePath: string): string[]; readonly ignoredFileGlobs: readonly string[]; readonly inputFileGlob: string; // (undocumented) - protected readonly _options: ITypingsGeneratorOptionsWithCustomReadFile; + protected readonly _options: ITypingsGeneratorOptionsWithCustomReadFile; registerDependency(consumer: string, rawDependency: string): void; // (undocumented) runWatcherAsync(): Promise; diff --git a/heft-plugins/heft-localization-typings-plugin/src/LocalizationTypingsPlugin.ts b/heft-plugins/heft-localization-typings-plugin/src/LocalizationTypingsPlugin.ts index 5d71f8da9a7..c93d7afca5c 100644 --- a/heft-plugins/heft-localization-typings-plugin/src/LocalizationTypingsPlugin.ts +++ b/heft-plugins/heft-localization-typings-plugin/src/LocalizationTypingsPlugin.ts @@ -42,6 +42,15 @@ export interface ILocalizationTypingsPluginOptions { * An array of string names to ignore when generating typings. */ stringNamesToIgnore?: string[]; + + /** + * If true, a `.d.ts.map` file is emitted next to each generated `.d.ts` file. This allows editors + * to resolve "go to definition" on a localized string to its declaration in the source + * localization file, instead of to the generated typings. + * + * @defaultValue false + */ + generateDeclarationMaps?: boolean; } const PLUGIN_NAME: 'localization-typings-plugin' = 'localization-typings-plugin'; diff --git a/heft-plugins/heft-localization-typings-plugin/src/schemas/heft-localization-typings-plugin.schema.json b/heft-plugins/heft-localization-typings-plugin/src/schemas/heft-localization-typings-plugin.schema.json index ae9cf8f52c3..2d8570602cd 100644 --- a/heft-plugins/heft-localization-typings-plugin/src/schemas/heft-localization-typings-plugin.schema.json +++ b/heft-plugins/heft-localization-typings-plugin/src/schemas/heft-localization-typings-plugin.schema.json @@ -80,6 +80,11 @@ "items": { "type": "string" } + }, + + "generateDeclarationMaps": { + "type": "boolean", + "description": "If true, a \".d.ts.map\" file is emitted next to each generated \".d.ts\" file, allowing editors to resolve \"go to definition\" to the source localization file instead of the generated typings." } } } diff --git a/libraries/localization-utilities/src/TypingsGenerator.ts b/libraries/localization-utilities/src/TypingsGenerator.ts index e70bab1aa8b..40e8c72d027 100644 --- a/libraries/localization-utilities/src/TypingsGenerator.ts +++ b/libraries/localization-utilities/src/TypingsGenerator.ts @@ -143,7 +143,8 @@ export class TypingsGenerator extends StringValuesTypingsGenerator { typings.push({ exportName: stringName, - comment + comment, + sourcePosition: value.sourcePosition }); } diff --git a/libraries/localization-utilities/src/interfaces.ts b/libraries/localization-utilities/src/interfaces.ts index 94fecf12a22..4236dcd84b8 100644 --- a/libraries/localization-utilities/src/interfaces.ts +++ b/libraries/localization-utilities/src/interfaces.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import type { ISourcePosition } from '@rushstack/typings-generator'; + /** * Options for the pseudolocale library. * @@ -32,6 +34,13 @@ export interface ILocalizationFile { export interface ILocalizedString { value: string; comment?: string; + + /** + * The zero-based position of this string's declaration in the source file, when the parser is + * able to determine it. This is used to emit declaration source maps so that editors can + * navigate from generated typings back to the string declaration. + */ + sourcePosition?: ISourcePosition; } /** diff --git a/libraries/localization-utilities/src/parsers/parseResx.ts b/libraries/localization-utilities/src/parsers/parseResx.ts index d7f72d4a012..0ac353c4901 100644 --- a/libraries/localization-utilities/src/parsers/parseResx.ts +++ b/libraries/localization-utilities/src/parsers/parseResx.ts @@ -204,7 +204,10 @@ function _readDataElement( return { value: value || '', - comment + comment, + // xmldoc's `column` refers to the end of the element's open tag rather than its start, so the + // beginning of the line is used instead. That reliably lands an editor on the element. + sourcePosition: { line: dataElement.line, column: 0 } }; } } diff --git a/libraries/localization-utilities/src/parsers/test/__snapshots__/parseResx.test.ts.snap b/libraries/localization-utilities/src/parsers/test/__snapshots__/parseResx.test.ts.snap index a2d7d505728..96065d30ee9 100644 --- a/libraries/localization-utilities/src/parsers/test/__snapshots__/parseResx.test.ts.snap +++ b/libraries/localization-utilities/src/parsers/test/__snapshots__/parseResx.test.ts.snap @@ -4,6 +4,10 @@ exports[`parseResx correctly ignores a string: Loc file 1`] = ` Object { "foo": Object { "comment": "Foo", + "sourcePosition": Object { + "column": 0, + "line": 57, + }, "value": "foo", }, } @@ -28,6 +32,10 @@ exports[`parseResx fails to parse a RESX file with a duplicate string: Loc file Object { "stringA": Object { "comment": undefined, + "sourcePosition": Object { + "column": 0, + "line": 5, + }, "value": "Another string", }, } @@ -43,6 +51,10 @@ exports[`parseResx ignoreMissingResxComments when set to false, warns on a missi Object { "stringWithoutAComment": Object { "comment": undefined, + "sourcePosition": Object { + "column": 0, + "line": 2, + }, "value": "String without a comment", }, } @@ -58,6 +70,10 @@ exports[`parseResx ignoreMissingResxComments when set to true, ignores a missing Object { "stringWithoutAComment": Object { "comment": undefined, + "sourcePosition": Object { + "column": 0, + "line": 2, + }, "value": "String without a comment", }, } @@ -69,6 +85,10 @@ exports[`parseResx ignoreMissingResxComments when set to undefined, warns on a m Object { "stringWithoutAComment": Object { "comment": undefined, + "sourcePosition": Object { + "column": 0, + "line": 2, + }, "value": "String without a comment", }, } @@ -80,10 +100,18 @@ exports[`parseResx parses a valid file with a schema: Loc file 1`] = ` Object { "bar": Object { "comment": "Bar", + "sourcePosition": Object { + "column": 0, + "line": 61, + }, "value": "bar", }, "foo": Object { "comment": "Foo", + "sourcePosition": Object { + "column": 0, + "line": 57, + }, "value": "foo", }, } @@ -95,6 +123,10 @@ exports[`parseResx parses a valid file with quotemarks: Loc file 1`] = ` Object { "stringWithQuotes": Object { "comment": "RESX string with quotemarks", + "sourcePosition": Object { + "column": 0, + "line": 2, + }, "value": "\\"RESX string with quotemarks\\"", }, } @@ -106,6 +138,10 @@ exports[`parseResx prints an error on invalid XML: Loc file 1`] = ` Object { "foo": Object { "comment": "Foo", + "sourcePosition": Object { + "column": 0, + "line": 2, + }, "value": "foo", }, } @@ -121,6 +157,10 @@ exports[`parseResx resxNewlineNormalization when set to CrLf, normalizes to CrLf Object { "stringWithTabsAndNewlines": Object { "comment": "RESX string with newlines and tabs", + "sourcePosition": Object { + "column": 0, + "line": 2, + }, "value": " RESX string with newlines and tabs ", @@ -134,6 +174,10 @@ exports[`parseResx resxNewlineNormalization when set to Lf, normalizes to Lf: Lo Object { "stringWithTabsAndNewlines": Object { "comment": "RESX string with newlines and tabs", + "sourcePosition": Object { + "column": 0, + "line": 2, + }, "value": " RESX string with newlines and tabs ", diff --git a/libraries/typings-generator/src/DeclarationMap.ts b/libraries/typings-generator/src/DeclarationMap.ts new file mode 100644 index 00000000000..2d8732df7a6 --- /dev/null +++ b/libraries/typings-generator/src/DeclarationMap.ts @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * A zero-based position within a source file. + * + * @public + */ +export interface ISourcePosition { + line: number; + column: number; +} + +/** + * Associates a position in a generated `.d.ts` file with the position in the source file that + * produced it. + * + * @public + */ +export interface IDeclarationMapping { + /** + * The zero-based line in the generated typings, before the generated file header is prepended. + */ + generatedLine: number; + + /** + * The zero-based column in the generated typings. + */ + generatedColumn: number; + + /** + * The zero-based position in the source file that produced this declaration. + */ + sourcePosition: ISourcePosition; +} + +const BASE64_VLQ_CHARS: string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; +const VLQ_BASE_SHIFT: 5 = 5; +const VLQ_BASE_MASK: 31 = 31; +const VLQ_CONTINUATION_BIT: 32 = 32; + +const SOURCE_MAP_VERSION: 3 = 3; + +interface ISourceMap { + version: 3; + file: string; + sourceRoot: string; + sources: string[]; + names: string[]; + mappings: string; +} + +function encodeVlq(value: number): string { + /* eslint-disable no-bitwise */ + let encoded: string = ''; + let remaining: number = value < 0 ? (-value << 1) | 1 : value << 1; + do { + let digit: number = remaining & VLQ_BASE_MASK; + remaining >>>= VLQ_BASE_SHIFT; + if (remaining > 0) { + digit |= VLQ_CONTINUATION_BIT; + } + + encoded += BASE64_VLQ_CHARS[digit]; + } while (remaining > 0); + + return encoded; + /* eslint-enable no-bitwise */ +} + +/** + * Serializes a declaration source map that points a generated `.d.ts` back at the file that + * produced it. TypeScript's language service follows these maps when resolving "go to definition", + * so an editor navigates to the original source rather than the generated typings. + * + * @param mappings - The positions to map. Generated lines are relative to the typings content + * produced by the parser, before `generatedLineOffset` is applied. + * @param generatedFileName - The file name of the generated typings, used as the map's `file`. + * @param sourcePath - The path of the source file, relative to the folder containing the map. + * @param generatedLineOffset - The number of header lines prepended to the generated typings. + * + * @public + */ +export function serializeDeclarationMap( + mappings: readonly IDeclarationMapping[], + generatedFileName: string, + sourcePath: string, + generatedLineOffset: number +): string { + // The module's own declaration span starts at the beginning of the generated file, so line 0 is + // always mapped to the top of the source file. Without this, navigating to the module itself + // would jump to whichever declaration happens to be emitted first. + const mappingsByLine: Map = new Map(); + const fileStart: IDeclarationMapping = { + generatedLine: 0, + generatedColumn: 0, + sourcePosition: { line: 0, column: 0 } + }; + mappingsByLine.set(0, [fileStart]); + + let maxGeneratedLine: number = 0; + for (const mapping of mappings) { + const generatedLine: number = mapping.generatedLine + generatedLineOffset; + maxGeneratedLine = Math.max(maxGeneratedLine, generatedLine); + let lineMappings: IDeclarationMapping[] | undefined = mappingsByLine.get(generatedLine); + if (!lineMappings) { + lineMappings = []; + mappingsByLine.set(generatedLine, lineMappings); + } + + lineMappings.push({ ...mapping, generatedLine }); + } + + const encodedLines: string[] = []; + let previousSourceLine: number = 0; + let previousSourceColumn: number = 0; + for (let generatedLine: number = 0; generatedLine <= maxGeneratedLine; generatedLine++) { + const lineMappings: IDeclarationMapping[] | undefined = mappingsByLine.get(generatedLine); + if (!lineMappings) { + encodedLines.push(''); + continue; + } + + lineMappings.sort( + (x: IDeclarationMapping, y: IDeclarationMapping) => x.generatedColumn - y.generatedColumn + ); + + let previousGeneratedColumn: number = 0; + const segments: string[] = []; + for (const mapping of lineMappings) { + segments.push( + encodeVlq(mapping.generatedColumn - previousGeneratedColumn) + + encodeVlq(0) + + encodeVlq(mapping.sourcePosition.line - previousSourceLine) + + encodeVlq(mapping.sourcePosition.column - previousSourceColumn) + ); + previousGeneratedColumn = mapping.generatedColumn; + previousSourceLine = mapping.sourcePosition.line; + previousSourceColumn = mapping.sourcePosition.column; + } + + encodedLines.push(segments.join(',')); + } + + const sourceMap: ISourceMap = { + version: SOURCE_MAP_VERSION, + file: generatedFileName, + sourceRoot: '', + sources: [sourcePath], + names: [], + mappings: encodedLines.join(';') + }; + + return JSON.stringify(sourceMap); +} diff --git a/libraries/typings-generator/src/StringValuesTypingsGenerator.ts b/libraries/typings-generator/src/StringValuesTypingsGenerator.ts index 1e05aa9379c..10c89f39147 100644 --- a/libraries/typings-generator/src/StringValuesTypingsGenerator.ts +++ b/libraries/typings-generator/src/StringValuesTypingsGenerator.ts @@ -8,8 +8,10 @@ import { Text } from '@rushstack/node-core-library'; import { type ITypingsGeneratorOptions, TypingsGenerator, - type ITypingsGeneratorOptionsWithCustomReadFile + type ITypingsGeneratorOptionsWithCustomReadFile, + type IGeneratedTypings } from './TypingsGenerator'; +import type { IDeclarationMapping, ISourcePosition } from './DeclarationMap'; /** * @public @@ -17,6 +19,12 @@ import { export interface IStringValueTyping { exportName: string; comment?: string; + + /** + * The zero-based position of this string's declaration in the source file. When provided and + * declaration maps are enabled, "go to definition" resolves to this position. + */ + sourcePosition?: ISourcePosition; } /** @@ -99,7 +107,7 @@ const EXPORT_AS_DEFAULT_INTERFACE_NAME: string = 'IExport'; function convertToTypingsGeneratorOptions( options: IStringValuesTypingsGeneratorOptionsWithCustomReadFile -): ITypingsGeneratorOptionsWithCustomReadFile { +): ITypingsGeneratorOptionsWithCustomReadFile { const { exportAsDefault: exportAsDefaultOptions, exportAsDefaultInterfaceName: exportAsDefaultInterfaceName_deprecated, @@ -130,7 +138,7 @@ function convertToTypingsGeneratorOptions( fileContents: TFileContents, filePath: string, relativePath: string - ): Promise { + ): Promise { const stringValueTypings: IStringValueTypings | undefined = await parseAndGenerateTypings( fileContents, filePath, @@ -175,6 +183,7 @@ function convertToTypingsGeneratorOptions( } const outputLines: string[] = []; + const declarationMappings: IDeclarationMapping[] = []; let indent: string = ''; if (exportAsDefaultInterfaceName) { if (interfaceDocumentationCommentLines) { @@ -191,12 +200,26 @@ function convertToTypingsGeneratorOptions( } for (const stringValueTyping of typings) { - const { exportName, comment } = stringValueTyping; + const { exportName, comment, sourcePosition } = stringValueTyping; if (comment && comment.trim() !== '') { outputLines.push(`${indent}/**`, `${indent} * ${comment.replace(/\*\//g, '*\\/')}`, `${indent} */`); } + // The declaration is emitted next, so its position in the output is known exactly here. This + // avoids having to parse the generated file to discover where each name ended up. + if (sourcePosition) { + const declarationPrefix: string = exportAsDefaultInterfaceName + ? `${indent}'` + : 'export declare const '; + const generatedLine: number = outputLines.length; + const generatedColumn: number = declarationPrefix.length; + declarationMappings.push( + { generatedLine, generatedColumn: 0, sourcePosition }, + { generatedLine, generatedColumn, sourcePosition } + ); + } + if (exportAsDefaultInterfaceName) { outputLines.push(`${indent}'${exportName}': string;`, ''); } else { @@ -223,10 +246,16 @@ function convertToTypingsGeneratorOptions( ); } - return outputLines.join(EOL); + return { + typingsData: outputLines.join(EOL), + declarationMappings + }; } - const convertedOptions: ITypingsGeneratorOptionsWithCustomReadFile = { + const convertedOptions: ITypingsGeneratorOptionsWithCustomReadFile< + string | IGeneratedTypings | undefined, + TFileContents + > = { ...options, parseAndGenerateTypings: parseAndGenerateTypingsOuter }; diff --git a/libraries/typings-generator/src/TypingsGenerator.ts b/libraries/typings-generator/src/TypingsGenerator.ts index f65d4595d8b..7d55c0ce88d 100644 --- a/libraries/typings-generator/src/TypingsGenerator.ts +++ b/libraries/typings-generator/src/TypingsGenerator.ts @@ -10,6 +10,27 @@ import * as chokidar from 'chokidar'; import { type ITerminal, Terminal, ConsoleTerminalProvider } from '@rushstack/terminal'; import { FileSystem, Path, NewlineKind, Async } from '@rushstack/node-core-library'; +import { type IDeclarationMapping, serializeDeclarationMap } from './DeclarationMap'; + +/** + * Typings content produced by a parser, along with the information needed to emit a declaration + * source map for it. + * + * @public + */ +export interface IGeneratedTypings { + /** + * The generated typings content, excluding the generated file header. + */ + typingsData: string; + + /** + * Positions associating declarations in {@link IGeneratedTypings.typingsData} with the source + * file that produced them. When omitted or empty, no declaration map is emitted. + */ + declarationMappings?: readonly IDeclarationMapping[]; +} + /** * @public */ @@ -19,6 +40,15 @@ export interface ITypingsGeneratorBaseOptions { secondaryGeneratedTsFolders?: string[]; globsToIgnore?: string[]; terminal?: ITerminal; + + /** + * If true, a `.d.ts.map` file is emitted next to each generated `.d.ts` file whose parser + * provided declaration positions. This allows editors to resolve "go to definition" to the + * original source file instead of the generated typings. + * + * @defaultValue false + */ + generateDeclarationMaps?: boolean; } /** @@ -82,7 +112,10 @@ export class TypingsGenerator { // Map of resolved file path -> relative file path private readonly _relativePaths: Map; - protected readonly _options: ITypingsGeneratorOptionsWithCustomReadFile; + protected readonly _options: ITypingsGeneratorOptionsWithCustomReadFile< + string | IGeneratedTypings | undefined, + TFileContents + >; protected readonly terminal: ITerminal; @@ -103,11 +136,15 @@ export class TypingsGenerator { public constructor( options: TFileContents extends string - ? ITypingsGeneratorOptions + ? ITypingsGeneratorOptions : never ); - public constructor(options: ITypingsGeneratorOptionsWithCustomReadFile); - public constructor(options: ITypingsGeneratorOptionsWithCustomReadFile) { + public constructor( + options: ITypingsGeneratorOptionsWithCustomReadFile + ); + public constructor( + options: ITypingsGeneratorOptionsWithCustomReadFile + ) { const { readFile = (filePath: string, relativePath: string): Promise => FileSystem.readFileAsync(filePath) as Promise, @@ -327,26 +364,54 @@ export class TypingsGenerator { try { const fileContents: TFileContents = await this._options.readFile(resolvedPath, relativePath); - const typingsData: string | undefined = await this._options.parseAndGenerateTypings( + const result: string | IGeneratedTypings | undefined = await this._options.parseAndGenerateTypings( fileContents, resolvedPath, relativePath ); // Typings data will be undefined when no types should be generated for the parsed file. - if (typingsData === undefined) { + if (result === undefined) { return; } - const prefixedTypingsData: string = [ + const typingsData: string = typeof result === 'string' ? result : result.typingsData; + const declarationMappings: readonly IDeclarationMapping[] | undefined = + typeof result === 'string' ? undefined : result.declarationMappings; + + const headerLines: string[] = [ '// This file was generated by a tool. Modifying it will produce unexpected behavior', - '', - typingsData - ].join(EOL); + '' + ]; + const emitDeclarationMap: boolean = !!( + this._options.generateDeclarationMaps && + declarationMappings && + declarationMappings.length > 0 + ); const generatedTsFilePaths: Iterable = this._getTypingsFilePaths(relativePath); for (const generatedTsFilePath of generatedTsFilePaths) { - await FileSystem.writeFileAsync(generatedTsFilePath, prefixedTypingsData, { + const outputLines: string[] = [...headerLines, typingsData]; + if (emitDeclarationMap) { + const mapFileName: string = `${path.basename(generatedTsFilePath)}.map`; + outputLines.push(`//# sourceMappingURL=${mapFileName}`); + + // The relative path differs per output folder, so the map is serialized for each one. + const sourcePath: string = Path.convertToSlashes( + path.relative(path.dirname(generatedTsFilePath), resolvedPath) + ); + const serializedMap: string = serializeDeclarationMap( + declarationMappings!, + path.basename(generatedTsFilePath), + sourcePath, + headerLines.length + ); + await FileSystem.writeFileAsync(`${generatedTsFilePath}.map`, serializedMap, { + ensureFolderExists: true + }); + } + + await FileSystem.writeFileAsync(generatedTsFilePath, outputLines.join(EOL), { ensureFolderExists: true, convertLineEndings: NewlineKind.OsDefault }); diff --git a/libraries/typings-generator/src/index.ts b/libraries/typings-generator/src/index.ts index 20e2e1dfcfe..296ff728f17 100644 --- a/libraries/typings-generator/src/index.ts +++ b/libraries/typings-generator/src/index.ts @@ -9,8 +9,11 @@ * @packageDocumentation */ +export { type ISourcePosition, type IDeclarationMapping, serializeDeclarationMap } from './DeclarationMap'; + export { type ReadFile, + type IGeneratedTypings, type ITypingsGeneratorBaseOptions, type ITypingsGeneratorOptionsWithoutReadFile, type ITypingsGeneratorOptions, diff --git a/libraries/typings-generator/src/test/DeclarationMap.test.ts b/libraries/typings-generator/src/test/DeclarationMap.test.ts new file mode 100644 index 00000000000..9aa046e6dd7 --- /dev/null +++ b/libraries/typings-generator/src/test/DeclarationMap.test.ts @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; +import type { IStringValuesTypingsGeneratorBaseOptions } from '../StringValuesTypingsGenerator'; + +let inputFs: Record; +let outputFs: Record; + +jest.mock('@rushstack/node-core-library', () => { + const realNcl: typeof import('@rushstack/node-core-library') = jest.requireActual( + '@rushstack/node-core-library' + ); + return { + ...realNcl, + FileSystem: { + readFileAsync: async (filePath: string) => { + const result: string | undefined = inputFs[filePath]; + if (result === undefined) { + const error: NodeJS.ErrnoException = new Error( + `Cannot read file ${filePath}` + ) as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + } else { + return result; + } + }, + writeFileAsync: async (filePath: string, contents: string) => { + outputFs[filePath] = contents; + } + } + }; +}); + +interface IDecodedSegment { + generatedLine: number; + generatedColumn: number; + sourceLine: number; + sourceColumn: number; +} + +const BASE64_VLQ_CHARS: string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +function decodeMappings(mappings: string): IDecodedSegment[] { + const decoded: IDecodedSegment[] = []; + let sourceLine: number = 0; + let sourceColumn: number = 0; + + mappings.split(';').forEach((lineMappings: string, generatedLine: number) => { + if (!lineMappings) { + return; + } + + let generatedColumn: number = 0; + for (const segment of lineMappings.split(',')) { + const values: number[] = []; + let shift: number = 0; + let value: number = 0; + for (const character of segment) { + const digit: number = BASE64_VLQ_CHARS.indexOf(character); + /* eslint-disable no-bitwise */ + const hasContinuation: boolean = (digit & 32) !== 0; + value += (digit & 31) * Math.pow(2, shift); + if (hasContinuation) { + shift += 5; + } else { + const negative: boolean = (value & 1) === 1; + value = Math.floor(value / 2); + values.push(negative ? -value : value); + shift = 0; + value = 0; + } + /* eslint-enable no-bitwise */ + } + + generatedColumn += values[0]; + sourceLine += values[2]; + sourceColumn += values[3]; + decoded.push({ generatedLine, generatedColumn, sourceLine, sourceColumn }); + } + }); + + return decoded; +} + +async function generateAsync( + baseOptions: IStringValuesTypingsGeneratorBaseOptions & { generateDeclarationMaps?: boolean }, + withSourcePositions: boolean, + secondaryGeneratedTsFolders?: string[] +): Promise { + const [{ StringValuesTypingsGenerator }, { Terminal, StringBufferTerminalProvider }] = await Promise.all([ + import('../StringValuesTypingsGenerator'), + import('@rushstack/terminal') + ]); + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(); + const terminal: Terminal = new Terminal(terminalProvider); + + inputFs['/src/test.ext'] = ''; + + const generator = new StringValuesTypingsGenerator({ + srcFolder: '/src', + generatedTsFolder: '/out', + secondaryGeneratedTsFolders, + readFile: () => Promise.resolve(''), + fileExtensions: ['.ext'], + parseAndGenerateTypings: () => ({ + typings: [ + { + exportName: 'first', + comment: 'first comment', + sourcePosition: withSourcePositions ? { line: 10, column: 0 } : undefined + }, + { + exportName: 'second', + sourcePosition: withSourcePositions ? { line: 20, column: 0 } : undefined + } + ] + }), + terminal, + ...baseOptions + }); + + await generator.generateTypingsAsync(['test.ext']); + expect(terminalProvider.getAllOutput(true)).toEqual({}); +} + +describe('StringValuesTypingsGenerator declaration maps', () => { + beforeEach(() => { + inputFs = {}; + outputFs = {}; + }); + + it('does not emit a map when generateDeclarationMaps is not enabled', async () => { + await generateAsync({}, true); + + expect(Object.keys(outputFs)).toEqual(['/out/test.ext.d.ts']); + expect(outputFs['/out/test.ext.d.ts']).not.toContain('sourceMappingURL'); + }); + + it('does not emit a map when the parser provides no source positions', async () => { + await generateAsync({ generateDeclarationMaps: true }, false); + + expect(Object.keys(outputFs)).toEqual(['/out/test.ext.d.ts']); + expect(outputFs['/out/test.ext.d.ts']).not.toContain('sourceMappingURL'); + }); + + it('emits a map and a sourceMappingURL comment when enabled', async () => { + await generateAsync({ generateDeclarationMaps: true }, true); + + expect(Object.keys(outputFs).sort()).toEqual(['/out/test.ext.d.ts', '/out/test.ext.d.ts.map']); + expect(outputFs['/out/test.ext.d.ts']).toContain('//# sourceMappingURL=test.ext.d.ts.map'); + + const map: { version: number; file: string; sources: string[] } = JSON.parse( + outputFs['/out/test.ext.d.ts.map'] + ); + expect(map.version).toBe(3); + expect(map.file).toBe('test.ext.d.ts'); + expect(map.sources).toEqual(['../src/test.ext']); + }); + + it('maps each declaration to its source position, accounting for the generated header', async () => { + await generateAsync({ generateDeclarationMaps: true }, true); + + const contents: string = outputFs['/out/test.ext.d.ts']; + const lines: string[] = contents.split(/\r?\n/); + const firstLine: number = lines.findIndex((line: string) => line.includes('const first')); + const secondLine: number = lines.findIndex((line: string) => line.includes('const second')); + expect(firstLine).toBeGreaterThan(0); + + const segments: IDecodedSegment[] = decodeMappings( + (JSON.parse(outputFs['/out/test.ext.d.ts.map']) as { mappings: string }).mappings + ); + + // The declaration positions must line up with where the names actually landed in the output, + // including the two-line header the generator prepends. + expect(segments.find((s: IDecodedSegment) => s.generatedLine === firstLine)?.sourceLine).toBe(10); + expect(segments.find((s: IDecodedSegment) => s.generatedLine === secondLine)?.sourceLine).toBe(20); + }); + + it('maps the start of the file to the top of the source', async () => { + await generateAsync({ generateDeclarationMaps: true }, true); + + const segments: IDecodedSegment[] = decodeMappings( + (JSON.parse(outputFs['/out/test.ext.d.ts.map']) as { mappings: string }).mappings + ); + + const fileStart: IDecodedSegment | undefined = segments.find( + (s: IDecodedSegment) => s.generatedLine === 0 && s.generatedColumn === 0 + ); + expect(fileStart).toBeDefined(); + expect(fileStart!.sourceLine).toBe(0); + expect(fileStart!.sourceColumn).toBe(0); + }); + + it('emits a correctly rooted map for each secondary output folder', async () => { + await generateAsync({ generateDeclarationMaps: true }, true, ['/nested/secondary']); + + expect(Object.keys(outputFs).sort()).toEqual([ + '/nested/secondary/test.ext.d.ts', + '/nested/secondary/test.ext.d.ts.map', + '/out/test.ext.d.ts', + '/out/test.ext.d.ts.map' + ]); + + // The relative path back to the source differs per output folder. + const primary: { sources: string[] } = JSON.parse(outputFs['/out/test.ext.d.ts.map']); + const secondary: { sources: string[] } = JSON.parse(outputFs['/nested/secondary/test.ext.d.ts.map']); + expect(primary.sources).toEqual(['../src/test.ext']); + expect(secondary.sources).toEqual(['../../src/test.ext']); + }); + + it('works when typings are wrapped in a default export', async () => { + await generateAsync({ generateDeclarationMaps: true, exportAsDefault: true }, true); + + const contents: string = outputFs['/out/test.ext.d.ts']; + const lines: string[] = contents.split(/\r?\n/); + const firstLine: number = lines.findIndex((line: string) => line.includes("'first'")); + + const segments: IDecodedSegment[] = decodeMappings( + (JSON.parse(outputFs['/out/test.ext.d.ts.map']) as { mappings: string }).mappings + ); + expect(segments.find((s: IDecodedSegment) => s.generatedLine === firstLine)?.sourceLine).toBe(10); + }); +});