diff --git a/.changeset/moody-hornets-hammer.md b/.changeset/moody-hornets-hammer.md new file mode 100644 index 00000000..85a8378f --- /dev/null +++ b/.changeset/moody-hornets-hammer.md @@ -0,0 +1,6 @@ +--- +'@css-modules-kit/ts-plugin': minor +'@css-modules-kit/core': minor +--- + +feat: support `prioritizeNamedImports` option diff --git a/README.md b/README.md index 7a7c167e..7719a981 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,26 @@ Determines whether to generate named exports in the d.ts file instead of a defau } ``` +### `cmkOptions.prioritizeNamedImports` + +Type: `boolean`, Default: `false` + +Whether to prioritize named imports over namespace imports when adding import statements. This option only takes effect when `cmkOptions.namedExports` is `true`. + +When this option is `true`, `import { button } from '...'` will be added. When this option is `false`, `import button from '...'` will be added. + +```jsonc +{ + "compilerOptions": { + // ... + }, + "cmkOptions": { + "namedExports": true, + "prioritizeNamedImports": true, + }, +} +``` + ## Limitations - Sass/Less are not supported to simplify the implementation diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts index 3c26a22a..308d4611 100644 --- a/packages/core/src/config.test.ts +++ b/packages/core/src/config.test.ts @@ -29,7 +29,8 @@ describe('readTsConfigFile', () => { "cmkOptions": { "dtsOutDir": "generated/cmk", "arbitraryExtensions": false, - "namedExports": true + "namedExports": true, + "prioritizeNamedImports": true } } `, @@ -42,6 +43,7 @@ describe('readTsConfigFile', () => { dtsOutDir: 'generated/cmk', arbitraryExtensions: false, namedExports: true, + prioritizeNamedImports: true, }, compilerOptions: expect.objectContaining({ module: ts.ModuleKind.ESNext, diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 7b98fa67..039518ba 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -15,8 +15,8 @@ export interface CMKConfig { excludes: string[]; dtsOutDir: string; arbitraryExtensions: boolean; - /** Whether to generate named exports in the d.ts file instead of a default export. */ namedExports: boolean; + prioritizeNamedImports: boolean; /** * A root directory to resolve relative path entries in the config file to. * This is an absolute path. @@ -71,6 +71,7 @@ interface UnnormalizedRawConfig { dtsOutDir?: string; arbitraryExtensions?: boolean; namedExports?: boolean; + prioritizeNamedImports?: boolean; } /** @@ -148,6 +149,17 @@ function parseRawData(raw: unknown, tsConfigSourceFile: ts.TsConfigSourceFile): }); } } + if ('prioritizeNamedImports' in raw.cmkOptions) { + if (typeof raw.cmkOptions.prioritizeNamedImports === 'boolean') { + result.config.prioritizeNamedImports = raw.cmkOptions.prioritizeNamedImports; + } else { + result.diagnostics.push({ + category: 'error', + text: `\`prioritizeNamedImports\` in ${tsConfigSourceFile.fileName} must be a boolean.`, + // MEMO: Location information can be obtained from `tsConfigSourceFile.statements`, but this is complicated and will be omitted. + }); + } + } } return result; } @@ -242,6 +254,7 @@ export function readConfigFile(project: string): CMKConfig { dtsOutDir: join(basePath, config.dtsOutDir ?? 'generated'), arbitraryExtensions: config.arbitraryExtensions ?? false, namedExports: config.namedExports ?? false, + prioritizeNamedImports: config.prioritizeNamedImports ?? false, basePath, configFileName, compilerOptions, diff --git a/packages/ts-plugin/e2e/named-exports.test.ts b/packages/ts-plugin/e2e/named-exports.test.ts index 075e215d..4204b4e1 100644 --- a/packages/ts-plugin/e2e/named-exports.test.ts +++ b/packages/ts-plugin/e2e/named-exports.test.ts @@ -212,7 +212,7 @@ describe('supports completions', async () => { .a_1 { color: red; } `, }); - describe('prioritize named imports by default', async () => { + describe('prioritize namespace imports by default', async () => { const iff = await baseIff.fork({ 'tsconfig.json': dedent` { @@ -231,6 +231,54 @@ describe('supports completions', async () => { includeCompletionsForModuleExports: true, }, }); + test.each([ + { + name: 'styles', + entryName: 'styles', + file: iff.paths['index.ts'], + line: 1, + offset: 7, + expected: [{ name: 'styles', sortText: '16', source: formatPath(iff.paths['a.module.css']) }], + }, + { + name: 'a_1', + entryName: 'a_1', + file: iff.paths['index.ts'], + line: 2, + offset: 4, + expected: [], + }, + ])('Completions for $name', async ({ entryName, file, line, offset, expected }) => { + const res = await tsserver.sendCompletionInfo({ + file, + line, + offset, + }); + expect( + normalizeCompletionEntry(res.body?.entries.filter((entry) => entry.name === entryName) ?? []), + ).toStrictEqual(normalizeCompletionEntry(expected)); + }); + }); + describe('prioritize named imports if prioritizeNamedImports is true', async () => { + const iff = await baseIff.fork({ + 'tsconfig.json': dedent` + { + "cmkOptions": { + "namedExports": true, + "prioritizeNamedImports": true + } + } + `, + }); + const tsserver = launchTsserver(); + await tsserver.sendUpdateOpen({ + openFiles: [{ file: iff.paths['tsconfig.json'] }], + }); + await tsserver.sendConfigure({ + preferences: { + includeCompletionsForModuleExports: true, + }, + }); test.each([ { name: 'styles', @@ -271,7 +319,7 @@ describe('supports code fixes', async () => { .a_1 { color: red; } `, }); - describe('prioritize named imports by default', async () => { + describe('prioritize namespace imports by default', async () => { const iff = await baseIff.fork({ 'tsconfig.json': dedent` { @@ -279,6 +327,68 @@ describe('supports code fixes', async () => { "namedExports": true } } + `, + }); + const tsserver = launchTsserver(); + await tsserver.sendUpdateOpen({ + openFiles: [{ file: iff.paths['tsconfig.json'] }], + }); + test.each([ + { + name: 'styles', + file: iff.paths['index.ts'], + startLine: 1, + startOffset: 1, + endLine: 1, + endOffset: 7, + expected: [ + { + fixName: 'import', + changes: [ + { + fileName: formatPath(iff.paths['index.ts']), + textChanges: [ + { + start: { line: 1, offset: 1 }, + end: { line: 1, offset: 1 }, + newText: `import * as styles from "./a.module.css";${ts.sys.newLine}${ts.sys.newLine}`, + }, + ], + }, + ], + }, + ], + }, + { + name: 'a_1', + file: iff.paths['index.ts'], + startLine: 2, + startOffset: 1, + endLine: 2, + endOffset: 4, + expected: [], + }, + ])('$name', async ({ file, startLine, startOffset, endLine, endOffset, expected }) => { + const res = await tsserver.sendGetCodeFixes({ + errorCodes: [2304], + file, + startLine, + startOffset, + endLine, + endOffset, + }); + expect(normalizeCodeFixActions(res.body!)).toStrictEqual(normalizeCodeFixActions(expected)); + }); + }); + describe('prioritize named imports if prioritizeNamedImports is true', async () => { + const iff = await baseIff.fork({ + 'tsconfig.json': dedent` + { + "cmkOptions": { + "namedExports": true, + "prioritizeNamedImports": true + } + } `, }); const tsserver = launchTsserver(); diff --git a/packages/ts-plugin/src/language-plugin.ts b/packages/ts-plugin/src/language-plugin.ts index a44fb578..c30f229a 100644 --- a/packages/ts-plugin/src/language-plugin.ts +++ b/packages/ts-plugin/src/language-plugin.ts @@ -53,11 +53,16 @@ export function createCSSLanguagePlugin( // So, ts-plugin uses a fault-tolerant Parser to parse CSS. safe: true, }); - const { text, mapping, linkedCodeMapping } = createDts(cssModule, { + // eslint-disable-next-line prefer-const + let { text, mapping, linkedCodeMapping } = createDts(cssModule, { resolver, matchesPattern, namedExports: config.namedExports, }); + if (config.namedExports && !config.prioritizeNamedImports) { + // Export `styles` to appear in code completion suggestions + text += 'declare const styles: {};\nexport default styles;\n'; + } return { id: 'main', languageId: LANGUAGE_ID, diff --git a/packages/ts-plugin/src/language-service/feature/code-fix.ts b/packages/ts-plugin/src/language-service/feature/code-fix.ts index d769b701..306992c9 100644 --- a/packages/ts-plugin/src/language-service/feature/code-fix.ts +++ b/packages/ts-plugin/src/language-service/feature/code-fix.ts @@ -1,9 +1,9 @@ -import type { CMKConfig } from '@css-modules-kit/core'; -import { isComponentFileName } from '@css-modules-kit/core'; +import type { CMKConfig, Resolver } from '@css-modules-kit/core'; +import { isComponentFileName, isCSSModuleFile } from '@css-modules-kit/core'; import type { Language } from '@volar/language-core'; import ts from 'typescript'; import { isCSSModuleScript } from '../../language-plugin.js'; -import { createPreferencesForCompletion } from '../../util.js'; +import { convertDefaultImportsToNamespaceImports, createPreferencesForCompletion } from '../../util.js'; // ref: https://github.com/microsoft/TypeScript/blob/220706eb0320ff46fad8bf80a5e99db624ee7dfb/src/compiler/diagnosticMessages.json export const CANNOT_FIND_NAME_ERROR_CODE = 2304; @@ -13,6 +13,7 @@ export function getCodeFixesAtPosition( language: Language, languageService: ts.LanguageService, project: ts.server.Project, + resolver: Resolver, config: CMKConfig, ): ts.LanguageService['getCodeFixesAtPosition'] { // eslint-disable-next-line max-params @@ -28,6 +29,11 @@ export function getCodeFixesAtPosition( ), ); + if (config.namedExports && !config.prioritizeNamedImports) { + convertDefaultImportsToNamespaceImports(prior, fileName, resolver); + excludeNamedImports(prior, fileName, resolver); + } + if (isComponentFileName(fileName)) { // If a user is trying to use a non-existent token (e.g. `styles.nonExistToken`), provide a code fix to add the token. if (errorCodes.includes(PROPERTY_DOES_NOT_EXIST_ERROR_CODE)) { @@ -42,10 +48,29 @@ export function getCodeFixesAtPosition( } } - return prior; + return prior.filter((codeFix) => codeFix.changes.length > 0); }; } +/** + * Exclude code fixes that add named imports (e.g. `import { foo } from './a.module.css'`) + */ +function excludeNamedImports(codeFixes: ts.CodeFixAction[], fileName: string, resolver: Resolver): void { + for (const codeFix of codeFixes) { + if (codeFix.fixName !== 'import') continue; + const match = codeFix.description.match(/^Add import from "(.*)"$/u); + if (!match) continue; + const specifier = match[1]!; + const resolved = resolver(specifier, { request: fileName }); + if (!resolved || !isCSSModuleFile(resolved)) continue; + + for (const change of codeFix.changes) { + change.textChanges = change.textChanges.filter((textChange) => !textChange.newText.startsWith(`import {`)); + } + codeFix.changes = codeFix.changes.filter((change) => change.textChanges.length > 0); + } +} + interface TokenConsumer { /** The token name (e.g. `foo` in `styles.foo`) */ tokenName: string; diff --git a/packages/ts-plugin/src/language-service/feature/completion.ts b/packages/ts-plugin/src/language-service/feature/completion.ts index 26ac9e83..e7430c99 100644 --- a/packages/ts-plugin/src/language-service/feature/completion.ts +++ b/packages/ts-plugin/src/language-service/feature/completion.ts @@ -1,7 +1,7 @@ -import type { CMKConfig } from '@css-modules-kit/core'; -import { getCssModuleFileName, isComponentFileName, STYLES_EXPORT_NAME } from '@css-modules-kit/core'; +import type { CMKConfig, Resolver } from '@css-modules-kit/core'; +import { getCssModuleFileName, isComponentFileName, isCSSModuleFile, STYLES_EXPORT_NAME } from '@css-modules-kit/core'; import ts from 'typescript'; -import { createPreferencesForCompletion } from '../../util.js'; +import { convertDefaultImportsToNamespaceImports, createPreferencesForCompletion } from '../../util.js'; export function getCompletionsAtPosition( languageService: ts.LanguageService, @@ -20,7 +20,7 @@ export function getCompletionsAtPosition( if (isComponentFileName(fileName)) { const cssModuleFileName = getCssModuleFileName(fileName); for (const entry of prior.entries) { - if (isStylesEntryForCSSModuleFile(entry, cssModuleFileName)) { + if (isDefaultExportedStylesEntry(entry) && entry.data.fileName === cssModuleFileName) { // Prioritize the completion of the `styles' import for the current .ts file for usability. // NOTE: This is a hack to make the completion item appear at the top entry.sortText = '0'; @@ -30,21 +30,52 @@ export function getCompletionsAtPosition( } } } + if (config.namedExports && !config.prioritizeNamedImports) { + // When `namedExports` is enabled, you can write code as follows: + // ```tsx + // import { button } from './a.module.css'; + // const Button = () => ; + // ``` + // However, it is more common to use namespace imports for styles. + // ```tsx + // import * as styles from './a.module.css'; + // const Button = () => ; + // ``` + // Therefore, completion for tokens like `button` is disabled. + prior.entries = prior.entries.filter((entry) => !isNamedExportedTokenEntry(entry)); + } return prior; }; } +type DefaultExportedStylesEntry = ts.CompletionEntry & { + data: ts.CompletionEntryData; +}; + /** - * Check if the completion entry is the `styles` entry for the CSS module file. + * Check if the completion entry is the default exported `styles` entry. */ -function isStylesEntryForCSSModuleFile(entry: ts.CompletionEntry, cssModuleFileName: string) { +function isDefaultExportedStylesEntry(entry: ts.CompletionEntry): entry is DefaultExportedStylesEntry { return ( entry.name === STYLES_EXPORT_NAME && - entry.data && + entry.data !== undefined && // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison entry.data.exportName === ts.InternalSymbolName.Default && - entry.data.fileName && - entry.data.fileName === cssModuleFileName + entry.data.fileName !== undefined && + isCSSModuleFile(entry.data.fileName) + ); +} + +/** + * Check if the completion entry is a named exported token entry. + */ +function isNamedExportedTokenEntry(entry: ts.CompletionEntry): boolean { + return ( + entry.data !== undefined && + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + entry.data.exportName !== ts.InternalSymbolName.Default && + entry.data.fileName !== undefined && + isCSSModuleFile(entry.data.fileName) ); } @@ -56,3 +87,28 @@ function isClassNamePropEntry(entry: ts.CompletionEntry) { entry.isSnippet ); } + +export function getCompletionEntryDetails( + languageService: ts.LanguageService, + resolver: Resolver, + config: CMKConfig, +): ts.LanguageService['getCompletionEntryDetails'] { + // eslint-disable-next-line max-params + return (fileName, position, entryName, formatOptions, source, preferences, data) => { + const details = languageService.getCompletionEntryDetails( + fileName, + position, + entryName, + formatOptions, + source, + preferences, + data, + ); + if (!details) return undefined; + + if (config.namedExports && !config.prioritizeNamedImports && details.codeActions) { + convertDefaultImportsToNamespaceImports(details.codeActions, fileName, resolver); + } + return details; + }; +} diff --git a/packages/ts-plugin/src/language-service/proxy.ts b/packages/ts-plugin/src/language-service/proxy.ts index 9c4bf156..8a4f4361 100644 --- a/packages/ts-plugin/src/language-service/proxy.ts +++ b/packages/ts-plugin/src/language-service/proxy.ts @@ -4,7 +4,7 @@ import type { Language } from '@volar/language-core'; import type ts from 'typescript'; import { CMK_DATA_KEY, isCSSModuleScript } from '../language-plugin.js'; import { getCodeFixesAtPosition } from './feature/code-fix.js'; -import { getCompletionsAtPosition } from './feature/completion.js'; +import { getCompletionEntryDetails, getCompletionsAtPosition } from './feature/completion.js'; import { getApplicableRefactors, getEditsForRefactor } from './feature/refactor.js'; import { getSemanticDiagnostics } from './feature/semantic-diagnostic.js'; import { getSyntacticDiagnostics } from './feature/syntactic-diagnostic.js'; @@ -48,7 +48,8 @@ export function proxyLanguageService( proxy.getApplicableRefactors = getApplicableRefactors(languageService, project); proxy.getEditsForRefactor = getEditsForRefactor(languageService); proxy.getCompletionsAtPosition = getCompletionsAtPosition(languageService, config); - proxy.getCodeFixesAtPosition = getCodeFixesAtPosition(language, languageService, project, config); + proxy.getCompletionEntryDetails = getCompletionEntryDetails(languageService, resolver, config); + proxy.getCodeFixesAtPosition = getCodeFixesAtPosition(language, languageService, project, resolver, config); return proxy; } diff --git a/packages/ts-plugin/src/util.ts b/packages/ts-plugin/src/util.ts index c377ba6c..f2e73195 100644 --- a/packages/ts-plugin/src/util.ts +++ b/packages/ts-plugin/src/util.ts @@ -1,4 +1,4 @@ -import type { CMKConfig } from '@css-modules-kit/core'; +import { type CMKConfig, isCSSModuleFile, type Resolver, STYLES_EXPORT_NAME } from '@css-modules-kit/core'; import ts from 'typescript'; /** The error code used by tsserver to display the css-modules-kit error in the editor. */ @@ -27,3 +27,32 @@ export function createPreferencesForCompletion(pre autoImportFileExcludePatterns: [...(preferences.autoImportFileExcludePatterns ?? []), config.dtsOutDir], }; } +/** + * Convert default imports to namespace imports for CSS modules. + * For example, convert `import styles from './styles.module.css'` to `import * as styles from './styles.module.css'`. + */ +export function convertDefaultImportsToNamespaceImports( + codeFixes: ts.CodeFixAction[] | ts.CodeAction[], + fileName: string, + resolver: Resolver, +): void { + for (const codeFix of codeFixes) { + if ('fixName' in codeFix && codeFix.fixName !== 'import') continue; + // Check if the code fix is to add an import for a CSS module. + const match = codeFix.description.match(/^Add import from "(.*)"$/u); + if (!match) continue; + const specifier = match[1]!; + const resolved = resolver(specifier, { request: fileName }); + if (!resolved || !isCSSModuleFile(resolved)) continue; + + // If the specifier is a CSS module, convert the import to a namespace import. + for (const change of codeFix.changes) { + for (const textChange of change.textChanges) { + textChange.newText = textChange.newText.replace( + `import ${STYLES_EXPORT_NAME} from`, + `import * as ${STYLES_EXPORT_NAME} from`, + ); + } + } + } +}