From c69f27786818ecbdb881c25627ebf00eb359dbd4 Mon Sep 17 00:00:00 2001 From: "Azat S." Date: Sat, 20 Jul 2024 00:11:30 +0300 Subject: [PATCH] feat: add groups option in sort-union-types rule --- docs/content/rules/sort-union-types.mdx | 58 ++++ rules/sort-union-types.ts | 121 ++++++- test/sort-union-types.test.ts | 420 ++++++++++++++++++++++++ 3 files changed, 591 insertions(+), 8 deletions(-) diff --git a/docs/content/rules/sort-union-types.mdx b/docs/content/rules/sort-union-types.mdx index f7837f27..cd7338e1 100644 --- a/docs/content/rules/sort-union-types.mdx +++ b/docs/content/rules/sort-union-types.mdx @@ -122,6 +122,64 @@ 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 union 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 , @@ -49,6 +67,9 @@ export default createEslintRule({ type: 'boolean', default: true, }, + groups: { + type: 'array', + }, }, additionalProperties: false, }, @@ -70,20 +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 => ({ - 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: 'unexpectedUnionTypesOrder', data: { @@ -92,7 +173,31 @@ export default createEslintRule({ }, node: right.node, fix: fixer => { - let sortedNodes: SortingNode[] = 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-union-types.test.ts b/test/sort-union-types.test.ts index 3541bb10..c3f83c18 100644 --- a/test/sort-union-types.test.ts +++ b/test/sort-union-types.test.ts @@ -264,6 +264,146 @@ describe(RULE_NAME, () => { }, ], }) + + ruleTester.run(`${RULE_NAME}: sorts unions 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: 'unexpectedUnionTypesOrder', + data: { + left: "{ name: 'a' }", + right: 'boolean', + }, + }, + { + messageId: 'unexpectedUnionTypesOrder', + data: { + left: 'boolean', + right: 'A', + }, + }, + { + messageId: 'unexpectedUnionTypesOrder', + data: { + left: 'keyof A', + right: 'bigint', + }, + }, + { + messageId: 'unexpectedUnionTypesOrder', + data: { + left: 'null', + right: '1', + }, + }, + { + messageId: 'unexpectedUnionTypesOrder', + data: { + left: 'A | B', + right: 'A & B', + }, + }, + ], + }, + ], + }) }) describe(`${RULE_NAME}: sorting by natural order`, () => { @@ -507,6 +647,146 @@ describe(RULE_NAME, () => { }, ], }) + + ruleTester.run(`${RULE_NAME}: sorts unions 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: 'unexpectedUnionTypesOrder', + data: { + left: "{ name: 'a' }", + right: 'boolean', + }, + }, + { + messageId: 'unexpectedUnionTypesOrder', + data: { + left: 'boolean', + right: 'A', + }, + }, + { + messageId: 'unexpectedUnionTypesOrder', + data: { + left: 'keyof A', + right: 'bigint', + }, + }, + { + messageId: 'unexpectedUnionTypesOrder', + data: { + left: 'null', + right: '1', + }, + }, + { + messageId: 'unexpectedUnionTypesOrder', + data: { + left: 'A | B', + right: 'A & B', + }, + }, + ], + }, + ], + }) }) describe(`${RULE_NAME}: sorting by line length`, () => { @@ -742,6 +1022,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: 'unexpectedUnionTypesOrder', + data: { + left: "{ name: 'a' }", + right: 'boolean', + }, + }, + { + messageId: 'unexpectedUnionTypesOrder', + data: { + left: 'boolean', + right: 'A', + }, + }, + { + messageId: 'unexpectedUnionTypesOrder', + data: { + left: 'keyof A', + right: 'bigint', + }, + }, + { + messageId: 'unexpectedUnionTypesOrder', + data: { + left: 'null', + right: '1', + }, + }, + { + messageId: 'unexpectedUnionTypesOrder', + data: { + left: 'A | B', + right: 'A & B', + }, + }, + ], + }, + ], + }) }) describe(`${RULE_NAME}: misc`, () => {