Skip to content

Commit 6b3bc59

Browse files
mizdraclaude
andcommitted
refactor(core, ts-plugin): replace secondaryMapping with CustomSourceMap
Remove the secondaryMapping workaround for the Single-Quote Span Problem and replace it with a custom Mapper that strips outer quote characters when a direct range lookup fails. The primary mapping now covers only the inner token name, while CustomSourceMap (registered via language.mapperFactory) handles getDefinitionAtPosition spans that include the surrounding quotes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bc168b2 commit 6b3bc59

File tree

6 files changed

+82
-149
lines changed

6 files changed

+82
-149
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@css-modules-kit/core': patch
3+
'@css-modules-kit/ts-plugin': patch
4+
---
5+
6+
refactor(core, ts-plugin): replace secondaryMapping with CustomSourceMap

docs/ts-plugin-internals.md

Lines changed: 33 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -172,28 +172,15 @@ a.module.css.d.ts:
172172
^ generatedOffsets[0] = 27 (after the opening quote)
173173
```
174174

175-
Here, the **primary mapping** points to the token name inside the quotes:
175+
The mapping points to the token name inside the quotes; the surrounding quotes are not covered:
176176

177177
```
178178
mapping: { sourceOffsets: [1], generatedOffsets: [27], lengths: [3] }
179179
```
180180

181-
But there is also a **secondary mapping** that includes the quotes:
182-
183-
```
184-
secondaryMapping: {
185-
sourceOffsets: [1],
186-
generatedOffsets: [26], // Points to the opening quote
187-
lengths: [3],
188-
generatedLengths: [5], // 'a_1' = 5 characters (quote + name + quote)
189-
}
190-
```
191-
192-
The reason for having two mappings is explained in the [Single-Quote Span Problem](#the-single-quote-span-problem) section.
193-
194181
### Mapping registration with Volar.js
195182

196-
Both mappings are registered with Volar.js through the `VirtualCode.mappings` array:
183+
The mapping is registered with Volar.js through the `VirtualCode.mappings` array:
197184

198185
```ts
199186
// packages/ts-plugin/src/language-plugin.ts
@@ -203,16 +190,12 @@ return {
203190
snapshot: {
204191
/* ... */
205192
},
206-
mappings: [mapping, secondaryMapping]
207-
.filter((mapping) => mapping !== undefined)
208-
.map((mapping) => ({ ...mapping, data: { navigation: true } })),
193+
mappings: [mapping].map((mapping) => ({ ...mapping, data: { navigation: true } })),
209194
linkedCodeMappings: [{ ...linkedCodeMapping, data: undefined }],
210195
};
211196
```
212197

213-
The `data: { navigation: true }` flag enables navigation features (Go to Definition, Find References, Rename) for these mappings.
214-
215-
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.
198+
The `data: { navigation: true }` flag enables navigation features (Go to Definition, Find References, Rename) for this mapping.
216199

217200
## How Volar.js Translates Positions
218201

@@ -330,7 +313,7 @@ The issue is that `findReferences` returns the position of `a` (offset 27), whic
330313

331314
### Why overlapping mappings in a single object don't work
332315

333-
Putting both mappings in the same CodeMapping object:
316+
Putting both ranges in the same CodeMapping object:
334317

335318
```
336319
Mapping: {
@@ -345,39 +328,41 @@ This doesn't work because Volar.js does not support overlapping ranges within a
345328

346329
Reference: https://github.com/volarjs/volar.js/issues/203
347330

348-
### The solution: separate mapping objects with priority
331+
### The solution: a custom mapper that strips outer quotes on fallback
349332

350-
CSS Modules Kit uses **two separate mapping objects**:
333+
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`:
351334

352335
```ts
353-
// Primary mapping (checked first): maps the unquoted token name
354-
mapping: { generatedOffsets: [27], lengths: [3], sourceOffsets: [1] }
355-
356-
// Secondary mapping (fallback): maps the quoted token name
357-
secondaryMapping: {
358-
generatedOffsets: [26],
359-
lengths: [3],
360-
sourceOffsets: [1],
361-
generatedLengths: [5]
362-
}
336+
// packages/ts-plugin/src/index.cts
337+
language.mapperFactory = (mappings) => new CustomSourceMap(mappings);
363338
```
364339

365-
These are registered as separate entries in `VirtualCode.mappings`:
340+
`CustomSourceMap` extends `@volar/language-core`'s `SourceMap` and overrides only `toSourceRange`:
366341

367342
```ts
368-
mappings: [mapping, secondaryMapping].filter((m) => m !== undefined).map((m) => ({ ...m, data: { navigation: true } }));
369-
```
370-
371-
Volar.js searches mappings in array order. For each API:
343+
// packages/ts-plugin/src/source-map.ts
344+
export class CustomSourceMap extends SourceMap<CodeInformation> {
345+
override *toSourceRange(start, end, fallbackToAnyMatch, filter) {
346+
let matched = false;
347+
for (const result of super.toSourceRange(start, end, fallbackToAnyMatch, filter)) {
348+
matched = true;
349+
yield result;
350+
}
351+
if (matched) return;
372352

373-
- **`findReferences`** (start=27): Primary mapping matches → `translateOffset(27, [27], [1], [3], [3])` → returns `1`. Correct!
374-
- **`getDefinitionAtPosition`** (start=26): Primary mapping doesn't match (26 < 27). Secondary mapping matches → `translateOffset(26, [26], [1], [5], [3])` → returns `1`. Correct!
353+
// The outer characters may be surrounding quotes (e.g. `'a_1'`).
354+
// Retry with them stripped so the inner token name's mapping can match.
355+
if (end - start >= 2) {
356+
yield* super.toSourceRange(start + 1, end - 1, fallbackToAnyMatch, filter);
357+
}
358+
}
359+
}
360+
```
375361

376-
This approach works because:
362+
For each API:
377363

378-
1. Non-overlapping mappings are checked independently
379-
2. The primary mapping handles `findReferences` and `findRenameLocations` correctly
380-
3. The secondary mapping handles `getDefinitionAtPosition` as a fallback
364+
- **`findReferences`** / **`findRenameLocations`** (start=27, length=3): the inner mapping matches directly → `translateOffset(27, [27], [1], [3], [3])` → returns `1`. Correct!
365+
- **`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!
381366

382367
Reference: https://github.com/mizdra/volar-single-quote-span-problem
383368

@@ -415,6 +400,8 @@ linkedCodeMapping: {
415400

416401
Note: Despite the names `sourceOffsets` and `generatedOffsets`, both refer to positions within the same generated `.d.ts` file. The names are interchangeable for `linkedCodeMappings`.
417402

403+
**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.
404+
418405
### How LinkedCodeMap works
419406

420407
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
458445
| Method | Enhancement |
459446
| ------------------------------------------------ | -------------------------------------------------------------------------------------- |
460447
| `getDefinitionAndBoundSpan` | Adds `contextSpan` for Definition Preview (shows the full CSS rule) |
461-
| `findReferences` | Merges duplicate `ReferencedSymbol`s caused by multiple mappings |
448+
| `findReferences` | Merges `ReferencedSymbol`s that share the same definition |
462449
| `getCompletionsAtPosition` | Prioritizes `styles` import, converts `className` to JSX format, filters named exports |
463450
| `getCompletionEntryDetails` | Converts default imports to namespace imports for CSS Modules |
464451
| `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
479466
| `_css-modules-kit:documentLink` | Returns import specifier positions as document links |
480467

481468
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.
482-
483-
## Volar.js proxyLanguageService.ts / transform.ts: API-specific behavior
484-
485-
In Volar.js, `transform.ts` contains the low-level translation logic, while `proxyLanguageService.ts` decides how each Language Service API uses it. Key differences:
486-
487-
### fallbackToAnyMatch parameter
488-
489-
Different APIs use different strictness for mapping resolution:
490-
491-
| API | fallbackToAnyMatch | Reason |
492-
| -------------------------- | ------------------ | ------------------------------------------ |
493-
| `getDefinitionAtPosition` | `true` | Cross-file definitions need loose matching |
494-
| `findReferences` | `true` | References may span multiple mappings |
495-
| `findRenameLocations` | `false` | Strict position matching for safety |
496-
| `getCompletionsAtPosition` | `false` | Precise position needed |
497-
| Diagnostics | `true` | Report even with loose mapping |
498-
499-
### Empty span fallback
500-
501-
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).
502-
503-
### CodeInformation filters
504-
505-
Each Language Service API uses a different filter function to select which mappings to search:
506-
507-
- Navigation features (definition, references, rename): truthy `data.navigation`
508-
- Completion: truthy `data.completion`
509-
- Diagnostics: truthy `data.verification`
510-
- Semantic features (hover, inlay hints): truthy `data.semantic`
511-
512-
CSS Modules Kit registers all mappings with `{ navigation: true }`, enabling them for navigation features only.
513-
514-
## Summary of data flow
515-
516-
### Go to Definition (from .ts file)
517-
518-
```
519-
1. User triggers "Go to Definition" on `styles.a_1` in index.ts
520-
2. Volar.js receives the request with position in index.ts
521-
3. TypeScript LS returns definition span in generated .d.ts (e.g., { start: 26, length: 5 })
522-
4. Volar.js translates span back to .module.css using CodeMapping
523-
- Tries primary mapping first (offset 27) → no match for start=26
524-
- Falls back to secondary mapping (offset 26) → matches → source position 1
525-
5. Returns { fileName: 'a.module.css', textSpan: { start: 1, length: 3 } }
526-
6. CSS Modules Kit proxy adds contextSpan (full CSS rule range)
527-
7. Editor navigates to the CSS class definition
528-
```
529-
530-
### Find All References (from .css file)
531-
532-
```
533-
1. User triggers "Find References" on `.a_1` in a.module.css
534-
2. Volar.js translates CSS position to generated .d.ts position
535-
3. TypeScript LS returns references including:
536-
- Definition in .d.ts: { start: 27, length: 3 }
537-
- Usage in index.ts: { start: 44, length: 3 }
538-
4. Volar.js translates .d.ts position back to .module.css
539-
- Primary mapping matches (offset 27) → source position 1
540-
5. CSS Modules Kit proxy merges duplicate ReferencedSymbols
541-
6. Returns references in both CSS and TypeScript files
542-
```

