Skip to content

Commit 431887d

Browse files
Group files by config (#622)
Co-authored-by: Sindre Sorhus <[email protected]>
1 parent c9bbfb1 commit 431887d

File tree

8 files changed

+197
-94
lines changed

8 files changed

+197
-94
lines changed

index.js

Lines changed: 59 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
import {fileURLToPath} from 'node:url';
21
import path from 'node:path';
32
import {ESLint} from 'eslint';
43
import {globby, isGitIgnoredSync} from 'globby';
5-
import {isEqual} from 'lodash-es';
4+
import {isEqual, groupBy} from 'lodash-es';
65
import micromatch from 'micromatch';
76
import arrify from 'arrify';
87
import slash from 'slash';
@@ -13,33 +12,6 @@ import {
1312
} from './lib/options-manager.js';
1413
import {mergeReports, processReport, getIgnoredReport} from './lib/report.js';
1514

16-
const runEslint = async (lint, options) => {
17-
const {filePath, eslintOptions, isQuiet} = options;
18-
const {cwd, baseConfig: {ignorePatterns}} = eslintOptions;
19-
20-
if (
21-
filePath
22-
&& (
23-
micromatch.isMatch(path.relative(cwd, filePath), ignorePatterns)
24-
|| isGitIgnoredSync({cwd, ignore: ignorePatterns})(filePath)
25-
)
26-
) {
27-
return getIgnoredReport(filePath);
28-
}
29-
30-
const eslint = new ESLint({
31-
...eslintOptions,
32-
resolvePluginsRelativeTo: path.dirname(fileURLToPath(import.meta.url)),
33-
});
34-
35-
if (filePath && await eslint.isPathIgnored(filePath)) {
36-
return getIgnoredReport(filePath);
37-
}
38-
39-
const report = await lint(eslint);
40-
return processReport(report, {isQuiet});
41-
};
42-
4315
const globFiles = async (patterns, options) => {
4416
const {ignores, extensions, cwd} = (await mergeWithFileConfig(options)).options;
4517

@@ -63,32 +35,76 @@ const getConfig = async options => {
6335

6436
const lintText = async (string, options) => {
6537
options = await parseOptions(options);
66-
const {filePath, warnIgnored, eslintOptions} = options;
67-
const {ignorePatterns} = eslintOptions.baseConfig;
38+
const {filePath, warnIgnored, eslintOptions, isQuiet} = options;
39+
const {cwd, baseConfig: {ignorePatterns}} = eslintOptions;
6840

6941
if (typeof filePath !== 'string' && !isEqual(getIgnores({}), ignorePatterns)) {
7042
throw new Error('The `ignores` option requires the `filePath` option to be defined.');
7143
}
7244

73-
return runEslint(
74-
eslint => eslint.lintText(string, {filePath, warnIgnored}),
75-
options,
76-
);
77-
};
45+
if (
46+
filePath
47+
&& (
48+
micromatch.isMatch(path.relative(cwd, filePath), ignorePatterns)
49+
|| isGitIgnoredSync({cwd, ignore: ignorePatterns})(filePath)
50+
)
51+
) {
52+
return getIgnoredReport(filePath);
53+
}
7854

79-
const lintFile = async (filePath, options) => runEslint(
80-
eslint => eslint.lintFiles([filePath]),
81-
await parseOptions({...options, filePath}),
82-
);
55+
const eslint = new ESLint(eslintOptions);
56+
57+
if (filePath && await eslint.isPathIgnored(filePath)) {
58+
return getIgnoredReport(filePath);
59+
}
60+
61+
const report = await eslint.lintText(string, {filePath, warnIgnored});
62+
return processReport(report, {isQuiet});
63+
};
8364

8465
const lintFiles = async (patterns, options) => {
8566
const files = await globFiles(patterns, options);
8667

87-
const reports = await Promise.all(
88-
files.map(filePath => lintFile(filePath, options)),
68+
const allOptions = await Promise.all(
69+
files.map(filePath => parseOptions({...options, filePath})),
8970
);
9071

91-
const report = mergeReports(reports.filter(({isIgnored}) => !isIgnored));
72+
// Files with same `xoConfigPath` can lint together
73+
// https://github.com/xojs/xo/issues/599
74+
const groups = groupBy(allOptions, 'eslintConfigId');
75+
76+
const reports = await Promise.all(
77+
Object.values(groups)
78+
.map(async filesWithOptions => {
79+
const options = filesWithOptions[0];
80+
const eslint = new ESLint(options.eslintOptions);
81+
const files = [];
82+
83+
for (const options of filesWithOptions) {
84+
const {filePath, eslintOptions} = options;
85+
const {cwd, baseConfig: {ignorePatterns}} = eslintOptions;
86+
if (filePath
87+
&& (
88+
micromatch.isMatch(path.relative(cwd, filePath), ignorePatterns)
89+
|| isGitIgnoredSync({cwd, ignore: ignorePatterns})(filePath)
90+
)) {
91+
continue;
92+
}
93+
94+
// eslint-disable-next-line no-await-in-loop
95+
if ((await eslint.isPathIgnored(filePath))) {
96+
continue;
97+
}
98+
99+
files.push(filePath);
100+
}
101+
102+
const report = await eslint.lintFiles(files);
103+
104+
return processReport(report, {isQuiet: options.isQuiet});
105+
}));
106+
107+
const report = mergeReports(reports);
92108

93109
return report;
94110
};

lib/options-manager.js

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ const DEFAULT_CONFIG = {
5050
cache: true,
5151
cacheLocation: path.join(cacheLocation(), 'xo-cache.json'),
5252
globInputPaths: false,
53+
resolvePluginsRelativeTo: __dirname,
5354
baseConfig: {
5455
extends: [
5556
resolveLocalConfig('xo'),
@@ -115,24 +116,57 @@ const mergeWithFileConfig = async options => {
115116
options = mergeOptions(options, xoOptions, enginesOptions);
116117
options.cwd = xoConfigPath && path.dirname(xoConfigPath) !== options.cwd ? path.resolve(options.cwd, path.dirname(xoConfigPath)) : options.cwd;
117118

119+
// Very simple way to ensure eslint is ran minimal times across
120+
// all linted files, once for each unique configuration - xo config path + override hash + tsconfig path
121+
let eslintConfigId = xoConfigPath;
118122
if (options.filePath) {
119-
({options} = applyOverrides(options.filePath, options));
123+
const overrides = applyOverrides(options.filePath, options);
124+
options = overrides.options;
125+
126+
if (overrides.hash) {
127+
eslintConfigId += overrides.hash;
128+
}
120129
}
121130

122131
const prettierOptions = options.prettier ? await prettier.resolveConfig(searchPath, {editorconfig: true}) || {} : {};
123132

124133
if (options.filePath && isTypescript(options.filePath)) {
125-
const tsConfigExplorer = cosmiconfig([], {searchPlaces: ['tsconfig.json'], loaders: {'.json': (_, content) => JSON5.parse(content)}});
126-
const {config: tsConfig, filepath: tsConfigPath} = (await tsConfigExplorer.search(options.filePath)) || {};
134+
// We can skip looking up the tsconfig if we have it defined
135+
// in our parser options already. Otherwise we can look it up and create it as normal
136+
const {project: tsConfigProjectPath, tsconfigRootDir} = options.parserOptions || {};
137+
138+
let tsConfig;
139+
let tsConfigPath;
140+
if (tsConfigProjectPath) {
141+
tsConfigPath = path.resolve(options.cwd, tsConfigProjectPath);
142+
tsConfig = await json.load(tsConfigPath);
143+
} else {
144+
const tsConfigExplorer = cosmiconfig([], {
145+
searchPlaces: ['tsconfig.json'],
146+
loaders: {'.json': (_, content) => JSON5.parse(content)},
147+
stopDir: tsconfigRootDir,
148+
});
149+
const searchResults = (await tsConfigExplorer.search(options.filePath)) || {};
150+
tsConfigPath = searchResults.filepath;
151+
tsConfig = searchResults.config;
152+
}
153+
154+
if (tsConfigPath) {
155+
options.tsConfigPath = tsConfigPath;
156+
eslintConfigId += tsConfigPath;
157+
} else {
158+
const {path: tsConfigCachePath, hash: tsConfigHash} = await getTsConfigCachePath([eslintConfigId], tsConfigPath, options.cwd);
159+
eslintConfigId += tsConfigHash;
160+
options.tsConfigPath = tsConfigCachePath;
161+
const config = makeTSConfig(tsConfig, tsConfigPath, [options.filePath]);
162+
await fs.mkdir(path.dirname(options.tsConfigPath), {recursive: true});
163+
await fs.writeFile(options.tsConfigPath, JSON.stringify(config));
164+
}
127165

128-
options.tsConfigPath = await getTsConfigCachePath([options.filePath], options.tsConfigPath, options.cwd);
129166
options.ts = true;
130-
const config = makeTSConfig(tsConfig, tsConfigPath, [options.filePath]);
131-
await fs.mkdir(path.dirname(options.tsConfigPath), {recursive: true});
132-
await fs.writeFile(options.tsConfigPath, JSON.stringify(config));
133167
}
134168

135-
return {options, prettierOptions};
169+
return {options, prettierOptions, eslintConfigId};
136170
};
137171

138172
/**
@@ -141,10 +175,14 @@ Hashing based on https://github.com/eslint/eslint/blob/cf38d0d939b62f3670cdd59f0
141175
*/
142176
const getTsConfigCachePath = async (files, tsConfigPath, cwd) => {
143177
const {version} = await json.load('../package.json');
144-
return path.join(
145-
cacheLocation(cwd),
146-
`tsconfig.${murmur(`${version}_${nodeVersion}_${stringify({files: files.sort(), tsConfigPath})}`).result().toString(36)}.json`,
147-
);
178+
const tsConfigHash = murmur(`${version}_${nodeVersion}_${stringify({files: files.sort(), tsConfigPath})}`).result().toString(36);
179+
return {
180+
path: path.join(
181+
cacheLocation(cwd),
182+
`tsconfig.${tsConfigHash}.json`,
183+
),
184+
hash: tsConfigHash,
185+
};
148186
};
149187

150188
const makeTSConfig = (tsConfig, tsConfigPath, files) => {
@@ -538,13 +576,14 @@ const gatherImportResolvers = options => {
538576

539577
const parseOptions = async options => {
540578
options = normalizeOptions(options);
541-
const {options: foundOptions, prettierOptions} = await mergeWithFileConfig(options);
579+
const {options: foundOptions, prettierOptions, eslintConfigId} = await mergeWithFileConfig(options);
542580
const {filePath, warnIgnored, ...eslintOptions} = buildConfig(foundOptions, prettierOptions);
543581
return {
544582
filePath,
545583
warnIgnored,
546584
isQuiet: options.quiet,
547585
eslintOptions,
586+
eslintConfigId,
548587
};
549588
};
550589

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"node": ">=12.20"
1717
},
1818
"scripts": {
19+
"test:clean": "find ./test -type d -name 'node_modules' -prune -not -path ./test/fixtures/project/node_modules -exec rm -rf '{}' +",
1920
"test": "node cli.js && nyc ava"
2021
},
2122
"files": [
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"xo": {
3+
"parserOptions": {
4+
"project": "./projectconfig.json"
5+
}
6+
}
7+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"extends": "./tsconfig.json"
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"include": ["**/*.ts", "**/*.tsx"]
3+
}

test/lint-text.js

Lines changed: 50 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -277,36 +277,66 @@ test('find configurations close to linted file', async t => {
277277
t.true(hasRule(results, 'indent'));
278278
});
279279

280-
test('typescript files', async t => {
281-
let {results} = await xo.lintText(`console.log([
282-
2,
283-
]);
284-
`, {filePath: 'fixtures/typescript/two-spaces.tsx'});
285-
280+
test('typescript files: two spaces fails', async t => {
281+
const twoSpacesCwd = path.resolve('fixtures', 'typescript');
282+
const twoSpacesfilePath = path.resolve(twoSpacesCwd, 'two-spaces.tsx');
283+
const twoSpacesText = (await fs.readFile(twoSpacesfilePath)).toString();
284+
const {results} = await xo.lintText(twoSpacesText, {
285+
filePath: twoSpacesfilePath,
286+
});
286287
t.true(hasRule(results, '@typescript-eslint/indent'));
288+
});
287289

288-
({results} = await xo.lintText(`console.log([
289-
2,
290-
]);
291-
`, {filePath: 'fixtures/typescript/two-spaces.tsx', space: 2}));
290+
test('typescript files: two spaces pass', async t => {
291+
const twoSpacesCwd = path.resolve('fixtures', 'typescript');
292+
const twoSpacesfilePath = path.resolve(twoSpacesCwd, 'two-spaces.tsx');
293+
const twoSpacesText = (await fs.readFile(twoSpacesfilePath)).toString();
294+
const {results} = await xo.lintText(twoSpacesText, {
295+
filePath: twoSpacesfilePath,
296+
space: 2,
297+
});
292298
t.is(results[0].errorCount, 0);
299+
});
293300

294-
({results} = await xo.lintText('console.log(\'extra-semicolon\');;\n', {filePath: 'fixtures/typescript/child/extra-semicolon.ts'}));
301+
test('typescript files: extra semi fail', async t => {
302+
const extraSemiCwd = path.resolve('fixtures', 'typescript', 'child');
303+
const extraSemiFilePath = path.resolve(extraSemiCwd, 'extra-semicolon.ts');
304+
const extraSemiText = (await fs.readFile(extraSemiFilePath)).toString();
305+
const {results} = await xo.lintText(extraSemiText, {
306+
filePath: extraSemiFilePath,
307+
});
295308
t.true(hasRule(results, '@typescript-eslint/no-extra-semi'));
309+
});
296310

297-
({results} = await xo.lintText('console.log(\'no-semicolon\')\n', {filePath: 'fixtures/typescript/child/no-semicolon.ts', semicolon: false}));
311+
test('typescript files: extra semi pass', async t => {
312+
const noSemiCwd = path.resolve('fixtures', 'typescript', 'child');
313+
const noSemiFilePath = path.resolve(noSemiCwd, 'no-semicolon.ts');
314+
const noSemiText = (await fs.readFile(noSemiFilePath)).toString();
315+
const {results} = await xo.lintText(noSemiText, {
316+
filePath: noSemiFilePath,
317+
semicolon: false,
318+
});
298319
t.is(results[0].errorCount, 0);
320+
});
299321

300-
({results} = await xo.lintText(`console.log([
301-
4,
302-
]);
303-
`, {filePath: 'fixtures/typescript/child/sub-child/four-spaces.ts'}));
322+
test('typescript files: four space fail', async t => {
323+
const fourSpacesCwd = path.resolve('fixtures', 'typescript', 'child', 'sub-child');
324+
const fourSpacesFilePath = path.resolve(fourSpacesCwd, 'four-spaces.ts');
325+
const fourSpacesText = (await fs.readFile(fourSpacesFilePath)).toString();
326+
const {results} = await xo.lintText(fourSpacesText, {
327+
filePath: fourSpacesFilePath,
328+
});
304329
t.true(hasRule(results, '@typescript-eslint/indent'));
330+
});
305331

306-
({results} = await xo.lintText(`console.log([
307-
4,
308-
]);
309-
`, {filePath: 'fixtures/typescript/child/sub-child/four-spaces.ts', space: 4}));
332+
test('typescript files: four space pass', async t => {
333+
const fourSpacesCwd = path.resolve('fixtures', 'typescript', 'child', 'sub-child');
334+
const fourSpacesFilePath = path.resolve(fourSpacesCwd, 'four-spaces.ts');
335+
const fourSpacesText = (await fs.readFile(fourSpacesFilePath)).toString();
336+
const {results} = await xo.lintText(fourSpacesText, {
337+
filePath: fourSpacesFilePath,
338+
space: 4,
339+
});
310340
t.is(results[0].errorCount, 0);
311341
});
312342

0 commit comments

Comments
 (0)