diff --git a/.eslintrc.js b/.eslintrc.js index 78a6f1e..6e6eca2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,21 +1,72 @@ "use strict"; -const baseRules = require("eslint-config-lydell"); +const error = "error"; +const warn = process.argv.includes("--report-unused-disable-directives") + ? "error" + : "warn"; module.exports = { root: true, - plugins: ["import", "jest"], + extends: ["eslint:recommended"], + plugins: ["jest"], + parserOptions: { + ecmaVersion: 2018, + }, env: { es6: true, node: true }, - rules: Object.assign({}, baseRules({ import: true }), { - "import/order": ["error", { "newlines-between": "always" }], - "no-console": "error", - "prefer-template": "off", - }), + rules: { + "arrow-body-style": warn, + "default-case": error, + "default-case-last": warn, + "dot-notation": warn, + "no-caller": error, + "no-console": warn, + "no-eval": error, + "no-labels": error, + "no-octal-escape": error, + "no-param-reassign": error, + "no-promise-executor-return": error, + "no-restricted-syntax": [ + error, + { + selector: "SequenceExpression", + message: + "The comma operator is confusing and a common mistake. Don’t use it!", + }, + ], + "no-self-compare": error, + "no-shadow": "error", + "no-template-curly-in-string": error, + "no-unmodified-loop-condition": error, + "no-unneeded-ternary": warn, + "no-useless-backreference": error, + "no-useless-computed-key": warn, + "no-useless-concat": warn, + "no-useless-constructor": warn, + "no-useless-rename": warn, + "no-var": warn, + "object-shorthand": warn, + "one-var": [warn, "never"], + "prefer-arrow-callback": warn, + "prefer-const": warn, + "prefer-destructuring": [warn, { object: true, array: false }], + "prefer-exponentiation-operator": warn, + "prefer-numeric-literals": warn, + "prefer-object-spread": warn, + "prefer-promise-reject-errors": error, + "prefer-regex-literals": warn, + "prefer-rest-params": warn, + "prefer-spread": warn, + "prefer-template": warn, + curly: warn, + eqeqeq: [error, "always", { null: "ignore" }], + strict: error, + yoda: warn, + }, overrides: [ { files: ["*.test.js"], - env: { jest: true }, - rules: baseRules({ builtin: false, jest: true }), + extends: ["plugin:jest/recommended"], + env: { "jest/globals": true }, }, ], }; diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..7d059ed --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,42 @@ +name: Check + +on: + push: + branches: + - "main" + pull_request: + +jobs: + main: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [15.x] + + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-node@v1 + with: + node-version: "${{ matrix.node-version }}" + + - name: Cache node_modules + id: cache-node_modules + uses: actions/cache@v2 + with: + path: node_modules + key: node_modules-${{ matrix.node-version }}-${{ hashFiles('package-lock.json') }} + + - name: npm ci + if: steps.cache-node_modules.outputs.cache-hit != 'true' + run: npm ci + + - name: Build + run: npm run build + + - name: ESLint + run: npx --no-install eslint . --report-unused-disable-directives + + - name: Prettier + run: npx --no-install prettier --check . diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index b3f13d6..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: CI - -on: [push, pull_request] - -jobs: - build: - runs-on: ${{ matrix.os }} - - strategy: - matrix: - node-version: [10.x, 12.x, 14.x] - os: [ubuntu-latest, windows-latest, macOS-latest] - - steps: - - name: Set git to use LF - run: | - git config --global core.autocrlf false - git config --global core.eol lf - - - uses: actions/checkout@v2 - - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - - name: Cache node_modules - uses: actions/cache@v1 - with: - path: node_modules - key: node_modules-${{ matrix.node-version }}-${{ matrix.os }}-${{ hashFiles('package-lock.json') }} - - - name: Run workflow - shell: bash - run: | - test -d node_modules || npm ci - npm test - env: - CI: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..930f58c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,36 @@ +name: Test + +on: + push: + branches: + - "main" + pull_request: + +jobs: + main: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [10.x, 12.x, 14.x, 15.x] + + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-node@v1 + with: + node-version: "${{ matrix.node-version }}" + + - name: Cache node_modules + id: cache-node_modules + uses: actions/cache@v2 + with: + path: node_modules + key: node_modules-${{ matrix.node-version }}-${{ hashFiles('package-lock.json') }} + + - name: npm ci + if: steps.cache-node_modules.outputs.cache-hit != 'true' + run: npm ci + + - name: Jest + run: npx --no-install jest --coverage diff --git a/CHANGELOG.md b/CHANGELOG.md index ee33496..06bc311 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +### Version 7.0.0 (2020-12-08) + +You can now customize where type imports (`import type { X } from "x"`) go, via the `groups` option. Type imports have `\u0000` at the end. + +This is only a breaking change if you use the `groups` option and your regexes care about what the _last_ character is. If so, you now need to account for the fact that the last character of type imports is `\u0000`. + +### Version 6.0.1 (2020-11-19) + +- Fixed: `as default` in exports no longer results in invalid code. + +### Version 6.0.0 (2020-11-15) + +- Renamed: `simple-import-sort/sort` is now called `simple-import-sort/imports`. +- Added: `simple-import-sort/exports` for sorting (some) exports. Big thanks to Remco Haszing (@remcohaszing) for the suggestion and great feedback, and to @JCrepin for the initial implementation! +- Fixed: `../..` imports are now sorted properly based on directory hierarchy. +- Improved: The default regexes for the `groups` option can now be reordered freely without causing imports to unexpectedly end up in other groups than before. +- Removed: Support for Node.js 8. + ### Version 5.0.3 (2020-04-27) - Improved: Reduced package size by 50%. diff --git a/README.md b/README.md index 5ea7a36..5d2a391 100644 --- a/README.md +++ b/README.md @@ -2,20 +2,20 @@ Easy autofixable import sorting. -- ✔️ Runs via `eslint --fix` – no new tooling -- ✔️ Handles comments -- ✔️ Handles [Flow type imports] \(via [babel-eslint]) -- ✔️ [TypeScript] friendly \(via [@typescript-eslint/parser]) -- ✔️ [Prettier] friendly -- ✔️ [eslint-plugin-import] friendly -- ✔️ `git diff` friendly -- ✔️ 100% code coverage +- ✅️ Runs via `eslint --fix` – no new tooling +- ✅️ Also sorts exports where possible +- ✅️ Handles comments +- ✅️ Handles type imports/exports +- ✅️ [TypeScript] friendly \(via [@typescript-eslint/parser]) +- ✅️ [Prettier] friendly +- ✅️ [eslint-plugin-import] friendly +- ✅️ `git diff` friendly +- ✅️ 100% code coverage - ❌ [Does not support `require`][no-require] This is for those who use `eslint --fix` (autofix) a lot and want to completely forget about sorting imports! [@typescript-eslint/parser]: https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/parser -[babel-eslint]: https://github.com/babel/babel-eslint [eslint-plugin-import]: https://github.com/benmosher/eslint-plugin-import/ [no-require]: https://github.com/lydell/eslint-plugin-simple-import-sort/#does-it-support-require [prettier]: https://prettier.io/ @@ -25,24 +25,26 @@ This is for those who use `eslint --fix` (autofix) a lot and want to completely - - [Example](#example) - [Installation](#installation) - [Usage](#usage) - [Example configuration](#example-configuration) - [Sort order](#sort-order) + - [Grouping](#grouping) + - [imports](#imports) + - [exports](#exports) + - [Sorting](#sorting) + - [Example](#example-1) - [Custom grouping](#custom-grouping) - [Comment and whitespace handling](#comment-and-whitespace-handling) - [FAQ](#faq) - [Does it support `require`?](#does-it-support-require) - [Why sort on `from`?](#why-sort-on-from) - - [Is sorting imports safe?](#is-sorting-imports-safe) + - [Is sorting imports/exports safe?](#is-sorting-importsexports-safe) - [The sorting autofix causes some odd whitespace!](#the-sorting-autofix-causes-some-odd-whitespace) - [Can I use this without autofix?](#can-i-use-this-without-autofix) - [How do I use eslint-ignore for this rule?](#how-do-i-use-eslint-ignore-for-this-rule) -- [Development](#development) - - [npm scripts](#npm-scripts) - - [Directories](#directories) + - [How is this rule different from `import/order`?](#how-is-this-rule-different-from-importorder) - [License](#license) @@ -83,16 +85,8 @@ import styles from "./styles.css"; ## Installation -First you need to install [ESLint]: - ``` -npm install --save-dev eslint -``` - -Next, install `eslint-plugin-simple-import-sort`: - -``` -npm install --save-dev eslint-plugin-simple-import-sort +npm install --save-dev eslint eslint-plugin-simple-import-sort ``` ## Usage @@ -105,12 +99,13 @@ Add `simple-import-sort` to the plugins section of your `.eslintrc` configuratio } ``` -Then add the import sort rule: +Then add the rules for sorting imports and exports: ```json { "rules": { - "simple-import-sort/sort": "error" + "simple-import-sort/imports": "error", + "simple-import-sort/exports": "error" } } ``` @@ -134,7 +129,7 @@ Since this plugin does not support [sorting `require`][no-require], you might wa { "files": "server/**/*.js", "rules": { - "simple-import-sort/sort": "off", + "simple-import-sort/imports": "off", "import/order": ["error", { "newlines-between": "always" }] } } @@ -156,7 +151,8 @@ It is recommended to also set up [Prettier], to help formatting your imports (an "env": { "es6": true }, "plugins": ["simple-import-sort", "import"], "rules": { - "simple-import-sort/sort": "error", + "simple-import-sort/imports": "error", + "simple-import-sort/exports": "error", "sort-imports": "off", "import/first": "error", "import/newline-after-import": "error", @@ -167,7 +163,7 @@ It is recommended to also set up [Prettier], to help formatting your imports (an "files": "server/**/*.js", "env": { "node": true }, "rules": { - "simple-import-sort/sort": "off", + "simple-import-sort/imports": "off", "import/order": ["error", { "newlines-between": "always" }] } } @@ -175,12 +171,12 @@ It is recommended to also set up [Prettier], to help formatting your imports (an } ``` -- `simple-import-sort/sort` is turned on by default. -- The standard [sort-imports] rule is turned off, in case you extend a config that includes it. +- `simple-import-sort/imports` and `simple-import-sort/exports` are turned on for all files. +- [sort-imports] \(ESLint core rule) is turned off, in case you extend a config that includes it. - [import/first] makes sure all imports are at the top of the file. (autofixable) - [import/newline-after-import] makes sure there’s a newline after the imports. (autofixable) - [import/no-duplicates] merges import statements of the same file. (autofixable, mostly) -- For Node.js code, `simple-import-sort/sort` is turned off and replaced with [import/order] for sorting of `require` calls. +- For Node.js code, `simple-import-sort/imports` is turned off and replaced with [import/order] for sorting of `require` calls. With the above configuration, you don’t need to scroll to the top of the file to add another import. Just put it above your function! ESLint will then snap it into place (at the top of the file, in order, and without duplicates). @@ -192,6 +188,10 @@ This section is for learning how the sorting works, not for how to manually fix **TL;DR:** First group, then sort alphabetically. +### Grouping + +#### imports + First, the plugin finds all _chunks_ of imports. A “chunk” is a sequence of import statements with only comments and whitespace between. Each chunk is sorted separately. Use [import/first] if you want to make sure that all imports end up in the same chunk. Then, each chunk is _grouped_ into sections with a blank line between each. @@ -203,7 +203,42 @@ Then, each chunk is _grouped_ into sections with a blank line between each. Note: The above groups are very loosely defined. See [Custom grouping] for more information. -Within each section, the imports are sorted alphabetically on the `from` string (see also [“Why sort on `from`?”][sort-from]). Keep it simple! It helps looking at the code here: +#### exports + +Sequences of re-exports (exports with `from`) are sorted. Other types of exports are not reordered. + +Unlike imports, there’s no automatic grouping of exports. Instead a comment on its own line starts a group. This leaves the grouping up to you to do manually. + +The following example has 3 groups (one with “x” and “y”, one with “a” and “b” and one with “./”): + +```js +export * from "x"; +export * from "y"; + +// This comment starts a new group. +/* This one does not. */ export * from "a"; // Neither does this one. +/* Nor this +one */ export * from "b"; +/* But this one does. */ +export * from "./"; +``` + +Each group is sorted separately, and the groups themselves aren’t sorted – they stay where you wrote them. + +Without the grouping comments the above example would end up like this: + +```js +export * from "./"; +/* This one does not. */ export * from "a"; // Neither does this one. +/* Nor this +one */ export * from "b"; +export * from "x"; +export * from "y"; +``` + +### Sorting + +Within each section, the imports/exports are sorted alphabetically on the `from` string (see also [“Why sort on `from`?”][sort-from]). Keep it simple! It helps looking at the code here: ```js const collator = new Intl.Collator("en", { @@ -216,13 +251,13 @@ function compare(a, b) { } ``` -In other words, the imports within groups are sorted alphabetically, case-insensitively and treating numbers like a human would, falling back to good old character code sorting in case of ties. See [Intl.Collator] for more information. +In other words, the imports/exports within groups are sorted alphabetically, case-insensitively and treating numbers like a human would, falling back to good old character code sorting in case of ties. See [Intl.Collator] for more information. -Since “.” sorts before “/”, relative imports of files higher up in the directory structure come before closer ones – `"../../utils"` comes before `"../utils"`. Perhaps surprisingly though, `".."` would come before `"../../utils"` (since shorter substrings sort before longer strings). For that reason there’s one addition to the alphabetical rule: `"."` and `".."` are treated as `"./"` and `"../"`. +There’s one addition to the alphabetical rule: Directory structure. Relative imports/exports of files higher up in the directory structure come before closer ones – `"../../utils"` comes before `"../utils"`, which comes before `"."`. (In short, `.` and `/` sort before any other (non-whitespace, non-control) character. `".."` and similar sort like `"../,"` (to avoid the “shorter prefix comes first” sorting concept).) -If both `import type` _and_ regular imports are used for the same source, the type imports come first. +If both `import type` _and_ regular imports are used for the same source, the type imports come first. Same thing for `export type`. (You can move type imports to their own group, as mentioned in [custom grouping].) -Example: +### Example ```js @@ -238,22 +273,52 @@ import fs from "fs"; import b from "https://example.com/script.js"; // Absolute imports and other imports. -import Error from "@/components/error.vue"; import c from "/"; import d from "/home/user/foo"; +import Error from "@/components/error.vue"; // Relative imports. import e from "../.."; -import f from "../../Utils"; // Case insensitive. import type { B } from "../types"; import typeof C from "../types"; +import f from "../Utils"; // Case insensitive. import g from "."; import h from "./constants"; import i from "./styles"; -// Regardless of group, imported items are sorted like this: +// Different types of exports: +export { a } from "../.."; +export { b } from "/"; +export { Error } from "@/components/error.vue"; +export * from "an-npm-package"; +export { readFile } from "fs"; +export * as ns from "https://example.com/script.js"; + +// This comment groups some more exports: +export { e } from "../.."; +export { f } from "../Utils"; +export { g } from "."; +export { h } from "./constants"; +export { i } from "./styles"; + +// Other exports – the plugin does not touch these, other than sorting named +// exports inside braces. +export var one = 1; +export let two = 2; +export const three = 3; +export function func() {} +export class Class {} +export type Type = string; +export { named, other as renamed }; +export type { T, U as V }; +export default whatever; +``` + +Regardless of group, imported items are sorted like this: + +```js import { - // First, type imports. + // First, type imports. (`export { type x, typeof y }` is a syntax error). type x, typeof y, // Numbers are sorted by their numeric value: @@ -263,12 +328,27 @@ import { // Then everything else, alphabetically: k, L, // Case insensitive. - m as anotherName, // Sorted by the original name “m”, not “anotherName”. - m as tie, // But do use the \`as\` name in case of a tie. + m as anotherName, // Sorted by the “external interface” name “m”, not “anotherName”. + m as tie, // But do use the file-local name in case of a tie. n, } from "./x"; ``` +Exported items are sorted even for exports _without_ `from` (even though the whole export statement itself isn’t sorted in relation to other exports): + +```js +export { + k, + L, // Case insensitive. + anotherName as m, // Sorted by the “external interface” name “m”, not “anotherName”. + // tie as m, // For exports there can’t be ties – all exports must be unique. + n, +}; +export type { A, B, A as C }; +``` + +At first it might sound counter-intuitive that `a as b` is sorted by `a` for imports, but by `b` for exports. The reason for doing it this way is to pick the most “stable” name. In `import { a as b } from "./some-file.js"`, the `as b` part is there to avoid a name collision in the file without having to change `some-file.js`. In `export { b as a }`, the `b as` part is there to avoid a name collision in the file without having to change the exported interface of the file. + ```js @@ -440,11 +522,11 @@ import {/* comment at start */ f, /* f */g/* g */ } from "wherever3"; If you wonder what’s up with the strange whitespace – see [“The sorting autofix causes some odd whitespace!”][odd-whitespace] -Speaking of whitespace – what about blank lines? Just like comments, it’s difficult to know where blank lines should go after sorting. This plugin went with a simple approach – all blank lines in chunks of imports are removed, except in `/**/` comments and the blank lines added between the groups mentioned in [Sort order]. +Speaking of whitespace – what about blank lines? Just like comments, it’s difficult to know where blank lines should go after sorting. This plugin went with a simple approach – all blank lines in chunks of imports/exports are removed, except in `/**/` comments and the blank lines added between the groups mentioned in [Sort order]. (Note: For exports, blank lines between groups are completely up to you – if you have blank lines around the grouping comments they are preserved.) (Since blank lines are removed, you might get slight incompatibilities with the [lines-around-comment] and [padding-line-between-statements] rules – I don’t use those myself, but I think there should be workarounds.) -The final whitespace rule is that this plugin puts one import per line. I’ve never seen real projects that intentionally puts several imports on the same line. +The final whitespace rule is that this plugin puts one import/export per line. I’ve never seen real projects that intentionally puts several imports/exports on the same line. ## FAQ @@ -479,11 +561,11 @@ import { productType } from "./constants"; On the other hand, if sorting based on the `from` string (like this plugin does), the imports stay in the same order. This prevents the imports from jumping around as you add and remove things, keeping your git history clean and reducing the risk of merge conflicts. -### Is sorting imports safe? +### Is sorting imports/exports safe? Mostly. -Imports can have side effects in JavaScript, so changing the order of the imports can change the order that those side effects execute in. It is best practice to _either_ import a module for its side effects _or_ for the things it exports. +Imports and re-exports can have side effects in JavaScript, so changing the order of them can change the order that those side effects execute in. It is best practice to _either_ import a module for its side effects _or_ for the things it exports (and _never_ rely on side effects from re-exports). ```js // An `import` that runs side effects: @@ -504,7 +586,7 @@ Imports that _both_ export stuff _and_ run side effects are rare. If you run int Another small caveat is that you sometimes need to move comments manually – see [Comment and whitespace handling][comment-handling]. -For completeness, sorting the imported _items_ of an import is always safe: +For completeness, sorting the imported/exported _items_ of an import is always safe: ```js import { c, b, a } from "wherever"; @@ -514,6 +596,54 @@ import { a, b, c } from "wherever"; Note: `import {} from "wherever"` is _not_ treated as a side effect import. +Finally, there’s one more thing to know about exports. Consider this case: + +_one.js:_ + +```js +export const title = "One"; +export const one = 1; +``` + +_two.js:_ + +```js +export const title = "Two"; +export const two = 2; +``` + +_reexport.js:_ + +```js +export * from "./one.js"; +export * from "./two.js"; +``` + +_main.js:_ + +```js +import * as reexport from "./rexport.js"; +console.log(reexport); +``` + +What happens if you run _main.js?_ In Node.js and browsers the result is: + +```js +{ + one: 1, + two: 2, +} +``` + +Note how `title` is not even present in the object! This is good for sorting, because it means that it’s safe to reorder the two `export * from` exports in _reexport.js_ – it’s not like the last import “wins” and you’d accidentally change the value of `title` by sorting. + +However, this _might_ still cause issues depending on which bundler you use. Here’s how a few bundlers handled the duplicate name `title` the time of this writing: + +- ✅ Webpack: Compile time error – safe. +- ✅ Parcel: Run time error – safe. +- ⚠️ Rollup: Compile time warning, but uses the first one of them so it’s potentially unsafe. It’s possible to configure Rollup to treat warnings as errors, though. +- ✅ TypeScript: Compile time error – safe. + ### The sorting autofix causes some odd whitespace! You might end up with slightly weird spacing, for example a missing space after a comma: @@ -523,7 +653,7 @@ You might end up with slightly weird spacing, for example a missing space after import {bar, baz,foo} from "example"; ``` -Sorting is the easy part of this plugin. Handling whitespace and comments is the hard part. The autofix might end up with a little odd spacing around an import sometimes. Rather than fixing those spaces by hand, I recommend using [Prettier] or enabling other autofixable ESLint whitespace rules. See [examples] for more information. +Sorting is the easy part of this plugin. Handling whitespace and comments is the hard part. The autofix might end up with a little odd spacing around an import/export sometimes. Rather than fixing those spaces by hand, I recommend using [Prettier] or enabling other autofixable ESLint whitespace rules. See [examples] for more information. The reason the whitespace can end up weird is because this plugin re-uses and moves around already existing whitespace rather than removing and adding new whitespace. This is to stay compatible with other ESLint rules that deal with whitespace. @@ -535,38 +665,36 @@ Not really. The error message for this rule is literally “Run autofix to sort Looking for `/* eslint-disable */` for this rule? Read all about **[ignoring (parts of) sorting][example-ignore].** -## Development - -You need [Node.js] ~12 and npm 6. +### How is this rule different from `import/order`? -### npm scripts +The [import/order] rule used to not support alphabetical sorting but now it does. So what does `eslint-plugin-simple-import-sort` bring to the table? -- `npx jest --watch`: Run [Jest] tests in watch mode. -- `npm run doctoc`: Run [doctoc] on README.md. -- `npm run prettier`: Autoformat files with [Prettier]. -- `npm run eslint`: Autofix [ESLint] errors. -- `npm run eslint:examples`: Used by `test/examples.test.js`. -- `npm test`: Check that everything works. -- `npm publish`: Publish to [npm], but only if `npm test` passes. +- Sorts imported/exported items (`import { a, b, c } from "."`): [eslint-plugin-import#1787](https://github.com/benmosher/eslint-plugin-import/issues/1787) +- Sorts re-exports: [eslint-plugin-import#1888](https://github.com/benmosher/eslint-plugin-import/issues/1888) +- Supports comments: [eslint-plugin-import#1450](https://github.com/benmosher/eslint-plugin-import/issues/1450), [eslint-plugin-import#1723](https://github.com/benmosher/eslint-plugin-import/issues/1723) +- Supports type imports: [eslint-plugin-import#645](https://github.com/benmosher/eslint-plugin-import/issues/645) +- Supports absolute imports: [eslint-plugin-import#512](https://github.com/benmosher/eslint-plugin-import/issues/512) +- Allows choosing where side effect imports go: [eslint-plugin-import#970](https://github.com/benmosher/eslint-plugin-import/issues/970) +- Allows custom ordering within groups: [eslint-plugin-import#1378](https://github.com/benmosher/eslint-plugin-import/issues/1378) +- Sorts numerically (`"./img10.jpg"` sorts after `"./img2.jpg"`, not before) +- Open `import/order` issues: [import/export ordering](https://github.com/benmosher/eslint-plugin-import/labels/import%2Fexport%20ordering) -### Directories +Some other differences: -- `src/`: Source code. -- `examples/`: Examples, tested in `test/examples.test.js`. -- `test/`: [Jest] tests. +- This plugin gives you a single error for each chunk of imports/exports, while `import/order` can give multiple (see [Can I use this without autofix?][autofix] for details). In other words, this plugin is noisier in terms of underlined lines in your editor, while `import/order` is noisier in terms of error count. +- This plugin has a single (though very powerful) option that is a bunch of regexes, while `import/order` has bunch of different options. It’s unclear which is easier to configure. But `eslint-plugin-simple-import-sort` tries to do the maximum out of the box. ## License [MIT](LICENSE) +[autofix]: #can-i-use-this-without-autofix [comment-handling]: #comment-and-whitespace-handling [custom grouping]: #custom-grouping -[doctoc]: https://github.com/thlorenz/doctoc/ [eslint-fix]: https://eslint.org/docs/user-guide/command-line-interface#--fix -[eslint]: https://eslint.org/ [example-ignore]: https://github.com/lydell/eslint-plugin-simple-import-sort/blob/master/examples/ignore.js [examples]: https://github.com/lydell/eslint-plugin-simple-import-sort/blob/master/examples/.eslintrc.js -[flow type imports]: https://flow.org/en/docs/types/modules/ +[exports]: #exports [flow]: https://flow.org/ [import/first]: https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/first.md [import/first]: https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/first.md @@ -575,10 +703,7 @@ You need [Node.js] ~12 and npm 6. [import/order]: https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/order.md [intl.collator]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Collator [issue #31]: https://github.com/lydell/eslint-plugin-simple-import-sort/issues/31 -[jest]: https://jestjs.io/ [lines-around-comment]: https://eslint.org/docs/rules/lines-around-comment -[node.js]: https://nodejs.org/en/ -[npm]: https://www.npmjs.com/ [odd-whitespace]: #the-sorting-autofix-causes-some-odd-whitespace [padding-line-between-statements]: https://eslint.org/docs/rules/padding-line-between-statements [sort order]: #sort-order diff --git a/examples/.eslintrc.js b/examples/.eslintrc.js index 09ff288..ab4c7fa 100644 --- a/examples/.eslintrc.js +++ b/examples/.eslintrc.js @@ -13,14 +13,17 @@ module.exports = { }, env: { es6: true }, rules: { - // The actual rule name is "simple-import-sort/sort", but for technical - // reasons it’s just called "sort" within the examples of this repo. - // "simple-import-sort/sort": "error", - sort: "error", + // The actual rule names are "simple-import-sort/imports" and + // "simple-import-sort/exports", but for technical reasons they’re called + // just "imports" and "exports" within the examples of this repo. + // "simple-import-sort/imports": "error", + // "simple-import-sort/exports": "error", + imports: "error", + exports: "error", }, overrides: [ { - // This file only enables the “sort” rule from this plugin. After + // This file only enables the “imports” rule from this plugin. After // autofixing, there might be some oddly placed spaces. files: ["1.spaces.just-sort.js"], }, @@ -65,7 +68,7 @@ module.exports = { { files: ["groups.custom.js"], rules: { - sort: [ + imports: [ "error", { groups: [ @@ -94,11 +97,125 @@ module.exports = { { files: ["groups.no-blank-lines.js"], rules: { - sort: [ + imports: [ "error", { // The default grouping, but with no blank lines. - groups: [["^\\u0000", "^@?\\w", "^[^.]", "^\\."]], + groups: [["^\\u0000", "^@?\\w", "^", "^\\."]], + }, + ], + }, + }, + { + files: ["groups.default-reverse.js"], + rules: { + imports: [ + "error", + { + // The default grouping, but in reverse. + groups: [["^\\."], ["^"], ["^@?\\w"], ["^\\u0000"]], + }, + ], + }, + }, + { + files: ["groups.type-imports-first.ts"], + parser: "@typescript-eslint/parser", + rules: { + imports: [ + "error", + { + // The default grouping, but with type imports first as a separate group. + groups: [["^.*\\u0000$"], ["^\\u0000"], ["^@?\\w"], ["^"], ["^\\."]], + }, + ], + }, + }, + { + files: ["groups.type-imports-last.ts"], + parser: "@typescript-eslint/parser", + rules: { + imports: [ + "error", + { + // The default grouping, but with type imports last as a separate group. + groups: [["^\\u0000"], ["^@?\\w"], ["^"], ["^\\."], ["^.+\\u0000$"]], + }, + ], + }, + }, + { + files: ["groups.type-imports-first-sorted.ts"], + parser: "@typescript-eslint/parser", + rules: { + imports: [ + "error", + { + // The default grouping, but with type imports first as a separate + // group, sorting that group like non-type imports are grouped. + groups: [ + ["^@?\\w.*\\u0000$", "^[^.].*\\u0000$", "^\\..*\\u0000$"], + ["^\\u0000"], + ["^@?\\w"], + ["^"], + ["^\\."], + ], + }, + ], + }, + }, + { + files: ["groups.type-imports-last-sorted.ts"], + parser: "@typescript-eslint/parser", + rules: { + imports: [ + "error", + { + // The default grouping, but with type imports last as a separate + // group, sorting that group like non-type imports are grouped. + groups: [ + ["^\\u0000"], + ["^@?\\w"], + ["^"], + ["^\\."], + ["^@?\\w.*\\u0000$", "^[^.].*\\u0000$", "^\\..*\\u0000$"], + ], + }, + ], + }, + }, + { + files: ["groups.type-imports-first-in-each-group.ts"], + parser: "@typescript-eslint/parser", + rules: { + imports: [ + "error", + { + // The default grouping, but with type imports first in each group. + groups: [ + ["^\\u0000"], + ["^@?\\w.*\\u0000$", "^@?\\w"], + ["(?<=\\u0000)$", "^"], + ["^\\..*\\u0000$", "^\\."], + ], + }, + ], + }, + }, + { + files: ["groups.type-imports-last-in-each-group.ts"], + parser: "@typescript-eslint/parser", + rules: { + imports: [ + "error", + { + // The default grouping, but with type imports last in each group. + groups: [ + ["^\\u0000"], + ["^@?\\w", "^@?\\w.*\\u0000$"], + ["(? ({ + Program: (programNode) => { + const sourceCode = context.getSourceCode(); + for (const chunk of shared.extractChunks(programNode, (node, lastNode) => + isPartOfChunk(node, lastNode, sourceCode) + )) { + maybeReportChunkSorting(chunk, context); + } + }, + ExportNamedDeclaration: (node) => { + if (node.source == null && node.declaration == null) { + maybeReportExportSpecifierSorting(node, context); + } + }, + }), +}; + +function maybeReportChunkSorting(chunk, context) { + const sourceCode = context.getSourceCode(); + const items = shared.getImportExportItems( + chunk, + sourceCode, + () => false, // isSideEffectImport + getSpecifiers + ); + const sortedItems = [[shared.sortImportExportItems(items)]]; + const sorted = shared.printSortedItems(sortedItems, items, sourceCode); + const { start } = items[0]; + const { end } = items[items.length - 1]; + shared.maybeReportSorting(context, sorted, start, end); +} + +function maybeReportExportSpecifierSorting(node, context) { + const sorted = shared.printWithSortedSpecifiers( + node, + context.getSourceCode(), + getSpecifiers + ); + const [start, end] = node.range; + shared.maybeReportSorting(context, sorted, start, end); +} + +// `export * from "a"` does not have `.specifiers`. +function getSpecifiers(exportNode) { + return exportNode.specifiers || []; +} + +function isPartOfChunk(node, lastNode, sourceCode) { + if (!isExportFrom(node)) { + return "NotPartOfChunk"; + } + + const hasGroupingComment = sourceCode + .getCommentsBefore(node) + .some( + (comment) => + (lastNode == null || comment.loc.start.line > lastNode.loc.end.line) && + comment.loc.end.line < node.loc.start.line + ); + + return hasGroupingComment ? "PartOfNewChunk" : "PartOfChunk"; +} + +// Full export-from statement. +// export {a, b} from "A" +// export * from "A" +// export * as A from "A" +function isExportFrom(node) { + return ( + (node.type === "ExportNamedDeclaration" || + node.type === "ExportAllDeclaration") && + node.source != null + ); +} diff --git a/src/imports.js b/src/imports.js new file mode 100644 index 0000000..9bfcba3 --- /dev/null +++ b/src/imports.js @@ -0,0 +1,146 @@ +"use strict"; + +const shared = require("./shared"); + +const defaultGroups = [ + // Side effect imports. + ["^\\u0000"], + // Packages. + // Things that start with a letter (or digit or underscore), or `@` followed by a letter. + ["^@?\\w"], + // Absolute imports and other imports such as Vue-style `@/foo`. + // Anything not matched in another group. + ["^"], + // Relative imports. + // Anything that starts with a dot. + ["^\\."], +]; + +module.exports = { + meta: { + type: "layout", + fixable: "code", + schema: [ + { + type: "object", + properties: { + groups: { + type: "array", + items: { + type: "array", + items: { + type: "string", + }, + }, + }, + }, + additionalProperties: false, + }, + ], + docs: { + url: + "https://github.com/lydell/eslint-plugin-simple-import-sort#sort-order", + }, + messages: { + sort: "Run autofix to sort these imports!", + }, + }, + create: (context) => { + const { groups: rawGroups = defaultGroups } = context.options[0] || {}; + const outerGroups = rawGroups.map((groups) => + groups.map((item) => RegExp(item, "u")) + ); + return { + Program: (programNode) => { + for (const chunk of shared.extractChunks(programNode, (node) => + isImport(node) ? "PartOfChunk" : "NotPartOfChunk" + )) { + maybeReportChunkSorting(chunk, context, outerGroups); + } + }, + }; + }, +}; + +function maybeReportChunkSorting(chunk, context, outerGroups) { + const sourceCode = context.getSourceCode(); + const items = shared.getImportExportItems( + chunk, + sourceCode, + isSideEffectImport, + getSpecifiers + ); + const sortedItems = makeSortedItems(items, outerGroups); + const sorted = shared.printSortedItems(sortedItems, items, sourceCode); + const { start } = items[0]; + const { end } = items[items.length - 1]; + shared.maybeReportSorting(context, sorted, start, end); +} + +function makeSortedItems(items, outerGroups) { + const itemGroups = outerGroups.map((groups) => + groups.map((regex) => ({ regex, items: [] })) + ); + const rest = []; + + for (const item of items) { + const { originalSource } = item.source; + const source = item.isSideEffectImport + ? `\0${originalSource}` + : item.source.kind !== "value" + ? `${originalSource}\0` + : originalSource; + const [matchedGroup] = shared + .flatMap(itemGroups, (groups) => + groups.map((group) => [group, group.regex.exec(source)]) + ) + .reduce( + ([group, longestMatch], [nextGroup, nextMatch]) => + nextMatch != null && + (longestMatch == null || nextMatch[0].length > longestMatch[0].length) + ? [nextGroup, nextMatch] + : [group, longestMatch], + [undefined, undefined] + ); + if (matchedGroup == null) { + rest.push(item); + } else { + matchedGroup.items.push(item); + } + } + + return itemGroups + .concat([[{ regex: /^/, items: rest }]]) + .map((groups) => groups.filter((group) => group.items.length > 0)) + .filter((groups) => groups.length > 0) + .map((groups) => + groups.map((group) => shared.sortImportExportItems(group.items)) + ); +} + +// Exclude "ImportDefaultSpecifier" – the "def" in `import def, {a, b}`. +function getSpecifiers(importNode) { + return importNode.specifiers.filter((node) => isImportSpecifier(node)); +} + +// Full import statement. +function isImport(node) { + return node.type === "ImportDeclaration"; +} + +// import def, { a, b as c, type d } from "A" +// ^ ^^^^^^ ^^^^^^ +function isImportSpecifier(node) { + return node.type === "ImportSpecifier"; +} + +// import "setup" +// But not: import {} from "setup" +// And not: import type {} from "setup" +function isSideEffectImport(importNode, sourceCode) { + return ( + importNode.specifiers.length === 0 && + (!importNode.importKind || importNode.importKind === "value") && + !shared.isPunctuator(sourceCode.getFirstToken(importNode, { skip: 1 }), "{") + ); +} diff --git a/src/index.js b/src/index.js index 726499c..2035226 100644 --- a/src/index.js +++ b/src/index.js @@ -1,9 +1,11 @@ "use strict"; -const sort = require("./sort"); +const importsRule = require("./imports"); +const exportsRule = require("./exports"); module.exports = { rules: { - sort, + imports: importsRule, + exports: exportsRule, }, }; diff --git a/src/sort.js b/src/shared.js similarity index 67% rename from src/sort.js rename to src/shared.js index 00d2448..8012b59 100644 --- a/src/sort.js +++ b/src/shared.js @@ -1,94 +1,51 @@ "use strict"; -const defaultGroups = [ - // Side effect imports. - ["^\\u0000"], - // Packages. - // Things that start with a letter (or digit or underscore), or `@` followed by a letter. - ["^@?\\w"], - // Absolute imports and other imports such as Vue-style `@/foo`. - // Anything that does not start with a dot. - ["^[^.]"], - // Relative imports. - // Anything that starts with a dot. - ["^\\."], -]; +// A “chunk” is a sequence of statements of a certain type with only comments +// and whitespace between. +function extractChunks(programNode, isPartOfChunk) { + const chunks = []; + let chunk = []; + let lastNode = undefined; + + for (const node of programNode.body) { + const result = isPartOfChunk(node, lastNode); + switch (result) { + case "PartOfChunk": + chunk.push(node); + break; -module.exports = { - meta: { - type: "layout", - fixable: "code", - schema: [ - { - type: "object", - properties: { - groups: { - type: "array", - items: { - type: "array", - items: { - type: "string", - }, - }, - }, - }, - additionalProperties: false, - }, - ], - docs: { - url: - "https://github.com/lydell/eslint-plugin-simple-import-sort#sort-order", - }, - messages: { - sort: "Run autofix to sort these imports!", - }, - }, - create: (context) => { - const { groups: rawGroups = defaultGroups } = context.options[0] || {}; - const outerGroups = rawGroups.map((groups) => - groups.map((item) => RegExp(item, "u")) - ); - return { - Program: (node) => { - for (const imports of extractImportChunks(node)) { - maybeReportSorting(imports, context, outerGroups); + case "PartOfNewChunk": + if (chunk.length > 0) { + chunks.push(chunk); } - }, - }; - }, -}; + chunk = [node]; + break; -// A “chunk” is a sequence of import statements with only comments and -// whitespace between. -function extractImportChunks(programNode) { - const chunks = []; - let imports = []; - - for (const item of programNode.body) { - if (isImport(item)) { - imports.push(item); - } else if (imports.length > 0) { - chunks.push(imports); - imports = []; + case "NotPartOfChunk": + if (chunk.length > 0) { + chunks.push(chunk); + chunk = []; + } + break; + + // istanbul ignore next + default: + throw new Error(`Unknown chunk result: ${result}`); } + + lastNode = node; } - if (imports.length > 0) { - chunks.push(imports); + if (chunk.length > 0) { + chunks.push(chunk); } return chunks; } -function maybeReportSorting(imports, context, outerGroups) { +function maybeReportSorting(context, sorted, start, end) { const sourceCode = context.getSourceCode(); - const items = getImportItems(imports, sourceCode); - const sorted = printSortedImports(items, sourceCode, outerGroups); - - const { start } = items[0]; - const { end } = items[items.length - 1]; const original = sourceCode.getText().slice(start, end); - if (original !== sorted) { context.report({ messageId: "sort", @@ -101,40 +58,7 @@ function maybeReportSorting(imports, context, outerGroups) { } } -function printSortedImports(importItems, sourceCode, outerGroups) { - const itemGroups = outerGroups.map((groups) => - groups.map((regex) => ({ regex, items: [] })) - ); - const rest = []; - - for (const item of importItems) { - const { originalSource } = item.source; - const source = item.isSideEffectImport - ? `\0${originalSource}` - : originalSource; - const [matchedGroup] = flatMap(itemGroups, (groups) => - groups.map((group) => [group, group.regex.exec(source)]) - ).reduce( - ([group, longestMatch], [nextGroup, nextMatch]) => - nextMatch != null && - (longestMatch == null || nextMatch[0].length > longestMatch[0].length) - ? [nextGroup, nextMatch] - : [group, longestMatch], - [undefined, undefined] - ); - if (matchedGroup == null) { - rest.push(item); - } else { - matchedGroup.items.push(item); - } - } - - const sortedItems = itemGroups - .concat([[{ regex: /^/, items: rest }]]) - .map((groups) => groups.filter((group) => group.items.length > 0)) - .filter((groups) => groups.length > 0) - .map((groups) => groups.map((group) => sortImportItems(group.items))); - +function printSortedItems(sortedItems, originalItems, sourceCode) { const newline = guessNewline(sourceCode); const sorted = sortedItems @@ -145,12 +69,12 @@ function printSortedImports(importItems, sourceCode, outerGroups) { ) .join(newline + newline); - // Edge case: If the last import (after sorting) ends with a line comment and - // there’s code (or a multiline block comment) on the same line, add a newline - // so we don’t accidentally comment stuff out. + // Edge case: If the last import/export (after sorting) ends with a line + // comment and there’s code (or a multiline block comment) on the same line, + // add a newline so we don’t accidentally comment stuff out. const flattened = flatMap(sortedItems, (groups) => [].concat(...groups)); const lastSortedItem = flattened[flattened.length - 1]; - const lastOriginalItem = importItems[importItems.length - 1]; + const lastOriginalItem = originalItems[originalItems.length - 1]; const nextToken = lastSortedItem.needsNewline ? sourceCode.getTokenAfter(lastOriginalItem.node, { includeComments: true, @@ -171,81 +95,85 @@ function printSortedImports(importItems, sourceCode, outerGroups) { return sorted + maybeNewline; } -// Wrap the import nodes in `passedImports` in objects with more data about the -// import. Most importantly there’s a `code` property that contains the import -// node as a string, with comments (if any). Finding the corresponding comments -// is the hard part. -function getImportItems(passedImports, sourceCode) { - const imports = handleLastSemicolon(passedImports, sourceCode); - return imports.map((importNode, importIndex) => { +// Wrap the import/export nodes in `passedChunk` in objects with more data about +// the import/export. Most importantly there’s a `code` property that contains +// the node as a string, with comments (if any). Finding the corresponding +// comments is the hard part. +function getImportExportItems( + passedChunk, + sourceCode, + isSideEffectImport, + getSpecifiers +) { + const chunk = handleLastSemicolon(passedChunk, sourceCode); + return chunk.map((node, nodeIndex) => { const lastLine = - importIndex === 0 - ? importNode.loc.start.line - 1 - : imports[importIndex - 1].loc.end.line; + nodeIndex === 0 + ? node.loc.start.line - 1 + : chunk[nodeIndex - 1].loc.end.line; - // Get all comments before the import, except: + // Get all comments before the import/export, except: // - // - Comments on another line for the first import. - // - Comments that belong to the previous import (if any) – that is, - // comments that are on the same line as the previous import. But multiline - // block comments always belong to this import, not the previous. + // - Comments on another line for the first import/export. + // - Comments that belong to the previous import/export (if any) – that is, + // comments that are on the same line as the previous import/export. But + // multiline block comments always belong to this import/export, not the + // previous. const commentsBefore = sourceCode - .getCommentsBefore(importNode) + .getCommentsBefore(node) .filter( (comment) => - comment.loc.start.line <= importNode.loc.start.line && + comment.loc.start.line <= node.loc.start.line && comment.loc.end.line > lastLine && - (importIndex > 0 || comment.loc.start.line > lastLine) + (nodeIndex > 0 || comment.loc.start.line > lastLine) ); - // Get all comments after the import that are on the same line. Multiline - // block comments belong to the _next_ import (or the following code in case - // of the last import). + // Get all comments after the import/export that are on the same line. + // Multiline block comments belong to the _next_ import/export (or the + // following code in case of the last import/export). const commentsAfter = sourceCode - .getCommentsAfter(importNode) - .filter((comment) => comment.loc.end.line === importNode.loc.end.line); + .getCommentsAfter(node) + .filter((comment) => comment.loc.end.line === node.loc.end.line); - const before = printCommentsBefore(importNode, commentsBefore, sourceCode); - const after = printCommentsAfter(importNode, commentsAfter, sourceCode); + const before = printCommentsBefore(node, commentsBefore, sourceCode); + const after = printCommentsAfter(node, commentsAfter, sourceCode); - // Print the indentation before the import or its first comment, if any, to - // support indentation in `