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); + }); +});