From fd1ae63492a554ded6c5cc7fc262034804819e5e Mon Sep 17 00:00:00 2001 From: "Xunnamius (Romulus)" Date: Sun, 22 Dec 2024 17:56:27 -0800 Subject: [PATCH] [New] `order`: add `sortTypesGroup` option to allow intragroup sorting of type-only imports Closes #2912 Closes #2347 Closes #2441 Subsumes #2615 --- CHANGELOG.md | 2 + docs/rules/order.md | 64 +++++++++++++- src/rules/order.js | 33 +++++-- tests/src/rules/order.js | 182 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 274 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80b1f632a..9d60bc311 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange - add [`enforce-node-protocol-usage`] rule and `import/node-version` setting ([#3024], thanks [@GoldStrikeArch] and [@sevenc-nanashi]) - add TypeScript types ([#3097], thanks [@G-Rath]) - [`extensions`]: add `pathGroupOverrides to allow enforcement decision overrides based on specifier ([#3105], thanks [@Xunnamius]) +- [`order`]: add `sortTypesGroup` option to allow intragroup sorting of type-only imports ([#3104], thanks [@Xunnamius]) ### Fixed - [`no-unused-modules`]: provide more meaningful error message when no .eslintrc is present ([#3116], thanks [@michaelfaith]) @@ -1174,6 +1175,7 @@ for info on changes for earlier releases. [#3116]: https://github.com/import-js/eslint-plugin-import/pull/3116 [#3106]: https://github.com/import-js/eslint-plugin-import/pull/3106 [#3105]: https://github.com/import-js/eslint-plugin-import/pull/3105 +[#3104]: https://github.com/import-js/eslint-plugin-import/pull/3104 [#3097]: https://github.com/import-js/eslint-plugin-import/pull/3097 [#3073]: https://github.com/import-js/eslint-plugin-import/pull/3073 [#3072]: https://github.com/import-js/eslint-plugin-import/pull/3072 diff --git a/docs/rules/order.md b/docs/rules/order.md index 02a2b4bed..996df915c 100644 --- a/docs/rules/order.md +++ b/docs/rules/order.md @@ -106,6 +106,7 @@ This rule supports the following options (none of which are required): - [`alphabetize`][30] - [`named`][33] - [`warnOnUnassignedImports`][5] + - [`sortTypesGroup`][7] --- @@ -156,7 +157,7 @@ Roughly speaking, the grouping algorithm is as follows: 1. If the import has no corresponding identifiers (e.g. `import './my/thing.js'`), is otherwise "unassigned," or is an unsupported use of `require()`, and [`warnOnUnassignedImports`][5] is disabled, it will be ignored entirely since the order of these imports may be important for their [side-effects][31] 2. If the import is part of an arcane TypeScript declaration (e.g. `import log = console.log`), it will be considered **object**. However, note that external module references (e.g. `import x = require('z')`) are treated as normal `require()`s and import-exports (e.g. `export import w = y;`) are ignored entirely -3. If the import is [type-only][6], and `"type"` is in `groups`, it will be considered **type** (with additional implications if using [`pathGroups`][8] and `"type"` is in [`pathGroupsExcludedImportTypes`][9]) +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/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** @@ -171,7 +172,7 @@ Roughly speaking, the grouping algorithm is as follows: 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 -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. +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]. ### `pathGroups` @@ -533,6 +534,64 @@ import path from 'path'; import './styles.css'; ``` +### `sortTypesGroup` + +Valid values: `boolean` \ +Default: `false` + +> \[!NOTE] +> +> This setting is only meaningful when `"type"` is included in [`groups`][18]. + +Sort [type-only imports][6] separately from normal non-type imports. + +When enabled, the intragroup sort order of [type-only imports][6] will mirror the intergroup ordering of normal imports as defined by [`groups`][18], [`pathGroups`][8], etc. + +#### Example + +Given the following settings: + +```jsonc +{ + "import/order": ["error", { + "groups": ["type", "builtin", "parent", "sibling", "index"], + "alphabetize": { "order": "asc" } + }] +} +``` + +This will fail the rule check even though it's logically ordered as we expect (builtins come before parents, parents come before siblings, siblings come before indices), the only difference is we separated type-only imports from normal imports: + +```ts +import type A from "fs"; +import type B from "path"; +import type C from "../foo.js"; +import type D from "./bar.js"; +import type E from './'; + +import a from "fs"; +import b from "path"; +import c from "../foo.js"; +import d from "./bar.js"; +import e from "./"; +``` + +This happens because [type-only imports][6] are considered part of one global +[`"type"` group](#how-imports-are-grouped) by default. However, if we set +`sortTypesGroup` to `true`: + +```jsonc +{ + "import/order": ["error", { + "groups": ["type", "builtin", "parent", "sibling", "index"], + "alphabetize": { "order": "asc" }, + "sortTypesGroup": true + }] +} +``` + +The same example will pass. + ## Related - [`import/external-module-folders`][29] @@ -543,6 +602,7 @@ import './styles.css'; [4]: https://nodejs.org/api/esm.html#terminology [5]: #warnonunassignedimports [6]: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html#type-only-imports-and-export +[7]: #sorttypesgroup [8]: #pathgroups [9]: #pathgroupsexcludedimporttypes [10]: https://www.npmjs.com/package/is-core-module diff --git a/src/rules/order.js b/src/rules/order.js index d6f25ddd3..ead2fdff9 100644 --- a/src/rules/order.js +++ b/src/rules/order.js @@ -513,22 +513,34 @@ function computePathRank(ranks, pathGroups, path, maxPosition) { } } -function computeRank(context, ranks, importEntry, excludedImportTypes) { +function computeRank(context, ranks, importEntry, excludedImportTypes, isSortingTypesGroup) { let impType; let rank; + + const isTypeGroupInGroups = ranks.omittedTypes.indexOf('type') === -1; + const isTypeOnlyImport = importEntry.node.importKind === 'type'; + const isExcludedFromPathRank = isTypeOnlyImport && isTypeGroupInGroups && excludedImportTypes.has('type'); + if (importEntry.type === 'import:object') { impType = 'object'; - } else if (importEntry.node.importKind === 'type' && ranks.omittedTypes.indexOf('type') === -1) { + } else if (isTypeOnlyImport && isTypeGroupInGroups && !isSortingTypesGroup) { impType = 'type'; } else { impType = importType(importEntry.value, context); } - if (!excludedImportTypes.has(impType)) { + + if (!excludedImportTypes.has(impType) && !isExcludedFromPathRank) { rank = computePathRank(ranks.groups, ranks.pathGroups, importEntry.value, ranks.maxPosition); } + if (typeof rank === 'undefined') { rank = ranks.groups[impType]; } + + if (isTypeOnlyImport && isSortingTypesGroup) { + rank = ranks.groups.type + rank / 10; + } + if (importEntry.type !== 'import' && !importEntry.type.startsWith('import:')) { rank += 100; } @@ -536,8 +548,8 @@ function computeRank(context, ranks, importEntry, excludedImportTypes) { return rank; } -function registerNode(context, importEntry, ranks, imported, excludedImportTypes) { - const rank = computeRank(context, ranks, importEntry, excludedImportTypes); +function registerNode(context, importEntry, ranks, imported, excludedImportTypes, isSortingTypesGroup) { + const rank = computeRank(context, ranks, importEntry, excludedImportTypes, isSortingTypesGroup); if (rank !== -1) { imported.push({ ...importEntry, rank }); } @@ -781,6 +793,10 @@ module.exports = { 'never', ], }, + sortTypesGroup: { + type: 'boolean', + default: false, + }, named: { default: false, oneOf: [{ @@ -837,6 +853,7 @@ module.exports = { const options = context.options[0] || {}; const newlinesBetweenImports = options['newlines-between'] || 'ignore'; const pathGroupsExcludedImportTypes = new Set(options.pathGroupsExcludedImportTypes || ['builtin', 'external', 'object']); + const sortTypesGroup = options.sortTypesGroup; const named = { types: 'mixed', @@ -879,6 +896,9 @@ module.exports = { const importMap = new Map(); const exportMap = new Map(); + const isTypeGroupInGroups = ranks.omittedTypes.indexOf('type') === -1; + const isSortingTypesGroup = isTypeGroupInGroups && sortTypesGroup; + function getBlockImports(node) { if (!importMap.has(node)) { importMap.set(node, []); @@ -932,6 +952,7 @@ module.exports = { ranks, getBlockImports(node.parent), pathGroupsExcludedImportTypes, + isSortingTypesGroup, ); if (named.import) { @@ -983,6 +1004,7 @@ module.exports = { ranks, getBlockImports(node.parent), pathGroupsExcludedImportTypes, + isSortingTypesGroup, ); }, CallExpression(node) { @@ -1005,6 +1027,7 @@ module.exports = { ranks, getBlockImports(block), pathGroupsExcludedImportTypes, + isSortingTypesGroup, ); }, ...named.require && { diff --git a/tests/src/rules/order.js b/tests/src/rules/order.js index ea62cec71..6da42474c 100644 --- a/tests/src/rules/order.js +++ b/tests/src/rules/order.js @@ -3285,6 +3285,188 @@ context('TypeScript', function () { }], }), ] : [], + // Option sortTypesGroup: false (default) + test({ + code: ` + import c from 'Bar'; + import a from 'foo'; + + import type { C } from 'dirA/Bar'; + import b from 'dirA/bar'; + import type { D } from 'dirA/bar'; + + import index from './'; + + import type { AA } from 'abc'; + import type { A } from 'foo'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + }, + ], + 'newlines-between': 'always', + pathGroupsExcludedImportTypes: [], + }, + ], + }), + test({ + code: ` + import c from 'Bar'; + import a from 'foo'; + + import type { C } from 'dirA/Bar'; + import b from 'dirA/bar'; + import type { D } from 'dirA/bar'; + + import index from './'; + + import type { AA } from 'abc'; + import type { A } from 'foo'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + }, + ], + 'newlines-between': 'always', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: false, + }, + ], + }), + // Option sortTypesGroup: true and 'type' in pathGroupsExcludedImportTypes + test({ + code: ` + import c from 'Bar'; + import a from 'foo'; + + import b from 'dirA/bar'; + + import index from './'; + + import type { AA } from 'abc'; + import type { C } from 'dirA/Bar'; + import type { D } from 'dirA/bar'; + import type { A } from 'foo'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + }, + ], + 'newlines-between': 'always', + pathGroupsExcludedImportTypes: ['type'], + sortTypesGroup: true, + }, + ], + }), + // Option sortTypesGroup: true and 'type' omitted from groups + test({ + code: ` + import c from 'Bar'; + import type { AA } from 'abc'; + import a from 'foo'; + import type { A } from 'foo'; + + import type { C } from 'dirA/Bar'; + import b from 'dirA/bar'; + import type { D } from 'dirA/bar'; + + import index from './'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + }, + ], + 'newlines-between': 'always', + pathGroupsExcludedImportTypes: [], + // Becomes a no-op without "type" in groups + sortTypesGroup: true, + }, + ], + }), + test({ + code: ` + import c from 'Bar'; + import type { AA } from 'abc'; + import a from 'foo'; + import type { A } from 'foo'; + + import type { C } from 'dirA/Bar'; + import b from 'dirA/bar'; + import type { D } from 'dirA/bar'; + + import index from './'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + }, + ], + 'newlines-between': 'always', + pathGroupsExcludedImportTypes: [], + }, + ], + }), + // Option: sortTypesGroup: true puts type imports in the same order as regular imports (from issue #2441, PR #2615) + test({ + code: ` + import type A from "fs"; + import type B from "path"; + import type C from "../foo.js"; + import type D from "./bar.js"; + import type E from './'; + + import a from "fs"; + import b from "path"; + import c from "../foo.js"; + import d from "./bar.js"; + import e from "./"; + `, + ...parserConfig, + options: [ + { + groups: ['type', 'builtin', 'parent', 'sibling', 'index'], + alphabetize: { + order: 'asc', + caseInsensitive: true, + }, + sortTypesGroup: true, + }, + ], + }), ), invalid: [].concat( // Option alphabetize: {order: 'asc'}