xo
Version:
JavaScript/TypeScript linter (ESLint wrapper) with great defaults
533 lines (526 loc) • 19.6 kB
JavaScript
import path from 'node:path';
import os from 'node:os';
import syncFs from 'node:fs';
import fs from 'node:fs/promises';
import process from 'node:process';
import { ESLint } from 'eslint';
import findCacheDirectory from 'find-cache-directory';
import { globby, isDynamicPattern } from 'globby';
import arrify from 'arrify';
import defineLazyProperty from 'define-lazy-prop';
import prettier from 'prettier';
import { defaultIgnores, cacheDirName, allExtensions, tsFilesGlob, tsconfigDefaults, } 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, typescriptParser, } from './utils.js';
export const ignoredFileWarningMessage = 'File ignored because of a matching ignore pattern.';
const createIgnoredLintResult = (filePath) => ({
filePath,
messages: [
{
ruleId: null,
severity: 1,
message: ignoredFileWarningMessage,
line: 0,
column: 0,
},
],
suppressedMessages: [],
errorCount: 0,
fatalErrorCount: 0,
warningCount: 1,
fixableErrorCount: 0,
fixableWarningCount: 0,
usedDeprecatedRules: [],
});
const resolveExplicitFilePath = (cwd, glob) => {
if (isDynamicPattern(glob)) {
// Negated and wildcard globs are treated as regular glob filtering, not as explicit file paths that should trigger an ignored-file warning.
return undefined;
}
const absolutePath = path.resolve(cwd, glob);
try {
if (syncFs.statSync(absolutePath).isFile()) {
return absolutePath;
}
}
catch {
// File does not exist or is inaccessible.
}
return undefined;
};
const getIgnoredExplicitFileResults = async (cwd, globs, eslint) => {
const explicitFilePaths = [...new Set(globs
.map(glob => resolveExplicitFilePath(cwd, glob))
.filter(filePath => filePath !== undefined))];
const results = await Promise.all(explicitFilePaths.map(async (filePath) => await eslint.isPathIgnored(filePath) ? createIgnoredLintResult(filePath) : undefined));
return results.filter(result => result !== undefined);
};
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 = [];
/**
Track whether ignores have been added to prevent duplicate ignore configs.
*/
ignoresHandled = false;
/**
Store per-file configs separately from base config to prevent unbounded array growth.
Key: file path, Value: config for that file.
This prevents memory bloat in long-running processes (e.g., language servers).
*/
fileConfigs = new Map();
/**
Track virtual/stdin files that share a single tsconfig.stdin.json.
These are handled differently from regular files.
*/
virtualFiles = new Set();
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);
}
try {
this.linterOptions.cwd = syncFs.realpathSync.native(this.linterOptions.cwd);
}
catch {
// Ignore invalid paths here; the caller will handle errors later.
}
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');
}
// Combine base config with per-file configs from Map
// Deduplicate configs since multiple files can share the same config object
const uniqueFileConfigs = [...new Set(this.fileConfigs.values())];
const allConfigs = [...this.xoConfig, ...uniqueFileConfigs];
// Always regenerate to support instance reuse with new files
this.eslintConfig = xoToEslintConfig(allConfigs, { prettierOptions: this.prettierConfig });
}
/**
Sets the ignores on the XO instance.
@private
*/
setIgnores() {
if (this.ignoresHandled || !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 });
this.ignoresHandled = true;
}
/**
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 an in-memory TypeScript Program for type aware linting.
@param files - The TypeScript files being linted.
*/
async handleUnincludedTsFiles(files) {
if (!this.linterOptions.ts || !files || files.length === 0) {
return;
}
// Get ALL TypeScript files being linted (both new and previously handled)
const allTsFiles = matchFilesForTsConfig(this.linterOptions.cwd, files, this.tsFilesGlob, this.tsFilesIgnoresGlob);
if (allTsFiles.length === 0) {
this.fileConfigs.clear();
if (this.virtualFiles.size > 0) {
await this.addVirtualFilesToConfig([]);
}
return;
}
const { program, existingFiles, virtualFiles } = handleTsconfig({
files: allTsFiles,
cwd: this.linterOptions.cwd,
cacheLocation: this.cacheLocation,
});
this.fileConfigs.clear();
if (existingFiles.length > 0) {
this.addExistingFilesToConfig(existingFiles, program);
}
await this.addVirtualFilesToConfig(virtualFiles);
}
/**
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,
cacheStrategy: 'content',
fix: this.linterOptions.fix,
};
// Always create new instance to support reuse with updated config
// ESLint's file-based cache (cacheLocation) persists across instances
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);
const files = await globby(globs, {
// Merge in command line ignores
ignore: [...defaultIgnores, ...arrify(this.baseXoConfig.ignores)],
onlyFiles: true,
gitignore: true,
absolute: true,
dot: true,
cwd: this.linterOptions.cwd,
});
await this.initEslint(files);
if (!this.eslint) {
throw new Error('Failed to initialize ESLint');
}
const { eslint } = this;
const ignoredResults = await getIgnoredExplicitFileResults(this.linterOptions.cwd, globs, eslint);
if (files.length === 0) {
return this.processReport(ignoredResults);
}
const results = await eslint.lintFiles(files);
const rulesMeta = eslint.getRulesMetaForResults(results);
// No overlap: `warnIgnored: false` makes ESLint silently drop ignored files from `results`.
return this.processReport([...results, ...ignoredResults], { 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');
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
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);
}
/**
Add virtual files to the config with a tsconfig approach.
*/
async addVirtualFilesToConfig(files) {
if (!this.xoConfig) {
return;
}
try {
const nextVirtualFiles = new Set(files);
const tsconfigPath = path.join(this.cacheLocation, 'tsconfig.stdin.json');
const configIndex = this.xoConfig.findIndex(configItem => {
const { languageOptions } = configItem;
const parserOptionsCandidate = languageOptions?.parserOptions;
const parserOptions = parserOptionsCandidate;
return parserOptions?.project === tsconfigPath;
});
if (nextVirtualFiles.size > 0) {
const filesArray = [...nextVirtualFiles];
const relativeFiles = filesArray.map(file => path.relative(this.linterOptions.cwd, file));
const tsconfigContent = {
compilerOptions: {
...tsconfigDefaults.compilerOptions,
module: 'ESNext',
moduleResolution: 'NodeNext',
esModuleInterop: true,
skipLibCheck: true,
},
files: filesArray,
};
await fs.writeFile(tsconfigPath, JSON.stringify(tsconfigContent, null, 2));
if (configIndex === -1) {
const parserOptions = {
projectService: false,
project: tsconfigPath,
tsconfigRootDir: this.linterOptions.cwd,
};
this.xoConfig.push({
files: relativeFiles,
languageOptions: {
parser: typescriptParser,
parserOptions,
},
});
}
else {
const existingConfig = this.xoConfig[configIndex];
this.xoConfig[configIndex] = {
...existingConfig,
files: relativeFiles,
};
}
this.virtualFiles.clear();
for (const file of nextVirtualFiles) {
this.virtualFiles.add(file);
}
return;
}
if (configIndex >= 0) {
this.xoConfig.splice(configIndex, 1);
}
this.virtualFiles.clear();
await fs.rm(tsconfigPath, { force: true });
}
catch (error) {
console.warn('XO: Failed to create tsconfig for virtual files. Type-aware linting will be disabled for these files.', error instanceof Error ? error.message : String(error));
}
}
/**
Add existing files to the config with an in-memory TypeScript Program.
*/
addExistingFilesToConfig(files, program) {
if (!this.xoConfig || files.length === 0) {
return;
}
const parserOptions = {
project: false,
projectService: false,
};
if (program) {
parserOptions.programs = [program];
}
const config = {
files: files.map(file => path.relative(this.linterOptions.cwd, file)),
languageOptions: {
parser: typescriptParser,
parserOptions,
},
};
// IMPORTANT: All files intentionally share the same config object reference for memory efficiency.
// This prevents unbounded memory growth in long-running processes (e.g., language servers).
// The config is immutable after creation, so sharing is safe.
// Deduplication happens in setEslintConfig() via Set to avoid duplicate configs in the final array.
for (const file of files) {
this.fileConfigs.set(file, config);
}
}
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