diff --git a/.changeset/eleven-jobs-matter.md b/.changeset/eleven-jobs-matter.md new file mode 100644 index 00000000..599feb7b --- /dev/null +++ b/.changeset/eleven-jobs-matter.md @@ -0,0 +1,10 @@ +--- +"eslint-plugin-import-x": minor +--- + +feat: port all `order` rule new options from upstream + +- [`newlines-between-types`](https://github.com/import-js/eslint-plugin-import/pull/3127) +- [`named`](https://github.com/import-js/eslint-plugin-import/pull/3043) +- [`consolidateIslands`](https://github.com/import-js/eslint-plugin-import/pull/3129) +- [`sortTypesGroup`](https://github.com/import-js/eslint-plugin-import/pull/3104) diff --git a/.remarkrc b/.remarkrc index 3e050958..2ebbd891 100644 --- a/.remarkrc +++ b/.remarkrc @@ -9,6 +9,8 @@ "lint-no-undefined-references", { "allow": [ + "!CAUTION", + "!IMPORTANT", "!NOTE", "!TIP", "!WARNING" diff --git a/docs/rules/order.md b/docs/rules/order.md index 52d2a9dd..12c90fe9 100644 --- a/docs/rules/order.md +++ b/docs/rules/order.md @@ -6,7 +6,7 @@ Enforce a convention in the order of `require()` / `import` statements. -With the [`groups`](#groups-array) option set to `["builtin", "external", "internal", "parent", "sibling", "index", "object", "type"]` the order is as shown in the following example: +With the [`groups`][18] option set to `["builtin", "external", "internal", "parent", "sibling", "index", "object", "type"]` the order is as shown in the following example: ```ts // 1. node "builtin" modules @@ -32,9 +32,7 @@ import log = console.log import type { Foo } from 'foo' ``` -Unassigned imports are ignored, as the order they are imported in may be important. - -Statements using the ES6 `import` syntax must appear before any `require()` statements. +See [here][3] for further details on how imports are grouped. ## Fail @@ -98,153 +96,238 @@ import 'format2.css' // OK ## Options -This rule supports the following options: +This rule supports the following options (none of which are required): -### `groups: [array]` +- [`groups`][18] +- [`pathGroups`][19] +- [`pathGroupsExcludedImportTypes`][15] +- [`distinctGroup`][32] +- [`newlines-between`][24] +- [`alphabetize`][30] +- [`named`][33] +- [`warnOnUnassignedImports`][5] +- [`sortTypesAmongThemselves`][7] +- [`newlines-between-types`][27] +- [`consolidateIslands`][25] -How groups are defined, and the order to respect. `groups` must be an array of `string` or `string[]`. The only allowed `string`s are: -`"builtin"`, `"external"`, `"internal"`, `"unknown"`, `"parent"`, `"sibling"`, `"index"`, `"object"`, `"type"`. -The enforced order is the same as the order of each element in a group. Omitted types are implicitly grouped together as the last element. Example: +--- -```ts -;[ - 'builtin', // Built-in types are first - ['sibling', 'parent'], // Then sibling and parent types. They can be mingled together - 'index', // Then the index file - 'object', - // Then the rest: internal and external type -] +### `groups` + +Valid values: `("builtin" | "external" | "internal" | "unknown" | "parent" | "sibling" | "index" | "object" | "type")[]` \ +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"`, +`"unknown"`, `"parent"`, `"sibling"`, `"index"`, `"object"`, and `"type"`. + +The import order enforced by this rule is the same as the order of each group +in `groups`. Imports belonging to groups omitted from `groups` are lumped +together at the end. + +#### Example + +```jsonc +{ + "import-x/order": [ + "error", + { + "groups": [ + // Imports of builtins are first + "builtin", + // Then sibling and parent imports. They can be mingled together + ["sibling", "parent"], + // Then index file imports + "index", + // Then any arcane TypeScript imports + "object", + // Then the omitted imports: internal, external, type, unknown + ], + }, + ], +} ``` -The default value is `["builtin", "external", "parent", "sibling", "index"]`. +#### How Imports Are Grouped -You can set the options like this: +An import (a `ImportDeclaration`, `TSImportEqualsDeclaration`, or `require()` `CallExpression`) is grouped by its type (`"require"` vs `"import"`), its [specifier][4], and any corresponding identifiers. ```ts -"import-x/order": [ - "error", - { - "groups": [ - "index", - "sibling", - "parent", - "internal", - "external", - "builtin", - "object", - "type" - ] - } -] -``` - -### `pathGroups: [array of objects]` - -To be able to group by paths mostly needed with aliases pathGroups can be defined. - -Properties of the objects - -| property | required | type | description | -| -------------- | :------: | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| pattern | x | string | minimatch pattern for the paths to be in this group (will not be used for builtins or externals) | -| patternOptions | | object | options for minimatch, default: { nocomment: true } | -| group | x | string | one of the allowed groups, the pathGroup will be positioned relative to this group | -| position | | string | defines where around the group the pathGroup will be positioned, can be 'after' or 'before', if not provided pathGroup will be positioned like the group | - -```json +import { identifier1, identifier2 } from 'specifier1' +import type { MyType } from 'specifier2' +const identifier3 = require('specifier3') +``` + +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], `"type"` is in `groups`, and [`sortTypesAmongThemselves`][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 + +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 [`sortTypesAmongThemselves`][7]. + +### `pathGroups` + +Valid values: `PathGroup[]` \ +Default: `[]` + +Sometimes [the predefined groups][18] are not fine-grained +enough, especially when using import aliases. `pathGroups` defines one or more +[`PathGroup`][13]s relative to a predefined group. Imports are associated with a +[`PathGroup`][13] based on path matching against the import specifier (using +[minimatch][14]). + +> \[!IMPORTANT] +> +> Note that, by default, imports grouped as `"builtin"`, `"external"`, or `"object"` will not be considered for further `pathGroups` matching unless they are removed from [`pathGroupsExcludedImportTypes`][15]. + +#### `PathGroup` + +| property | required | type | description | +| :--------------: | :------: | :--------------------: | ------------------------------------------------------------------------------------------------------------------------------- | +| `pattern` | ☑️ | `string` | [Minimatch pattern][16] for specifier matching | +| `patternOptions` | | `object` | [Minimatch options][17]; default: `{nocomment: true}` | +| `group` | ☑️ | [predefined group][18] | One of the [predefined groups][18] to which matching imports will be positioned relatively | +| `position` | | `"after" \| "before"` | Where, in relation to `group`, matching imports will be positioned; default: same position as `group` (neither before or after) | + +#### Example + +```jsonc { "import-x/order": [ "error", { "pathGroups": [ { + // Minimatch pattern used to match against specifiers "pattern": "~/**", - "group": "external" - } - ] - } - ] + // The predefined group this PathGroup is defined in relation to + "group": "external", + // How matching imports will be positioned relative to "group" + "position": "after", + }, + ], + }, + ], } ``` -### `distinctGroup: [boolean]` +### `pathGroupsExcludedImportTypes` -This changes how `pathGroups[].position` affects grouping. The property is most useful when `newlines-between` is set to `always` and at least 1 `pathGroups` entry has a `position` property set. +Valid values: `("builtin" | "external" | "internal" | "unknown" | "parent" | "sibling" | "index" | "object" | "type")[]` \ +Default: `["builtin", "external", "object"]` -By default, in the context of a particular `pathGroup` entry, when setting `position`, a new "group" will silently be created. That is, even if the `group` is specified, a newline will still separate imports that match that `pattern` with the rest of the group (assuming `newlines-between` is `always`). This is undesirable if your intentions are to use `position` to position _within_ the group (and not create a new one). Override this behavior by setting `distinctGroup` to `false`; this will keep imports within the same group as intended. +By default, imports in certain [groups][18] are excluded +from being matched against [`pathGroups`][19] to prevent overeager sorting. Use +`pathGroupsExcludedImportTypes` to modify which groups are excluded. -Note that currently, `distinctGroup` defaults to `true`. However, in a later update, the default will change to `false` +> \[!TIP] +> +> If using imports with custom specifier aliases (e.g. +> you're using `eslint-import-resolver-alias`, `paths` in `tsconfig.json`, etc) that [end up +> grouped][3] as `"builtin"` or `"external"` imports, +> remove them from `pathGroupsExcludedImportTypes` to ensure they are ordered +> correctly. -Example: +#### Example -```json +```jsonc { "import-x/order": [ "error", { - "newlines-between": "always", "pathGroups": [ { "pattern": "@app/**", "group": "external", - "position": "after" - } + "position": "after", + }, ], - "distinctGroup": false - } - ] + "pathGroupsExcludedImportTypes": ["builtin"], + }, + ], } ``` -### `pathGroupsExcludedImportTypes: [array]` +### `distinctGroup` + +Valid values: `boolean` \ +Default: `true` + +> \[!CAUTION] +> +> Currently, `distinctGroup` defaults to `true`. However, in a later update, the +> default will change to `false`. -This defines import types that are not handled by configured pathGroups. +This changes how [`PathGroup.position`][13] affects grouping, and is most useful when [`newlines-between`][20] is set to `always` and at least one [`PathGroup`][13] has a `position` property set. -If you have added path groups with patterns that look like `"builtin"` or `"external"` imports, you have to remove this group (`"builtin"` and/or `"external"`) from the default exclusion list (e.g., `["builtin", "external", "object"]`, etc) to sort these path groups correctly. +When [`newlines-between`][20] is set to `always` and an import matching a specific [`PathGroup.pattern`][13] is encountered, that import is added to a sort of "sub-group" associated with that [`PathGroup`][13]. Thanks to [`newlines-between`][20], imports in this "sub-group" will have a new line separating them from the rest of the imports in [`PathGroup.group`][13]. -Example: +This behavior can be undesirable when using [`PathGroup.position`][13] to order imports _within_ [`PathGroup.group`][13] instead of creating a distinct "sub-group". Set `distinctGroup` to `false` to disable the creation of these "sub-groups". -```json +#### Example + +```jsonc { "import-x/order": [ "error", { + "distinctGroup": false, + "newlines-between": "always", "pathGroups": [ { "pattern": "@app/**", "group": "external", - "position": "after" - } + "position": "after", + }, ], - "pathGroupsExcludedImportTypes": ["builtin"] - } - ] + }, + ], } ``` -[Import Type](https://github.com/un-ts/eslint-plugin-import-x/blob/ea7c13eb9b18357432e484b25dfa4451eca69c5b/src/utils/import-type.ts#L145) is resolved as a fixed string in predefined set, it can't be a `patterns` (e.g., `react`, `react-router-dom`, etc). +### `newlines-between` - +Valid values: `"ignore" | "always" | "always-and-inside-groups" | "never"` \ +Default: `"ignore"` -### `newlines-between: [ignore|always|always-and-inside-groups|never]` +Enforces or forbids new lines between import groups. -Enforces or forbids new lines between import groups: +- If set to `ignore`, no errors related to new lines between import groups will be reported -- If set to `ignore`, no errors related to new lines between import groups will be reported. -- If set to `always`, at least one new line between each group will be enforced, and new lines inside a group will be forbidden. To prevent multiple lines between imports, core `no-multiple-empty-lines` rule can be used. -- If set to `always-and-inside-groups`, it will act like `always` except newlines are allowed inside import groups. -- If set to `never`, no new lines are allowed in the entire import section. +- If set to `always`, at least one new line between each group will be enforced, and new lines inside a group will be forbidden -The default value is `"ignore"`. + > \[!TIP] + > + > To prevent multiple lines between imports, the [`no-multiple-empty-lines` rule][21], or a tool like [Prettier][22], can be used. -With the default group setting, the following will be invalid: +- If set to `always-and-inside-groups`, it will act like `always` except new lines are allowed inside import groups + +- If set to `never`, no new lines are allowed in the entire import section + +#### Example + +With the default [`groups`][18] setting, the following will fail the rule check: ```ts /* eslint import-x/order: ["error", {"newlines-between": "always"}] */ import fs from 'fs' import path from 'path' -import index from './' import sibling from './foo' +import index from './' ``` ```ts @@ -252,8 +335,8 @@ import sibling from './foo' import fs from 'fs' import path from 'path' -import index from './' import sibling from './foo' +import index from './' ``` ```ts @@ -261,21 +344,21 @@ import sibling from './foo' import fs from 'fs' import path from 'path' -import index from './' - import sibling from './foo' + +import index from './' ``` -while those will be valid: +While this will pass: ```ts /* eslint import-x/order: ["error", {"newlines-between": "always"}] */ import fs from 'fs' import path from 'path' -import index from './' - import sibling from './foo' + +import index from './' ``` ```ts @@ -284,42 +367,60 @@ import fs from 'fs' import path from 'path' -import index from './' - import sibling from './foo' + +import index from './' ``` ```ts /* eslint import-x/order: ["error", {"newlines-between": "never"}] */ import fs from 'fs' import path from 'path' -import index from './' import sibling from './foo' +import index from './' ``` - +### `alphabetize` -### `alphabetize: {order: asc|desc|ignore, orderImportKind: asc|desc|ignore, caseInsensitive: true|false}` +Valid values: `{ order?: "asc" | "desc" | "ignore", orderImportKind?: "asc" | "desc" | "ignore", caseInsensitive?: boolean }` \ +Default: `{ order: "ignore", orderImportKind: "ignore", caseInsensitive: false }` -Sort the order within each group in alphabetical manner based on **import path**: +Determine the sort order of imports within each [predefined group][18] or [`PathGroup`][19] alphabetically based on specifier. -- `order`: use `asc` to sort in ascending order, and `desc` to sort in descending order (default: `ignore`). -- `orderImportKind`: use `asc` to sort in ascending order various import kinds, e.g. imports prefixed with `type` or `typeof`, with same import path. Use `desc` to sort in descending order (default: `ignore`). -- `caseInsensitive`: use `true` to ignore case, and `false` to consider case (default: `false`). +> \[!NOTE] +> +> Imports will be alphabetized based on their _specifiers_, not by their +> identifiers. For example, `const a = require('z');` will come _after_ `const z = require('a');` when `alphabetize` is set to `{ order: "asc" }`. -Example setting: +Valid properties and their values include: -```ts -alphabetize: { - order: 'asc', /* sort in ascending order. Options: ['ignore', 'asc', 'desc'] */ - caseInsensitive: true /* ignore case. Options: [true, false] */ +- **`order`**: use `"asc"` to sort in ascending order, `"desc"` to sort in descending order, or "ignore" to prevent sorting + +- **`orderImportKind`**: use `"asc"` to sort various _import kinds_, e.g. [type-only and typeof imports][6], in ascending order, `"desc"` to sort them in descending order, or "ignore" to prevent sorting + +- **`caseInsensitive`**: use `true` to ignore case and `false` to consider case when sorting + +#### Example + +Given the following settings: + +```jsonc +{ + "import-x/order": [ + "error", + { + "alphabetize": { + "order": "asc", + "caseInsensitive": true, + }, + }, + ], } ``` This will fail the rule check: ```ts -/* eslint import-x/order: ["error", {"alphabetize": {"order": "asc", "caseInsensitive": true}}] */ import React, { PureComponent } from 'react' import aTypes from 'prop-types' import { compose, apply } from 'xcompose' @@ -330,7 +431,6 @@ import blist from 'BList' While this will pass: ```ts -/* eslint import-x/order: ["error", {"alphabetize": {"order": "asc", "caseInsensitive": true}}] */ import blist from 'BList' import * as classnames from 'classnames' import aTypes from 'prop-types' @@ -338,19 +438,121 @@ import React, { PureComponent } from 'react' import { compose, apply } from 'xcompose' ``` -### `warnOnUnassignedImports: true|false` +### `named` + +Valid values: `boolean | { enabled: boolean, import?: boolean, export?: boolean, require?: boolean, cjsExports?: boolean, types?: "mixed" | "types-first" | "types-last" }` \ +Default: `false` + +Enforce ordering of names within imports and exports. + +If set to `true` or `{ enabled: true }`, _all_ named imports must be ordered +according to [`alphabetize`][30]. If set to `false` or `{ enabled: +false }`, named imports can occur in any order. + +If set to `{ enabled: true, ... }`, and any of the properties `import`, +`export`, `require`, or `cjsExports` are set to `false`, named ordering is +disabled with respect to the following kind of expressions: + +- `import`: + + ```ts + import { Readline } from 'readline' + ``` + +- `export`: + + ```ts + export { Readline } + // and + export { Readline } from 'readline' + ``` + +- `require`: + + ```ts + const { Readline } = require('readline') + ``` + +- `cjsExports`: + + ```ts + module.exports.Readline = Readline + // and + module.exports = { Readline } + ``` + +Further, the `named.types` option allows you to specify the order of [import identifiers with inline type qualifiers][23] (or "type-only" identifiers/names), e.g. `import { type TypeIdentifier1, normalIdentifier2 } from 'specifier';`. + +`named.types` accepts the following values: + +- `types-first`: forces type-only identifiers to occur first +- `types-last`: forces type-only identifiers to occur last +- `mixed`: sorts all identifiers in alphabetical order + +#### Example + +Given the following settings: + +```jsonc +{ + "import-x/order": [ + "error", + { + "named": true, + "alphabetize": { + "order": "asc", + }, + }, + ], +} +``` + +This will fail the rule check: + +```ts +import { compose, apply } from 'xcompose' +``` + +While this will pass: + +```ts +import { apply, compose } from 'xcompose' +``` + +### `warnOnUnassignedImports` + +Valid values: `boolean` \ +Default: `false` + +Warn when "unassigned" imports are out of order. Unassigned imports are imports +with no corresponding identifiers (e.g. `import './my/thing.js'` or +`require('./side-effects.js')`). + +> \[!NOTE] +> +> These warnings are not fixable with `--fix` since unassigned imports might be +> used for their +> [side-effects][31], +> and changing the order of such imports cannot be done safely. -- default: `false` +#### Example -Warns when unassigned imports are out of order. These warning will not be fixed -with `--fix` because unassigned imports are used for side-effects and changing the -import of order of modules with side effects can not be done automatically in a -way that is safe. +Given the following settings: + +```jsonc +{ + "import-x/order": [ + "error", + { + "warnOnUnassignedImports": true, + }, + ], +} +``` This will fail the rule check: ```ts -/* eslint import-x/order: ["error", {"warnOnUnassignedImports": true}] */ import fs from 'fs' import './styles.css' import path from 'path' @@ -359,17 +561,452 @@ import path from 'path' While this will pass: ```ts -/* eslint import-x/order: ["error", {"warnOnUnassignedImports": true}] */ import fs from 'fs' import path from 'path' import './styles.css' ``` -## Related +### `sortTypesAmongThemselves` + +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-x/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 +`sortTypesAmongThemselves` to `true`: + +```jsonc +{ + "import-x/order": [ + "error", + { + "groups": ["type", "builtin", "parent", "sibling", "index"], + "alphabetize": { "order": "asc" }, + "sortTypesAmongThemselves": true, + }, + ], +} +``` + +The same example will pass. + +### `newlines-between-types` + +Valid values: `"ignore" | "always" | "always-and-inside-groups" | "never"` \ +Default: the value of [`newlines-between`][24] + +> \[!NOTE] +> +> This setting is only meaningful when [`sortTypesAmongThemselves`][7] is enabled. + +`newlines-between-types` is functionally identical to [`newlines-between`][24] +except it only enforces or forbids new lines between _[type-only][6] import +groups_, which exist only when [`sortTypesAmongThemselves`][7] is enabled. + +In addition, when determining if a new line is enforceable or forbidden between +the type-only imports and the normal imports, `newlines-between-types` takes +precedence over [`newlines-between`][24]. + +#### Example + +Given the following settings: -- [`import-x/external-module-folders`] setting +```jsonc +{ + "import-x/order": [ + "error", + { + "groups": ["type", "builtin", "parent", "sibling", "index"], + "sortTypesAmongThemselves": true, + "newlines-between": "always", + }, + ], +} +``` + +This will fail the rule check: + +```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 './' +``` + +However, if we set `newlines-between-types` to `"ignore"`: + +```jsonc +{ + "import-x/order": [ + "error", + { + "groups": ["type", "builtin", "parent", "sibling", "index"], + "sortTypesAmongThemselves": true, + "newlines-between": "always", + "newlines-between-types": "ignore", + }, + ], +} +``` + +The same example will pass. + +Note the new line after `import type E from './';` but before `import a from "fs";`. This new line separates the type-only imports from the normal imports. Its existence is governed by [`newlines-between-types`][27] and _not `newlines-between`_. + +> \[!IMPORTANT] +> +> In certain situations, [`consolidateIslands: true`][25] will take precedence over `newlines-between-types: "never"`, if used, when it comes to the new line separating type-only imports from normal imports. + +The next example will pass even though there's a new line preceding the normal import and [`newlines-between`][24] is set to `"never"`: + +```jsonc +{ + "import-x/order": [ + "error", + { + "groups": ["type", "builtin", "parent", "sibling", "index"], + "sortTypesAmongThemselves": true, + "newlines-between": "never", + "newlines-between-types": "always", + }, + ], +} +``` + +```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 './' +``` + +While the following fails due to the new line between the last type import and the first normal import: + +```jsonc +{ + "import-x/order": [ + "error", + { + "groups": ["type", "builtin", "parent", "sibling", "index"], + "sortTypesAmongThemselves": true, + "newlines-between": "always", + "newlines-between-types": "never", + }, + ], +} +``` + +```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 './' +``` + +### `consolidateIslands` + +Valid values: `"inside-groups" | "never"` \ +Default: `"never"` + +> \[!NOTE] +> +> This setting is only meaningful when [`newlines-between`][24] and/or [`newlines-between-types`][27] is set to `"always-and-inside-groups"`. + +When set to `"inside-groups"`, this ensures imports spanning multiple lines are separated from other imports with a new line while single-line imports are grouped together (and the space between them consolidated) if they belong to the same [group][18] or \[`pathGroups`]\[26]. + +> \[!IMPORTANT] +> +> When all of the following are true: +> +> - `consolidateIslands` is set to `"inside-groups"` +> - [`newlines-between`][24] is set to `"always-and-inside-groups"` +> - [`newlines-between-types`][27] is set to `"never"` +> - [`sortTypesAmongThemselves`][7] is set to `true` +> +> Then [`newlines-between-types`][27] will yield to `consolidateIslands` and allow new lines to separate multi-line imports and a single new line to separate all [type-only imports][6] from all normal imports. Other than that, [`newlines-between-types: "never"`][27] functions as described. +> +> This configuration is useful to keep type-only imports stacked tightly +> together at the bottom of your import block to preserve space while still +> logically organizing normal imports for quick and pleasant reference. + +#### Example -- [`import-x/internal-regex`] setting +Given the following settings: + +```jsonc +{ + "import-x/order": [ + "error", + { + "newlines-between": "always-and-inside-groups", + "consolidateIslands": "inside-groups", + }, + ], +} +``` + +This will fail the rule check: + +```ts +var fs = require('fs') +var path = require('path') +var { util1, util2, util3 } = require('util') +var async = require('async') +var relParent1 = require('../foo') +var { relParent21, relParent22, relParent23, relParent24 } = require('../') +var relParent3 = require('../bar') +var { sibling1, sibling2, sibling3 } = require('./foo') +var sibling2 = require('./bar') +var sibling3 = require('./foobar') +``` + +While this will succeed (and is what `--fix` would yield): + +```ts +var fs = require('fs') +var path = require('path') +var { util1, util2, util3 } = require('util') + +var async = require('async') + +var relParent1 = require('../foo') + +var { relParent21, relParent22, relParent23, relParent24 } = require('../') + +var relParent3 = require('../bar') + +var { sibling1, sibling2, sibling3 } = require('./foo') + +var sibling2 = require('./bar') +var sibling3 = require('./foobar') +``` + +Note the intragroup "islands" of grouped single-line imports, as well as multi-line imports, are surrounded by new lines. At the same time, note the typical new lines separating different groups are still maintained thanks to [`newlines-between`][24]. + +The same holds true for the next example; when given the following settings: + +```jsonc +{ + "import-x/order": [ + "error", + { + "alphabetize": { "order": "asc" }, + "groups": ["external", "internal", "index", "type"], + "pathGroups": [ + { + "pattern": "dirA/**", + "group": "internal", + "position": "after", + }, + { + "pattern": "dirB/**", + "group": "internal", + "position": "before", + }, + { + "pattern": "dirC/**", + "group": "internal", + }, + ], + "newlines-between": "always-and-inside-groups", + "newlines-between-types": "never", + "pathGroupsExcludedImportTypes": [], + "sortTypesAmongThemselves": true, + "consolidateIslands": "inside-groups", + }, + ], +} +``` + +> \[!IMPORTANT] +> +> **Pay special attention to the value of +> [`pathGroupsExcludedImportTypes`](#pathgroupsexcludedimporttypes)** in this +> example's settings. Without it, the successful example below would fail. This is +> because the imports with specifiers starting with "dirA/", "dirB/", and +> "dirC/" are all [considered part of the `"external"` +> group](#how-imports-are-grouped), and imports in that group are +> excluded from [`pathGroups`](#pathgroups) matching by default. +> +> The fix is to remove `"external"` (and, in this example, the others) from +> [`pathGroupsExcludedImportTypes`](#pathgroupsexcludedimporttypes). + +This will fail the rule check: + +```ts +import c from 'Bar' +import d from 'bar' +import { aa, bb, cc, dd, ee, ff, gg } from 'baz' +import { hh, ii, jj, kk, ll, mm, nn } from 'fizz' +import a from 'foo' +import b from 'dirA/bar' +import index from './' +import type { AA, BB, CC } from 'abc' +import type { Z } from 'fizz' +import type { A, B } from 'foo' +import type { C2 } from 'dirB/Bar' +import type { D2, X2, Y2 } from 'dirB/bar' +import type { E2 } from 'dirB/baz' +import type { C3 } from 'dirC/Bar' +import type { D3, X3, Y3 } from 'dirC/bar' +import type { E3 } from 'dirC/baz' +import type { F3 } from 'dirC/caz' +import type { C1 } from 'dirA/Bar' +import type { D1, X1, Y1 } from 'dirA/bar' +import type { E1 } from 'dirA/baz' +import type { F } from './index.js' +import type { G } from './aaa.js' +import type { H } from './bbb' +``` + +While this will succeed (and is what `--fix` would yield): + +```ts +import c from 'Bar' +import d from 'bar' + +import { aa, bb, cc, dd, ee, ff, gg } from 'baz' + +import { hh, ii, jj, kk, ll, mm, nn } from 'fizz' + +import a from 'foo' + +import b from 'dirA/bar' + +import index from './' + +import type { AA, BB, CC } from 'abc' + +import type { Z } from 'fizz' + +import type { A, B } from 'foo' + +import type { C2 } from 'dirB/Bar' + +import type { D2, X2, Y2 } from 'dirB/bar' + +import type { E2 } from 'dirB/baz' +import type { C3 } from 'dirC/Bar' + +import type { D3, X3, Y3 } from 'dirC/bar' + +import type { E3 } from 'dirC/baz' +import type { F3 } from 'dirC/caz' +import type { C1 } from 'dirA/Bar' + +import type { D1, X1, Y1 } from 'dirA/bar' + +import type { E1 } from 'dirA/baz' +import type { F } from './index.js' +import type { G } from './aaa.js' +import type { H } from './bbb' +``` + +## Related -[`import-x/external-module-folders`]: ../../README.md#importexternal-module-folders -[`import-x/internal-regex`]: ../../README.md#importinternal-regex +- [`import-x/external-module-folders`][29] +- [`import-x/internal-regex`][28] +- [`import-x/core-modules`][11] + +[3]: #how-imports-are-grouped +[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]: #sorttypesamongthemselves +[8]: #pathgroups +[9]: #pathgroupsexcludedimporttypes +[10]: https://www.npmjs.com/package/is-core-module +[11]: ../../README.md#importcore-modules +[12]: https://www.npmjs.com/package/package-up +[13]: #pathgroup +[14]: https://www.npmjs.com/package/minimatch +[15]: #pathgroupsexcludedimporttypes +[16]: https://www.npmjs.com/package/minimatch#features +[17]: https://www.npmjs.com/package/minimatch#options +[18]: #groups +[19]: #pathgroups +[20]: #newlines-between +[21]: https://eslint.org/docs/latest/rules/no-multiple-empty-lines +[22]: https://prettier.io +[23]: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-5.html#type-modifiers-on-import-names +[24]: #newlines-between +[25]: #consolidateislands +[27]: #newlines-between-types +[28]: ../../README.md#importinternal-regex +[29]: ../../README.md#importexternal-module-folders +[30]: #alphabetize +[31]: https://webpack.js.org/guides/tree-shaking#mark-the-file-as-side-effect-free +[32]: #distinctgroup +[33]: #named diff --git a/src/rules/order.ts b/src/rules/order.ts index 12d00cf7..916a5c05 100644 --- a/src/rules/order.ts +++ b/src/rules/order.ts @@ -1,18 +1,30 @@ import type { TSESLint, TSESTree } from '@typescript-eslint/utils' -import type { PicomatchOptions } from 'picomatch' +import debug from 'debug' import type { AlphabetizeOptions, Arrayable, + ImportEntry, + ImportEntryType, + ImportEntryWithRank, ImportType, + NamedOptions, + NewLinesOptions, PathGroup, + Ranks, + RanksGroups, + RanksPathGroup, RuleContext, } from '../types' -import { importType, isStaticRequire, createRule, isMatch } from '../utils' +import { + createRule, + getValue, + importType, + isMatch, + isStaticRequire, +} from '../utils' -type ImportEntryWithRank = { - rank: number -} & ImportEntry +const log = debug('eslint-plugin-import-x:rules:order') // This is a **non-spec compliant** but works in practice replacement of `object.groupby` package. const groupBy = ( @@ -25,6 +37,14 @@ const groupBy = ( return acc }, {}) +const categories = { + named: 'named', + import: 'import', + exports: 'exports', +} as const + +type Category = keyof typeof categories + const defaultGroups = [ 'builtin', 'external', @@ -36,11 +56,7 @@ const defaultGroups = [ // REPORTING AND FIXING function reverse(array: ImportEntryWithRank[]): ImportEntryWithRank[] { - return array - .map(function (v) { - return { ...v, rank: -v.rank } - }) - .reverse() + return array.map(v => ({ ...v, rank: -v.rank })).reverse() } function getTokensOrCommentsAfter( @@ -137,7 +153,7 @@ function findRootNode(node: TSESTree.Node) { ) { parent = parent.parent } - return parent as TSESTree.ProgramStatement + return parent } function findEndOfLineWithComments( @@ -199,6 +215,32 @@ function findStartOfLineWithComments( return result } +function findSpecifierStart( + sourceCode: TSESLint.SourceCode, + node: TSESTree.Node, +) { + let token: TSESTree.Token + + do { + token = sourceCode.getTokenBefore(node)! + } while (token.value !== ',' && token.value !== '{') + + return token.range[1] +} + +function findSpecifierEnd( + sourceCode: TSESLint.SourceCode, + node: TSESTree.Node, +) { + let token: TSESTree.Token + + do { + token = sourceCode.getTokenAfter(node)! + } while (token.value !== ',' && token.value !== '}') + + return token.range[0] +} + function isRequireExpression( expr?: TSESTree.Expression | null, ): expr is TSESTree.CallExpression { @@ -259,6 +301,50 @@ function isPlainImportEquals( ) } +function isCJSExports(context: RuleContext, node: TSESTree.Expression) { + if ( + node.type === 'MemberExpression' && + node.object.type === 'Identifier' && + node.property.type === 'Identifier' && + node.object.name === 'module' && + node.property.name === 'exports' + ) { + return !context.sourceCode + .getScope(node) + .variables.some(variable => variable.name === 'module') + } + if (node.type === 'Identifier' && node.name === 'exports') { + return !context.sourceCode + .getScope(node) + .variables.some(variable => variable.name === 'exports') + } +} + +function getNamedCJSExports(context: RuleContext, node: TSESTree.Node) { + if (node.type !== 'MemberExpression') { + return + } + const result: string[] = [] + let root: TSESTree.Expression = node + let parent!: TSESTree.Expression + while (root.type === 'MemberExpression') { + if (root.property.type !== 'Identifier') { + return + } + result.unshift(root.property.name) + parent = root + root = root.object + } + + if (isCJSExports(context, root)) { + return result + } + + if (isCJSExports(context, parent)) { + return result.slice(1) + } +} + function canCrossNodeWhileReorder(node: TSESTree.Node) { return ( isSupportedRequireModule(node) || @@ -267,14 +353,15 @@ function canCrossNodeWhileReorder(node: TSESTree.Node) { ) } -function canReorderItems( - firstNode: TSESTree.ProgramStatement, - secondNode: TSESTree.ProgramStatement, -) { - const parent = firstNode.parent as TSESTree.Program +function canReorderItems(firstNode: TSESTree.Node, secondNode: TSESTree.Node) { + const parent = firstNode.parent + if (!parent || !('body' in parent) || !Array.isArray(parent.body)) { + return false + } + const body: TSESTree.Node[] = parent.body const [firstIndex, secondIndex] = [ - parent.body.indexOf(firstNode), - parent.body.indexOf(secondNode), + body.indexOf(firstNode), + body.indexOf(secondNode), ].sort() const nodesBetween = parent.body.slice(firstIndex, secondIndex + 1) for (const nodeBetween of nodesBetween) { @@ -286,65 +373,163 @@ function canReorderItems( } function makeImportDescription(node: ImportEntry) { - if ('importKind' in node.node) { - if (node.node.importKind === 'type') { - return 'type import' - } - // @ts-expect-error - flow type - if (node.node.importKind === 'typeof') { - return 'typeof import' + if (node.type === 'export') { + if (node.node.exportKind === 'type') { + return 'type export' } + return 'export' + } + if (node.node.importKind === 'type') { + return 'type import' + } + // @ts-expect-error - flow type + if (node.node.importKind === 'typeof') { + return 'typeof import' } return 'import' } function fixOutOfOrder( - context: RuleContext, - firstNode: ImportEntryWithRank, - secondNode: ImportEntryWithRank, + context: RuleContext, + firstNode: ImportEntry, + secondNode: ImportEntry, order: 'before' | 'after', + category: Category, ) { + const isNamed = category === categories.named + const isExports = category === categories.exports const { sourceCode } = context - const firstRoot = findRootNode(firstNode.node) - const firstRootStart = findStartOfLineWithComments(sourceCode, firstRoot) - const firstRootEnd = findEndOfLineWithComments(sourceCode, firstRoot) + const { firstRoot, secondRoot } = isNamed + ? { firstRoot: firstNode.node, secondRoot: secondNode.node } + : { + firstRoot: findRootNode(firstNode.node), + secondRoot: findRootNode(secondNode.node), + } - const secondRoot = findRootNode(secondNode.node) - const secondRootStart = findStartOfLineWithComments(sourceCode, secondRoot) - const secondRootEnd = findEndOfLineWithComments(sourceCode, secondRoot) - const canFix = canReorderItems(firstRoot, secondRoot) + const { firstRootStart, firstRootEnd, secondRootStart, secondRootEnd } = + isNamed + ? { + firstRootStart: findSpecifierStart(sourceCode, firstRoot), + firstRootEnd: findSpecifierEnd(sourceCode, firstRoot), + secondRootStart: findSpecifierStart(sourceCode, secondRoot), + secondRootEnd: findSpecifierEnd(sourceCode, secondRoot), + } + : { + firstRootStart: findStartOfLineWithComments(sourceCode, firstRoot), + firstRootEnd: findEndOfLineWithComments(sourceCode, firstRoot), + secondRootStart: findStartOfLineWithComments(sourceCode, secondRoot), + secondRootEnd: findEndOfLineWithComments(sourceCode, secondRoot), + } - let newCode = sourceCode.text.slice(secondRootStart, secondRootEnd) - if (newCode[newCode.length - 1] !== '\n') { - newCode = `${newCode}\n` + if (firstNode.displayName === secondNode.displayName) { + if (firstNode.alias) { + firstNode.displayName = `${firstNode.displayName} as ${firstNode.alias}` + } + if (secondNode.alias) { + secondNode.displayName = `${secondNode.displayName} as ${secondNode.alias}` + } } - const firstImport = `${makeImportDescription(firstNode)} of \`${firstNode.displayName}\`` - const secondImport = `\`${secondNode.displayName}\` ${makeImportDescription(secondNode)}` + const firstDesc = makeImportDescription(firstNode) + const secondDesc = makeImportDescription(secondNode) - context.report({ - node: secondNode.node, + // FIXME: find out why this happens, upstream doesn't have this check + if ( + firstNode.displayName === secondNode.displayName && + firstDesc === secondDesc + ) { + log( + firstNode.displayName, + firstNode.node.loc, + secondNode.displayName, + secondNode.node.loc, + ) + return + } + + const firstImport = `${firstDesc} of \`${firstNode.displayName}\`` + const secondImport = `\`${secondNode.displayName}\` ${secondDesc}` + + const messageOptions = { messageId: 'order', - data: { - firstImport, - secondImport, - order, - }, - fix: canFix - ? fixer => - order === 'before' - ? fixer.replaceTextRange( + data: { firstImport, secondImport, order }, + } as const + + if (isNamed) { + const firstCode = sourceCode.text.slice(firstRootStart, firstRoot.range[1]) + const firstTrivia = sourceCode.text.slice(firstRoot.range[1], firstRootEnd) + const secondCode = sourceCode.text.slice( + secondRootStart, + secondRoot.range[1], + ) + const secondTrivia = sourceCode.text.slice( + secondRoot.range[1], + secondRootEnd, + ) + + if (order === 'before') { + const trimmedTrivia = secondTrivia.trimEnd() + const gapCode = sourceCode.text.slice(firstRootEnd, secondRootStart - 1) + const whitespaces = secondTrivia.slice(trimmedTrivia.length) + context.report({ + node: secondNode.node, + ...messageOptions, + fix: fixer => + fixer.replaceTextRange( + [firstRootStart, secondRootEnd], + `${secondCode},${trimmedTrivia}${firstCode}${firstTrivia}${gapCode}${whitespaces}`, + ), + }) + } else if (order === 'after') { + const trimmedTrivia = firstTrivia.trimEnd() + const gapCode = sourceCode.text.slice(secondRootEnd + 1, firstRootStart) + const whitespaces = firstTrivia.slice(trimmedTrivia.length) + context.report({ + node: secondNode.node, + ...messageOptions, + fix: fixes => + fixes.replaceTextRange( + [secondRootStart, firstRootEnd], + `${gapCode}${firstCode},${trimmedTrivia}${secondCode}${whitespaces}`, + ), + }) + } + } else { + const canFix = isExports || canReorderItems(firstRoot, secondRoot) + let newCode = sourceCode.text.slice(secondRootStart, secondRootEnd) + + if (newCode[newCode.length - 1] !== '\n') { + newCode = `${newCode}\n` + } + + if (order === 'before') { + context.report({ + node: secondNode.node, + ...messageOptions, + fix: canFix + ? fixer => + fixer.replaceTextRange( [firstRootStart, secondRootEnd], newCode + sourceCode.text.slice(firstRootStart, secondRootStart), ) - : fixer.replaceTextRange( + : null, + }) + } else if (order === 'after') { + context.report({ + node: secondNode.node, + ...messageOptions, + fix: canFix + ? fixer => + fixer.replaceTextRange( [secondRootStart, firstRootEnd], sourceCode.text.slice(secondRootEnd, firstRootEnd) + newCode, ) - : null, - }) + : null, + }) + } + } } function reportOutOfOrder( @@ -352,6 +537,7 @@ function reportOutOfOrder( imported: ImportEntryWithRank[], outOfOrder: ImportEntryWithRank[], order: 'before' | 'after', + category: Category, ) { for (const imp of outOfOrder) { fixOutOfOrder( @@ -359,6 +545,7 @@ function reportOutOfOrder( imported.find(importedItem => importedItem.rank > imp.rank)!, imp, order, + category, ) } } @@ -366,6 +553,7 @@ function reportOutOfOrder( function makeOutOfOrderReport( context: RuleContext, imported: ImportEntryWithRank[], + category: Category, ) { const outOfOrder = findOutOfOrder(imported) if (outOfOrder.length === 0) { @@ -376,10 +564,16 @@ function makeOutOfOrderReport( const reversedImported = reverse(imported) const reversedOrder = findOutOfOrder(reversedImported) if (reversedOrder.length < outOfOrder.length) { - reportOutOfOrder(context, reversedImported, reversedOrder, 'after') + reportOutOfOrder( + context, + reversedImported, + reversedOrder, + 'after', + category, + ) return } - reportOutOfOrder(context, imported, outOfOrder, 'before') + reportOutOfOrder(context, imported, outOfOrder, 'before', category) } const compareString = (a: string, b: string) => { @@ -396,10 +590,12 @@ const compareString = (a: string, b: string) => { const DEFAULT_IMPORT_KIND = 'value' const getNormalizedValue = (node: ImportEntry, toLowerCase?: boolean) => { - const value = node.value - return toLowerCase ? String(value).toLowerCase() : value + const value = String(node.value) + return toLowerCase ? value.toLowerCase() : value } +const RELATIVE_DOTS = new Set(['.', '..']) + function getSorter(alphabetizeOptions: AlphabetizeOptions) { const multiplier = alphabetizeOptions.order === 'asc' ? 1 : -1 const orderImportKind = alphabetizeOptions.orderImportKind @@ -407,7 +603,7 @@ function getSorter(alphabetizeOptions: AlphabetizeOptions) { orderImportKind !== 'ignore' && (alphabetizeOptions.orderImportKind === 'asc' ? 1 : -1) - return (nodeA: ImportEntry, nodeB: ImportEntry) => { + return function importsSorter(nodeA: ImportEntry, nodeB: ImportEntry) { const importA = getNormalizedValue( nodeA, alphabetizeOptions.caseInsensitive, @@ -427,7 +623,17 @@ function getSorter(alphabetizeOptions: AlphabetizeOptions) { const b = B.length for (let i = 0; i < Math.min(a, b); i++) { - result = compareString(A[i], B[i]) + // Skip comparing the first path segment, if they are relative segments for both imports + const x = A[i] + const y = B[i] + if (i === 0 && RELATIVE_DOTS.has(x) && RELATIVE_DOTS.has(y)) { + // If one is sibling and the other parent import, no need to compare at all, since the paths belong in different groups + if (x !== y) { + break + } + continue + } + result = compareString(x, y) if (result) { break } @@ -445,10 +651,8 @@ function getSorter(alphabetizeOptions: AlphabetizeOptions) { result = multiplierImportKind * compareString( - ('importKind' in nodeA.node && nodeA.node.importKind) || - DEFAULT_IMPORT_KIND, - ('importKind' in nodeB.node && nodeB.node.importKind) || - DEFAULT_IMPORT_KIND, + nodeA.node.importKind || DEFAULT_IMPORT_KIND, + nodeB.node.importKind || DEFAULT_IMPORT_KIND, ) } @@ -477,9 +681,8 @@ function mutateRanksToAlphabetize( const alphabetizedRanks = groupRanks.reduce>( (acc, groupRank) => { for (const importedItem of groupedByRanks[groupRank]) { - acc[ - `${importedItem.value}|${'importKind' in importedItem.node ? importedItem.node.importKind : ''}` - ] = Number.parseInt(groupRank, 10) + newRank + acc[`${importedItem.value}|${importedItem.node.importKind}`] = + Number.parseInt(groupRank, 10) + newRank newRank += 1 } return acc @@ -490,77 +693,72 @@ function mutateRanksToAlphabetize( // mutate the original group-rank with alphabetized-rank for (const importedItem of imported) { importedItem.rank = - alphabetizedRanks[ - `${importedItem.value}|${'importKind' in importedItem.node ? importedItem.node.importKind : ''}` - ] + alphabetizedRanks[`${importedItem.value}|${importedItem.node.importKind}`] } } -type Ranks = { - omittedTypes: string[] - groups: Record - pathGroups: Array<{ - pattern: string - patternOptions?: PicomatchOptions - group: string - position?: number - }> - maxPosition: number -} - // DETECTING function computePathRank( - ranks: Ranks['groups'], - pathGroups: Ranks['pathGroups'], + ranks: RanksGroups, + pathGroups: RanksPathGroup[], path: string, maxPosition: number, ) { - for (let i = 0, l = pathGroups.length; i < l; i++) { - const { pattern, patternOptions, group, position = 1 } = pathGroups[i] + for (const { pattern, patternOptions, group, position = 1 } of pathGroups) { if (isMatch(path, pattern, patternOptions)) { return ranks[group] + position / maxPosition } } } -type ImportEntry = { - type: 'import:object' | 'import' | 'require' - node: TSESTree.Node - value: string - displayName: string -} - function computeRank( context: RuleContext, ranks: Ranks, importEntry: ImportEntry, excludedImportTypes: Set, + isSortingTypesGroup?: boolean, ) { let impType: ImportType - let rank + let rank: number | undefined + + const isTypeGroupInGroups = !ranks.omittedTypes.includes('type') + const isTypeOnlyImport = importEntry.node.importKind === 'type' + const isExcludedFromPathRank = + isTypeOnlyImport && isTypeGroupInGroups && excludedImportTypes.has('type') + if (importEntry.type === 'import:object') { impType = 'object' - } else if ( - 'importKind' in importEntry.node && - importEntry.node.importKind === 'type' && - !ranks.omittedTypes.includes('type') - ) { + } else if (isTypeOnlyImport && isTypeGroupInGroups && !isSortingTypesGroup) { impType = 'type' } else { impType = importType(importEntry.value, context) } - if (!excludedImportTypes.has(impType)) { - rank = computePathRank( - ranks.groups, - ranks.pathGroups, - importEntry.value, - ranks.maxPosition, - ) + + if (!excludedImportTypes.has(impType) && !isExcludedFromPathRank) { + rank = + typeof importEntry.value === 'string' + ? computePathRank( + ranks.groups, + ranks.pathGroups, + importEntry.value, + ranks.maxPosition, + ) + : undefined } + if (rank === undefined) { rank = ranks.groups[impType] + + if (rank === undefined) { + return -1 + } + } + + if (isTypeOnlyImport && isSortingTypesGroup) { + rank = ranks.groups.type + rank / 10 } + if ( importEntry.type !== 'import' && !importEntry.type.startsWith('import:') @@ -577,10 +775,30 @@ function registerNode( ranks: Ranks, imported: ImportEntryWithRank[], excludedImportTypes: Set, + isSortingTypesGroup?: boolean, ) { - const rank = computeRank(context, ranks, importEntry, excludedImportTypes) + const rank = computeRank( + context, + ranks, + importEntry, + excludedImportTypes, + isSortingTypesGroup, + ) if (rank !== -1) { - imported.push({ ...importEntry, rank }) + let importNode = importEntry.node + + if ( + importEntry.type === 'require' && + importNode.parent?.parent?.type === 'VariableDeclaration' + ) { + importNode = importNode.parent.parent + } + + imported.push({ + ...importEntry, + rank, + isMultiline: importNode.loc.end.line !== importNode.loc.start.line, + }) } } @@ -589,22 +807,21 @@ function getRequireBlock(node: TSESTree.Node) { // Handle cases like `const baz = require('foo').bar.baz` // and `const foo = require('foo')()` while ( - n.parent && - ((n.parent.type === 'MemberExpression' && n.parent.object === n) || - (n.parent.type === 'CallExpression' && n.parent.callee === n)) + (n.parent?.type === 'MemberExpression' && n.parent.object === n) || + (n.parent?.type === 'CallExpression' && n.parent.callee === n) ) { n = n.parent } if ( n.parent?.type === 'VariableDeclarator' && - n.parent.parent?.type === 'VariableDeclaration' && - n.parent.parent.parent?.type === 'Program' + n.parent.parent.type === 'VariableDeclaration' && + n.parent.parent.parent.type === 'Program' ) { return n.parent.parent.parent } } -const types = [ +const types: ImportType[] = [ 'builtin', 'external', 'internal', @@ -614,7 +831,7 @@ const types = [ 'index', 'object', 'type', -] as const +] // Creates an object with type-rank pairs. // Example: { index: 0, sibling: 1, parent: 1, external: 1, builtin: 2, internal: 2 } @@ -640,9 +857,7 @@ function convertGroupsToRanks(groups: ReadonlyArray>) { {} as Record, ) - const omittedTypes = types.filter(function (type) { - return rankObject[type] === undefined - }) + const omittedTypes = types.filter(type => rankObject[type] === undefined) const ranks = omittedTypes.reduce(function (res, type) { res[type] = groups.length * 2 @@ -730,21 +945,24 @@ function removeNewLineAfterImport( if (/^\s*$/.test(sourceCode.text.slice(rangeToRemove[0], rangeToRemove[1]))) { return (fixer: TSESLint.RuleFixer) => fixer.removeRange(rangeToRemove) } + return } function makeNewlinesBetweenReport( context: RuleContext, imported: ImportEntryWithRank[], - newlinesBetweenImports: Options['newlines-between'], - distinctGroup?: boolean, + newlinesBetweenImports_: NewLinesOptions, + newlinesBetweenTypeOnlyImports_: NewLinesOptions, + distinctGroup: boolean, + isSortingTypesGroup?: boolean, + isConsolidatingSpaceBetweenImports?: boolean, ) { const getNumberOfEmptyLinesBetween = ( - currentImport: ImportEntryWithRank, - previousImport: ImportEntryWithRank, + currentImport: ImportEntry, + previousImport: ImportEntry, ) => { - return context - .getSourceCode() - .lines.slice( + return context.sourceCode.lines + .slice( previousImport.node.loc.end.line, currentImport.node.loc.start.line - 1, ) @@ -761,44 +979,147 @@ function makeNewlinesBetweenReport( currentImport, previousImport, ) + const isStartOfDistinctGroup = getIsStartOfDistinctGroup( currentImport, previousImport, ) - if ( - newlinesBetweenImports === 'always' || - newlinesBetweenImports === 'always-and-inside-groups' - ) { - if ( - currentImport.rank !== previousImport.rank && - emptyLinesBetween === 0 - ) { - if (distinctGroup || (!distinctGroup && isStartOfDistinctGroup)) { + const isTypeOnlyImport = currentImport.node.importKind === 'type' + const isPreviousImportTypeOnlyImport = + previousImport.node.importKind === 'type' + + const isNormalImportNextToTypeOnlyImportAndRelevant = + isTypeOnlyImport !== isPreviousImportTypeOnlyImport && isSortingTypesGroup + + const isTypeOnlyImportAndRelevant = isTypeOnlyImport && isSortingTypesGroup + + // In the special case where newlinesBetweenImports and consolidateIslands + // want the opposite thing, consolidateIslands wins + const newlinesBetweenImports = + isSortingTypesGroup && + isConsolidatingSpaceBetweenImports && + (previousImport.isMultiline || currentImport.isMultiline) && + newlinesBetweenImports_ === 'never' + ? 'always-and-inside-groups' + : newlinesBetweenImports_ + + // In the special case where newlinesBetweenTypeOnlyImports and + // consolidateIslands want the opposite thing, consolidateIslands wins + const newlinesBetweenTypeOnlyImports = + isSortingTypesGroup && + isConsolidatingSpaceBetweenImports && + (isNormalImportNextToTypeOnlyImportAndRelevant || + previousImport.isMultiline || + currentImport.isMultiline) && + newlinesBetweenTypeOnlyImports_ === 'never' + ? 'always-and-inside-groups' + : newlinesBetweenTypeOnlyImports_ + + const isNotIgnored = + (isTypeOnlyImportAndRelevant && + newlinesBetweenTypeOnlyImports !== 'ignore') || + (!isTypeOnlyImportAndRelevant && newlinesBetweenImports !== 'ignore') + + if (isNotIgnored) { + const shouldAssertNewlineBetweenGroups = + ((isTypeOnlyImportAndRelevant || + isNormalImportNextToTypeOnlyImportAndRelevant) && + (newlinesBetweenTypeOnlyImports === 'always' || + newlinesBetweenTypeOnlyImports === 'always-and-inside-groups')) || + (!isTypeOnlyImportAndRelevant && + !isNormalImportNextToTypeOnlyImportAndRelevant && + (newlinesBetweenImports === 'always' || + newlinesBetweenImports === 'always-and-inside-groups')) + + const shouldAssertNoNewlineWithinGroup = + ((isTypeOnlyImportAndRelevant || + isNormalImportNextToTypeOnlyImportAndRelevant) && + newlinesBetweenTypeOnlyImports !== 'always-and-inside-groups') || + (!isTypeOnlyImportAndRelevant && + !isNormalImportNextToTypeOnlyImportAndRelevant && + newlinesBetweenImports !== 'always-and-inside-groups') + + const shouldAssertNoNewlineBetweenGroup = + !isSortingTypesGroup || + !isNormalImportNextToTypeOnlyImportAndRelevant || + newlinesBetweenTypeOnlyImports === 'never' + + const isTheNewlineBetweenImportsInTheSameGroup = + (distinctGroup && currentImport.rank === previousImport.rank) || + (!distinctGroup && !isStartOfDistinctGroup) + + // Let's try to cut down on linting errors sent to the user + let alreadyReported = false + + if (shouldAssertNewlineBetweenGroups) { + if ( + currentImport.rank !== previousImport.rank && + emptyLinesBetween === 0 + ) { + if (distinctGroup || isStartOfDistinctGroup) { + alreadyReported = true + context.report({ + node: previousImport.node, + messageId: 'oneLineBetweenGroups', + fix: fixNewLineAfterImport(context, previousImport), + }) + } + } else if ( + emptyLinesBetween > 0 && + shouldAssertNoNewlineWithinGroup && + isTheNewlineBetweenImportsInTheSameGroup + ) { + alreadyReported = true context.report({ node: previousImport.node, - messageId: 'oneLineBetweenGroups', - fix: fixNewLineAfterImport(context, previousImport), + messageId: 'noLineWithinGroup', + fix: removeNewLineAfterImport( + context, + currentImport, + previousImport, + ), }) } - } else if ( - emptyLinesBetween > 0 && - newlinesBetweenImports !== 'always-and-inside-groups' && - ((distinctGroup && currentImport.rank === previousImport.rank) || - (!distinctGroup && !isStartOfDistinctGroup)) - ) { + } else if (emptyLinesBetween > 0 && shouldAssertNoNewlineBetweenGroup) { + alreadyReported = true context.report({ node: previousImport.node, - messageId: 'noLineWithinGroup', + messageId: 'noLineBetweenGroups', fix: removeNewLineAfterImport(context, currentImport, previousImport), }) } - } else if (emptyLinesBetween > 0) { - context.report({ - node: previousImport.node, - messageId: 'noLineBetweenGroups', - fix: removeNewLineAfterImport(context, currentImport, previousImport), - }) + + if (!alreadyReported && isConsolidatingSpaceBetweenImports) { + if (emptyLinesBetween === 0 && currentImport.isMultiline) { + context.report({ + node: previousImport.node, + messageId: 'oneLineBetweenTheMultiLineImport', + fix: fixNewLineAfterImport(context, previousImport), + }) + } else if (emptyLinesBetween === 0 && previousImport.isMultiline) { + context.report({ + node: previousImport.node, + messageId: 'oneLineBetweenThisMultiLineImport', + fix: fixNewLineAfterImport(context, previousImport), + }) + } else if ( + emptyLinesBetween > 0 && + !previousImport.isMultiline && + !currentImport.isMultiline && + isTheNewlineBetweenImportsInTheSameGroup + ) { + context.report({ + node: previousImport.node, + messageId: 'noLineBetweenSingleLineImport', + fix: removeNewLineAfterImport( + context, + currentImport, + previousImport, + ), + }) + } + } } previousImport = currentImport @@ -817,16 +1138,16 @@ function getAlphabetizeConfig(options: Options): AlphabetizeOptions { const defaultDistinctGroup = true type Options = { - 'newlines-between'?: - | 'always' - | 'always-and-inside-groups' - | 'ignore' - | 'never' + 'newlines-between'?: NewLinesOptions + 'newlines-between-types'?: NewLinesOptions + named?: boolean | NamedOptions alphabetize?: Partial + consolidateIslands?: 'inside-groups' | 'never' distinctGroup?: boolean groups?: ReadonlyArray> pathGroupsExcludedImportTypes?: ImportType[] pathGroups?: PathGroup[] + sortTypesGroup?: boolean warnOnUnassignedImports?: boolean } @@ -836,6 +1157,9 @@ type MessageId = | 'noLineBetweenGroups' | 'oneLineBetweenGroups' | 'order' + | 'oneLineBetweenTheMultiLineImport' + | 'oneLineBetweenThisMultiLineImport' + | 'noLineBetweenSingleLineImport' export = createRule<[Options?], MessageId>({ name: 'order', @@ -873,7 +1197,7 @@ export = createRule<[Options?], MessageId>({ }, group: { type: 'string', - enum: [...types], + enum: types, }, position: { type: 'string', @@ -888,6 +1212,41 @@ export = createRule<[Options?], MessageId>({ type: 'string', enum: ['ignore', 'always', 'always-and-inside-groups', 'never'], }, + 'newlines-between-types': { + type: 'string', + enum: ['ignore', 'always', 'always-and-inside-groups', 'never'], + }, + consolidateIslands: { + type: 'string', + enum: ['inside-groups', 'never'], + }, + sortTypesGroup: { + type: 'boolean', + default: false, + }, + named: { + default: false, + oneOf: [ + { + type: 'boolean', + }, + { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + import: { type: 'boolean' }, + export: { type: 'boolean' }, + require: { type: 'boolean' }, + cjsExports: { type: 'boolean' }, + types: { + type: 'string', + enum: ['mixed', 'types-first', 'types-last'], + }, + }, + additionalProperties: false, + }, + ], + }, alphabetize: { type: 'object', properties: { @@ -914,6 +1273,42 @@ export = createRule<[Options?], MessageId>({ }, }, additionalProperties: false, + dependencies: { + 'newlines-between-types': { + type: 'object', + properties: { + sortTypesGroup: { + type: 'boolean', + enum: [true], + }, + }, + required: ['sortTypesGroup'], + }, + consolidateIslands: { + anyOf: [ + { + type: 'object', + properties: { + 'newlines-between': { + type: 'string', + enum: ['always-and-inside-groups'], + }, + }, + required: ['newlines-between'], + }, + { + type: 'object', + properties: { + 'newlines-between-types': { + type: 'string', + enum: ['always-and-inside-groups'], + }, + }, + required: ['newlines-between-types'], + }, + ], + }, + }, }, ], messages: { @@ -924,19 +1319,63 @@ export = createRule<[Options?], MessageId>({ oneLineBetweenGroups: 'There should be at least one empty line between import groups', order: '{{secondImport}} should occur {{order}} {{firstImport}}', + oneLineBetweenTheMultiLineImport: + 'There should be at least one empty line between this import and the multi-line import that follows it', + oneLineBetweenThisMultiLineImport: + 'There should be at least one empty line between this multi-line import and the import that follows it', + noLineBetweenSingleLineImport: + 'There should be no empty lines between this single-line import and the single-line import that follows it', }, }, defaultOptions: [], create(context) { const options = context.options[0] || {} const newlinesBetweenImports = options['newlines-between'] || 'ignore' - const pathGroupsExcludedImportTypes = new Set( - options.pathGroupsExcludedImportTypes || [ - 'builtin', - 'external', - 'object', - ], + const newlinesBetweenTypeOnlyImports = + options['newlines-between-types'] || newlinesBetweenImports + const pathGroupsExcludedImportTypes = new Set( + options.pathGroupsExcludedImportTypes || + (['builtin', 'external', 'object'] as const), ) + const sortTypesGroup = options.sortTypesGroup + const consolidateIslands = options.consolidateIslands || 'never' + + const named: NamedOptions = { + types: 'mixed', + ...(typeof options.named === 'object' + ? { + ...options.named, + import: + 'import' in options.named + ? options.named.import + : options.named.enabled, + export: + 'export' in options.named + ? options.named.export + : options.named.enabled, + require: + 'require' in options.named + ? options.named.require + : options.named.enabled, + cjsExports: + 'cjsExports' in options.named + ? options.named.cjsExports + : options.named.enabled, + } + : { + import: options.named, + export: options.named, + require: options.named, + cjsExports: options.named, + }), + } + + const namedGroups = + named.types === 'mixed' + ? [] + : named.types === 'types-last' + ? ['value'] + : ['type'] const alphabetize = getAlphabetizeConfig(options) const distinctGroup = options.distinctGroup == null @@ -974,12 +1413,49 @@ export = createRule<[Options?], MessageId>({ } const importMap = new Map() + const exportMap = new Map() + + const isTypeGroupInGroups = !ranks.omittedTypes.includes('type') + const isSortingTypesGroup = isTypeGroupInGroups && sortTypesGroup function getBlockImports(node: TSESTree.Node) { - if (!importMap.has(node)) { - importMap.set(node, []) + let blockImports = importMap.get(node) + if (!blockImports) { + importMap.set(node, (blockImports = [])) + } + return blockImports + } + + function getBlockExports(node: TSESTree.Node) { + let blockExports = exportMap.get(node) + if (!blockExports) { + exportMap.set(node, (blockExports = [])) + } + return blockExports + } + + function makeNamedOrderReport( + context: RuleContext, + namedImports: ImportEntry[], + ) { + if (namedImports.length > 1) { + const imports = namedImports.map(namedImport => { + const kind = namedImport.kind || 'value' + const rank = namedGroups.indexOf(kind) + return { + displayName: namedImport.value, + rank: rank === -1 ? namedGroups.length : rank, + ...namedImport, + value: `${namedImport.value}:${namedImport.alias || ''}`, + } + }) + + if (alphabetize.order !== 'ignore') { + mutateRanksToAlphabetize(imports, alphabetize) + } + + makeOutOfOrderReport(context, imports, categories.named) } - return importMap.get(node)! } return { @@ -996,25 +1472,42 @@ export = createRule<[Options?], MessageId>({ type: 'import', }, ranks, - getBlockImports(node.parent!), + getBlockImports(node.parent), pathGroupsExcludedImportTypes, + isSortingTypesGroup, ) + + if (named.import) { + makeNamedOrderReport( + context, + node.specifiers + .filter(specifier => specifier.type === 'ImportSpecifier') + .map(specifier => ({ + node: specifier, + value: getValue(specifier.imported), + type: 'import', + kind: specifier.importKind, + ...(specifier.local.range[0] !== + specifier.imported.range[0] && { + alias: specifier.local.name, + }), + })), + ) + } } }, TSImportEqualsDeclaration(node) { - let displayName: string - let value: string - let type: 'import:object' | 'import' // @ts-expect-error - legacy parser type // skip "export import"s if (node.isExport) { return } - if ( - node.moduleReference.type === 'TSExternalModuleReference' && - 'value' in node.moduleReference.expression && - typeof node.moduleReference.expression.value === 'string' - ) { + + let displayName: string + let value: string + let type: ImportEntryType + + if (node.moduleReference.type === 'TSExternalModuleReference') { value = node.moduleReference.expression.value displayName = value type = 'import' @@ -1023,6 +1516,7 @@ export = createRule<[Options?], MessageId>({ displayName = context.sourceCode.getText(node.moduleReference) type = 'import:object' } + registerNode( context, { @@ -1032,8 +1526,9 @@ export = createRule<[Options?], MessageId>({ type, }, ranks, - getBlockImports(node.parent!), + getBlockImports(node.parent), pathGroupsExcludedImportTypes, + isSortingTypesGroup, ) }, CallExpression(node) { @@ -1042,35 +1537,141 @@ export = createRule<[Options?], MessageId>({ } const block = getRequireBlock(node) const firstArg = node.arguments[0] - if ( - !block || - !('value' in firstArg) || - typeof firstArg.value !== 'string' - ) { + if (!block || !('value' in firstArg)) { return } - const name = firstArg.value + const { value } = firstArg registerNode( context, { node, - value: name, - displayName: name, + value, + displayName: value, type: 'require', }, ranks, getBlockImports(block), pathGroupsExcludedImportTypes, + isSortingTypesGroup, ) }, + ...(named.require && { + VariableDeclarator(node) { + if ( + node.id.type === 'ObjectPattern' && + isRequireExpression(node.init) + ) { + const { properties } = node.id + for (const p of properties) { + if ( + !('key' in p) || + p.key.type !== 'Identifier' || + p.value.type !== 'Identifier' + ) { + return + } + } + makeNamedOrderReport( + context, + node.id.properties.map(prop_ => { + const prop = prop_ as TSESTree.Property + const key = prop.key as TSESTree.Identifier + const value = prop.value as TSESTree.Identifier + return { + node: prop, + value: key.name, + type: 'require', + ...(key.range[0] !== value.range[0] && { + alias: value.name, + }), + } + }), + ) + } + }, + }), + ...(named.export && { + ExportNamedDeclaration(node) { + makeNamedOrderReport( + context, + node.specifiers.map(specifier => ({ + node: specifier, + value: getValue(specifier.local), + type: 'export', + kind: specifier.exportKind, + ...(specifier.local.range[0] !== specifier.exported.range[0] && { + alias: getValue(specifier.exported), + }), + })), + ) + }, + }), + ...(named.cjsExports && { + AssignmentExpression(node) { + if (node.parent.type === 'ExpressionStatement') { + if (isCJSExports(context, node.left)) { + if (node.right.type === 'ObjectExpression') { + const { properties } = node.right + for (const p of properties) { + if ( + !('key' in p) || + p.key.type !== 'Identifier' || + p.value.type !== 'Identifier' + ) { + return + } + } + + makeNamedOrderReport( + context, + properties.map(prop_ => { + const prop = prop_ as TSESTree.Property + const key = prop.key as TSESTree.Identifier + const value = prop.value as TSESTree.Identifier + return { + node: prop, + value: key.name, + type: 'export', + ...(key.range[0] !== value.range[0] && { + alias: value.name, + }), + } + }), + ) + } + } else { + const nameParts = getNamedCJSExports(context, node.left) + if (nameParts && nameParts.length > 0) { + const name = nameParts.join('.') + getBlockExports(node.parent.parent).push({ + node, + value: name, + displayName: name, + type: 'export', + rank: 0, + }) + } + } + } + }, + }), 'Program:exit'() { for (const imported of importMap.values()) { - if (newlinesBetweenImports !== 'ignore') { + if ( + newlinesBetweenImports !== 'ignore' || + newlinesBetweenTypeOnlyImports !== 'ignore' + ) { makeNewlinesBetweenReport( context, imported, newlinesBetweenImports, + newlinesBetweenTypeOnlyImports, distinctGroup, + isSortingTypesGroup, + consolidateIslands === 'inside-groups' && + (newlinesBetweenImports === 'always-and-inside-groups' || + newlinesBetweenTypeOnlyImports === + 'always-and-inside-groups'), ) } @@ -1078,10 +1679,18 @@ export = createRule<[Options?], MessageId>({ mutateRanksToAlphabetize(imported, alphabetize) } - makeOutOfOrderReport(context, imported) + makeOutOfOrderReport(context, imported, categories.import) + } + + for (const exported of exportMap.values()) { + if (alphabetize.order !== 'ignore') { + mutateRanksToAlphabetize(exported, alphabetize) + makeOutOfOrderReport(context, exported, categories.exports) + } } importMap.clear() + exportMap.clear() }, } }, diff --git a/src/types.ts b/src/types.ts index 4930ae86..1a84ccb0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -171,8 +171,70 @@ export type PathGroup = { position?: 'before' | 'after' } +export type ExportAndImportKind = 'value' | 'type' + +export type NewLinesOptions = + | 'always' + | 'always-and-inside-groups' + | 'ignore' + | 'never' + +export type NamedTypes = 'mixed' | 'types-first' | 'types-last' + +export type NamedOptions = { + enabled?: boolean + import?: boolean + export?: boolean + require?: boolean + cjsExports?: boolean + types?: NamedTypes +} + export type AlphabetizeOptions = { caseInsensitive: boolean order: 'ignore' | 'asc' | 'desc' orderImportKind: 'ignore' | 'asc' | 'desc' } + +export type ImportEntryType = 'import:object' | 'import' | 'require' | 'export' + +export type LiteralNodeValue = + | string + | number + | bigint + | boolean + | RegExp + | null + +export type ImportEntry = { + type: ImportEntryType + node: TSESTree.Node & { + importKind?: ExportAndImportKind + exportKind?: ExportAndImportKind + } + value: LiteralNodeValue + alias?: string + kind?: ExportAndImportKind + displayName?: LiteralNodeValue +} + +export type ImportEntryWithRank = { + rank: number + isMultiline?: boolean +} & ImportEntry + +export type RanksPathGroup = { + pattern: string + patternOptions?: PicomatchOptions + group: string + position?: number +} + +export type RanksGroups = Record + +export type Ranks = { + omittedTypes: string[] + groups: RanksGroups + pathGroups: RanksPathGroup[] + maxPosition: number +} diff --git a/src/utils/import-type.ts b/src/utils/import-type.ts index 0c791a1b..71e52082 100644 --- a/src/utils/import-type.ts +++ b/src/utils/import-type.ts @@ -1,7 +1,7 @@ import { isBuiltin } from 'node:module' import path from 'node:path' -import type { PluginSettings, RuleContext } from '../types' +import type { LiteralNodeValue, PluginSettings, RuleContext } from '../types' import { getContextPackagePath } from './package-path' import { resolve } from './resolve' @@ -20,7 +20,7 @@ function isInternalRegexMatch(name: string, settings: PluginSettings) { return internalScope && new RegExp(internalScope).test(name) } -export function isAbsolute(name?: string | boolean | number | null) { +export function isAbsolute(name?: LiteralNodeValue) { return typeof name === 'string' && path.isAbsolute(name) } @@ -142,25 +142,31 @@ function isExternalLookingName(name: string) { return isModule(name) || isScoped(name) } -function typeTest(name: string, context: RuleContext, path?: string | null) { +function typeTest( + name: LiteralNodeValue, + context: RuleContext, + path?: string | null, +) { const { settings } = context - if (isInternalRegexMatch(name, settings)) { - return 'internal' - } - if (isAbsolute(name)) { - return 'absolute' - } - if (isBuiltIn(name, settings, path)) { - return 'builtin' - } - if (isRelativeToParent(name)) { - return 'parent' - } - if (isIndex(name)) { - return 'index' - } - if (isRelativeToSibling(name)) { - return 'sibling' + if (typeof name === 'string') { + if (isInternalRegexMatch(name, settings)) { + return 'internal' + } + if (isAbsolute(name)) { + return 'absolute' + } + if (isBuiltIn(name, settings, path)) { + return 'builtin' + } + if (isRelativeToParent(name)) { + return 'parent' + } + if (isIndex(name)) { + return 'index' + } + if (isRelativeToSibling(name)) { + return 'sibling' + } } if (isExternalPath(path, context)) { return 'external' @@ -168,14 +174,18 @@ function typeTest(name: string, context: RuleContext, path?: string | null) { if (isInternalPath(path, context)) { return 'internal' } - if (isExternalLookingName(name)) { + if (typeof name === 'string' && isExternalLookingName(name)) { return 'external' } return 'unknown' } -export function importType(name: string, context: RuleContext) { - return typeTest(name, context, resolve(name, context)) +export function importType(name: LiteralNodeValue, context: RuleContext) { + return typeTest( + name, + context, + typeof name === 'string' ? resolve(name, context) : null, + ) } export type ImportType = ReturnType diff --git a/test/fixtures/common-module.js b/test/fixtures/common-module.js index 98d02610..ba4bf4fe 100644 --- a/test/fixtures/common-module.js +++ b/test/fixtures/common-module.js @@ -1,7 +1,7 @@ module.exports = { a: 1, b: 2, - c: function () { + c() { return 3 }, } diff --git a/test/fixtures/foo-bar-resolver-v3.js b/test/fixtures/foo-bar-resolver-v3.js index 8b1ff408..c4e6adea 100644 --- a/test/fixtures/foo-bar-resolver-v3.js +++ b/test/fixtures/foo-bar-resolver-v3.js @@ -4,7 +4,7 @@ exports.foobarResolver = /** @type {import('eslint-plugin-import-x/types').NewResolver} */ { name: 'resolver-foo-bar', interfaceVersion: 3, - resolve: function (modulePath, sourceFile) { + resolve(modulePath, sourceFile) { var sourceFileName = path.basename(sourceFile) if (sourceFileName === 'foo.js') { return { found: true, path: path.join(__dirname, 'bar.jsx') } diff --git a/test/rules/order.spec.ts b/test/rules/order.spec.ts index 37e72c3c..755eb2bd 100644 --- a/test/rules/order.spec.ts +++ b/test/rules/order.spec.ts @@ -1192,6 +1192,159 @@ ruleTester.run('order', rule, { }, ], }), + // named import order + tValid({ + code: ` + import { a, B as C, Z } from './Z'; + const { D, n: c, Y } = require('./Z'); + export { C, D }; + export { A, B, C as default } from "./Z"; + const { ["ignore require-statements with non-identifier imports"]: z, d } = require("./Z"); + exports = { ["ignore exports statements with non-identifiers"]: Z, D }; + `, + options: [ + { + named: true, + alphabetize: { order: 'asc', caseInsensitive: true }, + }, + ], + }), + tValid({ + code: ` + const { b, A } = require('./Z'); + `, + options: [ + { + named: true, + alphabetize: { order: 'desc' }, + }, + ], + }), + tValid({ + code: ` + import { A, B } from "./Z"; + export { Z, A } from "./Z"; + export { N, P } from "./Z"; + const { X, Y } = require("./Z"); + `, + options: [ + { + named: { + require: true, + import: true, + export: false, + }, + }, + ], + }), + tValid({ + code: ` + import { B, A } from "./Z"; + const { D, C } = require("./Z"); + export { B, A } from "./Z"; + `, + options: [ + { + named: { + require: false, + import: false, + export: false, + }, + }, + ], + }), + tValid({ + code: ` + import { B, A, R } from "foo"; + const { D, O, G } = require("tunes"); + export { B, A, Z } from "foo"; + `, + options: [ + { + named: { enabled: false }, + }, + ], + }), + tValid({ + code: ` + import { A as A, A as B, A as C } from "./Z"; + const { a, a: b, a: c } = require("./Z"); + `, + options: [ + { + named: true, + }, + ], + }), + tValid({ + code: ` + import { A, B, C } from "./Z"; + exports = { A, B, C }; + module.exports = { a: A, b: B, c: C }; + `, + options: [ + { + named: { + cjsExports: true, + }, + alphabetize: { order: 'asc' }, + }, + ], + }), + tValid({ + code: ` + module.exports.A = { }; + module.exports.A.B = { }; + module.exports.B = { }; + exports.C = { }; + `, + options: [ + { + named: { + cjsExports: true, + }, + alphabetize: { order: 'asc' }, + }, + ], + }), + // ensure other assignments are untouched + tValid({ + code: ` + var exports = null; + var module = null; + exports = { }; + module = { }; + module.exports = { }; + module.exports.U = { }; + module.exports.N = { }; + module.exports.C = { }; + exports.L = { }; + exports.E = { }; + `, + options: [ + { + named: { + cjsExports: true, + }, + alphabetize: { order: 'asc' }, + }, + ], + }), + tValid({ + code: ` + exports["B"] = { }; + exports["C"] = { }; + exports["A"] = { }; + `, + options: [ + { + named: { + cjsExports: true, + }, + alphabetize: { order: 'asc' }, + }, + ], + }), ], invalid: [ // builtin before external module (require) @@ -2789,11 +2942,211 @@ ruleTester.run('order', rule, { createOrderError(['`./hello` import', 'before', 'import of `./cello`']), ], }), + // named import order + tInvalid({ + code: ` + var { B, A: R } = require("./Z"); + import { O as G, D } from "./Z"; + import { K, L, J } from "./Z"; + export { Z, X, Y } from "./Z"; + `, + output: ` + var { A: R, B } = require("./Z"); + import { D, O as G } from "./Z"; + import { J, K, L } from "./Z"; + export { X, Y, Z } from "./Z"; + `, + options: [ + { + named: true, + alphabetize: { order: 'asc' }, + }, + ], + errors: [ + createOrderError(['`A` import', 'before', 'import of `B`']), + createOrderError(['`D` import', 'before', 'import of `O`']), + createOrderError(['`J` import', 'before', 'import of `K`']), + createOrderError(['`Z` export', 'after', 'export of `Y`']), + ], + }), + tInvalid({ + code: ` + import { D, C } from "./Z"; + var { B, A } = require("./Z"); + export { B, A }; + `, + output: ` + import { C, D } from "./Z"; + var { B, A } = require("./Z"); + export { A, B }; + `, + options: [ + { + named: { + require: false, + import: true, + export: true, + }, + alphabetize: { order: 'asc' }, + }, + ], + errors: [ + createOrderError(['`C` import', 'before', 'import of `D`']), + createOrderError(['`A` export', 'before', 'export of `B`']), + ], + }), + tInvalid({ + code: ` + import { A as B, A as C, A } from "./Z"; + export { A, A as D, A as B, A as C } from "./Z"; + const { a: b, a: c, a } = require("./Z"); + `, + output: ` + import { A, A as B, A as C } from "./Z"; + export { A, A as B, A as C, A as D } from "./Z"; + const { a, a: b, a: c } = require("./Z"); + `, + options: [ + { + named: true, + alphabetize: { order: 'asc' }, + }, + ], + errors: [ + createOrderError(['`A` import', 'before', 'import of `A as B`']), + createOrderError(['`A as D` export', 'after', 'export of `A as C`']), + createOrderError(['`a` import', 'before', 'import of `a as b`']), + ], + }), + tInvalid({ + code: ` + import { A, B, C } from "./Z"; + exports = { B, C, A }; + module.exports = { c: C, a: A, b: B }; + `, + output: ` + import { A, B, C } from "./Z"; + exports = { A, B, C }; + module.exports = { a: A, b: B, c: C }; + `, + options: [ + { + named: { + cjsExports: true, + }, + alphabetize: { order: 'asc' }, + }, + ], + errors: [ + createOrderError(['`A` export', 'before', 'export of `B`']), + createOrderError(['`c` export', 'after', 'export of `b`']), + ], + }), + tInvalid({ + code: ` + exports.B = { }; + module.exports.A = { }; + module.exports.C = { }; + `, + output: ` + module.exports.A = { }; + exports.B = { }; + module.exports.C = { }; + `, + options: [ + { + named: { + cjsExports: true, + }, + alphabetize: { order: 'asc' }, + }, + ], + errors: [createOrderError(['`A` export', 'before', 'export of `B`'])], + }), + tInvalid({ + code: ` + exports.A.C = { }; + module.exports.A.A = { }; + exports.A.B = { }; + `, + output: ` + module.exports.A.A = { }; + exports.A.B = { }; + exports.A.C = { }; + `, + options: [ + { + named: { + cjsExports: true, + }, + alphabetize: { order: 'asc' }, + }, + ], + errors: [createOrderError(['`A.C` export', 'after', 'export of `A.B`'])], + }), + // multi-line named specifiers & trailing commas + tInvalid({ + code: ` + const { + F: O, + O: B, + /* Hello World */ + A: R + } = require("./Z"); + import { + Y, + X, + } from "./Z"; + export { + Z, A, + B + } from "./Z"; + module.exports = { + a: A, o: O, + b: B + }; + `, + output: ` + const { + /* Hello World */ + A: R, + F: O, + O: B + } = require("./Z"); + import { + X, + Y, + } from "./Z"; + export { A, + B, + Z + } from "./Z"; + module.exports = { + a: A, + b: B, o: O + }; + `, + options: [ + { + named: { + enabled: true, + }, + alphabetize: { order: 'asc' }, + }, + ], + errors: [ + createOrderError(['`A` import', 'before', 'import of `F`']), + createOrderError(['`X` import', 'before', 'import of `Y`']), + createOrderError(['`Z` export', 'after', 'export of `B`']), + createOrderError(['`b` export', 'before', 'export of `o`']), + ], + }), ], }) describe('TypeScript', () => { for (const parser of getNonDefaultParsers()) { + const supportsExportTypeSpecifiers = parser === parsers.TS const parserConfig = { languageOptions: { ...(parser === parsers.BABEL && { parser: require(parsers.BABEL) }), @@ -3047,72 +3400,1477 @@ describe('TypeScript', () => { }, ], }), - ], - invalid: [ - // Option alphabetize: {order: 'asc'} - tInvalid({ + // Option sortTypesGroup: false (default) + tValid({ code: ` - import b from 'bar'; - import c from 'Bar'; - import type { C } from 'Bar'; - import a from 'foo'; - import type { A } from 'foo'; + import c from 'Bar'; + import a from 'foo'; - import index from './'; - `, - output: ` - import c from 'Bar'; - import type { C } from 'Bar'; - import b from 'bar'; - 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 './'; - `, + import index from './'; + + import type { AA } from 'abc'; + import type { A } from 'foo'; + `, ...parserConfig, options: [ { - groups: ['external', 'index'], alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + }, + ], + 'newlines-between': 'always', + pathGroupsExcludedImportTypes: [], }, ], - errors: [ - createOrderError(['`bar` import', 'after', 'type import of `Bar`']), - ], }), - // Option alphabetize: {order: 'desc'} - tInvalid({ + tValid({ code: ` - import a from 'foo'; - import type { A } from 'foo'; - import c from 'Bar'; - import type { C } from 'Bar'; - import b from 'bar'; + import c from 'Bar'; + import a from 'foo'; - import index from './'; - `, - output: ` - import a from 'foo'; - import type { A } from 'foo'; - import b from 'bar'; - import c from 'Bar'; - import type { C } from 'Bar'; + import type { C } from 'dirA/Bar'; + import b from 'dirA/bar'; + import type { D } from 'dirA/bar'; - import index from './'; - `, + import index from './'; + + import type { AA } from 'abc'; + import type { A } from 'foo'; + `, ...parserConfig, options: [ { - groups: ['external', 'index'], - alphabetize: { order: 'desc' }, + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + }, + ], + 'newlines-between': 'always', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: false, }, ], - errors: [ - createOrderError(['`bar` import', 'before', 'import of `Bar`']), - ], }), - // Option alphabetize: {order: 'asc'} with type group - tInvalid({ + // Option sortTypesGroup: true and 'type' in pathGroupsExcludedImportTypes + tValid({ + 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 + tValid({ + 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, + }, + ], + }), + tValid({ + 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 and newlines-between-types defaults to the value of newlines-between + tValid({ + code: ` + import c from 'Bar'; + import a from 'foo'; + + import b from 'dirA/bar'; + + import index from './'; + + import type { AA } from 'abc'; + import type { A } from 'foo'; + + import type { C } from 'dirA/Bar'; + import type { D } from 'dirA/bar'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + }, + ], + 'newlines-between': 'always', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + }, + ], + }), + // Option: sortTypesGroup: true and newlines-between-types: 'always' (takes precedence over newlines-between between type-only and normal imports) + tValid({ + code: ` + import c from 'Bar'; + import a from 'foo'; + import b from 'dirA/bar'; + import index from './'; + + import type { AA } from 'abc'; + import type { A } from 'foo'; + + import type { C } from 'dirA/Bar'; + import type { D } from 'dirA/bar'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + }, + ], + 'newlines-between': 'never', + 'newlines-between-types': 'always', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + }, + ], + }), + // Option: sortTypesGroup: true and newlines-between-types: 'never' (takes precedence over newlines-between between type-only and normal imports) + tValid({ + code: ` + import c from 'Bar'; + import a from 'foo'; + + import b from 'dirA/bar'; + + import index from './'; + import type { AA } from 'abc'; + import type { A } from 'foo'; + import type { C } from 'dirA/Bar'; + import type { D } from 'dirA/bar'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + }, + ], + 'newlines-between': 'always', + 'newlines-between-types': 'never', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + }, + ], + }), + // Option: sortTypesGroup: true and newlines-between-types: 'ignore' + tValid({ + code: ` + import c from 'Bar'; + import a from 'foo'; + import b from 'dirA/bar'; + import index from './'; + import type { AA } from 'abc'; + + import type { A } from 'foo'; + import type { C } from 'dirA/Bar'; + import type { D } from 'dirA/bar'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + }, + ], + 'newlines-between': 'never', + 'newlines-between-types': 'ignore', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + }, + ], + }), + // Option: sortTypesGroup: true and newlines-between-types: 'always-and-inside-groups' + tValid({ + code: ` + import c from 'Bar'; + import a from 'foo'; + import b from 'dirA/bar'; + import index from './'; + + import type { AA } from 'abc'; + + import type { A } from 'foo'; + + import type { C } from 'dirA/Bar'; + + import type { D } from 'dirA/bar'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + }, + ], + 'newlines-between': 'never', + 'newlines-between-types': 'always-and-inside-groups', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + }, + ], + }), + // Option: sortTypesGroup: true puts type imports in the same order as regular imports (from issue #2441, PR #2615) + tValid({ + 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, + }, + ], + }), + // Options: sortTypesGroup + newlines-between-types example #1 from the documentation (pass) + tValid({ + 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'], + sortTypesGroup: true, + 'newlines-between': 'always', + 'newlines-between-types': 'ignore', + }, + ], + }), + // Options: sortTypesGroup + newlines-between-types example #2 from the documentation (pass) + tValid({ + 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'], + sortTypesGroup: true, + 'newlines-between': 'never', + 'newlines-between-types': 'always', + }, + ], + }), + // Ensure the rule doesn't choke and die on absolute paths trying to pass NaN around + tValid({ + code: ` + import fs from 'fs'; + + import '@scoped/package'; + import type { B } from 'fs'; + + import type { A1 } from '/bad/bad/bad/bad'; + import './a/b/c'; + import type { A2 } from '/bad/bad/bad/bad'; + import type { A3 } from '/bad/bad/bad/bad'; + import type { D1 } from '/bad/bad/not/good'; + import type { D2 } from '/bad/bad/not/good'; + import type { D3 } from '/bad/bad/not/good'; + + import type { C } from '@something/else'; + + import type { E } from './index.js'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['builtin', 'type', 'unknown', 'external'], + sortTypesGroup: true, + 'newlines-between': 'always', + }, + ], + }), + // Ensure the rule doesn't choke and die when right-hand-side AssignmentExpression properties lack a "key" attribute (e.g. SpreadElement) + tValid({ + code: ` + // https://prettier.io/docs/en/options.html + + module.exports = { + ...require('@xxxx/.prettierrc.js'), + }; + `, + ...parserConfig, + options: [{ named: { enabled: true } }], + }), + // Option: sortTypesGroup: true and newlines-between-types: 'always-and-inside-groups' and consolidateIslands: 'inside-groups' + tValid({ + code: ` + import c from 'Bar'; + import d from 'bar'; + + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; + + import a from 'foo'; + + import b from 'dirA/bar'; + + import index from './'; + + import type { AA, + BB, CC } from 'abc'; + + import type { Z } from 'fizz'; + + import type { + A, + B + } from 'foo'; + + import type { C2 } from 'dirB/Bar'; + + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + + import type { E2 } from 'dirB/baz'; + + import type { C3 } from 'dirC/Bar'; + + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + + import type { E3 } from 'dirC/baz'; + import type { F3 } from 'dirC/caz'; + + import type { C1 } from 'dirA/Bar'; + + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + + import type { E1 } from 'dirA/baz'; + + import type { F } from './index.js'; + + import type { G } from './aaa.js'; + import type { H } from './bbb'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + position: 'after', + }, + { + pattern: 'dirB/**', + group: 'internal', + position: 'before', + }, + { + pattern: 'dirC/**', + group: 'internal', + }, + ], + 'newlines-between': 'always-and-inside-groups', + 'newlines-between-types': 'always-and-inside-groups', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + consolidateIslands: 'inside-groups', + }, + ], + }), + // Option: sortTypesGroup: true and newlines-between-types: 'always-and-inside-groups' and consolidateIslands: 'never' (default) + tValid({ + code: ` + import c from 'Bar'; + import d from 'bar'; + + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; + + import a from 'foo'; + + import b from 'dirA/bar'; + + import index from './'; + + import type { AA, + BB, CC } from 'abc'; + + import type { Z } from 'fizz'; + + import type { + A, + B + } from 'foo'; + + import type { C2 } from 'dirB/Bar'; + + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + + import type { E2 } from 'dirB/baz'; + + import type { C3 } from 'dirC/Bar'; + + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + + import type { E3 } from 'dirC/baz'; + import type { F3 } from 'dirC/caz'; + + import type { C1 } from 'dirA/Bar'; + + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + + import type { E1 } from 'dirA/baz'; + + import type { F } from './index.js'; + + import type { G } from './aaa.js'; + import type { H } from './bbb'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + position: 'after', + }, + { + pattern: 'dirB/**', + group: 'internal', + position: 'before', + }, + { + pattern: 'dirC/**', + group: 'internal', + }, + ], + 'newlines-between': 'always-and-inside-groups', + 'newlines-between-types': 'always-and-inside-groups', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + consolidateIslands: 'never', + }, + ], + }), + // Ensure consolidateIslands: 'inside-groups', newlines-between: 'always-and-inside-groups', and newlines-between-types: 'never' do not fight for dominance + tValid({ + code: ` + import makeVanillaYargs from 'yargs/yargs'; + + import { createDebugLogger } from 'multiverse+rejoinder'; + + import { globalDebuggerNamespace } from 'rootverse+bfe:src/constant.ts'; + import { ErrorMessage, type KeyValueEntry } from 'rootverse+bfe:src/error.ts'; + + import { + $artificiallyInvoked, + $canonical, + $exists, + $genesis + } from 'rootverse+bfe:src/symbols.ts'; + + import type { + Entries, + LiteralUnion, + OmitIndexSignature, + Promisable, + StringKeyOf + } from 'type-fest'; + `, + ...parserConfig, + options: [ + { + alphabetize: { + order: 'asc', + orderImportKind: 'asc', + caseInsensitive: true, + }, + named: { + enabled: true, + types: 'types-last', + }, + groups: [ + 'builtin', + 'external', + 'internal', + ['parent', 'sibling', 'index'], + ['object', 'type'], + ], + pathGroups: [ + { + pattern: 'multiverse{*,*/**}', + group: 'external', + position: 'after', + }, + { + pattern: 'rootverse{*,*/**}', + group: 'external', + position: 'after', + }, + { + pattern: 'universe{*,*/**}', + group: 'external', + position: 'after', + }, + ], + distinctGroup: true, + pathGroupsExcludedImportTypes: ['builtin', 'object'], + 'newlines-between': 'always-and-inside-groups', + 'newlines-between-types': 'never', + sortTypesGroup: true, + consolidateIslands: 'inside-groups', + }, + ], + }), + // Ensure consolidateIslands: 'inside-groups', newlines-between: 'never', and newlines-between-types: 'always-and-inside-groups' do not fight for dominance + tValid({ + code: ` + import makeVanillaYargs from 'yargs/yargs'; + import { createDebugLogger } from 'multiverse+rejoinder'; + import { globalDebuggerNamespace } from 'rootverse+bfe:src/constant.ts'; + import { ErrorMessage, type KeyValueEntry } from 'rootverse+bfe:src/error.ts'; + import { $artificiallyInvoked } from 'rootverse+bfe:src/symbols.ts'; + + import type { + Entries, + LiteralUnion, + OmitIndexSignature, + Promisable, + StringKeyOf + } from 'type-fest'; + `, + ...parserConfig, + options: [ + { + alphabetize: { + order: 'asc', + orderImportKind: 'asc', + caseInsensitive: true, + }, + named: { + enabled: true, + types: 'types-last', + }, + groups: [ + 'builtin', + 'external', + 'internal', + ['parent', 'sibling', 'index'], + ['object', 'type'], + ], + pathGroups: [ + { + pattern: 'multiverse{*,*/**}', + group: 'external', + position: 'after', + }, + { + pattern: 'rootverse{*,*/**}', + group: 'external', + position: 'after', + }, + { + pattern: 'universe{*,*/**}', + group: 'external', + position: 'after', + }, + ], + distinctGroup: true, + pathGroupsExcludedImportTypes: ['builtin', 'object'], + 'newlines-between': 'never', + 'newlines-between-types': 'always-and-inside-groups', + sortTypesGroup: true, + consolidateIslands: 'inside-groups', + }, + ], + }), + tValid({ + code: ` + import c from 'Bar'; + import d from 'bar'; + + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; + + import a from 'foo'; + import b from 'dirA/bar'; + import index from './'; + + import type { AA, + BB, CC } from 'abc'; + + import type { Z } from 'fizz'; + + import type { + A, + B + } from 'foo'; + + import type { C2 } from 'dirB/Bar'; + + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + + import type { E2 } from 'dirB/baz'; + + import type { C3 } from 'dirC/Bar'; + + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + + import type { E3 } from 'dirC/baz'; + import type { F3 } from 'dirC/caz'; + + import type { C1 } from 'dirA/Bar'; + + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + + import type { E1 } from 'dirA/baz'; + + import type { F } from './index.js'; + + import type { G } from './aaa.js'; + import type { H } from './bbb'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + position: 'after', + }, + { + pattern: 'dirB/**', + group: 'internal', + position: 'before', + }, + { + pattern: 'dirC/**', + group: 'internal', + }, + ], + 'newlines-between': 'never', + 'newlines-between-types': 'always-and-inside-groups', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + consolidateIslands: 'inside-groups', + }, + ], + }), + tValid({ + code: ` + import assert from 'assert'; + import { isNativeError } from 'util/types'; + + import { runNoRejectOnBadExit } from '@-xun/run'; + import { TrialError } from 'named-app-errors'; + import { resolve as resolverLibrary } from 'resolve.exports'; + + import { toAbsolutePath, type AbsolutePath } from 'rootverse+project-utils:src/fs.ts'; + + import type { PackageJson } from 'type-fest'; + // Some comment about remembering to do something + import type { XPackageJson } from 'rootverse:src/assets/config/_package.json.ts'; + `, + ...parserConfig, + options: [ + { + alphabetize: { + order: 'asc', + orderImportKind: 'asc', + caseInsensitive: true, + }, + named: { + enabled: true, + types: 'types-last', + }, + groups: [ + 'builtin', + 'external', + 'internal', + ['parent', 'sibling', 'index'], + ['object', 'type'], + ], + pathGroups: [ + { + pattern: 'rootverse{*,*/**}', + group: 'external', + position: 'after', + }, + ], + distinctGroup: true, + pathGroupsExcludedImportTypes: ['builtin', 'object'], + 'newlines-between': 'always-and-inside-groups', + 'newlines-between-types': 'never', + sortTypesGroup: true, + consolidateIslands: 'inside-groups', + }, + ], + }), + + // Documentation passing example #1 for newlines-between + tValid({ + code: ` + import fs from 'fs'; + import path from 'path'; + + import sibling from './foo'; + + import index from './'; + `, + ...parserConfig, + options: [ + { + 'newlines-between': 'always', + }, + ], + }), + // Documentation passing example #2 for newlines-between + tValid({ + code: ` + import fs from 'fs'; + + import path from 'path'; + + import sibling from './foo'; + + import index from './'; + `, + ...parserConfig, + options: [ + { + 'newlines-between': 'always-and-inside-groups', + }, + ], + }), + // Documentation passing example #3 for newlines-between + tValid({ + code: ` + import fs from 'fs'; + import path from 'path'; + import sibling from './foo'; + import index from './'; + `, + ...parserConfig, + options: [ + { + 'newlines-between': 'never', + }, + ], + }), + // Documentation passing example #1 for alphabetize + tValid({ + code: ` + import blist2 from 'blist'; + import blist from 'BList'; + import * as classnames from 'classnames'; + import aTypes from 'prop-types'; + import React, { PureComponent } from 'react'; + import { compose, apply } from 'xcompose'; + `, + ...parserConfig, + options: [ + { + alphabetize: { + order: 'asc', + caseInsensitive: true, + }, + }, + ], + }), + // (not an example, but we also test caseInsensitive: false for completeness) + tValid({ + code: ` + import blist from 'BList'; + import blist2 from 'blist'; + import * as classnames from 'classnames'; + import aTypes from 'prop-types'; + import React, { PureComponent } from 'react'; + import { compose, apply } from 'xcompose'; + `, + ...parserConfig, + options: [ + { + alphabetize: { + order: 'asc', + caseInsensitive: false, + }, + }, + ], + }), + // Documentation passing example #1 for named + tValid({ + code: ` + import { apply, compose } from 'xcompose'; + `, + ...parserConfig, + options: [ + { + named: true, + alphabetize: { + order: 'asc', + }, + }, + ], + }), + // Documentation passing example #1 for warnOnUnassignedImports + tValid({ + code: ` + import fs from 'fs'; + import path from 'path'; + import './styles.css'; + `, + ...parserConfig, + options: [ + { + warnOnUnassignedImports: true, + }, + ], + }), + // Documentation passing example #1 for sortTypesGroup + tValid({ + 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' }, + sortTypesGroup: true, + }, + ], + }), + // (not an example, but we also test the reverse for completeness) + tValid({ + code: ` + import a from "fs"; + import b from "path"; + import c from "../foo.js"; + import d from "./bar.js"; + import e from "./"; + + 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 './'; + `, + ...parserConfig, + options: [ + { + groups: ['builtin', 'parent', 'sibling', 'index', 'type'], + sortTypesGroup: true, + }, + ], + }), + // (not an example, but we also test the reverse for completeness) + tValid({ + code: ` + import a from "fs"; + import b from "path"; + + import c from "../foo.js"; + + import d from "./bar.js"; + + import e from "./"; + + 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 './'; + `, + ...parserConfig, + options: [ + { + groups: ['builtin', 'parent', 'sibling', 'index', 'type'], + sortTypesGroup: true, + 'newlines-between': 'always', + 'newlines-between-types': 'ignore', + }, + ], + }), + // (not an example, but we also test the reverse for completeness) + tValid({ + code: ` + import a from "fs"; + import b from "path"; + import c from "../foo.js"; + import d from "./bar.js"; + import e from "./"; + + 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 './'; + `, + ...parserConfig, + options: [ + { + groups: ['builtin', 'parent', 'sibling', 'index', 'type'], + sortTypesGroup: true, + 'newlines-between': 'never', + 'newlines-between-types': 'always', + }, + ], + }), + // Documentation passing example #1 for consolidateIslands + tValid({ + code: ` + var fs = require('fs'); + var path = require('path'); + var { util1, util2, util3 } = require('util'); + + var async = require('async'); + + var relParent1 = require('../foo'); + + var { + relParent21, + relParent22, + relParent23, + relParent24, + } = require('../'); + + var relParent3 = require('../bar'); + + var { sibling1, + sibling2, sibling3 } = require('./foo'); + + var sibling2 = require('./bar'); + var sibling3 = require('./foobar'); + `, + ...parserConfig, + options: [ + { + 'newlines-between': 'always-and-inside-groups', + consolidateIslands: 'inside-groups', + }, + ], + }), + // Documentation passing example #2 for consolidateIslands + tValid({ + code: ` + import c from 'Bar'; + import d from 'bar'; + + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; + + import a from 'foo'; + + import b from 'dirA/bar'; + + import index from './'; + + import type { AA, + BB, CC } from 'abc'; + + import type { Z } from 'fizz'; + + import type { + A, + B + } from 'foo'; + + import type { C2 } from 'dirB/Bar'; + + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + + import type { E2 } from 'dirB/baz'; + import type { C3 } from 'dirC/Bar'; + + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + + import type { E3 } from 'dirC/baz'; + import type { F3 } from 'dirC/caz'; + import type { C1 } from 'dirA/Bar'; + + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + + import type { E1 } from 'dirA/baz'; + import type { F } from './index.js'; + import type { G } from './aaa.js'; + import type { H } from './bbb'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + position: 'after', + }, + { + pattern: 'dirB/**', + group: 'internal', + position: 'before', + }, + { + pattern: 'dirC/**', + group: 'internal', + }, + ], + 'newlines-between': 'always-and-inside-groups', + 'newlines-between-types': 'never', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + consolidateIslands: 'inside-groups', + }, + ], + }), + // (not an example, but we also test the reverse for completeness) + tValid({ + code: ` + import type { AA, + BB, CC } from 'abc'; + + import type { Z } from 'fizz'; + + import type { + A, + B + } from 'foo'; + + import type { C2 } from 'dirB/Bar'; + + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + + import type { E2 } from 'dirB/baz'; + import type { C3 } from 'dirC/Bar'; + + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + + import type { E3 } from 'dirC/baz'; + import type { F3 } from 'dirC/caz'; + import type { C1 } from 'dirA/Bar'; + + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + + import type { E1 } from 'dirA/baz'; + import type { F } from './index.js'; + import type { G } from './aaa.js'; + import type { H } from './bbb'; + + import c from 'Bar'; + import d from 'bar'; + + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; + + import a from 'foo'; + + import b from 'dirA/bar'; + + import index from './'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['type', 'external', 'internal', 'index'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + position: 'after', + }, + { + pattern: 'dirB/**', + group: 'internal', + position: 'before', + }, + { + pattern: 'dirC/**', + group: 'internal', + }, + ], + 'newlines-between': 'always-and-inside-groups', + 'newlines-between-types': 'never', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + consolidateIslands: 'inside-groups', + }, + ], + }), + ], + invalid: [ + // Option alphabetize: {order: 'asc'} + tInvalid({ + code: ` + import b from 'bar'; + import c from 'Bar'; + import type { C } from 'Bar'; + import a from 'foo'; + import type { A } from 'foo'; + + import index from './'; + `, + output: ` + import c from 'Bar'; + import type { C } from 'Bar'; + import b from 'bar'; + import a from 'foo'; + import type { A } from 'foo'; + + import index from './'; + `, + ...parserConfig, + options: [ + { + groups: ['external', 'index'], + alphabetize: { order: 'asc' }, + }, + ], + errors: [ + createOrderError(['`bar` import', 'after', 'type import of `Bar`']), + ], + }), + // Option alphabetize: {order: 'desc'} + tInvalid({ + code: ` + import a from 'foo'; + import type { A } from 'foo'; + import c from 'Bar'; + import type { C } from 'Bar'; + import b from 'bar'; + + import index from './'; + `, + output: ` + import a from 'foo'; + import type { A } from 'foo'; + import b from 'bar'; + import c from 'Bar'; + import type { C } from 'Bar'; + + import index from './'; + `, + ...parserConfig, + options: [ + { + groups: ['external', 'index'], + alphabetize: { order: 'desc' }, + }, + ], + errors: [ + createOrderError(['`bar` import', 'before', 'import of `Bar`']), + ], + }), + // Option alphabetize: {order: 'asc'} with type group + tInvalid({ code: ` import b from 'bar'; import c from 'Bar'; @@ -3268,6 +5026,132 @@ describe('TypeScript', () => { ], }), + // named import order + tInvalid({ + code: ` + import { type Z, A } from "./Z"; + import type N, { E, D } from "./Z"; + import type { L, G } from "./Z"; + `, + output: ` + import { A, type Z } from "./Z"; + import type N, { D, E } from "./Z"; + import type { G, L } from "./Z"; + `, + ...parserConfig, + options: [ + { + named: true, + alphabetize: { order: 'asc' }, + }, + ], + errors: [ + createOrderError(['`A` import', 'before', 'type import of `Z`']), + createOrderError(['`D` import', 'before', 'import of `E`']), + createOrderError(['`G` import', 'before', 'import of `L`']), + ], + }), + tInvalid({ + code: ` + const { B, /* Hello World */ A } = require("./Z"); + export { B, A } from "./Z"; + `, + output: ` + const { /* Hello World */ A, B } = require("./Z"); + export { A, B } from "./Z"; + `, + ...parserConfig, + options: [ + { + named: true, + alphabetize: { order: 'asc' }, + }, + ], + errors: [ + createOrderError(['`A` import', 'before', 'import of `B`']), + createOrderError(['`A` export', 'before', 'export of `B`']), + ], + }), + + ...(supportsExportTypeSpecifiers + ? [ + tInvalid({ + code: ` + export { type B, A }; + `, + output: ` + export { A, type B }; + `, + ...parserConfig, + options: [ + { + named: { + enabled: true, + types: 'mixed', + }, + alphabetize: { order: 'asc' }, + }, + ], + errors: [ + createOrderError([ + '`A` export', + 'before', + 'type export of `B`', + ]), + ], + }), + tInvalid({ + code: ` + import { type B, A, default as C } from "./Z"; + `, + output: ` + import { A, default as C, type B } from "./Z"; + `, + ...parserConfig, + options: [ + { + named: { + import: true, + types: 'types-last', + }, + alphabetize: { order: 'asc' }, + }, + ], + errors: [ + createOrderError([ + '`B` type import', + 'after', + 'import of `default`', + ]), + ], + }), + tInvalid({ + code: ` + export { A, type Z } from "./Z"; + `, + output: ` + export { type Z, A } from "./Z"; + `, + ...parserConfig, + options: [ + { + named: { + enabled: true, + types: 'types-first', + }, + }, + ], + errors: [ + createOrderError([ + '`Z` type export', + 'before', + 'export of `A`', + ]), + ], + }), + ] + : []), + tInvalid({ code: ` import express from 'express';