packages/core/src/dts-generator.ts

Lines changed: 13 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ interface LinkedCodeMapping extends CodeMapping {
3838
interface GenerateDtsResult {
3939
text: string;
4040
mapping: CodeMapping;
41-
secondaryMapping?: CodeMapping;
4241
linkedCodeMapping: LinkedCodeMapping;
4342
}
4443

@@ -271,21 +270,9 @@ function generateDefaultExportDts(
271270
): {
272271
text: string;
273272
mapping: CodeMapping;
274-
secondaryMapping: CodeMapping;
275273
linkedCodeMapping: LinkedCodeMapping;
276274
} {
277275
const mapping: CodeMapping = { sourceOffsets: [], lengths: [], generatedOffsets: [] };
278-
/**
279-
* In "Go to Definition", mapping only the inner part of the quotes does not work.
280-
* Therefore, we also generate a mapping that includes the quotes.
281-
* For more details, see https://github.com/mizdra/volar-single-quote-span-problem.
282-
*/
283-
const secondaryMapping: CodeMapping & { generatedLengths: number[] } = {
284-
sourceOffsets: [],
285-
lengths: [],
286-
generatedOffsets: [],
287-
generatedLengths: [],
288-
};
289276
const linkedCodeMapping: LinkedCodeMapping = {
290277
sourceOffsets: [],
291278
lengths: [],
@@ -315,21 +302,19 @@ function generateDefaultExportDts(
315302
* The mapping is created as follows:
316303
* a.module.css:
317304
* 1 | .a_1 { color: red; }
318-
* | ^ mapping.sourceOffsets[0], secondaryMapping.sourceOffsets[0]
305+
* | ^ mapping.sourceOffsets[0]
319306
* |
320307
* 2 | .a_2 { color: blue; }
321-
* | ^ mapping.sourceOffsets[1], secondaryMapping.sourceOffsets[1]
308+
* | ^ mapping.sourceOffsets[1]
322309
* |
323310
*
324311
* a.module.css.d.ts:
325312
* 1 | declare const styles = {
326313
* 2 | 'a_1': '' as readonly string,
327-
* | ^^ mapping.generatedOffsets[0]
328-
* | ^ secondaryMapping.generatedOffsets[0]
314+
* | ^ mapping.generatedOffsets[0]
329315
* |
330316
* 3 | 'a_2': '' as readonly string,
331-
* | ^^ mapping.generatedOffsets[1]
332-
* | ^ secondaryMapping.generatedOffsets[1]
317+
* | ^ mapping.generatedOffsets[1]
333318
* |
334319
* 4 | };
335320
*/
@@ -338,10 +323,6 @@ function generateDefaultExportDts(
338323
mapping.sourceOffsets.push(token.loc.start.offset);
339324
mapping.lengths.push(token.name.length);
340325
mapping.generatedOffsets.push(text.length);
341-
secondaryMapping.sourceOffsets.push(token.loc.start.offset);
342-
secondaryMapping.lengths.push(token.name.length);
343-
secondaryMapping.generatedOffsets.push(text.length - 1);
344-
secondaryMapping.generatedLengths.push(token.name.length + 2);
345326
text += `${token.name}': '' as readonly string,\n`;
346327
}
347328
for (const tokenImporter of tokenImporters) {
@@ -380,13 +361,13 @@ function generateDefaultExportDts(
380361
* a.module.css:
381362
* 1 | @value b_1, b_2 from './b.module.css';
382363
* | ^ ^ ^ mapping.sourceOffsets[1]
383-
* | ^ ^ mapping.sourceOffsets[2], secondaryMapping.sourceOffsets[1]
384-
* | ^ mapping.sourceOffsets[0], secondaryMapping.sourceOffsets[0]
364+
* | ^ ^ mapping.sourceOffsets[2]
365+
* | ^ mapping.sourceOffsets[0]
385366
* |
386367
* 2 | @value c_1 as aliased_c_1 from './c.module.css';
387368
* | ^ ^ ^ mapping.sourceOffsets[4]
388-
* | ^ ^ mapping.sourceOffsets[3], secondaryMapping.sourceOffsets[2]
389-
* | ^ mapping.sourceOffsets[5], secondaryMapping.sourceOffsets[3]
369+
* | ^ ^ mapping.sourceOffsets[3]
370+
* | ^ mapping.sourceOffsets[5]
390371
* |
391372
*
392373
* a.module.css.d.ts:
@@ -395,19 +376,19 @@ function generateDefaultExportDts(
395376
* | ^^ ^ ^ linkedCodeMapping.generatedOffsets[0]
396377
* | ^^ ^ mapping.generatedOffsets[1]
397378
* | ^^ mapping.generatedOffsets[0]
398-
* | ^ secondaryMapping.generatedOffsets[0], linkedCodeMapping.sourceOffsets[0]
379+
* | ^ linkedCodeMapping.sourceOffsets[0]
399380
* |
400381
* 3 | 'b_2': (await import('./b.module.css')).default['b_2'],
401382
* | ^^ ^ linkedCodeMapping.generatedOffsets[1]
402383
* | ^^ mapping.generatedOffsets[2]
403-
* | ^ secondaryMapping.generatedOffsets[1], linkedCodeMapping.sourceOffsets[1]
384+
* | ^ linkedCodeMapping.sourceOffsets[1]
404385
* |
405386
* 4 | 'aliased_c_1': (await import('./c.module.css')).default['c_1'],
406387
* | ^^ ^ ^^ mapping.generatedOffsets[5]
407-
* | ^^ ^ ^ secondaryMapping.generatedOffsets[3], linkedCodeMapping.generatedOffsets[2]
388+
* | ^^ ^ ^ linkedCodeMapping.generatedOffsets[2]
408389
* | ^^ ^ mapping.generatedOffsets[4]
409390
* | ^^ mapping.generatedOffsets[3]
410-
* | ^ secondaryMapping.generatedOffsets[2], linkedCodeMapping.sourceOffsets[2]
391+
* | ^ linkedCodeMapping.sourceOffsets[2]
411392
* |
412393
* 5 | };
413394
*
@@ -423,10 +404,6 @@ function generateDefaultExportDts(
423404
mapping.sourceOffsets.push(localLoc.start.offset);
424405
mapping.lengths.push(localName.length);
425406
mapping.generatedOffsets.push(text.length);
426-
secondaryMapping.sourceOffsets.push(localLoc.start.offset);
427-
secondaryMapping.lengths.push(localName.length);
428-
secondaryMapping.generatedOffsets.push(text.length - 1);
429-
secondaryMapping.generatedLengths.push(localName.length + 2);
430407
linkedCodeMapping.sourceOffsets.push(text.length - 1);
431408
linkedCodeMapping.lengths.push(localName.length + 2);
432409
text += `${localName}': (await import(`;
@@ -440,10 +417,6 @@ function generateDefaultExportDts(
440417
mapping.sourceOffsets.push(value.loc.start.offset);
441418
mapping.lengths.push(value.name.length);
442419
mapping.generatedOffsets.push(text.length);
443-
secondaryMapping.sourceOffsets.push(value.loc.start.offset);
444-
secondaryMapping.lengths.push(value.name.length);
445-
secondaryMapping.generatedOffsets.push(text.length - 1);
446-
secondaryMapping.generatedLengths.push(value.name.length + 2);
447420
}
448421
linkedCodeMapping.generatedOffsets.push(text.length - 1);
449422
linkedCodeMapping.generatedLengths.push(value.name.length + 2);
@@ -452,7 +425,7 @@ function generateDefaultExportDts(
452425
}
453426
}
454427
text += `};\nexport default ${STYLES_EXPORT_NAME};\n`;
455-
return { text, mapping, linkedCodeMapping, secondaryMapping };
428+
return { text, mapping, linkedCodeMapping };
456429
}
457430

458431
function isValidTokenName(name: string, options: ValidateTokenNameOptions): boolean {

packages/ts-plugin/src/index.cts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { proxyLanguageService } from './language-service/proxy.js';
99
import { createDocumentLinkHandler } from './protocol-handler/documentLink.js';
1010
import { createRenameHandler } from './protocol-handler/rename.js';
1111
import { createRenameInfoHandler } from './protocol-handler/renameInfo.js';
12+
import { CustomSourceMap } from './source-map.js';
1213

1314
const projectToLanguage = new WeakMap<ts.server.Project, Language<string>>();
1415

@@ -67,6 +68,7 @@ const plugin = createLanguageServicePlugin((ts, info) => {
6768
return {
6869
languagePlugins: [createCSSLanguagePlugin(matchesPattern, config)],
6970
setup: (language) => {
71+
language.mapperFactory = (mappings) => new CustomSourceMap(mappings);
7072
projectToLanguage.set(info.project, language);
7173
info.languageService = proxyLanguageService(
7274
language,

packages/ts-plugin/src/language-plugin.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export function createCSSLanguagePlugin(
5252
keyframes: config.keyframes,
5353
});
5454
// oxlint-disable-next-line prefer-const
55-
let { text, mapping, linkedCodeMapping, secondaryMapping } = generateDts(cssModule, {
55+
let { text, mapping, linkedCodeMapping } = generateDts(cssModule, {
5656
...config,
5757
forTsPlugin: true,
5858
});
@@ -65,7 +65,7 @@ export function createCSSLanguagePlugin(
6565
getChangeRange: () => undefined,
6666
},
6767
// `mappings` are required to support navigation features such as "Go to Definition" and "Find all References".
68-
mappings: [mapping, secondaryMapping]
68+
mappings: [mapping]
6969
.filter((mapping) => mapping !== undefined)
7070
.map((mapping) => ({ ...mapping, data: { navigation: true } })),
7171
// `linkedCodeMappings` are required to support navigation features for the imported tokens.

0 commit comments

Comments
 (0)