Repository: azat-io/eslint-plugin-perfectionist
Pull request #504 (2025-04-01 16:01:16)

refactor(sort-imports): extract and test internal functions
### Description

Giving users access to the new `customGroups` API in `sort-imports` is one of the big projects remaining (see https://github.com/azat-io/eslint-plugin-perfectionist/issues/494).

The `sort-imports` logic is quite complex and has a lot of internal functions. The idea of this PR is to simplify the "main" rule logic, and extract and test internal functions to prevent regressions.

### What is the purpose of this pull request?

- [x] Other


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/azat-io/eslint-plugin-perfectionist/pull/504?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 [(`e18a65a`)](https://app.codecov.io/gh/azat-io/eslint-plugin-perfectionist/commit/e18a65aafea8b1f15c2a9ec59fecaea4d0540225?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Azat+S.) to head [(`d08ce84`)](https://app.codecov.io/gh/azat-io/eslint-plugin-perfectionist/commit/d08ce84f45fc2b3f3179559fb3009eeefac5aaf3?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      #504   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           92        92           
  Lines         7669      7676    +7     
  Branches      1602      1594    -8     
=========================================
+ Hits          7669      7676    +7     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/azat-io/eslint-plugin-perfectionist/pull/504?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/rules/sort-imports.ts b/rules/sort-imports.ts
index 8c69a41..e1a2d0a 100644
--- a/rules/sort-imports.ts
+++ b/rules/sort-imports.ts
@@ -1,19 +1,7 @@
 import type { TSESTree } from '@typescript-eslint/types'
+import type { TSESLint } from '@typescript-eslint/utils'
 
-import { builtinModules } from 'node:module'
-
-import type {
-  DeprecatedCustomGroupsOption,
-  PartitionByCommentOption,
-  SpecialCharactersOption,
-  NewlinesBetweenOption,
-  FallbackSortOption,
-  GroupsOptions,
-  OrderOption,
-  RegexOption,
-  TypeOption,
-} from '../types/common-options'
-import type { SortingNode } from '../types/sorting-node'
+import type { SortImportsSortingNode, Options } from './sort-imports/types'
 
 import {
   partitionByCommentJsonSchema,
@@ -30,13 +18,14 @@ import {
   ORDER_ERROR,
 } from '../utils/report-errors'
 import { validateNewlinesAndPartitionConfiguration } from '../utils/validate-newlines-and-partition-configuration'
+import { validateSideEffectsConfiguration } from './sort-imports/validate-side-effects-configuration'
+import { computeCommonPredefinedGroups } from './sort-imports/compute-common-predefined-groups'
 import { validateCustomSortConfiguration } from '../utils/validate-custom-sort-configuration'
 import { readClosestTsConfigByPath } from './sort-imports/read-closest-ts-config-by-path'
 import { validateGroupsConfiguration } from '../utils/validate-groups-configuration'
 import { getOptionsWithCleanGroups } from '../utils/get-options-with-clean-groups'
-import { isNewlinesBetweenOption } from '../utils/is-newlines-between-option'
+import { isSideEffectOnlyGroup } from './sort-imports/is-side-effect-only-group'
 import { getEslintDisabledLines } from '../utils/get-eslint-disabled-lines'
-import { getTypescriptImport } from './sort-imports/get-typescript-import'
 import { isNodeEslintDisabled } from '../utils/is-node-eslint-disabled'
 import { sortNodesByGroups } from '../utils/sort-nodes-by-groups'
 import { createEslintRule } from '../utils/create-eslint-rule'
@@ -47,32 +36,6 @@ import { getSettings } from '../utils/get-settings'
 import { isSortable } from '../utils/is-sortable'
 import { useGroups } from '../utils/use-groups'
 import { complete } from '../utils/complete'
-import { matches } from '../utils/matches'
-
-export type Options = [
-  Partial<{
-    customGroups: {
-      value?: DeprecatedCustomGroupsOption
-      type?: DeprecatedCustomGroupsOption
-    }
-    partitionByComment: PartitionByCommentOption
-    specialCharacters: SpecialCharactersOption
-    locales: NonNullable<Intl.LocalesArgument>
-    newlinesBetween: NewlinesBetweenOption
-    fallbackSort: FallbackSortOption
-    internalPattern: RegexOption[]
-    groups: GroupsOptions<Group>
-    environment: 'node' | 'bun'
-    partitionByNewLine: boolean
-    sortSideEffects: boolean
-    tsconfigRootDir?: string
-    maxLineLength?: number
-    ignoreCase: boolean
-    order: OrderOption
-    type: TypeOption
-    alphabet: string
-  }>,
-]
 
 export type MESSAGE_ID =
   | 'missedSpacingBetweenImports'
@@ -80,31 +43,6 @@ export type MESSAGE_ID =
   | 'extraSpacingBetweenImports'
   | 'unexpectedImportsOrder'
 
-type Group =
-  | 'side-effect-style'
-  | 'external-type'
-  | 'internal-type'
-  | 'builtin-type'
-  | 'sibling-type'
-  | 'parent-type'
-  | 'side-effect'
-  | 'index-type'
-  | 'internal'
-  | 'external'
-  | 'sibling'
-  | 'unknown'
-  | 'builtin'
-  | 'parent'
-  | 'object'
-  | 'index'
-  | 'style'
-  | 'type'
-  | string
-
-interface SortImportsSortingNode extends SortingNode {
-  isIgnored: boolean
-}
-
 export default createEslintRule<Options, MESSAGE_ID>({
   create: context => {
     let settings = getSettings(context.settings)
@@ -168,6 +106,7 @@ export default createEslintRule<Options, MESSAGE_ID>({
     })
     validateCustomSortConfiguration(options)
     validateNewlinesAndPartitionConfiguration(options)
+    validateSideEffectsConfiguration(options)
 
     let tsConfigOutput = options.tsconfigRootDir
       ? readClosestTsConfigByPath({
@@ -177,45 +116,6 @@ export default createEslintRule<Options, MESSAGE_ID>({
         })
       : null
 
-    let isSideEffectOnlyGroup = (
-      group:
-        | { newlinesBetween: NewlinesBetweenOption }
-        | undefined
-        | string[]
-        | string,
-    ): boolean => {
-      if (!group || isNewlinesBetweenOption(group)) {
-        return false
-      }
-      if (typeof group === 'string') {
-        return group === 'side-effect' || group === 'side-effect-style'
-      }
-
-      return group.every(isSideEffectOnlyGroup)
-    }
-
-    /**
-     * Ensure that if `sortSideEffects: false`, no side effect group is in a
-     * nested group with non-side-effect groups.
-     */
-    if (!options.sortSideEffects) {
-      let hasInvalidGroup = options.groups
-        .filter(group => Array.isArray(group))
-        .some(
-          nestedGroup =>
-            !isSideEffectOnlyGroup(nestedGroup) &&
-            nestedGroup.some(
-              subGroup =>
-                subGroup === 'side-effect' || subGroup === 'side-effect-style',
-            ),
-        )
-      if (hasInvalidGroup) {
-        throw new Error(
-          "Side effect groups cannot be nested with non side effect groups when 'sortSideEffects' is 'false'.",
-        )
-      }
-    }
-
     let { sourceCode, filename, id } = context
     let eslintDisabledLines = getEslintDisabledLines({
       ruleName: id,
@@ -223,191 +123,52 @@ export default createEslintRule<Options, MESSAGE_ID>({
     })
     let sortingNodes: SortImportsSortingNode[] = []
 
-    let isSideEffectImport = (node: TSESTree.Node): boolean =>
-      node.type === 'ImportDeclaration' &&
-      node.specifiers.length === 0 &&
-      /* Avoid matching on named imports without specifiers */
-      !/\}\s*from\s+/u.test(sourceCode.getText(node))
-
-    let styleExtensions = [
-      '.less',
-      '.scss',
-      '.sass',
-      '.styl',
-      '.pcss',
-      '.css',
-      '.sss',
-    ]
-    let isStyle = (value: string): boolean => {
-      let [cleanedValue] = value.split('?')
-      return styleExtensions.some(extension =>
-        cleanedValue?.endsWith(extension),
-      )
-    }
-
     let flatGroups = new Set(options.groups.flat())
     let shouldRegroupSideEffectNodes = flatGroups.has('side-effect')
     let shouldRegroupSideEffectStyleNodes = flatGroups.has('side-effect-style')
+
     let registerNode = (
       node:
         | TSESTree.TSImportEqualsDeclaration
         | TSESTree.VariableDeclaration
         | TSESTree.ImportDeclaration,
     ): void => {
-      let name: string
-
-      if (node.type === 'ImportDeclaration') {
-        name = node.source.value
-      } else if (node.type === 'TSImportEqualsDeclaration') {
-        if (node.moduleReference.type === 'TSExternalModuleReference') {
-          name = node.moduleReference.expression.value
-        } else {
-          name = sourceCode.getText(node.moduleReference)
-        }
-      } else {
-        let decl = node.declarations[0].init as TSESTree.CallExpression
-        let { value } = decl.arguments[0] as TSESTree.Literal
-        name = value!.toString()
-      }
-
-      let isIndex = (value: string): boolean =>
-        [
-          './index.d.js',
-          './index.d.ts',
-          './index.js',
-          './index.ts',
-          './index',
-          './',
-          '.',
-        ].includes(value)
-
-      let isParent = (value: string): boolean => value.startsWith('..')
-
-      let isSibling = (value: string): boolean => value.startsWith('./')
-
       let { setCustomGroups, defineGroup, getGroup } = useGroups(options)
 
-      let matchesInternalPattern = (value: string): boolean | number =>
-        options.internalPattern.some(pattern => matches(value, pattern))
-
-      let isCoreModule = (value: string): boolean => {
-        let bunModules = [
-          'bun',
-          'bun:ffi',
-          'bun:jsc',
-          'bun:sqlite',
-          'bun:test',
-          'bun:wrap',
-          'detect-libc',
-          'undici',
-          'ws',
-        ]
-        let builtinPrefixOnlyModules = ['sea', 'sqlite', 'test']
-        let valueToCheck = value.startsWith('node:')
-          ? value.split('node:')[1]
-          : value
-        return (
-          (!!valueToCheck && builtinModules.includes(valueToCheck)) ||
-          builtinPrefixOnlyModules.some(module => `node:${module}` === value) ||
-          (options.environment === 'bun' ? bunModules.includes(value) : false)
-        )
-      }
-
-      let getInternalOrExternalGroup = (
-        value: string,
-      ): 'internal' | 'external' | null => {
-        let typescriptImport = getTypescriptImport()
-        if (!typescriptImport) {
-          return !value.startsWith('.') && !value.startsWith('/')
-            ? 'external'
-            : null
-        }
-
-        let isRelativeImport =
-          typescriptImport.isExternalModuleNameRelative(value)
-        if (isRelativeImport) {
-          return null
-        }
-        if (!tsConfigOutput) {
-          return 'external'
-        }
-
-        let resolution = typescriptImport.resolveModuleName(
-          value,
-          filename,
-          tsConfigOutput.compilerOptions,
-          typescriptImport.sys,
-          tsConfigOutput.cache,
-        )
-        // If the module can't be resolved, assume it is external.
-        if (
-          typeof resolution.resolvedModule?.isExternalLibraryImport !==
-          'boolean'
-        ) {
-          return 'external'
-        }
+      let name = getNodeName({
+        sourceCode,
+        node,
+      })
 
-        return resolution.resolvedModule.isExternalLibraryImport
-          ? 'external'
-          : 'internal'
-      }
+      let commonPredefinedGroups = computeCommonPredefinedGroups({
+        tsConfigOutput,
+        filename,
+        options,
+        name,
+      })
 
       if (node.type !== 'VariableDeclaration' && node.importKind === 'type') {
         if (node.type === 'ImportDeclaration') {
-          setCustomGroups(options.customGroups.type, node.source.value)
-          let internalExternalGroup = matchesInternalPattern(node.source.value)
-            ? 'internal'
-            : getInternalOrExternalGroup(node.source.value)
-
-          if (isIndex(node.source.value)) {
-            defineGroup('index-type')
-          }
-
-          if (isSibling(node.source.value)) {
-            defineGroup('sibling-type')
-          }
-
-          if (isParent(node.source.value)) {
-            defineGroup('parent-type')
-          }
-
-          if (internalExternalGroup === 'internal') {
-            defineGroup('internal-type')
-          }
-
-          if (isCoreModule(node.source.value)) {
-            defineGroup('builtin-type')
-          }
+          setCustomGroups(options.customGroups.type, name)
 
-          if (internalExternalGroup === 'external') {
-            defineGroup('external-type')
+          for (let group of commonPredefinedGroups) {
+            defineGroup(`${group}-type`)
           }
         }
 
         defineGroup('type')
       }
 
-      let isSideEffect = isSideEffectImport(node)
+      let isSideEffect = isSideEffectImport({ sourceCode, node })
       let isStyleSideEffect = false
       if (
         node.type === 'ImportDeclaration' ||
         node.type === 'VariableDeclaration'
       ) {
-        let value: string
-        if (node.type === 'ImportDeclaration') {
-          ;({ value } = node.source)
-        } else {
-          let decl = node.declarations[0].init as TSESTree.CallExpression
-          let declValue = (decl.arguments[0] as TSESTree.Literal).value
-          value = declValue!.toString()
-        }
-        let internalExternalGroup = matchesInternalPattern(value)
-          ? 'internal'
-          : getInternalOrExternalGroup(value)
-        let isStyleValue = isStyle(value)
+        let isStyleValue = isStyle(name)
         isStyleSideEffect = isSideEffect && isStyleValue
 
-        setCustomGroups(options.customGroups.value, value)
+        setCustomGroups(options.customGroups.value, name)
 
         if (isStyleSideEffect) {
           defineGroup('side-effect-style')
@@ -421,28 +182,8 @@ export default createEslintRule<Options, MESSAGE_ID>({
           defineGroup('style')
         }
 
-        if (isIndex(value)) {
-          defineGroup('index')
-        }
-
-        if (isSibling(value)) {
-          defineGroup('sibling')
-        }
-
-        if (isParent(value)) {
-          defineGroup('parent')
-        }
-
-        if (internalExternalGroup === 'internal') {
-          defineGroup('internal')
-        }
-
-        if (isCoreModule(value)) {
-          defineGroup('builtin')
-        }
-
-        if (internalExternalGroup === 'external') {
-          defineGroup('external')
+        for (let group of commonPredefinedGroups) {
+          defineGroup(group)
         }
       }
 
@@ -456,8 +197,8 @@ export default createEslintRule<Options, MESSAGE_ID>({
         size: rangeToDiff(node, sourceCode),
         addSafetySemicolonWhenInline: true,
         group: getGroup(),
-        node,
         name,
+        node,
         ...(options.type === 'line-length' &&
           options.maxLineLength && {
             hasMultipleImportDeclarations: isSortable(
@@ -469,14 +210,6 @@ export default createEslintRule<Options, MESSAGE_ID>({
 
     return {
       'Program:exit': () => {
-        let hasContentBetweenNodes = (
-          left: SortImportsSortingNode,
-          right: SortImportsSortingNode,
-        ): boolean =>
-          sourceCode.getTokensBetween(left.node, right.node, {
-            includeComments: false,
-          }).length > 0
-
         let formattedMembers: SortImportsSortingNode[][] = [[]]
         for (let sortingNode of sortingNodes) {
           let lastGroup = formattedMembers.at(-1)
@@ -490,7 +223,7 @@ export default createEslintRule<Options, MESSAGE_ID>({
               options,
             }) ||
             (lastSortingNode &&
-              hasContentBetweenNodes(lastSortingNode, sortingNode))
+              hasContentBetweenNodes(sourceCode, lastSortingNode, sortingNode))
           ) {
             lastGroup = []
             formattedMembers.push(lastGroup)
@@ -513,9 +246,11 @@ export default createEslintRule<Options, MESSAGE_ID>({
                 return {
                   options: {
                     ...options,
-                    type: isSideEffectOnlyGroup(options.groups[groupNumber])
-                      ? 'unsorted'
-                      : options.type,
+                    type:
+                      options.groups[groupNumber] &&
+                      isSideEffectOnlyGroup(options.groups[groupNumber])
+                        ? 'unsorted'
+                        : options.type,
                   },
                 }
               },
@@ -683,3 +418,65 @@ export default createEslintRule<Options, MESSAGE_ID>({
   ],
   name: 'sort-imports',
 })
+
+let hasContentBetweenNodes = (
+  sourceCode: TSESLint.SourceCode,
+  left: SortImportsSortingNode,
+  right: SortImportsSortingNode,
+): boolean =>
+  sourceCode.getTokensBetween(left.node, right.node, {
+    includeComments: false,
+  }).length > 0
+
+let styleExtensions = [
+  '.less',
+  '.scss',
+  '.sass',
+  '.styl',
+  '.pcss',
+  '.css',
+  '.sss',
+]
+let isStyle = (value: string): boolean => {
+  let [cleanedValue] = value.split('?')
+  return styleExtensions.some(extension => cleanedValue?.endsWith(extension))
+}
+
+let isSideEffectImport = ({
+  sourceCode,
+  node,
+}: {
+  sourceCode: TSESLint.SourceCode
+  node: TSESTree.Node
+}): boolean =>
+  node.type === 'ImportDeclaration' &&
+  node.specifiers.length === 0 &&
+  /* Avoid matching on named imports without specifiers */
+  !/\}\s*from\s+/u.test(sourceCode.getText(node))
+
+let getNodeName = ({
+  sourceCode,
+  node,
+}: {
+  node:
+    | TSESTree.TSImportEqualsDeclaration
+    | TSESTree.VariableDeclaration
+    | TSESTree.ImportDeclaration
+  sourceCode: TSESLint.SourceCode
+}): string => {
+  if (node.type === 'ImportDeclaration') {
+    return node.source.value
+  }
+
+  if (node.type === 'TSImportEqualsDeclaration') {
+    if (node.moduleReference.type === 'TSExternalModuleReference') {
+      return node.moduleReference.expression.value
+    }
+
+    return sourceCode.getText(node.moduleReference)
+  }
+
+  let callExpression = node.declarations[0].init as TSESTree.CallExpression
+  let { value } = callExpression.arguments[0] as TSESTree.Literal
+  return value!.toString()
+}
diff --git a/rules/sort-imports/compute-common-predefined-groups.ts b/rules/sort-imports/compute-common-predefined-groups.ts
new file mode 100644
index 0000000..31fb405
--- /dev/null
+++ b/rules/sort-imports/compute-common-predefined-groups.ts
@@ -0,0 +1,137 @@
+import { builtinModules } from 'node:module'
+
+import type { ReadClosestTsConfigByPathValue } from './read-closest-ts-config-by-path'
+import type { RegexOption } from '../../types/common-options'
+
+import { getTypescriptImport } from './get-typescript-import'
+import { matches } from '../../utils/matches'
+
+export let computeCommonPredefinedGroups = ({
+  tsConfigOutput,
+  filename,
+  options,
+  name,
+}: {
+  options: {
+    internalPattern: RegexOption[]
+    environment: 'node' | 'bun'
+  }
+  tsConfigOutput: ReadClosestTsConfigByPathValue | null
+  filename: string
+  name: string
+}): string[] => {
+  let matchesInternalPattern = (value: string): boolean | number =>
+    options.internalPattern.some(pattern => matches(value, pattern))
+
+  let internalExternalGroup = matchesInternalPattern(name)
+    ? 'internal'
+    : getInternalOrExternalGroup({
+        tsConfigOutput,
+        filename,
+        name,
+      })
+
+  let predefinedGroups: string[] = []
+
+  if (isIndex(name)) {
+    predefinedGroups.push('index')
+  }
+
+  if (isSibling(name)) {
+    predefinedGroups.push('sibling')
+  }
+
+  if (isParent(name)) {
+    predefinedGroups.push('parent')
+  }
+
+  if (internalExternalGroup === 'internal') {
+    predefinedGroups.push('internal')
+  }
+
+  if (isCoreModule(name, options.environment)) {
+    predefinedGroups.push('builtin')
+  }
+
+  if (internalExternalGroup === 'external') {
+    predefinedGroups.push('external')
+  }
+
+  return predefinedGroups
+}
+
+let bunModules = new Set([
+  'detect-libc',
+  'bun:sqlite',
+  'bun:test',
+  'bun:wrap',
+  'bun:ffi',
+  'bun:jsc',
+  'undici',
+  'bun',
+  'ws',
+])
+let nodeBuiltinModules = new Set(builtinModules)
+let builtinPrefixOnlyModules = new Set(['node:sqlite', 'node:test', 'node:sea'])
+let isCoreModule = (value: string, environment: 'node' | 'bun'): boolean => {
+  let valueToCheck = value.startsWith('node:') ? value.split('node:')[1] : value
+  return (
+    (!!valueToCheck && nodeBuiltinModules.has(valueToCheck)) ||
+    builtinPrefixOnlyModules.has(value) ||
+    (environment === 'bun' ? bunModules.has(value) : false)
+  )
+}
+
+let isParent = (value: string): boolean => value.startsWith('..')
+
+let isSibling = (value: string): boolean => value.startsWith('./')
+
+let isIndex = (value: string): boolean =>
+  [
+    './index.d.js',
+    './index.d.ts',
+    './index.js',
+    './index.ts',
+    './index',
+    './',
+    '.',
+  ].includes(value)
+
+let getInternalOrExternalGroup = ({
+  tsConfigOutput,
+  filename,
+  name,
+}: {
+  tsConfigOutput: ReadClosestTsConfigByPathValue | null
+  filename: string
+  name: string
+}): 'internal' | 'external' | null => {
+  let typescriptImport = getTypescriptImport()
+  if (!typescriptImport) {
+    return !name.startsWith('.') && !name.startsWith('/') ? 'external' : null
+  }
+
+  let isRelativeImport = typescriptImport.isExternalModuleNameRelative(name)
+  if (isRelativeImport) {
+    return null
+  }
+  if (!tsConfigOutput) {
+    return 'external'
+  }
+
+  let resolution = typescriptImport.resolveModuleName(
+    name,
+    filename,
+    tsConfigOutput.compilerOptions,
+    typescriptImport.sys,
+    tsConfigOutput.cache,
+  )
+  // If the module can't be resolved, assume it is external.
+  if (typeof resolution.resolvedModule?.isExternalLibraryImport !== 'boolean') {
+    return 'external'
+  }
+
+  return resolution.resolvedModule.isExternalLibraryImport
+    ? 'external'
+    : 'internal'
+}
diff --git a/rules/sort-imports/is-side-effect-only-group.ts b/rules/sort-imports/is-side-effect-only-group.ts
new file mode 100644
index 0000000..6291794
--- /dev/null
+++ b/rules/sort-imports/is-side-effect-only-group.ts
@@ -0,0 +1,16 @@
+import type { GroupsOptions } from '../../types/common-options'
+
+import { isNewlinesBetweenOption } from '../../utils/is-newlines-between-option'
+
+export let isSideEffectOnlyGroup = (
+  group: GroupsOptions<string>[0],
+): boolean => {
+  if (isNewlinesBetweenOption(group)) {
+    return false
+  }
+  if (typeof group === 'string') {
+    return group === 'side-effect' || group === 'side-effect-style'
+  }
+
+  return group.every(isSideEffectOnlyGroup)
+}
diff --git a/rules/sort-imports/read-closest-ts-config-by-path.ts b/rules/sort-imports/read-closest-ts-config-by-path.ts
index b6b1496..8a3d03b 100644
--- a/rules/sort-imports/read-closest-ts-config-by-path.ts
+++ b/rules/sort-imports/read-closest-ts-config-by-path.ts
@@ -10,7 +10,7 @@ import { getTypescriptImport } from './get-typescript-import'
  * https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-estree/src/parseSettings/getProjectConfigFiles.ts
  */
 
-interface ReadClosestTsConfigByPathValue {
+export interface ReadClosestTsConfigByPathValue {
   compilerOptions: ts.CompilerOptions
   cache: ts.ModuleResolutionCache
 }
diff --git a/rules/sort-imports/types.ts b/rules/sort-imports/types.ts
new file mode 100644
index 0000000..3094e81
--- /dev/null
+++ b/rules/sort-imports/types.ts
@@ -0,0 +1,62 @@
+import type {
+  DeprecatedCustomGroupsOption,
+  PartitionByCommentOption,
+  SpecialCharactersOption,
+  NewlinesBetweenOption,
+  FallbackSortOption,
+  GroupsOptions,
+  OrderOption,
+  RegexOption,
+  TypeOption,
+} from '../../types/common-options'
+import type { SortingNode } from '../../types/sorting-node'
+
+export type Options = [
+  Partial<{
+    customGroups: {
+      value?: DeprecatedCustomGroupsOption
+      type?: DeprecatedCustomGroupsOption
+    }
+    partitionByComment: PartitionByCommentOption
+    specialCharacters: SpecialCharactersOption
+    locales: NonNullable<Intl.LocalesArgument>
+    newlinesBetween: NewlinesBetweenOption
+    fallbackSort: FallbackSortOption
+    internalPattern: RegexOption[]
+    groups: GroupsOptions<Group>
+    environment: 'node' | 'bun'
+    partitionByNewLine: boolean
+    sortSideEffects: boolean
+    tsconfigRootDir?: string
+    maxLineLength?: number
+    ignoreCase: boolean
+    order: OrderOption
+    type: TypeOption
+    alphabet: string
+  }>,
+]
+
+export type Group =
+  | 'side-effect-style'
+  | 'external-type'
+  | 'internal-type'
+  | 'builtin-type'
+  | 'sibling-type'
+  | 'parent-type'
+  | 'side-effect'
+  | 'index-type'
+  | 'internal'
+  | 'external'
+  | 'sibling'
+  | 'unknown'
+  | 'builtin'
+  | 'parent'
+  | 'object'
+  | 'index'
+  | 'style'
+  | 'type'
+  | string
+
+export interface SortImportsSortingNode extends SortingNode {
+  isIgnored: boolean
+}
diff --git a/rules/sort-imports/validate-side-effects-configuration.ts b/rules/sort-imports/validate-side-effects-configuration.ts
new file mode 100644
index 0000000..a7ebfc4
--- /dev/null
+++ b/rules/sort-imports/validate-side-effects-configuration.ts
@@ -0,0 +1,35 @@
+import type { GroupsOptions } from '../../types/common-options'
+import type { Group } from './types'
+
+import { isSideEffectOnlyGroup } from './is-side-effect-only-group'
+
+export let validateSideEffectsConfiguration = ({
+  sortSideEffects,
+  groups,
+}: {
+  groups: GroupsOptions<Group>
+  sortSideEffects: boolean
+}): void => {
+  if (sortSideEffects) {
+    return
+  }
+  /**
+   * Ensure that if `sortSideEffects: false`, no side effect group is in a
+   * nested group with non-side-effect groups.
+   */
+  let hasInvalidGroup = groups
+    .filter(group => Array.isArray(group))
+    .some(
+      nestedGroup =>
+        !isSideEffectOnlyGroup(nestedGroup) &&
+        nestedGroup.some(
+          subGroup =>
+            subGroup === 'side-effect' || subGroup === 'side-effect-style',
+        ),
+    )
+  if (hasInvalidGroup) {
+    throw new Error(
+      "Side effect groups cannot be nested with non side effect groups when 'sortSideEffects' is 'false'.",
+    )
+  }
+}
diff --git a/utils/get-newlines-between-option.ts b/utils/get-newlines-between-option.ts
index 26db69b..d75de84 100644
--- a/utils/get-newlines-between-option.ts
+++ b/utils/get-newlines-between-option.ts
@@ -133,15 +133,15 @@ let buildGroupsWithAllNewlinesBetween = (
   let returnValue: GroupsOptions<string> = []
   for (let i = 0; i < groups.length; i++) {
     let group = groups[i]!
-    if (isNewlinesBetweenOption(group)) {
-      returnValue.push(group)
-    } else {
+
+    if (!isNewlinesBetweenOption(group)) {
       let previousGroup = groups[i - 1]
       if (previousGroup && !isNewlinesBetweenOption(previousGroup)) {
         returnValue.push({ newlinesBetween: globalNewlinesBetweenOption })
       }
-      returnValue.push(group)
     }
+
+    returnValue.push(group)
   }
   return returnValue
 }


Test patch:
diff --git a/test/rules/sort-imports.test.ts b/test/rules/sort-imports.test.ts
index f69bb31..965a060 100644
--- a/test/rules/sort-imports.test.ts
+++ b/test/rules/sort-imports.test.ts
@@ -11,7 +11,8 @@ import { createModuleResolutionCache } from 'typescript'
 import { RuleTester as EslintRuleTester } from 'eslint'
 import dedent from 'dedent'
 
-import type { MESSAGE_ID, Options } from '../../rules/sort-imports'
+import type { Options } from '../../rules/sort-imports/types'
+import type { MESSAGE_ID } from '../../rules/sort-imports'
 
 import * as readClosestTsConfigUtilities from '../../rules/sort-imports/read-closest-ts-config-by-path'
 import * as getTypescriptImportUtilities from '../../rules/sort-imports/get-typescript-import'
diff --git a/test/rules/sort-imports/compute-common-predefined-groups.test.ts b/test/rules/sort-imports/compute-common-predefined-groups.test.ts
new file mode 100644
index 0000000..000ac22
--- /dev/null
+++ b/test/rules/sort-imports/compute-common-predefined-groups.test.ts
@@ -0,0 +1,234 @@
+import type { Mock } from 'vitest'
+import type ts from 'typescript'
+
+import { describe, expect, it, vi } from 'vitest'
+import { builtinModules } from 'node:module'
+
+import type { ReadClosestTsConfigByPathValue } from '../../../rules/sort-imports/read-closest-ts-config-by-path'
+
+import { computeCommonPredefinedGroups } from '../../../rules/sort-imports/compute-common-predefined-groups'
+
+let mockGetTypescriptImport: Mock<() => typeof ts> = vi.fn()
+
+vi.mock('../../../rules/sort-imports/get-typescript-import', () => ({
+  getTypescriptImport: () => mockGetTypescriptImport(),
+}))
+
+describe('compute-common-predefined-groups', () => {
+  describe('`index` group', () => {
+    it.each([
+      './index.d.js',
+      './index.d.ts',
+      './index.js',
+      './index.ts',
+      './index',
+      './',
+      '.',
+    ])("should match with '%s'", name => {
+      expect(
+        computeCommonPredefinedGroups(buildParameters({ name })),
+      ).toContain('index')
+    })
+  })
+
+  describe('`sibling` group', () => {
+    it.each([
+      './foo.js',
+      './foo.ts',
+      './foo',
+      './foo/index.js',
+      './foo/index.ts',
+      './foo/index',
+    ])("should match with '%s'", name => {
+      expect(
+        computeCommonPredefinedGroups(buildParameters({ name })),
+      ).toContain('sibling')
+    })
+  })
+
+  describe('`parent` group', () => {
+    it.each([
+      '../foo.js',
+      '../foo.ts',
+      '../foo',
+      '../foo/index.js',
+      '../foo/index.ts',
+      '../foo/index',
+    ])("should match with '%s'", name => {
+      expect(
+        computeCommonPredefinedGroups(buildParameters({ name })),
+      ).toContain('parent')
+    })
+  })
+
+  describe('`builtin` group', () => {
+    describe('node builtin modules', () => {
+      it.each([
+        ...builtinModules,
+        ...builtinModules.map(name => `node:${name}`),
+        'node:sqlite',
+        'node:test',
+        'node:sea',
+      ])("should match with '%s'", name => {
+        expect(
+          computeCommonPredefinedGroups(buildParameters({ name })),
+        ).toContain('builtin')
+      })
+    })
+
+    describe('bun environment', () => {
+      it.each([
+        'bun',
+        'bun:ffi',
+        'bun:jsc',
+        'bun:sqlite',
+        'bun:test',
+        'bun:wrap',
+        'detect-libc',
+        'undici',
+        'ws',
+      ])("should match with '%s'", name => {
+        expect(
+          computeCommonPredefinedGroups(
+            buildParameters({ environment: 'bun', name }),
+          ),
+        ).toContain('builtin')
+      })
+    })
+  })
+
+  describe('`internal` group', () => {
+    it.each([
+      { internalPattern: ['internal'], name: 'internal' },
+      { internalPattern: ['foo', 'internal'], name: 'internalName' },
+    ])(
+      "should match through `internalPattern` with '%s'",
+      ({ internalPattern, name }) => {
+        expect(
+          computeCommonPredefinedGroups(
+            buildParameters({ internalPattern, name }),
+          ),
+        ).toContain('internal')
+      },
+    )
+
+    it('should match with typescript if the import is resolved as internal', () => {
+      mockGetTypescriptImportReturnValue({
+        resolveModuleName: { isExternalLibraryImport: false },
+        isExternalModuleNameRelative: false,
+      })
+
+      expect(
+        computeCommonPredefinedGroups(
+          buildParameters({ withTsConfigOutput: true, name: 'foo' }),
+        ),
+      ).toContain('internal')
+    })
+  })
+
+  describe('`external` group', () => {
+    it('should match without typescript if the import does not start with . nor /', () => {
+      expect(
+        computeCommonPredefinedGroups(
+          buildParameters({ name: 'somethingExternal' }),
+        ),
+      ).toContain('external')
+    })
+
+    it('should match with typescript if no tsconfig is entered', () => {
+      mockGetTypescriptImportReturnValue({
+        isExternalModuleNameRelative: false,
+      })
+
+      expect(
+        computeCommonPredefinedGroups(buildParameters({ name: 'foo' })),
+      ).toContain('external')
+    })
+
+    it("should match with typescript if the import can't be resolved", () => {
+      mockGetTypescriptImportReturnValue({
+        isExternalModuleNameRelative: false,
+      })
+
+      expect(
+        computeCommonPredefinedGroups(
+          buildParameters({ withTsConfigOutput: true, name: 'foo' }),
+        ),
+      ).toContain('external')
+    })
+
+    it('should match with typescript if the import is resolved as external', () => {
+      mockGetTypescriptImportReturnValue({
+        resolveModuleName: { isExternalLibraryImport: true },
+        isExternalModuleNameRelative: false,
+      })
+
+      expect(
+        computeCommonPredefinedGroups(
+          buildParameters({ withTsConfigOutput: true, name: 'foo' }),
+        ),
+      ).toContain('external')
+    })
+  })
+
+  describe('no match', () => {
+    it.each(['.foo', '/foo'])(
+      "should not match anything without typescript with '%s'",
+      name => {
+        expect(
+          computeCommonPredefinedGroups(buildParameters({ name })),
+        ).toEqual([])
+      },
+    )
+
+    it('should not match anything if the import is detected by typescript as a relative import', () => {
+      mockGetTypescriptImportReturnValue({
+        isExternalModuleNameRelative: true,
+      })
+
+      expect(
+        computeCommonPredefinedGroups(buildParameters({ name: 'foo' })),
+      ).toEqual([])
+    })
+  })
+
+  let buildParameters = ({
+    withTsConfigOutput,
+    internalPattern,
+    environment,
+    name,
+  }: {
+    withTsConfigOutput?: boolean
+    environment?: 'node' | 'bun'
+    internalPattern?: string[]
+    name: string
+  }): Parameters<typeof computeCommonPredefinedGroups>[0] => ({
+    tsConfigOutput: withTsConfigOutput
+      ? ({ compilerOptions: {} } as ReadClosestTsConfigByPathValue)
+      : null,
+    options: {
+      internalPattern: internalPattern ?? [],
+      environment: environment ?? 'node',
+    },
+    filename: '',
+    name,
+  })
+
+  let mockGetTypescriptImportReturnValue = ({
+    isExternalModuleNameRelative,
+    resolveModuleName,
+  }: {
+    resolveModuleName?: { isExternalLibraryImport: boolean }
+    isExternalModuleNameRelative: boolean
+  }): void => {
+    mockGetTypescriptImport.mockReturnValueOnce({
+      resolveModuleName: () => ({
+        resolvedModule: {
+          isExternalLibraryImport:
+            resolveModuleName?.isExternalLibraryImport ?? null,
+        },
+      }),
+      isExternalModuleNameRelative: () => isExternalModuleNameRelative,
+    } as unknown as typeof ts)
+  }
+})
diff --git a/test/rules/sort-imports/is-side-effect-only-group.test.ts b/test/rules/sort-imports/is-side-effect-only-group.test.ts
new file mode 100644
index 0000000..754ec5b
--- /dev/null
+++ b/test/rules/sort-imports/is-side-effect-only-group.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest'
+
+import { isSideEffectOnlyGroup } from '../../../rules/sort-imports/is-side-effect-only-group'
+
+describe('is-side-effect-only-group', () => {
+  it('should return false if option is a newlinesBetween option', () => {
+    expect(
+      isSideEffectOnlyGroup({
+        newlinesBetween: 'ignore',
+      }),
+    ).toBeFalsy()
+  })
+
+  it.each(['side-effect', 'side-effect-style'])(
+    'should return true if option `%s`',
+    group => {
+      expect(isSideEffectOnlyGroup(group)).toBeTruthy()
+    },
+  )
+
+  it('should return true if all elements of a subgroup is a side-effect group `%s`', () => {
+    expect(
+      isSideEffectOnlyGroup(['side-effect', 'side-effect-style']),
+    ).toBeTruthy()
+  })
+
+  it.each(['side-effect', 'side-effect-style'])(
+    'should return false if at least one element of a subgroup is not a side-effect group (`%s`)',
+    group => {
+      expect(isSideEffectOnlyGroup(['group1', group])).toBeFalsy()
+    },
+  )
+})
diff --git a/test/rules/sort-imports/read-closest-ts-config-by-path.test.ts b/test/rules/sort-imports/read-closest-ts-config-by-path.test.ts
index 7f6cfd4..ff2feb5 100644
--- a/test/rules/sort-imports/read-closest-ts-config-by-path.test.ts
+++ b/test/rules/sort-imports/read-closest-ts-config-by-path.test.ts
@@ -38,7 +38,7 @@ vi.mock('node:module', _ => ({
   }),
 }))
 
-let mockGetTypescriptImport: Mock<() => null> = vi.fn()
+let mockGetTypescriptImport: Mock<() => typeof ts | null> = vi.fn()
 
 vi.mock('../../../rules/sort-imports/get-typescript-import', () => ({
   getTypescriptImport: () => mockGetTypescriptImport(),
@@ -86,7 +86,7 @@ describe('readClosestTsConfigByPath', () => {
         '../../../rules/sort-imports/get-typescript-import',
       )
       mockGetTypescriptImport.mockImplementation(
-        actualGetTypescriptImport['getTypescriptImport'] as never,
+        actualGetTypescriptImport['getTypescriptImport'] as () => typeof ts,
       )
     })
 
diff --git a/test/rules/sort-imports/validate-side-effects-configuration.test.ts b/test/rules/sort-imports/validate-side-effects-configuration.test.ts
new file mode 100644
index 0000000..f558477
--- /dev/null
+++ b/test/rules/sort-imports/validate-side-effects-configuration.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from 'vitest'
+
+import { validateSideEffectsConfiguration } from '../../../rules/sort-imports/validate-side-effects-configuration'
+
+describe('validate-side-effects-configuration', () => {
+  it('should not throw an error when sortSideEffects is true', () => {
+    expect(() => {
+      validateSideEffectsConfiguration({
+        groups: [['side-effect', 'sibling']],
+        sortSideEffects: true,
+      })
+    }).not.toThrow()
+  })
+
+  it('should throw if `groups` contain a subgroup containing a side-effect group and a non-side effect group', () => {
+    expect(() => {
+      validateSideEffectsConfiguration({
+        groups: ['builtin', ['internal', 'object'], ['side-effect', 'sibling']],
+        sortSideEffects: false,
+      })
+    }).toThrow(
+      "Side effect groups cannot be nested with non side effect groups when 'sortSideEffects' is 'false'",
+    )
+  })
+})