Skip to content

Commit b1acb03

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

File tree

9 files changed

+410
-117
lines changed

9 files changed

+410
-117
lines changed

lib/config.ts

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

28+
const baseLanguageOptions = configXoTypescript[0]?.languageOptions;
29+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unnecessary-type-assertion
30+
const baseParserOptions = baseLanguageOptions?.['parserOptions'] as unknown as Linter.ParserOptions | undefined;
31+
32+
const typescriptLanguageOptions = configXoTypescript[4]?.languageOptions;
33+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unnecessary-type-assertion
34+
const typescriptParserOptions = typescriptLanguageOptions?.['parserOptions'] as unknown as Linter.ParserOptions | undefined;
35+
2836
/**
2937
The base config that XO builds on top of from user options.
3038
*/
@@ -53,10 +61,10 @@ export const config: Linter.Config[] = [
5361
...globals.es2021,
5462
...globals.node,
5563
},
56-
ecmaVersion: configXoTypescript[0]?.languageOptions?.ecmaVersion,
57-
sourceType: configXoTypescript[0]?.languageOptions?.sourceType,
64+
ecmaVersion: baseLanguageOptions?.['ecmaVersion'],
65+
sourceType: baseLanguageOptions?.['sourceType'],
5866
parserOptions: {
59-
...configXoTypescript[0]?.languageOptions?.parserOptions,
67+
...baseParserOptions,
6068
},
6169
},
6270
settings: {
@@ -381,7 +389,7 @@ export const config: Linter.Config[] = [
381389
languageOptions: {
382390
...configXoTypescript[4]?.languageOptions,
383391
parserOptions: {
384-
...configXoTypescript[4]?.languageOptions?.parserOptions,
392+
...typescriptParserOptions,
385393
// This needs to be explicitly set to `true`
386394
projectService: true,
387395
},

lib/handle-ts-files.ts

Lines changed: 110 additions & 14 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, cacheLocation}: {files: string[]; cwd: string; cacheLocation?: 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,47 @@ 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');
100+
if (unincludedFiles.length === 0) {
101+
return {existingFiles: [], virtualFiles: [], program: undefined};
102+
}
103+
104+
// Separate real files from virtual/cache files
105+
// Virtual files include: stdin files (in cache dir), non-existent files
106+
// TypeScript will surface opaque diagnostics for missing files; pre-filter so we only pay the program cost for real files.
107+
const existingFiles: string[] = [];
108+
const virtualFiles: string[] = [];
35109

36-
tsConfig.files = unincludedFiles;
110+
for (const file of unincludedFiles) {
111+
const fileExists = fs.existsSync(file);
37112

38-
if (unincludedFiles.length > 0) {
39-
try {
40-
await fs.writeFile(fallbackTsConfigPath, JSON.stringify(tsConfig, null, 2));
41-
} catch (error) {
42-
console.error(error);
113+
// Files that don't exist are always virtual
114+
if (!fileExists) {
115+
virtualFiles.push(file);
116+
continue;
117+
}
118+
119+
// Check if file is in cache directory (like stdin files)
120+
// These need tsconfig treatment even though they exist on disk
121+
if (cacheLocation) {
122+
const absolutePath = path.resolve(file);
123+
const cacheRoot = path.resolve(cacheLocation);
124+
const relativeToCache = path.relative(cacheRoot, absolutePath);
125+
126+
// File is inside cache if relative path doesn't escape (no '..')
127+
const isInCache = !relativeToCache.startsWith('..') && !path.isAbsolute(relativeToCache);
128+
129+
if (isInCache) {
130+
virtualFiles.push(file);
131+
continue;
132+
}
43133
}
134+
135+
existingFiles.push(file);
44136
}
45137

46-
return {unincludedFiles, fallbackTsConfigPath};
138+
return {
139+
existingFiles,
140+
virtualFiles,
141+
program: createInMemoryProgram(existingFiles, cwd),
142+
};
47143
}

lib/utils.ts

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

13+
type LanguageOptionsWithParser = Linter.LanguageOptions & {parser?: Linter.Parser};
14+
15+
type TypeScriptParserOptions = Linter.ParserOptions & {
16+
project?: string | string[];
17+
projectService?: boolean;
18+
tsconfigRootDir?: string;
19+
programs?: unknown[];
20+
};
21+
22+
const typescriptParserConfig = configXoTypescript.find(config => {
23+
const languageOptions = config.languageOptions as LanguageOptionsWithParser | undefined;
24+
return languageOptions?.parser;
25+
});
26+
27+
export const typescriptParser = (typescriptParserConfig?.languageOptions as LanguageOptionsWithParser | undefined)?.parser;
28+
29+
if (!typescriptParser) {
30+
throw new Error('XO: Failed to locate TypeScript parser in eslint-config-xo-typescript');
31+
}
32+
1333
/**
1434
Convert a `xo` config item to an ESLint config item.
1535
@@ -71,14 +91,17 @@ This includes ensuring that user-defined properties can override XO defaults, an
7191
@param xoConfig - The flat XO config to pre-process.
7292
@returns The pre-processed flat XO config.
7393
*/
74-
export const preProcessXoConfig = (xoConfig: XoConfigItem[]): // eslint-disable-line complexity
94+
export const preProcessXoConfig = (xoConfig: XoConfigItem[]):
7595
{config: XoConfigItem[]; tsFilesGlob: string[]; tsFilesIgnoresGlob: string[]} => {
7696
const tsFilesGlob: string[] = [];
7797
const tsFilesIgnoresGlob: string[] = [];
7898

7999
const processedConfig: XoConfigItem[] = [];
80100

81101
for (const [idx, {...config}] of xoConfig.entries()) {
102+
const languageOptions = config.languageOptions as Linter.LanguageOptions | undefined;
103+
const parserOptions = languageOptions?.parserOptions as TypeScriptParserOptions | undefined;
104+
82105
// We can skip the first config item, as it is the base config item.
83106
if (idx === 0) {
84107
processedConfig.push(config);
@@ -89,8 +112,10 @@ export const preProcessXoConfig = (xoConfig: XoConfigItem[]): // eslint-disable-
89112
// typescript-eslint rules set to "off" are ignored and not applied to JS files.
90113
if (
91114
config.rules
92-
&& !config.languageOptions?.parser
93-
&& !config.languageOptions?.parserOptions?.['project']
115+
// eslint-disable-next-line @typescript-eslint/dot-notation
116+
&& !languageOptions?.['parser']
117+
&& parserOptions?.project === undefined
118+
&& parserOptions?.programs === undefined
94119
&& !config.plugins?.['@typescript-eslint']
95120
) {
96121
const hasTsRules = Object.entries(config.rules).some(rulePair => {
@@ -123,26 +148,26 @@ export const preProcessXoConfig = (xoConfig: XoConfigItem[]): // eslint-disable-
123148
}
124149

125150
if (isAppliedToJsFiles) {
126-
config.languageOptions ??= {};
151+
const updatedLanguageOptions: Linter.LanguageOptions = languageOptions
152+
? {...languageOptions, parser: typescriptParser}
153+
: {parser: typescriptParser};
154+
config.languageOptions = updatedLanguageOptions;
127155
config.plugins ??= {};
128156
config.plugins = {
129157
...config.plugins,
130158
...configXoTypescript[1]?.plugins,
131159
};
132-
config.languageOptions.parser = configXoTypescript[1]?.languageOptions?.parser;
133160
tsFilesGlob.push(...arrify(config.files ?? allFilesGlob));
134161
tsFilesIgnoresGlob.push(...arrify(config.ignores));
135162
}
136163
}
137164
}
138165

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-
) {
166+
// If the config sets `parserOptions.project`, `projectService`, `tsconfigRootDir`, or `programs`, treat those files as opt-out for XO's automatic program wiring.
167+
if (parserOptions?.project !== undefined
168+
|| parserOptions?.projectService !== undefined
169+
|| parserOptions?.tsconfigRootDir !== undefined
170+
|| parserOptions?.programs !== undefined) {
146171
// The glob itself should NOT be negated
147172
tsFilesIgnoresGlob.push(...arrify(config.files ?? allFilesGlob));
148173
}

0 commit comments

Comments
 (0)