Skip to content

Commit b87fd27

Browse files
committed
support prioritizeNamedImports option
1 parent 2cbcdc4 commit b87fd27

8 files changed

Lines changed: 170 additions & 19 deletions

File tree

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 effects only `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/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;

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

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import type { CMKConfig } from '@css-modules-kit/core';
2-
import { getCssModuleFileName, isComponentFileName, STYLES_EXPORT_NAME } from '@css-modules-kit/core';
1+
import type { CMKConfig, Resolver } from '@css-modules-kit/core';
2+
import { getCssModuleFileName, isComponentFileName, isCSSModuleFile, STYLES_EXPORT_NAME } from '@css-modules-kit/core';
33
import ts from 'typescript';
4-
import { createPreferencesForCompletion } from '../../util.js';
4+
import { convertDefaultImportsToNamespaceImports, createPreferencesForCompletion } from '../../util.js';
55

66
export function getCompletionsAtPosition(
77
languageService: ts.LanguageService,
@@ -20,7 +20,7 @@ export function getCompletionsAtPosition(
2020
if (isComponentFileName(fileName)) {
2121
const cssModuleFileName = getCssModuleFileName(fileName);
2222
for (const entry of prior.entries) {
23-
if (isStylesEntryForCSSModuleFile(entry, cssModuleFileName)) {
23+
if (isDefaultExportedStylesEntry(entry) && entry.data.fileName === cssModuleFileName) {
2424
// Prioritize the completion of the `styles' import for the current .ts file for usability.
2525
// NOTE: This is a hack to make the completion item appear at the top
2626
entry.sortText = '0';
@@ -30,21 +30,52 @@ export function getCompletionsAtPosition(
3030
}
3131
}
3232
}
33+
if (config.namedExports && !config.prioritizeNamedImports) {
34+
// When `namedExports` is enabled, you can write code as follows:
35+
// ```tsx
36+
// import { button } from './a.module.css';
37+
// const Button = () => <button className={button}>Click me!</button>;
38+
// ```
39+
// However, it is more common to use namespace imports for styles.
40+
// ```tsx
41+
// import * as styles from './a.module.css';
42+
// const Button = () => <button className={styles.button}>Click me!</button>;
43+
// ```
44+
// Therefore, completion for tokens like `button` is disabled.
45+
prior.entries = prior.entries.filter((entry) => !isNamedExportedTokenEntry(entry));
46+
}
3347
return prior;
3448
};
3549
}
3650

