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/config/rush/browser-approved-packages.json b/common/config/rush/browser-approved-packages.json index 1a87efdf7fd..7eac741275b 100644 --- a/common/config/rush/browser-approved-packages.json +++ b/common/config/rush/browser-approved-packages.json @@ -10,6 +10,10 @@ "name": "@fluentui/react-components", "allowedCategories": [ "vscode-extensions" ] }, + { + "name": "@jridgewell/sourcemap-codec", + "allowedCategories": [ "libraries" ] + }, { "name": "@lifaon/path", "allowedCategories": [ "libraries" ] diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index a6e518999de..f986d675fe8 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -4461,6 +4461,9 @@ importers: ../../../libraries/typings-generator: dependencies: + '@jridgewell/sourcemap-codec': + specifier: ~1.5.5 + version: 1.5.5 '@rushstack/node-core-library': specifier: workspace:* version: link:../node-core-library @@ -4583,7 +4586,7 @@ importers: version: 1.2.22(@types/node@20.17.19) '@rushstack/heft-node-rig': specifier: 2.11.45 - version: 2.11.45(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0) + version: 2.11.45(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0) '@types/jest': specifier: 30.0.0 version: 30.0.0 @@ -24642,7 +24645,7 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@rushstack/heft-jest-plugin@2.0.12(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/jest@30.0.0)(@types/node@20.17.19)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)(jest-environment-node@30.3.0)': + '@rushstack/heft-jest-plugin@2.0.12(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/jest@30.0.0)(@types/node@20.17.19)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)(jest-environment-node@30.3.0)': dependencies: '@jest/core': 30.3.0(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)) '@jest/reporters': 30.3.0 @@ -24675,13 +24678,13 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@rushstack/heft-node-rig@2.11.45(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)': + '@rushstack/heft-node-rig@2.11.45(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)': dependencies: '@microsoft/api-extractor': 7.58.12(@types/node@20.17.19) '@rushstack/eslint-config': 4.6.4(eslint@9.37.0)(typescript@5.8.2) '@rushstack/heft': 1.2.22(@types/node@20.17.19) '@rushstack/heft-api-extractor-plugin': 1.3.22(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19) - '@rushstack/heft-jest-plugin': 2.0.12(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/jest@30.0.0)(@types/node@20.17.19)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)(jest-environment-node@30.3.0) + '@rushstack/heft-jest-plugin': 2.0.12(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/jest@30.0.0)(@types/node@20.17.19)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)(jest-environment-node@30.3.0) '@rushstack/heft-lint-plugin': 1.2.22(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19) '@rushstack/heft-typescript-plugin': 1.3.17(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19) '@types/jest': 30.0.0 @@ -30811,7 +30814,7 @@ snapshots: eslint@8.57.1: dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.37.0) '@eslint-community/regexpp': 4.12.2 '@eslint/eslintrc': 2.1.4 '@eslint/js': 8.57.1 diff --git a/common/config/subspaces/default/repo-state.json b/common/config/subspaces/default/repo-state.json index eb492e850ed..d24b50df380 100644 --- a/common/config/subspaces/default/repo-state.json +++ b/common/config/subspaces/default/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "288ee1cccceca305122bc3d4b1eb550efbcb2a0b", + "pnpmShrinkwrapHash": "121b6455d44723fc7bdb38b7d038aae935a28bd1", "preferredVersionsHash": "029c99bd6e65c5e1f25e2848340509811ff9753c" } 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/package.json b/libraries/typings-generator/package.json index 200db51d1f7..f47eed91d08 100644 --- a/libraries/typings-generator/package.json +++ b/libraries/typings-generator/package.json @@ -44,6 +44,7 @@ "_phase:test": "heft run --only test -- --clean" }, "dependencies": { + "@jridgewell/sourcemap-codec": "~1.5.5", "@rushstack/node-core-library": "workspace:*", "@rushstack/terminal": "workspace:*", "chokidar": "~3.6.0", diff --git a/libraries/typings-generator/src/DeclarationMap.ts b/libraries/typings-generator/src/DeclarationMap.ts new file mode 100644 index 00000000000..cc543c8dd45 --- /dev/null +++ b/libraries/typings-generator/src/DeclarationMap.ts @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { encode, type SourceMapSegment } from '@jridgewell/sourcemap-codec'; + +/** + * 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 SOURCE_MAP_VERSION: 3 = 3; + +interface ISourceMap { + version: 3; + file: string; + sourceRoot: string; + sources: string[]; + names: string[]; + mappings: string; +} + +/** + * 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 segmentsByLine: SourceMapSegment[][] = [[[0, 0, 0, 0]]]; + + for (const mapping of mappings) { + const generatedLine: number = mapping.generatedLine + generatedLineOffset; + while (segmentsByLine.length <= generatedLine) { + segmentsByLine.push([]); + } + + // The codec takes absolute positions and performs the relative encoding itself. + segmentsByLine[generatedLine].push([ + mapping.generatedColumn, + 0, + mapping.sourcePosition.line, + mapping.sourcePosition.column + ]); + } + + for (const lineSegments of segmentsByLine) { + lineSegments.sort((x: SourceMapSegment, y: SourceMapSegment) => x[0] - y[0]); + } + + const sourceMap: ISourceMap = { + version: SOURCE_MAP_VERSION, + file: generatedFileName, + sourceRoot: '', + sources: [sourcePath], + names: [], + mappings: encode(segmentsByLine) + }; + + 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..d4cef130524 --- /dev/null +++ b/libraries/typings-generator/src/test/DeclarationMap.test.ts @@ -0,0 +1,215 @@ +// 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; + } + } + }; +}); + +// Imported after jest.mock so that the mocking API precedes regular imports. +import { decode, type SourceMapSegment } from '@jridgewell/sourcemap-codec'; + +interface IDecodedSegment { + generatedLine: number; + generatedColumn: number; + sourceLine: number; + sourceColumn: number; +} + +type IMappedSegment = [number, number, number, number] | [number, number, number, number, number]; + +/** A segment with only a generated column carries no source position. */ +function isMappedSegment(segment: SourceMapSegment): segment is IMappedSegment { + return segment.length >= 4; +} + +/** + * Flattens the codec's decoded output into one entry per mapped segment, so the assertions can be + * written in terms of individual declarations. + */ +function decodeMappings(mappings: string): IDecodedSegment[] { + const decoded: IDecodedSegment[] = []; + decode(mappings).forEach((lineSegments: SourceMapSegment[], generatedLine: number) => { + for (const segment of lineSegments) { + if (!isMappedSegment(segment)) { + continue; + } + + decoded.push({ + generatedLine, + generatedColumn: segment[0], + sourceLine: segment[2], + sourceColumn: segment[3] + }); + } + }); + + 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); + }); +});