From ba66f25b6416898cf3870ab9e135a06d7a3a0550 Mon Sep 17 00:00:00 2001 From: Jeff Date: Fri, 13 Dec 2024 10:46:36 -0700 Subject: [PATCH] Add `specifier-order` rule (#25) --- .changeset/tough-glasses-end.md | 5 + README.md | 38 ++- docs/rules/order.md | 26 ++- docs/rules/specifier-order.md | 18 ++ package-lock.json | 309 ++++++++++++++----------- package.json | 8 +- src/index.ts | 4 +- src/rules/order.ts | 256 +++++++------------- src/rules/specifier-order.ts | 155 +++++++++++++ src/utils/compare.ts | 16 +- src/utils/get-comment.ts | 46 ++-- src/utils/get-eslint-disabled-lines.ts | 50 ++++ src/utils/get-eslint-disabled-rules.ts | 46 ++++ src/utils/get-newline-errors.ts | 50 ++++ src/utils/get-node-range.ts | 48 +++- src/utils/is-node-eslint-disabled.ts | 5 + src/utils/is-sortable.ts | 3 + src/utils/make-comment-after-fixes.ts | 40 ++++ src/utils/make-fixes.ts | 82 +++++++ src/utils/make-newline-fixes.ts | 95 ++++++++ src/utils/range-to-diff.ts | 9 + src/utils/sort-nodes-by-groups.ts | 64 +++++ src/utils/sort-nodes.ts | 30 ++- src/utils/types.ts | 6 + tests/rules/order.test.ts | 29 ++- tests/rules/specifier-order.test.ts | 200 ++++++++++++++++ 26 files changed, 1249 insertions(+), 389 deletions(-) create mode 100644 .changeset/tough-glasses-end.md create mode 100644 docs/rules/specifier-order.md create mode 100644 src/rules/specifier-order.ts create mode 100644 src/utils/get-eslint-disabled-lines.ts create mode 100644 src/utils/get-eslint-disabled-rules.ts create mode 100644 src/utils/get-newline-errors.ts create mode 100644 src/utils/is-node-eslint-disabled.ts create mode 100644 src/utils/is-sortable.ts create mode 100644 src/utils/make-comment-after-fixes.ts create mode 100644 src/utils/make-fixes.ts create mode 100644 src/utils/make-newline-fixes.ts create mode 100644 src/utils/range-to-diff.ts create mode 100644 src/utils/sort-nodes-by-groups.ts create mode 100644 tests/rules/specifier-order.test.ts diff --git a/.changeset/tough-glasses-end.md b/.changeset/tough-glasses-end.md new file mode 100644 index 0000000..7655405 --- /dev/null +++ b/.changeset/tough-glasses-end.md @@ -0,0 +1,5 @@ +--- +'eslint-plugin-import-sorting': minor +--- + +Add new `specifier-order` rule for sorting named imports diff --git a/README.md b/README.md index 0904675..07ae6b1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ -## eslint-plugin-import-sorting +# eslint-plugin-import-sorting -Enforce a convention in the order of `import` statements, inspired by [isort](https://timothycrosley.github.io/isort/#how-does-isort-work)’s grouping style: +Enforce a convention in the order of `import` statements, inspired by +[isort](https://timothycrosley.github.io/isort/#how-does-isort-work)’s grouping style: 1. Node standard modules 2. Framework modules @@ -8,17 +9,21 @@ Enforce a convention in the order of `import` statements, inspired by [isort](ht 4. Internal modules 5. Explicitly local modules -This plugin includes an additional group for “style” imports where the import source ends in `.css` or other style format. Imports are sorted alphabetically, except for local modules, which are sorted by the number of `.` segements in the path first, then alphabetically. +This plugin includes an additional group for “style” imports where the import +source ends in `.css` or other style format. Imports are sorted alphabetically, +except for local modules, which are sorted by the number of `.` segements in +the path first, then alphabetically. ## Usage -Install the plugin, and ESLint if is not already. +Install the plugin, and ESLint if it is not already. ```sh npm install --save-dev eslint eslint-plugin-import-sorting ``` -Include the plugin in the `plugins` key of your ESLint config and enable the rule. +Include the plugin in the `plugins` key of your ESLint config and enable the +rules. ```js // eslint.config.js @@ -31,26 +36,19 @@ export default [ 'import-sorting': importSortingPlugin, }, rules: { - 'import-sorting/order': 'warn', + 'import-sorting/order': 'error', }, }, ] ``` -
- Legacy config example + -```js -// .eslintrc.js - -module.exports = { - plugins: ['import-sorting'], - rules: { - 'import-sorting/order': 'warn', - }, -} -``` +🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix). -
+| Name | Description | 🔧 | +| :----------------------------------------------- | :------------------------------------------ | :- | +| [order](docs/rules/order.md) | Consistently order `import` statements. | 🔧 | +| [specifier-order](docs/rules/specifier-order.md) | Consistently order named import specifiers. | 🔧 | -See the [order](https://github.com/stormwarning/eslint-plugin-import-sorting/blob/main/docs/rules/order.md) rule docs for more configuration options. + diff --git a/docs/rules/order.md b/docs/rules/order.md index 2d8d305..dd6a16a 100644 --- a/docs/rules/order.md +++ b/docs/rules/order.md @@ -1,8 +1,8 @@ -# import-sorting/order +# Consistently order `import` statements (`import-sorting/order`) 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). -Enforce a convention in the order of `import` statements. + The grouping order is as follows: @@ -24,13 +24,17 @@ constitutes an “internal” module. For example: ```js -settings: { - // Group official React packages together. - 'import-sorting/framework-patterns': /^react(\/|-dom|-router|$)/.source, - // Group aliased imports together. - 'import-sorting/internal-patterns': /^~/.source, -}, -rules: { - 'import-sorting/order': 'error', -}, +export default [ + { + settings: { + // Group official React packages together. + 'import-sorting/framework-patterns': /^react(\/|-dom|-router|$)/.source, + // Group aliased imports together. + 'import-sorting/internal-patterns': /^~/.source, + }, + rules: { + 'import-sorting/order': 'error', + }, + }, +] ``` diff --git a/docs/rules/specifier-order.md b/docs/rules/specifier-order.md new file mode 100644 index 0000000..5b5c98d --- /dev/null +++ b/docs/rules/specifier-order.md @@ -0,0 +1,18 @@ +# Consistently order named import specifiers (`import-sorting/specifier-order`) + +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + + +Specifiers are sorted naturally, the same as imports within groups. `type` +keywords are ignored during sorting. + +```js +export default [ + { + rules: { + 'import-sorting/specifier-order': 'error', + }, + }, +] +``` diff --git a/package-lock.json b/package-lock.json index 9b8e471..49a055a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,26 +1,26 @@ { "name": "eslint-plugin-import-sorting", - "version": "1.2.2", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "eslint-plugin-import-sorting", - "version": "1.2.2", + "version": "2.0.0", "dependencies": { - "@typescript-eslint/utils": "7.8.0", + "@typescript-eslint/utils": "7.18.0", "object.groupby": "1.0.1" }, "devDependencies": { "@changesets/cli": "2.26.2", "@types/eslint": "8.44.8", "@types/node": "20.10.4", - "@typescript-eslint/rule-tester": "7.8.0", + "@typescript-eslint/rule-tester": "7.18.0", "@zazen/changesets-changelog": "2.0.3", - "@zazen/eslint-config": "6.6.0", + "@zazen/eslint-config": "6.8.0", "c8": "8.0.1", "dedent": "1.5.3", - "eslint": "8.56.0", + "eslint": "8.57.1", "eslint-doc-generator": "1.6.1", "eslint-plugin-eslint-plugin": "5.1.1", "husky": "8.0.3", @@ -838,20 +838,23 @@ } }, "node_modules/@eslint/js": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz", - "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "license": "MIT", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.13", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", - "integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==", - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.1", - "debug": "^4.1.1", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", "minimatch": "^3.0.5" }, "engines": { @@ -871,9 +874,11 @@ } }, "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz", - "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==" + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "license": "BSD-3-Clause" }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", @@ -1616,7 +1621,8 @@ "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true }, "node_modules/@types/json5": { "version": "0.0.29", @@ -1648,7 +1654,8 @@ "node_modules/@types/semver": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", - "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==" + "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", + "dev": true }, "node_modules/@types/yargs": { "version": "17.0.32", @@ -1666,16 +1673,17 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.19.0.tgz", - "integrity": "sha512-DUCUkQNklCQYnrBSSikjVChdc84/vMPDQSgJTHBZ64G9bA9w0Crc0rd2diujKbTdp6w2J47qkeHQLoi0rpLCdg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.19.0", - "@typescript-eslint/type-utils": "6.19.0", - "@typescript-eslint/utils": "6.19.0", - "@typescript-eslint/visitor-keys": "6.19.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/type-utils": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.2.4", @@ -1701,17 +1709,18 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.19.0.tgz", - "integrity": "sha512-QR41YXySiuN++/dC9UArYOg4X86OAYP83OWTewpVx5ct1IZhjjgTLocj7QNxGhWoTqknsgpl7L+hGygCO+sdYw==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@types/json-schema": "^7.0.12", "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.19.0", - "@typescript-eslint/types": "6.19.0", - "@typescript-eslint/typescript-estree": "6.19.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", "semver": "^7.5.4" }, "engines": { @@ -1867,15 +1876,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.19.0.tgz", - "integrity": "sha512-1DyBLG5SH7PYCd00QlroiW60YJ4rWMuUGa/JBV0iZuqi4l4IK3twKPq5ZkEebmGqRjXWVgsUzfd3+nZveewgow==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "6.19.0", - "@typescript-eslint/types": "6.19.0", - "@typescript-eslint/typescript-estree": "6.19.0", - "@typescript-eslint/visitor-keys": "6.19.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", "debug": "^4.3.4" }, "engines": { @@ -1895,14 +1905,16 @@ } }, "node_modules/@typescript-eslint/rule-tester": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/rule-tester/-/rule-tester-7.8.0.tgz", - "integrity": "sha512-f1wXWeZx8XJB/z9Oyjx0ZLmhvcFelSJ0CVvOurCkrISOZhre+imIj5FQQz1rBy/Ips0dCbVl5G4MWTuzlzj5QQ==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/rule-tester/-/rule-tester-7.18.0.tgz", + "integrity": "sha512-ClrFQlwen9pJcYPIBLuarzBpONQAwjmJ0+YUjAo1TGzoZFJPyUK/A7bb4Mps0u+SMJJnFXbfMN8I9feQDf0O5A==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "7.8.0", - "@typescript-eslint/utils": "7.8.0", + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/utils": "7.18.0", "ajv": "^6.12.6", + "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "4.6.2", "semver": "^7.6.0" }, @@ -1919,10 +1931,11 @@ } }, "node_modules/@typescript-eslint/rule-tester/node_modules/@typescript-eslint/types": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.8.0.tgz", - "integrity": "sha512-wf0peJ+ZGlcH+2ZS23aJbOv+ztjeeP8uQ9GgwMJGVLx/Nj9CJt17GWgWWoSmoRVKAX2X+7fzEnAjxdvK2gqCLw==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || >=20.0.0" }, @@ -1932,13 +1945,14 @@ } }, "node_modules/@typescript-eslint/rule-tester/node_modules/@typescript-eslint/typescript-estree": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.8.0.tgz", - "integrity": "sha512-5pfUCOwK5yjPaJQNy44prjCwtr981dO8Qo9J9PwYXZ0MosgAbfEMB008dJ5sNo3+/BN6ytBPuSvXUg9SAqB0dg==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "7.8.0", - "@typescript-eslint/visitor-keys": "7.8.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -1960,12 +1974,13 @@ } }, "node_modules/@typescript-eslint/rule-tester/node_modules/@typescript-eslint/visitor-keys": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.8.0.tgz", - "integrity": "sha512-q4/gibTNBQNA0lGyYQCmWRS5D15n8rXh4QjK3KV+MBPlTYHpfBUT3D3PaPR/HeNiI9W6R7FvlkcGhNyAoP+caA==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.8.0", + "@typescript-eslint/types": "7.18.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -1981,15 +1996,17 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/@typescript-eslint/rule-tester/node_modules/minimatch": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", - "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -2001,13 +2018,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.19.0.tgz", - "integrity": "sha512-dO1XMhV2ehBI6QN8Ufi7I10wmUovmLU0Oru3n5LVlM2JuzB4M+dVphCPLkVpKvGij2j/pHBWuJ9piuXx+BhzxQ==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "6.19.0", - "@typescript-eslint/visitor-keys": "6.19.0" + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" }, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -2018,13 +2036,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.19.0.tgz", - "integrity": "sha512-mcvS6WSWbjiSxKCwBcXtOM5pRkPQ6kcDds/juxcy/727IQr3xMEcwr/YLHW2A2+Fp5ql6khjbKBzOyjuPqGi/w==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "6.19.0", - "@typescript-eslint/utils": "6.19.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/utils": "6.21.0", "debug": "^4.3.4", "ts-api-utils": "^1.0.1" }, @@ -2045,17 +2064,18 @@ } }, "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.19.0.tgz", - "integrity": "sha512-QR41YXySiuN++/dC9UArYOg4X86OAYP83OWTewpVx5ct1IZhjjgTLocj7QNxGhWoTqknsgpl7L+hGygCO+sdYw==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@types/json-schema": "^7.0.12", "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.19.0", - "@typescript-eslint/types": "6.19.0", - "@typescript-eslint/typescript-estree": "6.19.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", "semver": "^7.5.4" }, "engines": { @@ -2070,10 +2090,11 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.19.0.tgz", - "integrity": "sha512-lFviGV/vYhOy3m8BJ/nAKoAyNhInTdXpftonhWle66XHAtT1ouBlkjL496b5H5hb8dWXHwtypTqgtb/DEa+j5A==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", "dev": true, + "license": "MIT", "engines": { "node": "^16.0.0 || >=18.0.0" }, @@ -2083,13 +2104,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.19.0.tgz", - "integrity": "sha512-o/zefXIbbLBZ8YJ51NlkSAt2BamrK6XOmuxSR3hynMIzzyMY33KuJ9vuMdFSXW+H0tVvdF9qBPTHA91HDb4BIQ==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "6.19.0", - "@typescript-eslint/visitor-keys": "6.19.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -2115,6 +2137,7 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -2124,6 +2147,7 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -2135,17 +2159,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.8.0.tgz", - "integrity": "sha512-L0yFqOCflVqXxiZyXrDr80lnahQfSOfc9ELAAZ75sqicqp2i36kEZZGuUymHNFoYOqxRT05up760b4iGsl02nQ==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", + "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.15", - "@types/semver": "^7.5.8", - "@typescript-eslint/scope-manager": "7.8.0", - "@typescript-eslint/types": "7.8.0", - "@typescript-eslint/typescript-estree": "7.8.0", - "semver": "^7.6.0" + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -2159,12 +2181,13 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.8.0.tgz", - "integrity": "sha512-viEmZ1LmwsGcnr85gIq+FCYI7nO90DVbE37/ll51hjv9aG+YZMb4WDE2fyWpUR4O/UrhGRpYXK/XajcGTk2B8g==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.8.0", - "@typescript-eslint/visitor-keys": "7.8.0" + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -2175,9 +2198,10 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.8.0.tgz", - "integrity": "sha512-wf0peJ+ZGlcH+2ZS23aJbOv+ztjeeP8uQ9GgwMJGVLx/Nj9CJt17GWgWWoSmoRVKAX2X+7fzEnAjxdvK2gqCLw==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "license": "MIT", "engines": { "node": "^18.18.0 || >=20.0.0" }, @@ -2187,12 +2211,13 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.8.0.tgz", - "integrity": "sha512-5pfUCOwK5yjPaJQNy44prjCwtr981dO8Qo9J9PwYXZ0MosgAbfEMB008dJ5sNo3+/BN6ytBPuSvXUg9SAqB0dg==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "7.8.0", - "@typescript-eslint/visitor-keys": "7.8.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -2214,11 +2239,12 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.8.0.tgz", - "integrity": "sha512-q4/gibTNBQNA0lGyYQCmWRS5D15n8rXh4QjK3KV+MBPlTYHpfBUT3D3PaPR/HeNiI9W6R7FvlkcGhNyAoP+caA==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.8.0", + "@typescript-eslint/types": "7.18.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -2233,14 +2259,16 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/@typescript-eslint/utils/node_modules/minimatch": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", - "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -2252,12 +2280,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.19.0.tgz", - "integrity": "sha512-hZaUCORLgubBvtGpp1JEFEazcuEdfxta9j4iUwdSAr7mEsYYAp3EAUyCZk3VEEqGj6W+AV4uWyrDGtrlawAsgQ==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "6.19.0", + "@typescript-eslint/types": "6.21.0", "eslint-visitor-keys": "^3.4.1" }, "engines": { @@ -2506,20 +2535,21 @@ } }, "node_modules/@zazen/eslint-config": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@zazen/eslint-config/-/eslint-config-6.6.0.tgz", - "integrity": "sha512-T7s+1DSRzK2nUc3lV3Heht2ATHiz+M0s8O9hejqOOrF7Y2PoopXZSiif2OhtAfTPqdGm7URk0Mal0QZ6VvKr9w==", + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@zazen/eslint-config/-/eslint-config-6.8.0.tgz", + "integrity": "sha512-bJ398ZElSYayF24tYq1wQTt4598wC+kxMBVoBY6aQFxta0GpI/Ml7Eza+XbP0GjPhC1hyVWpyl9gFJ0aw/OuKw==", "dev": true, + "license": "ISC", "dependencies": { "@rushstack/eslint-patch": "1.7.0", - "@typescript-eslint/eslint-plugin": "6.19.0", - "@typescript-eslint/parser": "6.19.0", + "@typescript-eslint/eslint-plugin": "6.21.0", + "@typescript-eslint/parser": "6.21.0", "eslint-config-prettier": "9.1.0", "eslint-config-xo": "0.43.1", "eslint-config-xo-typescript": "1.0.1", "eslint-plugin-etc": "2.0.3", "eslint-plugin-import": "2.29.1", - "eslint-plugin-import-sorting": "1.0.0", + "eslint-plugin-import-sorting": "1.1.0", "eslint-plugin-n": "16.6.2", "eslint-plugin-prefer-let": "3.0.1", "eslint-plugin-promise": "6.1.1", @@ -2548,15 +2578,6 @@ "node": ">=8" } }, - "node_modules/@zazen/eslint-config/node_modules/eslint-plugin-import-sorting": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import-sorting/-/eslint-plugin-import-sorting-1.0.0.tgz", - "integrity": "sha512-2X1GqpEBMhx23QA4A580N5UJ9LOxTKxXJr68V7n39qytKCOarBOqasDBRSOfLbsXaOzXVcbTzdr6CCt3sUxf6w==", - "dev": true, - "dependencies": { - "object.groupby": "1.0.1" - } - }, "node_modules/@zazen/eslint-config/node_modules/eslint-plugin-unicorn": { "version": "50.0.1", "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-50.0.1.tgz", @@ -4062,15 +4083,17 @@ } }, "node_modules/eslint": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", - "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.56.0", - "@humanwhocodes/config-array": "^0.11.13", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", @@ -4494,6 +4517,18 @@ "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" } }, + "node_modules/eslint-plugin-import-sorting": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import-sorting/-/eslint-plugin-import-sorting-1.1.0.tgz", + "integrity": "sha512-4qGrkAU8/YRXMaegYHLzZpy9rcFK0UX4nQglHGkecEBzsfyWNBIfoS1R67jJylL1buQrH8c2iHkZXgWhNsi+Rw==", + "dev": true, + "dependencies": { + "object.groupby": "1.0.1" + }, + "engines": { + "node": ">=16.17.0 || >=18.6.0" + } + }, "node_modules/eslint-plugin-import/node_modules/debug": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", diff --git a/package.json b/package.json index f55453b..c5b9e19 100644 --- a/package.json +++ b/package.json @@ -67,19 +67,19 @@ ] }, "dependencies": { - "@typescript-eslint/utils": "7.8.0", + "@typescript-eslint/utils": "7.18.0", "object.groupby": "1.0.1" }, "devDependencies": { "@changesets/cli": "2.26.2", "@types/eslint": "8.44.8", "@types/node": "20.10.4", - "@typescript-eslint/rule-tester": "7.8.0", + "@typescript-eslint/rule-tester": "7.18.0", "@zazen/changesets-changelog": "2.0.3", - "@zazen/eslint-config": "6.6.0", + "@zazen/eslint-config": "6.8.0", "c8": "8.0.1", "dedent": "1.5.3", - "eslint": "8.56.0", + "eslint": "8.57.1", "eslint-doc-generator": "1.6.1", "eslint-plugin-eslint-plugin": "5.1.1", "husky": "8.0.3", diff --git a/src/index.ts b/src/index.ts index 318a5c0..e3371b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,11 @@ import order from './rules/order.js' +import specifierOrder from './rules/specifier-order.js' const plugin = { - name: 'sorting-order', + name: 'import-sorting', rules: { order, + 'specifier-order': specifierOrder, }, } diff --git a/src/rules/order.ts b/src/rules/order.ts index 8894445..305deb9 100644 --- a/src/rules/order.ts +++ b/src/rules/order.ts @@ -1,13 +1,15 @@ -import { AST_NODE_TYPES, ESLintUtils, type TSESTree, type TSESLint } from '@typescript-eslint/utils' +import { AST_NODE_TYPES, ESLintUtils, type TSESTree } from '@typescript-eslint/utils' -import { compare } from '../utils/compare.js' -import { computeGroup, isSideEffectImport } from '../utils/compute-group.js' -import { getCommentBefore } from '../utils/get-comment.js' +import { computeGroup } from '../utils/compute-group.js' +import { getEslintDisabledLines } from '../utils/get-eslint-disabled-lines.js' import { getGroupNumber } from '../utils/get-group-number.js' -import { getLinesBetween } from '../utils/get-lines-between.js' -import { getNodeRange } from '../utils/get-node-range.js' +import { getNewlineErrors } from '../utils/get-newline-errors.js' +import { isNodeEslintDisabled } from '../utils/is-node-eslint-disabled.js' +import { makeFixes } from '../utils/make-fixes.js' +import { makeNewlineFixes } from '../utils/make-newline-fixes.js' import { pairwise } from '../utils/pairwise.js' -import { sortNodes } from '../utils/sort-nodes.js' +import { rangeToDiff } from '../utils/range-to-diff.js' +import { sortNodesByGroups } from '../utils/sort-nodes-by-groups.js' import type { ImportDeclarationNode, Options, SortingNode } from '../utils/types.js' export const IMPORT_GROUPS = [ @@ -22,19 +24,21 @@ export const IMPORT_GROUPS = [ 'unknown', ] as const +type MessageId = 'out-of-order' | 'needs-newline' | 'extra-newline' + // eslint-disable-next-line new-cap const createRule = ESLintUtils.RuleCreator( (name) => `https://github.com/stormwarning/eslint-plugin-import-sorting/blob/main/docs/rules/${name}.md`, ) -export default createRule({ +export default createRule({ name: 'order', meta: { type: 'suggestion', fixable: 'code', docs: { - description: 'Enforce a convention in the order of `import` statements.', + description: 'Consistently order `import` statements.', }, messages: { 'needs-newline': @@ -53,6 +57,10 @@ export default createRule({ order: 'asc', type: 'natural', } + let eslintDisabledLines = getEslintDisabledLines({ + ruleName: context.id, + sourceCode, + }) let nodes: SortingNode[] = [] function registerNode(node: ImportDeclarationNode) { @@ -63,19 +71,20 @@ export default createRule({ } else if (node.type === AST_NODE_TYPES.TSImportEqualsDeclaration) { name = node.moduleReference.type === AST_NODE_TYPES.TSExternalModuleReference - ? // @ts-expect-error -- `value` is not in the type definition. - `${node.moduleReference.expression.value}` - : sourceCode.text.slice(...node.moduleReference.range) + ? node.moduleReference.expression.value + : sourceCode.getText(node.moduleReference) } else { let decl = node.declarations[0].init as TSESTree.CallExpression - let declValue = (decl.arguments[0] as TSESTree.Literal).value - name = declValue!.toString() + let { value } = decl.arguments[0] as TSESTree.Literal + name = value!.toString() } nodes.push({ group: computeGroup(node, settings, sourceCode), - node, + isEslintDisabled: isNodeEslintDisabled(node, eslintDisabledLines), name, + node, + size: rangeToDiff(node, sourceCode), }) } @@ -95,190 +104,91 @@ export default createRule({ }, // eslint-disable-next-line @typescript-eslint/naming-convention 'Program:exit'() { - let hasContentBetweenNodes = (left: SortingNode, right: SortingNode): boolean => - sourceCode.getTokensBetween( - left.node, - getCommentBefore(right.node, sourceCode) ?? right.node, - { - includeComments: true, - }, - ).length > 0 + function hasContentBetweenNodes(left: SortingNode, right: SortingNode): boolean { + return ( + sourceCode.getTokensBetween(left.node, right.node, { + includeComments: false, + }).length > 0 + ) + } - let splittedNodes: SortingNode[][] = [[]] + let formattedNodes: SortingNode[][] = [[]] for (let node of nodes) { - let lastNode = splittedNodes.at(-1)?.at(-1) + let lastNode = formattedNodes.at(-1)?.at(-1) if (lastNode && hasContentBetweenNodes(lastNode, node)) { - splittedNodes.push([node]) + /** + * Including `node` in this empty array allows groups + * of imports separated by other statements to be + * sorted, but may break other aspects. + */ + formattedNodes.push([node]) } else { - splittedNodes.at(-1)!.push(node) + formattedNodes.at(-1)!.push(node) } } - for (let nodeList of splittedNodes) { + for (let nodeList of formattedNodes) { + let sortedNodes = sortNodesByGroups(nodeList, options, {}) pairwise(nodeList, (left, right) => { let leftNumber = getGroupNumber(IMPORT_GROUPS, left) let rightNumber = getGroupNumber(IMPORT_GROUPS, right) + let indexOfLeft = sortedNodes.indexOf(left) + let indexOfRight = sortedNodes.indexOf(right) - let numberOfEmptyLinesBetween = getLinesBetween(sourceCode, left, right) + let messages: MessageId[] = [] - if ( - !( - isSideEffectImport(left.node, sourceCode) && - isSideEffectImport(right.node, sourceCode) - ) && - !hasContentBetweenNodes(left, right) && - (leftNumber > rightNumber || - (leftNumber === rightNumber && compare(left, right, options) > 0)) - ) { - context.report({ - messageId: 'out-of-order', - data: { - left: left.name, - right: right.name, - }, - node: right.node, - fix: (fixer) => fix(fixer, nodeList, sourceCode, options), - }) + if (indexOfLeft > indexOfRight) { + messages.push( + leftNumber === rightNumber ? 'out-of-order' : 'out-of-order', + ) } - if (options.newlinesBetween === 'never' && numberOfEmptyLinesBetween > 0) { + messages = [ + ...messages, + ...getNewlineErrors({ + missingLineError: 'needs-newline', + extraLineError: 'extra-newline', + left, + leftNumber, + right, + rightNumber, + sourceCode, + options, + }), + ] + + for (let message of messages) { context.report({ - messageId: 'extra-newline', + fix: (fixer) => [ + ...makeFixes({ + fixer, + nodes: nodeList, + sortedNodes, + sourceCode, + }), + ...makeNewlineFixes({ + fixer, + nodes: nodeList, + sortedNodes, + sourceCode, + options, + }), + ], data: { - left: left.name, + rightGroup: right.group, + leftGroup: left.group, right: right.name, + left: left.name, }, node: right.node, - fix: (fixer) => fix(fixer, nodeList, sourceCode, options), + messageId: message, }) } - - if (options.newlinesBetween === 'always') { - if (leftNumber < rightNumber && numberOfEmptyLinesBetween === 0) { - context.report({ - messageId: 'needs-newline', - data: { - left: left.name, - right: right.name, - }, - node: right.node, - fix: (fixer) => fix(fixer, nodeList, sourceCode, options), - }) - } else if ( - numberOfEmptyLinesBetween > 1 || - (leftNumber === rightNumber && numberOfEmptyLinesBetween > 0) - ) { - context.report({ - messageId: 'extra-newline', - data: { - left: left.name, - right: right.name, - }, - node: right.node, - fix: (fixer) => fix(fixer, nodeList, sourceCode, options), - }) - } - } }) } }, } }, }) - -function fix( - fixer: TSESLint.RuleFixer, - nodesToFix: SortingNode[], - sourceCode: TSESLint.SourceCode, - options: Options, -): TSESLint.RuleFix[] { - let fixes: TSESLint.RuleFix[] = [] - let grouped: Record = {} - - for (let node of nodesToFix) { - let groupNumber = getGroupNumber(IMPORT_GROUPS, node) - - grouped[groupNumber] = - groupNumber in grouped ? sortNodes([...grouped[groupNumber], node], options) : [node] - } - - let formatted = Object.keys(grouped) - .sort((a, b) => Number(a) - Number(b)) - .reduce( - (accumulator: SortingNode[], group: string) => [...accumulator, ...grouped[group]], - [], - ) - - for (let max = formatted.length, index = 0; index < max; index++) { - let node = formatted.at(index)! - - fixes.push( - fixer.replaceTextRange( - getNodeRange(nodesToFix.at(index)!.node, sourceCode), - sourceCode.text.slice(...getNodeRange(node.node, sourceCode)), - ), - ) - - if (options.newlinesBetween !== 'ignore') { - let nextNode = formatted.at(index + 1) - - if (nextNode) { - let linesBetweenImports = getLinesBetween( - sourceCode, - nodesToFix.at(index)!, - nodesToFix.at(index + 1)!, - ) - - if ( - (options.newlinesBetween === 'always' && - getGroupNumber(IMPORT_GROUPS, node) === - getGroupNumber(IMPORT_GROUPS, nextNode) && - linesBetweenImports !== 0) || - (options.newlinesBetween === 'never' && linesBetweenImports > 0) - ) { - fixes.push( - fixer.removeRange([ - getNodeRange(nodesToFix.at(index)!.node, sourceCode).at(1)!, - getNodeRange(nodesToFix.at(index + 1)!.node, sourceCode).at(0)! - 1, - ]), - ) - } - - if ( - options.newlinesBetween === 'always' && - getGroupNumber(IMPORT_GROUPS, node) !== - getGroupNumber(IMPORT_GROUPS, nextNode) && - linesBetweenImports > 1 - ) { - fixes.push( - fixer.replaceTextRange( - [ - getNodeRange(nodesToFix.at(index)!.node, sourceCode).at(1)!, - getNodeRange(nodesToFix.at(index + 1)!.node, sourceCode).at(0)! - 1, - ], - '\n', - ), - ) - } - - if ( - options.newlinesBetween === 'always' && - getGroupNumber(IMPORT_GROUPS, node) !== - getGroupNumber(IMPORT_GROUPS, nextNode) && - linesBetweenImports === 0 - ) { - fixes.push( - fixer.insertTextAfterRange( - getNodeRange(nodesToFix.at(index)!.node, sourceCode), - '\n', - ), - ) - } - } - } - } - - return fixes -} diff --git a/src/rules/specifier-order.ts b/src/rules/specifier-order.ts new file mode 100644 index 0000000..f1577bd --- /dev/null +++ b/src/rules/specifier-order.ts @@ -0,0 +1,155 @@ +import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils' + +import { getEslintDisabledLines } from '../utils/get-eslint-disabled-lines.js' +import { isNodeEslintDisabled } from '../utils/is-node-eslint-disabled.js' +import { isSortable } from '../utils/is-sortable.js' +import { makeFixes } from '../utils/make-fixes.js' +import { pairwise } from '../utils/pairwise.js' +import { rangeToDiff } from '../utils/range-to-diff.js' +import { sortNodes } from '../utils/sort-nodes.js' +import type { MemberSortingNode } from '../utils/types.js' + +type ValuesFirstOrder = ['value', 'type'] +type TypesFirstOrder = ['type', 'value'] +type MixedOrder = ['any'] + +interface Options { + ignoreAlias?: boolean + ignoreCase?: boolean + groupKind?: 'values-first' | 'types-first' | 'mixed' + order: 'asc' | 'desc' + type: 'natural' +} + +type MessageId = 'specifier-out-of-order' + +// eslint-disable-next-line new-cap +const createRule = ESLintUtils.RuleCreator( + (name) => + `https://github.com/stormwarning/eslint-plugin-import-sorting/blob/main/docs/rules/${name}.md`, +) + +export default createRule({ + name: 'specifier-order', + meta: { + type: 'suggestion', + fixable: 'code', + docs: { + description: 'Consistently order named import specifiers.', + }, + messages: { + 'specifier-out-of-order': '{{right}} should occur before {{left}}', + }, + schema: [], + }, + defaultOptions: [], + create(context) { + let { sourceCode } = context + let options: Options = { + ignoreAlias: true, + ignoreCase: true, + groupKind: 'mixed', + order: 'asc', + type: 'natural', + } + + return { + ImportDeclaration(node) { + let specifiers = node.specifiers.filter( + ({ type }) => type === AST_NODE_TYPES.ImportSpecifier, + ) + if (!isSortable(specifiers)) return + + let eslintDisabledLines = getEslintDisabledLines({ + ruleName: context.id, + sourceCode, + }) + + let formattedMembers: MemberSortingNode[][] = [[]] + for (let specifier of specifiers) { + let { name } = specifier.local + + if (specifier.type === AST_NODE_TYPES.ImportSpecifier && options.ignoreAlias) { + name = + specifier.imported.type === AST_NODE_TYPES.Identifier + ? specifier.imported.name + : // @ts-expect-error -- `value` possibly missing from type declarations. + (specifier.imported.value as string) + } + + let sortingNode: MemberSortingNode = { + groupKind: + specifier.type === AST_NODE_TYPES.ImportSpecifier && + specifier.importKind === 'type' + ? 'type' + : 'value', + isEslintDisabled: isNodeEslintDisabled(specifier, eslintDisabledLines), + size: rangeToDiff(specifier, sourceCode), + node: specifier, + name, + } + + formattedMembers.at(-1)!.push(sortingNode) + } + + let groupKindOrder: ValuesFirstOrder | TypesFirstOrder | MixedOrder + if (options.groupKind === 'values-first') { + groupKindOrder = ['value', 'type'] + } else if (options.groupKind === 'types-first') { + groupKindOrder = ['type', 'value'] + } else { + groupKindOrder = ['any'] + } + + for (let nodes of formattedMembers) { + let filteredGroupKindNodes = groupKindOrder.map( + (groupKind: 'value' | 'type' | 'any') => + nodes.filter( + (currentNode) => + groupKind === 'any' || currentNode.groupKind === groupKind, + ), + ) + let sortNodesExcludingEslintDisabled = ( + ignoreEslintDisabledNodes: boolean, + ): MemberSortingNode[] => + filteredGroupKindNodes.flatMap((groupedNodes) => + sortNodes(groupedNodes, options, { + ignoreEslintDisabledNodes, + }), + ) + let sortedNodes = sortNodesExcludingEslintDisabled(false) + let sortedNodesExcludingEslintDisabled = sortNodesExcludingEslintDisabled(true) + + pairwise(nodes, (left, right) => { + let indexOfLeft = sortedNodes.indexOf(left) + let indexOfRight = sortedNodes.indexOf(right) + let indexOfRightExcludingEslintDisabled = + sortedNodesExcludingEslintDisabled.indexOf(right) + if ( + indexOfLeft < indexOfRight && + indexOfLeft < indexOfRightExcludingEslintDisabled + ) { + return + } + + context.report({ + fix: (fixer) => + makeFixes({ + fixer, + nodes, + sortedNodes: sortedNodesExcludingEslintDisabled, + sourceCode, + }), + data: { + right: right.name, + left: left.name, + }, + messageId: 'specifier-out-of-order', + node: right.node, + }) + }) + } + }, + } + }, +}) diff --git a/src/utils/compare.ts b/src/utils/compare.ts index 4212103..b93a6ba 100644 --- a/src/utils/compare.ts +++ b/src/utils/compare.ts @@ -1,32 +1,32 @@ import type { SortingNode } from './types.js' -interface BaseCompareOptions { +interface BaseCompareOptions { /** * Custom function to get the value of the node. By default, returns the * node's name. */ - nodeValueGetter?: (node: SortingNode) => string + nodeValueGetter?: (node: T) => string order: 'desc' | 'asc' } -interface NaturalCompareOptions extends BaseCompareOptions { +interface NaturalCompareOptions extends BaseCompareOptions { ignoreCase?: boolean type: 'natural' } -export type CompareOptions = NaturalCompareOptions +export type CompareOptions = NaturalCompareOptions -export function compare(a: SortingNode, b: SortingNode, options: CompareOptions): number { - /** Don't sort unsassigned imports. */ +export function compare(a: T, b: T, options: CompareOptions): number { + /** Don't sort unassigned imports. */ if (a.group === 'unassigned' || b.group === 'unassigned') return 0 if (b.dependencies?.includes(a.name)) return -1 if (a.dependencies?.includes(b.name)) return 1 let orderCoefficient = options.order === 'asc' ? 1 : -1 - let sortingFunction: (a: SortingNode, b: SortingNode) => number + let sortingFunction: (a: T, b: T) => number - let nodeValueGetter = options.nodeValueGetter ?? ((node: SortingNode) => node.name) + let nodeValueGetter = options.nodeValueGetter ?? ((node: T) => node.name) sortingFunction = (aNode, bNode) => { let aImport = stripProtocol(nodeValueGetter(aNode)) diff --git a/src/utils/get-comment.ts b/src/utils/get-comment.ts index bd2c898..5abce58 100644 --- a/src/utils/get-comment.ts +++ b/src/utils/get-comment.ts @@ -1,35 +1,49 @@ import { AST_TOKEN_TYPES, type TSESLint, type TSESTree } from '@typescript-eslint/utils' -export function getCommentBefore( +/** + * Returns a list of comments before a given node, excluding ones that are + * right after code. Includes comment blocks. + */ +export function getCommentsBefore( node: TSESTree.Node, source: TSESLint.SourceCode, -): TSESTree.Comment | undefined { - let [tokenBefore, tokenOrCommentBefore] = source.getTokensBefore(node, { - filter: ({ value, type }) => - !(type === AST_TOKEN_TYPES.Punctuator && [',', ';'].includes(value)), - includeComments: true, - count: 2, - }) as Array + tokenValueToIgnoreBefore?: string, +): TSESTree.Comment[] { + let commentsBefore = getCommentsBeforeNodeOrToken(node, source) + let tokenBeforeNode = source.getTokenBefore(node) if ( - (tokenOrCommentBefore?.type === AST_TOKEN_TYPES.Block || - tokenOrCommentBefore?.type === AST_TOKEN_TYPES.Line) && - node.loc.start.line - tokenOrCommentBefore.loc.end.line <= 1 && - tokenBefore?.loc.end.line !== tokenOrCommentBefore.loc.start.line + commentsBefore.length > 0 || + !tokenValueToIgnoreBefore || + tokenBeforeNode?.value !== tokenValueToIgnoreBefore ) { - return tokenOrCommentBefore + return commentsBefore } - return undefined + return getCommentsBeforeNodeOrToken(tokenBeforeNode, source) +} + +function getCommentsBeforeNodeOrToken( + node: TSESTree.Node | TSESTree.Token, + source: TSESLint.SourceCode, +): TSESTree.Comment[] { + /** + * `getCommentsBefore` also returns comments that are right after code, + * filter those out. + */ + return source.getCommentsBefore(node).filter((comment) => { + let tokenBeforeComment = source.getTokenBefore(comment) + return tokenBeforeComment?.loc.end.line !== comment.loc.end.line + }) } export function getCommentAfter( - node: TSESTree.Node, + node: TSESTree.Node | TSESTree.Token, source: TSESLint.SourceCode, ): TSESTree.Comment | undefined { let token = source.getTokenAfter(node, { filter: ({ value, type }) => - !(type === AST_TOKEN_TYPES.Punctuator && [',', ';'].includes(value)), + !(type === AST_TOKEN_TYPES.Punctuator && [',', ';', ':'].includes(value)), includeComments: true, }) diff --git a/src/utils/get-eslint-disabled-lines.ts b/src/utils/get-eslint-disabled-lines.ts new file mode 100644 index 0000000..da886cd --- /dev/null +++ b/src/utils/get-eslint-disabled-lines.ts @@ -0,0 +1,50 @@ +import type { TSESLint } from '@typescript-eslint/utils' + +import { getEslintDisabledRules } from './get-eslint-disabled-rules.js' + +export function getEslintDisabledLines({ + ruleName, + sourceCode, +}: { + ruleName: string + sourceCode: TSESLint.SourceCode +}): number[] { + let returnValue: number[] = [] + // eslint-disable-next-line no-undef-init + let lineRulePermanentlyDisabled: number | undefined = undefined + + for (let comment of sourceCode.getAllComments()) { + let eslintDisabledRules = getEslintDisabledRules(comment.value) + let isRuleDisabled = + eslintDisabledRules?.rules === 'all' || eslintDisabledRules?.rules.includes(ruleName) + + if (!isRuleDisabled) continue + + switch (eslintDisabledRules?.eslintDisableDirective) { + case 'eslint-disable-next-line': + returnValue.push(comment.loc.end.line + 1) + continue + case 'eslint-disable-line': + returnValue.push(comment.loc.start.line) + continue + case 'eslint-disable': + lineRulePermanentlyDisabled ??= comment.loc.start.line + break + case 'eslint-enable': + if (!lineRulePermanentlyDisabled) continue + + returnValue.push( + ...createArrayFromTo(lineRulePermanentlyDisabled + 1, comment.loc.start.line), + ) + lineRulePermanentlyDisabled = undefined + break + default: + break + } + } + + return returnValue +} + +let createArrayFromTo = (index_: number, index: number): number[] => + Array.from({ length: index - index_ + 1 }, (_, item) => index_ + item) diff --git a/src/utils/get-eslint-disabled-rules.ts b/src/utils/get-eslint-disabled-rules.ts new file mode 100644 index 0000000..38c602a --- /dev/null +++ b/src/utils/get-eslint-disabled-rules.ts @@ -0,0 +1,46 @@ +const ESLINT_DIRECTIVES = [ + 'eslint-disable', + 'eslint-enable', + 'eslint-disable-line', + 'eslint-disable-next-line', +] as const + +export type EslintDisableDirective = (typeof ESLINT_DIRECTIVES)[number] + +export function getEslintDisabledRules(comment: string): + | { + eslintDisableDirective: EslintDisableDirective + rules: string[] | 'all' + } + | undefined { + for (let eslintDisableDirective of ESLINT_DIRECTIVES) { + let disabledRules = getEslintDisabledRulesByType(comment, eslintDisableDirective) + if (disabledRules) { + return { + eslintDisableDirective, + rules: disabledRules, + } + } + } + + return undefined +} + +function getEslintDisabledRulesByType( + comment: string, + eslintDisableDirective: EslintDisableDirective, +): string[] | 'all' | undefined { + let trimmedCommentValue = comment.trim() + + if (eslintDisableDirective === trimmedCommentValue) return 'all' as const + + let regexp = new RegExp(`^${eslintDisableDirective} ((?:.|\\s)*)$`) + let disabledRulesMatch = trimmedCommentValue.match(regexp) + + if (!disabledRulesMatch) return undefined + + return disabledRulesMatch[1] + .split(',') + .map((rule) => rule.trim()) + .filter(Boolean) +} diff --git a/src/utils/get-newline-errors.ts b/src/utils/get-newline-errors.ts new file mode 100644 index 0000000..390f799 --- /dev/null +++ b/src/utils/get-newline-errors.ts @@ -0,0 +1,50 @@ +import type { TSESLint } from '@typescript-eslint/utils' + +import { getLinesBetween } from './get-lines-between.js' +import type { SortingNode } from './types.js' + +interface Options { + newlinesBetween: 'ignore' | 'always' | 'never' +} + +interface GetNewlineErrorsParameters { + missingLineError: T + extraLineError: T + right: SortingNode + rightNumber: number + left: SortingNode + leftNumber: number + sourceCode: TSESLint.SourceCode + options: Options +} + +export function getNewlineErrors({ + missingLineError, + extraLineError, + right, + rightNumber, + left, + leftNumber, + sourceCode, + options, +}: GetNewlineErrorsParameters): T[] { + let errors: T[] = [] + let numberOfEmptyLinesBetween = getLinesBetween(sourceCode, left, right) + + if (options.newlinesBetween === 'never' && numberOfEmptyLinesBetween > 0) { + errors.push(extraLineError) + } + + if (options.newlinesBetween === 'always') { + if (leftNumber < rightNumber && numberOfEmptyLinesBetween === 0) { + errors.push(missingLineError) + } else if ( + numberOfEmptyLinesBetween > 1 || + (leftNumber === rightNumber && numberOfEmptyLinesBetween > 0) + ) { + errors.push(extraLineError) + } + } + + return errors +} diff --git a/src/utils/get-node-range.ts b/src/utils/get-node-range.ts index 31adad2..2b64da2 100644 --- a/src/utils/get-node-range.ts +++ b/src/utils/get-node-range.ts @@ -1,11 +1,13 @@ -import { ASTUtils, type TSESLint, type TSESTree } from '@typescript-eslint/utils' +import { ASTUtils, type TSESLint, type TSESTree, AST_TOKEN_TYPES } from '@typescript-eslint/utils' -import { getCommentBefore } from './get-comment.js' +import { getCommentsBefore } from './get-comment.js' +import { getEslintDisabledRules } from './get-eslint-disabled-rules.js' export function getNodeRange( node: TSESTree.Node, sourceCode: TSESLint.SourceCode, additionalOptions?: { + ignoreHighestBlockComment?: boolean partitionComment?: string[] | boolean | string }, ): TSESTree.Range { @@ -23,21 +25,43 @@ export function getNodeRange( end = bodyClosingParen.range.at(1)! } - let comment = getCommentBefore(node, sourceCode) + let comments = getCommentsBefore(node, sourceCode) + let highestBlockComment = comments.find((comment) => comment.type === AST_TOKEN_TYPES.Block) + let relevantTopComment: TSESTree.Comment | undefined - if (raw.endsWith(';') || raw.endsWith(',')) { - let tokensAfter = sourceCode.getTokensAfter(node, { - includeComments: true, - count: 2, - }) + /** + * Iterate on all comments starting from the bottom until we reach the last + * of the comments, a newline between comments, a partition comment, + * or an eslint-disable comment. + */ + for (let index = comments.length - 1; index >= 0; index--) { + let comment = comments[index] - if (node.loc.start.line === tokensAfter.at(1)?.loc.start.line) { - end -= 1 + let eslintDisabledRules = getEslintDisabledRules(comment.value) + if ( + eslintDisabledRules?.eslintDisableDirective === 'eslint-disable' || + eslintDisabledRules?.eslintDisableDirective === 'eslint-enable' + ) { + break } + + // Check for newlines between comments or between the first comment and + // the node. + let previousCommentOrNodeStartLine = + index === comments.length - 1 ? node.loc.start.line : comments[index + 1].loc.start.line + if (comment.loc.end.line !== previousCommentOrNodeStartLine - 1) { + break + } + + if (additionalOptions?.ignoreHighestBlockComment && comment === highestBlockComment) { + break + } + + relevantTopComment = comment } - if (comment) { - start = comment.range.at(0)! + if (relevantTopComment) { + start = relevantTopComment.range.at(0)! } return [start, end] diff --git a/src/utils/is-node-eslint-disabled.ts b/src/utils/is-node-eslint-disabled.ts new file mode 100644 index 0000000..b23c04c --- /dev/null +++ b/src/utils/is-node-eslint-disabled.ts @@ -0,0 +1,5 @@ +import type { TSESTree } from '@typescript-eslint/utils' + +export function isNodeEslintDisabled(node: TSESTree.Node, eslintDisabledLines: number[]): boolean { + return eslintDisabledLines.includes(node.loc.start.line) +} diff --git a/src/utils/is-sortable.ts b/src/utils/is-sortable.ts new file mode 100644 index 0000000..5dd977b --- /dev/null +++ b/src/utils/is-sortable.ts @@ -0,0 +1,3 @@ +export function isSortable(node: unknown): boolean { + return Array.isArray(node) && node.length > 1 +} diff --git a/src/utils/make-comment-after-fixes.ts b/src/utils/make-comment-after-fixes.ts new file mode 100644 index 0000000..3119bff --- /dev/null +++ b/src/utils/make-comment-after-fixes.ts @@ -0,0 +1,40 @@ +import type { TSESLint, TSESTree } from '@typescript-eslint/utils' + +import { getCommentAfter } from './get-comment.js' + +interface CommentAfterFixesParameters { + fixer: TSESLint.RuleFixer + node: TSESTree.Token | TSESTree.Node + sortedNode: TSESTree.Token | TSESTree.Node + sourceCode: TSESLint.SourceCode +} + +export function makeCommentAfterFixes({ + fixer, + node, + sortedNode, + sourceCode, +}: CommentAfterFixesParameters): TSESLint.RuleFix[] { + let commentAfter = getCommentAfter(sortedNode, sourceCode) + let isNodesOnSameLine = node.loc.start.line === sortedNode.loc.end.line + + if (!commentAfter || isNodesOnSameLine) return [] + + let fixes: TSESLint.RuleFix[] = [] + let tokenBefore = sourceCode.getTokenBefore(commentAfter) + + let range: TSESTree.Range = [tokenBefore!.range.at(1)!, commentAfter.range.at(1)!] + + fixes.push(fixer.replaceTextRange(range, '')) + + let tokenAfterNode = sourceCode.getTokenAfter(node) + + fixes.push( + fixer.insertTextAfter( + tokenAfterNode?.loc.end.line === node.loc.end.line ? tokenAfterNode : node, + sourceCode.text.slice(...range), + ), + ) + + return fixes +} diff --git a/src/utils/make-fixes.ts b/src/utils/make-fixes.ts new file mode 100644 index 0000000..16fd6be --- /dev/null +++ b/src/utils/make-fixes.ts @@ -0,0 +1,82 @@ +import type { TSESLint } from '@typescript-eslint/utils' + +import { getNodeRange } from './get-node-range.js' +import { makeCommentAfterFixes } from './make-comment-after-fixes.js' +import type { SortingNode } from './types.js' + +interface MakeFixesParameters { + fixer: TSESLint.RuleFixer + nodes: SortingNode[] + sortedNodes: SortingNode[] + sourceCode: TSESLint.SourceCode + ignoreFirstNodeHighestBlockComment?: boolean +} + +export function makeFixes({ + fixer, + nodes, + sortedNodes, + sourceCode, + ignoreFirstNodeHighestBlockComment, +}: MakeFixesParameters): TSESLint.RuleFix[] { + let fixes: TSESLint.RuleFix[] = [] + + for (let max = nodes.length, index = 0; index < max; index++) { + let sortingNode = nodes.at(index)! + let sortedSortingNode = sortedNodes.at(index)! + let { node } = sortingNode + let { node: sortedNode } = sortedSortingNode + let isNodeFirstNode = node === nodes.at(0)!.node + let isSortedNodeFirstNode = sortedNode === nodes.at(0)!.node + + if (node === sortedNode) { + continue + } + + let sortedNodeCode = sourceCode.text.slice( + ...getNodeRange(sortedNode, sourceCode, { + ignoreHighestBlockComment: + ignoreFirstNodeHighestBlockComment && isSortedNodeFirstNode, + }), + ) + let sortedNodeText = sourceCode.getText(sortedNode) + let tokensAfter = sourceCode.getTokensAfter(node, { + includeComments: false, + count: 1, + }) + let nextToken = tokensAfter.at(0) + + let sortedNextNodeEndsWithSafeCharacter = + sortedNodeText.endsWith(';') || sortedNodeText.endsWith(',') + let isNextTokenOnSameLineAsNode = nextToken?.loc.start.line === node.loc.end.line + let isNextTokenSafeCharacter = nextToken?.value === ';' || nextToken?.value === ',' + if ( + isNextTokenOnSameLineAsNode && + !sortedNextNodeEndsWithSafeCharacter && + !isNextTokenSafeCharacter + ) { + sortedNodeCode += ';' + } + + fixes.push( + fixer.replaceTextRange( + getNodeRange(node, sourceCode, { + ignoreHighestBlockComment: + ignoreFirstNodeHighestBlockComment && isNodeFirstNode, + }), + sortedNodeCode, + ), + ) + fixes = [ + ...fixes, + ...makeCommentAfterFixes({ + fixer, + node, + sortedNode, + sourceCode, + }), + ] + } + + return fixes +} diff --git a/src/utils/make-newline-fixes.ts b/src/utils/make-newline-fixes.ts new file mode 100644 index 0000000..0f5f9c5 --- /dev/null +++ b/src/utils/make-newline-fixes.ts @@ -0,0 +1,95 @@ +import type { TSESLint } from '@typescript-eslint/utils' + +import { IMPORT_GROUPS } from '../rules/order.js' +import { getGroupNumber } from './get-group-number.js' +import { getLinesBetween } from './get-lines-between.js' +import { getNodeRange } from './get-node-range.js' +import type { SortingNode } from './types.js' + +interface MakeNewlineFixesParameters { + fixer: TSESLint.RuleFixer + nodes: SortingNode[] + sortedNodes: SortingNode[] + sourceCode: TSESLint.SourceCode + options: { + newlinesBetween: 'ignore' | 'always' | 'never' + } +} + +export function makeNewlineFixes({ + fixer, + nodes, + sortedNodes, + sourceCode, + options, +}: MakeNewlineFixesParameters): TSESLint.RuleFix[] { + let fixes: TSESLint.RuleFix[] = [] + + for (let max = sortedNodes.length, index = 0; index < max; index++) { + let sortingNode = sortedNodes.at(index)! + let nextSortingNode = sortedNodes.at(index + 1) + + if (options.newlinesBetween === 'ignore' || !nextSortingNode) { + continue + } + + let nodeGroupNumber = getGroupNumber(IMPORT_GROUPS, sortingNode) + let nextNodeGroupNumber = getGroupNumber(IMPORT_GROUPS, nextSortingNode) + let currentNodeRange = getNodeRange(nodes.at(index)!.node, sourceCode) + let nextNodeRangeStart = getNodeRange(nodes.at(index + 1)!.node, sourceCode).at(0)! + let rangeToReplace: [number, number] = [currentNodeRange.at(1)!, nextNodeRangeStart] + let textBetweenNodes = sourceCode.text.slice(currentNodeRange.at(1), nextNodeRangeStart) + + let linesBetweenMembers = getLinesBetween( + sourceCode, + nodes.at(index)!, + nodes.at(index + 1)!, + ) + + let rangeReplacement: undefined | string + if ( + (options.newlinesBetween === 'always' && + nodeGroupNumber === nextNodeGroupNumber && + linesBetweenMembers !== 0) || + (options.newlinesBetween === 'never' && linesBetweenMembers > 0) + ) { + rangeReplacement = getStringWithoutInvalidNewlines(textBetweenNodes) + } + + if ( + options.newlinesBetween === 'always' && + nodeGroupNumber !== nextNodeGroupNumber && + linesBetweenMembers !== 1 + ) { + rangeReplacement = addNewlineBeforeFirstNewline( + linesBetweenMembers > 1 + ? getStringWithoutInvalidNewlines(textBetweenNodes) + : textBetweenNodes, + ) + let isOnSameLine = + linesBetweenMembers === 0 && + nodes.at(index)!.node.loc.end.line === nodes.at(index + 1)!.node.loc.start.line + if (isOnSameLine) { + rangeReplacement = addNewlineBeforeFirstNewline(rangeReplacement) + } + } + + if (rangeReplacement) { + fixes.push(fixer.replaceTextRange(rangeToReplace, rangeReplacement)) + } + } + + return fixes +} + +function getStringWithoutInvalidNewlines(value: string): string { + return value.replaceAll(/\n\s*\n/gu, '\n').replaceAll(/\n+/gu, '\n') +} + +function addNewlineBeforeFirstNewline(value: string): string { + let firstNewlineIndex = value.indexOf('\n') + + if (firstNewlineIndex === -1) return `${value}\n` + + return `${value.slice(0, firstNewlineIndex)}\n${value.slice(firstNewlineIndex)}` +} diff --git a/src/utils/range-to-diff.ts b/src/utils/range-to-diff.ts new file mode 100644 index 0000000..0cb4c5b --- /dev/null +++ b/src/utils/range-to-diff.ts @@ -0,0 +1,9 @@ +import type { TSESLint, TSESTree } from '@typescript-eslint/utils' + +export function rangeToDiff(node: TSESTree.Node, sourceCode: TSESLint.SourceCode): number { + let nodeText = sourceCode.getText(node) + let hasTrailingCommaOrSemicolon = nodeText.endsWith(';') || nodeText.endsWith(',') + let [from, to] = node.range + + return to - from - (hasTrailingCommaOrSemicolon ? 1 : 0) +} diff --git a/src/utils/sort-nodes-by-groups.ts b/src/utils/sort-nodes-by-groups.ts new file mode 100644 index 0000000..a0b5b9e --- /dev/null +++ b/src/utils/sort-nodes-by-groups.ts @@ -0,0 +1,64 @@ +import { IMPORT_GROUPS } from '../rules/order.js' +import type { CompareOptions } from './compare.js' +import { getGroupNumber } from './get-group-number.js' +import { sortNodes } from './sort-nodes.js' +import type { SortingNode } from './types.js' + +interface ExtraOptions { + /** + * If not provided, `options` will be used. If function returns undefined, + * nodes will not be sorted within the group. + */ + getGroupCompareOptions?(groupNumber: number): CompareOptions | undefined + // eslint-disable-next-line @typescript-eslint/member-ordering + ignoreEslintDisabledNodes?: boolean + isNodeIgnored?(node: T): boolean +} + +export function sortNodesByGroups( + nodes: T[], + options: CompareOptions, + extraOptions?: ExtraOptions, +): T[] { + let nodesByNonIgnoredGroupNumber: Record = {} + let ignoredNodeIndices: number[] = [] + + for (let [index, sortingNode] of nodes.entries()) { + if ( + (sortingNode.isEslintDisabled && extraOptions?.ignoreEslintDisabledNodes) ?? + extraOptions?.isNodeIgnored?.(sortingNode) + ) { + ignoredNodeIndices.push(index) + continue + } + + let groupNumber = getGroupNumber(IMPORT_GROUPS, sortingNode) + nodesByNonIgnoredGroupNumber[groupNumber] ??= [] + nodesByNonIgnoredGroupNumber[groupNumber].push(sortingNode) + } + + let sortedNodes: T[] = [] + for (let groupNumber of Object.keys(nodesByNonIgnoredGroupNumber).sort( + (a, b) => Number(a) - Number(b), + )) { + let compareOptions = extraOptions?.getGroupCompareOptions + ? extraOptions.getGroupCompareOptions(Number(groupNumber)) + : options + + if (!compareOptions) { + sortedNodes.push(...nodesByNonIgnoredGroupNumber[Number(groupNumber)]) + continue + } + + sortedNodes.push( + ...sortNodes(nodesByNonIgnoredGroupNumber[Number(groupNumber)], compareOptions), + ) + } + + // Add ignored nodes at the same position as they were before linting. + for (let ignoredIndex of ignoredNodeIndices) { + sortedNodes.splice(ignoredIndex, 0, nodes[ignoredIndex]) + } + + return sortedNodes +} diff --git a/src/utils/sort-nodes.ts b/src/utils/sort-nodes.ts index c5ae299..d5691b4 100644 --- a/src/utils/sort-nodes.ts +++ b/src/utils/sort-nodes.ts @@ -1,6 +1,32 @@ import { compare, type CompareOptions } from './compare.js' import type { SortingNode } from './types.js' -export function sortNodes(nodes: T[], options: CompareOptions): T[] { - return [...nodes].sort((a, b) => compare(a, b, options)) +interface ExtraOptions { + ignoreEslintDisabledNodes?: boolean +} + +export function sortNodes( + nodes: T[], + options: CompareOptions, + extraOptions?: ExtraOptions, +): T[] { + let nonIgnoredNodes: T[] = [] + let ignoredNodeIndices: number[] = [] + + for (let [index, sortingNode] of nodes.entries()) { + if (sortingNode.isEslintDisabled && extraOptions?.ignoreEslintDisabledNodes) { + ignoredNodeIndices.push(index) + } else { + nonIgnoredNodes.push(sortingNode) + } + } + + let sortedNodes = [...nonIgnoredNodes].sort((a, b) => compare(a, b, options)) + + // Add ignored nodes at the same position as they were before linting. + for (let ignoredIndex of ignoredNodeIndices) { + sortedNodes.splice(ignoredIndex, 0, nodes[ignoredIndex]) + } + + return sortedNodes } diff --git a/src/utils/types.ts b/src/utils/types.ts index 68fb783..cd1f969 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -10,11 +10,17 @@ export type ImportDeclarationNode = export interface SortingNode { name: string node: Node + size: number dependencies?: string[] group?: string + isEslintDisabled: boolean hasMultipleImportDeclarations?: boolean } +export interface MemberSortingNode extends SortingNode { + groupKind: 'value' | 'type' +} + export type ImportGroup = (typeof IMPORT_GROUPS)[number] export type ImportGroups = typeof IMPORT_GROUPS diff --git a/tests/rules/order.test.ts b/tests/rules/order.test.ts index e283556..b55c677 100644 --- a/tests/rules/order.test.ts +++ b/tests/rules/order.test.ts @@ -51,10 +51,7 @@ describe('order', () => { errors: [ { messageId: 'out-of-order', - data: { - left: 'b', - right: 'a', - }, + data: { left: 'b', right: 'a' }, }, ], }, @@ -280,7 +277,13 @@ describe('order', () => { import { a } from 'a' import { c } from 'c' `, - errors: [{ messageId: 'out-of-order' }, { messageId: 'out-of-order' }], + errors: [ + { + data: { left: 'd', right: 'b' }, + messageId: 'out-of-order', + }, + { messageId: 'out-of-order' }, + ], }, ], }) @@ -342,5 +345,21 @@ describe('order', () => { }, ], }) + + ruleTester.run('ignores dynamic requires', orderRule, { + valid: [ + { + name: 'without errors', + code: dedent` + const path = require('path') + + const myFilename = require('the-filename') + const file = require(path.join(myDir, myFilename)) + const other = require('./other.js') + `, + }, + ], + invalid: [], + }) }) }) diff --git a/tests/rules/specifier-order.test.ts b/tests/rules/specifier-order.test.ts new file mode 100644 index 0000000..dc5837f --- /dev/null +++ b/tests/rules/specifier-order.test.ts @@ -0,0 +1,200 @@ +import { RuleTester } from '@typescript-eslint/rule-tester' +import dedent from 'dedent' +import { afterAll, describe, it } from 'vitest' + +import rule from '../../src/rules/specifier-order.js' + +describe('specifier-order', () => { + RuleTester.describeSkip = describe.skip + RuleTester.afterAll = afterAll + RuleTester.describe = describe + RuleTester.itOnly = it.only + RuleTester.itSkip = it.skip + RuleTester.it = it + + let ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + parserOptions: { + sourceType: 'module', + ecmaVersion: 'latest', + }, + // Use this after upgrade to eslint@9. + // languageOptions: { ecmaVersion: 'latest', sourceType: 'module' }, + settings: { + 'import-sorting/framework-patterns': [/^react(\/|-dom|-router|$)/.source, 'prop-types'], + 'import-sorting/internal-patterns': /^~/.source, + }, + }) + + describe('sorting by natural order', () => { + ruleTester.run('sorts specifiers', rule, { + valid: [ + { + name: 'without errors', + code: dedent` + import { a, bb, ccc } from 'package' + `, + }, + ], + invalid: [ + { + name: 'fixes import order', + code: dedent` + import { bb, a, ccc } from 'package' + `, + output: dedent` + import { a, bb, ccc } from 'package' + `, + errors: [ + { + messageId: 'specifier-out-of-order', + data: { left: 'bb', right: 'a' }, + }, + ], + }, + ], + }) + + ruleTester.run('sorts specifiers on multiple lines', rule, { + valid: [ + { + name: 'without errors', + code: dedent` + import { + a, + bb, + ccc, + dddd, + } from 'package' + `, + }, + ], + invalid: [ + { + name: 'fixes member order', + code: dedent` + import { + a, + dddd, + bb, + ccc, + } from 'package' + `, + output: dedent` + import { + a, + bb, + ccc, + dddd, + } from 'package' + `, + errors: [ + { + messageId: 'specifier-out-of-order', + data: { left: 'dddd', right: 'bb' }, + }, + ], + }, + ], + }) + + ruleTester.run('sorts aliased specifiers', rule, { + valid: [ + { + name: 'without errors', + code: dedent` + import { + a as z, + bb as y, + ccc, + } from 'package' + `, + }, + ], + invalid: [ + { + name: 'fixes member order', + code: dedent` + import { + ccc, + bb as y, + a as z, + } from 'package' + `, + output: dedent` + import { + a as z, + bb as y, + ccc, + } from 'package' + `, + errors: [ + { + messageId: 'specifier-out-of-order', + data: { left: 'ccc', right: 'bb' }, + }, + { + messageId: 'specifier-out-of-order', + data: { left: 'bb', right: 'a' }, + }, + ], + }, + ], + }) + + ruleTester.run('does not sort default specifiers', rule, { + valid: [ + { + name: 'without errors', + code: dedent` + import C, { b as A } from 'package' + `, + }, + ], + invalid: [], + }) + + ruleTester.run('groups specifiers by kind', rule, { + valid: [ + { + name: 'without errors', + code: dedent` + import { + a, + type bb, + bb, + type ccc, + } from 'package' + `, + }, + ], + invalid: [ + { + name: 'fixes member order', + code: dedent` + import { + a, + type bb, + type ccc, + bb, + } from 'package' + `, + output: dedent` + import { + a, + type bb, + bb, + type ccc, + } from 'package' + `, + errors: [ + { + messageId: 'specifier-out-of-order', + data: { left: 'ccc', right: 'bb' }, + }, + ], + }, + ], + }) + }) +})