Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/moody-hornets-hammer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@css-modules-kit/ts-plugin': minor
'@css-modules-kit/core': minor
---

feat: support `prioritizeNamedImports` option
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ describe('readTsConfigFile', () => {
"cmkOptions": {
"dtsOutDir": "generated/cmk",
"arbitraryExtensions": false,
"namedExports": true
"namedExports": true,
"prioritizeNamedImports": true
}
}
`,
Expand All @@ -42,6 +43,7 @@ describe('readTsConfigFile', () => {
dtsOutDir: 'generated/cmk',
arbitraryExtensions: false,
namedExports: true,
prioritizeNamedImports: true,
},
compilerOptions: expect.objectContaining({
module: ts.ModuleKind.ESNext,
Expand Down
15 changes: 14 additions & 1 deletion packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -71,6 +71,7 @@ interface UnnormalizedRawConfig {
dtsOutDir?: string;
arbitraryExtensions?: boolean;
namedExports?: boolean;
prioritizeNamedImports?: boolean;
}

/**
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down
114 changes: 112 additions & 2 deletions packages/ts-plugin/e2e/named-exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
{
Expand All @@ -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',
Expand Down Expand Up @@ -271,14 +319,76 @@ 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`
{
"cmkOptions": {
"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();
Expand Down
7 changes: 6 additions & 1 deletion packages/ts-plugin/src/language-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
33 changes: 29 additions & 4 deletions packages/ts-plugin/src/language-service/feature/code-fix.ts
Original file line number Diff line number Diff line change
@@ -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';
Comment thread
mizdra marked this conversation as resolved.

// ref: https://github.com/microsoft/TypeScript/blob/220706eb0320ff46fad8bf80a5e99db624ee7dfb/src/compiler/diagnosticMessages.json
export const CANNOT_FIND_NAME_ERROR_CODE = 2304;
Expand All @@ -13,6 +13,7 @@ export function getCodeFixesAtPosition(
language: Language<string>,
languageService: ts.LanguageService,
project: ts.server.Project,
resolver: Resolver,
config: CMKConfig,
): ts.LanguageService['getCodeFixesAtPosition'] {
// eslint-disable-next-line max-params
Expand All @@ -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)) {
Expand All @@ -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;
Expand Down
Loading