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

Fix handling of tsconfig's #632

Merged
merged 3 commits into from Nov 3, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
15 changes: 5 additions & 10 deletions index.js
@@ -1,14 +1,15 @@
import path from 'node:path';
import {ESLint} from 'eslint';
import {globby, isGitIgnoredSync} from 'globby';
import {isEqual, groupBy} from 'lodash-es';
import {isEqual} from 'lodash-es';
import micromatch from 'micromatch';
import arrify from 'arrify';
import slash from 'slash';
import {
parseOptions,
getIgnores,
mergeWithFileConfig,
getOptionGroups,
} from './lib/options-manager.js';
import {mergeReports, processReport, getIgnoredReport} from './lib/report.js';

Expand All @@ -34,8 +35,8 @@ const getConfig = async options => {
};

const lintText = async (string, options) => {
options = await parseOptions(options);
const {filePath, warnIgnored, eslintOptions, isQuiet} = options;
const [[options_]] = Object.values(await getOptionGroups([options && options.filePath], options));
const {filePath, warnIgnored, eslintOptions, isQuiet} = options_;
const {cwd, baseConfig: {ignorePatterns}} = eslintOptions;

if (typeof filePath !== 'string' && !isEqual(getIgnores({}), ignorePatterns)) {
Expand Down Expand Up @@ -65,13 +66,7 @@ const lintText = async (string, options) => {
const lintFiles = async (patterns, options) => {
const files = await globFiles(patterns, options);

const allOptions = await Promise.all(
files.map(filePath => parseOptions({...options, filePath})),
);

// Files with same `xoConfigPath` can lint together
// https://github.com/xojs/xo/issues/599
const groups = groupBy(allOptions, 'eslintConfigId');
const groups = await getOptionGroups(files, options);

const reports = await Promise.all(
Object.values(groups)
Expand Down
194 changes: 108 additions & 86 deletions lib/options-manager.js
Expand Up @@ -11,11 +11,10 @@ import semver from 'semver';
import {cosmiconfig, defaultLoaders} from 'cosmiconfig';
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 {Legacy} from '@eslint/eslintrc';
import createEsmUtils from 'esm-utils';
import MurmurHash3 from 'imurmurhash';
import {
DEFAULT_IGNORES,
DEFAULT_EXTENSION,
Expand All @@ -42,7 +41,6 @@ resolveFrom.silent = (moduleId, fromDirectory) => {

const resolveLocalConfig = name => resolveModule(normalizePackageName(name, 'eslint-config'), import.meta.url);

const nodeVersion = process && process.version;
const cacheLocation = cwd => findCacheDir({name: CACHE_DIR_NAME, cwd}) || path.join(os.homedir() || os.tmpdir(), '.xo-cache/');

const DEFAULT_CONFIG = {
Expand Down Expand Up @@ -109,93 +107,92 @@ const mergeWithFileConfig = async options => {
}

const searchPath = options.filePath || options.cwd;

const {config: xoOptions, filepath: xoConfigPath} = (await configExplorer.search(searchPath)) || {};
const {config: enginesOptions} = (await pkgConfigExplorer.search(searchPath)) || {};

options = mergeOptions(options, xoOptions, enginesOptions);
options = normalizeOptions({
...xoOptions,
...(enginesOptions && enginesOptions.node && semver.validRange(enginesOptions.node) ? {nodeVersion: enginesOptions.node} : {}),
...options,
});
options.extensions = [...DEFAULT_EXTENSION, ...(options.extensions || [])];
options.ignores = getIgnores(options);
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;
// Ensure eslint is ran minimal times across all linted files, once for each unique configuration
// incremental hash of: xo config path + override hash + tsconfig path
// ensures unique configurations
options.eslintConfigId = new MurmurHash3(xoConfigPath);
if (options.filePath) {
const overrides = applyOverrides(options.filePath, options);
options = overrides.options;

if (overrides.hash) {
eslintConfigId += overrides.hash;
options.eslintConfigId = options.eslintConfigId.hash(`${overrides.hash}`);
}
}

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

if (options.filePath && isTypescript(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.ts = true;
options = await handleTSConfig(options);
}

return {options, prettierOptions, eslintConfigId};
};
// Ensure this field ends up as a string
options.eslintConfigId = options.eslintConfigId.result();

/**
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
*/
const getTsConfigCachePath = async (files, tsConfigPath, cwd) => {
const {version} = await json.load('../package.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,
};
return {options, prettierOptions};
};

const makeTSConfig = (tsConfig, tsConfigPath, files) => {
const config = {files: files.filter(file => isTypescript(file))};

if (tsConfig) {
config.extends = tsConfigPath;
config.include = arrify(tsConfig.include).map(pattern => toAbsoluteGlob(pattern, {cwd: path.dirname(tsConfigPath)}));
/**
* Find the tsconfig or create a default config
* If a config is found but it doesn't cover the file as needed by parserOptions.project
* we create a temp config for that file that extends the found config. If no config is found
* for a file we apply a default config.
*/
const handleTSConfig = async options => {
// 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
options.ts = true;
options.tsConfig = {};
options.tsConfigPath = '';

const {project: tsConfigProjectPath, tsconfigRootDir} = options.parserOptions || {};

if (tsConfigProjectPath) {
options.tsConfigPath = path.resolve(options.cwd, tsConfigProjectPath);
options.tsConfig = await json.load(options.tsConfigPath);
} else {
Object.assign(config, TSCONFIG_DEFAULTS);
const tsConfigExplorer = cosmiconfig([], {
searchPlaces: ['tsconfig.json'],
loaders: {'.json': (_, content) => JSON5.parse(content)},
stopDir: tsconfigRootDir,
});
const searchResults = (await tsConfigExplorer.search(options.filePath)) || {};
options.tsConfigPath = searchResults.filepath;
options.tsConfig = searchResults.config;
}

return config;
// If there is no files of include property - ts uses **/* as default so all TS files are matched
// TODO: Improve this matching - however, even if we get it wrong, it should still lint correctly as it will just extend the nearest tsconfig
const hasMatch = options.tsConfig && !options.tsConfig.include && !options.tsConfig.files ? true : micromatch.contains(options.filePath, [
...(options.tsConfig && Array.isArray(options.tsConfig.include) ? options.tsConfig.include : []),
...(options.tsConfig && Array.isArray(options.tsConfig.files) ? options.tsConfig.files : []),
]);

if (!hasMatch) {
// Only use our default tsconfig if no other tsconfig is found - otherwise extend the found config for linting
options.tsConfig = options.tsConfigPath ? {extends: options.tsConfigPath} : TSCONFIG_DEFAULTS;
options.tsConfigHash = new MurmurHash3(stringify(options.tsConfig)).result();
options.tsConfigPath = path.join(
cacheLocation(options.cwd),
`tsconfig.${options.tsConfigHash}.json`,
);
}

options.eslintConfigId = options.eslintConfigId.hash(options.tsConfigPath);

return options;
};

const normalizeOptions = options => {
Expand Down Expand Up @@ -235,22 +232,6 @@ const normalizeOptions = options => {

const normalizeSpaces = options => typeof options.space === 'number' ? options.space : 2;

/**
Merge option passed via CLI/API via options found in config files.
*/
const mergeOptions = (options, xoOptions = {}, enginesOptions = {}) => {
const mergedOptions = normalizeOptions({
...xoOptions,
...(enginesOptions && enginesOptions.node && semver.validRange(enginesOptions.node) ? {nodeVersion: enginesOptions.node} : {}),
...options,
});

mergedOptions.extensions = [...DEFAULT_EXTENSION, ...(mergedOptions.extensions || [])];
mergedOptions.ignores = getIgnores(mergedOptions);

return mergedOptions;
};

/**
Transform an XO options into ESLint compatible options:
- Apply rules based on XO options (e.g `spaces` => `indent` rules or `semicolon` => `semi` rule).
Expand Down Expand Up @@ -487,9 +468,6 @@ const buildTSConfig = options => config => {
? options.parserOptions.projectFolderIgnoreList
: [new RegExp(`/node_modules/(?!.*\\.cache/${CACHE_DIR_NAME})`)],
};

delete config.tsConfigPath;
delete config.ts;
}

return config;
Expand Down Expand Up @@ -576,17 +554,59 @@ const gatherImportResolvers = options => {

const parseOptions = async options => {
options = normalizeOptions(options);
const {options: foundOptions, prettierOptions, eslintConfigId} = await mergeWithFileConfig(options);
const {options: foundOptions, prettierOptions} = await mergeWithFileConfig(options);
const {eslintConfigId, tsConfigHash, tsConfig, tsConfigPath} = foundOptions;
const {filePath, warnIgnored, ...eslintOptions} = buildConfig(foundOptions, prettierOptions);
return {
filePath,
warnIgnored,
isQuiet: options.quiet,
eslintOptions,
eslintConfigId,
tsConfigHash,
tsConfigPath,
tsConfig,
};
};

const getOptionGroups = async (files, options) => {
const allOptions = await Promise.all(
arrify(files).map(filePath => parseOptions({...options, filePath})),
);

const tsGroups = {};
const optionGroups = {};
for (const options of allOptions) {
if (Array.isArray(optionGroups[options.eslintConfigId])) {
optionGroups[options.eslintConfigId].push(options);
} else {
optionGroups[options.eslintConfigId] = [options];
}

if (options.tsConfigHash) {
if (Array.isArray(tsGroups[options.tsConfigHash])) {
tsGroups[options.tsConfigHash].push(options);
} else {
tsGroups[options.tsConfigHash] = [options];
}
}
}

await Promise.all(Object.values(tsGroups).map(async tsGroup => {
await fs.mkdir(path.dirname(tsGroup[0].tsConfigPath), {recursive: true});
await fs.writeFile(tsGroup[0].tsConfigPath, JSON.stringify({
...tsGroup[0].tsConfig,
files: tsGroup.map(o => o.filePath),
include: [],
exclude: [],
}));
}));

// Files with same `xoConfigPath` can lint together
// https://github.com/xojs/xo/issues/599
return optionGroups;
};

export {
parseOptions,
getIgnores,
Expand All @@ -598,4 +618,6 @@ export {
mergeWithPrettierConfig,
normalizeOptions,
buildConfig,
getOptionGroups,
handleTSConfig,
};
3 changes: 3 additions & 0 deletions test/fixtures/typescript/extends-config/package.json
@@ -0,0 +1,3 @@
{
"xo": {}
}
3 changes: 3 additions & 0 deletions test/fixtures/typescript/extends-config/tsconfig.json
@@ -0,0 +1,3 @@
{
"include": ["foo.ts"]
}
8 changes: 4 additions & 4 deletions test/lint-files.js
Expand Up @@ -187,7 +187,7 @@ test('find configurations close to linted file', async t => {
);
});

test('typescript files', async t => {
test.serial('typescript files', async t => {
const {results} = await xo.lintFiles('**/*', {cwd: 'fixtures/typescript'});

t.true(
Expand Down Expand Up @@ -215,18 +215,18 @@ test('typescript files', async t => {
);
});

test('typescript 2 space option', async t => {
test.serial('typescript 2 space option', async t => {
const {errorCount, results} = await xo.lintFiles('two-spaces.tsx', {cwd: 'fixtures/typescript', space: 2});
// eslint-disable-next-line ava/assertion-arguments
t.is(errorCount, 0, JSON.stringify(results[0].messages));
});

test('typescript 4 space option', async t => {
test.serial('typescript 4 space option', async t => {
const {errorCount} = await xo.lintFiles('child/sub-child/four-spaces.ts', {cwd: 'fixtures/typescript', space: 4});
t.is(errorCount, 0);
});

test('typescript no semicolon option', async t => {
test.serial('typescript no semicolon option', async t => {
const {errorCount} = await xo.lintFiles('child/no-semicolon.ts', {cwd: 'fixtures/typescript', semicolon: false});
t.is(errorCount, 0);
});
Expand Down