diff --git a/index.js b/index.js index 44dab75a..233a337e 100644 --- a/index.js +++ b/index.js @@ -1,8 +1,7 @@ -import {fileURLToPath} from 'node:url'; import path from 'node:path'; import {ESLint} from 'eslint'; import {globby, isGitIgnoredSync} from 'globby'; -import {isEqual} from 'lodash-es'; +import {isEqual, groupBy} from 'lodash-es'; import micromatch from 'micromatch'; import arrify from 'arrify'; import slash from 'slash'; @@ -13,33 +12,6 @@ import { } from './lib/options-manager.js'; import {mergeReports, processReport, getIgnoredReport} from './lib/report.js'; -const runEslint = async (lint, options) => { - const {filePath, eslintOptions, isQuiet} = options; - const {cwd, baseConfig: {ignorePatterns}} = eslintOptions; - - if ( - filePath - && ( - micromatch.isMatch(path.relative(cwd, filePath), ignorePatterns) - || isGitIgnoredSync({cwd, ignore: ignorePatterns})(filePath) - ) - ) { - return getIgnoredReport(filePath); - } - - const eslint = new ESLint({ - ...eslintOptions, - resolvePluginsRelativeTo: path.dirname(fileURLToPath(import.meta.url)), - }); - - if (filePath && await eslint.isPathIgnored(filePath)) { - return getIgnoredReport(filePath); - } - - const report = await lint(eslint); - return processReport(report, {isQuiet}); -}; - const globFiles = async (patterns, options) => { const {ignores, extensions, cwd} = (await mergeWithFileConfig(options)).options; @@ -63,32 +35,76 @@ const getConfig = async options => { const lintText = async (string, options) => { options = await parseOptions(options); - const {filePath, warnIgnored, eslintOptions} = options; - const {ignorePatterns} = eslintOptions.baseConfig; + const {filePath, warnIgnored, eslintOptions, isQuiet} = options; + const {cwd, baseConfig: {ignorePatterns}} = eslintOptions; if (typeof filePath !== 'string' && !isEqual(getIgnores({}), ignorePatterns)) { throw new Error('The `ignores` option requires the `filePath` option to be defined.'); } - return runEslint( - eslint => eslint.lintText(string, {filePath, warnIgnored}), - options, - ); -}; + if ( + filePath + && ( + micromatch.isMatch(path.relative(cwd, filePath), ignorePatterns) + || isGitIgnoredSync({cwd, ignore: ignorePatterns})(filePath) + ) + ) { + return getIgnoredReport(filePath); + } -const lintFile = async (filePath, options) => runEslint( - eslint => eslint.lintFiles([filePath]), - await parseOptions({...options, filePath}), -); + const eslint = new ESLint(eslintOptions); + + if (filePath && await eslint.isPathIgnored(filePath)) { + return getIgnoredReport(filePath); + } + + const report = await eslint.lintText(string, {filePath, warnIgnored}); + return processReport(report, {isQuiet}); +}; const lintFiles = async (patterns, options) => { const files = await globFiles(patterns, options); - const reports = await Promise.all( - files.map(filePath => lintFile(filePath, options)), + const allOptions = await Promise.all( + files.map(filePath => parseOptions({...options, filePath})), ); - const report = mergeReports(reports.filter(({isIgnored}) => !isIgnored)); + // Files with same `xoConfigPath` can lint together + // https://github.com/xojs/xo/issues/599 + const groups = groupBy(allOptions, 'eslintConfigId'); + + const reports = await Promise.all( + Object.values(groups) + .map(async filesWithOptions => { + const options = filesWithOptions[0]; + const eslint = new ESLint(options.eslintOptions); + const files = []; + + for (const options of filesWithOptions) { + const {filePath, eslintOptions} = options; + const {cwd, baseConfig: {ignorePatterns}} = eslintOptions; + if (filePath + && ( + micromatch.isMatch(path.relative(cwd, filePath), ignorePatterns) + || isGitIgnoredSync({cwd, ignore: ignorePatterns})(filePath) + )) { + continue; + } + + // eslint-disable-next-line no-await-in-loop + if ((await eslint.isPathIgnored(filePath))) { + continue; + } + + files.push(filePath); + } + + const report = await eslint.lintFiles(files); + + return processReport(report, {isQuiet: options.isQuiet}); + })); + + const report = mergeReports(reports); return report; }; diff --git a/lib/options-manager.js b/lib/options-manager.js index 61aa757c..7ebf3597 100644 --- a/lib/options-manager.js +++ b/lib/options-manager.js @@ -50,6 +50,7 @@ const DEFAULT_CONFIG = { cache: true, cacheLocation: path.join(cacheLocation(), 'xo-cache.json'), globInputPaths: false, + resolvePluginsRelativeTo: __dirname, baseConfig: { extends: [ resolveLocalConfig('xo'), @@ -115,24 +116,57 @@ const mergeWithFileConfig = async options => { options = mergeOptions(options, xoOptions, enginesOptions); options.cwd = xoConfigPath && path.dirname(xoConfigPath) !== options.cwd ? path.resolve(options.cwd, path.dirname(xoConfigPath)) : options.cwd; + // Very simple way to ensure eslint is ran minimal times across + // all linted files, once for each unique configuration - xo config path + override hash + tsconfig path + let eslintConfigId = xoConfigPath; if (options.filePath) { - ({options} = applyOverrides(options.filePath, options)); + const overrides = applyOverrides(options.filePath, options); + options = overrides.options; + + if (overrides.hash) { + eslintConfigId += overrides.hash; + } } const prettierOptions = options.prettier ? await prettier.resolveConfig(searchPath, {editorconfig: true}) || {} : {}; if (options.filePath && isTypescript(options.filePath)) { - const tsConfigExplorer = cosmiconfig([], {searchPlaces: ['tsconfig.json'], loaders: {'.json': (_, content) => JSON5.parse(content)}}); - const {config: tsConfig, filepath: tsConfigPath} = (await tsConfigExplorer.search(options.filePath)) || {}; + // We can skip looking up the tsconfig if we have it defined + // in our parser options already. Otherwise we can look it up and create it as normal + const {project: tsConfigProjectPath, tsconfigRootDir} = options.parserOptions || {}; + + let tsConfig; + let tsConfigPath; + if (tsConfigProjectPath) { + tsConfigPath = path.resolve(options.cwd, tsConfigProjectPath); + tsConfig = await json.load(tsConfigPath); + } else { + const tsConfigExplorer = cosmiconfig([], { + searchPlaces: ['tsconfig.json'], + loaders: {'.json': (_, content) => JSON5.parse(content)}, + stopDir: tsconfigRootDir, + }); + const searchResults = (await tsConfigExplorer.search(options.filePath)) || {}; + tsConfigPath = searchResults.filepath; + tsConfig = searchResults.config; + } + + if (tsConfigPath) { + options.tsConfigPath = tsConfigPath; + eslintConfigId += tsConfigPath; + } else { + const {path: tsConfigCachePath, hash: tsConfigHash} = await getTsConfigCachePath([eslintConfigId], tsConfigPath, options.cwd); + eslintConfigId += tsConfigHash; + options.tsConfigPath = tsConfigCachePath; + const config = makeTSConfig(tsConfig, tsConfigPath, [options.filePath]); + await fs.mkdir(path.dirname(options.tsConfigPath), {recursive: true}); + await fs.writeFile(options.tsConfigPath, JSON.stringify(config)); + } - options.tsConfigPath = await getTsConfigCachePath([options.filePath], options.tsConfigPath, options.cwd); options.ts = true; - const config = makeTSConfig(tsConfig, tsConfigPath, [options.filePath]); - await fs.mkdir(path.dirname(options.tsConfigPath), {recursive: true}); - await fs.writeFile(options.tsConfigPath, JSON.stringify(config)); } - return {options, prettierOptions}; + return {options, prettierOptions, eslintConfigId}; }; /** @@ -141,10 +175,14 @@ Hashing based on https://github.com/eslint/eslint/blob/cf38d0d939b62f3670cdd59f0 */ const getTsConfigCachePath = async (files, tsConfigPath, cwd) => { const {version} = await json.load('../package.json'); - return path.join( - cacheLocation(cwd), - `tsconfig.${murmur(`${version}_${nodeVersion}_${stringify({files: files.sort(), tsConfigPath})}`).result().toString(36)}.json`, - ); + const tsConfigHash = murmur(`${version}_${nodeVersion}_${stringify({files: files.sort(), tsConfigPath})}`).result().toString(36); + return { + path: path.join( + cacheLocation(cwd), + `tsconfig.${tsConfigHash}.json`, + ), + hash: tsConfigHash, + }; }; const makeTSConfig = (tsConfig, tsConfigPath, files) => { @@ -538,13 +576,14 @@ const gatherImportResolvers = options => { const parseOptions = async options => { options = normalizeOptions(options); - const {options: foundOptions, prettierOptions} = await mergeWithFileConfig(options); + const {options: foundOptions, prettierOptions, eslintConfigId} = await mergeWithFileConfig(options); const {filePath, warnIgnored, ...eslintOptions} = buildConfig(foundOptions, prettierOptions); return { filePath, warnIgnored, isQuiet: options.quiet, eslintOptions, + eslintConfigId, }; }; diff --git a/package.json b/package.json index 4c4bd75d..c6989c2f 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "node": ">=12.20" }, "scripts": { + "test:clean": "find ./test -type d -name 'node_modules' -prune -not -path ./test/fixtures/project/node_modules -exec rm -rf '{}' +", "test": "node cli.js && nyc ava" }, "files": [ diff --git a/test/fixtures/typescript/parseroptions-project/package.json b/test/fixtures/typescript/parseroptions-project/package.json new file mode 100644 index 00000000..174a84ca --- /dev/null +++ b/test/fixtures/typescript/parseroptions-project/package.json @@ -0,0 +1,7 @@ +{ + "xo": { + "parserOptions": { + "project": "./projectconfig.json" + } + } +} diff --git a/test/fixtures/typescript/parseroptions-project/projectconfig.json b/test/fixtures/typescript/parseroptions-project/projectconfig.json new file mode 100644 index 00000000..ea6be8e9 --- /dev/null +++ b/test/fixtures/typescript/parseroptions-project/projectconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.json" +} diff --git a/test/fixtures/typescript/parseroptions-project/tsconfig.json b/test/fixtures/typescript/parseroptions-project/tsconfig.json new file mode 100644 index 00000000..10671361 --- /dev/null +++ b/test/fixtures/typescript/parseroptions-project/tsconfig.json @@ -0,0 +1,3 @@ +{ + "include": ["**/*.ts", "**/*.tsx"] +} diff --git a/test/lint-text.js b/test/lint-text.js index 18a7d90b..12ddaf74 100644 --- a/test/lint-text.js +++ b/test/lint-text.js @@ -277,36 +277,66 @@ test('find configurations close to linted file', async t => { t.true(hasRule(results, 'indent')); }); -test('typescript files', async t => { - let {results} = await xo.lintText(`console.log([ - 2, -]); -`, {filePath: 'fixtures/typescript/two-spaces.tsx'}); - +test('typescript files: two spaces fails', async t => { + const twoSpacesCwd = path.resolve('fixtures', 'typescript'); + const twoSpacesfilePath = path.resolve(twoSpacesCwd, 'two-spaces.tsx'); + const twoSpacesText = (await fs.readFile(twoSpacesfilePath)).toString(); + const {results} = await xo.lintText(twoSpacesText, { + filePath: twoSpacesfilePath, + }); t.true(hasRule(results, '@typescript-eslint/indent')); +}); - ({results} = await xo.lintText(`console.log([ - 2, -]); -`, {filePath: 'fixtures/typescript/two-spaces.tsx', space: 2})); +test('typescript files: two spaces pass', async t => { + const twoSpacesCwd = path.resolve('fixtures', 'typescript'); + const twoSpacesfilePath = path.resolve(twoSpacesCwd, 'two-spaces.tsx'); + const twoSpacesText = (await fs.readFile(twoSpacesfilePath)).toString(); + const {results} = await xo.lintText(twoSpacesText, { + filePath: twoSpacesfilePath, + space: 2, + }); t.is(results[0].errorCount, 0); +}); - ({results} = await xo.lintText('console.log(\'extra-semicolon\');;\n', {filePath: 'fixtures/typescript/child/extra-semicolon.ts'})); +test('typescript files: extra semi fail', async t => { + const extraSemiCwd = path.resolve('fixtures', 'typescript', 'child'); + const extraSemiFilePath = path.resolve(extraSemiCwd, 'extra-semicolon.ts'); + const extraSemiText = (await fs.readFile(extraSemiFilePath)).toString(); + const {results} = await xo.lintText(extraSemiText, { + filePath: extraSemiFilePath, + }); t.true(hasRule(results, '@typescript-eslint/no-extra-semi')); +}); - ({results} = await xo.lintText('console.log(\'no-semicolon\')\n', {filePath: 'fixtures/typescript/child/no-semicolon.ts', semicolon: false})); +test('typescript files: extra semi pass', async t => { + const noSemiCwd = path.resolve('fixtures', 'typescript', 'child'); + const noSemiFilePath = path.resolve(noSemiCwd, 'no-semicolon.ts'); + const noSemiText = (await fs.readFile(noSemiFilePath)).toString(); + const {results} = await xo.lintText(noSemiText, { + filePath: noSemiFilePath, + semicolon: false, + }); t.is(results[0].errorCount, 0); +}); - ({results} = await xo.lintText(`console.log([ - 4, -]); -`, {filePath: 'fixtures/typescript/child/sub-child/four-spaces.ts'})); +test('typescript files: four space fail', async t => { + const fourSpacesCwd = path.resolve('fixtures', 'typescript', 'child', 'sub-child'); + const fourSpacesFilePath = path.resolve(fourSpacesCwd, 'four-spaces.ts'); + const fourSpacesText = (await fs.readFile(fourSpacesFilePath)).toString(); + const {results} = await xo.lintText(fourSpacesText, { + filePath: fourSpacesFilePath, + }); t.true(hasRule(results, '@typescript-eslint/indent')); +}); - ({results} = await xo.lintText(`console.log([ - 4, -]); -`, {filePath: 'fixtures/typescript/child/sub-child/four-spaces.ts', space: 4})); +test('typescript files: four space pass', async t => { + const fourSpacesCwd = path.resolve('fixtures', 'typescript', 'child', 'sub-child'); + const fourSpacesFilePath = path.resolve(fourSpacesCwd, 'four-spaces.ts'); + const fourSpacesText = (await fs.readFile(fourSpacesFilePath)).toString(); + const {results} = await xo.lintText(fourSpacesText, { + filePath: fourSpacesFilePath, + space: 4, + }); t.is(results[0].errorCount, 0); }); diff --git a/test/options-manager.js b/test/options-manager.js index 9bc26bab..6988e98d 100644 --- a/test/options-manager.js +++ b/test/options-manager.js @@ -1,8 +1,6 @@ -import {promises as fs} from 'node:fs'; import process from 'node:process'; import path from 'node:path'; import test from 'ava'; -import {omit} from 'lodash-es'; import slash from 'slash'; import createEsmUtils from 'esm-utils'; import {DEFAULT_EXTENSION, DEFAULT_IGNORES} from '../lib/constants.js'; @@ -542,9 +540,10 @@ test('mergeWithFileConfig: XO engine options false supersede package.json\'s', a t.deepEqual(options, expected); }); -test('mergeWithFileConfig: typescript files', async t => { +test('mergeWithFileConfig: resolves expected typescript file options', async t => { const cwd = path.resolve('fixtures', 'typescript', 'child'); const filePath = path.resolve(cwd, 'file.ts'); + const tsConfigPath = path.resolve(cwd, 'tsconfig.json'); const {options} = await manager.mergeWithFileConfig({cwd, filePath}); const expected = { filePath, @@ -553,21 +552,16 @@ test('mergeWithFileConfig: typescript files', async t => { cwd, semicolon: false, ts: true, + tsConfigPath, }; - const expectedConfigPath = new RegExp(`${slash(cwd)}/node_modules/.cache/xo-linter/tsconfig\\..*\\.json[\\/]?$`, 'u'); - t.regex(slash(options.tsConfigPath), expectedConfigPath); - t.deepEqual(omit(options, 'tsConfigPath'), expected); - t.deepEqual(JSON.parse(await fs.readFile(options.tsConfigPath)), { - extends: path.resolve(cwd, 'tsconfig.json'), - files: [path.resolve(cwd, 'file.ts')], - include: [slash(path.resolve(cwd, '**/*.ts')), slash(path.resolve(cwd, '**/*.tsx'))], - }); + t.deepEqual(options, expected); }); -test('mergeWithFileConfig: tsx files', async t => { +test('mergeWithFileConfig: resolves expected tsx file options', async t => { const cwd = path.resolve('fixtures', 'typescript', 'child'); const filePath = path.resolve(cwd, 'file.tsx'); const {options} = await manager.mergeWithFileConfig({cwd, filePath}); + const tsConfigPath = path.resolve(cwd, 'tsconfig.json'); const expected = { filePath, extensions: DEFAULT_EXTENSION, @@ -575,15 +569,25 @@ test('mergeWithFileConfig: tsx files', async t => { cwd, semicolon: false, ts: true, + tsConfigPath, }; + t.deepEqual(options, expected); +}); + +test('mergeWithFileConfig: uses specified parserOptions.project as tsconfig', async t => { + const cwd = path.resolve('fixtures', 'typescript', 'parseroptions-project'); + const filePath = path.resolve(cwd, 'does-not-matter.ts'); + const expectedTsConfigPath = path.resolve(cwd, 'projectconfig.json'); + const {options} = await manager.mergeWithFileConfig({cwd, filePath}); + t.is(options.tsConfigPath, expectedTsConfigPath); +}); + +test('mergeWithFileConfig: creates temp tsconfig if none present', async t => { + const cwd = path.resolve('fixtures', 'typescript'); const expectedConfigPath = new RegExp(`${slash(cwd)}/node_modules/.cache/xo-linter/tsconfig\\..*\\.json[\\/]?$`, 'u'); + const filePath = path.resolve(cwd, 'does-not-matter.ts'); + const {options} = await manager.mergeWithFileConfig({cwd, filePath}); t.regex(slash(options.tsConfigPath), expectedConfigPath); - t.deepEqual(omit(options, 'tsConfigPath'), expected); - t.deepEqual(JSON.parse(await fs.readFile(options.tsConfigPath)), { - extends: path.resolve(cwd, 'tsconfig.json'), - files: [path.resolve(cwd, 'file.tsx')], - include: [slash(path.resolve(cwd, '**/*.ts')), slash(path.resolve(cwd, '**/*.tsx'))], - }); }); test('applyOverrides', t => {