Skip to content

Commit fb0563d

Browse files
authored
Support prioritizeNamedImports option (#187)
* add test for `prioritizeNamedImports` option * support `prioritizeNamedImports` option * add changelog
1 parent dd51d69 commit fb0563d

10 files changed

Lines changed: 288 additions & 21 deletions

File tree

.changeset/moody-hornets-hammer.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@css-modules-kit/ts-plugin': minor
3+
'@css-modules-kit/core': minor
4+
---
5+
6+
feat: support `prioritizeNamedImports` option

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,26 @@ Determines whether to generate named exports in the d.ts file instead of a defau
165165
}
166166
```
167167

168+
### `cmkOptions.prioritizeNamedImports`
169+
170+
Type: `boolean`, Default: `false`
171+
172+
Whether to prioritize named imports over namespace imports when adding import statements. This option only takes effect when `cmkOptions.namedExports` is `true`.
173+
174+
When this option is `true`, `import { button } from '...'` will be added. When this option is `false`, `import button from '...'` will be added.
175+
176+
```jsonc
177+
{
178+
"compilerOptions": {
179+
// ...
180+
},
181+
"cmkOptions": {
182+
"namedExports": true,
183+
"prioritizeNamedImports": true,
184+
},
185+
}
186+
```
187+
168188
## Limitations
169189

170190
- Sass/Less are not supported to simplify the implementation

packages/core/src/config.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ describe('readTsConfigFile', () => {
2929
"cmkOptions": {
3030
"dtsOutDir": "generated/cmk",
3131
"arbitraryExtensions": false,
32-
"namedExports": true
32+
"namedExports": true,
33+
"prioritizeNamedImports": true
3334
}
3435
}
3536
`,
@@ -42,6 +43,7 @@ describe('readTsConfigFile', () => {
4243
dtsOutDir: 'generated/cmk',
4344
arbitraryExtensions: false,
4445
namedExports: true,
46+
prioritizeNamedImports: true,
4547
},
4648
compilerOptions: expect.objectContaining({
4749
module: ts.ModuleKind.ESNext,

packages/core/src/config.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ export interface CMKConfig {
1515
excludes: string[];
1616
dtsOutDir: string;
1717
arbitraryExtensions: boolean;
18-
/** Whether to generate named exports in the d.ts file instead of a default export. */
1918
namedExports: boolean;
19+
prioritizeNamedImports: boolean;
2020
/**
2121
* A root directory to resolve relative path entries in the config file to.
2222
* This is an absolute path.
@@ -71,6 +71,7 @@ interface UnnormalizedRawConfig {
7171
dtsOutDir?: string;
7272
arbitraryExtensions?: boolean;
7373
namedExports?: boolean;
74+
prioritizeNamedImports?: boolean;
7475
}
7576

7677
/**
@@ -148,6 +149,17 @@ function parseRawData(raw: unknown, tsConfigSourceFile: ts.TsConfigSourceFile):
148149
});
149150
}
150151
}
152+
if ('prioritizeNamedImports' in raw.cmkOptions) {
153+
if (typeof raw.cmkOptions.prioritizeNamedImports === 'boolean') {
154+
result.config.prioritizeNamedImports = raw.cmkOptions.prioritizeNamedImports;
155+
} else {
156+
result.diagnostics.push({
157+
category: 'error',
158+
text: `\`prioritizeNamedImports\` in ${tsConfigSourceFile.fileName} must be a boolean.`,
159+
// MEMO: Location information can be obtained from `tsConfigSourceFile.statements`, but this is complicated and will be omitted.
160+
});
161+
}
162+
}
151163
}
152164
return result;
153165
}
@@ -242,6 +254,7 @@ export function readConfigFile(project: string): CMKConfig {
242254
dtsOutDir: join(basePath, config.dtsOutDir ?? 'generated'),
243255
arbitraryExtensions: config.arbitraryExtensions ?? false,
244256
namedExports: config.namedExports ?? false,
257+
prioritizeNamedImports: config.prioritizeNamedImports ?? false,
245258
basePath,
246259
configFileName,
247260
compilerOptions,

packages/ts-plugin/e2e/named-exports.test.ts

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ describe('supports completions', async () => {
212212
.a_1 { color: red; }
213213
`,
214214
});
215-
describe('prioritize named imports by default', async () => {
215+
describe('prioritize namespace imports by default', async () => {
216216
const iff = await baseIff.fork({
217217
'tsconfig.json': dedent`
218218
{
@@ -231,6 +231,54 @@ describe('supports completions', async () => {
231231
includeCompletionsForModuleExports: true,
232232
},
233233
});
234+
test.each([
235+
{
236+
name: 'styles',
237+
entryName: 'styles',
238+
file: iff.paths['index.ts'],
239+
line: 1,
240+
offset: 7,
241+
expected: [{ name: 'styles', sortText: '16', source: formatPath(iff.paths['a.module.css']) }],
242+
},
243+
{
244+
name: 'a_1',
245+
entryName: 'a_1',
246+
file: iff.paths['index.ts'],
247+
line: 2,
248+
offset: 4,
249+
expected: [],
250+
},
251+
])('Completions for $name', async ({ entryName, file, line, offset, expected }) => {
252+
const res = await tsserver.sendCompletionInfo({
253+
file,
254+
line,
255+
offset,
256+
});
257+
expect(
258+
normalizeCompletionEntry(res.body?.entries.filter((entry) => entry.name === entryName) ?? []),
259+
).toStrictEqual(normalizeCompletionEntry(expected));
260+
});
261+
});
262+
describe('prioritize named imports if prioritizeNamedImports is true', async () => {
263+
const iff = await baseIff.fork({
264+
'tsconfig.json': dedent`
265+
{
266+
"cmkOptions": {
267+
"namedExports": true,
268+
"prioritizeNamedImports": true
269+
}
270+
}
271+
`,
272+
});
273+
const tsserver = launchTsserver();
274+
await tsserver.sendUpdateOpen({
275+
openFiles: [{ file: iff.paths['tsconfig.json'] }],
276+
});
277+
await tsserver.sendConfigure({
278+
preferences: {
279+
includeCompletionsForModuleExports: true,
280+
},
281+
});
234282
test.each([
235283
{
236284
name: 'styles',
@@ -271,14 +319,76 @@ describe('supports code fixes', async () => {
271319
.a_1 { color: red; }
272320
`,
273321
});
274-
describe('prioritize named imports by default', async () => {
322+
describe('prioritize namespace imports by default', async () => {
275323
const iff = await baseIff.fork({
276324
'tsconfig.json': dedent`
277325
{
278326
"cmkOptions": {
279327
"namedExports": true
280328
}
281329
}
330+
`,
331+
});
332+
const tsserver = launchTsserver();
333+
await tsserver.sendUpdateOpen({
334+
openFiles: [{ file: iff.paths['tsconfig.json'] }],
335+
});
336+
test.each([
337+
{
338+
name: 'styles',
339+
file: iff.paths['index.ts'],
340+
startLine: 1,
341+
startOffset: 1,
342+
endLine: 1,
343+
endOffset: 7,
344+
expected: [
345+
{
346+
fixName: 'import',
347+
changes: [
348+
{
349+
fileName: formatPath(iff.paths['index.ts']),
350+
textChanges: [
351+
{
352+
start: { line: 1, offset: 1 },
353+
end: { line: 1, offset: 1 },
354+
newText: `import * as styles from "./a.module.css";${ts.sys.newLine}${ts.sys.newLine}`,
355+
},
356+
],
357+
},
358+
],
359+
},
360+
],
361+
},
362+
{
363+
name: 'a_1',
364+
file: iff.paths['index.ts'],
365+
startLine: 2,
366+
startOffset: 1,
367+
endLine: 2,
368+
endOffset: 4,
369+
expected: [],
370+
},
371+
])('$name', async ({ file, startLine, startOffset, endLine, endOffset, expected }) => {
372+
const res = await tsserver.sendGetCodeFixes({
373+
errorCodes: [2304],
374+
file,
375+
startLine,
376+
startOffset,
377+
endLine,
378+
endOffset,
379+
});
380+
expect(normalizeCodeFixActions(res.body!)).toStrictEqual(normalizeCodeFixActions(expected));
381+
});
382+
});
383+
describe('prioritize named imports if prioritizeNamedImports is true', async () => {
384+
const iff = await baseIff.fork({
385+
'tsconfig.json': dedent`
386+
{
387+
"cmkOptions": {
388+
"namedExports": true,
389+
"prioritizeNamedImports": true
390+
}
391+
}
282392
`,
283393
});
284394
const tsserver = launchTsserver();

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,16 @@ export function createCSSLanguagePlugin(
5353
// So, ts-plugin uses a fault-tolerant Parser to parse CSS.
5454
safe: true,
5555
});
56-
const { text, mapping, linkedCodeMapping } = createDts(cssModule, {
56+
// eslint-disable-next-line prefer-const
57+
let { text, mapping, linkedCodeMapping } = createDts(cssModule, {
5758
resolver,
5859
matchesPattern,
5960
namedExports: config.namedExports,
6061
});
62+
if (config.namedExports && !config.prioritizeNamedImports) {
63+
// Export `styles` to appear in code completion suggestions
64+
text += 'declare const styles: {};\nexport default styles;\n';
65+
}
6166
return {
6267
id: 'main',
6368
languageId: LANGUAGE_ID,

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

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import type { CMKConfig } from '@css-modules-kit/core';
2-
import { isComponentFileName } from '@css-modules-kit/core';
1+
import type { CMKConfig, Resolver } from '@css-modules-kit/core';
2+
import { isComponentFileName, isCSSModuleFile } from '@css-modules-kit/core';
33
import type { Language } from '@volar/language-core';
44
import ts from 'typescript';
55
import { isCSSModuleScript } from '../../language-plugin.js';
6-
import { createPreferencesForCompletion } from '../../util.js';
6+
import { convertDefaultImportsToNamespaceImports, createPreferencesForCompletion } from '../../util.js';
77

88
// ref: https://github.com/microsoft/TypeScript/blob/220706eb0320ff46fad8bf80a5e99db624ee7dfb/src/compiler/diagnosticMessages.json
99
export const CANNOT_FIND_NAME_ERROR_CODE = 2304;
@@ -13,6 +13,7 @@ export function getCodeFixesAtPosition(
1313
language: Language<string>,
1414
languageService: ts.LanguageService,
1515
project: ts.server.Project,
16+
resolver: Resolver,
1617
config: CMKConfig,
1718
): ts.LanguageService['getCodeFixesAtPosition'] {
1819
// eslint-disable-next-line max-params
@@ -28,6 +29,11 @@ export function getCodeFixesAtPosition(
2829
),
2930
);
3031

32+
if (config.namedExports && !config.prioritizeNamedImports) {
33+
convertDefaultImportsToNamespaceImports(prior, fileName, resolver);
34+
excludeNamedImports(prior, fileName, resolver);
35+
}
36+
3137
if (isComponentFileName(fileName)) {
3238
// If a user is trying to use a non-existent token (e.g. `styles.nonExistToken`), provide a code fix to add the token.
3339
if (errorCodes.includes(PROPERTY_DOES_NOT_EXIST_ERROR_CODE)) {
@@ -42,10 +48,29 @@ export function getCodeFixesAtPosition(
4248
}
4349
}
4450

45-
return prior;
51+
return prior.filter((codeFix) => codeFix.changes.length > 0);
4652
};
4753
}
4854

55+
/**
56+
* Exclude code fixes that add named imports (e.g. `import { foo } from './a.module.css'`)
57+
*/
58+
function excludeNamedImports(codeFixes: ts.CodeFixAction[], fileName: string, resolver: Resolver): void {
59+
for (const codeFix of codeFixes) {
60+
if (codeFix.fixName !== 'import') continue;
61+
const match = codeFix.description.match(/^Add import from "(.*)"$/u);
62+
if (!match) continue;
63+
const specifier = match[1]!;
64+
const resolved = resolver(specifier, { request: fileName });
65+
if (!resolved || !isCSSModuleFile(resolved)) continue;
66+
67+
for (const change of codeFix.changes) {
68+
change.textChanges = change.textChanges.filter((textChange) => !textChange.newText.startsWith(`import {`));
69+
}
70+
codeFix.changes = codeFix.changes.filter((change) => change.textChanges.length > 0);
71+
}
72+
}
73+
4974
interface TokenConsumer {
5075
/** The token name (e.g. `foo` in `styles.foo`) */
5176
tokenName: string;

0 commit comments

Comments
 (0)