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