51+
type DefaultExportedStylesEntry = ts.CompletionEntry & {
52+
data: ts.CompletionEntryData;
53+
};
54+
3755
/**
38-
* Check if the completion entry is the `styles` entry for the CSS module file.
56+
* Check if the completion entry is the default exported `styles` entry.
3957
*/
40-
function isStylesEntryForCSSModuleFile(entry: ts.CompletionEntry, cssModuleFileName: string) {
58+
function isDefaultExportedStylesEntry(entry: ts.CompletionEntry): entry is DefaultExportedStylesEntry {
4159
return (
4260
entry.name === STYLES_EXPORT_NAME &&
43-
entry.data &&
61+
entry.data !== undefined &&
4462
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
4563
entry.data.exportName === ts.InternalSymbolName.Default &&
46-
entry.data.fileName &&
47-
entry.data.fileName === cssModuleFileName
64+
entry.data.fileName !== undefined &&
65+
isCSSModuleFile(entry.data.fileName)
66+
);
67+
}
68+
69+
/**
70+
* Check if the completion entry is a named exported token entry.
71+
*/
72+
function isNamedExportedTokenEntry(entry: ts.CompletionEntry): boolean {
73+
return (
74+
entry.data !== undefined &&
75+
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
76+
entry.data.exportName !== ts.InternalSymbolName.Default &&
77+
entry.data.fileName !== undefined &&
78+
isCSSModuleFile(entry.data.fileName)
4879
);
4980
}
5081

@@ -56,3 +87,28 @@ function isClassNamePropEntry(entry: ts.CompletionEntry) {
5687
entry.isSnippet
5788
);
5889
}
90+
91+
export function getCompletionEntryDetails(
92+
languageService: ts.LanguageService,
93+
resolver: Resolver,
94+
config: CMKConfig,
95+
): ts.LanguageService['getCompletionEntryDetails'] {
96+
// eslint-disable-next-line max-params
97+
return (fileName, position, entryName, formatOptions, source, preferences, data) => {
98+
const details = languageService.getCompletionEntryDetails(
99+
fileName,
100+
position,
101+
entryName,
102+
formatOptions,
103+
source,
104+
preferences,
105+
data,
106+
);
107+
if (!details) return undefined;
108+
109+
if (config.namedExports && !config.prioritizeNamedImports && details.codeActions) {
110+
convertDefaultImportsToNamespaceImports(details.codeActions, fileName, resolver);
111+
}
112+
return details;
113+
};
114+
}

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { Language } from '@volar/language-core';
44
import type ts from 'typescript';
55
import { CMK_DATA_KEY, isCSSModuleScript } from '../language-plugin.js';
66
import { getCodeFixesAtPosition } from './feature/code-fix.js';
7-
import { getCompletionsAtPosition } from './feature/completion.js';
7+
import { getCompletionEntryDetails, getCompletionsAtPosition } from './feature/completion.js';
88
import { getApplicableRefactors, getEditsForRefactor } from './feature/refactor.js';
99
import { getSemanticDiagnostics } from './feature/semantic-diagnostic.js';
1010
import { getSyntacticDiagnostics } from './feature/syntactic-diagnostic.js';
@@ -48,7 +48,8 @@ export function proxyLanguageService(
4848
proxy.getApplicableRefactors = getApplicableRefactors(languageService, project);
4949
proxy.getEditsForRefactor = getEditsForRefactor(languageService);
5050
proxy.getCompletionsAtPosition = getCompletionsAtPosition(languageService, config);
51-
proxy.getCodeFixesAtPosition = getCodeFixesAtPosition(language, languageService, project, config);
51+
proxy.getCompletionEntryDetails = getCompletionEntryDetails(languageService, resolver, config);
52+
proxy.getCodeFixesAtPosition = getCodeFixesAtPosition(language, languageService, project, resolver, config);
5253

5354
return proxy;
5455
}

packages/ts-plugin/src/util.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { CMKConfig } from '@css-modules-kit/core';
1+
import { type CMKConfig, isCSSModuleFile, type Resolver, STYLES_EXPORT_NAME } from '@css-modules-kit/core';
22
import ts from 'typescript';
33

44
/** The error code used by tsserver to display the css-modules-kit error in the editor. */
@@ -27,3 +27,32 @@ export function createPreferencesForCompletion<T extends ts.UserPreferences>(pre
2727
autoImportFileExcludePatterns: [...(preferences.autoImportFileExcludePatterns ?? []), config.dtsOutDir],
2828
};
2929
}
30+
/**
31+
* Convert default imports to namespace imports for CSS modules.
32+
* For example, convert `import styles from './styles.module.css'` to `import * as styles from './styles.module.css'`.
33+
*/
34+
export function convertDefaultImportsToNamespaceImports(
35+
codeFixes: ts.CodeFixAction[] | ts.CodeAction[],
36+
fileName: string,
37+
resolver: Resolver,
38+
): void {
39+
for (const codeFix of codeFixes) {
40+
if ('fixName' in codeFix && codeFix.fixName !== 'import') continue;
41+
// Check if the code fix is to add an import for a CSS module.
42+
const match = codeFix.description.match(/^Add import from "(.*)"$/u);
43+
if (!match) continue;
44+
const specifier = match[1]!;
45+
const resolved = resolver(specifier, { request: fileName });
46+
if (!resolved || !isCSSModuleFile(resolved)) continue;
47+
48+
// If the specifier is a CSS module, convert the import to a namespace import.
49+
for (const change of codeFix.changes) {
50+
for (const textChange of change.textChanges) {
51+
textChange.newText = textChange.newText.replace(
52+
`import ${STYLES_EXPORT_NAME} from`,
53+
`import * as ${STYLES_EXPORT_NAME} from`,
54+
);
55+
}
56+
}
57+
}
58+
}

0 commit comments

Comments
 (0)