xo
Version:
JavaScript/TypeScript linter (ESLint wrapper) with great defaults
366 lines (361 loc) • 12.9 kB
JavaScript
import path from 'node:path';
import os from 'node:os';
import process from 'node:process';
import { ESLint } from 'eslint';
import findCacheDirectory from 'find-cache-directory';
import { globby } from 'globby';
import arrify from 'arrify';
import defineLazyProperty from 'define-lazy-prop';
import micromatch from 'micromatch';
import prettier from 'prettier';
import { defaultIgnores, cacheDirName, allExtensions, tsFilesGlob, allFilesGlob, } from './constants.js';
import { xoToEslintConfig } from './xo-to-eslint.js';
import resolveXoConfig from './resolve-config.js';
import { handleTsconfig } from './handle-ts-files.js';
// Import {handleTsconfig} from './handle-ts-files-typescript.js';
export class Xo {
/**
Static helper to convert an XO config to an ESLint config to be used in `eslint.config.js`.
*/
static xoToEslintConfig = xoToEslintConfig;
/**
Static helper for backwards compatibility and use in editor extensions and other tools.
*/
static async lintText(code, options) {
const xo = new Xo({
cwd: options.cwd,
fix: options.fix,
filePath: options.filePath,
quiet: options.quiet,
ts: options.ts ?? true,
}, {
react: options.react,
space: options.space,
semicolon: options.semicolon,
prettier: options.prettier,
ignores: options.ignores,
});
return xo.lintText(code, {
filePath: options.filePath,
warnIgnored: options.warnIgnored,
});
}
/**
Static helper for backwards compatibility and use in editor extensions and other tools.
*/
static async lintFiles(globs, options) {
const xo = new Xo({
cwd: options.cwd,
fix: options.fix,
filePath: options.filePath,
quiet: options.quiet,
ts: options.ts,
}, {
react: options.react,
space: options.space,
semicolon: options.semicolon,
prettier: options.prettier,
ignores: options.ignores,
});
return xo.lintFiles(globs);
}
/**
Write the fixes to disk.
*/
static async outputFixes(results) {
await ESLint.outputFixes(results?.results ?? []);
}
/**
Required linter options: `cwd`, `fix`, and `filePath` (in case of `lintText`).
*/
linterOptions;
/**
Base XO config options that allow configuration from CLI or other sources. Not to be confused with the `xoConfig` property which is the resolved XO config from the flat config AND base config.
*/
baseXoConfig;
/**
File path to the ESLint cache.
*/
cacheLocation;
/**
A re-usable ESLint instance configured with options calculated from the XO config.
*/
eslint;
/**
XO config derived from both the base config and the resolved flat config.
*/
xoConfig;
/**
The ESLint config calculated from the resolved XO config.
*/
eslintConfig;
/**
The flat XO config path, if there is one.
*/
flatConfigPath;
/**
If any user configs container Prettier, we will need to fetch the Prettier config.
*/
prettier;
/**
The Prettier config if it exists and is needed.
*/
prettierConfig;
constructor(_linterOptions, _baseXoConfig = {}) {
this.linterOptions = _linterOptions;
this.baseXoConfig = _baseXoConfig;
// Fix relative cwd paths
if (!path.isAbsolute(this.linterOptions.cwd)) {
this.linterOptions.cwd = path.resolve(process.cwd(), this.linterOptions.cwd);
}
const backupCacheLocation = path.join(os.tmpdir(), cacheDirName);
this.cacheLocation = findCacheDirectory({ name: cacheDirName, cwd: this.linterOptions.cwd }) ?? backupCacheLocation;
}
/**
Sets the XO config on the XO instance.
@private
*/
async setXoConfig() {
if (this.xoConfig) {
return;
}
const { flatOptions, flatConfigPath } = await resolveXoConfig({
...this.linterOptions,
});
this.xoConfig = [
this.baseXoConfig,
...flatOptions,
];
// Split off the TS rules in a special case, so that you won't get errors
// for JS files when the TS rules are not in the config.
this.xoConfig = this.xoConfig.flatMap(config => {
// If the user does not specify files, then we can assume they want everything to work correctly and
// for rules to apply to all files. However, TS rules will error with JS files, so we need to split them off.
// if the user supplies files, then we cannot make the same assumption, so we will not split them off.
if (config.files) {
return config;
}
const ruleEntries = Object.entries(config.rules ?? {});
const otherRules = [];
const tsRules = [];
for (const [rule, ruleValue] of ruleEntries) {
if (!rule || !ruleValue) {
continue;
}
if (rule.startsWith('@typescript-eslint')) {
tsRules.push([rule, ruleValue]);
}
else {
otherRules.push([rule, ruleValue]);
}
}
// If no TS rules, return the config as is
if (tsRules.length === 0) {
return config;
}
// If there are TS rules, we need to split them off into a new config
const tsConfig = {
...config,
rules: Object.fromEntries(tsRules),
};
// Apply TS rules to all files
tsConfig.files = [tsFilesGlob];
const otherConfig = {
...config,
// Set the other rules to the original config
rules: Object.fromEntries(otherRules),
};
// These rules should still apply to all files
otherConfig.files = [allFilesGlob];
return [tsConfig, otherConfig];
});
this.prettier = this.xoConfig.some(config => config.prettier);
this.prettierConfig = await prettier.resolveConfig(flatConfigPath, { editorconfig: true }) ?? {};
this.flatConfigPath = flatConfigPath;
}
/**
Sets the ESLint config on the XO instance.
@private
*/
setEslintConfig() {
if (!this.xoConfig) {
throw new Error('"Xo.setEslintConfig" failed');
}
this.eslintConfig ??= xoToEslintConfig([...this.xoConfig], { prettierOptions: this.prettierConfig });
}
/**
Sets the ignores on the XO instance.
@private
*/
setIgnores() {
if (!this.baseXoConfig.ignores) {
return;
}
let ignores = [];
if (typeof this.baseXoConfig.ignores === 'string') {
ignores = arrify(this.baseXoConfig.ignores);
}
else if (Array.isArray(this.baseXoConfig.ignores)) {
ignores = this.baseXoConfig.ignores;
}
if (!this.xoConfig) {
throw new Error('"Xo.setIgnores" failed');
}
if (ignores.length === 0) {
return;
}
this.xoConfig.push({ ignores });
}
/**
Checks every TS file to ensure its included in the tsconfig and any that are not included are added to a generated tsconfig for type aware linting.
@param files - The TypeScript files being linted.
*/
async handleUnincludedTsFiles(files) {
if (!this.linterOptions.ts || !files || files.length === 0) {
return;
}
const tsFiles = files.filter(file => micromatch.isMatch(file, tsFilesGlob, { dot: true }));
if (tsFiles.length === 0) {
return;
}
const { fallbackTsConfigPath, unincludedFiles } = await handleTsconfig({
cwd: this.linterOptions.cwd,
files: tsFiles,
});
if (!this.xoConfig || unincludedFiles.length === 0) {
return;
}
const config = {};
config.files = unincludedFiles.map(file => path.relative(this.linterOptions.cwd, file));
config.languageOptions ??= {};
config.languageOptions.parserOptions ??= {};
config.languageOptions.parserOptions['projectService'] = false;
config.languageOptions.parserOptions['project'] = fallbackTsConfigPath;
config.languageOptions.parserOptions['tsconfigRootDir'] = this.linterOptions.cwd;
this.xoConfig.push(config);
}
/**
Initializes the ESLint instance on the XO instance.
*/
async initEslint(files) {
await this.setXoConfig();
this.setIgnores();
await this.handleUnincludedTsFiles(files);
this.setEslintConfig();
if (!this.xoConfig) {
throw new Error('"Xo.initEslint" failed');
}
const eslintOptions = {
cwd: this.linterOptions.cwd,
overrideConfig: this.eslintConfig,
overrideConfigFile: true,
globInputPaths: false,
warnIgnored: false,
cache: true,
cacheLocation: this.cacheLocation,
fix: this.linterOptions.fix,
};
this.eslint ??= new ESLint(eslintOptions);
}
/**
Lints the files on the XO instance.
@param globs - Glob pattern to pass to `globby`.
@throws Error
*/
async lintFiles(globs) {
if (!globs || (Array.isArray(globs) && globs.length === 0)) {
globs = `**/*.{${allExtensions.join(',')}}`;
}
globs = arrify(globs);
let files = await globby(globs, {
// Merge in command line ignores
ignore: [...defaultIgnores, ...arrify(this.baseXoConfig.ignores)],
onlyFiles: true,
gitignore: true,
absolute: true,
cwd: this.linterOptions.cwd,
});
await this.initEslint(files);
if (!this.eslint) {
throw new Error('Failed to initialize ESLint');
}
if (files.length === 0) {
files = '!**/*';
}
const results = await this.eslint.lintFiles(files);
const rulesMeta = this.eslint.getRulesMetaForResults(results);
return this.processReport(results, { rulesMeta });
}
/**
Lints the text on the XO instance.
*/
async lintText(code, lintTextOptions) {
const { filePath, warnIgnored } = lintTextOptions;
await this.initEslint([filePath]);
if (!this.eslint) {
throw new Error('Failed to initialize ESLint');
}
const results = await this.eslint?.lintText(code, {
filePath,
warnIgnored,
});
const rulesMeta = this.eslint.getRulesMetaForResults(results ?? []);
return this.processReport(results ?? [], { rulesMeta });
}
async calculateConfigForFile(filePath) {
await this.initEslint([filePath]);
if (!this.eslint) {
throw new Error('Failed to initialize ESLint');
}
return this.eslint.calculateConfigForFile(filePath);
}
async getFormatter(name) {
await this.initEslint();
if (!this.eslint) {
throw new Error('Failed to initialize ESLint');
}
return this.eslint.loadFormatter(name);
}
processReport(report, { rulesMeta = {} } = {}) {
if (this.linterOptions.quiet) {
report = ESLint.getErrorResults(report);
}
const result = {
results: report,
rulesMeta,
...this.getReportStatistics(report),
};
defineLazyProperty(result, 'usedDeprecatedRules', () => {
const seenRules = new Set();
const rules = [];
for (const { usedDeprecatedRules } of report) {
for (const rule of usedDeprecatedRules) {
if (seenRules.has(rule.ruleId)) {
continue;
}
seenRules.add(rule.ruleId);
rules.push(rule);
}
}
return rules;
});
return result;
}
getReportStatistics(results) {
const statistics = {
errorCount: 0,
warningCount: 0,
fixableErrorCount: 0,
fixableWarningCount: 0,
};
for (const result of results) {
statistics.errorCount += result.errorCount;
statistics.warningCount += result.warningCount;
statistics.fixableErrorCount += result.fixableErrorCount;
statistics.fixableWarningCount += result.fixableWarningCount;
}
return statistics;
}
}
export default Xo;
//# sourceMappingURL=xo.js.map