Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions lib/eslint-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import {type Linter} from 'eslint';
import {Xo} from './xo.js';

const eslintConfigNames = [
'eslint.config.js',
'eslint.config.mjs',
'eslint.config.cjs',
'eslint.config.ts',
'eslint.config.mts',
'eslint.config.cts',
];

function findEslintConfigDirectory(cwd: string): string | undefined {
let currentDirectory = cwd;

for (;;) {
for (const configName of eslintConfigNames) {
if (fs.existsSync(path.join(currentDirectory, configName))) {
return currentDirectory;
}
}

const parentDirectory = path.dirname(currentDirectory);

if (parentDirectory === currentDirectory) {
return undefined;
}

currentDirectory = parentDirectory;
}
}

function resolveAdapterCwd(): string {
const inlineConfigPath = process.argv.find(argument => argument.startsWith('--config=') || argument.startsWith('-c='))?.split('=').slice(1).join('=');

if (inlineConfigPath) {
return path.dirname(path.resolve(process.cwd(), inlineConfigPath));
}

const configFlagIndex = process.argv.findIndex(argument => argument === '--config' || argument === '-c');
const configPath = configFlagIndex === -1 ? undefined : process.argv[configFlagIndex + 1];

if (configPath) {
return path.dirname(path.resolve(process.cwd(), configPath));
}

return findEslintConfigDirectory(process.cwd()) ?? process.cwd();
}

/*
Keep the adapter small: resolve XO relative to the ESLint config location, then reuse XO's existing project config pipeline.

This is a snapshot of the current project files, not a long-lived parser shim for files created after the adapter is imported.
*/
const eslintConfig: Linter.Config[] = await new Xo({cwd: resolveAdapterCwd(), ts: true}).getProjectEslintConfig();

export default eslintConfig;
1 change: 1 addition & 0 deletions lib/xo-to-eslint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ const hoistPlugins = (configs: Linter.Config[], userPluginOverrides: Map<string,
];
};

