Skip to content

Commit

Permalink
Group files by config (#622)
Browse files Browse the repository at this point in the history
Co-authored-by: Sindre Sorhus <sindresorhus@gmail.com>
  • Loading branch information
spence-s and sindresorhus committed Oct 27, 2021
1 parent c9bbfb1 commit 431887d
Show file tree
Hide file tree
Showing 8 changed files with 197 additions and 94 deletions.
102 changes: 59 additions & 43 deletions 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';
Expand All @@ -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;

Expand All @@ -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;
};
Expand Down
65 changes: 52 additions & 13 deletions lib/options-manager.js
Expand Up @@ -50,6 +50,7 @@ const DEFAULT_CONFIG = {
cache: true,
cacheLocation: path.join(cacheLocation(), 'xo-cache.json'),
globInputPaths: false,
resolvePluginsRelativeTo: __dirname,
baseConfig: {
extends: [
resolveLocalConfig('xo'),
Expand Down Expand Up @@ -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};
};

/**
Expand All @@ -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) => {
Expand Down Expand Up @@ -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,
};
};

Expand Down
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -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": [
Expand Down
7 changes: 7 additions & 0 deletions test/fixtures/typescript/parseroptions-project/package.json
@@ -0,0 +1,7 @@
{
"xo": {
"parserOptions": {
"project": "./projectconfig.json"
}
}
}
@@ -0,0 +1,3 @@
{
"extends": "./tsconfig.json"
}
3 changes: 3 additions & 0 deletions test/fixtures/typescript/parseroptions-project/tsconfig.json
@@ -0,0 +1,3 @@
{
"include": ["**/*.ts", "**/*.tsx"]
}
70 changes: 50 additions & 20 deletions test/lint-text.js
Expand Up @@ -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);
});

Expand Down

0 comments on commit 431887d

Please sign in to comment.