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/heft-sass-plugin/feature-heft-sass-plugin-declaration-maps_2026-07-29-20-30-00.json b/common/changes/@rushstack/heft-sass-plugin/feature-heft-sass-plugin-declaration-maps_2026-07-29-20-30-00.json new file mode 100644 index 00000000000..a6f11b3a082 --- /dev/null +++ b/common/changes/@rushstack/heft-sass-plugin/feature-heft-sass-plugin-declaration-maps_2026-07-29-20-30-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft-sass-plugin", + "comment": "Add an opt-in \"generateDeclarationMaps\" option that emits a \".d.ts.map\" beside each generated typings file, so that \"go to definition\" on a CSS module class resolves to the rule in the stylesheet instead of the generated typings.", + "type": "minor" + } + ], + "packageName": "@rushstack/heft-sass-plugin" +} 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-declaration-map-decoding_2026-07-29-20-30-00.json b/common/changes/@rushstack/typings-generator/feature-declaration-map-decoding_2026-07-29-20-30-00.json new file mode 100644 index 00000000000..5a1a90a456f --- /dev/null +++ b/common/changes/@rushstack/typings-generator/feature-declaration-map-decoding_2026-07-29-20-30-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/typings-generator", + "comment": "Support multiple sources in \"serializeDeclarationMap\", and add \"decodeMappings\" and \"originalPositionFor\" so that generators which compile their input can translate positions back to the original file.", + "type": "minor" + } + ], + "packageName": "@rushstack/typings-generator" +} 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/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index a6e518999de..189aeda74ed 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -3402,6 +3402,9 @@ importers: '@rushstack/node-core-library': specifier: workspace:* version: link:../../libraries/node-core-library + '@rushstack/typings-generator': + specifier: workspace:* + version: link:../../libraries/typings-generator '@types/tapable': specifier: 1.0.6 version: 1.0.6 @@ -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..fc864799cb1 100644 --- a/common/reviews/api/typings-generator.api.md +++ b/common/reviews/api/typings-generator.api.md @@ -6,6 +6,29 @@ import { ITerminal } from '@rushstack/terminal'; +// @public +export function decodeMappings(mappings: string): IDecodedSegment[][]; + +// @public +export interface IDeclarationMapping { + generatedColumn: number; + generatedLine: number; + sourceIndex?: number; + sourcePosition: ISourcePosition; +} + +// @public +export interface IDecodedSegment { + // (undocumented) + generatedColumn: number; + // (undocumented) + sourceColumn?: number; + // (undocumented) + sourceIndex?: number; + // (undocumented) + sourceLine?: number; +} + // @public (undocumented) export interface IExportAsDefaultOptions { // @deprecated (undocumented) @@ -15,6 +38,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 +73,7 @@ export interface IStringValueTyping { comment?: string; // (undocumented) exportName: string; + sourcePosition?: ISourcePosition; } // @public (undocumented) @@ -47,6 +85,7 @@ export interface IStringValueTypings { // @public (undocumented) export interface ITypingsGeneratorBaseOptions { + generateDeclarationMaps?: boolean; // (undocumented) generatedTsFolder: string; // (undocumented) @@ -81,9 +120,19 @@ export interface ITypingsGeneratorOptionsWithoutReadFile TTypingsResult | Promise; } +// @public +export function originalPositionFor(decoded: readonly IDecodedSegment[][], line: number, column: number): { + sourceIndex: number; + line: number; + column: number; +} | undefined; + // @public (undocumented) export type ReadFile = (filePath: string, relativePath: string) => Promise | TFileContents; +// @public +export function serializeDeclarationMap(mappings: readonly IDeclarationMapping[], generatedFileName: string, sources: string | readonly string[], generatedLineOffset: number): string; + // @public export class StringValuesTypingsGenerator extends TypingsGenerator { constructor(options: TFileContents extends string ? IStringValuesTypingsGeneratorOptions : never); @@ -92,15 +141,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/heft-plugins/heft-sass-plugin/package.json b/heft-plugins/heft-sass-plugin/package.json index 09b3b76d1e2..ddf24392457 100644 --- a/heft-plugins/heft-sass-plugin/package.json +++ b/heft-plugins/heft-sass-plugin/package.json @@ -47,6 +47,7 @@ }, "dependencies": { "@rushstack/node-core-library": "workspace:*", + "@rushstack/typings-generator": "workspace:*", "@types/tapable": "1.0.6", "postcss": "~8.5.10", "postcss-modules": "~6.0.0", diff --git a/heft-plugins/heft-sass-plugin/src/SassDeclarationMaps.ts b/heft-plugins/heft-sass-plugin/src/SassDeclarationMaps.ts new file mode 100644 index 00000000000..2b1aff5c7a7 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/SassDeclarationMaps.ts @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { Plugin as PostcssPlugin, Rule } from 'postcss'; + +import { + decodeMappings, + originalPositionFor, + type IDecodedSegment, + type ISourcePosition +} from '@rushstack/typings-generator'; + +/** + * The location of a class declaration in the original stylesheet. The class may be declared in an + * imported partial rather than the entry file, so the file is tracked alongside the position. + * + * @public + */ +export interface IResolvedClassPosition extends ISourcePosition { + /** Absolute path of the stylesheet that declares the class. */ + absoluteSourcePath: string; +} + +/** + * The subset of a raw source map consumed when resolving positions. + * + * @public + */ +export interface IRawSourceMap { + sources: string[]; + mappings: string; + sourceRoot?: string; +} + +/** + * Records where each class selector first appears in the CSS being processed. + * + * @public + */ +export interface IClassPositionRecorder { + /** Must be registered before `postcss-modules`, which rewrites class names. */ + plugin: PostcssPlugin; + positions: Map; +} + +/** + * Matches a class selector, capturing its name. The leading boundary avoids matching `foo` in a + * compound selector such as `.a.foo`, where it is not the subject of the rule. + */ +const CLASS_SELECTOR_REGEXP: RegExp = /(?:^|[\s>+~])\.([A-Za-z_-][A-Za-z0-9_-]*)/g; + +/** + * Creates a PostCSS plugin that records the position of each class selector in the CSS being + * processed. + * + * Positions are recorded in compiled-CSS order, so the top-level rule for a class is kept rather + * than a later restatement inside a media query or theme block. + * + * @public + */ +export function createClassPositionRecorder(): IClassPositionRecorder { + const positions: Map = new Map(); + + const plugin: PostcssPlugin = { + postcssPlugin: 'rushstack-record-class-positions', + Rule(rule: Rule): void { + const start: { line: number; column: number } | undefined = rule.source?.start; + if (!start) { + return; + } + + // PostCSS positions are one-based. + const position: ISourcePosition = { line: start.line - 1, column: start.column - 1 }; + + for (const selector of rule.selectors) { + CLASS_SELECTOR_REGEXP.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = CLASS_SELECTOR_REGEXP.exec(selector)) !== null) { + if (!positions.has(match[1])) { + positions.set(match[1], position); + } + } + } + } + }; + + return { plugin, positions }; +} + +/** + * Converts a `sources` entry from a Sass source map into an absolute file path. Sass emits `file:` + * URLs by default, but a compilation driven through a custom importer may use another scheme, in + * which case the caller supplies its own resolver. + * + * @public + */ +export function resolveSourceUrl(source: string, baseFolder: string): string { + if (source.startsWith('file:')) { + return fileURLToPath(source); + } + + return path.resolve(baseFolder, source); +} + +/** + * Translates recorded compiled-CSS positions back to the original stylesheets, using the source map + * that Sass produced for the compilation. + * + * Classes whose position cannot be mapped are omitted, leaving navigation for those names + * unchanged. + * + * `resolveSourcePath` converts a `sources` entry from the Sass source map into an absolute file + * path; it defaults to {@link resolveSourceUrl}. + * + * @public + */ +export function resolveStylesheetPositions( + cssPositions: ReadonlyMap, + sassSourceMap: IRawSourceMap, + baseFolder: string, + resolveSourcePath: (source: string, baseFolder: string) => string = resolveSourceUrl +): Map { + const resolved: Map = new Map(); + const decoded: IDecodedSegment[][] = decodeMappings(sassSourceMap.mappings); + const sourceRoot: string = sassSourceMap.sourceRoot ? sassSourceMap.sourceRoot.replace(/\/?$/, '/') : ''; + + const absoluteSources: (string | undefined)[] = sassSourceMap.sources.map((source: string) => { + try { + return resolveSourcePath(`${sourceRoot}${source}`, baseFolder); + } catch { + // An unrecognized source is skipped rather than failing the build. + return undefined; + } + }); + + for (const [className, cssPosition] of cssPositions) { + const original: { sourceIndex: number; line: number; column: number } | undefined = originalPositionFor( + decoded, + cssPosition.line, + cssPosition.column + ); + if (!original) { + continue; + } + + const absoluteSourcePath: string | undefined = absoluteSources[original.sourceIndex]; + if (!absoluteSourcePath) { + continue; + } + + resolved.set(className, { + absoluteSourcePath, + line: original.line, + column: original.column + }); + } + + return resolved; +} diff --git a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts index 76a22befe69..c6e32402fb4 100644 --- a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts +++ b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts @@ -33,6 +33,19 @@ import { RealNodeModulePathResolver, Sort } from '@rushstack/node-core-library'; +import { + serializeDeclarationMap, + type IDeclarationMapping, + type ISourcePosition +} from '@rushstack/typings-generator'; + +import { + createClassPositionRecorder, + resolveSourceUrl, + resolveStylesheetPositions, + type IClassPositionRecorder, + type IResolvedClassPosition +} from './SassDeclarationMaps'; const SIMPLE_IDENTIFIER_REGEX: RegExp = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; @@ -136,6 +149,19 @@ export interface ISassProcessorOptions { */ sourceMap?: boolean; + /** + * If true, a `.d.ts.map` file is emitted next to each generated typings file. This allows editors + * to resolve "go to definition" on a CSS module class to the rule that declares it in the + * stylesheet, instead of stopping at the generated typings. + * + * Enabling this requests a source map from the Sass compiler even when `sourceMap` is false, + * because the declaration map is built by translating positions in the compiled CSS back to the + * original stylesheet. + * + * Defaults to false. + */ + generateDeclarationMaps?: boolean; + /** * A callback to further modify the raw CSS text after it has been generated. Only relevant if emitting CSS files. */ @@ -271,7 +297,10 @@ export class SassProcessor { } ], silenceDeprecations: deprecationsToSilence, - ...(options.sourceMap && { sourceMap: true, sourceMapIncludeSources: true }) + ...((options.sourceMap || options.generateDeclarationMaps) && { + sourceMap: true, + sourceMapIncludeSources: options.sourceMap === true + }) }; } @@ -780,11 +809,13 @@ export class SassProcessor { doNotTrimOriginalFileExtension, postProcessCssAsync, preserveIcssExports, - sourceMap + sourceMap, + generateDeclarationMaps } = this._options; // Handle CSS modules let moduleMap: JsonObject | undefined; + let classPositions: ReadonlyMap | undefined; if (record.isModule) { const postCssModules: postcss.Plugin = cssModules({ getJSON: (cssFileName: string, json: JsonObject) => { @@ -795,8 +826,14 @@ export class SassProcessor { generateScopedName: (name: string) => name }); + // The recorder must run before postcss-modules, which rewrites class names. + const recorder: IClassPositionRecorder | undefined = generateDeclarationMaps + ? createClassPositionRecorder() + : undefined; + classPositions = recorder?.positions; + const postCssResult: postcss.Result = await postcss - .default([postCssModules]) + .default(recorder ? [recorder.plugin, postCssModules] : [postCssModules]) .process(css, { from: sourceFilePath }); if (!preserveIcssExports) { @@ -818,18 +855,74 @@ export class SassProcessor { // default export at runtime, so treat it as a side-effect-only import just like // a non-module file. const hasModuleExports: boolean | undefined = moduleMap && Object.keys(moduleMap).length > 0; - const dtsContent: string = createDTS(moduleMap, exportAsDefault, hasModuleExports); + const declarationPositions: Map | undefined = classPositions + ? new Map() + : undefined; + const dtsContent: string = createDTS(moduleMap, exportAsDefault, hasModuleExports, declarationPositions); const writeFileOptions: IFileSystemWriteFileOptions = { ensureFolderExists: true }; - for (const dtsOutputFolder of dtsOutputFolders) { - await FileSystem.writeFileAsync( - path.resolve(dtsOutputFolder, `${relativeFilePath}.d.ts`), - dtsContent, - writeFileOptions + const declarationMappings: IDeclarationMapping[] = []; + const declarationMapSources: string[] = []; + if (classPositions && declarationPositions && result.sourceMap) { + const sourcePositions: Map = resolveStylesheetPositions( + classPositions, + result.sourceMap, + path.dirname(sourceFilePath), + (source: string, baseFolder: string) => + source.startsWith('heft:') ? heftUrlToPath(source) : resolveSourceUrl(source, baseFolder) ); + + const sourceIndexByPath: Map = new Map(); + for (const [className, generated] of declarationPositions) { + const source: IResolvedClassPosition | undefined = sourcePositions.get(className); + if (!source) { + continue; + } + + let sourceIndex: number | undefined = sourceIndexByPath.get(source.absoluteSourcePath); + if (sourceIndex === undefined) { + sourceIndex = declarationMapSources.length; + sourceIndexByPath.set(source.absoluteSourcePath, sourceIndex); + declarationMapSources.push(source.absoluteSourcePath); + } + + declarationMappings.push({ + generatedLine: generated.line, + generatedColumn: generated.column, + sourcePosition: { line: source.line, column: source.column }, + sourceIndex + }); + } + } + + for (const dtsOutputFolder of dtsOutputFolders) { + const dtsFilePath: string = path.resolve(dtsOutputFolder, `${relativeFilePath}.d.ts`); + await FileSystem.writeFileAsync(dtsFilePath, dtsContent, writeFileOptions); + + // A file whose classes could not be mapped gets no map at all, which leaves navigation for + // that file exactly as it is without this feature. + if (declarationMappings.length > 0) { + const dtsFolder: string = path.dirname(dtsFilePath); + // Source map paths are POSIX-style regardless of platform, and are relative to the folder + // containing the map, so they are recomputed for each output folder. + const relativeSources: string[] = declarationMapSources.map((absoluteSourcePath: string) => + path.relative(dtsFolder, absoluteSourcePath).split(path.sep).join('/') + ); + + await FileSystem.writeFileAsync( + `${dtsFilePath}.map`, + serializeDeclarationMap( + declarationMappings, + `${path.basename(relativeFilePath)}.d.ts`, + relativeSources, + 0 + ), + writeFileOptions + ); + } } if (cssOutputFolders && cssOutputFolders.length > 0) { @@ -899,13 +992,20 @@ export class SassProcessor { function createDTS( moduleMap: JsonObject | undefined, exportAsDefault: boolean, - hasModuleExports: boolean | undefined + hasModuleExports: boolean | undefined, + declarationPositions?: Map +): string; +function createDTS( + moduleMap: JsonObject, + exportAsDefault: boolean, + hasModuleExports: true, + declarationPositions?: Map ): string; -function createDTS(moduleMap: JsonObject, exportAsDefault: boolean, hasModuleExports: true): string; function createDTS( moduleMap: JsonObject | undefined, exportAsDefault: boolean, - hasModuleExports: boolean | undefined + hasModuleExports: boolean | undefined, + declarationPositions?: Map ): string { if (hasModuleExports) { // Create a source file. @@ -918,6 +1018,7 @@ function createDTS( ? className : JSON.stringify(className); // Quote and escape class names as needed. + declarationPositions?.set(className, { line: source.length, column: 2 }); source.push(` ${safeClassName}: string;`); } @@ -932,12 +1033,14 @@ function createDTS( ); } + declarationPositions?.set(className, { line: source.length, column: 'export const '.length }); source.push(`export const ${className}: string;`); } } return source.join('\n'); } else { + declarationPositions?.clear(); return `export {};`; } } diff --git a/heft-plugins/heft-sass-plugin/src/index.ts b/heft-plugins/heft-sass-plugin/src/index.ts index 91ca269f566..e7cec388a2a 100644 --- a/heft-plugins/heft-sass-plugin/src/index.ts +++ b/heft-plugins/heft-sass-plugin/src/index.ts @@ -3,3 +3,11 @@ export { PLUGIN_NAME as SassPluginName } from './constants'; export type { ISassPluginAccessor } from './SassPlugin'; +export { + createClassPositionRecorder, + resolveStylesheetPositions, + resolveSourceUrl, + type IClassPositionRecorder, + type IResolvedClassPosition, + type IRawSourceMap +} from './SassDeclarationMaps'; diff --git a/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json b/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json index 71d163bff9d..780327d478b 100644 --- a/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json +++ b/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json @@ -121,6 +121,11 @@ "sourceMap": { "type": "boolean", "description": "If true, a `.css.map` source map file will be written next to each emitted `.css` file, and a `sourceMappingURL` comment will be appended to the `.css`. Defaults to `false`." + }, + + "generateDeclarationMaps": { + "type": "boolean", + "description": "If true, a `.d.ts.map` file is emitted next to each generated typings file, allowing editors to resolve \"go to definition\" on a CSS module class to the rule that declares it in the stylesheet instead of the generated typings. Defaults to `false`." } } } diff --git a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts index 4faa8027702..6f091600356 100644 --- a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts +++ b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts @@ -29,6 +29,7 @@ type ICreateProcessorOptions = Partial< | 'dtsOutputFolders' | 'exportAsDefault' | 'fileExtensions' + | 'generateDeclarationMaps' | 'nonModuleFileExtensions' | 'postProcessCssAsync' | 'preserveIcssExports' @@ -154,7 +155,7 @@ describe(SassProcessor.name, () => { // Source map contents include the absolute-relative path back to the source file and the // verbatim source file bytes. Both vary by checkout location and OS line endings, which makes // raw snapshots non-portable. Normalize them to stable forms before storing. - if (filePath.endsWith('.css.map')) { + if (filePath.endsWith('.map')) { serialized = normalizeSourceMapForSnapshot(serialized); } writtenFiles.set(filePath, serialized); @@ -826,4 +827,124 @@ describe(SassProcessor.name, () => { expect(css).toMatch(/\/\*# sourceMappingURL=classes-and-exports\.module\.scss\.css\.map \*\//); }); }); + + describe('declaration maps', () => { + interface IDecodedMapping { + generatedLine: number; + name: string; + source: string; + sourceLine: number; + } + + /** + * Decodes a `.d.ts.map` and pairs each mapping with the declaration text on the generated line + * it points at, so assertions can be written in terms of class names rather than offsets. + */ + function decodeDeclarationMap(mapJson: string, dtsContent: string): IDecodedMapping[] { + const map: { sources: string[]; mappings: string } = JSON.parse(mapJson); + const dtsLines: string[] = dtsContent.split('\n'); + const base64: string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + + const results: IDecodedMapping[] = []; + let sourceIndex: number = 0; + let sourceLine: number = 0; + let generatedLine: number = 0; + + for (const lineText of map.mappings.split(';')) { + if (lineText) { + for (const segmentText of lineText.split(',')) { + let index: number = 0; + const readVlq: () => number = () => { + let result: number = 0; + let shift: number = 0; + let isContinuation: boolean = true; + while (isContinuation) { + const digit: number = base64.indexOf(segmentText[index++]); + /* eslint-disable no-bitwise */ + isContinuation = (digit & 32) !== 0; + result += (digit & 31) << shift; + shift += 5; + } + const isNegative: boolean = (result & 1) === 1; + result >>>= 1; + /* eslint-enable no-bitwise */ + return isNegative ? -result : result; + }; + + readVlq(); // generated column + if (index < segmentText.length) { + sourceIndex += readVlq(); + sourceLine += readVlq(); + readVlq(); // source column + const declaration: string = (dtsLines[generatedLine] || '').trim(); + const nameMatch: RegExpMatchArray | null = declaration.match(/([A-Za-z_$][A-Za-z0-9_$]*)\s*:/); + results.push({ + generatedLine, + name: nameMatch ? nameMatch[1] : '', + source: map.sources[sourceIndex], + sourceLine + }); + } + } + } + + generatedLine++; + } + + return results; + } + + it('does not emit a map when the option is disabled', async () => { + const { processor } = createProcessor(terminalProvider); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + + expect(getAllWrittenPathsMatching('.d.ts.map')).toHaveLength(0); + }); + + it('resolves each declaration to the rule that declares it', async () => { + const { processor } = createProcessor(terminalProvider, { + generateDeclarationMaps: true, + exportAsDefault: false + }); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + + const decoded: IDecodedMapping[] = decodeDeclarationMap( + getWrittenFile('classes-and-exports.module.scss.d.ts.map'), + getDtsOutput('classes-and-exports.module.scss') + ); + + // .root is declared on line 2 and .highlighted on line 7 of the fixture (one-based). + const byName: Map = new Map(decoded.map((m) => [m.name, m])); + expect(byName.get('root')?.sourceLine).toBe(1); + expect(byName.get('highlighted')?.sourceLine).toBe(6); + }); + + it('resolves a class declared in a partial into that partial', async () => { + const { processor } = createProcessor(terminalProvider, { + generateDeclarationMaps: true, + exportAsDefault: false + }); + await compileFixtureAsync(processor, 'partial-class.module.scss'); + + const decoded: IDecodedMapping[] = decodeDeclarationMap( + getWrittenFile('partial-class.module.scss.d.ts.map'), + getDtsOutput('partial-class.module.scss') + ); + const byName: Map = new Map(decoded.map((m) => [m.name, m])); + + // The class is declared in _partial-with-class.scss, not in the file that imports it. + expect(byName.get('fromPartial')?.source).toMatch(/_partial-with-class\.scss$/); + expect(byName.get('fromPartial')?.sourceLine).toBe(2); + + // Sources must be plain relative paths. A URL scheme surviving into the map would still end + // with the right file name while being unresolvable by an editor. + for (const mapping of decoded) { + expect(mapping.source).not.toMatch(/^[A-Za-z][A-Za-z0-9+.-]*:/); + } + + // .localClass is restated inside a media query; the primary rule wins. + expect(byName.get('localClass')?.source).toMatch(/partial-class\.module\.scss$/); + expect(byName.get('localClass')?.sourceLine).toBe(4); + }); + }); }); diff --git a/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap b/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap index d2fab19a07f..181e795a8d5 100644 --- a/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap +++ b/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap @@ -366,6 +366,66 @@ export default styles;", } `; +exports[`SassProcessor declaration maps does not emit a map when the option is disabled: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor declaration maps does not emit a map when the option is disabled: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/classes-and-exports.module.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +}", +} +`; + +exports[`SassProcessor declaration maps resolves a class declared in a partial into that partial: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor declaration maps resolves a class declared in a partial into that partial: written-files 1`] = ` +Map { + "/fake/output/dts/partial-class.module.scss.d.ts" => "export const fromPartial: string; +export const localClass: string;", + "/fake/output/dts/partial-class.module.scss.d.ts.map" => "{\\"version\\":3,\\"file\\":\\"partial-class.module.scss.d.ts\\",\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/_partial-with-class.scss\\",\\"fixtures/partial-class.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AAAA,aAEA;aCEA\\"}", +} +`; + +exports[`SassProcessor declaration maps resolves each declaration to the rule that declares it: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor declaration maps resolves each declaration to the rule that declares it: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "export const themeColor: string; +export const spacing: string; +export const root: string; +export const highlighted: string;", + "/fake/output/dts/classes-and-exports.module.scss.d.ts.map" => "{\\"version\\":3,\\"file\\":\\"classes-and-exports.module.scss.d.ts\\",\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/classes-and-exports.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AAAA;;aACA;aAKA\\"}", +} +`; + exports[`SassProcessor doNotTrimOriginalFileExtension preserves the source extension when doNotTrimOriginalFileExtension is true: terminal-output 1`] = ` Array [ "[verbose] Checking for changes to 1 files...[n]", diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/_partial-with-class.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/_partial-with-class.scss new file mode 100644 index 00000000000..bb78067017c --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/_partial-with-class.scss @@ -0,0 +1,5 @@ +// Sass partial that declares a class, used to verify that a declaration map resolves a class +// back into the partial that declares it rather than the file that imports it. +.fromPartial { + color: green; +} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/partial-class.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/partial-class.module.scss new file mode 100644 index 00000000000..cf24dc032cf --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/partial-class.module.scss @@ -0,0 +1,13 @@ +// Imports a partial that declares a class, and restates a class inside a media query so that the +// declaration map can be checked for preferring the primary rule. +@use 'partial-with-class'; + +.localClass { + padding: 4px; +} + +@media (max-width: 600px) { + .localClass { + padding: 0; + } +} 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..a1ca197b012 --- /dev/null +++ b/libraries/typings-generator/src/DeclarationMap.ts @@ -0,0 +1,303 @@ +// 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; + + /** + * The index into the `sources` passed to {@link serializeDeclarationMap}. Defaults to `0`, which + * is correct whenever the typings are produced from a single source file. Generators whose output + * can draw on more than one input - for example Sass, where a class may be declared in an + * imported partial - set this to identify the declaring file. + */ + sourceIndex?: number; +} + +/** + * A segment decoded from the `mappings` field of a source map. A segment with no `sourceIndex` + * marks generated content that has no counterpart in any source. + * + * @public + */ +export interface IDecodedSegment { + generatedColumn: number; + sourceIndex?: number; + sourceLine?: number; + sourceColumn?: number; +} + +interface IMappedSegment extends IDecodedSegment { + sourceIndex: number; + sourceLine: number; + sourceColumn: number; +} + +function isMappedSegment(segment: IDecodedSegment): segment is IMappedSegment { + return segment.sourceIndex !== undefined; +} + +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 sources - The path of the source file, relative to the folder containing the map. An + * array may be supplied when declarations can originate from more than one file, in which case + * each mapping selects one via `sourceIndex`. + * @param generatedLineOffset - The number of header lines prepended to the generated typings. + * + * @public + */ +export function serializeDeclarationMap( + mappings: readonly IDeclarationMapping[], + generatedFileName: string, + sources: string | readonly 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 previousSourceIndex: number = 0; + 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) { + const sourceIndex: number = mapping.sourceIndex ?? 0; + segments.push( + encodeVlq(mapping.generatedColumn - previousGeneratedColumn) + + encodeVlq(sourceIndex - previousSourceIndex) + + encodeVlq(mapping.sourcePosition.line - previousSourceLine) + + encodeVlq(mapping.sourcePosition.column - previousSourceColumn) + ); + previousGeneratedColumn = mapping.generatedColumn; + previousSourceIndex = sourceIndex; + previousSourceLine = mapping.sourcePosition.line; + previousSourceColumn = mapping.sourcePosition.column; + } + + encodedLines.push(segments.join(',')); + } + + const sourceMap: ISourceMap = { + version: SOURCE_MAP_VERSION, + file: generatedFileName, + sourceRoot: '', + sources: typeof sources === 'string' ? [sources] : [...sources], + names: [], + mappings: encodedLines.join(';') + }; + + return JSON.stringify(sourceMap); +} + +function decodeVlq(text: string, state: { index: number }): number { + /* eslint-disable no-bitwise -- Base64 VLQ is defined in terms of bitwise operations. */ + let result: number = 0; + let shift: number = 0; + let isContinuation: boolean = true; + while (isContinuation) { + const digit: number = BASE64_VLQ_CHARS.indexOf(text[state.index++]); + if (digit < 0) { + throw new Error(`Invalid Base64 VLQ character in source map: "${text}"`); + } + + isContinuation = (digit & VLQ_CONTINUATION_BIT) !== 0; + result += (digit & VLQ_BASE_MASK) << shift; + shift += VLQ_BASE_SHIFT; + } + + const isNegative: boolean = (result & 1) === 1; + result >>>= 1; + return isNegative ? -result : result; + /* eslint-enable no-bitwise */ +} + +/** + * Decodes the `mappings` field of a source map into per-generated-line segment lists. + * + * This is the inverse of {@link serializeDeclarationMap}, and is also used to consume source maps + * produced by other tools: a typings generator that compiles its input first - such as Sass - must + * translate a position in the compiled output back to the original file before it can emit a + * declaration map of its own. + * + * @public + */ +export function decodeMappings(mappings: string): IDecodedSegment[][] { + const lines: IDecodedSegment[][] = []; + let sourceIndex: number = 0; + let sourceLine: number = 0; + let sourceColumn: number = 0; + + for (const lineText of mappings.split(';')) { + const segments: IDecodedSegment[] = []; + let generatedColumn: number = 0; + + if (lineText) { + for (const segmentText of lineText.split(',')) { + if (!segmentText) { + continue; + } + + const state: { index: number } = { index: 0 }; + generatedColumn += decodeVlq(segmentText, state); + const segment: IDecodedSegment = { generatedColumn }; + + if (state.index < segmentText.length) { + sourceIndex += decodeVlq(segmentText, state); + sourceLine += decodeVlq(segmentText, state); + sourceColumn += decodeVlq(segmentText, state); + segment.sourceIndex = sourceIndex; + segment.sourceLine = sourceLine; + segment.sourceColumn = sourceColumn; + } + + segments.push(segment); + } + } + + segments.sort((x: IDecodedSegment, y: IDecodedSegment) => x.generatedColumn - y.generatedColumn); + lines.push(segments); + } + + return lines; +} + +/** + * Looks up the original position for a position in generated output, given the decoded mappings of + * the source map that produced it. Returns `undefined` when the line has no mapping. + * + * @public + */ +export function originalPositionFor( + decoded: readonly IDecodedSegment[][], + line: number, + column: number +): { sourceIndex: number; line: number; column: number } | undefined { + const segments: readonly IDecodedSegment[] | undefined = decoded[line]; + if (!segments || segments.length === 0) { + return undefined; + } + + let best: IMappedSegment | undefined; + for (const segment of segments) { + if (segment.generatedColumn > column) { + break; + } + + if (isMappedSegment(segment)) { + best = segment; + } + } + + // The construct may begin before the first mapped column on its line; falling back to the first + // mapped segment still lands on the correct line. + if (!best) { + best = segments.find(isMappedSegment); + } + + if (!best) { + return undefined; + } + + return { sourceIndex: best.sourceIndex, line: best.sourceLine, column: best.sourceColumn }; +} 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..4f8f9d5138d 100644 --- a/libraries/typings-generator/src/index.ts +++ b/libraries/typings-generator/src/index.ts @@ -9,8 +9,18 @@ * @packageDocumentation */ +export { + type ISourcePosition, + type IDeclarationMapping, + type IDecodedSegment, + serializeDeclarationMap, + decodeMappings, + originalPositionFor +} 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); + }); +});