xo
Version:
JavaScript/TypeScript linter (ESLint wrapper) with great defaults
358 lines (351 loc) • 12.4 kB
JavaScript
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs/promises';
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 prettier from 'prettier';
import { defaultIgnores, cacheDirName, allExtensions, tsFilesGlob, } from './constants.js';
import { xoToEslintConfig } from './xo-to-eslint.js';
import resolveXoConfig from './resolve-config.js';
import { handleTsconfig } from './handle-ts-files.js';
import { matchFilesForTsConfig, preProcessXoConfig } from './utils.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,
configPath: options.configPath,
}, {
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,
configPath: options.configPath,
}, {
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 contain Prettier, we will need to fetch the Prettier config.
*/
prettier;
/**
The Prettier config if it exists and is needed.
*/
prettierConfig;
/**
The glob pattern for TypeScript files, for which we will handle TS files and tsconfig.
We expand this based on the XO config and the files glob patterns.
*/
tsFilesGlob = [tsFilesGlob];
/**
We use this to also add negative glob patterns in case a user overrides the parserOptions in their XO config.
*/
tsFilesIgnoresGlob = [];
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,
});
const { config, tsFilesGlob, tsFilesIgnoresGlob } = preProcessXoConfig([
this.baseXoConfig,
...flatOptions,
]);
this.xoConfig = config;
this.tsFilesGlob.push(...tsFilesGlob);
this.tsFilesIgnoresGlob.push(...tsFilesIgnoresGlob);
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 });
}
/**
Ensures the cache directory exists. This needs to run once before both tsconfig handling and running ESLint occur.
@private
*/
async ensureCacheDirectory() {
try {
const cacheStats = await fs.stat(this.cacheLocation);
// If file, re-create as directory
if (cacheStats.isFile()) {
await fs.rm(this.cacheLocation, { recursive: true, force: true });
await fs.mkdir(this.cacheLocation, { recursive: true });
}
}
catch {
// If not exists, create the directory
await fs.mkdir(this.cacheLocation, { recursive: true });
}
}
/**
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 = matchFilesForTsConfig(this.linterOptions.cwd, files, this.tsFilesGlob, this.tsFilesIgnoresGlob);
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.ensureCacheDirectory();
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