diff --git a/docs/rules/sort-imports.md b/docs/rules/sort-imports.md index 11637de..0d90cc1 100644 --- a/docs/rules/sort-imports.md +++ b/docs/rules/sort-imports.md @@ -196,6 +196,28 @@ If you use [one of the configs](/configs/) exported by this plugin, you get the } ``` +### custom-groups + +(default: `{ value: {}, type: {} }`) + +You can define your own groups for importing values or types. The [minimatch](https://github.com/isaacs/minimatch) library is used for pattern matching. + +Example: + +``` +{ + "custom-groups": { + "value": { + "react": ["react", "react-*"], + "lodash": "lodash" + }, + "type": { + "react": ["react", "react-*"] + } + } +} +``` + ### internal-pattern (default: `['~/**']`) @@ -234,6 +256,8 @@ If your project is written in TypeScript, you can read `tsconfig.json` and use ` "order": "asc", "groups": [ "type", + "react", + "nanostores", ["builtin", "external"], "internal-type", "internal", @@ -244,6 +268,15 @@ If your project is written in TypeScript, you can read `tsconfig.json` and use ` "object", "unknown" ], + "custom-groups": { + "value": { + "react": ["react", "react-*"], + "nanostores": "@nanostores/**" + }, + "type": { + "react": "react" + } + }, "newlines-between": "always", "internal-pattern": [ "@/components/**", @@ -275,6 +308,8 @@ export default [ order: 'asc', groups: [ 'type', + 'react', + 'nanostores', ['builtin', 'external'], 'internal-type', 'internal', @@ -285,6 +320,15 @@ export default [ 'object', 'unknown', ], + 'custom-groups': { + value: { + react: ['react', 'react-*'], + nanostores: '@nanostores/**', + }, + type: { + react: 'react' + } + }, 'newlines-between': 'always', 'internal-pattern': [ '@/components/**', diff --git a/index.ts b/index.ts index 5699dc3..619deb3 100644 --- a/index.ts +++ b/index.ts @@ -44,6 +44,10 @@ let createConfigWithOptions = (options: { 'object', 'unknown', ], + 'custom-groups': { + value: {}, + type: {}, + }, 'newlines-between': 'always', 'internal-pattern': ['~/**'], 'read-tsconfig': false, diff --git a/rules/sort-imports.ts b/rules/sort-imports.ts index ce29782..2c8aa21 100644 --- a/rules/sort-imports.ts +++ b/rules/sort-imports.ts @@ -29,30 +29,35 @@ export enum NewlinesBetweenValue { 'never' = 'never', } -type Group = - | 'internal-type' +type Group = | 'external-type' - | 'sibling-type' + | 'internal-type' | 'builtin-type' - | 'side-effect' + | 'sibling-type' | 'parent-type' + | 'side-effect' | 'index-type' - | 'external' | 'internal' - | 'builtin' - | 'unknown' + | 'external' + | T[number] | 'sibling' - | 'object' + | 'unknown' + | 'builtin' | 'parent' - | 'style' + | 'object' | 'index' + | 'style' | 'type' -type Options = [ +type Options = [ Partial<{ + 'custom-groups': { + value?: { [key in T[number]]: string[] | string } + type?: { [key in T[number]]: string[] | string } + } 'newlines-between': NewlinesBetweenValue + groups: (Group[] | Group)[] 'internal-pattern': string[] - groups: (Group[] | Group)[] 'read-tsconfig': boolean 'ignore-case': boolean order: SortOrder @@ -66,9 +71,11 @@ type ModuleDeclaration = | TSESTree.TSImportEqualsDeclaration | TSESTree.ImportDeclaration -type SortingNodeWithGroup = SortingNode & { group: Group } +type SortingNodeWithGroup = SortingNode & { + group: Group +} -export default createEslintRule({ +export default createEslintRule, MESSAGE_ID>({ name: RULE_NAME, meta: { type: 'suggestion', @@ -81,6 +88,18 @@ export default createEslintRule({ { type: 'object', properties: { + 'custom-groups': { + type: 'object', + properties: { + type: { + type: 'object', + }, + value: { + type: 'object', + }, + }, + additionalProperties: false, + }, type: { enum: [ SortType.alphabetical, @@ -138,6 +157,7 @@ export default createEslintRule({ create: context => { let options = complete(context.options.at(0), { 'newlines-between': NewlinesBetweenValue.always, + 'custom-groups': { type: {}, value: {} }, 'internal-pattern': ['~/**'], type: SortType.alphabetical, 'read-tsconfig': false, @@ -160,10 +180,10 @@ export default createEslintRule({ let source = context.getSourceCode() - let nodes: SortingNodeWithGroup[] = [] + let nodes: SortingNodeWithGroup[] = [] - let computeGroup = (node: ModuleDeclaration): Group => { - let group: undefined | Group + let computeGroup = (node: ModuleDeclaration): Group => { + let group: Group | undefined let isStyle = (value: string) => ['.less', '.scss', '.sass', '.styl', '.pcss', '.css', '.sss'].some( @@ -185,7 +205,7 @@ export default createEslintRule({ let isSibling = (value: string) => value.indexOf('./') === 0 - let defineGroup = (nodeGroup: Group) => { + let defineGroup = (nodeGroup: Group) => { if (!group && options.groups.flat().includes(nodeGroup)) { group = nodeGroup } @@ -198,8 +218,30 @@ export default createEslintRule({ )) || tsPaths.some(pattern => minimatch(nodeElement.source.value, pattern)) + let determineCustomGroup = ( + groupType: 'value' | 'type', + value: string, + ) => { + Object.entries(options['custom-groups'][groupType] ?? {}).forEach( + ([key, pattern]) => { + if ( + Array.isArray(pattern) && + pattern.some(patternValue => minimatch(value, patternValue)) + ) { + defineGroup(key) + } + + if (typeof pattern === 'string' && minimatch(value, pattern)) { + defineGroup(key) + } + }, + ) + } + if (node.importKind === 'type') { if (node.type === AST_NODE_TYPES.ImportDeclaration) { + determineCustomGroup('type', node.source.value) + if (isCoreModule(node.source.value)) { defineGroup('builtin-type') } @@ -225,6 +267,8 @@ export default createEslintRule({ } if (!group && node.type === AST_NODE_TYPES.ImportDeclaration) { + determineCustomGroup('value', node.source.value) + if (isCoreModule(node.source.value)) { defineGroup('builtin') } @@ -288,7 +332,7 @@ export default createEslintRule({ TSImportEqualsDeclaration: registerNode, ImportDeclaration: registerNode, 'Program:exit': () => { - let getGroupNumber = (node: SortingNodeWithGroup): number => { + let getGroupNumber = (node: SortingNodeWithGroup): number => { for (let i = 0, max = options.groups.length; i < max; i++) { let currentGroup = options.groups[i] @@ -328,14 +372,14 @@ export default createEslintRule({ let fix = ( fixer: TSESLint.RuleFixer, - nodesToFix: SortingNodeWithGroup[], + nodesToFix: SortingNodeWithGroup[], ): TSESLint.RuleFix[] => { let fixes: TSESLint.RuleFix[] = [] let grouped = nodesToFix.reduce( ( accumulator: { - [key: string]: SortingNodeWithGroup[] + [key: string]: SortingNodeWithGroup[] }, node, ) => { @@ -358,10 +402,10 @@ export default createEslintRule({ let formatted = Object.keys(grouped) .sort() .reduce( - (accumulator: SortingNodeWithGroup[], group: string) => [ - ...accumulator, - ...grouped[group], - ], + ( + accumulator: SortingNodeWithGroup[], + group: string, + ) => [...accumulator, ...grouped[group]], [], ) @@ -437,8 +481,8 @@ export default createEslintRule({ let splittedNodes = nodes.reduce( ( - accumulator: SortingNodeWithGroup[][], - node: SortingNodeWithGroup, + accumulator: SortingNodeWithGroup[][], + node: SortingNodeWithGroup, ) => { let lastNode = accumulator.at(-1)?.at(-1) diff --git a/test/sort-imports.test.ts b/test/sort-imports.test.ts index af61299..3aa57cd 100644 --- a/test/sort-imports.test.ts +++ b/test/sort-imports.test.ts @@ -941,6 +941,122 @@ describe(RULE_NAME, () => { ], }) }) + + it(`${RULE_NAME}(${type}): allows to define custom groups`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [], + invalid: [ + { + code: dedent` + import type { Titan } from 'titans' + + import Armin from '@scout-regiment/armin' + import Mikasa from '@scout-regiment/mikasa' + import Reiner from '@titans/armored-titan' + import Eren from '@titans/attack-titan' + import Zeke from '@titans/beast-titan' + import { KennyAckermann } from 'military-police' + `, + output: dedent` + import Reiner from '@titans/armored-titan' + import Eren from '@titans/attack-titan' + import Zeke from '@titans/beast-titan' + import type { Titan } from 'titans' + + import Armin from '@scout-regiment/armin' + import Mikasa from '@scout-regiment/mikasa' + + import { KennyAckermann } from 'military-police' + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + 'ignore-case': true, + 'custom-groups': { + type: { + titans: ['titans', '@titans/**'], + }, + value: { + titans: ['titans', '@titans/**'], + scouts: '@scout-regiment/**', + }, + }, + groups: [ + 'type', + 'titans', + 'scouts', + ['builtin', 'external'], + 'internal-type', + 'internal', + ['parent-type', 'sibling-type', 'index-type'], + ['parent', 'sibling', 'index'], + 'object', + 'unknown', + ], + }, + ], + errors: [ + { + messageId: 'unexpectedImportsOrder', + data: { + left: '@scout-regiment/mikasa', + right: '@titans/armored-titan', + }, + }, + { + messageId: 'missedSpacingBetweenImports', + data: { + left: '@titans/beast-titan', + right: 'military-police', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): allows to define value only custom groups`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [], + invalid: [ + { + code: dedent` + import type { Child } from 'giovannis-island' + import { Kanta, Junpei } from 'giovannis-island' + `, + output: dedent` + import type { Child } from 'giovannis-island' + + import { Kanta, Junpei } from 'giovannis-island' + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + 'ignore-case': true, + 'custom-groups': { + value: { + giovanni: ['giovannis-island'], + }, + }, + groups: ['type', 'giovanni'], + }, + ], + errors: [ + { + messageId: 'missedSpacingBetweenImports', + data: { + left: 'giovannis-island', + right: 'giovannis-island', + }, + }, + ], + }, + ], + }) + }) }) describe(`${RULE_NAME}: sorting by natural order`, () => { @@ -1874,6 +1990,122 @@ describe(RULE_NAME, () => { ], }) }) + + it(`${RULE_NAME}(${type}): allows to define custom groups`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [], + invalid: [ + { + code: dedent` + import type { Titan } from 'titans' + + import Armin from '@scout-regiment/armin' + import Mikasa from '@scout-regiment/mikasa' + import Reiner from '@titans/armored-titan' + import Eren from '@titans/attack-titan' + import Zeke from '@titans/beast-titan' + import { KennyAckermann } from 'military-police' + `, + output: dedent` + import Reiner from '@titans/armored-titan' + import Eren from '@titans/attack-titan' + import Zeke from '@titans/beast-titan' + import type { Titan } from 'titans' + + import Armin from '@scout-regiment/armin' + import Mikasa from '@scout-regiment/mikasa' + + import { KennyAckermann } from 'military-police' + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + 'ignore-case': true, + 'custom-groups': { + type: { + titans: ['titans', '@titans/**'], + }, + value: { + titans: ['titans', '@titans/**'], + scouts: '@scout-regiment/**', + }, + }, + groups: [ + 'type', + 'titans', + 'scouts', + ['builtin', 'external'], + 'internal-type', + 'internal', + ['parent-type', 'sibling-type', 'index-type'], + ['parent', 'sibling', 'index'], + 'object', + 'unknown', + ], + }, + ], + errors: [ + { + messageId: 'unexpectedImportsOrder', + data: { + left: '@scout-regiment/mikasa', + right: '@titans/armored-titan', + }, + }, + { + messageId: 'missedSpacingBetweenImports', + data: { + left: '@titans/beast-titan', + right: 'military-police', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): allows to define value only custom groups`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [], + invalid: [ + { + code: dedent` + import type { Child } from 'giovannis-island' + import { Kanta, Junpei } from 'giovannis-island' + `, + output: dedent` + import type { Child } from 'giovannis-island' + + import { Kanta, Junpei } from 'giovannis-island' + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + 'ignore-case': true, + 'custom-groups': { + value: { + giovanni: ['giovannis-island'], + }, + }, + groups: ['type', 'giovanni'], + }, + ], + errors: [ + { + messageId: 'missedSpacingBetweenImports', + data: { + left: 'giovannis-island', + right: 'giovannis-island', + }, + }, + ], + }, + ], + }) + }) }) describe(`${RULE_NAME}: sorting by line length`, () => { @@ -2849,6 +3081,129 @@ describe(RULE_NAME, () => { ], }) }) + + it(`${RULE_NAME}(${type}): allows to define custom groups`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [], + invalid: [ + { + code: dedent` + import type { Titan } from 'titans' + + import Armin from '@scout-regiment/armin' + import Mikasa from '@scout-regiment/mikasa' + import Reiner from '@titans/armored-titan' + import Eren from '@titans/attack-titan' + import Zeke from '@titans/beast-titan' + import { KennyAckermann } from 'military-police' + `, + output: dedent` + import Reiner from '@titans/armored-titan' + import Eren from '@titans/attack-titan' + import Zeke from '@titans/beast-titan' + import type { Titan } from 'titans' + + import Mikasa from '@scout-regiment/mikasa' + import Armin from '@scout-regiment/armin' + + import { KennyAckermann } from 'military-police' + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + 'ignore-case': true, + 'custom-groups': { + type: { + titans: ['titans', '@titans/**'], + }, + value: { + titans: ['titans', '@titans/**'], + scouts: '@scout-regiment/**', + }, + }, + groups: [ + 'type', + 'titans', + 'scouts', + ['builtin', 'external'], + 'internal-type', + 'internal', + ['parent-type', 'sibling-type', 'index-type'], + ['parent', 'sibling', 'index'], + 'object', + 'unknown', + ], + }, + ], + errors: [ + { + messageId: 'unexpectedImportsOrder', + data: { + left: '@scout-regiment/armin', + right: '@scout-regiment/mikasa', + }, + }, + { + messageId: 'unexpectedImportsOrder', + data: { + left: '@scout-regiment/mikasa', + right: '@titans/armored-titan', + }, + }, + { + messageId: 'missedSpacingBetweenImports', + data: { + left: '@titans/beast-titan', + right: 'military-police', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): allows to define value only custom groups`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [], + invalid: [ + { + code: dedent` + import type { Child } from 'giovannis-island' + import { Kanta, Junpei } from 'giovannis-island' + `, + output: dedent` + import type { Child } from 'giovannis-island' + + import { Kanta, Junpei } from 'giovannis-island' + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + 'ignore-case': true, + 'custom-groups': { + value: { + giovanni: ['giovannis-island'], + }, + }, + groups: ['type', 'giovanni'], + }, + ], + errors: [ + { + messageId: 'missedSpacingBetweenImports', + data: { + left: 'giovannis-island', + right: 'giovannis-island', + }, + }, + ], + }, + ], + }) + }) }) describe(`${RULE_NAME}: misc`, () => {