diff --git a/.changeset/clear-bikes-jog.md b/.changeset/clear-bikes-jog.md new file mode 100644 index 00000000..32845014 --- /dev/null +++ b/.changeset/clear-bikes-jog.md @@ -0,0 +1,5 @@ +--- +"eslint-plugin-import-x": minor +--- + +feat: add option `followTsOrganizeImports` for `order` rule diff --git a/.changeset/proud-comics-trade.md b/.changeset/proud-comics-trade.md new file mode 100644 index 00000000..12111d1a --- /dev/null +++ b/.changeset/proud-comics-trade.md @@ -0,0 +1,5 @@ +--- +"eslint-plugin-import-x": minor +--- + +feat: new usable group `private` for `order` rule diff --git a/docs/rules/order.md b/docs/rules/order.md index df8d7ad1..8189fe54 100644 --- a/docs/rules/order.md +++ b/docs/rules/order.md @@ -109,6 +109,7 @@ This rule supports the following options (none of which are required): - [`sortTypesGroup`][7] - [`newlines-between-types`][27] - [`consolidateIslands`][25] +- [`followTsOrganizeImports`][26] --- @@ -118,7 +119,7 @@ Valid values: `("builtin" | "external" | "internal" | "unknown" | "parent" | "si Default: `["builtin", "external", "parent", "sibling", "index"]` Determines which imports are subject to ordering, and how to order -them. The predefined groups are: `"builtin"`, `"external"`, `"internal"`, +them. The predefined groups are: `"builtin"`, `"external"`, `"internal"`, `"private"`, `"unknown"`, `"parent"`, `"sibling"`, `"index"`, `"object"`, and `"type"`. The import order enforced by this rule is the same as the order of each group @@ -165,17 +166,21 @@ Roughly speaking, the grouping algorithm is as follows: 3. If the import is [type-only][6], `"type"` is in `groups`, and [`sortTypesGroup`][7] is disabled, it will be considered **type** (with additional implications if using [`pathGroups`][8] and `"type"` is in [`pathGroupsExcludedImportTypes`][9]) 4. If the import's specifier matches [`import-x/internal-regex`][28], it will be considered **internal** 5. If the import's specifier is an absolute path, it will be considered **unknown** -6. If the import's specifier has the name of a Node.js core module (using [is-core-module][10]), it will be considered **builtin** -7. If the import's specifier matches [`import-x/core-modules`][11], it will be considered **builtin** -8. If the import's specifier is a path relative to the parent directory of its containing file (e.g. starts with `../`), it will be considered **parent** -9. If the import's specifier is one of `['.', './', './index', './index.js']`, it will be considered **index** -10. If the import's specifier is a path relative to its containing file (e.g. starts with `./`), it will be considered **sibling** -11. If the import's specifier is a path pointing to a file outside the current package's root directory (determined using [package-up][12]), it will be considered **external** -12. If the import's specifier matches [`import-x/external-module-folders`][29] (defaults to matching anything pointing to files within the current package's `node_modules` directory), it will be considered **external** -13. If the import's specifier is a path pointing to a file within the current package's root directory (determined using [package-up][12]), it will be considered **internal** -14. If the import's specifier has a name that looks like a scoped package (e.g. `@scoped/package-name`), it will be considered **external** -15. If the import's specifier has a name that starts with a word character, it will be considered **external** -16. If this point is reached, the import will be ignored entirely +6. If the import's specifier is starting with `#`, it will be considered **private** +7. If the import's specifier has the name of a Node.js core module (using [is-core-module][10]), it will be considered **builtin** +8. If the import's specifier matches [`import-x/core-modules`][11], it will be considered **builtin** +9. If the import's specifier is a path relative to the parent directory of its containing file (e.g. starts with `../`), it will be considered **parent** +10. If the import's specifier is one of `['.', './', './index', './index.js']`, it will be considered **index** +11. If the import's specifier is a path relative to its containing file (e.g. starts with `./`), it will be considered **sibling** +12. If the import's specifier is a path pointing to a file outside the current package's root directory (determined using [package-up][12]), it will be considered **external** +13. If the import's specifier matches [`import-x/external-module-folders`][29] (defaults to matching anything pointing to files within the current package's `node_modules` directory), it will be considered **external** +14. If the import's specifier is a path pointing to a file within the current package's root directory (determined using [package-up][12]), it will be considered **internal** +15. If the import's specifier has a name that looks like a scoped package (e.g. `@scoped/package-name`), it will be considered **external** +16. If the import's specifier has a name that starts with a word character, it will be considered **external** +17. If this point is reached, the import will be ignored entirely + +If `followTsOrganizeImports` is enabled, the default grouping algorithm is following [TypeScript's LSP Organize Imports][34] feature. \ +However, the `followTsOrganizeImports` will be ignored if custom `groups` are defined. At the end of the process, if they co-exist in the same file, all top-level `require()` statements that haven't been ignored are shifted (with respect to their order) below any ES6 `import` or similar declarations. Finally, any type-only declarations are potentially reorganized according to [`sortTypesGroup`][7]. @@ -958,6 +963,17 @@ import type { G } from './aaa.js' import type { H } from './bbb' ``` +### `followTsOrganizeImports` + +Valid values: `boolean` \ +Default: `false` + +> [!CAUTION] +> +> Currently, `followTsOrganizeImports` defaults to `false`. However, in a later update, the default might change to `true`. + +When set to `true`, this option will align the behavior with [TypeScript's LSP Organize Imports][34] feature. This only has an effect if no manual `groups` are defined. + ## Related - [`import-x/external-module-folders`][29] @@ -984,6 +1000,7 @@ import type { H } from './bbb' [22]: https://prettier.io [23]: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-5.html#type-modifiers-on-import-names [25]: #consolidateislands +[26]: #followtsorganizeimports [27]: #newlines-between-types [28]: ../../README.md#importinternal-regex [29]: ../../README.md#importexternal-module-folders @@ -991,3 +1008,4 @@ import type { H } from './bbb' [31]: https://webpack.js.org/guides/tree-shaking#mark-the-file-as-side-effect-free [32]: #distinctgroup [33]: #named +[34]: https://code.visualstudio.com/docs/typescript/typescript-refactoring#_organize-imports diff --git a/src/rules/order.ts b/src/rules/order.ts index 567907af..077c931e 100644 --- a/src/rules/order.ts +++ b/src/rules/order.ts @@ -53,6 +53,15 @@ const defaultGroups = [ 'index', ] as const +const defaultGroupsTsOrganizeImports = [ + 'private', + 'external', + 'builtin', + 'parent', + 'index', + 'sibling', +] as const + // REPORTING AND FIXING function reverse(array: ImportEntryWithRank[]): ImportEntryWithRank[] { @@ -824,6 +833,7 @@ function getRequireBlock(node: TSESTree.Node) { const types: ImportType[] = [ 'builtin', 'external', + 'private', 'internal', 'unknown', 'parent', @@ -1149,6 +1159,7 @@ export interface Options { pathGroups?: PathGroup[] sortTypesGroup?: boolean warnOnUnassignedImports?: boolean + followTsOrganizeImports?: boolean } type MessageId = @@ -1271,6 +1282,11 @@ export default createRule<[Options?], MessageId>({ type: 'boolean', default: false, }, + followTsOrganizeImports: { + type: 'boolean', + // TODO: switch default to true in next major + default: false, + }, }, additionalProperties: false, dependencies: { @@ -1389,7 +1405,10 @@ export default createRule<[Options?], MessageId>({ options.pathGroups || [], ) const { groups, omittedTypes } = convertGroupsToRanks( - options.groups || defaultGroups, + options.groups || + (options.followTsOrganizeImports + ? defaultGroupsTsOrganizeImports + : defaultGroups), ) ranks = { groups, diff --git a/src/utils/import-type.ts b/src/utils/import-type.ts index c3b7b088..8fffcfb5 100644 --- a/src/utils/import-type.ts +++ b/src/utils/import-type.ts @@ -88,6 +88,11 @@ export function isScopedMain(name: string) { return !!name && scopedMainRegExp.test(name) } +function isPrivate(name: string) { + // see https://nodejs.org/api/packages.html#imports + return name.startsWith('#') +} + function isRelativeToParent(name: string) { return /^\.\.$|^\.\.[/\\]/.test(name) } @@ -177,6 +182,9 @@ function typeTest( if (typeof name === 'string' && isExternalLookingName(name)) { return 'external' } + if (typeof name === 'string' && isPrivate(name)) { + return 'private' + } return 'unknown' } diff --git a/test/fixtures/typescript-order-custom-paths-mapping/a.ts b/test/fixtures/typescript-order-custom-paths-mapping/a.ts new file mode 100644 index 00000000..e69de29b diff --git a/test/fixtures/typescript-order-custom-paths-mapping/src/a.ts b/test/fixtures/typescript-order-custom-paths-mapping/src/a.ts new file mode 100644 index 00000000..e69de29b diff --git a/test/fixtures/typescript-order-custom-paths-mapping/src/components/index.ts b/test/fixtures/typescript-order-custom-paths-mapping/src/components/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/test/fixtures/typescript-order-custom-paths-mapping/src/index.ts b/test/fixtures/typescript-order-custom-paths-mapping/src/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/test/fixtures/typescript-order-custom-paths-mapping/src/private/a.ts b/test/fixtures/typescript-order-custom-paths-mapping/src/private/a.ts new file mode 100644 index 00000000..e69de29b diff --git a/test/fixtures/typescript-order-custom-paths-mapping/tsconfig-with-path-mapping.json b/test/fixtures/typescript-order-custom-paths-mapping/tsconfig-with-path-mapping.json new file mode 100644 index 00000000..41e14c3c --- /dev/null +++ b/test/fixtures/typescript-order-custom-paths-mapping/tsconfig-with-path-mapping.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "paths": { + "@/*": [ + "./src/*" + ], + "@components/*": [ + "./src/components/*" + ], + "#private": [ + "./src/private/*" + ] + } + } +} diff --git a/test/rules/order.spec.ts b/test/rules/order.spec.ts index 9fca76dc..368dfb96 100644 --- a/test/rules/order.spec.ts +++ b/test/rules/order.spec.ts @@ -4809,6 +4809,151 @@ describe('TypeScript', () => { }, ], }), + // By default it should order same as TS LSP (issue-286) + tValid({ + code: `import { internA } from "#a"; +import { scopeA } from "@a/a"; +import a from 'a'; +import 'format.css'; +import { glob } from 'glob'; +import fs from 'node:fs'; +import path from "path"; +import index from './'; +import { localA } from "./a"; +import sibling from './foo'; +`, + ...parserConfig, + options: [ + { + followTsOrganizeImports: true, + }, + ], + }), + // test for explicit followTsOrganizeImports=false + tValid({ + code: `import 'format.css'; +import fs from 'node:fs'; +import path from "path"; +import { glob } from 'glob'; +import a from 'a'; +import { scopeA } from "@a/a"; +import { localA } from "./a"; +import sibling from './foo'; +import index from './'; +import { internA } from "#a"; +`, + ...parserConfig, + options: [ + { + followTsOrganizeImports: false, + }, + ], + }), + // manual `groups` always take precedence over `followTsOrganizeImports` + tValid({ + code: `import 'format.css'; +import a from 'a'; +import { scopeA } from "@a/a"; +import { glob } from 'glob'; +import index from './'; +import fs from 'node:fs'; +import path from "path"; +import { localA } from "./a"; +import sibling from './foo'; +import { internA } from "#a"; +`, + ...parserConfig, + options: [ + { + groups: ['external', 'internal', 'index'], + followTsOrganizeImports: true, + }, + ], + }), + // test with tsconfig paths mappings and followTsOrganizeImports: true + tValid({ + code: `import { internA } from "#a"; +import { privateA } from "#private/a"; +import { scopeA } from "@a/a"; +import a from 'a'; +import 'format.css'; +import { glob } from 'glob'; +import fs from 'node:fs'; +import path from "path"; +import index from './'; +import { localA } from "./a"; +import sibling from './foo'; +`, + ...parserConfig, + settings: { + ...parserConfig.settings, + 'import-x/resolver': { + typescript: { + project: testFilePath( + 'typescript-order-custom-paths-mapping/tsconfig-with-path-mapping.json', + ), + }, + }, + }, + options: [ + { + followTsOrganizeImports: true, + }, + ], + }), + // test with tsconfig paths mappings and followTsOrganizeImports: false + tValid({ + code: `import 'format.css'; +import fs from 'node:fs'; +import path from "path"; +import a from 'a'; +import { scopeA } from "@a/a"; +import { glob } from 'glob'; +import { localA } from "./a"; +import sibling from './foo'; +import index from './'; +import { internA } from "#a"; +import { privateA } from "#private/a"; +`, + ...parserConfig, + settings: { + ...parserConfig.settings, + 'import-x/resolver': { + typescript: { + project: testFilePath( + 'typescript-order-custom-paths-mapping/tsconfig-with-path-mapping.json', + ), + }, + }, + }, + options: [ + { + followTsOrganizeImports: false, + }, + ], + }), + // test followTsOrganizeImports: true and splitted node:* group + tValid({ + code: `import fs from 'node:fs'; +import path from "path"; + +import { internA } from "#a"; +import { privateA } from "#private/a"; +import { scopeA } from "@a/a"; +import a from 'a'; +import 'format.css'; +import { glob } from 'glob'; +import index from './'; +import { localA } from "./a"; +import sibling from './foo'; +`, + ...parserConfig, + options: [ + { + followTsOrganizeImports: true, + }, + ], + }), ], invalid: [ // Option alphabetize: {order: 'asc'} @@ -5192,6 +5337,30 @@ describe('TypeScript', () => { // { message: '`node:fs/promises` import should occur before import of `express`' }, ], }), + // By default it should order same as TS LSP (issue-286) + tInvalid({ + code: `import { scopeA } from "@a/a"; +import fs from 'node:fs'; +import path from "path"; +import { localA } from "./a"; +import { internA } from "#a"; +`, + output: `import { internA } from "#a"; +import { scopeA } from "@a/a"; +import fs from 'node:fs'; +import path from "path"; +import { localA } from "./a"; +`, + ...parserConfig, + options: [ + { + followTsOrganizeImports: true, + }, + ], + errors: [ + createOrderError(['`#a` import', 'before', 'import of `@a/a`']), + ], + }), ], }) }