diff --git a/.changeset/replace-secondary-mapping-with-custom-source-map.md b/.changeset/replace-secondary-mapping-with-custom-source-map.md new file mode 100644 index 00000000..41cdb710 --- /dev/null +++ b/.changeset/replace-secondary-mapping-with-custom-source-map.md @@ -0,0 +1,6 @@ +--- +'@css-modules-kit/core': patch +'@css-modules-kit/ts-plugin': patch +--- + +refactor(core, ts-plugin): replace secondaryMapping with CustomSourceMap diff --git a/docs/ts-plugin-internals.md b/docs/ts-plugin-internals.md index 31bf838c..9019e99e 100644 --- a/docs/ts-plugin-internals.md +++ b/docs/ts-plugin-internals.md @@ -172,28 +172,15 @@ a.module.css.d.ts: ^ generatedOffsets[0] = 27 (after the opening quote) ``` -Here, the **primary mapping** points to the token name inside the quotes: +The mapping points to the token name inside the quotes; the surrounding quotes are not covered: ``` mapping: { sourceOffsets: [1], generatedOffsets: [27], lengths: [3] } ``` -But there is also a **secondary mapping** that includes the quotes: - -``` -secondaryMapping: { - sourceOffsets: [1], - generatedOffsets: [26], // Points to the opening quote - lengths: [3], - generatedLengths: [5], // 'a_1' = 5 characters (quote + name + quote) -} -``` - -The reason for having two mappings is explained in the [Single-Quote Span Problem](#the-single-quote-span-problem) section. - ### Mapping registration with Volar.js -Both mappings are registered with Volar.js through the `VirtualCode.mappings` array: +The mapping is registered with Volar.js through the `VirtualCode.mappings` array: ```ts // packages/ts-plugin/src/language-plugin.ts @@ -203,16 +190,12 @@ return { snapshot: { /* ... */ }, - mappings: [mapping, secondaryMapping] - .filter((mapping) => mapping !== undefined) - .map((mapping) => ({ ...mapping, data: { navigation: true } })), + mappings: [mapping].map((mapping) => ({ ...mapping, data: { navigation: true } })), linkedCodeMappings: [{ ...linkedCodeMapping, data: undefined }], }; ``` -The `data: { navigation: true }` flag enables navigation features (Go to Definition, Find References, Rename) for these mappings. - -Volar.js searches mappings **in order**. The primary mapping (without quotes) is checked first; if it cannot resolve the position, the secondary mapping (with quotes) is used as fallback. +The `data: { navigation: true }` flag enables navigation features (Go to Definition, Find References, Rename) for this mapping. ## How Volar.js Translates Positions @@ -330,7 +313,7 @@ The issue is that `findReferences` returns the position of `a` (offset 27), whic ### Why overlapping mappings in a single object don't work -Putting both mappings in the same CodeMapping object: +Putting both ranges in the same CodeMapping object: ``` Mapping: { @@ -345,39 +328,41 @@ This doesn't work because Volar.js does not support overlapping ranges within a Reference: https://github.com/volarjs/volar.js/issues/203 -### The solution: separate mapping objects with priority +### The solution: a custom mapper that strips outer quotes on fallback -CSS Modules Kit uses **two separate mapping objects**: +As a rule, CSS Modules Kit registers mappings that **do not include the surrounding quotes**, and instead swaps Volar.js's default mapper for a custom one that retries with outer characters stripped when the direct lookup fails. The custom mapper is installed by overriding `language.mapperFactory`: ```ts -// Primary mapping (checked first): maps the unquoted token name -mapping: { generatedOffsets: [27], lengths: [3], sourceOffsets: [1] } - -// Secondary mapping (fallback): maps the quoted token name -secondaryMapping: { - generatedOffsets: [26], - lengths: [3], - sourceOffsets: [1], - generatedLengths: [5] -} +// packages/ts-plugin/src/index.cts +language.mapperFactory = (mappings) => new CustomSourceMap(mappings); ``` -These are registered as separate entries in `VirtualCode.mappings`: +`CustomSourceMap` extends `@volar/language-core`'s `SourceMap` and overrides only `toSourceRange`: ```ts -mappings: [mapping, secondaryMapping].filter((m) => m !== undefined).map((m) => ({ ...m, data: { navigation: true } })); -``` - -Volar.js searches mappings in array order. For each API: +// packages/ts-plugin/src/source-map.ts +export class CustomSourceMap extends SourceMap { + override *toSourceRange(start, end, fallbackToAnyMatch, filter) { + let matched = false; + for (const result of super.toSourceRange(start, end, fallbackToAnyMatch, filter)) { + matched = true; + yield result; + } + if (matched) return; -- **`findReferences`** (start=27): Primary mapping matches → `translateOffset(27, [27], [1], [3], [3])` → returns `1`. Correct! -- **`getDefinitionAtPosition`** (start=26): Primary mapping doesn't match (26 < 27). Secondary mapping matches → `translateOffset(26, [26], [1], [5], [3])` → returns `1`. Correct! + // The outer characters may be surrounding quotes (e.g. `'a_1'`). + // Retry with them stripped so the inner token name's mapping can match. + if (end - start >= 2) { + yield* super.toSourceRange(start + 1, end - 1, fallbackToAnyMatch, filter); + } + } +} +``` -This approach works because: +For each API: -1. Non-overlapping mappings are checked independently -2. The primary mapping handles `findReferences` and `findRenameLocations` correctly -3. The secondary mapping handles `getDefinitionAtPosition` as a fallback +- **`findReferences`** / **`findRenameLocations`** (start=27, length=3): the inner mapping matches directly → `translateOffset(27, [27], [1], [3], [3])` → returns `1`. Correct! +- **`getDefinitionAtPosition`** (start=26, length=5): the inner mapping doesn't match (26 < 27). The fallback retries with `toSourceRange(27, 29, …)`, which matches → returns `1`. Correct! Reference: https://github.com/mizdra/volar-single-quote-span-problem @@ -415,6 +400,8 @@ linkedCodeMapping: { Note: Despite the names `sourceOffsets` and `generatedOffsets`, both refer to positions within the same generated `.d.ts` file. The names are interchangeable for `linkedCodeMappings`. +**Why the ranges include the surrounding quotes:** When `getDefinitionAtPosition` is invoked on a quoted property name, TypeScript returns a `textSpan` that includes the surrounding single quotes (see [The Single-Quote Span Problem](#the-single-quote-span-problem)). To resolve links between related positions, Volar.js feeds that raw `textSpan.start` into `LinkedCodeMap.getLinkedOffsets` — and this path does **not** go through `mapperFactory`, so the `CustomSourceMap` quote-strip fallback cannot help here. For the linked-code lookup to match, the offsets stored in `linkedCodeMappings` must themselves cover the opening quote. + ### How LinkedCodeMap works The `LinkedCodeMap` class (in `@volar/language-core`) extends `SourceMap` and provides `getLinkedOffsets(start)`: @@ -458,7 +445,7 @@ The proxy in `packages/ts-plugin/src/language-service/proxy.ts` adds CSS-specifi | Method | Enhancement | | ------------------------------------------------ | -------------------------------------------------------------------------------------- | | `getDefinitionAndBoundSpan` | Adds `contextSpan` for Definition Preview (shows the full CSS rule) | -| `findReferences` | Merges duplicate `ReferencedSymbol`s caused by multiple mappings | +| `findReferences` | Merges `ReferencedSymbol`s that share the same definition | | `getCompletionsAtPosition` | Prioritizes `styles` import, converts `className` to JSX format, filters named exports | | `getCompletionEntryDetails` | Converts default imports to namespace imports for CSS Modules | | `getSyntacticDiagnostics` | Reports parse-time diagnostics that do not overlap with the standard CSS LS | @@ -479,64 +466,3 @@ Some features are implemented as custom protocol handlers rather than relying on | `_css-modules-kit:documentLink` | Returns import specifier positions as document links | These handlers do **not** bypass CSS Modules Kit's own proxied Language Service. They call `project.getLanguageService()`, and that service has already been wrapped by both Volar.js and CSS Modules Kit during plugin setup. - -## Volar.js proxyLanguageService.ts / transform.ts: API-specific behavior - -In Volar.js, `transform.ts` contains the low-level translation logic, while `proxyLanguageService.ts` decides how each Language Service API uses it. Key differences: - -### fallbackToAnyMatch parameter - -Different APIs use different strictness for mapping resolution: - -| API | fallbackToAnyMatch | Reason | -| -------------------------- | ------------------ | ------------------------------------------ | -| `getDefinitionAtPosition` | `true` | Cross-file definitions need loose matching | -| `findReferences` | `true` | References may span multiple mappings | -| `findRenameLocations` | `false` | Strict position matching for safety | -| `getCompletionsAtPosition` | `false` | Precise position needed | -| Diagnostics | `true` | Report even with loose mapping | - -### Empty span fallback - -When Volar.js cannot find a valid mapping for a span, some APIs fall back to `{ start: 0, length: 0 }` instead of dropping the result entirely. This happens in `transformDocumentSpan` when `shouldFallback` is true (typically for cross-file definitions). - -### CodeInformation filters - -Each Language Service API uses a different filter function to select which mappings to search: - -- Navigation features (definition, references, rename): truthy `data.navigation` -- Completion: truthy `data.completion` -- Diagnostics: truthy `data.verification` -- Semantic features (hover, inlay hints): truthy `data.semantic` - -CSS Modules Kit registers all mappings with `{ navigation: true }`, enabling them for navigation features only. - -## Summary of data flow - -### Go to Definition (from .ts file) - -``` -1. User triggers "Go to Definition" on `styles.a_1` in index.ts -2. Volar.js receives the request with position in index.ts -3. TypeScript LS returns definition span in generated .d.ts (e.g., { start: 26, length: 5 }) -4. Volar.js translates span back to .module.css using CodeMapping - - Tries primary mapping first (offset 27) → no match for start=26 - - Falls back to secondary mapping (offset 26) → matches → source position 1 -5. Returns { fileName: 'a.module.css', textSpan: { start: 1, length: 3 } } -6. CSS Modules Kit proxy adds contextSpan (full CSS rule range) -7. Editor navigates to the CSS class definition -``` - -### Find All References (from .css file) - -``` -1. User triggers "Find References" on `.a_1` in a.module.css -2. Volar.js translates CSS position to generated .d.ts position -3. TypeScript LS returns references including: - - Definition in .d.ts: { start: 27, length: 3 } - - Usage in index.ts: { start: 44, length: 3 } -4. Volar.js translates .d.ts position back to .module.css - - Primary mapping matches (offset 27) → source position 1 -5. CSS Modules Kit proxy merges duplicate ReferencedSymbols -6. Returns references in both CSS and TypeScript files -``` diff --git a/packages/core/src/dts-generator.ts b/packages/core/src/dts-generator.ts index 9de029c2..c8c6870e 100644 --- a/packages/core/src/dts-generator.ts +++ b/packages/core/src/dts-generator.ts @@ -38,7 +38,6 @@ interface LinkedCodeMapping extends CodeMapping { interface GenerateDtsResult { text: string; mapping: CodeMapping; - secondaryMapping?: CodeMapping; linkedCodeMapping: LinkedCodeMapping; } @@ -271,21 +270,9 @@ function generateDefaultExportDts( ): { text: string; mapping: CodeMapping; - secondaryMapping: CodeMapping; linkedCodeMapping: LinkedCodeMapping; } { const mapping: CodeMapping = { sourceOffsets: [], lengths: [], generatedOffsets: [] }; - /** - * In "Go to Definition", mapping only the inner part of the quotes does not work. - * Therefore, we also generate a mapping that includes the quotes. - * For more details, see https://github.com/mizdra/volar-single-quote-span-problem. - */ - const secondaryMapping: CodeMapping & { generatedLengths: number[] } = { - sourceOffsets: [], - lengths: [], - generatedOffsets: [], - generatedLengths: [], - }; const linkedCodeMapping: LinkedCodeMapping = { sourceOffsets: [], lengths: [], @@ -315,21 +302,19 @@ function generateDefaultExportDts( * The mapping is created as follows: * a.module.css: * 1 | .a_1 { color: red; } - * | ^ mapping.sourceOffsets[0], secondaryMapping.sourceOffsets[0] + * | ^ mapping.sourceOffsets[0] * | * 2 | .a_2 { color: blue; } - * | ^ mapping.sourceOffsets[1], secondaryMapping.sourceOffsets[1] + * | ^ mapping.sourceOffsets[1] * | * * a.module.css.d.ts: * 1 | declare const styles = { * 2 | 'a_1': '' as readonly string, - * | ^^ mapping.generatedOffsets[0] - * | ^ secondaryMapping.generatedOffsets[0] + * | ^ mapping.generatedOffsets[0] * | * 3 | 'a_2': '' as readonly string, - * | ^^ mapping.generatedOffsets[1] - * | ^ secondaryMapping.generatedOffsets[1] + * | ^ mapping.generatedOffsets[1] * | * 4 | }; */ @@ -338,10 +323,6 @@ function generateDefaultExportDts( mapping.sourceOffsets.push(token.loc.start.offset); mapping.lengths.push(token.name.length); mapping.generatedOffsets.push(text.length); - secondaryMapping.sourceOffsets.push(token.loc.start.offset); - secondaryMapping.lengths.push(token.name.length); - secondaryMapping.generatedOffsets.push(text.length - 1); - secondaryMapping.generatedLengths.push(token.name.length + 2); text += `${token.name}': '' as readonly string,\n`; } for (const tokenImporter of tokenImporters) { @@ -380,13 +361,13 @@ function generateDefaultExportDts( * a.module.css: * 1 | @value b_1, b_2 from './b.module.css'; * | ^ ^ ^ mapping.sourceOffsets[1] - * | ^ ^ mapping.sourceOffsets[2], secondaryMapping.sourceOffsets[1] - * | ^ mapping.sourceOffsets[0], secondaryMapping.sourceOffsets[0] + * | ^ ^ mapping.sourceOffsets[2] + * | ^ mapping.sourceOffsets[0] * | * 2 | @value c_1 as aliased_c_1 from './c.module.css'; * | ^ ^ ^ mapping.sourceOffsets[4] - * | ^ ^ mapping.sourceOffsets[3], secondaryMapping.sourceOffsets[2] - * | ^ mapping.sourceOffsets[5], secondaryMapping.sourceOffsets[3] + * | ^ ^ mapping.sourceOffsets[3] + * | ^ mapping.sourceOffsets[5] * | * * a.module.css.d.ts: @@ -395,19 +376,19 @@ function generateDefaultExportDts( * | ^^ ^ ^ linkedCodeMapping.generatedOffsets[0] * | ^^ ^ mapping.generatedOffsets[1] * | ^^ mapping.generatedOffsets[0] - * | ^ secondaryMapping.generatedOffsets[0], linkedCodeMapping.sourceOffsets[0] + * | ^ linkedCodeMapping.sourceOffsets[0] * | * 3 | 'b_2': (await import('./b.module.css')).default['b_2'], * | ^^ ^ linkedCodeMapping.generatedOffsets[1] * | ^^ mapping.generatedOffsets[2] - * | ^ secondaryMapping.generatedOffsets[1], linkedCodeMapping.sourceOffsets[1] + * | ^ linkedCodeMapping.sourceOffsets[1] * | * 4 | 'aliased_c_1': (await import('./c.module.css')).default['c_1'], * | ^^ ^ ^^ mapping.generatedOffsets[5] - * | ^^ ^ ^ secondaryMapping.generatedOffsets[3], linkedCodeMapping.generatedOffsets[2] + * | ^^ ^ ^ linkedCodeMapping.generatedOffsets[2] * | ^^ ^ mapping.generatedOffsets[4] * | ^^ mapping.generatedOffsets[3] - * | ^ secondaryMapping.generatedOffsets[2], linkedCodeMapping.sourceOffsets[2] + * | ^ linkedCodeMapping.sourceOffsets[2] * | * 5 | }; * @@ -423,10 +404,6 @@ function generateDefaultExportDts( mapping.sourceOffsets.push(localLoc.start.offset); mapping.lengths.push(localName.length); mapping.generatedOffsets.push(text.length); - secondaryMapping.sourceOffsets.push(localLoc.start.offset); - secondaryMapping.lengths.push(localName.length); - secondaryMapping.generatedOffsets.push(text.length - 1); - secondaryMapping.generatedLengths.push(localName.length + 2); linkedCodeMapping.sourceOffsets.push(text.length - 1); linkedCodeMapping.lengths.push(localName.length + 2); text += `${localName}': (await import(`; @@ -440,10 +417,6 @@ function generateDefaultExportDts( mapping.sourceOffsets.push(value.loc.start.offset); mapping.lengths.push(value.name.length); mapping.generatedOffsets.push(text.length); - secondaryMapping.sourceOffsets.push(value.loc.start.offset); - secondaryMapping.lengths.push(value.name.length); - secondaryMapping.generatedOffsets.push(text.length - 1); - secondaryMapping.generatedLengths.push(value.name.length + 2); } linkedCodeMapping.generatedOffsets.push(text.length - 1); linkedCodeMapping.generatedLengths.push(value.name.length + 2); @@ -452,7 +425,7 @@ function generateDefaultExportDts( } } text += `};\nexport default ${STYLES_EXPORT_NAME};\n`; - return { text, mapping, linkedCodeMapping, secondaryMapping }; + return { text, mapping, linkedCodeMapping }; } function isValidTokenName(name: string, options: ValidateTokenNameOptions): boolean { diff --git a/packages/ts-plugin/src/index.cts b/packages/ts-plugin/src/index.cts index dfd0a3db..dbfec96e 100644 --- a/packages/ts-plugin/src/index.cts +++ b/packages/ts-plugin/src/index.cts @@ -9,6 +9,7 @@ import { proxyLanguageService } from './language-service/proxy.js'; import { createDocumentLinkHandler } from './protocol-handler/documentLink.js'; import { createRenameHandler } from './protocol-handler/rename.js'; import { createRenameInfoHandler } from './protocol-handler/renameInfo.js'; +import { CustomSourceMap } from './source-map.js'; const projectToLanguage = new WeakMap>(); @@ -67,6 +68,7 @@ const plugin = createLanguageServicePlugin((ts, info) => { return { languagePlugins: [createCSSLanguagePlugin(matchesPattern, config)], setup: (language) => { + language.mapperFactory = (mappings) => new CustomSourceMap(mappings); projectToLanguage.set(info.project, language); info.languageService = proxyLanguageService( language, diff --git a/packages/ts-plugin/src/language-plugin.ts b/packages/ts-plugin/src/language-plugin.ts index f3f918aa..fd5f541f 100644 --- a/packages/ts-plugin/src/language-plugin.ts +++ b/packages/ts-plugin/src/language-plugin.ts @@ -52,7 +52,7 @@ export function createCSSLanguagePlugin( keyframes: config.keyframes, }); // oxlint-disable-next-line prefer-const - let { text, mapping, linkedCodeMapping, secondaryMapping } = generateDts(cssModule, { + let { text, mapping, linkedCodeMapping } = generateDts(cssModule, { ...config, forTsPlugin: true, }); @@ -65,7 +65,7 @@ export function createCSSLanguagePlugin( getChangeRange: () => undefined, }, // `mappings` are required to support navigation features such as "Go to Definition" and "Find all References". - mappings: [mapping, secondaryMapping] + mappings: [mapping] .filter((mapping) => mapping !== undefined) .map((mapping) => ({ ...mapping, data: { navigation: true } })), // `linkedCodeMappings` are required to support navigation features for the imported tokens. diff --git a/packages/ts-plugin/src/source-map.ts b/packages/ts-plugin/src/source-map.ts new file mode 100644 index 00000000..4c45c09a --- /dev/null +++ b/packages/ts-plugin/src/source-map.ts @@ -0,0 +1,26 @@ +import type { CodeInformation, Mapping } from '@volar/language-core'; +import { SourceMap } from '@volar/language-core'; + +export class CustomSourceMap extends SourceMap { + override *toSourceRange( + start: number, + end: number, + fallbackToAnyMatch: boolean, + filter?: (data: CodeInformation) => boolean, + ): Generator<[number, number, Mapping, Mapping]> { + let matched = false; + for (const result of super.toSourceRange(start, end, fallbackToAnyMatch, filter)) { + matched = true; + yield result; + } + if (matched) return; + + // When a single-quote-wrapped range (e.g. `'a_1'` in the generated .d.ts) is passed, + // the mapping registers only the inner token name without the quotes, so the direct + // lookup fails. Retry with the range stripped of its outer characters to recover the + // inner token name's source range. + if (end - start >= 2) { + yield* super.toSourceRange(start + 1, end - 1, fallbackToAnyMatch, filter); + } + } +}