Skip to content

Commit 9aeeb86

Browse files
committed
use namespace import if named exports
1 parent 38826e2 commit 9aeeb86

6 files changed

Lines changed: 118 additions & 5 deletions

File tree

packages/core/src/file.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { join, parse } from './path.js';
33
import type { MatchesPattern } from './type.js';
44

55
export const CSS_MODULE_EXTENSION = '.module.css';
6+
export const CSS_MODULE_DTS_EXTENSION = `.module.css.d.ts`;
7+
export const ARBITRARY_CSS_MODULE_DTS_EXTENSION = '.module.d.css.ts';
68
const COMPONENT_EXTENSIONS = ['.tsx', '.jsx'];
79

810
export function isCSSModuleFile(fileName: string): boolean {
@@ -14,6 +16,14 @@ export function getCssModuleFileName(tsFileName: string): string {
1416
return join(dir, `${name}${CSS_MODULE_EXTENSION}`);
1517
}
1618

19+
export function isCSSModulesDtsFile(fileName: string, arbitraryExtensions: boolean): boolean {
20+
if (arbitraryExtensions) {
21+
return fileName.endsWith(ARBITRARY_CSS_MODULE_DTS_EXTENSION);
22+
} else {
23+
return fileName.endsWith(CSS_MODULE_DTS_EXTENSION);
24+
}
25+
}
26+
1727
export function isComponentFileName(fileName: string): boolean {
1828
// NOTE: Do not check whether it is an upper camel case or not, since lower camel case (e.g. `page.tsx`) is used in Next.js.
1929
return COMPONENT_EXTENSIONS.some((ext) => fileName.endsWith(ext));

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export { createResolver } from './resolver.js';
2626
export {
2727
CSS_MODULE_EXTENSION,
2828
getCssModuleFileName,
29+
isCSSModulesDtsFile,
2930
isComponentFileName,
3031
isCSSModuleFile,
3132
findComponentFile,

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) {
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: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { isComponentFileName } from '@css-modules-kit/core';
1+
import type { CMKConfig, Resolver } from '@css-modules-kit/core';
2+
import { isComponentFileName, isCSSModuleFile, STYLES_EXPORT_NAME } from '@css-modules-kit/core';
23
import type { Language } from '@volar/language-core';
34
import ts from 'typescript';
45
import { isCSSModuleScript } from '../../language-plugin.js';
@@ -10,13 +11,19 @@ export function getCodeFixesAtPosition(
1011
language: Language<string>,
1112
languageService: ts.LanguageService,
1213
project: ts.server.Project,
14+
resolver: Resolver,
15+
config: CMKConfig,
1316
): ts.LanguageService['getCodeFixesAtPosition'] {
1417
// eslint-disable-next-line max-params
1518
return (fileName, start, end, errorCodes, formatOptions, preferences) => {
1619
const prior = Array.from(
1720
languageService.getCodeFixesAtPosition(fileName, start, end, errorCodes, formatOptions, preferences) ?? [],
1821
);
1922

23+
if (config.namedExports) {
24+
convertDefaultImportsToNamespaceImports(prior, fileName, resolver);
25+
}
26+
2027
if (isComponentFileName(fileName)) {
2128
// If a user is trying to use a non-existent token (e.g. `styles.nonExistToken`), provide a code fix to add the token.
2229
if (errorCodes.includes(PROPERTY_DOES_NOT_EXIST_ERROR_CODE)) {
@@ -35,6 +42,35 @@ export function getCodeFixesAtPosition(
3542
};
3643
}
3744

45+
function convertDefaultImportsToNamespaceImports(
46+
codeFixes: ts.CodeFixAction[],
47+
fileName: string,
48+
resolver: Resolver,
49+
): void {
50+
// Convert default imports to namespace imports for CSS modules.
51+
// For example, convert `import styles from './styles.module.css'` to `import * as styles from './styles.module.css'`.
52+
for (const codeFix of codeFixes) {
53+
if (codeFix.fixName === 'import') {
54+
// Check if the code fix is to add an import for a CSS module.
55+
const match = codeFix.description.match(/^Add import from "(.*)"$/u);
56+
if (!match) continue;
57+
const specifier = match[1]!;
58+
const resolved = resolver(specifier, { request: fileName });
59+
if (!resolved || !isCSSModuleFile(resolved)) continue;
60+
61+
// If the specifier is a CSS module, convert the import to a namespace import.
62+
for (const change of codeFix.changes) {
63+
for (const textChange of change.textChanges) {
64+
textChange.newText = textChange.newText.replace(
65+
`import ${STYLES_EXPORT_NAME} from`,
66+
`import * as ${STYLES_EXPORT_NAME} from`,
67+
);
68+
}
69+
}
70+
}
71+
}
72+
}
73+
3874
interface TokenConsumer {
3975
/** The token name (e.g. `foo` in `styles.foo`) */
4076
tokenName: string;

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

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import type { CMKConfig } from '@css-modules-kit/core';
2-
import { getCssModuleFileName, isComponentFileName, isCSSModuleFile, STYLES_EXPORT_NAME } from '@css-modules-kit/core';
2+
import {
3+
getCssModuleFileName,
4+
isComponentFileName,
5+
isCSSModuleFile,
6+
isCSSModulesDtsFile,
7+
STYLES_EXPORT_NAME,
8+
} from '@css-modules-kit/core';
39
import ts from 'typescript';
410

511
export function getCompletionsAtPosition(
@@ -80,3 +86,57 @@ function isClassNamePropEntry(entry: ts.CompletionEntry) {
8086
entry.isSnippet
8187
);
8288
}
89+
90+
export function getCompletionEntryDetails(
91+
languageService: ts.LanguageService,
92+
config: CMKConfig,
93+
): ts.LanguageService['getCompletionEntryDetails'] {
94+
// eslint-disable-next-line max-params
95+
return (fileName, position, entryName, formatOptions, source, preferences, data) => {
96+
const details = languageService.getCompletionEntryDetails(
97+
fileName,
98+
position,
99+
entryName,
100+
formatOptions,
101+
source,
102+
preferences,
103+
data,
104+
);
105+
if (!details) return undefined;
106+
107+
if (config.namedExports && isDefaultExportedStylesEntryDetails(entryName, data, config)) {
108+
for (const codeAction of details.codeActions ?? []) {
109+
for (const change of codeAction.changes) {
110+
// `import styles from` => `import * as styles from`
111+
for (const textChange of change.textChanges) {
112+
if (textChange.newText.startsWith(`import ${STYLES_EXPORT_NAME} from`)) {
113+
textChange.newText = textChange.newText.replace(
114+
`import ${STYLES_EXPORT_NAME} from`,
115+
`import * as ${STYLES_EXPORT_NAME} from`,
116+
);
117+
}
118+
}
119+
}
120+
}
121+
}
122+
return details;
123+
};
124+
}
125+
126+
/**
127+
* Check if the completion entry details are the default exported `styles` entry.
128+
*/
129+
function isDefaultExportedStylesEntryDetails(
130+
entryName: string,
131+
data: ts.CompletionEntryData | undefined,
132+
config: CMKConfig,
133+
): boolean {
134+
return (
135+
entryName === STYLES_EXPORT_NAME &&
136+
!!data &&
137+
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
138+
data.exportName === ts.InternalSymbolName.Default &&
139+
data.fileName !== undefined &&
140+
isCSSModulesDtsFile(data.fileName, config.arbitraryExtensions)
141+
);
142+
}

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);
51+
proxy.getCompletionEntryDetails = getCompletionEntryDetails(languageService, config);
52+
proxy.getCodeFixesAtPosition = getCodeFixesAtPosition(language, languageService, project, resolver, config);
5253

5354
return proxy;
5455
}

0 commit comments

Comments
 (0)