Skip to content

Commit 8e7f90b

Browse files
committed
Simplify TypeScript handling by using in-memory programs
Fixes #839
1 parent de01ff1 commit 8e7f90b

File tree

9 files changed

+348
-114
lines changed

9 files changed

+348
-114
lines changed

lib/config.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ if (!configXoTypescript[4]) {
2525
throw new Error('Invalid eslint-config-xo-typescript');
2626
}
2727

28+
const isLanguageOptions = (value: unknown): value is Linter.LanguageOptions => typeof value === 'object' && value !== null;
29+
const isParserOptions = (value: unknown): value is Linter.ParserOptions => typeof value === 'object' && value !== null;
30+
31+
const maybeBaseLanguageOptions = configXoTypescript[0]?.languageOptions;
32+
const baseLanguageOptions = isLanguageOptions(maybeBaseLanguageOptions) ? maybeBaseLanguageOptions : undefined;
33+
const baseParserOptions = baseLanguageOptions && isParserOptions(baseLanguageOptions.parserOptions) ? baseLanguageOptions.parserOptions : undefined;
34+
35+
const maybeTypescriptLanguageOptions = configXoTypescript[4]?.languageOptions;
36+
const typescriptLanguageOptions = isLanguageOptions(maybeTypescriptLanguageOptions) ? maybeTypescriptLanguageOptions : undefined;
37+
const typescriptParserOptions = typescriptLanguageOptions && isParserOptions(typescriptLanguageOptions.parserOptions) ? typescriptLanguageOptions.parserOptions : undefined;
38+
2839
/**
2940
The base config that XO builds on top of from user options.
3041
*/
@@ -53,10 +64,10 @@ export const config: Linter.Config[] = [
5364
...globals.es2021,
5465
...globals.node,
5566
},
56-
ecmaVersion: configXoTypescript[0]?.languageOptions?.ecmaVersion,
57-
sourceType: configXoTypescript[0]?.languageOptions?.sourceType,
67+
ecmaVersion: baseLanguageOptions?.ecmaVersion,
68+
sourceType: baseLanguageOptions?.sourceType,
5869
parserOptions: {
59-
...configXoTypescript[0]?.languageOptions?.parserOptions,
70+
...baseParserOptions,
6071
},
6172
},
6273
settings: {
@@ -381,7 +392,7 @@ export const config: Linter.Config[] = [
381392
languageOptions: {
382393
...configXoTypescript[4]?.languageOptions,
383394
parserOptions: {
384-
...configXoTypescript[4]?.languageOptions?.parserOptions,
395+
...typescriptParserOptions,
385396
// This needs to be explicitly set to `true`
386397
projectService: true,
387398
},

lib/handle-ts-files.ts

Lines changed: 81 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,78 @@
11
import path from 'node:path';
2-
import fs from 'node:fs/promises';
2+
import fs from 'node:fs';
3+
import ts from 'typescript';
34
import {getTsconfig, createFilesMatcher} from 'get-tsconfig';
4-
import {tsconfigDefaults as tsConfig, cacheDirName} from './constants.js';
5+
import {tsconfigDefaults} from './constants.js';
6+
7+
const createInMemoryProgram = (files: string[], cwd: string): ts.Program | undefined => {
8+
if (files.length === 0) {
9+
return undefined;
10+
}
11+
12+
try {
13+
const compilerOptions = getFallbackCompilerOptions(cwd);
14+
const program = ts.createProgram(files, {...compilerOptions});
15+
Object.defineProperty(program, 'toJSON', {
16+
value: () => ({
17+
__type: 'TypeScriptProgram',
18+
files: files.map(file => path.relative(cwd, file)),
19+
}),
20+
configurable: true,
21+
});
22+
23+
return program;
24+
} catch (error) {
25+
console.warn(
26+
'XO: Failed to create TypeScript Program for type-aware linting. Continuing without type information for unincluded files.',
27+
error instanceof Error ? error.message : String(error),
28+
);
29+
return undefined;
30+
}
31+
};
32+
33+
const fallbackCompilerOptionsCache = new Map<string, ts.CompilerOptions>();
34+
35+
const getFallbackCompilerOptions = (cwd: string): ts.CompilerOptions => {
36+
const cacheKey = path.resolve(cwd);
37+
const cached = fallbackCompilerOptionsCache.get(cacheKey);
38+
39+
if (cached) {
40+
return cached;
41+
}
42+
43+
const compilerOptionsResult = ts.convertCompilerOptionsFromJson(
44+
tsconfigDefaults.compilerOptions ?? {},
45+
cacheKey,
46+
);
47+
48+
if (compilerOptionsResult.errors.length > 0) {
49+
throw new Error('XO: Invalid default TypeScript compiler options');
50+
}
51+
52+
const compilerOptions: ts.CompilerOptions = {
53+
...compilerOptionsResult.options,
54+
esModuleInterop: true,
55+
resolveJsonModules: true,
56+
allowJs: true,
57+
skipLibCheck: true,
58+
skipDefaultLibCheck: true,
59+
};
60+
61+
fallbackCompilerOptionsCache.set(cacheKey, compilerOptions);
62+
return compilerOptions;
63+
};
564

665
/**
766
This function checks if the files are matched by the tsconfig include, exclude, and it returns the unmatched files.
867
9-
If no tsconfig is found, it will create a fallback tsconfig file in the `node_modules/.cache/xo` directory.
68+
If no tsconfig is found, it will create an in-memory TypeScript Program for type-aware linting.
1069
1170
@param options
12-
@returns The unmatched files.
71+
@returns The unmatched files and an in-memory TypeScript Program.
1372
*/
14-
export async function handleTsconfig({cwd, files}: {cwd: string; files: string[]}) {
73+
export function handleTsconfig({files, cwd}: {files: string[]; cwd: string}) {
1574
const unincludedFiles: string[] = [];
75+
const filesMatcherCache = new Map<string, ReturnType<typeof createFilesMatcher>>();
1676

1777
for (const filePath of files) {
1878
const result = getTsconfig(filePath);
@@ -22,7 +82,13 @@ export async function handleTsconfig({cwd, files}: {cwd: string; files: string[]
2282
continue;
2383
}
2484

25-
const filesMatcher = createFilesMatcher(result);
85+
const cacheKey = result.path ? path.resolve(result.path) : filePath;
86+
let filesMatcher = filesMatcherCache.get(cacheKey);
87+
88+
if (!filesMatcher) {
89+
filesMatcher = createFilesMatcher(result);
90+
filesMatcherCache.set(cacheKey, filesMatcher);
91+
}
2692

2793
if (filesMatcher(filePath)) {
2894
continue;
@@ -31,17 +97,15 @@ export async function handleTsconfig({cwd, files}: {cwd: string; files: string[]
3197
unincludedFiles.push(filePath);
3298
}
3399

34-
const fallbackTsConfigPath = path.join(cwd, 'node_modules', '.cache', cacheDirName, 'tsconfig.xo.json');
35-
36-
tsConfig.files = unincludedFiles;
37-
38-
if (unincludedFiles.length > 0) {
39-
try {
40-
await fs.writeFile(fallbackTsConfigPath, JSON.stringify(tsConfig, null, 2));
41-
} catch (error) {
42-
console.error(error);
43-
}
100+
if (unincludedFiles.length === 0) {
101+
return {unincludedFiles, program: undefined};
44102
}
45103

46-
return {unincludedFiles, fallbackTsConfigPath};
104+
const existingFiles = unincludedFiles.filter(file => fs.existsSync(file));
105+
// TypeScript will surface opaque diagnostics for missing files; pre-filter so we only pay the program cost for real files.
106+
107+
return {
108+
unincludedFiles,
109+
program: createInMemoryProgram(existingFiles, cwd),
110+
};
47111
}

lib/utils.ts

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,20 @@ import {
1010
jsFilesGlob,
1111
} from './constants.js';
1212

13+
type TypeScriptParserOptions = Linter.ParserOptions & {
14+
project?: string | string[];
15+
projectService?: boolean;
16+
tsconfigRootDir?: string;
17+
programs?: unknown[];
18+
};
19+
20+
const isLanguageOptions = (value: unknown): value is Linter.LanguageOptions => typeof value === 'object' && value !== null;
21+
const isTypeScriptParserOptions = (value: unknown): value is TypeScriptParserOptions => typeof value === 'object' && value !== null;
22+
23+
const maybeTypescriptLanguageOptions = configXoTypescript[1]?.languageOptions;
24+
const typescriptLanguageOptions = isLanguageOptions(maybeTypescriptLanguageOptions) ? maybeTypescriptLanguageOptions : undefined;
25+
const typescriptParser = typescriptLanguageOptions?.parser;
26+
1327
/**
1428
Convert a `xo` config item to an ESLint config item.
1529
@@ -79,6 +93,11 @@ export const preProcessXoConfig = (xoConfig: XoConfigItem[]): // eslint-disable-
7993
const processedConfig: XoConfigItem[] = [];
8094

8195
for (const [idx, {...config}] of xoConfig.entries()) {
96+
const languageOptions = isLanguageOptions(config.languageOptions) ? config.languageOptions : undefined;
97+
const parserOptions = languageOptions && isTypeScriptParserOptions(languageOptions.parserOptions)
98+
? languageOptions.parserOptions
99+
: undefined;
100+
82101
// We can skip the first config item, as it is the base config item.
83102
if (idx === 0) {
84103
processedConfig.push(config);
@@ -89,8 +108,9 @@ export const preProcessXoConfig = (xoConfig: XoConfigItem[]): // eslint-disable-
89108
// typescript-eslint rules set to "off" are ignored and not applied to JS files.
90109
if (
91110
config.rules
92-
&& !config.languageOptions?.parser
93-
&& !config.languageOptions?.parserOptions?.['project']
111+
&& !languageOptions?.parser
112+
&& parserOptions?.project === undefined
113+
&& parserOptions?.programs === undefined
94114
&& !config.plugins?.['@typescript-eslint']
95115
) {
96116
const hasTsRules = Object.entries(config.rules).some(rulePair => {
@@ -123,26 +143,26 @@ export const preProcessXoConfig = (xoConfig: XoConfigItem[]): // eslint-disable-
123143
}
124144

125145
if (isAppliedToJsFiles) {
126-
config.languageOptions ??= {};
146+
const updatedLanguageOptions: Linter.LanguageOptions = languageOptions
147+
? {...languageOptions, parser: typescriptParser}
148+
: {parser: typescriptParser};
149+
config.languageOptions = updatedLanguageOptions;
127150
config.plugins ??= {};
128151
config.plugins = {
129152
...config.plugins,
130153
...configXoTypescript[1]?.plugins,
131154
};
132-
config.languageOptions.parser = configXoTypescript[1]?.languageOptions?.parser;
133155
tsFilesGlob.push(...arrify(config.files ?? allFilesGlob));
134156
tsFilesIgnoresGlob.push(...arrify(config.ignores));
135157
}
136158
}
137159
}
138160

139-
// If a user sets the `parserOptions.project` or `projectService` or `tsconfigRootDir`, we need to ensure that the tsFilesGlob is set to exclude those files,
140-
// as this indicates the user has opted out of the default TypeScript handling for those files.
141-
if (
142-
config.languageOptions?.parserOptions?.['project'] !== undefined
143-
|| config.languageOptions?.parserOptions?.['projectService'] !== undefined
144-
|| config.languageOptions?.parserOptions?.['tsconfigRootDir'] !== undefined
145-
) {
161+
// If the config sets `parserOptions.project`, `projectService`, `tsconfigRootDir`, or `programs`, treat those files as opt-out for XO's automatic program wiring.
162+
if (parserOptions?.project !== undefined
163+
|| parserOptions?.projectService !== undefined
164+
|| parserOptions?.tsconfigRootDir !== undefined
165+
|| parserOptions?.programs !== undefined) {
146166
// The glob itself should NOT be negated
147167
tsFilesIgnoresGlob.push(...arrify(config.files ?? allFilesGlob));
148168
}

0 commit comments

Comments
 (0)