diff --git a/index.js b/index.js index d13017e2..538f6b35 100644 --- a/index.js +++ b/index.js @@ -2,16 +2,21 @@ import process from 'node:process'; import path from 'node:path'; import {ESLint} from 'eslint'; import {globby, isGitIgnoredSync} from 'globby'; -import {omit, isEqual} from 'lodash-es'; +import {isEqual} from 'lodash-es'; import micromatch from 'micromatch'; import arrify from 'arrify'; +import pReduce from 'p-reduce'; import pMap from 'p-map'; +import {cosmiconfig, defaultLoaders} from 'cosmiconfig'; import defineLazyProperty from 'define-lazy-prop'; +import pFilter from 'p-filter'; import slash from 'slash'; +import {CONFIG_FILES, MODULE_NAME, DEFAULT_IGNORES} from './lib/constants.js'; import { normalizeOptions, getIgnores, mergeWithFileConfig, + mergeWithFileConfigs, buildConfig, mergeOptions, } from './lib/options-manager.js'; @@ -82,19 +87,10 @@ const processReport = (report, {isQuiet = false} = {}) => { return result; }; -const runEslint = async (filePath, options, processorOptions) => { - const engine = new ESLint(omit(options, ['filePath', 'warnIgnored'])); - const filename = path.relative(options.cwd, filePath); +const runEslint = async (paths, options, processorOptions) => { + const engine = new ESLint(options); - if ( - micromatch.isMatch(filename, options.baseConfig.ignorePatterns) - || isGitIgnoredSync({cwd: options.cwd, ignore: options.baseConfig.ignorePatterns})(filePath) - || await engine.isPathIgnored(filePath) - ) { - return; - } - - const report = await engine.lintFiles([filePath]); + const report = await engine.lintFiles(await pFilter(paths, async path => !(await engine.isPathIgnored(path)))); return processReport(report, processorOptions); }; @@ -149,24 +145,25 @@ const lintText = async (string, inputOptions = {}) => { }; const lintFiles = async (patterns, inputOptions = {}) => { - inputOptions = normalizeOptions(inputOptions); inputOptions.cwd = path.resolve(inputOptions.cwd || process.cwd()); - - const files = await globFiles(patterns, mergeOptions(inputOptions)); - - const reports = await pMap( - files, - async filePath => { - const {options: foundOptions, prettierOptions} = mergeWithFileConfig({ - ...inputOptions, - filePath, - }); - const options = buildConfig(foundOptions, prettierOptions); - return runEslint(filePath, options, {isQuiet: inputOptions.quiet}); - }, - ); - - return mergeReports(reports.filter(Boolean)); + const configExplorer = cosmiconfig(MODULE_NAME, {searchPlaces: CONFIG_FILES, loaders: {noExt: defaultLoaders['.json']}, stopDir: inputOptions.cwd}); + + const configFiles = (await Promise.all( + (await globby( + CONFIG_FILES.map(configFile => `**/${configFile}`), + {ignore: DEFAULT_IGNORES, gitignore: true, absolute: true, cwd: inputOptions.cwd}, + )).map(configFile => configExplorer.load(configFile)), + )).filter(Boolean); + + const paths = configFiles.length > 0 + ? await pReduce( + configFiles, + async (paths, {filepath, config}) => + [...paths, ...(await globFiles(patterns, {...mergeOptions(inputOptions, config), cwd: path.dirname(filepath)}))], + []) + : await globFiles(patterns, mergeOptions(inputOptions)); + + return mergeReports(await pMap(await mergeWithFileConfigs([...new Set(paths)], inputOptions, configFiles), async ({files, options, prettierOptions}) => runEslint(files, buildConfig(options, prettierOptions), {isQuiet: options.quiet}))); }; const getFormatter = async name => { diff --git a/lib/options-manager.js b/lib/options-manager.js index 150b3dcb..5996ef14 100644 --- a/lib/options-manager.js +++ b/lib/options-manager.js @@ -4,17 +4,19 @@ import os from 'node:os'; import path from 'node:path'; import fsExtra from 'fs-extra'; import arrify from 'arrify'; -import {mergeWith, flow, pick} from 'lodash-es'; +import {mergeWith, groupBy, flow, pick} from 'lodash-es'; import {findUpSync} from 'find-up'; import findCacheDir from 'find-cache-dir'; import prettier from 'prettier'; import semver from 'semver'; -import {cosmiconfigSync, defaultLoaders} from 'cosmiconfig'; +import {cosmiconfig, cosmiconfigSync, defaultLoaders} from 'cosmiconfig'; +import pReduce from 'p-reduce'; import micromatch from 'micromatch'; import JSON5 from 'json5'; import toAbsoluteGlob from 'to-absolute-glob'; import stringify from 'json-stable-stringify-without-jsonify'; import murmur from 'imurmurhash'; +import isPathInside from 'is-path-inside'; import {Legacy} from '@eslint/eslintrc'; import createEsmUtils from 'esm-utils'; import { @@ -31,7 +33,7 @@ import { const {__dirname, json, require} = createEsmUtils(import.meta); const pkg = json.loadSync('../package.json'); -const {outputJsonSync} = fsExtra; +const {outputJson, outputJsonSync} = fsExtra; const {normalizePackageName} = Legacy.naming; const resolveModule = Legacy.ModuleResolver.resolve; @@ -136,6 +138,69 @@ const mergeWithFileConfig = options => { return {options, prettierOptions}; }; +/** +Find config for each files found by `lintFiles`. +The config files are searched starting from each files. +*/ +const mergeWithFileConfigs = async (files, options, configFiles) => { + configFiles = configFiles.sort((a, b) => b.filepath.split(path.sep).length - a.filepath.split(path.sep).length); + const tsConfigs = {}; + + const groups = [...(await pReduce(files, async (configs, file) => { + const pkgConfigExplorer = cosmiconfig('engines', {searchPlaces: ['package.json'], stopDir: options.cwd}); + + const {config: xoOptions, filepath: xoConfigPath} = findApplicableConfig(file, configFiles) || {}; + const {config: enginesOptions, filepath: enginesConfigPath} = await pkgConfigExplorer.search(file) || {}; + + let fileOptions = mergeOptions(options, xoOptions, enginesOptions); + fileOptions.cwd = xoConfigPath && path.dirname(xoConfigPath) !== fileOptions.cwd ? path.resolve(fileOptions.cwd, path.dirname(xoConfigPath)) : fileOptions.cwd; + + const {hash, options: optionsWithOverrides} = applyOverrides(file, fileOptions); + fileOptions = optionsWithOverrides; + + const prettierOptions = fileOptions.prettier ? await prettier.resolveConfig(file, {editorconfig: true}) || {} : {}; + + let tsConfigPath; + if (isTypescript(file)) { + let tsConfig; + const tsConfigExplorer = cosmiconfig([], {searchPlaces: ['tsconfig.json'], loaders: {'.json': (_, content) => JSON5.parse(content)}}); + ({config: tsConfig, filepath: tsConfigPath} = await tsConfigExplorer.search(file) || {}); + + fileOptions.tsConfigPath = tsConfigPath; + tsConfigs[tsConfigPath || ''] = tsConfig; + fileOptions.ts = true; + } + + const cacheKey = stringify({xoConfigPath, enginesConfigPath, prettierOptions, hash, tsConfigPath: fileOptions.tsConfigPath, ts: fileOptions.ts}); + const cachedGroup = configs.get(cacheKey); + + configs.set(cacheKey, { + files: [file, ...(cachedGroup ? cachedGroup.files : [])], + options: cachedGroup ? cachedGroup.options : fileOptions, + prettierOptions, + }); + + return configs; + }, new Map())).values()]; + + await Promise.all(Object.entries(groupBy(groups.filter(({options}) => Boolean(options.ts)), group => group.options.tsConfigPath || '')).map( + ([tsConfigPath, groups]) => { + const files = groups.flatMap(group => group.files); + const cachePath = getTsConfigCachePath(files, tsConfigPath, options.cwd); + + for (const group of groups) { + group.options.tsConfigPath = cachePath; + } + + return outputJson(cachePath, makeTSConfig(tsConfigs[tsConfigPath], tsConfigPath, files)); + }, + )); + + return groups; +}; + +const findApplicableConfig = (file, configFiles) => configFiles.find(({filepath}) => isPathInside(file, path.dirname(filepath))); + /** Generate a unique and consistent path for the temporary `tsconfig.json`. Hashing based on https://github.com/eslint/eslint/blob/cf38d0d939b62f3670cdd59f0143fd896fccd771/lib/cli-engine/lint-result-cache.js#L30 @@ -539,6 +604,7 @@ export { mergeWithPrettierConfig, normalizeOptions, getIgnores, + mergeWithFileConfigs, mergeWithFileConfig, buildConfig, applyOverrides, diff --git a/package.json b/package.json index 0b9a712e..3cf6f1a5 100644 --- a/package.json +++ b/package.json @@ -79,14 +79,16 @@ "get-stdin": "^9.0.0", "globby": "^12.0.2", "imurmurhash": "^0.1.4", + "is-path-inside": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "json5": "^2.2.0", "lodash-es": "^4.17.21", "meow": "^10.1.1", "micromatch": "^4.0.4", "open-editor": "^3.0.0", + "p-filter": "^2.1.0", "p-map": "^5.1.0", - "path-exists": "^4.0.0", + "p-reduce": "^3.0.0", "prettier": "^2.4.1", "semver": "^7.3.5", "slash": "^4.0.0", diff --git a/test/options-manager.js b/test/options-manager.js index 137c7553..e8a93574 100644 --- a/test/options-manager.js +++ b/test/options-manager.js @@ -587,6 +587,159 @@ test('mergeWithFileConfig: tsx files', async t => { }); }); +test('mergeWithFileConfigs: nested configs with prettier', async t => { + const cwd = path.resolve('fixtures', 'nested-configs'); + const paths = [ + 'no-semicolon.js', + 'child/semicolon.js', + 'child-override/two-spaces.js', + 'child-override/child-prettier-override/semicolon.js', + ].map(file => path.resolve(cwd, file)); + const result = await manager.mergeWithFileConfigs(paths, {cwd}, [ + { + filepath: path.resolve(cwd, 'child-override', 'child-prettier-override', 'package.json'), + config: {overrides: [{files: 'semicolon.js', prettier: true}]}, + }, + {filepath: path.resolve(cwd, 'package.json'), config: {semicolon: true}}, + { + filepath: path.resolve(cwd, 'child-override', 'package.json'), + config: {overrides: [{files: 'two-spaces.js', space: 4}]}, + }, + {filepath: path.resolve(cwd, 'child', 'package.json'), config: {semicolon: false}}, + ]); + + t.deepEqual(result, [ + { + files: [path.resolve(cwd, 'no-semicolon.js')], + options: { + semicolon: true, + cwd, + extensions: DEFAULT_EXTENSION, + ignores: DEFAULT_IGNORES, + }, + prettierOptions: {}, + }, + { + files: [path.resolve(cwd, 'child/semicolon.js')], + options: { + semicolon: false, + cwd: path.resolve(cwd, 'child'), + extensions: DEFAULT_EXTENSION, + ignores: DEFAULT_IGNORES, + }, + prettierOptions: {}, + }, + { + files: [path.resolve(cwd, 'child-override/two-spaces.js')], + options: { + space: 4, + rules: {}, + settings: {}, + globals: [], + envs: [], + plugins: [], + extends: [], + cwd: path.resolve(cwd, 'child-override'), + extensions: DEFAULT_EXTENSION, + ignores: DEFAULT_IGNORES, + }, + prettierOptions: {}, + }, + { + files: [path.resolve(cwd, 'child-override/child-prettier-override/semicolon.js')], + options: { + prettier: true, + rules: {}, + settings: {}, + globals: [], + envs: [], + plugins: [], + extends: [], + cwd: path.resolve(cwd, 'child-override', 'child-prettier-override'), + extensions: DEFAULT_EXTENSION, + ignores: DEFAULT_IGNORES, + }, + prettierOptions: {endOfLine: 'lf', semi: false, useTabs: true}, + }, + ]); +}); + +test('mergeWithFileConfigs: typescript files', async t => { + const cwd = path.resolve('fixtures', 'typescript'); + const paths = ['two-spaces.tsx', 'child/extra-semicolon.ts', 'child/sub-child/four-spaces.ts'].map(file => path.resolve(cwd, file)); + const configFiles = [ + {filepath: path.resolve(cwd, 'child/sub-child/package.json'), config: {space: 2}}, + {filepath: path.resolve(cwd, 'package.json'), config: {space: 4}}, + {filepath: path.resolve(cwd, 'child/package.json'), config: {semicolon: false}}, + ]; + const result = await manager.mergeWithFileConfigs(paths, {cwd}, configFiles); + + t.deepEqual(omit(result[0], 'options.tsConfigPath'), { + files: [path.resolve(cwd, 'two-spaces.tsx')], + options: { + space: 4, + cwd, + extensions: DEFAULT_EXTENSION, + ignores: DEFAULT_IGNORES, + ts: true, + }, + prettierOptions: {}, + }); + t.deepEqual(await readJson(result[0].options.tsConfigPath), { + files: [path.resolve(cwd, 'two-spaces.tsx')], + compilerOptions: { + newLine: 'lf', + noFallthroughCasesInSwitch: true, + noImplicitReturns: true, + noUnusedLocals: true, + noUnusedParameters: true, + strict: true, + target: 'es2018', + }, + }); + + t.deepEqual(omit(result[1], 'options.tsConfigPath'), { + files: [path.resolve(cwd, 'child/extra-semicolon.ts')], + options: { + semicolon: false, + cwd: path.resolve(cwd, 'child'), + extensions: DEFAULT_EXTENSION, + ignores: DEFAULT_IGNORES, + ts: true, + }, + prettierOptions: {}, + }); + + t.deepEqual(omit(result[2], 'options.tsConfigPath'), { + files: [path.resolve(cwd, 'child/sub-child/four-spaces.ts')], + options: { + space: 2, + cwd: path.resolve(cwd, 'child/sub-child'), + extensions: DEFAULT_EXTENSION, + ignores: DEFAULT_IGNORES, + ts: true, + }, + prettierOptions: {}, + }); + + // Verify that we use the same temporary tsconfig.json for both files group sharing the same original tsconfig.json even if they have different xo config + t.is(result[1].options.tsConfigPath, result[2].options.tsConfigPath); + t.deepEqual(await readJson(result[1].options.tsConfigPath), { + extends: path.resolve(cwd, 'child/tsconfig.json'), + files: [path.resolve(cwd, 'child/extra-semicolon.ts'), path.resolve(cwd, 'child/sub-child/four-spaces.ts')], + include: [ + slash(path.resolve(cwd, 'child/**/*.ts')), + slash(path.resolve(cwd, 'child/**/*.tsx')), + ], + }); + + const secondResult = await manager.mergeWithFileConfigs(paths, {cwd}, configFiles); + + // Verify that on each run the options.tsConfigPath is consistent to preserve ESLint cache + t.is(result[0].options.tsConfigPath, secondResult[0].options.tsConfigPath); + t.is(result[1].options.tsConfigPath, secondResult[1].options.tsConfigPath); +}); + test('applyOverrides', t => { t.deepEqual( manager.applyOverrides(