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

Issue:
Feature: allow to find alternate tsconfig file (i.e.: tsconfig.base.json when using nx workspace)
### What rule do you want to change?

sort-imports

### Describe the problem

In the `sort-imports` rule, you can specify the `tsconfigRootDir` option but the rule actually looks only for the file `tsconfig.json`. 
It would be nice to also be able to search for alternate file name like `tsconfig.base.json`. 
Maybe this could be an additional property to the `sort-imports` rule.

### Code example

```js
export default [
  // ...
  {
    // ...
    'perfectionist/sort-imports': [
        'error',
        {
          groups: [ /* ... */ ],
          tsconfigRootDir: '.',
          tsconfigFileName: 'tsconfig.base.json', // optinal: default to tsconfig.json
          order: 'asc',
          type: 'natural',
          newlinesBetween: 'never',
        },
      ],
    // ...
  },
  // ...
];
```
or
```js
export default [
  // ...
  {
    // ...
    'perfectionist/sort-imports': [
        'error',
        {
          groups: [ /* ... */ ],
          tsconfigRootPath: './tsconfig.base.json', // replace tsconfigRootDir entirely
          order: 'asc',
          type: 'natural',
          newlinesBetween: 'never',
        },
      ],
    // ...
  },
  // ...
];
```

### Additional comments

_No response_

### 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:
feat(sort-imports): allow to find alternate `tsconfig` file
- Resolves #536

### Description

This PR:
- Adds an option `tsconfig: { rootDir: string; filename?: string}`.
  - If `filename` is not entered, will search for the closest `tsconfig.json` file.
- Deprecates `tsconfigRootDir`.

### What is the purpose of this pull request?

- [x] New Feature


Patch:
diff --git a/docs/content/rules/sort-imports.mdx b/docs/content/rules/sort-imports.mdx
index 3a0dabd..7b984ca 100644
--- a/docs/content/rules/sort-imports.mdx
+++ b/docs/content/rules/sort-imports.mdx
@@ -267,14 +267,32 @@ Specifies a maximum line length for sorting imports. When the line length exceed
 
 This option is only available when the type is set to `'line-length'`.
 
