From 208a9db40e17b08edbba1b4e520b5bbc2df50f8f Mon Sep 17 00:00:00 2001 From: "Azat S." Date: Fri, 19 Jul 2024 23:46:36 +0300 Subject: [PATCH] feat: add groups option in sort-intersection-types rule --- .../content/rules/sort-intersection-types.mdx | 56 +++ rules/sort-intersection-types.ts | 124 +++++- test/sort-intersection-types.test.ts | 420 ++++++++++++++++++ 3 files changed, 588 insertions(+), 12 deletions(-) diff --git a/docs/content/rules/sort-intersection-types.mdx b/docs/content/rules/sort-intersection-types.mdx index 040e2271a..196898ff6 100644 --- a/docs/content/rules/sort-intersection-types.mdx +++ b/docs/content/rules/sort-intersection-types.mdx @@ -102,6 +102,62 @@ Controls whether sorting should be case-sensitive or not. - `true` — Ignore case when sorting alphabetically or naturally (e.g., “A” and “a” are the same). - `false` — Consider case when sorting (e.g., “A” comes before “a”). +### groups + +(default: `[]`) + +Allows you to specify a list of intersection type groups for sorting. Groups help organize types into categories, making your type definitions more readable and maintainable. Multiple groups can be combined to achieve the desired sorting order. + +There are a lot of predefined groups. + +Predefined Groups: + +- `'conditional`' — Conditional types. +- `'function`' — Function types. +- `'import`' — Imported types. +- `'intersection`' — Intersection types. +- `'keyword`' — Keyword types. +- `'literal`' — Literal types. +- `'named`' — Named types. +- `'object`' — Object types. +- `'operator`' — Operator types. +- `'tuple`' — Tuple types. +- `'union`' — Union types. +- `'nullish`' — Nullish types (`null` or `undefined`). +- `'unknown`' — Types that don’t fit into any other group. + +Example: + +```ts +type Example = + // 'conditional' — Conditional types. + (A extends B ? C : D) & + // 'function' — Function types. + ((arg: T) => U) & + // 'import' — Imported types. + import('module').Type & + // 'intersection' — Intersection types. + (A & B) & + // 'keyword' — Keyword types. + any & + // 'literal' — Literal types. + 'literal' & + 42 & + // 'named' — Named types. + SomeType & + AnotherType & + // 'object' — Object types. + { a: string; b: number; } & + // 'operator' — Operator types. + keyof T & + // 'tuple' — Tuple types. + [string, number] & + // 'union' — Union types. + (A | B) & + // 'nullish' — Nullish types. + null & + undefined; +``` ## Usage diff --git a/rules/sort-intersection-types.ts b/rules/sort-intersection-types.ts index afeeb42b0..0d98a4e62 100644 --- a/rules/sort-intersection-types.ts +++ b/rules/sort-intersection-types.ts @@ -1,11 +1,13 @@ import type { SortingNode } from '../typings' import { createEslintRule } from '../utils/create-eslint-rule' +import { getGroupNumber } from '../utils/get-group-number' import { getSourceCode } from '../utils/get-source-code' import { toSingleLine } from '../utils/to-single-line' import { rangeToDiff } from '../utils/range-to-diff' import { isPositive } from '../utils/is-positive' import { sortNodes } from '../utils/sort-nodes' +import { useGroups } from '../utils/use-groups' import { makeFixes } from '../utils/make-fixes' import { complete } from '../utils/complete' import { pairwise } from '../utils/pairwise' @@ -13,9 +15,25 @@ import { compare } from '../utils/compare' type MESSAGE_ID = 'unexpectedIntersectionTypesOrder' +type Group = + | 'intersection' + | 'conditional' + | 'function' + | 'operator' + | 'keyword' + | 'literal' + | 'nullish' + | 'unknown' + | 'import' + | 'object' + | 'named' + | 'tuple' + | 'union' + type Options = [ Partial<{ type: 'alphabetical' | 'line-length' | 'natural' + groups: (Group[] | Group)[] order: 'desc' | 'asc' ignoreCase: boolean }>, @@ -49,6 +67,9 @@ export default createEslintRule({ type: 'boolean', default: true, }, + groups: { + type: 'array', + }, }, additionalProperties: false, }, @@ -70,24 +91,80 @@ export default createEslintRule({ type: 'alphabetical', ignoreCase: true, order: 'asc', + groups: [], } as const) let sourceCode = getSourceCode(context) - let nodes: SortingNode[] = node.types.map(type => ({ - group: - type.type === 'TSNullKeyword' || type.type === 'TSUndefinedKeyword' - ? 'nullable' - : 'unknown', - name: sourceCode.text.slice(...type.range), - size: rangeToDiff(type.range), - node: type, - })) + let nodes: SortingNode[] = node.types.map(type => { + let { getGroup, defineGroup } = useGroups(options.groups) + + switch (type.type) { + case 'TSConditionalType': + defineGroup('conditional') + break + case 'TSFunctionType': + defineGroup('function') + break + case 'TSImportType': + defineGroup('import') + break + case 'TSIntersectionType': + defineGroup('intersection') + break + case 'TSAnyKeyword': + case 'TSBigIntKeyword': + case 'TSBooleanKeyword': + case 'TSNeverKeyword': + case 'TSNumberKeyword': + case 'TSObjectKeyword': + case 'TSStringKeyword': + case 'TSUnknownKeyword': + case 'TSVoidKeyword': + defineGroup('keyword') + break + case 'TSLiteralType': + defineGroup('literal') + break + case 'TSTypeReference': + case 'TSIndexedAccessType': + defineGroup('named') + break + case 'TSTypeLiteral': + defineGroup('object') + break + case 'TSTypeQuery': + case 'TSTypeOperator': + defineGroup('operator') + break + case 'TSTupleType': + defineGroup('tuple') + break + case 'TSUnionType': + defineGroup('union') + break + case 'TSNullKeyword': + case 'TSUndefinedKeyword': + defineGroup('nullish') + break + } + + return { + name: sourceCode.text.slice(...type.range), + size: rangeToDiff(type.range), + group: getGroup(), + node: type, + } + }) pairwise(nodes, (left, right) => { - let compareValue = isPositive(compare(left, right, options)) + let leftNum = getGroupNumber(options.groups, left) + let rightNum = getGroupNumber(options.groups, right) - if (compareValue) { + if ( + leftNum > rightNum || + (leftNum === rightNum && isPositive(compare(left, right, options))) + ) { context.report({ messageId: 'unexpectedIntersectionTypesOrder', data: { @@ -96,7 +173,30 @@ export default createEslintRule({ }, node: right.node, fix: fixer => { - let sortedNodes = sortNodes(nodes, options) + let grouped: { + [key: string]: SortingNode[] + } = {} + + for (let currentNode of nodes) { + let groupNum = getGroupNumber(options.groups, currentNode) + + if (!(groupNum in grouped)) { + grouped[groupNum] = [currentNode] + } else { + grouped[groupNum] = sortNodes( + [...grouped[groupNum], currentNode], + options, + ) + } + } + + let sortedNodes: SortingNode[] = [] + + for (let group of Object.keys(grouped).sort( + (a, b) => Number(a) - Number(b), + )) { + sortedNodes.push(...sortNodes(grouped[group], options)) + } return makeFixes(fixer, nodes, sortedNodes, sourceCode) }, diff --git a/test/sort-intersection-types.test.ts b/test/sort-intersection-types.test.ts index b6da9db56..50418be00 100644 --- a/test/sort-intersection-types.test.ts +++ b/test/sort-intersection-types.test.ts @@ -261,6 +261,146 @@ describe(RULE_NAME, () => { ], }, ) + + ruleTester.run(`${RULE_NAME}: sorts intersections using groups`, rule, { + valid: [ + { + code: dedent` + type Type = + & A + & any + & bigint + & boolean + & keyof A + & typeof B + & 'aaa' + & 1 + & (import('path')) + & (A extends B ? C : D) + & { name: 'a' } + & [A, B, C] + & (A & B) + & (A | B) + & null + `, + options: [ + { + ...options, + groups: [ + 'named', + 'keyword', + 'operator', + 'literal', + 'function', + 'import', + 'conditional', + 'object', + 'tuple', + 'intersection', + 'union', + 'nullish', + ], + }, + ], + }, + ], + invalid: [ + { + code: dedent` + type Type = + & any + & { name: 'a' } + & boolean + & A + & keyof A + & bigint + & typeof B + & 'aaa' + & (import('path')) + & null + & 1 + & (A extends B ? C : D) + & [A, B, C] + & (A | B) + & (A & B) + `, + output: dedent` + type Type = + & A + & any + & bigint + & boolean + & keyof A + & typeof B + & 'aaa' + & 1 + & (import('path')) + & (A extends B ? C : D) + & { name: 'a' } + & [A, B, C] + & (A & B) + & (A | B) + & null + `, + options: [ + { + ...options, + groups: [ + 'named', + 'keyword', + 'operator', + 'literal', + 'function', + 'import', + 'conditional', + 'object', + 'tuple', + 'intersection', + 'union', + 'nullish', + ], + }, + ], + errors: [ + { + messageId: 'unexpectedIntersectionTypesOrder', + data: { + left: "{ name: 'a' }", + right: 'boolean', + }, + }, + { + messageId: 'unexpectedIntersectionTypesOrder', + data: { + left: 'boolean', + right: 'A', + }, + }, + { + messageId: 'unexpectedIntersectionTypesOrder', + data: { + left: 'keyof A', + right: 'bigint', + }, + }, + { + messageId: 'unexpectedIntersectionTypesOrder', + data: { + left: 'null', + right: '1', + }, + }, + { + messageId: 'unexpectedIntersectionTypesOrder', + data: { + left: 'A | B', + right: 'A & B', + }, + }, + ], + }, + ], + }) }) describe(`${RULE_NAME}: sorting by natural order`, () => { @@ -488,6 +628,146 @@ describe(RULE_NAME, () => { ], }, ) + + ruleTester.run(`${RULE_NAME}: sorts intersections using groups`, rule, { + valid: [ + { + code: dedent` + type Type = + & A + & any + & bigint + & boolean + & keyof A + & typeof B + & 'aaa' + & 1 + & (import('path')) + & (A extends B ? C : D) + & { name: 'a' } + & [A, B, C] + & (A & B) + & (A | B) + & null + `, + options: [ + { + ...options, + groups: [ + 'named', + 'keyword', + 'operator', + 'literal', + 'function', + 'import', + 'conditional', + 'object', + 'tuple', + 'intersection', + 'union', + 'nullish', + ], + }, + ], + }, + ], + invalid: [ + { + code: dedent` + type Type = + & any + & { name: 'a' } + & boolean + & A + & keyof A + & bigint + & typeof B + & 'aaa' + & (import('path')) + & null + & 1 + & (A extends B ? C : D) + & [A, B, C] + & (A | B) + & (A & B) + `, + output: dedent` + type Type = + & A + & any + & bigint + & boolean + & keyof A + & typeof B + & 'aaa' + & 1 + & (import('path')) + & (A extends B ? C : D) + & { name: 'a' } + & [A, B, C] + & (A & B) + & (A | B) + & null + `, + options: [ + { + ...options, + groups: [ + 'named', + 'keyword', + 'operator', + 'literal', + 'function', + 'import', + 'conditional', + 'object', + 'tuple', + 'intersection', + 'union', + 'nullish', + ], + }, + ], + errors: [ + { + messageId: 'unexpectedIntersectionTypesOrder', + data: { + left: "{ name: 'a' }", + right: 'boolean', + }, + }, + { + messageId: 'unexpectedIntersectionTypesOrder', + data: { + left: 'boolean', + right: 'A', + }, + }, + { + messageId: 'unexpectedIntersectionTypesOrder', + data: { + left: 'keyof A', + right: 'bigint', + }, + }, + { + messageId: 'unexpectedIntersectionTypesOrder', + data: { + left: 'null', + right: '1', + }, + }, + { + messageId: 'unexpectedIntersectionTypesOrder', + data: { + left: 'A | B', + right: 'A & B', + }, + }, + ], + }, + ], + }) }) describe(`${RULE_NAME}: sorting by line length`, () => { @@ -710,6 +990,146 @@ describe(RULE_NAME, () => { ], }, ) + + ruleTester.run(`${RULE_NAME}: sorts intersections using groups`, rule, { + valid: [ + { + code: dedent` + type Type = + & A + & boolean + & bigint + & any + & typeof B + & keyof A + & 'aaa' + & 1 + & (import('path')) + & (A extends B ? C : D) + & { name: 'a' } + & [A, B, C] + & (A & B) + & (A | B) + & null + `, + options: [ + { + ...options, + groups: [ + 'named', + 'keyword', + 'operator', + 'literal', + 'function', + 'import', + 'conditional', + 'object', + 'tuple', + 'intersection', + 'union', + 'nullish', + ], + }, + ], + }, + ], + invalid: [ + { + code: dedent` + type Type = + & any + & { name: 'a' } + & boolean + & A + & keyof A + & bigint + & typeof B + & 'aaa' + & (import('path')) + & null + & 1 + & (A extends B ? C : D) + & [A, B, C] + & (A | B) + & (A & B) + `, + output: dedent` + type Type = + & A + & boolean + & bigint + & any + & typeof B + & keyof A + & 'aaa' + & 1 + & (import('path')) + & (A extends B ? C : D) + & { name: 'a' } + & [A, B, C] + & (A & B) + & (A | B) + & null + `, + options: [ + { + ...options, + groups: [ + 'named', + 'keyword', + 'operator', + 'literal', + 'function', + 'import', + 'conditional', + 'object', + 'tuple', + 'intersection', + 'union', + 'nullish', + ], + }, + ], + errors: [ + { + messageId: 'unexpectedIntersectionTypesOrder', + data: { + left: "{ name: 'a' }", + right: 'boolean', + }, + }, + { + messageId: 'unexpectedIntersectionTypesOrder', + data: { + left: 'boolean', + right: 'A', + }, + }, + { + messageId: 'unexpectedIntersectionTypesOrder', + data: { + left: 'keyof A', + right: 'bigint', + }, + }, + { + messageId: 'unexpectedIntersectionTypesOrder', + data: { + left: 'null', + right: '1', + }, + }, + { + messageId: 'unexpectedIntersectionTypesOrder', + data: { + left: 'A | B', + right: 'A & B', + }, + }, + ], + }, + ], + }) }) describe(`${RULE_NAME}: misc`, () => {