Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
16 changes: 12 additions & 4 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ if (!configXoTypescript[4]) {
throw new Error('Invalid eslint-config-xo-typescript');
}

const baseLanguageOptions = configXoTypescript[0]?.languageOptions;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unnecessary-type-assertion
const baseParserOptions = baseLanguageOptions?.['parserOptions'] as unknown as Linter.ParserOptions | undefined;

const typescriptLanguageOptions = configXoTypescript[4]?.languageOptions;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unnecessary-type-assertion
const typescriptParserOptions = typescriptLanguageOptions?.['parserOptions'] as unknown as Linter.ParserOptions | undefined;

/**
The base config that XO builds on top of from user options.
*/
Expand Down Expand Up @@ -53,10 +61,10 @@ export const config: Linter.Config[] = [
...globals.es2021,
...globals.node,
},
ecmaVersion: configXoTypescript[0]?.languageOptions?.ecmaVersion,
sourceType: configXoTypescript[0]?.languageOptions?.sourceType,
ecmaVersion: baseLanguageOptions?.['ecmaVersion'],
sourceType: baseLanguageOptions?.['sourceType'],
parserOptions: {
...configXoTypescript[0]?.languageOptions?.parserOptions,
...baseParserOptions,
},
},
settings: {
Expand Down Expand Up @@ -381,7 +389,7 @@ export const config: Linter.Config[] = [
languageOptions: {
...configXoTypescript[4]?.languageOptions,
parserOptions: {
...configXoTypescript[4]?.languageOptions?.parserOptions,
...typescriptParserOptions,
// This needs to be explicitly set to `true`
projectService: true,
},
Expand Down
124 changes: 110 additions & 14 deletions lib/handle-ts-files.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,78 @@
import path from 'node:path';
import fs from 'node:fs/promises';
import fs from 'node:fs';
import ts from 'typescript';
import {getTsconfig, createFilesMatcher} from 'get-tsconfig';
import {tsconfigDefaults as tsConfig, cacheDirName} from './constants.js';
import {tsconfigDefaults} from './constants.js';

const createInMemoryProgram = (files: string[], cwd: string): ts.Program | undefined => {
if (files.length === 0) {
return undefined;
}

try {
const compilerOptions = getFallbackCompilerOptions(cwd);
const program = ts.createProgram(files, {...compilerOptions});
Object.defineProperty(program, 'toJSON', {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Always got stuck on the serialization errors in the past with this. This is really cool.

value: () => ({
__type: 'TypeScriptProgram',
files: files.map(file => path.relative(cwd, file)),
}),
configurable: true,
});

return program;
} catch (error) {
console.warn(
'XO: Failed to create TypeScript Program for type-aware linting. Continuing without type information for unincluded files.',
error instanceof Error ? error.message : String(error),
);
return undefined;
}
};

const fallbackCompilerOptionsCache = new Map<string, ts.CompilerOptions>();

const getFallbackCompilerOptions = (cwd: string): ts.CompilerOptions => {
const cacheKey = path.resolve(cwd);
const cached = fallbackCompilerOptionsCache.get(cacheKey);

if (cached) {
return cached;
}

const compilerOptionsResult = ts.convertCompilerOptionsFromJson(
tsconfigDefaults.compilerOptions ?? {},
cacheKey,
);

if (compilerOptionsResult.errors.length > 0) {
throw new Error('XO: Invalid default TypeScript compiler options');
}

const compilerOptions: ts.CompilerOptions = {
...compilerOptionsResult.options,
esModuleInterop: true,
resolveJsonModules: true,
allowJs: true,
skipLibCheck: true,
skipDefaultLibCheck: true,
};

fallbackCompilerOptionsCache.set(cacheKey, compilerOptions);
return compilerOptions;
};

/**
This function checks if the files are matched by the tsconfig include, exclude, and it returns the unmatched files.

If no tsconfig is found, it will create a fallback tsconfig file in the `node_modules/.cache/xo` directory.
If no tsconfig is found, it will create an in-memory TypeScript Program for type-aware linting.

@param options
@returns The unmatched files.
@returns The unmatched files and an in-memory TypeScript Program.
*/
export async function handleTsconfig({cwd, files}: {cwd: string; files: string[]}) {
export function handleTsconfig({files, cwd, cacheLocation}: {files: string[]; cwd: string; cacheLocation?: string}) {
const unincludedFiles: string[] = [];
const filesMatcherCache = new Map<string, ReturnType<typeof createFilesMatcher>>();

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

const filesMatcher = createFilesMatcher(result);
const cacheKey = result.path ? path.resolve(result.path) : filePath;
let filesMatcher = filesMatcherCache.get(cacheKey);

if (!filesMatcher) {
filesMatcher = createFilesMatcher(result);
filesMatcherCache.set(cacheKey, filesMatcher);
}

if (filesMatcher(filePath)) {
continue;
Expand All @@ -31,17 +97,47 @@ export async function handleTsconfig({cwd, files}: {cwd: string; files: string[]
unincludedFiles.push(filePath);
}

const fallbackTsConfigPath = path.join(cwd, 'node_modules', '.cache', cacheDirName, 'tsconfig.xo.json');
if (unincludedFiles.length === 0) {
return {existingFiles: [], virtualFiles: [], program: undefined};
}

// Separate real files from virtual/cache files
// Virtual files include: stdin files (in cache dir), non-existent files
// TypeScript will surface opaque diagnostics for missing files; pre-filter so we only pay the program cost for real files.
const existingFiles: string[] = [];
const virtualFiles: string[] = [];

tsConfig.files = unincludedFiles;
for (const file of unincludedFiles) {
const fileExists = fs.existsSync(file);

if (unincludedFiles.length > 0) {
try {
await fs.writeFile(fallbackTsConfigPath, JSON.stringify(tsConfig, null, 2));
} catch (error) {
console.error(error);
// Files that don't exist are always virtual
if (!fileExists) {
virtualFiles.push(file);
continue;
}

// Check if file is in cache directory (like stdin files)
// These need tsconfig treatment even though they exist on disk
if (cacheLocation) {
const absolutePath = path.resolve(file);
const cacheRoot = path.resolve(cacheLocation);
const relativeToCache = path.relative(cacheRoot, absolutePath);

// File is inside cache if relative path doesn't escape (no '..')
const isInCache = !relativeToCache.startsWith('..') && !path.isAbsolute(relativeToCache);

if (isInCache) {
virtualFiles.push(file);
continue;
}
}

existingFiles.push(file);
}

return {unincludedFiles, fallbackTsConfigPath};
return {
existingFiles,
virtualFiles,
program: createInMemoryProgram(existingFiles, cwd),
};
}
49 changes: 37 additions & 12 deletions lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@ import {
jsFilesGlob,
} from './constants.js';

type LanguageOptionsWithParser = Linter.LanguageOptions & {parser?: Linter.Parser};

type TypeScriptParserOptions = Linter.ParserOptions & {
project?: string | string[];
projectService?: boolean;
tsconfigRootDir?: string;
programs?: unknown[];
};

const typescriptParserConfig = configXoTypescript.find(config => {
const languageOptions = config.languageOptions as LanguageOptionsWithParser | undefined;
return languageOptions?.parser;
});

export const typescriptParser = (typescriptParserConfig?.languageOptions as LanguageOptionsWithParser | undefined)?.parser;

if (!typescriptParser) {
throw new Error('XO: Failed to locate TypeScript parser in eslint-config-xo-typescript');
}

/**
Convert a `xo` config item to an ESLint config item.

Expand Down Expand Up @@ -71,14 +91,17 @@ This includes ensuring that user-defined properties can override XO defaults, an
@param xoConfig - The flat XO config to pre-process.
@returns The pre-processed flat XO config.
*/
export const preProcessXoConfig = (xoConfig: XoConfigItem[]): // eslint-disable-line complexity
export const preProcessXoConfig = (xoConfig: XoConfigItem[]):
{config: XoConfigItem[]; tsFilesGlob: string[]; tsFilesIgnoresGlob: string[]} => {
const tsFilesGlob: string[] = [];
const tsFilesIgnoresGlob: string[] = [];

const processedConfig: XoConfigItem[] = [];

for (const [idx, {...config}] of xoConfig.entries()) {
const languageOptions = config.languageOptions as Linter.LanguageOptions | undefined;
const parserOptions = languageOptions?.parserOptions as TypeScriptParserOptions | undefined;

// We can skip the first config item, as it is the base config item.
if (idx === 0) {
processedConfig.push(config);
Expand All @@ -89,8 +112,10 @@ export const preProcessXoConfig = (xoConfig: XoConfigItem[]): // eslint-disable-
// typescript-eslint rules set to "off" are ignored and not applied to JS files.
if (
config.rules
&& !config.languageOptions?.parser
&& !config.languageOptions?.parserOptions?.['project']
// eslint-disable-next-line @typescript-eslint/dot-notation
&& !languageOptions?.['parser']
&& parserOptions?.project === undefined
&& parserOptions?.programs === undefined
&& !config.plugins?.['@typescript-eslint']
) {
const hasTsRules = Object.entries(config.rules).some(rulePair => {
Expand Down Expand Up @@ -123,26 +148,26 @@ export const preProcessXoConfig = (xoConfig: XoConfigItem[]): // eslint-disable-
}

if (isAppliedToJsFiles) {
config.languageOptions ??= {};
const updatedLanguageOptions: Linter.LanguageOptions = languageOptions
? {...languageOptions, parser: typescriptParser}
: {parser: typescriptParser};
config.languageOptions = updatedLanguageOptions;
config.plugins ??= {};
config.plugins = {
...config.plugins,
...configXoTypescript[1]?.plugins,
};
config.languageOptions.parser = configXoTypescript[1]?.languageOptions?.parser;
tsFilesGlob.push(...arrify(config.files ?? allFilesGlob));
tsFilesIgnoresGlob.push(...arrify(config.ignores));
}
}
}

// If a user sets the `parserOptions.project` or `projectService` or `tsconfigRootDir`, we need to ensure that the tsFilesGlob is set to exclude those files,
// as this indicates the user has opted out of the default TypeScript handling for those files.
if (
config.languageOptions?.parserOptions?.['project'] !== undefined
|| config.languageOptions?.parserOptions?.['projectService'] !== undefined
|| config.languageOptions?.parserOptions?.['tsconfigRootDir'] !== undefined
) {
// If the config sets `parserOptions.project`, `projectService`, `tsconfigRootDir`, or `programs`, treat those files as opt-out for XO's automatic program wiring.
if (parserOptions?.project !== undefined
|| parserOptions?.projectService !== undefined
|| parserOptions?.tsconfigRootDir !== undefined
|| parserOptions?.programs !== undefined) {
// The glob itself should NOT be negated
tsFilesIgnoresGlob.push(...arrify(config.files ?? allFilesGlob));
}
Expand Down
Loading