Skip to content

Commit ea5dfc9

Browse files
mizdraclaude
andauthored
fix(ts-plugin): narrow Go to Definition results to tokens declared in the current module (#364)
When `namedExports: false`, Go to Definition for tokens imported via `@value val from './other.module.css'` additionally returned the re-export location (the `@value ... from ...` line) alongside the original definition. Re-export locations aren't declarations and shouldn't show up as definitions. For definitions pointing into a CSS module, keep only those that match a token in `cssModule.localTokens` — the set of tokens actually declared in that module. Zero-length spans (module-level references such as `import styles from '...'`) are preserved as an exception. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e1f2983 commit ea5dfc9

3 files changed

Lines changed: 33 additions & 60 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@css-modules-kit/ts-plugin': patch
3+
---
4+
5+
fix(ts-plugin): exclude re-exported `@value ... from '...'` tokens from Go to Definition results

packages/ts-plugin/e2e-test/feature/go-to-definition.test.ts

Lines changed: 4 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -226,96 +226,60 @@ describe.each([
226226
file: iff.paths['index.ts'],
227227
line: 7,
228228
offset: 8,
229-
// NOTE: Ideally, only the definition from c.module.css should be returned.
230-
// However, for simplicity of implementation, when `namedExports: false`, the definition from a.module.css is also returned.
231229
expected: [
232-
!namedExports && {
233-
file: formatPath(iff.paths['a.module.css']),
234-
start: { line: 2, offset: 8 },
235-
end: { line: 2, offset: 11 },
236-
contextStart: { line: 2, offset: 8 },
237-
contextEnd: { line: 2, offset: 11 },
238-
},
239230
{
240231
file: formatPath(iff.paths['c.module.css']),
241232
start: { line: 1, offset: 8 },
242233
end: { line: 1, offset: 11 },
243234
contextStart: { line: 1, offset: 1 },
244235
contextEnd: { line: 1, offset: 16 },
245236
},
246-
].filter((c) => c !== false),
237+
],
247238
},
248239
{
249240
name: 'c_1 in a.module.ts',
250241
file: iff.paths['a.module.css'],
251242
line: 2,
252243
offset: 8,
253-
// NOTE: Ideally, only the definition from c.module.css should be returned.
254-
// However, for simplicity of implementation, when `namedExports: false`, the definition from a.module.css is also returned.
255244
expected: [
256-
!namedExports && {
257-
file: formatPath(iff.paths['a.module.css']),
258-
start: { line: 2, offset: 8 },
259-
end: { line: 2, offset: 11 },
260-
contextStart: { line: 2, offset: 8 },
261-
contextEnd: { line: 2, offset: 11 },
262-
},
263245
{
264246
file: formatPath(iff.paths['c.module.css']),
265247
start: { line: 1, offset: 8 },
266248
end: { line: 1, offset: 11 },
267249
contextStart: { line: 1, offset: 1 },
268250
contextEnd: { line: 1, offset: 16 },
269251
},
270-
].filter((c) => c !== false),
252+
],
271253
},
272254
{
273255
name: 'c_alias in index.ts',
274256
file: iff.paths['index.ts'],
275257
line: 8,
276258
offset: 8,
277-
// NOTE: Ideally, only the definition from c.module.css should be returned.
278-
// However, for simplicity of implementation, when `namedExports: false`, the definition from a.module.css is also returned.
279259
expected: [
280-
!namedExports && {
281-
file: formatPath(iff.paths['a.module.css']),
282-
start: { line: 2, offset: 20 },
283-
end: { line: 2, offset: 27 },
284-
contextStart: { line: 2, offset: 20 },
285-
contextEnd: { line: 2, offset: 27 },
286-
},
287260
{
288261
file: formatPath(iff.paths['c.module.css']),
289262
start: { line: 2, offset: 8 },
290263
end: { line: 2, offset: 11 },
291264
contextStart: { line: 2, offset: 1 },
292265
contextEnd: { line: 2, offset: 16 },
293266
},
294-
].filter((c) => c !== false),
267+
],
295268
},
296269
{
297270
name: 'c_alias in a.module.css',
298271
file: iff.paths['a.module.css'],
299272
line: 2,
300273
offset: 20,
301-
// NOTE: Ideally, only the definition from c.module.css should be returned.
302-
// However, for simplicity of implementation, when `namedExports: false`, the definition from a.module.css is also returned.
303274
expected: [
304-
!namedExports && {
305-
file: formatPath(iff.paths['a.module.css']),
306-
start: { line: 2, offset: 20 },
307-
end: { line: 2, offset: 27 },
308-
contextStart: { line: 2, offset: 20 },
309-
contextEnd: { line: 2, offset: 27 },
310-
},
311275
{
312276
file: formatPath(iff.paths['c.module.css']),
313277
start: { line: 2, offset: 8 },
314278
end: { line: 2, offset: 11 },
315279
contextStart: { line: 2, offset: 1 },
316280
contextEnd: { line: 2, offset: 16 },
317281
},
318-
].filter((c) => c !== false),
282+
],
319283
},
320284
{
321285
name: 'c_2 in a.module.css',

packages/ts-plugin/src/language-service/feature/definition-and-bound-span.ts

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,39 +10,43 @@ export function getDefinitionAndBoundSpan(
1010
const result = languageService.getDefinitionAndBoundSpan(...args);
1111
if (!result) return;
1212
if (!result.definitions) return result;
13+
14+
const newDefinitions: ts.DefinitionInfo[] = [];
1315
for (const def of result.definitions) {
16+
// Clicks on a module-level reference (e.g. `styles` in `import styles from '...'`
17+
// or the module specifier itself) surface as a zero-length span at file start.
18+
// Keep them as-is; they aren't tokens to be matched against `localTokens`.
19+
if (def.textSpan.start === 0 && def.textSpan.length === 0) {
20+
newDefinitions.push(def);
21+
continue;
22+
}
1423
const script = language.scripts.get(def.fileName);
15-
if (!isCSSModuleScript(script)) continue;
16-
24+
if (!isCSSModuleScript(script)) {
25+
newDefinitions.push(def);
26+
continue;
27+
}
1728
const cssModule = script.generated.root[CMK_DATA_KEY];
18-
19-
// Search tokens and set `contextSpan`. `contextSpan` is used for Definition Preview in editors.
2029
const defName = unquote(def.name);
30+
31+
// Keep only definitions that map to a token declared in this module's `localTokens`.
32+
// Re-exports from `@value ... from '...'` aren't declarations here, so they're excluded —
33+
// their real declaration lives in the target file.
2134
const localToken = cssModule.localTokens.find(
2235
(t) => t.name === defName && t.loc.start.offset === def.textSpan.start,
2336
);
24-
if (localToken?.declarationLoc) {
37+
if (!localToken) continue;
38+
39+
// Set `contextSpan` for local tokens. `contextSpan` is used for Definition Preview in editors.
40+
if (localToken.declarationLoc) {
2541
def.contextSpan = {
2642
start: localToken.declarationLoc.start.offset,
2743
length: localToken.declarationLoc.end.offset - localToken.declarationLoc.start.offset,
2844
};
29-
continue;
30-
}
31-
const importedValue = cssModule.tokenImporters
32-
.flatMap((i) => (i.type === 'value' ? i.values : []))
33-
.find((v) => {
34-
const localName = v.localName ?? v.name;
35-
const localLoc = v.localLoc ?? v.loc;
36-
return localName === defName && localLoc.start.offset === def.textSpan.start;
37-
});
38-
if (importedValue) {
39-
const loc = importedValue.localLoc ?? importedValue.loc;
40-
def.contextSpan = {
41-
start: loc.start.offset,
42-
length: loc.end.offset - loc.start.offset,
43-
};
4445
}
46+
47+
newDefinitions.push(def);
4548
}
49+
result.definitions = newDefinitions;
4650
return result;
4751
};
4852
}

0 commit comments

Comments
 (0)