diff --git a/docs/content/rules/sort-enums.mdx b/docs/content/rules/sort-enums.mdx index 9c072040..ad930d39 100644 --- a/docs/content/rules/sort-enums.mdx +++ b/docs/content/rules/sort-enums.mdx @@ -112,6 +112,26 @@ 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”). +### sortByValue + +default: `false` + +Controls whether sorting should be done using the enum's values or names. + +- `true` — Use enum values. +- `false` — Use enum names. + +When this setting is `true`, numeric enums will have their values sorted numerically regardless of the `type` setting. + +### forceNumericSort + +default: `false` + +Controls whether numeric enums should always be sorted numerically, regardless of the `type` and `sortByValue` settings. + +- `true` — Use enum values. +- `false` — Use enum names. + ### partitionByComment default: `false` @@ -144,6 +164,7 @@ Allows you to use comments to separate the members of enums into logical groups. order: 'asc', ignoreCase: true, partitionByComment: false, + sortByValue: false }, ], }, @@ -168,6 +189,8 @@ Allows you to use comments to separate the members of enums into logical groups. order: 'asc', ignoreCase: true, partitionByComment: false, + sortByValue: false, + forceNumericSort: false }, ], }, diff --git a/rules/sort-enums.ts b/rules/sort-enums.ts index 44a9f96c..d30ba0c2 100644 --- a/rules/sort-enums.ts +++ b/rules/sort-enums.ts @@ -1,5 +1,6 @@ import type { TSESTree } from '@typescript-eslint/types' +import type { CompareOptions } from '../utils/compare' import type { SortingNode } from '../typings' import { isPartitionComment } from '../utils/is-partition-comment' @@ -17,11 +18,13 @@ import { compare } from '../utils/compare' type MESSAGE_ID = 'unexpectedEnumsOrder' -type Options = [ +export type Options = [ Partial<{ type: 'alphabetical' | 'line-length' | 'natural' partitionByComment: string[] | boolean | string + forceNumericSort: boolean order: 'desc' | 'asc' + sortByValue: boolean ignoreCase: boolean }>, ] @@ -54,6 +57,15 @@ export default createEslintRule({ 'Controls whether sorting should be case-sensitive or not.', type: 'boolean', }, + sortByValue: { + description: 'Compare enum values instead of names.', + type: 'boolean', + }, + forceNumericSort: { + description: + 'Will always sort numeric enums by their value regardless of the sort type specified.', + type: 'boolean', + }, partitionByComment: { description: 'Allows you to use comments to separate the class members into logical groups.', @@ -85,7 +97,9 @@ export default createEslintRule({ type: 'alphabetical', order: 'asc', ignoreCase: true, + sortByValue: false, partitionByComment: false, + forceNumericSort: false, }, ], create: context => ({ @@ -94,21 +108,24 @@ export default createEslintRule({ /* v8 ignore next 2 */ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition node.body?.members ?? nodeValue.members ?? [] + let members = getMembers(node) if ( - getMembers(node).length > 1 && - getMembers(node).every(({ initializer }) => initializer) + members.length > 1 && + members.every(({ initializer }) => initializer) ) { let options = complete(context.options.at(0), { partitionByComment: false, type: 'alphabetical', ignoreCase: true, order: 'asc', + sortByValue: false, + forceNumericSort: false, } as const) let sourceCode = getSourceCode(context) let partitionComment = options.partitionByComment - let formattedMembers: SortingNode[][] = getMembers(node).reduce( + let formattedMembers: SortingNode[][] = members.reduce( (accumulator: SortingNode[][], member) => { let comment = getCommentBefore(member, sourceCode) @@ -135,10 +152,37 @@ export default createEslintRule({ }, [[]], ) + let isNumericEnum = members.every( + member => + member.initializer?.type === 'Literal' && + typeof member.initializer.value === 'number', + ) + let compareOptions: CompareOptions = { + // If the enum is numeric, and we sort by value, always use the `natural` sort type, which will correctly sort them. + type: + isNumericEnum && (options.forceNumericSort || options.sortByValue) + ? 'natural' + : options.type, + order: options.order, + ignoreCase: options.ignoreCase, + // Get the enum value rather than the name if needed + nodeValueGetter: + options.sortByValue || (isNumericEnum && options.forceNumericSort) + ? sortingNode => { + if ( + sortingNode.node.type === 'TSEnumMember' && + sortingNode.node.initializer?.type === 'Literal' + ) { + return sortingNode.node.initializer.value?.toString() ?? '' + } + return '' + } + : undefined, + } for (let nodes of formattedMembers) { pairwise(nodes, (left, right) => { - if (isPositive(compare(left, right, options))) { + if (isPositive(compare(left, right, compareOptions))) { context.report({ messageId: 'unexpectedEnumsOrder', data: { @@ -150,7 +194,7 @@ export default createEslintRule({ makeFixes( fixer, nodes, - sortNodes(nodes, options), + sortNodes(nodes, compareOptions), sourceCode, { partitionComment }, ), diff --git a/test/sort-enums.test.ts b/test/sort-enums.test.ts index 66629266..76581ca2 100644 --- a/test/sort-enums.test.ts +++ b/test/sort-enums.test.ts @@ -2,6 +2,8 @@ import { RuleTester } from '@typescript-eslint/rule-tester' import { afterAll, describe, it } from 'vitest' import { dedent } from 'ts-dedent' +import type { Options } from '../rules/sort-enums' + import rule from '../rules/sort-enums' let ruleName = 'sort-enums' @@ -396,6 +398,115 @@ describe(ruleName, () => { ], }, ) + + ruleTester.run(`${ruleName}: sort enum values correctly`, rule, { + valid: [], + invalid: [ + { + code: dedent` + enum Enum { + 'a' = 'i', + 'b' = 'h', + 'c' = 'g', + 'd' = 'f', + 'e' = 'e', + 'f' = 'd', + 'g' = 'c', + 'h' = 'b', + 'i' = 'a', + 'j' = null, + 'k' = undefined, + } + `, + output: dedent` + enum Enum { + 'j' = null, + 'k' = undefined, + 'i' = 'a', + 'h' = 'b', + 'g' = 'c', + 'f' = 'd', + 'e' = 'e', + 'd' = 'f', + 'c' = 'g', + 'b' = 'h', + 'a' = 'i', + } + `, + options: [ + { + ...options, + sortByValue: true, + }, + ], + errors: [ + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'a', + right: 'b', + }, + }, + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'b', + right: 'c', + }, + }, + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'c', + right: 'd', + }, + }, + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'd', + right: 'e', + }, + }, + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'e', + right: 'f', + }, + }, + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'f', + right: 'g', + }, + }, + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'g', + right: 'h', + }, + }, + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'h', + right: 'i', + }, + }, + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'i', + right: 'j', + }, + }, + ], + }, + ], + }) }) describe(`${ruleName}: sorting by natural order`, () => { @@ -778,6 +889,108 @@ describe(ruleName, () => { ], }, ) + + ruleTester.run(`${ruleName}: sort enum values correctly`, rule, { + valid: [], + invalid: [ + { + code: dedent` + enum Enum { + 'a' = 'ffffff', + 'b' = 4444, + 'c' = 6, + 'd' = 5, + 'e' = 4, + 'f' = '3', + 'g' = 2, + 'h' = 1, + 'i' = '', + 'j' = null, + 'k' = undefined, + } + `, + output: dedent` + enum Enum { + 'i' = '', + 'j' = null, + 'k' = undefined, + 'h' = 1, + 'g' = 2, + 'f' = '3', + 'e' = 4, + 'd' = 5, + 'c' = 6, + 'b' = 4444, + 'a' = 'ffffff', + } + `, + options: [ + { + ...options, + sortByValue: true, + }, + ], + errors: [ + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'a', + right: 'b', + }, + }, + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'b', + right: 'c', + }, + }, + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'c', + right: 'd', + }, + }, + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'd', + right: 'e', + }, + }, + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'e', + right: 'f', + }, + }, + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'f', + right: 'g', + }, + }, + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'g', + right: 'h', + }, + }, + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'h', + right: 'i', + }, + }, + ], + }, + ], + }) }) describe(`${ruleName}: sorting by line length`, () => { @@ -1152,9 +1365,291 @@ describe(ruleName, () => { ], }, ) + + ruleTester.run(`${ruleName}: sort enum values correctly`, rule, { + valid: [], + invalid: [ + { + code: dedent` + enum Enum { + 'a' = '', + 'b' = 'b', + 'c' = 'cc', + 'd' = 'ddd', + 'e' = 'eeee', + 'f' = 'fffff', + 'g' = 'gggggg', + 'h' = 'hhhhhhh', + 'i' = 'iiiiiiii', + 'j' = 'jj', + } + `, + output: dedent` + enum Enum { + 'i' = 'iiiiiiii', + 'h' = 'hhhhhhh', + 'g' = 'gggggg', + 'f' = 'fffff', + 'e' = 'eeee', + 'd' = 'ddd', + 'c' = 'cc', + 'j' = 'jj', + 'b' = 'b', + 'a' = '', + } + `, + options: [ + { + ...options, + sortByValue: true, + }, + ], + errors: [ + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'a', + right: 'b', + }, + }, + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'b', + right: 'c', + }, + }, + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'c', + right: 'd', + }, + }, + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'd', + right: 'e', + }, + }, + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'e', + right: 'f', + }, + }, + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'f', + right: 'g', + }, + }, + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'g', + right: 'h', + }, + }, + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'h', + right: 'i', + }, + }, + ], + }, + ], + }) }) describe(`${ruleName}: misc`, () => { + ruleTester.run(`${ruleName}: detects numeric enums`, rule, { + valid: [ + { + code: dedent` + enum Enum { + 'a' = '1', + 'b' = 2, + 'c' = 0, + } + `, + options: [ + { + forceNumericSort: true, + }, + ], + }, + { + code: dedent` + enum Enum { + 'a' = 1, + 'b' = 2, + 'c' = 0, + d, + } + `, + options: [ + { + forceNumericSort: true, + }, + ], + }, + { + code: dedent` + enum Enum { + 'a' = 1, + 'b' = 2, + 'c' = 0, + d = undefined, + } + `, + options: [ + { + forceNumericSort: true, + }, + ], + }, + { + code: dedent` + enum Enum { + 'a' = 1, + 'b' = 2, + 'c' = 0, + d = null, + } + `, + options: [ + { + forceNumericSort: true, + }, + ], + }, + { + code: dedent` + enum Enum { + 'c' = 0, + 'a' = 1, + 'b' = 2, + } + `, + options: [ + { + forceNumericSort: true, + }, + ], + }, + ], + invalid: [], + }) + + let sortTypes: Options[0]['type'][] = [ + 'alphabetical', + 'line-length', + 'natural', + ] + for (let type of sortTypes) { + ruleTester.run( + `${ruleName}: sortByValue = true => sorts numerical enums numerically for type ${type}`, + rule, + { + valid: [], + invalid: [ + { + code: dedent` + enum Enum { + 'b' = 2, + 'a' = 1, + 'c' = 0, + } + `, + output: dedent` + enum Enum { + 'c' = 0, + 'a' = 1, + 'b' = 2, + } + `, + options: [ + { + type, + sortByValue: true, + }, + ], + errors: [ + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'b', + right: 'a', + }, + }, + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'a', + right: 'c', + }, + }, + ], + }, + ], + }, + ) + + ruleTester.run( + `${ruleName}: forceNumericSort = true => sorts numerical enums numerically regardless for type ${type}`, + rule, + { + valid: [], + invalid: [ + { + code: dedent` + enum Enum { + 'b' = 2, + 'a' = 1, + 'c' = 0, + } + `, + output: dedent` + enum Enum { + 'c' = 0, + 'a' = 1, + 'b' = 2, + } + `, + options: [ + { + type, + forceNumericSort: true, + }, + ], + errors: [ + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'b', + right: 'a', + }, + }, + { + messageId: 'unexpectedEnumsOrder', + data: { + left: 'a', + right: 'c', + }, + }, + ], + }, + ], + }, + ) + } + ruleTester.run( `${ruleName}: sets alphabetical asc sorting as default`, rule, diff --git a/utils/compare.ts b/utils/compare.ts index a233d75f..1cf8e3d9 100644 --- a/utils/compare.ts +++ b/utils/compare.ts @@ -2,15 +2,38 @@ import naturalCompare from 'natural-compare-lite' import type { SortingNode } from '../typings' +interface BaseCompareOptions { + /** + * Custom function to get the value of the node. By default, returns the node's name. + */ + nodeValueGetter?: (node: SortingNode) => string + order: 'desc' | 'asc' +} + +interface AlphabeticalCompareOptions extends BaseCompareOptions { + type: 'alphabetical' + ignoreCase?: boolean +} + +interface LineLengthCompareOptions extends BaseCompareOptions { + maxLineLength?: number + type: 'line-length' +} + +interface NaturalCompareOptions extends BaseCompareOptions { + ignoreCase?: boolean + type: 'natural' +} + +export type CompareOptions = + | AlphabeticalCompareOptions + | LineLengthCompareOptions + | NaturalCompareOptions + export let compare = ( a: SortingNode, b: SortingNode, - options: { - type: 'alphabetical' | 'line-length' | 'natural' - maxLineLength?: number - order: 'desc' | 'asc' - ignoreCase?: boolean - }, + options: CompareOptions, ): number => { if (b.dependencies?.includes(a.name)) { return -1 @@ -21,12 +44,19 @@ export let compare = ( let orderCoefficient = options.order === 'asc' ? 1 : -1 let sortingFunction: (a: SortingNode, b: SortingNode) => number - let formatString = (string: string) => - options.ignoreCase ? string.toLowerCase() : string + let formatString = + options.type === 'line-length' || !options.ignoreCase + ? (string: string) => string + : (string: string) => string.toLowerCase() + + let nodeValueGetter = + options.nodeValueGetter ?? ((node: SortingNode) => node.name) if (options.type === 'alphabetical') { sortingFunction = (aNode, bNode) => - formatString(aNode.name).localeCompare(formatString(bNode.name)) + formatString(nodeValueGetter(aNode)).localeCompare( + formatString(nodeValueGetter(bNode)), + ) } else if (options.type === 'natural') { let prepareNumeric = (string: string) => { let formattedNumberPattern = /^[+-]?[\d ,_]+(\.[\d ,_]+)?$/ @@ -37,8 +67,8 @@ export let compare = ( } sortingFunction = (aNode, bNode) => naturalCompare( - prepareNumeric(formatString(aNode.name)), - prepareNumeric(formatString(bNode.name)), + prepareNumeric(formatString(nodeValueGetter(aNode))), + prepareNumeric(formatString(nodeValueGetter(bNode))), ) } else { sortingFunction = (aNode, bNode) => { @@ -52,11 +82,11 @@ export let compare = ( size > maxLineLength && node.hasMultipleImportDeclarations if (isTooLong(aSize, aNode)) { - aSize = aNode.name.length + 10 + aSize = nodeValueGetter(aNode).length + 10 } if (isTooLong(bSize, bNode)) { - bSize = bNode.name.length + 10 + bSize = nodeValueGetter(bNode).length + 10 } } diff --git a/utils/sort-nodes.ts b/utils/sort-nodes.ts index da88ffb3..c8c1487d 100644 --- a/utils/sort-nodes.ts +++ b/utils/sort-nodes.ts @@ -1,12 +1,9 @@ +import type { CompareOptions } from './compare' import type { SortingNode } from '../typings' import { compare } from './compare' export let sortNodes = ( nodes: T[], - options: { - type: 'alphabetical' | 'line-length' | 'natural' - order: 'desc' | 'asc' - ignoreCase?: boolean - }, + options: CompareOptions, ): T[] => [...nodes].sort((a, b) => compare(a, b, options))