Repository: azat-io/eslint-plugin-perfectionist (ts)

Issue:
Bug: sort-enums negative values not sorted correctly with forceNumericSort: true
### Describe the bug

In the sort-enums rule when the forceNumericSort: true flag is set a negative value causes the ordering to fallback on the configured type (alphabetical etc.)



### Code example

```typescript
export enum NumericTypeCorrectOrder {
    One = 1,
    Two = 2,
    Three = 3
}

export enum NumericTypeWrongOrder {
    One = 1,
    OneNegative = -1, 
    Three = 3,
    Two = 2
}

```

### ESLint version

v9.15.0

### ESLint Plugin Perfectionist version

v4.0.3

### Additional comments

The sort-enums.ts source code has a check for determining an enum is a numeric one (https://github.com/azat-io/eslint-plugin-perfectionist/blob/main/rules/sort-enums.ts#L157)

```typescript
     let isNumericEnum = members.every(
        member =>
          member.initializer?.type === 'Literal' &&
          typeof member.initializer.value === 'number',
      )
```

I locally logged out the type for the members, and when it hit the negative value it had a type of `UnaryExpression` instead of `Literal`

### Validations

- [X] Read the [docs](https://perfectionist.dev).
- [X] Check that there isn't [already an issue](https://github.com/azat-io/eslint-plugin-perfectionist/issues) that reports the same bug to avoid creating a duplicate.

Pull request description:
feat(sort-enums): handles numeric operations
Fixes #386

### Description

Today, users have the possibility to sort numeric enums. However, numeric enums might not directly be written with literal numeric values.

```ts
enum Period {
  A = 60,
  B = 60 * 60 * 24 * 31 // About 1 month in seconds 
}
``` 

This PR handles numeric operations on enum values to deduce their value, and sort them appropriately.

### What is the purpose of this pull request? <!-- (put an "X" next to an item) -->

- [x] Bug fix
- [x] New Feature


Patch:
diff --git a/rules/sort-classes-utils.ts b/rules/sort-classes-utils.ts
index 1dd177f..3df9c7f 100644
--- a/rules/sort-classes-utils.ts
+++ b/rules/sort-classes-utils.ts
@@ -7,6 +7,7 @@ import type {
   Modifier,
   Selector,
 } from './sort-classes.types'
+import type { SortingNodeWithDependencies } from '../utils/sort-nodes-by-dependencies'
 import type { CompareOptions } from '../utils/compare'
 
 import { isSortable } from '../utils/is-sortable'
@@ -150,7 +151,7 @@ export let customGroupMatches = (props: CustomGroupMatchesProps): boolean => {
 export let getCompareOptions = (
   options: Required<SortClassesOptions[0]>,
   groupNumber: number,
-): CompareOptions | null => {
+): CompareOptions<SortingNodeWithDependencies> | null => {
   let group = options.groups[groupNumber]
   let customGroup =
     typeof group === 'string'
diff --git a/rules/sort-enums.ts b/rules/sort-enums.ts
index 385b6e9..d0fcdd1 100644
--- a/rules/sort-enums.ts
+++ b/rules/sort-enums.ts
@@ -47,6 +47,11 @@ export type Options = [
   }>,
 ]
 
+interface SortEnumsSortingNode
+  extends SortingNodeWithDependencies<TSESTree.TSEnumMember> {
+  numericValue: number | null
+}
+
 type MESSAGE_ID = 'unexpectedEnumsDependencyOrder' | 'unexpectedEnumsOrder'
 
 let defaultOptions: Required<Options[0]> = {
@@ -63,8 +68,8 @@ let defaultOptions: Required<Options[0]> = {
 
 export default createEslintRule<Options, MESSAGE_ID>({
   create: context => ({
-    TSEnumDeclaration: node => {
-      let members = getEnumMembers(node)
+    TSEnumDeclaration: enumDeclaration => {
+      let members = getEnumMembers(enumDeclaration)
       if (
         !isSortable(members) ||
         !members.every(({ initializer }) => initializer)
@@ -116,14 +121,22 @@ export default createEslintRule<Options, MESSAGE_ID>({
         checkNode(expression)
         return dependencies
       }
-      let formattedMembers: SortingNodeWithDependencies[][] = members.reduce(
-        (accumulator: SortingNodeWithDependencies[][], member) => {
+      let formattedMembers: SortEnumsSortingNode[][] = members.reduce(
+        (accumulator: SortEnumsSortingNode[][], member) => {
           let dependencies: string[] = []
           if (member.initializer) {
-            dependencies = extractDependencies(member.initializer, node.id.name)
+            dependencies = extractDependencies(
+              member.initializer,
+              enumDeclaration.id.name,
+            )
           }
           let lastSortingNode = accumulator.at(-1)?.at(-1)
-          let sortingNode: SortingNodeWithDependencies = {
+          let sortingNode: SortEnumsSortingNode = {
+            numericValue: member.initializer
+              ? getExpressionNumberValue(
+                  member.initializer,
+                ) /* v8 ignore next - Unsure how we can reach that case */
+              : null,
             name:
               member.id.type === 'Literal'
                 ? `${member.id.value}`
@@ -152,20 +165,22 @@ export default createEslintRule<Options, MESSAGE_ID>({
         },
         [[]],
       )
-      let isNumericEnum = members.every(
-        member =>
-          member.initializer?.type === 'Literal' &&
-          typeof member.initializer.value === 'number',
+
+      let sortingNodes = formattedMembers.flat()
+      let isNumericEnum = sortingNodes.every(
+        sortingNode =>
+          sortingNode.numericValue !== null &&
+          !Number.isNaN(sortingNode.numericValue),
       )
-      let compareOptions: CompareOptions = {
+      let compareOptions: CompareOptions<SortEnumsSortingNode> = {
         // 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'
-                ) {
+                if (isNumericEnum) {
+                  return sortingNode.numericValue!.toString()
+                }
+                if (sortingNode.node.initializer?.type === 'Literal') {
                   return sortingNode.node.initializer.value?.toString() ?? ''
                 }
                 return ''
@@ -184,7 +199,7 @@ export default createEslintRule<Options, MESSAGE_ID>({
 
       let sortNodesIgnoringEslintDisabledNodes = (
         ignoreEslintDisabledNodes: boolean,
-      ): SortingNodeWithDependencies[] =>
+      ): SortEnumsSortingNode[] =>
         sortNodesByDependencies(
           formattedMembers.flatMap(nodes =>
             sortNodes(nodes, compareOptions, {
@@ -198,9 +213,8 @@ export default createEslintRule<Options, MESSAGE_ID>({
       let sortedNodes = sortNodesIgnoringEslintDisabledNodes(false)
       let sortedNodesExcludingEslintDisabled =
         sortNodesIgnoringEslintDisabledNodes(true)
-      let nodes = formattedMembers.flat()
 
-      pairwise(nodes, (left, right) => {
+      pairwise(sortingNodes, (left, right) => {
         let indexOfLeft = sortedNodes.indexOf(left)
         let indexOfRight = sortedNodes.indexOf(right)
         let indexOfRightExcludingEslintDisabled =
@@ -213,15 +227,15 @@ export default createEslintRule<Options, MESSAGE_ID>({
         }
 
         let firstUnorderedNodeDependentOnRight =
-          getFirstUnorderedNodeDependentOn(right, nodes)
+          getFirstUnorderedNodeDependentOn(right, sortingNodes)
         context.report({
           fix: fixer =>
             makeFixes({
               sortedNodes: sortedNodesExcludingEslintDisabled,
+              nodes: sortingNodes,
               sourceCode,
               options,
               fixer,
-              nodes,
             }),
           data: {
             nodeDependentOnRight: firstUnorderedNodeDependentOnRight?.name,
@@ -281,3 +295,79 @@ export default createEslintRule<Options, MESSAGE_ID>({
   defaultOptions: [defaultOptions],
   name: 'sort-enums',
 })
+
+let getExpressionNumberValue = (expression: TSESTree.Node): number => {
+  switch (expression.type) {
+    case 'BinaryExpression':
+      return getBinaryExpressionNumberValue(
+        expression.left,
+        expression.right,
+        expression.operator,
+      )
+    case 'UnaryExpression':
+      return getUnaryExpressionNumberValue(
+        expression.argument,
+        expression.operator,
+      )
+    case 'Literal':
+      return typeof expression.value === 'number'
+        ? expression.value
+        : Number.NaN
+    default:
+      return Number.NaN
+  }
+}
+
+let getUnaryExpressionNumberValue = (
+  argumentExpression: TSESTree.Expression,
+  operator: string,
+): number => {
+  let argument = getExpressionNumberValue(argumentExpression)
+  switch (operator) {
+    case '+':
+      return argument
+    case '-':
+      return -argument
+    case '~':
+      return ~argument
+    /* v8 ignore next 2 - Unsure if we can reach it */
+    default:
+      return Number.NaN
+  }
+}
+
+let getBinaryExpressionNumberValue = (
+  leftExpression: TSESTree.PrivateIdentifier | TSESTree.Expression,
+  rightExpression: TSESTree.Expression,
+  operator: string,
+): number => {
+  let left = getExpressionNumberValue(leftExpression)
+  let right = getExpressionNumberValue(rightExpression)
+  switch (operator) {
+    case '**':
+      return left ** right
+    case '>>':
+      return left >> right
+    case '<<':
+      return left << right
+    case '+':
+      return left + right
+    case '-':
+      return left - right
+    case '*':
+      return left * right
+    case '/':
+      return left / right
+    case '%':
+      return left % right
+    case '|':
+      return left | right
+    case '&':
+      return left & right
+    case '^':
+      return left ^ right
+    /* v8 ignore next 2 - Unsure if we can reach it */
+    default:
+      return Number.NaN
+  }
+}
diff --git a/rules/sort-modules-utils.ts b/rules/sort-modules-utils.ts
index 3cc9ede..a0a22cf 100644
--- a/rules/sort-modules-utils.ts
+++ b/rules/sort-modules-utils.ts
@@ -5,6 +5,7 @@ import type {
   Modifier,
   Selector,
 } from './sort-modules.types'
+import type { SortingNodeWithDependencies } from '../utils/sort-nodes-by-dependencies'
 import type { CompareOptions } from '../utils/compare'
 
 import { matches } from '../utils/matches'
@@ -89,7 +90,7 @@ export let customGroupMatches = (props: CustomGroupMatchesProps): boolean => {
 export let getCompareOptions = (
   options: Required<SortModulesOptions[0]>,
   groupNumber: number,
-): CompareOptions | null => {
+): CompareOptions<SortingNodeWithDependencies> | null => {
   let group = options.groups[groupNumber]
   let customGroup =
     typeof group === 'string'
diff --git a/utils/compare.ts b/utils/compare.ts
index 106d6ac..cade523 100644
--- a/utils/compare.ts
+++ b/utils/compare.ts
@@ -2,48 +2,50 @@ import { compare as createNaturalCompare } from 'natural-orderby'
 
 import type { SortingNode } from '../typings'
 
-export type CompareOptions =
-  | AlphabeticalCompareOptions
-  | LineLengthCompareOptions
-  | NaturalCompareOptions
+export type CompareOptions<T extends SortingNode> =
+  | AlphabeticalCompareOptions<T>
+  | LineLengthCompareOptions<T>
+  | NaturalCompareOptions<T>
 
-interface BaseCompareOptions {
+interface BaseCompareOptions<T extends SortingNode> {
   /**
    * Custom function to get the value of the node. By default, returns the
    * node's name.
    */
-  nodeValueGetter?: ((node: SortingNode) => string) | null
+  nodeValueGetter?: ((node: T) => string) | null
   order: 'desc' | 'asc'
 }
 
-interface AlphabeticalCompareOptions extends BaseCompareOptions {
+interface AlphabeticalCompareOptions<T extends SortingNode>
+  extends BaseCompareOptions<T> {
   specialCharacters: 'remove' | 'trim' | 'keep'
   locales: NonNullable<Intl.LocalesArgument>
   type: 'alphabetical'
   ignoreCase: boolean
 }
 
-interface NaturalCompareOptions extends BaseCompareOptions {
+interface NaturalCompareOptions<T extends SortingNode>
+  extends BaseCompareOptions<T> {
   specialCharacters: 'remove' | 'trim' | 'keep'
   locales: NonNullable<Intl.LocalesArgument>
   ignoreCase: boolean
   type: 'natural'
 }
 
-interface LineLengthCompareOptions extends BaseCompareOptions {
+interface LineLengthCompareOptions<T extends SortingNode>
+  extends BaseCompareOptions<T> {
   maxLineLength?: number
   type: 'line-length'
 }
 
-export let compare = (
-  a: SortingNode,
-  b: SortingNode,
-  options: CompareOptions,
+export let compare = <T extends SortingNode>(
+  a: T,
+  b: T,
+  options: CompareOptions<T>,
 ): number => {
   let orderCoefficient = options.order === 'asc' ? 1 : -1
-  let sortingFunction: (a: SortingNode, b: SortingNode) => number
-  let nodeValueGetter =
-    options.nodeValueGetter ?? ((node: SortingNode) => node.name)
+  let sortingFunction: (a: T, b: T) => number
+  let nodeValueGetter = options.nodeValueGetter ?? ((node: T) => node.name)
   if (options.type === 'alphabetical') {
     let formatString = getFormatStringFunction(
       options.ignoreCase,
@@ -76,10 +78,7 @@ export let compare = (
       let { maxLineLength } = options
 
       if (maxLineLength) {
-        let isTooLong = (
-          size: number,
-          node: SortingNode,
-        ): undefined | boolean =>
+        let isTooLong = (size: number, node: T): undefined | boolean =>
           size > maxLineLength && node.hasMultipleImportDeclarations
 
         if (isTooLong(aSize, aNode)) {
diff --git a/utils/sort-nodes-by-groups.ts b/utils/sort-nodes-by-groups.ts
index 822ef63..72ce9e7 100644
--- a/utils/sort-nodes-by-groups.ts
+++ b/utils/sort-nodes-by-groups.ts
@@ -9,7 +9,7 @@ interface ExtraOptions<T extends SortingNode> {
    * If not provided, `options` will be used. If function returns null, nodes
    * will not be sorted within the group.
    */
-  getGroupCompareOptions?(groupNumber: number): CompareOptions | null
+  getGroupCompareOptions?(groupNumber: number): CompareOptions<T> | null
   ignoreEslintDisabledNodes?: boolean
   isNodeIgnored?(node: T): boolean
 }
@@ -20,7 +20,7 @@ interface GroupOptions {
 
 export let sortNodesByGroups = <T extends SortingNode>(
   nodes: T[],
-  options: CompareOptions & GroupOptions,
+  options: CompareOptions<T> & GroupOptions,
   extraOptions?: ExtraOptions<T>,
 ): T[] => {
   let nodesByNonIgnoredGroupNumber: Record<number, T[]> = {}
diff --git a/utils/sort-nodes.ts b/utils/sort-nodes.ts
index dc92bb3..9d591dd 100644
--- a/utils/sort-nodes.ts
+++ b/utils/sort-nodes.ts
@@ -9,7 +9,7 @@ interface ExtraOptions {
 
 export let sortNodes = <T extends SortingNode>(
   nodes: T[],
-  options: CompareOptions,
+  options: CompareOptions<T>,
   extraOptions?: ExtraOptions,
 ): T[] => {
   let nonIgnoredNodes: T[] = []


Test patch:
diff --git a/test/sort-enums.test.ts b/test/sort-enums.test.ts
index dd11d60..fd8628e 100644
--- a/test/sort-enums.test.ts
+++ b/test/sort-enums.test.ts
@@ -1681,9 +1681,20 @@ describe(ruleName, () => {
         {
           code: dedent`
             enum Enum {
-                'c' = 0,
-                'a' = 1,
-                'b' = 2,
+                'i' = ~2, // -3
+                'k' = -1,
+                'j' = - 0.1,
+                'e' = - (((1 + 1) * 2) ** 2) / 4 % 2, // 0
+                'f' = 0,
+                'h' = +1,
+                'g' = 3 - 1, // 2
+                'b' = 5^6, // 3
+                'l' = 1 + 3, // 4
+                'm' = 2.1 ** 2, // 4.41
+                'a' = 20 >> 2, // 5
+                'm' = 7 & 6, // 6
+                'c' = 5 | 6, // 7
+                'd' = 2 << 2, // 8
               }
             `,
           options: [