Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Group files by config #622

Merged
merged 9 commits into from Oct 27, 2021
Merged
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