Skip to content

Commit 41ec0a2

Browse files
mizdraclaude
andauthored
fix(ts-plugin): omit default export from namespace member completion (#410)
* fix(ts-plugin): omit default export from namespace member completion When `namedExports` is enabled and `prioritizeNamedImports` is disabled, the generated d.ts adds `export default styles` so the `styles` binding shows up as an auto-import suggestion. As a side effect, the default export leaked into namespace member completions (`import * as styles from './a.module.css'; styles.`) as a `default` member that the CSS module does not export. Filter out the `default` member when the completion accesses a namespace import of a CSS module. The CSS module is detected by resolving the accessed binding to its source file via `getDefinitionAtPosition`, sharing the property access lookup with `code-fix.ts` through the new `language-service/ast.ts`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(ts-plugin): trigger completion exactly at `styles.` in namespace test Change the fixture from `styles.a_1` to `styles.` and send the completion request immediately after the dot, so the test directly covers the `styles.|` regression scenario described in the PR. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4f59d0c commit 41ec0a2

5 files changed

Lines changed: 89 additions & 19 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@css-modules-kit/ts-plugin': patch
3+
---
4+
5+
fix(ts-plugin): omit the `default` member from namespace member completion
6+
7+
When `namedExports` is enabled and `prioritizeNamedImports` is disabled, completing members of a namespace import (`import * as styles from './a.module.css'; styles.`) no longer suggests a `default` member that the CSS module does not export.

packages/ts-plugin/e2e-test/completion.test.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -189,9 +189,9 @@ describe.each([{ namedExports: false }, { namedExports: true }])('namedExports:
189189
});
190190
});
191191

