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 0000000000..a6f11b3a08 --- /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 0000000000..20b4edccfe --- /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 \"originalPositionFor\" so that generators which compile their input can translate a position in the compiled output 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 4ec1b48ee8..2cd8e13d29 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -3415,9 +3415,15 @@ importers: ../../../heft-plugins/heft-sass-plugin: dependencies: + '@jridgewell/sourcemap-codec': + specifier: ~1.5.5 + version: 1.5.5 '@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 diff --git a/common/reviews/api/typings-generator.api.md b/common/reviews/api/typings-generator.api.md index ca5acb0922..d52afd63d6 100644 --- a/common/reviews/api/typings-generator.api.md +++ b/common/reviews/api/typings-generator.api.md @@ -5,11 +5,13 @@ ```ts import { ITerminal } from '@rushstack/terminal'; +import { SourceMapSegment } from '@jridgewell/sourcemap-codec'; // @public export interface IDeclarationMapping { generatedColumn: number; generatedLine: number; + sourceIndex?: number; sourcePosition: ISourcePosition; } @@ -104,11 +106,18 @@ export interface ITypingsGeneratorOptionsWithoutReadFile TTypingsResult | Promise; } +// @public +export function originalPositionFor(decoded: readonly SourceMapSegment[][], 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 09b3b76d1e..4164603654 100644 --- a/heft-plugins/heft-sass-plugin/package.json +++ b/heft-plugins/heft-sass-plugin/package.json @@ -46,7 +46,9 @@ "@rushstack/heft": "^1.2.22" }, "dependencies": { + "@jridgewell/sourcemap-codec": "~1.5.5", "@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 0000000000..7a363e15d2 --- /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 { decode, type SourceMapSegment } from '@jridgewell/sourcemap-codec'; + +import { originalPositionFor, 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 each class in a selector, capturing its name. + * + * Every class is captured, including each one in a compound selector such as `.primary.secondary` + * and a class qualified by an element such as `div.only`, because CSS Modules exports all of them + * and each therefore needs a mapping. The negative lookbehind skips an escaped dot so that a + * literal `.` inside a name is not treated as the start of another class. + */ +const CLASS_SELECTOR_REGEXP: RegExp = /(? = 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: SourceMapSegment[][] = decode(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/SassPlugin.ts b/heft-plugins/heft-sass-plugin/src/SassPlugin.ts index 32d1ef5a74..1429988462 100644 --- a/heft-plugins/heft-sass-plugin/src/SassPlugin.ts +++ b/heft-plugins/heft-sass-plugin/src/SassPlugin.ts @@ -32,6 +32,7 @@ export interface ISassConfigurationJson { doNotTrimOriginalFileExtension?: boolean; preserveIcssExports?: boolean; sourceMap?: boolean; + generateDeclarationMaps?: boolean; } const SASS_CONFIGURATION_LOCATION: string = 'config/sass.json'; @@ -102,7 +103,8 @@ export default class SassPlugin implements IHeftPlugin { excludeFiles, doNotTrimOriginalFileExtension, preserveIcssExports, - sourceMap + sourceMap, + generateDeclarationMaps } = sassConfigurationJson || {}; function resolveFolder(folder: string): string { @@ -132,6 +134,7 @@ export default class SassPlugin implements IHeftPlugin { doNotTrimOriginalFileExtension, preserveIcssExports, sourceMap, + generateDeclarationMaps, postProcessCssAsync: hooks.postProcessCss.isUsed() ? async (cssText: string) => hooks.postProcessCss.promise(cssText) : undefined diff --git a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts index 76a22befe6..5e07e8b8cf 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,80 @@ 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) ); + + // Index 0 must be the stylesheet being compiled: serializeDeclarationMap maps generated + // line 0 to source 0, so that entry determines where navigating to the module itself lands. + // Populating in declaration order would otherwise make an imported partial the primary + // source whenever it happens to declare the first class. + const sourceIndexByPath: Map = new Map([[sourceFilePath, 0]]); + declarationMapSources.push(sourceFilePath); + + 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.convertToSlashes(path.relative(dtsFolder, absoluteSourcePath)) + ); + + await FileSystem.writeFileAsync( + `${dtsFilePath}.map`, + serializeDeclarationMap( + declarationMappings, + `${path.basename(relativeFilePath)}.d.ts`, + relativeSources, + 0 + ), + writeFileOptions + ); + } } if (cssOutputFolders && cssOutputFolders.length > 0) { @@ -899,13 +998,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 +1024,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 +1039,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 91ca269f56..e7cec388a2 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 71d163bff9..780327d478 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 4faa802770..9a45d308c8 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,150 @@ 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('maps every class in a compound or element-qualified selector', async () => { + const { processor } = createProcessor(terminalProvider, { + generateDeclarationMaps: true, + exportAsDefault: false + }); + await compileFixtureAsync(processor, 'compound-selectors.module.scss'); + + const decoded: IDecodedMapping[] = decodeDeclarationMap( + getWrittenFile('compound-selectors.module.scss.d.ts.map'), + getDtsOutput('compound-selectors.module.scss') + ); + const byName: Map = new Map(decoded.map((m) => [m.name, m])); + + // Both classes of `.primary.secondary` are exported, so both need a mapping. + expect(byName.get('primary')?.sourceLine).toBe(2); + expect(byName.get('secondary')?.sourceLine).toBe(2); + // A class qualified by an element is still the subject of the rule. + expect(byName.get('qualified')?.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); + + // Generated line 0 is always mapped to source index 0, so the entry stylesheet must occupy + // that slot even though a partial declares the first class. Otherwise navigating to the + // module itself would land in the partial. + const map: { sources: string[] } = JSON.parse(getWrittenFile('partial-class.module.scss.d.ts.map')); + expect(map.sources[0]).toMatch(/partial-class\.module\.scss$/); + }); + }); }); 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 d2fab19a07..be3a22e8de 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,82 @@ 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 maps every class in a compound or element-qualified selector: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor declaration maps maps every class in a compound or element-qualified selector: written-files 1`] = ` +Map { + "/fake/output/dts/compound-selectors.module.scss.d.ts" => "export const primary: string; +export const secondary: string; +export const qualified: string;", + "/fake/output/dts/compound-selectors.module.scss.d.ts.map" => "{\\"version\\":3,\\"file\\":\\"compound-selectors.module.scss.d.ts\\",\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/compound-selectors.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AAAA,aAEA;aAAA;aAIA\\"}", +} +`; + +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-class.module.scss\\",\\"fixtures/_partial-with-class.scss\\"],\\"names\\":[],\\"mappings\\":\\"AAAA,aCEA;aDEA\\"}", +} +`; + +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 0000000000..bb78067017 --- /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/compound-selectors.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/compound-selectors.module.scss new file mode 100644 index 0000000000..48dbe64974 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/compound-selectors.module.scss @@ -0,0 +1,9 @@ +// Classes that only ever appear in a compound selector or qualified by an element. CSS Modules +// exports all of them, so each needs a mapping in the declaration map. +.primary.secondary { + color: red; +} + +div.qualified { + color: blue; +} 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 0000000000..cf24dc032c --- /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 cc543c8dd4..91c547ff08 100644 --- a/libraries/typings-generator/src/DeclarationMap.ts +++ b/libraries/typings-generator/src/DeclarationMap.ts @@ -34,6 +34,14 @@ 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; } const SOURCE_MAP_VERSION: 3 = 3; @@ -55,7 +63,9 @@ interface ISourceMap { * @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 @@ -63,7 +73,7 @@ interface ISourceMap { 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 @@ -80,7 +90,7 @@ export function serializeDeclarationMap( // The codec takes absolute positions and performs the relative encoding itself. segmentsByLine[generatedLine].push([ mapping.generatedColumn, - 0, + mapping.sourceIndex ?? 0, mapping.sourcePosition.line, mapping.sourcePosition.column ]); @@ -94,10 +104,61 @@ export function serializeDeclarationMap( version: SOURCE_MAP_VERSION, file: generatedFileName, sourceRoot: '', - sources: [sourcePath], + sources: typeof sources === 'string' ? [sources] : [...sources], names: [], mappings: encode(segmentsByLine) }; return JSON.stringify(sourceMap); } + +type IMappedSegment = [number, number, number, number] | [number, number, number, number, number]; + +/** A segment with only a generated column marks output that has no counterpart in any source. */ +function isMappedSegment(segment: SourceMapSegment): segment is IMappedSegment { + return segment.length >= 4; +} + +/** + * Looks up the original position for a position in generated output, given source map mappings that + * have been decoded with `@jridgewell/sourcemap-codec`. Returns `undefined` when the line has no + * mapping. + * + * A generator that compiles its input before producing typings - for example Sass - needs this to + * translate a position in the compiled output back to the file the developer wrote. + * + * @public + */ +export function originalPositionFor( + decoded: readonly SourceMapSegment[][], + line: number, + column: number +): { sourceIndex: number; line: number; column: number } | undefined { + const segments: readonly SourceMapSegment[] | undefined = decoded[line]; + if (!segments || segments.length === 0) { + return undefined; + } + + let best: IMappedSegment | undefined; + for (const segment of segments) { + if (segment[0] > 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[1], line: best[2], column: best[3] }; +} diff --git a/libraries/typings-generator/src/index.ts b/libraries/typings-generator/src/index.ts index 296ff728f1..fe3b6acd47 100644 --- a/libraries/typings-generator/src/index.ts +++ b/libraries/typings-generator/src/index.ts @@ -9,7 +9,12 @@ * @packageDocumentation */ -export { type ISourcePosition, type IDeclarationMapping, serializeDeclarationMap } from './DeclarationMap'; +export { + type ISourcePosition, + type IDeclarationMapping, + serializeDeclarationMap, + originalPositionFor +} from './DeclarationMap'; export { type ReadFile,