// TODO: Remove this export if no one complains. Users should use `xo/eslint-adapter` instead.
/**
Takes a XO flat config and returns an ESlint flat config.
*/
Expand Down
114 changes: 70 additions & 44 deletions lib/xo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,11 +251,6 @@ const discoverLintFiles = async ({cwd, globs, positiveGlobalIgnores, discoveryIg
};

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.
*/
Expand Down Expand Up @@ -502,13 +497,7 @@ export class Xo {
Initializes the ESLint instance on the XO instance.
*/
public async initEslint(files?: string[], cliIgnores: string[] = arrify(this.#baseXoConfig.ignores), stripDefaultIgnores = false) {
await this.setXoConfig();

await this.ensureCacheDirectory();

await this.handleUnincludedTsFiles(files);

this.setEslintConfig(cliIgnores, stripDefaultIgnores);
await this.prepareEslintConfig(files, cliIgnores, stripDefaultIgnores);

if (!this._xoConfig) {
throw new Error('"Xo.initEslint" failed');
Expand All @@ -533,6 +522,15 @@ export class Xo {
this.#eslint = new ESLint(eslintOptions);
}

/**
Create an ESLint flat config for editor integrations using the same XO pipeline as the CLI.
*/
public async getProjectEslintConfig(): Promise<Linter.Config[]> {
const {cliIgnores, files} = await this.discoverFiles([`**/*.{${allExtensions.join(',')}}`]);

return this.prepareEslintConfig(files, cliIgnores);
}

/**
Lints the files on the XO instance.

Expand All @@ -550,31 +548,8 @@ export class Xo {
// Dynamic glob patterns matching nothing is acceptable — the project may simply have no matching files yet.
// The default glob substitution above is always dynamic, so this is false when no globs were provided.
const hasExplicitFilePaths = globs.some(glob => !isDynamicPattern(glob));
await this.setXoConfig();

const cliIgnores = arrify(this.#baseXoConfig.ignores);
const configIgnores = (this._xoConfig ?? []).slice(1)
.filter(config => isGlobalIgnoreConfig(config))
.flatMap(config => arrify(config.ignores));
const globalIgnores = [...configIgnores, ...cliIgnores];
const positiveGlobalIgnores = globalIgnores.filter(pattern => !pattern.startsWith('!'));
const reopenedDefaultPatterns = getReopenedDefaultPatterns(globalIgnores);
const discoveryIgnores = [...configIgnores, ...cliIgnores];
const files = await discoverLintFiles({
cwd: this._linterOptions.cwd,
globs,
positiveGlobalIgnores,
discoveryIgnores,
reopenedDefaultPatterns,
});

if (this._linterOptions.suppressionsLocation) {
const suppressionsFilePath = path.resolve(this._linterOptions.cwd, this._linterOptions.suppressionsLocation);

if (!syncFs.existsSync(suppressionsFilePath)) {
throw createErrorWithExitCode(suppressionsFileMissingErrorMessage, 2);
}
}
const {cliIgnores, discoveryIgnores, files} = await this.discoverFiles(globs);
this.ensureSuppressionsFileExists();

await this.initEslint(files, cliIgnores, true);

Expand Down Expand Up @@ -610,13 +585,7 @@ export class Xo {
): Promise<XoLintResult> {
const {filePath, warnIgnored} = lintTextOptions;

if (this._linterOptions.suppressionsLocation) {
const suppressionsFilePath = path.resolve(this._linterOptions.cwd, this._linterOptions.suppressionsLocation);

if (!syncFs.existsSync(suppressionsFilePath)) {
throw createErrorWithExitCode(suppressionsFileMissingErrorMessage, 2);
}
}
this.ensureSuppressionsFileExists();

await this.initEslint([filePath]);

Expand Down Expand Up @@ -655,6 +624,63 @@ export class Xo {
return this.#eslint.loadFormatter(name);
}

/**
Initializes the ESLint flat config on the XO instance.
*/
private async prepareEslintConfig(files?: string[], cliIgnores: string[] = arrify(this.#baseXoConfig.ignores), stripDefaultIgnores = false): Promise<Linter.Config[]> {
await this.setXoConfig();

await this.ensureCacheDirectory();

await this.handleUnincludedTsFiles(files);

this.setEslintConfig(cliIgnores, stripDefaultIgnores);

if (!this.#eslintConfig) {
throw new Error('"Xo.prepareEslintConfig" failed');
}

return this.#eslintConfig;
}

private ensureSuppressionsFileExists(): void {
if (!this._linterOptions.suppressionsLocation) {
return;
}

const suppressionsFilePath = path.resolve(this._linterOptions.cwd, this._linterOptions.suppressionsLocation);

if (!syncFs.existsSync(suppressionsFilePath)) {
throw createErrorWithExitCode(suppressionsFileMissingErrorMessage, 2);
}
}

private async discoverFiles(globs: string[]): Promise<{cliIgnores: string[]; discoveryIgnores: string[]; files: string[]}> {
await this.setXoConfig();

const cliIgnores = arrify(this.#baseXoConfig.ignores);
const configIgnores = (this._xoConfig ?? []).slice(1)
.filter(config => isGlobalIgnoreConfig(config))
.flatMap(config => arrify(config.ignores));
const globalIgnores = [...configIgnores, ...cliIgnores];
const positiveGlobalIgnores = globalIgnores.filter(pattern => !pattern.startsWith('!'));
const reopenedDefaultPatterns = getReopenedDefaultPatterns(globalIgnores);
const discoveryIgnores = [...configIgnores, ...cliIgnores];
const files = await discoverLintFiles({
cwd: this._linterOptions.cwd,
globs,
positiveGlobalIgnores,
discoveryIgnores,
reopenedDefaultPatterns,
});

return {
cliIgnores,
discoveryIgnores,
files,
};
}

/**
Add virtual files to the config with a tsconfig approach.
*/
Expand Down
10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,14 @@
"type": "module",
"bin": "./dist/cli.js",
"exports": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./eslint-adapter": {
"types": "./dist/lib/eslint-adapter.d.ts",
"default": "./dist/lib/eslint-adapter.js"
}
},
"sideEffects": false,
"engines": {
Expand Down
14 changes: 5 additions & 9 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,20 +312,16 @@ You can opt out of XO's automatic tsconfig handling by specifying your own `lang

## Usage as an ESLint Configuration

With the introduction of the ESLint flat config, many of the original goals of `xo` were brought into the ESLint core, and shareable configs with plugins became possible. Although we highly recommend the use of the `xo` cli, we understand that some teams need to rely on ESLint directly.
If you want to use ESLint directly without the `xo` CLI, use [`eslint-config-xo`](https://github.com/xojs/eslint-config-xo).

For these purposes, you can still get most of the features of `xo` by using our ESLint configuration helpers.
### ESLint adapter

### xoToEslintConfig

The `xoToEslintConfig` function is designed for use in an `eslint.config.js` file. It is NOT for use in an `xo.config.js` file. This function takes a `FlatXoConfig` and outputs an ESLint config object. This function will neither be able to automatically handle TS integration for you nor automatic Prettier integration. You are responsible for configuring your other tools appropriately. The `xo` cli, will, however, handle all of these details for you.
If you already use the `xo` CLI and want your editor's ESLint integration to match exactly, use the `xo/eslint-adapter` subpath export. It automatically reads your `xo.config.js` and converts it to an ESLint flat config.
Comment thread
spence-s marked this conversation as resolved.
Outdated

`eslint.config.js`

```js
import xo from 'xo';

export default xo.xoToEslintConfig([{space: true, prettier: 'compat'}]);
export {default} from 'xo/eslint-adapter';
```

## Tips
Expand Down Expand Up @@ -360,7 +356,7 @@ When XO finds errors, warnings are automatically hidden to reduce noise and let

XO automatically respects an [`eslint-suppressions.json`](https://eslint.org/docs/latest/use/suppressions) file if one exists in the working directory. This lets you suppress existing violations while still enforcing rules on new code — useful for incrementally adopting stricter rules in a large codebase.

To generate the suppressions file, create a temporary `eslint.config.js` using [`xoToEslintConfig`](#xotoeslintconfig) and run ESLint with `--suppress-all`:
To generate the suppressions file, create a temporary `eslint.config.js` using [`xo/eslint-adapter`](#eslint-adapter) and run ESLint with `--suppress-all`:

```sh
npx eslint --suppress-all
Expand Down
Loading
Loading