192-
describe('named token completion (namedExports: true)', () => {
192+
describe('prioritizeNamedImports (namedExports: true)', () => {
193193
describe('prioritizeNamedImports: false', () => {
194-
test('omits named tokens from suggestions', async () => {
194+
test('omits named token auto-imports', async () => {
195195
const { iff, getRange } = await setupFixture({
196196
'tsconfig.json': buildTSConfigJSON({
197197
cmkOptions: { namedExports: true, prioritizeNamedImports: false },
@@ -213,10 +213,36 @@ describe('named token completion (namedExports: true)', () => {
213213
[],
214214
);
215215
});
216+
217+
test('omits the default export from namespace member completion', async () => {
218+
const { iff, getRange } = await setupFixture({
219+
'tsconfig.json': buildTSConfigJSON({
220+
cmkOptions: { namedExports: true, prioritizeNamedImports: false },
221+
}),
222+
'index.ts': dedent`
223+
import * as styles from './a.module.css';
224+
styles.
225+
`,
226+
'a.module.css': `.a_1 { color: red; }`,
227+
});
228+
await tsserver.sendUpdateOpen({ openFiles: [{ file: iff.paths['index.ts'] }] });
229+
await tsserver.sendConfigure({
230+
preferences: { includeCompletionsForModuleExports: true },
231+
});
232+
233+
const res = await tsserver.sendCompletionInfo({
234+
file: iff.paths['index.ts'],
235+
...getRange('index.ts', 'styles.').end,
236+
});
237+
238+
const names = res.body?.entries.map((entry) => entry.name) ?? [];
239+
expect(names).toContain('a_1');
240+
expect(names).not.toContain('default');
241+
});
216242
});
217243

218244
describe('prioritizeNamedImports: true', () => {
219-
test('omits the default styles binding from suggestions', async () => {
245+
test('omits the styles binding auto-import', async () => {
220246
const { iff, getRange } = await setupFixture({
221247
'tsconfig.json': buildTSConfigJSON({
222248
cmkOptions: { namedExports: true, prioritizeNamedImports: true },
@@ -239,7 +265,7 @@ describe('named token completion (namedExports: true)', () => {
239265
).toStrictEqual([]);
240266
});
241267

242-
test('suggests named token bindings', async () => {
268+
test('suggests named token auto-imports', async () => {
243269
const { iff, getRange } = await setupFixture({
244270
'tsconfig.json': buildTSConfigJSON({
245271
cmkOptions: { namedExports: true, prioritizeNamedImports: true },
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import ts from 'typescript';
2+
3+
/** Get the property access expression at the specified position. (e.g. `obj.foo`, `styles.foo`) */
4+
export function getPropertyAccessExpressionAtPosition(
5+
sourceFile: ts.SourceFile,
6+
position: number,
7+
): ts.PropertyAccessExpression | undefined {
8+
function find(node: ts.Node): ts.PropertyAccessExpression | undefined {
9+
if (node.pos <= position && position <= node.end && ts.isPropertyAccessExpression(node)) {
10+
return node;
11+
}
12+
return ts.forEachChild(node, find);
13+
}
14+
return find(sourceFile);
15+
}

packages/ts-plugin/src/language-service/feature/code-fix.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import type { CMKConfig, Resolver } from '@css-modules-kit/core';
22
import { isCSSModuleFile } from '@css-modules-kit/core';
33
import type { Language } from '@volar/language-core';
4-
import ts from 'typescript';
4+
import type ts from 'typescript';
55
import { convertDefaultImportsToNamespaceImports, createPreferencesForCompletion } from '../../util.js';
6+
import { getPropertyAccessExpressionAtPosition } from '../ast.js';
67

78
// ref: https://github.com/microsoft/TypeScript/blob/220706eb0320ff46fad8bf80a5e99db624ee7dfb/src/compiler/diagnosticMessages.json
89
export const CANNOT_FIND_NAME_ERROR_CODE = 2304;
@@ -115,20 +116,6 @@ function getTokenConsumerAtPosition(
115116
return undefined;
116117
}
117118

118-
/** Get the property access expression at the specified position. (e.g. `obj.foo`, `styles.foo`) */
119-
function getPropertyAccessExpressionAtPosition(
120-
sourceFile: ts.SourceFile,
121-
position: number,
122-
): ts.PropertyAccessExpression | undefined {
123-
function getPropertyAccessExpressionImpl(node: ts.Node): ts.PropertyAccessExpression | undefined {
124-
if (node.pos <= position && position <= node.end && ts.isPropertyAccessExpression(node)) {
125-
return node;
126-
}
127-
return ts.forEachChild(node, getPropertyAccessExpressionImpl);
128-
}
129-
return getPropertyAccessExpressionImpl(sourceFile);
130-
}
131-
132119
function createInsertRuleFileChange(
133120
cssModuleFileName: string,
134121
className: string,

packages/ts-plugin/src/language-service/feature/completion.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import type { CMKConfig, Resolver } from '@css-modules-kit/core';
22
import { getCssModuleFileName, isComponentFileName, isCSSModuleFile, STYLES_EXPORT_NAME } from '@css-modules-kit/core';
33
import ts from 'typescript';
44
import { convertDefaultImportsToNamespaceImports, createPreferencesForCompletion } from '../../util.js';
5+
import { getPropertyAccessExpressionAtPosition } from '../ast.js';
6+
7+
const DEFAULT_EXPORT_NAME = 'default';
58

69
export function getCompletionsAtPosition(
710
languageService: ts.LanguageService,
@@ -44,11 +47,43 @@ export function getCompletionsAtPosition(
4447
// ```
4548
// Therefore, completion for tokens like `button` is disabled.
4649
prior.entries = prior.entries.filter((entry) => !isNamedExportedTokenEntry(entry));
50+
51+
// The d.ts adds `export default styles` so that the `styles` binding appears as an auto-import
52+
// suggestion. As a side effect, the default export leaks into namespace member completions
53+
// (`import * as styles from './a.module.css'; styles.|`) as a `default` member. The `default`
54+
// member is not a token of the CSS module, so it is removed.
55+
if (
56+
prior.isMemberCompletion &&
57+
prior.entries.some((entry) => entry.name === DEFAULT_EXPORT_NAME) &&
58+
isCSSModuleNamespaceAccess(languageService, fileName, position)
59+
) {
60+
prior.entries = prior.entries.filter((entry) => entry.name !== DEFAULT_EXPORT_NAME);
61+
}
4762
}
4863
return prior;
4964
};
5065
}
5166

67+
/**
68+
* Check if the completion position accesses a member of a namespace import of a CSS module
69+
* (e.g. the `styles.|` position in `import * as styles from './a.module.css'; styles.|`).
70+
*/
71+
function isCSSModuleNamespaceAccess(languageService: ts.LanguageService, fileName: string, position: number): boolean {
72+
const sourceFile = languageService.getProgram()?.getSourceFile(fileName);
73+
if (!sourceFile) return false;
74+
75+
const propertyAccess = getPropertyAccessExpressionAtPosition(sourceFile, position);
76+
if (!propertyAccess) return false;
77+
78+
// Resolve the accessed expression (e.g. `styles` in `styles.foo`) to its CSS module file.
79+
// The first definition points to the `styles` binding of `import * as styles from './a.module.css'`
80+
// in this file, and the second resolves that binding to the CSS module file.
81+
const [binding] = languageService.getDefinitionAtPosition(fileName, propertyAccess.expression.getStart()) ?? [];
82+
if (!binding) return false;
83+
const [moduleDefinition] = languageService.getDefinitionAtPosition(binding.fileName, binding.textSpan.start) ?? [];
84+
return moduleDefinition !== undefined && isCSSModuleFile(moduleDefinition.fileName);
85+
}
86+
5287
type DefaultExportedStylesEntry = ts.CompletionEntry & {
5388
data: ts.CompletionEntryData;
5489
};

0 commit comments

Comments
 (0)