Repository: azat-io/eslint-plugin-perfectionist
Pull request #519 (2025-04-20 16:38:39)

feat(sort-imports): add back `ts-equals` (`object` group) modifier
# Description

In [`62c5c62` (#505)](https://github.com/azat-io/eslint-plugin-perfectionist/pull/505/commits/62c5c621162bc2102c9890d5f450594259eed33d), the `object` group/selector was deprecated as it was never matched.

The previous documentation had this description:

https://github.com/azat-io/eslint-plugin-perfectionist/blob/f7907794ea899e1deae3f75de77b965bb3d09d5a/docs/content/rules/sort-imports.mdx?plain=1#L309

This PR does two things:
- It adds a `ts-equals` modifier that matches those objects.
- It prevents non-external reference ts import-equals imports from matching `external`:
  - `import log = console.log` currently matches `external` (it was matching `unknown` before https://github.com/azat-io/eslint-plugin-perfectionist/pull/508), but this syntax can also match internal imports (see the second example below).

## Example

```ts
// `value-ts-equals-import`
import log = console.log
```

```ts
// `type-ts-equals-import`
import type ForbiddenError = ErrorsNamespace.ForbiddenError
```

```ts
// `value-ts-equals-external`
import someImport = require('some-external-module')
```

### What is the purpose of this pull request?

- [x] Bug fix
- [x] New Feature


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/azat-io/eslint-plugin-perfectionist/pull/519?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Azat+S.) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`d7bf203`)](https://app.codecov.io/gh/azat-io/eslint-plugin-perfectionist/commit/d7bf2030a0f54c6ab21eca3e99cd51a8fd024361?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Azat+S.) to head [(`cc554a1`)](https://app.codecov.io/gh/azat-io/eslint-plugin-perfectionist/commit/cc554a1d9f7f0f67986b6bfe36f2d84cf28eab88?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Azat+S.).
> Report is 1 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##              main      #519   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           98        98           
  Lines         7942      7991   +49     
  Branches      1602      1614   +12     
=========================================
+ Hits          7942      7991   +49     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/azat-io/eslint-plugin-perfectionist/pull/519?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Azat+S.).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Azat+S.).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>

Patch:
diff --git a/docs/content/rules/sort-imports.mdx b/docs/content/rules/sort-imports.mdx
index 1e98c87..0aba116 100644
--- a/docs/content/rules/sort-imports.mdx
+++ b/docs/content/rules/sort-imports.mdx
@@ -288,6 +288,7 @@ Specifies the  directory of the root `tsconfig.json` file (ex: `.`). This is use
     'value-internal',
     ['type-parent', 'type-sibling', 'type-index'],
     ['value-parent', 'value-sibling', 'value-index'],
+    'ts-equals-import',
     'unknown',
   ]
   ```
@@ -325,6 +326,7 @@ The list of selectors is sorted from most to least important:
 
 - `'type'` — Typescript time imports.
 - `'value'` — Value imports.
+- `'ts-equals'` — TypeScript import-equals imports.
 
 #### Important notes
 
@@ -387,6 +389,8 @@ import type { InputProps } from '../Input'
 import type { Details } from './data'
 // 'type-index' - TypeScript type imports from main directory file
 import type { BaseOptions } from './index.d.ts'
+// 'value-ts-equals-import' - TypeScript import-equals imports
+import NotFoundError = ErrorsNamespace.NotFoundError
 ```
 
 #### Newlines between groups
@@ -527,6 +531,7 @@ you must write a custom group definition that does the same as what the predefin
      'value-internal',
      ['type-parent', 'type-sibling', 'type-index'],
      ['value-parent', 'value-sibling', 'value-index'],
+     'ts-equals-import',
      'unknown',
    ],
 +  customGroups: [                      // [!code ++]
@@ -583,6 +588,7 @@ Specifies which environment’s built-in modules should be recognized. If you ar
                     'value-internal',
                     ['type-parent', 'type-sibling', 'type-index'],
                     ['value-parent', 'value-sibling', 'value-index'],
+                    'ts-equals-import',
                     'unknown',
                   ],
                   customGroups: [],