-### tsconfigRootDir
+### tsconfig
+
+<sub>
+  type: `{ rootDir: string; filename?: string }`
+</sub>
+<sub>default: undefined</sub>
+
+- `rootDir` — Specifies the directory of the root `tsconfig.json` file (ex: `.`). This is used in [`groups`](#groups) for:
+  - Marking aliased imports as `internal`.
+  - Marking imports matching [tsconfig paths](https://www.typescriptlang.org/tsconfig/#paths) as `tsconfig-path`.
+- `filename` — Specifies the `tsconfig` filename to search for (by default: `tsconfig.json`).
+
+If this option is not set, the rule will not search for a `tsconfig.json` file.
+
+### [DEPRECATED] tsconfigRootDir
 
 <sub>default: `undefined`</sub>
 
+Use the [tsconfig](#tsconfig) option with the `rootDir` attribute.
+
 Specifies the  directory of the root `tsconfig.json` file (ex: `.`). This is used in [`groups`](#groups) for:
 - Marking aliased imports as `internal`.
 - Marking imports matching [tsconfig paths](https://www.typescriptlang.org/tsconfig/#paths) as `tsconfig-path`.
 
+If this option is not set, the rule will not search for a `tsconfig.json` file.
+
 ### groups
 
 <sub>
diff --git a/rules/sort-imports.ts b/rules/sort-imports.ts
index a399bd5..044dec3 100644
--- a/rules/sort-imports.ts
+++ b/rules/sort-imports.ts
@@ -62,17 +62,6 @@ import { complete } from '../utils/complete'
  */
 let cachedGroupsByModifiersAndSelectors = new Map<string, string[]>()
 
-let defaultGroups = [
-  'type-import',
-  ['value-builtin', 'value-external'],
-  'type-internal',
-  'value-internal',
-  ['type-parent', 'type-sibling', 'type-index'],
-  ['value-parent', 'value-sibling', 'value-index'],
-  'ts-equals-import',
-  'unknown',
-]
-
 export type MESSAGE_ID =
   | 'unexpectedImportsDependencyOrder'
   | 'missedSpacingBetweenImports'
@@ -80,29 +69,43 @@ export type MESSAGE_ID =
   | 'extraSpacingBetweenImports'
   | 'unexpectedImportsOrder'
 
+let defaultOptions: Required<
+  Omit<Options[0], 'tsconfigRootDir' | 'maxLineLength' | 'tsconfig'>
+> &
+  Pick<Options[0], 'tsconfigRootDir' | 'maxLineLength' | 'tsconfig'> = {
+  groups: [
+    'type-import',
+    ['value-builtin', 'value-external'],
+    'type-internal',
+    'value-internal',
+    ['type-parent', 'type-sibling', 'type-index'],
+    ['value-parent', 'value-sibling', 'value-index'],
+    'ts-equals-import',
+    'unknown',
+  ],
+  internalPattern: ['^~/.+', '^@/.+'],
+  fallbackSort: { type: 'unsorted' },
+  partitionByComment: false,
+  partitionByNewLine: false,
+  newlinesBetween: 'always',
+  specialCharacters: 'keep',
+  sortSideEffects: false,
+  type: 'alphabetical',
+  environment: 'node',
+  customGroups: [],
+  ignoreCase: true,
+  locales: 'en-US',
+  alphabet: '',
+  order: 'asc',
+}
+
 export default createEslintRule<Options, MESSAGE_ID>({
   create: context => {
     let settings = getSettings(context.settings)
 
     let userOptions = context.options.at(0)
     let options = getOptionsWithCleanGroups(
-      complete(userOptions, settings, {
-        internalPattern: ['^~/.+', '^@/.+'],
-        fallbackSort: { type: 'unsorted' },
-        partitionByComment: false,
-        partitionByNewLine: false,
-        newlinesBetween: 'always',
-        specialCharacters: 'keep',
-        sortSideEffects: false,
-        groups: defaultGroups,
-        type: 'alphabetical',
-        environment: 'node',
-        customGroups: [],
-        ignoreCase: true,
-        locales: 'en-US',
-        alphabet: '',
-        order: 'asc',
-      } as const),
+      complete(userOptions, settings, defaultOptions),
     )
 
     validateGeneratedGroupsConfiguration({
@@ -122,9 +125,12 @@ export default createEslintRule<Options, MESSAGE_ID>({
     validateNewlinesAndPartitionConfiguration(options)
     validateSideEffectsConfiguration(options)
 
-    let tsConfigOutput = options.tsconfigRootDir
+    let tsconfigRootDirectory =
+      options.tsconfig?.rootDir ?? options.tsconfigRootDir
+    let tsConfigOutput = tsconfigRootDirectory
       ? readClosestTsConfigByPath({
-          tsconfigRootDir: options.tsconfigRootDir,
+          tsconfigFilename: options.tsconfig?.filename ?? 'tsconfig.json',
+          tsconfigRootDir: tsconfigRootDirectory,
           filePath: context.physicalFilename,
           contextCwd: context.cwd,
         })
@@ -439,6 +445,21 @@ export default createEslintRule<Options, MESSAGE_ID>({
               buildCustomGroupsArrayJsonSchema({ singleCustomGroupJsonSchema }),
             ],
           },
+          tsconfig: {
+            properties: {
+              rootDir: {
+                description: 'Specifies the tsConfig root directory.',
+                type: 'string',
+              },
+              filename: {
+                description: 'Specifies the tsConfig filename.',
+                type: 'string',
+              },
+            },
+            additionalProperties: false,
+            required: ['rootDir'],
+            type: 'object',
+          },
           maxLineLength: {
             description: 'Specifies the maximum line length.',
             exclusiveMinimum: true,
@@ -486,24 +507,7 @@ export default createEslintRule<Options, MESSAGE_ID>({
     type: 'suggestion',
     fixable: 'code',
   },
-  defaultOptions: [
-    {
-      customGroups: { value: {}, type: {} },
-      internalPattern: ['^~/.+', '^@/.+'],
-      partitionByComment: false,
-      partitionByNewLine: false,
-      specialCharacters: 'keep',
-      newlinesBetween: 'always',
-      sortSideEffects: false,
-      groups: defaultGroups,
-      type: 'alphabetical',
-      environment: 'node',
-      ignoreCase: true,
-      locales: 'en-US',
-      alphabet: '',
-      order: 'asc',
-    },
-  ],
+  defaultOptions: [defaultOptions],
   name: 'sort-imports',
 })
 
@@ -594,7 +598,7 @@ let computeGroupExceptUnknown = ({
 }: {
   options: Omit<
     Required<Options[0]>,
-    'tsconfigRootDir' | 'maxLineLength' | 'customGroups'
+    'tsconfigRootDir' | 'maxLineLength' | 'customGroups' | 'tsconfig'
   >
   customGroups: DeprecatedCustomGroupsOption | CustomGroupsOption | undefined
   selectors?: Selector[]
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 8a3d03b..d2d22fa 100644
--- a/rules/sort-imports/read-closest-ts-config-by-path.ts
+++ b/rules/sort-imports/read-closest-ts-config-by-path.ts
@@ -15,31 +15,33 @@ export interface ReadClosestTsConfigByPathValue {
   cache: ts.ModuleResolutionCache
 }
 
-interface ReadClosestTsConfigByPathParameters {
-  tsconfigRootDir: string
-  contextCwd: string
-  filePath: string
-}
-
 export let directoryCacheByPath = new Map<string, string>()
 export let contentCacheByPath = new Map<
   string,
   ReadClosestTsConfigByPathValue
 >()
 
-export let readClosestTsConfigByPath = (
-  input: ReadClosestTsConfigByPathParameters,
-): ReadClosestTsConfigByPathValue | null => {
+export let readClosestTsConfigByPath = ({
+  tsconfigFilename,
+  tsconfigRootDir,
+  contextCwd,
+  filePath,
+}: {
+  tsconfigFilename: string
+  tsconfigRootDir: string
+  contextCwd: string
+  filePath: string
+}): ReadClosestTsConfigByPathValue | null => {
   let typescriptImport = getTypescriptImport()
   if (!typescriptImport) {
     return null
   }
 
-  let directory = path.dirname(input.filePath)
+  let directory = path.dirname(filePath)
   let checkedDirectories = [directory]
 
   do {
-    let tsconfigPath = path.join(directory, 'tsconfig.json')
+    let tsconfigPath = path.join(directory, tsconfigFilename)
     let cachedDirectory = directoryCacheByPath.get(directory)
     if (!cachedDirectory && fs.existsSync(tsconfigPath)) {
       cachedDirectory = tsconfigPath
@@ -49,22 +51,15 @@ export let readClosestTsConfigByPath = (
       for (let checkedDirectory of checkedDirectories) {
         directoryCacheByPath.set(checkedDirectory, cachedDirectory)
       }
-      return getCompilerOptions(
-        typescriptImport,
-        input.contextCwd,
-        cachedDirectory,
-      )
+      return getCompilerOptions(typescriptImport, contextCwd, cachedDirectory)
     }
 
     directory = path.dirname(directory)
     checkedDirectories.push(directory)
-  } while (
-    directory.length > 1 &&
-    directory.length >= input.tsconfigRootDir.length
-  )
+  } while (directory.length > 1 && directory.length >= tsconfigRootDir.length)
 
   throw new Error(
-    `Couldn't find any tsconfig.json relative to '${input.filePath}' within '${input.tsconfigRootDir}'.`,
+    `Couldn't find any ${tsconfigFilename} relative to '${filePath}' within '${tsconfigRootDir}'.`,
   )
 }
 
diff --git a/rules/sort-imports/types.ts b/rules/sort-imports/types.ts
index fc1d0e5..78185ff 100644
--- a/rules/sort-imports/types.ts
+++ b/rules/sort-imports/types.ts
@@ -28,18 +28,27 @@ export type Options = Partial<{
         type?: DeprecatedCustomGroupsOption
       }
     | CustomGroupsOption<SingleCustomGroup>
+  tsconfig:
+    | {
+        filename?: string
+        rootDir: string
+      }
+    | undefined
   partitionByComment: PartitionByCommentOption
   specialCharacters: SpecialCharactersOption
   locales: NonNullable<Intl.LocalesArgument>
   newlinesBetween: NewlinesBetweenOption
+  /**
+   * @deprecated for `tsconfig`
+   */
+  tsconfigRootDir: undefined | string
+  maxLineLength: undefined | number
   fallbackSort: FallbackSortOption
   internalPattern: RegexOption[]
   groups: GroupsOptions<Group>
   environment: 'node' | 'bun'
   partitionByNewLine: boolean
   sortSideEffects: boolean
-  tsconfigRootDir?: string
-  maxLineLength?: number
   ignoreCase: boolean
   order: OrderOption
   type: TypeOption


Test patch:
diff --git a/test/rules/sort-imports.test.ts b/test/rules/sort-imports.test.ts
index 05f1184..796a84a 100644
--- a/test/rules/sort-imports.test.ts
+++ b/test/rules/sort-imports.test.ts
@@ -7464,119 +7464,271 @@ describe(ruleName, () => {
     })
 
     describe(`${ruleName}: handles tsconfig.json`, () => {
-      ruleTester.run(
-        `${ruleName}: marks internal imports as 'internal'`,
-        rule,
-        {
-          valid: [
-            {
-              options: [
-                {
-                  groups: ['internal', 'unknown'],
-                  tsconfigRootDir: '.',
+      describe('tsconfigRootDir option', () => {
+        ruleTester.run(
+          `${ruleName}: marks internal imports as 'internal'`,
+          rule,
+          {
+            valid: [
+              {
+                options: [
+                  {
+                    groups: ['internal', 'unknown'],
+                    tsconfigRootDir: '.',
+                  },
+                ],
+                before: () => {
+                  mockReadClosestTsConfigByPathWith({
+                    baseUrl: './rules/',
+                  })
+                },
+                code: dedent`
+                  import { x } from 'sort-imports'
+
+                  import { a } from './a';
+                `,
+                after: () => {
+                  vi.resetAllMocks()
                 },
-              ],
-              before: () => {
-                mockReadClosestTsConfigByPathWith({
-                  baseUrl: './rules/',
-                })
               },
-              code: dedent`
-                import { x } from 'sort-imports'
+            ],
+            invalid: [],
+          },
+        )
 
-                import { a } from './a';
-              `,
-            },
-          ],
-          invalid: [],
-        },
-      )
+        ruleTester.run(
+          `${ruleName}: marks external imports as 'external'`,
+          rule,
+          {
+            valid: [
+              {
+                options: [
+                  {
+                    groups: ['external', 'unknown'],
+                    tsconfigRootDir: '.',
+                  },
+                ],
+                code: dedent`
+                  import type { ParsedCommandLine } from 'typescript'
 
-      ruleTester.run(
-        `${ruleName}: marks external imports as 'external'`,
-        rule,
-        {
-          valid: [
-            {
-              options: [
-                {
-                  groups: ['external', 'unknown'],
-                  tsconfigRootDir: '.',
+                  import { a } from './a';
+                `,
+                before: () => {
+                  mockReadClosestTsConfigByPathWith({
+                    baseUrl: '.',
+                  })
                 },
-              ],
-              code: dedent`
-                import type { ParsedCommandLine } from 'typescript'
+                after: () => {
+                  vi.resetAllMocks()
+                },
+              },
+            ],
+            invalid: [],
+          },
+        )
 
-                import { a } from './a';
-              `,
-              before: () => {
-                mockReadClosestTsConfigByPathWith({
-                  baseUrl: '.',
-                })
+        ruleTester.run(
+          `${ruleName}: marks non-resolved imports as 'external'`,
+          rule,
+          {
+            valid: [
+              {
+                options: [
+                  {
+                    groups: ['external', 'unknown'],
+                    tsconfigRootDir: '.',
+                  },
+                ],
+                before: () => {
+                  mockReadClosestTsConfigByPathWith({
+                    baseUrl: '.',
+                  })
+                },
+                code: dedent`
+                  import { b } from 'b'
+
+                  import { a } from './a';
+                `,
+                after: () => {
+                  vi.resetAllMocks()
+                },
               },
-            },
-          ],
-          invalid: [],
-        },
-      )
+            ],
+            invalid: [],
+          },
+        )
 
-      ruleTester.run(
-        `${ruleName}: marks non-resolved imports as 'external'`,
-        rule,
-        {
-          valid: [
-            {
-              options: [
-                {
-                  groups: ['external', 'unknown'],
-                  tsconfigRootDir: '.',
+        ruleTester.run(
+          `${ruleName}: uses the fallback algorithm if typescript is not present`,
+          rule,
+          {
+            valid: [
+              {
+                before: () => {
+                  vi.spyOn(
+                    getTypescriptImportUtilities,
+                    'getTypescriptImport',
+                  ).mockReturnValue(null)
+                },
+                options: [
+                  {
+                    groups: ['external', 'unknown'],
+                    tsconfigRootDir: '.',
+                  },
+                ],
+                code: dedent`
+                  import { b } from 'b'
+
+                  import { a } from './a';
+                `,
+                after: () => {
+                  vi.resetAllMocks()
                 },
-              ],
-              before: () => {
-                mockReadClosestTsConfigByPathWith({
-                  baseUrl: '.',
-                })
               },
-              code: dedent`
-                import { b } from 'b'
+            ],
+            invalid: [],
+          },
+        )
+      })
 
-                import { a } from './a';
-              `,
-            },
-          ],
-          invalid: [],
-        },
-      )
+      describe('tsconfig option', () => {
+        ruleTester.run(
+          `${ruleName}: marks internal imports as 'internal'`,
+          rule,
+          {
+            valid: [
+              {
+                options: [
+                  {
+                    tsconfig: {
+                      filename: 'tsconfig.json',
+                      rootDir: '.',
+                    },
+                    groups: ['internal', 'unknown'],
+                  },
+                ],
+                before: () => {
+                  mockReadClosestTsConfigByPathWith({
+                    baseUrl: './rules/',
+                  })
+                },
+                code: dedent`
+                  import { x } from 'sort-imports'
 
-      ruleTester.run(
-        `${ruleName}: uses the fallback algorithm if typescript is not present`,
-        rule,
-        {
-          valid: [
-            {
-              before: () => {
-                mockReadClosestTsConfigByPathWith(null)
-                vi.spyOn(
-                  getTypescriptImportUtilities,
-                  'getTypescriptImport',
-                ).mockReturnValue(null)
+                  import { a } from './a';
+                `,
+                after: () => {
+                  vi.resetAllMocks()
+                },
               },
-              options: [
-                {
-                  groups: ['external', 'unknown'],
-                  tsconfigRootDir: '.',
+            ],
+            invalid: [],
+          },
+        )
+
+        ruleTester.run(
+          `${ruleName}: marks external imports as 'external'`,
+          rule,
+          {
+            valid: [
+              {
+                options: [
+                  {
+                    tsconfig: {
+                      filename: 'tsconfig.json',
+                      rootDir: '.',
+                    },
+                    groups: ['external', 'unknown'],
+                  },
+                ],
+                code: dedent`
+                  import type { ParsedCommandLine } from 'typescript'
+
+                  import { a } from './a';
+                `,
+                before: () => {
+                  mockReadClosestTsConfigByPathWith({
+                    baseUrl: '.',
+                  })
                 },
-              ],
-              code: dedent`
-                import { b } from 'b'
+                after: () => {
+                  vi.resetAllMocks()
+                },
+              },
+            ],
+            invalid: [],
+          },
+        )
 
-                import { a } from './a';
-              `,
-            },
-          ],
-          invalid: [],
-        },
-      )
+        ruleTester.run(
+          `${ruleName}: marks non-resolved imports as 'external'`,
+          rule,
+          {
+            valid: [
+              {
+                options: [
+                  {
+                    tsconfig: {
+                      filename: 'tsconfig.json',
+                      rootDir: '.',
+                    },
+                    groups: ['external', 'unknown'],
+                  },
+                ],
+                before: () => {
+                  mockReadClosestTsConfigByPathWith({
+                    baseUrl: '.',
+                  })
+                },
+                code: dedent`
+                  import { b } from 'b'
+
+                  import { a } from './a';
+                `,
+                after: () => {
+                  vi.resetAllMocks()
+                },
+              },
+            ],
+            invalid: [],
+          },
+        )
+
+        ruleTester.run(
+          `${ruleName}: uses the fallback algorithm if typescript is not present`,
+          rule,
+          {
+            valid: [
+              {
+                options: [
+                  {
+                    tsconfig: {
+                      filename: 'tsconfig.json',
+                      rootDir: '.',
+                    },
+                    groups: ['external', 'unknown'],
+                  },
+                ],
+                before: () => {
+                  vi.spyOn(
+                    getTypescriptImportUtilities,
+                    'getTypescriptImport',
+                  ).mockReturnValue(null)
+                },
+                code: dedent`
+                  import { b } from 'b'
+
+                  import { a } from './a';
+                `,
+                after: () => {
+                  vi.resetAllMocks()
+                },
+              },
+            ],
+            invalid: [],
+          },
+        )
+      })
     })
 
     let eslintDisableRuleTesterName = `${ruleName}: supports 'eslint-disable' for individual nodes`
@@ -7901,22 +8053,18 @@ describe(ruleName, () => {
   })
 
   let mockReadClosestTsConfigByPathWith = (
-    compilerOptions: CompilerOptions | null,
+    compilerOptions: CompilerOptions,
   ): void => {
     vi.spyOn(
       readClosestTsConfigUtilities,
       'readClosestTsConfigByPath',
-    ).mockReturnValue(
-      compilerOptions
-        ? {
-            cache: createModuleResolutionCache(
-              '.',
-              filename => filename,
-              compilerOptions,
-            ),
-            compilerOptions,
-          }
-        : null,
-    )
+    ).mockReturnValue({
+      cache: createModuleResolutionCache(
+        '.',
+        filename => filename,
+        compilerOptions,
+      ),
+      compilerOptions,
+    })
   }
 })
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 ff2feb5..72023ec 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
@@ -47,6 +47,7 @@ vi.mock('../../../rules/sort-imports/get-typescript-import', () => ({
 let testInput = {
   filePath: '../../repos/repo/packages/package/file.ts',
   tsconfigRootDir: '../../repos/repo',
+  tsconfigFilename: 'tsconfig.json',
   contextCwd: '../../../',
 }
 
@@ -146,6 +147,7 @@ describe('readClosestTsConfigByPath', () => {
 
         // This should call to fs.existsSync three times: c, b, a.
         readClosestTsConfigByPath({
+          tsconfigFilename: 'tsconfig.json',
           filePath: './a/b/c/d.ts',
           tsconfigRootDir: './a',
           contextCwd: '../../',
@@ -156,6 +158,7 @@ describe('readClosestTsConfigByPath', () => {
          * from cache, pointing to a.
          */
         let actual = readClosestTsConfigByPath({
+          tsconfigFilename: 'tsconfig.json',
           filePath: './a/b/c/e/f.ts',
           tsconfigRootDir: './a',
           contextCwd: './',
@@ -179,6 +182,7 @@ describe('readClosestTsConfigByPath', () => {
          * This should call to fs.existsSync 4 times: d, c, b, a
          */
         readClosestTsConfigByPath({
+          tsconfigFilename: 'tsconfig.json',
           filePath: './a/b/c/d/e.ts',
           tsconfigRootDir: './a',
           contextCwd: '../../',
@@ -189,6 +193,7 @@ describe('readClosestTsConfigByPath', () => {
          * Then it should retrieve b from cache, pointing to a
          */
         let actual = readClosestTsConfigByPath({
+          tsconfigFilename: 'tsconfig.json',
           filePath: './a/b/f/g/h.ts',
           tsconfigRootDir: './a',
           contextCwd: '../../',
@@ -231,7 +236,7 @@ describe('readClosestTsConfigByPath', () => {
         )
       })
 
-      it('throws when searching hits.', () => {
+      it('throws when searching fails.', () => {
         mockExistsSync.mockReturnValue(false)
         mockReadConfigFileReturnValue()
         mockParseJsonConfigFileContentReturnValue()