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 { convertDefaultImportsToNamespaceImports, createPreferencesForCompletion } from '../../util.js';
import { getPropertyAccessExpressionAtPosition } from '../ast.js';
const DEFAULT_EXPORT_NAME = 'default';
export function getCompletionsAtPosition(
languageService: ts.LanguageService,
config: CMKConfig,
): ts.LanguageService['getCompletionsAtPosition'] {
return (...args) => {
const [fileName, position, options, ...rest] = args;
const prior = languageService.getCompletionsAtPosition(
fileName,
position,
createPreferencesForCompletion(options ?? {}, config),
...rest,
);
if (!prior) return;
if (isComponentFileName(fileName)) {
const cssModuleFileName = getCssModuleFileName(fileName);
for (const entry of prior.entries) {
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';
} else if (isClassNamePropEntry(entry)) {
// Complete `className={...}` instead of `className="..."` for usability.
entry.insertText = 'className={$1}';
}
}
}
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));
// The d.ts adds `export default styles` so that the `styles` binding appears as an auto-import
// suggestion. As a side effect, the default export leaks into namespace member completions
// (`import * as styles from './a.module.css'; styles.|`) as a `default` member. The `default`
// member is not a token of the CSS module, so it is removed.
if (
prior.isMemberCompletion &&
prior.entries.some((entry) => entry.name === DEFAULT_EXPORT_NAME) &&
isCSSModuleNamespaceAccess(languageService, fileName, position)
) {
prior.entries = prior.entries.filter((entry) => entry.name !== DEFAULT_EXPORT_NAME);
}
}
return prior;
};
}
/**
* Check if the completion position accesses a member of a namespace import of a CSS module
* (e.g. the `styles.|` position in `import * as styles from './a.module.css'; styles.|`).
*/
function isCSSModuleNamespaceAccess(languageService: ts.LanguageService, fileName: string, position: number): boolean {
const sourceFile = languageService.getProgram()?.getSourceFile(fileName);
if (!sourceFile) return false;
const propertyAccess = getPropertyAccessExpressionAtPosition(sourceFile, position);
if (!propertyAccess) return false;
// Resolve the accessed expression (e.g. `styles` in `styles.foo`) to its CSS module file.
// The first definition points to the `styles` binding of `import * as styles from './a.module.css'`
// in this file, and the second resolves that binding to the CSS module file.
const [binding] = languageService.getDefinitionAtPosition(fileName, propertyAccess.expression.getStart()) ?? [];
if (!binding) return false;
const [moduleDefinition] = languageService.getDefinitionAtPosition(binding.fileName, binding.textSpan.start) ?? [];
return moduleDefinition !== undefined && isCSSModuleFile(moduleDefinition.fileName);
}
type DefaultExportedStylesEntry = ts.CompletionEntry & {
data: ts.CompletionEntryData;
};
/**
* Check if the completion entry is the default exported `styles` entry.
*/
function isDefaultExportedStylesEntry(entry: ts.CompletionEntry): entry is DefaultExportedStylesEntry {
return (
entry.name === STYLES_EXPORT_NAME &&
entry.data !== undefined &&
// oxlint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
entry.data.exportName === ts.InternalSymbolName.Default &&
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 &&
// oxlint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
entry.data.exportName !== ts.InternalSymbolName.Default &&
entry.data.fileName !== undefined &&
isCSSModuleFile(entry.data.fileName)
);
}
function isClassNamePropEntry(entry: ts.CompletionEntry) {
return (
entry.name === 'className' &&
entry.kind === ts.ScriptElementKind.memberVariableElement &&
(entry.insertText === 'className="$1"' || entry.insertText === "className='$1'") &&
entry.isSnippet
);
}
export function getCompletionEntryDetails(
languageService: ts.LanguageService,
resolver: Resolver,
config: CMKConfig,
): ts.LanguageService['getCompletionEntryDetails'] {
return (...args) => {
const [fileName] = args;
const details = languageService.getCompletionEntryDetails(...args);
if (!details) return undefined;
if (config.namedExports && !config.prioritizeNamedImports && details.codeActions) {
convertDefaultImportsToNamespaceImports(details.codeActions, fileName, resolver);
}
return details;
};
}