@@ -624,6 +630,7 @@ Specifies which environment’s built-in modules should be recognized. If you ar
                   'value-internal',
                   ['type-parent', 'type-sibling', 'type-index'],
                   ['value-parent', 'value-sibling', 'value-index'],
+                  'ts-equals-import',
                   'unknown',
                 ],
                 customGroups: [],
diff --git a/rules/sort-imports.ts b/rules/sort-imports.ts
index a57780b..0a72853 100644
--- a/rules/sort-imports.ts
+++ b/rules/sort-imports.ts
@@ -69,6 +69,7 @@ let defaultGroups = [
   'value-internal',
   ['type-parent', 'type-sibling', 'type-index'],
   ['value-parent', 'value-sibling', 'value-index'],
+  'ts-equals-import',
   'unknown',
 ]
 
@@ -220,20 +221,22 @@ export default createEslintRule<Options, MESSAGE_ID>({
         })
       }
 
-      if (isStyleSideEffect) {
-        selectors.push('side-effect-style')
-      }
+      if (!isNonExternalReferenceTsImportEquals(node)) {
+        if (isStyleSideEffect) {
+          selectors.push('side-effect-style')
+        }
 
-      if (isSideEffect) {
-        selectors.push('side-effect')
-      }
+        if (isSideEffect) {
+          selectors.push('side-effect')
+        }
 
-      if (isStyleValue) {
-        selectors.push('style')
-      }
+        if (isStyleValue) {
+          selectors.push('style')
+        }
 
-      for (let selector of commonSelectors) {
-        selectors.push(selector)
+        for (let selector of commonSelectors) {
+          selectors.push(selector)
+        }
       }
       selectors.push('import')
 
@@ -241,6 +244,10 @@ export default createEslintRule<Options, MESSAGE_ID>({
         modifiers.push('value')
       }
 
+      if (node.type === 'TSImportEqualsDeclaration') {
+        modifiers.push('ts-equals')
+      }
+
       group ??=
         computeGroupExceptUnknown({
           customGroups: Array.isArray(options.customGroups)
@@ -702,3 +709,16 @@ let computeDependencyNames = ({
   }
   return returnValue
 }
+
+let isNonExternalReferenceTsImportEquals = (
+  node:
+    | TSESTree.TSImportEqualsDeclaration
+    | TSESTree.VariableDeclaration
+    | TSESTree.ImportDeclaration,
+): node is TSESTree.TSImportEqualsDeclaration => {
+  if (node.type !== 'TSImportEqualsDeclaration') {
+    return false
+  }
+
+  return node.moduleReference.type !== 'TSExternalModuleReference'
+}
diff --git a/rules/sort-imports/types.ts b/rules/sort-imports/types.ts
index cf3a509..8bad72f 100644
--- a/rules/sort-imports/types.ts
+++ b/rules/sort-imports/types.ts
@@ -77,9 +77,9 @@ export interface SortImportsSortingNode extends SortingNodeWithDependencies {
   isIgnored: boolean
 }
 
-export type Group = ValueGroup | TypeGroup | 'unknown' | string
+export type Modifier = TsEqualsModifier | ValueModifier | TypeModifier
 
-export type Modifier = ValueModifier | TypeModifier
+export type Group = ValueGroup | TypeGroup | 'unknown' | string
 
 type TypeGroup = JoinWithDash<[TypeModifier, Selector]>
 
@@ -119,6 +119,8 @@ type ParentTypeSelector = 'parent-type'
  */
 type IndexTypeSelector = 'index-type'
 
+type TsEqualsModifier = 'ts-equals'
+
 type ExternalSelector = 'external'
 
 type InternalSelector = 'internal'
@@ -170,7 +172,7 @@ export let allDeprecatedSelectors: Selector[] = [
   'object',
 ]
 
-export let allModifiers: Modifier[] = ['type', 'value']
+export let allModifiers: Modifier[] = ['type', 'ts-equals', 'value']
 
 /**
  * Ideally, we should generate as many schemas as there are selectors, and ensure
diff --git a/utils/validate-generated-groups-configuration.ts b/utils/validate-generated-groups-configuration.ts
index e4b9567..f2a0899 100644
--- a/utils/validate-generated-groups-configuration.ts
+++ b/utils/validate-generated-groups-configuration.ts
@@ -50,24 +50,63 @@ let isPredefinedGroup = (
     return true
   }
 
-  let parts = input.split('-')
+  let elementsSeparatedWithDash = input.split('-')
 
-  let possibleSelector = [
-    { selector: parts.slice(-3).join('-'), wordCount: 3 },
-    { selector: parts.slice(-2).join('-'), wordCount: 2 },
-    { selector: parts.at(-1), wordCount: 1 },
-  ]
-    .filter(({ wordCount }) => parts.length >= wordCount)
-    .find(({ selector }) => selector && allSelectors.includes(selector))
-
-  if (!possibleSelector) {
+  let longestAllowedSelector = computeLongestAllowedWord({
+    allowedValues: allSelectors,
+    elementsSeparatedWithDash,
+    allowThreeWord: true,
+  })
+  if (!longestAllowedSelector) {
     return false
   }
 
-  let modifiers = parts.slice(0, -possibleSelector.wordCount)
-
-  return (
-    new Set(modifiers).size === modifiers.length &&
-    modifiers.every(modifier => allModifiers.includes(modifier))
+  let modifiersToParse = elementsSeparatedWithDash.slice(
+    0,
+    -longestAllowedSelector.wordCount,
   )
+  let parsedModifiers = new Set<string>()
+  while (modifiersToParse.length > 0) {
+    let longestAllowedModifier = computeLongestAllowedWord({
+      elementsSeparatedWithDash: modifiersToParse,
+      allowedValues: allModifiers,
+      allowThreeWord: false,
+    })
+    if (!longestAllowedModifier) {
+      return false
+    }
+    if (parsedModifiers.has(longestAllowedModifier.word)) {
+      return false
+    }
+    parsedModifiers.add(longestAllowedModifier.word)
+    modifiersToParse = modifiersToParse.slice(
+      0,
+      -longestAllowedModifier.wordCount,
+    )
+  }
+
+  return true
+}
+
+let computeLongestAllowedWord = ({
+  elementsSeparatedWithDash,
+  allowedValues,
+}: {
+  elementsSeparatedWithDash: string[]
+  allowedValues: string[]
+  allowThreeWord: boolean
+}): { wordCount: number; word: string } | null => {
+  let match = [
+    { word: elementsSeparatedWithDash.slice(-3).join('-'), wordCount: 3 },
+    { word: elementsSeparatedWithDash.slice(-2).join('-'), wordCount: 2 },
+    { word: elementsSeparatedWithDash.at(-1)!, wordCount: 1 },
+  ]
+    .filter(({ wordCount }) => elementsSeparatedWithDash.length >= wordCount)
+    .find(({ word }) => word && allowedValues.includes(word))
+
+  if (!match) {
+    return null
+  }
+
+  return match
 }


Test patch:
diff --git a/test/rules/sort-imports.test.ts b/test/rules/sort-imports.test.ts
index 91d5afb..7c5578c 100644
--- a/test/rules/sort-imports.test.ts
+++ b/test/rules/sort-imports.test.ts
@@ -368,7 +368,7 @@ describe(ruleName, () => {
     })
 
     ruleTester.run(
-      `${ruleName}(${type}): supports typescript object-imports`,
+      `${ruleName}(${type}): supports typescript ts import-equals`,
       rule,
       {
         invalid: [
@@ -383,19 +383,12 @@ describe(ruleName, () => {
               },
               {
                 data: {
+                  leftGroup: 'ts-equals-import',
                   rightGroup: 'value-external',
-                  leftGroup: 'value-parent',
-                  right: 'console.log',
-                  left: '../b',
-                },
-                messageId: 'unexpectedImportsGroupOrder',
-              },
-              {
-                data: {
                   left: 'console.log',
                   right: 'c/c',
                 },
-                messageId: 'unexpectedImportsOrder',
+                messageId: 'unexpectedImportsGroupOrder',
               },
             ],
             output: dedent`
@@ -403,9 +396,10 @@ describe(ruleName, () => {
 
               import { A } from 'a'
               import c = require('c/c')
-              import log = console.log
 
               import { B } from '../b'
+
+              import log = console.log
             `,
             code: dedent`
               import type T = require("T")
@@ -426,9 +420,10 @@ describe(ruleName, () => {
 
               import { A } from 'a'
               import c = require('c/c')
-              import log = console.log
 
               import { B } from '../b'
+
+              import log = console.log
             `,
             options: [options],
           },
@@ -2974,6 +2969,92 @@ describe(ruleName, () => {
         )
       })
 
+      describe('modifiers priority', () => {
+        ruleTester.run(
+          `${ruleName}(${type}): prioritizes "type" over "ts-equals"`,
+          rule,
+          {
+            invalid: [
+              {
+                errors: [
+                  {
+                    data: {
+                      rightGroup: 'type-import',
+                      leftGroup: 'external',
+                      right: 'z',
+                      left: 'f',
+                    },
+                    messageId: 'unexpectedImportsGroupOrder',
+                  },
+                ],
+                options: [
+                  {
+                    ...options,
+                    groups: ['type-import', 'external', 'ts-equals-import'],
+                  },
+                ],
+                output: dedent`
+                  import type a from './a'
+                  import type z = z
+
+                  import f from 'f'
+                `,
+                code: dedent`
+                  import type a from './a'
+
+                  import f from 'f'
+
+                  import type z = z
+                `,
+              },
+            ],
+            valid: [],
+          },
+        )
+
+        ruleTester.run(
+          `${ruleName}(${type}): prioritizes "value" over "ts-equals"`,
+          rule,
+          {
+            invalid: [
+              {
+                errors: [
+                  {
+                    data: {
+                      rightGroup: 'value-import',
+                      leftGroup: 'external',
+                      right: 'z',
+                      left: 'f',
+                    },
+                    messageId: 'unexpectedImportsGroupOrder',
+                  },
+                ],
+                options: [
+                  {
+                    ...options,
+                    groups: ['value-import', 'external', 'ts-equals-import'],
+                  },
+                ],
+                output: dedent`
+                  import a from './a'
+                  import z = z
+
+                  import f from 'f'
+                `,
+                code: dedent`
+                  import a from './a'
+
+                  import f from 'f'
+
+                  import z = z
+                `,
+              },
+            ],
+            valid: [],
+          },
+        )
+      })
+
       describe(`custom groups`, () => {
         for (let elementNamePattern of [
           'hello',
@@ -3564,6 +3645,7 @@ describe(ruleName, () => {
               ],
               output: dedent`
                 import aImport from "b";
+
                 // Part: 1
                 import a = aImport.a1.a2;
               `,
@@ -4025,7 +4107,7 @@ describe(ruleName, () => {
     })
 
     ruleTester.run(
-      `${ruleName}(${type}): supports typescript object-imports`,
+      `${ruleName}(${type}): supports typescript ts import-equals`,
       rule,
       {
         invalid: [
@@ -4040,19 +4122,12 @@ describe(ruleName, () => {
               },
               {
                 data: {
+                  leftGroup: 'ts-equals-import',
                   rightGroup: 'value-external',
-                  leftGroup: 'value-parent',
-                  right: 'console.log',
-                  left: '../b',
-                },
-                messageId: 'unexpectedImportsGroupOrder',
-              },
-              {
-                data: {
                   left: 'console.log',
                   right: 'c/c',
                 },
-                messageId: 'unexpectedImportsOrder',
+                messageId: 'unexpectedImportsGroupOrder',
               },
             ],
             output: dedent`
@@ -4060,9 +4135,10 @@ describe(ruleName, () => {
 
               import { A } from 'a'
               import c = require('c/c')
-              import log = console.log
 
               import { B } from '../b'
+
+              import log = console.log
             `,
             code: dedent`
               import type T = require("T")
@@ -4083,10 +4159,10 @@ describe(ruleName, () => {
 
               import { A } from 'a'
               import c = require('c/c')
-              import log = console.log
 
               import { B } from '../b'
 
+              import log = console.log
             `,
             options: [options],
           },
@@ -5374,7 +5450,7 @@ describe(ruleName, () => {
     })
 
     ruleTester.run(
-      `${ruleName}(${type}): supports typescript object-imports`,
+      `${ruleName}(${type}): supports typescript ts import-equals`,
       rule,
       {
         invalid: [
@@ -5389,29 +5465,23 @@ describe(ruleName, () => {
               },
               {
                 data: {
+                  leftGroup: 'ts-equals-import',
                   rightGroup: 'value-external',
-                  leftGroup: 'value-parent',
-                  right: 'console.log',
-                  left: '../b',
-                },
-                messageId: 'unexpectedImportsGroupOrder',
-              },
-              {
-                data: {
                   left: 'console.log',
                   right: 'c/c',
                 },
-                messageId: 'unexpectedImportsOrder',
+                messageId: 'unexpectedImportsGroupOrder',
               },
             ],
             output: dedent`
               import type T = require("T")
 
               import c = require('c/c')
-              import log = console.log
               import { A } from 'a'
 
               import { B } from '../b'
+
+              import log = console.log
             `,
             code: dedent`
               import type T = require("T")
@@ -5431,10 +5501,11 @@ describe(ruleName, () => {
               import type T = require("T")
 
               import c = require('c/c')
-              import log = console.log
               import { A } from 'a'
 
               import { B } from '../b'
+
+              import log = console.log
             `,
             options: [options],
           },
diff --git a/test/utils/validate-generated-groups-configuration.test.ts b/test/utils/validate-generated-groups-configuration.test.ts
index 1c2f0bd..3dae583 100644
--- a/test/utils/validate-generated-groups-configuration.test.ts
+++ b/test/utils/validate-generated-groups-configuration.test.ts
@@ -11,7 +11,7 @@ describe('validate-generated-groups-configuration', () => {
     'double-selector',
     'three-word-selector',
   ]
-  let modifiers = ['modifier1', 'modifier2', 'modifier3']
+  let modifiers = ['modifier1', 'modifier2', 'modifier3', 'double-modifier']
 
   it('allows predefined groups', () => {
     let allModifierCombinationPermutations =
@@ -54,18 +54,21 @@ describe('validate-generated-groups-configuration', () => {
     ).not.toThrow()
   })
 
-  it('throws an error with predefined groups with duplicate modifiers', () => {
-    expect(() =>
-      validateGeneratedGroupsConfiguration({
-        options: {
-          groups: ['modifier1-modifier1-selector1'],
-          customGroups: [],
-        },
-        selectors,
-        modifiers,
-      }),
-    ).toThrow('Invalid group(s): modifier1-modifier1-selector1')
-  })
+  it.each(['modifier1-modifier1', 'double-modifier-modifier1-double-modifier'])(
+    "throws an error with duplicate modifiers '(%s)'",
+    groupModifiers => {
+      expect(() =>
+        validateGeneratedGroupsConfiguration({
+          options: {
+            groups: [`${groupModifiers}-selector1`],
+            customGroups: [],
+          },
+          selectors,
+          modifiers,
+        }),
+      ).toThrow(`Invalid group(s): ${groupModifiers}-selector1`)
+    },
+  )
 
   it('throws an error if a duplicate group is provided', () => {
     expect(() =>
@@ -84,17 +87,22 @@ describe('validate-generated-groups-configuration', () => {
     expect(() =>
       validateGeneratedGroupsConfiguration({
         options: {
+          groups: [
+            'modifier1-selector1',
+            'nonAllowedModifier-selector1',
+            'myCustomGroup',
+            '',
+          ],
           customGroups: [
             {
               groupName: 'myCustomGroupNotReferenced',
             },
           ],
-          groups: ['modifier1-selector1', 'myCustomGroup', ''],
         },
         selectors,
         modifiers,
       }),
-    ).toThrow('Invalid group(s): myCustomGroup')
+    ).toThrow('Invalid group(s): nonAllowedModifier-selector1, myCustomGroup')
   })
 
   it('throws an error with consecutive newlines objects', () => {