Skip to content

Commit 122487b

Browse files
Improve tsconfig handling (#818)
Co-authored-by: Sindre Sorhus <sindresorhus@gmail.com>
1 parent e4e668f commit 122487b

File tree

7 files changed

+1206
-100
lines changed

7 files changed

+1206
-100
lines changed

‎cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const cli = meow(
2525
--config Path to a XO configuration file
2626
--semicolon Use semicolons [Default: true]
2727
--react Include React specific parsing and xo-react linting rules [Default: false]
28-
--prettier Format with prettier or turn off prettier conflicted rules when set to 'compat' [Default: false]
28+
--prettier Format with prettier or turn off Prettier-conflicted rules when set to 'compat' [Default: false]
2929
--print-config Print the effective ESLint config for the given file
3030
--version Print XO version
3131
--open Open files with issues in your editor

‎lib/handle-ts-files.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,6 @@ export async function handleTsconfig({cwd, files}: {cwd: string; files: string[]
3333

3434
const fallbackTsConfigPath = path.join(cwd, 'node_modules', '.cache', cacheDirName, 'tsconfig.xo.json');
3535

36-
delete tsConfig.include;
37-
delete tsConfig.exclude;
38-
delete tsConfig.files;
39-
4036
tsConfig.files = unincludedFiles;
4137

4238
if (unincludedFiles.length > 0) {

‎lib/utils.ts

Lines changed: 124 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1-
import arrify from 'arrify';
2-
import {type SetRequired} from 'type-fest';
1+
import path from 'node:path';
2+
import micromatch from 'micromatch';
33
import {type Linter} from 'eslint';
4-
import {allFilesGlob} from './constants.js';
4+
import {type SetRequired} from 'type-fest';
5+
import arrify from 'arrify';
6+
import configXoTypescript from 'eslint-config-xo-typescript';
57
import {type XoConfigItem} from './types.js';
8+
import {
9+
allFilesGlob,
10+
jsExtensions,
11+
jsFilesGlob,
12+
} from './constants.js';
613

714
/**
815
Convert a `xo` config item to an ESLint config item.
@@ -36,3 +43,117 @@ export const xoToEslintConfigItem = (xoConfig: XoConfigItem): SetRequired<Linter
3643

3744
return eslintConfig;
3845
};
46+
47+
/**
48+
Function used to match files which should be included in the `tsconfig.json` files.
49+
50+
@param cwd - The current working directory to resolve relative filepaths.
51+
@param files - The _absolute_ file paths to match against the globs.
52+
@param globs - The globs to match the files against.
53+
@param ignores - The globs to ignore when matching the files.
54+
@returns An array of file paths that match the globs and do not match the ignores.
55+
*/
56+
export const matchFilesForTsConfig = (cwd: string, files: string[], globs: string[], ignores: string[]) => micromatch(
57+
files.map(file => path.normalize(path.relative(cwd, file))),
58+
// https://github.com/micromatch/micromatch/issues/217
59+
globs.map(glob => path.normalize(glob)),
60+
{
61+
dot: true,
62+
ignore: ignores.map(file => path.normalize(file)),
63+
cwd,
64+
},
65+
).map(file => path.resolve(cwd, file));
66+
67+
/**
68+
Once a config is resolved, it is pre-processed to ensure that all properties are set correctly.
69+
70+
This includes ensuring that user-defined properties can override XO defaults, and that files are parsed correctly and performantly based on the users XO config.
71+
72+
@param xoConfig - The flat XO config to pre-process.
73+
@returns The pre-processed flat XO config.
74+
*/
75+
export const preProcessXoConfig = (xoConfig: XoConfigItem[]): // eslint-disable-line complexity
76+
{config: XoConfigItem[]; tsFilesGlob: string[]; tsFilesIgnoresGlob: string[]} => {
77+
const tsFilesGlob: string[] = [];
78+
const tsFilesIgnoresGlob: string[] = [];
79+
80+
const processedConfig: XoConfigItem[] = [];
81+
82+
for (const [idx, {...config}] of xoConfig.entries()) {
83+
// We can skip the first config item, as it is the base config item.
84+
if (idx === 0) {
85+
processedConfig.push(config);
86+
continue;
87+
}
88+
89+
// Use TS parser/plugin for JS files if the config contains TypeScript rules which are applied to JS files.
90+
// typescript-eslint rules set to "off" are ignored and not applied to JS files.
91+
if (
92+
config.rules
93+
&& !config.languageOptions?.parser
94+
&& !config.languageOptions?.parserOptions?.['project']
95+
&& !config.plugins?.['@typescript-eslint']
96+
) {
97+
const hasTsRules = Object.entries(config.rules).some(rulePair => {
98+
// If its not a @typescript-eslint rule, we don't care
99+
if (!rulePair[0].startsWith('@typescript-eslint/')) {
100+
return false;
101+
}
102+
103+
if (Array.isArray(rulePair[1])) {
104+
return rulePair[1]?.[0] !== 'off' && rulePair[1]?.[0] !== 0;
105+
}
106+
107+
return rulePair[1] !== 'off'
108+
&& rulePair[1] !== 0;
109+
});
110+
111+
if (hasTsRules) {
112+
let isAppliedToJsFiles = false;
113+
114+
if (config.files) {
115+
const normalizedFiles = arrify(config.files).map(file => path.normalize(file));
116+
// Strip the basename off any globs
117+
const globs = normalizedFiles.map(file => micromatch.scan(file, {dot: true}).glob).filter(Boolean);
118+
// Check if the files globs match a test file with a js extension
119+
// If not, check that the file paths match a js extension
120+
isAppliedToJsFiles = micromatch.some(jsExtensions.map(ext => `test.${ext}`), globs, {dot: true})
121+
|| micromatch.some(normalizedFiles, jsFilesGlob, {dot: true});
122+
} else if (config.files === undefined) {
123+
isAppliedToJsFiles = true;
124+
}
125+
126+
if (isAppliedToJsFiles) {
127+
config.languageOptions ??= {};
128+
config.plugins ??= {};
129+
config.plugins = {
130+
...config.plugins,
131+
...configXoTypescript[1]?.plugins,
132+
};
133+
config.languageOptions.parser = configXoTypescript[1]?.languageOptions?.parser;
134+
tsFilesGlob.push(...arrify(config.files ?? allFilesGlob));
135+
tsFilesIgnoresGlob.push(...arrify(config.ignores));
136+
}
137+
}
138+
}
139+
140+
// If a user sets the `parserOptions.project` or `projectService` or `tsconfigRootDir`, we need to ensure that the tsFilesGlob is set to exclude those files,
141+
// as this indicates the user has opted out of the default TypeScript handling for those files.
142+
if (
143+
config.languageOptions?.parserOptions?.['project'] !== undefined
144+
|| config.languageOptions?.parserOptions?.['projectService'] !== undefined
145+
|| config.languageOptions?.parserOptions?.['tsconfigRootDir'] !== undefined
146+
) {
147+
// The glob itself should NOT be negated
148+
tsFilesIgnoresGlob.push(...arrify(config.files ?? allFilesGlob));
149+
}
150+
151+
processedConfig.push(config);
152+
}
153+
154+
return {
155+
config: processedConfig,
156+
tsFilesGlob,
157+
tsFilesIgnoresGlob,
158+
};
159+
};

‎lib/xo.ts

Lines changed: 20 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import findCacheDirectory from 'find-cache-directory';
66
import {globby} from 'globby';
77
import arrify from 'arrify';
88
import defineLazyProperty from 'define-lazy-prop';
9-
import micromatch from 'micromatch';
109
import prettier from 'prettier';
1110
import {
1211
type XoLintResult,
@@ -20,12 +19,11 @@ import {
2019
cacheDirName,
2120
allExtensions,
2221
tsFilesGlob,
23-
allFilesGlob,
2422
} from './constants.js';
2523
import {xoToEslintConfig} from './xo-to-eslint.js';
2624
import resolveXoConfig from './resolve-config.js';
2725
import {handleTsconfig} from './handle-ts-files.js';
28-
// Import {handleTsconfig} from './handle-ts-files-typescript.js';
26+
import {matchFilesForTsConfig, preProcessXoConfig} from './utils.js';
2927

3028
export class Xo {
3129
/**
@@ -127,7 +125,7 @@ export class Xo {
127125
flatConfigPath?: string | undefined;
128126

129127
/**
130-
If any user configs container Prettier, we will need to fetch the Prettier config.
128+
If any user configs contains Prettier, we will need to fetch the Prettier config.
131129
*/
132130
prettier?: boolean;
133131

@@ -136,6 +134,18 @@ export class Xo {
136134
*/
137135
prettierConfig?: prettier.Options;
138136

137+
/**
138+
The glob pattern for TypeScript files, for which we will handle TS files and tsconfig.
139+
140+
We expand this based on the XO config and the files glob patterns.
141+
*/
142+
tsFilesGlob: string[] = [tsFilesGlob];
143+
144+
/**
145+
We use this to also add negative glob patterns in case a user overrides the parserOptions in their XO config.
146+
*/
147+
tsFilesIgnoresGlob: string[] = [];
148+
139149
constructor(_linterOptions: LinterOptions, _baseXoConfig: XoConfigOptions = {}) {
140150
this.linterOptions = _linterOptions;
141151
this.baseXoConfig = _baseXoConfig;
@@ -164,63 +174,14 @@ export class Xo {
164174
...this.linterOptions,
165175
});
166176

167-
this.xoConfig = [
177+
const {config, tsFilesGlob, tsFilesIgnoresGlob} = preProcessXoConfig([
168178
this.baseXoConfig,
169179
...flatOptions,
170-
];
171-
172-
// Split off the TS rules in a special case, so that you won't get errors
173-
// for JS files when the TS rules are not in the config.
174-
this.xoConfig = this.xoConfig.flatMap(config => {
175-
// If the user does not specify files, then we can assume they want everything to work correctly and
176-
// for rules to apply to all files. However, TS rules will error with JS files, so we need to split them off.
177-
// if the user supplies files, then we cannot make the same assumption, so we will not split them off.
178-
if (config.files) {
179-
return config;
180-
}
181-
182-
const ruleEntries = Object.entries(config.rules ?? {});
183-
const otherRules: Array<[string, Linter.RuleEntry]> = [];
184-
const tsRules: Array<[string, Linter.RuleEntry]> = [];
185-
186-
for (const [rule, ruleValue] of ruleEntries) {
187-
if (!rule || !ruleValue) {
188-
continue;
189-
}
190-
191-
if (rule.startsWith('@typescript-eslint')) {
192-
tsRules.push([rule, ruleValue]);
193-
} else {
194-
otherRules.push([rule, ruleValue]);
195-
}
196-
}
197-
198-
// If no TS rules, return the config as is
199-
if (tsRules.length === 0) {
200-
return config;
201-
}
202-
203-
// If there are TS rules, we need to split them off into a new config
204-
const tsConfig: XoConfigItem = {
205-
...config,
206-
rules: Object.fromEntries(tsRules),
207-
};
208-
209-
// Apply TS rules to all files
210-
tsConfig.files = [tsFilesGlob];
211-
212-
const otherConfig: XoConfigItem = {
213-
...config,
214-
// Set the other rules to the original config
215-
rules: Object.fromEntries(otherRules),
216-
};
217-
218-
// These rules should still apply to all files
219-
otherConfig.files = [allFilesGlob];
220-
221-
return [tsConfig, otherConfig];
222-
});
180+
]);
223181

182+
this.xoConfig = config;
183+
this.tsFilesGlob.push(...tsFilesGlob);
184+
this.tsFilesIgnoresGlob.push(...tsFilesIgnoresGlob);
224185
this.prettier = this.xoConfig.some(config => config.prettier);
225186
this.prettierConfig = await prettier.resolveConfig(flatConfigPath, {editorconfig: true}) ?? {};
226187
this.flatConfigPath = flatConfigPath;
@@ -278,7 +239,7 @@ export class Xo {
278239
return;
279240
}
280241

281-
const tsFiles = files.filter(file => micromatch.isMatch(file, tsFilesGlob, {dot: true}));
242+
const tsFiles = matchFilesForTsConfig(this.linterOptions.cwd, files, this.tsFilesGlob, this.tsFilesIgnoresGlob);
282243

283244
if (tsFiles.length === 0) {
284245
return;

‎readme.md

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ It uses [ESLint](https://eslint.org) underneath, so issues regarding built-in ru
3434
- Open all files with errors at the correct line in your editor with `$ xo --open`.
3535
- Specify [indent](#space) and [semicolon](#semicolon) preferences easily without messing with the rule config.
3636
- Optionally use the [Prettier](https://github.com/prettier/prettier) code style or turn off all Prettier rules with the `compat` option.
37-
- Optionally use `eslint-config-xo-react` for easy jsx and react linting with zero config.
37+
- Optionally use `eslint-config-xo-react` for easy JSX and React linting with zero config.
3838
- Optionally use with ESLint [directly](#usage-as-an-eslint-configuration)
3939
- Great [editor plugins](#editor-plugins).
4040

@@ -63,7 +63,7 @@ $ xo --help
6363
--config Path to a XO configuration file
6464
--semicolon Use semicolons [Default: true]
6565
--react Include React specific parsing and xo-react linting rules [Default: false]
66-
--prettier Format with prettier or turn off prettier conflicted rules when set to 'compat' [Default: false]
66+
--prettier Format with prettier or turn off prettier-conflicted rules when set to 'compat' [Default: false]
6767
--print-config Print the effective ESLint config for the given file
6868
--version Print XO version
6969
--open Open files with issues in your editor
@@ -108,7 +108,7 @@ Simply run `$ npm init xo` (with any options) to add XO to create an `xo.config.
108108

109109
## Config
110110

111-
You can configure XO options by creating an `xo.config.js` or an `xo.config.ts` file in the root directory of your project. XO supports all js/ts file extensions (js,cjs,mjs,ts,cts,mts) automatically. A XO config is an extension of ESLint's Flat Config. Like ESLint, an XO config exports an array of XO config objects. XO config objects extend [ESLint Configuration Objects](https://eslint.org/docs/latest/use/configure/configuration-files#configuration-objects). This means all the available configuration params for ESLint also work for `XO`. However, `XO` enhances and adds extra params to the configuration objects to make them easier to work with.
111+
You can configure XO options by creating an `xo.config.js` or an `xo.config.ts` file in the root directory of your project, or you can add an `xo` field to your `package.json`. XO supports all js/ts file extensions (js,cjs,mjs,ts,cts,mts) automatically. A XO config is an extension of ESLint's Flat Config. Like ESLint, an XO config exports an array of XO config objects. XO config objects extend [ESLint Configuration Objects](https://eslint.org/docs/latest/use/configure/configuration-files#configuration-objects). This means all the available configuration params for ESLint also work for `XO`. However, `XO` enhances and adds extra params to the configuration objects to make them easier to work with.
112112

113113
### Config types
114114

@@ -124,24 +124,32 @@ const xoConfig = [...]
124124

125125
`xo.config.ts`
126126

127-
```js
127+
```ts
128128
import {type FlatXoConfig} from 'xo';
129129

130130
const xoConfig: FlatXoConfig = [...]
131131
```
132132

133+
```ts
134+
export default [...] satisfies import('xo').FlatXoConfig
135+
```
136+
133137
### files
134138

135139
Type: `string | string[] | undefined`\
136140
Default: `**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}`
137141

138142
A glob or array of glob strings which the config object will apply. By default `XO` will apply the configuration to [all files](lib/constants.ts).
139143

144+
> Tip: If you are adding additional `@typescript-eslint` rules to your config, these rules will apply to JS files as well unless you separate them appropriately with the `files` option. `@typescript-eslint` rules set to `'off'` or `0`, however, will have no effect on JS linting.
145+
140146
### ignores
141147

142148
Type: `string[]`
143149

144-
Some [paths](lib/constants.ts) are ignored by default, including paths in `.gitignore`. Additional ignores can be added here. For global ignores, keep `ignores` as the only key in the config item.
150+
Some [paths](lib/constants.ts) are ignored by default, including paths in `.gitignore`. Additional ignores can be added here.
151+
152+
> Tip: For *global* ignores, keep `ignores` as the only key in the config item. You can optionally set a `name` property. Adding more properties will cause ignores to be scoped down to your files selection, which may have unexpected effects.
145153
146154
### space
147155

@@ -202,15 +210,17 @@ XO will automatically lint TypeScript files (`.ts`, `.mts`, `.cts`, and `.tsx`)
202210

203211
XO will handle the [@typescript-eslint/parser `project` option](https://typescript-eslint.io/packages/parser/#project) automatically even if you don't have a `tsconfig.json` in your project.
204212

213+
You can opt out of XO's automatic tsconfig handling by specifying your own `languageOptions.parserOptions.project`, `languageOptions.parserOptions.projectService`, or `languageOptions.parserOptions.tsconfigRootDir`. Files in a config with these properties will be excluded from automatic tsconfig handling.
214+
205215
## Usage as an ESLint Configuration
206216

207217
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.
208218

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

211-
### `xoToEslintConfig`
221+
### xoToEslintConfig
212222

213-
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.
223+
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.
214224

215225
`eslint.config.js`
216226

0 commit comments

Comments
 (0)