diff --git a/docs/content/rules/sort-imports.mdx b/docs/content/rules/sort-imports.mdx index ddf0dfae0..70c892bb3 100644 --- a/docs/content/rules/sort-imports.mdx +++ b/docs/content/rules/sort-imports.mdx @@ -288,7 +288,6 @@ Specifies the directory of the root `tsconfig.json` file (ex: `.`). This is use 'internal', ['parent-type', 'sibling-type', 'index-type'], ['parent', 'sibling', 'index'], - 'object', 'unknown', ] ``` @@ -296,28 +295,6 @@ Specifies the directory of the root `tsconfig.json` file (ex: `.`). This is use Specifies a list of import groups for sorting. Groups help organize imports into meaningful categories, making your code more readable and maintainable. -Predefined groups: - -- `'builtin'` — Node.js Built-in Modules. -- `'external'` — External modules installed in the project. -- `'internal'` — Your internal modules. -- `'parent'` — Modules from the parent directory. -- `'sibling'` — Modules from the same directory. -- `'side-effect'` — Side effect imports. -- `'side-effect-style'` — Side effect style imports. -- `'index'` — Main file from the current directory. -- `'object'` — TypeScript object imports. -- `'style'` — Styles. -- `'external-type'` — TypeScript type imports from external modules. -- `'builtin-type'` — TypeScript type imports from built-in modules. -- `'internal-type'` — TypeScript type imports from your internal modules. -- `'parent-type'` — TypeScript type imports from the parent directory. -- `'sibling-type'` — TypeScript type imports from the same directory. -- `'index-type'` — TypeScript type imports from the main directory file. -- `'unknown'` — Imports that don’t fit into any group specified in the `groups` option. - -If the `unknown` group is not specified in the `groups` option, it will automatically be added to the end of the list. - Each import will be assigned a single group specified in the `groups` option (or the `unknown` group if no match is found). The order of items in the `groups` option determines how groups are ordered. @@ -326,7 +303,54 @@ Within a given group, members will be sorted according to the `type`, `order`, ` Individual groups can be combined together by placing them in an array. The order of groups in that array does not matter. All members of the groups in the array will be sorted together as if they were part of a single group. -#### Example +Predefined groups are characterized by a single selector and potentially multiple modifiers. You may enter modifiers in any order, but the selector must always come at the end. + +#### Selectors + +The list of selectors is sorted from most to least important: + +- `'type'` — TypeScript type imports. +- `'side-effect-style'` — Side effect style imports. +- `'side-effect'` — Side effect imports. +- `'style'` — Styles. +- `'index'` — Main file from the current directory. +- `'sibling'` — Modules from the same directory. +- `'parent'` — Modules from the parent directory. +- `'internal'` — Your internal modules. +- `'builtin'` — Node.js Built-in Modules. +- `'external'` — External modules installed in the project. + +#### Modifiers + +- `'type'` — Typescript time imports. + +#### Important notes + +##### The `unknown` group + +Members that don’t fit into any group specified in the `groups` option will be placed in the `unknown` group. If the `unknown` group is not specified in the `groups` option, +the members will remain in their original order. + +##### Behavior when multiple groups match an element + +The lists of selectors and modifiers above are both sorted by importance, from most to least important. +In case of multiple groups matching an element, the following rules will be applied: + +1. Selector priority: `type`, `index`, ... will take precedence over `external` groups for example. +2. If the selector is the same, the group with the most modifiers matching will be selected. + +Example 1: + +```ts +import type { FC } from 'react' +``` + +`react` can be matched by the following groups, from most to least important: +- `type` (`type` selector). +- `type-external` (`type` modifier). +- `external`. + +Example 2 (The most important group is written in the comments): ```ts // 'builtin' - Node.js Built-in Modules @@ -345,21 +369,19 @@ import './set-production-env.js' import './styles.scss' // 'index' - Main file from the current directory import main from '.' -// 'object' - TypeScript object-imports -import log = console.log // 'style' - Styles import styles from './index.module.css' -// 'external-type' - TypeScript type imports +// 'type-external' - TypeScript type imports import type { FC } from 'react' -// 'builtin-type' - TypeScript type imports from Built-in Modules +// 'type-builtin' - TypeScript type imports from Built-in Modules import type { Server } from 'http' -// 'internal-type' - TypeScript type imports from your internal modules +// 'type-internal' - TypeScript type imports from your internal modules import type { User } from '~/users' -// 'parent-type' - TypeScript type imports from parent directory +// 'type-parent' - TypeScript type imports from parent directory import type { InputProps } from '../Input' -// 'sibling-type' - TypeScript type imports from the same directory +// 'type-sibling' - TypeScript type imports from the same directory import type { Details } from './data' -// 'index-type' - TypeScript type imports from main directory file +// 'type-index' - TypeScript type imports from main directory file import type { BaseOptions } from './index.d.ts' ``` @@ -384,25 +406,110 @@ This feature is only applicable when [`partitionByNewLine`](#partitionbynewline) ### customGroups - - type: - ``` + +Support for the object-based `customGroups` option is deprecated. + +Here is how to migrate from the old to the current API: + +Old API: +```ts +{ + value: { + "keyForValue1": "value1", + "keyForValue2": "value2" + }, + type: { + "keyForType1": "value1", + "keyForType2": "value2" + } +} +``` + +Current API: +```ts +[ { - value: { [groupName: string]: string | string[] } - type: { [groupName: string]: string | string[] } + "selector": "type", + "groupName": "keyForType1", + "elementNamePattern": "value1" + }, + { + "selector": "type", + "groupName": "keyForType2", + "elementNamePattern": "value2" + }, + { + "groupName": "keyForValue1", + "elementNamePattern": "value1" + }, + { + "groupName": "keyForValue2", + "elementNamePattern": "value2" } - ``` +] +``` + + + + type: `Array` -default: `{ value: {}, type: {} }` +default: `[]` + +You can define your own groups and use regex for matching very specific imports. + +A custom group definition may follow one of the two following interfaces: + +```ts +interface CustomGroupDefinition { + groupName: string + type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted' + order?: 'asc' | 'desc' + fallbackSort?: { type: string; order?: 'asc' | 'desc' } + newlinesInside?: 'always' | 'never' + selector?: string + modifiers?: string[] + elementNamePattern?: string | string[] | { pattern: string; flags?: string } | { pattern: string; flags?: string }[] +} +``` +An import will match a `CustomGroupDefinition` group if it matches all the filters of the custom group's definition. + +or: + +```ts +interface CustomGroupAnyOfDefinition { + groupName: string + type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted' + order?: 'asc' | 'desc' + fallbackSort?: { type: string; order?: 'asc' | 'desc' } + newlinesInside?: 'always' | 'never' + anyOf: Array<{ + selector?: string + modifiers?: string[] + elementNamePattern?: string | string[] | { pattern: string; flags?: string } | { pattern: string; flags?: string }[] + }> +} +``` + +An import will match a `CustomGroupAnyOfDefinition` group if it matches all the filters of at least one of the `anyOf` items. + +#### Attributes + +- `groupName` — The group's name, which needs to be put in the [`groups`](#groups) option. +- `selector` — Filter on the `selector` of the element. +- `modifiers` — Filter on the `modifiers` of the element. (All the modifiers of the element must be present in that list) +- `elementNamePattern` — If entered, will check that the name of the element matches the pattern entered. +- `type` — Overrides the [`type`](#type) option for that custom group. `unsorted` will not sort the group. +- `order` — Overrides the [`order`](#order) option for that custom group. +- `fallbackSort` — Overrides the [`fallbackSort`](#fallbacksort) option for that custom group. +- `newlinesInside` — Enforces a specific newline behavior between elements of the group. -Defines custom groups to match specific imports. +#### Match importance -Each key of the `value` or `type` fields represents a group name which you can then use in the `groups` option. The value for each key can either be of type: -- `string` — An import matching the value will be marked as part of the group referenced by the key. -- `string[]` — An import matching any of the values of the array will be marked as part of the group referenced by the key. -The order of values in the array does not matter. +The `customGroups` list is ordered: +The first custom group definition that matches an element will be used. -Custom group matching takes precedence over predefined group matching. +Custom groups have a higher priority than any predefined group. If you want a predefined group to take precedence over a custom group, +you must write a custom group definition that does the same as what the predefined group does (using `selector` and `modifiers` filters), and put it first in the list. #### Example @@ -416,18 +523,18 @@ Custom group matching takes precedence over predefined group matching. 'internal', ['parent-type', 'sibling-type', 'index-type'], ['parent', 'sibling', 'index'], - 'object', 'unknown', ], -+ customGroups: { // [!code ++] -+ value: { // [!code ++] -+ react: ['^react$', '^react-.+'], // [!code ++] -+ lodash: 'lodash', // [!code ++] ++ customGroups: [ // [!code ++] ++ { // [!code ++] ++ groupName: 'react', // [!code ++] ++ elementNamePattern: ['^react$', '^react-.+'], // [!code ++] + }, // [!code ++] -+ type: { // [!code ++] -+ react: ['^react$', '^react-.+'], // [!code ++] -+ } // [!code ++] -+ }, // [!code ++] ++ { // [!code ++] ++ groupName: 'lodash', // [!code ++] ++ elementNamePattern: 'lodash', // [!code ++] ++ } // [!code ++] ++ ], // [!code ++] } ``` @@ -472,10 +579,9 @@ Specifies which environment’s built-in modules should be recognized. If you ar 'internal', ['parent-type', 'sibling-type', 'index-type'], ['parent', 'sibling', 'index'], - 'object', 'unknown', ], - customGroups: { type: {}, value: {} }, + customGroups: [], environment: 'node', }, ], @@ -514,10 +620,9 @@ Specifies which environment’s built-in modules should be recognized. If you ar 'internal', ['parent-type', 'sibling-type', 'index-type'], ['parent', 'sibling', 'index'], - 'object', 'unknown', ], - customGroups: { type: {}, value: {} }, + customGroups: [], environment: 'node', }, ], diff --git a/rules/sort-imports.ts b/rules/sort-imports.ts index 190cf751e..ebbb7d54e 100644 --- a/rules/sort-imports.ts +++ b/rules/sort-imports.ts @@ -3,12 +3,18 @@ import type { TSESLint } from '@typescript-eslint/utils' import type { SortImportsSortingNode, + Selector, + Modifier, Options, Group, } from './sort-imports/types' -import type { DeprecatedCustomGroupsOption } from '../types/common-options' +import type { + DeprecatedCustomGroupsOption, + CustomGroupsOption, +} from '../types/common-options' import { + buildCustomGroupsArrayJsonSchema, partitionByCommentJsonSchema, partitionByNewLineJsonSchema, newlinesBetweenJsonSchema, @@ -23,17 +29,24 @@ import { ORDER_ERROR, } from '../utils/report-errors' import { validateNewlinesAndPartitionConfiguration } from '../utils/validate-newlines-and-partition-configuration' +import { validateGeneratedGroupsConfiguration } from '../utils/validate-generated-groups-configuration' import { validateSideEffectsConfiguration } from './sort-imports/validate-side-effects-configuration' -import { computeCommonPredefinedGroups } from './sort-imports/compute-common-predefined-groups' import { validateCustomSortConfiguration } from '../utils/validate-custom-sort-configuration' +import { getCustomGroupOverriddenOptions } from '../utils/get-custom-groups-compare-options' import { readClosestTsConfigByPath } from './sort-imports/read-closest-ts-config-by-path' import { validateGroupsConfiguration } from '../utils/validate-groups-configuration' import { getOptionsWithCleanGroups } from '../utils/get-options-with-clean-groups' +import { computeCommonSelectors } from './sort-imports/compute-common-selectors' import { isSideEffectOnlyGroup } from './sort-imports/is-side-effect-only-group' +import { generatePredefinedGroups } from '../utils/generate-predefined-groups' import { getEslintDisabledLines } from '../utils/get-eslint-disabled-lines' import { isNodeEslintDisabled } from '../utils/is-node-eslint-disabled' +import { doesCustomGroupMatch } from '../utils/does-custom-group-match' +import { singleCustomGroupJsonSchema } from './sort-imports/types' import { sortNodesByGroups } from '../utils/sort-nodes-by-groups' +import { allModifiers, allSelectors } from './sort-imports/types' import { createEslintRule } from '../utils/create-eslint-rule' +import { allDeprecatedSelectors } from './sort-imports/types' import { reportAllErrors } from '../utils/report-all-errors' import { shouldPartition } from '../utils/should-partition' import { computeGroup } from '../utils/compute-group' @@ -42,6 +55,11 @@ import { getSettings } from '../utils/get-settings' import { isSortable } from '../utils/is-sortable' import { complete } from '../utils/complete' +/** + * Cache computed groups by modifiers and selectors for performance + */ +let cachedGroupsByModifiersAndSelectors = new Map() + export type MESSAGE_ID = | 'missedSpacingBetweenImports' | 'unexpectedImportsGroupOrder' @@ -62,10 +80,8 @@ export default createEslintRule({ 'internal', ['parent-type', 'sibling-type', 'index-type'], ['parent', 'sibling', 'index'], - 'object', 'unknown', ], - customGroups: { value: {}, type: {} }, fallbackSort: { type: 'unsorted' }, internalPattern: ['^~/.+'], partitionByComment: false, @@ -75,6 +91,7 @@ export default createEslintRule({ sortSideEffects: false, type: 'alphabetical', environment: 'node', + customGroups: [], ignoreCase: true, locales: 'en-US', alphabet: '', @@ -82,33 +99,34 @@ export default createEslintRule({ } as const), ) - validateGroupsConfiguration({ - allowedPredefinedGroups: [ - 'side-effect-style', - 'external-type', - 'internal-type', - 'builtin-type', - 'sibling-type', - 'parent-type', - 'side-effect', - 'index-type', - 'internal', - 'external', - 'sibling', - 'unknown', - 'builtin', - 'parent', - 'object', - 'index', - 'style', - 'type', - ], - allowedCustomGroups: [ - ...Object.keys(options.customGroups.type ?? {}), - ...Object.keys(options.customGroups.value ?? {}), - ], - options, - }) + if (Array.isArray(options.customGroups)) { + validateGeneratedGroupsConfiguration({ + options: { + ...options, + customGroups: options.customGroups, + }, + selectors: [...allSelectors, ...allDeprecatedSelectors], + modifiers: allModifiers, + }) + } else { + let generatedGroups = generatePredefinedGroups({ + cache: cachedGroupsByModifiersAndSelectors, + selectors: allSelectors, + modifiers: allModifiers, + }) + validateGroupsConfiguration({ + allowedCustomGroups: [ + ...Object.keys(options.customGroups.type ?? {}), + ...Object.keys(options.customGroups.value ?? {}), + ], + allowedPredefinedGroups: [ + ...generatedGroups, + ...allDeprecatedSelectors, + 'unknown', + ], + options, + }) + } validateCustomSortConfiguration(options) validateNewlinesAndPartitionConfiguration(options) validateSideEffectsConfiguration(options) @@ -143,71 +161,83 @@ export default createEslintRule({ node, }) - let commonPredefinedGroups = computeCommonPredefinedGroups({ + let commonSelectors = computeCommonSelectors({ tsConfigOutput, filename, options, name, }) - let predefinedGroups: Group[] = [] + let selectors: Selector[] = [] + let modifiers: Modifier[] = [] let group: Group | null = null if (node.type !== 'VariableDeclaration' && node.importKind === 'type') { if (node.type === 'ImportDeclaration') { + if (!Array.isArray(options.customGroups)) { + // For deprecated `customGroups.type` + group = computeGroupExceptUnknown({ + customGroups: options.customGroups.type, + options, + name, + }) + } + + for (let selector of commonSelectors) { + selectors.push(`${selector}-type`) + } + } + + selectors.push('type') + modifiers.push('type') + + if (!group && !Array.isArray(options.customGroups)) { group = computeGroupExceptUnknown({ - customGroups: options.customGroups.type, - predefinedGroups: [], + customGroups: [], + selectors, + modifiers, options, name, }) - - for (let predefinedGroup of commonPredefinedGroups) { - predefinedGroups.push(`${predefinedGroup}-type`) - } } + } - predefinedGroups.push('type') + let isSideEffect = isSideEffectImport({ sourceCode, node }) + let isStyleValue = isStyle(name) + let isStyleSideEffect = isSideEffect && isStyleValue - group ??= computeGroupExceptUnknown({ - predefinedGroups, + if (!group && !Array.isArray(options.customGroups)) { + // For deprecated `customGroups.value` + group = computeGroupExceptUnknown({ + customGroups: options.customGroups.value, options, name, }) } - let isSideEffect = isSideEffectImport({ sourceCode, node }) - let isStyleSideEffect = false - - let isStyleValue = isStyle(name) - isStyleSideEffect = isSideEffect && isStyleValue - - group ??= computeGroupExceptUnknown({ - customGroups: options.customGroups.value, - predefinedGroups: [], - options, - name, - }) - if (isStyleSideEffect) { - predefinedGroups.push('side-effect-style') + selectors.push('side-effect-style') } if (isSideEffect) { - predefinedGroups.push('side-effect') + selectors.push('side-effect') } if (isStyleValue) { - predefinedGroups.push('style') + selectors.push('style') } - for (let predefinedGroup of commonPredefinedGroups) { - predefinedGroups.push(predefinedGroup) + for (let selector of commonSelectors) { + selectors.push(selector) } group ??= computeGroupExceptUnknown({ - predefinedGroups, + customGroups: Array.isArray(options.customGroups) + ? options.customGroups + : [], + selectors, + modifiers, options, name, }) ?? 'unknown' @@ -263,19 +293,39 @@ export default createEslintRule({ ): SortImportsSortingNode[] => sortNodesByGroups({ getOptionsByGroupNumber: groupNumber => { + let customGroupOverriddenOptions = + getCustomGroupOverriddenOptions({ + options: { + ...options, + customGroups: Array.isArray(options.customGroups) + ? options.customGroups + : [], + }, + groupNumber, + }) + if (options.sortSideEffects) { return { - options, + options: { + ...options, + ...customGroupOverriddenOptions, + }, } } + let overriddenOptions = { + ...options, + ...customGroupOverriddenOptions, + } return { options: { - ...options, + ...overriddenOptions, type: - options.groups[groupNumber] && - isSideEffectOnlyGroup(options.groups[groupNumber]) + overriddenOptions.groups[groupNumber] && + isSideEffectOnlyGroup( + overriddenOptions.groups[groupNumber], + ) ? 'unsorted' - : options.type, + : overriddenOptions.type, }, } }, @@ -294,7 +344,9 @@ export default createEslintRule({ }, options: { ...options, - customGroups: [], + customGroups: Array.isArray(options.customGroups) + ? options.customGroups + : [], }, sortNodesExcludingEslintDisabled, sourceCode, @@ -319,24 +371,29 @@ export default createEslintRule({ } }, meta: { - schema: [ - { + schema: { + items: { properties: { ...commonJsonSchemas, customGroups: { - properties: { - value: { - description: 'Specifies custom groups for value imports.', - type: 'object', - }, - type: { - description: 'Specifies custom groups for type imports.', + oneOf: [ + { + properties: { + value: { + description: 'Specifies custom groups for value imports.', + type: 'object', + }, + type: { + description: 'Specifies custom groups for type imports.', + type: 'object', + }, + }, + description: 'Specifies custom groups.', + additionalProperties: false, type: 'object', }, - }, - description: 'Specifies custom groups.', - additionalProperties: false, - type: 'object', + buildCustomGroupsArrayJsonSchema({ singleCustomGroupJsonSchema }), + ], }, maxLineLength: { description: 'Specifies the maximum line length.', @@ -399,7 +456,9 @@ export default createEslintRule({ id: 'sort-imports', type: 'object', }, - ], + uniqueItems: true, + type: 'array', + }, messages: { missedSpacingBetweenImports: MISSED_SPACING_ERROR, extraSpacingBetweenImports: EXTRA_SPACING_ERROR, @@ -423,7 +482,6 @@ export default createEslintRule({ 'internal', ['parent-type', 'sibling-type', 'index-type'], ['parent', 'sibling', 'index'], - 'object', 'unknown', ], customGroups: { value: {}, type: {} }, @@ -507,8 +565,9 @@ let getNodeName = ({ } let computeGroupExceptUnknown = ({ - predefinedGroups, customGroups, + selectors, + modifiers, options, name, }: { @@ -516,11 +575,27 @@ let computeGroupExceptUnknown = ({ Required, 'tsconfigRootDir' | 'maxLineLength' | 'customGroups' > - customGroups?: DeprecatedCustomGroupsOption | undefined - predefinedGroups: Group[] + customGroups: DeprecatedCustomGroupsOption | CustomGroupsOption | undefined + selectors?: Selector[] + modifiers?: Modifier[] name: string -}): Group | null => { +}): string | null => { + let predefinedGroups = + modifiers && selectors + ? generatePredefinedGroups({ + cache: cachedGroupsByModifiersAndSelectors, + selectors, + modifiers, + }) + : [] let computedCustomGroup = computeGroup({ + customGroupMatcher: customGroup => + doesCustomGroupMatch({ + modifiers: modifiers!, + selectors: selectors!, + elementName: name, + customGroup, + }), options: { ...options, customGroups, diff --git a/rules/sort-imports/compute-common-predefined-groups.ts b/rules/sort-imports/compute-common-selectors.ts similarity index 92% rename from rules/sort-imports/compute-common-predefined-groups.ts rename to rules/sort-imports/compute-common-selectors.ts index 31fb405da..cceb04cf1 100644 --- a/rules/sort-imports/compute-common-predefined-groups.ts +++ b/rules/sort-imports/compute-common-selectors.ts @@ -2,11 +2,17 @@ import { builtinModules } from 'node:module' import type { ReadClosestTsConfigByPathValue } from './read-closest-ts-config-by-path' import type { RegexOption } from '../../types/common-options' +import type { Selector } from './types' import { getTypescriptImport } from './get-typescript-import' import { matches } from '../../utils/matches' -export let computeCommonPredefinedGroups = ({ +type CommonSelector = Extract< + Selector, + 'internal' | 'external' | 'sibling' | 'builtin' | 'parent' | 'index' +> + +export let computeCommonSelectors = ({ tsConfigOutput, filename, options, @@ -19,7 +25,7 @@ export let computeCommonPredefinedGroups = ({ tsConfigOutput: ReadClosestTsConfigByPathValue | null filename: string name: string -}): string[] => { +}): CommonSelector[] => { let matchesInternalPattern = (value: string): boolean | number => options.internalPattern.some(pattern => matches(value, pattern)) @@ -31,7 +37,7 @@ export let computeCommonPredefinedGroups = ({ name, }) - let predefinedGroups: string[] = [] + let predefinedGroups: CommonSelector[] = [] if (isIndex(name)) { predefinedGroups.push('index') diff --git a/rules/sort-imports/types.ts b/rules/sort-imports/types.ts index 3094e8168..9d19c9214 100644 --- a/rules/sort-imports/types.ts +++ b/rules/sort-imports/types.ts @@ -1,62 +1,178 @@ +import type { JSONSchema4 } from '@typescript-eslint/utils/json-schema' + import type { DeprecatedCustomGroupsOption, PartitionByCommentOption, SpecialCharactersOption, NewlinesBetweenOption, FallbackSortOption, + CustomGroupsOption, GroupsOptions, OrderOption, RegexOption, TypeOption, } from '../../types/common-options' +import type { JoinWithDash } from '../../types/join-with-dash' import type { SortingNode } from '../../types/sorting-node' -export type Options = [ - Partial<{ - customGroups: { - value?: DeprecatedCustomGroupsOption - type?: DeprecatedCustomGroupsOption - } - partitionByComment: PartitionByCommentOption - specialCharacters: SpecialCharactersOption - locales: NonNullable - newlinesBetween: NewlinesBetweenOption - fallbackSort: FallbackSortOption - internalPattern: RegexOption[] - groups: GroupsOptions - environment: 'node' | 'bun' - partitionByNewLine: boolean - sortSideEffects: boolean - tsconfigRootDir?: string - maxLineLength?: number - ignoreCase: boolean - order: OrderOption - type: TypeOption - alphabet: string - }>, -] +import { + buildCustomGroupModifiersJsonSchema, + buildCustomGroupSelectorJsonSchema, + regexJsonSchema, +} from '../../utils/common-json-schemas' + +export type Options = Partial<{ + customGroups: + | { + value?: DeprecatedCustomGroupsOption + type?: DeprecatedCustomGroupsOption + } + | CustomGroupsOption + partitionByComment: PartitionByCommentOption + specialCharacters: SpecialCharactersOption + locales: NonNullable + newlinesBetween: NewlinesBetweenOption + fallbackSort: FallbackSortOption + internalPattern: RegexOption[] + groups: GroupsOptions + environment: 'node' | 'bun' + partitionByNewLine: boolean + sortSideEffects: boolean + tsconfigRootDir?: string + maxLineLength?: number + ignoreCase: boolean + order: OrderOption + type: TypeOption + alphabet: string +}>[] -export type Group = - | 'side-effect-style' - | 'external-type' - | 'internal-type' - | 'builtin-type' - | 'sibling-type' - | 'parent-type' - | 'side-effect' - | 'index-type' - | 'internal' - | 'external' - | 'sibling' - | 'unknown' - | 'builtin' - | 'parent' - | 'object' - | 'index' - | 'style' - | 'type' - | string +export type Selector = + | SideEffectStyleSelector + | InternalTypeSelector + | ExternalTypeSelector + | SiblingTypeSelector + | BuiltinTypeSelector + | SideEffectSelector + | ParentTypeSelector + | IndexTypeSelector + | ExternalSelector + | InternalSelector + | BuiltinSelector + | SiblingSelector + | ObjectSelector + | ParentSelector + | IndexSelector + | StyleSelector + | TypeSelector + +export type SingleCustomGroup = { + modifiers?: Modifier[] + selector?: Selector +} & { + elementNamePattern?: RegexOption +} export interface SortImportsSortingNode extends SortingNode { isIgnored: boolean } + +export type Group = ValueGroup | TypeGroup | 'unknown' | string + +export type Modifier = TypeModifier + +type TypeGroup = JoinWithDash<[TypeModifier, Selector]> + +type SideEffectStyleSelector = 'side-effect-style' + +/** + * @deprecated for the modifier and selector + */ +type InternalTypeSelector = 'internal-type' + +/** + * @deprecated for the modifier and selector + */ +type ExternalTypeSelector = 'external-type' + +type ValueGroup = JoinWithDash<[Selector]> + +/** + * @deprecated for the modifier and selector + */ +type SiblingTypeSelector = 'sibling-type' + +/** + * @deprecated for the modifier and selector + */ +type BuiltinTypeSelector = 'builtin-type' + +type SideEffectSelector = 'side-effect' + +/** + * @deprecated for the modifier and selector + */ +type ParentTypeSelector = 'parent-type' + +/** + * @deprecated for the modifier and selector + */ +type IndexTypeSelector = 'index-type' + +type ExternalSelector = 'external' + +type InternalSelector = 'internal' + +type BuiltinSelector = 'builtin' + +type SiblingSelector = 'sibling' + +type ParentSelector = 'parent' + +/** + * @deprecated This selector is never matched + */ +type ObjectSelector = 'object' + +type IndexSelector = 'index' + +type StyleSelector = 'style' + +type TypeModifier = 'type' + +type TypeSelector = 'type' + +export let allSelectors: Selector[] = [ + 'side-effect-style', + 'side-effect', + 'external', + 'internal', + 'builtin', + 'sibling', + 'parent', + 'index', + 'style', + 'type', +] + +export let allDeprecatedSelectors: Selector[] = [ + 'internal-type', + 'external-type', + 'sibling-type', + 'builtin-type', + 'parent-type', + 'index-type', + 'object', +] + +export let allModifiers: Modifier[] = ['type'] + +/** + * Ideally, we should generate as many schemas as there are selectors, and ensure + * that users do not enter invalid modifiers for a given selector + */ +export let singleCustomGroupJsonSchema: Record = { + modifiers: buildCustomGroupModifiersJsonSchema(allModifiers), + selector: buildCustomGroupSelectorJsonSchema(allSelectors), + elementValuePattern: regexJsonSchema, + elementNamePattern: regexJsonSchema, +} diff --git a/test/rules/sort-imports.test.ts b/test/rules/sort-imports.test.ts index f1fc8e687..a6f54a411 100644 --- a/test/rules/sort-imports.test.ts +++ b/test/rules/sort-imports.test.ts @@ -576,7 +576,6 @@ describe(ruleName, () => { ['parent-type', 'sibling-type', 'index-type'], ['parent', 'sibling', 'index'], 'style', - 'object', 'unknown', ], }, @@ -610,7 +609,6 @@ describe(ruleName, () => { ['parent-type', 'sibling-type', 'index-type'], ['parent', 'sibling', 'index'], 'side-effect', - 'object', 'unknown', ], }, @@ -667,7 +665,7 @@ describe(ruleName, () => { 'internal', ['parent-type', 'sibling-type', 'index-type'], ['parent', 'sibling', 'index'], - ['object', 'unknown'], + 'unknown', ], }, ], @@ -786,7 +784,6 @@ describe(ruleName, () => { 'internal', ['parent-type', 'sibling-type', 'index-type'], ['parent', 'sibling', 'index'], - 'object', 'unknown', ], customGroups: { @@ -2666,6 +2663,663 @@ describe(ruleName, () => { ], }, ) + + describe(`${ruleName}(${type}): handles the new "groups" and "customGroups" API`, () => { + describe('selectors priority', () => { + ruleTester.run( + `${ruleName}(${type}): prioritizes "index-type" over "sibling-type"`, + rule, + { + invalid: [ + { + errors: [ + { + data: { + leftGroup: 'sibling-type', + rightGroup: 'index-type', + right: './index', + left: './a', + }, + messageId: 'unexpectedImportsGroupOrder', + }, + ], + options: [ + { + ...options, + groups: ['index-type', 'sibling-type'], + }, + ], + output: dedent` + import type b from './index' + + import type a from './a' + `, + code: dedent` + import type a from './a' + + import type b from './index' + `, + }, + ], + valid: [], + }, + ) + + ruleTester.run( + `${ruleName}(${type}): prioritizes precise "type" selectors over "type"`, + rule, + { + invalid: [ + { + options: [ + { + ...options, + groups: [ + [ + 'index-type', + 'internal-type', + 'external-type', + 'sibling-type', + 'builtin-type', + ], + 'type', + ], + }, + ], + errors: [ + { + data: { + rightGroup: 'sibling-type', + leftGroup: 'type', + right: './b', + left: '../a', + }, + messageId: 'unexpectedImportsGroupOrder', + }, + ], + output: dedent` + import type b from './b' + import type c from './index' + import type d from 'd' + import type e from 'timers' + + import type a from '../a' + `, + code: dedent` + import type a from '../a' + + import type b from './b' + import type c from './index' + import type d from 'd' + import type e from 'timers' + `, + }, + ], + valid: [], + }, + ) + + ruleTester.run( + `${ruleName}(${type}): prioritizes "index" over "sibling"`, + rule, + { + invalid: [ + { + errors: [ + { + data: { + leftGroup: 'sibling', + rightGroup: 'index', + right: './index', + left: './a', + }, + messageId: 'unexpectedImportsGroupOrder', + }, + ], + options: [ + { + ...options, + groups: ['index', 'sibling'], + }, + ], + output: dedent` + import b from './index' + + import a from './a' + `, + code: dedent` + import a from './a' + + import b from './index' + `, + }, + ], + valid: [], + }, + ) + + ruleTester.run( + `${ruleName}(${type}): prioritizes "side-effect-style" over "side-effect"`, + rule, + { + invalid: [ + { + errors: [ + { + data: { + rightGroup: 'side-effect-style', + leftGroup: 'side-effect', + right: 'style.css', + left: 'something', + }, + messageId: 'unexpectedImportsGroupOrder', + }, + ], + options: [ + { + ...options, + groups: ['side-effect-style', 'side-effect'], + }, + ], + output: dedent` + import 'style.css' + + import 'something' + `, + code: dedent` + import 'something' + + import 'style.css' + `, + }, + ], + valid: [], + }, + ) + + ruleTester.run( + `${ruleName}(${type}): prioritizes "side-effect" over "style"`, + rule, + { + invalid: [ + { + errors: [ + { + data: { + rightGroup: 'side-effect', + right: 'something', + leftGroup: 'style', + left: 'style.css', + }, + messageId: 'unexpectedImportsGroupOrder', + }, + ], + options: [ + { + ...options, + groups: ['side-effect', 'style'], + }, + ], + output: dedent` + import 'something' + + import style from 'style.css' + `, + code: dedent` + import style from 'style.css' + + import 'something' + `, + }, + ], + valid: [], + }, + ) + + ruleTester.run( + `${ruleName}(${type}): prioritizes "style" over any other non-type "selector"`, + rule, + { + invalid: [ + { + options: [ + { + ...options, + + groups: [ + 'style', + [ + 'index', + 'internal', + 'external', + 'sibling', + 'builtin', + 'parent', + ], + ], + }, + ], + errors: [ + { + data: { + leftGroup: 'builtin', + rightGroup: 'style', + right: 'style.css', + left: 'timers', + }, + messageId: 'unexpectedImportsGroupOrder', + }, + ], + output: dedent` + import style from 'style.css' + + import a from '../a' + import b from './b' + import c from './index' + import d from 'd' + import e from 'timers' + `, + code: dedent` + import a from '../a' + import b from './b' + import c from './index' + import d from 'd' + import e from 'timers' + + import style from 'style.css' + `, + }, + ], + valid: [], + }, + ) + }) + + describe(`custom groups`, () => { + for (let elementNamePattern of [ + 'hello', + ['noMatch', 'hello'], + { pattern: 'HELLO', flags: 'i' }, + ['noMatch', { pattern: 'HELLO', flags: 'i' }], + ]) { + ruleTester.run(`${ruleName}: filters on elementNamePattern`, rule, { + invalid: [ + { + errors: [ + { + data: { + rightGroup: 'importsStartingWithHello', + right: 'helloImport', + leftGroup: 'unknown', + left: 'a', + }, + messageId: 'unexpectedImportsGroupOrder', + }, + ], + options: [ + { + customGroups: [ + { + groupName: 'importsStartingWithHello', + elementNamePattern, + }, + ], + groups: ['importsStartingWithHello', 'unknown'], + }, + ], + output: dedent` + import hello from 'helloImport' + + import a from 'a' + `, + code: dedent` + import a from 'a' + + import hello from 'helloImport' + `, + }, + ], + valid: [], + }) + } + + ruleTester.run( + `${ruleName}: sort custom groups by overriding 'type' and 'order'`, + rule, + { + invalid: [ + { + errors: [ + { + data: { + right: 'bb', + left: 'a', + }, + messageId: 'unexpectedImportsOrder', + }, + { + data: { + right: 'ccc', + left: 'bb', + }, + messageId: 'unexpectedImportsOrder', + }, + { + data: { + right: 'dddd', + left: 'ccc', + }, + messageId: 'unexpectedImportsOrder', + }, + { + data: { + rightGroup: 'reversedExternalImportsByLineLength', + leftGroup: 'unknown', + left: './jjjjj', + right: 'eee', + }, + messageId: 'unexpectedImportsGroupOrder', + }, + ], + options: [ + { + customGroups: [ + { + groupName: 'reversedExternalImportsByLineLength', + selector: 'external', + type: 'line-length', + order: 'desc', + }, + ], + groups: ['reversedExternalImportsByLineLength', 'unknown'], + newlinesBetween: 'ignore', + type: 'alphabetical', + order: 'asc', + }, + ], + output: dedent` + import dddd from 'dddd' + import ccc from 'ccc' + import eee from 'eee' + import bb from 'bb' + import ff from 'ff' + import a from 'a' + import g from 'g' + import h from './h' + import i from './i' + import jjjjj from './jjjjj' + `, + code: dedent` + import a from 'a' + import bb from 'bb' + import ccc from 'ccc' + import dddd from 'dddd' + import jjjjj from './jjjjj' + import eee from 'eee' + import ff from 'ff' + import g from 'g' + import h from './h' + import i from './i' + `, + }, + ], + valid: [], + }, + ) + + ruleTester.run( + `${ruleName}: sort custom groups by overriding 'fallbackSort'`, + rule, + { + invalid: [ + { + options: [ + { + customGroups: [ + { + fallbackSort: { + type: 'alphabetical', + order: 'asc', + }, + elementNamePattern: '^foo', + type: 'line-length', + groupName: 'foo', + order: 'desc', + }, + ], + type: 'alphabetical', + groups: ['foo'], + order: 'asc', + }, + ], + errors: [ + { + data: { + right: 'fooBar', + left: 'fooZar', + }, + messageId: 'unexpectedImportsOrder', + }, + ], + output: dedent` + import fooBar from 'fooBar' + import fooZar from 'fooZar' + `, + code: dedent` + import fooZar from 'fooZar' + import fooBar from 'fooBar' + `, + }, + ], + valid: [], + }, + ) + + ruleTester.run( + `${ruleName}: does not sort custom groups with 'unsorted' type`, + rule, + { + invalid: [ + { + options: [ + { + customGroups: [ + { + groupName: 'unsortedExternalImports', + selector: 'external', + type: 'unsorted', + }, + ], + groups: ['unsortedExternalImports', 'unknown'], + newlinesBetween: 'ignore', + }, + ], + errors: [ + { + data: { + rightGroup: 'unsortedExternalImports', + leftGroup: 'unknown', + left: './something', + right: 'c', + }, + messageId: 'unexpectedImportsGroupOrder', + }, + ], + output: dedent` + import b from 'b' + import a from 'a' + import d from 'd' + import e from 'e' + import c from 'c' + import something from './something' + `, + code: dedent` + import b from 'b' + import a from 'a' + import d from 'd' + import e from 'e' + import something from './something' + import c from 'c' + `, + }, + ], + valid: [], + }, + ) + + ruleTester.run(`${ruleName}: sort custom group blocks`, rule, { + invalid: [ + { + options: [ + { + customGroups: [ + { + anyOf: [ + { + selector: 'external', + }, + { + selector: 'sibling', + modifiers: ['type'], + }, + ], + groupName: 'externalAndTypeSiblingImports', + }, + ], + groups: [ + ['externalAndTypeSiblingImports', 'index'], + 'unknown', + ], + newlinesBetween: 'ignore', + }, + ], + errors: [ + { + data: { + rightGroup: 'externalAndTypeSiblingImports', + leftGroup: 'unknown', + right: './c', + left: './b', + }, + messageId: 'unexpectedImportsGroupOrder', + }, + { + data: { + right: './index', + left: 'e', + }, + messageId: 'unexpectedImportsOrder', + }, + ], + output: dedent` + import type c from './c' + import type d from './d' + import i from './index' + import a from 'a' + import e from 'e' + import b from './b' + `, + code: dedent` + import a from 'a' + import b from './b' + import type c from './c' + import type d from './d' + import e from 'e' + import i from './index' + `, + }, + ], + valid: [], + }) + + describe('newlinesInside', () => { + ruleTester.run( + `${ruleName}: allows to use newlinesInside: always`, + rule, + { + invalid: [ + { + options: [ + { + customGroups: [ + { + newlinesInside: 'always', + selector: 'external', + groupName: 'group1', + }, + ], + groups: ['group1'], + }, + ], + errors: [ + { + data: { + right: 'b', + left: 'a', + }, + messageId: 'missedSpacingBetweenImports', + }, + ], + output: dedent` + import a from 'a' + + import b from 'b' + `, + code: dedent` + import a from 'a' + import b from 'b' + `, + }, + ], + valid: [], + }, + ) + + ruleTester.run( + `${ruleName}: allows to use newlinesInside: never`, + rule, + { + invalid: [ + { + options: [ + { + customGroups: [ + { + newlinesInside: 'never', + selector: 'external', + groupName: 'group1', + }, + ], + type: 'alphabetical', + groups: ['group1'], + }, + ], + errors: [ + { + data: { + right: 'b', + left: 'a', + }, + messageId: 'extraSpacingBetweenImports', + }, + ], + output: dedent` + import a from 'a' + import b from 'b' + `, + code: dedent` + import a from 'a' + + import b from 'b' + `, + }, + ], + valid: [], + }, + ) + }) + }) + }) }) describe(`${ruleName}: sorting by natural order`, () => { @@ -3213,7 +3867,6 @@ describe(ruleName, () => { ['parent-type', 'sibling-type', 'index-type'], ['parent', 'sibling', 'index'], 'style', - 'object', 'unknown', ], }, @@ -3247,7 +3900,6 @@ describe(ruleName, () => { ['parent-type', 'sibling-type', 'index-type'], ['parent', 'sibling', 'index'], 'side-effect', - 'object', 'unknown', ], }, @@ -3304,7 +3956,7 @@ describe(ruleName, () => { 'internal', ['parent-type', 'sibling-type', 'index-type'], ['parent', 'sibling', 'index'], - ['object', 'unknown'], + 'unknown', ], }, ], @@ -3423,7 +4075,6 @@ describe(ruleName, () => { 'internal', ['parent-type', 'sibling-type', 'index-type'], ['parent', 'sibling', 'index'], - 'object', 'unknown', ], customGroups: { @@ -4550,7 +5201,6 @@ describe(ruleName, () => { ['parent-type', 'sibling-type', 'index-type'], ['parent', 'sibling', 'index'], 'style', - 'object', 'unknown', ], }, @@ -4584,7 +5234,6 @@ describe(ruleName, () => { ['parent-type', 'sibling-type', 'index-type'], ['parent', 'sibling', 'index'], 'side-effect', - 'object', 'unknown', ], }, @@ -4641,7 +5290,7 @@ describe(ruleName, () => { 'internal', ['parent-type', 'sibling-type', 'index-type'], ['parent', 'sibling', 'index'], - ['object', 'unknown'], + 'unknown', ], }, ], @@ -4730,7 +5379,6 @@ describe(ruleName, () => { 'internal', ['parent-type', 'sibling-type', 'index-type'], ['parent', 'sibling', 'index'], - 'object', 'unknown', ], customGroups: { @@ -5455,7 +6103,6 @@ describe(ruleName, () => { 'unknown', 'builtin', 'parent', - 'object', 'index', 'style', 'type', @@ -5822,6 +6469,21 @@ describe(ruleName, () => { ], options: [ { + customGroups: { + value: { + validators: ['~/validators/.+'], + composable: ['~/composable/.+'], + components: ['~/components/.+'], + services: ['~/services/.+'], + widgets: ['~/widgets/.+'], + stores: ['~/stores/.+'], + logics: ['~/logics/.+'], + assets: ['~/assets/.+'], + utils: ['~/utils/.+'], + pages: ['~/pages/.+'], + ui: ['~/ui/.+'], + }, + }, groups: [ ['builtin', 'external'], 'internal', @@ -5841,24 +6503,8 @@ describe(ruleName, () => { 'side-effect', 'index', 'style', - 'object', 'unknown', ], - customGroups: { - value: { - validators: ['~/validators/.+'], - composable: ['~/composable/.+'], - components: ['~/components/.+'], - services: ['~/services/.+'], - widgets: ['~/widgets/.+'], - stores: ['~/stores/.+'], - logics: ['~/logics/.+'], - assets: ['~/assets/.+'], - utils: ['~/utils/.+'], - pages: ['~/pages/.+'], - ui: ['~/ui/.+'], - }, - }, type: 'line-length', }, ], @@ -5907,6 +6553,21 @@ describe(ruleName, () => { { options: [ { + customGroups: { + value: { + validators: ['^~/validators/.+'], + composable: ['^~/composable/.+'], + components: ['^~/components/.+'], + services: ['^~/services/.+'], + widgets: ['^~/widgets/.+'], + stores: ['^~/stores/.+'], + logics: ['^~/logics/.+'], + assets: ['^~/assets/.+'], + utils: ['^~/utils/.+'], + pages: ['^~/pages/.+'], + ui: ['^~/ui/.+'], + }, + }, groups: [ ['builtin', 'external'], 'internal', @@ -5926,24 +6587,8 @@ describe(ruleName, () => { 'side-effect', 'index', 'style', - 'object', 'unknown', ], - customGroups: { - value: { - validators: ['^~/validators/.+'], - composable: ['^~/composable/.+'], - components: ['^~/components/.+'], - services: ['^~/services/.+'], - widgets: ['^~/widgets/.+'], - stores: ['^~/stores/.+'], - logics: ['^~/logics/.+'], - assets: ['^~/assets/.+'], - utils: ['^~/utils/.+'], - pages: ['^~/pages/.+'], - ui: ['^~/ui/.+'], - }, - }, type: 'line-length', }, ], diff --git a/test/rules/sort-imports/compute-common-predefined-groups.test.ts b/test/rules/sort-imports/compute-common-selector.test.ts similarity index 74% rename from test/rules/sort-imports/compute-common-predefined-groups.test.ts rename to test/rules/sort-imports/compute-common-selector.test.ts index 000ac22d5..03748f8a8 100644 --- a/test/rules/sort-imports/compute-common-predefined-groups.test.ts +++ b/test/rules/sort-imports/compute-common-selector.test.ts @@ -6,7 +6,7 @@ import { builtinModules } from 'node:module' import type { ReadClosestTsConfigByPathValue } from '../../../rules/sort-imports/read-closest-ts-config-by-path' -import { computeCommonPredefinedGroups } from '../../../rules/sort-imports/compute-common-predefined-groups' +import { computeCommonSelectors } from '../../../rules/sort-imports/compute-common-selectors' let mockGetTypescriptImport: Mock<() => typeof ts> = vi.fn() @@ -14,8 +14,8 @@ vi.mock('../../../rules/sort-imports/get-typescript-import', () => ({ getTypescriptImport: () => mockGetTypescriptImport(), })) -describe('compute-common-predefined-groups', () => { - describe('`index` group', () => { +describe('compute-common-selector', () => { + describe('`index` selector', () => { it.each([ './index.d.js', './index.d.ts', @@ -25,13 +25,13 @@ describe('compute-common-predefined-groups', () => { './', '.', ])("should match with '%s'", name => { - expect( - computeCommonPredefinedGroups(buildParameters({ name })), - ).toContain('index') + expect(computeCommonSelectors(buildParameters({ name }))).toContain( + 'index', + ) }) }) - describe('`sibling` group', () => { + describe('`sibling` selector', () => { it.each([ './foo.js', './foo.ts', @@ -40,13 +40,13 @@ describe('compute-common-predefined-groups', () => { './foo/index.ts', './foo/index', ])("should match with '%s'", name => { - expect( - computeCommonPredefinedGroups(buildParameters({ name })), - ).toContain('sibling') + expect(computeCommonSelectors(buildParameters({ name }))).toContain( + 'sibling', + ) }) }) - describe('`parent` group', () => { + describe('`parent` selector', () => { it.each([ '../foo.js', '../foo.ts', @@ -55,13 +55,13 @@ describe('compute-common-predefined-groups', () => { '../foo/index.ts', '../foo/index', ])("should match with '%s'", name => { - expect( - computeCommonPredefinedGroups(buildParameters({ name })), - ).toContain('parent') + expect(computeCommonSelectors(buildParameters({ name }))).toContain( + 'parent', + ) }) }) - describe('`builtin` group', () => { + describe('`builtin` selector', () => { describe('node builtin modules', () => { it.each([ ...builtinModules, @@ -70,9 +70,9 @@ describe('compute-common-predefined-groups', () => { 'node:test', 'node:sea', ])("should match with '%s'", name => { - expect( - computeCommonPredefinedGroups(buildParameters({ name })), - ).toContain('builtin') + expect(computeCommonSelectors(buildParameters({ name }))).toContain( + 'builtin', + ) }) }) @@ -89,15 +89,13 @@ describe('compute-common-predefined-groups', () => { 'ws', ])("should match with '%s'", name => { expect( - computeCommonPredefinedGroups( - buildParameters({ environment: 'bun', name }), - ), + computeCommonSelectors(buildParameters({ environment: 'bun', name })), ).toContain('builtin') }) }) }) - describe('`internal` group', () => { + describe('`internal` selector', () => { it.each([ { internalPattern: ['internal'], name: 'internal' }, { internalPattern: ['foo', 'internal'], name: 'internalName' }, @@ -105,9 +103,7 @@ describe('compute-common-predefined-groups', () => { "should match through `internalPattern` with '%s'", ({ internalPattern, name }) => { expect( - computeCommonPredefinedGroups( - buildParameters({ internalPattern, name }), - ), + computeCommonSelectors(buildParameters({ internalPattern, name })), ).toContain('internal') }, ) @@ -119,19 +115,17 @@ describe('compute-common-predefined-groups', () => { }) expect( - computeCommonPredefinedGroups( + computeCommonSelectors( buildParameters({ withTsConfigOutput: true, name: 'foo' }), ), ).toContain('internal') }) }) - describe('`external` group', () => { + describe('`external` selector', () => { it('should match without typescript if the import does not start with . nor /', () => { expect( - computeCommonPredefinedGroups( - buildParameters({ name: 'somethingExternal' }), - ), + computeCommonSelectors(buildParameters({ name: 'somethingExternal' })), ).toContain('external') }) @@ -141,7 +135,7 @@ describe('compute-common-predefined-groups', () => { }) expect( - computeCommonPredefinedGroups(buildParameters({ name: 'foo' })), + computeCommonSelectors(buildParameters({ name: 'foo' })), ).toContain('external') }) @@ -151,7 +145,7 @@ describe('compute-common-predefined-groups', () => { }) expect( - computeCommonPredefinedGroups( + computeCommonSelectors( buildParameters({ withTsConfigOutput: true, name: 'foo' }), ), ).toContain('external') @@ -164,7 +158,7 @@ describe('compute-common-predefined-groups', () => { }) expect( - computeCommonPredefinedGroups( + computeCommonSelectors( buildParameters({ withTsConfigOutput: true, name: 'foo' }), ), ).toContain('external') @@ -175,9 +169,7 @@ describe('compute-common-predefined-groups', () => { it.each(['.foo', '/foo'])( "should not match anything without typescript with '%s'", name => { - expect( - computeCommonPredefinedGroups(buildParameters({ name })), - ).toEqual([]) + expect(computeCommonSelectors(buildParameters({ name }))).toEqual([]) }, ) @@ -186,9 +178,9 @@ describe('compute-common-predefined-groups', () => { isExternalModuleNameRelative: true, }) - expect( - computeCommonPredefinedGroups(buildParameters({ name: 'foo' })), - ).toEqual([]) + expect(computeCommonSelectors(buildParameters({ name: 'foo' }))).toEqual( + [], + ) }) }) @@ -202,7 +194,7 @@ describe('compute-common-predefined-groups', () => { environment?: 'node' | 'bun' internalPattern?: string[] name: string - }): Parameters[0] => ({ + }): Parameters[0] => ({ tsConfigOutput: withTsConfigOutput ? ({ compilerOptions: {} } as ReadClosestTsConfigByPathValue) : null, diff --git a/test/utils/validate-generated-groups-configuration.test.ts b/test/utils/validate-generated-groups-configuration.test.ts index 40d4d86c8..1c2f0bd53 100644 --- a/test/utils/validate-generated-groups-configuration.test.ts +++ b/test/utils/validate-generated-groups-configuration.test.ts @@ -1,20 +1,29 @@ import { describe, expect, it } from 'vitest' import { validateGeneratedGroupsConfiguration } from '../../utils/validate-generated-groups-configuration' -import { allModifiers, allSelectors } from '../../rules/sort-classes/types' import { getArrayCombinations } from '../../utils/get-array-combinations' describe('validate-generated-groups-configuration', () => { + let selectors = [ + 'selector1', + 'selector2', + 'selector3', + 'double-selector', + 'three-word-selector', + ] + let modifiers = ['modifier1', 'modifier2', 'modifier3'] + it('allows predefined groups', () => { let allModifierCombinationPermutations = - getAllNonEmptyCombinations(allModifiers) + getAllNonEmptyCombinations(modifiers) let allPredefinedGroups = [ - ...allSelectors.flatMap(selector => + ...selectors.flatMap(selector => allModifierCombinationPermutations.map( - modifiers => `${modifiers.join('-')}-${selector}`, + modifiersCombinations => + `${modifiersCombinations.join('-')}-${selector}`, ), ), - ...allSelectors, + ...selectors, ] expect(() => validateGeneratedGroupsConfiguration({ @@ -22,8 +31,8 @@ describe('validate-generated-groups-configuration', () => { groups: allPredefinedGroups, customGroups: [], }, - selectors: allSelectors, - modifiers: allModifiers, + selectors, + modifiers, }), ).not.toThrow() }) @@ -37,10 +46,10 @@ describe('validate-generated-groups-configuration', () => { groupName: 'myCustomGroup', }, ], - groups: ['static-property', 'myCustomGroup'], + groups: ['modifier1-selector1', 'myCustomGroup'], }, - selectors: allSelectors, - modifiers: allModifiers, + selectors, + modifiers, }), ).not.toThrow() }) @@ -49,26 +58,26 @@ describe('validate-generated-groups-configuration', () => { expect(() => validateGeneratedGroupsConfiguration({ options: { - groups: ['static-static-property'], + groups: ['modifier1-modifier1-selector1'], customGroups: [], }, - selectors: allSelectors, - modifiers: allModifiers, + selectors, + modifiers, }), - ).toThrow('Invalid group(s): static-static-property') + ).toThrow('Invalid group(s): modifier1-modifier1-selector1') }) it('throws an error if a duplicate group is provided', () => { expect(() => validateGeneratedGroupsConfiguration({ options: { - groups: ['static-property', 'static-property'], + groups: ['modifier1-selector1', 'modifier1-selector1'], customGroups: [], }, - selectors: allSelectors, - modifiers: allModifiers, + selectors, + modifiers, }), - ).toThrow('Duplicated group(s): static-property') + ).toThrow('Duplicated group(s): modifier1-selector1') }) it('throws an error if invalid groups are provided', () => { @@ -80,10 +89,10 @@ describe('validate-generated-groups-configuration', () => { groupName: 'myCustomGroupNotReferenced', }, ], - groups: ['static-property', 'myCustomGroup', ''], + groups: ['modifier1-selector1', 'myCustomGroup', ''], }, - selectors: allSelectors, - modifiers: allModifiers, + selectors, + modifiers, }), ).toThrow('Invalid group(s): myCustomGroup') }) diff --git a/utils/compute-group.ts b/utils/compute-group.ts index 6843c6234..5e94a6f92 100644 --- a/utils/compute-group.ts +++ b/utils/compute-group.ts @@ -26,8 +26,8 @@ export let computeGroup = ({ predefinedGroups, options, name, -}: GetGroupParameters): string => { - let group: undefined | 'unknown' | string +}: GetGroupParameters): 'unknown' | string => { + let group: undefined | string // For lookup performance. let groupsSet = new Set(options.groups.flat()) diff --git a/utils/validate-generated-groups-configuration.ts b/utils/validate-generated-groups-configuration.ts index d7d349fb4..e4b956767 100644 --- a/utils/validate-generated-groups-configuration.ts +++ b/utils/validate-generated-groups-configuration.ts @@ -49,16 +49,23 @@ let isPredefinedGroup = ( if (input === 'unknown') { return true } - let singleWordSelector = input.split('-').at(-1) - if (!singleWordSelector) { - return false - } - let twoWordsSelector = input.split('-').slice(-2).join('-') - let isTwoWordSelectorValid = allSelectors.includes(twoWordsSelector) - if (!allSelectors.includes(singleWordSelector) && !isTwoWordSelectorValid) { + + let parts = input.split('-') + + let possibleSelector = [ + { selector: parts.slice(-3).join('-'), wordCount: 3 }, + { selector: parts.slice(-2).join('-'), wordCount: 2 }, + { selector: parts.at(-1), wordCount: 1 }, + ] + .filter(({ wordCount }) => parts.length >= wordCount) + .find(({ selector }) => selector && allSelectors.includes(selector)) + + if (!possibleSelector) { return false } - let modifiers = input.split('-').slice(0, isTwoWordSelectorValid ? -2 : -1) + + let modifiers = parts.slice(0, -possibleSelector.wordCount) + return ( new Set(modifiers).size === modifiers.length && modifiers.every(modifier => allModifiers.includes(modifier))