diff --git a/.editorconfig b/.editorconfig index e2bfac523f..b7b8d09991 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,3 +7,4 @@ insert_final_newline = true indent_style = space indent_size = 2 end_of_line = lf +quote_type = single diff --git a/.eslintignore b/.eslintignore index 9d22006820..3516f09b9c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -7,6 +7,7 @@ tests/files/with-syntax-error tests/files/just-json-files/invalid.json tests/files/typescript-d-ts/ resolvers/webpack/test/files +examples # we want to ignore "tests/files" here, but unfortunately doing so would # interfere with unit test and fail it for some reason. # tests/files diff --git a/.eslintrc b/.eslintrc index 3c9c658f2f..80e1014c60 100644 --- a/.eslintrc +++ b/.eslintrc @@ -96,6 +96,7 @@ "no-multiple-empty-lines": [2, { "max": 1, "maxEOF": 1, "maxBOF": 0 }], "no-return-assign": [2, "always"], "no-trailing-spaces": 2, + "no-use-before-define": [2, { "functions": true, "classes": true, "variables": true }], "no-var": 2, "object-curly-spacing": [2, "always"], "object-shorthand": ["error", "always", { @@ -209,10 +210,10 @@ "exports": "always-multiline", "functions": "never" }], - "prefer-destructuring": "warn", + "prefer-destructuring": "off", "prefer-object-spread": "off", "prefer-rest-params": "off", - "prefer-spread": "warn", + "prefer-spread": "off", "prefer-template": "off", } }, @@ -225,6 +226,24 @@ "no-console": 1, }, }, + { + "files": [ + "utils/**", // TODO + ], + "rules": { + "no-use-before-define": "off", + }, + }, + { + "files": [ + "resolvers/webpack/index.js", + "resolvers/webpack/test/example.js", + "utils/parse.js", + ], + "rules": { + "no-console": "off", + }, + }, { "files": [ "resolvers/*/test/**/*", diff --git a/.github/issue_template.md b/.github/issue_template.md new file mode 100644 index 0000000000..673d652375 --- /dev/null +++ b/.github/issue_template.md @@ -0,0 +1,10 @@ + + diff --git a/.github/workflows/eslint-8+.yml b/.github/workflows/eslint-8+.yml new file mode 100644 index 0000000000..ff79a5fa27 --- /dev/null +++ b/.github/workflows/eslint-8+.yml @@ -0,0 +1,69 @@ +name: 'Tests: eslint 8+' + +on: [pull_request, push] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + matrix: + runs-on: ubuntu-latest + outputs: + latest: ${{ steps.set-matrix.outputs.requireds }} + minors: ${{ steps.set-matrix.outputs.optionals }} + steps: + - uses: ljharb/actions/node/matrix@main + id: set-matrix + with: + versionsAsRoot: true + type: majors + preset: '^12.22.0 || ^14.17.0 || >=16.0.0' # eslint 8 engines.node + + latest: + needs: [matrix] + name: majors + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + node-version: ${{ fromJson(needs.matrix.outputs.latest) }} + eslint: + - 9 + - 8 + exclude: + - node-version: 16 + eslint: 9 + - node-version: 14 + eslint: 9 + - node-version: 12 + eslint: 9 + + steps: + - uses: actions/checkout@v4 + - uses: ljharb/actions/node/install@main + name: 'nvm install ${{ matrix.node-version }} && npm install, with eslint ${{ matrix.eslint }}' + env: + ESLINT_VERSION: ${{ matrix.eslint }} + TRAVIS_NODE_VERSION: ${{ matrix.node-version }} + with: + node-version: ${{ matrix.node-version }} + after_install: npm run copy-metafiles && ./tests/dep-time-travel.sh + skip-ls-check: true + - run: npm run pretest + - run: npm run tests-only + - uses: codecov/codecov-action@v3.1.5 + + node: + name: 'eslint 8+' + needs: [latest] + runs-on: ubuntu-latest + steps: + - run: true diff --git a/.github/workflows/node-4+.yml b/.github/workflows/eslint-old.yml similarity index 78% rename from .github/workflows/node-4+.yml rename to .github/workflows/eslint-old.yml index 2925adda8a..d6ced9cd81 100644 --- a/.github/workflows/node-4+.yml +++ b/.github/workflows/eslint-old.yml @@ -1,7 +1,11 @@ -name: 'Tests: node.js' +name: 'Tests: eslint (old versions)' on: [pull_request, push] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read @@ -22,14 +26,16 @@ jobs: latest: needs: [matrix] name: 'majors' - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: + os: + - ubuntu-latest + - macos-latest node-version: ${{ fromJson(needs.matrix.outputs.latest) }} eslint: - - 8 - 7 - 6 - 5 @@ -38,59 +44,42 @@ jobs: - 2 include: - node-version: 'lts/*' + os: ubuntu-latest eslint: 7 ts-parser: 4 env: TS_PARSER: 4 - node-version: 'lts/*' + os: ubuntu-latest eslint: 7 ts-parser: 3 env: TS_PARSER: 3 - node-version: 'lts/*' + os: ubuntu-latest eslint: 7 ts-parser: 2 env: TS_PARSER: 2 exclude: - - node-version: 15 - eslint: 8 - - node-version: 13 - eslint: 8 - - node-version: 11 - eslint: 8 - - node-version: 10 - eslint: 8 - - node-version: 9 - eslint: 8 - node-version: 9 eslint: 7 - - node-version: 8 - eslint: 8 - node-version: 8 eslint: 7 - - node-version: 7 - eslint: 8 - node-version: 7 eslint: 7 - node-version: 7 eslint: 6 - - node-version: 6 - eslint: 8 - node-version: 6 eslint: 7 - node-version: 6 eslint: 6 - - node-version: 5 - eslint: 8 - node-version: 5 eslint: 7 - node-version: 5 eslint: 6 - node-version: 5 eslint: 5 - - node-version: 4 - eslint: 8 - node-version: 4 eslint: 7 - node-version: 4 @@ -99,7 +88,7 @@ jobs: eslint: 5 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ljharb/actions/node/install@main continue-on-error: ${{ matrix.eslint == 4 && matrix.node-version == 4 }} name: 'nvm install ${{ matrix.node-version }} && npm install, with eslint ${{ matrix.eslint }}' @@ -113,11 +102,11 @@ jobs: skip-ls-check: true - run: npm run pretest - run: npm run tests-only - - uses: codecov/codecov-action@v3 + - uses: codecov/codecov-action@v3.1.5 node: - name: 'node 4+' + name: 'eslint 2 - 7' needs: [latest] runs-on: ubuntu-latest steps: - - run: 'echo tests completed' + - run: true diff --git a/.github/workflows/native-wsl.yml b/.github/workflows/native-wsl.yml new file mode 100644 index 0000000000..5e8318899e --- /dev/null +++ b/.github/workflows/native-wsl.yml @@ -0,0 +1,155 @@ +name: Native and WSL + +on: [push, pull_request] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + runs-on: ${{ matrix.os }} + defaults: + run: + shell: ${{ matrix.configuration == 'wsl' && 'wsl-bash {0}' || 'pwsh' }} + strategy: + fail-fast: false + matrix: + os: [windows-2019] + node-version: [18, 16, 14, 12, 10, 8, 6, 4] + configuration: [wsl, native] + + steps: + - uses: actions/checkout@v4 + - uses: Vampire/setup-wsl@v3 + if: matrix.configuration == 'wsl' + with: + distribution: Ubuntu-22.04 + - run: curl --version + - name: 'WSL: do all npm install steps' + if: matrix.configuration == 'wsl' + env: + ESLINT_VERSION: 7 + TRAVIS_NODE_VERSION: ${{ matrix.node-version }} + run: | + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash + export NVM_DIR="$HOME/.nvm" + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm + nvm install --latest-npm ${{ matrix.node-version }} + + if [ ${{ matrix.node-version }} -ge 4 ] && [ ${{ matrix.node-version }} -lt 6 ]; then + npm install eslint@4 --no-save --ignore-scripts + npm install + npm install eslint-import-resolver-typescript@1.0.2 --no-save + npm uninstall @angular-eslint/template-parser @typescript-eslint/parser --no-save + fi + if [ ${{ matrix.node-version }} -ge 6 ] && [ ${{ matrix.node-version }} -lt 7 ]; then + npm install eslint@5 --no-save --ignore-scripts + npm install + npm uninstall @angular-eslint/template-parser --no-save + npm install eslint-import-resolver-typescript@1.0.2 @typescript-eslint/parser@3 --no-save + fi + if [ ${{ matrix.node-version }} -ge 7 ] && [ ${{ matrix.node-version }} -lt 8 ]; then + npm install eslint@6 --no-save --ignore-scripts + npm install + npm install eslint-import-resolver-typescript@1.0.2 typescript-eslint-parser@20 --no-save + npm uninstall @angular-eslint/template-parser --no-save + fi + if [ ${{ matrix.node-version }} -eq 8 ]; then + npm install eslint@6 --no-save --ignore-scripts + npm install + npm uninstall @angular-eslint/template-parser --no-save + npm install @typescript-eslint/parser@3 --no-save + fi + if [ ${{ matrix.node-version }} -gt 8 ] && [ ${{ matrix.node-version }} -lt 10 ]; then + npm install eslint@7 --no-save --ignore-scripts + npm install + npm install @typescript-eslint/parser@3 --no-save + fi + if [ ${{ matrix.node-version }} -ge 10 ] && [ ${{ matrix.node-version }} -lt 12 ]; then + npm install + npm install @typescript-eslint/parser@4 --no-save + fi + if [ ${{ matrix.node-version }} -ge 12 ]; then + npm install + fi + npm run copy-metafiles + npm run pretest + npm run tests-only + + - name: install dependencies for node <= 10 + if: matrix.node-version <= '10' && matrix.configuration == 'native' + run: | + npm install --legacy-peer-deps + npm install eslint@7 --no-save + + - name: Install dependencies for node > 10 + if: matrix.node-version > '10' && matrix.configuration == 'native' + run: npm install + + - name: install the latest version of nyc + if: matrix.configuration == 'native' + run: npm install nyc@latest --no-save + + - name: copy metafiles for node <= 8 + if: matrix.node-version <= 8 && matrix.configuration == 'native' + env: + ESLINT_VERSION: 6 + TRAVIS_NODE_VERSION: ${{ matrix.node-version }} + run: | + npm run copy-metafiles + bash ./tests/dep-time-travel.sh 2>&1 + - name: copy metafiles for Node > 8 + if: matrix.node-version > 8 && matrix.configuration == 'native' + env: + ESLINT_VERSION: 7 + TRAVIS_NODE_VERSION: ${{ matrix.node-version }} + run: | + npm run copy-metafiles + bash ./tests/dep-time-travel.sh 2>&1 + + - name: install ./resolver dependencies in Native + if: matrix.configuration == 'native' + shell: pwsh + run: | + npm config set package-lock false + $resolverDir = "./resolvers" + Get-ChildItem -Directory $resolverDir | + ForEach { + Write-output $(Resolve-Path $(Join-Path $resolverDir $_.Name)) + Push-Location $(Resolve-Path $(Join-Path $resolverDir $_.Name)) + npm install + npm ls nyc > $null; + if ($?) { + npm install nyc@latest --no-save + } + Pop-Location + } + + - name: run tests in Native + if: matrix.configuration == 'native' + shell: pwsh + run: | + npm run pretest + npm run tests-only + $resolverDir = "./resolvers"; + $resolvers = @(); + Get-ChildItem -Directory $resolverDir | + ForEach { + $resolvers += "$(Resolve-Path $(Join-Path $resolverDir $_.Name))"; + } + $env:RESOLVERS = [string]::Join(";", $resolvers); + foreach ($resolver in $resolvers) { + Set-Location -Path $resolver.Trim('"') + npm run tests-only + Set-Location -Path $PSScriptRoot + } + + - name: codecov + uses: codecov/codecov-action@v3.1.5 + + windows: + runs-on: ubuntu-latest + needs: [build] + steps: + - run: true diff --git a/.github/workflows/node-pretest.yml b/.github/workflows/node-pretest.yml index e4340018e4..e25b00e499 100644 --- a/.github/workflows/node-pretest.yml +++ b/.github/workflows/node-pretest.yml @@ -10,7 +10,7 @@ jobs: # runs-on: ubuntu-latest # steps: - # - uses: actions/checkout@v3 + # - uses: actions/checkout@v4 # - uses: ljharb/actions/node/install@main # name: 'nvm install lts/* && npm install' # with: @@ -18,11 +18,24 @@ jobs: # skip-ls-check: true # - run: npm run pretest + types: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ljharb/actions/node/install@main + name: 'npm install' + with: + skip-ls-check: true + # for some reason we've got to force typescript to install here + # even though the npm script has `typescript@latest` + - run: npm i --force typescript@latest + - run: npm run test-types + posttest: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ljharb/actions/node/install@main name: 'nvm install lts/* && npm install' with: diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index a6fb4e4cb5..f73f8e18ff 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -2,6 +2,10 @@ name: 'Tests: packages' on: [pull_request, push] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read @@ -38,7 +42,7 @@ jobs: # - utils steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ljharb/actions/node/install@main name: 'nvm install ${{ matrix.node-version }} && npm install' env: @@ -50,7 +54,7 @@ jobs: after_install: npm run copy-metafiles && ./tests/dep-time-travel.sh && cd ${{ matrix.package }} && npm install skip-ls-check: true - run: cd ${{ matrix.package }} && npm run tests-only - - uses: codecov/codecov-action@v3 + - uses: codecov/codecov-action@v3.1.5 packages: name: 'packages: all tests' diff --git a/.markdownlint.json b/.markdownlint.json index d179615f45..f434832d2b 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -1,5 +1,9 @@ { "line-length": false, + "ignore_case": true, + "no-duplicate-heading": { + "siblings_only": true + }, "ul-indent": { "start_indent": 1, "start_indented": true diff --git a/.nycrc b/.nycrc index 5d75e2157c..c5396cb18c 100644 --- a/.nycrc +++ b/.nycrc @@ -14,6 +14,7 @@ "resolvers/*/test", "scripts", "memo-parser", - "lib" + "lib", + "examples" ] } diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 21a7070fb7..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,40 +0,0 @@ -language: node_js - -# osx backlog is often deep, so to be polite we can just hit these highlights -matrix: - include: - - os: osx - env: ESLINT_VERSION=5 - node_js: 14 - - os: osx - env: ESLINT_VERSION=5 - node_js: 12 - - os: osx - env: ESLINT_VERSION=5 - node_js: 10 - - os: osx - env: ESLINT_VERSION=4 - node_js: 8 - - os: osx - env: ESLINT_VERSION=3 - node_js: 6 - - os: osx - env: ESLINT_VERSION=2 - node_js: 4 - - fast_finish: true - -before_install: - - 'nvm install-latest-npm' - - 'NPM_CONFIG_LEGACY_PEER_DEPS=true npm install' - - 'npm run copy-metafiles' -install: - - 'NPM_CONFIG_LEGACY_PEER_DEPS=true npm install' - - 'if [ -n "${ESLINT_VERSION}" ]; then ./tests/dep-time-travel.sh; fi' - - 'npm run pretest' - -script: - - npm run tests-only - -after_success: - - bash <(curl -Os https://uploader.codecov.io/latest/linux/codecov) diff --git a/CHANGELOG.md b/CHANGELOG.md index b81ad61a61..fb12deb24d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,84 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange ## [Unreleased] +## [2.32.0] - 2025-06-20 + +### Added +- add [`enforce-node-protocol-usage`] rule and `import/node-version` setting ([#3024], thanks [@GoldStrikeArch] and [@sevenc-nanashi]) +- add TypeScript types ([#3097], thanks [@G-Rath]) +- [`extensions`]: add `pathGroupOverrides to allow enforcement decision overrides based on specifier ([#3105], thanks [@Xunnamius]) +- [`order`]: add `sortTypesGroup` option to allow intragroup sorting of type-only imports ([#3104], thanks [@Xunnamius]) +- [`order`]: add `newlines-between-types` option to control intragroup sorting of type-only imports ([#3127], thanks [@Xunnamius]) +- [`order`]: add `consolidateIslands` option to collapse excess spacing for aesthetically pleasing imports ([#3129], thanks [@Xunnamius]) + +### Fixed +- [`no-unused-modules`]: provide more meaningful error message when no .eslintrc is present ([#3116], thanks [@michaelfaith]) +- configs: added missing name attribute for eslint config inspector ([#3151], thanks [@NishargShah]) +- [`order`]: ensure arcane imports do not cause undefined behavior ([#3128], thanks [@Xunnamius]) +- [`order`]: resolve undefined property access issue when using `named` ordering ([#3166], thanks [@Xunnamius]) +- [`enforce-node-protocol-usage`]: avoid a crash with some TS code ([#3173], thanks [@ljharb]) +- [`order`]: codify invariants from docs into config schema ([#3152], thanks [@Xunnamius]) + +### Changed +- [Docs] [`extensions`], [`order`]: improve documentation ([#3106], thanks [@Xunnamius]) +- [Docs] add flat config guide for using `tseslint.config()` ([#3125], thanks [@lnuvy]) +- [Docs] add missing comma ([#3122], thanks [@RyanGst]) +- [readme] Update flatConfig example to include typescript config ([#3138], thanks [@intellix]) +- [Refactor] [`order`]: remove unnecessary negative check ([#3167], thanks [@JounQin]) +- [Docs] [`no-unused-modules`]: add missing double quote ([#3191], thanks [@albertpastrana]) +- [Docs] `no-restricted-paths`: clarify wording and fix errors ([#3172], thanks [@greim]) + +## [2.31.0] - 2024-10-03 + +### Added +- support eslint v9 ([#2996], thanks [@G-Rath] [@michaelfaith]) +- [`order`]: allow validating named imports ([#3043], thanks [@manuth]) +- [`extensions`]: add the `checkTypeImports` option ([#2817], thanks [@phryneas]) + +### Fixed +- `ExportMap` / flat config: include `languageOptions` in context ([#3052], thanks [@michaelfaith]) +- [`no-named-as-default`]: Allow using an identifier if the export is both a named and a default export ([#3032], thanks [@akwodkiewicz]) +- [`export`]: False positive for exported overloaded functions in TS ([#3065], thanks [@liuxingbaoyu]) +- `exportMap`: export map cache is tainted by unreliable parse results ([#3062], thanks [@michaelfaith]) +- `exportMap`: improve cacheKey when using flat config ([#3072], thanks [@michaelfaith]) +- adjust "is source type module" checks for flat config ([#2996], thanks [@G-Rath]) + +### Changed +- [Docs] [`no-relative-packages`]: fix typo ([#3066], thanks [@joshuaobrien]) +- [Performance] [`no-cycle`]: dont scc for each linted file ([#3068], thanks [@soryy708]) +- [Docs] [`no-cycle`]: add `disableScc` to docs ([#3070], thanks [@soryy708]) +- [Tests] use re-exported `RuleTester` ([#3071], thanks [@G-Rath]) +- [Docs] [`no-restricted-paths`]: fix grammar ([#3073], thanks [@unbeauvoyage]) +- [Tests] [`no-default-export`], [`no-named-export`]: add test case (thanks [@G-Rath]) + +## [2.30.0] - 2024-09-02 + +### Added +- [`dynamic-import-chunkname`]: add `allowEmpty` option to allow empty leading comments ([#2942], thanks [@JiangWeixian]) +- [`dynamic-import-chunkname`]: Allow empty chunk name when webpackMode: 'eager' is set; add suggestions to remove name in eager mode ([#3004], thanks [@amsardesai]) +- [`no-unused-modules`]: Add `ignoreUnusedTypeExports` option ([#3011], thanks [@silverwind]) +- add support for Flat Config ([#3018], thanks [@michaelfaith]) + +### Fixed +- [`no-extraneous-dependencies`]: allow wrong path ([#3012], thanks [@chabb]) +- [`no-cycle`]: use scc algorithm to optimize ([#2998], thanks [@soryy708]) +- [`no-duplicates`]: Removing duplicates breaks in TypeScript ([#3033], thanks [@yesl-kim]) +- [`newline-after-import`]: fix considerComments option when require ([#2952], thanks [@developer-bandi]) +- [`order`]: do not compare first path segment for relative paths ([#2682]) ([#2885], thanks [@mihkeleidast]) + +### Changed +- [Docs] [`no-extraneous-dependencies`]: Make glob pattern description more explicit ([#2944], thanks [@mulztob]) +- [`no-unused-modules`]: add console message to help debug [#2866] +- [Refactor] `ExportMap`: make procedures static instead of monkeypatching exportmap ([#2982], thanks [@soryy708]) +- [Refactor] `ExportMap`: separate ExportMap instance from its builder logic ([#2985], thanks [@soryy708]) +- [Docs] [`order`]: Add a quick note on how unbound imports and --fix ([#2640], thanks [@minervabot]) +- [Tests] appveyor -> GHA (run tests on Windows in both pwsh and WSL + Ubuntu) ([#2987], thanks [@joeyguerra]) +- [actions] migrate OSX tests to GHA ([ljharb#37], thanks [@aks-]) +- [Refactor] `exportMapBuilder`: avoid hoisting ([#2989], thanks [@soryy708]) +- [Refactor] `ExportMap`: extract "builder" logic to separate files ([#2991], thanks [@soryy708]) +- [Docs] [`order`]: update the description of the `pathGroupsExcludedImportTypes` option ([#3036], thanks [@liby]) +- [readme] Clarify how to install the plugin ([#2993], thanks [@jwbth]) + ## [2.29.1] - 2023-12-14 ### Fixed @@ -34,7 +112,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange - [`no-duplicates`]: remove duplicate identifiers in duplicate imports ([#2577], thanks [@joe-matsec]) - [`consistent-type-specifier-style`]: fix accidental removal of comma in certain cases ([#2754], thanks [@bradzacher]) - [Perf] `ExportMap`: Improve `ExportMap.for` performance on larger codebases ([#2756], thanks [@leipert]) -- [`no-extraneous-dependencies`]/TypeScript: do not error when importing inline type from dev dependencies ([#1820], thanks [@andyogo]) +- [`no-extraneous-dependencies`]/TypeScript: do not error when importing inline type from dev dependencies ([#2735], thanks [@andyogo]) - [`newline-after-import`]/TypeScript: do not error when re-exporting a namespaced import ([#2832], thanks [@laurens-dg]) - [`order`]: partial fix for [#2687] (thanks [@ljharb]) - [`no-duplicates`]: Detect across type and regular imports ([#2835], thanks [@benkrejci]) @@ -290,7 +368,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange - [`no-webpack-loader-syntax`]/TypeScript: avoid crash on missing name ([#1947], thanks [@leonardodino]) - [`no-extraneous-dependencies`]: Add package.json cache ([#1948], thanks [@fa93hws]) - [`prefer-default-export`]: handle empty array destructuring ([#1965], thanks [@ljharb]) -- [`no-unused-modules`]: make type imports mark a module as used (fixes #1924) ([#1974], thanks [@cherryblossom000]) +- [`no-unused-modules`]: make type imports mark a module as used (fixes [#1924]) ([#1974], thanks [@cherryblossom000]) - [`no-cycle`]: fix perf regression ([#1944], thanks [@Blasz]) - [`first`]: fix handling of `import = require` ([#1963], thanks [@MatthiasKunnen]) - [`no-cycle`]/[`extensions`]: fix isExternalModule usage ([#1696], thanks [@paztis]) @@ -318,7 +396,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange - [`dynamic-import-chunkname`]: allow single quotes to match Webpack support ([#1848], thanks [@straub]) ### Changed -- [`export`]: add tests for a name collision with `export * from` ([#1704], thanks @tomprats) +- [`export`]: add tests for a name collision with `export * from` ([#1704], thanks [@tomprats]) ## [2.22.0] - 2020-06-26 @@ -1052,10 +1130,12 @@ for info on changes for earlier releases. [`import/core-modules` setting]: ./README.md#importcore-modules [`import/external-module-folders` setting]: ./README.md#importexternal-module-folders [`internal-regex` setting]: ./README.md#importinternal-regex +[`import/node-version` setting]: ./README.md#importnode-version [`consistent-type-specifier-style`]: ./docs/rules/consistent-type-specifier-style.md [`default`]: ./docs/rules/default.md [`dynamic-import-chunkname`]: ./docs/rules/dynamic-import-chunkname.md +[`enforce-node-protocol-usage`]: ./docs/rules/enforce-node-protocol-usage.md [`export`]: ./docs/rules/export.md [`exports-last`]: ./docs/rules/exports-last.md [`extensions`]: ./docs/rules/extensions.md @@ -1101,14 +1181,64 @@ for info on changes for earlier releases. [`memo-parser`]: ./memo-parser/README.md +[#3191]: https://github.com/import-js/eslint-plugin-import/pull/3191 +[#3173]: https://github.com/import-js/eslint-plugin-import/pull/3173 +[#3172]: https://github.com/import-js/eslint-plugin-import/pull/3172 +[#3167]: https://github.com/import-js/eslint-plugin-import/pull/3167 +[#3166]: https://github.com/import-js/eslint-plugin-import/pull/3166 +[#3152]: https://github.com/import-js/eslint-plugin-import/pull/3152 +[#3151]: https://github.com/import-js/eslint-plugin-import/pull/3151 +[#3138]: https://github.com/import-js/eslint-plugin-import/pull/3138 +[#3129]: https://github.com/import-js/eslint-plugin-import/pull/3129 +[#3128]: https://github.com/import-js/eslint-plugin-import/pull/3128 +[#3127]: https://github.com/import-js/eslint-plugin-import/pull/3127 +[#3125]: https://github.com/import-js/eslint-plugin-import/pull/3125 +[#3122]: https://github.com/import-js/eslint-plugin-import/pull/3122 +[#3116]: https://github.com/import-js/eslint-plugin-import/pull/3116 +[#3106]: https://github.com/import-js/eslint-plugin-import/pull/3106 +[#3105]: https://github.com/import-js/eslint-plugin-import/pull/3105 +[#3104]: https://github.com/import-js/eslint-plugin-import/pull/3104 +[#3097]: https://github.com/import-js/eslint-plugin-import/pull/3097 +[#3073]: https://github.com/import-js/eslint-plugin-import/pull/3073 +[#3072]: https://github.com/import-js/eslint-plugin-import/pull/3072 +[#3071]: https://github.com/import-js/eslint-plugin-import/pull/3071 +[#3070]: https://github.com/import-js/eslint-plugin-import/pull/3070 +[#3068]: https://github.com/import-js/eslint-plugin-import/pull/3068 +[#3066]: https://github.com/import-js/eslint-plugin-import/pull/3066 +[#3065]: https://github.com/import-js/eslint-plugin-import/pull/3065 +[#3062]: https://github.com/import-js/eslint-plugin-import/pull/3062 +[#3052]: https://github.com/import-js/eslint-plugin-import/pull/3052 +[#3043]: https://github.com/import-js/eslint-plugin-import/pull/3043 +[#3036]: https://github.com/import-js/eslint-plugin-import/pull/3036 +[#3033]: https://github.com/import-js/eslint-plugin-import/pull/3033 +[#3032]: https://github.com/import-js/eslint-plugin-import/pull/3032 +[#3024]: https://github.com/import-js/eslint-plugin-import/pull/3024 +[#3018]: https://github.com/import-js/eslint-plugin-import/pull/3018 +[#3012]: https://github.com/import-js/eslint-plugin-import/pull/3012 +[#3011]: https://github.com/import-js/eslint-plugin-import/pull/3011 +[#3004]: https://github.com/import-js/eslint-plugin-import/pull/3004 +[#2998]: https://github.com/import-js/eslint-plugin-import/pull/2998 +[#2996]: https://github.com/import-js/eslint-plugin-import/pull/2996 +[#2993]: https://github.com/import-js/eslint-plugin-import/pull/2993 +[#2991]: https://github.com/import-js/eslint-plugin-import/pull/2991 +[#2989]: https://github.com/import-js/eslint-plugin-import/pull/2989 +[#2987]: https://github.com/import-js/eslint-plugin-import/pull/2987 +[#2985]: https://github.com/import-js/eslint-plugin-import/pull/2985 +[#2982]: https://github.com/import-js/eslint-plugin-import/pull/2982 +[#2952]: https://github.com/import-js/eslint-plugin-import/pull/2952 +[#2944]: https://github.com/import-js/eslint-plugin-import/pull/2944 +[#2942]: https://github.com/import-js/eslint-plugin-import/pull/2942 [#2919]: https://github.com/import-js/eslint-plugin-import/pull/2919 +[#2885]: https://github.com/import-js/eslint-plugin-import/pull/2885 [#2884]: https://github.com/import-js/eslint-plugin-import/pull/2884 +[#2866]: https://github.com/import-js/eslint-plugin-import/pull/2866 [#2854]: https://github.com/import-js/eslint-plugin-import/pull/2854 [#2851]: https://github.com/import-js/eslint-plugin-import/pull/2851 [#2850]: https://github.com/import-js/eslint-plugin-import/pull/2850 [#2842]: https://github.com/import-js/eslint-plugin-import/pull/2842 [#2835]: https://github.com/import-js/eslint-plugin-import/pull/2835 [#2832]: https://github.com/import-js/eslint-plugin-import/pull/2832 +[#2817]: https://github.com/import-js/eslint-plugin-import/pull/2817 [#2778]: https://github.com/import-js/eslint-plugin-import/pull/2778 [#2756]: https://github.com/import-js/eslint-plugin-import/pull/2756 [#2754]: https://github.com/import-js/eslint-plugin-import/pull/2754 @@ -1116,6 +1246,7 @@ for info on changes for earlier releases. [#2735]: https://github.com/import-js/eslint-plugin-import/pull/2735 [#2699]: https://github.com/import-js/eslint-plugin-import/pull/2699 [#2664]: https://github.com/import-js/eslint-plugin-import/pull/2664 +[#2640]: https://github.com/import-js/eslint-plugin-import/pull/2640 [#2613]: https://github.com/import-js/eslint-plugin-import/pull/2613 [#2608]: https://github.com/import-js/eslint-plugin-import/pull/2608 [#2605]: https://github.com/import-js/eslint-plugin-import/pull/2605 @@ -1422,10 +1553,7 @@ for info on changes for earlier releases. [#297]: https://github.com/import-js/eslint-plugin-import/pull/297 [#296]: https://github.com/import-js/eslint-plugin-import/pull/296 [#290]: https://github.com/import-js/eslint-plugin-import/pull/290 -[#289]: https://github.com/import-js/eslint-plugin-import/pull/289 [#288]: https://github.com/import-js/eslint-plugin-import/pull/288 -[#287]: https://github.com/import-js/eslint-plugin-import/pull/287 -[#278]: https://github.com/import-js/eslint-plugin-import/pull/278 [#261]: https://github.com/import-js/eslint-plugin-import/pull/261 [#256]: https://github.com/import-js/eslint-plugin-import/pull/256 [#254]: https://github.com/import-js/eslint-plugin-import/pull/254 @@ -1437,12 +1565,14 @@ for info on changes for earlier releases. [#239]: https://github.com/import-js/eslint-plugin-import/pull/239 [#228]: https://github.com/import-js/eslint-plugin-import/pull/228 [#211]: https://github.com/import-js/eslint-plugin-import/pull/211 -[#164]: https://github.com/import-js/eslint-plugin-import/pull/164 [#157]: https://github.com/import-js/eslint-plugin-import/pull/157 +[ljharb#37]: https://github.com/ljharb/eslint-plugin-import/pull/37 + [#2930]: https://github.com/import-js/eslint-plugin-import/issues/2930 [#2687]: https://github.com/import-js/eslint-plugin-import/issues/2687 [#2684]: https://github.com/import-js/eslint-plugin-import/issues/2684 +[#2682]: https://github.com/import-js/eslint-plugin-import/issues/2682 [#2674]: https://github.com/import-js/eslint-plugin-import/issues/2674 [#2668]: https://github.com/import-js/eslint-plugin-import/issues/2668 [#2666]: https://github.com/import-js/eslint-plugin-import/issues/2666 @@ -1547,7 +1677,6 @@ for info on changes for earlier releases. [#313]: https://github.com/import-js/eslint-plugin-import/issues/313 [#311]: https://github.com/import-js/eslint-plugin-import/issues/311 [#306]: https://github.com/import-js/eslint-plugin-import/issues/306 -[#286]: https://github.com/import-js/eslint-plugin-import/issues/286 [#283]: https://github.com/import-js/eslint-plugin-import/issues/283 [#281]: https://github.com/import-js/eslint-plugin-import/issues/281 [#275]: https://github.com/import-js/eslint-plugin-import/issues/275 @@ -1567,7 +1696,10 @@ for info on changes for earlier releases. [#119]: https://github.com/import-js/eslint-plugin-import/issues/119 [#89]: https://github.com/import-js/eslint-plugin-import/issues/89 -[Unreleased]: https://github.com/import-js/eslint-plugin-import/compare/v2.29.1...HEAD +[Unreleased]: https://github.com/import-js/eslint-plugin-import/compare/v2.32.0...HEAD +[2.32.0]: https://github.com/import-js/eslint-plugin-import/compare/v2.31.0...v2.32.0 +[2.31.0]: https://github.com/import-js/eslint-plugin-import/compare/v2.30.0...v2.31.0 +[2.30.0]: https://github.com/import-js/eslint-plugin-import/compare/v2.29.1...v2.30.0 [2.29.1]: https://github.com/import-js/eslint-plugin-import/compare/v2.29.0...v2.29.1 [2.29.0]: https://github.com/import-js/eslint-plugin-import/compare/v2.28.1...v2.29.0 [2.28.1]: https://github.com/import-js/eslint-plugin-import/compare/v2.28.0...v2.28.1 @@ -1596,10 +1728,9 @@ for info on changes for earlier releases. [2.22.0]: https://github.com/import-js/eslint-plugin-import/compare/v2.21.1...v2.22.0 [2.21.2]: https://github.com/import-js/eslint-plugin-import/compare/v2.21.1...v2.21.2 [2.21.1]: https://github.com/import-js/eslint-plugin-import/compare/v2.21.0...v2.21.1 -[2.21.0]: https://github.com/import-js/eslint-plugin-import/compare/v2.20.2...v2.21.0 -[2.20.1]: https://github.com/import-js/eslint-plugin-import/compare/v2.20.1...v2.20.2 -[2.20.0]: https://github.com/import-js/eslint-plugin-import/compare/v2.20.0...v2.20.1 -[2.19.1]: https://github.com/import-js/eslint-plugin-import/compare/v2.19.1...v2.20.0 +[2.21.0]: https://github.com/import-js/eslint-plugin-import/compare/v2.20.1...v2.21.0 +[2.20.1]: https://github.com/import-js/eslint-plugin-import/compare/v2.20.0...v2.20.1 +[2.20.0]: https://github.com/import-js/eslint-plugin-import/compare/v2.19.1...v2.20.0 [2.19.1]: https://github.com/import-js/eslint-plugin-import/compare/v2.19.0...v2.19.1 [2.19.0]: https://github.com/import-js/eslint-plugin-import/compare/v2.18.2...v2.19.0 [2.18.2]: https://github.com/import-js/eslint-plugin-import/compare/v2.18.1...v2.18.2 @@ -1672,9 +1803,13 @@ for info on changes for earlier releases. [@adjerbetian]: https://github.com/adjerbetian [@AdriAt360]: https://github.com/AdriAt360 [@ai]: https://github.com/ai +[@aks-]: https://github.com/aks- +[@akwodkiewicz]: https://github.com/akwodkiewicz [@aladdin-add]: https://github.com/aladdin-add +[@albertpastrana]: https://github.com/albertpastrana [@alex-page]: https://github.com/alex-page [@alexgorbatchev]: https://github.com/alexgorbatchev +[@amsardesai]: https://github.com/amsardesai [@andreubotella]: https://github.com/andreubotella [@AndrewLeedham]: https://github.com/AndrewLeedham [@andyogo]: https://github.com/andyogo @@ -1704,6 +1839,7 @@ for info on changes for earlier releases. [@bradzacher]: https://github.com/bradzacher [@brendo]: https://github.com/brendo [@brettz9]: https://github.com/brettz9 +[@chabb]: https://github.com/chabb [@Chamion]: https://github.com/Chamion [@charlessuh]: https://github.com/charlessuh [@charpeni]: https://github.com/charpeni @@ -1717,6 +1853,7 @@ for info on changes for earlier releases. [@darkartur]: https://github.com/darkartur [@davidbonnet]: https://github.com/davidbonnet [@dbrewer5]: https://github.com/dbrewer5 +[@developer-bandi]: https://github.com/developer-bandi [@devinrhode2]: https://github.com/devinrhode2 [@devongovett]: https://github.com/devongovett [@dmnd]: https://github.com/dmnd @@ -1744,16 +1881,19 @@ for info on changes for earlier releases. [@fsmaia]: https://github.com/fsmaia [@fson]: https://github.com/fson [@futpib]: https://github.com/futpib +[@G-Rath]: https://github.com/G-Rath [@gajus]: https://github.com/gajus [@gausie]: https://github.com/gausie [@gavriguy]: https://github.com/gavriguy [@georeith]: https://github.com/georeith [@giodamelio]: https://github.com/giodamelio [@gnprice]: https://github.com/gnprice +[@GoldStrikeArch]: https://github.com/GoldStrikeArch [@golergka]: https://github.com/golergka [@golopot]: https://github.com/golopot [@GoodForOneFare]: https://github.com/GoodForOneFare [@graingert]: https://github.com/graingert +[@greim]: https://github.com/greim [@grit96]: https://github.com/grit96 [@guilhermelimak]: https://github.com/guilhermelimak [@guillaumewuip]: https://github.com/guillaumewuip @@ -1762,6 +1902,7 @@ for info on changes for earlier releases. [@hulkish]: https://github.com/hulkish [@hyperupcall]: https://github.com/hyperupcall [@Hypnosphi]: https://github.com/Hypnosphi +[@intellix]: https://github.com/intellix [@isiahmeadows]: https://github.com/isiahmeadows [@IvanGoncharov]: https://github.com/IvanGoncharov [@ivo-stefchev]: https://github.com/ivo-stefchev @@ -1770,19 +1911,23 @@ for info on changes for earlier releases. [@jeffshaver]: https://github.com/jeffshaver [@jf248]: https://github.com/jf248 [@jfmengels]: https://github.com/jfmengels +[@JiangWeixian]: https://github.com/JiangWeixian [@jimbolla]: https://github.com/jimbolla [@jkimbo]: https://github.com/jkimbo [@joaovieira]: https://github.com/joaovieira [@joe-matsec]: https://github.com/joe-matsec +[@joeyguerra]: https://github.com/joeyguerra [@johndevedu]: https://github.com/johndevedu [@johnthagen]: https://github.com/johnthagen [@jonboiser]: https://github.com/jonboiser [@josh]: https://github.com/josh +[@joshuaobrien]: https://github.com/joshuaobrien [@JounQin]: https://github.com/JounQin [@jquense]: https://github.com/jquense [@jseminck]: https://github.com/jseminck [@julien1619]: https://github.com/julien1619 [@justinanastos]: https://github.com/justinanastos +[@jwbth]: https://github.com/jwbth [@k15a]: https://github.com/k15a [@kentcdodds]: https://github.com/kentcdodds [@kevin940726]: https://github.com/kevin940726 @@ -1805,8 +1950,10 @@ for info on changes for earlier releases. [@Librazy]: https://github.com/Librazy [@liby]: https://github.com/liby [@lilling]: https://github.com/lilling +[@liuxingbaoyu]: https://github.com/liuxingbaoyu [@ljharb]: https://github.com/ljharb [@ljqx]: https://github.com/ljqx +[@lnuvy]: https://github.com/lnuvy [@lo1tuma]: https://github.com/lo1tuma [@loganfsmyth]: https://github.com/loganfsmyth [@luczsoma]: https://github.com/luczsoma @@ -1830,27 +1977,33 @@ for info on changes for earlier releases. [@meowtec]: https://github.com/meowtec [@mgwalker]: https://github.com/mgwalker [@mhmadhamster]: https://github.com/MhMadHamster +[@michaelfaith]: https://github.com/michaelfaith +[@mihkeleidast]: https://github.com/mihkeleidast [@MikeyBeLike]: https://github.com/MikeyBeLike +[@minervabot]: https://github.com/minervabot [@mpint]: https://github.com/mpint [@mplewis]: https://github.com/mplewis [@mrmckeb]: https://github.com/mrmckeb [@msvab]: https://github.com/msvab +[@mulztob]: https://github.com/mulztob [@mx-bernhard]: https://github.com/mx-bernhard [@Nfinished]: https://github.com/Nfinished [@nickofthyme]: https://github.com/nickofthyme [@nicolashenry]: https://github.com/nicolashenry +[@NishargShah]: https://github.com/NishargShah [@noelebrun]: https://github.com/noelebrun [@ntdb]: https://github.com/ntdb [@nwalters512]: https://github.com/nwalters512 [@ombene]: https://github.com/ombene -[@Pandemic1617]: https://github.com/Pandemic1617 [@ota-meshi]: https://github.com/ota-meshi [@OutdatedVersion]: https://github.com/OutdatedVersion +[@Pandemic1617]: https://github.com/Pandemic1617 [@panrafal]: https://github.com/panrafal [@paztis]: https://github.com/paztis [@pcorpet]: https://github.com/pcorpet [@Pearce-Ropion]: https://github.com/Pearce-Ropion [@Pessimistress]: https://github.com/Pessimistress +[@phryneas]: https://github.com/phryneas [@pmcelhaney]: https://github.com/pmcelhaney [@preco21]: https://github.com/preco21 [@pri1311]: https://github.com/pri1311 @@ -1868,6 +2021,7 @@ for info on changes for earlier releases. [@rosswarren]: https://github.com/rosswarren [@rperello]: https://github.com/rperello [@rsolomon]: https://github.com/rsolomon +[@RyanGst]: https://github.com/ [@s-h-a-d-o-w]: https://github.com/s-h-a-d-o-w [@saschanaz]: https://github.com/saschanaz [@schmidsi]: https://github.com/schmidsi @@ -1875,8 +2029,10 @@ for info on changes for earlier releases. [@Schweinepriester]: https://github.com/Schweinepriester [@scottnonnenberg]: https://github.com/scottnonnenberg [@sergei-startsev]: https://github.com/sergei-startsev +[@sevenc-nanashi]: https://github.com/sevenc-nanashi [@sharmilajesupaul]: https://github.com/sharmilajesupaul [@sheepsteak]: https://github.com/sheepsteak +[@silverwind]: https://github.com/silverwind [@silviogutierrez]: https://github.com/silviogutierrez [@SimenB]: https://github.com/SimenB [@simmo]: https://github.com/simmo @@ -1899,7 +2055,6 @@ for info on changes for earlier releases. [@sveyret]: https://github.com/sveyret [@swernerx]: https://github.com/swernerx [@syymza]: https://github.com/syymza -[@taion]: https://github.com/taion [@TakeScoop]: https://github.com/TakeScoop [@tapayne88]: https://github.com/tapayne88 [@Taranys]: https://github.com/Taranys @@ -1912,6 +2067,7 @@ for info on changes for earlier releases. [@tomprats]: https://github.com/tomprats [@TrevorBurnham]: https://github.com/TrevorBurnham [@ttmarek]: https://github.com/ttmarek +[@unbeauvoyage]: https://github.com/unbeauvoyage [@vikr01]: https://github.com/vikr01 [@wenfangdu]: https://github.com/wenfangdu [@wKich]: https://github.com/wKich @@ -1919,6 +2075,8 @@ for info on changes for earlier releases. [@wtgtybhertgeghgtwtg]: https://github.com/wtgtybhertgeghgtwtg [@xM8WVqaG]: https://github.com/xM8WVqaG [@xpl]: https://github.com/xpl +[@Xunnamius]: https://github.com/Xunnamius +[@yesl-kim]: https://github.com/yesl-kim [@yndajas]: https://github.com/yndajas [@yordis]: https://github.com/yordis [@Zamiell]: https://github.com/Zamiell diff --git a/README.md b/README.md index 1baa0069b3..885f34873c 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ This plugin intends to support linting of ES2015+ (ES6+) import/export syntax, a ⌨️ Set in the `typescript` configuration.\ 🚸 Set in the `warnings` configuration.\ 🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\ -💡 Manually fixable by [editor suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions).\ +💡 Manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).\ ❌ Deprecated. ### Helpful warnings @@ -51,29 +51,30 @@ This plugin intends to support linting of ES2015+ (ES6+) import/export syntax, a ### Static analysis -| Name                       | Description | 💼 | ⚠️ | 🚫 | 🔧 | 💡 | ❌ | -| :--------------------------------------------------------------------- | :----------------------------------------------------------------------------------- | :--- | :- | :- | :- | :- | :- | -| [default](docs/rules/default.md) | Ensure a default export is present, given a default import. | ❗ ☑️ | | | | | | -| [named](docs/rules/named.md) | Ensure named imports correspond to a named export in the remote file. | ❗ ☑️ | | ⌨️ | | | | -| [namespace](docs/rules/namespace.md) | Ensure imported namespaces contain dereferenced properties as they are dereferenced. | ❗ ☑️ | | | | | | -| [no-absolute-path](docs/rules/no-absolute-path.md) | Forbid import of modules using absolute paths. | | | | 🔧 | | | -| [no-cycle](docs/rules/no-cycle.md) | Forbid a module from importing a module with a dependency path back to itself. | | | | | | | -| [no-dynamic-require](docs/rules/no-dynamic-require.md) | Forbid `require()` calls with expressions. | | | | | | | -| [no-internal-modules](docs/rules/no-internal-modules.md) | Forbid importing the submodules of other modules. | | | | | | | -| [no-relative-packages](docs/rules/no-relative-packages.md) | Forbid importing packages through relative paths. | | | | 🔧 | | | -| [no-relative-parent-imports](docs/rules/no-relative-parent-imports.md) | Forbid importing modules from parent directories. | | | | | | | -| [no-restricted-paths](docs/rules/no-restricted-paths.md) | Enforce which files can be imported in a given folder. | | | | | | | -| [no-self-import](docs/rules/no-self-import.md) | Forbid a module from importing itself. | | | | | | | -| [no-unresolved](docs/rules/no-unresolved.md) | Ensure imports point to a file/module that can be resolved. | ❗ ☑️ | | | | | | -| [no-useless-path-segments](docs/rules/no-useless-path-segments.md) | Forbid unnecessary path segments in import and require statements. | | | | 🔧 | | | -| [no-webpack-loader-syntax](docs/rules/no-webpack-loader-syntax.md) | Forbid webpack loader syntax in imports. | | | | | | | +| Name                        | Description | 💼 | ⚠️ | 🚫 | 🔧 | 💡 | ❌ | +| :----------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------- | :--- | :- | :- | :- | :- | :- | +| [default](docs/rules/default.md) | Ensure a default export is present, given a default import. | ❗ ☑️ | | | | | | +| [enforce-node-protocol-usage](docs/rules/enforce-node-protocol-usage.md) | Enforce either using, or omitting, the `node:` protocol when importing Node.js builtin modules. | | | | 🔧 | | | +| [named](docs/rules/named.md) | Ensure named imports correspond to a named export in the remote file. | ❗ ☑️ | | ⌨️ | | | | +| [namespace](docs/rules/namespace.md) | Ensure imported namespaces contain dereferenced properties as they are dereferenced. | ❗ ☑️ | | | | | | +| [no-absolute-path](docs/rules/no-absolute-path.md) | Forbid import of modules using absolute paths. | | | | 🔧 | | | +| [no-cycle](docs/rules/no-cycle.md) | Forbid a module from importing a module with a dependency path back to itself. | | | | | | | +| [no-dynamic-require](docs/rules/no-dynamic-require.md) | Forbid `require()` calls with expressions. | | | | | | | +| [no-internal-modules](docs/rules/no-internal-modules.md) | Forbid importing the submodules of other modules. | | | | | | | +| [no-relative-packages](docs/rules/no-relative-packages.md) | Forbid importing packages through relative paths. | | | | 🔧 | | | +| [no-relative-parent-imports](docs/rules/no-relative-parent-imports.md) | Forbid importing modules from parent directories. | | | | | | | +| [no-restricted-paths](docs/rules/no-restricted-paths.md) | Enforce which files can be imported in a given folder. | | | | | | | +| [no-self-import](docs/rules/no-self-import.md) | Forbid a module from importing itself. | | | | | | | +| [no-unresolved](docs/rules/no-unresolved.md) | Ensure imports point to a file/module that can be resolved. | ❗ ☑️ | | | | | | +| [no-useless-path-segments](docs/rules/no-useless-path-segments.md) | Forbid unnecessary path segments in import and require statements. | | | | 🔧 | | | +| [no-webpack-loader-syntax](docs/rules/no-webpack-loader-syntax.md) | Forbid webpack loader syntax in imports. | | | | | | | ### Style guide | Name                            | Description | 💼 | ⚠️ | 🚫 | 🔧 | 💡 | ❌ | | :------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :- | :---- | :- | :- | :- | :- | | [consistent-type-specifier-style](docs/rules/consistent-type-specifier-style.md) | Enforce or ban the use of inline type-only markers for named imports. | | | | 🔧 | | | -| [dynamic-import-chunkname](docs/rules/dynamic-import-chunkname.md) | Enforce a leading comment with the webpackChunkName for dynamic imports. | | | | | | | +| [dynamic-import-chunkname](docs/rules/dynamic-import-chunkname.md) | Enforce a leading comment with the webpackChunkName for dynamic imports. | | | | | 💡 | | | [exports-last](docs/rules/exports-last.md) | Ensure all exports appear after other statements. | | | | | | | | [extensions](docs/rules/extensions.md) | Ensure consistent use of file extension within the import path. | | | | | | | | [first](docs/rules/first.md) | Ensure all imports appear before other statements. | | | | 🔧 | | | @@ -106,29 +107,60 @@ The maintainers of `eslint-plugin-import` and thousands of other packages are wo npm install eslint-plugin-import --save-dev ``` -All rules are off by default. However, you may configure them manually -in your `.eslintrc.(yml|json|js)`, or extend one of the canned configs: +### Config - Legacy (`.eslintrc`) -```yaml ---- -extends: - - eslint:recommended - - plugin:import/recommended - # alternatively, 'recommended' is the combination of these two rule sets: - - plugin:import/errors - - plugin:import/warnings - -# or configure manually: -plugins: - - import - -rules: - import/no-unresolved: [2, {commonjs: true, amd: true}] - import/named: 2 - import/namespace: 2 - import/default: 2 - import/export: 2 - # etc... +All rules are off by default. However, you may extend one of the preset configs, or configure them manually in your `.eslintrc.(yml|json|js)`. + + - Extending a preset config: + +```jsonc +{ + "extends": [ + "eslint:recommended", + "plugin:import/recommended", + ], +} +``` + + - Configuring manually: + +```jsonc +{ + "rules": { + "import/no-unresolved": ["error", { "commonjs": true, "amd": true }], + "import/named": "error", + "import/namespace": "error", + "import/default": "error", + "import/export": "error", + // etc... + }, +}, +``` + +### Config - Flat (`eslint.config.js`) + +All rules are off by default. However, you may configure them manually in your `eslint.config.(js|cjs|mjs)`, or extend one of the preset configs: + +```js +import importPlugin from 'eslint-plugin-import'; +import js from '@eslint/js'; + +export default [ + js.configs.recommended, + importPlugin.flatConfigs.recommended, + { + files: ['**/*.{js,mjs,cjs}'], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + rules: { + 'no-unused-vars': 'off', + 'import/no-dynamic-require': 'warn', + 'import/no-nodejs-modules': 'warn', + }, + }, +]; ``` ## TypeScript @@ -137,23 +169,48 @@ You may use the following snippet or assemble your own config using the granular Make sure you have installed [`@typescript-eslint/parser`] and [`eslint-import-resolver-typescript`] which are used in the following configuration. -```yaml -extends: - - eslint:recommended - - plugin:import/recommended -# the following lines do the trick - - plugin:import/typescript -settings: - import/resolver: - # You will also need to install and configure the TypeScript resolver - # See also https://github.com/import-js/eslint-import-resolver-typescript#configuration - typescript: true - node: true +```jsonc +{ + "extends": [ + "eslint:recommended", + "plugin:import/recommended", +// the following lines do the trick + "plugin:import/typescript", + ], + "settings": { + "import/resolver": { + // You will also need to install and configure the TypeScript resolver + // See also https://github.com/import-js/eslint-import-resolver-typescript#configuration + "typescript": true, + "node": true, + }, + }, +} ``` [`@typescript-eslint/parser`]: https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser [`eslint-import-resolver-typescript`]: https://github.com/import-js/eslint-import-resolver-typescript +### Config - Flat with `config()` in `typescript-eslint` + +If you are using the `config` method from [`typescript-eslint`](https://github.com/typescript-eslint/typescript-eslint), ensure that the `flatConfig` is included within the `extends` array. + +```js +import tseslint from 'typescript-eslint'; +import importPlugin from 'eslint-plugin-import'; +import js from '@eslint/js'; + +export default tseslint.config( + js.configs.recommended, + // other configs... + { + files: ['**/*.{ts,tsx}'], + extends: [importPlugin.flatConfigs.recommended, importPlugin.flatConfigs.typescript], + // other configs... + } +); +``` + ## Resolvers With the advent of module bundlers and the current state of modules and module @@ -177,6 +234,16 @@ You can reference resolvers in several ways (in order of precedence): - as a conventional `eslint-import-resolver` name, like `eslint-import-resolver-foo`: + ```jsonc +// .eslintrc +{ + "settings": { + // uses 'eslint-import-resolver-foo': + "import/resolver": "foo", + }, +} +``` + ```yaml # .eslintrc.yml settings: @@ -197,6 +264,15 @@ module.exports = { - with a full npm module name, like `my-awesome-npm-module`: +```jsonc +// .eslintrc +{ + "settings": { + "import/resolver": "my-awesome-npm-module", + }, +} +``` + ```yaml # .eslintrc.yml settings: @@ -292,11 +368,15 @@ In practice, this means rules other than [`no-unresolved`](./docs/rules/no-unres `no-unresolved` has its own [`ignore`](./docs/rules/no-unresolved.md#ignore) setting. -```yaml -settings: - import/ignore: - - \.coffee$ # fraught with parse errors - - \.(scss|less|css)$ # can't parse unprocessed CSS modules, either +```jsonc +{ + "settings": { + "import/ignore": [ + "\.coffee$", // fraught with parse errors + "\.(scss|less|css)$", // can't parse unprocessed CSS modules, either + ], + }, +} ``` ### `import/core-modules` @@ -315,10 +395,13 @@ import 'electron' // without extra config, will be flagged as unresolved! that would otherwise be unresolved. To avoid this, you may provide `electron` as a core module: -```yaml -# .eslintrc.yml -settings: - import/core-modules: [ electron ] +```jsonc +// .eslintrc +{ + "settings": { + "import/core-modules": ["electron"], + }, +} ``` In Electron's specific case, there is a shared config named `electron` @@ -351,11 +434,15 @@ dependency parser will require and use the map key as the parser instead of the configured ESLint parser. This is useful if you're inter-op-ing with TypeScript directly using webpack, for example: -```yaml -# .eslintrc.yml -settings: - import/parsers: - "@typescript-eslint/parser": [ .ts, .tsx ] +```jsonc +// .eslintrc +{ + "settings": { + "import/parsers": { + "@typescript-eslint/parser": [".ts", ".tsx"], + }, + }, +} ``` In this case, [`@typescript-eslint/parser`](https://www.npmjs.com/package/@typescript-eslint/parser) @@ -385,20 +472,28 @@ For long-lasting processes, like [`eslint_d`] or [`eslint-loader`], however, it' If you never use [`eslint_d`] or [`eslint-loader`], you may set the cache lifetime to `Infinity` and everything should be fine: -```yaml -# .eslintrc.yml -settings: - import/cache: - lifetime: ∞ # or Infinity +```jsonc +// .eslintrc +{ + "settings": { + "import/cache": { + "lifetime": "∞", // or Infinity, in a JS config + }, + }, +} ``` Otherwise, set some integer, and cache entries will be evicted after that many seconds have elapsed: -```yaml -# .eslintrc.yml -settings: - import/cache: - lifetime: 5 # 30 is the default +```jsonc +// .eslintrc +{ + "settings": { + "import/cache": { + "lifetime": 5, // 30 is the default + }, + }, +} ``` [`eslint_d`]: https://www.npmjs.com/package/eslint_d @@ -412,10 +507,27 @@ By default, any package referenced from [`import/external-module-folders`](#impo For example, if your packages in a monorepo are all in `@scope`, you can configure `import/internal-regex` like this -```yaml -# .eslintrc.yml -settings: - import/internal-regex: ^@scope/ +```jsonc +// .eslintrc +{ + "settings": { + "import/internal-regex": "^@scope/", + }, +} +``` + +### `import/node-version` + +A string that represents the version of Node.js that you are using. +A falsy value will imply the version of Node.js that you are running ESLint with. + +```jsonc +// .eslintrc +{ + "settings": { + "import/node-version": "22.3.4", + }, +} ``` ## SublimeLinter-eslint diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index e50ab87d2a..0000000000 --- a/appveyor.yml +++ /dev/null @@ -1,165 +0,0 @@ -configuration: - - Native - - WSL - -# Test against this version of Node.js -environment: - matrix: - - nodejs_version: "16" - - nodejs_version: "14" - - nodejs_version: "12" - - nodejs_version: "10" - - nodejs_version: "8" - # - nodejs_version: "6" - # - nodejs_version: "4" - -image: Visual Studio 2019 -matrix: - fast_finish: false - exclude: - - configuration: WSL - nodejs_version: "8" - - configuration: WSL - nodejs_version: "6" - - configuration: WSL - nodejs_version: "4" - - allow_failures: - - nodejs_version: "4" # for eslint 5 - - configuration: WSL - -platform: - - x86 - - x64 - -# Initialization scripts. (runs before repo cloning) -init: - # Declare version-numbers of packages to install - - ps: >- - if ($env:nodejs_version -eq "4") { - $env:NPM_VERSION="3" - } - if ($env:nodejs_version -in @("8")) { - $env:NPM_VERSION="6" - } - if ($env:nodejs_version -in @("10", "12", "14", "16")) { - $env:NPM_VERSION="6" # TODO: use npm 7 - $env:NPM_CONFIG_LEGACY_PEER_DEPS="true" - } - - ps: >- - $env:ESLINT_VERSION="7"; - if ([int]$env:nodejs_version -le 8) { - $env:ESLINT_VERSION="6" - } - if ([int]$env:nodejs_version -le 7) { - $env:ESLINT_VERSION="5" - } - if ([int]$env:nodejs_version -le 6) { - $env:ESLINT_VERSION="4" - } - - ps: $env:WINDOWS_NYC_VERSION = "15.0.1" - - ps: $env:TRAVIS_NODE_VERSION = $env:nodejs_version - - # Add `ci`-command to `PATH` for running commands either using cmd or wsl depending on the configuration - - ps: $env:PATH += ";$(Join-Path $(pwd) "scripts")" - -# Install scripts. (runs after repo cloning) -before_build: - # Install propert `npm`-version - - IF DEFINED NPM_VERSION ci sudo npm install -g npm@%NPM_VERSION% - - # Install dependencies - - ci npm install - - ci npm run copy-metafiles - - bash ./tests/dep-time-travel.sh 2>&1 - - # fix symlinks - - git config core.symlinks true - - git reset --hard - - ci git reset --hard - - # Install dependencies of resolvers - - ps: >- - $resolverDir = "./resolvers"; - $resolvers = @(); - Get-ChildItem -Directory $resolverDir | - ForEach { - $resolvers += "$(Resolve-Path $(Join-Path $resolverDir $_))"; - } - $env:RESOLVERS = [string]::Join(";", $resolvers); - - FOR %%G in ("%RESOLVERS:;=";"%") do ( pushd %%~G & ci npm install & popd ) - - # Install proper `eslint`-version - - IF DEFINED ESLINT_VERSION ci npm install --no-save eslint@%ESLINT_VERSION% - -# Build scripts (project isn't actually built) -build_script: - - ps: "# This Project isn't actually built" - -# Test scripts -test_script: - # Output useful info for debugging. - - ci node --version - - ci npm --version - - # Run core tests - - ci npm run pretest - - ci npm run tests-only - - # Run resolver tests - - ps: >- - $resolverDir = "./resolvers"; - $resolvers = @(); - Get-ChildItem -Directory $resolverDir | - ForEach { - $resolvers += "$(Resolve-Path $(Join-Path $resolverDir $_))"; - } - $env:RESOLVERS = [string]::Join(";", $resolvers); - - FOR %%G in ("%RESOLVERS:;=";"%") do ( pushd %%~G & ci npm test & popd ) - -# Configuration-specific steps -for: - - matrix: - except: - - configuration: WSL - install: - # Get the latest stable version of Node.js or io.js - - ps: Install-Product node $env:nodejs_version - before_test: - # Upgrade nyc - - ci npm i --no-save nyc@%WINDOWS_NYC_VERSION% - - ps: >- - $resolverDir = "./resolvers"; - $resolvers = @(); - Get-ChildItem -Directory $resolverDir | - ForEach { - Push-Location $(Resolve-Path $(Join-Path $resolverDir $_)); - ci npm ls nyc > $null; - if ($?) { - $resolvers += "$(pwd)"; - } - Pop-Location; - } - $env:RESOLVERS = [string]::Join(";", $resolvers); - - IF DEFINED RESOLVERS FOR %%G in ("%RESOLVERS:;=";"%") do ( pushd %%~G & ci npm install --no-save nyc@%WINDOWS_NYC_VERSION% & popd ) - # TODO: enable codecov for native windows builds - #on_success: - #- ci $ProgressPreference = 'SilentlyContinue' - #- ci Invoke-WebRequest -Uri https://uploader.codecov.io/latest/windows/codecov.exe - #- ci -Outfile codecov.exe - #- ci .\codecov.exe - - matrix: - only: - - configuration: WSL - # Install scripts. (runs after repo cloning) - install: - # Get a specific version of Node.js - - ps: $env:WSLENV += ":nodejs_version" - - ps: wsl curl -sL 'https://deb.nodesource.com/setup_${nodejs_version}.x' `| sudo APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=1 -E bash - - - wsl sudo DEBIAN_FRONTEND=noninteractive apt install -y nodejs - on_success: - - ci curl -Os https://uploader.codecov.io/latest/linux/codecov - - ci chmod +x codecov - - ci ./codecov - -build: on diff --git a/config/flat/errors.js b/config/flat/errors.js new file mode 100644 index 0000000000..98c19f824d --- /dev/null +++ b/config/flat/errors.js @@ -0,0 +1,14 @@ +/** + * unopinionated config. just the things that are necessarily runtime errors + * waiting to happen. + * @type {Object} + */ +module.exports = { + rules: { + 'import/no-unresolved': 2, + 'import/named': 2, + 'import/namespace': 2, + 'import/default': 2, + 'import/export': 2, + }, +}; diff --git a/config/flat/react.js b/config/flat/react.js new file mode 100644 index 0000000000..0867471422 --- /dev/null +++ b/config/flat/react.js @@ -0,0 +1,19 @@ +/** + * Adds `.jsx` as an extension, and enables JSX parsing. + * + * Even if _you_ aren't using JSX (or .jsx) directly, if your dependencies + * define jsnext:main and have JSX internally, you may run into problems + * if you don't enable these settings at the top level. + */ +module.exports = { + settings: { + 'import/extensions': ['.js', '.jsx', '.mjs', '.cjs'], + }, + languageOptions: { + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, +}; diff --git a/config/flat/recommended.js b/config/flat/recommended.js new file mode 100644 index 0000000000..11bc1f52a4 --- /dev/null +++ b/config/flat/recommended.js @@ -0,0 +1,26 @@ +/** + * The basics. + * @type {Object} + */ +module.exports = { + rules: { + // analysis/correctness + 'import/no-unresolved': 'error', + 'import/named': 'error', + 'import/namespace': 'error', + 'import/default': 'error', + 'import/export': 'error', + + // red flags (thus, warnings) + 'import/no-named-as-default': 'warn', + 'import/no-named-as-default-member': 'warn', + 'import/no-duplicates': 'warn', + }, + + // need all these for parsing dependencies (even if _your_ code doesn't need + // all of them) + languageOptions: { + ecmaVersion: 2018, + sourceType: 'module', + }, +}; diff --git a/config/flat/warnings.js b/config/flat/warnings.js new file mode 100644 index 0000000000..e788ff9cde --- /dev/null +++ b/config/flat/warnings.js @@ -0,0 +1,11 @@ +/** + * more opinionated config. + * @type {Object} + */ +module.exports = { + rules: { + 'import/no-named-as-default': 1, + 'import/no-named-as-default-member': 1, + 'import/no-duplicates': 1, + }, +}; diff --git a/config/react.js b/config/react.js index 68555512d7..1ae8e1a51a 100644 --- a/config/react.js +++ b/config/react.js @@ -6,7 +6,6 @@ * if you don't enable these settings at the top level. */ module.exports = { - settings: { 'import/extensions': ['.js', '.jsx'], }, @@ -14,5 +13,4 @@ module.exports = { parserOptions: { ecmaFeatures: { jsx: true }, }, - }; diff --git a/config/typescript.js b/config/typescript.js index ff7d0795c8..d5eb57a465 100644 --- a/config/typescript.js +++ b/config/typescript.js @@ -9,7 +9,7 @@ // `.ts`/`.tsx`/`.js`/`.jsx` implementation. const typeScriptExtensions = ['.ts', '.cts', '.mts', '.tsx']; -const allExtensions = [...typeScriptExtensions, '.js', '.jsx']; +const allExtensions = [...typeScriptExtensions, '.js', '.jsx', '.mjs', '.cjs']; module.exports = { settings: { diff --git a/docs/rules/dynamic-import-chunkname.md b/docs/rules/dynamic-import-chunkname.md index 35ae9df516..d9ee8d15e2 100644 --- a/docs/rules/dynamic-import-chunkname.md +++ b/docs/rules/dynamic-import-chunkname.md @@ -1,5 +1,7 @@ # import/dynamic-import-chunkname +💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions). + This rule reports any dynamic imports without a webpackChunkName specified in a leading block comment in the proper format. @@ -13,9 +15,10 @@ You can also configure the regex format you'd like to accept for the webpackChun ```javascript { - "dynamic-import-chunkname": [2, { + "import/dynamic-import-chunkname": [2, { importFunctions: ["dynamicImport"], - webpackChunknameFormat: "[a-zA-Z0-57-9-/_]+" + webpackChunknameFormat: "[a-zA-Z0-57-9-/_]+", + allowEmpty: false }] } ``` @@ -55,6 +58,13 @@ import( // webpackChunkName: "someModule" 'someModule', ); + +// chunk names are disallowed when eager mode is set +import( + /* webpackMode: "eager" */ + /* webpackChunkName: "someModule" */ + 'someModule', +) ``` ### valid @@ -87,6 +97,38 @@ The following patterns are valid: ); ``` +### `allowEmpty: true` + +If you want to allow dynamic imports without a webpackChunkName, you can set `allowEmpty: true` in the rule config. This will allow dynamic imports without a leading comment, or with a leading comment that does not contain a webpackChunkName. + +Given `{ "allowEmpty": true }`: + + +### valid + +The following patterns are valid: + +```javascript +import('someModule'); + +import( + /* webpackChunkName: "someModule" */ + 'someModule', +); +``` + +### invalid + +The following patterns are invalid: + +```javascript +// incorrectly formatted comment +import( + /*webpackChunkName:"someModule"*/ + 'someModule', +); +``` + ## When Not To Use It If you don't care that webpack will autogenerate chunk names and may blow up browser caches and bundle size reports. diff --git a/docs/rules/enforce-node-protocol-usage.md b/docs/rules/enforce-node-protocol-usage.md new file mode 100644 index 0000000000..8d023b4e9c --- /dev/null +++ b/docs/rules/enforce-node-protocol-usage.md @@ -0,0 +1,81 @@ +# import/enforce-node-protocol-usage + +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + + +Enforce either using, or omitting, the `node:` protocol when importing Node.js builtin modules. + +## Rule Details + +This rule enforces that builtins node imports are using, or omitting, the `node:` protocol. + +Determining whether a specifier is a core module depends on the node version being used to run `eslint`. +This version can be specified in the configuration with the [`import/node-version` setting](../../README.md#importnode-version). + +Reasons to prefer using the protocol include: + + - the code is more explicitly and clearly referencing a Node.js built-in module + +Reasons to prefer omitting the protocol include: + + - some tools don't support the `node:` protocol + - the code is more portable, because import maps and automatic polyfilling can be used + +## Options + +The rule requires a single string option which may be one of: + + - `'always'` - enforces that builtins node imports are using the `node:` protocol. + - `'never'` - enforces that builtins node imports are not using the `node:` protocol. + +## Examples + +### `'always'` + +❌ Invalid + +```js +import fs from 'fs'; +export { promises } from 'fs'; +// require +const fs = require('fs/promises'); +``` + +✅ Valid + +```js +import fs from 'node:fs'; +export { promises } from 'node:fs'; +import * as test from 'node:test'; +// require +const fs = require('node:fs/promises'); +``` + +### `'never'` + +❌ Invalid + +```js +import fs from 'node:fs'; +export { promises } from 'node:fs'; +// require +const fs = require('node:fs/promises'); +``` + +✅ Valid + +```js +import fs from 'fs'; +export { promises } from 'fs'; + +// require +const fs = require('fs/promises'); + +// This rule will not enforce not using `node:` protocol when the module is only available under the `node:` protocol. +import * as test from 'node:test'; +``` + +## When Not To Use It + +If you don't want to consistently enforce using, or omitting, the `node:` protocol when importing Node.js builtin modules. diff --git a/docs/rules/extensions.md b/docs/rules/extensions.md index 946ccb7bf8..bd9f3f3584 100644 --- a/docs/rules/extensions.md +++ b/docs/rules/extensions.md @@ -10,13 +10,13 @@ In order to provide a consistent use of file extensions across your code base, t This rule either takes one string option, one object option, or a string and an object option. If it is the string `"never"` (the default value), then the rule forbids the use for any extension. If it is the string `"always"`, then the rule enforces the use of extensions for all import statements. If it is the string `"ignorePackages"`, then the rule enforces the use of extensions for all import statements except package imports. -```json +```jsonc "import/extensions": [, "never" | "always" | "ignorePackages"] ``` By providing an object you can configure each extension separately. -```json +```jsonc "import/extensions": [, { : "never" | "always" | "ignorePackages" }] @@ -26,7 +26,7 @@ By providing an object you can configure each extension separately. By providing both a string and an object, the string will set the default setting for all extensions, and the object can be used to set granular overrides for specific extensions. -```json +```jsonc "import/extensions": [ , "never" | "always" | "ignorePackages", @@ -40,7 +40,7 @@ For example, `["error", "never", { "svg": "always" }]` would require that all ex `ignorePackages` can be set as a separate boolean option like this: -```json +```jsonc "import/extensions": [ , "never" | "always" | "ignorePackages", @@ -56,6 +56,52 @@ For example, `["error", "never", { "svg": "always" }]` would require that all ex In that case, if you still want to specify extensions, you can do so inside the **pattern** property. Default value of `ignorePackages` is `false`. +By default, `import type` and `export type` style imports/exports are ignored. If you want to check them as well, you can set the `checkTypeImports` option to `true`. + +Unfortunately, in more advanced linting setups, such as when employing custom specifier aliases (e.g. you're using `eslint-import-resolver-alias`, `paths` in `tsconfig.json`, etc), this rule can be too coarse-grained when determining which imports to ignore and on which to enforce the config. +This is especially troublesome if you have import specifiers that [look like externals or builtins](./order.md#how-imports-are-grouped). + +Set `pathGroupOverrides` to force this rule to always ignore certain imports and never ignore others. +`pathGroupOverrides` accepts an array of one or more [`PathGroupOverride`](#pathgroupoverride) objects. + +For example: + +```jsonc +"import/extensions": [ + , + "never" | "always" | "ignorePackages", + { + ignorePackages: true | false, + pattern: { + : "never" | "always" | "ignorePackages" + }, + pathGroupOverrides: [ + { + pattern: "package-name-to-ignore", + action: "ignore", + }, + { + pattern: "bespoke+alias:{*,*/**}", + action: "enforce", + } + ] + } +] +``` + +> \[!NOTE] +> +> `pathGroupOverrides` is inspired by [`pathGroups` in `'import/order'`](./order.md#pathgroups) and shares a similar interface. +> If you're using `pathGroups` already, you may find `pathGroupOverrides` very useful. + +### `PathGroupOverride` + +| property | required | type | description | +| :--------------: | :------: | :---------------------: | --------------------------------------------------------------- | +| `pattern` | ☑️ | `string` | [Minimatch pattern][16] for specifier matching | +| `patternOptions` | | `object` | [Minimatch options][17]; default: `{nocomment: true}` | +| `action` | ☑️ | `"enforce" \| "ignore"` | What action to take on imports whose specifiers match `pattern` | + ### Exception When disallowing the use of certain extensions this rule makes an exception and allows the use of extension when the file would not be resolvable without extension. @@ -104,6 +150,14 @@ import express from 'express/index'; import * as path from 'path'; ``` +The following patterns are considered problems when the configuration is set to "never" and the option "checkTypeImports" is set to `true`: + +```js +import type { Foo } from './foo.ts'; + +export type { Foo } from './foo.ts'; +``` + The following patterns are considered problems when configuration set to "always": ```js @@ -167,8 +221,19 @@ import express from 'express'; import foo from '@/foo'; ``` +The following patterns are considered problems when the configuration is set to "always" and the option "checkTypeImports" is set to `true`: + +```js +import type { Foo } from './foo'; + +export type { Foo } from './foo'; +``` + ## When Not To Use It If you are not concerned about a consistent usage of file extension. In the future, when this rule supports native node ESM resolution, and the plugin is configured to use native rather than transpiled ESM (a config option that is not yet available) - setting this to `always` will have no effect. + +[16]: https://www.npmjs.com/package/minimatch#features +[17]: https://www.npmjs.com/package/minimatch#options diff --git a/docs/rules/no-cycle.md b/docs/rules/no-cycle.md index 76e96f95f2..898b75330e 100644 --- a/docs/rules/no-cycle.md +++ b/docs/rules/no-cycle.md @@ -94,6 +94,14 @@ export function getBar() { return import('./bar'); } > Cyclic dependency are **always** a dangerous anti-pattern as discussed extensively in [#2265](https://github.com/import-js/eslint-plugin-import/issues/2265). Please be extra careful about using this option. +#### `disableScc` + +This option disables a pre-processing step that calculates [Strongly Connected Components](https://en.wikipedia.org/wiki/Strongly_connected_component), which are used for avoiding unnecessary work checking files in different SCCs for cycles. + +However, under some configurations, this pre-processing may be more expensive than the time it saves. + +When this option is `true`, we don't calculate any SCC graph, and check all files for cycles (leading to higher time-complexity). Default is `false`. + ## When Not To Use It This rule is comparatively computationally expensive. If you are pressed for lint diff --git a/docs/rules/no-empty-named-blocks.md b/docs/rules/no-empty-named-blocks.md index 85821d8afe..ad83c535f8 100644 --- a/docs/rules/no-empty-named-blocks.md +++ b/docs/rules/no-empty-named-blocks.md @@ -1,6 +1,6 @@ # import/no-empty-named-blocks -🔧💡 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix) and manually fixable by [editor suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions). +🔧💡 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix) and manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions). diff --git a/docs/rules/no-extraneous-dependencies.md b/docs/rules/no-extraneous-dependencies.md index 547e5c2e57..848d5bb0da 100644 --- a/docs/rules/no-extraneous-dependencies.md +++ b/docs/rules/no-extraneous-dependencies.md @@ -32,7 +32,7 @@ You can also use an array of globs instead of literal booleans: "import/no-extraneous-dependencies": ["error", {"devDependencies": ["**/*.test.js", "**/*.spec.js"]}] ``` -When using an array of globs, the setting will be set to `true` (no errors reported) if the name of the file being linted matches a single glob in the array, and `false` otherwise. +When using an array of globs, the setting will be set to `true` (no errors reported) if the name of the file being linted (i.e. not the imported file/module) matches a single glob in the array, and `false` otherwise. There are 2 boolean options to opt into checking extra imports that are normally ignored: `includeInternal`, which enables the checking of internal modules, and `includeTypes`, which enables checking of type imports in TypeScript. diff --git a/docs/rules/no-relative-packages.md b/docs/rules/no-relative-packages.md index 4014ed9859..ed724a9ebe 100644 --- a/docs/rules/no-relative-packages.md +++ b/docs/rules/no-relative-packages.md @@ -6,8 +6,7 @@ Use this rule to prevent importing packages through relative paths. -It's useful in Yarn/Lerna workspaces, were it's possible to import a sibling -package using `../package` relative path, while direct `package` is the correct one. +It's useful in Yarn/Lerna workspaces, where it's possible to import a sibling package using `../package` relative path, while direct `package` is the correct one. ## Examples diff --git a/docs/rules/no-restricted-paths.md b/docs/rules/no-restricted-paths.md index 293f3ba009..5ba4b393e3 100644 --- a/docs/rules/no-restricted-paths.md +++ b/docs/rules/no-restricted-paths.md @@ -5,194 +5,216 @@ Some projects contain files which are not always meant to be executed in the same environment. For example consider a web application that contains specific code for the server and some specific code for the browser/client. In this case you don’t want to import server-only files in your client code. -In order to prevent such scenarios this rule allows you to define restricted zones where you can forbid files from imported if they match a specific path. +In order to prevent such scenarios this rule allows you to define restricted zones where you can forbid files from being imported if they match a specific path. ## Rule Details -This rule has one option. The option is an object containing the definition of all restricted `zones` and the optional `basePath` which is used to resolve relative paths within. -The default value for `basePath` is the current working directory. - -Each zone consists of the `target` paths, a `from` paths, and an optional `except` and `message` attribute. - - - `target` contains the paths where the restricted imports should be applied. It can be expressed by - - directory string path that matches all its containing files - - glob pattern matching all the targeted files - - an array of multiple of the two types above - - `from` paths define the folders that are not allowed to be used in an import. It can be expressed by - - directory string path that matches all its containing files - - glob pattern matching all the files restricted to be imported - - an array of multiple directory string path - - an array of multiple glob patterns - - `except` may be defined for a zone, allowing exception paths that would otherwise violate the related `from`. Note that it does not alter the behaviour of `target` in any way. - - in case `from` contains only glob patterns, `except` must be an array of glob patterns as well - - in case `from` contains only directory path, `except` is relative to `from` and cannot backtrack to a parent directory - - `message` - will be displayed in case of the rule violation. +This rule has one option, which is an object containing all `zones` where restrictions will be applied, plus an optional `basePath` used to resolve relative paths within each zone. +The default for `basePath` is the current working directory. + +Each zone consists of a `target`, a `from`, and optional `except` and `message` attributes. + + - `target` - Identifies which files are part of the zone. It can be expressed as: + - A simple directory path, matching all files contained recursively within it + - A glob pattern + - An array of any of the two types above + - *Example: `target: './client'` - this zone consists of all files under the 'client' dir* + - `from` - Identifies folders from which the zone is not allowed to import. It can be expressed as: + - A simple directory path, matching all files contained recursively within it + - A glob pattern + - An array of only simple directories, or of only glob patterns (mixing both types within the array is not allowed) + - *Example: `from: './server'` - this zone is not allowed to import anything from the 'server' dir* + - `except` - Optional. Allows exceptions that would otherwise violate the related `from`. Note that it does not alter the behaviour of `target` in any way. + - If `from` is an array of glob patterns, `except` must be an array of glob patterns as well. + - If `from` is an array of simple directories, `except` is relative to `from` and cannot backtrack to a parent directory. + - *Example: `except: './server/config'` this zone is allowed to import server config, even if it can't import other server code* + - `message` - Optional. Displayed in case of rule violation. + +*Note: The `from` attribute is NOT matched literally against the import path string as it appears in the code. Instead, it's matched against the path to the imported file after it's been resolved against `basePath`.* ### Examples -Given the following folder structure: +Given this folder structure: ```pt -my-project +. ├── client -│ └── foo.js +│ ├── foo.js │ └── baz.js └── server └── bar.js ``` -and the current file being linted is `my-project/client/foo.js`. +And this configuration: -The following patterns are considered problems when configuration set to `{ "zones": [ { "target": "./client", "from": "./server" } ] }`: +```json +{ + "zones": [ + { + "target": "./client", + "from": "./server" + } + ] +} +``` + +:x: The following is considered incorrect: ```js +// client/foo.js import bar from '../server/bar'; ``` -The following patterns are not considered problems when configuration set to `{ "zones": [ { "target": "./client", "from": "./server" } ] }`: +:white_check_mark: The following is considered correct: ```js +// server/bar.js import baz from '../client/baz'; ``` --------------- -Given the following folder structure: +Given this folder structure: ```pt -my-project +. ├── client -│ └── foo.js -│ └── baz.js +│ └── ... └── server ├── one - │ └── a.js + │ ├── a.js │ └── b.js └── two + └── a.js ``` -and the current file being linted is `my-project/server/one/a.js`. - -and the current configuration is set to: +And this configuration: ```json -{ "zones": [ { - "target": "./tests/files/restricted-paths/server/one", - "from": "./tests/files/restricted-paths/server", - "except": ["./one"] -} ] } +{ + "zones": [ + { + "target": "./server/one", + "from": "./server", + "except": ["./one"] + } + ] +} ``` -The following pattern is considered a problem: +:x: The following is considered incorrect: ```js +// server/one/a.js import a from '../two/a' ``` -The following pattern is not considered a problem: +:white_check_mark: The following is considered correct: ```js +// server/one/a.js import b from './b' - ``` --------------- -Given the following folder structure: +Given this folder structure: ```pt -my-project -├── client - └── foo.js +. +└── client + ├── foo.js └── sub-module - └── bar.js + ├── bar.js └── baz.js - ``` -and the current configuration is set to: +And this configuration: ```json -{ "zones": [ { - "target": "./tests/files/restricted-paths/client/!(sub-module)/**/*", - "from": "./tests/files/restricted-paths/client/sub-module/**/*", -} ] } +{ + "zones": [ + { + "target": "./client/!(sub-module)/**/*", + "from": "./client/sub-module/**/*", + } + ] +} ``` -The following import is considered a problem in `my-project/client/foo.js`: +:x: The following is considered incorrect: ```js +// client/foo.js import a from './sub-module/baz' ``` -The following import is not considered a problem in `my-project/client/sub-module/bar.js`: +:white_check_mark: The following is considered correct: ```js +// client/sub-module/bar.js import b from './baz' ``` --------------- -Given the following folder structure: +Given this folder structure: ```pt -my-project -└── one - └── a.js - └── b.js -└── two - └── a.js - └── b.js +. +├── one +│ ├── a.js +│ └── b.js +├── two +│ ├── a.js +│ └── b.js └── three - └── a.js - └── b.js + ├── a.js + └── b.js ``` -and the current configuration is set to: +And this configuration: ```json { "zones": [ { - "target": ["./tests/files/restricted-paths/two/*", "./tests/files/restricted-paths/three/*"], - "from": ["./tests/files/restricted-paths/one", "./tests/files/restricted-paths/three"], + "target": [ + "./two/*", + "./three/*" + ], + "from": [ + "./one", + "./three" + ] } ] } ``` -The following patterns are not considered a problem in `my-project/one/b.js`: +:white_check_mark: The following is considered correct: ```js +// one/b.js import a from '../three/a' -``` - -```js import a from './a' ``` -The following pattern is not considered a problem in `my-project/two/b.js`: - ```js +// two/b.js import a from './a' ``` -The following patterns are considered a problem in `my-project/two/a.js`: +:x: The following is considered incorrect: ```js +// two/a.js import a from '../one/a' -``` - -```js import a from '../three/a' ``` -The following patterns are considered a problem in `my-project/three/b.js`: - ```js +// three/b.js import a from '../one/a' -``` - -```js import a from './a' ``` diff --git a/docs/rules/no-unused-modules.md b/docs/rules/no-unused-modules.md index 53c2479272..8d6398e511 100644 --- a/docs/rules/no-unused-modules.md +++ b/docs/rules/no-unused-modules.md @@ -17,7 +17,7 @@ In order for this plugin to work, at least one of the options `missingExports` o Example: ```json -"rules: { +"rules": { ...otherRules, "import/no-unused-modules": [1, {"unusedExports": true}] } @@ -29,8 +29,9 @@ This rule takes the following option: - **`missingExports`**: if `true`, files without any exports are reported (defaults to `false`) - **`unusedExports`**: if `true`, exports without any static usage within other modules are reported (defaults to `false`) - - `src`: an array with files/paths to be analyzed. It only applies to unused exports. Defaults to `process.cwd()`, if not provided - - `ignoreExports`: an array with files/paths for which unused exports will not be reported (e.g module entry points in a published package) + - **`ignoreUnusedTypeExports`**: if `true`, TypeScript type exports without any static usage within other modules are reported (defaults to `false` and has no effect unless `unusedExports` is `true`) + - **`src`**: an array with files/paths to be analyzed. It only applies to unused exports. Defaults to `process.cwd()`, if not provided + - **`ignoreExports`**: an array with files/paths for which unused exports will not be reported (e.g module entry points in a published package) ### Example for missing exports @@ -116,6 +117,16 @@ export function doAnything() { export default 5 // will not be reported ``` +### Unused exports with `ignoreUnusedTypeExports` set to `true` + +The following will not be reported: + +```ts +export type Foo = {}; // will not be reported +export interface Foo = {}; // will not be reported +export enum Foo {}; // will not be reported +``` + #### Important Note Exports from files listed as a main file (`main`, `browser`, or `bin` fields in `package.json`) will be ignored by default. This only applies if the `package.json` is not set to `private: true` diff --git a/docs/rules/order.md b/docs/rules/order.md index 2335699e6c..4a52b823e1 100644 --- a/docs/rules/order.md +++ b/docs/rules/order.md @@ -6,7 +6,7 @@ Enforce a convention in the order of `require()` / `import` statements. -With the [`groups`](#groups-array) option set to `["builtin", "external", "internal", "parent", "sibling", "index", "object", "type"]` the order is as shown in the following example: +With the [`groups`][18] option set to `["builtin", "external", "internal", "parent", "sibling", "index", "object", "type"]` the order is as shown in the following example: ```ts // 1. node "builtin" modules @@ -32,9 +32,7 @@ import log = console.log; import type { Foo } from 'foo'; ``` -Unassigned imports are ignored, as the order they are imported in may be important. - -Statements using the ES6 `import` syntax must appear before any `require()` statements. +See [here][3] for further details on how imports are grouped. ## Fail @@ -77,88 +75,169 @@ import foo from './foo'; var path = require('path'); ``` -## Options +## Limitations of `--fix` -This rule supports the following options: +Unbound imports are assumed to have side effects, and will never be moved/reordered. This can cause other imports to get "stuck" around them, and the fix to fail. -### `groups: [array]` +```javascript +import b from 'b' +import 'format.css'; // This will prevent --fix from working. +import a from 'a' +``` -How groups are defined, and the order to respect. `groups` must be an array of `string` or [`string`]. The only allowed `string`s are: -`"builtin"`, `"external"`, `"internal"`, `"unknown"`, `"parent"`, `"sibling"`, `"index"`, `"object"`, `"type"`. -The enforced order is the same as the order of each element in a group. Omitted types are implicitly grouped together as the last element. Example: +As a workaround, move unbound imports to be entirely above or below bound ones. -```ts -[ - 'builtin', // Built-in types are first - ['sibling', 'parent'], // Then sibling and parent types. They can be mingled together - 'index', // Then the index file - 'object', - // Then the rest: internal and external type -] +```javascript +import 'format1.css'; // OK +import b from 'b' +import a from 'a' +import 'format2.css'; // OK ``` -The default value is `["builtin", "external", "parent", "sibling", "index"]`. +## Options -You can set the options like this: +This rule supports the following options (none of which are required): -```ts -"import/order": [ - "error", - { + - [`groups`][18] + - [`pathGroups`][8] + - [`pathGroupsExcludedImportTypes`][9] + - [`distinctGroup`][32] + - [`newlines-between`][20] + - [`alphabetize`][30] + - [`named`][33] + - [`warnOnUnassignedImports`][5] + - [`sortTypesGroup`][7] + - [`newlines-between-types`][27] + - [`consolidateIslands`][25] + +--- + +### `groups` + +Valid values: `("builtin" | "external" | "internal" | "unknown" | "parent" | "sibling" | "index" | "object" | "type")[]` \ +Default: `["builtin", "external", "parent", "sibling", "index"]` + +Determines which imports are subject to ordering, and how to order +them. The predefined groups are: `"builtin"`, `"external"`, `"internal"`, +`"unknown"`, `"parent"`, `"sibling"`, `"index"`, `"object"`, and `"type"`. + +The import order enforced by this rule is the same as the order of each group +in `groups`. Imports belonging to groups omitted from `groups` are lumped +together at the end. + +#### Example + +```jsonc +{ + "import/order": ["error", { "groups": [ - "index", - "sibling", - "parent", - "internal", - "external", + // Imports of builtins are first "builtin", + // Then sibling and parent imports. They can be mingled together + ["sibling", "parent"], + // Then index file imports + "index", + // Then any arcane TypeScript imports "object", - "type" - ] - } -] + // Then the omitted imports: internal, external, type, unknown + ], + }], +} ``` -### `pathGroups: [array of objects]` +#### How Imports Are Grouped + +An import (a `ImportDeclaration`, `TSImportEqualsDeclaration`, or `require()` `CallExpression`) is grouped by its type (`"require"` vs `"import"`), its [specifier][4], and any corresponding identifiers. + +```ts +import { identifier1, identifier2 } from 'specifier1'; +import type { MyType } from 'specifier2'; +const identifier3 = require('specifier3'); +``` + +Roughly speaking, the grouping algorithm is as follows: + +1. If the import has no corresponding identifiers (e.g. `import './my/thing.js'`), is otherwise "unassigned," or is an unsupported use of `require()`, and [`warnOnUnassignedImports`][5] is disabled, it will be ignored entirely since the order of these imports may be important for their [side-effects][31] +2. If the import is part of an arcane TypeScript declaration (e.g. `import log = console.log`), it will be considered **object**. However, note that external module references (e.g. `import x = require('z')`) are treated as normal `require()`s and import-exports (e.g. `export import w = y;`) are ignored entirely +3. If the import is [type-only][6], `"type"` is in `groups`, and [`sortTypesGroup`][7] is disabled, it will be considered **type** (with additional implications if using [`pathGroups`][8] and `"type"` is in [`pathGroupsExcludedImportTypes`][9]) +4. If the import's specifier matches [`import/internal-regex`][28], it will be considered **internal** +5. If the import's specifier is an absolute path, it will be considered **unknown** +6. If the import's specifier has the name of a Node.js core module (using [is-core-module][10]), it will be considered **builtin** +7. If the import's specifier matches [`import/core-modules`][11], it will be considered **builtin** +8. If the import's specifier is a path relative to the parent directory of its containing file (e.g. starts with `../`), it will be considered **parent** +9. If the import's specifier is one of `['.', './', './index', './index.js']`, it will be considered **index** +10. If the import's specifier is a path relative to its containing file (e.g. starts with `./`), it will be considered **sibling** +11. If the import's specifier is a path pointing to a file outside the current package's root directory (determined using [package-up][12]), it will be considered **external** +12. If the import's specifier matches [`import/external-module-folders`][29] (defaults to matching anything pointing to files within the current package's `node_modules` directory), it will be considered **external** +13. If the import's specifier is a path pointing to a file within the current package's root directory (determined using [package-up][12]), it will be considered **internal** +14. If the import's specifier has a name that looks like a scoped package (e.g. `@scoped/package-name`), it will be considered **external** +15. If the import's specifier has a name that starts with a word character, it will be considered **external** +16. If this point is reached, the import will be ignored entirely + +At the end of the process, if they co-exist in the same file, all top-level `require()` statements that haven't been ignored are shifted (with respect to their order) below any ES6 `import` or similar declarations. Finally, any type-only declarations are potentially reorganized according to [`sortTypesGroup`][7]. -To be able to group by paths mostly needed with aliases pathGroups can be defined. +### `pathGroups` -Properties of the objects +Valid values: `PathGroup[]` \ +Default: `[]` -| property | required | type | description | -|----------------|:--------:|--------|---------------| -| pattern | x | string | minimatch pattern for the paths to be in this group (will not be used for builtins or externals) | -| patternOptions | | object | options for minimatch, default: { nocomment: true } | -| group | x | string | one of the allowed groups, the pathGroup will be positioned relative to this group | -| position | | string | defines where around the group the pathGroup will be positioned, can be 'after' or 'before', if not provided pathGroup will be positioned like the group | +Sometimes [the predefined groups][18] are not fine-grained enough, especially when using import aliases. +`pathGroups` defines one or more [`PathGroup`][13]s relative to a predefined group. +Imports are associated with a [`PathGroup`][13] based on path matching against the import specifier (using [minimatch][14]). -```json +> [!IMPORTANT] +> +> Note that, by default, imports grouped as `"builtin"`, `"external"`, or `"object"` will not be considered for further `pathGroups` matching unless they are removed from [`pathGroupsExcludedImportTypes`][9]. + +#### `PathGroup` + +| property | required | type | description | +| :--------------: | :------: | :--------------------: | ------------------------------------------------------------------------------------------------------------------------------- | +| `pattern` | ☑️ | `string` | [Minimatch pattern][16] for specifier matching | +| `patternOptions` | | `object` | [Minimatch options][17]; default: `{nocomment: true}` | +| `group` | ☑️ | [predefined group][18] | One of the [predefined groups][18] to which matching imports will be positioned relatively | +| `position` | | `"after" \| "before"` | Where, in relation to `group`, matching imports will be positioned; default: same position as `group` (neither before or after) | + +#### Example + +```jsonc { "import/order": ["error", { "pathGroups": [ { + // Minimatch pattern used to match against specifiers "pattern": "~/**", - "group": "external" + // The predefined group this PathGroup is defined in relation to + "group": "external", + // How matching imports will be positioned relative to "group" + "position": "after" } ] }] } ``` -### `distinctGroup: [boolean]` +### `pathGroupsExcludedImportTypes` -This changes how `pathGroups[].position` affects grouping. The property is most useful when `newlines-between` is set to `always` and at least 1 `pathGroups` entry has a `position` property set. +Valid values: `("builtin" | "external" | "internal" | "unknown" | "parent" | "sibling" | "index" | "object" | "type")[]` \ +Default: `["builtin", "external", "object"]` -By default, in the context of a particular `pathGroup` entry, when setting `position`, a new "group" will silently be created. That is, even if the `group` is specified, a newline will still separate imports that match that `pattern` with the rest of the group (assuming `newlines-between` is `always`). This is undesirable if your intentions are to use `position` to position _within_ the group (and not create a new one). Override this behavior by setting `distinctGroup` to `false`; this will keep imports within the same group as intended. +By default, imports in certain [groups][18] are excluded from being matched against [`pathGroups`][8] to prevent overeager sorting. +Use `pathGroupsExcludedImportTypes` to modify which groups are excluded. -Note that currently, `distinctGroup` defaults to `true`. However, in a later update, the default will change to `false` +> [!TIP] +> +> If using imports with custom specifier aliases (e.g. +> you're using `eslint-import-resolver-alias`, `paths` in `tsconfig.json`, etc) that [end up +> grouped][3] as `"builtin"` or `"external"` imports, +> remove them from `pathGroupsExcludedImportTypes` to ensure they are ordered +> correctly. -Example: +#### Example -```json +```jsonc { "import/order": ["error", { - "newlines-between": "always", "pathGroups": [ { "pattern": "@app/**", @@ -166,76 +245,74 @@ Example: "position": "after" } ], - "distinctGroup": false + "pathGroupsExcludedImportTypes": ["builtin"] }] } ``` -### `pathGroupsExcludedImportTypes: [array]` +### `distinctGroup` -This defines import types that are not handled by configured pathGroups. -This is mostly needed when you want to handle path groups that look like external imports. +Valid values: `boolean` \ +Default: `true` -Example: +> [!CAUTION] +> +> Currently, `distinctGroup` defaults to `true`. However, in a later update, the +> default will change to `false`. -```json +This changes how [`PathGroup.position`][13] affects grouping, and is most useful when [`newlines-between`][20] is set to `always` and at least one [`PathGroup`][13] has a `position` property set. + +When [`newlines-between`][20] is set to `always` and an import matching a specific [`PathGroup.pattern`][13] is encountered, that import is added to a sort of "sub-group" associated with that [`PathGroup`][13]. Thanks to [`newlines-between`][20], imports in this "sub-group" will have a new line separating them from the rest of the imports in [`PathGroup.group`][13]. + +This behavior can be undesirable when using [`PathGroup.position`][13] to order imports _within_ [`PathGroup.group`][13] instead of creating a distinct "sub-group". Set `distinctGroup` to `false` to disable the creation of these "sub-groups". + +#### Example + +```jsonc { "import/order": ["error", { + "distinctGroup": false, + "newlines-between": "always", "pathGroups": [ { "pattern": "@app/**", "group": "external", "position": "after" } - ], - "pathGroupsExcludedImportTypes": ["builtin"] + ] }] } ``` -You can also use `patterns`(e.g., `react`, `react-router-dom`, etc). +### `newlines-between` -Example: +Valid values: `"ignore" | "always" | "always-and-inside-groups" | "never"` \ +Default: `"ignore"` -```json -{ - "import/order": [ - "error", - { - "pathGroups": [ - { - "pattern": "react", - "group": "builtin", - "position": "before" - } - ], - "pathGroupsExcludedImportTypes": ["react"] - } - ] -} -``` +Enforces or forbids new lines between import groups. -The default value is `["builtin", "external", "object"]`. + - If set to `ignore`, no errors related to new lines between import groups will be reported -### `newlines-between: [ignore|always|always-and-inside-groups|never]` + - If set to `always`, at least one new line between each group will be enforced, and new lines inside a group will be forbidden -Enforces or forbids new lines between import groups: + > [!TIP] + > + > To prevent multiple lines between imports, the [`no-multiple-empty-lines` rule][21], or a tool like [Prettier][22], can be used. - - If set to `ignore`, no errors related to new lines between import groups will be reported. - - If set to `always`, at least one new line between each group will be enforced, and new lines inside a group will be forbidden. To prevent multiple lines between imports, core `no-multiple-empty-lines` rule can be used. - - If set to `always-and-inside-groups`, it will act like `always` except newlines are allowed inside import groups. - - If set to `never`, no new lines are allowed in the entire import section. + - If set to `always-and-inside-groups`, it will act like `always` except new lines are allowed inside import groups -The default value is `"ignore"`. + - If set to `never`, no new lines are allowed in the entire import section -With the default group setting, the following will be invalid: +#### Example + +With the default [`groups`][18] setting, the following will fail the rule check: ```ts /* eslint import/order: ["error", {"newlines-between": "always"}] */ import fs from 'fs'; import path from 'path'; -import index from './'; import sibling from './foo'; +import index from './'; ``` ```ts @@ -243,8 +320,8 @@ import sibling from './foo'; import fs from 'fs'; import path from 'path'; -import index from './'; import sibling from './foo'; +import index from './'; ``` ```ts @@ -252,21 +329,21 @@ import sibling from './foo'; import fs from 'fs'; import path from 'path'; -import index from './'; - import sibling from './foo'; + +import index from './'; ``` -while those will be valid: +While this will pass: ```ts /* eslint import/order: ["error", {"newlines-between": "always"}] */ import fs from 'fs'; import path from 'path'; -import index from './'; - import sibling from './foo'; + +import index from './'; ``` ```ts @@ -275,40 +352,57 @@ import fs from 'fs'; import path from 'path'; -import index from './'; - import sibling from './foo'; + +import index from './'; ``` ```ts /* eslint import/order: ["error", {"newlines-between": "never"}] */ import fs from 'fs'; import path from 'path'; -import index from './'; import sibling from './foo'; +import index from './'; ``` -### `alphabetize: {order: asc|desc|ignore, orderImportKind: asc|desc|ignore, caseInsensitive: true|false}` +### `alphabetize` -Sort the order within each group in alphabetical manner based on **import path**: +Valid values: `{ order?: "asc" | "desc" | "ignore", orderImportKind?: "asc" | "desc" | "ignore", caseInsensitive?: boolean }` \ +Default: `{ order: "ignore", orderImportKind: "ignore", caseInsensitive: false }` - - `order`: use `asc` to sort in ascending order, and `desc` to sort in descending order (default: `ignore`). - - `orderImportKind`: use `asc` to sort in ascending order various import kinds, e.g. imports prefixed with `type` or `typeof`, with same import path. Use `desc` to sort in descending order (default: `ignore`). - - `caseInsensitive`: use `true` to ignore case, and `false` to consider case (default: `false`). +Determine the sort order of imports within each [predefined group][18] or [`PathGroup`][8] alphabetically based on specifier. -Example setting: +> [!NOTE] +> +> Imports will be alphabetized based on their _specifiers_, not by their +> identifiers. For example, `const a = require('z');` will come _after_ `const z = require('a');` when `alphabetize` is set to `{ order: "asc" }`. -```ts -alphabetize: { - order: 'asc', /* sort in ascending order. Options: ['ignore', 'asc', 'desc'] */ - caseInsensitive: true /* ignore case. Options: [true, false] */ +Valid properties and their values include: + + - **`order`**: use `"asc"` to sort in ascending order, `"desc"` to sort in descending order, or "ignore" to prevent sorting + + - **`orderImportKind`**: use `"asc"` to sort various _import kinds_, e.g. [type-only and typeof imports][6], in ascending order, `"desc"` to sort them in descending order, or "ignore" to prevent sorting + + - **`caseInsensitive`**: use `true` to ignore case and `false` to consider case when sorting + +#### Example + +Given the following settings: + +```jsonc +{ + "import/order": ["error", { + "alphabetize": { + "order": "asc", + "caseInsensitive": true + } + }] } ``` This will fail the rule check: ```ts -/* eslint import/order: ["error", {"alphabetize": {"order": "asc", "caseInsensitive": true}}] */ import React, { PureComponent } from 'react'; import aTypes from 'prop-types'; import { compose, apply } from 'xcompose'; @@ -319,7 +413,6 @@ import blist from 'BList'; While this will pass: ```ts -/* eslint import/order: ["error", {"alphabetize": {"order": "asc", "caseInsensitive": true}}] */ import blist from 'BList'; import * as classnames from 'classnames'; import aTypes from 'prop-types'; @@ -327,19 +420,109 @@ import React, { PureComponent } from 'react'; import { compose, apply } from 'xcompose'; ``` -### `warnOnUnassignedImports: true|false` +### `named` + +Valid values: `boolean | { enabled: boolean, import?: boolean, export?: boolean, require?: boolean, cjsExports?: boolean, types?: "mixed" | "types-first" | "types-last" }` \ +Default: `false` + +Enforce ordering of names within imports and exports. + +If set to `true` or `{ enabled: true }`, _all_ named imports must be ordered according to [`alphabetize`][30]. +If set to `false` or `{ enabled: false }`, named imports can occur in any order. + +If set to `{ enabled: true, ... }`, and any of the properties `import`, `export`, `require`, or `cjsExports` are set to `false`, named ordering is disabled with respect to the following kind of expressions: + + - `import`: + + ```ts + import { Readline } from "readline"; + ``` + + - `export`: + + ```ts + export { Readline }; + // and + export { Readline } from "readline"; + ``` + + - `require`: + + ```ts + const { Readline } = require("readline"); + ``` + + - `cjsExports`: + + ```ts + module.exports.Readline = Readline; + // and + module.exports = { Readline }; + ``` + +Further, the `named.types` option allows you to specify the order of [import identifiers with inline type qualifiers][23] (or "type-only" identifiers/names), e.g. `import { type TypeIdentifier1, normalIdentifier2 } from 'specifier';`. + +`named.types` accepts the following values: + + - `types-first`: forces type-only identifiers to occur first + - `types-last`: forces type-only identifiers to occur last + - `mixed`: sorts all identifiers in alphabetical order + +#### Example + +Given the following settings: + +```jsonc +{ + "import/order": ["error", { + "named": true, + "alphabetize": { + "order": "asc" + } + }] +} +``` + +This will fail the rule check: + +```ts +import { compose, apply } from 'xcompose'; +``` + +While this will pass: + +```ts +import { apply, compose } from 'xcompose'; +``` + +### `warnOnUnassignedImports` - - default: `false` +Valid values: `boolean` \ +Default: `false` -Warns when unassigned imports are out of order. These warning will not be fixed -with `--fix` because unassigned imports are used for side-effects and changing the -import of order of modules with side effects can not be done automatically in a -way that is safe. +Warn when "unassigned" imports are out of order. +Unassigned imports are imports with no corresponding identifiers (e.g. `import './my/thing.js'` or `require('./side-effects.js')`). + +> [!NOTE] +> +> These warnings are not fixable with `--fix` since unassigned imports might be used for their [side-effects][31], +> and changing the order of such imports cannot be done safely. + +#### Example + +Given the following settings: + +```jsonc +{ + "import/order": ["error", { + "warnOnUnassignedImports": true + }] +} +``` This will fail the rule check: ```ts -/* eslint import/order: ["error", {"warnOnUnassignedImports": true}] */ import fs from 'fs'; import './styles.css'; import path from 'path'; @@ -348,18 +531,494 @@ import path from 'path'; While this will pass: ```ts -/* eslint import/order: ["error", {"warnOnUnassignedImports": true}] */ import fs from 'fs'; import path from 'path'; import './styles.css'; ``` -## Related +### `sortTypesGroup` + +Valid values: `boolean` \ +Default: `false` + +> [!NOTE] +> +> This setting is only meaningful when `"type"` is included in [`groups`][18]. + +Sort [type-only imports][6] separately from normal non-type imports. + +When enabled, the intragroup sort order of [type-only imports][6] will mirror the intergroup ordering of normal imports as defined by [`groups`][18], [`pathGroups`][8], etc. - - [`import/external-module-folders`] setting +#### Example - - [`import/internal-regex`] setting +Given the following settings: -[`import/external-module-folders`]: ../../README.md#importexternal-module-folders +```jsonc +{ + "import/order": ["error", { + "groups": ["type", "builtin", "parent", "sibling", "index"], + "alphabetize": { "order": "asc" } + }] +} +``` + +This will fail the rule check even though it's logically ordered as we expect (builtins come before parents, parents come before siblings, siblings come before indices), the only difference is we separated type-only imports from normal imports: + +```ts +import type A from "fs"; +import type B from "path"; +import type C from "../foo.js"; +import type D from "./bar.js"; +import type E from './'; + +import a from "fs"; +import b from "path"; +import c from "../foo.js"; +import d from "./bar.js"; +import e from "./"; +``` + +This happens because [type-only imports][6] are considered part of one global +[`"type"` group](#how-imports-are-grouped) by default. However, if we set +`sortTypesGroup` to `true`: + +```jsonc +{ + "import/order": ["error", { + "groups": ["type", "builtin", "parent", "sibling", "index"], + "alphabetize": { "order": "asc" }, + "sortTypesGroup": true + }] +} +``` + +The same example will pass. + +### `newlines-between-types` + +Valid values: `"ignore" | "always" | "always-and-inside-groups" | "never"` \ +Default: the value of [`newlines-between`][20] + +> [!NOTE] +> +> This setting is only meaningful when [`sortTypesGroup`][7] is enabled. + +`newlines-between-types` is functionally identical to [`newlines-between`][20] except it only enforces or forbids new lines between _[type-only][6] import groups_, which exist only when [`sortTypesGroup`][7] is enabled. + +In addition, when determining if a new line is enforceable or forbidden between the type-only imports and the normal imports, `newlines-between-types` takes precedence over [`newlines-between`][20]. + +#### Example + +Given the following settings: + +```jsonc +{ + "import/order": ["error", { + "groups": ["type", "builtin", "parent", "sibling", "index"], + "sortTypesGroup": true, + "newlines-between": "always" + }] +} +``` + +This will fail the rule check: + +```ts +import type A from "fs"; +import type B from "path"; +import type C from "../foo.js"; +import type D from "./bar.js"; +import type E from './'; + +import a from "fs"; +import b from "path"; + +import c from "../foo.js"; + +import d from "./bar.js"; + +import e from "./"; +``` + +However, if we set `newlines-between-types` to `"ignore"`: + +```jsonc +{ + "import/order": ["error", { + "groups": ["type", "builtin", "parent", "sibling", "index"], + "sortTypesGroup": true, + "newlines-between": "always", + "newlines-between-types": "ignore" + }] +} +``` + +The same example will pass. + +Note the new line after `import type E from './';` but before `import a from "fs";`. This new line separates the type-only imports from the normal imports. Its existence is governed by [`newlines-between-types`][27] and _not `newlines-between`_. + +> [!IMPORTANT] +> +> In certain situations, [`consolidateIslands: true`][25] will take precedence over `newlines-between-types: "never"`, if used, when it comes to the new line separating type-only imports from normal imports. + +The next example will pass even though there's a new line preceding the normal import and [`newlines-between`][20] is set to `"never"`: + +```jsonc +{ + "import/order": ["error", { + "groups": ["type", "builtin", "parent", "sibling", "index"], + "sortTypesGroup": true, + "newlines-between": "never", + "newlines-between-types": "always" + }] +} +``` + +```ts +import type A from "fs"; + +import type B from "path"; + +import type C from "../foo.js"; + +import type D from "./bar.js"; + +import type E from './'; + +import a from "fs"; +import b from "path"; +import c from "../foo.js"; +import d from "./bar.js"; +import e from "./"; +``` + +While the following fails due to the new line between the last type import and the first normal import: + +```jsonc +{ + "import/order": ["error", { + "groups": ["type", "builtin", "parent", "sibling", "index"], + "sortTypesGroup": true, + "newlines-between": "always", + "newlines-between-types": "never" + }] +} +``` + +```ts +import type A from "fs"; +import type B from "path"; +import type C from "../foo.js"; +import type D from "./bar.js"; +import type E from './'; + +import a from "fs"; + +import b from "path"; + +import c from "../foo.js"; + +import d from "./bar.js"; + +import e from "./"; +``` + +### `consolidateIslands` + +Valid values: `"inside-groups" | "never"` \ +Default: `"never"` + +> [!NOTE] +> +> This setting is only meaningful when [`newlines-between`][20] and/or [`newlines-between-types`][27] is set to `"always-and-inside-groups"`. + +When set to `"inside-groups"`, this ensures imports spanning multiple lines are separated from other imports with a new line while single-line imports are grouped together (and the space between them consolidated) if they belong to the same [group][18] or [`pathGroups`][8]. + +> [!IMPORTANT] +> +> When all of the following are true: +> +> - [`sortTypesGroup`][7] is set to `true` +> - `consolidateIslands` is set to `"inside-groups"` +> - [`newlines-between`][20] is set to `"always-and-inside-groups"` when [`newlines-between-types`][27] is set to `"never"` (or vice-versa) +> +> Then [`newlines-between`][20]/[`newlines-between-types`][27] will yield to +> `consolidateIslands` and allow new lines to separate multi-line imports +> regardless of the `"never"` setting. +> +> This configuration is useful, for instance, to keep single-line type-only +> imports stacked tightly together at the bottom of your import block to +> preserve space while still logically organizing normal imports for quick and +> pleasant reference. + +#### Example + +Given the following settings: + +```jsonc +{ + "import/order": ["error", { + "newlines-between": "always-and-inside-groups", + "consolidateIslands": "inside-groups" + }] +} +``` + +This will fail the rule check: + +```ts +var fs = require('fs'); +var path = require('path'); +var { util1, util2, util3 } = require('util'); +var async = require('async'); +var relParent1 = require('../foo'); +var { + relParent21, + relParent22, + relParent23, + relParent24, +} = require('../'); +var relParent3 = require('../bar'); +var { sibling1, + sibling2, sibling3 } = require('./foo'); +var sibling2 = require('./bar'); +var sibling3 = require('./foobar'); +``` + +While this will succeed (and is what `--fix` would yield): + +```ts +var fs = require('fs'); +var path = require('path'); +var { util1, util2, util3 } = require('util'); + +var async = require('async'); + +var relParent1 = require('../foo'); + +var { + relParent21, + relParent22, + relParent23, + relParent24, +} = require('../'); + +var relParent3 = require('../bar'); + +var { sibling1, + sibling2, sibling3 } = require('./foo'); + +var sibling2 = require('./bar'); +var sibling3 = require('./foobar'); +``` + +Note the intragroup "islands" of grouped single-line imports, as well as multi-line imports, are surrounded by new lines. At the same time, note the typical new lines separating different groups are still maintained thanks to [`newlines-between`][20]. + +The same holds true for the next example; when given the following settings: + +```jsonc +{ + "import/order": ["error", { + "alphabetize": { "order": "asc" }, + "groups": ["external", "internal", "index", "type"], + "pathGroups": [ + { + "pattern": "dirA/**", + "group": "internal", + "position": "after" + }, + { + "pattern": "dirB/**", + "group": "internal", + "position": "before" + }, + { + "pattern": "dirC/**", + "group": "internal" + } + ], + "newlines-between": "always-and-inside-groups", + "newlines-between-types": "never", + "pathGroupsExcludedImportTypes": [], + "sortTypesGroup": true, + "consolidateIslands": "inside-groups" + }] +} +``` + +> [!IMPORTANT] +> +> **Pay special attention to the value of [`pathGroupsExcludedImportTypes`][9]** in this example's settings. +> Without it, the successful example below would fail. +> This is because the imports with specifiers starting with "dirA/", "dirB/", and "dirC/" are all [considered part of the `"external"` group](#how-imports-are-grouped), and imports in that group are excluded from [`pathGroups`][8] matching by default. +> +> The fix is to remove `"external"` (and, in this example, the others) from [`pathGroupsExcludedImportTypes`][9]. + +This will fail the rule check: + +```ts +import c from 'Bar'; +import d from 'bar'; +import { + aa, + bb, + cc, + dd, + ee, + ff, + gg +} from 'baz'; +import { + hh, + ii, + jj, + kk, + ll, + mm, + nn +} from 'fizz'; +import a from 'foo'; +import b from 'dirA/bar'; +import index from './'; +import type { AA, + BB, CC } from 'abc'; +import type { Z } from 'fizz'; +import type { + A, + B +} from 'foo'; +import type { C2 } from 'dirB/Bar'; +import type { + D2, + X2, + Y2 +} from 'dirB/bar'; +import type { E2 } from 'dirB/baz'; +import type { C3 } from 'dirC/Bar'; +import type { + D3, + X3, + Y3 +} from 'dirC/bar'; +import type { E3 } from 'dirC/baz'; +import type { F3 } from 'dirC/caz'; +import type { C1 } from 'dirA/Bar'; +import type { + D1, + X1, + Y1 +} from 'dirA/bar'; +import type { E1 } from 'dirA/baz'; +import type { F } from './index.js'; +import type { G } from './aaa.js'; +import type { H } from './bbb'; +``` + +While this will succeed (and is what `--fix` would yield): + +```ts +import c from 'Bar'; +import d from 'bar'; + +import { + aa, + bb, + cc, + dd, + ee, + ff, + gg +} from 'baz'; + +import { + hh, + ii, + jj, + kk, + ll, + mm, + nn +} from 'fizz'; + +import a from 'foo'; + +import b from 'dirA/bar'; + +import index from './'; + +import type { AA, + BB, CC } from 'abc'; + +import type { Z } from 'fizz'; + +import type { + A, + B +} from 'foo'; + +import type { C2 } from 'dirB/Bar'; + +import type { + D2, + X2, + Y2 +} from 'dirB/bar'; + +import type { E2 } from 'dirB/baz'; +import type { C3 } from 'dirC/Bar'; + +import type { + D3, + X3, + Y3 +} from 'dirC/bar'; + +import type { E3 } from 'dirC/baz'; +import type { F3 } from 'dirC/caz'; +import type { C1 } from 'dirA/Bar'; + +import type { + D1, + X1, + Y1 +} from 'dirA/bar'; + +import type { E1 } from 'dirA/baz'; +import type { F } from './index.js'; +import type { G } from './aaa.js'; +import type { H } from './bbb'; +``` + +## Related -[`import/internal-regex`]: ../../README.md#importinternal-regex + - [`import/external-module-folders`][29] + - [`import/internal-regex`][28] + - [`import/core-modules`][11] + +[3]: #how-imports-are-grouped +[4]: https://nodejs.org/api/esm.html#terminology +[5]: #warnonunassignedimports +[6]: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html#type-only-imports-and-export +[7]: #sorttypesgroup +[8]: #pathgroups +[9]: #pathgroupsexcludedimporttypes +[10]: https://www.npmjs.com/package/is-core-module +[11]: ../../README.md#importcore-modules +[12]: https://www.npmjs.com/package/package-up +[13]: #pathgroup +[14]: https://www.npmjs.com/package/minimatch +[16]: https://www.npmjs.com/package/minimatch#features +[17]: https://www.npmjs.com/package/minimatch#options +[18]: #groups +[20]: #newlines-between +[21]: https://eslint.org/docs/latest/rules/no-multiple-empty-lines +[22]: https://prettier.io +[23]: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-5.html#type-modifiers-on-import-names +[25]: #consolidateislands +[27]: #newlines-between-types +[28]: ../../README.md#importinternal-regex +[29]: ../../README.md#importexternal-module-folders +[30]: #alphabetize +[31]: https://webpack.js.org/guides/tree-shaking#mark-the-file-as-side-effect-free +[32]: #distinctgroup +[33]: #named diff --git a/examples/flat/eslint.config.mjs b/examples/flat/eslint.config.mjs new file mode 100644 index 0000000000..1432652658 --- /dev/null +++ b/examples/flat/eslint.config.mjs @@ -0,0 +1,26 @@ +import importPlugin from 'eslint-plugin-import'; +import js from '@eslint/js'; +import tsParser from '@typescript-eslint/parser'; + +export default [ + js.configs.recommended, + importPlugin.flatConfigs.recommended, + importPlugin.flatConfigs.react, + importPlugin.flatConfigs.typescript, + { + files: ['**/*.{js,mjs,cjs,jsx,mjsx,ts,tsx,mtsx}'], + languageOptions: { + parser: tsParser, + ecmaVersion: 'latest', + sourceType: 'module', + }, + ignores: ['eslint.config.mjs', '**/exports-unused.ts'], + rules: { + 'no-unused-vars': 'off', + 'import/no-dynamic-require': 'warn', + 'import/no-nodejs-modules': 'warn', + 'import/no-unused-modules': ['warn', { unusedExports: true }], + 'import/no-cycle': 'warn', + }, + }, +]; diff --git a/examples/flat/package.json b/examples/flat/package.json new file mode 100644 index 0000000000..0894d29f28 --- /dev/null +++ b/examples/flat/package.json @@ -0,0 +1,17 @@ +{ + "name": "flat", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "lint": "cross-env ESLINT_USE_FLAT_CONFIG=true eslint src --report-unused-disable-directives" + }, + "devDependencies": { + "@eslint/js": "^9.5.0", + "@types/node": "^20.14.5", + "@typescript-eslint/parser": "^7.13.1", + "cross-env": "^7.0.3", + "eslint": "^8.57.0", + "eslint-plugin-import": "file:../..", + "typescript": "^5.4.5" + } +} diff --git a/examples/flat/src/depth-zero.js b/examples/flat/src/depth-zero.js new file mode 100644 index 0000000000..8cfde99795 --- /dev/null +++ b/examples/flat/src/depth-zero.js @@ -0,0 +1,3 @@ +import { foo } from "./es6/depth-one-dynamic"; + +foo(); diff --git a/examples/flat/src/es6/depth-one-dynamic.js b/examples/flat/src/es6/depth-one-dynamic.js new file mode 100644 index 0000000000..ca129fd622 --- /dev/null +++ b/examples/flat/src/es6/depth-one-dynamic.js @@ -0,0 +1,3 @@ +export function foo() {} + +export const bar = () => import("../depth-zero").then(({foo}) => foo); diff --git a/examples/flat/src/exports-unused.ts b/examples/flat/src/exports-unused.ts new file mode 100644 index 0000000000..af8061ec2b --- /dev/null +++ b/examples/flat/src/exports-unused.ts @@ -0,0 +1,12 @@ +export type ScalarType = string | number; +export type ObjType = { + a: ScalarType; + b: ScalarType; +}; + +export const a = 13; +export const b = 18; + +const defaultExport: ObjType = { a, b }; + +export default defaultExport; diff --git a/examples/flat/src/exports.ts b/examples/flat/src/exports.ts new file mode 100644 index 0000000000..af8061ec2b --- /dev/null +++ b/examples/flat/src/exports.ts @@ -0,0 +1,12 @@ +export type ScalarType = string | number; +export type ObjType = { + a: ScalarType; + b: ScalarType; +}; + +export const a = 13; +export const b = 18; + +const defaultExport: ObjType = { a, b }; + +export default defaultExport; diff --git a/examples/flat/src/imports.ts b/examples/flat/src/imports.ts new file mode 100644 index 0000000000..643219ae42 --- /dev/null +++ b/examples/flat/src/imports.ts @@ -0,0 +1,7 @@ +//import c from './exports'; +import { a, b } from './exports'; +import type { ScalarType, ObjType } from './exports'; + +import path from 'path'; +import fs from 'node:fs'; +import console from 'console'; diff --git a/examples/flat/src/jsx.tsx b/examples/flat/src/jsx.tsx new file mode 100644 index 0000000000..970d53cb84 --- /dev/null +++ b/examples/flat/src/jsx.tsx @@ -0,0 +1,3 @@ +const Components = () => { + return <>; +}; diff --git a/examples/flat/tsconfig.json b/examples/flat/tsconfig.json new file mode 100644 index 0000000000..e100bfc980 --- /dev/null +++ b/examples/flat/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "rootDir": "./", + "moduleResolution": "Bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + } +} diff --git a/examples/legacy/.eslintrc.cjs b/examples/legacy/.eslintrc.cjs new file mode 100644 index 0000000000..90e065c9dc --- /dev/null +++ b/examples/legacy/.eslintrc.cjs @@ -0,0 +1,25 @@ +module.exports = { + root: true, + env: { es2022: true }, + extends: [ + 'eslint:recommended', + 'plugin:import/recommended', + 'plugin:import/react', + 'plugin:import/typescript', + ], + settings: {}, + ignorePatterns: ['.eslintrc.cjs', '**/exports-unused.ts'], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + plugins: ['import'], + rules: { + 'no-unused-vars': 'off', + 'import/no-dynamic-require': 'warn', + 'import/no-nodejs-modules': 'warn', + 'import/no-unused-modules': ['warn', { unusedExports: true }], + 'import/no-cycle': 'warn', + }, +}; diff --git a/examples/legacy/package.json b/examples/legacy/package.json new file mode 100644 index 0000000000..e3ca094887 --- /dev/null +++ b/examples/legacy/package.json @@ -0,0 +1,16 @@ +{ + "name": "legacy", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "lint": "cross-env ESLINT_USE_FLAT_CONFIG=false eslint src --ext js,jsx,ts,tsx --report-unused-disable-directives" + }, + "devDependencies": { + "@types/node": "^20.14.5", + "@typescript-eslint/parser": "^7.13.1", + "cross-env": "^7.0.3", + "eslint": "^8.57.0", + "eslint-plugin-import": "file:../..", + "typescript": "^5.4.5" + } +} diff --git a/examples/legacy/src/depth-zero.js b/examples/legacy/src/depth-zero.js new file mode 100644 index 0000000000..8cfde99795 --- /dev/null +++ b/examples/legacy/src/depth-zero.js @@ -0,0 +1,3 @@ +import { foo } from "./es6/depth-one-dynamic"; + +foo(); diff --git a/examples/legacy/src/es6/depth-one-dynamic.js b/examples/legacy/src/es6/depth-one-dynamic.js new file mode 100644 index 0000000000..cda7091cdc --- /dev/null +++ b/examples/legacy/src/es6/depth-one-dynamic.js @@ -0,0 +1,3 @@ +export function foo() {} + +export const bar = () => import("../depth-zero").then(({ foo }) => foo); diff --git a/examples/legacy/src/exports-unused.ts b/examples/legacy/src/exports-unused.ts new file mode 100644 index 0000000000..af8061ec2b --- /dev/null +++ b/examples/legacy/src/exports-unused.ts @@ -0,0 +1,12 @@ +export type ScalarType = string | number; +export type ObjType = { + a: ScalarType; + b: ScalarType; +}; + +export const a = 13; +export const b = 18; + +const defaultExport: ObjType = { a, b }; + +export default defaultExport; diff --git a/examples/legacy/src/exports.ts b/examples/legacy/src/exports.ts new file mode 100644 index 0000000000..af8061ec2b --- /dev/null +++ b/examples/legacy/src/exports.ts @@ -0,0 +1,12 @@ +export type ScalarType = string | number; +export type ObjType = { + a: ScalarType; + b: ScalarType; +}; + +export const a = 13; +export const b = 18; + +const defaultExport: ObjType = { a, b }; + +export default defaultExport; diff --git a/examples/legacy/src/imports.ts b/examples/legacy/src/imports.ts new file mode 100644 index 0000000000..643219ae42 --- /dev/null +++ b/examples/legacy/src/imports.ts @@ -0,0 +1,7 @@ +//import c from './exports'; +import { a, b } from './exports'; +import type { ScalarType, ObjType } from './exports'; + +import path from 'path'; +import fs from 'node:fs'; +import console from 'console'; diff --git a/examples/legacy/src/jsx.tsx b/examples/legacy/src/jsx.tsx new file mode 100644 index 0000000000..970d53cb84 --- /dev/null +++ b/examples/legacy/src/jsx.tsx @@ -0,0 +1,3 @@ +const Components = () => { + return <>; +}; diff --git a/examples/legacy/tsconfig.json b/examples/legacy/tsconfig.json new file mode 100644 index 0000000000..e100bfc980 --- /dev/null +++ b/examples/legacy/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "rootDir": "./", + "moduleResolution": "Bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + } +} diff --git a/examples/v9/eslint.config.mjs b/examples/v9/eslint.config.mjs new file mode 100644 index 0000000000..7b7534d140 --- /dev/null +++ b/examples/v9/eslint.config.mjs @@ -0,0 +1,22 @@ +import importPlugin from 'eslint-plugin-import'; +import js from '@eslint/js'; + +export default [ + js.configs.recommended, + importPlugin.flatConfigs.recommended, + { + files: ['**/*.{js,mjs,cjs}'], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + ignores: ['eslint.config.mjs', 'node_modules/*'], + rules: { + 'no-unused-vars': 'off', + 'import/no-dynamic-require': 'warn', + 'import/no-nodejs-modules': 'warn', + 'import/no-unused-modules': ['warn', { unusedExports: true }], + 'import/no-cycle': 'warn', + }, + }, +]; diff --git a/examples/v9/package.json b/examples/v9/package.json new file mode 100644 index 0000000000..4746d74005 --- /dev/null +++ b/examples/v9/package.json @@ -0,0 +1,14 @@ +{ + "name": "v9", + "version": "1.0.0", + "main": "index.js", + "type": "module", + "scripts": { + "lint": "eslint src --report-unused-disable-directives" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "eslint": "^9.17.0", + "eslint-plugin-import": "file:../.." + } +} diff --git a/examples/v9/src/depth-zero.js b/examples/v9/src/depth-zero.js new file mode 100644 index 0000000000..8cfde99795 --- /dev/null +++ b/examples/v9/src/depth-zero.js @@ -0,0 +1,3 @@ +import { foo } from "./es6/depth-one-dynamic"; + +foo(); diff --git a/examples/v9/src/es6/depth-one-dynamic.js b/examples/v9/src/es6/depth-one-dynamic.js new file mode 100644 index 0000000000..ca129fd622 --- /dev/null +++ b/examples/v9/src/es6/depth-one-dynamic.js @@ -0,0 +1,3 @@ +export function foo() {} + +export const bar = () => import("../depth-zero").then(({foo}) => foo); diff --git a/examples/v9/src/exports-unused.js b/examples/v9/src/exports-unused.js new file mode 100644 index 0000000000..3c44db68d0 --- /dev/null +++ b/examples/v9/src/exports-unused.js @@ -0,0 +1,6 @@ +export const a = 13; +export const b = 18; + +const defaultExport = { a, b }; + +export default defaultExport; diff --git a/examples/v9/src/exports.js b/examples/v9/src/exports.js new file mode 100644 index 0000000000..3c44db68d0 --- /dev/null +++ b/examples/v9/src/exports.js @@ -0,0 +1,6 @@ +export const a = 13; +export const b = 18; + +const defaultExport = { a, b }; + +export default defaultExport; diff --git a/examples/v9/src/imports.js b/examples/v9/src/imports.js new file mode 100644 index 0000000000..edf1336865 --- /dev/null +++ b/examples/v9/src/imports.js @@ -0,0 +1,6 @@ +//import c from './exports'; +import { a, b } from './exports'; + +import path from 'path'; +import fs from 'node:fs'; +import console from 'console'; diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000000..2bef94ec2b --- /dev/null +++ b/index.d.ts @@ -0,0 +1,33 @@ +import { ESLint, Linter, Rule } from 'eslint'; + +declare const plugin: ESLint.Plugin & { + meta: { + name: string; + version: string; + }; + configs: { + 'recommended': Linter.LegacyConfig; + 'errors': Linter.LegacyConfig; + 'warnings': Linter.LegacyConfig; + 'stage-0': Linter.LegacyConfig; + 'react': Linter.LegacyConfig; + 'react-native': Linter.LegacyConfig; + 'electron': Linter.LegacyConfig; + 'typescript': Linter.LegacyConfig; + }; + flatConfigs: { + 'recommended': Linter.FlatConfig; + 'errors': Linter.FlatConfig; + 'warnings': Linter.FlatConfig; + 'stage-0': Linter.FlatConfig; + 'react': Linter.FlatConfig; + 'react-native': Linter.FlatConfig; + 'electron': Linter.FlatConfig; + 'typescript': Linter.FlatConfig; + }; + rules: { + [key: string]: Rule.RuleModule; + }; +}; + +export = plugin; diff --git a/memo-parser/package.json b/memo-parser/package.json index 723005d21b..b89c3c5ada 100644 --- a/memo-parser/package.json +++ b/memo-parser/package.json @@ -12,7 +12,8 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/import-js/eslint-plugin-import.git" + "url": "git+https://github.com/import-js/eslint-plugin-import.git", + "directory": "memo-parser" }, "keywords": [ "eslint", diff --git a/package.json b/package.json index 5c0af48543..3163544b95 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,24 @@ { "name": "eslint-plugin-import", - "version": "2.29.1", + "version": "2.32.0", "description": "Import with sanity.", "engines": { "node": ">=4" }, "main": "lib/index.js", + "types": "index.d.ts", "directories": { "test": "tests" }, "files": [ "*.md", + "!{CONTRIBUTING,RELEASE}.md", "LICENSE", "docs", "lib", "config", - "memo-parser/{*.js,LICENSE,*.md}" + "memo-parser/{*.js,LICENSE,*.md}", + "index.d.ts" ], "scripts": { "prebuild": "rimraf lib", @@ -30,6 +33,11 @@ "test": "npm run tests-only", "test-compiled": "npm run prepublish && BABEL_ENV=testCompiled mocha --compilers js:babel-register tests/src", "test-all": "node --require babel-register ./scripts/testAll", + "test-examples": "npm run build && npm run test-example:legacy && npm run test-example:flat && npm run test-example:v9", + "test-example:legacy": "cd examples/legacy && npm install && npm run lint", + "test-example:flat": "cd examples/flat && npm install && npm run lint", + "test-example:v9": "cd examples/v9 && npm install && npm run lint", + "test-types": "npx --package typescript@latest tsc --noEmit index.d.ts", "prepublishOnly": "safe-publish-latest && npm run build", "prepublish": "not-in-publish || npm run prepublishOnly", "preupdate:eslint-docs": "npm run build", @@ -59,6 +67,7 @@ "@angular-eslint/template-parser": "^13.5.0", "@eslint/import-test-order-redirect-scoped": "file:./tests/files/order-redirect-scoped", "@test-scope/some-module": "file:./tests/files/symlinked-module", + "@types/eslint": "^8.56.12", "@typescript-eslint/parser": "^2.23.0 || ^3.3.0 || ^4.29.3 || ^5.10.0", "babel-cli": "^6.26.0", "babel-core": "^6.26.3", @@ -72,7 +81,7 @@ "chai": "^4.3.10", "cross-env": "^4.0.0", "escope": "^3.6.0", - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8", + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9", "eslint-doc-generator": "^1.6.1", "eslint-import-resolver-node": "file:./resolvers/node", "eslint-import-resolver-typescript": "^1.0.2 || ^1.1.1", @@ -82,13 +91,15 @@ "eslint-plugin-eslint-plugin": "^2.3.0", "eslint-plugin-import": "2.x", "eslint-plugin-json": "^2.1.2", + "find-babel-config": "=1.2.0", "fs-copy-file-sync": "^1.1.1", "glob": "^7.2.3", "in-publish": "^2.0.1", "jackspeak": "=2.1.1", + "jsonc-parser": "=3.2.0", "linklocal": "^2.8.2", "lodash.isarray": "^4.0.0", - "markdownlint-cli": "^0.38.0", + "markdownlint-cli": "~0.35", "mocha": "^3.5.3", "npm-which": "^3.0.1", "nyc": "^11.9.0", @@ -96,29 +107,32 @@ "rimraf": "^2.7.1", "safe-publish-latest": "^2.0.0", "sinon": "^2.4.1", + "tmp": "^0.2.1", "typescript": "^2.8.1 || ~3.9.5 || ~4.5.2", "typescript-eslint-parser": "^15 || ^20 || ^22" }, "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" }, "dependencies": { - "array-includes": "^3.1.7", - "array.prototype.findlastindex": "^1.2.3", - "array.prototype.flat": "^1.3.2", - "array.prototype.flatmap": "^1.3.2", + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.8.0", - "hasown": "^2.0.0", - "is-core-module": "^2.13.1", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", - "object.fromentries": "^2.0.7", - "object.groupby": "^1.0.1", - "object.values": "^1.1.7", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" } } diff --git a/resolvers/node/index.js b/resolvers/node/index.js index 7f207fbf31..9e0e753cc7 100644 --- a/resolvers/node/index.js +++ b/resolvers/node/index.js @@ -8,26 +8,6 @@ const log = require('debug')('eslint-plugin-import:resolver:node'); exports.interfaceVersion = 2; -exports.resolve = function (source, file, config) { - log('Resolving:', source, 'from:', file); - let resolvedPath; - - if (isCoreModule(source)) { - log('resolved to core'); - return { found: true, path: null }; - } - - try { - const cachedFilter = function (pkg, dir) { return packageFilter(pkg, dir, config); }; - resolvedPath = resolve(source, opts(file, config, cachedFilter)); - log('Resolved to:', resolvedPath); - return { found: true, path: resolvedPath }; - } catch (err) { - log('resolve threw error:', err); - return { found: false }; - } -}; - function opts(file, config, packageFilter) { return Object.assign({ // more closely matches Node (#333) // plus 'mjs' for native modules! (#939) @@ -64,3 +44,23 @@ function packageFilter(pkg, dir, config) { } return pkg; } + +exports.resolve = function (source, file, config) { + log('Resolving:', source, 'from:', file); + let resolvedPath; + + if (isCoreModule(source)) { + log('resolved to core'); + return { found: true, path: null }; + } + + try { + const cachedFilter = function (pkg, dir) { return packageFilter(pkg, dir, config); }; + resolvedPath = resolve(source, opts(file, config, cachedFilter)); + log('Resolved to:', resolvedPath); + return { found: true, path: resolvedPath }; + } catch (err) { + log('resolve threw error:', err); + return { found: false }; + } +}; diff --git a/resolvers/node/package.json b/resolvers/node/package.json index bfaab40413..6f6999e6cb 100644 --- a/resolvers/node/package.json +++ b/resolvers/node/package.json @@ -13,7 +13,8 @@ }, "repository": { "type": "git", - "url": "https://github.com/import-js/eslint-plugin-import" + "url": "https://github.com/import-js/eslint-plugin-import", + "directory": "resolvers/node" }, "keywords": [ "eslint", diff --git a/resolvers/webpack/CHANGELOG.md b/resolvers/webpack/CHANGELOG.md index 4fed046b46..d904a60828 100644 --- a/resolvers/webpack/CHANGELOG.md +++ b/resolvers/webpack/CHANGELOG.md @@ -5,11 +5,18 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange ## Unreleased +## 0.13.10 - 2024-12-10 +- [new] add cache option ([#3100], thanks [@seiyab]) + +## 0.13.9 - 2024-09-02 +- [refactor] simplify loop ([#3029], thanks [@fregante]) +- [meta] add `repository.directory` field +- [refactor] avoid hoisting, misc cleanup + ## 0.13.8 - 2023-10-22 - [refactor] use `hasown` instead of `has` - [deps] update `array.prototype.find`, `is-core-module`, `resolve` - ## 0.13.7 - 2023-08-19 - [fix] use the `dirname` of the `configPath` as `basedir` ([#2859]) @@ -178,6 +185,8 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange ### Added - `interpret` configs (such as `.babel.js`). Thanks to [@gausie] for the initial PR ([#164], ages ago! 😅) and [@jquense] for tests ([#278]). +[#3100]: https://github.com/import-js/eslint-plugin-import/pull/3100 +[#3029]: https://github.com/import-js/eslint-plugin-import/pull/3029 [#2287]: https://github.com/import-js/eslint-plugin-import/pull/2287 [#2023]: https://github.com/import-js/eslint-plugin-import/pull/2023 [#1967]: https://github.com/import-js/eslint-plugin-import/pull/1967 @@ -222,6 +231,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange [@benmvp]: https://github.com/benmvp [@daltones]: https://github.com/daltones [@echenley]: https://github.com/echenley +[@fregante]: https://github.com/fregante [@gausie]: https://github.com/gausie [@grahamb]: https://github.com/grahamb [@graingert]: https://github.com/graingert @@ -241,6 +251,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange [@Rogeres]: https://github.com/Rogeres [@Satyam]: https://github.com/Satyam [@Schweinepriester]: https://github.com/Schweinepriester +[@seiyab]: https://github.com/seiyab [@SkeLLLa]: https://github.com/SkeLLLa [@taion]: https://github.com/taion [@toshafed]: https://github.com/toshafed diff --git a/resolvers/webpack/README.md b/resolvers/webpack/README.md index 06513ba141..9b01396898 100644 --- a/resolvers/webpack/README.md +++ b/resolvers/webpack/README.md @@ -94,6 +94,17 @@ settings: production: true ``` +If your config is set as a function, it will be evaluated at every resolution. You have an option to prevent this by caching it using the `cache` parameter: + +```yaml +--- +settings: + import/resolver: + webpack: + config: 'webpack.config.js' + cache: true +``` + ## Support [Get supported eslint-import-resolver-webpack with the Tidelift Subscription](https://tidelift.com/subscription/pkg/npm-eslint-import-resolver-webpack?utm_source=npm-eslint-import-resolver-webpack&utm_medium=referral&utm_campaign=readme) diff --git a/resolvers/webpack/index.js b/resolvers/webpack/index.js index 3ca2874dd8..ae736abe76 100644 --- a/resolvers/webpack/index.js +++ b/resolvers/webpack/index.js @@ -3,216 +3,141 @@ const findRoot = require('find-root'); const path = require('path'); const isEqual = require('lodash/isEqual'); -const find = require('array.prototype.find'); const interpret = require('interpret'); -const fs = require('fs'); +const existsSync = require('fs').existsSync; const isCore = require('is-core-module'); const resolve = require('resolve/sync'); const semver = require('semver'); const hasOwn = require('hasown'); const isRegex = require('is-regex'); +const isArray = Array.isArray; +const keys = Object.keys; +const assign = Object.assign; const log = require('debug')('eslint-plugin-import:resolver:webpack'); exports.interfaceVersion = 2; -/** - * Find the full path to 'source', given 'file' as a full reference path. - * - * resolveImport('./foo', '/Users/ben/bar.js') => '/Users/ben/foo.js' - * @param {string} source - the module to resolve; i.e './some-module' - * @param {string} file - the importing file's full path; i.e. '/usr/local/bin/file.js' - * @param {object} settings - the webpack config file name, as well as cwd - * @example - * options: { - * // Path to the webpack config - * config: 'webpack.config.js', - * // Path to be used to determine where to resolve webpack from - * // (may differ from the cwd in some cases) - * cwd: process.cwd() - * } - * @return {string?} the resolved path to source, undefined if not resolved, or null - * if resolved to a non-FS resource (i.e. script tag at page load) - */ -exports.resolve = function (source, file, settings) { - - // strip loaders - const finalBang = source.lastIndexOf('!'); - if (finalBang >= 0) { - source = source.slice(finalBang + 1); - } - - // strip resource query - const finalQuestionMark = source.lastIndexOf('?'); - if (finalQuestionMark >= 0) { - source = source.slice(0, finalQuestionMark); - } - - let webpackConfig; - - const _configPath = settings && settings.config; - /** - * Attempt to set the current working directory. - * If none is passed, default to the `cwd` where the config is located. - */ - const cwd = settings && settings.cwd; - const configIndex = settings && settings['config-index']; - const env = settings && settings.env; - const argv = settings && typeof settings.argv !== 'undefined' ? settings.argv : {}; - let packageDir; - - let configPath = typeof _configPath === 'string' && _configPath.startsWith('.') - ? path.resolve(_configPath) - : _configPath; - - log('Config path from settings:', configPath); - - // see if we've got a config path, a config object, an array of config objects or a config function - if (!configPath || typeof configPath === 'string') { - - // see if we've got an absolute path - if (!configPath || !path.isAbsolute(configPath)) { - // if not, find ancestral package.json and use its directory as base for the path - packageDir = findRoot(path.resolve(file)); - if (!packageDir) { throw new Error('package not found above ' + file); } +function registerCompiler(moduleDescriptor) { + if (moduleDescriptor) { + if (typeof moduleDescriptor === 'string') { + require(moduleDescriptor); + } else if (!isArray(moduleDescriptor)) { + moduleDescriptor.register(require(moduleDescriptor.module)); + } else { + for (let i = 0; i < moduleDescriptor.length; i++) { + try { + registerCompiler(moduleDescriptor[i]); + break; + } catch (e) { + log('Failed to register compiler for moduleDescriptor[]:', i, moduleDescriptor); + } + } } + } +} - configPath = findConfigPath(configPath, packageDir); +function findConfigPath(configPath, packageDir) { + const extensions = keys(interpret.extensions).sort(function (a, b) { + return a === '.js' ? -1 : b === '.js' ? 1 : a.length - b.length; + }); + let extension; - log('Config path resolved to:', configPath); - if (configPath) { - try { - webpackConfig = require(configPath); - } catch (e) { - console.log('Error resolving webpackConfig', e); - throw e; + if (configPath) { + for (let i = extensions.length - 1; i >= 0 && !extension; i--) { + const maybeExtension = extensions[i]; + if (configPath.slice(-maybeExtension.length) === maybeExtension) { + extension = maybeExtension; } - } else { - log('No config path found relative to', file, '; using {}'); - webpackConfig = {}; } - if (webpackConfig && webpackConfig.default) { - log('Using ES6 module "default" key instead of module.exports.'); - webpackConfig = webpackConfig.default; + // see if we've got an absolute path + if (!path.isAbsolute(configPath)) { + configPath = path.join(packageDir, configPath); } - } else { - webpackConfig = configPath; - configPath = null; - } - - if (typeof webpackConfig === 'function') { - webpackConfig = webpackConfig(env, argv); - } - - if (Array.isArray(webpackConfig)) { - webpackConfig = webpackConfig.map((cfg) => { - if (typeof cfg === 'function') { - return cfg(env, argv); + for (let i = 0; i < extensions.length && !extension; i++) { + const maybeExtension = extensions[i]; + const maybePath = path.resolve( + path.join(packageDir, 'webpack.config' + maybeExtension) + ); + if (existsSync(maybePath)) { + configPath = maybePath; + extension = maybeExtension; } - - return cfg; - }); - - if (typeof configIndex !== 'undefined' && webpackConfig.length > configIndex) { - webpackConfig = webpackConfig[configIndex]; - } else { - webpackConfig = find(webpackConfig, function findFirstWithResolve(config) { - return !!config.resolve; - }); } } - if (typeof webpackConfig.then === 'function') { - webpackConfig = {}; - - console.warn('Webpack config returns a `Promise`; that signature is not supported at the moment. Using empty object instead.'); - } - - if (webpackConfig == null) { - webpackConfig = {}; - - console.warn('No webpack configuration with a "resolve" field found. Using empty object instead.'); - } + registerCompiler(interpret.extensions[extension]); + return configPath; +} - log('Using config: ', webpackConfig); +function findExternal(source, externals, context, resolveSync) { + if (!externals) { return false; } - const resolveSync = getResolveSync(configPath, webpackConfig, cwd); + // string match + if (typeof externals === 'string') { return source === externals; } - // externals - if (findExternal(source, webpackConfig.externals, path.dirname(file), resolveSync)) { - return { found: true, path: null }; + // array: recurse + if (isArray(externals)) { + return externals.some(function (e) { return findExternal(source, e, context, resolveSync); }); } - // otherwise, resolve "normally" - - try { - return { found: true, path: resolveSync(path.dirname(file), source) }; - } catch (err) { - if (isCore(source)) { - return { found: true, path: null }; - } - - log('Error during module resolution:', err); - return { found: false }; + if (isRegex(externals)) { + return externals.test(source); } -}; -const MAX_CACHE = 10; -const _cache = []; -function getResolveSync(configPath, webpackConfig, cwd) { - const cacheKey = { configPath, webpackConfig }; - let cached = find(_cache, function (entry) { return isEqual(entry.key, cacheKey); }); - if (!cached) { - cached = { - key: cacheKey, - value: createResolveSync(configPath, webpackConfig, cwd), + if (typeof externals === 'function') { + let functionExternalFound = false; + const callback = function (err, value) { + if (err) { + functionExternalFound = false; + } else { + functionExternalFound = findExternal(source, value, context, resolveSync); + } }; - // put in front and pop last item - if (_cache.unshift(cached) > MAX_CACHE) { - _cache.pop(); + // - for prior webpack 5, 'externals function' uses 3 arguments + // - for webpack 5, the count of arguments is less than 3 + if (externals.length === 3) { + externals.call(null, context, source, callback); + } else { + const ctx = { + context, + request: source, + contextInfo: { + issuer: '', + issuerLayer: null, + compiler: '', + }, + getResolve: () => (resolveContext, requestToResolve, cb) => { + if (cb) { + try { + cb(null, resolveSync(resolveContext, requestToResolve)); + } catch (e) { + cb(e); + } + } else { + log('getResolve without callback not supported'); + return Promise.reject(new Error('Not supported')); + } + }, + }; + const result = externals.call(null, ctx, callback); + // todo handling Promise object (using synchronous-promise package?) + if (result && typeof result.then === 'function') { + log('Asynchronous functions for externals not supported'); + } } + return functionExternalFound; } - return cached.value; -} - -function createResolveSync(configPath, webpackConfig, cwd) { - let webpackRequire; - let basedir = null; - - if (typeof configPath === 'string') { - // This can be changed via the settings passed in when defining the resolver - basedir = cwd || path.dirname(configPath); - log(`Attempting to load webpack path from ${basedir}`); - } - - try { - // Attempt to resolve webpack from the given `basedir` - const webpackFilename = resolve('webpack', { basedir, preserveSymlinks: false }); - const webpackResolveOpts = { basedir: path.dirname(webpackFilename), preserveSymlinks: false }; - - webpackRequire = function (id) { - return require(resolve(id, webpackResolveOpts)); - }; - } catch (e) { - // Something has gone wrong (or we're in a test). Use our own bundled - // enhanced-resolve. - log('Using bundled enhanced-resolve.'); - webpackRequire = require; - } - - const enhancedResolvePackage = webpackRequire('enhanced-resolve/package.json'); - const enhancedResolveVersion = enhancedResolvePackage.version; - log('enhanced-resolve version:', enhancedResolveVersion); - const resolveConfig = webpackConfig.resolve || {}; - - if (semver.major(enhancedResolveVersion) >= 2) { - return createWebpack2ResolveSync(webpackRequire, resolveConfig); + // else, vanilla object + for (const key in externals) { + if (hasOwn(externals, key) && source === key) { + return true; + } } - - return createWebpack1ResolveSync(webpackRequire, resolveConfig, webpackConfig.plugins); + return false; } /** @@ -231,31 +156,53 @@ const webpack2DefaultResolveConfig = { function createWebpack2ResolveSync(webpackRequire, resolveConfig) { const EnhancedResolve = webpackRequire('enhanced-resolve'); - return EnhancedResolve.create.sync(Object.assign({}, webpack2DefaultResolveConfig, resolveConfig)); + return EnhancedResolve.create.sync(assign({}, webpack2DefaultResolveConfig, resolveConfig)); } /** * webpack 1 defaults: https://webpack.github.io/docs/configuration.html#resolve-packagemains - * @type {Array} + * @type {string[]} */ const webpack1DefaultMains = [ - 'webpack', 'browser', 'web', 'browserify', ['jam', 'main'], 'main', + 'webpack', + 'browser', + 'web', + 'browserify', + ['jam', 'main'], + 'main', ]; -// adapted from tests & -// https://github.com/webpack/webpack/blob/v1.13.0/lib/WebpackOptionsApply.js#L322 -function createWebpack1ResolveSync(webpackRequire, resolveConfig, plugins) { - const Resolver = webpackRequire('enhanced-resolve/lib/Resolver'); - const SyncNodeJsInputFileSystem = webpackRequire('enhanced-resolve/lib/SyncNodeJsInputFileSystem'); - - const ModuleAliasPlugin = webpackRequire('enhanced-resolve/lib/ModuleAliasPlugin'); - const ModulesInDirectoriesPlugin = webpackRequire('enhanced-resolve/lib/ModulesInDirectoriesPlugin'); - const ModulesInRootPlugin = webpackRequire('enhanced-resolve/lib/ModulesInRootPlugin'); - const ModuleAsFilePlugin = webpackRequire('enhanced-resolve/lib/ModuleAsFilePlugin'); - const ModuleAsDirectoryPlugin = webpackRequire('enhanced-resolve/lib/ModuleAsDirectoryPlugin'); - const DirectoryDescriptionFilePlugin = webpackRequire('enhanced-resolve/lib/DirectoryDescriptionFilePlugin'); - const DirectoryDefaultFilePlugin = webpackRequire('enhanced-resolve/lib/DirectoryDefaultFilePlugin'); - const FileAppendPlugin = webpackRequire('enhanced-resolve/lib/FileAppendPlugin'); +/* eslint-disable */ +// from https://github.com/webpack/webpack/blob/v1.13.0/lib/WebpackOptionsApply.js#L365 +function makeRootPlugin(ModulesInRootPlugin, name, root) { + if (typeof root === 'string') { + return new ModulesInRootPlugin(name, root); + } + if (isArray(root)) { + return function () { + root.forEach(function (root) { + this.apply(new ModulesInRootPlugin(name, root)); + }, this); + }; + } + return function () {}; +} +/* eslint-enable */ + +// adapted from tests & +// https://github.com/webpack/webpack/blob/v1.13.0/lib/WebpackOptionsApply.js#L322 +function createWebpack1ResolveSync(webpackRequire, resolveConfig, plugins) { + const Resolver = webpackRequire('enhanced-resolve/lib/Resolver'); + const SyncNodeJsInputFileSystem = webpackRequire('enhanced-resolve/lib/SyncNodeJsInputFileSystem'); + + const ModuleAliasPlugin = webpackRequire('enhanced-resolve/lib/ModuleAliasPlugin'); + const ModulesInDirectoriesPlugin = webpackRequire('enhanced-resolve/lib/ModulesInDirectoriesPlugin'); + const ModulesInRootPlugin = webpackRequire('enhanced-resolve/lib/ModulesInRootPlugin'); + const ModuleAsFilePlugin = webpackRequire('enhanced-resolve/lib/ModuleAsFilePlugin'); + const ModuleAsDirectoryPlugin = webpackRequire('enhanced-resolve/lib/ModuleAsDirectoryPlugin'); + const DirectoryDescriptionFilePlugin = webpackRequire('enhanced-resolve/lib/DirectoryDescriptionFilePlugin'); + const DirectoryDefaultFilePlugin = webpackRequire('enhanced-resolve/lib/DirectoryDefaultFilePlugin'); + const FileAppendPlugin = webpackRequire('enhanced-resolve/lib/FileAppendPlugin'); const ResultSymlinkPlugin = webpackRequire('enhanced-resolve/lib/ResultSymlinkPlugin'); const DirectoryDescriptionFileFieldAliasPlugin = webpackRequire('enhanced-resolve/lib/DirectoryDescriptionFileFieldAliasPlugin'); @@ -291,7 +238,7 @@ function createWebpack1ResolveSync(webpackRequire, resolveConfig, plugins) { if ( plugin.constructor && plugin.constructor.name === 'ResolverPlugin' - && Array.isArray(plugin.plugins) + && isArray(plugin.plugins) ) { resolvePlugins.push.apply(resolvePlugins, plugin.plugins); } @@ -305,147 +252,226 @@ function createWebpack1ResolveSync(webpackRequire, resolveConfig, plugins) { }; } -/* eslint-disable */ -// from https://github.com/webpack/webpack/blob/v1.13.0/lib/WebpackOptionsApply.js#L365 -function makeRootPlugin(ModulesInRootPlugin, name, root) { - if (typeof root === 'string') { - return new ModulesInRootPlugin(name, root); - } else if (Array.isArray(root)) { - return function() { - root.forEach(function (root) { - this.apply(new ModulesInRootPlugin(name, root)); - }, this); +function createResolveSync(configPath, webpackConfig, cwd) { + let webpackRequire; + let basedir = null; + + if (typeof configPath === 'string') { + // This can be changed via the settings passed in when defining the resolver + basedir = cwd || path.dirname(configPath); + log(`Attempting to load webpack path from ${basedir}`); + } + + try { + // Attempt to resolve webpack from the given `basedir` + const webpackFilename = resolve('webpack', { basedir, preserveSymlinks: false }); + const webpackResolveOpts = { basedir: path.dirname(webpackFilename), preserveSymlinks: false }; + + webpackRequire = function (id) { + return require(resolve(id, webpackResolveOpts)); }; + } catch (e) { + // Something has gone wrong (or we're in a test). Use our own bundled + // enhanced-resolve. + log('Using bundled enhanced-resolve.'); + webpackRequire = require; } - return function () {}; -} -/* eslint-enable */ -function findExternal(source, externals, context, resolveSync) { - if (!externals) { return false; } + const enhancedResolvePackage = webpackRequire('enhanced-resolve/package.json'); + const enhancedResolveVersion = enhancedResolvePackage.version; + log('enhanced-resolve version:', enhancedResolveVersion); - // string match - if (typeof externals === 'string') { return source === externals; } + const resolveConfig = webpackConfig.resolve || {}; - // array: recurse - if (Array.isArray(externals)) { - return externals.some(function (e) { return findExternal(source, e, context, resolveSync); }); + if (semver.major(enhancedResolveVersion) >= 2) { + return createWebpack2ResolveSync(webpackRequire, resolveConfig); } - if (isRegex(externals)) { - return externals.test(source); - } + return createWebpack1ResolveSync(webpackRequire, resolveConfig, webpackConfig.plugins); +} - if (typeof externals === 'function') { - let functionExternalFound = false; - const callback = function (err, value) { - if (err) { - functionExternalFound = false; - } else { - functionExternalFound = findExternal(source, value, context, resolveSync); - } - }; - // - for prior webpack 5, 'externals function' uses 3 arguments - // - for webpack 5, the count of arguments is less than 3 - if (externals.length === 3) { - externals.call(null, context, source, callback); - } else { - const ctx = { - context, - request: source, - contextInfo: { - issuer: '', - issuerLayer: null, - compiler: '', - }, - getResolve: () => (resolveContext, requestToResolve, cb) => { - if (cb) { - try { - cb(null, resolveSync(resolveContext, requestToResolve)); - } catch (e) { - cb(e); - } - } else { - log('getResolve without callback not supported'); - return Promise.reject(new Error('Not supported')); - } - }, - }; - const result = externals.call(null, ctx, callback); - // todo handling Promise object (using synchronous-promise package?) - if (result && typeof result.then === 'function') { - log('Asynchronous functions for externals not supported'); - } +const MAX_CACHE = 10; +const _cache = []; +function getResolveSync(configPath, webpackConfig, cwd) { + const cacheKey = { configPath, webpackConfig }; + for (let i = 0; i < _cache.length; i++) { + if (isEqual(_cache[i].key, cacheKey)) { + return _cache[i].value; } - return functionExternalFound; } - // else, vanilla object - for (const key in externals) { - if (!hasOwn(externals, key)) { continue; } - if (source === key) { return true; } + const cached = { + key: cacheKey, + value: createResolveSync(configPath, webpackConfig, cwd), + }; + // put in front and pop last item + if (_cache.unshift(cached) > MAX_CACHE) { + _cache.pop(); } - return false; + return cached.value; } -function findConfigPath(configPath, packageDir) { - const extensions = Object.keys(interpret.extensions).sort(function (a, b) { - return a === '.js' ? -1 : b === '.js' ? 1 : a.length - b.length; - }); - let extension; +const _evalCache = new Map(); +function evaluateFunctionConfigCached(configPath, webpackConfig, env, argv) { + const cacheKey = JSON.stringify({ configPath, args: [env, argv] }); + if (_evalCache.has(cacheKey)) { + return _evalCache.get(cacheKey); + } + const cached = webpackConfig(env, argv); + _evalCache.set(cacheKey, cached); - if (configPath) { - // extensions is not reused below, so safe to mutate it here. - extensions.reverse(); - extensions.forEach(function (maybeExtension) { - if (extension) { - return; - } + while (_evalCache.size > MAX_CACHE) { + // remove oldest item + _evalCache.delete(_evalCache.keys().next().value); + } + return cached; +} - if (configPath.substr(-maybeExtension.length) === maybeExtension) { - extension = maybeExtension; - } - }); +/** + * Find the full path to 'source', given 'file' as a full reference path. + * + * resolveImport('./foo', '/Users/ben/bar.js') => '/Users/ben/foo.js' + * @param {string} source - the module to resolve; i.e './some-module' + * @param {string} file - the importing file's full path; i.e. '/usr/local/bin/file.js' + * @param {object} settings - the webpack config file name, as well as cwd + * @example + * options: { + * // Path to the webpack config + * config: 'webpack.config.js', + * // Path to be used to determine where to resolve webpack from + * // (may differ from the cwd in some cases) + * cwd: process.cwd() + * } + * @return {string?} the resolved path to source, undefined if not resolved, or null + * if resolved to a non-FS resource (i.e. script tag at page load) + */ +exports.resolve = function (source, file, settings) { + + // strip loaders + const finalBang = source.lastIndexOf('!'); + if (finalBang >= 0) { + source = source.slice(finalBang + 1); + } + + // strip resource query + const finalQuestionMark = source.lastIndexOf('?'); + if (finalQuestionMark >= 0) { + source = source.slice(0, finalQuestionMark); + } + + let webpackConfig; + + const _configPath = settings && settings.config; + /** + * Attempt to set the current working directory. + * If none is passed, default to the `cwd` where the config is located. + */ + const cwd = settings && settings.cwd; + const configIndex = settings && settings['config-index']; + const env = settings && settings.env; + const argv = settings && typeof settings.argv !== 'undefined' ? settings.argv : {}; + const shouldCacheFunctionConfig = settings && settings.cache; + let packageDir; + + let configPath = typeof _configPath === 'string' && _configPath.startsWith('.') + ? path.resolve(_configPath) + : _configPath; + + log('Config path from settings:', configPath); + + // see if we've got a config path, a config object, an array of config objects or a config function + if (!configPath || typeof configPath === 'string') { // see if we've got an absolute path - if (!path.isAbsolute(configPath)) { - configPath = path.join(packageDir, configPath); + if (!configPath || !path.isAbsolute(configPath)) { + // if not, find ancestral package.json and use its directory as base for the path + packageDir = findRoot(path.resolve(file)); + if (!packageDir) { throw new Error('package not found above ' + file); } } - } else { - extensions.forEach(function (maybeExtension) { - if (extension) { - return; - } - const maybePath = path.resolve( - path.join(packageDir, 'webpack.config' + maybeExtension) - ); - if (fs.existsSync(maybePath)) { - configPath = maybePath; - extension = maybeExtension; + configPath = findConfigPath(configPath, packageDir); + + log('Config path resolved to:', configPath); + if (configPath) { + try { + webpackConfig = require(configPath); + } catch (e) { + console.log('Error resolving webpackConfig', e); + throw e; } - }); + } else { + log('No config path found relative to', file, '; using {}'); + webpackConfig = {}; + } + + if (webpackConfig && webpackConfig.default) { + log('Using ES6 module "default" key instead of module.exports.'); + webpackConfig = webpackConfig.default; + } + + } else { + webpackConfig = configPath; + configPath = null; } - registerCompiler(interpret.extensions[extension]); - return configPath; -} + if (typeof webpackConfig === 'function') { + webpackConfig = shouldCacheFunctionConfig + ? evaluateFunctionConfigCached(configPath, webpackConfig, env, argv) + : webpackConfig(env, argv); + } -function registerCompiler(moduleDescriptor) { - if (moduleDescriptor) { - if (typeof moduleDescriptor === 'string') { - require(moduleDescriptor); - } else if (!Array.isArray(moduleDescriptor)) { - moduleDescriptor.register(require(moduleDescriptor.module)); + if (isArray(webpackConfig)) { + webpackConfig = webpackConfig.map((cfg) => { + if (typeof cfg === 'function') { + return cfg(env, argv); + } + + return cfg; + }); + + if (typeof configIndex !== 'undefined' && webpackConfig.length > configIndex) { + webpackConfig = webpackConfig[configIndex]; } else { - for (let i = 0; i < moduleDescriptor.length; i++) { - try { - registerCompiler(moduleDescriptor[i]); + for (let i = 0; i < webpackConfig.length; i++) { + if (webpackConfig[i].resolve) { + webpackConfig = webpackConfig[i]; break; - } catch (e) { - log('Failed to register compiler for moduleDescriptor[]:', i, moduleDescriptor); } } } } -} + + if (typeof webpackConfig.then === 'function') { + webpackConfig = {}; + + console.warn('Webpack config returns a `Promise`; that signature is not supported at the moment. Using empty object instead.'); + } + + if (webpackConfig == null) { + webpackConfig = {}; + + console.warn('No webpack configuration with a "resolve" field found. Using empty object instead.'); + } + + log('Using config: ', webpackConfig); + + const resolveSync = getResolveSync(configPath, webpackConfig, cwd); + + // externals + if (findExternal(source, webpackConfig.externals, path.dirname(file), resolveSync)) { + return { found: true, path: null }; + } + + // otherwise, resolve "normally" + + try { + return { found: true, path: resolveSync(path.dirname(file), source) }; + } catch (err) { + if (isCore(source)) { + return { found: true, path: null }; + } + + log('Error during module resolution:', err); + return { found: false }; + } +}; diff --git a/resolvers/webpack/package.json b/resolvers/webpack/package.json index 3fa47d9362..057711e9bf 100644 --- a/resolvers/webpack/package.json +++ b/resolvers/webpack/package.json @@ -1,6 +1,6 @@ { "name": "eslint-import-resolver-webpack", - "version": "0.13.8", + "version": "0.13.10", "description": "Resolve paths to dependencies, given a webpack.config.js. Plugin for eslint-plugin-import.", "main": "index.js", "scripts": { @@ -14,7 +14,8 @@ ], "repository": { "type": "git", - "url": "git+https://github.com/import-js/eslint-plugin-import.git" + "url": "git+https://github.com/import-js/eslint-plugin-import.git", + "directory": "resolvers/webpack" }, "keywords": [ "eslint-plugin-import", @@ -30,14 +31,13 @@ }, "homepage": "https://github.com/import-js/eslint-plugin-import/tree/HEAD/resolvers/webpack", "dependencies": { - "array.prototype.find": "^2.2.2", "debug": "^3.2.7", "enhanced-resolve": "^0.9.1", "find-root": "^1.1.0", - "hasown": "^2.0.0", + "hasown": "^2.0.2", "interpret": "^1.4.0", - "is-core-module": "^2.13.1", - "is-regex": "^1.1.4", + "is-core-module": "^2.15.1", + "is-regex": "^1.2.0", "lodash": "^4.17.21", "resolve": "^2.0.0-next.5", "semver": "^5.7.2" diff --git a/resolvers/webpack/test/cache.js b/resolvers/webpack/test/cache.js new file mode 100644 index 0000000000..04d6de0577 --- /dev/null +++ b/resolvers/webpack/test/cache.js @@ -0,0 +1,48 @@ +'use strict'; + +const chai = require('chai'); +const expect = chai.expect; +const path = require('path'); + +const resolve = require('../index').resolve; + +const file = path.join(__dirname, 'files', 'src', 'jsx', 'dummy.js'); + +describe('cache', function () { + it('can distinguish different config files', function () { + const setting1 = { + config: require(path.join(__dirname, './files/webpack.function.config.js')), + argv: { + mode: 'test', + }, + cache: true, + }; + expect(resolve('baz', file, setting1)).to.have.property('path') + .and.equal(path.join(__dirname, 'files', 'some', 'bar', 'bar.js')); + const setting2 = { + config: require(path.join(__dirname, './files/webpack.function.config.multiple.js')), + cache: true, + }; + expect(resolve('baz', file, setting2)).to.have.property('path') + .and.equal(path.join(__dirname, 'files', 'some', 'goofy', 'path', 'foo.js')); + }); + + it('can distinguish different config', function () { + const setting1 = { + config: require(path.join(__dirname, './files/webpack.function.config.js')), + env: { + dummy: true, + }, + cache: true, + }; + expect(resolve('bar', file, setting1)).to.have.property('path') + .and.equal(path.join(__dirname, 'files', 'some', 'goofy', 'path', 'bar.js')); + const setting2 = { + config: require(path.join(__dirname, './files/webpack.function.config.multiple.js')), + cache: true, + }; + const result = resolve('bar', file, setting2); + expect(result).not.to.have.property('path'); + expect(result).to.have.property('found').to.be.false; + }); +}); diff --git a/resolvers/webpack/test/files/webpack.function.config.multiple.js b/resolvers/webpack/test/files/webpack.function.config.multiple.js index 4dbc94bbc9..8ab982bbc6 100644 --- a/resolvers/webpack/test/files/webpack.function.config.multiple.js +++ b/resolvers/webpack/test/files/webpack.function.config.multiple.js @@ -7,6 +7,7 @@ module.exports = [function(env) { alias: { 'foo': path.join(__dirname, 'some', 'goofy', 'path', 'foo.js'), 'bar': env ? path.join(__dirname, 'some', 'goofy', 'path', 'bar.js') : undefined, + 'baz': path.join(__dirname, 'some', 'goofy', 'path', 'foo.js'), 'some-alias': path.join(__dirname, 'some'), }, modules: [ diff --git a/src/ExportMap.js b/src/ExportMap.js deleted file mode 100644 index f61d3c170a..0000000000 --- a/src/ExportMap.js +++ /dev/null @@ -1,826 +0,0 @@ -import fs from 'fs'; -import { dirname } from 'path'; - -import doctrine from 'doctrine'; - -import debug from 'debug'; - -import { SourceCode } from 'eslint'; - -import parse from 'eslint-module-utils/parse'; -import visit from 'eslint-module-utils/visit'; -import resolve from 'eslint-module-utils/resolve'; -import isIgnored, { hasValidExtension } from 'eslint-module-utils/ignore'; - -import { hashObject } from 'eslint-module-utils/hash'; -import * as unambiguous from 'eslint-module-utils/unambiguous'; - -import { tsConfigLoader } from 'tsconfig-paths/lib/tsconfig-loader'; - -import includes from 'array-includes'; - -let ts; - -const log = debug('eslint-plugin-import:ExportMap'); - -const exportCache = new Map(); -const tsconfigCache = new Map(); - -export default class ExportMap { - constructor(path) { - this.path = path; - this.namespace = new Map(); - // todo: restructure to key on path, value is resolver + map of names - this.reexports = new Map(); - /** - * star-exports - * @type {Set} of () => ExportMap - */ - this.dependencies = new Set(); - /** - * dependencies of this module that are not explicitly re-exported - * @type {Map} from path = () => ExportMap - */ - this.imports = new Map(); - this.errors = []; - /** - * type {'ambiguous' | 'Module' | 'Script'} - */ - this.parseGoal = 'ambiguous'; - } - - get hasDefault() { return this.get('default') != null; } // stronger than this.has - - get size() { - let size = this.namespace.size + this.reexports.size; - this.dependencies.forEach((dep) => { - const d = dep(); - // CJS / ignored dependencies won't exist (#717) - if (d == null) { return; } - size += d.size; - }); - return size; - } - - /** - * Note that this does not check explicitly re-exported names for existence - * in the base namespace, but it will expand all `export * from '...'` exports - * if not found in the explicit namespace. - * @param {string} name - * @return {Boolean} true if `name` is exported by this module. - */ - has(name) { - if (this.namespace.has(name)) { return true; } - if (this.reexports.has(name)) { return true; } - - // default exports must be explicitly re-exported (#328) - if (name !== 'default') { - for (const dep of this.dependencies) { - const innerMap = dep(); - - // todo: report as unresolved? - if (!innerMap) { continue; } - - if (innerMap.has(name)) { return true; } - } - } - - return false; - } - - /** - * ensure that imported name fully resolves. - * @param {string} name - * @return {{ found: boolean, path: ExportMap[] }} - */ - hasDeep(name) { - if (this.namespace.has(name)) { return { found: true, path: [this] }; } - - if (this.reexports.has(name)) { - const reexports = this.reexports.get(name); - const imported = reexports.getImport(); - - // if import is ignored, return explicit 'null' - if (imported == null) { return { found: true, path: [this] }; } - - // safeguard against cycles, only if name matches - if (imported.path === this.path && reexports.local === name) { - return { found: false, path: [this] }; - } - - const deep = imported.hasDeep(reexports.local); - deep.path.unshift(this); - - return deep; - } - - // default exports must be explicitly re-exported (#328) - if (name !== 'default') { - for (const dep of this.dependencies) { - const innerMap = dep(); - if (innerMap == null) { return { found: true, path: [this] }; } - // todo: report as unresolved? - if (!innerMap) { continue; } - - // safeguard against cycles - if (innerMap.path === this.path) { continue; } - - const innerValue = innerMap.hasDeep(name); - if (innerValue.found) { - innerValue.path.unshift(this); - return innerValue; - } - } - } - - return { found: false, path: [this] }; - } - - get(name) { - if (this.namespace.has(name)) { return this.namespace.get(name); } - - if (this.reexports.has(name)) { - const reexports = this.reexports.get(name); - const imported = reexports.getImport(); - - // if import is ignored, return explicit 'null' - if (imported == null) { return null; } - - // safeguard against cycles, only if name matches - if (imported.path === this.path && reexports.local === name) { return undefined; } - - return imported.get(reexports.local); - } - - // default exports must be explicitly re-exported (#328) - if (name !== 'default') { - for (const dep of this.dependencies) { - const innerMap = dep(); - // todo: report as unresolved? - if (!innerMap) { continue; } - - // safeguard against cycles - if (innerMap.path === this.path) { continue; } - - const innerValue = innerMap.get(name); - if (innerValue !== undefined) { return innerValue; } - } - } - - return undefined; - } - - forEach(callback, thisArg) { - this.namespace.forEach((v, n) => { callback.call(thisArg, v, n, this); }); - - this.reexports.forEach((reexports, name) => { - const reexported = reexports.getImport(); - // can't look up meta for ignored re-exports (#348) - callback.call(thisArg, reexported && reexported.get(reexports.local), name, this); - }); - - this.dependencies.forEach((dep) => { - const d = dep(); - // CJS / ignored dependencies won't exist (#717) - if (d == null) { return; } - - d.forEach((v, n) => { - if (n !== 'default') { - callback.call(thisArg, v, n, this); - } - }); - }); - } - - // todo: keys, values, entries? - - reportErrors(context, declaration) { - const msg = this.errors - .map((e) => `${e.message} (${e.lineNumber}:${e.column})`) - .join(', '); - context.report({ - node: declaration.source, - message: `Parse errors in imported module '${declaration.source.value}': ${msg}`, - }); - } -} - -/** - * parse docs from the first node that has leading comments - */ -function captureDoc(source, docStyleParsers, ...nodes) { - const metadata = {}; - - // 'some' short-circuits on first 'true' - nodes.some((n) => { - try { - - let leadingComments; - - // n.leadingComments is legacy `attachComments` behavior - if ('leadingComments' in n) { - leadingComments = n.leadingComments; - } else if (n.range) { - leadingComments = source.getCommentsBefore(n); - } - - if (!leadingComments || leadingComments.length === 0) { return false; } - - for (const name in docStyleParsers) { - const doc = docStyleParsers[name](leadingComments); - if (doc) { - metadata.doc = doc; - } - } - - return true; - } catch (err) { - return false; - } - }); - - return metadata; -} - -const availableDocStyleParsers = { - jsdoc: captureJsDoc, - tomdoc: captureTomDoc, -}; - -/** - * parse JSDoc from leading comments - * @param {object[]} comments - * @return {{ doc: object }} - */ -function captureJsDoc(comments) { - let doc; - - // capture XSDoc - comments.forEach((comment) => { - // skip non-block comments - if (comment.type !== 'Block') { return; } - try { - doc = doctrine.parse(comment.value, { unwrap: true }); - } catch (err) { - /* don't care, for now? maybe add to `errors?` */ - } - }); - - return doc; -} - -/** - * parse TomDoc section from comments - */ -function captureTomDoc(comments) { - // collect lines up to first paragraph break - const lines = []; - for (let i = 0; i < comments.length; i++) { - const comment = comments[i]; - if (comment.value.match(/^\s*$/)) { break; } - lines.push(comment.value.trim()); - } - - // return doctrine-like object - const statusMatch = lines.join(' ').match(/^(Public|Internal|Deprecated):\s*(.+)/); - if (statusMatch) { - return { - description: statusMatch[2], - tags: [{ - title: statusMatch[1].toLowerCase(), - description: statusMatch[2], - }], - }; - } -} - -const supportedImportTypes = new Set(['ImportDefaultSpecifier', 'ImportNamespaceSpecifier']); - -ExportMap.get = function (source, context) { - const path = resolve(source, context); - if (path == null) { return null; } - - return ExportMap.for(childContext(path, context)); -}; - -ExportMap.for = function (context) { - const { path } = context; - - const cacheKey = context.cacheKey || hashObject(context).digest('hex'); - let exportMap = exportCache.get(cacheKey); - - // return cached ignore - if (exportMap === null) { return null; } - - const stats = fs.statSync(path); - if (exportMap != null) { - // date equality check - if (exportMap.mtime - stats.mtime === 0) { - return exportMap; - } - // future: check content equality? - } - - // check valid extensions first - if (!hasValidExtension(path, context)) { - exportCache.set(cacheKey, null); - return null; - } - - // check for and cache ignore - if (isIgnored(path, context)) { - log('ignored path due to ignore settings:', path); - exportCache.set(cacheKey, null); - return null; - } - - const content = fs.readFileSync(path, { encoding: 'utf8' }); - - // check for and cache unambiguous modules - if (!unambiguous.test(content)) { - log('ignored path due to unambiguous regex:', path); - exportCache.set(cacheKey, null); - return null; - } - - log('cache miss', cacheKey, 'for path', path); - exportMap = ExportMap.parse(path, content, context); - - // ambiguous modules return null - if (exportMap == null) { - log('ignored path due to ambiguous parse:', path); - exportCache.set(cacheKey, null); - return null; - } - - exportMap.mtime = stats.mtime; - - exportCache.set(cacheKey, exportMap); - return exportMap; -}; - -ExportMap.parse = function (path, content, context) { - const m = new ExportMap(path); - const isEsModuleInteropTrue = isEsModuleInterop(); - - let ast; - let visitorKeys; - try { - const result = parse(path, content, context); - ast = result.ast; - visitorKeys = result.visitorKeys; - } catch (err) { - m.errors.push(err); - return m; // can't continue - } - - m.visitorKeys = visitorKeys; - - let hasDynamicImports = false; - - function processDynamicImport(source) { - hasDynamicImports = true; - if (source.type !== 'Literal') { - return null; - } - const p = remotePath(source.value); - if (p == null) { - return null; - } - const importedSpecifiers = new Set(); - importedSpecifiers.add('ImportNamespaceSpecifier'); - const getter = thunkFor(p, context); - m.imports.set(p, { - getter, - declarations: new Set([{ - source: { - // capturing actual node reference holds full AST in memory! - value: source.value, - loc: source.loc, - }, - importedSpecifiers, - dynamic: true, - }]), - }); - } - - visit(ast, visitorKeys, { - ImportExpression(node) { - processDynamicImport(node.source); - }, - CallExpression(node) { - if (node.callee.type === 'Import') { - processDynamicImport(node.arguments[0]); - } - }, - }); - - const unambiguouslyESM = unambiguous.isModule(ast); - if (!unambiguouslyESM && !hasDynamicImports) { return null; } - - const docstyle = context.settings && context.settings['import/docstyle'] || ['jsdoc']; - const docStyleParsers = {}; - docstyle.forEach((style) => { - docStyleParsers[style] = availableDocStyleParsers[style]; - }); - - // attempt to collect module doc - if (ast.comments) { - ast.comments.some((c) => { - if (c.type !== 'Block') { return false; } - try { - const doc = doctrine.parse(c.value, { unwrap: true }); - if (doc.tags.some((t) => t.title === 'module')) { - m.doc = doc; - return true; - } - } catch (err) { /* ignore */ } - return false; - }); - } - - const namespaces = new Map(); - - function remotePath(value) { - return resolve.relative(value, path, context.settings); - } - - function resolveImport(value) { - const rp = remotePath(value); - if (rp == null) { return null; } - return ExportMap.for(childContext(rp, context)); - } - - function getNamespace(identifier) { - if (!namespaces.has(identifier.name)) { return; } - - return function () { - return resolveImport(namespaces.get(identifier.name)); - }; - } - - function addNamespace(object, identifier) { - const nsfn = getNamespace(identifier); - if (nsfn) { - Object.defineProperty(object, 'namespace', { get: nsfn }); - } - - return object; - } - - function processSpecifier(s, n, m) { - const nsource = n.source && n.source.value; - const exportMeta = {}; - let local; - - switch (s.type) { - case 'ExportDefaultSpecifier': - if (!nsource) { return; } - local = 'default'; - break; - case 'ExportNamespaceSpecifier': - m.namespace.set(s.exported.name, Object.defineProperty(exportMeta, 'namespace', { - get() { return resolveImport(nsource); }, - })); - return; - case 'ExportAllDeclaration': - m.namespace.set(s.exported.name || s.exported.value, addNamespace(exportMeta, s.source.value)); - return; - case 'ExportSpecifier': - if (!n.source) { - m.namespace.set(s.exported.name || s.exported.value, addNamespace(exportMeta, s.local)); - return; - } - // else falls through - default: - local = s.local.name; - break; - } - - // todo: JSDoc - m.reexports.set(s.exported.name, { local, getImport: () => resolveImport(nsource) }); - } - - function captureDependencyWithSpecifiers(n) { - // import type { Foo } (TS and Flow); import typeof { Foo } (Flow) - const declarationIsType = n.importKind === 'type' || n.importKind === 'typeof'; - // import './foo' or import {} from './foo' (both 0 specifiers) is a side effect and - // shouldn't be considered to be just importing types - let specifiersOnlyImportingTypes = n.specifiers.length > 0; - const importedSpecifiers = new Set(); - n.specifiers.forEach((specifier) => { - if (specifier.type === 'ImportSpecifier') { - importedSpecifiers.add(specifier.imported.name || specifier.imported.value); - } else if (supportedImportTypes.has(specifier.type)) { - importedSpecifiers.add(specifier.type); - } - - // import { type Foo } (Flow); import { typeof Foo } (Flow) - specifiersOnlyImportingTypes = specifiersOnlyImportingTypes - && (specifier.importKind === 'type' || specifier.importKind === 'typeof'); - }); - captureDependency(n, declarationIsType || specifiersOnlyImportingTypes, importedSpecifiers); - } - - function captureDependency({ source }, isOnlyImportingTypes, importedSpecifiers = new Set()) { - if (source == null) { return null; } - - const p = remotePath(source.value); - if (p == null) { return null; } - - const declarationMetadata = { - // capturing actual node reference holds full AST in memory! - source: { value: source.value, loc: source.loc }, - isOnlyImportingTypes, - importedSpecifiers, - }; - - const existing = m.imports.get(p); - if (existing != null) { - existing.declarations.add(declarationMetadata); - return existing.getter; - } - - const getter = thunkFor(p, context); - m.imports.set(p, { getter, declarations: new Set([declarationMetadata]) }); - return getter; - } - - const source = makeSourceCode(content, ast); - - function readTsConfig(context) { - const tsconfigInfo = tsConfigLoader({ - cwd: context.parserOptions && context.parserOptions.tsconfigRootDir || process.cwd(), - getEnv: (key) => process.env[key], - }); - try { - if (tsconfigInfo.tsConfigPath !== undefined) { - // Projects not using TypeScript won't have `typescript` installed. - if (!ts) { ts = require('typescript'); } // eslint-disable-line import/no-extraneous-dependencies - - const configFile = ts.readConfigFile(tsconfigInfo.tsConfigPath, ts.sys.readFile); - return ts.parseJsonConfigFileContent( - configFile.config, - ts.sys, - dirname(tsconfigInfo.tsConfigPath), - ); - } - } catch (e) { - // Catch any errors - } - - return null; - } - - function isEsModuleInterop() { - const cacheKey = hashObject({ - tsconfigRootDir: context.parserOptions && context.parserOptions.tsconfigRootDir, - }).digest('hex'); - let tsConfig = tsconfigCache.get(cacheKey); - if (typeof tsConfig === 'undefined') { - tsConfig = readTsConfig(context); - tsconfigCache.set(cacheKey, tsConfig); - } - - return tsConfig && tsConfig.options ? tsConfig.options.esModuleInterop : false; - } - - ast.body.forEach(function (n) { - if (n.type === 'ExportDefaultDeclaration') { - const exportMeta = captureDoc(source, docStyleParsers, n); - if (n.declaration.type === 'Identifier') { - addNamespace(exportMeta, n.declaration); - } - m.namespace.set('default', exportMeta); - return; - } - - if (n.type === 'ExportAllDeclaration') { - const getter = captureDependency(n, n.exportKind === 'type'); - if (getter) { m.dependencies.add(getter); } - if (n.exported) { - processSpecifier(n, n.exported, m); - } - return; - } - - // capture namespaces in case of later export - if (n.type === 'ImportDeclaration') { - captureDependencyWithSpecifiers(n); - - const ns = n.specifiers.find((s) => s.type === 'ImportNamespaceSpecifier'); - if (ns) { - namespaces.set(ns.local.name, n.source.value); - } - return; - } - - if (n.type === 'ExportNamedDeclaration') { - captureDependencyWithSpecifiers(n); - - // capture declaration - if (n.declaration != null) { - switch (n.declaration.type) { - case 'FunctionDeclaration': - case 'ClassDeclaration': - case 'TypeAlias': // flowtype with babel-eslint parser - case 'InterfaceDeclaration': - case 'DeclareFunction': - case 'TSDeclareFunction': - case 'TSEnumDeclaration': - case 'TSTypeAliasDeclaration': - case 'TSInterfaceDeclaration': - case 'TSAbstractClassDeclaration': - case 'TSModuleDeclaration': - m.namespace.set(n.declaration.id.name, captureDoc(source, docStyleParsers, n)); - break; - case 'VariableDeclaration': - n.declaration.declarations.forEach((d) => { - recursivePatternCapture( - d.id, - (id) => m.namespace.set(id.name, captureDoc(source, docStyleParsers, d, n)), - ); - }); - break; - default: - } - } - - n.specifiers.forEach((s) => processSpecifier(s, n, m)); - } - - const exports = ['TSExportAssignment']; - if (isEsModuleInteropTrue) { - exports.push('TSNamespaceExportDeclaration'); - } - - // This doesn't declare anything, but changes what's being exported. - if (includes(exports, n.type)) { - const exportedName = n.type === 'TSNamespaceExportDeclaration' - ? (n.id || n.name).name - : n.expression && n.expression.name || n.expression.id && n.expression.id.name || null; - const declTypes = [ - 'VariableDeclaration', - 'ClassDeclaration', - 'TSDeclareFunction', - 'TSEnumDeclaration', - 'TSTypeAliasDeclaration', - 'TSInterfaceDeclaration', - 'TSAbstractClassDeclaration', - 'TSModuleDeclaration', - ]; - const exportedDecls = ast.body.filter(({ type, id, declarations }) => includes(declTypes, type) && ( - id && id.name === exportedName || declarations && declarations.find((d) => d.id.name === exportedName) - )); - if (exportedDecls.length === 0) { - // Export is not referencing any local declaration, must be re-exporting - m.namespace.set('default', captureDoc(source, docStyleParsers, n)); - return; - } - if ( - isEsModuleInteropTrue // esModuleInterop is on in tsconfig - && !m.namespace.has('default') // and default isn't added already - ) { - m.namespace.set('default', {}); // add default export - } - exportedDecls.forEach((decl) => { - if (decl.type === 'TSModuleDeclaration') { - if (decl.body && decl.body.type === 'TSModuleDeclaration') { - m.namespace.set(decl.body.id.name, captureDoc(source, docStyleParsers, decl.body)); - } else if (decl.body && decl.body.body) { - decl.body.body.forEach((moduleBlockNode) => { - // Export-assignment exports all members in the namespace, - // explicitly exported or not. - const namespaceDecl = moduleBlockNode.type === 'ExportNamedDeclaration' - ? moduleBlockNode.declaration - : moduleBlockNode; - - if (!namespaceDecl) { - // TypeScript can check this for us; we needn't - } else if (namespaceDecl.type === 'VariableDeclaration') { - namespaceDecl.declarations.forEach((d) => recursivePatternCapture(d.id, (id) => m.namespace.set( - id.name, - captureDoc(source, docStyleParsers, decl, namespaceDecl, moduleBlockNode), - )), - ); - } else { - m.namespace.set( - namespaceDecl.id.name, - captureDoc(source, docStyleParsers, moduleBlockNode)); - } - }); - } - } else { - // Export as default - m.namespace.set('default', captureDoc(source, docStyleParsers, decl)); - } - }); - } - }); - - if ( - isEsModuleInteropTrue // esModuleInterop is on in tsconfig - && m.namespace.size > 0 // anything is exported - && !m.namespace.has('default') // and default isn't added already - ) { - m.namespace.set('default', {}); // add default export - } - - if (unambiguouslyESM) { - m.parseGoal = 'Module'; - } - return m; -}; - -/** - * The creation of this closure is isolated from other scopes - * to avoid over-retention of unrelated variables, which has - * caused memory leaks. See #1266. - */ -function thunkFor(p, context) { - return () => ExportMap.for(childContext(p, context)); -} - -/** - * Traverse a pattern/identifier node, calling 'callback' - * for each leaf identifier. - * @param {node} pattern - * @param {Function} callback - * @return {void} - */ -export function recursivePatternCapture(pattern, callback) { - switch (pattern.type) { - case 'Identifier': // base case - callback(pattern); - break; - - case 'ObjectPattern': - pattern.properties.forEach((p) => { - if (p.type === 'ExperimentalRestProperty' || p.type === 'RestElement') { - callback(p.argument); - return; - } - recursivePatternCapture(p.value, callback); - }); - break; - - case 'ArrayPattern': - pattern.elements.forEach((element) => { - if (element == null) { return; } - if (element.type === 'ExperimentalRestProperty' || element.type === 'RestElement') { - callback(element.argument); - return; - } - recursivePatternCapture(element, callback); - }); - break; - - case 'AssignmentPattern': - callback(pattern.left); - break; - default: - } -} - -let parserOptionsHash = ''; -let prevParserOptions = ''; -let settingsHash = ''; -let prevSettings = ''; -/** - * don't hold full context object in memory, just grab what we need. - * also calculate a cacheKey, where parts of the cacheKey hash are memoized - */ -function childContext(path, context) { - const { settings, parserOptions, parserPath } = context; - - if (JSON.stringify(settings) !== prevSettings) { - settingsHash = hashObject({ settings }).digest('hex'); - prevSettings = JSON.stringify(settings); - } - - if (JSON.stringify(parserOptions) !== prevParserOptions) { - parserOptionsHash = hashObject({ parserOptions }).digest('hex'); - prevParserOptions = JSON.stringify(parserOptions); - } - - return { - cacheKey: String(parserPath) + parserOptionsHash + settingsHash + String(path), - settings, - parserOptions, - parserPath, - path, - }; -} - -/** - * sometimes legacy support isn't _that_ hard... right? - */ -function makeSourceCode(text, ast) { - if (SourceCode.length > 1) { - // ESLint 3 - return new SourceCode(text, ast); - } else { - // ESLint 4, 5 - return new SourceCode({ text, ast }); - } -} diff --git a/src/core/importType.js b/src/core/importType.js index 6a37d1bb14..32e200f1de 100644 --- a/src/core/importType.js +++ b/src/core/importType.js @@ -4,6 +4,11 @@ import isCoreModule from 'is-core-module'; import resolve from 'eslint-module-utils/resolve'; import { getContextPackagePath } from './packagePath'; +const scopedRegExp = /^@[^/]+\/?[^/]+/; +export function isScoped(name) { + return name && scopedRegExp.test(name); +} + function baseModule(name) { if (isScoped(name)) { const [scope, pkg] = name.split('/'); @@ -30,20 +35,6 @@ export function isBuiltIn(name, settings, path) { return isCoreModule(base) || extras.indexOf(base) > -1; } -export function isExternalModule(name, path, context) { - if (arguments.length < 3) { - throw new TypeError('isExternalModule: name, path, and context are all required'); - } - return (isModule(name) || isScoped(name)) && typeTest(name, context, path) === 'external'; -} - -export function isExternalModuleMain(name, path, context) { - if (arguments.length < 3) { - throw new TypeError('isExternalModule: name, path, and context are all required'); - } - return isModuleMain(name) && typeTest(name, context, path) === 'external'; -} - const moduleRegExp = /^\w/; function isModule(name) { return name && moduleRegExp.test(name); @@ -54,20 +45,9 @@ function isModuleMain(name) { return name && moduleMainRegExp.test(name); } -const scopedRegExp = /^@[^/]+\/?[^/]+/; -export function isScoped(name) { - return name && scopedRegExp.test(name); -} - -const scopedMainRegExp = /^@[^/]+\/?[^/]+$/; -export function isScopedMain(name) { - return name && scopedMainRegExp.test(name); -} - function isRelativeToParent(name) { return (/^\.\.$|^\.\.[\\/]/).test(name); } - const indexFiles = ['.', './', './index', './index.js']; function isIndex(name) { return indexFiles.indexOf(name) !== -1; @@ -123,6 +103,25 @@ function typeTest(name, context, path) { return 'unknown'; } +export function isExternalModule(name, path, context) { + if (arguments.length < 3) { + throw new TypeError('isExternalModule: name, path, and context are all required'); + } + return (isModule(name) || isScoped(name)) && typeTest(name, context, path) === 'external'; +} + +export function isExternalModuleMain(name, path, context) { + if (arguments.length < 3) { + throw new TypeError('isExternalModule: name, path, and context are all required'); + } + return isModuleMain(name) && typeTest(name, context, path) === 'external'; +} + +const scopedMainRegExp = /^@[^/]+\/?[^/]+$/; +export function isScopedMain(name) { + return name && scopedMainRegExp.test(name); +} + export default function resolveImportType(name, context) { return typeTest(name, context, resolve(name, context)); } diff --git a/src/core/packagePath.js b/src/core/packagePath.js index 1a7a28f4b4..f45f543260 100644 --- a/src/core/packagePath.js +++ b/src/core/packagePath.js @@ -1,16 +1,17 @@ import { dirname } from 'path'; +import { getPhysicalFilename } from 'eslint-module-utils/contextCompat'; import pkgUp from 'eslint-module-utils/pkgUp'; import readPkgUp from 'eslint-module-utils/readPkgUp'; -export function getContextPackagePath(context) { - return getFilePackagePath(context.getPhysicalFilename ? context.getPhysicalFilename() : context.getFilename()); -} - export function getFilePackagePath(filePath) { const fp = pkgUp({ cwd: filePath }); return dirname(fp); } +export function getContextPackagePath(context) { + return getFilePackagePath(getPhysicalFilename(context)); +} + export function getFilePackageName(filePath) { const { pkg, path } = readPkgUp({ cwd: filePath, normalize: false }); if (pkg) { diff --git a/src/core/sourceType.js b/src/core/sourceType.js new file mode 100644 index 0000000000..5ff92edc97 --- /dev/null +++ b/src/core/sourceType.js @@ -0,0 +1,12 @@ +/** + * @param {import('eslint').Rule.RuleContext} context + * @returns 'module' | 'script' | 'commonjs' | undefined + */ +export default function sourceType(context) { + if ('sourceType' in context.parserOptions) { + return context.parserOptions.sourceType; + } + if ('languageOptions' in context && context.languageOptions) { + return context.languageOptions.sourceType; + } +} diff --git a/src/exportMap/builder.js b/src/exportMap/builder.js new file mode 100644 index 0000000000..f7b9006eff --- /dev/null +++ b/src/exportMap/builder.js @@ -0,0 +1,210 @@ +import fs from 'fs'; + +import doctrine from 'doctrine'; + +import debug from 'debug'; + +import parse from 'eslint-module-utils/parse'; +import visit from 'eslint-module-utils/visit'; +import resolve from 'eslint-module-utils/resolve'; +import isIgnored, { hasValidExtension } from 'eslint-module-utils/ignore'; + +import { hashObject } from 'eslint-module-utils/hash'; +import * as unambiguous from 'eslint-module-utils/unambiguous'; + +import ExportMap from '.'; +import childContext from './childContext'; +import { isEsModuleInterop } from './typescript'; +import { RemotePath } from './remotePath'; +import ImportExportVisitorBuilder from './visitor'; + +const log = debug('eslint-plugin-import:ExportMap'); + +const exportCache = new Map(); + +/** + * The creation of this closure is isolated from other scopes + * to avoid over-retention of unrelated variables, which has + * caused memory leaks. See #1266. + */ +function thunkFor(p, context) { + // eslint-disable-next-line no-use-before-define + return () => ExportMapBuilder.for(childContext(p, context)); +} + +export default class ExportMapBuilder { + static get(source, context) { + const path = resolve(source, context); + if (path == null) { return null; } + + return ExportMapBuilder.for(childContext(path, context)); + } + + static for(context) { + const { path } = context; + + const cacheKey = context.cacheKey || hashObject(context).digest('hex'); + let exportMap = exportCache.get(cacheKey); + + // return cached ignore + if (exportMap === null) { return null; } + + const stats = fs.statSync(path); + if (exportMap != null) { + // date equality check + if (exportMap.mtime - stats.mtime === 0) { + return exportMap; + } + // future: check content equality? + } + + // check valid extensions first + if (!hasValidExtension(path, context)) { + exportCache.set(cacheKey, null); + return null; + } + + // check for and cache ignore + if (isIgnored(path, context)) { + log('ignored path due to ignore settings:', path); + exportCache.set(cacheKey, null); + return null; + } + + const content = fs.readFileSync(path, { encoding: 'utf8' }); + + // check for and cache unambiguous modules + if (!unambiguous.test(content)) { + log('ignored path due to unambiguous regex:', path); + exportCache.set(cacheKey, null); + return null; + } + + log('cache miss', cacheKey, 'for path', path); + exportMap = ExportMapBuilder.parse(path, content, context); + + // ambiguous modules return null + if (exportMap == null) { + log('ignored path due to ambiguous parse:', path); + exportCache.set(cacheKey, null); + return null; + } + + exportMap.mtime = stats.mtime; + + // If the visitor keys were not populated, then we shouldn't save anything to the cache, + // since the parse results may not be reliable. + if (exportMap.visitorKeys) { + exportCache.set(cacheKey, exportMap); + } + return exportMap; + } + + static parse(path, content, context) { + const exportMap = new ExportMap(path); + const isEsModuleInteropTrue = isEsModuleInterop(context); + + let ast; + let visitorKeys; + try { + const result = parse(path, content, context); + ast = result.ast; + visitorKeys = result.visitorKeys; + } catch (err) { + exportMap.errors.push(err); + return exportMap; // can't continue + } + + exportMap.visitorKeys = visitorKeys; + + let hasDynamicImports = false; + + const remotePathResolver = new RemotePath(path, context); + + function processDynamicImport(source) { + hasDynamicImports = true; + if (source.type !== 'Literal') { + return null; + } + const p = remotePathResolver.resolve(source.value); + if (p == null) { + return null; + } + const importedSpecifiers = new Set(); + importedSpecifiers.add('ImportNamespaceSpecifier'); + const getter = thunkFor(p, context); + exportMap.imports.set(p, { + getter, + declarations: new Set([{ + source: { + // capturing actual node reference holds full AST in memory! + value: source.value, + loc: source.loc, + }, + importedSpecifiers, + dynamic: true, + }]), + }); + } + + visit(ast, visitorKeys, { + ImportExpression(node) { + processDynamicImport(node.source); + }, + CallExpression(node) { + if (node.callee.type === 'Import') { + processDynamicImport(node.arguments[0]); + } + }, + }); + + const unambiguouslyESM = unambiguous.isModule(ast); + if (!unambiguouslyESM && !hasDynamicImports) { return null; } + + // attempt to collect module doc + if (ast.comments) { + ast.comments.some((c) => { + if (c.type !== 'Block') { return false; } + try { + const doc = doctrine.parse(c.value, { unwrap: true }); + if (doc.tags.some((t) => t.title === 'module')) { + exportMap.doc = doc; + return true; + } + } catch (err) { /* ignore */ } + return false; + }); + } + + const visitorBuilder = new ImportExportVisitorBuilder( + path, + context, + exportMap, + ExportMapBuilder, + content, + ast, + isEsModuleInteropTrue, + thunkFor, + ); + ast.body.forEach(function (astNode) { + const visitor = visitorBuilder.build(astNode); + + if (visitor[astNode.type]) { + visitor[astNode.type].call(visitorBuilder); + } + }); + + if ( + isEsModuleInteropTrue // esModuleInterop is on in tsconfig + && exportMap.namespace.size > 0 // anything is exported + && !exportMap.namespace.has('default') // and default isn't added already + ) { + exportMap.namespace.set('default', {}); // add default export + } + + if (unambiguouslyESM) { + exportMap.parseGoal = 'Module'; + } + return exportMap; + } +} diff --git a/src/exportMap/captureDependency.js b/src/exportMap/captureDependency.js new file mode 100644 index 0000000000..9ad37d0e20 --- /dev/null +++ b/src/exportMap/captureDependency.js @@ -0,0 +1,60 @@ +export function captureDependency( + { source }, + isOnlyImportingTypes, + remotePathResolver, + exportMap, + context, + thunkFor, + importedSpecifiers = new Set(), +) { + if (source == null) { return null; } + + const p = remotePathResolver.resolve(source.value); + if (p == null) { return null; } + + const declarationMetadata = { + // capturing actual node reference holds full AST in memory! + source: { value: source.value, loc: source.loc }, + isOnlyImportingTypes, + importedSpecifiers, + }; + + const existing = exportMap.imports.get(p); + if (existing != null) { + existing.declarations.add(declarationMetadata); + return existing.getter; + } + + const getter = thunkFor(p, context); + exportMap.imports.set(p, { getter, declarations: new Set([declarationMetadata]) }); + return getter; +} + +const supportedImportTypes = new Set(['ImportDefaultSpecifier', 'ImportNamespaceSpecifier']); + +export function captureDependencyWithSpecifiers( + n, + remotePathResolver, + exportMap, + context, + thunkFor, +) { + // import type { Foo } (TS and Flow); import typeof { Foo } (Flow) + const declarationIsType = n.importKind === 'type' || n.importKind === 'typeof'; + // import './foo' or import {} from './foo' (both 0 specifiers) is a side effect and + // shouldn't be considered to be just importing types + let specifiersOnlyImportingTypes = n.specifiers.length > 0; + const importedSpecifiers = new Set(); + n.specifiers.forEach((specifier) => { + if (specifier.type === 'ImportSpecifier') { + importedSpecifiers.add(specifier.imported.name || specifier.imported.value); + } else if (supportedImportTypes.has(specifier.type)) { + importedSpecifiers.add(specifier.type); + } + + // import { type Foo } (Flow); import { typeof Foo } (Flow) + specifiersOnlyImportingTypes = specifiersOnlyImportingTypes + && (specifier.importKind === 'type' || specifier.importKind === 'typeof'); + }); + captureDependency(n, declarationIsType || specifiersOnlyImportingTypes, remotePathResolver, exportMap, context, thunkFor, importedSpecifiers); +} diff --git a/src/exportMap/childContext.js b/src/exportMap/childContext.js new file mode 100644 index 0000000000..8994ac206a --- /dev/null +++ b/src/exportMap/childContext.js @@ -0,0 +1,56 @@ +import { hashObject } from 'eslint-module-utils/hash'; + +let optionsHash = ''; +let prevOptions = ''; +let settingsHash = ''; +let prevSettings = ''; + +// Replacer function helps us with serializing the parser nested within `languageOptions`. +function stringifyReplacerFn(_, value) { + if (typeof value === 'function') { + return String(value); + } + return value; +} + +/** + * don't hold full context object in memory, just grab what we need. + * also calculate a cacheKey, where parts of the cacheKey hash are memoized + */ +export default function childContext(path, context) { + const { settings, parserOptions, parserPath, languageOptions } = context; + + if (JSON.stringify(settings) !== prevSettings) { + settingsHash = hashObject({ settings }).digest('hex'); + prevSettings = JSON.stringify(settings); + } + + // We'll use either a combination of `parserOptions` and `parserPath` or `languageOptions` + // to construct the cache key, depending on whether this is using a flat config or not. + let optionsToken; + if (!parserPath && languageOptions) { + if (JSON.stringify(languageOptions, stringifyReplacerFn) !== prevOptions) { + optionsHash = hashObject({ languageOptions }).digest('hex'); + prevOptions = JSON.stringify(languageOptions, stringifyReplacerFn); + } + // For languageOptions, we're just using the hashed options as the options token + optionsToken = optionsHash; + } else { + if (JSON.stringify(parserOptions) !== prevOptions) { + optionsHash = hashObject({ parserOptions }).digest('hex'); + prevOptions = JSON.stringify(parserOptions); + } + // When not using flat config, we use a combination of the hashed parserOptions + // and parserPath as the token + optionsToken = String(parserPath) + optionsHash; + } + + return { + cacheKey: optionsToken + settingsHash + String(path), + settings, + parserOptions, + parserPath, + path, + languageOptions, + }; +} diff --git a/src/exportMap/doc.js b/src/exportMap/doc.js new file mode 100644 index 0000000000..c721ae25fc --- /dev/null +++ b/src/exportMap/doc.js @@ -0,0 +1,90 @@ +import doctrine from 'doctrine'; + +/** + * parse docs from the first node that has leading comments + */ +export function captureDoc(source, docStyleParsers, ...nodes) { + const metadata = {}; + + // 'some' short-circuits on first 'true' + nodes.some((n) => { + try { + + let leadingComments; + + // n.leadingComments is legacy `attachComments` behavior + if ('leadingComments' in n) { + leadingComments = n.leadingComments; + } else if (n.range) { + leadingComments = source.getCommentsBefore(n); + } + + if (!leadingComments || leadingComments.length === 0) { return false; } + + for (const name in docStyleParsers) { + const doc = docStyleParsers[name](leadingComments); + if (doc) { + metadata.doc = doc; + } + } + + return true; + } catch (err) { + return false; + } + }); + + return metadata; +} + +/** + * parse JSDoc from leading comments + * @param {object[]} comments + * @return {{ doc: object }} + */ +function captureJsDoc(comments) { + let doc; + + // capture XSDoc + comments.forEach((comment) => { + // skip non-block comments + if (comment.type !== 'Block') { return; } + try { + doc = doctrine.parse(comment.value, { unwrap: true }); + } catch (err) { + /* don't care, for now? maybe add to `errors?` */ + } + }); + + return doc; +} + +/** + * parse TomDoc section from comments + */ +function captureTomDoc(comments) { + // collect lines up to first paragraph break + const lines = []; + for (let i = 0; i < comments.length; i++) { + const comment = comments[i]; + if (comment.value.match(/^\s*$/)) { break; } + lines.push(comment.value.trim()); + } + + // return doctrine-like object + const statusMatch = lines.join(' ').match(/^(Public|Internal|Deprecated):\s*(.+)/); + if (statusMatch) { + return { + description: statusMatch[2], + tags: [{ + title: statusMatch[1].toLowerCase(), + description: statusMatch[2], + }], + }; + } +} + +export const availableDocStyleParsers = { + jsdoc: captureJsDoc, + tomdoc: captureTomDoc, +}; diff --git a/src/exportMap/index.js b/src/exportMap/index.js new file mode 100644 index 0000000000..e4d61638c5 --- /dev/null +++ b/src/exportMap/index.js @@ -0,0 +1,178 @@ +export default class ExportMap { + constructor(path) { + this.path = path; + this.namespace = new Map(); + // todo: restructure to key on path, value is resolver + map of names + this.reexports = new Map(); + /** + * star-exports + * @type {Set<() => ExportMap>} + */ + this.dependencies = new Set(); + /** + * dependencies of this module that are not explicitly re-exported + * @type {Map ExportMap>} + */ + this.imports = new Map(); + this.errors = []; + /** + * type {'ambiguous' | 'Module' | 'Script'} + */ + this.parseGoal = 'ambiguous'; + } + + get hasDefault() { return this.get('default') != null; } // stronger than this.has + + get size() { + let size = this.namespace.size + this.reexports.size; + this.dependencies.forEach((dep) => { + const d = dep(); + // CJS / ignored dependencies won't exist (#717) + if (d == null) { return; } + size += d.size; + }); + return size; + } + + /** + * Note that this does not check explicitly re-exported names for existence + * in the base namespace, but it will expand all `export * from '...'` exports + * if not found in the explicit namespace. + * @param {string} name + * @return {boolean} true if `name` is exported by this module. + */ + has(name) { + if (this.namespace.has(name)) { return true; } + if (this.reexports.has(name)) { return true; } + + // default exports must be explicitly re-exported (#328) + if (name !== 'default') { + for (const dep of this.dependencies) { + const innerMap = dep(); + + // todo: report as unresolved? + if (!innerMap) { continue; } + + if (innerMap.has(name)) { return true; } + } + } + + return false; + } + + /** + * ensure that imported name fully resolves. + * @param {string} name + * @return {{ found: boolean, path: ExportMap[] }} + */ + hasDeep(name) { + if (this.namespace.has(name)) { return { found: true, path: [this] }; } + + if (this.reexports.has(name)) { + const reexports = this.reexports.get(name); + const imported = reexports.getImport(); + + // if import is ignored, return explicit 'null' + if (imported == null) { return { found: true, path: [this] }; } + + // safeguard against cycles, only if name matches + if (imported.path === this.path && reexports.local === name) { + return { found: false, path: [this] }; + } + + const deep = imported.hasDeep(reexports.local); + deep.path.unshift(this); + + return deep; + } + + // default exports must be explicitly re-exported (#328) + if (name !== 'default') { + for (const dep of this.dependencies) { + const innerMap = dep(); + if (innerMap == null) { return { found: true, path: [this] }; } + // todo: report as unresolved? + if (!innerMap) { continue; } + + // safeguard against cycles + if (innerMap.path === this.path) { continue; } + + const innerValue = innerMap.hasDeep(name); + if (innerValue.found) { + innerValue.path.unshift(this); + return innerValue; + } + } + } + + return { found: false, path: [this] }; + } + + get(name) { + if (this.namespace.has(name)) { return this.namespace.get(name); } + + if (this.reexports.has(name)) { + const reexports = this.reexports.get(name); + const imported = reexports.getImport(); + + // if import is ignored, return explicit 'null' + if (imported == null) { return null; } + + // safeguard against cycles, only if name matches + if (imported.path === this.path && reexports.local === name) { return undefined; } + + return imported.get(reexports.local); + } + + // default exports must be explicitly re-exported (#328) + if (name !== 'default') { + for (const dep of this.dependencies) { + const innerMap = dep(); + // todo: report as unresolved? + if (!innerMap) { continue; } + + // safeguard against cycles + if (innerMap.path === this.path) { continue; } + + const innerValue = innerMap.get(name); + if (innerValue !== undefined) { return innerValue; } + } + } + + return undefined; + } + + forEach(callback, thisArg) { + this.namespace.forEach((v, n) => { callback.call(thisArg, v, n, this); }); + + this.reexports.forEach((reexports, name) => { + const reexported = reexports.getImport(); + // can't look up meta for ignored re-exports (#348) + callback.call(thisArg, reexported && reexported.get(reexports.local), name, this); + }); + + this.dependencies.forEach((dep) => { + const d = dep(); + // CJS / ignored dependencies won't exist (#717) + if (d == null) { return; } + + d.forEach((v, n) => { + if (n !== 'default') { + callback.call(thisArg, v, n, this); + } + }); + }); + } + + // todo: keys, values, entries? + + reportErrors(context, declaration) { + const msg = this.errors + .map((e) => `${e.message} (${e.lineNumber}:${e.column})`) + .join(', '); + context.report({ + node: declaration.source, + message: `Parse errors in imported module '${declaration.source.value}': ${msg}`, + }); + } +} diff --git a/src/exportMap/namespace.js b/src/exportMap/namespace.js new file mode 100644 index 0000000000..370f47579d --- /dev/null +++ b/src/exportMap/namespace.js @@ -0,0 +1,39 @@ +import childContext from './childContext'; +import { RemotePath } from './remotePath'; + +export default class Namespace { + constructor( + path, + context, + ExportMapBuilder, + ) { + this.remotePathResolver = new RemotePath(path, context); + this.context = context; + this.ExportMapBuilder = ExportMapBuilder; + this.namespaces = new Map(); + } + + resolveImport(value) { + const rp = this.remotePathResolver.resolve(value); + if (rp == null) { return null; } + return this.ExportMapBuilder.for(childContext(rp, this.context)); + } + + getNamespace(identifier) { + if (!this.namespaces.has(identifier.name)) { return; } + return () => this.resolveImport(this.namespaces.get(identifier.name)); + } + + add(object, identifier) { + const nsfn = this.getNamespace(identifier); + if (nsfn) { + Object.defineProperty(object, 'namespace', { get: nsfn }); + } + + return object; + } + + rawSet(name, value) { + this.namespaces.set(name, value); + } +} diff --git a/src/exportMap/patternCapture.js b/src/exportMap/patternCapture.js new file mode 100644 index 0000000000..5bc9806417 --- /dev/null +++ b/src/exportMap/patternCapture.js @@ -0,0 +1,40 @@ +/** + * Traverse a pattern/identifier node, calling 'callback' + * for each leaf identifier. + * @param {node} pattern + * @param {Function} callback + * @return {void} + */ +export default function recursivePatternCapture(pattern, callback) { + switch (pattern.type) { + case 'Identifier': // base case + callback(pattern); + break; + + case 'ObjectPattern': + pattern.properties.forEach((p) => { + if (p.type === 'ExperimentalRestProperty' || p.type === 'RestElement') { + callback(p.argument); + return; + } + recursivePatternCapture(p.value, callback); + }); + break; + + case 'ArrayPattern': + pattern.elements.forEach((element) => { + if (element == null) { return; } + if (element.type === 'ExperimentalRestProperty' || element.type === 'RestElement') { + callback(element.argument); + return; + } + recursivePatternCapture(element, callback); + }); + break; + + case 'AssignmentPattern': + callback(pattern.left); + break; + default: + } +} diff --git a/src/exportMap/remotePath.js b/src/exportMap/remotePath.js new file mode 100644 index 0000000000..0dc5fc0954 --- /dev/null +++ b/src/exportMap/remotePath.js @@ -0,0 +1,12 @@ +import resolve from 'eslint-module-utils/resolve'; + +export class RemotePath { + constructor(path, context) { + this.path = path; + this.context = context; + } + + resolve(value) { + return resolve.relative(value, this.path, this.context.settings); + } +} diff --git a/src/exportMap/specifier.js b/src/exportMap/specifier.js new file mode 100644 index 0000000000..dfaaf618e4 --- /dev/null +++ b/src/exportMap/specifier.js @@ -0,0 +1,32 @@ +export default function processSpecifier(specifier, astNode, exportMap, namespace) { + const nsource = astNode.source && astNode.source.value; + const exportMeta = {}; + let local; + + switch (specifier.type) { + case 'ExportDefaultSpecifier': + if (!nsource) { return; } + local = 'default'; + break; + case 'ExportNamespaceSpecifier': + exportMap.namespace.set(specifier.exported.name, Object.defineProperty(exportMeta, 'namespace', { + get() { return namespace.resolveImport(nsource); }, + })); + return; + case 'ExportAllDeclaration': + exportMap.namespace.set(specifier.exported.name || specifier.exported.value, namespace.add(exportMeta, specifier.source.value)); + return; + case 'ExportSpecifier': + if (!astNode.source) { + exportMap.namespace.set(specifier.exported.name || specifier.exported.value, namespace.add(exportMeta, specifier.local)); + return; + } + // else falls through + default: + local = specifier.local.name; + break; + } + + // todo: JSDoc + exportMap.reexports.set(specifier.exported.name, { local, getImport: () => namespace.resolveImport(nsource) }); +} diff --git a/src/exportMap/typescript.js b/src/exportMap/typescript.js new file mode 100644 index 0000000000..7db4356da8 --- /dev/null +++ b/src/exportMap/typescript.js @@ -0,0 +1,43 @@ +import { dirname } from 'path'; +import { tsConfigLoader } from 'tsconfig-paths/lib/tsconfig-loader'; +import { hashObject } from 'eslint-module-utils/hash'; + +let ts; +const tsconfigCache = new Map(); + +function readTsConfig(context) { + const tsconfigInfo = tsConfigLoader({ + cwd: context.parserOptions && context.parserOptions.tsconfigRootDir || process.cwd(), + getEnv: (key) => process.env[key], + }); + try { + if (tsconfigInfo.tsConfigPath !== undefined) { + // Projects not using TypeScript won't have `typescript` installed. + if (!ts) { ts = require('typescript'); } // eslint-disable-line import/no-extraneous-dependencies + + const configFile = ts.readConfigFile(tsconfigInfo.tsConfigPath, ts.sys.readFile); + return ts.parseJsonConfigFileContent( + configFile.config, + ts.sys, + dirname(tsconfigInfo.tsConfigPath), + ); + } + } catch (e) { + // Catch any errors + } + + return null; +} + +export function isEsModuleInterop(context) { + const cacheKey = hashObject({ + tsconfigRootDir: context.parserOptions && context.parserOptions.tsconfigRootDir, + }).digest('hex'); + let tsConfig = tsconfigCache.get(cacheKey); + if (typeof tsConfig === 'undefined') { + tsConfig = readTsConfig(context); + tsconfigCache.set(cacheKey, tsConfig); + } + + return tsConfig && tsConfig.options ? tsConfig.options.esModuleInterop : false; +} diff --git a/src/exportMap/visitor.js b/src/exportMap/visitor.js new file mode 100644 index 0000000000..21c1a7c644 --- /dev/null +++ b/src/exportMap/visitor.js @@ -0,0 +1,171 @@ +import includes from 'array-includes'; +import { SourceCode } from 'eslint'; +import { availableDocStyleParsers, captureDoc } from './doc'; +import Namespace from './namespace'; +import processSpecifier from './specifier'; +import { captureDependency, captureDependencyWithSpecifiers } from './captureDependency'; +import recursivePatternCapture from './patternCapture'; +import { RemotePath } from './remotePath'; + +/** + * sometimes legacy support isn't _that_ hard... right? + */ +function makeSourceCode(text, ast) { + if (SourceCode.length > 1) { + // ESLint 3 + return new SourceCode(text, ast); + } else { + // ESLint 4, 5 + return new SourceCode({ text, ast }); + } +} + +export default class ImportExportVisitorBuilder { + constructor( + path, + context, + exportMap, + ExportMapBuilder, + content, + ast, + isEsModuleInteropTrue, + thunkFor, + ) { + this.context = context; + this.namespace = new Namespace(path, context, ExportMapBuilder); + this.remotePathResolver = new RemotePath(path, context); + this.source = makeSourceCode(content, ast); + this.exportMap = exportMap; + this.ast = ast; + this.isEsModuleInteropTrue = isEsModuleInteropTrue; + this.thunkFor = thunkFor; + const docstyle = this.context.settings && this.context.settings['import/docstyle'] || ['jsdoc']; + this.docStyleParsers = {}; + docstyle.forEach((style) => { + this.docStyleParsers[style] = availableDocStyleParsers[style]; + }); + } + + build(astNode) { + return { + ExportDefaultDeclaration() { + const exportMeta = captureDoc(this.source, this.docStyleParsers, astNode); + if (astNode.declaration.type === 'Identifier') { + this.namespace.add(exportMeta, astNode.declaration); + } + this.exportMap.namespace.set('default', exportMeta); + }, + ExportAllDeclaration() { + const getter = captureDependency(astNode, astNode.exportKind === 'type', this.remotePathResolver, this.exportMap, this.context, this.thunkFor); + if (getter) { this.exportMap.dependencies.add(getter); } + if (astNode.exported) { + processSpecifier(astNode, astNode.exported, this.exportMap, this.namespace); + } + }, + /** capture namespaces in case of later export */ + ImportDeclaration() { + captureDependencyWithSpecifiers(astNode, this.remotePathResolver, this.exportMap, this.context, this.thunkFor); + const ns = astNode.specifiers.find((s) => s.type === 'ImportNamespaceSpecifier'); + if (ns) { + this.namespace.rawSet(ns.local.name, astNode.source.value); + } + }, + ExportNamedDeclaration() { + captureDependencyWithSpecifiers(astNode, this.remotePathResolver, this.exportMap, this.context, this.thunkFor); + // capture declaration + if (astNode.declaration != null) { + switch (astNode.declaration.type) { + case 'FunctionDeclaration': + case 'ClassDeclaration': + case 'TypeAlias': // flowtype with babel-eslint parser + case 'InterfaceDeclaration': + case 'DeclareFunction': + case 'TSDeclareFunction': + case 'TSEnumDeclaration': + case 'TSTypeAliasDeclaration': + case 'TSInterfaceDeclaration': + case 'TSAbstractClassDeclaration': + case 'TSModuleDeclaration': + this.exportMap.namespace.set(astNode.declaration.id.name, captureDoc(this.source, this.docStyleParsers, astNode)); + break; + case 'VariableDeclaration': + astNode.declaration.declarations.forEach((d) => { + recursivePatternCapture( + d.id, + (id) => this.exportMap.namespace.set(id.name, captureDoc(this.source, this.docStyleParsers, d, astNode)), + ); + }); + break; + default: + } + } + astNode.specifiers.forEach((s) => processSpecifier(s, astNode, this.exportMap, this.namespace)); + }, + TSExportAssignment: () => this.typeScriptExport(astNode), + ...this.isEsModuleInteropTrue && { TSNamespaceExportDeclaration: () => this.typeScriptExport(astNode) }, + }; + } + + // This doesn't declare anything, but changes what's being exported. + typeScriptExport(astNode) { + const exportedName = astNode.type === 'TSNamespaceExportDeclaration' + ? (astNode.id || astNode.name).name + : astNode.expression && astNode.expression.name || astNode.expression.id && astNode.expression.id.name || null; + const declTypes = [ + 'VariableDeclaration', + 'ClassDeclaration', + 'TSDeclareFunction', + 'TSEnumDeclaration', + 'TSTypeAliasDeclaration', + 'TSInterfaceDeclaration', + 'TSAbstractClassDeclaration', + 'TSModuleDeclaration', + ]; + const exportedDecls = this.ast.body.filter(({ type, id, declarations }) => includes(declTypes, type) && ( + id && id.name === exportedName || declarations && declarations.find((d) => d.id.name === exportedName) + )); + if (exportedDecls.length === 0) { + // Export is not referencing any local declaration, must be re-exporting + this.exportMap.namespace.set('default', captureDoc(this.source, this.docStyleParsers, astNode)); + return; + } + if ( + this.isEsModuleInteropTrue // esModuleInterop is on in tsconfig + && !this.exportMap.namespace.has('default') // and default isn't added already + ) { + this.exportMap.namespace.set('default', {}); // add default export + } + exportedDecls.forEach((decl) => { + if (decl.type === 'TSModuleDeclaration') { + if (decl.body && decl.body.type === 'TSModuleDeclaration') { + this.exportMap.namespace.set(decl.body.id.name, captureDoc(this.source, this.docStyleParsers, decl.body)); + } else if (decl.body && decl.body.body) { + decl.body.body.forEach((moduleBlockNode) => { + // Export-assignment exports all members in the namespace, + // explicitly exported or not. + const namespaceDecl = moduleBlockNode.type === 'ExportNamedDeclaration' + ? moduleBlockNode.declaration + : moduleBlockNode; + + if (!namespaceDecl) { + // TypeScript can check this for us; we needn't + } else if (namespaceDecl.type === 'VariableDeclaration') { + namespaceDecl.declarations.forEach((d) => recursivePatternCapture(d.id, (id) => this.exportMap.namespace.set( + id.name, + captureDoc(this.source, this.docStyleParsers, decl, namespaceDecl, moduleBlockNode), + )), + ); + } else { + this.exportMap.namespace.set( + namespaceDecl.id.name, + captureDoc(this.source, this.docStyleParsers, moduleBlockNode)); + } + }); + } + } else { + // Export as default + this.exportMap.namespace.set('default', captureDoc(this.source, this.docStyleParsers, decl)); + } + }); + } +} diff --git a/src/importDeclaration.js b/src/importDeclaration.js index 0d5e1870a7..49446b2603 100644 --- a/src/importDeclaration.js +++ b/src/importDeclaration.js @@ -1,4 +1,6 @@ -export default function importDeclaration(context) { - const ancestors = context.getAncestors(); +import { getAncestors } from 'eslint-module-utils/contextCompat'; + +export default function importDeclaration(context, node) { + const ancestors = getAncestors(context, node); return ancestors[ancestors.length - 1]; } diff --git a/src/index.js b/src/index.js index feafba9003..6cd7bffe2f 100644 --- a/src/index.js +++ b/src/index.js @@ -1,3 +1,5 @@ +import { name, version } from '../package.json'; + export const rules = { 'no-unresolved': require('./rules/no-unresolved'), named: require('./rules/named'), @@ -43,6 +45,7 @@ export const rules = { 'dynamic-import-chunkname': require('./rules/dynamic-import-chunkname'), 'no-import-module-exports': require('./rules/no-import-module-exports'), 'no-empty-named-blocks': require('./rules/no-empty-named-blocks'), + 'enforce-node-protocol-usage': require('./rules/enforce-node-protocol-usage'), // export 'exports-last': require('./rules/exports-last'), @@ -69,3 +72,32 @@ export const configs = { electron: require('../config/electron'), typescript: require('../config/typescript'), }; + +// Base Plugin Object +const importPlugin = { + meta: { name, version }, + rules, +}; + +// Create flat configs (Only ones that declare plugins and parser options need to be different from the legacy config) +const createFlatConfig = (baseConfig, configName) => ({ + ...baseConfig, + name: `import/${configName}`, + plugins: { import: importPlugin }, +}); + +export const flatConfigs = { + recommended: createFlatConfig( + require('../config/flat/recommended'), + 'recommended', + ), + + errors: createFlatConfig(require('../config/flat/errors'), 'errors'), + warnings: createFlatConfig(require('../config/flat/warnings'), 'warnings'), + + // useful stuff for folks using various environments + react: createFlatConfig(require('../config/flat/react'), 'react'), + 'react-native': createFlatConfig(configs['react-native'], 'react-native'), + electron: createFlatConfig(configs.electron, 'electron'), + typescript: createFlatConfig(configs.typescript, 'typescript'), +}; diff --git a/src/rules/consistent-type-specifier-style.js b/src/rules/consistent-type-specifier-style.js index 9119976b19..84c33ecd81 100644 --- a/src/rules/consistent-type-specifier-style.js +++ b/src/rules/consistent-type-specifier-style.js @@ -1,9 +1,17 @@ +import { getSourceCode } from 'eslint-module-utils/contextCompat'; + import docsUrl from '../docsUrl'; function isComma(token) { return token.type === 'Punctuator' && token.value === ','; } +/** + * @param {import('eslint').Rule.Fix[]} fixes + * @param {import('eslint').Rule.RuleFixer} fixer + * @param {import('eslint').SourceCode.SourceCode} sourceCode + * @param {(ImportSpecifier | ImportDefaultSpecifier | ImportNamespaceSpecifier)[]} specifiers + * */ function removeSpecifiers(fixes, fixer, sourceCode, specifiers) { for (const specifier of specifiers) { // remove the trailing comma @@ -15,6 +23,7 @@ function removeSpecifiers(fixes, fixer, sourceCode, specifiers) { } } +/** @type {(node: import('estree').Node, sourceCode: import('eslint').SourceCode.SourceCode, specifiers: (ImportSpecifier | ImportNamespaceSpecifier)[], kind: 'type' | 'typeof') => string} */ function getImportText( node, sourceCode, @@ -36,6 +45,7 @@ function getImportText( return `import ${kind} {${names.join(', ')}} from ${sourceString};`; } +/** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { type: 'suggestion', @@ -55,7 +65,7 @@ module.exports = { }, create(context) { - const sourceCode = context.getSourceCode(); + const sourceCode = getSourceCode(context); if (context.options[0] === 'prefer-inline') { return { @@ -100,6 +110,7 @@ module.exports = { // prefer-top-level return { + /** @param {import('estree').ImportDeclaration} node */ ImportDeclaration(node) { if ( // already top-level is valid @@ -118,9 +129,13 @@ module.exports = { return; } + /** @type {typeof node.specifiers} */ const typeSpecifiers = []; + /** @type {typeof node.specifiers} */ const typeofSpecifiers = []; + /** @type {typeof node.specifiers} */ const valueSpecifiers = []; + /** @type {typeof node.specifiers[number]} */ let defaultSpecifier = null; for (const specifier of node.specifiers) { if (specifier.type === 'ImportDefaultSpecifier') { @@ -142,6 +157,7 @@ module.exports = { const newImports = `${typeImport}\n${typeofImport}`.trim(); if (typeSpecifiers.length + typeofSpecifiers.length === node.specifiers.length) { + /** @type {('type' | 'typeof')[]} */ // all specifiers have inline specifiers - so we replace the entire import const kind = [].concat( typeSpecifiers.length > 0 ? 'type' : [], @@ -160,7 +176,7 @@ module.exports = { }); } else { // remove specific specifiers and insert new imports for them - for (const specifier of typeSpecifiers.concat(typeofSpecifiers)) { + typeSpecifiers.concat(typeofSpecifiers).forEach((specifier) => { context.report({ node: specifier, message: 'Prefer using a top-level {{kind}}-only import instead of inline {{kind}} specifiers.', @@ -168,6 +184,7 @@ module.exports = { kind: specifier.importKind, }, fix(fixer) { + /** @type {import('eslint').Rule.Fix[]} */ const fixes = []; // if there are no value specifiers, then the other report fixer will be called, not this one @@ -213,7 +230,7 @@ module.exports = { ); }, }); - } + }); } }, }; diff --git a/src/rules/default.js b/src/rules/default.js index 297a80c463..0de787c33c 100644 --- a/src/rules/default.js +++ b/src/rules/default.js @@ -1,4 +1,4 @@ -import Exports from '../ExportMap'; +import ExportMapBuilder from '../exportMap/builder'; import docsUrl from '../docsUrl'; module.exports = { @@ -19,7 +19,7 @@ module.exports = { ); if (!defaultSpecifier) { return; } - const imports = Exports.get(node.source.value, context); + const imports = ExportMapBuilder.get(node.source.value, context); if (imports == null) { return; } if (imports.errors.length) { diff --git a/src/rules/dynamic-import-chunkname.js b/src/rules/dynamic-import-chunkname.js index 96ceff2e16..12a7650082 100644 --- a/src/rules/dynamic-import-chunkname.js +++ b/src/rules/dynamic-import-chunkname.js @@ -1,4 +1,6 @@ +import { getSourceCode } from 'eslint-module-utils/contextCompat'; import vm from 'vm'; + import docsUrl from '../docsUrl'; module.exports = { @@ -19,30 +21,36 @@ module.exports = { type: 'string', }, }, + allowEmpty: { + type: 'boolean', + }, webpackChunknameFormat: { type: 'string', }, }, }], + hasSuggestions: true, }, create(context) { const config = context.options[0]; - const { importFunctions = [] } = config || {}; + const { importFunctions = [], allowEmpty = false } = config || {}; const { webpackChunknameFormat = '([0-9a-zA-Z-_/.]|\\[(request|index)\\])+' } = config || {}; const paddedCommentRegex = /^ (\S[\s\S]+\S) $/; const commentStyleRegex = /^( ((webpackChunkName: .+)|((webpackPrefetch|webpackPreload): (true|false|-?[0-9]+))|(webpackIgnore: (true|false))|((webpackInclude|webpackExclude): \/.*\/)|(webpackMode: ["'](lazy|lazy-once|eager|weak)["'])|(webpackExports: (['"]\w+['"]|\[(['"]\w+['"], *)+(['"]\w+['"]*)\]))),?)+ $/; - const chunkSubstrFormat = ` webpackChunkName: ["']${webpackChunknameFormat}["'],? `; + const chunkSubstrFormat = `webpackChunkName: ["']${webpackChunknameFormat}["'],? `; const chunkSubstrRegex = new RegExp(chunkSubstrFormat); + const eagerModeFormat = `webpackMode: ["']eager["'],? `; + const eagerModeRegex = new RegExp(eagerModeFormat); function run(node, arg) { - const sourceCode = context.getSourceCode(); + const sourceCode = getSourceCode(context); const leadingComments = sourceCode.getCommentsBefore ? sourceCode.getCommentsBefore(arg) // This method is available in ESLint >= 4. : sourceCode.getComments(arg).leading; // This method is deprecated in ESLint 7. - if (!leadingComments || leadingComments.length === 0) { + if ((!leadingComments || leadingComments.length === 0) && !allowEmpty) { context.report({ node, message: 'dynamic imports require a leading comment with the webpack chunkname', @@ -51,6 +59,7 @@ module.exports = { } let isChunknamePresent = false; + let isEagerModePresent = false; for (const comment of leadingComments) { if (comment.type !== 'Block') { @@ -89,12 +98,55 @@ module.exports = { return; } + if (eagerModeRegex.test(comment.value)) { + isEagerModePresent = true; + } + if (chunkSubstrRegex.test(comment.value)) { isChunknamePresent = true; } } - if (!isChunknamePresent) { + if (isChunknamePresent && isEagerModePresent) { + context.report({ + node, + message: 'dynamic imports using eager mode do not need a webpackChunkName', + suggest: [ + { + desc: 'Remove webpackChunkName', + fix(fixer) { + for (const comment of leadingComments) { + if (chunkSubstrRegex.test(comment.value)) { + const replacement = comment.value.replace(chunkSubstrRegex, '').trim().replace(/,$/, ''); + if (replacement === '') { + return fixer.remove(comment); + } else { + return fixer.replaceText(comment, `/* ${replacement} */`); + } + } + } + }, + }, + { + desc: 'Remove webpackMode', + fix(fixer) { + for (const comment of leadingComments) { + if (eagerModeRegex.test(comment.value)) { + const replacement = comment.value.replace(eagerModeRegex, '').trim().replace(/,$/, ''); + if (replacement === '') { + return fixer.remove(comment); + } else { + return fixer.replaceText(comment, `/* ${replacement} */`); + } + } + } + }, + }, + ], + }); + } + + if (!isChunknamePresent && !allowEmpty && !isEagerModePresent) { context.report({ node, message: diff --git a/src/rules/enforce-node-protocol-usage.js b/src/rules/enforce-node-protocol-usage.js new file mode 100644 index 0000000000..603f740586 --- /dev/null +++ b/src/rules/enforce-node-protocol-usage.js @@ -0,0 +1,147 @@ +'use strict'; + +const isCoreModule = require('is-core-module'); +const { default: docsUrl } = require('../docsUrl'); + +const DO_PREFER_MESSAGE_ID = 'requireNodeProtocol'; +const NEVER_PREFER_MESSAGE_ID = 'forbidNodeProtocol'; +const messages = { + [DO_PREFER_MESSAGE_ID]: 'Prefer `node:{{moduleName}}` over `{{moduleName}}`.', + [NEVER_PREFER_MESSAGE_ID]: 'Prefer `{{moduleName}}` over `node:{{moduleName}}`.', +}; + +function replaceStringLiteral( + fixer, + node, + text, + relativeRangeStart, + relativeRangeEnd, +) { + const firstCharacterIndex = node.range[0] + 1; + const start = Number.isInteger(relativeRangeEnd) + ? relativeRangeStart + firstCharacterIndex + : firstCharacterIndex; + const end = Number.isInteger(relativeRangeEnd) + ? relativeRangeEnd + firstCharacterIndex + : node.range[1] - 1; + + return fixer.replaceTextRange([start, end], text); +} + +function isStringLiteral(node) { + return node && node.type === 'Literal' && typeof node.value === 'string'; +} + +function isStaticRequireWith1Param(node) { + return !node.optional + && node.callee.type === 'Identifier' + && node.callee.name === 'require' + // check for only 1 argument + && node.arguments.length === 1 + && node.arguments[0] + && isStringLiteral(node.arguments[0]); +} + +function checkAndReport(src, context) { + // TODO use src.quasis[0].value.raw + if (!src || src.type === 'TemplateLiteral') { return; } + const moduleName = 'value' in src ? src.value : src.name; + if (typeof moduleName !== 'string') { console.log(src, moduleName); } + const { settings } = context; + const nodeVersion = settings && settings['import/node-version']; + if ( + typeof nodeVersion !== 'undefined' + && ( + typeof nodeVersion !== 'string' + || !(/^[0-9]+\.[0-9]+\.[0-9]+$/).test(nodeVersion) + ) + ) { + throw new TypeError('`import/node-version` setting must be a string in the format "10.23.45" (a semver version, with no leading zero)'); + } + + if (context.options[0] === 'never') { + if (!moduleName.startsWith('node:')) { return; } + + const actualModuleName = moduleName.slice(5); + if (!isCoreModule(actualModuleName, nodeVersion || undefined)) { return; } + + context.report({ + node: src, + message: messages[NEVER_PREFER_MESSAGE_ID], + data: { moduleName: actualModuleName }, + /** @param {import('eslint').Rule.RuleFixer} fixer */ + fix(fixer) { + return replaceStringLiteral(fixer, src, '', 0, 5); + }, + }); + } else if (context.options[0] === 'always') { + if ( + moduleName.startsWith('node:') + || !isCoreModule(moduleName, nodeVersion || undefined) + || !isCoreModule(`node:${moduleName}`, nodeVersion || undefined) + ) { + return; + } + + context.report({ + node: src, + message: messages[DO_PREFER_MESSAGE_ID], + data: { moduleName }, + /** @param {import('eslint').Rule.RuleFixer} fixer */ + fix(fixer) { + return replaceStringLiteral(fixer, src, 'node:', 0, 0); + }, + }); + } else if (typeof context.options[0] === 'undefined') { + throw new Error('Missing option'); + } else { + throw new Error(`Unexpected option: ${context.options[0]}`); + } +} + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: 'Enforce either using, or omitting, the `node:` protocol when importing Node.js builtin modules.', + recommended: true, + category: 'Static analysis', + url: docsUrl('enforce-node-protocol-usage'), + }, + fixable: 'code', + schema: { + type: 'array', + minItems: 1, + maxItems: 1, + items: [ + { + enum: ['always', 'never'], + }, + ], + }, + messages, + }, + create(context) { + return { + CallExpression(node) { + if (!isStaticRequireWith1Param(node)) { return; } + + const arg = node.arguments[0]; + + return checkAndReport(arg, context); + }, + ExportNamedDeclaration(node) { + return checkAndReport(node.source, context); + }, + ImportDeclaration(node) { + return checkAndReport(node.source, context); + }, + ImportExpression(node) { + if (!isStringLiteral(node.source)) { return; } + + return checkAndReport(node.source, context); + }, + }; + }, +}; diff --git a/src/rules/export.js b/src/rules/export.js index c540f1e3c9..fbbc39d75f 100644 --- a/src/rules/export.js +++ b/src/rules/export.js @@ -1,7 +1,7 @@ -import ExportMap, { recursivePatternCapture } from '../ExportMap'; +import ExportMapBuilder from '../exportMap/builder'; +import recursivePatternCapture from '../exportMap/patternCapture'; import docsUrl from '../docsUrl'; import includes from 'array-includes'; -import flatMap from 'array.prototype.flatmap'; /* Notes on TypeScript namespaces aka TSModuleDeclaration: @@ -26,42 +26,25 @@ const rootProgram = 'root'; const tsTypePrefix = 'type:'; /** - * Detect function overloads like: + * remove function overloads like: * ```ts * export function foo(a: number); * export function foo(a: string); - * export function foo(a: number|string) { return a; } * ``` * @param {Set} nodes - * @returns {boolean} */ -function isTypescriptFunctionOverloads(nodes) { - const nodesArr = Array.from(nodes); - - const idents = flatMap( - nodesArr, - (node) => node.declaration && ( - node.declaration.type === 'TSDeclareFunction' // eslint 6+ - || node.declaration.type === 'TSEmptyBodyFunctionDeclaration' // eslint 4-5 - ) - ? node.declaration.id.name - : [], - ); - if (new Set(idents).size !== idents.length) { - return true; - } - - const types = new Set(nodesArr.map((node) => node.parent.type)); - if (!types.has('TSDeclareFunction')) { - return false; - } - if (types.size === 1) { - return true; - } - if (types.size === 2 && types.has('FunctionDeclaration')) { - return true; - } - return false; +function removeTypescriptFunctionOverloads(nodes) { + nodes.forEach((node) => { + const declType = node.type === 'ExportDefaultDeclaration' ? node.declaration.type : node.parent.type; + if ( + // eslint 6+ + declType === 'TSDeclareFunction' + // eslint 4-5 + || declType === 'TSEmptyBodyFunctionDeclaration' + ) { + nodes.delete(node); + } + }); } /** @@ -197,7 +180,7 @@ module.exports = { // `export * as X from 'path'` does not conflict if (node.exported && node.exported.name) { return; } - const remoteExports = ExportMap.get(node.source.value, context); + const remoteExports = ExportMapBuilder.get(node.source.value, context); if (remoteExports == null) { return; } if (remoteExports.errors.length) { @@ -226,9 +209,11 @@ module.exports = { 'Program:exit'() { for (const [, named] of namespace) { for (const [name, nodes] of named) { + removeTypescriptFunctionOverloads(nodes); + if (nodes.size <= 1) { continue; } - if (isTypescriptFunctionOverloads(nodes) || isTypescriptNamespaceMerging(nodes)) { continue; } + if (isTypescriptNamespaceMerging(nodes)) { continue; } for (const node of nodes) { if (shouldSkipTypescriptNamespace(node, nodes)) { continue; } diff --git a/src/rules/extensions.js b/src/rules/extensions.js index b1e5c6d9f1..2aeef64758 100644 --- a/src/rules/extensions.js +++ b/src/rules/extensions.js @@ -1,5 +1,6 @@ import path from 'path'; +import minimatch from 'minimatch'; import resolve from 'eslint-module-utils/resolve'; import { isBuiltIn, isExternalModule, isScoped } from '../core/importType'; import moduleVisitor from 'eslint-module-utils/moduleVisitor'; @@ -14,7 +15,28 @@ const properties = { type: 'object', properties: { pattern: patternProperties, + checkTypeImports: { type: 'boolean' }, ignorePackages: { type: 'boolean' }, + pathGroupOverrides: { + type: 'array', + items: { + type: 'object', + properties: { + pattern: { + type: 'string', + }, + patternOptions: { + type: 'object', + }, + action: { + type: 'string', + enum: ['enforce', 'ignore'], + }, + }, + additionalProperties: false, + required: ['pattern', 'action'], + }, + }, }, }; @@ -35,7 +57,7 @@ function buildProperties(context) { } // If this is not the new structure, transfer all props to result.pattern - if (obj.pattern === undefined && obj.ignorePackages === undefined) { + if (obj.pattern === undefined && obj.ignorePackages === undefined && obj.checkTypeImports === undefined) { Object.assign(result.pattern, obj); return; } @@ -49,6 +71,14 @@ function buildProperties(context) { if (obj.ignorePackages !== undefined) { result.ignorePackages = obj.ignorePackages; } + + if (obj.checkTypeImports !== undefined) { + result.checkTypeImports = obj.checkTypeImports; + } + + if (obj.pathGroupOverrides !== undefined) { + result.pathGroupOverrides = obj.pathGroupOverrides; + } }); if (result.defaultConfig === 'ignorePackages') { @@ -138,20 +168,39 @@ module.exports = { return false; } + function computeOverrideAction(pathGroupOverrides, path) { + for (let i = 0, l = pathGroupOverrides.length; i < l; i++) { + const { pattern, patternOptions, action } = pathGroupOverrides[i]; + if (minimatch(path, pattern, patternOptions || { nocomment: true })) { + return action; + } + } + } + function checkFileExtension(source, node) { // bail if the declaration doesn't have a source, e.g. "export { foo };", or if it's only partially typed like in an editor if (!source || !source.value) { return; } const importPathWithQueryString = source.value; + // If not undefined, the user decided if rules are enforced on this import + const overrideAction = computeOverrideAction( + props.pathGroupOverrides || [], + importPathWithQueryString, + ); + + if (overrideAction === 'ignore') { + return; + } + // don't enforce anything on builtins - if (isBuiltIn(importPathWithQueryString, context.settings)) { return; } + if (!overrideAction && isBuiltIn(importPathWithQueryString, context.settings)) { return; } const importPath = importPathWithQueryString.replace(/\?(.*)$/, ''); // don't enforce in root external packages as they may have names with `.js`. // Like `import Decimal from decimal.js`) - if (isExternalRootModule(importPath)) { return; } + if (!overrideAction && isExternalRootModule(importPath)) { return; } const resolvedPath = resolve(importPath, context); @@ -168,8 +217,8 @@ module.exports = { if (!extension || !importPath.endsWith(`.${extension}`)) { // ignore type-only imports and exports - if (node.importKind === 'type' || node.exportKind === 'type') { return; } - const extensionRequired = isUseOfExtensionRequired(extension, isPackage); + if (!props.checkTypeImports && (node.importKind === 'type' || node.exportKind === 'type')) { return; } + const extensionRequired = isUseOfExtensionRequired(extension, !overrideAction && isPackage); const extensionForbidden = isUseOfExtensionForbidden(extension); if (extensionRequired && !extensionForbidden) { context.report({ diff --git a/src/rules/first.js b/src/rules/first.js index f8cc273a31..e7df26ac92 100644 --- a/src/rules/first.js +++ b/src/rules/first.js @@ -1,3 +1,5 @@ +import { getDeclaredVariables, getSourceCode } from 'eslint-module-utils/contextCompat'; + import docsUrl from '../docsUrl'; function getImportValue(node) { @@ -38,7 +40,7 @@ module.exports = { } const absoluteFirst = context.options[0] === 'absolute-first'; const message = 'Import in body of module; reorder to top.'; - const sourceCode = context.getSourceCode(); + const sourceCode = getSourceCode(context); const originSourceCode = sourceCode.getText(); let nonImportCount = 0; let anyExpressions = false; @@ -66,7 +68,7 @@ module.exports = { } } if (nonImportCount > 0) { - for (const variable of context.getDeclaredVariables(node)) { + for (const variable of getDeclaredVariables(context, node)) { if (!shouldSort) { break; } const references = variable.references; if (references.length) { diff --git a/src/rules/named.js b/src/rules/named.js index e7fe4e4dce..ab5f3103f2 100644 --- a/src/rules/named.js +++ b/src/rules/named.js @@ -1,5 +1,7 @@ import * as path from 'path'; -import Exports from '../ExportMap'; +import { getFilename, getPhysicalFilename } from 'eslint-module-utils/contextCompat'; + +import ExportMapBuilder from '../exportMap/builder'; import docsUrl from '../docsUrl'; module.exports = { @@ -41,7 +43,7 @@ module.exports = { return; // no named imports/exports } - const imports = Exports.get(node.source.value, context); + const imports = ExportMapBuilder.get(node.source.value, context); if (imports == null || imports.parseGoal === 'ambiguous') { return; } @@ -67,7 +69,7 @@ module.exports = { if (!deepLookup.found) { if (deepLookup.path.length > 1) { const deepPath = deepLookup.path - .map((i) => path.relative(path.dirname(context.getPhysicalFilename ? context.getPhysicalFilename() : context.getFilename()), i.path)) + .map((i) => path.relative(path.dirname(getPhysicalFilename(context)), i.path)) .join(' -> '); context.report(im[key], `${name} not found via ${deepPath}`); @@ -93,7 +95,7 @@ module.exports = { const call = node.init; const [source] = call.arguments; const variableImports = node.id.properties; - const variableExports = Exports.get(source.value, context); + const variableExports = ExportMapBuilder.get(source.value, context); if ( // return if it's not a commonjs require statement @@ -121,7 +123,7 @@ module.exports = { if (!deepLookup.found) { if (deepLookup.path.length > 1) { const deepPath = deepLookup.path - .map((i) => path.relative(path.dirname(context.getFilename()), i.path)) + .map((i) => path.relative(path.dirname(getFilename(context)), i.path)) .join(' -> '); context.report(im.key, `${im.key.name} not found via ${deepPath}`); diff --git a/src/rules/namespace.js b/src/rules/namespace.js index 77a3ea9077..b2de7f225f 100644 --- a/src/rules/namespace.js +++ b/src/rules/namespace.js @@ -1,5 +1,6 @@ import declaredScope from 'eslint-module-utils/declaredScope'; -import Exports from '../ExportMap'; +import ExportMapBuilder from '../exportMap/builder'; +import ExportMap from '../exportMap'; import importDeclaration from '../importDeclaration'; import docsUrl from '../docsUrl'; @@ -8,7 +9,7 @@ function processBodyStatement(context, namespaces, declaration) { if (declaration.specifiers.length === 0) { return; } - const imports = Exports.get(declaration.source.value, context); + const imports = ExportMapBuilder.get(declaration.source.value, context); if (imports == null) { return null; } if (imports.errors.length > 0) { @@ -86,9 +87,9 @@ module.exports = { // same as above, but does not add names to local map ExportNamespaceSpecifier(namespace) { - const declaration = importDeclaration(context); + const declaration = importDeclaration(context, namespace); - const imports = Exports.get(declaration.source.value, context); + const imports = ExportMapBuilder.get(declaration.source.value, context); if (imports == null) { return null; } if (imports.errors.length) { @@ -109,7 +110,7 @@ module.exports = { MemberExpression(dereference) { if (dereference.object.type !== 'Identifier') { return; } if (!namespaces.has(dereference.object.name)) { return; } - if (declaredScope(context, dereference.object.name) !== 'module') { return; } + if (declaredScope(context, dereference.object.name, dereference) !== 'module') { return; } if (dereference.parent.type === 'AssignmentExpression' && dereference.parent.left === dereference) { context.report( @@ -122,7 +123,7 @@ module.exports = { let namespace = namespaces.get(dereference.object.name); const namepath = [dereference.object.name]; // while property is namespace and parent is member expression, keep validating - while (namespace instanceof Exports && dereference.type === 'MemberExpression') { + while (namespace instanceof ExportMap && dereference.type === 'MemberExpression') { if (dereference.computed) { if (!allowComputed) { context.report( @@ -157,11 +158,11 @@ module.exports = { if (!namespaces.has(init.name)) { return; } // check for redefinition in intermediate scopes - if (declaredScope(context, init.name) !== 'module') { return; } + if (declaredScope(context, init.name, init) !== 'module') { return; } // DFS traverse child namespaces function testKey(pattern, namespace, path = [init.name]) { - if (!(namespace instanceof Exports)) { return; } + if (!(namespace instanceof ExportMap)) { return; } if (pattern.type !== 'ObjectPattern') { return; } diff --git a/src/rules/newline-after-import.js b/src/rules/newline-after-import.js index a33bb615b9..c645d2bc6f 100644 --- a/src/rules/newline-after-import.js +++ b/src/rules/newline-after-import.js @@ -3,6 +3,8 @@ * @author Radek Benkel */ +import { getPhysicalFilename, getScope } from 'eslint-module-utils/contextCompat'; + import isStaticRequire from '../core/staticRequire'; import docsUrl from '../docsUrl'; @@ -124,7 +126,7 @@ module.exports = { } } - function commentAfterImport(node, nextComment) { + function commentAfterImport(node, nextComment, type) { const lineDifference = getLineDifference(node, nextComment); const EXPECTED_LINE_DIFFERENCE = options.count + 1; @@ -140,7 +142,7 @@ module.exports = { line: node.loc.end.line, column, }, - message: `Expected ${options.count} empty line${options.count > 1 ? 's' : ''} after import statement not followed by another import.`, + message: `Expected ${options.count} empty line${options.count > 1 ? 's' : ''} after ${type} statement not followed by another ${type}.`, fix: options.exactCount && EXPECTED_LINE_DIFFERENCE < lineDifference ? undefined : (fixer) => fixer.insertTextAfter( node, '\n'.repeat(EXPECTED_LINE_DIFFERENCE - lineDifference), @@ -178,7 +180,7 @@ module.exports = { } if (nextComment && typeof nextComment !== 'undefined') { - commentAfterImport(node, nextComment); + commentAfterImport(node, nextComment, 'import'); } else if (nextNode && nextNode.type !== 'ImportDeclaration' && (nextNode.type !== 'TSImportEqualsDeclaration' || nextNode.isExport)) { checkForNewLine(node, nextNode, 'import'); } @@ -192,9 +194,9 @@ module.exports = { requireCalls.push(node); } }, - 'Program:exit'() { - log('exit processing for', context.getPhysicalFilename ? context.getPhysicalFilename() : context.getFilename()); - const scopeBody = getScopeBody(context.getScope()); + 'Program:exit'(node) { + log('exit processing for', getPhysicalFilename(context)); + const scopeBody = getScopeBody(getScope(context, node)); log('got scope:', scopeBody); requireCalls.forEach((node, index) => { @@ -215,8 +217,18 @@ module.exports = { || !containsNodeOrEqual(nextStatement, nextRequireCall) ) ) { - - checkForNewLine(statementWithRequireCall, nextStatement, 'require'); + let nextComment; + if (typeof statementWithRequireCall.parent.comments !== 'undefined' && options.considerComments) { + const endLine = node.loc.end.line; + nextComment = statementWithRequireCall.parent.comments.find((o) => o.loc.start.line >= endLine && o.loc.start.line <= endLine + options.count + 1); + } + + if (nextComment && typeof nextComment !== 'undefined') { + + commentAfterImport(statementWithRequireCall, nextComment, 'require'); + } else { + checkForNewLine(statementWithRequireCall, nextStatement, 'require'); + } } }); }, diff --git a/src/rules/no-absolute-path.js b/src/rules/no-absolute-path.js index 04f67383f2..0dbd8cb86c 100644 --- a/src/rules/no-absolute-path.js +++ b/src/rules/no-absolute-path.js @@ -1,5 +1,7 @@ import path from 'path'; +import { getPhysicalFilename } from 'eslint-module-utils/contextCompat'; import moduleVisitor, { makeOptionsSchema } from 'eslint-module-utils/moduleVisitor'; + import { isAbsolute } from '../core/importType'; import docsUrl from '../docsUrl'; @@ -22,9 +24,8 @@ module.exports = { node: source, message: 'Do not import modules using an absolute path', fix(fixer) { - const resolvedContext = context.getPhysicalFilename ? context.getPhysicalFilename() : context.getFilename(); // node.js and web imports work with posix style paths ("/") - let relativePath = path.posix.relative(path.dirname(resolvedContext), source.value); + let relativePath = path.posix.relative(path.dirname(getPhysicalFilename(context)), source.value); if (!relativePath.startsWith('.')) { relativePath = `./${relativePath}`; } diff --git a/src/rules/no-amd.js b/src/rules/no-amd.js index 5edfe3e698..05ed0a5210 100644 --- a/src/rules/no-amd.js +++ b/src/rules/no-amd.js @@ -3,6 +3,8 @@ * @author Jamund Ferguson */ +import { getScope } from 'eslint-module-utils/contextCompat'; + import docsUrl from '../docsUrl'; //------------------------------------------------------------------------------ @@ -23,7 +25,7 @@ module.exports = { create(context) { return { CallExpression(node) { - if (context.getScope().type !== 'module') { return; } + if (getScope(context, node).type !== 'module') { return; } if (node.callee.type !== 'Identifier') { return; } if (node.callee.name !== 'require' && node.callee.name !== 'define') { return; } diff --git a/src/rules/no-commonjs.js b/src/rules/no-commonjs.js index dde509222b..33b77da597 100644 --- a/src/rules/no-commonjs.js +++ b/src/rules/no-commonjs.js @@ -3,6 +3,8 @@ * @author Jamund Ferguson */ +import { getScope } from 'eslint-module-utils/contextCompat'; + import docsUrl from '../docsUrl'; const EXPORT_MESSAGE = 'Expected "export" or "export default"'; @@ -107,7 +109,7 @@ module.exports = { // exports. if (node.object.name === 'exports') { - const isInScope = context.getScope() + const isInScope = getScope(context, node) .variables .some((variable) => variable.name === 'exports'); if (!isInScope) { @@ -117,7 +119,7 @@ module.exports = { }, CallExpression(call) { - if (!validateScope(context.getScope())) { return; } + if (!validateScope(getScope(context, call))) { return; } if (call.callee.type !== 'Identifier') { return; } if (call.callee.name !== 'require') { return; } diff --git a/src/rules/no-cycle.js b/src/rules/no-cycle.js index 5b9d8c0709..713503d9f8 100644 --- a/src/rules/no-cycle.js +++ b/src/rules/no-cycle.js @@ -3,14 +3,21 @@ * @author Ben Mosher */ +import { getPhysicalFilename } from 'eslint-module-utils/contextCompat'; +import moduleVisitor, { makeOptionsSchema } from 'eslint-module-utils/moduleVisitor'; import resolve from 'eslint-module-utils/resolve'; -import Exports from '../ExportMap'; + +import ExportMapBuilder from '../exportMap/builder'; +import StronglyConnectedComponentsBuilder from '../scc'; import { isExternalModule } from '../core/importType'; -import moduleVisitor, { makeOptionsSchema } from 'eslint-module-utils/moduleVisitor'; import docsUrl from '../docsUrl'; const traversed = new Set(); +function routeString(route) { + return route.map((s) => `${s.value}:${s.loc.start.line}`).join('=>'); +} + module.exports = { meta: { type: 'suggestion', @@ -43,11 +50,16 @@ module.exports = { type: 'boolean', default: false, }, + disableScc: { + description: 'When true, don\'t calculate a strongly-connected-components graph. SCC is used to reduce the time-complexity of cycle detection, but adds overhead.', + type: 'boolean', + default: false, + }, })], }, create(context) { - const myPath = context.getPhysicalFilename ? context.getPhysicalFilename() : context.getFilename(); + const myPath = getPhysicalFilename(context); if (myPath === '') { return {}; } // can't cycle-check a non-file const options = context.options[0] || {}; @@ -58,6 +70,8 @@ module.exports = { context, ); + const scc = options.disableScc ? {} : StronglyConnectedComponentsBuilder.get(myPath, context); + function checkSourceValue(sourceNode, importer) { if (ignoreModule(sourceNode.value)) { return; // ignore external modules @@ -84,7 +98,7 @@ module.exports = { return; // ignore type imports } - const imported = Exports.get(sourceNode.value, context); + const imported = ExportMapBuilder.get(sourceNode.value, context); if (imported == null) { return; // no-unresolved territory @@ -94,6 +108,16 @@ module.exports = { return; // no-self-import territory } + /* If we're in the same Strongly Connected Component, + * Then there exists a path from each node in the SCC to every other node in the SCC, + * Then there exists at least one path from them to us and from us to them, + * Then we have a cycle between us. + */ + const hasDependencyCycle = options.disableScc || scc[myPath] === scc[imported.path]; + if (!hasDependencyCycle) { + return; + } + const untraversed = [{ mget: () => imported, route: [] }]; function detectCycle({ mget, route }) { const m = mget(); @@ -102,6 +126,9 @@ module.exports = { traversed.add(m.path); for (const [path, { getter, declarations }] of m.imports) { + // If we're in different SCCs, we can't have a circular dependency + if (!options.disableScc && scc[myPath] !== scc[path]) { continue; } + if (traversed.has(path)) { continue; } const toTraverse = [...declarations].filter(({ source, isOnlyImportingTypes }) => !ignoreModule(source.value) // Ignore only type imports @@ -125,9 +152,9 @@ module.exports = { */ if (path === myPath && toTraverse.length > 0) { return true; } if (route.length + 1 < maxDepth) { - for (const { source } of toTraverse) { + toTraverse.forEach(({ source }) => { untraversed.push({ mget: getter, route: route.concat(source) }); - } + }); } } } @@ -151,7 +178,3 @@ module.exports = { }); }, }; - -function routeString(route) { - return route.map((s) => `${s.value}:${s.loc.start.line}`).join('=>'); -} diff --git a/src/rules/no-default-export.js b/src/rules/no-default-export.js index dabbae543a..d18f0c48f6 100644 --- a/src/rules/no-default-export.js +++ b/src/rules/no-default-export.js @@ -1,4 +1,7 @@ +import { getSourceCode } from 'eslint-module-utils/contextCompat'; + import docsUrl from '../docsUrl'; +import sourceType from '../core/sourceType'; module.exports = { meta: { @@ -13,7 +16,7 @@ module.exports = { create(context) { // ignore non-modules - if (context.parserOptions.sourceType !== 'module') { + if (sourceType(context) !== 'module') { return {}; } @@ -22,7 +25,7 @@ module.exports = { return { ExportDefaultDeclaration(node) { - const { loc } = context.getSourceCode().getFirstTokens(node)[1] || {}; + const { loc } = getSourceCode(context).getFirstTokens(node)[1] || {}; context.report({ node, message: preferNamed, loc }); }, @@ -30,7 +33,7 @@ module.exports = { node.specifiers .filter((specifier) => (specifier.exported.name || specifier.exported.value) === 'default') .forEach((specifier) => { - const { loc } = context.getSourceCode().getFirstTokens(node)[1] || {}; + const { loc } = getSourceCode(context).getFirstTokens(node)[1] || {}; if (specifier.type === 'ExportDefaultSpecifier') { context.report({ node, message: preferNamed, loc }); } else if (specifier.type === 'ExportSpecifier') { diff --git a/src/rules/no-deprecated.js b/src/rules/no-deprecated.js index 06eeff8ea7..9559046b98 100644 --- a/src/rules/no-deprecated.js +++ b/src/rules/no-deprecated.js @@ -1,5 +1,6 @@ import declaredScope from 'eslint-module-utils/declaredScope'; -import Exports from '../ExportMap'; +import ExportMapBuilder from '../exportMap/builder'; +import ExportMap from '../exportMap'; import docsUrl from '../docsUrl'; function message(deprecation) { @@ -31,7 +32,7 @@ module.exports = { if (node.type !== 'ImportDeclaration') { return; } if (node.source == null) { return; } // local export, ignore - const imports = Exports.get(node.source.value, context); + const imports = ExportMapBuilder.get(node.source.value, context); if (imports == null) { return; } const moduleDeprecation = imports.doc && imports.doc.tags.find((t) => t.title === 'deprecated'); @@ -97,7 +98,7 @@ module.exports = { if (!deprecated.has(node.name)) { return; } - if (declaredScope(context, node.name) !== 'module') { return; } + if (declaredScope(context, node.name, node) !== 'module') { return; } context.report({ node, message: message(deprecated.get(node.name)), @@ -108,13 +109,13 @@ module.exports = { if (dereference.object.type !== 'Identifier') { return; } if (!namespaces.has(dereference.object.name)) { return; } - if (declaredScope(context, dereference.object.name) !== 'module') { return; } + if (declaredScope(context, dereference.object.name, dereference) !== 'module') { return; } // go deep let namespace = namespaces.get(dereference.object.name); const namepath = [dereference.object.name]; // while property is namespace and parent is member expression, keep validating - while (namespace instanceof Exports && dereference.type === 'MemberExpression') { + while (namespace instanceof ExportMap && dereference.type === 'MemberExpression') { // ignore computed parts for now if (dereference.computed) { return; } diff --git a/src/rules/no-duplicates.js b/src/rules/no-duplicates.js index 6b4f4d559e..b8c8d848ca 100644 --- a/src/rules/no-duplicates.js +++ b/src/rules/no-duplicates.js @@ -1,3 +1,4 @@ +import { getSourceCode } from 'eslint-module-utils/contextCompat'; import resolve from 'eslint-module-utils/resolve'; import semver from 'semver'; import flatMap from 'array.prototype.flatmap'; @@ -9,30 +10,71 @@ try { typescriptPkg = require('typescript/package.json'); // eslint-disable-line import/no-extraneous-dependencies } catch (e) { /**/ } -function checkImports(imported, context) { - for (const [module, nodes] of imported.entries()) { - if (nodes.length > 1) { - const message = `'${module}' imported multiple times.`; - const [first, ...rest] = nodes; - const sourceCode = context.getSourceCode(); - const fix = getFix(first, rest, sourceCode, context); +function isPunctuator(node, value) { + return node.type === 'Punctuator' && node.value === value; +} - context.report({ - node: first.source, - message, - fix, // Attach the autofix (if any) to the first import. - }); +// Get the name of the default import of `node`, if any. +function getDefaultImportName(node) { + const defaultSpecifier = node.specifiers + .find((specifier) => specifier.type === 'ImportDefaultSpecifier'); + return defaultSpecifier != null ? defaultSpecifier.local.name : undefined; +} - for (const node of rest) { - context.report({ - node: node.source, - message, - }); - } - } - } +// Checks whether `node` has a namespace import. +function hasNamespace(node) { + const specifiers = node.specifiers + .filter((specifier) => specifier.type === 'ImportNamespaceSpecifier'); + return specifiers.length > 0; +} + +// Checks whether `node` has any non-default specifiers. +function hasSpecifiers(node) { + const specifiers = node.specifiers + .filter((specifier) => specifier.type === 'ImportSpecifier'); + return specifiers.length > 0; } +// Checks whether `node` has a comment (that ends) on the previous line or on +// the same line as `node` (starts). +function hasCommentBefore(node, sourceCode) { + return sourceCode.getCommentsBefore(node) + .some((comment) => comment.loc.end.line >= node.loc.start.line - 1); +} + +// Checks whether `node` has a comment (that starts) on the same line as `node` +// (ends). +function hasCommentAfter(node, sourceCode) { + return sourceCode.getCommentsAfter(node) + .some((comment) => comment.loc.start.line === node.loc.end.line); +} + +// Checks whether `node` has any comments _inside,_ except inside the `{...}` +// part (if any). +function hasCommentInsideNonSpecifiers(node, sourceCode) { + const tokens = sourceCode.getTokens(node); + const openBraceIndex = tokens.findIndex((token) => isPunctuator(token, '{')); + const closeBraceIndex = tokens.findIndex((token) => isPunctuator(token, '}')); + // Slice away the first token, since we're no looking for comments _before_ + // `node` (only inside). If there's a `{...}` part, look for comments before + // the `{`, but not before the `}` (hence the `+1`s). + const someTokens = openBraceIndex >= 0 && closeBraceIndex >= 0 + ? tokens.slice(1, openBraceIndex + 1).concat(tokens.slice(closeBraceIndex + 1)) + : tokens.slice(1); + return someTokens.some((token) => sourceCode.getCommentsBefore(token).length > 0); +} + +// It's not obvious what the user wants to do with comments associated with +// duplicate imports, so skip imports with comments when autofixing. +function hasProblematicComments(node, sourceCode) { + return ( + hasCommentBefore(node, sourceCode) + || hasCommentAfter(node, sourceCode) + || hasCommentInsideNonSpecifiers(node, sourceCode) + ); +} + +/** @type {(first: import('estree').ImportDeclaration, rest: import('estree').ImportDeclaration[], sourceCode: import('eslint').SourceCode.SourceCode, context: import('eslint').Rule.RuleContext) => import('eslint').Rule.ReportFixer | undefined} */ function getFix(first, rest, sourceCode, context) { // Sorry ESLint <= 3 users, no autofix for you. Autofixing duplicate imports // requires multiple `fixer.whatever()` calls in the `fix`: We both need to @@ -82,7 +124,7 @@ function getFix(first, rest, sourceCode, context) { isEmpty: !hasSpecifiers(node), }; }) - .filter(Boolean); + .filter((x) => !!x); const unnecessaryImports = restWithoutComments.filter((node) => !hasSpecifiers(node) && !hasNamespace(node) @@ -92,11 +134,13 @@ function getFix(first, rest, sourceCode, context) { const shouldAddDefault = getDefaultImportName(first) == null && defaultImportNames.size === 1; const shouldAddSpecifiers = specifiers.length > 0; const shouldRemoveUnnecessary = unnecessaryImports.length > 0; + const preferInline = context.options[0] && context.options[0]['prefer-inline']; if (!(shouldAddDefault || shouldAddSpecifiers || shouldRemoveUnnecessary)) { return undefined; } + /** @type {import('eslint').Rule.ReportFixer} */ return (fixer) => { const tokens = sourceCode.getTokens(first); const openBrace = tokens.find((token) => isPunctuator(token, '{')); @@ -117,8 +161,7 @@ function getFix(first, rest, sourceCode, context) { ([result, needsComma, existingIdentifiers], specifier) => { const isTypeSpecifier = specifier.importNode.importKind === 'type'; - const preferInline = context.options[0] && context.options[0]['prefer-inline']; - // a user might set prefer-inline but not have a supporting TypeScript version. Flow does not support inline types so this should fail in that case as well. + // a user might set prefer-inline but not have a supporting TypeScript version. Flow does not support inline types so this should fail in that case as well. if (preferInline && (!typescriptPkg || !semver.satisfies(typescriptPkg.version, '>= 4.5'))) { throw new Error('Your version of TypeScript does not support inline type imports.'); } @@ -144,8 +187,21 @@ function getFix(first, rest, sourceCode, context) { ['', !firstHasTrailingComma && !firstIsEmpty, firstExistingIdentifiers], ); + /** @type {import('eslint').Rule.Fix[]} */ const fixes = []; + if (shouldAddSpecifiers && preferInline && first.importKind === 'type') { + // `import type {a} from './foo'` → `import {type a} from './foo'` + const typeIdentifierToken = tokens.find((token) => token.type === 'Identifier' && token.value === 'type'); + fixes.push(fixer.removeRange([typeIdentifierToken.range[0], typeIdentifierToken.range[1] + 1])); + + tokens + .filter((token) => firstExistingIdentifiers.has(token.value)) + .forEach((identifier) => { + fixes.push(fixer.replaceTextRange([identifier.range[0], identifier.range[1]], `type ${identifier.value}`)); + }); + } + if (shouldAddDefault && openBrace == null && shouldAddSpecifiers) { // `import './foo'` → `import def, {...} from './foo'` fixes.push( @@ -175,7 +231,7 @@ function getFix(first, rest, sourceCode, context) { } // Remove imports whose specifiers have been moved into the first import. - for (const specifier of specifiers) { + specifiers.forEach((specifier) => { const importNode = specifier.importNode; fixes.push(fixer.remove(importNode)); @@ -184,12 +240,12 @@ function getFix(first, rest, sourceCode, context) { if (charAfterImport === '\n') { fixes.push(fixer.removeRange(charAfterImportRange)); } - } + }); // Remove imports whose default import has been moved to the first import, // and side-effect-only imports that are unnecessary due to the first // import. - for (const node of unnecessaryImports) { + unnecessaryImports.forEach((node) => { fixes.push(fixer.remove(node)); const charAfterImportRange = [node.range[1], node.range[1] + 1]; @@ -197,76 +253,38 @@ function getFix(first, rest, sourceCode, context) { if (charAfterImport === '\n') { fixes.push(fixer.removeRange(charAfterImportRange)); } - } + }); return fixes; }; } -function isPunctuator(node, value) { - return node.type === 'Punctuator' && node.value === value; -} - -// Get the name of the default import of `node`, if any. -function getDefaultImportName(node) { - const defaultSpecifier = node.specifiers - .find((specifier) => specifier.type === 'ImportDefaultSpecifier'); - return defaultSpecifier != null ? defaultSpecifier.local.name : undefined; -} - -// Checks whether `node` has a namespace import. -function hasNamespace(node) { - const specifiers = node.specifiers - .filter((specifier) => specifier.type === 'ImportNamespaceSpecifier'); - return specifiers.length > 0; -} - -// Checks whether `node` has any non-default specifiers. -function hasSpecifiers(node) { - const specifiers = node.specifiers - .filter((specifier) => specifier.type === 'ImportSpecifier'); - return specifiers.length > 0; -} - -// It's not obvious what the user wants to do with comments associated with -// duplicate imports, so skip imports with comments when autofixing. -function hasProblematicComments(node, sourceCode) { - return ( - hasCommentBefore(node, sourceCode) - || hasCommentAfter(node, sourceCode) - || hasCommentInsideNonSpecifiers(node, sourceCode) - ); -} - -// Checks whether `node` has a comment (that ends) on the previous line or on -// the same line as `node` (starts). -function hasCommentBefore(node, sourceCode) { - return sourceCode.getCommentsBefore(node) - .some((comment) => comment.loc.end.line >= node.loc.start.line - 1); -} +/** @type {(imported: Map, context: import('eslint').Rule.RuleContext) => void} */ +function checkImports(imported, context) { + for (const [module, nodes] of imported.entries()) { + if (nodes.length > 1) { + const message = `'${module}' imported multiple times.`; + const [first, ...rest] = nodes; + const sourceCode = getSourceCode(context); + const fix = getFix(first, rest, sourceCode, context); -// Checks whether `node` has a comment (that starts) on the same line as `node` -// (ends). -function hasCommentAfter(node, sourceCode) { - return sourceCode.getCommentsAfter(node) - .some((comment) => comment.loc.start.line === node.loc.end.line); -} + context.report({ + node: first.source, + message, + fix, // Attach the autofix (if any) to the first import. + }); -// Checks whether `node` has any comments _inside,_ except inside the `{...}` -// part (if any). -function hasCommentInsideNonSpecifiers(node, sourceCode) { - const tokens = sourceCode.getTokens(node); - const openBraceIndex = tokens.findIndex((token) => isPunctuator(token, '{')); - const closeBraceIndex = tokens.findIndex((token) => isPunctuator(token, '}')); - // Slice away the first token, since we're no looking for comments _before_ - // `node` (only inside). If there's a `{...}` part, look for comments before - // the `{`, but not before the `}` (hence the `+1`s). - const someTokens = openBraceIndex >= 0 && closeBraceIndex >= 0 - ? tokens.slice(1, openBraceIndex + 1).concat(tokens.slice(closeBraceIndex + 1)) - : tokens.slice(1); - return someTokens.some((token) => sourceCode.getCommentsBefore(token).length > 0); + rest.forEach((node) => { + context.report({ + node: node.source, + message, + }); + }); + } + } } +/** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { type: 'problem', @@ -292,10 +310,13 @@ module.exports = { ], }, + /** @param {import('eslint').Rule.RuleContext} context */ create(context) { + /** @type {boolean} */ // Prepare the resolver from options. - const considerQueryStringOption = context.options[0] - && context.options[0].considerQueryString; + const considerQueryStringOption = context.options[0] && context.options[0].considerQueryString; + /** @type {boolean} */ + const preferInline = context.options[0] && context.options[0]['prefer-inline']; const defaultResolver = (sourcePath) => resolve(sourcePath, context) || sourcePath; const resolver = considerQueryStringOption ? (sourcePath) => { const parts = sourcePath.match(/^([^?]*)\?(.*)$/); @@ -305,11 +326,14 @@ module.exports = { return `${defaultResolver(parts[1])}?${parts[2]}`; } : defaultResolver; + /** @type {Map, nsImported: Map, defaultTypesImported: Map, namedTypesImported: Map}>} */ const moduleMaps = new Map(); + /** @param {import('estree').ImportDeclaration} n */ + /** @returns {typeof moduleMaps[keyof typeof moduleMaps]} */ function getImportMap(n) { if (!moduleMaps.has(n.parent)) { - moduleMaps.set(n.parent, { + moduleMaps.set(n.parent, /** @type {typeof moduleMaps} */ { imported: new Map(), nsImported: new Map(), defaultTypesImported: new Map(), @@ -317,7 +341,6 @@ module.exports = { }); } const map = moduleMaps.get(n.parent); - const preferInline = context.options[0] && context.options[0]['prefer-inline']; if (!preferInline && n.importKind === 'type') { return n.specifiers.length > 0 && n.specifiers[0].type === 'ImportDefaultSpecifier' ? map.defaultTypesImported : map.namedTypesImported; } @@ -329,7 +352,9 @@ module.exports = { } return { + /** @param {import('estree').ImportDeclaration} n */ ImportDeclaration(n) { + /** @type {string} */ // resolved path will cover aliased duplicates const resolvedPath = resolver(n.source.value); const importMap = getImportMap(n); diff --git a/src/rules/no-empty-named-blocks.js b/src/rules/no-empty-named-blocks.js index 3ec1501b8f..d68ecee38b 100644 --- a/src/rules/no-empty-named-blocks.js +++ b/src/rules/no-empty-named-blocks.js @@ -1,3 +1,5 @@ +import { getSourceCode } from 'eslint-module-utils/contextCompat'; + import docsUrl from '../docsUrl'; function getEmptyBlockRange(tokens, index) { @@ -72,7 +74,7 @@ module.exports = { fix(fixer) { // Remove the empty block and the 'from' token, leaving the import only for its side // effects, e.g. `import 'mod'` - const sourceCode = context.getSourceCode(); + const sourceCode = getSourceCode(context); const fromToken = program.tokens.find((t) => t.value === 'from'); const importToken = program.tokens.find((t) => t.value === 'import'); const hasSpaceAfterFrom = sourceCode.isSpaceBetween(fromToken, sourceCode.getTokenAfter(fromToken)); diff --git a/src/rules/no-extraneous-dependencies.js b/src/rules/no-extraneous-dependencies.js index df97987901..bf0a1ed477 100644 --- a/src/rules/no-extraneous-dependencies.js +++ b/src/rules/no-extraneous-dependencies.js @@ -1,9 +1,11 @@ import path from 'path'; import fs from 'fs'; -import pkgUp from 'eslint-module-utils/pkgUp'; import minimatch from 'minimatch'; +import { getPhysicalFilename } from 'eslint-module-utils/contextCompat'; +import pkgUp from 'eslint-module-utils/pkgUp'; import resolve from 'eslint-module-utils/resolve'; import moduleVisitor from 'eslint-module-utils/moduleVisitor'; + import importType from '../core/importType'; import { getFilePackageName } from '../core/packagePath'; import docsUrl from '../docsUrl'; @@ -42,8 +44,11 @@ function extractDepFields(pkg) { function getPackageDepFields(packageJsonPath, throwAtRead) { if (!depFieldCache.has(packageJsonPath)) { - const depFields = extractDepFields(readJSON(packageJsonPath, throwAtRead)); - depFieldCache.set(packageJsonPath, depFields); + const packageJson = readJSON(packageJsonPath, throwAtRead); + if (packageJson) { + const depFields = extractDepFields(packageJson); + depFieldCache.set(packageJsonPath, depFields); + } } return depFieldCache.get(packageJsonPath); @@ -72,14 +77,16 @@ function getDependencies(context, packageDir) { // use rule config to find package.json paths.forEach((dir) => { const packageJsonPath = path.join(dir, 'package.json'); - const _packageContent = getPackageDepFields(packageJsonPath, true); - Object.keys(packageContent).forEach((depsKey) => { - Object.assign(packageContent[depsKey], _packageContent[depsKey]); - }); + const _packageContent = getPackageDepFields(packageJsonPath, paths.length === 1); + if (_packageContent) { + Object.keys(packageContent).forEach((depsKey) => { + Object.assign(packageContent[depsKey], _packageContent[depsKey]); + }); + } }); } else { const packageJsonPath = pkgUp({ - cwd: context.getPhysicalFilename ? context.getPhysicalFilename() : context.getFilename(), + cwd: getPhysicalFilename(context), normalize: false, }); @@ -278,7 +285,7 @@ module.exports = { create(context) { const options = context.options[0] || {}; - const filename = context.getPhysicalFilename ? context.getPhysicalFilename() : context.getFilename(); + const filename = getPhysicalFilename(context); const deps = getDependencies(context, options.packageDir) || extractDepFields({}); const depsOptions = { diff --git a/src/rules/no-import-module-exports.js b/src/rules/no-import-module-exports.js index bc4605c39d..bf6fba61bd 100644 --- a/src/rules/no-import-module-exports.js +++ b/src/rules/no-import-module-exports.js @@ -1,9 +1,10 @@ import minimatch from 'minimatch'; import path from 'path'; +import { getPhysicalFilename, getSourceCode } from 'eslint-module-utils/contextCompat'; import pkgUp from 'eslint-module-utils/pkgUp'; function getEntryPoint(context) { - const pkgPath = pkgUp({ cwd: context.getPhysicalFilename ? context.getPhysicalFilename() : context.getFilename() }); + const pkgPath = pkgUp({ cwd: getPhysicalFilename(context) }); try { return require.resolve(path.dirname(pkgPath)); } catch (error) { @@ -14,7 +15,7 @@ function getEntryPoint(context) { } function findScope(context, identifier) { - const { scopeManager } = context.getSourceCode(); + const { scopeManager } = getSourceCode(context); return scopeManager && scopeManager.scopes.slice().reverse().find((scope) => scope.variables.some((variable) => variable.identifiers.some((node) => node.name === identifier))); } @@ -50,7 +51,7 @@ module.exports = { let alreadyReported = false; function report(node) { - const fileName = context.getPhysicalFilename ? context.getPhysicalFilename() : context.getFilename(); + const fileName = getPhysicalFilename(context); const isEntryPoint = entryPoint === fileName; const isIdentifier = node.object.type === 'Identifier'; const hasKeywords = (/^(module|exports)$/).test(node.object.name); diff --git a/src/rules/no-mutable-exports.js b/src/rules/no-mutable-exports.js index 433d64e167..0a0e128dc0 100644 --- a/src/rules/no-mutable-exports.js +++ b/src/rules/no-mutable-exports.js @@ -1,5 +1,8 @@ +import { getScope } from 'eslint-module-utils/contextCompat'; + import docsUrl from '../docsUrl'; +/** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { type: 'suggestion', @@ -19,41 +22,41 @@ module.exports = { } } + /** @type {(scope: import('eslint').Scope.Scope, name: string) => void} */ function checkDeclarationsInScope({ variables }, name) { - for (const variable of variables) { - if (variable.name === name) { - for (const def of variable.defs) { - if (def.type === 'Variable' && def.parent) { + variables + .filter((variable) => variable.name === name) + .forEach((variable) => { + variable.defs + .filter((def) => def.type === 'Variable' && def.parent) + .forEach((def) => { checkDeclaration(def.parent); - } - } - } - } - } - - function handleExportDefault(node) { - const scope = context.getScope(); - - if (node.declaration.name) { - checkDeclarationsInScope(scope, node.declaration.name); - } + }); + }); } - function handleExportNamed(node) { - const scope = context.getScope(); + return { + /** @param {import('estree').ExportDefaultDeclaration} node */ + ExportDefaultDeclaration(node) { + const scope = getScope(context, node); - if (node.declaration) { - checkDeclaration(node.declaration); - } else if (!node.source) { - for (const specifier of node.specifiers) { - checkDeclarationsInScope(scope, specifier.local.name); + if ('name' in node.declaration && node.declaration.name) { + checkDeclarationsInScope(scope, node.declaration.name); } - } - } - - return { - ExportDefaultDeclaration: handleExportDefault, - ExportNamedDeclaration: handleExportNamed, + }, + + /** @param {import('estree').ExportNamedDeclaration} node */ + ExportNamedDeclaration(node) { + const scope = getScope(context, node); + + if ('declaration' in node && node.declaration) { + checkDeclaration(node.declaration); + } else if (!('source' in node) || !node.source) { + node.specifiers.forEach((specifier) => { + checkDeclarationsInScope(scope, specifier.local.name); + }); + } + }, }; }, }; diff --git a/src/rules/no-named-as-default-member.js b/src/rules/no-named-as-default-member.js index e00a4cbc87..c6abc46a81 100644 --- a/src/rules/no-named-as-default-member.js +++ b/src/rules/no-named-as-default-member.js @@ -4,7 +4,7 @@ * @copyright 2016 Desmond Brand. All rights reserved. * See LICENSE in root directory for full license. */ -import Exports from '../ExportMap'; +import ExportMapBuilder from '../exportMap/builder'; import importDeclaration from '../importDeclaration'; import docsUrl from '../docsUrl'; @@ -35,8 +35,8 @@ module.exports = { return { ImportDefaultSpecifier(node) { - const declaration = importDeclaration(context); - const exportMap = Exports.get(declaration.source.value, context); + const declaration = importDeclaration(context, node); + const exportMap = ExportMapBuilder.get(declaration.source.value, context); if (exportMap == null) { return; } if (exportMap.errors.length) { diff --git a/src/rules/no-named-as-default.js b/src/rules/no-named-as-default.js index 40b1e175b2..dacd294f46 100644 --- a/src/rules/no-named-as-default.js +++ b/src/rules/no-named-as-default.js @@ -1,4 +1,4 @@ -import Exports from '../ExportMap'; +import ExportMapBuilder from '../exportMap/builder'; import importDeclaration from '../importDeclaration'; import docsUrl from '../docsUrl'; @@ -15,28 +15,71 @@ module.exports = { create(context) { function checkDefault(nameKey, defaultSpecifier) { + /** + * For ImportDefaultSpecifier we're interested in the "local" name (`foo` for `import {bar as foo} ...`) + * For ExportDefaultSpecifier we're interested in the "exported" name (`foo` for `export {bar as foo} ...`) + */ + const analyzedName = defaultSpecifier[nameKey].name; + // #566: default is a valid specifier - if (defaultSpecifier[nameKey].name === 'default') { return; } + if (analyzedName === 'default') { return; } - const declaration = importDeclaration(context); + const declaration = importDeclaration(context, defaultSpecifier); + /** @type {import('../exportMap').default | null} */ + const importedModule = ExportMapBuilder.get(declaration.source.value, context); + if (importedModule == null) { return; } - const imports = Exports.get(declaration.source.value, context); - if (imports == null) { return; } + if (importedModule.errors.length > 0) { + importedModule.reportErrors(context, declaration); + return; + } - if (imports.errors.length) { - imports.reportErrors(context, declaration); + if (!importedModule.hasDefault) { + // The rule is triggered for default imports/exports, so if the imported module has no default + // this means we're dealing with incorrect source code anyway return; } - if (imports.has('default') && imports.has(defaultSpecifier[nameKey].name)) { + if (!importedModule.has(analyzedName)) { + // The name used locally for the default import was not even used in the imported module. + return; + } - context.report( - defaultSpecifier, - `Using exported name '${defaultSpecifier[nameKey].name}' as identifier for default export.`, - ); + /** + * FIXME: We can verify if a default and a named export are pointing to the same symbol only + * if they are both `reexports`. In case one of the symbols is not a re-export, but defined + * in the file, the ExportMap structure has no info about what actually is being exported -- + * the value in the `namespace` Map is an empty object. + * + * To solve this, it would require not relying on the ExportMap, but on some other way of + * accessing the imported module and its exported values. + * + * Additionally, although `ExportMap.get` is a unified way to get info from both `reexports` + * and `namespace` maps, it does not return valid output we need here, and I think this is + * related to the "cycle safeguards" in the `get` function. + */ + if (importedModule.reexports.has(analyzedName) && importedModule.reexports.has('default')) { + const thingImportedWithNamedImport = importedModule.reexports.get(analyzedName).getImport(); + const thingImportedWithDefaultImport = importedModule.reexports.get('default').getImport(); + + // Case: both imports point to the same file and they both refer to the same symbol in this file. + if ( + thingImportedWithNamedImport.path === thingImportedWithDefaultImport.path + && thingImportedWithNamedImport.local === thingImportedWithDefaultImport.local + ) { + // #1594: the imported module exports the same thing via a default export and a named export + return; + } } + + context.report( + defaultSpecifier, + `Using exported name '${defaultSpecifier[nameKey].name}' as identifier for default ${nameKey === 'local' ? `import` : `export`}.`, + ); + } + return { ImportDefaultSpecifier: checkDefault.bind(null, 'local'), ExportDefaultSpecifier: checkDefault.bind(null, 'exported'), diff --git a/src/rules/no-named-export.js b/src/rules/no-named-export.js index efaf9dc4c8..fc9b2c48d6 100644 --- a/src/rules/no-named-export.js +++ b/src/rules/no-named-export.js @@ -1,3 +1,4 @@ +import sourceType from '../core/sourceType'; import docsUrl from '../docsUrl'; module.exports = { @@ -13,7 +14,7 @@ module.exports = { create(context) { // ignore non-modules - if (context.parserOptions.sourceType !== 'module') { + if (sourceType(context) !== 'module') { return {}; } diff --git a/src/rules/no-namespace.js b/src/rules/no-namespace.js index d3e591876f..7ab60bd215 100644 --- a/src/rules/no-namespace.js +++ b/src/rules/no-namespace.js @@ -4,11 +4,78 @@ */ import minimatch from 'minimatch'; +import { getScope, getSourceCode } from 'eslint-module-utils/contextCompat'; + import docsUrl from '../docsUrl'; -//------------------------------------------------------------------------------ -// Rule Definition -//------------------------------------------------------------------------------ +/** + * @param {MemberExpression} memberExpression + * @returns {string} the name of the member in the object expression, e.g. the `x` in `namespace.x` + */ +function getMemberPropertyName(memberExpression) { + return memberExpression.property.type === 'Identifier' + ? memberExpression.property.name + : memberExpression.property.value; +} + +/** + * @param {ScopeManager} scopeManager + * @param {ASTNode} node + * @return {Set} + */ +function getVariableNamesInScope(scopeManager, node) { + let currentNode = node; + let scope = scopeManager.acquire(currentNode); + while (scope == null) { + currentNode = currentNode.parent; + scope = scopeManager.acquire(currentNode, true); + } + return new Set(scope.variables.concat(scope.upper.variables).map((variable) => variable.name)); +} + +/** + * + * @param {*} names + * @param {*} nameConflicts + * @param {*} namespaceName + */ +function generateLocalNames(names, nameConflicts, namespaceName) { + const localNames = {}; + names.forEach((name) => { + let localName; + if (!nameConflicts[name].has(name)) { + localName = name; + } else if (!nameConflicts[name].has(`${namespaceName}_${name}`)) { + localName = `${namespaceName}_${name}`; + } else { + for (let i = 1; i < Infinity; i++) { + if (!nameConflicts[name].has(`${namespaceName}_${name}_${i}`)) { + localName = `${namespaceName}_${name}_${i}`; + break; + } + } + } + localNames[name] = localName; + }); + return localNames; +} + +/** + * @param {Identifier[]} namespaceIdentifiers + * @returns {boolean} `true` if the namespace variable is more than just a glorified constant + */ +function usesNamespaceAsObject(namespaceIdentifiers) { + return !namespaceIdentifiers.every((identifier) => { + const parent = identifier.parent; + + // `namespace.x` or `namespace['x']` + return ( + parent + && parent.type === 'MemberExpression' + && (parent.property.type === 'Identifier' || parent.property.type === 'Literal') + ); + }); +} module.exports = { meta: { @@ -43,7 +110,7 @@ module.exports = { return; } - const scopeVariables = context.getScope().variables; + const scopeVariables = getScope(context, node).variables; const namespaceVariable = scopeVariables.find((variable) => variable.defs[0].node === node); const namespaceReferences = namespaceVariable.references; const namespaceIdentifiers = namespaceReferences.map((reference) => reference.identifier); @@ -53,7 +120,7 @@ module.exports = { node, message: `Unexpected namespace import.`, fix: canFix && ((fixer) => { - const scopeManager = context.getSourceCode().scopeManager; + const { scopeManager } = getSourceCode(context); const fixes = []; // Pass 1: Collect variable names that are already in scope for each reference we want @@ -103,72 +170,3 @@ module.exports = { }; }, }; - -/** - * @param {Identifier[]} namespaceIdentifiers - * @returns {boolean} `true` if the namespace variable is more than just a glorified constant - */ -function usesNamespaceAsObject(namespaceIdentifiers) { - return !namespaceIdentifiers.every((identifier) => { - const parent = identifier.parent; - - // `namespace.x` or `namespace['x']` - return ( - parent - && parent.type === 'MemberExpression' - && (parent.property.type === 'Identifier' || parent.property.type === 'Literal') - ); - }); -} - -/** - * @param {MemberExpression} memberExpression - * @returns {string} the name of the member in the object expression, e.g. the `x` in `namespace.x` - */ -function getMemberPropertyName(memberExpression) { - return memberExpression.property.type === 'Identifier' - ? memberExpression.property.name - : memberExpression.property.value; -} - -/** - * @param {ScopeManager} scopeManager - * @param {ASTNode} node - * @return {Set} - */ -function getVariableNamesInScope(scopeManager, node) { - let currentNode = node; - let scope = scopeManager.acquire(currentNode); - while (scope == null) { - currentNode = currentNode.parent; - scope = scopeManager.acquire(currentNode, true); - } - return new Set(scope.variables.concat(scope.upper.variables).map((variable) => variable.name)); -} - -/** - * - * @param {*} names - * @param {*} nameConflicts - * @param {*} namespaceName - */ -function generateLocalNames(names, nameConflicts, namespaceName) { - const localNames = {}; - names.forEach((name) => { - let localName; - if (!nameConflicts[name].has(name)) { - localName = name; - } else if (!nameConflicts[name].has(`${namespaceName}_${name}`)) { - localName = `${namespaceName}_${name}`; - } else { - for (let i = 1; i < Infinity; i++) { - if (!nameConflicts[name].has(`${namespaceName}_${name}_${i}`)) { - localName = `${namespaceName}_${name}_${i}`; - break; - } - } - } - localNames[name] = localName; - }); - return localNames; -} diff --git a/src/rules/no-relative-packages.js b/src/rules/no-relative-packages.js index 1d215519fd..ebc280ff9b 100644 --- a/src/rules/no-relative-packages.js +++ b/src/rules/no-relative-packages.js @@ -1,6 +1,7 @@ import path from 'path'; import readPkgUp from 'eslint-module-utils/readPkgUp'; +import { getPhysicalFilename } from 'eslint-module-utils/contextCompat'; import resolve from 'eslint-module-utils/resolve'; import moduleVisitor, { makeOptionsSchema } from 'eslint-module-utils/moduleVisitor'; import importType from '../core/importType'; @@ -26,7 +27,7 @@ function checkImportForRelativePackage(context, importPath, node) { } const resolvedImport = resolve(importPath, context); - const resolvedContext = context.getPhysicalFilename ? context.getPhysicalFilename() : context.getFilename(); + const resolvedContext = getPhysicalFilename(context); if (!resolvedImport || !resolvedContext) { return; diff --git a/src/rules/no-relative-parent-imports.js b/src/rules/no-relative-parent-imports.js index decd2ef7d2..94972d3ddf 100644 --- a/src/rules/no-relative-parent-imports.js +++ b/src/rules/no-relative-parent-imports.js @@ -1,9 +1,10 @@ -import moduleVisitor, { makeOptionsSchema } from 'eslint-module-utils/moduleVisitor'; -import docsUrl from '../docsUrl'; import { basename, dirname, relative } from 'path'; +import { getPhysicalFilename } from 'eslint-module-utils/contextCompat'; +import moduleVisitor, { makeOptionsSchema } from 'eslint-module-utils/moduleVisitor'; import resolve from 'eslint-module-utils/resolve'; import importType from '../core/importType'; +import docsUrl from '../docsUrl'; module.exports = { meta: { @@ -17,7 +18,7 @@ module.exports = { }, create: function noRelativePackages(context) { - const myPath = context.getPhysicalFilename ? context.getPhysicalFilename() : context.getFilename(); + const myPath = getPhysicalFilename(context); if (myPath === '') { return {}; } // can't check a non-file function checkSourceValue(sourceNode) { diff --git a/src/rules/no-restricted-paths.js b/src/rules/no-restricted-paths.js index cd680a1946..2e1bc608c6 100644 --- a/src/rules/no-restricted-paths.js +++ b/src/rules/no-restricted-paths.js @@ -1,17 +1,27 @@ import path from 'path'; - +import { getPhysicalFilename } from 'eslint-module-utils/contextCompat'; import resolve from 'eslint-module-utils/resolve'; import moduleVisitor from 'eslint-module-utils/moduleVisitor'; import isGlob from 'is-glob'; import { Minimatch } from 'minimatch'; -import docsUrl from '../docsUrl'; + import importType from '../core/importType'; +import docsUrl from '../docsUrl'; const containsPath = (filepath, target) => { const relative = path.relative(target, filepath); return relative === '' || !relative.startsWith('..'); }; +function isMatchingTargetPath(filename, targetPath) { + if (isGlob(targetPath)) { + const mm = new Minimatch(targetPath); + return mm.match(filename); + } + + return containsPath(filename, targetPath); +} + module.exports = { meta: { type: 'problem', @@ -76,22 +86,13 @@ module.exports = { const options = context.options[0] || {}; const restrictedPaths = options.zones || []; const basePath = options.basePath || process.cwd(); - const currentFilename = context.getPhysicalFilename ? context.getPhysicalFilename() : context.getFilename(); + const currentFilename = getPhysicalFilename(context); const matchingZones = restrictedPaths.filter( (zone) => [].concat(zone.target) .map((target) => path.resolve(basePath, target)) .some((targetPath) => isMatchingTargetPath(currentFilename, targetPath)), ); - function isMatchingTargetPath(filename, targetPath) { - if (isGlob(targetPath)) { - const mm = new Minimatch(targetPath); - return mm.match(filename); - } - - return containsPath(filename, targetPath); - } - function isValidExceptionPath(absoluteFromPath, absoluteExceptionPath) { const relativeExceptionPath = path.relative(absoluteFromPath, absoluteExceptionPath); diff --git a/src/rules/no-self-import.js b/src/rules/no-self-import.js index 0ba0f66694..99c5342709 100644 --- a/src/rules/no-self-import.js +++ b/src/rules/no-self-import.js @@ -3,12 +3,14 @@ * @author Gio d'Amelio */ +import { getPhysicalFilename } from 'eslint-module-utils/contextCompat'; import resolve from 'eslint-module-utils/resolve'; import moduleVisitor from 'eslint-module-utils/moduleVisitor'; + import docsUrl from '../docsUrl'; function isImportingSelf(context, node, requireName) { - const filePath = context.getPhysicalFilename ? context.getPhysicalFilename() : context.getFilename(); + const filePath = getPhysicalFilename(context); // If the input is from stdin, this test can't fail if (filePath !== '' && filePath === resolve(requireName, context)) { diff --git a/src/rules/no-unassigned-import.js b/src/rules/no-unassigned-import.js index 0af9f2e9f3..fec232afef 100644 --- a/src/rules/no-unassigned-import.js +++ b/src/rules/no-unassigned-import.js @@ -1,5 +1,6 @@ import path from 'path'; import minimatch from 'minimatch'; +import { getPhysicalFilename } from 'eslint-module-utils/contextCompat'; import isStaticRequire from '../core/staticRequire'; import docsUrl from '../docsUrl'; @@ -31,7 +32,7 @@ function testIsAllow(globs, filename, source) { function create(context) { const options = context.options[0] || {}; - const filename = context.getPhysicalFilename ? context.getPhysicalFilename() : context.getFilename(); + const filename = getPhysicalFilename(context); const isAllow = (source) => testIsAllow(options.allow, filename, source); return { diff --git a/src/rules/no-unused-modules.js b/src/rules/no-unused-modules.js index 8229d880ce..86302a0ea6 100644 --- a/src/rules/no-unused-modules.js +++ b/src/rules/no-unused-modules.js @@ -4,6 +4,7 @@ * @author René Fermann */ +import { getPhysicalFilename } from 'eslint-module-utils/contextCompat'; import { getFileExtensions } from 'eslint-module-utils/ignore'; import resolve from 'eslint-module-utils/resolve'; import visit from 'eslint-module-utils/visit'; @@ -13,54 +14,155 @@ import values from 'object.values'; import includes from 'array-includes'; import flatMap from 'array.prototype.flatmap'; -import Exports, { recursivePatternCapture } from '../ExportMap'; +import ExportMapBuilder from '../exportMap/builder'; +import recursivePatternCapture from '../exportMap/patternCapture'; import docsUrl from '../docsUrl'; -let FileEnumerator; -let listFilesToProcess; +/** + * Attempt to load the internal `FileEnumerator` class, which has existed in a couple + * of different places, depending on the version of `eslint`. Try requiring it from both + * locations. + * @returns Returns the `FileEnumerator` class if its requirable, otherwise `undefined`. + */ +function requireFileEnumerator() { + let FileEnumerator; -try { - ({ FileEnumerator } = require('eslint/use-at-your-own-risk')); -} catch (e) { + // Try getting it from the eslint private / deprecated api try { - // has been moved to eslint/lib/cli-engine/file-enumerator in version 6 - ({ FileEnumerator } = require('eslint/lib/cli-engine/file-enumerator')); + ({ FileEnumerator } = require('eslint/use-at-your-own-risk')); } catch (e) { + // Absorb this if it's MODULE_NOT_FOUND + if (e.code !== 'MODULE_NOT_FOUND') { + throw e; + } + + // If not there, then try getting it from eslint/lib/cli-engine/file-enumerator (moved there in v6) try { - // eslint/lib/util/glob-util has been moved to eslint/lib/util/glob-utils with version 5.3 - const { listFilesToProcess: originalListFilesToProcess } = require('eslint/lib/util/glob-utils'); - - // Prevent passing invalid options (extensions array) to old versions of the function. - // https://github.com/eslint/eslint/blob/v5.16.0/lib/util/glob-utils.js#L178-L280 - // https://github.com/eslint/eslint/blob/v5.2.0/lib/util/glob-util.js#L174-L269 - listFilesToProcess = function (src, extensions) { - return originalListFilesToProcess(src, { - extensions, - }); - }; + ({ FileEnumerator } = require('eslint/lib/cli-engine/file-enumerator')); } catch (e) { - const { listFilesToProcess: originalListFilesToProcess } = require('eslint/lib/util/glob-util'); + // Absorb this if it's MODULE_NOT_FOUND + if (e.code !== 'MODULE_NOT_FOUND') { + throw e; + } + } + } + return FileEnumerator; +} + +/** + * Given a FileEnumerator class, instantiate and load the list of files. + * @param FileEnumerator the `FileEnumerator` class from `eslint`'s internal api + * @param {string} src path to the src root + * @param {string[]} extensions list of supported extensions + * @returns {{ filename: string, ignored: boolean }[]} list of files to operate on + */ +function listFilesUsingFileEnumerator(FileEnumerator, src, extensions) { + // We need to know whether this is being run with flat config in order to + // determine how to report errors if FileEnumerator throws due to a lack of eslintrc. + + const { ESLINT_USE_FLAT_CONFIG } = process.env; - listFilesToProcess = function (src, extensions) { - const patterns = src.concat(flatMap(src, (pattern) => extensions.map((extension) => (/\*\*|\*\./).test(pattern) ? pattern : `${pattern}/**/*${extension}`))); + // This condition is sufficient to test in v8, since the environment variable is necessary to turn on flat config + let isUsingFlatConfig = ESLINT_USE_FLAT_CONFIG && process.env.ESLINT_USE_FLAT_CONFIG !== 'false'; - return originalListFilesToProcess(patterns); - }; + // In the case of using v9, we can check the `shouldUseFlatConfig` function + // If this function is present, then we assume it's v9 + try { + const { shouldUseFlatConfig } = require('eslint/use-at-your-own-risk'); + isUsingFlatConfig = shouldUseFlatConfig && ESLINT_USE_FLAT_CONFIG !== 'false'; + } catch (_) { + // We don't want to throw here, since we only want to update the + // boolean if the function is available. + } + + const enumerator = new FileEnumerator({ + extensions, + }); + + try { + return Array.from( + enumerator.iterateFiles(src), + ({ filePath, ignored }) => ({ filename: filePath, ignored }), + ); + } catch (e) { + // If we're using flat config, and FileEnumerator throws due to a lack of eslintrc, + // then we want to throw an error so that the user knows about this rule's reliance on + // the legacy config. + if ( + isUsingFlatConfig + && e.message.includes('No ESLint configuration found') + ) { + throw new Error(` +Due to the exclusion of certain internal ESLint APIs when using flat config, +the import/no-unused-modules rule requires an .eslintrc file to know which +files to ignore (even when using flat config). +The .eslintrc file only needs to contain "ignorePatterns", or can be empty if +you do not want to ignore any files. + +See https://github.com/import-js/eslint-plugin-import/issues/3079 +for additional context. +`); } + // If this isn't the case, then we'll just let the error bubble up + throw e; } } -if (FileEnumerator) { - listFilesToProcess = function (src, extensions) { - const e = new FileEnumerator({ +/** + * Attempt to require old versions of the file enumeration capability from v6 `eslint` and earlier, and use + * those functions to provide the list of files to operate on + * @param {string} src path to the src root + * @param {string[]} extensions list of supported extensions + * @returns {string[]} list of files to operate on + */ +function listFilesWithLegacyFunctions(src, extensions) { + try { + // eslint/lib/util/glob-util has been moved to eslint/lib/util/glob-utils with version 5.3 + const { listFilesToProcess: originalListFilesToProcess } = require('eslint/lib/util/glob-utils'); + // Prevent passing invalid options (extensions array) to old versions of the function. + // https://github.com/eslint/eslint/blob/v5.16.0/lib/util/glob-utils.js#L178-L280 + // https://github.com/eslint/eslint/blob/v5.2.0/lib/util/glob-util.js#L174-L269 + + return originalListFilesToProcess(src, { extensions, }); + } catch (e) { + // Absorb this if it's MODULE_NOT_FOUND + if (e.code !== 'MODULE_NOT_FOUND') { + throw e; + } - return Array.from(e.iterateFiles(src), ({ filePath, ignored }) => ({ - ignored, - filename: filePath, - })); - }; + // Last place to try (pre v5.3) + const { + listFilesToProcess: originalListFilesToProcess, + } = require('eslint/lib/util/glob-util'); + const patterns = src.concat( + flatMap( + src, + (pattern) => extensions.map((extension) => (/\*\*|\*\./).test(pattern) ? pattern : `${pattern}/**/*${extension}`), + ), + ); + + return originalListFilesToProcess(patterns); + } +} + +/** + * Given a src pattern and list of supported extensions, return a list of files to process + * with this rule. + * @param {string} src - file, directory, or glob pattern of files to act on + * @param {string[]} extensions - list of supported file extensions + * @returns {string[] | { filename: string, ignored: boolean }[]} the list of files that this rule will evaluate. + */ +function listFilesToProcess(src, extensions) { + const FileEnumerator = requireFileEnumerator(); + + // If we got the FileEnumerator, then let's go with that + if (FileEnumerator) { + return listFilesUsingFileEnumerator(FileEnumerator, src, extensions); + } + // If not, then we can try even older versions of this capability (listFilesToProcess) + return listFilesWithLegacyFunctions(src, extensions); } const EXPORT_DEFAULT_DECLARATION = 'ExportDefaultDeclaration'; @@ -82,28 +184,30 @@ const DEFAULT = 'default'; function forEachDeclarationIdentifier(declaration, cb) { if (declaration) { + const isTypeDeclaration = declaration.type === TS_INTERFACE_DECLARATION + || declaration.type === TS_TYPE_ALIAS_DECLARATION + || declaration.type === TS_ENUM_DECLARATION; + if ( declaration.type === FUNCTION_DECLARATION || declaration.type === CLASS_DECLARATION - || declaration.type === TS_INTERFACE_DECLARATION - || declaration.type === TS_TYPE_ALIAS_DECLARATION - || declaration.type === TS_ENUM_DECLARATION + || isTypeDeclaration ) { - cb(declaration.id.name); + cb(declaration.id.name, isTypeDeclaration); } else if (declaration.type === VARIABLE_DECLARATION) { declaration.declarations.forEach(({ id }) => { if (id.type === OBJECT_PATTERN) { recursivePatternCapture(id, (pattern) => { if (pattern.type === IDENTIFIER) { - cb(pattern.name); + cb(pattern.name, false); } }); } else if (id.type === ARRAY_PATTERN) { id.elements.forEach(({ name }) => { - cb(name); + cb(name, false); }); } else { - cb(id.name); + cb(id.name, false); } }); } @@ -160,6 +264,7 @@ const exportList = new Map(); const visitorKeyMap = new Map(); +/** @type {Set} */ const ignoredFiles = new Set(); const filesOutsideSrc = new Set(); @@ -169,22 +274,30 @@ const isNodeModule = (path) => (/\/(node_modules)\//).test(path); * read all files matching the patterns in src and ignoreExports * * return all files matching src pattern, which are not matching the ignoreExports pattern + * @type {(src: string, ignoreExports: string, context: import('eslint').Rule.RuleContext) => Set} */ -const resolveFiles = (src, ignoreExports, context) => { +function resolveFiles(src, ignoreExports, context) { const extensions = Array.from(getFileExtensions(context.settings)); const srcFileList = listFilesToProcess(src, extensions); // prepare list of ignored files const ignoredFilesList = listFilesToProcess(ignoreExports, extensions); - ignoredFilesList.forEach(({ filename }) => ignoredFiles.add(filename)); + + // The modern api will return a list of file paths, rather than an object + if (ignoredFilesList.length && typeof ignoredFilesList[0] === 'string') { + ignoredFilesList.forEach((filename) => ignoredFiles.add(filename)); + } else { + ignoredFilesList.forEach(({ filename }) => ignoredFiles.add(filename)); + } // prepare list of source files, don't consider files from node_modules + const resolvedFiles = srcFileList.length && typeof srcFileList[0] === 'string' + ? srcFileList.filter((filePath) => !isNodeModule(filePath)) + : flatMap(srcFileList, ({ filename }) => isNodeModule(filename) ? [] : filename); - return new Set( - flatMap(srcFileList, ({ filename }) => isNodeModule(filename) ? [] : filename), - ); -}; + return new Set(resolvedFiles); +} /** * parse all source files and build up 2 maps containing the existing imports and exports @@ -194,7 +307,7 @@ const prepareImportsAndExports = (srcFiles, context) => { srcFiles.forEach((file) => { const exports = new Map(); const imports = new Map(); - const currentExports = Exports.get(file, context); + const currentExports = ExportMapBuilder.get(file, context); if (currentExports) { const { dependencies, @@ -223,7 +336,7 @@ const prepareImportsAndExports = (srcFiles, context) => { } else { exports.set(key, { whereUsed: new Set() }); } - const reexport = value.getImport(); + const reexport = value.getImport(); if (!reexport) { return; } @@ -326,6 +439,7 @@ const getSrc = (src) => { * prepare the lists of existing imports and exports - should only be executed once at * the start of a new eslint run */ +/** @type {Set} */ let srcFiles; let lastPrepareKey; const doPreparation = (src, ignoreExports, context) => { @@ -442,6 +556,10 @@ module.exports = { description: 'report exports without any usage', type: 'boolean', }, + ignoreUnusedTypeExports: { + description: 'ignore type exports without any usage', + type: 'boolean', + }, }, anyOf: [ { @@ -469,13 +587,14 @@ module.exports = { ignoreExports = [], missingExports, unusedExports, + ignoreUnusedTypeExports, } = context.options[0] || {}; if (unusedExports) { doPreparation(src, ignoreExports, context); } - const file = context.getPhysicalFilename ? context.getPhysicalFilename() : context.getFilename(); + const file = getPhysicalFilename(context); const checkExportPresence = (node) => { if (!missingExports) { @@ -501,11 +620,15 @@ module.exports = { exportCount.set(IMPORT_NAMESPACE_SPECIFIER, namespaceImports); }; - const checkUsage = (node, exportedValue) => { + const checkUsage = (node, exportedValue, isTypeExport) => { if (!unusedExports) { return; } + if (isTypeExport && ignoreUnusedTypeExports) { + return; + } + if (ignoredFiles.has(file)) { return; } @@ -529,6 +652,10 @@ module.exports = { exports = exportList.get(file); + if (!exports) { + console.error(`file \`${file}\` has no exports. Please update to the latest, and if it still happens, report this on https://github.com/import-js/eslint-plugin-import/issues/2866!`); + } + // special case: export * from const exportAll = exports.get(EXPORT_ALL_DECLARATION); if (typeof exportAll !== 'undefined' && exportedValue !== IMPORT_DEFAULT_SPECIFIER) { @@ -930,14 +1057,14 @@ module.exports = { checkExportPresence(node); }, ExportDefaultDeclaration(node) { - checkUsage(node, IMPORT_DEFAULT_SPECIFIER); + checkUsage(node, IMPORT_DEFAULT_SPECIFIER, false); }, ExportNamedDeclaration(node) { node.specifiers.forEach((specifier) => { - checkUsage(specifier, specifier.exported.name || specifier.exported.value); + checkUsage(specifier, specifier.exported.name || specifier.exported.value, false); }); - forEachDeclarationIdentifier(node.declaration, (name) => { - checkUsage(node, name); + forEachDeclarationIdentifier(node.declaration, (name, isTypeExport) => { + checkUsage(node, name, isTypeExport); }); }, }; diff --git a/src/rules/no-useless-path-segments.js b/src/rules/no-useless-path-segments.js index 390a7546d3..2d8dd35269 100644 --- a/src/rules/no-useless-path-segments.js +++ b/src/rules/no-useless-path-segments.js @@ -3,6 +3,7 @@ * @author Thomas Grainger */ +import { getPhysicalFilename } from 'eslint-module-utils/contextCompat'; import { getFileExtensions } from 'eslint-module-utils/ignore'; import moduleVisitor from 'eslint-module-utils/moduleVisitor'; import resolve from 'eslint-module-utils/resolve'; @@ -60,7 +61,7 @@ module.exports = { }, create(context) { - const currentDir = path.dirname(context.getPhysicalFilename ? context.getPhysicalFilename() : context.getFilename()); + const currentDir = path.dirname(getPhysicalFilename(context)); const options = context.options[0]; function checkSourceValue(source) { diff --git a/src/rules/order.js b/src/rules/order.js index 44d25be63c..579dbb0444 100644 --- a/src/rules/order.js +++ b/src/rules/order.js @@ -3,19 +3,25 @@ import minimatch from 'minimatch'; import includes from 'array-includes'; import groupBy from 'object.groupby'; +import { getScope, getSourceCode } from 'eslint-module-utils/contextCompat'; +import trimEnd from 'string.prototype.trimend'; import importType from '../core/importType'; import isStaticRequire from '../core/staticRequire'; import docsUrl from '../docsUrl'; +const categories = { + named: 'named', + import: 'import', + exports: 'exports', +}; + const defaultGroups = ['builtin', 'external', 'parent', 'sibling', 'index']; // REPORTING AND FIXING function reverse(array) { - return array.map(function (v) { - return { ...v, rank: -v.rank }; - }).reverse(); + return array.map((v) => ({ ...v, rank: -v.rank })).reverse(); } function getTokensOrCommentsAfter(sourceCode, node, count) { @@ -92,6 +98,12 @@ function findRootNode(node) { return parent; } +function commentOnSameLineAs(node) { + return (token) => (token.type === 'Block' || token.type === 'Line') + && token.loc.start.line === token.loc.end.line + && token.loc.end.line === node.loc.end.line; +} + function findEndOfLineWithComments(sourceCode, node) { const tokensToEndOfLine = takeTokensAfterWhile(sourceCode, node, commentOnSameLineAs(node)); const endOfTokens = tokensToEndOfLine.length > 0 @@ -111,12 +123,6 @@ function findEndOfLineWithComments(sourceCode, node) { return result; } -function commentOnSameLineAs(node) { - return (token) => (token.type === 'Block' || token.type === 'Line') - && token.loc.start.line === token.loc.end.line - && token.loc.end.line === node.loc.end.line; -} - function findStartOfLineWithComments(sourceCode, node) { const tokensToEndOfLine = takeTokensBeforeWhile(sourceCode, node, commentOnSameLineAs(node)); const startOfTokens = tokensToEndOfLine.length > 0 ? tokensToEndOfLine[0].range[0] : node.range[0]; @@ -130,6 +136,26 @@ function findStartOfLineWithComments(sourceCode, node) { return result; } +function findSpecifierStart(sourceCode, node) { + let token; + + do { + token = sourceCode.getTokenBefore(node); + } while (token.value !== ',' && token.value !== '{'); + + return token.range[1]; +} + +function findSpecifierEnd(sourceCode, node) { + let token; + + do { + token = sourceCode.getTokenAfter(node); + } while (token.value !== ',' && token.value !== '}'); + + return token.range[0]; +} + function isRequireExpression(expr) { return expr != null && expr.type === 'CallExpression' @@ -169,6 +195,49 @@ function isPlainImportEquals(node) { return node.type === 'TSImportEqualsDeclaration' && node.moduleReference.expression; } +function isCJSExports(context, node) { + if ( + node.type === 'MemberExpression' + && node.object.type === 'Identifier' + && node.property.type === 'Identifier' + && node.object.name === 'module' + && node.property.name === 'exports' + ) { + return getScope(context, node).variables.findIndex((variable) => variable.name === 'module') === -1; + } + if ( + node.type === 'Identifier' + && node.name === 'exports' + ) { + return getScope(context, node).variables.findIndex((variable) => variable.name === 'exports') === -1; + } +} + +function getNamedCJSExports(context, node) { + if (node.type !== 'MemberExpression') { + return; + } + const result = []; + let root = node; + let parent = null; + while (root.type === 'MemberExpression') { + if (root.property.type !== 'Identifier') { + return; + } + result.unshift(root.property.name); + parent = root; + root = root.object; + } + + if (isCJSExports(context, root)) { + return result; + } + + if (isCJSExports(context, parent)) { + return result.slice(1); + } +} + function canCrossNodeWhileReorder(node) { return isSupportedRequireModule(node) || isPlainImportModule(node) || isPlainImportEquals(node); } @@ -189,6 +258,12 @@ function canReorderItems(firstNode, secondNode) { } function makeImportDescription(node) { + if (node.type === 'export') { + if (node.node.exportKind === 'type') { + return 'type export'; + } + return 'export'; + } if (node.node.importKind === 'type') { return 'type import'; } @@ -198,58 +273,123 @@ function makeImportDescription(node) { return 'import'; } -function fixOutOfOrder(context, firstNode, secondNode, order) { - const sourceCode = context.getSourceCode(); - - const firstRoot = findRootNode(firstNode.node); - const firstRootStart = findStartOfLineWithComments(sourceCode, firstRoot); - const firstRootEnd = findEndOfLineWithComments(sourceCode, firstRoot); +function fixOutOfOrder(context, firstNode, secondNode, order, category) { + const isNamed = category === categories.named; + const isExports = category === categories.exports; + const sourceCode = getSourceCode(context); + + const { + firstRoot, + secondRoot, + } = isNamed ? { + firstRoot: firstNode.node, + secondRoot: secondNode.node, + } : { + firstRoot: findRootNode(firstNode.node), + secondRoot: findRootNode(secondNode.node), + }; - const secondRoot = findRootNode(secondNode.node); - const secondRootStart = findStartOfLineWithComments(sourceCode, secondRoot); - const secondRootEnd = findEndOfLineWithComments(sourceCode, secondRoot); - const canFix = canReorderItems(firstRoot, secondRoot); + const { + firstRootStart, + firstRootEnd, + secondRootStart, + secondRootEnd, + } = isNamed ? { + firstRootStart: findSpecifierStart(sourceCode, firstRoot), + firstRootEnd: findSpecifierEnd(sourceCode, firstRoot), + secondRootStart: findSpecifierStart(sourceCode, secondRoot), + secondRootEnd: findSpecifierEnd(sourceCode, secondRoot), + } : { + firstRootStart: findStartOfLineWithComments(sourceCode, firstRoot), + firstRootEnd: findEndOfLineWithComments(sourceCode, firstRoot), + secondRootStart: findStartOfLineWithComments(sourceCode, secondRoot), + secondRootEnd: findEndOfLineWithComments(sourceCode, secondRoot), + }; - let newCode = sourceCode.text.substring(secondRootStart, secondRootEnd); - if (newCode[newCode.length - 1] !== '\n') { - newCode = `${newCode}\n`; + if (firstNode.displayName === secondNode.displayName) { + if (firstNode.alias) { + firstNode.displayName = `${firstNode.displayName} as ${firstNode.alias}`; + } + if (secondNode.alias) { + secondNode.displayName = `${secondNode.displayName} as ${secondNode.alias}`; + } } const firstImport = `${makeImportDescription(firstNode)} of \`${firstNode.displayName}\``; const secondImport = `\`${secondNode.displayName}\` ${makeImportDescription(secondNode)}`; const message = `${secondImport} should occur ${order} ${firstImport}`; - if (order === 'before') { - context.report({ - node: secondNode.node, - message, - fix: canFix && ((fixer) => fixer.replaceTextRange( - [firstRootStart, secondRootEnd], - newCode + sourceCode.text.substring(firstRootStart, secondRootStart), - )), - }); - } else if (order === 'after') { - context.report({ - node: secondNode.node, - message, - fix: canFix && ((fixer) => fixer.replaceTextRange( - [secondRootStart, firstRootEnd], - sourceCode.text.substring(secondRootEnd, firstRootEnd) + newCode, - )), - }); + if (isNamed) { + const firstCode = sourceCode.text.slice(firstRootStart, firstRoot.range[1]); + const firstTrivia = sourceCode.text.slice(firstRoot.range[1], firstRootEnd); + const secondCode = sourceCode.text.slice(secondRootStart, secondRoot.range[1]); + const secondTrivia = sourceCode.text.slice(secondRoot.range[1], secondRootEnd); + + if (order === 'before') { + const trimmedTrivia = trimEnd(secondTrivia); + const gapCode = sourceCode.text.slice(firstRootEnd, secondRootStart - 1); + const whitespaces = secondTrivia.slice(trimmedTrivia.length); + context.report({ + node: secondNode.node, + message, + fix: (fixer) => fixer.replaceTextRange( + [firstRootStart, secondRootEnd], + `${secondCode},${trimmedTrivia}${firstCode}${firstTrivia}${gapCode}${whitespaces}`, + ), + }); + } else if (order === 'after') { + const trimmedTrivia = trimEnd(firstTrivia); + const gapCode = sourceCode.text.slice(secondRootEnd + 1, firstRootStart); + const whitespaces = firstTrivia.slice(trimmedTrivia.length); + context.report({ + node: secondNode.node, + message, + fix: (fixes) => fixes.replaceTextRange( + [secondRootStart, firstRootEnd], + `${gapCode}${firstCode},${trimmedTrivia}${secondCode}${whitespaces}`, + ), + }); + } + } else { + const canFix = isExports || canReorderItems(firstRoot, secondRoot); + let newCode = sourceCode.text.substring(secondRootStart, secondRootEnd); + + if (newCode[newCode.length - 1] !== '\n') { + newCode = `${newCode}\n`; + } + + if (order === 'before') { + context.report({ + node: secondNode.node, + message, + fix: canFix && ((fixer) => fixer.replaceTextRange( + [firstRootStart, secondRootEnd], + newCode + sourceCode.text.substring(firstRootStart, secondRootStart), + )), + }); + } else if (order === 'after') { + context.report({ + node: secondNode.node, + message, + fix: canFix && ((fixer) => fixer.replaceTextRange( + [secondRootStart, firstRootEnd], + sourceCode.text.substring(secondRootEnd, firstRootEnd) + newCode, + )), + }); + } } } -function reportOutOfOrder(context, imported, outOfOrder, order) { +function reportOutOfOrder(context, imported, outOfOrder, order, category) { outOfOrder.forEach(function (imp) { const found = imported.find(function hasHigherRank(importedItem) { return importedItem.rank > imp.rank; }); - fixOutOfOrder(context, found, imp, order); + fixOutOfOrder(context, found, imp, order, category); }); } -function makeOutOfOrderReport(context, imported) { +function makeOutOfOrderReport(context, imported, category) { const outOfOrder = findOutOfOrder(imported); if (!outOfOrder.length) { return; @@ -259,10 +399,10 @@ function makeOutOfOrderReport(context, imported) { const reversedImported = reverse(imported); const reversedOrder = findOutOfOrder(reversedImported); if (reversedOrder.length < outOfOrder.length) { - reportOutOfOrder(context, reversedImported, reversedOrder, 'after'); + reportOutOfOrder(context, reversedImported, reversedOrder, 'after', category); return; } - reportOutOfOrder(context, imported, outOfOrder, 'before'); + reportOutOfOrder(context, imported, outOfOrder, 'before', category); } const compareString = (a, b) => { @@ -276,7 +416,7 @@ const compareString = (a, b) => { }; /** Some parsers (languages without types) don't provide ImportKind */ -const DEAFULT_IMPORT_KIND = 'value'; +const DEFAULT_IMPORT_KIND = 'value'; const getNormalizedValue = (node, toLowerCase) => { const value = node.value; return toLowerCase ? String(value).toLowerCase() : value; @@ -302,6 +442,12 @@ function getSorter(alphabetizeOptions) { const b = B.length; for (let i = 0; i < Math.min(a, b); i++) { + // Skip comparing the first path segment, if they are relative segments for both imports + if (i === 0 && ((A[i] === '.' || A[i] === '..') && (B[i] === '.' || B[i] === '..'))) { + // If one is sibling and the other parent import, no need to compare at all, since the paths belong in different groups + if (A[i] !== B[i]) { break; } + continue; + } result = compareString(A[i], B[i]); if (result) { break; } } @@ -316,8 +462,8 @@ function getSorter(alphabetizeOptions) { // In case the paths are equal (result === 0), sort them by importKind if (!result && multiplierImportKind) { result = multiplierImportKind * compareString( - nodeA.node.importKind || DEAFULT_IMPORT_KIND, - nodeB.node.importKind || DEAFULT_IMPORT_KIND, + nodeA.node.importKind || DEFAULT_IMPORT_KIND, + nodeB.node.importKind || DEFAULT_IMPORT_KIND, ); } @@ -367,22 +513,38 @@ function computePathRank(ranks, pathGroups, path, maxPosition) { } } -function computeRank(context, ranks, importEntry, excludedImportTypes) { +function computeRank(context, ranks, importEntry, excludedImportTypes, isSortingTypesGroup) { let impType; let rank; + + const isTypeGroupInGroups = ranks.omittedTypes.indexOf('type') === -1; + const isTypeOnlyImport = importEntry.node.importKind === 'type'; + const isExcludedFromPathRank = isTypeOnlyImport && isTypeGroupInGroups && excludedImportTypes.has('type'); + if (importEntry.type === 'import:object') { impType = 'object'; - } else if (importEntry.node.importKind === 'type' && ranks.omittedTypes.indexOf('type') === -1) { + } else if (isTypeOnlyImport && isTypeGroupInGroups && !isSortingTypesGroup) { impType = 'type'; } else { impType = importType(importEntry.value, context); } - if (!excludedImportTypes.has(impType)) { + + if (!excludedImportTypes.has(impType) && !isExcludedFromPathRank) { rank = computePathRank(ranks.groups, ranks.pathGroups, importEntry.value, ranks.maxPosition); } + if (typeof rank === 'undefined') { rank = ranks.groups[impType]; + + if (typeof rank === 'undefined') { + return -1; + } } + + if (isTypeOnlyImport && isSortingTypesGroup) { + rank = ranks.groups.type + rank / 10; + } + if (importEntry.type !== 'import' && !importEntry.type.startsWith('import:')) { rank += 100; } @@ -390,10 +552,20 @@ function computeRank(context, ranks, importEntry, excludedImportTypes) { return rank; } -function registerNode(context, importEntry, ranks, imported, excludedImportTypes) { - const rank = computeRank(context, ranks, importEntry, excludedImportTypes); +function registerNode(context, importEntry, ranks, imported, excludedImportTypes, isSortingTypesGroup) { + const rank = computeRank(context, ranks, importEntry, excludedImportTypes, isSortingTypesGroup); if (rank !== -1) { - imported.push({ ...importEntry, rank }); + let importNode = importEntry.node; + + if (importEntry.type === 'require' && importNode.parent.parent.type === 'VariableDeclaration') { + importNode = importNode.parent.parent; + } + + imported.push({ + ...importEntry, + rank, + isMultiline: importNode.loc.end.line !== importNode.loc.start.line, + }); } } @@ -418,18 +590,14 @@ function getRequireBlock(node) { const types = ['builtin', 'external', 'internal', 'unknown', 'parent', 'sibling', 'index', 'object', 'type']; -// Creates an object with type-rank pairs. -// Example: { index: 0, sibling: 1, parent: 1, external: 1, builtin: 2, internal: 2 } -// Will throw an error if it contains a type that does not exist, or has a duplicate +/** + * Creates an object with type-rank pairs. + * + * Example: { index: 0, sibling: 1, parent: 1, external: 1, builtin: 2, internal: 2 } + */ function convertGroupsToRanks(groups) { const rankObject = groups.reduce(function (res, group, index) { [].concat(group).forEach(function (groupItem) { - if (types.indexOf(groupItem) === -1) { - throw new Error(`Incorrect configuration of the rule: Unknown type \`${JSON.stringify(groupItem)}\``); - } - if (res[groupItem] !== undefined) { - throw new Error(`Incorrect configuration of the rule: \`${groupItem}\` is duplicated`); - } res[groupItem] = index * 2; }); return res; @@ -493,7 +661,10 @@ function convertPathGroupsForRanks(pathGroups) { function fixNewLineAfterImport(context, previousImport) { const prevRoot = findRootNode(previousImport.node); const tokensToEndOfLine = takeTokensAfterWhile( - context.getSourceCode(), prevRoot, commentOnSameLineAs(prevRoot)); + getSourceCode(context), + prevRoot, + commentOnSameLineAs(prevRoot), + ); let endOfLine = prevRoot.range[1]; if (tokensToEndOfLine.length > 0) { @@ -503,7 +674,7 @@ function fixNewLineAfterImport(context, previousImport) { } function removeNewLineAfterImport(context, currentImport, previousImport) { - const sourceCode = context.getSourceCode(); + const sourceCode = getSourceCode(context); const prevRoot = findRootNode(previousImport.node); const currRoot = findRootNode(currentImport.node); const rangeToRemove = [ @@ -516,9 +687,9 @@ function removeNewLineAfterImport(context, currentImport, previousImport) { return undefined; } -function makeNewlinesBetweenReport(context, imported, newlinesBetweenImports, distinctGroup) { +function makeNewlinesBetweenReport(context, imported, newlinesBetweenImports_, newlinesBetweenTypeOnlyImports_, distinctGroup, isSortingTypesGroup, isConsolidatingSpaceBetweenImports) { const getNumberOfEmptyLinesBetween = (currentImport, previousImport) => { - const linesBetweenImports = context.getSourceCode().lines.slice( + const linesBetweenImports = getSourceCode(context).lines.slice( previousImport.node.loc.end.line, currentImport.node.loc.start.line - 1, ); @@ -529,35 +700,126 @@ function makeNewlinesBetweenReport(context, imported, newlinesBetweenImports, di let previousImport = imported[0]; imported.slice(1).forEach(function (currentImport) { - const emptyLinesBetween = getNumberOfEmptyLinesBetween(currentImport, previousImport); - const isStartOfDistinctGroup = getIsStartOfDistinctGroup(currentImport, previousImport); + const emptyLinesBetween = getNumberOfEmptyLinesBetween( + currentImport, + previousImport, + ); + + const isStartOfDistinctGroup = getIsStartOfDistinctGroup( + currentImport, + previousImport, + ); - if (newlinesBetweenImports === 'always' - || newlinesBetweenImports === 'always-and-inside-groups') { - if (currentImport.rank !== previousImport.rank && emptyLinesBetween === 0) { - if (distinctGroup || !distinctGroup && isStartOfDistinctGroup) { + const isTypeOnlyImport = currentImport.node.importKind === 'type'; + const isPreviousImportTypeOnlyImport = previousImport.node.importKind === 'type'; + + const isNormalImportNextToTypeOnlyImportAndRelevant = isTypeOnlyImport !== isPreviousImportTypeOnlyImport && isSortingTypesGroup; + + const isTypeOnlyImportAndRelevant = isTypeOnlyImport && isSortingTypesGroup; + + // In the special case where newlinesBetweenImports and consolidateIslands + // want the opposite thing, consolidateIslands wins + const newlinesBetweenImports = isSortingTypesGroup + && isConsolidatingSpaceBetweenImports + && (previousImport.isMultiline || currentImport.isMultiline) + && newlinesBetweenImports_ === 'never' + ? 'always-and-inside-groups' + : newlinesBetweenImports_; + + // In the special case where newlinesBetweenTypeOnlyImports and + // consolidateIslands want the opposite thing, consolidateIslands wins + const newlinesBetweenTypeOnlyImports = isSortingTypesGroup + && isConsolidatingSpaceBetweenImports + && (isNormalImportNextToTypeOnlyImportAndRelevant + || previousImport.isMultiline + || currentImport.isMultiline) + && newlinesBetweenTypeOnlyImports_ === 'never' + ? 'always-and-inside-groups' + : newlinesBetweenTypeOnlyImports_; + + const isNotIgnored = isTypeOnlyImportAndRelevant + && newlinesBetweenTypeOnlyImports !== 'ignore' + || !isTypeOnlyImportAndRelevant && newlinesBetweenImports !== 'ignore'; + + if (isNotIgnored) { + const shouldAssertNewlineBetweenGroups = (isTypeOnlyImportAndRelevant || isNormalImportNextToTypeOnlyImportAndRelevant) + && (newlinesBetweenTypeOnlyImports === 'always' + || newlinesBetweenTypeOnlyImports === 'always-and-inside-groups') + || !isTypeOnlyImportAndRelevant && !isNormalImportNextToTypeOnlyImportAndRelevant + && (newlinesBetweenImports === 'always' + || newlinesBetweenImports === 'always-and-inside-groups'); + + const shouldAssertNoNewlineWithinGroup = (isTypeOnlyImportAndRelevant || isNormalImportNextToTypeOnlyImportAndRelevant) + && newlinesBetweenTypeOnlyImports !== 'always-and-inside-groups' + || !isTypeOnlyImportAndRelevant && !isNormalImportNextToTypeOnlyImportAndRelevant + && newlinesBetweenImports !== 'always-and-inside-groups'; + + const shouldAssertNoNewlineBetweenGroup = !isSortingTypesGroup + || !isNormalImportNextToTypeOnlyImportAndRelevant + || newlinesBetweenTypeOnlyImports === 'never'; + + const isTheNewlineBetweenImportsInTheSameGroup = distinctGroup && currentImport.rank === previousImport.rank + || !distinctGroup && !isStartOfDistinctGroup; + + // Let's try to cut down on linting errors sent to the user + let alreadyReported = false; + + if (shouldAssertNewlineBetweenGroups) { + if (currentImport.rank !== previousImport.rank && emptyLinesBetween === 0) { + if (distinctGroup || isStartOfDistinctGroup) { + alreadyReported = true; + context.report({ + node: previousImport.node, + message: 'There should be at least one empty line between import groups', + fix: fixNewLineAfterImport(context, previousImport), + }); + } + } else if (emptyLinesBetween > 0 && shouldAssertNoNewlineWithinGroup) { + if (isTheNewlineBetweenImportsInTheSameGroup) { + alreadyReported = true; + context.report({ + node: previousImport.node, + message: 'There should be no empty line within import group', + fix: removeNewLineAfterImport(context, currentImport, previousImport), + }); + } + } + } else if (emptyLinesBetween > 0 && shouldAssertNoNewlineBetweenGroup) { + alreadyReported = true; + context.report({ + node: previousImport.node, + message: 'There should be no empty line between import groups', + fix: removeNewLineAfterImport(context, currentImport, previousImport), + }); + } + + if (!alreadyReported && isConsolidatingSpaceBetweenImports) { + if (emptyLinesBetween === 0 && currentImport.isMultiline) { context.report({ node: previousImport.node, - message: 'There should be at least one empty line between import groups', + message: 'There should be at least one empty line between this import and the multi-line import that follows it', fix: fixNewLineAfterImport(context, previousImport), }); - } - } else if (emptyLinesBetween > 0 - && newlinesBetweenImports !== 'always-and-inside-groups') { - if (distinctGroup && currentImport.rank === previousImport.rank || !distinctGroup && !isStartOfDistinctGroup) { + } else if (emptyLinesBetween === 0 && previousImport.isMultiline) { + context.report({ + node: previousImport.node, + message: 'There should be at least one empty line between this multi-line import and the import that follows it', + fix: fixNewLineAfterImport(context, previousImport), + }); + } else if ( + emptyLinesBetween > 0 + && !previousImport.isMultiline + && !currentImport.isMultiline + && isTheNewlineBetweenImportsInTheSameGroup + ) { context.report({ node: previousImport.node, - message: 'There should be no empty line within import group', + message: + 'There should be no empty lines between this single-line import and the single-line import that follows it', fix: removeNewLineAfterImport(context, currentImport, previousImport), }); } } - } else if (emptyLinesBetween > 0) { - context.report({ - node: previousImport.node, - message: 'There should be no empty line between import groups', - fix: removeNewLineAfterImport(context, currentImport, previousImport), - }); } previousImport = currentImport; @@ -592,6 +854,17 @@ module.exports = { properties: { groups: { type: 'array', + uniqueItems: true, + items: { + oneOf: [ + { enum: types }, + { + type: 'array', + uniqueItems: true, + items: { enum: types }, + }, + ], + }, }, pathGroupsExcludedImportTypes: { type: 'array', @@ -632,6 +905,48 @@ module.exports = { 'never', ], }, + 'newlines-between-types': { + enum: [ + 'ignore', + 'always', + 'always-and-inside-groups', + 'never', + ], + }, + consolidateIslands: { + enum: [ + 'inside-groups', + 'never', + ], + }, + sortTypesGroup: { + type: 'boolean', + default: false, + }, + named: { + default: false, + oneOf: [{ + type: 'boolean', + }, { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + import: { type: 'boolean' }, + export: { type: 'boolean' }, + require: { type: 'boolean' }, + cjsExports: { type: 'boolean' }, + types: { + type: 'string', + enum: [ + 'mixed', + 'types-first', + 'types-last', + ], + }, + }, + additionalProperties: false, + }], + }, alphabetize: { type: 'object', properties: { @@ -656,14 +971,103 @@ module.exports = { }, }, additionalProperties: false, + dependencies: { + sortTypesGroup: { + oneOf: [ + { + // When sortTypesGroup is true, groups must NOT be an array that does not contain 'type' + properties: { + sortTypesGroup: { enum: [true] }, + groups: { + not: { + type: 'array', + uniqueItems: true, + items: { + oneOf: [ + { enum: types.filter((t) => t !== 'type') }, + { + type: 'array', + uniqueItems: true, + items: { enum: types.filter((t) => t !== 'type') }, + }, + ], + }, + }, + }, + }, + required: ['groups'], + }, + { + properties: { + sortTypesGroup: { enum: [false] }, + }, + }, + ], + }, + 'newlines-between-types': { + properties: { + sortTypesGroup: { enum: [true] }, + }, + required: ['sortTypesGroup'], + }, + consolidateIslands: { + oneOf: [ + { + properties: { + consolidateIslands: { enum: ['inside-groups'] }, + }, + anyOf: [ + { + properties: { + 'newlines-between': { enum: ['always-and-inside-groups'] }, + }, + required: ['newlines-between'], + }, + { + properties: { + 'newlines-between-types': { enum: ['always-and-inside-groups'] }, + }, + required: ['newlines-between-types'], + }, + ], + }, + { + properties: { + consolidateIslands: { enum: ['never'] }, + }, + }, + ], + }, + }, }, ], }, - create: function importOrderRule(context) { + create(context) { const options = context.options[0] || {}; const newlinesBetweenImports = options['newlines-between'] || 'ignore'; + const newlinesBetweenTypeOnlyImports = options['newlines-between-types'] || newlinesBetweenImports; const pathGroupsExcludedImportTypes = new Set(options.pathGroupsExcludedImportTypes || ['builtin', 'external', 'object']); + const sortTypesGroup = options.sortTypesGroup; + const consolidateIslands = options.consolidateIslands || 'never'; + + const named = { + types: 'mixed', + ...typeof options.named === 'object' ? { + ...options.named, + import: 'import' in options.named ? options.named.import : options.named.enabled, + export: 'export' in options.named ? options.named.export : options.named.enabled, + require: 'require' in options.named ? options.named.require : options.named.enabled, + cjsExports: 'cjsExports' in options.named ? options.named.cjsExports : options.named.enabled, + } : { + import: options.named, + export: options.named, + require: options.named, + cjsExports: options.named, + }, + }; + + const namedGroups = named.types === 'mixed' ? [] : named.types === 'types-last' ? ['value'] : ['type']; const alphabetize = getAlphabetizeConfig(options); const distinctGroup = options.distinctGroup == null ? defaultDistinctGroup : !!options.distinctGroup; let ranks; @@ -686,6 +1090,10 @@ module.exports = { }; } const importMap = new Map(); + const exportMap = new Map(); + + const isTypeGroupInGroups = ranks.omittedTypes.indexOf('type') === -1; + const isSortingTypesGroup = isTypeGroupInGroups && sortTypesGroup; function getBlockImports(node) { if (!importMap.has(node)) { @@ -694,8 +1102,38 @@ module.exports = { return importMap.get(node); } + function getBlockExports(node) { + if (!exportMap.has(node)) { + exportMap.set(node, []); + } + return exportMap.get(node); + } + + function makeNamedOrderReport(context, namedImports) { + if (namedImports.length > 1) { + const imports = namedImports.map( + (namedImport) => { + const kind = namedImport.kind || 'value'; + const rank = namedGroups.findIndex((entry) => [].concat(entry).indexOf(kind) > -1); + + return { + displayName: namedImport.value, + rank: rank === -1 ? namedGroups.length : rank, + ...namedImport, + value: `${namedImport.value}:${namedImport.alias || ''}`, + }; + }); + + if (alphabetize.order !== 'ignore') { + mutateRanksToAlphabetize(imports, alphabetize); + } + + makeOutOfOrderReport(context, imports, categories.named); + } + } + return { - ImportDeclaration: function handleImports(node) { + ImportDeclaration(node) { // Ignoring unassigned imports unless warnOnUnassignedImports is set if (node.specifiers.length || options.warnOnUnassignedImports) { const name = node.source.value; @@ -710,26 +1148,47 @@ module.exports = { ranks, getBlockImports(node.parent), pathGroupsExcludedImportTypes, + isSortingTypesGroup, ); + + if (named.import) { + makeNamedOrderReport( + context, + node.specifiers.filter( + (specifier) => specifier.type === 'ImportSpecifier').map( + (specifier) => ({ + node: specifier, + value: specifier.imported.name, + type: 'import', + kind: specifier.importKind, + ...specifier.local.range[0] !== specifier.imported.range[0] && { + alias: specifier.local.name, + }, + }), + ), + ); + } } }, - TSImportEqualsDeclaration: function handleImports(node) { - let displayName; - let value; - let type; + TSImportEqualsDeclaration(node) { // skip "export import"s if (node.isExport) { return; } + + let displayName; + let value; + let type; if (node.moduleReference.type === 'TSExternalModuleReference') { value = node.moduleReference.expression.value; displayName = value; type = 'import'; } else { value = ''; - displayName = context.getSourceCode().getText(node.moduleReference); + displayName = getSourceCode(context).getText(node.moduleReference); type = 'import:object'; } + registerNode( context, { @@ -741,9 +1200,10 @@ module.exports = { ranks, getBlockImports(node.parent), pathGroupsExcludedImportTypes, + isSortingTypesGroup, ); }, - CallExpression: function handleRequires(node) { + CallExpression(node) { if (!isStaticRequire(node)) { return; } @@ -763,22 +1223,126 @@ module.exports = { ranks, getBlockImports(block), pathGroupsExcludedImportTypes, + isSortingTypesGroup, ); }, - 'Program:exit': function reportAndReset() { + ...named.require && { + VariableDeclarator(node) { + if (node.id.type === 'ObjectPattern' && isRequireExpression(node.init)) { + for (let i = 0; i < node.id.properties.length; i++) { + if ( + node.id.properties[i].key.type !== 'Identifier' + || node.id.properties[i].value.type !== 'Identifier' + ) { + return; + } + } + makeNamedOrderReport( + context, + node.id.properties.map((prop) => ({ + node: prop, + value: prop.key.name, + type: 'require', + ...prop.key.range[0] !== prop.value.range[0] && { + alias: prop.value.name, + }, + })), + ); + } + }, + }, + ...named.export && { + ExportNamedDeclaration(node) { + makeNamedOrderReport( + context, + node.specifiers.map((specifier) => ({ + node: specifier, + value: specifier.local.name, + type: 'export', + kind: specifier.exportKind, + ...specifier.local.range[0] !== specifier.exported.range[0] && { + alias: specifier.exported.name, + }, + })), + ); + }, + }, + ...named.cjsExports && { + AssignmentExpression(node) { + if (node.parent.type === 'ExpressionStatement') { + if (isCJSExports(context, node.left)) { + if (node.right.type === 'ObjectExpression') { + for (let i = 0; i < node.right.properties.length; i++) { + if ( + !node.right.properties[i].key + || node.right.properties[i].key.type !== 'Identifier' + || !node.right.properties[i].value + || node.right.properties[i].value.type !== 'Identifier' + ) { + return; + } + } + + makeNamedOrderReport( + context, + node.right.properties.map((prop) => ({ + node: prop, + value: prop.key.name, + type: 'export', + ...prop.key.range[0] !== prop.value.range[0] && { + alias: prop.value.name, + }, + })), + ); + } + } else { + const nameParts = getNamedCJSExports(context, node.left); + if (nameParts && nameParts.length > 0) { + const name = nameParts.join('.'); + getBlockExports(node.parent.parent).push({ + node, + value: name, + displayName: name, + type: 'export', + rank: 0, + }); + } + } + } + }, + }, + 'Program:exit'() { importMap.forEach((imported) => { - if (newlinesBetweenImports !== 'ignore') { - makeNewlinesBetweenReport(context, imported, newlinesBetweenImports, distinctGroup); + if (newlinesBetweenImports !== 'ignore' || newlinesBetweenTypeOnlyImports !== 'ignore') { + makeNewlinesBetweenReport( + context, + imported, + newlinesBetweenImports, + newlinesBetweenTypeOnlyImports, + distinctGroup, + isSortingTypesGroup, + consolidateIslands === 'inside-groups' + && (newlinesBetweenImports === 'always-and-inside-groups' + || newlinesBetweenTypeOnlyImports === 'always-and-inside-groups'), + ); } if (alphabetize.order !== 'ignore') { mutateRanksToAlphabetize(imported, alphabetize); } - makeOutOfOrderReport(context, imported); + makeOutOfOrderReport(context, imported, categories.import); + }); + + exportMap.forEach((exported) => { + if (alphabetize.order !== 'ignore') { + mutateRanksToAlphabetize(exported, alphabetize); + makeOutOfOrderReport(context, exported, categories.exports); + } }); importMap.clear(); + exportMap.clear(); }, }; }, diff --git a/src/rules/unambiguous.js b/src/rules/unambiguous.js index 91152ea2af..2491fad3eb 100644 --- a/src/rules/unambiguous.js +++ b/src/rules/unambiguous.js @@ -5,6 +5,7 @@ import { isModule } from 'eslint-module-utils/unambiguous'; import docsUrl from '../docsUrl'; +import sourceType from '../core/sourceType'; module.exports = { meta: { @@ -19,7 +20,7 @@ module.exports = { create(context) { // ignore non-modules - if (context.parserOptions.sourceType !== 'module') { + if (sourceType(context) !== 'module') { return {}; } diff --git a/src/scc.js b/src/scc.js new file mode 100644 index 0000000000..c2b2c637dc --- /dev/null +++ b/src/scc.js @@ -0,0 +1,92 @@ +import calculateScc from '@rtsao/scc'; +import { hashObject } from 'eslint-module-utils/hash'; +import resolve from 'eslint-module-utils/resolve'; +import ExportMapBuilder from './exportMap/builder'; +import childContext from './exportMap/childContext'; + +let cache = new Map(); + +export default class StronglyConnectedComponentsBuilder { + static clearCache() { + cache = new Map(); + } + + static get(source, context) { + const path = resolve(source, context); + if (path == null) { return null; } + return StronglyConnectedComponentsBuilder.for(childContext(path, context)); + } + + static for(context) { + const settingsHash = hashObject({ + settings: context.settings, + parserOptions: context.parserOptions, + parserPath: context.parserPath, + }).digest('hex'); + const cacheKey = context.path + settingsHash; + if (cache.has(cacheKey)) { + return cache.get(cacheKey); + } + const scc = StronglyConnectedComponentsBuilder.calculate(context); + const visitedFiles = Object.keys(scc); + visitedFiles.forEach((filePath) => cache.set(filePath + settingsHash, scc)); + return scc; + } + + static calculate(context) { + const exportMap = ExportMapBuilder.for(context); + const adjacencyList = this.exportMapToAdjacencyList(exportMap); + const calculatedScc = calculateScc(adjacencyList); + return StronglyConnectedComponentsBuilder.calculatedSccToPlainObject(calculatedScc); + } + + /** @returns {Map>} for each dep, what are its direct deps */ + static exportMapToAdjacencyList(initialExportMap) { + const adjacencyList = new Map(); + // BFS + function visitNode(exportMap) { + if (!exportMap) { + return; + } + exportMap.imports.forEach((v, importedPath) => { + const from = exportMap.path; + const to = importedPath; + + // Ignore type-only imports, because we care only about SCCs of value imports + const toTraverse = [...v.declarations].filter(({ isOnlyImportingTypes }) => !isOnlyImportingTypes); + if (toTraverse.length === 0) { return; } + + if (!adjacencyList.has(from)) { + adjacencyList.set(from, new Set()); + } + + if (adjacencyList.get(from).has(to)) { + return; // prevent endless loop + } + adjacencyList.get(from).add(to); + visitNode(v.getter()); + }); + } + visitNode(initialExportMap); + // Fill gaps + adjacencyList.forEach((values) => { + values.forEach((value) => { + if (!adjacencyList.has(value)) { + adjacencyList.set(value, new Set()); + } + }); + }); + return adjacencyList; + } + + /** @returns {Record} for each key, its SCC's index */ + static calculatedSccToPlainObject(sccs) { + const obj = {}; + sccs.forEach((scc, index) => { + scc.forEach((node) => { + obj[node] = index; + }); + }); + return obj; + } +} diff --git a/tests/files/issue210.config.flat.js b/tests/files/issue210.config.flat.js new file mode 100644 index 0000000000..c894376f48 --- /dev/null +++ b/tests/files/issue210.config.flat.js @@ -0,0 +1,3 @@ +exports.languageOptions = { + sourceType: 'module', +} diff --git a/tests/files/just-json-files/eslint.config.js b/tests/files/just-json-files/eslint.config.js new file mode 100644 index 0000000000..b1bf2070bb --- /dev/null +++ b/tests/files/just-json-files/eslint.config.js @@ -0,0 +1,28 @@ +var jsonPlugin = require('eslint-plugin-json'); + +if (!jsonPlugin.processors.json) { + jsonPlugin.processors.json = jsonPlugin.processors['.json']; +} + +module.exports = [ + { + files: ['tests/files/just-json-files/*.json'], + plugins:{ + json: jsonPlugin, + }, + processor: 'json/json', + rules: Object.assign( + {}, + { + 'import/no-unused-modules': [ + 'error', + { + 'missingExports': false, + 'unusedExports': true, + }, + ], + }, + jsonPlugin.configs.recommended.rules + ) + }, +]; diff --git a/tests/files/minified/no-newline.js b/tests/files/minified/no-newline.js new file mode 100644 index 0000000000..261989a75c --- /dev/null +++ b/tests/files/minified/no-newline.js @@ -0,0 +1,3 @@ +function y() { + console.log("y"); +}export {y}; diff --git a/tests/files/minified/one-line-no-semi-renamed.js b/tests/files/minified/one-line-no-semi-renamed.js new file mode 100644 index 0000000000..601f9c7b71 --- /dev/null +++ b/tests/files/minified/one-line-no-semi-renamed.js @@ -0,0 +1 @@ +function a(){console.log('foo')}export{a as foo}; diff --git a/tests/files/minified/one-line-no-semi.js b/tests/files/minified/one-line-no-semi.js new file mode 100644 index 0000000000..09a6328118 --- /dev/null +++ b/tests/files/minified/one-line-no-semi.js @@ -0,0 +1 @@ +function a(){return true}export{a}; diff --git a/tests/files/minified/one-line.js b/tests/files/minified/one-line.js new file mode 100644 index 0000000000..b79898eb33 --- /dev/null +++ b/tests/files/minified/one-line.js @@ -0,0 +1 @@ +function a(){console.log("foo")};export{a}; diff --git a/tests/files/no-named-as-default/exports.js b/tests/files/no-named-as-default/exports.js new file mode 100644 index 0000000000..62402634f6 --- /dev/null +++ b/tests/files/no-named-as-default/exports.js @@ -0,0 +1,4 @@ +const variable = 1; + +export { variable }; +export default variable; diff --git a/tests/files/no-named-as-default/misleading-re-exports.js b/tests/files/no-named-as-default/misleading-re-exports.js new file mode 100644 index 0000000000..8d36a0866e --- /dev/null +++ b/tests/files/no-named-as-default/misleading-re-exports.js @@ -0,0 +1,2 @@ +export { variable as something } from './exports'; +export { something as default } from './something'; diff --git a/tests/files/no-named-as-default/no-default-export.js b/tests/files/no-named-as-default/no-default-export.js new file mode 100644 index 0000000000..db30747974 --- /dev/null +++ b/tests/files/no-named-as-default/no-default-export.js @@ -0,0 +1 @@ +export const foobar = 4; diff --git a/tests/files/no-named-as-default/re-exports.js b/tests/files/no-named-as-default/re-exports.js new file mode 100644 index 0000000000..20306c1829 --- /dev/null +++ b/tests/files/no-named-as-default/re-exports.js @@ -0,0 +1,2 @@ +export { something as default } from "./something"; +export { something } from "./something"; diff --git a/tests/files/no-named-as-default/something.js b/tests/files/no-named-as-default/something.js new file mode 100644 index 0000000000..d8fd6851b3 --- /dev/null +++ b/tests/files/no-named-as-default/something.js @@ -0,0 +1 @@ +export const something = 42; diff --git a/tests/src/cli.js b/tests/src/cli.js index 8a73454878..60b8382d09 100644 --- a/tests/src/cli.js +++ b/tests/src/cli.js @@ -15,17 +15,29 @@ describe('CLI regression tests', function () { let cli; before(function () { if (ESLint) { - eslint = new ESLint({ - useEslintrc: false, - overrideConfigFile: './tests/files/issue210.config.js', - rulePaths: ['./src/rules'], - overrideConfig: { - rules: { - named: 2, + if (semver.satisfies(eslintPkg.version, '>= 9')) { + eslint = new ESLint({ + overrideConfigFile: './tests/files/issue210.config.flat.js', + overrideConfig: { + rules: { + 'import/named': 2, + }, }, - }, - plugins: { 'eslint-plugin-import': importPlugin }, - }); + plugins: { 'eslint-plugin-import': importPlugin }, + }); + } else { + eslint = new ESLint({ + useEslintrc: false, + overrideConfigFile: './tests/files/issue210.config.js', + rulePaths: ['./src/rules'], + overrideConfig: { + rules: { + named: 2, + }, + }, + plugins: { 'eslint-plugin-import': importPlugin }, + }); + } } else { cli = new CLIEngine({ useEslintrc: false, @@ -56,13 +68,20 @@ describe('CLI regression tests', function () { this.skip(); } else { if (ESLint) { - eslint = new ESLint({ - useEslintrc: false, - overrideConfigFile: './tests/files/just-json-files/.eslintrc.json', - rulePaths: ['./src/rules'], - ignore: false, - plugins: { 'eslint-plugin-import': importPlugin }, - }); + if (semver.satisfies(eslintPkg.version, '>= 9')) { + eslint = new ESLint({ + overrideConfigFile: './tests/files/just-json-files/eslint.config.js', + plugins: { 'eslint-plugin-import': importPlugin }, + }); + } else { + eslint = new ESLint({ + useEslintrc: false, + overrideConfigFile: './tests/files/just-json-files/.eslintrc.json', + rulePaths: ['./src/rules'], + ignore: false, + plugins: { 'eslint-plugin-import': importPlugin }, + }); + } } else { cli = new CLIEngine({ useEslintrc: false, diff --git a/tests/src/core/getExports.js b/tests/src/core/getExports.js index 1dd6e88014..a02edb85c2 100644 --- a/tests/src/core/getExports.js +++ b/tests/src/core/getExports.js @@ -1,15 +1,18 @@ import { expect } from 'chai'; +import fs from 'fs'; import semver from 'semver'; import sinon from 'sinon'; import eslintPkg from 'eslint/package.json'; +import { test as testUnambiguous } from 'eslint-module-utils/unambiguous'; import typescriptPkg from 'typescript/package.json'; import * as tsConfigLoader from 'tsconfig-paths/lib/tsconfig-loader'; -import ExportMap from '../../../src/ExportMap'; - -import * as fs from 'fs'; +import ExportMapBuilder from '../../../src/exportMap/builder'; import { getFilename } from '../utils'; -import { test as testUnambiguous } from 'eslint-module-utils/unambiguous'; + +const babelPath = require.resolve('babel-eslint'); +const hypotheticalLocation = babelPath.replace('index.js', 'visitor-keys.js'); +const isVisitorKeysSupported = fs.existsSync(hypotheticalLocation); describe('ExportMap', function () { const fakeContext = Object.assign( @@ -21,14 +24,14 @@ describe('ExportMap', function () { }, { settings: {}, - parserPath: 'babel-eslint', + parserPath: require.resolve('babel-eslint'), }, ); it('handles ExportAllDeclaration', function () { let imports; expect(function () { - imports = ExportMap.get('./export-all', fakeContext); + imports = ExportMapBuilder.get('./export-all', fakeContext); }).not.to.throw(Error); expect(imports).to.exist; @@ -36,26 +39,35 @@ describe('ExportMap', function () { }); - it('returns a cached copy on subsequent requests', function () { - expect(ExportMap.get('./named-exports', fakeContext)) - .to.exist.and.equal(ExportMap.get('./named-exports', fakeContext)); + (isVisitorKeysSupported ? it : it.skip)('returns a cached copy on subsequent requests', function () { + expect(ExportMapBuilder.get('./named-exports', fakeContext)) + .to.exist.and.equal(ExportMapBuilder.get('./named-exports', fakeContext)); + }); + + it('does not return a cached copy if the parse does not yield a visitor keys', function () { + const mockContext = { + ...fakeContext, + parserPath: 'not-real', + }; + expect(ExportMapBuilder.get('./named-exports', mockContext)) + .to.exist.and.not.equal(ExportMapBuilder.get('./named-exports', mockContext)); }); it('does not return a cached copy after modification', (done) => { - const firstAccess = ExportMap.get('./mutator', fakeContext); + const firstAccess = ExportMapBuilder.get('./mutator', fakeContext); expect(firstAccess).to.exist; // mutate (update modified time) const newDate = new Date(); fs.utimes(getFilename('mutator.js'), newDate, newDate, (error) => { expect(error).not.to.exist; - expect(ExportMap.get('./mutator', fakeContext)).not.to.equal(firstAccess); + expect(ExportMapBuilder.get('./mutator', fakeContext)).not.to.equal(firstAccess); done(); }); }); it('does not return a cached copy with different settings', () => { - const firstAccess = ExportMap.get('./named-exports', fakeContext); + const firstAccess = ExportMapBuilder.get('./named-exports', fakeContext); expect(firstAccess).to.exist; const differentSettings = { @@ -63,7 +75,7 @@ describe('ExportMap', function () { parserPath: 'espree', }; - expect(ExportMap.get('./named-exports', differentSettings)) + expect(ExportMapBuilder.get('./named-exports', differentSettings)) .to.exist.and .not.to.equal(firstAccess); }); @@ -71,7 +83,7 @@ describe('ExportMap', function () { it('does not throw for a missing file', function () { let imports; expect(function () { - imports = ExportMap.get('./does-not-exist', fakeContext); + imports = ExportMapBuilder.get('./does-not-exist', fakeContext); }).not.to.throw(Error); expect(imports).not.to.exist; @@ -81,7 +93,7 @@ describe('ExportMap', function () { it('exports explicit names for a missing file in exports', function () { let imports; expect(function () { - imports = ExportMap.get('./exports-missing', fakeContext); + imports = ExportMapBuilder.get('./exports-missing', fakeContext); }).not.to.throw(Error); expect(imports).to.exist; @@ -92,7 +104,7 @@ describe('ExportMap', function () { it('finds exports for an ES7 module with babel-eslint', function () { const path = getFilename('jsx/FooES7.js'); const contents = fs.readFileSync(path, { encoding: 'utf8' }); - const imports = ExportMap.parse( + const imports = ExportMapBuilder.parse( path, contents, { parserPath: 'babel-eslint', settings: {} }, @@ -112,7 +124,7 @@ describe('ExportMap', function () { before('parse file', function () { const path = getFilename('deprecated.js'); const contents = fs.readFileSync(path, { encoding: 'utf8' }).replace(/[\r]\n/g, lineEnding); - imports = ExportMap.parse(path, contents, parseContext); + imports = ExportMapBuilder.parse(path, contents, parseContext); // sanity checks expect(imports.errors).to.be.empty; @@ -181,7 +193,7 @@ describe('ExportMap', function () { before('parse file', function () { const path = getFilename('deprecated-file.js'); const contents = fs.readFileSync(path, { encoding: 'utf8' }); - imports = ExportMap.parse(path, contents, parseContext); + imports = ExportMapBuilder.parse(path, contents, parseContext); // sanity checks expect(imports.errors).to.be.empty; @@ -243,7 +255,7 @@ describe('ExportMap', function () { it('works with espree & traditional namespace exports', function () { const path = getFilename('deep/a.js'); const contents = fs.readFileSync(path, { encoding: 'utf8' }); - const a = ExportMap.parse(path, contents, espreeContext); + const a = ExportMapBuilder.parse(path, contents, espreeContext); expect(a.errors).to.be.empty; expect(a.get('b').namespace).to.exist; expect(a.get('b').namespace.has('c')).to.be.true; @@ -252,7 +264,7 @@ describe('ExportMap', function () { it('captures namespace exported as default', function () { const path = getFilename('deep/default.js'); const contents = fs.readFileSync(path, { encoding: 'utf8' }); - const def = ExportMap.parse(path, contents, espreeContext); + const def = ExportMapBuilder.parse(path, contents, espreeContext); expect(def.errors).to.be.empty; expect(def.get('default').namespace).to.exist; expect(def.get('default').namespace.has('c')).to.be.true; @@ -261,7 +273,7 @@ describe('ExportMap', function () { it('works with babel-eslint & ES7 namespace exports', function () { const path = getFilename('deep-es7/a.js'); const contents = fs.readFileSync(path, { encoding: 'utf8' }); - const a = ExportMap.parse(path, contents, babelContext); + const a = ExportMapBuilder.parse(path, contents, babelContext); expect(a.errors).to.be.empty; expect(a.get('b').namespace).to.exist; expect(a.get('b').namespace.has('c')).to.be.true; @@ -278,7 +290,7 @@ describe('ExportMap', function () { const path = getFilename('deep/cache-1.js'); const contents = fs.readFileSync(path, { encoding: 'utf8' }); - a = ExportMap.parse(path, contents, espreeContext); + a = ExportMapBuilder.parse(path, contents, espreeContext); expect(a.errors).to.be.empty; expect(a.get('b').namespace).to.exist; @@ -304,10 +316,10 @@ describe('ExportMap', function () { context('Map API', function () { context('#size', function () { - it('counts the names', () => expect(ExportMap.get('./named-exports', fakeContext)) + it('counts the names', () => expect(ExportMapBuilder.get('./named-exports', fakeContext)) .to.have.property('size', 12)); - it('includes exported namespace size', () => expect(ExportMap.get('./export-all', fakeContext)) + it('includes exported namespace size', () => expect(ExportMapBuilder.get('./export-all', fakeContext)) .to.have.property('size', 1)); }); @@ -315,14 +327,14 @@ describe('ExportMap', function () { context('issue #210: self-reference', function () { it(`doesn't crash`, function () { - expect(() => ExportMap.get('./narcissist', fakeContext)).not.to.throw(Error); + expect(() => ExportMapBuilder.get('./narcissist', fakeContext)).not.to.throw(Error); }); it(`'has' circular reference`, function () { - expect(ExportMap.get('./narcissist', fakeContext)) + expect(ExportMapBuilder.get('./narcissist', fakeContext)) .to.exist.and.satisfy((m) => m.has('soGreat')); }); it(`can 'get' circular reference`, function () { - expect(ExportMap.get('./narcissist', fakeContext)) + expect(ExportMapBuilder.get('./narcissist', fakeContext)) .to.exist.and.satisfy((m) => m.get('soGreat') != null); }); }); @@ -335,7 +347,7 @@ describe('ExportMap', function () { let imports; before('load imports', function () { - imports = ExportMap.get('./typescript.ts', context); + imports = ExportMapBuilder.get('./typescript.ts', context); }); it('returns nothing for a TypeScript file', function () { @@ -372,7 +384,7 @@ describe('ExportMap', function () { before('load imports', function () { this.timeout(20e3); // takes a long time :shrug: sinon.spy(tsConfigLoader, 'tsConfigLoader'); - imports = ExportMap.get('./typescript.ts', context); + imports = ExportMapBuilder.get('./typescript.ts', context); }); after('clear spies', function () { tsConfigLoader.tsConfigLoader.restore(); @@ -414,9 +426,9 @@ describe('ExportMap', function () { }, }; expect(tsConfigLoader.tsConfigLoader.callCount).to.equal(0); - ExportMap.parse('./baz.ts', 'export const baz = 5', customContext); + ExportMapBuilder.parse('./baz.ts', 'export const baz = 5', customContext); expect(tsConfigLoader.tsConfigLoader.callCount).to.equal(1); - ExportMap.parse('./baz.ts', 'export const baz = 5', customContext); + ExportMapBuilder.parse('./baz.ts', 'export const baz = 5', customContext); expect(tsConfigLoader.tsConfigLoader.callCount).to.equal(1); const differentContext = { @@ -426,17 +438,17 @@ describe('ExportMap', function () { }, }; - ExportMap.parse('./baz.ts', 'export const baz = 5', differentContext); + ExportMapBuilder.parse('./baz.ts', 'export const baz = 5', differentContext); expect(tsConfigLoader.tsConfigLoader.callCount).to.equal(2); }); it('should cache after parsing for an ambiguous module', function () { const source = './typescript-declare-module.ts'; - const parseSpy = sinon.spy(ExportMap, 'parse'); + const parseSpy = sinon.spy(ExportMapBuilder, 'parse'); - expect(ExportMap.get(source, context)).to.be.null; + expect(ExportMapBuilder.get(source, context)).to.be.null; - ExportMap.get(source, context); + ExportMapBuilder.get(source, context); expect(parseSpy.callCount).to.equal(1); @@ -453,6 +465,10 @@ describe('ExportMap', function () { ['bar.js', true], ['deep-es7/b.js', true], ['common.js', false], + ['./minified/no-newline.js', true], + ['./minified/one-line-no-semi-renamed.js', true], + ['./minified/one-line-no-semi.js', true], + ['./minified/one-line.js', true], ]; for (const [testFile, expectedRegexResult] of testFiles) { diff --git a/tests/src/core/parse.js b/tests/src/core/parse.js index 275b93982b..b213268907 100644 --- a/tests/src/core/parse.js +++ b/tests/src/core/parse.js @@ -138,4 +138,18 @@ describe('parse(content, { settings, ecmaFeatures })', function () { parseStubParser.parse = parseSpy; expect(parse.bind(null, path, content, { settings: {}, parserPath: 'espree', languageOptions: { parserOptions: { sourceType: 'module', ecmaVersion: 2015, ecmaFeatures: { jsx: true } } }, parserOptions: { sourceType: 'script' } })).not.to.throw(Error); }); + + it('passes ecmaVersion and sourceType from languageOptions to parser', () => { + const parseSpy = sinon.spy(); + const languageOptions = { ecmaVersion: 'latest', sourceType: 'module', parserOptions: { ecmaFeatures: { jsx: true } } }; + parseStubParser.parse = parseSpy; + parse(path, content, { settings: {}, parserPath: parseStubParserPath, languageOptions }); + expect(parseSpy.args[0][1], 'custom parser to clone the parserOptions object').to.not.equal(languageOptions); + expect(parseSpy.args[0][1], 'custom parser to get ecmaFeatures in parserOptions which is a clone of ecmaFeatures passed in') + .to.have.property('ecmaFeatures') + .that.is.eql(languageOptions.parserOptions.ecmaFeatures) + .and.is.not.equal(languageOptions.parserOptions.ecmaFeatures); + expect(parseSpy.args[0][1], 'custom parser to get ecmaVersion in parserOptions from languageOptions').to.have.property('ecmaVersion', languageOptions.ecmaVersion); + expect(parseSpy.args[0][1], 'custom parser to get sourceType in parserOptions from languageOptions').to.have.property('sourceType', languageOptions.sourceType); + }); }); diff --git a/tests/src/exportMap/childContext.js b/tests/src/exportMap/childContext.js new file mode 100644 index 0000000000..5bc53fdb06 --- /dev/null +++ b/tests/src/exportMap/childContext.js @@ -0,0 +1,121 @@ +import { expect } from 'chai'; +import { hashObject } from 'eslint-module-utils/hash'; + +import childContext from '../../../src/exportMap/childContext'; + +describe('childContext', () => { + const settings = { + setting1: true, + setting2: false, + }; + const parserOptions = { + ecmaVersion: 'latest', + sourceType: 'module', + }; + const parserPath = 'path/to/parser'; + const path = 'path/to/src/file'; + const languageOptions = { + ecmaVersion: 2024, + sourceType: 'module', + parser: { + parseForESLint() { return 'parser1'; }, + }, + }; + const languageOptionsHash = hashObject({ languageOptions }).digest('hex'); + const parserOptionsHash = hashObject({ parserOptions }).digest('hex'); + const settingsHash = hashObject({ settings }).digest('hex'); + + // https://github.com/import-js/eslint-plugin-import/issues/3051 + it('should pass context properties through, if present', () => { + const mockContext = { + settings, + parserOptions, + parserPath, + languageOptions, + }; + + const result = childContext(path, mockContext); + + expect(result.settings).to.deep.equal(settings); + expect(result.parserOptions).to.deep.equal(parserOptions); + expect(result.parserPath).to.equal(parserPath); + expect(result.languageOptions).to.deep.equal(languageOptions); + }); + + it('should add path and cacheKey to context', () => { + const mockContext = { + settings, + parserOptions, + parserPath, + }; + + const result = childContext(path, mockContext); + + expect(result.path).to.equal(path); + expect(result.cacheKey).to.be.a('string'); + }); + + it('should construct cache key out of languageOptions if present', () => { + const mockContext = { + settings, + languageOptions, + }; + + const result = childContext(path, mockContext); + + expect(result.cacheKey).to.equal(languageOptionsHash + settingsHash + path); + }); + + it('should use the same cache key upon multiple calls', () => { + const mockContext = { + settings, + languageOptions, + }; + + let result = childContext(path, mockContext); + + const expectedCacheKey = languageOptionsHash + settingsHash + path; + expect(result.cacheKey).to.equal(expectedCacheKey); + + result = childContext(path, mockContext); + expect(result.cacheKey).to.equal(expectedCacheKey); + }); + + it('should update cacheKey if different languageOptions are passed in', () => { + const mockContext = { + settings, + languageOptions, + }; + + let result = childContext(path, mockContext); + + const firstCacheKey = languageOptionsHash + settingsHash + path; + expect(result.cacheKey).to.equal(firstCacheKey); + + // Second run with different parser function + mockContext.languageOptions = { + ...languageOptions, + parser: { + parseForESLint() { return 'parser2'; }, + }, + }; + + result = childContext(path, mockContext); + + const secondCacheKey = hashObject({ languageOptions: mockContext.languageOptions }).digest('hex') + settingsHash + path; + expect(result.cacheKey).to.not.equal(firstCacheKey); + expect(result.cacheKey).to.equal(secondCacheKey); + }); + + it('should construct cache key out of parserOptions and parserPath if no languageOptions', () => { + const mockContext = { + settings, + parserOptions, + parserPath, + }; + + const result = childContext(path, mockContext); + + expect(result.cacheKey).to.equal(String(parserPath) + parserOptionsHash + settingsHash + path); + }); +}); diff --git a/tests/src/package.js b/tests/src/package.js index 08138084c6..c56bd1333d 100644 --- a/tests/src/package.js +++ b/tests/src/package.js @@ -45,6 +45,13 @@ describe('package', function () { }); }); + function getRulePath(ruleName) { + // 'require' does not work with dynamic paths because of the compilation step by babel + // (which resolves paths according to the root folder configuration) + // the usage of require.resolve on a static path gets around this + return path.resolve(require.resolve('rules/no-unresolved'), '..', ruleName); + } + it('has configs only for rules that exist', function () { for (const configFile in module.configs) { const preamble = 'import/'; @@ -54,13 +61,6 @@ describe('package', function () { .not.to.throw(Error); } } - - function getRulePath(ruleName) { - // 'require' does not work with dynamic paths because of the compilation step by babel - // (which resolves paths according to the root folder configuration) - // the usage of require.resolve on a static path gets around this - return path.resolve(require.resolve('rules/no-unresolved'), '..', ruleName); - } }); it('marks deprecated rules in their metadata', function () { diff --git a/tests/src/rule-tester.js b/tests/src/rule-tester.js new file mode 100644 index 0000000000..103f2fd6fe --- /dev/null +++ b/tests/src/rule-tester.js @@ -0,0 +1,47 @@ +import { RuleTester } from 'eslint'; +import { version as eslintVersion } from 'eslint/package.json'; +import semver from 'semver'; + +export const usingFlatConfig = semver.major(eslintVersion) >= 9; + +export function withoutAutofixOutput(test) { + return { ...test, ...usingFlatConfig || { output: test.code } }; +} + +class FlatCompatRuleTester { + constructor(testerConfig = { parserOptions: { sourceType: 'script' } }) { + this._tester = new RuleTester(FlatCompatRuleTester._flatCompat(testerConfig)); + } + + run(ruleName, rule, tests) { + this._tester.run(ruleName, rule, { + valid: tests.valid.map((t) => FlatCompatRuleTester._flatCompat(t)), + invalid: tests.invalid.map((t) => FlatCompatRuleTester._flatCompat(t)), + }); + } + + static _flatCompat(config) { + if (!config || !usingFlatConfig || typeof config !== 'object') { + return config; + } + + const { parser, parserOptions = {}, languageOptions = {}, ...remainingConfig } = config; + const { ecmaVersion, sourceType, ...remainingParserOptions } = parserOptions; + const parserObj = typeof parser === 'string' ? require(parser) : parser; + + return { + ...remainingConfig, + languageOptions: { + ...languageOptions, + ...parserObj ? { parser: parserObj } : {}, + ...ecmaVersion ? { ecmaVersion } : {}, + ...sourceType ? { sourceType } : {}, + parserOptions: { + ...remainingParserOptions, + }, + }, + }; + } +} + +export { FlatCompatRuleTester as RuleTester }; diff --git a/tests/src/rules/consistent-type-specifier-style.js b/tests/src/rules/consistent-type-specifier-style.js index 7799238c32..139457ff60 100644 --- a/tests/src/rules/consistent-type-specifier-style.js +++ b/tests/src/rules/consistent-type-specifier-style.js @@ -1,4 +1,4 @@ -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; import { test, parsers, tsVersionSatisfies, eslintVersionSatisfies, typescriptEslintParserSatisfies } from '../utils'; const rule = require('rules/consistent-type-specifier-style'); diff --git a/tests/src/rules/default.js b/tests/src/rules/default.js index eb2028c71a..1df57a23aa 100644 --- a/tests/src/rules/default.js +++ b/tests/src/rules/default.js @@ -1,6 +1,6 @@ import path from 'path'; import { test, testVersion, SYNTAX_CASES, getTSParsers, parsers } from '../utils'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; import semver from 'semver'; import { version as tsEslintVersion } from 'typescript-eslint-parser/package.json'; diff --git a/tests/src/rules/dynamic-import-chunkname.js b/tests/src/rules/dynamic-import-chunkname.js index 73617a6f36..e8f97475da 100644 --- a/tests/src/rules/dynamic-import-chunkname.js +++ b/tests/src/rules/dynamic-import-chunkname.js @@ -1,5 +1,5 @@ import { SYNTAX_CASES, getTSParsers, parsers } from '../utils'; -import { RuleTester } from 'eslint'; +import { RuleTester, withoutAutofixOutput } from '../rule-tester'; import semver from 'semver'; const rule = require('rules/dynamic-import-chunkname'); @@ -12,6 +12,10 @@ const pickyCommentOptions = [{ importFunctions: ['dynamicImport'], webpackChunknameFormat: pickyCommentFormat, }]; +const allowEmptyOptions = [{ + importFunctions: ['dynamicImport'], + allowEmpty: true, +}]; const multipleImportFunctionOptions = [{ importFunctions: ['dynamicImport', 'definitelyNotStaticImport'], }]; @@ -22,8 +26,9 @@ const nonBlockCommentError = 'dynamic imports require a /* foo */ style comment, const noPaddingCommentError = 'dynamic imports require a block comment padded with spaces - /* foo */'; const invalidSyntaxCommentError = 'dynamic imports require a "webpack" comment with valid syntax'; const commentFormatError = `dynamic imports require a "webpack" comment with valid syntax`; -const chunkNameFormatError = `dynamic imports require a leading comment in the form /* webpackChunkName: ["']${commentFormat}["'],? */`; -const pickyChunkNameFormatError = `dynamic imports require a leading comment in the form /* webpackChunkName: ["']${pickyCommentFormat}["'],? */`; +const chunkNameFormatError = `dynamic imports require a leading comment in the form /*webpackChunkName: ["']${commentFormat}["'],? */`; +const pickyChunkNameFormatError = `dynamic imports require a leading comment in the form /*webpackChunkName: ["']${pickyCommentFormat}["'],? */`; +const eagerModeError = `dynamic imports using eager mode do not need a webpackChunkName`; ruleTester.run('dynamic-import-chunkname', rule, { valid: [ @@ -83,6 +88,19 @@ ruleTester.run('dynamic-import-chunkname', rule, { )`, options, }, + { + code: `import('test')`, + options: allowEmptyOptions, + parser, + }, + { + code: `import( + /* webpackMode: "lazy" */ + 'test' + )`, + options: allowEmptyOptions, + parser, + }, { code: `import( /* webpackChunkName: "someModule" */ @@ -337,7 +355,6 @@ ruleTester.run('dynamic-import-chunkname', rule, { }, { code: `import( - /* webpackChunkName: "someModule" */ /* webpackMode: "eager" */ 'someModule' )`, @@ -395,7 +412,7 @@ ruleTester.run('dynamic-import-chunkname', rule, { /* webpackPrefetch: true */ /* webpackPreload: true */ /* webpackIgnore: false */ - /* webpackMode: "eager" */ + /* webpackMode: "lazy" */ /* webpackExports: ["default", "named"] */ 'someModule' )`, @@ -406,225 +423,172 @@ ruleTester.run('dynamic-import-chunkname', rule, { ], invalid: [ - { + withoutAutofixOutput({ code: `import( // webpackChunkName: "someModule" 'someModule' )`, options, parser, - output: `import( - // webpackChunkName: "someModule" - 'someModule' - )`, errors: [{ message: nonBlockCommentError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: 'import(\'test\')', options, parser, - output: 'import(\'test\')', errors: [{ message: noLeadingCommentError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackChunkName: someModule */ 'someModule' )`, options, parser, - output: `import( - /* webpackChunkName: someModule */ - 'someModule' - )`, errors: [{ message: invalidSyntaxCommentError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackChunkName: "someModule' */ 'someModule' )`, options, parser, - output: `import( - /* webpackChunkName: "someModule' */ - 'someModule' - )`, errors: [{ message: invalidSyntaxCommentError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackChunkName: 'someModule" */ 'someModule' )`, options, parser, - output: `import( - /* webpackChunkName: 'someModule" */ - 'someModule' - )`, errors: [{ message: invalidSyntaxCommentError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackChunkName "someModule" */ 'someModule' )`, options, parser, - output: `import( - /* webpackChunkName "someModule" */ - 'someModule' - )`, errors: [{ message: invalidSyntaxCommentError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackChunkName:"someModule" */ 'someModule' )`, options, parser, - output: `import( - /* webpackChunkName:"someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackChunkName: true */ 'someModule' )`, options, parser, - output: `import( - /* webpackChunkName: true */ - 'someModule' - )`, errors: [{ message: chunkNameFormatError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackChunkName: "my-module-[id]" */ 'someModule' )`, options, parser, - output: `import( - /* webpackChunkName: "my-module-[id]" */ - 'someModule' - )`, errors: [{ message: chunkNameFormatError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackChunkName: ["request"] */ 'someModule' )`, options, parser, - output: `import( - /* webpackChunkName: ["request"] */ - 'someModule' - )`, errors: [{ message: chunkNameFormatError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /*webpackChunkName: "someModule"*/ 'someModule' )`, options, parser, - output: `import( - /*webpackChunkName: "someModule"*/ - 'someModule' - )`, errors: [{ message: noPaddingCommentError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackChunkName : "someModule" */ 'someModule' )`, options, parser, - output: `import( - /* webpackChunkName : "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackChunkName: "someModule" ; */ 'someModule' )`, options, parser, - output: `import( - /* webpackChunkName: "someModule" ; */ - 'someModule' - )`, errors: [{ message: invalidSyntaxCommentError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* totally not webpackChunkName: "someModule" */ 'someModule' )`, options, parser, - output: `import( - /* totally not webpackChunkName: "someModule" */ - 'someModule' - )`, errors: [{ message: invalidSyntaxCommentError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackPrefetch: true */ /* webpackChunk: "someModule" */ @@ -632,338 +596,283 @@ ruleTester.run('dynamic-import-chunkname', rule, { )`, options, parser, - output: `import( - /* webpackPrefetch: true */ - /* webpackChunk: "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackPrefetch: true, webpackChunk: "someModule" */ 'someModule' )`, options, parser, - output: `import( - /* webpackPrefetch: true, webpackChunk: "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackChunkName: "someModule123" */ 'someModule' )`, options: pickyCommentOptions, parser, - output: `import( - /* webpackChunkName: "someModule123" */ - 'someModule' - )`, errors: [{ message: pickyChunkNameFormatError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackPrefetch: "module", webpackChunkName: "someModule" */ 'someModule' )`, options, parser, - output: `import( - /* webpackPrefetch: "module", webpackChunkName: "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackPreload: "module", webpackChunkName: "someModule" */ 'someModule' )`, options, parser, - output: `import( - /* webpackPreload: "module", webpackChunkName: "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackIgnore: "no", webpackChunkName: "someModule" */ 'someModule' )`, options, parser, - output: `import( - /* webpackIgnore: "no", webpackChunkName: "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackInclude: "someModule", webpackChunkName: "someModule" */ 'someModule' )`, options, parser, - output: `import( - /* webpackInclude: "someModule", webpackChunkName: "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackInclude: true, webpackChunkName: "someModule" */ 'someModule' )`, options, parser, - output: `import( - /* webpackInclude: true, webpackChunkName: "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackExclude: "someModule", webpackChunkName: "someModule" */ 'someModule' )`, options, parser, - output: `import( - /* webpackExclude: "someModule", webpackChunkName: "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackExclude: true, webpackChunkName: "someModule" */ 'someModule' )`, options, parser, - output: `import( - /* webpackExclude: true, webpackChunkName: "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackMode: "fast", webpackChunkName: "someModule" */ 'someModule' )`, options, parser, - output: `import( - /* webpackMode: "fast", webpackChunkName: "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackMode: true, webpackChunkName: "someModule" */ 'someModule' )`, options, parser, - output: `import( - /* webpackMode: true, webpackChunkName: "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackExports: true, webpackChunkName: "someModule" */ 'someModule' )`, options, parser, - output: `import( - /* webpackExports: true, webpackChunkName: "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackExports: /default/, webpackChunkName: "someModule" */ 'someModule' )`, options, parser, - output: `import( - /* webpackExports: /default/, webpackChunkName: "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `dynamicImport( /* webpackChunkName "someModule" */ 'someModule' )`, options: multipleImportFunctionOptions, - output: `dynamicImport( - /* webpackChunkName "someModule" */ - 'someModule' - )`, errors: [{ message: invalidSyntaxCommentError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `definitelyNotStaticImport( /* webpackChunkName "someModule" */ 'someModule' )`, options: multipleImportFunctionOptions, - output: `definitelyNotStaticImport( - /* webpackChunkName "someModule" */ - 'someModule' - )`, errors: [{ message: invalidSyntaxCommentError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `dynamicImport( // webpackChunkName: "someModule" 'someModule' )`, options, - output: `dynamicImport( - // webpackChunkName: "someModule" - 'someModule' - )`, errors: [{ message: nonBlockCommentError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: 'dynamicImport(\'test\')', options, - output: 'dynamicImport(\'test\')', errors: [{ message: noLeadingCommentError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `dynamicImport( /* webpackChunkName: someModule */ 'someModule' )`, options, - output: `dynamicImport( - /* webpackChunkName: someModule */ - 'someModule' - )`, errors: [{ message: invalidSyntaxCommentError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `dynamicImport( /* webpackChunkName "someModule" */ 'someModule' )`, options, - output: `dynamicImport( - /* webpackChunkName "someModule" */ - 'someModule' - )`, errors: [{ message: invalidSyntaxCommentError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `dynamicImport( /* webpackChunkName:"someModule" */ 'someModule' )`, options, - output: `dynamicImport( - /* webpackChunkName:"someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: 'CallExpression', }], - }, - { + }), + withoutAutofixOutput({ code: `dynamicImport( /* webpackChunkName: "someModule123" */ 'someModule' )`, options: pickyCommentOptions, - output: `dynamicImport( - /* webpackChunkName: "someModule123" */ + errors: [{ + message: pickyChunkNameFormatError, + type: 'CallExpression', + }], + }), + withoutAutofixOutput({ + code: `import( + /* webpackChunkName: "someModule" */ + /* webpackMode: "eager" */ 'someModule' )`, + options, + parser, errors: [{ - message: pickyChunkNameFormatError, + message: eagerModeError, type: 'CallExpression', + suggestions: [ + { + desc: 'Remove webpackChunkName', + output: `import( + ${''} + /* webpackMode: "eager" */ + 'someModule' + )`, + }, + { + desc: 'Remove webpackMode', + output: `import( + /* webpackChunkName: "someModule" */ + ${''} + 'someModule' + )`, + }, + ], }], - }, + }), ], }); @@ -975,6 +884,19 @@ context('TypeScript', () => { ruleTester.run('dynamic-import-chunkname', rule, { valid: [ + { + code: `import('test')`, + options: allowEmptyOptions, + parser: typescriptParser, + }, + { + code: `import( + /* webpackMode: "lazy" */ + 'test' + )`, + options: allowEmptyOptions, + parser: typescriptParser, + }, { code: `import( /* webpackChunkName: "someModule" */ @@ -1183,15 +1105,6 @@ context('TypeScript', () => { options, parser: typescriptParser, }, - { - code: `import( - /* webpackChunkName: "someModule" */ - /* webpackMode: "lazy" */ - 'someModule' - )`, - options, - parser: typescriptParser, - }, { code: `import( /* webpackChunkName: 'someModule', webpackMode: 'lazy' */ @@ -1212,7 +1125,7 @@ context('TypeScript', () => { { code: `import( /* webpackChunkName: "someModule" */ - /* webpackMode: "eager" */ + /* webpackMode: "lazy" */ 'someModule' )`, options, @@ -1269,186 +1182,153 @@ context('TypeScript', () => { /* webpackPrefetch: true */ /* webpackPreload: true */ /* webpackIgnore: false */ - /* webpackMode: "eager" */ + /* webpackMode: "lazy" */ /* webpackExports: ["default", "named"] */ 'someModule' )`, options, parser: typescriptParser, }, - ], - invalid: [ { code: `import( - // webpackChunkName: "someModule" + /* webpackMode: "eager" */ 'someModule' )`, options, parser: typescriptParser, - output: `import( + }, + ], + invalid: [ + withoutAutofixOutput({ + code: `import( // webpackChunkName: "someModule" 'someModule' )`, + options, + parser: typescriptParser, errors: [{ message: nonBlockCommentError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: 'import(\'test\')', options, parser: typescriptParser, - output: 'import(\'test\')', errors: [{ message: noLeadingCommentError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackChunkName: someModule */ 'someModule' )`, options, parser: typescriptParser, - output: `import( - /* webpackChunkName: someModule */ - 'someModule' - )`, errors: [{ message: invalidSyntaxCommentError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackChunkName "someModule' */ 'someModule' )`, options, parser: typescriptParser, - output: `import( - /* webpackChunkName "someModule' */ - 'someModule' - )`, errors: [{ message: invalidSyntaxCommentError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackChunkName 'someModule" */ 'someModule' )`, options, parser: typescriptParser, - output: `import( - /* webpackChunkName 'someModule" */ - 'someModule' - )`, errors: [{ message: invalidSyntaxCommentError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackChunkName "someModule" */ 'someModule' )`, options, parser: typescriptParser, - output: `import( - /* webpackChunkName "someModule" */ - 'someModule' - )`, errors: [{ message: invalidSyntaxCommentError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackChunkName:"someModule" */ 'someModule' )`, options, parser: typescriptParser, - output: `import( - /* webpackChunkName:"someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( /*webpackChunkName: "someModule"*/ 'someModule' )`, options, parser: typescriptParser, - output: `import( - /*webpackChunkName: "someModule"*/ - 'someModule' - )`, errors: [{ message: noPaddingCommentError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackChunkName : "someModule" */ 'someModule' )`, options, parser: typescriptParser, - output: `import( - /* webpackChunkName : "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackChunkName: "someModule" ; */ 'someModule' )`, options, parser: typescriptParser, - output: `import( - /* webpackChunkName: "someModule" ; */ - 'someModule' - )`, errors: [{ message: invalidSyntaxCommentError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* totally not webpackChunkName: "someModule" */ 'someModule' )`, options, parser: typescriptParser, - output: `import( - /* totally not webpackChunkName: "someModule" */ - 'someModule' - )`, errors: [{ message: invalidSyntaxCommentError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackPrefetch: true */ /* webpackChunk: "someModule" */ @@ -1456,272 +1336,336 @@ context('TypeScript', () => { )`, options, parser: typescriptParser, - output: `import( - /* webpackPrefetch: true */ - /* webpackChunk: "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackPrefetch: true, webpackChunk: "someModule" */ 'someModule' )`, options, parser: typescriptParser, - output: `import( - /* webpackPrefetch: true, webpackChunk: "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackChunkName: true */ 'someModule' )`, options, parser: typescriptParser, - output: `import( - /* webpackChunkName: true */ - 'someModule' - )`, errors: [{ message: chunkNameFormatError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackChunkName: "my-module-[id]" */ 'someModule' )`, options, parser: typescriptParser, - output: `import( - /* webpackChunkName: "my-module-[id]" */ - 'someModule' - )`, errors: [{ message: chunkNameFormatError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackChunkName: ["request"] */ 'someModule' )`, options, parser: typescriptParser, - output: `import( - /* webpackChunkName: ["request"] */ - 'someModule' - )`, errors: [{ message: chunkNameFormatError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackChunkName: "someModule123" */ 'someModule' )`, options: pickyCommentOptions, parser: typescriptParser, - output: `import( - /* webpackChunkName: "someModule123" */ - 'someModule' - )`, errors: [{ message: pickyChunkNameFormatError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackPrefetch: "module", webpackChunkName: "someModule" */ 'someModule' )`, options, parser: typescriptParser, - output: `import( - /* webpackPrefetch: "module", webpackChunkName: "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackPreload: "module", webpackChunkName: "someModule" */ 'someModule' )`, options, parser: typescriptParser, - output: `import( - /* webpackPreload: "module", webpackChunkName: "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackIgnore: "no", webpackChunkName: "someModule" */ 'someModule' )`, options, parser: typescriptParser, - output: `import( - /* webpackIgnore: "no", webpackChunkName: "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackInclude: "someModule", webpackChunkName: "someModule" */ 'someModule' )`, options, parser: typescriptParser, - output: `import( - /* webpackInclude: "someModule", webpackChunkName: "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackInclude: true, webpackChunkName: "someModule" */ 'someModule' )`, options, parser: typescriptParser, - output: `import( - /* webpackInclude: true, webpackChunkName: "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackExclude: "someModule", webpackChunkName: "someModule" */ 'someModule' )`, options, parser: typescriptParser, - output: `import( - /* webpackExclude: "someModule", webpackChunkName: "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackExclude: true, webpackChunkName: "someModule" */ 'someModule' )`, options, parser: typescriptParser, - output: `import( - /* webpackExclude: true, webpackChunkName: "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackMode: "fast", webpackChunkName: "someModule" */ 'someModule' )`, options, parser: typescriptParser, - output: `import( - /* webpackMode: "fast", webpackChunkName: "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackMode: true, webpackChunkName: "someModule" */ 'someModule' )`, options, parser: typescriptParser, - output: `import( - /* webpackMode: true, webpackChunkName: "someModule" */ - 'someModule' - )`, errors: [{ message: commentFormatError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( /* webpackExports: true, webpackChunkName: "someModule" */ 'someModule' )`, options, parser: typescriptParser, - output: `import( - /* webpackExports: true, webpackChunkName: "someModule" */ + errors: [{ + message: commentFormatError, + type: nodeType, + }], + }), + withoutAutofixOutput({ + code: `import( + /* webpackExports: /default/, webpackChunkName: "someModule" */ 'someModule' )`, + options, + parser: typescriptParser, errors: [{ message: commentFormatError, type: nodeType, }], - }, - { + }), + withoutAutofixOutput({ code: `import( - /* webpackExports: /default/, webpackChunkName: "someModule" */ + /* webpackChunkName: "someModule", webpackMode: "eager" */ 'someModule' )`, options, parser: typescriptParser, - output: `import( - /* webpackExports: /default/, webpackChunkName: "someModule" */ + errors: [{ + message: eagerModeError, + type: nodeType, + suggestions: [ + { + desc: 'Remove webpackChunkName', + output: `import( + /* webpackMode: "eager" */ 'someModule' )`, + }, + { + desc: 'Remove webpackMode', + output: `import( + /* webpackChunkName: "someModule" */ + 'someModule' + )`, + }, + ], + }], + }), + withoutAutofixOutput({ + code: ` + import( + /* webpackMode: "eager", webpackChunkName: "someModule" */ + 'someModule' + ) + `, + options, + parser: typescriptParser, errors: [{ - message: commentFormatError, + message: eagerModeError, type: nodeType, + suggestions: [ + { + desc: 'Remove webpackChunkName', + output: ` + import( + /* webpackMode: "eager" */ + 'someModule' + ) + `, + }, + { + desc: 'Remove webpackMode', + output: ` + import( + /* webpackChunkName: "someModule" */ + 'someModule' + ) + `, + }, + ], }], - }, + }), + withoutAutofixOutput({ + code: ` + import( + /* webpackMode: "eager", webpackPrefetch: true, webpackChunkName: "someModule" */ + 'someModule' + ) + `, + options, + parser: typescriptParser, + errors: [{ + message: eagerModeError, + type: nodeType, + suggestions: [ + { + desc: 'Remove webpackChunkName', + output: ` + import( + /* webpackMode: "eager", webpackPrefetch: true */ + 'someModule' + ) + `, + }, + { + desc: 'Remove webpackMode', + output: ` + import( + /* webpackPrefetch: true, webpackChunkName: "someModule" */ + 'someModule' + ) + `, + }, + ], + }], + }), + withoutAutofixOutput({ + code: ` + import( + /* webpackChunkName: "someModule" */ + /* webpackMode: "eager" */ + 'someModule' + ) + `, + options, + parser: typescriptParser, + errors: [{ + message: eagerModeError, + type: nodeType, + suggestions: [ + { + desc: 'Remove webpackChunkName', + output: ` + import( + ${''} + /* webpackMode: "eager" */ + 'someModule' + ) + `, + }, + { + desc: 'Remove webpackMode', + output: ` + import( + /* webpackChunkName: "someModule" */ + ${''} + 'someModule' + ) + `, + }, + ], + }], + }), ], }); }); diff --git a/tests/src/rules/enforce-node-protocol-usage.js b/tests/src/rules/enforce-node-protocol-usage.js new file mode 100644 index 0000000000..7bced2d1ba --- /dev/null +++ b/tests/src/rules/enforce-node-protocol-usage.js @@ -0,0 +1,370 @@ +import { RuleTester } from '../rule-tester'; +import flatMap from 'array.prototype.flatmap'; +import { satisfies } from 'semver'; + +import { getTSParsers, test, testVersion } from '../utils'; + +const ruleTester = new RuleTester(); +const rule = require('rules/enforce-node-protocol-usage'); + +const preferUsingProtocol = ['always']; +const preferNotUsingProtocol = ['never']; +const useNewerParser = { ecmaVersion: 2021 }; + +const actualModules = ['fs', 'fs/promises', 'buffer', 'child_process', 'timers/promises']; + +const settings = { + 'import/node-version': '16.0.0', // the node: prefix is only available as of `^14.18 || >= 16` +}; + +const invalidTests = [].concat( + flatMap(actualModules, (moduleName) => [].concat( + { + code: `import x from "${moduleName}";`, + output: `import x from "node:${moduleName}";`, + options: preferUsingProtocol, + errors: [ + { messageId: 'requireNodeProtocol', data: { moduleName } }, + ], + }, + { + code: `export {promises} from "${moduleName}";`, + output: `export {promises} from "node:${moduleName}";`, + options: preferUsingProtocol, + errors: [ + { messageId: 'requireNodeProtocol', data: { moduleName } }, + ], + }, + testVersion('>= 7', () => ({ + code: ` + async function foo() { + const x = await import('${moduleName}'); + } + `, + output: ` + async function foo() { + const x = await import('node:${moduleName}'); + } + `, + options: preferUsingProtocol, + parserOptions: useNewerParser, + errors: [ + { messageId: 'requireNodeProtocol', data: { moduleName } }, + ], + })), + )), + + { + code: 'import fs from "fs/promises";', + output: 'import fs from "node:fs/promises";', + options: preferUsingProtocol, + errors: [ + { + messageId: 'requireNodeProtocol', + data: { moduleName: 'fs/promises' }, + }, + ], + settings, + }, + { + code: 'export {default} from "fs/promises";', + output: 'export {default} from "node:fs/promises";', + options: preferUsingProtocol, + errors: [ + { + messageId: 'requireNodeProtocol', + data: { moduleName: 'fs/promises' }, + }, + ], + settings, + }, + testVersion('>= 7', () => ({ + code: ` + async function foo() { + const fs = await import('fs/promises'); + } + `, + output: ` + async function foo() { + const fs = await import('node:fs/promises'); + } + `, + options: preferUsingProtocol, + parserOptions: useNewerParser, + errors: [ + { + messageId: 'requireNodeProtocol', + data: { moduleName: 'fs/promises' }, + }, + ], + settings, + })), + { + code: 'import {promises} from "fs";', + output: 'import {promises} from "node:fs";', + options: preferUsingProtocol, + errors: [ + { messageId: 'requireNodeProtocol', data: { moduleName: 'fs' } }, + ], + }, + { + code: 'export {default as promises} from "fs";', + output: 'export {default as promises} from "node:fs";', + options: preferUsingProtocol, + errors: [ + { messageId: 'requireNodeProtocol', data: { moduleName: 'fs' } }, + ], + }, + testVersion('>= 7', () => ({ + code: ` + async function foo() { + const fs = await import("fs/promises"); + } + `, + output: ` + async function foo() { + const fs = await import("node:fs/promises"); + } + `, + options: preferUsingProtocol, + parserOptions: useNewerParser, + errors: [ + { + messageId: 'requireNodeProtocol', + data: { moduleName: 'fs/promises' }, + }, + ], + settings, + })), + { + code: 'import "buffer";', + output: 'import "node:buffer";', + options: preferUsingProtocol, + errors: [ + { + messageId: 'requireNodeProtocol', + data: { moduleName: 'buffer' }, + }, + ], + }, + { + code: 'import "child_process";', + output: 'import "node:child_process";', + options: preferUsingProtocol, + errors: [ + { + messageId: 'requireNodeProtocol', + data: { moduleName: 'child_process' }, + }, + ], + }, + { + code: 'import "timers/promises";', + output: 'import "node:timers/promises";', + options: preferUsingProtocol, + errors: [ + { + messageId: 'requireNodeProtocol', + data: { moduleName: 'timers/promises' }, + }, + ], + settings, + }, + { + code: 'const {promises} = require("fs")', + output: 'const {promises} = require("node:fs")', + options: preferUsingProtocol, + errors: [ + { messageId: 'requireNodeProtocol', data: { moduleName: 'fs' } }, + ], + }, + { + code: 'const fs = require("fs/promises")', + output: 'const fs = require("node:fs/promises")', + options: preferUsingProtocol, + errors: [ + { + messageId: 'requireNodeProtocol', + data: { moduleName: 'fs/promises' }, + }, + ], + settings, + }, +); + +ruleTester.run('enforce-node-protocol-usage', rule, { + valid: [].concat( + test({ + code: 'import unicorn from "unicorn";', + options: preferUsingProtocol, + }), + test({ + code: 'import fs from "./fs";', + options: preferUsingProtocol, + }), + test({ + code: 'import fs from "unknown-builtin-module";', + options: preferUsingProtocol, + }), + test({ + code: 'import fs from "node:fs";', + options: preferUsingProtocol, + }), + testVersion('>= 7', () => ({ + code: ` + async function foo() { + const fs = await import(fs); + } + `, + options: preferUsingProtocol, + parserOptions: useNewerParser, + })), + testVersion('>= 7', () => ({ + code: ` + async function foo() { + const fs = await import(0); + } + `, + options: preferUsingProtocol, + parserOptions: useNewerParser, + })), + testVersion('>= 7', () => ({ + code: ` + async function foo() { + const fs = await import(\`fs\`); + } + `, + options: preferUsingProtocol, + parserOptions: useNewerParser, + })), + test({ + code: 'import "punycode/";', + options: preferUsingProtocol, + }), + test({ + code: 'const fs = require("node:fs");', + options: preferUsingProtocol, + }), + test({ + code: 'const fs = require("node:fs/promises");', + options: preferUsingProtocol, + settings, + }), + test({ + code: 'const fs = require(fs);', + options: preferUsingProtocol, + }), + test({ + code: 'const fs = notRequire("fs");', + options: preferUsingProtocol, + }), + test({ + code: 'const fs = foo.require("fs");', + options: preferUsingProtocol, + }), + test({ + code: 'const fs = require.resolve("fs");', + options: preferUsingProtocol, + }), + test({ + code: 'const fs = require(`fs`);', + options: preferUsingProtocol, + }), + testVersion('>= 7', () => ({ + code: 'const fs = require?.("fs");', + parserOptions: useNewerParser, + options: preferUsingProtocol, + })), + test({ + code: 'const fs = require("fs", extra);', + options: preferUsingProtocol, + }), + test({ + code: 'const fs = require();', + options: preferUsingProtocol, + }), + test({ + code: 'const fs = require(...["fs"]);', + options: preferUsingProtocol, + }), + test({ + code: 'const fs = require("unicorn");', + options: preferUsingProtocol, + }), + test({ + code: 'import fs from "fs";', + options: preferNotUsingProtocol, + }), + test({ + code: 'const fs = require("fs");', + options: preferNotUsingProtocol, + }), + test({ + code: 'const fs = require("fs/promises");', + options: preferNotUsingProtocol, + settings, + }), + test({ + code: 'import "punycode/";', + options: preferNotUsingProtocol, + }), + + // should not report if the module requires `node:` protocol + test({ + code: 'const fs = require("node:test");', + options: preferNotUsingProtocol, + settings, + }), + ), + + invalid: [].concat( + // Prefer using the protocol + // in node versions without `node:`, the rule should not report + satisfies('^14.18 || >= 16') ? invalidTests.map((testCase) => test({ + ...testCase, + errors: testCase.errors.map(({ messageId, data, ...testCase }) => ({ + ...testCase, + message: rule.meta.messages[messageId].replace(/{{moduleName}}/g, data.moduleName), + })), + })) : [], + + // Prefer not using the protocol: flip the output and code + invalidTests.map((testCase) => test({ + ...testCase, + code: testCase.output, + options: preferNotUsingProtocol, + output: testCase.code, + // eslint-disable-next-line no-unused-vars + errors: testCase.errors.map(({ messageId, data, ...testCase }) => ({ + ...testCase, + message: rule.meta.messages.forbidNodeProtocol.replace(/{{moduleName}}/g, data.moduleName), + })), + settings, + })), + ), +}); + +context('TypeScript', function () { + getTSParsers().forEach((parser) => { + ruleTester.run('enforce-node-protocol-usage', rule, { + valid: [ + test({ + code: ` + export class Thing { + constructor(public readonly name: string) { + // Do nothing. + } + + public sayHello(): void { + console.log(\`Hello, \${this.name}!\`); + } + } + `, + parser, + options: preferUsingProtocol, + }), + ], + invalid: [], + }); + }); +}); diff --git a/tests/src/rules/export.js b/tests/src/rules/export.js index a7f2bec122..338501511c 100644 --- a/tests/src/rules/export.js +++ b/tests/src/rules/export.js @@ -1,6 +1,6 @@ import { test, testFilePath, SYNTAX_CASES, getTSParsers, testVersion } from '../utils'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; import eslintPkg from 'eslint/package.json'; import semver from 'semver'; import { version as tsEslintVersion } from 'typescript-eslint-parser/package.json'; @@ -56,6 +56,15 @@ ruleTester.run('export', rule, { `, parser, })), + getTSParsers().map((parser) => ({ + code: ` + export default function foo(param: string): boolean; + export default function foo(param: string, param1?: number): boolean { + return param && param1; + } + `, + parser, + })), ), invalid: [].concat( @@ -154,6 +163,19 @@ ruleTester.run('export', rule, { ecmaVersion: 2022, }, })), + + getTSParsers().map((parser) => ({ + code: ` + export default function a(): void; + export default function a() {} + export { x as default }; + `, + errors: [ + 'Multiple default exports.', + 'Multiple default exports.', + ], + parser, + })), ), }); @@ -510,7 +532,7 @@ context('TypeScript', function () { }), test({ code: ` - export function Foo(); + export function Foo() { }; export class Foo { } export namespace Foo { } `, @@ -529,7 +551,7 @@ context('TypeScript', function () { test({ code: ` export const Foo = 'bar'; - export function Foo(); + export function Foo() { }; export namespace Foo { } `, errors: [ diff --git a/tests/src/rules/exports-last.js b/tests/src/rules/exports-last.js index d7122e9a00..a676ae044b 100644 --- a/tests/src/rules/exports-last.js +++ b/tests/src/rules/exports-last.js @@ -1,6 +1,6 @@ import { test } from '../utils'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; import rule from 'rules/exports-last'; const ruleTester = new RuleTester(); diff --git a/tests/src/rules/extensions.js b/tests/src/rules/extensions.js index 14d84eaa62..8843713e34 100644 --- a/tests/src/rules/extensions.js +++ b/tests/src/rules/extensions.js @@ -1,8 +1,17 @@ -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; import rule from 'rules/extensions'; import { getTSParsers, test, testFilePath, parsers } from '../utils'; const ruleTester = new RuleTester(); +const ruleTesterWithTypeScriptImports = new RuleTester({ + settings: { + 'import/resolver': { + typescript: { + alwaysTryTypes: true, + }, + }, + }, +}); ruleTester.run('extensions', rule, { valid: [ @@ -689,6 +698,184 @@ describe('TypeScript', () => { ], parser, }), + test({ + code: 'import type T from "./typescript-declare";', + errors: ['Missing file extension for "./typescript-declare"'], + options: [ + 'always', + { ts: 'never', tsx: 'never', js: 'never', jsx: 'never', checkTypeImports: true }, + ], + parser, + }), + test({ + code: 'export type { MyType } from "./typescript-declare";', + errors: ['Missing file extension for "./typescript-declare"'], + options: [ + 'always', + { ts: 'never', tsx: 'never', js: 'never', jsx: 'never', checkTypeImports: true }, + ], + parser, + }), + ], + }); + ruleTesterWithTypeScriptImports.run(`${parser}: (with TS resolver) extensions are enforced for type imports/export when checkTypeImports is set`, rule, { + valid: [ + test({ + code: 'import type { MyType } from "./typescript-declare.ts";', + options: [ + 'always', + { checkTypeImports: true }, + ], + parser, + }), + test({ + code: 'export type { MyType } from "./typescript-declare.ts";', + options: [ + 'always', + { checkTypeImports: true }, + ], + parser, + }), + + // pathGroupOverrides: no patterns match good bespoke specifiers + test({ + code: ` + import { ErrorMessage as UpstreamErrorMessage } from '@black-flag/core/util'; + + import { $instances } from 'rootverse+debug:src.ts'; + import { $exists } from 'rootverse+bfe:src/symbols.ts'; + + import type { Entries } from 'type-fest'; + `, + parser, + options: [ + 'always', + { + ignorePackages: true, + checkTypeImports: true, + pathGroupOverrides: [ + { + pattern: 'multiverse{*,*/**}', + action: 'enforce', + }, + ], + }, + ], + }), + // pathGroupOverrides: an enforce pattern matches good bespoke specifiers + test({ + code: ` + import { ErrorMessage as UpstreamErrorMessage } from '@black-flag/core/util'; + + import { $instances } from 'rootverse+debug:src.ts'; + import { $exists } from 'rootverse+bfe:src/symbols.ts'; + + import type { Entries } from 'type-fest'; + `, + parser, + options: [ + 'always', + { + ignorePackages: true, + checkTypeImports: true, + pathGroupOverrides: [ + { + pattern: 'rootverse{*,*/**}', + action: 'enforce', + }, + ], + }, + ], + }), + // pathGroupOverrides: an ignore pattern matches bad bespoke specifiers + test({ + code: ` + import { ErrorMessage as UpstreamErrorMessage } from '@black-flag/core/util'; + + import { $instances } from 'rootverse+debug:src'; + import { $exists } from 'rootverse+bfe:src/symbols'; + + import type { Entries } from 'type-fest'; + `, + parser, + options: [ + 'always', + { + ignorePackages: true, + checkTypeImports: true, + pathGroupOverrides: [ + { + pattern: 'multiverse{*,*/**}', + action: 'enforce', + }, + { + pattern: 'rootverse{*,*/**}', + action: 'ignore', + }, + ], + }, + ], + }), + ], + invalid: [ + test({ + code: 'import type { MyType } from "./typescript-declare";', + errors: ['Missing file extension "ts" for "./typescript-declare"'], + options: [ + 'always', + { checkTypeImports: true }, + ], + parser, + }), + test({ + code: 'export type { MyType } from "./typescript-declare";', + errors: ['Missing file extension "ts" for "./typescript-declare"'], + options: [ + 'always', + { checkTypeImports: true }, + ], + parser, + }), + + // pathGroupOverrides: an enforce pattern matches bad bespoke specifiers + test({ + code: ` + import { ErrorMessage as UpstreamErrorMessage } from '@black-flag/core/util'; + + import { $instances } from 'rootverse+debug:src'; + import { $exists } from 'rootverse+bfe:src/symbols'; + + import type { Entries } from 'type-fest'; + `, + parser, + options: [ + 'always', + { + ignorePackages: true, + checkTypeImports: true, + pathGroupOverrides: [ + { + pattern: 'rootverse{*,*/**}', + action: 'enforce', + }, + { + pattern: 'universe{*,*/**}', + action: 'ignore', + }, + ], + }, + ], + errors: [ + { + message: 'Missing file extension for "rootverse+debug:src"', + line: 4, + }, + { + message: 'Missing file extension for "rootverse+bfe:src/symbols"', + line: 5, + }, + ], + }), ], }); }); diff --git a/tests/src/rules/first.js b/tests/src/rules/first.js index f34f227b2d..52b71db861 100644 --- a/tests/src/rules/first.js +++ b/tests/src/rules/first.js @@ -2,7 +2,7 @@ import { test, getTSParsers, testVersion } from '../utils'; import fs from 'fs'; import path from 'path'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; const ruleTester = new RuleTester(); const rule = require('rules/first'); diff --git a/tests/src/rules/group-exports.js b/tests/src/rules/group-exports.js index c3d07046f0..6f05bc866b 100644 --- a/tests/src/rules/group-exports.js +++ b/tests/src/rules/group-exports.js @@ -1,5 +1,5 @@ import { test } from '../utils'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; import rule from 'rules/group-exports'; import { resolve } from 'path'; import { default as babelPresetFlow } from 'babel-preset-flow'; diff --git a/tests/src/rules/max-dependencies.js b/tests/src/rules/max-dependencies.js index 982a4b427a..959ee68de3 100644 --- a/tests/src/rules/max-dependencies.js +++ b/tests/src/rules/max-dependencies.js @@ -1,6 +1,6 @@ import { test, getTSParsers, parsers } from '../utils'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; const ruleTester = new RuleTester(); const rule = require('rules/max-dependencies'); diff --git a/tests/src/rules/named.js b/tests/src/rules/named.js index 227bffc80d..51a76c1290 100644 --- a/tests/src/rules/named.js +++ b/tests/src/rules/named.js @@ -1,5 +1,5 @@ import { test, SYNTAX_CASES, getTSParsers, testFilePath, testVersion, parsers } from '../utils'; -import { RuleTester } from 'eslint'; +import { RuleTester, usingFlatConfig } from '../rule-tester'; import path from 'path'; import { CASE_SENSITIVE_FS } from 'eslint-module-utils/resolve'; @@ -32,7 +32,7 @@ ruleTester.run('named', rule, { settings: { 'import/resolve': { extensions: ['.js', '.jsx'] } } }), // validate that eslint-disable-line silences this properly - test({ code: 'import {a, b, d} from "./common"; // eslint-disable-line named' }), + test({ code: `import {a, b, d} from "./common"; // eslint-disable-line ${usingFlatConfig ? 'rule-to-test/' : ''}named` }), test({ code: 'import { foo, bar } from "./re-export-names"' }), diff --git a/tests/src/rules/namespace.js b/tests/src/rules/namespace.js index 1475ae9b7d..2a31d57e19 100644 --- a/tests/src/rules/namespace.js +++ b/tests/src/rules/namespace.js @@ -1,8 +1,8 @@ import { test, SYNTAX_CASES, getTSParsers, testVersion, testFilePath, parsers } from '../utils'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; import flatMap from 'array.prototype.flatmap'; -const ruleTester = new RuleTester({ env: { es6: true } }); +const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 6 } }); const rule = require('rules/namespace'); function error(name, namespace) { @@ -336,10 +336,10 @@ const invalid = [].concat( test({ parser, code: `import { b } from "./${folder}/a"; console.log(b.c.d.e)` }), test({ parser, code: `import * as a from "./${folder}/a"; console.log(a.b.c.d.e.f)` }), test({ parser, code: `import * as a from "./${folder}/a"; var {b:{c:{d:{e}}}} = a` }), - test({ parser, code: `import { b } from "./${folder}/a"; var {c:{d:{e}}} = b` })); - - // deep namespaces should include explicitly exported defaults - test({ parser, code: `import * as a from "./${folder}/a"; console.log(a.b.default)` }), + test({ parser, code: `import { b } from "./${folder}/a"; var {c:{d:{e}}} = b` }), + // deep namespaces should include explicitly exported defaults + test({ parser, code: `import * as a from "./${folder}/a"; console.log(a.b.default)` }), + ); invalid.push( test({ @@ -371,7 +371,8 @@ const invalid = [].concat( parser, code: `import * as a from "./${folder}/a"; var {b:{c:{ e }}} = a`, errors: ["'e' not found in deeply imported namespace 'a.b.c'."], - })); + }), + ); }); ruleTester.run('namespace', rule, { valid, invalid }); diff --git a/tests/src/rules/newline-after-import.js b/tests/src/rules/newline-after-import.js index 6a8fb83e40..984e898550 100644 --- a/tests/src/rules/newline-after-import.js +++ b/tests/src/rules/newline-after-import.js @@ -1,4 +1,4 @@ -import { RuleTester } from 'eslint'; +import { RuleTester, withoutAutofixOutput } from '../rule-tester'; import flatMap from 'array.prototype.flatmap'; import semver from 'semver'; import { version as tsEslintVersion } from 'typescript-eslint-parser/package.json'; @@ -8,6 +8,7 @@ import { getTSParsers, parsers, testVersion } from '../utils'; const IMPORT_ERROR_MESSAGE = 'Expected 1 empty line after import statement not followed by another import.'; const IMPORT_ERROR_MESSAGE_MULTIPLE = (count) => `Expected ${count} empty lines after import statement not followed by another import.`; const REQUIRE_ERROR_MESSAGE = 'Expected 1 empty line after require statement not followed by another require.'; +const REQUIRE_ERROR_MESSAGE_MULTIPLE = (count) => `Expected ${count} empty lines after require statement not followed by another require.`; const ruleTester = new RuleTester(); @@ -202,7 +203,7 @@ ruleTester.run('newline-after-import', require('rules/newline-after-import'), { options: [{ count: 4, exactCount: true }], }, { - code: `var foo = require('foo-module');\n\n\n\n// Some random comment\nvar foo = 'bar';`, + code: `var foo = require('foo-module');\n\n\n\n\n// Some random comment\nvar foo = 'bar';`, parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, options: [{ count: 4, exactCount: true, considerComments: true }], }, @@ -394,6 +395,19 @@ ruleTester.run('newline-after-import', require('rules/newline-after-import'), { `, parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, }, + { + code: `var foo = require('foo-module');\n\n\n// Some random comment\nvar foo = 'bar';`, + options: [{ count: 2, considerComments: true }], + }, + { + code: `var foo = require('foo-module');\n\n\n/**\n * Test comment\n */\nvar foo = 'bar';`, + options: [{ count: 2, considerComments: true }], + }, + { + code: `const foo = require('foo');\n\n\n// some random comment\nconst bar = function() {};`, + options: [{ count: 2, exactCount: true, considerComments: true }], + parserOptions: { ecmaVersion: 2015 }, + }, ), invalid: [].concat( @@ -696,9 +710,8 @@ ruleTester.run('newline-after-import', require('rules/newline-after-import'), { }], parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, }, - { + withoutAutofixOutput({ code: `import foo from 'foo';\n\n\n\nexport default function() {};`, - output: `import foo from 'foo';\n\n\n\nexport default function() {};`, options: [{ count: 2, exactCount: true }], errors: [{ line: 1, @@ -706,10 +719,9 @@ ruleTester.run('newline-after-import', require('rules/newline-after-import'), { message: IMPORT_ERROR_MESSAGE_MULTIPLE(2), }], parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, - }, - { + }), + withoutAutofixOutput({ code: `import foo from 'foo';\n\n\n\n\nexport default function() {};`, - output: `import foo from 'foo';\n\n\n\n\nexport default function() {};`, options: [{ count: 2, exactCount: true }], errors: [{ line: 1, @@ -717,7 +729,7 @@ ruleTester.run('newline-after-import', require('rules/newline-after-import'), { message: IMPORT_ERROR_MESSAGE_MULTIPLE(2), }], parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, - }, + }), { code: `import foo from 'foo';\n// some random comment\nexport default function() {};`, output: `import foo from 'foo';\n\n// some random comment\nexport default function() {};`, @@ -729,9 +741,8 @@ ruleTester.run('newline-after-import', require('rules/newline-after-import'), { }], parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, }, - { + withoutAutofixOutput({ code: `import foo from 'foo';\n// some random comment\n\n\nexport default function() {};`, - output: `import foo from 'foo';\n// some random comment\n\n\nexport default function() {};`, options: [{ count: 2, exactCount: true }], errors: [{ line: 1, @@ -739,10 +750,9 @@ ruleTester.run('newline-after-import', require('rules/newline-after-import'), { message: IMPORT_ERROR_MESSAGE_MULTIPLE(2), }], parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, - }, - { + }), + withoutAutofixOutput({ code: `import foo from 'foo';\n// some random comment\n\n\n\nexport default function() {};`, - output: `import foo from 'foo';\n// some random comment\n\n\n\nexport default function() {};`, options: [{ count: 2, exactCount: true }], errors: [{ line: 1, @@ -750,7 +760,7 @@ ruleTester.run('newline-after-import', require('rules/newline-after-import'), { message: IMPORT_ERROR_MESSAGE_MULTIPLE(2), }], parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, - }, + }), { code: `import foo from 'foo';\n// some random comment\nexport default function() {};`, output: `import foo from 'foo';\n\n\n// some random comment\nexport default function() {};`, @@ -773,9 +783,8 @@ ruleTester.run('newline-after-import', require('rules/newline-after-import'), { }], parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, }, - { + withoutAutofixOutput({ code: `import foo from 'foo';\n\n\n\n// some random comment\nexport default function() {};`, - output: `import foo from 'foo';\n\n\n\n// some random comment\nexport default function() {};`, options: [{ count: 2, exactCount: true, considerComments: true }], errors: [{ line: 1, @@ -783,19 +792,12 @@ ruleTester.run('newline-after-import', require('rules/newline-after-import'), { message: IMPORT_ERROR_MESSAGE_MULTIPLE(2), }], parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, - }, - { + }), + withoutAutofixOutput({ code: ` import foo from 'foo'; - // Some random single line comment - var bar = 42; - `, - output: ` - import foo from 'foo'; - - // Some random single line comment var bar = 42; `, @@ -806,7 +808,7 @@ ruleTester.run('newline-after-import', require('rules/newline-after-import'), { }], parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, options: [{ considerComments: true, count: 1, exactCount: true }], - }, + }), { code: `import foo from 'foo';export default function() {};`, output: `import foo from 'foo';\n\nexport default function() {};`, @@ -818,28 +820,26 @@ ruleTester.run('newline-after-import', require('rules/newline-after-import'), { }], parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, }, - { + withoutAutofixOutput({ code: `const foo = require('foo');\n\n\n\nconst bar = function() {};`, - output: `const foo = require('foo');\n\n\n\nconst bar = function() {};`, options: [{ count: 2, exactCount: true }], errors: [{ line: 1, column: 1, - message: 'Expected 2 empty lines after require statement not followed by another require.', + message: REQUIRE_ERROR_MESSAGE_MULTIPLE(2), }], parserOptions: { ecmaVersion: 2015 }, - }, - { + }), + withoutAutofixOutput({ code: `const foo = require('foo');\n\n\n\n// some random comment\nconst bar = function() {};`, - output: `const foo = require('foo');\n\n\n\n// some random comment\nconst bar = function() {};`, options: [{ count: 2, exactCount: true }], errors: [{ line: 1, column: 1, - message: 'Expected 2 empty lines after require statement not followed by another require.', + message: REQUIRE_ERROR_MESSAGE_MULTIPLE(2), }], parserOptions: { ecmaVersion: 2015 }, - }, + }), { code: `import foo from 'foo';// some random comment\nexport default function() {};`, output: `import foo from 'foo';\n\n// some random comment\nexport default function() {};`, @@ -852,14 +852,26 @@ ruleTester.run('newline-after-import', require('rules/newline-after-import'), { parserOptions: { ecmaVersion: 2015, considerComments: true, sourceType: 'module' }, }, { - code: `const foo = require('foo');\n\n\n// some random comment\nconst bar = function() {};`, - options: [{ count: 2, exactCount: true, considerComments: true }], + code: `var foo = require('foo-module');\nvar foo = require('foo-module');\n\n// Some random comment\nvar foo = 'bar';`, + output: `var foo = require('foo-module');\nvar foo = require('foo-module');\n\n\n// Some random comment\nvar foo = 'bar';`, + errors: [{ + line: 2, + column: 1, + message: REQUIRE_ERROR_MESSAGE_MULTIPLE(2), + }], + parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, + options: [{ considerComments: true, count: 2 }], + }, + { + code: `var foo = require('foo-module');\n\n/**\n * Test comment\n */\nvar foo = 'bar';`, + output: `var foo = require('foo-module');\n\n\n/**\n * Test comment\n */\nvar foo = 'bar';`, errors: [{ line: 1, column: 1, - message: 'Expected 2 empty lines after require statement not followed by another require.', + message: REQUIRE_ERROR_MESSAGE_MULTIPLE(2), }], parserOptions: { ecmaVersion: 2015 }, + options: [{ considerComments: true, count: 2 }], }, ), }); diff --git a/tests/src/rules/no-absolute-path.js b/tests/src/rules/no-absolute-path.js index bfa08465c0..bcf215137b 100644 --- a/tests/src/rules/no-absolute-path.js +++ b/tests/src/rules/no-absolute-path.js @@ -1,6 +1,6 @@ import { test } from '../utils'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; const ruleTester = new RuleTester(); const rule = require('rules/no-absolute-path'); diff --git a/tests/src/rules/no-amd.js b/tests/src/rules/no-amd.js index 5317aa8fde..6b66578df3 100644 --- a/tests/src/rules/no-amd.js +++ b/tests/src/rules/no-amd.js @@ -1,4 +1,4 @@ -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; import eslintPkg from 'eslint/package.json'; import semver from 'semver'; diff --git a/tests/src/rules/no-anonymous-default-export.js b/tests/src/rules/no-anonymous-default-export.js index 53b2fc6fbb..37b3009f0c 100644 --- a/tests/src/rules/no-anonymous-default-export.js +++ b/tests/src/rules/no-anonymous-default-export.js @@ -1,6 +1,6 @@ import { test, testVersion, SYNTAX_CASES } from '../utils'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; const ruleTester = new RuleTester(); const rule = require('rules/no-anonymous-default-export'); diff --git a/tests/src/rules/no-commonjs.js b/tests/src/rules/no-commonjs.js index b7c0aa803f..3211c085a7 100644 --- a/tests/src/rules/no-commonjs.js +++ b/tests/src/rules/no-commonjs.js @@ -1,4 +1,4 @@ -import { RuleTester } from 'eslint'; +import { RuleTester, withoutAutofixOutput } from '../rule-tester'; import eslintPkg from 'eslint/package.json'; import semver from 'semver'; @@ -69,47 +69,41 @@ ruleTester.run('no-commonjs', require('rules/no-commonjs'), { // imports ...semver.satisfies(eslintPkg.version, '< 4.0.0') ? [] : [ - { code: 'var x = require("x")', output: 'var x = require("x")', errors: [{ message: IMPORT_MESSAGE }] }, - { code: 'x = require("x")', output: 'x = require("x")', errors: [{ message: IMPORT_MESSAGE }] }, - { code: 'require("x")', output: 'require("x")', errors: [{ message: IMPORT_MESSAGE }] }, - { code: 'require(`x`)', + withoutAutofixOutput({ code: 'var x = require("x")', errors: [{ message: IMPORT_MESSAGE }] }), + withoutAutofixOutput({ code: 'x = require("x")', errors: [{ message: IMPORT_MESSAGE }] }), + withoutAutofixOutput({ code: 'require("x")', errors: [{ message: IMPORT_MESSAGE }] }), + withoutAutofixOutput({ code: 'require(`x`)', parserOptions: { ecmaVersion: 2015 }, - output: 'require(`x`)', errors: [{ message: IMPORT_MESSAGE }], - }, + }), - { code: 'if (typeof window !== "undefined") require("x")', + withoutAutofixOutput({ code: 'if (typeof window !== "undefined") require("x")', options: [{ allowConditionalRequire: false }], - output: 'if (typeof window !== "undefined") require("x")', errors: [{ message: IMPORT_MESSAGE }], - }, - { code: 'if (typeof window !== "undefined") { require("x") }', + }), + withoutAutofixOutput({ code: 'if (typeof window !== "undefined") { require("x") }', options: [{ allowConditionalRequire: false }], - output: 'if (typeof window !== "undefined") { require("x") }', errors: [{ message: IMPORT_MESSAGE }], - }, - { code: 'try { require("x") } catch (error) {}', + }), + withoutAutofixOutput({ code: 'try { require("x") } catch (error) {}', options: [{ allowConditionalRequire: false }], - output: 'try { require("x") } catch (error) {}', errors: [{ message: IMPORT_MESSAGE }], - }, + }), ], // exports - { code: 'exports.face = "palm"', output: 'exports.face = "palm"', errors: [{ message: EXPORT_MESSAGE }] }, - { code: 'module.exports.face = "palm"', output: 'module.exports.face = "palm"', errors: [{ message: EXPORT_MESSAGE }] }, - { code: 'module.exports = face', output: 'module.exports = face', errors: [{ message: EXPORT_MESSAGE }] }, - { code: 'exports = module.exports = {}', output: 'exports = module.exports = {}', errors: [{ message: EXPORT_MESSAGE }] }, - { code: 'var x = module.exports = {}', output: 'var x = module.exports = {}', errors: [{ message: EXPORT_MESSAGE }] }, - { code: 'module.exports = {}', + withoutAutofixOutput({ code: 'exports.face = "palm"', errors: [{ message: EXPORT_MESSAGE }] }), + withoutAutofixOutput({ code: 'module.exports.face = "palm"', errors: [{ message: EXPORT_MESSAGE }] }), + withoutAutofixOutput({ code: 'module.exports = face', errors: [{ message: EXPORT_MESSAGE }] }), + withoutAutofixOutput({ code: 'exports = module.exports = {}', errors: [{ message: EXPORT_MESSAGE }] }), + withoutAutofixOutput({ code: 'var x = module.exports = {}', errors: [{ message: EXPORT_MESSAGE }] }), + withoutAutofixOutput({ code: 'module.exports = {}', options: ['allow-primitive-modules'], - output: 'module.exports = {}', errors: [{ message: EXPORT_MESSAGE }], - }, - { code: 'var x = module.exports', + }), + withoutAutofixOutput({ code: 'var x = module.exports', options: ['allow-primitive-modules'], - output: 'var x = module.exports', errors: [{ message: EXPORT_MESSAGE }], - }, + }), ], }); diff --git a/tests/src/rules/no-cycle.js b/tests/src/rules/no-cycle.js index d2adbf61f9..ae4baab666 100644 --- a/tests/src/rules/no-cycle.js +++ b/tests/src/rules/no-cycle.js @@ -1,6 +1,6 @@ import { parsers, test as _test, testFilePath, testVersion as _testVersion } from '../utils'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; import flatMap from 'array.prototype.flatmap'; const ruleTester = new RuleTester(); @@ -8,16 +8,18 @@ const rule = require('rules/no-cycle'); const error = (message) => ({ message }); -const test = (def) => _test(Object.assign(def, { +const test = (def) => _test({ filename: testFilePath('./cycles/depth-zero.js'), -})); -const testVersion = (specifier, t) => _testVersion(specifier, () => Object.assign(t(), { + ...def, +}); +const testVersion = (specifier, t) => _testVersion(specifier, () => ({ filename: testFilePath('./cycles/depth-zero.js'), + ...t(), })); const testDialects = ['es6']; -ruleTester.run('no-cycle', rule, { +const cases = { valid: [].concat( // this rule doesn't care if the cycle length is 0 test({ code: 'import foo from "./foo.js"' }), @@ -232,16 +234,6 @@ ruleTester.run('no-cycle', rule, { errors: [error(`Dependency cycle via ./depth-two:1=>./depth-one:1`)], parser: parsers.BABEL_OLD, }), - test({ - code: `import { foo } from "./${testDialect}/depth-two"`, - options: [{ maxDepth: Infinity }], - errors: [error(`Dependency cycle via ./depth-one:1`)], - }), - test({ - code: `import { foo } from "./${testDialect}/depth-two"`, - options: [{ maxDepth: '∞' }], - errors: [error(`Dependency cycle via ./depth-one:1`)], - }), test({ code: `function bar(){ return import("./${testDialect}/depth-one"); } // #2265 5`, errors: [error(`Dependency cycle detected.`)], @@ -290,4 +282,30 @@ ruleTester.run('no-cycle', rule, { ], }), ), +}; + +ruleTester.run('no-cycle', rule, { + valid: flatMap(cases.valid, (testCase) => [ + testCase, + { + ...testCase, + code: `${testCase.code} // disableScc=true`, + options: [{ + ...testCase.options && testCase.options[0] || {}, + disableScc: true, + }], + }, + ]), + + invalid: flatMap(cases.invalid, (testCase) => [ + testCase, + { + ...testCase, + code: `${testCase.code} // disableScc=true`, + options: [{ + ...testCase.options && testCase.options[0] || {}, + disableScc: true, + }], + }, + ]), }); diff --git a/tests/src/rules/no-default-export.js b/tests/src/rules/no-default-export.js index 6c1a85a1d5..8434ee1486 100644 --- a/tests/src/rules/no-default-export.js +++ b/tests/src/rules/no-default-export.js @@ -1,12 +1,21 @@ import { parsers, test, testVersion } from '../utils'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; const ruleTester = new RuleTester(); const rule = require('rules/no-default-export'); ruleTester.run('no-default-export', rule, { valid: [ + test({ + code: 'module.exports = function foo() {}', + parserOptions: { + sourceType: 'script', + }, + }), + test({ + code: 'module.exports = function foo() {}', + }), test({ code: ` export const foo = 'foo'; diff --git a/tests/src/rules/no-deprecated.js b/tests/src/rules/no-deprecated.js index 318ea7c368..ad51d23c21 100644 --- a/tests/src/rules/no-deprecated.js +++ b/tests/src/rules/no-deprecated.js @@ -1,6 +1,6 @@ import { test, SYNTAX_CASES, getTSParsers } from '../utils'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; const ruleTester = new RuleTester(); const rule = require('rules/no-deprecated'); diff --git a/tests/src/rules/no-duplicates.js b/tests/src/rules/no-duplicates.js index f83221105a..cf57a3d599 100644 --- a/tests/src/rules/no-duplicates.js +++ b/tests/src/rules/no-duplicates.js @@ -2,7 +2,7 @@ import * as path from 'path'; import { test as testUtil, getNonDefaultParsers, parsers, tsVersionSatisfies, typescriptEslintParserSatisfies } from '../utils'; import jsxConfig from '../../../config/react'; -import { RuleTester } from 'eslint'; +import { RuleTester, withoutAutofixOutput } from '../rule-tester'; import eslintPkg from 'eslint/package.json'; import semver from 'semver'; import flatMap from 'array.prototype.flatmap'; @@ -96,15 +96,14 @@ ruleTester.run('no-duplicates', rule, { }), // #86: duplicate unresolved modules should be flagged - test({ + // Autofix bail because of different default import names. + test(withoutAutofixOutput({ code: "import foo from 'non-existent'; import bar from 'non-existent';", - // Autofix bail because of different default import names. - output: "import foo from 'non-existent'; import bar from 'non-existent';", errors: [ "'non-existent' imported multiple times.", "'non-existent' imported multiple times.", ], - }), + })), test({ code: "import type { x } from './foo'; import type { y } from './foo'", @@ -227,12 +226,11 @@ ruleTester.run('no-duplicates', rule, { errors: ['\'./foo\' imported multiple times.', '\'./foo\' imported multiple times.'], }), - test({ + // Autofix bail because cannot merge namespace imports. + test(withoutAutofixOutput({ code: "import * as ns1 from './foo'; import * as ns2 from './foo'", - // Autofix bail because cannot merge namespace imports. - output: "import * as ns1 from './foo'; import * as ns2 from './foo'", errors: ['\'./foo\' imported multiple times.', '\'./foo\' imported multiple times.'], - }), + })), test({ code: "import * as ns from './foo'; import {x} from './foo'; import {y} from './foo'", @@ -248,89 +246,57 @@ ruleTester.run('no-duplicates', rule, { errors: ['\'./foo\' imported multiple times.', '\'./foo\' imported multiple times.', '\'./foo\' imported multiple times.'], }), - test({ + // Autofix bail because of comment. + test(withoutAutofixOutput({ code: ` // some-tool-disable-next-line import {x} from './foo' import {//y\ny} from './foo' `, - // Autofix bail because of comment. - output: ` - // some-tool-disable-next-line - import {x} from './foo' - import {//y\ny} from './foo' - `, errors: ['\'./foo\' imported multiple times.', '\'./foo\' imported multiple times.'], - }), - - test({ + })), + // Autofix bail because of comment. + test(withoutAutofixOutput({ code: ` import {x} from './foo' // some-tool-disable-next-line import {y} from './foo' `, - // Autofix bail because of comment. - output: ` - import {x} from './foo' - // some-tool-disable-next-line - import {y} from './foo' - `, errors: ['\'./foo\' imported multiple times.', '\'./foo\' imported multiple times.'], - }), - - test({ + })), + // Autofix bail because of comment. + test(withoutAutofixOutput({ code: ` import {x} from './foo' // some-tool-disable-line import {y} from './foo' `, - // Autofix bail because of comment. - output: ` - import {x} from './foo' // some-tool-disable-line - import {y} from './foo' - `, errors: ['\'./foo\' imported multiple times.', '\'./foo\' imported multiple times.'], - }), - - test({ + })), + // Autofix bail because of comment. + test(withoutAutofixOutput({ code: ` import {x} from './foo' import {y} from './foo' // some-tool-disable-line `, - // Autofix bail because of comment. - output: ` - import {x} from './foo' - import {y} from './foo' // some-tool-disable-line - `, errors: ['\'./foo\' imported multiple times.', '\'./foo\' imported multiple times.'], - }), - - test({ + })), + // Autofix bail because of comment. + test(withoutAutofixOutput({ code: ` import {x} from './foo' /* comment */ import {y} from './foo' `, - // Autofix bail because of comment. - output: ` - import {x} from './foo' - /* comment */ import {y} from './foo' - `, errors: ['\'./foo\' imported multiple times.', '\'./foo\' imported multiple times.'], - }), - - test({ + })), + // Autofix bail because of comment. + test(withoutAutofixOutput({ code: ` import {x} from './foo' import {y} from './foo' /* comment multiline */ `, - // Autofix bail because of comment. - output: ` - import {x} from './foo' - import {y} from './foo' /* comment - multiline */ - `, errors: ['\'./foo\' imported multiple times.', '\'./foo\' imported multiple times.'], - }), + })), test({ code: ` @@ -361,75 +327,48 @@ import {x,y} from './foo' `, errors: ['\'./foo\' imported multiple times.', '\'./foo\' imported multiple times.'], }), - - test({ + // Autofix bail because of comment. + test(withoutAutofixOutput({ code: ` import {x} from './foo' import/* comment */{y} from './foo' `, - // Autofix bail because of comment. - output: ` - import {x} from './foo' - import/* comment */{y} from './foo' - `, errors: ['\'./foo\' imported multiple times.', '\'./foo\' imported multiple times.'], - }), - - test({ + })), + // Autofix bail because of comment. + test(withoutAutofixOutput({ code: ` import {x} from './foo' import/* comment */'./foo' `, - // Autofix bail because of comment. - output: ` - import {x} from './foo' - import/* comment */'./foo' - `, errors: ['\'./foo\' imported multiple times.', '\'./foo\' imported multiple times.'], - }), - - test({ + })), + // Autofix bail because of comment. + test(withoutAutofixOutput({ code: ` import {x} from './foo' import{y}/* comment */from './foo' `, - // Autofix bail because of comment. - output: ` - import {x} from './foo' - import{y}/* comment */from './foo' - `, errors: ['\'./foo\' imported multiple times.', '\'./foo\' imported multiple times.'], - }), - - test({ + })), + // Autofix bail because of comment. + test(withoutAutofixOutput({ code: ` import {x} from './foo' import{y}from/* comment */'./foo' `, - // Autofix bail because of comment. - output: ` - import {x} from './foo' - import{y}from/* comment */'./foo' - `, errors: ['\'./foo\' imported multiple times.', '\'./foo\' imported multiple times.'], - }), - - test({ + })), + // Autofix bail because of comment. + test(withoutAutofixOutput({ code: ` import {x} from // some-tool-disable-next-line './foo' import {y} from './foo' `, - // Autofix bail because of comment. - output: ` - import {x} from - // some-tool-disable-next-line - './foo' - import {y} from './foo' - `, errors: ['\'./foo\' imported multiple times.', '\'./foo\' imported multiple times.'], - }), + })), // #2027 long import list generate empty lines test({ @@ -455,28 +394,28 @@ import {x,y} from './foo' import { BULK_ACTIONS_ENABLED } from '../constants'; - + ${''} const TestComponent = () => { return
; } - + ${''} export default TestComponent; `, output: ` import { DEFAULT_FILTER_KEYS, BULK_DISABLED, - + ${''} BULK_ACTIONS_ENABLED } from '../constants'; import React from 'react'; - + ${''} const TestComponent = () => { return
; } - + ${''} export default TestComponent; `, errors: ["'../constants' imported multiple times.", "'../constants' imported multiple times."], @@ -616,9 +555,8 @@ context('TypeScript', function () { ]); const invalid = [ - test({ + test(withoutAutofixOutput({ code: "import type x from './foo'; import type y from './foo'", - output: "import type x from './foo'; import type y from './foo'", ...parserConfig, errors: [ { @@ -632,7 +570,7 @@ context('TypeScript', function () { message: "'./foo' imported multiple times.", }, ], - }), + })), test({ code: "import type x from './foo'; import type x from './foo'", output: "import type x from './foo'; ", @@ -704,6 +642,24 @@ context('TypeScript', function () { }, ], }), + test({ + code: "import type {x} from 'foo'; import {type y} from 'foo'", + ...parserConfig, + options: [{ 'prefer-inline': true }], + output: `import {type x,type y} from 'foo'; `, + errors: [ + { + line: 1, + column: 22, + message: "'foo' imported multiple times.", + }, + { + line: 1, + column: 50, + message: "'foo' imported multiple times.", + }, + ], + }), test({ code: "import {type x} from 'foo'; import type {y} from 'foo'", ...parserConfig, diff --git a/tests/src/rules/no-dynamic-require.js b/tests/src/rules/no-dynamic-require.js index e316470ec8..fc7cf2b066 100644 --- a/tests/src/rules/no-dynamic-require.js +++ b/tests/src/rules/no-dynamic-require.js @@ -1,6 +1,6 @@ import { parsers, test, testVersion } from '../utils'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; import flatMap from 'array.prototype.flatmap'; const ruleTester = new RuleTester(); diff --git a/tests/src/rules/no-empty-named-blocks.js b/tests/src/rules/no-empty-named-blocks.js index f65e5a2045..d9514a845b 100644 --- a/tests/src/rules/no-empty-named-blocks.js +++ b/tests/src/rules/no-empty-named-blocks.js @@ -1,6 +1,6 @@ import { parsers, test } from '../utils'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; const ruleTester = new RuleTester(); const rule = require('rules/no-empty-named-blocks'); @@ -10,6 +10,7 @@ function generateSuggestionsTestCases(cases, parser) { code, parser, errors: [{ + message: 'Unexpected empty named import block', suggestions: [ { desc: 'Remove unused import', diff --git a/tests/src/rules/no-extraneous-dependencies.js b/tests/src/rules/no-extraneous-dependencies.js index cb0398ada2..4a465eb39d 100644 --- a/tests/src/rules/no-extraneous-dependencies.js +++ b/tests/src/rules/no-extraneous-dependencies.js @@ -3,7 +3,7 @@ import typescriptConfig from '../../../config/typescript'; import path from 'path'; import fs from 'fs'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; import flatMap from 'array.prototype.flatmap'; const ruleTester = new RuleTester(); @@ -26,6 +26,7 @@ const packageDirWithEmpty = path.join(__dirname, '../../files/empty'); const packageDirBundleDeps = path.join(__dirname, '../../files/bundled-dependencies/as-array-bundle-deps'); const packageDirBundledDepsAsObject = path.join(__dirname, '../../files/bundled-dependencies/as-object'); const packageDirBundledDepsRaceCondition = path.join(__dirname, '../../files/bundled-dependencies/race-condition'); +const emptyPackageDir = path.join(__dirname, '../../files/empty-folder'); const { dependencies: deps, @@ -42,11 +43,9 @@ ruleTester.run('no-extraneous-dependencies', rule, { test({ code: `export { foo } from "${pkg}"` }), test({ code: `export * from "${pkg}"` }), ]), - test({ code: 'import "eslint"' }), test({ code: 'import "eslint/lib/api"' }), test({ code: 'import "fs"' }), test({ code: 'import "./foo"' }), - test({ code: 'import "@org/package"' }), test({ code: 'import "electron"', settings: { 'import/core-modules': ['electron'] } }), test({ @@ -104,6 +103,14 @@ ruleTester.run('no-extraneous-dependencies', rule, { code: 'import leftpad from "left-pad";', options: [{ packageDir: packageDirMonoRepoRoot }], }), + test({ + code: 'import leftpad from "left-pad";', + options: [{ packageDir: [emptyPackageDir, packageDirMonoRepoRoot] }], + }), + test({ + code: 'import leftpad from "left-pad";', + options: [{ packageDir: [packageDirMonoRepoRoot, emptyPackageDir] }], + }), test({ code: 'import react from "react";', options: [{ packageDir: [packageDirMonoRepoRoot, packageDirMonoRepoWithNested] }], @@ -377,15 +384,6 @@ ruleTester.run('no-extraneous-dependencies', rule, { }], }), - test({ - code: 'import "not-a-dependency"', - filename: path.join(packageDirMonoRepoRoot, 'foo.js'), - options: [{ packageDir: packageDirMonoRepoRoot }], - errors: [{ - message: `'not-a-dependency' should be listed in the project's dependencies. Run 'npm i -S not-a-dependency' to add it`, - }], - }), - test({ code: 'import "esm-package-not-in-pkg-json/esm-module";', errors: [{ diff --git a/tests/src/rules/no-import-module-exports.js b/tests/src/rules/no-import-module-exports.js index c2bf7ed132..5738f8c524 100644 --- a/tests/src/rules/no-import-module-exports.js +++ b/tests/src/rules/no-import-module-exports.js @@ -1,5 +1,5 @@ import path from 'path'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; import { eslintVersionSatisfies, test, testVersion } from '../utils'; @@ -74,13 +74,13 @@ ruleTester.run('no-import-module-exports', rule, { import fs from 'fs/promises'; const subscriptions = new Map(); - + ${''} export default async (client) => { /** * loads all modules and their subscriptions */ const modules = await fs.readdir('./src/modules'); - + ${''} await Promise.all( modules.map(async (moduleName) => { // Loads the module @@ -97,7 +97,7 @@ ruleTester.run('no-import-module-exports', rule, { } }) ); - + ${''} /** * Setting up all events. * binds all events inside the subscriptions map to call all functions provided diff --git a/tests/src/rules/no-internal-modules.js b/tests/src/rules/no-internal-modules.js index c1c3015453..9fa91ea3d6 100644 --- a/tests/src/rules/no-internal-modules.js +++ b/tests/src/rules/no-internal-modules.js @@ -1,4 +1,4 @@ -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; import flatMap from 'array.prototype.flatmap'; import rule from 'rules/no-internal-modules'; diff --git a/tests/src/rules/no-mutable-exports.js b/tests/src/rules/no-mutable-exports.js index 1171443c4a..ff9643b0d7 100644 --- a/tests/src/rules/no-mutable-exports.js +++ b/tests/src/rules/no-mutable-exports.js @@ -1,5 +1,5 @@ import { parsers, test, testVersion } from '../utils'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; import rule from 'rules/no-mutable-exports'; const ruleTester = new RuleTester(); diff --git a/tests/src/rules/no-named-as-default-member.js b/tests/src/rules/no-named-as-default-member.js index 1773176f4f..5c00224ed4 100644 --- a/tests/src/rules/no-named-as-default-member.js +++ b/tests/src/rules/no-named-as-default-member.js @@ -1,5 +1,5 @@ import { test, testVersion, SYNTAX_CASES } from '../utils'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; import rule from 'rules/no-named-as-default-member'; const ruleTester = new RuleTester(); diff --git a/tests/src/rules/no-named-as-default.js b/tests/src/rules/no-named-as-default.js index c6646a4f0d..349372067b 100644 --- a/tests/src/rules/no-named-as-default.js +++ b/tests/src/rules/no-named-as-default.js @@ -1,5 +1,5 @@ import { test, testVersion, SYNTAX_CASES, parsers } from '../utils'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; const ruleTester = new RuleTester(); const rule = require('rules/no-named-as-default'); @@ -12,14 +12,20 @@ ruleTester.run('no-named-as-default', rule, { test({ code: 'import bar, { foo } from "./empty-folder";' }), // es7 - test({ code: 'export bar, { foo } from "./bar";', - parser: parsers.BABEL_OLD }), - test({ code: 'export bar from "./bar";', - parser: parsers.BABEL_OLD }), + test({ + code: 'export bar, { foo } from "./bar";', + parser: parsers.BABEL_OLD, + }), + test({ + code: 'export bar from "./bar";', + parser: parsers.BABEL_OLD, + }), // #566: don't false-positive on `default` itself - test({ code: 'export default from "./bar";', - parser: parsers.BABEL_OLD }), + test({ + code: 'export default from "./bar";', + parser: parsers.BABEL_OLD, + }), // es2022: Arbitrary module namespae identifier names testVersion('>= 8.7', () => ({ @@ -27,6 +33,48 @@ ruleTester.run('no-named-as-default', rule, { parserOptions: { ecmaVersion: 2022 }, })), + // #1594: Allow importing as default if object is exported both as default and named + test({ code: 'import something from "./no-named-as-default/re-exports.js";' }), + test({ + code: 'import { something } from "./no-named-as-default/re-exports.js";', + }), + test({ + code: 'import myOwnNameForVariable from "./no-named-as-default/exports.js";', + }), + test({ + code: 'import { variable } from "./no-named-as-default/exports.js";', + }), + test({ + code: 'import variable from "./no-named-as-default/misleading-re-exports.js";', + }), + test({ + // incorrect import + code: 'import foobar from "./no-named-as-default/no-default-export.js";', + }), + // same tests, but for exports + test({ + code: 'export something from "./no-named-as-default/re-exports.js";', + parser: parsers.BABEL_OLD, + }), + test({ + code: 'export { something } from "./no-named-as-default/re-exports.js";', + }), + test({ + code: 'export myOwnNameForVariable from "./no-named-as-default/exports.js";', + parser: parsers.BABEL_OLD, + }), + test({ + code: 'export { variable } from "./no-named-as-default/exports.js";', + }), + test({ + code: 'export variable from "./no-named-as-default/misleading-re-exports.js";', + parser: parsers.BABEL_OLD, + }), + test({ + code: 'export foobar from "./no-named-as-default/no-default-export.js";', + parser: parsers.BABEL_OLD, + }), + ...SYNTAX_CASES, ), @@ -34,13 +82,17 @@ ruleTester.run('no-named-as-default', rule, { test({ code: 'import foo from "./bar";', errors: [{ - message: 'Using exported name \'foo\' as identifier for default export.', - type: 'ImportDefaultSpecifier' }] }), + message: 'Using exported name \'foo\' as identifier for default import.', + type: 'ImportDefaultSpecifier', + }], + }), test({ code: 'import foo, { foo as bar } from "./bar";', errors: [{ - message: 'Using exported name \'foo\' as identifier for default export.', - type: 'ImportDefaultSpecifier' }] }), + message: 'Using exported name \'foo\' as identifier for default import.', + type: 'ImportDefaultSpecifier', + }], + }), // es7 test({ @@ -48,13 +100,17 @@ ruleTester.run('no-named-as-default', rule, { parser: parsers.BABEL_OLD, errors: [{ message: 'Using exported name \'foo\' as identifier for default export.', - type: 'ExportDefaultSpecifier' }] }), + type: 'ExportDefaultSpecifier', + }], + }), test({ code: 'export foo, { foo as bar } from "./bar";', parser: parsers.BABEL_OLD, errors: [{ message: 'Using exported name \'foo\' as identifier for default export.', - type: 'ExportDefaultSpecifier' }] }), + type: 'ExportDefaultSpecifier', + }], + }), test({ code: 'import foo from "./malformed.js"', @@ -68,7 +124,7 @@ ruleTester.run('no-named-as-default', rule, { testVersion('>= 8.7', () => ({ code: 'import foo from "./export-default-string-and-named"', errors: [{ - message: 'Using exported name \'foo\' as identifier for default export.', + message: 'Using exported name \'foo\' as identifier for default import.', type: 'ImportDefaultSpecifier', }], parserOptions: { ecmaVersion: 2022 }, @@ -76,10 +132,36 @@ ruleTester.run('no-named-as-default', rule, { testVersion('>= 8.7', () => ({ code: 'import foo, { foo as bar } from "./export-default-string-and-named"', errors: [{ - message: 'Using exported name \'foo\' as identifier for default export.', + message: 'Using exported name \'foo\' as identifier for default import.', type: 'ImportDefaultSpecifier', }], parserOptions: { ecmaVersion: 2022 }, })), + + // #1594: Allow importing as default if object is exported both as default and named + test({ + code: 'import something from "./no-named-as-default/misleading-re-exports.js";', + parser: parsers.BABEL_OLD, + errors: [{ + message: 'Using exported name \'something\' as identifier for default import.', + type: 'ImportDefaultSpecifier', + }], + }), + // The only cases that are not covered by the fix + test({ + code: 'import variable from "./no-named-as-default/exports.js";', + errors: [{ + message: 'Using exported name \'variable\' as identifier for default import.', + type: 'ImportDefaultSpecifier', + }], + }), + test({ + code: 'export variable from "./no-named-as-default/exports.js";', + parser: parsers.BABEL_OLD, + errors: [{ + message: 'Using exported name \'variable\' as identifier for default export.', + type: 'ExportDefaultSpecifier', + }], + }), ), }); diff --git a/tests/src/rules/no-named-default.js b/tests/src/rules/no-named-default.js index 191c9c6ce9..d36e26c448 100644 --- a/tests/src/rules/no-named-default.js +++ b/tests/src/rules/no-named-default.js @@ -1,5 +1,5 @@ import { test, testVersion, SYNTAX_CASES, parsers } from '../utils'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; const ruleTester = new RuleTester(); const rule = require('rules/no-named-default'); diff --git a/tests/src/rules/no-named-export.js b/tests/src/rules/no-named-export.js index 58b5da2f85..41f8e8f02c 100644 --- a/tests/src/rules/no-named-export.js +++ b/tests/src/rules/no-named-export.js @@ -1,4 +1,4 @@ -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; import { parsers, test, testVersion } from '../utils'; const ruleTester = new RuleTester(); @@ -6,6 +6,15 @@ const rule = require('rules/no-named-export'); ruleTester.run('no-named-export', rule, { valid: [].concat( + test({ + code: 'module.export.foo = function () {}', + parserOptions: { + sourceType: 'script', + }, + }), + test({ + code: 'module.export.foo = function () {}', + }), test({ code: 'export default function bar() {};', }), diff --git a/tests/src/rules/no-namespace.js b/tests/src/rules/no-namespace.js index 03a23e3dd7..f5cd967a22 100644 --- a/tests/src/rules/no-namespace.js +++ b/tests/src/rules/no-namespace.js @@ -1,4 +1,4 @@ -import { RuleTester } from 'eslint'; +import { RuleTester, withoutAutofixOutput } from '../rule-tester'; import eslintPkg from 'eslint/package.json'; import semver from 'semver'; import { test } from '../utils'; @@ -82,33 +82,30 @@ ruleTester.run('no-namespace', require('rules/no-namespace'), { ], invalid: [ - test({ + test(withoutAutofixOutput({ code: 'import * as foo from \'foo\';', - output: 'import * as foo from \'foo\';', errors: [{ line: 1, column: 8, message: ERROR_MESSAGE, }], - }), - test({ + })), + test(withoutAutofixOutput({ code: 'import defaultExport, * as foo from \'foo\';', - output: 'import defaultExport, * as foo from \'foo\';', errors: [{ line: 1, column: 23, message: ERROR_MESSAGE, }], - }), - test({ + })), + test(withoutAutofixOutput({ code: 'import * as foo from \'./foo\';', - output: 'import * as foo from \'./foo\';', errors: [{ line: 1, column: 8, message: ERROR_MESSAGE, }], - }), + })), ...FIX_TESTS, ], }); diff --git a/tests/src/rules/no-nodejs-modules.js b/tests/src/rules/no-nodejs-modules.js index b25eb0ce85..cf131ffee2 100644 --- a/tests/src/rules/no-nodejs-modules.js +++ b/tests/src/rules/no-nodejs-modules.js @@ -1,6 +1,6 @@ import { test } from '../utils'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; const isCore = require('is-core-module'); const ruleTester = new RuleTester(); diff --git a/tests/src/rules/no-relative-packages.js b/tests/src/rules/no-relative-packages.js index 6104aeb9ca..9b424506c5 100644 --- a/tests/src/rules/no-relative-packages.js +++ b/tests/src/rules/no-relative-packages.js @@ -1,4 +1,4 @@ -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; import rule from 'rules/no-relative-packages'; import { normalize } from 'path'; diff --git a/tests/src/rules/no-relative-parent-imports.js b/tests/src/rules/no-relative-parent-imports.js index 1af9b8cf8d..93c8b97aac 100644 --- a/tests/src/rules/no-relative-parent-imports.js +++ b/tests/src/rules/no-relative-parent-imports.js @@ -1,4 +1,4 @@ -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; import rule from 'rules/no-relative-parent-imports'; import { parsers, test as _test, testFilePath } from '../utils'; diff --git a/tests/src/rules/no-restricted-paths.js b/tests/src/rules/no-restricted-paths.js index a83a804a0a..c3382ad086 100644 --- a/tests/src/rules/no-restricted-paths.js +++ b/tests/src/rules/no-restricted-paths.js @@ -1,4 +1,4 @@ -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; import rule from 'rules/no-restricted-paths'; import { getTSParsers, test, testFilePath } from '../utils'; diff --git a/tests/src/rules/no-self-import.js b/tests/src/rules/no-self-import.js index ff1248b43c..dd2ea1bf2d 100644 --- a/tests/src/rules/no-self-import.js +++ b/tests/src/rules/no-self-import.js @@ -1,6 +1,6 @@ import { test, testFilePath } from '../utils'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; const ruleTester = new RuleTester(); const rule = require('rules/no-self-import'); diff --git a/tests/src/rules/no-unassigned-import.js b/tests/src/rules/no-unassigned-import.js index f96808cbcc..b73246ac0d 100644 --- a/tests/src/rules/no-unassigned-import.js +++ b/tests/src/rules/no-unassigned-import.js @@ -1,7 +1,7 @@ import { test } from '../utils'; import * as path from 'path'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; const ruleTester = new RuleTester(); const rule = require('rules/no-unassigned-import'); diff --git a/tests/src/rules/no-unresolved.js b/tests/src/rules/no-unresolved.js index 04a53d887b..c6e300c5dc 100644 --- a/tests/src/rules/no-unresolved.js +++ b/tests/src/rules/no-unresolved.js @@ -4,7 +4,7 @@ import { getTSParsers, test, SYNTAX_CASES, testVersion, parsers } from '../utils import { CASE_SENSITIVE_FS } from 'eslint-module-utils/resolve'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; const ruleTester = new RuleTester(); const rule = require('rules/no-unresolved'); @@ -136,15 +136,6 @@ function runResolverTests(resolver) { ], }), - rest({ - code: "import bar from './baz';", - errors: [ - { - message: "Unable to resolve path to module './baz'.", - type: 'Literal', - }, - ], - }), rest({ code: "import bar from './baz';", errors: [ diff --git a/tests/src/rules/no-unused-modules.js b/tests/src/rules/no-unused-modules.js index b09d5d759c..a15d2c2376 100644 --- a/tests/src/rules/no-unused-modules.js +++ b/tests/src/rules/no-unused-modules.js @@ -2,9 +2,13 @@ import { test, testVersion, testFilePath, getTSParsers, parsers } from '../utils import jsxConfig from '../../../config/react'; import typescriptConfig from '../../../config/typescript'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; +import { expect } from 'chai'; +import { execSync } from 'child_process'; import fs from 'fs'; import eslintPkg from 'eslint/package.json'; +import path from 'path'; +import process from 'process'; import semver from 'semver'; let FlatRuleTester; @@ -14,6 +18,7 @@ try { // TODO: figure out why these tests fail in eslint 4 and 5 const isESLint4TODO = semver.satisfies(eslintPkg.version, '^4 || ^5'); +const isESLint9 = semver.satisfies(eslintPkg.version, '>=9'); const ruleTester = new RuleTester(); const typescriptRuleTester = new RuleTester(typescriptConfig); @@ -38,6 +43,13 @@ const unusedExportsTypescriptOptions = [{ ignoreExports: undefined, }]; +const unusedExportsTypescriptIgnoreUnusedTypesOptions = [{ + unusedExports: true, + ignoreUnusedTypeExports: true, + src: [testFilePath('./no-unused-modules/typescript')], + ignoreExports: undefined, +}]; + const unusedExportsJsxOptions = [{ unusedExports: true, src: [testFilePath('./no-unused-modules/jsx')], @@ -281,8 +293,8 @@ describe('dynamic imports', function () { // test for unused exports with `import()` ruleTester.run('no-unused-modules', rule, { - valid: [ - test({ + valid: [].concat( + testVersion('< 9', () => ({ options: unusedExportsOptions, code: ` export const a = 10 @@ -293,10 +305,10 @@ describe('dynamic imports', function () { `, parser: parsers.BABEL_OLD, filename: testFilePath('./no-unused-modules/exports-for-dynamic-js.js'), - }), - ], - invalid: [ - test({ + })), + ), + invalid: [].concat( + testVersion('< 9', () => ({ options: unusedExportsOptions, code: ` export const a = 10 @@ -312,8 +324,8 @@ describe('dynamic imports', function () { error(`exported declaration 'b' not used within other modules`), error(`exported declaration 'c' not used within other modules`), error(`exported declaration 'default' not used within other modules`), - ] }), - ], + ] })), + ), }); typescriptRuleTester.run('no-unused-modules', rule, { valid: [ @@ -1209,6 +1221,66 @@ context('TypeScript', function () { }); }); +describe('ignoreUnusedTypeExports', () => { + getTSParsers().forEach((parser) => { + typescriptRuleTester.run('no-unused-modules', rule, { + valid: [ + // unused vars should not report + test({ + options: unusedExportsTypescriptIgnoreUnusedTypesOptions, + code: `export interface c {};`, + parser, + filename: testFilePath( + './no-unused-modules/typescript/file-ts-c-unused.ts', + ), + }), + test({ + options: unusedExportsTypescriptIgnoreUnusedTypesOptions, + code: `export type d = {};`, + parser, + filename: testFilePath( + './no-unused-modules/typescript/file-ts-d-unused.ts', + ), + }), + test({ + options: unusedExportsTypescriptIgnoreUnusedTypesOptions, + code: `export enum e { f };`, + parser, + filename: testFilePath( + './no-unused-modules/typescript/file-ts-e-unused.ts', + ), + }), + // used vars should not report + test({ + options: unusedExportsTypescriptIgnoreUnusedTypesOptions, + code: `export interface c {};`, + parser, + filename: testFilePath( + './no-unused-modules/typescript/file-ts-c-used-as-type.ts', + ), + }), + test({ + options: unusedExportsTypescriptIgnoreUnusedTypesOptions, + code: `export type d = {};`, + parser, + filename: testFilePath( + './no-unused-modules/typescript/file-ts-d-used-as-type.ts', + ), + }), + test({ + options: unusedExportsTypescriptIgnoreUnusedTypesOptions, + code: `export enum e { f };`, + parser, + filename: testFilePath( + './no-unused-modules/typescript/file-ts-e-used-as-type.ts', + ), + }), + ], + invalid: [], + }); + }); +}); + describe('correctly work with JSX only files', () => { jsxRuleTester.run('no-unused-modules', rule, { valid: [ @@ -1415,3 +1487,37 @@ describe('parser ignores prefixes like BOM and hashbang', () => { }); }); }); + +(isESLint9 ? describe : describe.skip)('with eslint 9+', () => { + it('provides meaningful error when eslintrc is not present', () => { + const tmp = require('tmp'); + + // Create temp directory outside of project root + const tempDir = tmp.dirSync({ unsafeCleanup: true }); + + // Copy example project to temp directory + fs.cpSync(path.join(process.cwd(), 'examples/v9'), tempDir.name, { recursive: true }); + + let errorMessage = ''; + + // Build the plugin + try { + execSync('npm run build'); + } catch (_) { + /* ignore */ + } + + // Install the plugin and run the lint command in the temp directory + try { + execSync(`npm install -D ${process.cwd()} && npm run lint`, { cwd: tempDir.name }); + } catch (error) { + errorMessage = error.stderr.toString(); + } + + // Verify that the error message is as expected + expect(errorMessage).to.contain('the import/no-unused-modules rule requires an .eslintrc file'); + + // Cleanup + tempDir.removeCallback(); + }).timeout(100000); +}); diff --git a/tests/src/rules/no-useless-path-segments.js b/tests/src/rules/no-useless-path-segments.js index d6d0395dea..87f7a73e9a 100644 --- a/tests/src/rules/no-useless-path-segments.js +++ b/tests/src/rules/no-useless-path-segments.js @@ -1,5 +1,5 @@ import { parsers, test } from '../utils'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; const ruleTester = new RuleTester(); const rule = require('rules/no-useless-path-segments'); diff --git a/tests/src/rules/no-webpack-loader-syntax.js b/tests/src/rules/no-webpack-loader-syntax.js index 05ad242f50..86114b36c6 100644 --- a/tests/src/rules/no-webpack-loader-syntax.js +++ b/tests/src/rules/no-webpack-loader-syntax.js @@ -1,6 +1,6 @@ import { test, getTSParsers, parsers } from '../utils'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; import semver from 'semver'; const ruleTester = new RuleTester(); diff --git a/tests/src/rules/order.js b/tests/src/rules/order.js index a6a8735a6f..9b0a2a63ff 100644 --- a/tests/src/rules/order.js +++ b/tests/src/rules/order.js @@ -1,6 +1,6 @@ import { test, getTSParsers, getNonDefaultParsers, testFilePath, parsers } from '../utils'; -import { RuleTester } from 'eslint'; +import { RuleTester, withoutAutofixOutput } from '../rule-tester'; import eslintPkg from 'eslint/package.json'; import semver from 'semver'; import flatMap from 'array.prototype.flatmap'; @@ -21,10 +21,6 @@ const flowRuleTester = new RuleTester({ }); const rule = require('rules/order'); -function withoutAutofixOutput(test) { - return { ...test, output: test.code }; -} - ruleTester.run('order', rule, { valid: [ // Default order using require @@ -169,6 +165,34 @@ ruleTester.run('order', rule, { ['sibling', 'parent', 'external'], ] }], }), + // Grouping import types and alphabetize + test({ + code: ` + import async from 'async'; + import fs from 'fs'; + import path from 'path'; + + import index from '.'; + import relParent3 from '../'; + import relParent1 from '../foo'; + import sibling from './foo'; + `, + options: [{ groups: [ + ['builtin', 'external'], + ], alphabetize: { order: 'asc', caseInsensitive: true } }], + }), + test({ + code: ` + import { fooz } from '../baz.js' + import { foo } from './bar.js' + `, + options: [{ + alphabetize: { order: 'asc', caseInsensitive: true }, + groups: ['builtin', 'external', 'internal', ['parent', 'sibling', 'index'], 'object'], + 'newlines-between': 'always', + warnOnUnassignedImports: true, + }], + }), // Omitted types should implicitly be considered as the last type test({ code: ` @@ -506,7 +530,7 @@ ruleTester.run('order', rule, { }, ], }), - // Option newlines-between: 'always' with multiline imports #1 + // Option newlines-between: 'always' with multi-line imports #1 test({ code: ` import path from 'path'; @@ -522,7 +546,7 @@ ruleTester.run('order', rule, { `, options: [{ 'newlines-between': 'always' }], }), - // Option newlines-between: 'always' with multiline imports #2 + // Option newlines-between: 'always' with multi-line imports #2 test({ code: ` import path from 'path'; @@ -533,7 +557,7 @@ ruleTester.run('order', rule, { `, options: [{ 'newlines-between': 'always' }], }), - // Option newlines-between: 'always' with multiline imports #3 + // Option newlines-between: 'always' with multi-line imports #3 test({ code: ` import foo @@ -647,6 +671,33 @@ ruleTester.run('order', rule, { }, ], }), + // Option newlines-between: 'always-and-inside-groups' and consolidateIslands: true + test({ + code: ` + var fs = require('fs'); + var path = require('path'); + var util = require('util'); + + var async = require('async'); + + var relParent1 = require('../foo'); + + var { + relParent2 } = require('../'); + + var relParent3 = require('../bar'); + + var sibling = require('./foo'); + var sibling2 = require('./bar'); + var sibling3 = require('./foobar'); + `, + options: [ + { + 'newlines-between': 'always-and-inside-groups', + consolidateIslands: 'inside-groups', + }, + ], + }), // Option alphabetize: {order: 'ignore'} test({ code: ` @@ -1105,6 +1156,140 @@ ruleTester.run('order', rule, { }, ], }), + // named import order + test({ + code: ` + import { a, B as C, Z } from './Z'; + const { D, n: c, Y } = require('./Z'); + export { C, D }; + export { A, B, C as default } from "./Z"; + + const { ["ignore require-statements with non-identifier imports"]: z, d } = require("./Z"); + exports = { ["ignore exports statements with non-identifiers"]: Z, D }; + `, + options: [{ + named: true, + alphabetize: { order: 'asc', caseInsensitive: true }, + }], + }), + test({ + code: ` + const { b, A } = require('./Z'); + `, + options: [{ + named: true, + alphabetize: { order: 'desc' }, + }], + }), + test({ + code: ` + import { A, B } from "./Z"; + export { Z, A } from "./Z"; + export { N, P } from "./Z"; + const { X, Y } = require("./Z"); + `, + options: [{ + named: { + require: true, + import: true, + export: false, + }, + }], + }), + test({ + code: ` + import { B, A } from "./Z"; + const { D, C } = require("./Z"); + export { B, A } from "./Z"; + `, + options: [{ + named: { + require: false, + import: false, + export: false, + }, + }], + }), + test({ + code: ` + import { B, A, R } from "foo"; + const { D, O, G } = require("tunes"); + export { B, A, Z } from "foo"; + `, + options: [{ + named: { enabled: false }, + }], + }), + test({ + code: ` + import { A as A, A as B, A as C } from "./Z"; + const { a, a: b, a: c } = require("./Z"); + `, + options: [{ + named: true, + }], + }), + test({ + code: ` + import { A, B, C } from "./Z"; + exports = { A, B, C }; + module.exports = { a: A, b: B, c: C }; + `, + options: [{ + named: { + cjsExports: true, + }, + alphabetize: { order: 'asc' }, + }], + }), + test({ + code: ` + module.exports.A = { }; + module.exports.A.B = { }; + module.exports.B = { }; + exports.C = { }; + `, + options: [{ + named: { + cjsExports: true, + }, + alphabetize: { order: 'asc' }, + }], + }), + // ensure other assignments are untouched + test({ + code: ` + var exports = null; + var module = null; + exports = { }; + module = { }; + module.exports = { }; + module.exports.U = { }; + module.exports.N = { }; + module.exports.C = { }; + exports.L = { }; + exports.E = { }; + `, + options: [{ + named: { + cjsExports: true, + }, + alphabetize: { order: 'asc' }, + }], + }), + test({ + code: ` + exports["B"] = { }; + exports["C"] = { }; + exports["A"] = { }; + `, + options: [{ + named: { + cjsExports: true, + }, + alphabetize: { order: 'asc' }, + }], + }), ], invalid: [ // builtin before external module (require) @@ -1185,19 +1370,19 @@ ruleTester.run('order', rule, { message: '`fs` import should occur before import of `async`', }], }), - // fix order with multilines comments at the end and start of line + // fix order with multi-lines comments at the end and start of line test({ code: ` - /* multiline1 - comment1 */ var async = require('async'); /* multiline2 - comment2 */ var fs = require('fs'); /* multiline3 + /* multi-line1 + comment1 */ var async = require('async'); /* multi-line2 + comment2 */ var fs = require('fs'); /* multi-line3 comment3 */ `, output: ` - /* multiline1 + /* multi-line1 comment1 */ var fs = require('fs');` + ' ' + ` - var async = require('async'); /* multiline2 - comment2 *//* multiline3 + var async = require('async'); /* multi-line2 + comment2 *//* multi-line3 comment3 */ `, errors: [{ @@ -1218,7 +1403,7 @@ ruleTester.run('order', rule, { message: '`fs` import should occur before import of `async`', }], }), - // fix order of multiline import + // fix order of multi-line import test({ code: ` var async = require('async'); @@ -1458,63 +1643,6 @@ ruleTester.run('order', rule, { message: '`async` import should occur before import of `path`', }], }), - // Setting the order for an unknown type - // should make the rule trigger an error and do nothing else - test({ - code: ` - var async = require('async'); - var index = require('./'); - `, - options: [{ groups: [ - 'index', - ['sibling', 'parent', 'UNKNOWN', 'internal'], - ] }], - errors: [{ - message: 'Incorrect configuration of the rule: Unknown type `"UNKNOWN"`', - }], - }), - // Type in an array can't be another array, too much nesting - test({ - code: ` - var async = require('async'); - var index = require('./'); - `, - options: [{ groups: [ - 'index', - ['sibling', 'parent', ['builtin'], 'internal'], - ] }], - errors: [{ - message: 'Incorrect configuration of the rule: Unknown type `["builtin"]`', - }], - }), - // No numbers - test({ - code: ` - var async = require('async'); - var index = require('./'); - `, - options: [{ groups: [ - 'index', - ['sibling', 'parent', 2, 'internal'], - ] }], - errors: [{ - message: 'Incorrect configuration of the rule: Unknown type `2`', - }], - }), - // Duplicate - test({ - code: ` - var async = require('async'); - var index = require('./'); - `, - options: [{ groups: [ - 'index', - ['sibling', 'parent', 'parent', 'internal'], - ] }], - errors: [{ - message: 'Incorrect configuration of the rule: `parent` is duplicated', - }], - }), // Mixing require and import should have import up top test({ code: ` @@ -1743,16 +1871,10 @@ ruleTester.run('order', rule, { }, ], }), - // Cannot fix newlines-between with multiline comment after - test({ + // Cannot fix newlines-between with multi-line comment after + test(withoutAutofixOutput({ code: ` - var fs = require('fs'); /* multiline - comment */ - - var index = require('./'); - `, - output: ` - var fs = require('fs'); /* multiline + var fs = require('fs'); /* multi-line comment */ var index = require('./'); @@ -1769,7 +1891,7 @@ ruleTester.run('order', rule, { message: 'There should be no empty line between import groups', }, ], - }), + })), // Option newlines-between: 'always' - should report lack of newline between groups test({ code: ` @@ -1855,7 +1977,7 @@ ruleTester.run('order', rule, { }), // Option newlines-between: 'never' with unassigned imports and warnOnUnassignedImports disabled // newline is preserved to match existing behavior - test({ + test(withoutAutofixOutput({ code: ` import path from 'path'; import 'loud-rejection'; @@ -1863,13 +1985,6 @@ ruleTester.run('order', rule, { import 'something-else'; import _ from 'lodash'; `, - output: ` - import path from 'path'; - import 'loud-rejection'; - - import 'something-else'; - import _ from 'lodash'; - `, options: [{ 'newlines-between': 'never', warnOnUnassignedImports: false }], errors: [ { @@ -1877,7 +1992,7 @@ ruleTester.run('order', rule, { message: 'There should be no empty line between import groups', }, ], - }), + })), // Option newlines-between: 'never' with unassigned imports and warnOnUnassignedImports enabled test({ code: ` @@ -1902,7 +2017,7 @@ ruleTester.run('order', rule, { ], }), // Option newlines-between: 'never' cannot fix if there are other statements between imports - test({ + test(withoutAutofixOutput({ code: ` import path from 'path'; export const abc = 123; @@ -1910,13 +2025,6 @@ ruleTester.run('order', rule, { import 'something-else'; import _ from 'lodash'; `, - output: ` - import path from 'path'; - export const abc = 123; - - import 'something-else'; - import _ from 'lodash'; - `, options: [{ 'newlines-between': 'never' }], errors: [ { @@ -1924,7 +2032,7 @@ ruleTester.run('order', rule, { message: 'There should be no empty line between import groups', }, ], - }), + })), // Option newlines-between: 'always' should report missing empty lines when using not assigned imports test({ code: ` @@ -2008,7 +2116,7 @@ ruleTester.run('order', rule, { ], }), // reorder fix cannot cross function call on moving below #1 - test({ + test(withoutAutofixOutput({ code: ` const local = require('./local'); @@ -2019,22 +2127,12 @@ ruleTester.run('order', rule, { fn_call(); `, - output: ` - const local = require('./local'); - - fn_call(); - - const global1 = require('global1'); - const global2 = require('global2'); - - fn_call(); - `, errors: [{ message: '`./local` import should occur after import of `global2`', }], - }), + })), // reorder fix cannot cross function call on moving below #2 - test({ + test(withoutAutofixOutput({ code: ` const local = require('./local'); fn_call(); @@ -2043,20 +2141,12 @@ ruleTester.run('order', rule, { fn_call(); `, - output: ` - const local = require('./local'); - fn_call(); - const global1 = require('global1'); - const global2 = require('global2'); - - fn_call(); - `, errors: [{ message: '`./local` import should occur after import of `global2`', }], - }), + })), // reorder fix cannot cross function call on moving below #3 - test({ + test(withoutAutofixOutput({ code: ` const local1 = require('./local1'); const local2 = require('./local2'); @@ -2070,26 +2160,13 @@ ruleTester.run('order', rule, { const global5 = require('global5'); fn_call(); `, - output: ` - const local1 = require('./local1'); - const local2 = require('./local2'); - const local3 = require('./local3'); - const local4 = require('./local4'); - fn_call(); - const global1 = require('global1'); - const global2 = require('global2'); - const global3 = require('global3'); - const global4 = require('global4'); - const global5 = require('global5'); - fn_call(); - `, errors: [ '`./local1` import should occur after import of `global5`', '`./local2` import should occur after import of `global5`', '`./local3` import should occur after import of `global5`', '`./local4` import should occur after import of `global5`', ], - }), + })), // reorder fix cannot cross function call on moving below test(withoutAutofixOutput({ code: ` @@ -2377,7 +2454,7 @@ ruleTester.run('order', rule, { { pattern: '@namespace', group: 'external', position: 'after' }, { pattern: '@namespace/**', group: 'external', position: 'after' }, ], - pathGroupsExcludedImportTypes: ['@namespace'], + pathGroupsExcludedImportTypes: [], }, ], errors: [ @@ -2399,7 +2476,7 @@ ruleTester.run('order', rule, { }], })), // reorder fix cannot cross function call on moving below (from #1252) - test({ + test(withoutAutofixOutput({ code: ` const env = require('./config'); @@ -2410,20 +2487,10 @@ ruleTester.run('order', rule, { http.createServer(express()); `, - output: ` - const env = require('./config'); - - Object.keys(env); - - const http = require('http'); - const express = require('express'); - - http.createServer(express()); - `, errors: [{ message: '`./config` import should occur after import of `express`', }], - }), + })), // reorder cannot cross non plain requires test(withoutAutofixOutput({ code: ` @@ -2714,13 +2781,212 @@ ruleTester.run('order', rule, { message: 'There should be no empty line within import group', }], }), - // Alphabetize with require - ...semver.satisfies(eslintPkg.version, '< 3.0.0') ? [] : [ - test({ - code: ` - const { cello } = require('./cello'); - import { int } from './int'; - const blah = require('./blah'); + // named import order + test({ + code: ` + var { B, A: R } = require("./Z"); + import { O as G, D } from "./Z"; + import { K, L, J } from "./Z"; + export { Z, X, Y } from "./Z"; + `, + output: ` + var { A: R, B } = require("./Z"); + import { D, O as G } from "./Z"; + import { J, K, L } from "./Z"; + export { X, Y, Z } from "./Z"; + `, + options: [{ + named: true, + alphabetize: { order: 'asc' }, + }], + errors: [{ + message: '`A` import should occur before import of `B`', + }, { + message: '`D` import should occur before import of `O`', + }, { + message: '`J` import should occur before import of `K`', + }, { + message: '`Z` export should occur after export of `Y`', + }], + }), + test({ + code: ` + import { D, C } from "./Z"; + var { B, A } = require("./Z"); + export { B, A }; + `, + output: ` + import { C, D } from "./Z"; + var { B, A } = require("./Z"); + export { A, B }; + `, + options: [{ + named: { + require: false, + import: true, + export: true, + }, + alphabetize: { order: 'asc' }, + }], + errors: [{ + message: '`C` import should occur before import of `D`', + }, { + message: '`A` export should occur before export of `B`', + }], + }), + test({ + code: ` + import { A as B, A as C, A } from "./Z"; + export { A, A as D, A as B, A as C } from "./Z"; + const { a: b, a: c, a } = require("./Z"); + `, + output: ` + import { A, A as B, A as C } from "./Z"; + export { A, A as B, A as C, A as D } from "./Z"; + const { a, a: b, a: c } = require("./Z"); + `, + options: [{ + named: true, + alphabetize: { order: 'asc' }, + }], + errors: [{ + message: '`A` import should occur before import of `A as B`', + }, { + message: '`A as D` export should occur after export of `A as C`', + }, { + message: '`a` import should occur before import of `a as b`', + }], + }), + test({ + code: ` + import { A, B, C } from "./Z"; + exports = { B, C, A }; + module.exports = { c: C, a: A, b: B }; + `, + output: ` + import { A, B, C } from "./Z"; + exports = { A, B, C }; + module.exports = { a: A, b: B, c: C }; + `, + options: [{ + named: { + cjsExports: true, + }, + alphabetize: { order: 'asc' }, + }], + errors: [{ + message: '`A` export should occur before export of `B`', + }, { + message: '`c` export should occur after export of `b`', + }], + }), + test({ + code: ` + exports.B = { }; + module.exports.A = { }; + module.exports.C = { }; + `, + output: ` + module.exports.A = { }; + exports.B = { }; + module.exports.C = { }; + `, + options: [{ + named: { + cjsExports: true, + }, + alphabetize: { order: 'asc' }, + }], + errors: [{ + message: '`A` export should occur before export of `B`', + }], + }), + test({ + code: ` + exports.A.C = { }; + module.exports.A.A = { }; + exports.A.B = { }; + `, + output: ` + module.exports.A.A = { }; + exports.A.B = { }; + exports.A.C = { }; + `, + options: [{ + named: { + cjsExports: true, + }, + alphabetize: { order: 'asc' }, + }], + errors: [{ + message: '`A.C` export should occur after export of `A.B`', + }], + }), + // multi-line named specifiers & trailing commas + test({ + code: ` + const { + F: O, + O: B, + /* Hello World */ + A: R + } = require("./Z"); + import { + Y, + X, + } from "./Z"; + export { + Z, A, + B + } from "./Z"; + module.exports = { + a: A, o: O, + b: B + }; + `, + output: ` + const { + /* Hello World */ + A: R, + F: O, + O: B + } = require("./Z"); + import { + X, + Y, + } from "./Z"; + export { A, + B, + Z + } from "./Z"; + module.exports = { + a: A, + b: B, o: O + }; + `, + options: [{ + named: { + enabled: true, + }, + alphabetize: { order: 'asc' }, + }], + errors: [{ + message: '`A` import should occur before import of `F`', + }, { + message: '`X` import should occur before import of `Y`', + }, { + message: '`Z` export should occur after export of `B`', + }, { + message: '`b` export should occur before export of `o`', + }], + }), + // Alphabetize with require + ...semver.satisfies(eslintPkg.version, '< 3.0.0') ? [] : [ + test({ + code: ` + const { cello } = require('./cello'); + import { int } from './int'; + const blah = require('./blah'); import { hello } from './hello'; `, output: ` @@ -2736,6 +3002,156 @@ ruleTester.run('order', rule, { }], }), ], + // Option newlines-between: 'always-and-inside-groups' and consolidateIslands: true + test({ + code: ` + var fs = require('fs'); + var path = require('path'); + var { util1, util2, util3 } = require('util'); + var async = require('async'); + var relParent1 = require('../foo'); + var { + relParent21, + relParent22, + relParent23, + relParent24, + } = require('../'); + var relParent3 = require('../bar'); + var { sibling1, + sibling2, sibling3 } = require('./foo'); + var sibling2 = require('./bar'); + var sibling3 = require('./foobar'); + `, + output: ` + var fs = require('fs'); + var path = require('path'); + var { util1, util2, util3 } = require('util'); + + var async = require('async'); + + var relParent1 = require('../foo'); + + var { + relParent21, + relParent22, + relParent23, + relParent24, + } = require('../'); + + var relParent3 = require('../bar'); + + var { sibling1, + sibling2, sibling3 } = require('./foo'); + + var sibling2 = require('./bar'); + var sibling3 = require('./foobar'); + `, + options: [ + { + 'newlines-between': 'always-and-inside-groups', + consolidateIslands: 'inside-groups', + }, + ], + errors: [ + { + message: 'There should be at least one empty line between import groups', + line: 4, + }, + { + message: 'There should be at least one empty line between import groups', + line: 5, + }, + { + message: 'There should be at least one empty line between this import and the multi-line import that follows it', + line: 6, + }, + { + message: 'There should be at least one empty line between this multi-line import and the import that follows it', + line: 12, + }, + { + message: 'There should be at least one empty line between import groups', + line: 13, + }, + { + message: 'There should be at least one empty line between this multi-line import and the import that follows it', + line: 15, + }, + ], + }), + test({ + code: ` + var fs = require('fs'); + + var path = require('path'); + + var { util1, util2, util3 } = require('util'); + + var async = require('async'); + + var relParent1 = require('../foo'); + + var { + relParent21, + relParent22, + relParent23, + relParent24, + } = require('../'); + + var relParent3 = require('../bar'); + + var { sibling1, + sibling2, sibling3 } = require('./foo'); + + var sibling2 = require('./bar'); + + var sibling3 = require('./foobar'); + `, + output: ` + var fs = require('fs'); + var path = require('path'); + var { util1, util2, util3 } = require('util'); + + var async = require('async'); + + var relParent1 = require('../foo'); + + var { + relParent21, + relParent22, + relParent23, + relParent24, + } = require('../'); + + var relParent3 = require('../bar'); + + var { sibling1, + sibling2, sibling3 } = require('./foo'); + + var sibling2 = require('./bar'); + var sibling3 = require('./foobar'); + `, + options: [ + { + 'newlines-between': 'always-and-inside-groups', + consolidateIslands: 'inside-groups', + }, + ], + errors: [ + { + message: 'There should be no empty lines between this single-line import and the single-line import that follows it', + line: 2, + }, + { + message: 'There should be no empty lines between this single-line import and the single-line import that follows it', + line: 4, + }, + { + message: 'There should be no empty lines between this single-line import and the single-line import that follows it', + line: 24, + }, + ], + }), ].filter(Boolean), }); @@ -2744,6 +3160,9 @@ context('TypeScript', function () { // Type-only imports were added in TypeScript ESTree 2.23.0 .filter((parser) => parser !== parsers.TS_OLD) .forEach((parser) => { + const supportsTypeSpecifiers = semver.satisfies(require('@typescript-eslint/parser/package.json').version, '>= 5'); + const supportsImportTypeSpecifiers = parser !== parsers.TS_NEW || supportsTypeSpecifiers; + const supportsExportTypeSpecifiers = parser === parsers.TS_NEW && supportsTypeSpecifiers; const parserConfig = { parser, settings: { @@ -2816,7 +3235,6 @@ context('TypeScript', function () { }), // Option alphabetize: {order: 'asc'} with type group & path group test({ - // only: true, code: ` import c from 'Bar'; import a from 'foo'; @@ -2846,7 +3264,6 @@ context('TypeScript', function () { }), // Option alphabetize: {order: 'asc'} with path group test({ - // only: true, code: ` import c from 'Bar'; import type { A } from 'foo'; @@ -2986,231 +3403,3235 @@ context('TypeScript', function () { }], }), ] : [], - ), - invalid: [].concat( - // Option alphabetize: {order: 'asc'} + // Option sortTypesGroup: false (default) test({ code: ` - import b from 'bar'; import c from 'Bar'; - import type { C } from 'Bar'; import a from 'foo'; - import type { A } from 'foo'; - import index from './'; - `, - output: ` - import c from 'Bar'; - import type { C } from 'Bar'; - import b from 'bar'; - import a from 'foo'; - import type { A } from 'foo'; + import type { C } from 'dirA/Bar'; + import b from 'dirA/bar'; + import type { D } from 'dirA/bar'; import index from './'; + + import type { AA } from 'abc'; + import type { A } from 'foo'; `, ...parserConfig, options: [ { - groups: ['external', 'index'], alphabetize: { order: 'asc' }, - }, - ], - errors: [ - { - message: semver.satisfies(eslintPkg.version, '< 3') - ? '`bar` import should occur after type import of `Bar`' - : /(`bar` import should occur after type import of `Bar`)|(`Bar` type import should occur before import of `bar`)/, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + }, + ], + 'newlines-between': 'always', + pathGroupsExcludedImportTypes: [], }, ], }), - // Option alphabetize: {order: 'desc'} test({ code: ` - import a from 'foo'; - import type { A } from 'foo'; import c from 'Bar'; - import type { C } from 'Bar'; - import b from 'bar'; - - import index from './'; - `, - output: ` import a from 'foo'; - import type { A } from 'foo'; - import b from 'bar'; - import c from 'Bar'; - import type { C } from 'Bar'; + + import type { C } from 'dirA/Bar'; + import b from 'dirA/bar'; + import type { D } from 'dirA/bar'; import index from './'; + + import type { AA } from 'abc'; + import type { A } from 'foo'; `, ...parserConfig, options: [ { - groups: ['external', 'index'], - alphabetize: { order: 'desc' }, - }, - ], - errors: [ - { - message: semver.satisfies(eslintPkg.version, '< 3') - ? '`bar` import should occur before import of `Bar`' - : /(`bar` import should occur before import of `Bar`)|(`Bar` import should occur after import of `bar`)/, - }, - ], + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + }, + ], + 'newlines-between': 'always', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: false, + }, + ], + }), + // Option sortTypesGroup: true and 'type' in pathGroupsExcludedImportTypes + test({ + code: ` + import c from 'Bar'; + import a from 'foo'; + + import b from 'dirA/bar'; + + import index from './'; + + import type { AA } from 'abc'; + import type { C } from 'dirA/Bar'; + import type { D } from 'dirA/bar'; + import type { A } from 'foo'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + }, + ], + 'newlines-between': 'always', + pathGroupsExcludedImportTypes: ['type'], + sortTypesGroup: true, + }, + ], + }), + test({ + code: ` + import c from 'Bar'; + import type { AA } from 'abc'; + import a from 'foo'; + import type { A } from 'foo'; + + import type { C } from 'dirA/Bar'; + import b from 'dirA/bar'; + import type { D } from 'dirA/bar'; + + import index from './'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + }, + ], + 'newlines-between': 'always', + pathGroupsExcludedImportTypes: [], + }, + ], + }), + // Option sortTypesGroup: true and newlines-between-types defaults to the value of newlines-between + test({ + code: ` + import c from 'Bar'; + import a from 'foo'; + + import b from 'dirA/bar'; + + import index from './'; + + import type { AA } from 'abc'; + import type { A } from 'foo'; + + import type { C } from 'dirA/Bar'; + import type { D } from 'dirA/bar'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + }, + ], + 'newlines-between': 'always', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + }, + ], + }), + // Option: sortTypesGroup: true and newlines-between-types: 'always' (takes precedence over newlines-between between type-only and normal imports) + test({ + code: ` + import c from 'Bar'; + import a from 'foo'; + import b from 'dirA/bar'; + import index from './'; + + import type { AA } from 'abc'; + import type { A } from 'foo'; + + import type { C } from 'dirA/Bar'; + import type { D } from 'dirA/bar'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + }, + ], + 'newlines-between': 'never', + 'newlines-between-types': 'always', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + }, + ], + }), + // Option: sortTypesGroup: true and newlines-between-types: 'never' (takes precedence over newlines-between between type-only and normal imports) + test({ + code: ` + import c from 'Bar'; + import a from 'foo'; + + import b from 'dirA/bar'; + + import index from './'; + import type { AA } from 'abc'; + import type { A } from 'foo'; + import type { C } from 'dirA/Bar'; + import type { D } from 'dirA/bar'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + }, + ], + 'newlines-between': 'always', + 'newlines-between-types': 'never', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + }, + ], + }), + // Option: sortTypesGroup: true and newlines-between-types: 'ignore' + test({ + code: ` + import c from 'Bar'; + import a from 'foo'; + import b from 'dirA/bar'; + import index from './'; + import type { AA } from 'abc'; + + import type { A } from 'foo'; + import type { C } from 'dirA/Bar'; + import type { D } from 'dirA/bar'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + }, + ], + 'newlines-between': 'never', + 'newlines-between-types': 'ignore', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + }, + ], + }), + // Option: sortTypesGroup: true and newlines-between-types: 'always-and-inside-groups' + test({ + code: ` + import c from 'Bar'; + import a from 'foo'; + import b from 'dirA/bar'; + import index from './'; + + import type { AA } from 'abc'; + + import type { A } from 'foo'; + + import type { C } from 'dirA/Bar'; + + import type { D } from 'dirA/bar'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + }, + ], + 'newlines-between': 'never', + 'newlines-between-types': 'always-and-inside-groups', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + }, + ], + }), + // Option: sortTypesGroup: true puts type imports in the same order as regular imports (from issue #2441, PR #2615) + test({ + code: ` + import type A from "fs"; + import type B from "path"; + import type C from "../foo.js"; + import type D from "./bar.js"; + import type E from './'; + + import a from "fs"; + import b from "path"; + import c from "../foo.js"; + import d from "./bar.js"; + import e from "./"; + `, + ...parserConfig, + options: [ + { + groups: ['type', 'builtin', 'parent', 'sibling', 'index'], + alphabetize: { + order: 'asc', + caseInsensitive: true, + }, + sortTypesGroup: true, + }, + ], + }), + // Options: sortTypesGroup + newlines-between-types example #1 from the documentation (pass) + test({ + code: ` + import type A from "fs"; + import type B from "path"; + import type C from "../foo.js"; + import type D from "./bar.js"; + import type E from './'; + + import a from "fs"; + import b from "path"; + + import c from "../foo.js"; + + import d from "./bar.js"; + + import e from "./"; + `, + ...parserConfig, + options: [ + { + groups: ['type', 'builtin', 'parent', 'sibling', 'index'], + sortTypesGroup: true, + 'newlines-between': 'always', + 'newlines-between-types': 'ignore', + }, + ], + }), + test({ + code: ` + import a from "fs"; + import b from "path"; + + import c from "../foo.js"; + + import d from "./bar.js"; + + import e from "./"; + + import type A from "fs"; + import type B from "path"; + import type C from "../foo.js"; + import type D from "./bar.js"; + import type E from './'; + `, + ...parserConfig, + options: [ + { + groups: ['builtin', 'parent', 'sibling', 'index', 'type'], + sortTypesGroup: true, + 'newlines-between': 'always', + 'newlines-between-types': 'ignore', + }, + ], + }), + // Options: sortTypesGroup + newlines-between-types example #2 from the documentation (pass) + test({ + code: ` + import type A from "fs"; + import type B from "path"; + + import type C from "../foo.js"; + + import type D from "./bar.js"; + + import type E from './'; + + import a from "fs"; + import b from "path"; + import c from "../foo.js"; + import d from "./bar.js"; + import e from "./"; + `, + ...parserConfig, + options: [ + { + groups: ['type', 'builtin', 'parent', 'sibling', 'index'], + sortTypesGroup: true, + 'newlines-between': 'never', + 'newlines-between-types': 'always', + }, + ], + }), + test({ + code: ` + import a from "fs"; + import b from "path"; + import c from "../foo.js"; + import d from "./bar.js"; + import e from "./"; + + import type A from "fs"; + import type B from "path"; + + import type C from "../foo.js"; + + import type D from "./bar.js"; + + import type E from './'; + `, + ...parserConfig, + options: [ + { + groups: ['builtin', 'parent', 'sibling', 'index', 'type'], + sortTypesGroup: true, + 'newlines-between': 'never', + 'newlines-between-types': 'always', + }, + ], + }), + // Ensure the rule doesn't choke and die on absolute paths trying to pass NaN around + test({ + code: ` + import fs from 'fs'; + + import '@scoped/package'; + import type { B } from 'fs'; + + import type { A1 } from '/bad/bad/bad/bad'; + import './a/b/c'; + import type { A2 } from '/bad/bad/bad/bad'; + import type { A3 } from '/bad/bad/bad/bad'; + import type { D1 } from '/bad/bad/not/good'; + import type { D2 } from '/bad/bad/not/good'; + import type { D3 } from '/bad/bad/not/good'; + + import type { C } from '@something/else'; + + import type { E } from './index.js'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['builtin', 'type', 'unknown', 'external'], + sortTypesGroup: true, + 'newlines-between': 'always', + }, + ], + }), + // Ensure the rule doesn't choke and die when right-hand-side AssignmentExpression properties lack a "key" attribute (e.g. SpreadElement) + test({ + code: ` + // https://prettier.io/docs/en/options.html + + module.exports = { + ...require('@xxxx/.prettierrc.js'), + }; + `, + ...parserConfig, + options: [{ named: { enabled: true } }], + }), + // Option: sortTypesGroup: true and newlines-between-types: 'always-and-inside-groups' and consolidateIslands: 'inside-groups' + test({ + code: ` + import c from 'Bar'; + import d from 'bar'; + + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; + + import a from 'foo'; + + import b from 'dirA/bar'; + + import index from './'; + + import type { AA, + BB, CC } from 'abc'; + + import type { Z } from 'fizz'; + + import type { + A, + B + } from 'foo'; + + import type { C2 } from 'dirB/Bar'; + + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + + import type { E2 } from 'dirB/baz'; + + import type { C3 } from 'dirC/Bar'; + + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + + import type { E3 } from 'dirC/baz'; + import type { F3 } from 'dirC/caz'; + + import type { C1 } from 'dirA/Bar'; + + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + + import type { E1 } from 'dirA/baz'; + + import type { F } from './index.js'; + + import type { G } from './aaa.js'; + import type { H } from './bbb'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + position: 'after', + }, + { + pattern: 'dirB/**', + group: 'internal', + position: 'before', + }, + { + pattern: 'dirC/**', + group: 'internal', + }, + ], + 'newlines-between': 'always-and-inside-groups', + 'newlines-between-types': 'always-and-inside-groups', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + consolidateIslands: 'inside-groups', + }, + ], + }), + // Option: sortTypesGroup: true and newlines-between-types: 'always-and-inside-groups' and consolidateIslands: 'never' (default) + test({ + code: ` + import c from 'Bar'; + import d from 'bar'; + + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; + + import a from 'foo'; + + import b from 'dirA/bar'; + + import index from './'; + + import type { AA, + BB, CC } from 'abc'; + + import type { Z } from 'fizz'; + + import type { + A, + B + } from 'foo'; + + import type { C2 } from 'dirB/Bar'; + + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + + import type { E2 } from 'dirB/baz'; + + import type { C3 } from 'dirC/Bar'; + + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + + import type { E3 } from 'dirC/baz'; + import type { F3 } from 'dirC/caz'; + + import type { C1 } from 'dirA/Bar'; + + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + + import type { E1 } from 'dirA/baz'; + + import type { F } from './index.js'; + + import type { G } from './aaa.js'; + import type { H } from './bbb'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + position: 'after', + }, + { + pattern: 'dirB/**', + group: 'internal', + position: 'before', + }, + { + pattern: 'dirC/**', + group: 'internal', + }, + ], + 'newlines-between': 'always-and-inside-groups', + 'newlines-between-types': 'always-and-inside-groups', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + consolidateIslands: 'never', + }, + ], + }), + // Ensure consolidateIslands: 'inside-groups', newlines-between: 'always-and-inside-groups', and newlines-between-types: 'never' do not fight for dominance + test({ + code: ` + import makeVanillaYargs from 'yargs/yargs'; + + import { createDebugLogger } from 'multiverse+rejoinder'; + + import { globalDebuggerNamespace } from 'rootverse+bfe:src/constant.ts'; + import { ErrorMessage, type KeyValueEntry } from 'rootverse+bfe:src/error.ts'; + + import { + $artificiallyInvoked, + $canonical, + $exists, + $genesis + } from 'rootverse+bfe:src/symbols.ts'; + + import type { + Entries, + LiteralUnion, + OmitIndexSignature, + Promisable, + StringKeyOf + } from 'type-fest'; + `, + ...parserConfig, + options: [ + { + alphabetize: { + order: 'asc', + orderImportKind: 'asc', + caseInsensitive: true, + }, + named: { + enabled: true, + types: 'types-last', + }, + groups: [ + 'builtin', + 'external', + 'internal', + ['parent', 'sibling', 'index'], + ['object', 'type'], + ], + pathGroups: [ + { + pattern: 'multiverse{*,*/**}', + group: 'external', + position: 'after', + }, + { + pattern: 'rootverse{*,*/**}', + group: 'external', + position: 'after', + }, + { + pattern: 'universe{*,*/**}', + group: 'external', + position: 'after', + }, + ], + distinctGroup: true, + pathGroupsExcludedImportTypes: ['builtin', 'object'], + 'newlines-between': 'always-and-inside-groups', + 'newlines-between-types': 'never', + sortTypesGroup: true, + consolidateIslands: 'inside-groups', + }, + ], + }), + // Ensure consolidateIslands: 'inside-groups', newlines-between: 'never', and newlines-between-types: 'always-and-inside-groups' do not fight for dominance + test({ + code: ` + import makeVanillaYargs from 'yargs/yargs'; + import { createDebugLogger } from 'multiverse+rejoinder'; + import { globalDebuggerNamespace } from 'rootverse+bfe:src/constant.ts'; + import { ErrorMessage, type KeyValueEntry } from 'rootverse+bfe:src/error.ts'; + import { $artificiallyInvoked } from 'rootverse+bfe:src/symbols.ts'; + + import type { + Entries, + LiteralUnion, + OmitIndexSignature, + Promisable, + StringKeyOf + } from 'type-fest'; + `, + ...parserConfig, + options: [ + { + alphabetize: { + order: 'asc', + orderImportKind: 'asc', + caseInsensitive: true, + }, + named: { + enabled: true, + types: 'types-last', + }, + groups: [ + 'builtin', + 'external', + 'internal', + ['parent', 'sibling', 'index'], + ['object', 'type'], + ], + pathGroups: [ + { + pattern: 'multiverse{*,*/**}', + group: 'external', + position: 'after', + }, + { + pattern: 'rootverse{*,*/**}', + group: 'external', + position: 'after', + }, + { + pattern: 'universe{*,*/**}', + group: 'external', + position: 'after', + }, + ], + distinctGroup: true, + pathGroupsExcludedImportTypes: ['builtin', 'object'], + 'newlines-between': 'never', + 'newlines-between-types': 'always-and-inside-groups', + sortTypesGroup: true, + consolidateIslands: 'inside-groups', + }, + ], + }), + test({ + code: ` + import c from 'Bar'; + import d from 'bar'; + + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; + + import a from 'foo'; + import b from 'dirA/bar'; + import index from './'; + + import type { AA, + BB, CC } from 'abc'; + + import type { Z } from 'fizz'; + + import type { + A, + B + } from 'foo'; + + import type { C2 } from 'dirB/Bar'; + + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + + import type { E2 } from 'dirB/baz'; + + import type { C3 } from 'dirC/Bar'; + + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + + import type { E3 } from 'dirC/baz'; + import type { F3 } from 'dirC/caz'; + + import type { C1 } from 'dirA/Bar'; + + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + + import type { E1 } from 'dirA/baz'; + + import type { F } from './index.js'; + + import type { G } from './aaa.js'; + import type { H } from './bbb'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + position: 'after', + }, + { + pattern: 'dirB/**', + group: 'internal', + position: 'before', + }, + { + pattern: 'dirC/**', + group: 'internal', + }, + ], + 'newlines-between': 'never', + 'newlines-between-types': 'always-and-inside-groups', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + consolidateIslands: 'inside-groups', + }, + ], + }), + test({ + code: ` + import assert from 'assert'; + import { isNativeError } from 'util/types'; + + import { runNoRejectOnBadExit } from '@-xun/run'; + import { TrialError } from 'named-app-errors'; + import { resolve as resolverLibrary } from 'resolve.exports'; + + import { ${supportsImportTypeSpecifiers ? 'toAbsolutePath, type AbsolutePath' : 'AbsolutePath, toAbsolutePath'} } from 'rootverse+project-utils:src/fs.ts'; + + import type { PackageJson } from 'type-fest'; + // Some comment about remembering to do something + import type { XPackageJson } from 'rootverse:src/assets/config/_package.json.ts'; + `, + ...parserConfig, + options: [ + { + alphabetize: { + order: 'asc', + orderImportKind: 'asc', + caseInsensitive: true, + }, + named: { + enabled: true, + types: 'types-last', + }, + groups: [ + 'builtin', + 'external', + 'internal', + ['parent', 'sibling', 'index'], + ['object', 'type'], + ], + pathGroups: [ + { + pattern: 'rootverse{*,*/**}', + group: 'external', + position: 'after', + }, + ], + distinctGroup: true, + pathGroupsExcludedImportTypes: ['builtin', 'object'], + 'newlines-between': 'always-and-inside-groups', + 'newlines-between-types': 'never', + sortTypesGroup: true, + consolidateIslands: 'inside-groups', + }, + ], + }), + + // Documentation passing example #1 for newlines-between + test({ + code: ` + import fs from 'fs'; + import path from 'path'; + + import sibling from './foo'; + + import index from './'; + `, + ...parserConfig, + options: [ + { + 'newlines-between': 'always', + }, + ], + }), + // Documentation passing example #2 for newlines-between + test({ + code: ` + import fs from 'fs'; + + import path from 'path'; + + import sibling from './foo'; + + import index from './'; + `, + ...parserConfig, + options: [ + { + 'newlines-between': 'always-and-inside-groups', + }, + ], + }), + // Documentation passing example #3 for newlines-between + test({ + code: ` + import fs from 'fs'; + import path from 'path'; + import sibling from './foo'; + import index from './'; + `, + ...parserConfig, + options: [ + { + 'newlines-between': 'never', + }, + ], + }), + // Documentation passing example #1 for alphabetize + test({ + code: ` + import blist2 from 'blist'; + import blist from 'BList'; + import * as classnames from 'classnames'; + import aTypes from 'prop-types'; + import React, { PureComponent } from 'react'; + import { compose, apply } from 'xcompose'; + `, + ...parserConfig, + options: [ + { + alphabetize: { + order: 'asc', + caseInsensitive: true, + }, + }, + ], + }), + // (not an example, but we also test caseInsensitive: false for completeness) + test({ + code: ` + import blist from 'BList'; + import blist2 from 'blist'; + import * as classnames from 'classnames'; + import aTypes from 'prop-types'; + import React, { PureComponent } from 'react'; + import { compose, apply } from 'xcompose'; + `, + ...parserConfig, + options: [ + { + alphabetize: { + order: 'asc', + caseInsensitive: false, + }, + }, + ], + }), + // Documentation passing example #1 for named + test({ + code: ` + import { apply, compose } from 'xcompose'; + `, + ...parserConfig, + options: [ + { + named: true, + alphabetize: { + order: 'asc', + }, + }, + ], + }), + // Documentation passing example #1 for warnOnUnassignedImports + test({ + code: ` + import fs from 'fs'; + import path from 'path'; + import './styles.css'; + `, + ...parserConfig, + options: [ + { + warnOnUnassignedImports: true, + }, + ], + }), + // Documentation passing example #1 for sortTypesGroup + test({ + code: ` + import type A from "fs"; + import type B from "path"; + import type C from "../foo.js"; + import type D from "./bar.js"; + import type E from './'; + + import a from "fs"; + import b from "path"; + import c from "../foo.js"; + import d from "./bar.js"; + import e from "./"; + `, + ...parserConfig, + options: [ + { + groups: ['type', 'builtin', 'parent', 'sibling', 'index'], + alphabetize: { order: 'asc' }, + sortTypesGroup: true, + }, + ], + }), + // (not an example, but we also test the reverse for completeness) + test({ + code: ` + import a from "fs"; + import b from "path"; + import c from "../foo.js"; + import d from "./bar.js"; + import e from "./"; + + import type A from "fs"; + import type B from "path"; + import type C from "../foo.js"; + import type D from "./bar.js"; + import type E from './'; + `, + ...parserConfig, + options: [ + { + groups: ['builtin', 'parent', 'sibling', 'index', 'type'], + sortTypesGroup: true, + }, + ], + }), + // Documentation passing example #1 for newlines-between-types + test({ + code: ` + import type A from "fs"; + import type B from "path"; + import type C from "../foo.js"; + import type D from "./bar.js"; + import type E from './'; + + import a from "fs"; + import b from "path"; + + import c from "../foo.js"; + + import d from "./bar.js"; + + import e from "./"; + `, + ...parserConfig, + options: [ + { + groups: ['type', 'builtin', 'parent', 'sibling', 'index'], + sortTypesGroup: true, + 'newlines-between': 'always', + 'newlines-between-types': 'ignore', + }, + ], + }), + // (not an example, but we also test the reverse for completeness) + test({ + code: ` + import a from "fs"; + import b from "path"; + + import c from "../foo.js"; + + import d from "./bar.js"; + + import e from "./"; + + import type A from "fs"; + import type B from "path"; + import type C from "../foo.js"; + import type D from "./bar.js"; + import type E from './'; + `, + ...parserConfig, + options: [ + { + groups: ['builtin', 'parent', 'sibling', 'index', 'type'], + sortTypesGroup: true, + 'newlines-between': 'always', + 'newlines-between-types': 'ignore', + }, + ], + }), + // Documentation passing example #2 for newlines-between-types + test({ + code: ` + import type A from "fs"; + import type B from "path"; + + import type C from "../foo.js"; + + import type D from "./bar.js"; + + import type E from './'; + + import a from "fs"; + import b from "path"; + import c from "../foo.js"; + import d from "./bar.js"; + import e from "./"; + `, + ...parserConfig, + options: [ + { + groups: ['type', 'builtin', 'parent', 'sibling', 'index'], + sortTypesGroup: true, + 'newlines-between': 'never', + 'newlines-between-types': 'always', + }, + ], + }), + // (not an example, but we also test the reverse for completeness) + test({ + code: ` + import a from "fs"; + import b from "path"; + import c from "../foo.js"; + import d from "./bar.js"; + import e from "./"; + + import type A from "fs"; + import type B from "path"; + + import type C from "../foo.js"; + + import type D from "./bar.js"; + + import type E from './'; + `, + ...parserConfig, + options: [ + { + groups: ['builtin', 'parent', 'sibling', 'index', 'type'], + sortTypesGroup: true, + 'newlines-between': 'never', + 'newlines-between-types': 'always', + }, + ], + }), + // Documentation passing example #1 for consolidateIslands + test({ + code: ` + var fs = require('fs'); + var path = require('path'); + var { util1, util2, util3 } = require('util'); + + var async = require('async'); + + var relParent1 = require('../foo'); + + var { + relParent21, + relParent22, + relParent23, + relParent24, + } = require('../'); + + var relParent3 = require('../bar'); + + var { sibling1, + sibling2, sibling3 } = require('./foo'); + + var sibling2 = require('./bar'); + var sibling3 = require('./foobar'); + `, + ...parserConfig, + options: [ + { + 'newlines-between': 'always-and-inside-groups', + consolidateIslands: 'inside-groups', + }, + ], + }), + // Documentation passing example #2 for consolidateIslands + test({ + code: ` + import c from 'Bar'; + import d from 'bar'; + + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; + + import a from 'foo'; + + import b from 'dirA/bar'; + + import index from './'; + + import type { AA, + BB, CC } from 'abc'; + + import type { Z } from 'fizz'; + + import type { + A, + B + } from 'foo'; + + import type { C2 } from 'dirB/Bar'; + + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + + import type { E2 } from 'dirB/baz'; + import type { C3 } from 'dirC/Bar'; + + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + + import type { E3 } from 'dirC/baz'; + import type { F3 } from 'dirC/caz'; + import type { C1 } from 'dirA/Bar'; + + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + + import type { E1 } from 'dirA/baz'; + import type { F } from './index.js'; + import type { G } from './aaa.js'; + import type { H } from './bbb'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + position: 'after', + }, + { + pattern: 'dirB/**', + group: 'internal', + position: 'before', + }, + { + pattern: 'dirC/**', + group: 'internal', + }, + ], + 'newlines-between': 'always-and-inside-groups', + 'newlines-between-types': 'never', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + consolidateIslands: 'inside-groups', + }, + ], + }), + // (not an example, but we also test the reverse for completeness) + test({ + code: ` + import type { AA, + BB, CC } from 'abc'; + + import type { Z } from 'fizz'; + + import type { + A, + B + } from 'foo'; + + import type { C2 } from 'dirB/Bar'; + + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + + import type { E2 } from 'dirB/baz'; + import type { C3 } from 'dirC/Bar'; + + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + + import type { E3 } from 'dirC/baz'; + import type { F3 } from 'dirC/caz'; + import type { C1 } from 'dirA/Bar'; + + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + + import type { E1 } from 'dirA/baz'; + import type { F } from './index.js'; + import type { G } from './aaa.js'; + import type { H } from './bbb'; + + import c from 'Bar'; + import d from 'bar'; + + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; + + import a from 'foo'; + + import b from 'dirA/bar'; + + import index from './'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['type', 'external', 'internal', 'index'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + position: 'after', + }, + { + pattern: 'dirB/**', + group: 'internal', + position: 'before', + }, + { + pattern: 'dirC/**', + group: 'internal', + }, + ], + 'newlines-between': 'always-and-inside-groups', + 'newlines-between-types': 'never', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + consolidateIslands: 'inside-groups', + }, + ], + }), + ), + invalid: [].concat( + // Option alphabetize: {order: 'asc'} + test({ + code: ` + import b from 'bar'; + import c from 'Bar'; + import type { C } from 'Bar'; + import a from 'foo'; + import type { A } from 'foo'; + + import index from './'; + `, + output: ` + import c from 'Bar'; + import type { C } from 'Bar'; + import b from 'bar'; + import a from 'foo'; + import type { A } from 'foo'; + + import index from './'; + `, + ...parserConfig, + options: [ + { + groups: ['external', 'index'], + alphabetize: { order: 'asc' }, + }, + ], + errors: [ + { + message: semver.satisfies(eslintPkg.version, '< 3') + ? '`bar` import should occur after type import of `Bar`' + : /(`bar` import should occur after type import of `Bar`)|(`Bar` type import should occur before import of `bar`)/, + }, + ], + }), + // Option alphabetize: {order: 'desc'} + test({ + code: ` + import a from 'foo'; + import type { A } from 'foo'; + import c from 'Bar'; + import type { C } from 'Bar'; + import b from 'bar'; + + import index from './'; + `, + output: ` + import a from 'foo'; + import type { A } from 'foo'; + import b from 'bar'; + import c from 'Bar'; + import type { C } from 'Bar'; + + import index from './'; + `, + ...parserConfig, + options: [ + { + groups: ['external', 'index'], + alphabetize: { order: 'desc' }, + }, + ], + errors: [ + { + message: semver.satisfies(eslintPkg.version, '< 3') + ? '`bar` import should occur before import of `Bar`' + : /(`bar` import should occur before import of `Bar`)|(`Bar` import should occur after import of `bar`)/, + }, + ], + }), + // Option alphabetize: {order: 'asc'} with type group + test({ + code: ` + import b from 'bar'; + import c from 'Bar'; + import a from 'foo'; + + import index from './'; + + import type { A } from 'foo'; + import type { C } from 'Bar'; + `, + output: ` + import c from 'Bar'; + import b from 'bar'; + import a from 'foo'; + + import index from './'; + + import type { C } from 'Bar'; + import type { A } from 'foo'; + `, + ...parserConfig, + options: [ + { + groups: ['external', 'index', 'type'], + alphabetize: { order: 'asc' }, + }, + ], + errors: semver.satisfies(eslintPkg.version, '< 3') ? [ + { message: '`Bar` import should occur before import of `bar`' }, + { message: '`Bar` type import should occur before type import of `foo`' }, + ] : [ + { message: /(`Bar` import should occur before import of `bar`)|(`bar` import should occur after import of `Bar`)/ }, + { message: /(`Bar` type import should occur before type import of `foo`)|(`foo` type import should occur after type import of `Bar`)/ }, + ], + }), + // Option alphabetize: {order: 'desc'} with type group + test({ + code: ` + import a from 'foo'; + import c from 'Bar'; + import b from 'bar'; + + import index from './'; + + import type { C } from 'Bar'; + import type { A } from 'foo'; + `, + output: ` + import a from 'foo'; + import b from 'bar'; + import c from 'Bar'; + + import index from './'; + + import type { A } from 'foo'; + import type { C } from 'Bar'; + `, + ...parserConfig, + options: [ + { + groups: ['external', 'index', 'type'], + alphabetize: { order: 'desc' }, + }, + ], + errors: semver.satisfies(eslintPkg.version, '< 3') ? [ + { message: '`bar` import should occur before import of `Bar`' }, + { message: '`foo` type import should occur before type import of `Bar`' }, + ] : [ + { message: /(`bar` import should occur before import of `Bar`)|(`Bar` import should occur after import of `bar`)/ }, + { message: /(`foo` type import should occur before type import of `Bar`)|(`Bar` type import should occur after import of type `foo`)/ }, + ], + }), + // warns for out of order unassigned imports (warnOnUnassignedImports enabled) + test(withoutAutofixOutput({ + code: ` + import './local1'; + import global from 'global1'; + import local from './local2'; + import 'global2'; + `, + errors: [ + { + message: '`global1` import should occur before import of `./local1`', + }, + { + message: '`global2` import should occur before import of `./local1`', + }, + ], + options: [{ warnOnUnassignedImports: true }], + })), + // fix cannot move below unassigned import (warnOnUnassignedImports enabled) + test(withoutAutofixOutput({ + code: ` + import local from './local'; + + import 'global1'; + + import global2 from 'global2'; + import global3 from 'global3'; + `, + errors: [{ + message: '`./local` import should occur after import of `global3`', + }], + options: [{ warnOnUnassignedImports: true }], + })), + // Imports inside module declaration + test({ + code: ` + import type { ParsedPath } from 'path'; + import type { CopyOptions } from 'fs'; + + declare module 'my-module' { + import type { ParsedPath } from 'path'; + import type { CopyOptions } from 'fs'; + } + `, + output: ` + import type { CopyOptions } from 'fs'; + import type { ParsedPath } from 'path'; + + declare module 'my-module' { + import type { CopyOptions } from 'fs'; + import type { ParsedPath } from 'path'; + } + `, + errors: [ + { message: '`fs` type import should occur before type import of `path`' }, + { message: '`fs` type import should occur before type import of `path`' }, + ], + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + }, + ], + }), + // named import order + test({ + code: ` + import { type Z, A } from "./Z"; + import type N, { E, D } from "./Z"; + import type { L, G } from "./Z"; + `, + output: ` + import { A, type Z } from "./Z"; + import type N, { D, E } from "./Z"; + import type { G, L } from "./Z"; + `, + ...parserConfig, + options: [{ + named: true, + alphabetize: { order: 'asc' }, + }], + errors: [ + { message: `\`A\` import should occur before${supportsImportTypeSpecifiers ? ' type' : ''} import of \`Z\`` }, + { message: '`D` import should occur before import of `E`' }, + { message: '`G` import should occur before import of `L`' }, + ], + }), + test({ + code: ` + const { B, /* Hello World */ A } = require("./Z"); + export { B, A } from "./Z"; + `, + output: ` + const { /* Hello World */ A, B } = require("./Z"); + export { A, B } from "./Z"; + `, + ...parserConfig, + options: [{ + named: true, + alphabetize: { order: 'asc' }, + }], + errors: [{ + message: '`A` import should occur before import of `B`', + }, { + message: '`A` export should occur before export of `B`', + }], + }), + // Options: sortTypesGroup + newlines-between-types example #1 from the documentation (fail) + test({ + code: ` + import type A from "fs"; + import type B from "path"; + import type C from "../foo.js"; + import type D from "./bar.js"; + import type E from './'; + + import a from "fs"; + import b from "path"; + + import c from "../foo.js"; + + import d from "./bar.js"; + + import e from "./"; + `, + output: ` + import type A from "fs"; + import type B from "path"; + + import type C from "../foo.js"; + + import type D from "./bar.js"; + + import type E from './'; + + import a from "fs"; + import b from "path"; + + import c from "../foo.js"; + + import d from "./bar.js"; + + import e from "./"; + `, + ...parserConfig, + options: [ + { + groups: ['type', 'builtin', 'parent', 'sibling', 'index'], + sortTypesGroup: true, + 'newlines-between': 'always', + }, + ], + errors: [ + { + message: 'There should be at least one empty line between import groups', + line: 3, + }, + { + message: 'There should be at least one empty line between import groups', + line: 4, + }, + { + message: 'There should be at least one empty line between import groups', + line: 5, + }, + ], + }), + // Options: sortTypesGroup + newlines-between-types example #2 from the documentation (fail) + test({ + code: ` + import type A from "fs"; + import type B from "path"; + import type C from "../foo.js"; + import type D from "./bar.js"; + import type E from './'; + + import a from "fs"; + import b from "path"; + + import c from "../foo.js"; + + import d from "./bar.js"; + + import e from "./"; + `, + output: ` + import type A from "fs"; + import type B from "path"; + import type C from "../foo.js"; + import type D from "./bar.js"; + import type E from './'; + import a from "fs"; + import b from "path"; + + import c from "../foo.js"; + + import d from "./bar.js"; + + import e from "./"; + `, + ...parserConfig, + options: [ + { + groups: ['type', 'builtin', 'parent', 'sibling', 'index'], + sortTypesGroup: true, + 'newlines-between': 'always', + 'newlines-between-types': 'never', + }, + ], + errors: [ + { + message: 'There should be no empty line between import groups', + line: 6, + }, + ], + }), + // Option: sortTypesGroup: true and newlines-between-types: 'always-and-inside-groups' and consolidateIslands: 'inside-groups' with all newlines + test({ + code: ` + import c from 'Bar'; + + import d from 'bar'; + + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; + + import a from 'foo'; + + import b from 'dirA/bar'; + + import index from './'; + + import type { AA, + BB, CC } from 'abc'; + + import type { Z } from 'fizz'; + + import type { + A, + B + } from 'foo'; + + import type { C2 } from 'dirB/Bar'; + + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + + import type { E2 } from 'dirB/baz'; + + import type { C3 } from 'dirC/Bar'; + + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + + import type { E3 } from 'dirC/baz'; + + import type { F3 } from 'dirC/caz'; + + import type { C1 } from 'dirA/Bar'; + + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + + import type { E1 } from 'dirA/baz'; + + import type { F } from './index.js'; + + import type { G } from './aaa.js'; + + import type { H } from './bbb'; + `, + output: ` + import c from 'Bar'; + import d from 'bar'; + + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; + + import a from 'foo'; + + import b from 'dirA/bar'; + + import index from './'; + + import type { AA, + BB, CC } from 'abc'; + + import type { Z } from 'fizz'; + + import type { + A, + B + } from 'foo'; + + import type { C2 } from 'dirB/Bar'; + + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + + import type { E2 } from 'dirB/baz'; + + import type { C3 } from 'dirC/Bar'; + + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + + import type { E3 } from 'dirC/baz'; + import type { F3 } from 'dirC/caz'; + + import type { C1 } from 'dirA/Bar'; + + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + + import type { E1 } from 'dirA/baz'; + + import type { F } from './index.js'; + + import type { G } from './aaa.js'; + import type { H } from './bbb'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + position: 'after', + }, + { + pattern: 'dirB/**', + group: 'internal', + position: 'before', + }, + { + pattern: 'dirC/**', + group: 'internal', + }, + ], + 'newlines-between': 'always-and-inside-groups', + 'newlines-between-types': 'always-and-inside-groups', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + consolidateIslands: 'inside-groups', + }, + ], + errors: [ + { + message: + 'There should be no empty lines between this single-line import and the single-line import that follows it', + line: 2, + }, + { + message: + 'There should be no empty lines between this single-line import and the single-line import that follows it', + line: 60, + }, + { + message: + 'There should be no empty lines between this single-line import and the single-line import that follows it', + line: 76, + }, + ], + }), + // Option: sortTypesGroup: true and newlines-between-types: 'always-and-inside-groups' and consolidateIslands: 'inside-groups' with no newlines + test({ + code: ` + import c from 'Bar'; + import d from 'bar'; + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; + import a from 'foo'; + import b from 'dirA/bar'; + import index from './'; + import type { AA, + BB, CC } from 'abc'; + import type { Z } from 'fizz'; + import type { + A, + B + } from 'foo'; + import type { C2 } from 'dirB/Bar'; + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + import type { E2 } from 'dirB/baz'; + import type { C3 } from 'dirC/Bar'; + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + import type { E3 } from 'dirC/baz'; + import type { F3 } from 'dirC/caz'; + import type { C1 } from 'dirA/Bar'; + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + import type { E1 } from 'dirA/baz'; + import type { F } from './index.js'; + import type { G } from './aaa.js'; + import type { H } from './bbb'; + `, + output: ` + import c from 'Bar'; + import d from 'bar'; + + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; + + import a from 'foo'; + + import b from 'dirA/bar'; + + import index from './'; + + import type { AA, + BB, CC } from 'abc'; + + import type { Z } from 'fizz'; + + import type { + A, + B + } from 'foo'; + + import type { C2 } from 'dirB/Bar'; + + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + + import type { E2 } from 'dirB/baz'; + + import type { C3 } from 'dirC/Bar'; + + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + + import type { E3 } from 'dirC/baz'; + import type { F3 } from 'dirC/caz'; + + import type { C1 } from 'dirA/Bar'; + + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + + import type { E1 } from 'dirA/baz'; + + import type { F } from './index.js'; + + import type { G } from './aaa.js'; + import type { H } from './bbb'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + position: 'after', + }, + { + pattern: 'dirB/**', + group: 'internal', + position: 'before', + }, + { + pattern: 'dirC/**', + group: 'internal', + }, + ], + 'newlines-between': 'always-and-inside-groups', + 'newlines-between-types': 'always-and-inside-groups', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + consolidateIslands: 'inside-groups', + }, + ], + errors: [ + { + message: 'There should be at least one empty line between this import and the multi-line import that follows it', + line: 3, + }, + { + message: 'There should be at least one empty line between this import and the multi-line import that follows it', + line: 4, + }, + { + message: 'There should be at least one empty line between this multi-line import and the import that follows it', + line: 13, + }, + { + message: 'There should be at least one empty line between import groups', + line: 22, + }, + { + message: 'There should be at least one empty line between import groups', + line: 23, + }, + { + message: 'There should be at least one empty line between import groups', + line: 24, + }, + { + message: 'There should be at least one empty line between this multi-line import and the import that follows it', + line: 25, + }, + { + message: 'There should be at least one empty line between this import and the multi-line import that follows it', + line: 27, + }, + { + message: 'There should be at least one empty line between import groups', + line: 28, + }, + { + message: 'There should be at least one empty line between this import and the multi-line import that follows it', + line: 32, + }, + { + message: 'There should be at least one empty line between this multi-line import and the import that follows it', + line: 33, + }, + { + message: 'There should be at least one empty line between import groups', + line: 38, + }, + { + message: 'There should be at least one empty line between this import and the multi-line import that follows it', + line: 39, + }, + { + message: 'There should be at least one empty line between this multi-line import and the import that follows it', + line: 40, + }, + { + message: 'There should be at least one empty line between import groups', + line: 46, + }, + { + message: 'There should be at least one empty line between this import and the multi-line import that follows it', + line: 47, + }, + { + message: 'There should be at least one empty line between this multi-line import and the import that follows it', + line: 48, + }, + { + message: 'There should be at least one empty line between import groups', + line: 53, + }, + { + message: 'There should be at least one empty line between import groups', + line: 54, + }, + ], + }), + // Option: sortTypesGroup: true and newlines-between-types: 'always-and-inside-groups' and consolidateIslands: 'never' (default) + test({ + code: ` + import c from 'Bar'; + import d from 'bar'; + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; + import a from 'foo'; + import b from 'dirA/bar'; + import index from './'; + import type { AA, + BB, CC } from 'abc'; + import type { Z } from 'fizz'; + import type { + A, + B + } from 'foo'; + import type { C2 } from 'dirB/Bar'; + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + import type { E2 } from 'dirB/baz'; + import type { C3 } from 'dirC/Bar'; + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + import type { E3 } from 'dirC/baz'; + import type { F3 } from 'dirC/caz'; + import type { C1 } from 'dirA/Bar'; + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + import type { E1 } from 'dirA/baz'; + import type { F } from './index.js'; + import type { G } from './aaa.js'; + import type { H } from './bbb'; + `, + output: ` + import c from 'Bar'; + import d from 'bar'; + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; + import a from 'foo'; + + import b from 'dirA/bar'; + + import index from './'; + + import type { AA, + BB, CC } from 'abc'; + import type { Z } from 'fizz'; + import type { + A, + B + } from 'foo'; + + import type { C2 } from 'dirB/Bar'; + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + import type { E2 } from 'dirB/baz'; + + import type { C3 } from 'dirC/Bar'; + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + import type { E3 } from 'dirC/baz'; + import type { F3 } from 'dirC/caz'; + + import type { C1 } from 'dirA/Bar'; + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + import type { E1 } from 'dirA/baz'; + + import type { F } from './index.js'; + + import type { G } from './aaa.js'; + import type { H } from './bbb'; + `, + ...parserConfig, + options: [ + { + alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + position: 'after', + }, + { + pattern: 'dirB/**', + group: 'internal', + position: 'before', + }, + { + pattern: 'dirC/**', + group: 'internal', + }, + ], + 'newlines-between': 'always-and-inside-groups', + 'newlines-between-types': 'always-and-inside-groups', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + consolidateIslands: 'never', + }, + ], + errors: [ + { + message: 'There should be at least one empty line between import groups', + line: 22, + }, + { + message: 'There should be at least one empty line between import groups', + line: 23, + }, + { + message: 'There should be at least one empty line between import groups', + line: 24, + }, + { + message: 'There should be at least one empty line between import groups', + line: 28, + }, + { + message: 'There should be at least one empty line between import groups', + line: 38, + }, + { + message: 'There should be at least one empty line between import groups', + line: 46, + }, + { + message: 'There should be at least one empty line between import groups', + line: 53, + }, + { + message: 'There should be at least one empty line between import groups', + line: 54, + }, + + ], + }), + + // Documentation failing example #1 for newlines-between + test({ + code: ` + import fs from 'fs'; + import path from 'path'; + import sibling from './foo'; + import index from './'; + `, + output: ` + import fs from 'fs'; + import path from 'path'; + + import sibling from './foo'; + + import index from './'; + `, + ...parserConfig, + options: [ + { + 'newlines-between': 'always', + }, + ], + errors: [ + { + message: 'There should be at least one empty line between import groups', + line: 3, + }, + { + message: 'There should be at least one empty line between import groups', + line: 4, + }, + ], + }), + // Documentation failing example #2 for newlines-between + test({ + code: ` + import fs from 'fs'; + + import path from 'path'; + import sibling from './foo'; + import index from './'; + `, + output: ` + import fs from 'fs'; + + import path from 'path'; + + import sibling from './foo'; + + import index from './'; + `, + ...parserConfig, + options: [ + { + 'newlines-between': 'always-and-inside-groups', + }, + ], + errors: [ + { + message: 'There should be at least one empty line between import groups', + line: 4, + }, + { + message: 'There should be at least one empty line between import groups', + line: 5, + }, + ], + }), + // Documentation failing example #3 for newlines-between + test({ + code: ` + import fs from 'fs'; + import path from 'path'; + + import sibling from './foo'; + + import index from './'; + `, + output: ` + import fs from 'fs'; + import path from 'path'; + import sibling from './foo'; + import index from './'; + `, + ...parserConfig, + options: [ + { + 'newlines-between': 'never', + }, + ], + errors: [ + { + message: 'There should be no empty line between import groups', + line: 3, + }, + { + message: 'There should be no empty line between import groups', + line: 5, + }, + ], + }), + // Multiple errors + ...semver.satisfies(eslintPkg.version, '< 3.0.0') ? [] : [ + // Documentation failing example #1 for alphabetize + test({ + code: ` + import React, { PureComponent } from 'react'; + import aTypes from 'prop-types'; + import { compose, apply } from 'xcompose'; + import * as classnames from 'classnames'; + import blist2 from 'blist'; + import blist from 'BList'; + `, + // The reason why this output does not match the success example after being fixed is because eslint will leave overlapping errors alone, so only one import gets reordered when fixes are applied + output: ` + import aTypes from 'prop-types'; + import React, { PureComponent } from 'react'; + import { compose, apply } from 'xcompose'; + import * as classnames from 'classnames'; + import blist2 from 'blist'; + import blist from 'BList'; + `, + ...parserConfig, + options: [ + { + alphabetize: { + order: 'asc', + caseInsensitive: true, + }, + }, + ], + errors: [ + { + message: '`prop-types` import should occur before import of `react`', + line: 3, + }, + { + message: '`classnames` import should occur before import of `react`', + line: 5, + }, + { + message: '`blist` import should occur before import of `react`', + line: 6, + }, + { + message: '`BList` import should occur before import of `react`', + line: 7, + }, + ], + }), + ], + // Documentation failing example #1 for named + test({ + code: ` + import { compose, apply } from 'xcompose'; + `, + output: ` + import { apply, compose } from 'xcompose'; + `, + ...parserConfig, + options: [ + { + named: true, + alphabetize: { + order: 'asc', + }, + }, + ], + errors: [ + { + message: '`apply` import should occur before import of `compose`', + line: 2, + }, + ], + }), + // Documentation failing example #1 for warnOnUnassignedImports + test({ + code: ` + import fs from 'fs'; + import './styles.css'; + import path from 'path'; + `, + // Should not be fixed (eslint@>=9 expects null) + output: semver.satisfies(eslintPkg.version, '< 9.0.0') ? ` + import fs from 'fs'; + import './styles.css'; + import path from 'path'; + ` : null, + ...parserConfig, + options: [ + { + warnOnUnassignedImports: true, + }, + ], + errors: [ + { + message: '`path` import should occur before import of `./styles.css`', + line: 4, + }, + ], }), - // Option alphabetize: {order: 'asc'} with type group + // Documentation failing example #1 for sortTypesGroup test({ code: ` - import b from 'bar'; - import c from 'Bar'; - import a from 'foo'; - - import index from './'; - - import type { A } from 'foo'; - import type { C } from 'Bar'; + import type A from "fs"; + import type B from "path"; + import type C from "../foo.js"; + import type D from "./bar.js"; + import type E from './'; + + import a from "fs"; + import b from "path"; + import c from "../foo.js"; + import d from "./bar.js"; + import e from "./"; `, - output: ` - import c from 'Bar'; - import b from 'bar'; - import a from 'foo'; - - import index from './'; - - import type { C } from 'Bar'; - import type { A } from 'foo'; + // This is the "correct" behavior, but it's the wrong outcome (expectedly) + output: semver.satisfies(eslintPkg.version, '< 3.0.0') + // eslint@2 apparently attempts to fix multiple errors in one pass, + // which results in different erroneous output + ? ` + import type E from './'; + import type A from "fs"; + import type B from "path"; + import type C from "../foo.js"; + import type D from "./bar.js"; + + import a from "fs"; + import b from "path"; + import c from "../foo.js"; + import d from "./bar.js"; + import e from "./"; + ` : ` + import type C from "../foo.js"; + import type A from "fs"; + import type B from "path"; + import type D from "./bar.js"; + import type E from './'; + + import a from "fs"; + import b from "path"; + import c from "../foo.js"; + import d from "./bar.js"; + import e from "./"; `, ...parserConfig, options: [ { - groups: ['external', 'index', 'type'], + groups: ['type', 'builtin', 'parent', 'sibling', 'index'], alphabetize: { order: 'asc' }, }, ], - errors: semver.satisfies(eslintPkg.version, '< 3') ? [ - { message: '`Bar` import should occur before import of `bar`' }, - { message: '`Bar` type import should occur before type import of `foo`' }, - ] : [ - { message: /(`Bar` import should occur before import of `bar`)|(`bar` import should occur after import of `Bar`)/ }, - { message: /(`Bar` type import should occur before type import of `foo`)|(`foo` type import should occur after type import of `Bar`)/ }, + errors: [ + { + message: '`../foo.js` type import should occur before type import of `fs`', + line: 4, + }, + { + message: '`./bar.js` type import should occur before type import of `fs`', + line: 5, + }, + { + message: '`./` type import should occur before type import of `fs`', + line: 6, + }, ], }), - // Option alphabetize: {order: 'desc'} with type group + // Documentation failing example #1 for newlines-between-types test({ code: ` - import a from 'foo'; - import c from 'Bar'; - import b from 'bar'; + import type A from "fs"; + import type B from "path"; + import type C from "../foo.js"; + import type D from "./bar.js"; + import type E from './'; - import index from './'; + import a from "fs"; + import b from "path"; - import type { C } from 'Bar'; - import type { A } from 'foo'; + import c from "../foo.js"; + + import d from "./bar.js"; + + import e from "./"; `, output: ` - import a from 'foo'; - import b from 'bar'; - import c from 'Bar'; + import type A from "fs"; + import type B from "path"; - import index from './'; + import type C from "../foo.js"; - import type { A } from 'foo'; - import type { C } from 'Bar'; + import type D from "./bar.js"; + + import type E from './'; + + import a from "fs"; + import b from "path"; + + import c from "../foo.js"; + + import d from "./bar.js"; + + import e from "./"; `, ...parserConfig, options: [ { - groups: ['external', 'index', 'type'], - alphabetize: { order: 'desc' }, + groups: ['type', 'builtin', 'parent', 'sibling', 'index'], + sortTypesGroup: true, + 'newlines-between': 'always', }, ], - errors: semver.satisfies(eslintPkg.version, '< 3') ? [ - { message: '`bar` import should occur before import of `Bar`' }, - { message: '`foo` type import should occur before type import of `Bar`' }, - ] : [ - { message: /(`bar` import should occur before import of `Bar`)|(`Bar` import should occur after import of `bar`)/ }, - { message: /(`foo` type import should occur before type import of `Bar`)|(`Bar` type import should occur after import of type `foo`)/ }, + errors: [ + { + message: 'There should be at least one empty line between import groups', + line: 3, + }, + { + message: 'There should be at least one empty line between import groups', + line: 4, + }, + { + message: 'There should be at least one empty line between import groups', + line: 5, + }, ], }), - // warns for out of order unassigned imports (warnOnUnassignedImports enabled) + // Documentation failing example #2 for newlines-between-types test({ code: ` - import './local1'; - import global from 'global1'; - import local from './local2'; - import 'global2'; + import type A from "fs"; + import type B from "path"; + import type C from "../foo.js"; + import type D from "./bar.js"; + import type E from './'; + + import a from "fs"; + import b from "path"; + + import c from "../foo.js"; + + import d from "./bar.js"; + + import e from "./"; `, output: ` - import './local1'; - import global from 'global1'; - import local from './local2'; - import 'global2'; + import type A from "fs"; + import type B from "path"; + import type C from "../foo.js"; + import type D from "./bar.js"; + import type E from './'; + import a from "fs"; + import b from "path"; + + import c from "../foo.js"; + + import d from "./bar.js"; + + import e from "./"; `, - errors: [ + ...parserConfig, + options: [ { - message: '`global1` import should occur before import of `./local1`', + groups: ['type', 'builtin', 'parent', 'sibling', 'index'], + sortTypesGroup: true, + 'newlines-between': 'always', + 'newlines-between-types': 'never', }, + ], + errors: [ { - message: '`global2` import should occur before import of `./local1`', + message: 'There should be no empty line between import groups', + line: 6, }, ], - options: [{ warnOnUnassignedImports: true }], }), - // fix cannot move below unassigned import (warnOnUnassignedImports enabled) + // Documentation failing example #1 for consolidateIslands test({ code: ` - import local from './local'; - - import 'global1'; - - import global2 from 'global2'; - import global3 from 'global3'; + var fs = require('fs'); + var path = require('path'); + var { util1, util2, util3 } = require('util'); + var async = require('async'); + var relParent1 = require('../foo'); + var { + relParent21, + relParent22, + relParent23, + relParent24, + } = require('../'); + var relParent3 = require('../bar'); + var { sibling1, + sibling2, sibling3 } = require('./foo'); + var sibling2 = require('./bar'); + var sibling3 = require('./foobar'); `, output: ` - import local from './local'; + var fs = require('fs'); + var path = require('path'); + var { util1, util2, util3 } = require('util'); - import 'global1'; + var async = require('async'); - import global2 from 'global2'; - import global3 from 'global3'; + var relParent1 = require('../foo'); + + var { + relParent21, + relParent22, + relParent23, + relParent24, + } = require('../'); + + var relParent3 = require('../bar'); + + var { sibling1, + sibling2, sibling3 } = require('./foo'); + + var sibling2 = require('./bar'); + var sibling3 = require('./foobar'); `, - errors: [{ - message: '`./local` import should occur after import of `global3`', - }], - options: [{ warnOnUnassignedImports: true }], + ...parserConfig, + options: [ + { + 'newlines-between': 'always-and-inside-groups', + consolidateIslands: 'inside-groups', + }, + ], + errors: [ + { + message: 'There should be at least one empty line between import groups', + line: 4, + }, + { + message: 'There should be at least one empty line between import groups', + line: 5, + }, + { + message: + 'There should be at least one empty line between this import and the multi-line import that follows it', + line: 6, + }, + { + message: + 'There should be at least one empty line between this multi-line import and the import that follows it', + line: 12, + }, + { + message: 'There should be at least one empty line between import groups', + line: 13, + }, + { + message: + 'There should be at least one empty line between this multi-line import and the import that follows it', + line: 15, + }, + ], }), - // Imports inside module declaration + // Documentation failing example #2 for consolidateIslands test({ code: ` - import type { ParsedPath } from 'path'; - import type { CopyOptions } from 'fs'; - - declare module 'my-module' { - import type { ParsedPath } from 'path'; - import type { CopyOptions } from 'fs'; - } + import c from 'Bar'; + import d from 'bar'; + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; + import a from 'foo'; + import b from 'dirA/bar'; + import index from './'; + import type { AA, + BB, CC } from 'abc'; + import type { Z } from 'fizz'; + import type { + A, + B + } from 'foo'; + import type { C2 } from 'dirB/Bar'; + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + import type { E2 } from 'dirB/baz'; + import type { C3 } from 'dirC/Bar'; + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + import type { E3 } from 'dirC/baz'; + import type { F3 } from 'dirC/caz'; + import type { C1 } from 'dirA/Bar'; + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + import type { E1 } from 'dirA/baz'; + import type { F } from './index.js'; + import type { G } from './aaa.js'; + import type { H } from './bbb'; `, output: ` - import type { CopyOptions } from 'fs'; - import type { ParsedPath } from 'path'; + import c from 'Bar'; + import d from 'bar'; + + import { + aa, + bb, + cc, + dd, + ee, + ff, + gg + } from 'baz'; + + import { + hh, + ii, + jj, + kk, + ll, + mm, + nn + } from 'fizz'; - declare module 'my-module' { - import type { CopyOptions } from 'fs'; - import type { ParsedPath } from 'path'; - } + import a from 'foo'; + + import b from 'dirA/bar'; + + import index from './'; + + import type { AA, + BB, CC } from 'abc'; + + import type { Z } from 'fizz'; + + import type { + A, + B + } from 'foo'; + + import type { C2 } from 'dirB/Bar'; + + import type { + D2, + X2, + Y2 + } from 'dirB/bar'; + + import type { E2 } from 'dirB/baz'; + import type { C3 } from 'dirC/Bar'; + + import type { + D3, + X3, + Y3 + } from 'dirC/bar'; + + import type { E3 } from 'dirC/baz'; + import type { F3 } from 'dirC/caz'; + import type { C1 } from 'dirA/Bar'; + + import type { + D1, + X1, + Y1 + } from 'dirA/bar'; + + import type { E1 } from 'dirA/baz'; + import type { F } from './index.js'; + import type { G } from './aaa.js'; + import type { H } from './bbb'; `, - errors: [ - { message: '`fs` type import should occur before type import of `path`' }, - { message: '`fs` type import should occur before type import of `path`' }, - ], ...parserConfig, options: [ { alphabetize: { order: 'asc' }, + groups: ['external', 'internal', 'index', 'type'], + pathGroups: [ + { + pattern: 'dirA/**', + group: 'internal', + position: 'after', + }, + { + pattern: 'dirB/**', + group: 'internal', + position: 'before', + }, + { + pattern: 'dirC/**', + group: 'internal', + }, + ], + 'newlines-between': 'always-and-inside-groups', + 'newlines-between-types': 'never', + pathGroupsExcludedImportTypes: [], + sortTypesGroup: true, + consolidateIslands: 'inside-groups', + }, + ], + errors: [ + { + message: + 'There should be at least one empty line between this import and the multi-line import that follows it', + line: 3, + }, + { + message: + 'There should be at least one empty line between this import and the multi-line import that follows it', + line: 4, + }, + { + message: + 'There should be at least one empty line between this multi-line import and the import that follows it', + line: 13, + }, + { + message: 'There should be at least one empty line between import groups', + line: 22, + }, + { + message: 'There should be at least one empty line between import groups', + line: 23, + }, + { + message: 'There should be at least one empty line between import groups', + line: 24, + }, + { + message: + 'There should be at least one empty line between this multi-line import and the import that follows it', + line: 25, + }, + { + message: + 'There should be at least one empty line between this import and the multi-line import that follows it', + line: 27, + }, + { + message: 'There should be at least one empty line between import groups', + line: 28, + }, + { + message: + 'There should be at least one empty line between this import and the multi-line import that follows it', + line: 32, + }, + { + message: + 'There should be at least one empty line between this multi-line import and the import that follows it', + line: 33, + }, + { + message: + 'There should be at least one empty line between this import and the multi-line import that follows it', + line: 39, + }, + { + message: + 'There should be at least one empty line between this multi-line import and the import that follows it', + line: 40, + }, + { + message: + 'There should be at least one empty line between this import and the multi-line import that follows it', + line: 47, + }, + { + message: + 'There should be at least one empty line between this multi-line import and the import that follows it', + line: 48, }, ], }), + supportsExportTypeSpecifiers ? [ + test({ + code: ` + export { type B, A }; + `, + output: ` + export { A, type B }; + `, + ...parserConfig, + options: [{ + named: { + enabled: true, + types: 'mixed', + }, + alphabetize: { order: 'asc' }, + }], + errors: [{ + message: '`A` export should occur before type export of `B`', + }], + }), + test({ + code: ` + import { type B, A, default as C } from "./Z"; + `, + output: ` + import { A, default as C, type B } from "./Z"; + `, + ...parserConfig, + options: [{ + named: { + import: true, + types: 'types-last', + }, + alphabetize: { order: 'asc' }, + }], + errors: [{ + message: '`B` type import should occur after import of `default`', + }], + }), + test({ + code: ` + export { A, type Z } from "./Z"; + `, + output: ` + export { type Z, A } from "./Z"; + `, + ...parserConfig, + options: [{ + named: { + enabled: true, + types: 'types-first', + }, + }], + errors: [ + { message: '`Z` type export should occur before export of `A`' }, + ], + }), + ] : [], + isCoreModule('node:child_process') && isCoreModule('node:fs/promises') ? [ test({ code: ` @@ -3379,7 +6800,7 @@ flowRuleTester.run('order', rule, { }, }, ], - pathGroupsExcludedImportTypes: ['react'], + pathGroupsExcludedImportTypes: [], alphabetize: { order: 'asc', }, diff --git a/tests/src/rules/prefer-default-export.js b/tests/src/rules/prefer-default-export.js index a7310445b5..8e459873f9 100644 --- a/tests/src/rules/prefer-default-export.js +++ b/tests/src/rules/prefer-default-export.js @@ -1,6 +1,6 @@ import { test, testVersion, getNonDefaultParsers, parsers } from '../utils'; -import { RuleTester } from 'eslint'; +import { RuleTester } from '../rule-tester'; import semver from 'semver'; import { version as tsEslintVersion } from 'typescript-eslint-parser/package.json'; diff --git a/tests/src/rules/unambiguous.js b/tests/src/rules/unambiguous.js index 8cef69625f..15c67470ef 100644 --- a/tests/src/rules/unambiguous.js +++ b/tests/src/rules/unambiguous.js @@ -1,4 +1,4 @@ -import { RuleTester } from 'eslint'; +import { RuleTester, withoutAutofixOutput } from '../rule-tester'; import { parsers } from '../utils'; const ruleTester = new RuleTester(); @@ -48,11 +48,10 @@ ruleTester.run('unambiguous', rule, { }, ], invalid: [ - { + withoutAutofixOutput({ code: 'function x() {}', parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, - output: 'function x() {}', errors: ['This module could be parsed as a valid script.'], - }, + }), ], }); diff --git a/tests/src/scc.js b/tests/src/scc.js new file mode 100644 index 0000000000..376b783ce1 --- /dev/null +++ b/tests/src/scc.js @@ -0,0 +1,179 @@ +import sinon from 'sinon'; +import { expect } from 'chai'; +import StronglyConnectedComponentsBuilder from '../../src/scc'; +import ExportMapBuilder from '../../src/exportMap/builder'; + +function exportMapFixtureBuilder(path, imports, isOnlyImportingTypes = false) { + return { + path, + imports: new Map(imports.map((imp) => [imp.path, { getter: () => imp, declarations: [{ isOnlyImportingTypes }] }])), + }; +} + +describe('Strongly Connected Components Builder', () => { + afterEach(() => ExportMapBuilder.for.restore()); + afterEach(() => StronglyConnectedComponentsBuilder.clearCache()); + + describe('When getting an SCC', () => { + const source = ''; + const context = { + settings: {}, + parserOptions: {}, + parserPath: '', + }; + + describe('Given two files', () => { + describe('When they don\'t value-cycle', () => { + it('Should return foreign SCCs', () => { + sinon.stub(ExportMapBuilder, 'for').returns( + exportMapFixtureBuilder('foo.js', [exportMapFixtureBuilder('bar.js', [])]), + ); + const actual = StronglyConnectedComponentsBuilder.for(source, context); + expect(actual).to.deep.equal({ 'foo.js': 1, 'bar.js': 0 }); + }); + }); + + describe('When they do value-cycle', () => { + it('Should return same SCC', () => { + sinon.stub(ExportMapBuilder, 'for').returns( + exportMapFixtureBuilder('foo.js', [ + exportMapFixtureBuilder('bar.js', [ + exportMapFixtureBuilder('foo.js', [exportMapFixtureBuilder('bar.js', [])]), + ]), + ]), + ); + const actual = StronglyConnectedComponentsBuilder.for(source, context); + expect(actual).to.deep.equal({ 'foo.js': 0, 'bar.js': 0 }); + }); + }); + + describe('When they type-cycle', () => { + it('Should return foreign SCCs', () => { + sinon.stub(ExportMapBuilder, 'for').returns( + exportMapFixtureBuilder('foo.js', [ + exportMapFixtureBuilder('bar.js', [ + exportMapFixtureBuilder('foo.js', []), + ], true), + ]), + ); + const actual = StronglyConnectedComponentsBuilder.for(source, context); + expect(actual).to.deep.equal({ 'foo.js': 1, 'bar.js': 0 }); + }); + }); + }); + + describe('Given three files', () => { + describe('When they form a line', () => { + describe('When A -> B -> C', () => { + it('Should return foreign SCCs', () => { + sinon.stub(ExportMapBuilder, 'for').returns( + exportMapFixtureBuilder('foo.js', [ + exportMapFixtureBuilder('bar.js', [ + exportMapFixtureBuilder('buzz.js', []), + ]), + ]), + ); + const actual = StronglyConnectedComponentsBuilder.for(source, context); + expect(actual).to.deep.equal({ 'foo.js': 2, 'bar.js': 1, 'buzz.js': 0 }); + }); + }); + + describe('When A -> B <-> C', () => { + it('Should return 2 SCCs, A on its own', () => { + sinon.stub(ExportMapBuilder, 'for').returns( + exportMapFixtureBuilder('foo.js', [ + exportMapFixtureBuilder('bar.js', [ + exportMapFixtureBuilder('buzz.js', [ + exportMapFixtureBuilder('bar.js', []), + ]), + ]), + ]), + ); + const actual = StronglyConnectedComponentsBuilder.for(source, context); + expect(actual).to.deep.equal({ 'foo.js': 1, 'bar.js': 0, 'buzz.js': 0 }); + }); + }); + + describe('When A <-> B -> C', () => { + it('Should return 2 SCCs, C on its own', () => { + sinon.stub(ExportMapBuilder, 'for').returns( + exportMapFixtureBuilder('foo.js', [ + exportMapFixtureBuilder('bar.js', [ + exportMapFixtureBuilder('buzz.js', []), + exportMapFixtureBuilder('foo.js', []), + ]), + ]), + ); + const actual = StronglyConnectedComponentsBuilder.for(source, context); + expect(actual).to.deep.equal({ 'foo.js': 1, 'bar.js': 1, 'buzz.js': 0 }); + }); + }); + + describe('When A <-> B <-> C', () => { + it('Should return same SCC', () => { + sinon.stub(ExportMapBuilder, 'for').returns( + exportMapFixtureBuilder('foo.js', [ + exportMapFixtureBuilder('bar.js', [ + exportMapFixtureBuilder('foo.js', []), + exportMapFixtureBuilder('buzz.js', [ + exportMapFixtureBuilder('bar.js', []), + ]), + ]), + ]), + ); + const actual = StronglyConnectedComponentsBuilder.for(source, context); + expect(actual).to.deep.equal({ 'foo.js': 0, 'bar.js': 0, 'buzz.js': 0 }); + }); + }); + }); + + describe('When they form a loop', () => { + it('Should return same SCC', () => { + sinon.stub(ExportMapBuilder, 'for').returns( + exportMapFixtureBuilder('foo.js', [ + exportMapFixtureBuilder('bar.js', [ + exportMapFixtureBuilder('buzz.js', [ + exportMapFixtureBuilder('foo.js', []), + ]), + ]), + ]), + ); + const actual = StronglyConnectedComponentsBuilder.for(source, context); + expect(actual).to.deep.equal({ 'foo.js': 0, 'bar.js': 0, 'buzz.js': 0 }); + }); + }); + + describe('When they form a Y', () => { + it('Should return 3 distinct SCCs', () => { + sinon.stub(ExportMapBuilder, 'for').returns( + exportMapFixtureBuilder('foo.js', [ + exportMapFixtureBuilder('bar.js', []), + exportMapFixtureBuilder('buzz.js', []), + ]), + ); + const actual = StronglyConnectedComponentsBuilder.for(source, context); + expect(actual).to.deep.equal({ 'foo.js': 2, 'bar.js': 0, 'buzz.js': 1 }); + }); + }); + + describe('When they form a Mercedes', () => { + it('Should return 1 SCC', () => { + sinon.stub(ExportMapBuilder, 'for').returns( + exportMapFixtureBuilder('foo.js', [ + exportMapFixtureBuilder('bar.js', [ + exportMapFixtureBuilder('foo.js', []), + exportMapFixtureBuilder('buzz.js', []), + ]), + exportMapFixtureBuilder('buzz.js', [ + exportMapFixtureBuilder('foo.js', []), + exportMapFixtureBuilder('bar.js', []), + ]), + ]), + ); + const actual = StronglyConnectedComponentsBuilder.for(source, context); + expect(actual).to.deep.equal({ 'foo.js': 0, 'bar.js': 0, 'buzz.js': 0 }); + }); + }); + }); + }); +}); diff --git a/tests/src/utils.js b/tests/src/utils.js index d5215b02e3..24d5504a71 100644 --- a/tests/src/utils.js +++ b/tests/src/utils.js @@ -42,10 +42,6 @@ export function eslintVersionSatisfies(specifier) { return semver.satisfies(eslintPkg.version, specifier); } -export function testVersion(specifier, t) { - return eslintVersionSatisfies(specifier) ? test(t()) : []; -} - export function test(t) { if (arguments.length !== 1) { throw new SyntaxError('`test` requires exactly one object argument'); @@ -61,6 +57,10 @@ export function test(t) { }; } +export function testVersion(specifier, t) { + return eslintVersionSatisfies(specifier) ? test(t()) : []; +} + export function testContext(settings) { return { getFilename() { return FILENAME; }, settings: settings || {} }; diff --git a/utils/.attw.json b/utils/.attw.json new file mode 100644 index 0000000000..45dd01e12f --- /dev/null +++ b/utils/.attw.json @@ -0,0 +1,5 @@ +{ + "ignoreRules": [ + "cjs-only-exports-default" + ] +} diff --git a/utils/.npmignore b/utils/.npmignore new file mode 100644 index 0000000000..366f3ebb6e --- /dev/null +++ b/utils/.npmignore @@ -0,0 +1 @@ +.attw.json diff --git a/utils/CHANGELOG.md b/utils/CHANGELOG.md index ae3588a390..9a35cfba7f 100644 --- a/utils/CHANGELOG.md +++ b/utils/CHANGELOG.md @@ -5,6 +5,60 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange ## Unreleased +## v2.12.1 - 2025-06-19 + +### Fixed +- `unambiguous`: detect modules exported from minified code ([#3124], thanks [@michaelfaith]) + +### Changed +- [refactor] `parse`: avoid using a regex here (thanks [@ljharb]) + +## v2.12.0 - 2024-09-26 + +### Added +- `hash`: add support for hashing functions ([#3072], thanks [@michaelfaith]) + +## v2.11.1 - 2024-09-23 + +### Fixed +- `parse`: remove unneeded extra backticks ([#3057], thanks [@G-Rath]) +- `parse`: espree parser isn't working with flat config ([#3061], thanks [@michaelfaith]) +- `parse`: add `ecmaVersion` and `sourceType` to `parserOptions` ([#3061], thanks [@michaelfaith]) + +## v2.11.0 - 2024-09-05 + +### New +- `declaredScope`: take a `node` for modern eslint versions (thanks [@michaelfaith]) + +## v2.10.0 - 2024-09-05 + +### New +- add context compatibility helpers ([#3049], thanks [@michaelfaith]) + +## v2.9.0 - 2024-09-02 + +### New +- add support for Flat Config ([#3018], thanks [@michaelfaith]) + +## v2.8.2 - 2024-08-25 + +### Fixed +- `parse`: also delete `parserOptions.projectService` ([#3039], thanks [@Mysak0CZ]) + +### Changed +- [types] use shared config (thanks [@ljharb]) +- [meta] add `exports`, `main` +- [meta] add `repository.directory` field +- [refactor] avoid hoisting + +## v2.8.1 - 2024-02-26 + +### Fixed +- `parse`: also delete `parserOptions.EXPERIMENTAL_useProjectService` ([#2963], thanks [@JoshuaKGoldberg]) + +### Changed +- add types (thanks [@ljharb]) + ## v2.8.0 - 2023-04-14 ### New @@ -131,6 +185,14 @@ Yanked due to critical issue with cache key resulting from #839. ### Fixed - `unambiguous.test()` regex is now properly in multiline mode +[#3124]: https://github.com/import-js/eslint-plugin-import/pull/3124 +[#3072]: https://github.com/import-js/eslint-plugin-import/pull/3072 +[#3061]: https://github.com/import-js/eslint-plugin-import/pull/3061 +[#3057]: https://github.com/import-js/eslint-plugin-import/pull/3057 +[#3049]: https://github.com/import-js/eslint-plugin-import/pull/3049 +[#3039]: https://github.com/import-js/eslint-plugin-import/pull/3039 +[#3018]: https://github.com/import-js/eslint-plugin-import/pull/3018 +[#2963]: https://github.com/import-js/eslint-plugin-import/pull/2963 [#2755]: https://github.com/import-js/eslint-plugin-import/pull/2755 [#2714]: https://github.com/import-js/eslint-plugin-import/pull/2714 [#2523]: https://github.com/import-js/eslint-plugin-import/pull/2523 @@ -166,18 +228,24 @@ Yanked due to critical issue with cache key resulting from #839. [@brettz9]: https://github.com/brettz9 [@christophercurrie]: https://github.com/christophercurrie [@DMartens]: https://github.com/DMartens +[@G-Rath]: https://github.com/G-Rath [@hulkish]: https://github.com/hulkish [@Hypnosphi]: https://github.com/Hypnosphi [@iamnapo]: https://github.com/iamnapo +[@JoshuaKGoldberg]: https://github.com/JoshuaKGoldberg [@JounQin]: https://github.com/JounQin [@kaiyoma]: https://github.com/kaiyoma [@leipert]: https://github.com/leipert +[@ljharb]: https://github.com/ljharb [@manuth]: https://github.com/manuth [@maxkomarychev]: https://github.com/maxkomarychev [@mgwalker]: https://github.com/mgwalker +[@michaelfaith]: https://github.com/michaelfaith +[@Mysak0CZ]: https://github.com/Mysak0CZ [@nicolo-ribaudo]: https://github.com/nicolo-ribaudo [@pmcelhaney]: https://github.com/pmcelhaney [@sergei-startsev]: https://github.com/sergei-startsev +[@silverwind]: https://github.com/silverwind [@sompylasar]: https://github.com/sompylasar [@timkraut]: https://github.com/timkraut [@vikr01]: https://github.com/vikr01 diff --git a/utils/ModuleCache.d.ts b/utils/ModuleCache.d.ts new file mode 100644 index 0000000000..72a72a0699 --- /dev/null +++ b/utils/ModuleCache.d.ts @@ -0,0 +1,22 @@ +import type { ESLintSettings } from "./types"; + +export type CacheKey = unknown; +export type CacheObject = { + result: unknown; + lastSeen: ReturnType; +}; + +declare class ModuleCache { + map: Map; + + constructor(map?: Map); + + get(cacheKey: CacheKey, settings: ESLintSettings): T | undefined; + + set(cacheKey: CacheKey, result: T): T; + + static getSettings(settings: ESLintSettings): { lifetime: number } & Omit; +} +export default ModuleCache; + +export type { ModuleCache } diff --git a/utils/ModuleCache.js b/utils/ModuleCache.js index 4b1edc0eff..24c76849dd 100644 --- a/utils/ModuleCache.js +++ b/utils/ModuleCache.js @@ -4,26 +4,26 @@ exports.__esModule = true; const log = require('debug')('eslint-module-utils:ModuleCache'); +/** @type {import('./ModuleCache').ModuleCache} */ class ModuleCache { + /** @param {typeof import('./ModuleCache').ModuleCache.prototype.map} map */ constructor(map) { - this.map = map || new Map(); + this.map = map || /** @type {{typeof import('./ModuleCache').ModuleCache.prototype.map} */ new Map(); } - /** - * returns value for returning inline - * @param {[type]} cacheKey [description] - * @param {[type]} result [description] - */ + /** @type {typeof import('./ModuleCache').ModuleCache.prototype.set} */ set(cacheKey, result) { this.map.set(cacheKey, { result, lastSeen: process.hrtime() }); log('setting entry for', cacheKey); return result; } + /** @type {typeof import('./ModuleCache').ModuleCache.prototype.get} */ get(cacheKey, settings) { if (this.map.has(cacheKey)) { const f = this.map.get(cacheKey); // check freshness + // @ts-expect-error TS can't narrow properly from `has` and `get` if (process.hrtime(f.lastSeen)[0] < settings.lifetime) { return f.result; } } else { log('cache miss for', cacheKey); @@ -32,19 +32,21 @@ class ModuleCache { return undefined; } -} - -ModuleCache.getSettings = function (settings) { - const cacheSettings = Object.assign({ - lifetime: 30, // seconds - }, settings['import/cache']); + /** @type {typeof import('./ModuleCache').ModuleCache.getSettings} */ + static getSettings(settings) { + /** @type {ReturnType} */ + const cacheSettings = Object.assign({ + lifetime: 30, // seconds + }, settings['import/cache']); + + // parse infinity + // @ts-expect-error the lack of type overlap is because we're abusing `cacheSettings` as a temporary object + if (cacheSettings.lifetime === '∞' || cacheSettings.lifetime === 'Infinity') { + cacheSettings.lifetime = Infinity; + } - // parse infinity - if (cacheSettings.lifetime === '∞' || cacheSettings.lifetime === 'Infinity') { - cacheSettings.lifetime = Infinity; + return cacheSettings; } - - return cacheSettings; -}; +} exports.default = ModuleCache; diff --git a/utils/contextCompat.d.ts b/utils/contextCompat.d.ts new file mode 100644 index 0000000000..43fe0a91b8 --- /dev/null +++ b/utils/contextCompat.d.ts @@ -0,0 +1,38 @@ +import { Scope, SourceCode, Rule } from 'eslint'; +import * as ESTree from 'estree'; + +type LegacyContext = { + getFilename: () => string, + getPhysicalFilename: () => string, + getSourceCode: () => SourceCode, + getScope: never, + getAncestors: never, + getDeclaredVariables: never, +}; + +type NewContext = { + filename: string, + sourceCode: SourceCode, + getPhysicalFilename?: () => string, + getScope: () => Scope.Scope, + getAncestors: () => ESTree.Node[], + getDeclaredVariables: (node: ESTree.Node) => Scope.Variable[], +}; + +export type Context = LegacyContext | NewContext | Rule.RuleContext; + +declare function getAncestors(context: Context, node: ESTree.Node): ESTree.Node[]; +declare function getDeclaredVariables(context: Context, node: ESTree.Node): Scope.Variable[]; +declare function getFilename(context: Context): string; +declare function getPhysicalFilename(context: Context): string; +declare function getScope(context: Context, node: ESTree.Node): Scope.Scope; +declare function getSourceCode(context: Context): SourceCode; + +export { + getAncestors, + getDeclaredVariables, + getFilename, + getPhysicalFilename, + getScope, + getSourceCode, +}; diff --git a/utils/contextCompat.js b/utils/contextCompat.js new file mode 100644 index 0000000000..b1bdc598ef --- /dev/null +++ b/utils/contextCompat.js @@ -0,0 +1,72 @@ +'use strict'; + +exports.__esModule = true; + +/** @type {import('./contextCompat').getAncestors} */ +function getAncestors(context, node) { + const sourceCode = getSourceCode(context); + + if (sourceCode && sourceCode.getAncestors) { + return sourceCode.getAncestors(node); + } + + return context.getAncestors(); +} + +/** @type {import('./contextCompat').getDeclaredVariables} */ +function getDeclaredVariables(context, node) { + const sourceCode = getSourceCode(context); + + if (sourceCode && sourceCode.getDeclaredVariables) { + return sourceCode.getDeclaredVariables(node); + } + + return context.getDeclaredVariables(node); +} + +/** @type {import('./contextCompat').getFilename} */ +function getFilename(context) { + if ('filename' in context) { + return context.filename; + } + + return context.getFilename(); +} + +/** @type {import('./contextCompat').getPhysicalFilename} */ +function getPhysicalFilename(context) { + if (context.getPhysicalFilename) { + return context.getPhysicalFilename(); + } + + return getFilename(context); +} + +/** @type {import('./contextCompat').getScope} */ +function getScope(context, node) { + const sourceCode = getSourceCode(context); + + if (sourceCode && sourceCode.getScope) { + return sourceCode.getScope(node); + } + + return context.getScope(); +} + +/** @type {import('./contextCompat').getSourceCode} */ +function getSourceCode(context) { + if ('sourceCode' in context) { + return context.sourceCode; + } + + return context.getSourceCode(); +} + +module.exports = { + getAncestors, + getDeclaredVariables, + getFilename, + getPhysicalFilename, + getScope, + getSourceCode, +}; diff --git a/utils/declaredScope.d.ts b/utils/declaredScope.d.ts new file mode 100644 index 0000000000..90053e8e70 --- /dev/null +++ b/utils/declaredScope.d.ts @@ -0,0 +1,10 @@ +import { Rule, Scope } from 'eslint'; +import * as ESTree from 'estree'; + +declare function declaredScope( + context: Rule.RuleContext, + name: string, + node?: ESTree.Node, +): Scope.Scope['type'] | undefined; + +export default declaredScope; diff --git a/utils/declaredScope.js b/utils/declaredScope.js index dd2a20149f..aa3e38b47a 100644 --- a/utils/declaredScope.js +++ b/utils/declaredScope.js @@ -2,9 +2,12 @@ exports.__esModule = true; -exports.default = function declaredScope(context, name) { - const references = context.getScope().references; +const { getScope } = require('./contextCompat'); + +/** @type {import('./declaredScope').default} */ +exports.default = function declaredScope(context, name, node) { + const references = (node ? getScope(context, node) : context.getScope()).references; const reference = references.find((x) => x.identifier.name === name); - if (!reference) { return undefined; } + if (!reference || !reference.resolved) { return undefined; } return reference.resolved.scope.type; }; diff --git a/utils/hash.d.ts b/utils/hash.d.ts new file mode 100644 index 0000000000..5e4cf471bd --- /dev/null +++ b/utils/hash.d.ts @@ -0,0 +1,14 @@ +import type { Hash } from 'crypto'; + +declare function hashArray(value: Array, hash?: Hash): Hash; + +declare function hashObject(value: T, hash?: Hash): Hash; + +declare function hashify( + value: Array | object | unknown, + hash?: Hash, +): Hash; + +export default hashify; + +export { hashArray, hashObject }; diff --git a/utils/hash.js b/utils/hash.js index b9bff25bd9..21ed524a9f 100644 --- a/utils/hash.js +++ b/utils/hash.js @@ -11,11 +11,14 @@ const createHash = require('crypto').createHash; const stringify = JSON.stringify; +/** @type {import('./hash').default} */ function hashify(value, hash) { if (!hash) { hash = createHash('sha256'); } if (Array.isArray(value)) { hashArray(value, hash); + } else if (typeof value === 'function') { + hash.update(String(value)); } else if (value instanceof Object) { hashObject(value, hash); } else { @@ -26,6 +29,7 @@ function hashify(value, hash) { } exports.default = hashify; +/** @type {import('./hash').hashArray} */ function hashArray(array, hash) { if (!hash) { hash = createHash('sha256'); } @@ -41,13 +45,15 @@ function hashArray(array, hash) { hashify.array = hashArray; exports.hashArray = hashArray; -function hashObject(object, hash) { - if (!hash) { hash = createHash('sha256'); } +/** @type {import('./hash').hashObject} */ +function hashObject(object, optionalHash) { + const hash = optionalHash || createHash('sha256'); hash.update('{'); Object.keys(object).sort().forEach((key) => { hash.update(stringify(key)); hash.update(':'); + // @ts-expect-error the key is guaranteed to exist on the object here hashify(object[key], hash); hash.update(','); }); diff --git a/utils/ignore.d.ts b/utils/ignore.d.ts new file mode 100644 index 0000000000..53953b33e9 --- /dev/null +++ b/utils/ignore.d.ts @@ -0,0 +1,12 @@ +import { Rule } from 'eslint'; +import type { ESLintSettings, Extension } from './types'; + +declare function ignore(path: string, context: Rule.RuleContext): boolean; + +declare function getFileExtensions(settings: ESLintSettings): Set; + +declare function hasValidExtension(path: string, context: Rule.RuleContext): path is `${string}${Extension}`; + +export default ignore; + +export { getFileExtensions, hasValidExtension } diff --git a/utils/ignore.js b/utils/ignore.js index 960538e706..a42d4ceb1f 100644 --- a/utils/ignore.js +++ b/utils/ignore.js @@ -7,20 +7,14 @@ const extname = require('path').extname; const log = require('debug')('eslint-plugin-import:utils:ignore'); // one-shot memoized -let cachedSet; let lastSettings; -function validExtensions(context) { - if (cachedSet && context.settings === lastSettings) { - return cachedSet; - } - - lastSettings = context.settings; - cachedSet = makeValidExtensionSet(context.settings); - return cachedSet; -} +/** @type {Set} */ let cachedSet; +/** @type {import('./types').ESLintSettings} */ let lastSettings; +/** @type {import('./ignore').getFileExtensions} */ function makeValidExtensionSet(settings) { // start with explicit JS-parsed extensions - const exts = new Set(settings['import/extensions'] || ['.js']); + /** @type {Set} */ + const exts = new Set(settings['import/extensions'] || ['.js', '.mjs', '.cjs']); // all alternate parser extensions are also valid if ('import/parsers' in settings) { @@ -37,11 +31,34 @@ function makeValidExtensionSet(settings) { } exports.getFileExtensions = makeValidExtensionSet; +/** @type {(context: import('eslint').Rule.RuleContext) => Set} */ +function validExtensions(context) { + if (cachedSet && context.settings === lastSettings) { + return cachedSet; + } + + lastSettings = context.settings; + cachedSet = makeValidExtensionSet(context.settings); + return cachedSet; +} + +/** @type {import('./ignore').hasValidExtension} */ +function hasValidExtension(path, context) { + // eslint-disable-next-line no-extra-parens + return validExtensions(context).has(/** @type {import('./types').Extension} */ (extname(path))); +} +exports.hasValidExtension = hasValidExtension; + +/** @type {import('./ignore').default} */ exports.default = function ignore(path, context) { // check extension whitelist first (cheap) - if (!hasValidExtension(path, context)) { return true; } + if (!hasValidExtension(path, context)) { + return true; + } - if (!('import/ignore' in context.settings)) { return false; } + if (!('import/ignore' in context.settings)) { + return false; + } const ignoreStrings = context.settings['import/ignore']; for (let i = 0; i < ignoreStrings.length; i++) { @@ -54,8 +71,3 @@ exports.default = function ignore(path, context) { return false; }; - -function hasValidExtension(path, context) { - return validExtensions(context).has(extname(path)); -} -exports.hasValidExtension = hasValidExtension; diff --git a/utils/module-require.d.ts b/utils/module-require.d.ts new file mode 100644 index 0000000000..91df90d616 --- /dev/null +++ b/utils/module-require.d.ts @@ -0,0 +1,3 @@ +declare function moduleRequire(p: string): T; + +export default moduleRequire; diff --git a/utils/module-require.js b/utils/module-require.js index 96ef82ba51..14006c5dc6 100644 --- a/utils/module-require.js +++ b/utils/module-require.js @@ -6,23 +6,28 @@ const Module = require('module'); const path = require('path'); // borrowed from babel-eslint +/** @type {(filename: string) => Module} */ function createModule(filename) { const mod = new Module(filename); mod.filename = filename; + // @ts-expect-error _nodeModulesPaths are undocumented mod.paths = Module._nodeModulePaths(path.dirname(filename)); return mod; } +/** @type {import('./module-require').default} */ exports.default = function moduleRequire(p) { try { // attempt to get espree relative to eslint const eslintPath = require.resolve('eslint'); const eslintModule = createModule(eslintPath); + // @ts-expect-error _resolveFilename is undocumented return require(Module._resolveFilename(p, eslintModule)); } catch (err) { /* ignore */ } try { // try relative to entry point + // @ts-expect-error TODO: figure out what this is return require.main.require(p); } catch (err) { /* ignore */ } diff --git a/utils/moduleVisitor.d.ts b/utils/moduleVisitor.d.ts new file mode 100644 index 0000000000..6f30186d71 --- /dev/null +++ b/utils/moduleVisitor.d.ts @@ -0,0 +1,26 @@ +import type { Rule } from 'eslint'; +import type { Node } from 'estree'; + +type Visitor = (source: Node, importer: unknown) => any; + +type Options = { + amd?: boolean; + commonjs?: boolean; + esmodule?: boolean; + ignore?: string[]; +}; + +declare function moduleVisitor( + visitor: Visitor, + options?: Options, +): object; + +export default moduleVisitor; + +export type Schema = NonNullable; + +declare function makeOptionsSchema(additionalProperties?: Partial): Schema + +declare const optionsSchema: Schema; + +export { makeOptionsSchema, optionsSchema }; diff --git a/utils/moduleVisitor.js b/utils/moduleVisitor.js index c312ca2d45..acdee6774f 100644 --- a/utils/moduleVisitor.js +++ b/utils/moduleVisitor.js @@ -2,50 +2,58 @@ exports.__esModule = true; +/** @typedef {import('estree').Node} Node */ +/** @typedef {{ arguments: import('estree').CallExpression['arguments'], callee: Node }} Call */ +/** @typedef {import('estree').ImportDeclaration | import('estree').ExportNamedDeclaration | import('estree').ExportAllDeclaration} Declaration */ + /** * Returns an object of node visitors that will call * 'visitor' with every discovered module path. * - * todo: correct function prototype for visitor - * @param {Function(String)} visitor [description] - * @param {[type]} options [description] - * @return {object} + * @type {(import('./moduleVisitor').default)} */ exports.default = function visitModules(visitor, options) { + const ignore = options && options.ignore; + const amd = !!(options && options.amd); + const commonjs = !!(options && options.commonjs); // if esmodule is not explicitly disabled, it is assumed to be enabled - options = Object.assign({ esmodule: true }, options); + const esmodule = !!Object.assign({ esmodule: true }, options).esmodule; - let ignoreRegExps = []; - if (options.ignore != null) { - ignoreRegExps = options.ignore.map((p) => new RegExp(p)); - } + const ignoreRegExps = ignore == null ? [] : ignore.map((p) => new RegExp(p)); + /** @type {(source: undefined | null | import('estree').Literal, importer: Parameters[1]) => void} */ function checkSourceValue(source, importer) { if (source == null) { return; } //? // handle ignore - if (ignoreRegExps.some((re) => re.test(source.value))) { return; } + if (ignoreRegExps.some((re) => re.test(String(source.value)))) { return; } // fire visitor visitor(source, importer); } // for import-y declarations + /** @type {(node: Declaration) => void} */ function checkSource(node) { checkSourceValue(node.source, node); } // for esmodule dynamic `import()` calls + /** @type {(node: import('estree').ImportExpression | import('estree').CallExpression) => void} */ function checkImportCall(node) { + /** @type {import('estree').Expression | import('estree').Literal | import('estree').CallExpression['arguments'][0]} */ let modulePath; // refs https://github.com/estree/estree/blob/HEAD/es2020.md#importexpression if (node.type === 'ImportExpression') { modulePath = node.source; } else if (node.type === 'CallExpression') { + // @ts-expect-error this structure is from an older version of eslint if (node.callee.type !== 'Import') { return; } if (node.arguments.length !== 1) { return; } modulePath = node.arguments[0]; + } else { + throw new TypeError('this should be unreachable'); } if (modulePath.type !== 'Literal') { return; } @@ -56,6 +64,7 @@ exports.default = function visitModules(visitor, options) { // for CommonJS `require` calls // adapted from @mctep: https://git.io/v4rAu + /** @type {(call: Call) => void} */ function checkCommon(call) { if (call.callee.type !== 'Identifier') { return; } if (call.callee.name !== 'require') { return; } @@ -68,6 +77,7 @@ exports.default = function visitModules(visitor, options) { checkSourceValue(modulePath, call); } + /** @type {(call: Call) => void} */ function checkAMD(call) { if (call.callee.type !== 'Identifier') { return; } if (call.callee.name !== 'require' && call.callee.name !== 'define') { return; } @@ -77,6 +87,7 @@ exports.default = function visitModules(visitor, options) { if (modules.type !== 'ArrayExpression') { return; } for (const element of modules.elements) { + if (!element) { continue; } if (element.type !== 'Literal') { continue; } if (typeof element.value !== 'string') { continue; } @@ -92,7 +103,7 @@ exports.default = function visitModules(visitor, options) { } const visitors = {}; - if (options.esmodule) { + if (esmodule) { Object.assign(visitors, { ImportDeclaration: checkSource, ExportNamedDeclaration: checkSource, @@ -102,12 +113,12 @@ exports.default = function visitModules(visitor, options) { }); } - if (options.commonjs || options.amd) { + if (commonjs || amd) { const currentCallExpression = visitors.CallExpression; - visitors.CallExpression = function (call) { + visitors.CallExpression = /** @type {(call: Call) => void} */ function (call) { if (currentCallExpression) { currentCallExpression(call); } - if (options.commonjs) { checkCommon(call); } - if (options.amd) { checkAMD(call); } + if (commonjs) { checkCommon(call); } + if (amd) { checkAMD(call); } }; } @@ -115,10 +126,11 @@ exports.default = function visitModules(visitor, options) { }; /** - * make an options schema for the module visitor, optionally - * adding extra fields. + * make an options schema for the module visitor, optionally adding extra fields. + * @type {import('./moduleVisitor').makeOptionsSchema} */ function makeOptionsSchema(additionalProperties) { + /** @type {import('./moduleVisitor').Schema} */ const base = { type: 'object', properties: { @@ -137,6 +149,7 @@ function makeOptionsSchema(additionalProperties) { if (additionalProperties) { for (const key in additionalProperties) { + // @ts-expect-error TS always has trouble with arbitrary object assignment/mutation base.properties[key] = additionalProperties[key]; } } @@ -146,8 +159,6 @@ function makeOptionsSchema(additionalProperties) { exports.makeOptionsSchema = makeOptionsSchema; /** - * json schema object for options parameter. can be used to build - * rule options schema object. - * @type {Object} + * json schema object for options parameter. can be used to build rule options schema object. */ exports.optionsSchema = makeOptionsSchema(); diff --git a/utils/package.json b/utils/package.json index d56c442b1a..8cbbf84d3c 100644 --- a/utils/package.json +++ b/utils/package.json @@ -1,17 +1,51 @@ { "name": "eslint-module-utils", - "version": "2.8.0", + "version": "2.12.1", "description": "Core utilities to support eslint-plugin-import and other module-related plugins.", "engines": { "node": ">=4" }, + "main": false, + "exports": { + "./contextCompat": "./contextCompat.js", + "./ModuleCache": "./ModuleCache.js", + "./ModuleCache.js": "./ModuleCache.js", + "./declaredScope": "./declaredScope.js", + "./declaredScope.js": "./declaredScope.js", + "./hash": "./hash.js", + "./hash.js": "./hash.js", + "./ignore": "./ignore.js", + "./ignore.js": "./ignore.js", + "./module-require": "./module-require.js", + "./module-require.js": "./module-require.js", + "./moduleVisitor": "./moduleVisitor.js", + "./moduleVisitor.js": "./moduleVisitor.js", + "./parse": "./parse.js", + "./parse.js": "./parse.js", + "./pkgDir": "./pkgDir.js", + "./pkgDir.js": "./pkgDir.js", + "./pkgUp": "./pkgUp.js", + "./pkgUp.js": "./pkgUp.js", + "./readPkgUp": "./readPkgUp.js", + "./readPkgUp.js": "./readPkgUp.js", + "./resolve": "./resolve.js", + "./resolve.js": "./resolve.js", + "./unambiguous": "./unambiguous.js", + "./unambiguous.js": "./unambiguous.js", + "./visit": "./visit.js", + "./visit.js": "./visit.js", + "./package.json": "./package.json" + }, "scripts": { "prepublishOnly": "cp ../{LICENSE,.npmrc} ./", + "tsc": "tsc -p .", + "posttsc": "attw -P .", "test": "echo \"Error: no test specified\" && exit 1" }, "repository": { "type": "git", - "url": "git+https://github.com/import-js/eslint-plugin-import.git" + "url": "git+https://github.com/import-js/eslint-plugin-import.git", + "directory": "utils" }, "keywords": [ "eslint-plugin-import", @@ -28,9 +62,22 @@ "dependencies": { "debug": "^3.2.7" }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.18.2", + "@ljharb/tsconfig": "^0.3.2", + "@types/debug": "^4.1.12", + "@types/eslint": "^8.56.3", + "@types/node": "^20.19.1", + "typescript": "next" + }, "peerDependenciesMeta": { "eslint": { "optional": true } + }, + "publishConfig": { + "ignore": [ + ".attw.json" + ] } } diff --git a/utils/parse.d.ts b/utils/parse.d.ts new file mode 100644 index 0000000000..f92ab3edc6 --- /dev/null +++ b/utils/parse.d.ts @@ -0,0 +1,11 @@ +import { AST, Rule } from 'eslint'; + + + +declare function parse( + path: string, + content: string, + context: Rule.RuleContext +): AST.Program | null | undefined; + +export default parse; diff --git a/utils/parse.js b/utils/parse.js index 7646b3177c..fb0fba7b93 100644 --- a/utils/parse.js +++ b/utils/parse.js @@ -2,12 +2,16 @@ exports.__esModule = true; +/** @typedef {`.${string}`} Extension */ +/** @typedef {NonNullable & { 'import/extensions'?: Extension[], 'import/parsers'?: { [k: string]: Extension[] }, 'import/cache'?: { lifetime: number | '∞' | 'Infinity' } }} ESLintSettings */ + const moduleRequire = require('./module-require').default; const extname = require('path').extname; const fs = require('fs'); const log = require('debug')('eslint-plugin-import:parse'); +/** @type {(parserPath: NonNullable) => unknown} */ function getBabelEslintVisitorKeys(parserPath) { if (parserPath.endsWith('index.js')) { const hypotheticalLocation = parserPath.replace('index.js', 'visitor-keys.js'); @@ -19,43 +23,98 @@ function getBabelEslintVisitorKeys(parserPath) { return null; } +/** @type {(parserPath: import('eslint').Rule.RuleContext['parserPath'], parserInstance: { VisitorKeys: unknown }, parsedResult?: { visitorKeys?: unknown }) => unknown} */ function keysFromParser(parserPath, parserInstance, parsedResult) { // Exposed by @typescript-eslint/parser and @babel/eslint-parser if (parsedResult && parsedResult.visitorKeys) { return parsedResult.visitorKeys; } - if (typeof parserPath === 'string' && (/.*espree.*/).test(parserPath)) { - return parserInstance.VisitorKeys; - } - if (typeof parserPath === 'string' && (/.*babel-eslint.*/).test(parserPath)) { + // The old babel parser doesn't have a `parseForESLint` eslint function, so we don't end + // up with a `parsedResult` here. It also doesn't expose the visitor keys on the parser itself, + // so we have to try and infer the visitor-keys module from the parserPath. + // This is NOT supported in flat config! + if (typeof parserPath === 'string' && parserPath.indexOf('babel-eslint') > -1) { return getBabelEslintVisitorKeys(parserPath); } + // The espree parser doesn't have the `parseForESLint` function, so we don't end up with a + // `parsedResult` here, but it does expose the visitor keys on the parser instance that we can use. + if (parserInstance && parserInstance.VisitorKeys) { + return parserInstance.VisitorKeys; + } return null; } // this exists to smooth over the unintentional breaking change in v2.7. // TODO, semver-major: avoid mutating `ast` and return a plain object instead. +/** @type {(ast: T, visitorKeys: unknown) => T} */ function makeParseReturn(ast, visitorKeys) { if (ast) { + // @ts-expect-error see TODO ast.visitorKeys = visitorKeys; + // @ts-expect-error see TODO ast.ast = ast; } return ast; } +/** @type {(text: string) => string} */ function stripUnicodeBOM(text) { return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text; } +/** @type {(text: string) => string} */ function transformHashbang(text) { return text.replace(/^#!([^\r\n]+)/u, (_, captured) => `//${captured}`); } +/** @type {(path: string, context: import('eslint').Rule.RuleContext & { settings?: ESLintSettings }) => import('eslint').Rule.RuleContext['parserPath']} */ +function getParserPath(path, context) { + const parsers = context.settings['import/parsers']; + if (parsers != null) { + // eslint-disable-next-line no-extra-parens + const extension = /** @type {Extension} */ (extname(path)); + for (const parserPath in parsers) { + if (parsers[parserPath].indexOf(extension) > -1) { + // use this alternate parser + log('using alt parser:', parserPath); + return parserPath; + } + } + } + // default to use ESLint parser + return context.parserPath; +} + +/** @type {(path: string, context: import('eslint').Rule.RuleContext) => string | null | import('eslint').Linter.ParserModule | import('eslint').Linter.FlatConfigParserModule} */ +function getParser(path, context) { + const parserPath = getParserPath(path, context); + if (parserPath) { + return parserPath; + } + if ( + !!context.languageOptions + && !!context.languageOptions.parser + && typeof context.languageOptions.parser !== 'string' + && ( + // @ts-expect-error TODO: figure out a better type + typeof context.languageOptions.parser.parse === 'function' + // @ts-expect-error TODO: figure out a better type + || typeof context.languageOptions.parser.parseForESLint === 'function' + ) + ) { + return context.languageOptions.parser; + } + + return null; +} + +/** @type {import('./parse').default} */ exports.default = function parse(path, content, context) { if (context == null) { throw new Error('need context to parse properly'); } // ESLint in "flat" mode only sets context.languageOptions.parserOptions - let parserOptions = context.languageOptions && context.languageOptions.parserOptions || context.parserOptions; + const languageOptions = context.languageOptions; + let parserOptions = languageOptions && languageOptions.parserOptions || context.parserOptions; const parserOrPath = getParser(path, context); if (!parserOrPath) { throw new Error('parserPath or languageOptions.parser is required!'); } @@ -81,9 +140,22 @@ exports.default = function parse(path, content, context) { // "project" or "projects" in parserOptions. Removing these options means the parser will // only parse one file in isolate mode, which is much, much faster. // https://github.com/import-js/eslint-plugin-import/issues/1408#issuecomment-509298962 + delete parserOptions.EXPERIMENTAL_useProjectService; + delete parserOptions.projectService; delete parserOptions.project; delete parserOptions.projects; + // If this is a flat config, we need to add ecmaVersion and sourceType (if present) from languageOptions + if (languageOptions && languageOptions.ecmaVersion) { + parserOptions.ecmaVersion = languageOptions.ecmaVersion; + } + if (languageOptions && languageOptions.sourceType) { + // @ts-expect-error languageOptions is from the flatConfig Linter type in 8.57 while parserOptions is not. + // Non-flat config parserOptions.sourceType doesn't have "commonjs" in the type. Once upgraded to v9 types, + // they'll be the same and this expect-error should be removed. + parserOptions.sourceType = languageOptions.sourceType; + } + // require the parser relative to the main module (i.e., ESLint) const parser = typeof parserOrPath === 'string' ? moduleRequire(parserOrPath) : parserOrPath; @@ -96,54 +168,26 @@ exports.default = function parse(path, content, context) { try { const parserRaw = parser.parseForESLint(content, parserOptions); ast = parserRaw.ast; + // @ts-expect-error TODO: FIXME return makeParseReturn(ast, keysFromParser(parserOrPath, parser, parserRaw)); } catch (e) { console.warn(); console.warn('Error while parsing ' + parserOptions.filePath); + // @ts-expect-error e is almost certainly an Error here console.warn('Line ' + e.lineNumber + ', column ' + e.column + ': ' + e.message); } if (!ast || typeof ast !== 'object') { console.warn( // Can only be invalid for custom parser per imports/parser - '`parseForESLint` from parser `' + (typeof parserOrPath === 'string' ? parserOrPath : '`context.languageOptions.parser`') + '` is invalid and will just be ignored' + '`parseForESLint` from parser `' + (typeof parserOrPath === 'string' ? parserOrPath : 'context.languageOptions.parser') + '` is invalid and will just be ignored' ); } else { + // @ts-expect-error TODO: FIXME return makeParseReturn(ast, keysFromParser(parserOrPath, parser, undefined)); } } const ast = parser.parse(content, parserOptions); + // @ts-expect-error TODO: FIXME return makeParseReturn(ast, keysFromParser(parserOrPath, parser, undefined)); }; - -function getParser(path, context) { - const parserPath = getParserPath(path, context); - if (parserPath) { - return parserPath; - } - const isFlat = context.languageOptions - && context.languageOptions.parser - && typeof context.languageOptions.parser !== 'string' - && ( - typeof context.languageOptions.parser.parse === 'function' - || typeof context.languageOptions.parser.parseForESLint === 'function' - ); - - return isFlat ? context.languageOptions.parser : null; -} - -function getParserPath(path, context) { - const parsers = context.settings['import/parsers']; - if (parsers != null) { - const extension = extname(path); - for (const parserPath in parsers) { - if (parsers[parserPath].indexOf(extension) > -1) { - // use this alternate parser - log('using alt parser:', parserPath); - return parserPath; - } - } - } - // default to use ESLint parser - return context.parserPath; -} diff --git a/utils/pkgDir.d.ts b/utils/pkgDir.d.ts new file mode 100644 index 0000000000..af01e2e9bf --- /dev/null +++ b/utils/pkgDir.d.ts @@ -0,0 +1,3 @@ +declare function pkgDir(cwd: string): string | null; + +export default pkgDir; diff --git a/utils/pkgDir.js b/utils/pkgDir.js index 34412202f1..84c334680a 100644 --- a/utils/pkgDir.js +++ b/utils/pkgDir.js @@ -5,6 +5,7 @@ const pkgUp = require('./pkgUp').default; exports.__esModule = true; +/** @type {import('./pkgDir').default} */ exports.default = function (cwd) { const fp = pkgUp({ cwd }); return fp ? path.dirname(fp) : null; diff --git a/utils/pkgUp.d.ts b/utils/pkgUp.d.ts new file mode 100644 index 0000000000..6382457bec --- /dev/null +++ b/utils/pkgUp.d.ts @@ -0,0 +1,3 @@ +declare function pkgUp(opts?: { cwd?: string }): string | null; + +export default pkgUp; diff --git a/utils/pkgUp.js b/utils/pkgUp.js index 889f62265f..076e59fd76 100644 --- a/utils/pkgUp.js +++ b/utils/pkgUp.js @@ -31,10 +31,13 @@ const path = require('path'); * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ + +/** @type {(filename: string | string[], cwd?: string) => string | null} */ function findUp(filename, cwd) { let dir = path.resolve(cwd || ''); const root = path.parse(dir).root; + /** @type {string[]} */ // @ts-expect-error TS sucks with concat const filenames = [].concat(filename); // eslint-disable-next-line no-constant-condition @@ -52,6 +55,7 @@ function findUp(filename, cwd) { } } +/** @type {import('./pkgUp').default} */ exports.default = function pkgUp(opts) { return findUp('package.json', opts && opts.cwd); }; diff --git a/utils/readPkgUp.d.ts b/utils/readPkgUp.d.ts new file mode 100644 index 0000000000..5fc1668879 --- /dev/null +++ b/utils/readPkgUp.d.ts @@ -0,0 +1,5 @@ +import pkgUp from './pkgUp'; + +declare function readPkgUp(opts?: Parameters[0]): {} | { pkg: string, path: string }; + +export default readPkgUp; diff --git a/utils/readPkgUp.js b/utils/readPkgUp.js index d34fa6c818..08371931f2 100644 --- a/utils/readPkgUp.js +++ b/utils/readPkgUp.js @@ -5,6 +5,7 @@ exports.__esModule = true; const fs = require('fs'); const pkgUp = require('./pkgUp').default; +/** @type {(str: string) => string} */ function stripBOM(str) { return str.replace(/^\uFEFF/, ''); } @@ -35,6 +36,7 @@ function stripBOM(str) { * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ +/** @type {import('./readPkgUp').default} */ exports.default = function readPkgUp(opts) { const fp = pkgUp(opts); diff --git a/utils/resolve.d.ts b/utils/resolve.d.ts new file mode 100644 index 0000000000..bb885bcfaf --- /dev/null +++ b/utils/resolve.d.ts @@ -0,0 +1,30 @@ +import type { Rule } from 'eslint'; + +import type ModuleCache from './ModuleCache'; +import type { ESLintSettings } from './types'; + +export type ResultNotFound = { found: false, path?: undefined }; +export type ResultFound = { found: true, path: string | null }; +export type ResolvedResult = ResultNotFound | ResultFound; + +export type ResolverResolve = (modulePath: string, sourceFile:string, config: unknown) => ResolvedResult; +export type ResolverResolveImport = (modulePath: string, sourceFile:string, config: unknown) => string | undefined; +export type Resolver = { interfaceVersion?: 1 | 2, resolve: ResolverResolve, resolveImport: ResolverResolveImport }; + +declare function resolve( + p: string, + context: Rule.RuleContext, +): ResolvedResult['path']; + +export default resolve; + +declare function fileExistsWithCaseSync( + filepath: string | null, + cacheSettings: ESLintSettings, + strict: boolean +): boolean | ReturnType; + +declare function relative(modulePath: string, sourceFile: string, settings: ESLintSettings): ResolvedResult['path']; + + +export { fileExistsWithCaseSync, relative }; diff --git a/utils/resolve.js b/utils/resolve.js index 0ed5bdb0c9..b332d2ec2c 100644 --- a/utils/resolve.js +++ b/utils/resolve.js @@ -5,6 +5,7 @@ exports.__esModule = true; const fs = require('fs'); const Module = require('module'); const path = require('path'); +const { getPhysicalFilename } = require('./contextCompat'); const hashObject = require('./hash').hashObject; const ModuleCache = require('./ModuleCache').default; @@ -19,16 +20,30 @@ const fileExistsCache = new ModuleCache(); // Polyfill Node's `Module.createRequireFromPath` if not present (added in Node v10.12.0) // Use `Module.createRequire` if available (added in Node v12.2.0) -const createRequire = Module.createRequire || Module.createRequireFromPath || function (filename) { - const mod = new Module(filename, null); - mod.filename = filename; - mod.paths = Module._nodeModulePaths(path.dirname(filename)); - - mod._compile(`module.exports = require;`, filename); - - return mod.exports; -}; +const createRequire = Module.createRequire + // @ts-expect-error this only exists in older node + || Module.createRequireFromPath + || /** @type {(filename: string) => unknown} */ function (filename) { + const mod = new Module(filename, void null); + mod.filename = filename; + // @ts-expect-error _nodeModulePaths is undocumented + mod.paths = Module._nodeModulePaths(path.dirname(filename)); + + // @ts-expect-error _compile is undocumented + mod._compile(`module.exports = require;`, filename); + + return mod.exports; + }; + +/** @type {(resolver: object) => resolver is import('./resolve').Resolver} */ +function isResolverValid(resolver) { + if ('interfaceVersion' in resolver && resolver.interfaceVersion === 2) { + return 'resolve' in resolver && !!resolver.resolve && typeof resolver.resolve === 'function'; + } + return 'resolveImport' in resolver && !!resolver.resolveImport && typeof resolver.resolveImport === 'function'; +} +/** @type {(target: T, sourceFile?: string | null | undefined) => undefined | ReturnType} */ function tryRequire(target, sourceFile) { let resolved; try { @@ -51,7 +66,58 @@ function tryRequire(target, sourceFile) { return require(resolved); } +/** @type {>(resolvers: string[] | string | { [k: string]: string }, map: T) => T} */ +function resolverReducer(resolvers, map) { + if (Array.isArray(resolvers)) { + resolvers.forEach((r) => resolverReducer(r, map)); + return map; + } + + if (typeof resolvers === 'string') { + map.set(resolvers, null); + return map; + } + + if (typeof resolvers === 'object') { + for (const key in resolvers) { + map.set(key, resolvers[key]); + } + return map; + } + + const err = new Error('invalid resolver config'); + err.name = ERROR_NAME; + throw err; +} + +/** @type {(sourceFile: string) => string} */ +function getBaseDir(sourceFile) { + return pkgDir(sourceFile) || process.cwd(); +} + +/** @type {(name: string, sourceFile: string) => import('./resolve').Resolver} */ +function requireResolver(name, sourceFile) { + // Try to resolve package with conventional name + const resolver = tryRequire(`eslint-import-resolver-${name}`, sourceFile) + || tryRequire(name, sourceFile) + || tryRequire(path.resolve(getBaseDir(sourceFile), name)); + + if (!resolver) { + const err = new Error(`unable to load resolver "${name}".`); + err.name = ERROR_NAME; + throw err; + } + if (!isResolverValid(resolver)) { + const err = new Error(`${name} with invalid interface loaded as resolver`); + err.name = ERROR_NAME; + throw err; + } + + return resolver; +} + // https://stackoverflow.com/a/27382838 +/** @type {import('./resolve').fileExistsWithCaseSync} */ exports.fileExistsWithCaseSync = function fileExistsWithCaseSync(filepath, cacheSettings, strict) { // don't care if the FS is case-sensitive if (CASE_SENSITIVE_FS) { return true; } @@ -80,12 +146,10 @@ exports.fileExistsWithCaseSync = function fileExistsWithCaseSync(filepath, cache return result; }; -function relative(modulePath, sourceFile, settings) { - return fullResolve(modulePath, sourceFile, settings).path; -} - +/** @type {import('./types').ESLintSettings | null} */ let prevSettings = null; let memoizedHash = ''; +/** @type {(modulePath: string, sourceFile: string, settings: import('./types').ESLintSettings) => import('./resolve').ResolvedResult} */ function fullResolve(modulePath, sourceFile, settings) { // check if this is a bonus core module const coreSet = new Set(settings['import/core-modules']); @@ -105,10 +169,12 @@ function fullResolve(modulePath, sourceFile, settings) { const cachedPath = fileExistsCache.get(cacheKey, cacheSettings); if (cachedPath !== undefined) { return { found: true, path: cachedPath }; } + /** @type {(resolvedPath: string | null) => void} */ function cache(resolvedPath) { fileExistsCache.set(cacheKey, resolvedPath); } + /** @type {(resolver: import('./resolve').Resolver, config: unknown) => import('./resolve').ResolvedResult} */ function withResolver(resolver, config) { if (resolver.interfaceVersion === 2) { return resolver.resolve(modulePath, sourceFile, config); @@ -145,81 +211,35 @@ function fullResolve(modulePath, sourceFile, settings) { // cache(undefined) return { found: false }; } -exports.relative = relative; - -function resolverReducer(resolvers, map) { - if (Array.isArray(resolvers)) { - resolvers.forEach((r) => resolverReducer(r, map)); - return map; - } - - if (typeof resolvers === 'string') { - map.set(resolvers, null); - return map; - } - - if (typeof resolvers === 'object') { - for (const key in resolvers) { - map.set(key, resolvers[key]); - } - return map; - } - - const err = new Error('invalid resolver config'); - err.name = ERROR_NAME; - throw err; -} -function getBaseDir(sourceFile) { - return pkgDir(sourceFile) || process.cwd(); -} -function requireResolver(name, sourceFile) { - // Try to resolve package with conventional name - const resolver = tryRequire(`eslint-import-resolver-${name}`, sourceFile) - || tryRequire(name, sourceFile) - || tryRequire(path.resolve(getBaseDir(sourceFile), name)); - - if (!resolver) { - const err = new Error(`unable to load resolver "${name}".`); - err.name = ERROR_NAME; - throw err; - } - if (!isResolverValid(resolver)) { - const err = new Error(`${name} with invalid interface loaded as resolver`); - err.name = ERROR_NAME; - throw err; - } - - return resolver; -} - -function isResolverValid(resolver) { - if (resolver.interfaceVersion === 2) { - return resolver.resolve && typeof resolver.resolve === 'function'; - } else { - return resolver.resolveImport && typeof resolver.resolveImport === 'function'; - } +/** @type {import('./resolve').relative} */ +function relative(modulePath, sourceFile, settings) { + return fullResolve(modulePath, sourceFile, settings).path; } +exports.relative = relative; +/** @type {Set} */ const erroredContexts = new Set(); /** * Given - * @param {string} p - module path - * @param {object} context - ESLint context - * @return {string} - the full module filesystem path; - * null if package is core; - * undefined if not found + * @param p - module path + * @param context - ESLint context + * @return - the full module filesystem path; null if package is core; undefined if not found + * @type {import('./resolve').default} */ function resolve(p, context) { try { - return relative(p, context.getPhysicalFilename ? context.getPhysicalFilename() : context.getFilename(), context.settings); + return relative(p, getPhysicalFilename(context), context.settings); } catch (err) { if (!erroredContexts.has(context)) { // The `err.stack` string starts with `err.name` followed by colon and `err.message`. // We're filtering out the default `err.name` because it adds little value to the message. + // @ts-expect-error this might be an Error let errMessage = err.message; + // @ts-expect-error this might be an Error if (err.name !== ERROR_NAME && err.stack) { + // @ts-expect-error this might be an Error errMessage = err.stack.replace(/^Error: /, ''); } context.report({ diff --git a/utils/tsconfig.json b/utils/tsconfig.json new file mode 100644 index 0000000000..9e6fbc5cc1 --- /dev/null +++ b/utils/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@ljharb/tsconfig", + "compilerOptions": { + "target": "ES2017", + "moduleResolution": "node", + "maxNodeModuleJsDepth": 0, + }, + "exclude": [ + "coverage", + ], +} diff --git a/utils/types.d.ts b/utils/types.d.ts new file mode 100644 index 0000000000..e0c4f5749d --- /dev/null +++ b/utils/types.d.ts @@ -0,0 +1,9 @@ +import type { Rule } from 'eslint'; + +export type Extension = `.${string}`; + +export type ESLintSettings = NonNullable & { + 'import/extensions'?: Extension[]; + 'import/parsers'?: { [k: string]: Extension[] }; + 'import/cache'?: { lifetime: number | '∞' | 'Infinity' }; +}; diff --git a/utils/unambiguous.d.ts b/utils/unambiguous.d.ts new file mode 100644 index 0000000000..1679224189 --- /dev/null +++ b/utils/unambiguous.d.ts @@ -0,0 +1,7 @@ +import type { AST } from 'eslint'; + +declare function isModule(ast: AST.Program): boolean; + +declare function test(content: string): boolean; + +export { isModule, test } diff --git a/utils/unambiguous.js b/utils/unambiguous.js index 24cb123157..df7b9be7a5 100644 --- a/utils/unambiguous.js +++ b/utils/unambiguous.js @@ -2,7 +2,7 @@ exports.__esModule = true; -const pattern = /(^|;)\s*(export|import)((\s+\w)|(\s*[{*=]))|import\(/m; +const pattern = /(^|[;})])\s*(export|import)((\s+\w)|(\s*[{*=]))|import\(/m; /** * detect possible imports/exports without a full parse. * @@ -11,7 +11,7 @@ const pattern = /(^|;)\s*(export|import)((\s+\w)|(\s*[{*=]))|import\(/m; * * Not perfect, just a fast way to disqualify large non-ES6 modules and * avoid a parse. - * @type {RegExp} + * @type {import('./unambiguous').test} */ exports.test = function isMaybeUnambiguousModule(content) { return pattern.test(content); @@ -22,8 +22,7 @@ const unambiguousNodeType = /^(?:(?:Exp|Imp)ort.*Declaration|TSExportAssignment) /** * Given an AST, return true if the AST unambiguously represents a module. - * @param {Program node} ast - * @return {Boolean} + * @type {import('./unambiguous').isModule} */ exports.isModule = function isUnambiguousModule(ast) { return ast.body && ast.body.some((node) => unambiguousNodeType.test(node.type)); diff --git a/utils/visit.d.ts b/utils/visit.d.ts new file mode 100644 index 0000000000..50559aaab0 --- /dev/null +++ b/utils/visit.d.ts @@ -0,0 +1,9 @@ +import type { Node } from 'estree'; + +declare function visit( + node: Node, + keys: { [k in Node['type']]?: (keyof Node)[] }, + visitorSpec: { [k in Node['type'] | `${Node['type']}:Exit`]?: Function } +): void; + +export default visit; diff --git a/utils/visit.js b/utils/visit.js index 6178faeaa0..dd0c6248da 100644 --- a/utils/visit.js +++ b/utils/visit.js @@ -2,24 +2,29 @@ exports.__esModule = true; +/** @type {import('./visit').default} */ exports.default = function visit(node, keys, visitorSpec) { if (!node || !keys) { return; } const type = node.type; - if (typeof visitorSpec[type] === 'function') { - visitorSpec[type](node); + const visitor = visitorSpec[type]; + if (typeof visitor === 'function') { + visitor(node); } const childFields = keys[type]; if (!childFields) { return; } childFields.forEach((fieldName) => { + // @ts-expect-error TS sucks with concat [].concat(node[fieldName]).forEach((item) => { visit(item, keys, visitorSpec); }); }); - if (typeof visitorSpec[`${type}:Exit`] === 'function') { - visitorSpec[`${type}:Exit`](node); + + const exit = visitorSpec[`${type}:Exit`]; + if (typeof exit === 'function') { + exit(node); } };