From 806e3c2ccc65456a2d8532d575c9f443355bda82 Mon Sep 17 00:00:00 2001 From: michael faith Date: Tue, 18 Jun 2024 07:10:18 -0500 Subject: [PATCH] [New] add support for Flat Config This change adds support for ESLint's new Flat config system. It maintains backwards compatibility with `eslintrc`-style configs as well. To achieve this, we're now dynamically creating flat configs on a new `flatConfigs` export. Usage ```js 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.js'], rules: { 'no-unused-vars': 'off', 'import/no-dynamic-require': 'warn', 'import/no-nodejs-modules': 'warn', }, }, ]; ``` --- .editorconfig | 1 + .eslintignore | 1 + CHANGELOG.md | 3 + README.md | 31 +++- config/flat/errors.js | 14 ++ config/flat/react.js | 19 +++ config/flat/recommended.js | 26 ++++ config/flat/warnings.js | 11 ++ config/react.js | 2 - config/typescript.js | 2 +- examples/flat/eslint.config.mjs | 25 +++ examples/flat/package.json | 17 +++ examples/flat/src/exports-unused.ts | 12 ++ examples/flat/src/exports.ts | 12 ++ examples/flat/src/imports.ts | 7 + examples/flat/src/jsx.tsx | 3 + examples/flat/tsconfig.json | 14 ++ examples/legacy/.eslintrc.cjs | 24 +++ examples/legacy/package.json | 16 ++ examples/legacy/src/exports-unused.ts | 12 ++ examples/legacy/src/exports.ts | 12 ++ examples/legacy/src/imports.ts | 7 + examples/legacy/src/jsx.tsx | 3 + examples/legacy/tsconfig.json | 14 ++ package.json | 3 + src/core/fsWalk.js | 48 ++++++ src/index.js | 31 ++++ src/rules/no-unused-modules.js | 210 ++++++++++++++++++++------ utils/ignore.js | 10 +- 29 files changed, 541 insertions(+), 49 deletions(-) create mode 100644 config/flat/errors.js create mode 100644 config/flat/react.js create mode 100644 config/flat/recommended.js create mode 100644 config/flat/warnings.js create mode 100644 examples/flat/eslint.config.mjs create mode 100644 examples/flat/package.json create mode 100644 examples/flat/src/exports-unused.ts create mode 100644 examples/flat/src/exports.ts create mode 100644 examples/flat/src/imports.ts create mode 100644 examples/flat/src/jsx.tsx create mode 100644 examples/flat/tsconfig.json create mode 100644 examples/legacy/.eslintrc.cjs create mode 100644 examples/legacy/package.json create mode 100644 examples/legacy/src/exports-unused.ts create mode 100644 examples/legacy/src/exports.ts create mode 100644 examples/legacy/src/imports.ts create mode 100644 examples/legacy/src/jsx.tsx create mode 100644 examples/legacy/tsconfig.json create mode 100644 src/core/fsWalk.js diff --git a/.editorconfig b/.editorconfig index e2bfac523..b7b8d0999 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 9d2200682..3516f09b9 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/CHANGELOG.md b/CHANGELOG.md index 775247301..05d623f41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange - [`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]) @@ -1125,6 +1126,7 @@ for info on changes for earlier releases. [#3036]: https://github.com/import-js/eslint-plugin-import/pull/3036 [#3033]: https://github.com/import-js/eslint-plugin-import/pull/3033 +[#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 @@ -1874,6 +1876,7 @@ 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 [@MikeyBeLike]: https://github.com/MikeyBeLike [@minervabot]: https://github.com/minervabot [@mpint]: https://github.com/mpint diff --git a/README.md b/README.md index 1fd113c7d..bf563f4d7 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,8 @@ The maintainers of `eslint-plugin-import` and thousands of other packages are wo npm install eslint-plugin-import --save-dev ``` +### Config - Legacy (`.eslintrc`) + 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: @@ -123,7 +125,7 @@ plugins: - import rules: - import/no-unresolved: [2, {commonjs: true, amd: true}] + import/no-unresolved: [2, { commonjs: true, amd: true }] import/named: 2 import/namespace: 2 import/default: 2 @@ -131,6 +133,33 @@ rules: # 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 canned 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 You may use the following snippet or assemble your own config using the granular settings described below it. diff --git a/config/flat/errors.js b/config/flat/errors.js new file mode 100644 index 000000000..98c19f824 --- /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 000000000..086747142 --- /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 000000000..11bc1f52a --- /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 000000000..e788ff9cd --- /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 68555512d..1ae8e1a51 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 ff7d0795c..d5eb57a46 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/examples/flat/eslint.config.mjs b/examples/flat/eslint.config.mjs new file mode 100644 index 000000000..370514a65 --- /dev/null +++ b/examples/flat/eslint.config.mjs @@ -0,0 +1,25 @@ +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 }], + }, + }, +]; diff --git a/examples/flat/package.json b/examples/flat/package.json new file mode 100644 index 000000000..0894d29f2 --- /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/exports-unused.ts b/examples/flat/src/exports-unused.ts new file mode 100644 index 000000000..af8061ec2 --- /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 000000000..af8061ec2 --- /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 000000000..643219ae4 --- /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 000000000..970d53cb8 --- /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 000000000..e100bfc98 --- /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 000000000..e3cec097f --- /dev/null +++ b/examples/legacy/.eslintrc.cjs @@ -0,0 +1,24 @@ +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 }], + }, +}; diff --git a/examples/legacy/package.json b/examples/legacy/package.json new file mode 100644 index 000000000..e3ca09488 --- /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/exports-unused.ts b/examples/legacy/src/exports-unused.ts new file mode 100644 index 000000000..af8061ec2 --- /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 000000000..af8061ec2 --- /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 000000000..643219ae4 --- /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 000000000..970d53cb8 --- /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 000000000..e100bfc98 --- /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/package.json b/package.json index b0ddaf816..f9a95fabb 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,9 @@ "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", + "test-example:legacy": "cd examples/legacy && npm install && npm run lint", + "test-example:flat": "cd examples/flat && npm install && npm run lint", "prepublishOnly": "safe-publish-latest && npm run build", "prepublish": "not-in-publish || npm run prepublishOnly", "preupdate:eslint-docs": "npm run build", diff --git a/src/core/fsWalk.js b/src/core/fsWalk.js new file mode 100644 index 000000000..fa112590f --- /dev/null +++ b/src/core/fsWalk.js @@ -0,0 +1,48 @@ +/** + * This is intended to provide similar capability as the sync api from @nodelib/fs.walk, until `eslint-plugin-import` + * is willing to modernize and update their minimum node version to at least v16. I intentionally made the + * shape of the API (for the part we're using) the same as @nodelib/fs.walk so that that can be swapped in + * when the repo is ready for it. + */ + +import path from 'path'; +import { readdirSync } from 'fs'; + +/** @typedef {{ name: string, path: string, dirent: import('fs').Dirent }} Entry */ + +/** + * Do a comprehensive walk of the provided src directory, and collect all entries. Filter out + * any directories or entries using the optional filter functions. + * @param {string} root - path to the root of the folder we're walking + * @param {{ deepFilter?: (entry: Entry) => boolean, entryFilter?: (entry: Entry) => boolean }} options + * @param {Entry} currentEntry - entry for the current directory we're working in + * @param {Entry[]} existingEntries - list of all entries so far + * @returns {Entry[]} an array of directory entries + */ +export function walkSync(root, options, currentEntry, existingEntries) { + // Extract the filter functions. Default to evaluating true, if no filter passed in. + const { deepFilter = () => true, entryFilter = () => true } = options; + + let entryList = existingEntries || []; + const currentRelativePath = currentEntry ? currentEntry.path : '.'; + const fullPath = currentEntry ? path.join(root, currentEntry.path) : root; + + const dirents = readdirSync(fullPath, { withFileTypes: true }); + dirents.forEach((dirent) => { + /** @type {Entry} */ + const entry = { + name: dirent.name, + path: path.join(currentRelativePath, dirent.name), + dirent, + }; + + if (dirent.isDirectory() && deepFilter(entry)) { + entryList.push(entry); + entryList = walkSync(root, options, entry, entryList); + } else if (dirent.isFile() && entryFilter(entry)) { + entryList.push(entry); + } + }); + + return entryList; +} diff --git a/src/index.js b/src/index.js index feafba900..0ab82ebee 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'), @@ -69,3 +71,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: require('../config/flat/react'), + 'react-native': configs['react-native'], + electron: configs.electron, + typescript: configs.typescript, +}; diff --git a/src/rules/no-unused-modules.js b/src/rules/no-unused-modules.js index 46fc93bfe..702f2f889 100644 --- a/src/rules/no-unused-modules.js +++ b/src/rules/no-unused-modules.js @@ -7,61 +7,177 @@ import { getFileExtensions } from 'eslint-module-utils/ignore'; import resolve from 'eslint-module-utils/resolve'; import visit from 'eslint-module-utils/visit'; -import { dirname, join } from 'path'; +import { dirname, join, resolve as resolvePath } from 'path'; import readPkgUp from 'eslint-module-utils/readPkgUp'; import values from 'object.values'; import includes from 'array-includes'; import flatMap from 'array.prototype.flatmap'; +import { walkSync } from '../core/fsWalk'; 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; +} + +/** + * + * @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) { + const e = new FileEnumerator({ + extensions, + }); + + return Array.from( + e.iterateFiles(src), + ({ filePath, ignored }) => ({ filename: filePath, ignored }), + ); +} - listFilesToProcess = function (src, extensions) { - const patterns = src.concat(flatMap(src, (pattern) => extensions.map((extension) => (/\*\*|\*\./).test(pattern) ? pattern : `${pattern}/**/*${extension}`))); +/** + * 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(patterns); - }; + return originalListFilesToProcess(src, { + extensions, + }); + } catch (e) { + // Absorb this if it's MODULE_NOT_FOUND + if (e.code !== 'MODULE_NOT_FOUND') { + throw e; } + + // 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); } } -if (FileEnumerator) { - listFilesToProcess = function (src, extensions) { - const e = new FileEnumerator({ - extensions, +/** + * Given a source root and list of supported extensions, use fsWalk and the + * new `eslint` `context.session` api to build the list of files we want to operate on + * @param {string[]} srcPaths array of source paths (for flat config this should just be a singular root (e.g. cwd)) + * @param {string[]} extensions list of supported extensions + * @param {{ isDirectoryIgnored: (path: string) => boolean, isFileIgnored: (path: string) => boolean }} session eslint context session object + * @returns {string[]} list of files to operate on + */ +function listFilesWithModernApi(srcPaths, extensions, session) { + /** @type {string[]} */ + const files = []; + + for (let i = 0; i < srcPaths.length; i++) { + const src = srcPaths[i]; + // Use walkSync along with the new session api to gather the list of files + const entries = walkSync(src, { + deepFilter(entry) { + const fullEntryPath = resolvePath(src, entry.path); + + // Include the directory if it's not marked as ignore by eslint + return !session.isDirectoryIgnored(fullEntryPath); + }, + entryFilter(entry) { + const fullEntryPath = resolvePath(src, entry.path); + + // Include the file if it's not marked as ignore by eslint and its extension is included in our list + return ( + !session.isFileIgnored(fullEntryPath) + && extensions.find((extension) => entry.path.endsWith(extension)) + ); + }, }); - return Array.from(e.iterateFiles(src), ({ filePath, ignored }) => ({ - ignored, - filename: filePath, - })); - }; + // Filter out directories and map entries to their paths + files.push( + ...entries + .filter((entry) => !entry.dirent.isDirectory()) + .map((entry) => entry.path), + ); + } + return files; +} + +/** + * 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 + * @param {import('eslint').Rule.RuleContext} context - the eslint context object + * @returns {string[] | { filename: string, ignored: boolean }[]} the list of files that this rule will evaluate. + */ +function listFilesToProcess(src, extensions, context) { + // If the context object has the new session functions, then prefer those + // Otherwise, fallback to using the deprecated `FileEnumerator` for legacy support. + // https://github.com/eslint/eslint/issues/18087 + if ( + context.session + && context.session.isFileIgnored + && context.session.isDirectoryIgnored + ) { + return listFilesWithModernApi(src, extensions, context.session); + } + + // Fallback to og FileEnumerator + 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'; @@ -163,6 +279,7 @@ const exportList = new Map(); const visitorKeyMap = new Map(); +/** @type {Set} */ const ignoredFiles = new Set(); const filesOutsideSrc = new Set(); @@ -172,22 +289,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); + const srcFileList = listFilesToProcess(src, extensions, context); // prepare list of ignored files - const ignoredFilesList = listFilesToProcess(ignoreExports, extensions); - ignoredFilesList.forEach(({ filename }) => ignoredFiles.add(filename)); + const ignoredFilesList = listFilesToProcess(ignoreExports, extensions, context); + + // 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 @@ -226,7 +351,7 @@ const prepareImportsAndExports = (srcFiles, context) => { } else { exports.set(key, { whereUsed: new Set() }); } - const reexport = value.getImport(); + const reexport = value.getImport(); if (!reexport) { return; } @@ -329,6 +454,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) => { diff --git a/utils/ignore.js b/utils/ignore.js index 56f2ef723..a42d4ceb1 100644 --- a/utils/ignore.js +++ b/utils/ignore.js @@ -14,7 +14,7 @@ const log = require('debug')('eslint-plugin-import:utils:ignore'); function makeValidExtensionSet(settings) { // start with explicit JS-parsed extensions /** @type {Set} */ - const exts = new Set(settings['import/extensions'] || ['.js']); + const exts = new Set(settings['import/extensions'] || ['.js', '.mjs', '.cjs']); // all alternate parser extensions are also valid if ('import/parsers' in settings) { @@ -52,9 +52,13 @@ 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++) {