diff --git a/docs/architecture/Parser.mdx b/docs/architecture/Parser.mdx index 5039dc1bec55..39ade74168a0 100644 --- a/docs/architecture/Parser.mdx +++ b/docs/architecture/Parser.mdx @@ -45,7 +45,7 @@ interface ParserOptions { lib?: string[]; moduleResolver?: string; program?: import('typescript').Program; - project?: string | string[]; + project?: string | string[] | true; projectFolderIgnoreList?: string[]; tsconfigRootDir?: string; warnOnUnsupportedTypeScriptVersion?: boolean; @@ -188,6 +188,9 @@ This option allows you to provide a path to your project's `tsconfig.json`. **Th - Accepted values: ```js + // find the tsconfig.json nearest each source file + project: true, + // path project: './tsconfig.json'; @@ -198,6 +201,10 @@ This option allows you to provide a path to your project's `tsconfig.json`. **Th project: ['./packages/**/tsconfig.json', './separate-package/tsconfig.json']; ``` +- If `true`, each source file's parse will find the nearest `tsconfig.json` file to that source file. + + - This is done by checking that source file's directory tree for the nearest `tsconfig.json`. + - If you use project references, TypeScript will not automatically use project references to resolve files. This means that you will have to add each referenced tsconfig to the `project` field either separately, or via a glob. - Note that using wide globs `**` in your `parserOptions.project` may cause performance implications. Instead of globs that use `**` to recursively check all folders, prefer paths that use a single `*` at a time. For more info see [#2611](https://github.com/typescript-eslint/typescript-eslint/issues/2611). diff --git a/docs/architecture/TypeScript-ESTree.mdx b/docs/architecture/TypeScript-ESTree.mdx index f74aa65e2234..40d426062965 100644 --- a/docs/architecture/TypeScript-ESTree.mdx +++ b/docs/architecture/TypeScript-ESTree.mdx @@ -174,10 +174,11 @@ interface ParseAndGenerateServicesOptions extends ParseOptions { preserveNodeMaps?: boolean; /** - * Absolute (or relative to `tsconfigRootDir`) paths to the tsconfig(s). + * Absolute (or relative to `tsconfigRootDir`) paths to the tsconfig(s), + * or `true` to find the nearest tsconfig.json to the file. * If this is provided, type information will be returned. */ - project?: string | string[]; + project?: string | string[] | true; /** * If you provide a glob (or globs) to the project option, you can use this option to ignore certain folders from diff --git a/docs/linting/Typed_Linting.mdx b/docs/linting/Typed_Linting.mdx index 7d9de2f25bb1..a51afdad02b5 100644 --- a/docs/linting/Typed_Linting.mdx +++ b/docs/linting/Typed_Linting.mdx @@ -18,7 +18,7 @@ module.exports = { parser: '@typescript-eslint/parser', // Added lines start parserOptions: { - project: ['./tsconfig.json'], + project: true, tsconfigRootDir: __dirname, }, // Added lines end @@ -36,6 +36,31 @@ In more detail: With that done, run the same lint command you ran before. You may see new rules reporting errors based on type information! +## Specifying TSConfigs + +The `parserOptions.project` option can be turned on with either: + +- `true`: to always use `tsconfig.json`s nearest to source files +- `string | string[]`: any number of glob paths to match TSConfig files relative to the + +For example, if you use a specific `tsconfig.eslint.json` for linting, you'd specify: + +```js title=".eslintrc.js" +module.exports = { + // ... + parserOptions: { + project: './tsconfig.eslint.json', + }, + // ... +}; +``` + +See [the `@typescript-eslint/parser` docs for more details](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/README.md#parseroptionsproject). + +:::note +If your project is a multi-package monorepo, see [our docs on configuring a monorepo](./typed-linting/Monorepos.mdx). +::: + ## FAQs ### How is performance? diff --git a/docs/linting/typed-linting/Monorepos.mdx b/docs/linting/typed-linting/Monorepos.mdx index 7b01b81d6622..9d0d67e331fa 100644 --- a/docs/linting/typed-linting/Monorepos.mdx +++ b/docs/linting/typed-linting/Monorepos.mdx @@ -53,7 +53,7 @@ module.exports = { parserOptions: { tsconfigRootDir: __dirname, // Remove this line - project: ['./tsconfig.json'], + project: true, // Add this line project: ['./tsconfig.eslint.json', './packages/*/tsconfig.json'], }, diff --git a/packages/types/src/parser-options.ts b/packages/types/src/parser-options.ts index a7fe3ce3ab36..f77b601e0d05 100644 --- a/packages/types/src/parser-options.ts +++ b/packages/types/src/parser-options.ts @@ -52,7 +52,7 @@ interface ParserOptions { filePath?: string; loc?: boolean; program?: Program; - project?: string | string[]; + project?: string | string[] | true; projectFolderIgnoreList?: (string | RegExp)[]; range?: boolean; sourceType?: SourceType; diff --git a/packages/typescript-estree/src/parseSettings/ExpiringCache.ts b/packages/typescript-estree/src/parseSettings/ExpiringCache.ts index f296c9f5f590..e28506d1d9bd 100644 --- a/packages/typescript-estree/src/parseSettings/ExpiringCache.ts +++ b/packages/typescript-estree/src/parseSettings/ExpiringCache.ts @@ -3,15 +3,18 @@ import type { CacheDurationSeconds } from '@typescript-eslint/types'; export const DEFAULT_TSCONFIG_CACHE_DURATION_SECONDS = 30; const ZERO_HR_TIME: [number, number] = [0, 0]; +export interface CacheLike { + get(key: Key): Value | void; + set(key: Key, value: Value): this; +} + /** * A map with key-level expiration. */ -export class ExpiringCache { +export class ExpiringCache implements CacheLike { readonly #cacheDurationSeconds: CacheDurationSeconds; - /** - * The mapping of path-like string to the resolved TSConfig(s) - */ - protected readonly map = new Map< + + readonly #map = new Map< TKey, Readonly<{ value: TValue; @@ -24,7 +27,7 @@ export class ExpiringCache { } set(key: TKey, value: TValue): this { - this.map.set(key, { + this.#map.set(key, { value, lastSeen: this.#cacheDurationSeconds === 'Infinity' @@ -36,7 +39,7 @@ export class ExpiringCache { } get(key: TKey): TValue | undefined { - const entry = this.map.get(key); + const entry = this.#map.get(key); if (entry?.value != null) { if (this.#cacheDurationSeconds === 'Infinity') { return entry.value; @@ -48,22 +51,14 @@ export class ExpiringCache { return entry.value; } else { // key has expired - clean it up to free up memory - this.cleanupKey(key); + this.#map.delete(key); } } // no hit :'( return undefined; } - protected cleanupKey(key: TKey): void { - this.map.delete(key); - } - - get size(): number { - return this.map.size; - } - clear(): void { - this.map.clear(); + this.#map.clear(); } } diff --git a/packages/typescript-estree/src/parseSettings/createParseSettings.ts b/packages/typescript-estree/src/parseSettings/createParseSettings.ts index e7267a852686..028088765a28 100644 --- a/packages/typescript-estree/src/parseSettings/createParseSettings.ts +++ b/packages/typescript-estree/src/parseSettings/createParseSettings.ts @@ -2,6 +2,11 @@ import debug from 'debug'; import { ensureAbsolutePath } from '../create-program/shared'; import type { TSESTreeOptions } from '../parser-options'; +import { + DEFAULT_TSCONFIG_CACHE_DURATION_SECONDS, + ExpiringCache, +} from './ExpiringCache'; +import { getProjectConfigFiles } from './getProjectConfigFiles'; import type { MutableParseSettings } from './index'; import { inferSingleRun } from './inferSingleRun'; import { resolveProjectList } from './resolveProjectList'; @@ -11,10 +16,13 @@ const log = debug( 'typescript-eslint:typescript-estree:parser:parseSettings:createParseSettings', ); +let TSCONFIG_MATCH_CACHE: ExpiringCache | null; + export function createParseSettings( code: string, options: Partial = {}, ): MutableParseSettings { + const singleRun = inferSingleRun(options); const tsconfigRootDir = typeof options.tsconfigRootDir === 'string' ? options.tsconfigRootDir @@ -58,8 +66,14 @@ export function createParseSettings( programs: Array.isArray(options.programs) ? options.programs : null, projects: [], range: options.range === true, - singleRun: inferSingleRun(options), + singleRun, tokens: options.tokens === true ? [] : null, + tsconfigMatchCache: (TSCONFIG_MATCH_CACHE ??= new ExpiringCache( + singleRun + ? 'Infinity' + : options.cacheLifetime?.glob ?? + DEFAULT_TSCONFIG_CACHE_DURATION_SECONDS, + )), tsconfigRootDir, }; @@ -95,7 +109,7 @@ export function createParseSettings( if (!parseSettings.programs) { parseSettings.projects = resolveProjectList({ cacheLifetime: options.cacheLifetime, - project: options.project, + project: getProjectConfigFiles(parseSettings, options.project), projectFolderIgnoreList: options.projectFolderIgnoreList, singleRun: parseSettings.singleRun, tsconfigRootDir: tsconfigRootDir, diff --git a/packages/typescript-estree/src/parseSettings/getProjectConfigFiles.ts b/packages/typescript-estree/src/parseSettings/getProjectConfigFiles.ts new file mode 100644 index 000000000000..d3b97d6102ab --- /dev/null +++ b/packages/typescript-estree/src/parseSettings/getProjectConfigFiles.ts @@ -0,0 +1,59 @@ +import debug from 'debug'; +import * as fs from 'fs'; +import * as path from 'path'; + +import type { ParseSettings } from '.'; + +const log = debug('typescript-eslint:typescript-estree:getProjectConfigFiles'); + +/** + * Checks for a matching TSConfig to a file including its parent directories, + * permanently caching results under each directory it checks. + * + * @remarks + * We don't (yet!) have a way to attach file watchers on disk, but still need to + * cache file checks for rapid subsequent calls to fs.existsSync. See discussion + * in https://github.com/typescript-eslint/typescript-eslint/issues/101. + */ +export function getProjectConfigFiles( + parseSettings: Pick< + ParseSettings, + 'filePath' | 'tsconfigMatchCache' | 'tsconfigRootDir' + >, + project: string | string[] | true | undefined, +): string[] | undefined { + if (project !== true) { + return project === undefined || Array.isArray(project) + ? project + : [project]; + } + + log('Looking for tsconfig.json at or above file: %s', parseSettings.filePath); + let directory = path.dirname(parseSettings.filePath); + const checkedDirectories = [directory]; + + do { + log('Checking tsconfig.json path: %s', directory); + const tsconfigPath = path.join(directory, 'tsconfig.json'); + const cached = + parseSettings.tsconfigMatchCache.get(directory) ?? + (fs.existsSync(tsconfigPath) && tsconfigPath); + + if (cached) { + for (const directory of checkedDirectories) { + parseSettings.tsconfigMatchCache.set(directory, cached); + } + return [cached]; + } + + directory = path.dirname(directory); + checkedDirectories.push(directory); + } while ( + directory.length > 1 && + directory.length >= parseSettings.tsconfigRootDir.length + ); + + throw new Error( + `project was set to \`true\` but couldn't find any tsconfig.json relative to '${parseSettings.filePath}' within '${parseSettings.tsconfigRootDir}'.`, + ); +} diff --git a/packages/typescript-estree/src/parseSettings/index.ts b/packages/typescript-estree/src/parseSettings/index.ts index 53b1acf4a6fb..11df4c78489e 100644 --- a/packages/typescript-estree/src/parseSettings/index.ts +++ b/packages/typescript-estree/src/parseSettings/index.ts @@ -2,6 +2,7 @@ import type * as ts from 'typescript'; import type { CanonicalPath } from '../create-program/shared'; import type { TSESTree } from '../ts-estree'; +import type { CacheLike } from './ExpiringCache'; type DebugModule = 'typescript-eslint' | 'eslint' | 'typescript'; @@ -115,6 +116,11 @@ export interface MutableParseSettings { */ tokens: null | TSESTree.Token[]; + /** + * Caches searches for TSConfigs from project directories. + */ + tsconfigMatchCache: CacheLike; + /** * The absolute path to the root directory for all provided `project`s. */ diff --git a/packages/typescript-estree/src/parser-options.ts b/packages/typescript-estree/src/parser-options.ts index 8cfe3c934c2c..57feea094278 100644 --- a/packages/typescript-estree/src/parser-options.ts +++ b/packages/typescript-estree/src/parser-options.ts @@ -120,10 +120,11 @@ interface ParseAndGenerateServicesOptions extends ParseOptions { preserveNodeMaps?: boolean; /** - * Absolute (or relative to `tsconfigRootDir`) paths to the tsconfig(s). + * Absolute (or relative to `tsconfigRootDir`) paths to the tsconfig(s), + * or `true` to find the nearest tsconfig.json to the file. * If this is provided, type information will be returned. */ - project?: string | string[]; + project?: string | string[] | true; /** * If you provide a glob (or globs) to the project option, you can use this option to ignore certain folders from diff --git a/packages/typescript-estree/tests/fixtures/projectTrue/nested/deep/included.ts b/packages/typescript-estree/tests/fixtures/projectTrue/nested/deep/included.ts new file mode 100644 index 000000000000..9fe571f28c0d --- /dev/null +++ b/packages/typescript-estree/tests/fixtures/projectTrue/nested/deep/included.ts @@ -0,0 +1 @@ +const b = true; diff --git a/packages/typescript-estree/tests/fixtures/projectTrue/nested/included.ts b/packages/typescript-estree/tests/fixtures/projectTrue/nested/included.ts new file mode 100644 index 000000000000..9fe571f28c0d --- /dev/null +++ b/packages/typescript-estree/tests/fixtures/projectTrue/nested/included.ts @@ -0,0 +1 @@ +const b = true; diff --git a/packages/typescript-estree/tests/fixtures/projectTrue/nested/tsconfig.json b/packages/typescript-estree/tests/fixtures/projectTrue/nested/tsconfig.json new file mode 100644 index 000000000000..d144c8ddb02c --- /dev/null +++ b/packages/typescript-estree/tests/fixtures/projectTrue/nested/tsconfig.json @@ -0,0 +1,3 @@ +{ + "include": ["."] +} diff --git a/packages/typescript-estree/tests/fixtures/projectTrue/notIncluded.ts b/packages/typescript-estree/tests/fixtures/projectTrue/notIncluded.ts new file mode 100644 index 000000000000..7ceea3e98854 --- /dev/null +++ b/packages/typescript-estree/tests/fixtures/projectTrue/notIncluded.ts @@ -0,0 +1 @@ +const c = true; diff --git a/packages/typescript-estree/tests/lib/createParseSettings.test.ts b/packages/typescript-estree/tests/lib/createParseSettings.test.ts new file mode 100644 index 000000000000..043050cdd2bc --- /dev/null +++ b/packages/typescript-estree/tests/lib/createParseSettings.test.ts @@ -0,0 +1,14 @@ +import { createParseSettings } from '../../src/parseSettings/createParseSettings'; + +describe('createParseSettings', () => { + describe('tsconfigMatchCache', () => { + it('reuses the TSConfig match cache when called a subsequent time', () => { + const parseSettings1 = createParseSettings('input.ts'); + const parseSettings2 = createParseSettings('input.ts'); + + expect(parseSettings1.tsconfigMatchCache).toBe( + parseSettings2.tsconfigMatchCache, + ); + }); + }); +}); diff --git a/packages/typescript-estree/tests/lib/getProjectConfigFiles.test.ts b/packages/typescript-estree/tests/lib/getProjectConfigFiles.test.ts new file mode 100644 index 000000000000..7378508b001c --- /dev/null +++ b/packages/typescript-estree/tests/lib/getProjectConfigFiles.test.ts @@ -0,0 +1,158 @@ +import { ExpiringCache } from '../../src/parseSettings/ExpiringCache'; +import { getProjectConfigFiles } from '../../src/parseSettings/getProjectConfigFiles'; + +const mockExistsSync = jest.fn(); + +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + existsSync: (filePath: string): boolean => mockExistsSync(filePath), +})); + +const parseSettings = { + filePath: './repos/repo/packages/package/file.ts', + tsconfigMatchCache: new ExpiringCache(1), + tsconfigRootDir: './repos/repo', +}; + +beforeEach(() => { + parseSettings.tsconfigMatchCache.clear(); + jest.clearAllMocks(); +}); + +describe('getProjectConfigFiles', () => { + it('returns an array with just the project when given as a string', () => { + const project = './tsconfig.eslint.json'; + + const actual = getProjectConfigFiles(parseSettings, project); + + expect(actual).toEqual([project]); + }); + + it('returns the project when given as a string array', () => { + const project = ['./tsconfig.eslint.json']; + + const actual = getProjectConfigFiles(parseSettings, project); + + expect(actual).toEqual(project); + }); + + it('returns the project when given as undefined', () => { + const project = undefined; + + const actual = getProjectConfigFiles(parseSettings, project); + + expect(actual).toEqual(project); + }); + + describe('when caching hits', () => { + it('returns a local tsconfig.json without calling existsSync a second time', () => { + mockExistsSync.mockReturnValue(true); + + getProjectConfigFiles(parseSettings, true); + const actual = getProjectConfigFiles(parseSettings, true); + + expect(actual).toEqual(['repos/repo/packages/package/tsconfig.json']); + expect(mockExistsSync).toHaveBeenCalledTimes(1); + }); + + it('returns a nearby parent tsconfig.json when it was previously cached by a different directory search', () => { + mockExistsSync.mockImplementation(input => input === 'a/tsconfig.json'); + + const tsconfigMatchCache = new ExpiringCache(1); + + // This should call to fs.existsSync three times: c, b, a + getProjectConfigFiles( + { + filePath: './a/b/c/d.ts', + tsconfigRootDir: './a', + tsconfigMatchCache, + }, + true, + ); + + // This should call to fs.existsSync once: e + // Then it should retrieve c from cache, pointing to a + const actual = getProjectConfigFiles( + { + filePath: './a/b/c/e/f.ts', + tsconfigRootDir: './a', + tsconfigMatchCache, + }, + true, + ); + + expect(actual).toEqual(['a/tsconfig.json']); + expect(mockExistsSync).toHaveBeenCalledTimes(4); + }); + + it('returns a distant parent tsconfig.json when it was previously cached by a different directory search', () => { + mockExistsSync.mockImplementation(input => input === 'a/tsconfig.json'); + + const tsconfigMatchCache = new ExpiringCache(1); + + // This should call to fs.existsSync 4 times: d, c, b, a + getProjectConfigFiles( + { + filePath: './a/b/c/d/e.ts', + tsconfigRootDir: './a', + tsconfigMatchCache, + }, + true, + ); + + // This should call to fs.existsSync 2: g, f + // Then it should retrieve b from cache, pointing to a + const actual = getProjectConfigFiles( + { + filePath: './a/b/f/g/h.ts', + tsconfigRootDir: './a', + tsconfigMatchCache, + }, + true, + ); + + expect(actual).toEqual(['a/tsconfig.json']); + expect(mockExistsSync).toHaveBeenCalledTimes(6); + }); + }); + + describe('when caching misses', () => { + it('returns a local tsconfig.json when matched', () => { + mockExistsSync.mockReturnValue(true); + + const actual = getProjectConfigFiles(parseSettings, true); + + expect(actual).toEqual(['repos/repo/packages/package/tsconfig.json']); + }); + + it('returns a parent tsconfig.json when matched', () => { + mockExistsSync.mockImplementation( + filePath => filePath === 'repos/repo/tsconfig.json', + ); + + const actual = getProjectConfigFiles(parseSettings, true); + + expect(actual).toEqual(['repos/repo/tsconfig.json']); + }); + + it('throws when searching hits .', () => { + mockExistsSync.mockReturnValue(false); + + expect(() => + getProjectConfigFiles(parseSettings, true), + ).toThrowErrorMatchingInlineSnapshot( + `"project was set to \`true\` but couldn't find any tsconfig.json relative to './repos/repo/packages/package/file.ts' within './repos/repo'."`, + ); + }); + + it('throws when searching passes the tsconfigRootDir', () => { + mockExistsSync.mockReturnValue(false); + + expect(() => + getProjectConfigFiles({ ...parseSettings, tsconfigRootDir: '/' }, true), + ).toThrowErrorMatchingInlineSnapshot( + `"project was set to \`true\` but couldn't find any tsconfig.json relative to './repos/repo/packages/package/file.ts' within '/'."`, + ); + }); + }); +}); diff --git a/packages/typescript-estree/tests/lib/parse.project-true.test.ts b/packages/typescript-estree/tests/lib/parse.project-true.test.ts new file mode 100644 index 000000000000..3e4e47cb2c94 --- /dev/null +++ b/packages/typescript-estree/tests/lib/parse.project-true.test.ts @@ -0,0 +1,49 @@ +import { join } from 'path'; + +import * as parser from '../../src'; + +const PROJECT_DIR = join(__dirname, '../fixtures/projectTrue'); + +const config = { + tsconfigRootDir: PROJECT_DIR, + project: true, +} satisfies Partial; + +describe('parseAndGenerateServices', () => { + describe('when project is true', () => { + it('finds a parent project when it exists in the project', () => { + const result = parser.parseAndGenerateServices('const a = true', { + ...config, + filePath: join(PROJECT_DIR, 'nested/deep/included.ts'), + }); + + expect(result).toEqual({ + ast: expect.any(Object), + services: expect.any(Object), + }); + }); + + it('finds a sibling project when it exists in the project', () => { + const result = parser.parseAndGenerateServices('const a = true', { + ...config, + filePath: join(PROJECT_DIR, 'nested/included.ts'), + }); + + expect(result).toEqual({ + ast: expect.any(Object), + services: expect.any(Object), + }); + }); + + it('throws an error when a parent project does not exist', () => { + expect(() => + parser.parseAndGenerateServices('const a = true', { + ...config, + filePath: join(PROJECT_DIR, 'notIncluded.ts'), + }), + ).toThrow( + /project was set to `true` but couldn't find any tsconfig.json relative to '.+\/tests\/fixtures\/projectTrue\/notIncluded.ts' within '.+\/tests\/fixtures\/projectTrue'./, + ); + }); + }); +}); diff --git a/packages/website/src/components/linter/config.ts b/packages/website/src/components/linter/config.ts index f077f3786ee1..5fa05a4a11b7 100644 --- a/packages/website/src/components/linter/config.ts +++ b/packages/website/src/components/linter/config.ts @@ -18,6 +18,7 @@ export const parseSettings: ParseSettings = { range: true, tokens: [], tsconfigRootDir: '/', + tsconfigMatchCache: new Map(), errorOnTypeScriptSyntacticAndSemanticIssues: false, EXPERIMENTAL_useSourceOfProjectReferenceRedirect: false, singleRun: false, pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy