xo
Version:
JavaScript/TypeScript linter (ESLint wrapper) with great defaults
721 lines (601 loc) • 22.2 kB
JavaScript
import {existsSync, promises as fs} from 'node:fs';
import process from 'node:process';
import os from 'node:os';
import path from 'node:path';
import {createRequire} from 'node:module';
import arrify from 'arrify';
import {mergeWith, flow, pick} from 'lodash-es';
import {findUpSync} from 'find-up';
import findCacheDir from 'find-cache-dir';
import prettier from 'prettier';
import semver from 'semver';
import {cosmiconfig, defaultLoaders} from 'cosmiconfig';
import micromatch from 'micromatch';
import JSON5 from 'json5';
import stringify from 'json-stable-stringify-without-jsonify';
import {Legacy} from '@eslint/eslintrc';
import createEsmUtils from 'esm-utils';
import MurmurHash3 from 'imurmurhash';
import slash from 'slash';
import {
DEFAULT_IGNORES,
DEFAULT_EXTENSION,
TYPESCRIPT_EXTENSION,
ENGINE_RULES,
MODULE_NAME,
CONFIG_FILES,
MERGE_OPTIONS_CONCAT,
TSCONFIG_DEFAULTS,
CACHE_DIR_NAME,
} from './constants.js';
const {__dirname, require} = createEsmUtils(import.meta);
const {normalizePackageName} = Legacy.naming;
const resolveModule = Legacy.ModuleResolver.resolve;
const resolveFrom = (moduleId, fromDirectory = process.cwd()) => resolveModule(moduleId, path.join(fromDirectory, '__placeholder__.js'));
resolveFrom.silent = (moduleId, fromDirectory) => {
try {
return resolveFrom(moduleId, fromDirectory);
} catch {}
};
const resolveLocalConfig = name => resolveModule(normalizePackageName(name, 'eslint-config'), import.meta.url);
const cacheLocation = cwd => findCacheDir({name: CACHE_DIR_NAME, cwd}) || path.join(os.homedir() || os.tmpdir(), '.xo-cache/');
const DEFAULT_CONFIG = {
useEslintrc: false,
cache: true,
cacheLocation: path.join(cacheLocation(), 'xo-cache.json'),
globInputPaths: false,
resolvePluginsRelativeTo: __dirname,
baseConfig: {
extends: [
resolveLocalConfig('xo'),
path.join(__dirname, '../config/overrides.cjs'),
path.join(__dirname, '../config/plugins.cjs'),
],
},
};
/**
Define the shape of deep properties for `mergeWith`.
*/
const getEmptyConfig = () => ({
baseConfig: {
rules: {},
settings: {},
globals: {},
ignorePatterns: [],
env: {},
plugins: [],
extends: [],
},
});
const getEmptyXOConfig = () => ({
rules: {},
settings: {},
globals: [],
envs: [],
plugins: [],
extends: [],
});
const mergeFn = (previousValue, value, key) => {
if (Array.isArray(previousValue)) {
if (MERGE_OPTIONS_CONCAT.includes(key)) {
return [...previousValue, ...value];
}
return value;
}
};
const isTypescript = file => TYPESCRIPT_EXTENSION.includes(path.extname(file).slice(1));
/**
Find config for `lintText`.
The config files are searched starting from `options.filePath` if defined or `options.cwd` otherwise.
*/
const mergeWithFileConfig = async options => {
options.cwd = path.resolve(options.cwd || process.cwd());
const configExplorer = cosmiconfig(MODULE_NAME, {searchPlaces: CONFIG_FILES, loaders: {noExt: defaultLoaders['.json']}, stopDir: options.cwd});
const pkgConfigExplorer = cosmiconfig('engines', {searchPlaces: ['package.json'], stopDir: options.cwd});
if (options.filePath) {
options.filePath = path.resolve(options.cwd, options.filePath);
}
const searchPath = options.filePath || options.cwd;
const {config: xoOptions, filepath: xoConfigPath} = (await configExplorer.search(searchPath)) || {};
const {config: enginesOptions} = (await pkgConfigExplorer.search(searchPath)) || {};
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;
// 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) {
options.eslintConfigId = options.eslintConfigId.hash(`${overrides.hash}`);
}
}
const prettierOptions = options.prettier ? await prettier.resolveConfig(searchPath, {editorconfig: true}) || {} : {};
if (options.filePath && isTypescript(options.filePath)) {
options = await handleTSConfig(options);
}
// Ensure this field ends up as a string
options.eslintConfigId = options.eslintConfigId.result();
return {options, prettierOptions};
};
/**
* 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 = JSON5.parse(await fs.readFile(options.tsConfigPath));
} else {
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;
}
if (options.tsConfig) {
// If the tsconfig extends from another file, we need to ensure that the file is covered by the tsconfig
// or not. The basefile could have includes/excludes/files properties that should be applied to the final tsconfig representation.
options.tsConfig = await recursiveBuildTsConfig(options.tsConfig, options.tsConfigPath);
}
let hasMatch;
// If there is no files or include property - ts uses **/* as default so all TS files are matched
// in tsconfig, excludes override includes - so we need to prioritize that matching logic
if (
options.tsConfig
&& !options.tsConfig.include
&& !options.tsConfig.files
) {
// If we have an excludes property, we need to check it
// If we match on excluded, then we definitively know that there is no tsconfig match
if (Array.isArray(options.tsConfig.exclude)) {
const exclude = options.tsConfig && Array.isArray(options.tsConfig.exclude) ? options.tsConfig.exclude : [];
hasMatch = !micromatch.contains(options.filePath, exclude);
} else {
// Not explicitly excluded and included by tsconfig defaults
hasMatch = true;
}
} else {
// We have either and include or a files property in tsconfig
const include = options.tsConfig && Array.isArray(options.tsConfig.include) ? options.tsConfig.include : [];
const files = options.tsConfig && Array.isArray(options.tsConfig.files) ? options.tsConfig.files : [];
const exclude = options.tsConfig && Array.isArray(options.tsConfig.exclude) ? options.tsConfig.exclude : [];
// If we also have an exlcude we need to check all the arrays, (files, include, exclude)
// this check not excluded and included in one of the file/include array
hasMatch = !micromatch.contains(options.filePath, exclude)
&& micromatch.contains(options.filePath, [...include, ...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 => {
options = {...options};
// Aliases for humans
const aliases = [
'env',
'global',
'ignore',
'plugin',
'rule',
'setting',
'extend',
'extension',
];
for (const singular of aliases) {
const plural = singular + 's';
let value = options[plural] || options[singular];
delete options[singular];
if (value === undefined) {
continue;
}
if (singular !== 'rule' && singular !== 'setting') {
value = arrify(value);
}
options[plural] = value;
}
return options;
};
const normalizeSpaces = options => typeof options.space === 'number' ? options.space : 2;
/**
Transform an XO options into ESLint compatible options:
- Apply rules based on XO options (e.g `spaces` => `indent` rules or `semicolon` => `semi` rule).
- Resolve the extended configurations.
- Apply rules based on Prettier config if `prettier` option is `true`.
*/
const buildConfig = (options, prettierOptions) => {
options = normalizeOptions(options);
if (options.useEslintrc) {
throw new Error('The `useEslintrc` option is not supported');
}
return flow(
buildESLintConfig(options),
buildXOConfig(options),
buildTSConfig(options),
buildExtendsConfig(options),
buildPrettierConfig(options, prettierOptions),
)(mergeWith(getEmptyConfig(), DEFAULT_CONFIG, mergeFn));
};
const toValueMap = (array, value = true) => Object.fromEntries(array.map(item => [item, value]));
const buildESLintConfig = options => config => {
if (options.rules) {
config.baseConfig.rules = {
...config.baseConfig.rules,
...options.rules,
};
}
if (options.parser) {
config.baseConfig.parser = options.parser;
}
if (options.processor) {
config.baseConfig.processor = options.processor;
}
config.baseConfig.settings = options.settings || {};
if (options.envs) {
config.baseConfig.env = {
...config.baseConfig.env,
...toValueMap(options.envs),
};
}
if (options.globals) {
config.baseConfig.globals = {
...config.baseConfig.globals,
...toValueMap(options.globals, 'readonly'),
};
}
if (options.plugins) {
config.baseConfig.plugins = [
...config.baseConfig.plugins,
...options.plugins,
];
}
if (options.ignores) {
config.baseConfig.ignorePatterns = [
...config.baseConfig.ignorePatterns,
...options.ignores,
];
}
if (options.parserOptions) {
config.baseConfig.parserOptions = {
...config.baseConfig.parserOptions,
...options.parserOptions,
};
}
return {
...config,
...pick(options, ['cwd', 'filePath', 'fix']),
};
};
const buildXOConfig = options => config => {
const spaces = normalizeSpaces(options);
for (const [rule, ruleConfig] of Object.entries(ENGINE_RULES)) {
for (const minVersion of Object.keys(ruleConfig).sort(semver.rcompare)) {
if (!options.nodeVersion || semver.intersects(options.nodeVersion, `<${minVersion}`)) {
config.baseConfig.rules[rule] = ruleConfig[minVersion];
}
}
}
if (options.nodeVersion) {
config.baseConfig.rules['n/no-unsupported-features/es-builtins'] = ['error', {version: options.nodeVersion}];
config.baseConfig.rules['n/no-unsupported-features/es-syntax'] = ['error', {version: options.nodeVersion, ignores: ['modules']}];
config.baseConfig.rules['n/no-unsupported-features/node-builtins'] = ['error', {version: options.nodeVersion}];
}
if (options.space && !options.prettier) {
if (options.ts) {
config.baseConfig.rules['@typescript-eslint/indent'] = ['error', spaces, {SwitchCase: 1}];
} else {
config.baseConfig.rules.indent = ['error', spaces, {SwitchCase: 1}];
}
// Only apply if the user has the React plugin
if (options.cwd && resolveFrom.silent('eslint-plugin-react', options.cwd)) {
config.baseConfig.plugins.push('react');
config.baseConfig.rules['react/jsx-indent-props'] = ['error', spaces];
config.baseConfig.rules['react/jsx-indent'] = ['error', spaces];
}
}
if (options.semicolon === false && !options.prettier) {
if (options.ts) {
config.baseConfig.rules['@typescript-eslint/semi'] = ['error', 'never'];
} else {
config.baseConfig.rules.semi = ['error', 'never'];
}
config.baseConfig.rules['semi-spacing'] = ['error', {
before: false,
after: true,
}];
}
if (options.ts) {
config.baseConfig.rules['unicorn/import-style'] = 'off';
config.baseConfig.rules['node/file-extension-in-import'] = 'off';
// Disabled because of https://github.com/benmosher/eslint-plugin-import/issues/1590
config.baseConfig.rules['import/export'] = 'off';
// Does not work when the TS definition exports a default const.
config.baseConfig.rules['import/default'] = 'off';
// Disabled as it doesn't work with TypeScript.
// This issue and some others: https://github.com/benmosher/eslint-plugin-import/issues/1341
config.baseConfig.rules['import/named'] = 'off';
}
config.baseConfig.settings['import/resolver'] = gatherImportResolvers(options);
return config;
};
const buildExtendsConfig = options => config => {
if (options.extends && options.extends.length > 0) {
const configs = options.extends.map(name => {
// Don't do anything if it's a filepath
if (existsSync(path.resolve(options.cwd || process.cwd(), name))) {
return name;
}
// Don't do anything if it's a config from a plugin or an internal eslint config
if (name.startsWith('eslint:') || name.startsWith('plugin:')) {
return name;
}
const returnValue = resolveFrom(normalizePackageName(name, 'eslint-config'), options.cwd);
if (!returnValue) {
throw new Error(`Couldn't find ESLint config: ${name}`);
}
return returnValue;
});
config.baseConfig.extends = [...config.baseConfig.extends, ...configs];
}
return config;
};
const buildPrettierConfig = (options, prettierConfig) => config => {
if (options.prettier) {
// The prettier plugin uses Prettier to format the code with `--fix`
config.baseConfig.plugins.push('prettier');
// The prettier plugin overrides ESLint stylistic rules that are handled by Prettier
config.baseConfig.extends.push('plugin:prettier/recommended');
// The `prettier/prettier` rule reports errors if the code is not formatted in accordance to Prettier
config.baseConfig.rules['prettier/prettier'] = ['error', mergeWithPrettierConfig(options, prettierConfig)];
}
return config;
};
const mergeWithPrettierConfig = (options, prettierOptions) => {
if ((options.semicolon === true && prettierOptions.semi === false)
|| (options.semicolon === false && prettierOptions.semi === true)) {
throw new Error(`The Prettier config \`semi\` is ${prettierOptions.semi} while XO \`semicolon\` is ${options.semicolon}`);
}
if (((options.space === true || typeof options.space === 'number') && prettierOptions.useTabs === true)
|| ((options.space === false) && prettierOptions.useTabs === false)) {
throw new Error(`The Prettier config \`useTabs\` is ${prettierOptions.useTabs} while XO \`space\` is ${options.space}`);
}
if (typeof options.space === 'number' && typeof prettierOptions.tabWidth === 'number' && options.space !== prettierOptions.tabWidth) {
throw new Error(`The Prettier config \`tabWidth\` is ${prettierOptions.tabWidth} while XO \`space\` is ${options.space}`);
}
return mergeWith(
{},
{
singleQuote: true,
bracketSpacing: false,
bracketSameLine: false,
trailingComma: 'all',
tabWidth: normalizeSpaces(options),
useTabs: !options.space,
semi: options.semicolon !== false,
},
prettierOptions,
mergeFn,
);
};
const buildTSConfig = options => config => {
if (options.ts) {
config.baseConfig.extends.push(require.resolve('eslint-config-xo-typescript'));
config.baseConfig.parser = require.resolve('@typescript-eslint/parser');
config.baseConfig.parserOptions = {
...config.baseConfig.parserOptions,
warnOnUnsupportedTypeScriptVersion: false,
ecmaFeatures: {jsx: true},
project: options.tsConfigPath,
projectFolderIgnoreList:
options.parserOptions && options.parserOptions.projectFolderIgnoreList
? options.parserOptions.projectFolderIgnoreList
: [new RegExp(`/node_modules/(?!.*\\.cache/${CACHE_DIR_NAME})`)],
};
}
return config;
};
const applyOverrides = (file, options) => {
if (options.overrides && options.overrides.length > 0) {
const {overrides} = options;
delete options.overrides;
const {applicable, hash} = findApplicableOverrides(path.relative(options.cwd, file), overrides);
options = mergeWith(getEmptyXOConfig(), options, ...applicable.map(override => normalizeOptions(override)), mergeFn);
delete options.files;
return {options, hash};
}
return {options};
};
/**
Builds a list of overrides for a particular path, and a hash value.
The hash value is a binary representation of which elements in the `overrides` array apply to the path.
If `overrides.length === 4`, and only the first and third elements apply, then our hash is: 1010 (in binary)
*/
const findApplicableOverrides = (path, overrides) => {
let hash = 0;
const applicable = [];
for (const override of overrides) {
hash <<= 1; // eslint-disable-line no-bitwise
if (micromatch.isMatch(path, override.files)) {
applicable.push(override);
hash |= 1; // eslint-disable-line no-bitwise
}
}
return {
hash,
applicable,
};
};
const getIgnores = ({ignores}) => [...DEFAULT_IGNORES, ...(ignores || [])];
const gatherImportResolvers = options => {
let resolvers = {};
const resolverSettings = options.settings && options.settings['import/resolver'];
if (resolverSettings) {
if (typeof resolverSettings === 'string') {
resolvers[resolverSettings] = {};
} else {
resolvers = {...resolverSettings};
}
}
let webpackResolverSettings;
if (options.webpack) {
webpackResolverSettings = options.webpack === true ? {} : options.webpack;
} else if (!(options.webpack === false || resolvers.webpack)) {
// If a webpack config file exists, add the import resolver automatically
const webpackConfigPath = findUpSync('webpack.config.js', {cwd: options.cwd});
if (webpackConfigPath) {
webpackResolverSettings = {config: webpackConfigPath};
}
}
if (webpackResolverSettings) {
resolvers = {
...resolvers,
webpack: {
...resolvers.webpack,
...webpackResolverSettings,
},
};
}
return resolvers;
};
const parseOptions = async options => {
options = normalizeOptions(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;
};
async function recursiveBuildTsConfig(tsConfig, tsConfigPath) {
tsConfig = tsConfigResolvePaths(tsConfig, tsConfigPath);
if (!tsConfig.extends || (typeof tsConfig.extends === 'string' && tsConfig.extends.includes('node_modules'))) {
return tsConfig;
}
// If any of the following are missing, then we need to look up the base config as it could apply
const require = createRequire(tsConfigPath);
let basePath;
try {
basePath = require.resolve(tsConfig.extends);
} catch (error) {
// Tsconfig resolution is odd, It allows behavior that is not exactly like node resolution
// therefore we attempt to smooth this out here with this hack
try {
basePath = require.resolve(path.join(tsConfig.extends, 'tsconfig.json'));
} catch {
// Throw the orginal resolution error to let the user know their extends block is invalid
throw error;
}
}
const baseTsConfig = JSON5.parse(await fs.readFile(basePath));
delete tsConfig.extends;
tsConfig = {
compilerOptions: {
...baseTsConfig.compilerOptions,
...tsConfig.compilerOptions,
},
...baseTsConfig,
...tsConfig,
};
return recursiveBuildTsConfig(tsConfig, basePath);
}
// Convert all include, files, and exclude to absolute paths
// and or globs. This works because ts only allows simple glob subset
const tsConfigResolvePaths = (tsConfig, tsConfigPath) => {
const tsConfigDirectory = path.dirname(tsConfigPath);
if (Array.isArray(tsConfig.files)) {
tsConfig.files = tsConfig.files.map(
filePath => slash(path.resolve(tsConfigDirectory, filePath)),
);
}
if (Array.isArray(tsConfig.include)) {
tsConfig.include = tsConfig.include.map(
globPath => slash(path.resolve(tsConfigDirectory, globPath)),
);
}
if (Array.isArray(tsConfig.exclude)) {
tsConfig.exclude = tsConfig.exclude.map(
globPath => slash(path.resolve(tsConfigDirectory, globPath)),
);
}
return tsConfig;
};
export {
parseOptions,
getIgnores,
mergeWithFileConfig,
// For tests
applyOverrides,
findApplicableOverrides,
mergeWithPrettierConfig,
normalizeOptions,
buildConfig,
getOptionGroups,
handleTSConfig,
tsConfigResolvePaths,
};