diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98af2e8ed08..355422cdcc8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -193,6 +193,38 @@ jobs: # Sadly 1 day is the minimum retention-days: 1 + unit_tests_tsserver: + name: Run Unit Tests with Experimental TSServer + needs: [build] + runs-on: ubuntu-latest + strategy: + matrix: + package: + [ + 'eslint-plugin', + 'eslint-plugin-internal', + 'eslint-plugin-tslint', + 'typescript-estree', + ] + env: + COLLECT_COVERAGE: false + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 2 + - name: Install + uses: ./.github/actions/prepare-install + with: + node-version: 18 + - name: Build + uses: ./.github/actions/prepare-build + - name: Run unit tests for ${{ matrix.package }} + run: npx nx test ${{ matrix.package }} --coverage=false + env: + CI: true + TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER: true + website_tests: # The NETLIFY_TOKEN secret will not be available on forks if: github.repository_owner == 'typescript-eslint' diff --git a/.vscode/launch.json b/.vscode/launch.json index 4cee04bec4a..82c02f90b2e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -141,6 +141,40 @@ "${workspaceFolder}/packages/scope-manager/dist/index.js", ], }, + { + "type": "node", + "request": "launch", + "name": "Jest Test Current eslint-plugin-tslint Rule", + "cwd": "${workspaceFolder}/packages/eslint-plugin-tslint/", + "program": "${workspaceFolder}/node_modules/jest/bin/jest.js", + "args": [ + "--runInBand", + "--no-cache", + "--no-coverage", + "${fileBasenameNoExtension}" + ], + "sourceMaps": true, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "skipFiles": [ + "${workspaceFolder}/packages/utils/src/index.ts", + "${workspaceFolder}/packages/utils/dist/index.js", + "${workspaceFolder}/packages/utils/src/ts-estree.ts", + "${workspaceFolder}/packages/utils/dist/ts-estree.js", + "${workspaceFolder}/packages/type-utils/src/index.ts", + "${workspaceFolder}/packages/type-utils/dist/index.js", + "${workspaceFolder}/packages/parser/src/index.ts", + "${workspaceFolder}/packages/parser/dist/index.js", + "${workspaceFolder}/packages/typescript-estree/src/index.ts", + "${workspaceFolder}/packages/typescript-estree/dist/index.js", + "${workspaceFolder}/packages/types/src/index.ts", + "${workspaceFolder}/packages/types/dist/index.js", + "${workspaceFolder}/packages/visitor-keys/src/index.ts", + "${workspaceFolder}/packages/visitor-keys/dist/index.js", + "${workspaceFolder}/packages/scope-manager/dist/index.js", + "${workspaceFolder}/packages/scope-manager/dist/index.js", + ], + }, { "type": "node", "request": "launch", diff --git a/docs/packages/TypeScript_ESTree.mdx b/docs/packages/TypeScript_ESTree.mdx index 68e2706ca9a..6354565e828 100644 --- a/docs/packages/TypeScript_ESTree.mdx +++ b/docs/packages/TypeScript_ESTree.mdx @@ -147,6 +147,15 @@ interface ParseAndGenerateServicesOptions extends ParseOptions { */ errorOnTypeScriptSyntacticAndSemanticIssues?: boolean; + /** + * ***EXPERIMENTAL FLAG*** - Use this at your own risk. + * + * Whether to create a shared TypeScript server to power program creation. + * + * @see https://github.com/typescript-eslint/typescript-eslint/issues/6575 + */ + EXPERIMENTAL_useProjectService?: boolean; + /** * ***EXPERIMENTAL FLAG*** - Use this at your own risk. * @@ -155,7 +164,7 @@ interface ParseAndGenerateServicesOptions extends ParseOptions { * * This flag REQUIRES at least TS v3.9, otherwise it does nothing. * - * See: https://github.com/typescript-eslint/typescript-eslint/issues/2094 + * @see https://github.com/typescript-eslint/typescript-eslint/issues/2094 */ EXPERIMENTAL_useSourceOfProjectReferenceRedirect?: boolean; diff --git a/packages/eslint-plugin-tslint/src/rules/config.ts b/packages/eslint-plugin-tslint/src/rules/config.ts index e7918218a90..23b7558b80f 100644 --- a/packages/eslint-plugin-tslint/src/rules/config.ts +++ b/packages/eslint-plugin-tslint/src/rules/config.ts @@ -1,4 +1,5 @@ import { ESLintUtils } from '@typescript-eslint/utils'; +import path from 'path'; import type { RuleSeverity } from 'tslint'; import { Configuration } from 'tslint'; @@ -118,7 +119,7 @@ export default createRule({ context, [{ rules: tslintRules, rulesDirectory: tslintRulesDirectory, lintFile }], ) { - const fileName = context.getFilename(); + const fileName = path.resolve(context.getCwd(), context.getFilename()); const sourceCode = context.getSourceCode().text; const services = ESLintUtils.getParserServices(context); const program = services.program; diff --git a/packages/eslint-plugin/tests/rules/consistent-type-imports.test.ts b/packages/eslint-plugin/tests/rules/consistent-type-imports.test.ts index 3c94e99ff14..1910b1986ee 100644 --- a/packages/eslint-plugin/tests/rules/consistent-type-imports.test.ts +++ b/packages/eslint-plugin/tests/rules/consistent-type-imports.test.ts @@ -16,6 +16,7 @@ const ruleTester = new RuleTester({ }); const withMetaParserOptions = { + EXPERIMENTAL_useProjectService: false, tsconfigRootDir: getFixturesRootDir(), project: './tsconfig-withmeta.json', }; diff --git a/packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts b/packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts index 397d4b507cc..c68c735ae11 100644 --- a/packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts @@ -595,6 +595,7 @@ function getElem(dict: Record, key: string) { } `, parserOptions: { + EXPERIMENTAL_useProjectService: false, tsconfigRootDir: getFixturesRootDir(), project: './tsconfig.noUncheckedIndexedAccess.json', }, diff --git a/packages/eslint-plugin/tests/rules/no-unsafe-assignment.test.ts b/packages/eslint-plugin/tests/rules/no-unsafe-assignment.test.ts index 27028234b31..fb2d713e647 100644 --- a/packages/eslint-plugin/tests/rules/no-unsafe-assignment.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unsafe-assignment.test.ts @@ -65,6 +65,7 @@ function assignmentTest( const ruleTester = new RuleTester({ parser: '@typescript-eslint/parser', parserOptions: { + EXPERIMENTAL_useProjectService: false, project: './tsconfig.noImplicitThis.json', tsconfigRootDir: getFixturesRootDir(), }, diff --git a/packages/eslint-plugin/tests/rules/no-unsafe-call.test.ts b/packages/eslint-plugin/tests/rules/no-unsafe-call.test.ts index b91b2ec6273..bb844011fd7 100644 --- a/packages/eslint-plugin/tests/rules/no-unsafe-call.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unsafe-call.test.ts @@ -6,6 +6,7 @@ import { getFixturesRootDir } from '../RuleTester'; const ruleTester = new RuleTester({ parser: '@typescript-eslint/parser', parserOptions: { + EXPERIMENTAL_useProjectService: false, project: './tsconfig.noImplicitThis.json', tsconfigRootDir: getFixturesRootDir(), }, diff --git a/packages/eslint-plugin/tests/rules/no-unsafe-member-access.test.ts b/packages/eslint-plugin/tests/rules/no-unsafe-member-access.test.ts index b66b96d4663..8298cec6ceb 100644 --- a/packages/eslint-plugin/tests/rules/no-unsafe-member-access.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unsafe-member-access.test.ts @@ -6,6 +6,7 @@ import { getFixturesRootDir } from '../RuleTester'; const ruleTester = new RuleTester({ parser: '@typescript-eslint/parser', parserOptions: { + EXPERIMENTAL_useProjectService: false, project: './tsconfig.noImplicitThis.json', tsconfigRootDir: getFixturesRootDir(), }, diff --git a/packages/eslint-plugin/tests/rules/no-unsafe-return.test.ts b/packages/eslint-plugin/tests/rules/no-unsafe-return.test.ts index f0cbd8b2535..b75cdfb28c0 100644 --- a/packages/eslint-plugin/tests/rules/no-unsafe-return.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unsafe-return.test.ts @@ -6,6 +6,7 @@ import { getFixturesRootDir } from '../RuleTester'; const ruleTester = new RuleTester({ parser: '@typescript-eslint/parser', parserOptions: { + EXPERIMENTAL_useProjectService: false, project: './tsconfig.noImplicitThis.json', tsconfigRootDir: getFixturesRootDir(), }, diff --git a/packages/eslint-plugin/tests/rules/no-unused-vars/no-unused-vars.test.ts b/packages/eslint-plugin/tests/rules/no-unused-vars/no-unused-vars.test.ts index 2c651a1b61d..e891452136a 100644 --- a/packages/eslint-plugin/tests/rules/no-unused-vars/no-unused-vars.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unused-vars/no-unused-vars.test.ts @@ -14,6 +14,7 @@ const ruleTester = new RuleTester({ }); const withMetaParserOptions = { + EXPERIMENTAL_useProjectService: false, tsconfigRootDir: getFixturesRootDir(), project: './tsconfig-withmeta.json', }; diff --git a/packages/eslint-plugin/tests/rules/non-nullable-type-assertion-style.test.ts b/packages/eslint-plugin/tests/rules/non-nullable-type-assertion-style.test.ts index 2f5d7163de3..8b86634d279 100644 --- a/packages/eslint-plugin/tests/rules/non-nullable-type-assertion-style.test.ts +++ b/packages/eslint-plugin/tests/rules/non-nullable-type-assertion-style.test.ts @@ -204,6 +204,7 @@ const y = x!; const ruleTesterWithNoUncheckedIndexAccess = new RuleTester({ parserOptions: { + EXPERIMENTAL_useProjectService: false, sourceType: 'module', tsconfigRootDir: getFixturesRootDir(), project: './tsconfig.noUncheckedIndexedAccess.json', diff --git a/packages/parser/tests/lib/parser.ts b/packages/parser/tests/lib/parser.ts index 952e388eb8c..e6bd731db07 100644 --- a/packages/parser/tests/lib/parser.ts +++ b/packages/parser/tests/lib/parser.ts @@ -1,6 +1,7 @@ import * as scopeManager from '@typescript-eslint/scope-manager'; import type { ParserOptions } from '@typescript-eslint/types'; import * as typescriptESTree from '@typescript-eslint/typescript-estree'; +import path from 'path'; import { parse, parseForESLint } from '../../src/parser'; @@ -33,10 +34,10 @@ describe('parser', () => { jsx: false, }, // ts-estree specific - filePath: 'isolated-file.src.ts', + filePath: './isolated-file.src.ts', project: 'tsconfig.json', errorOnTypeScriptSyntacticAndSemanticIssues: false, - tsconfigRootDir: 'tests/fixtures/services', + tsconfigRootDir: path.resolve(__dirname, '../fixtures/services'), extraFileExtensions: ['.foo'], }; parseForESLint(code, config); @@ -89,7 +90,7 @@ describe('parser', () => { filePath: 'isolated-file.src.ts', project: 'tsconfig.json', errorOnTypeScriptSyntacticAndSemanticIssues: false, - tsconfigRootDir: 'tests/fixtures/services', + tsconfigRootDir: path.join(__dirname, '../fixtures/services'), extraFileExtensions: ['.foo'], }; parseForESLint(code, config); diff --git a/packages/rule-tester/tests/RuleTester.test.ts b/packages/rule-tester/tests/RuleTester.test.ts index 93f9f6d35d2..25b6aa0888e 100644 --- a/packages/rule-tester/tests/RuleTester.test.ts +++ b/packages/rule-tester/tests/RuleTester.test.ts @@ -169,6 +169,7 @@ describe('RuleTester', () => { { code: 'type-aware parser options should override the constructor config', parserOptions: { + EXPERIMENTAL_useProjectService: false, project: 'tsconfig.test-specific.json', tsconfigRootDir: '/set/in/the/test/', }, @@ -209,6 +210,7 @@ describe('RuleTester', () => { "code": "type-aware parser options should override the constructor config", "filename": "/set/in/the/test/file.ts", "parserOptions": { + "EXPERIMENTAL_useProjectService": false, "project": "tsconfig.test-specific.json", "tsconfigRootDir": "/set/in/the/test/", }, diff --git a/packages/type-utils/tests/TypeOrValueSpecifier.test.ts b/packages/type-utils/tests/TypeOrValueSpecifier.test.ts index 8ddca54d3b3..a91358b4b17 100644 --- a/packages/type-utils/tests/TypeOrValueSpecifier.test.ts +++ b/packages/type-utils/tests/TypeOrValueSpecifier.test.ts @@ -196,18 +196,18 @@ describe('TypeOrValueSpecifier', () => { ], [ 'interface Foo {prop: string}; type Test = Foo;', - { from: 'file', name: 'Foo', path: 'tests/fixtures/file.ts' }, + { from: 'file', name: 'Foo', path: 'file.ts' }, ], [ 'type Foo = {prop: string}; type Test = Foo;', - { from: 'file', name: 'Foo', path: 'tests/fixtures/file.ts' }, + { from: 'file', name: 'Foo', path: 'file.ts' }, ], [ 'interface Foo {prop: string}; type Test = Foo;', { from: 'file', name: 'Foo', - path: 'tests/../tests/fixtures/////file.ts', + path: './////file.ts', }, ], [ @@ -215,7 +215,7 @@ describe('TypeOrValueSpecifier', () => { { from: 'file', name: 'Foo', - path: 'tests/../tests/fixtures/////file.ts', + path: './////file.ts', }, ], [ @@ -223,7 +223,7 @@ describe('TypeOrValueSpecifier', () => { { from: 'file', name: ['Foo', 'Bar'], - path: 'tests/fixtures/file.ts', + path: 'file.ts', }, ], [ @@ -231,7 +231,7 @@ describe('TypeOrValueSpecifier', () => { { from: 'file', name: ['Foo', 'Bar'], - path: 'tests/fixtures/file.ts', + path: 'file.ts', }, ], ])('matches a matching file specifier: %s', runTestPositive); @@ -247,14 +247,14 @@ describe('TypeOrValueSpecifier', () => { ], [ 'interface Foo {prop: string}; type Test = Foo;', - { from: 'file', name: 'Foo', path: 'tests/fixtures/wrong-file.ts' }, + { from: 'file', name: 'Foo', path: 'wrong-file.ts' }, ], [ 'interface Foo {prop: string}; type Test = Foo;', { from: 'file', name: ['Foo', 'Bar'], - path: 'tests/fixtures/wrong-file.ts', + path: 'wrong-file.ts', }, ], ])("doesn't match a mismatched file specifier: %s", runTestNegative); @@ -399,14 +399,14 @@ describe('TypeOrValueSpecifier', () => { ['type Test = RegExp;', { from: 'file', name: ['RegExp', 'BigInt'] }], [ 'type Test = RegExp;', - { from: 'file', name: 'RegExp', path: 'tests/fixtures/file.ts' }, + { from: 'file', name: 'RegExp', path: 'file.ts' }, ], [ 'type Test = RegExp;', { from: 'file', name: ['RegExp', 'BigInt'], - path: 'tests/fixtures/file.ts', + path: 'file.ts', }, ], [ diff --git a/packages/types/src/parser-options.ts b/packages/types/src/parser-options.ts index 4e8bb90dfae..5389efba75e 100644 --- a/packages/types/src/parser-options.ts +++ b/packages/types/src/parser-options.ts @@ -47,6 +47,7 @@ interface ParserOptions { debugLevel?: DebugLevel; errorOnTypeScriptSyntacticAndSemanticIssues?: boolean; errorOnUnknownASTType?: boolean; + EXPERIMENTAL_useProjectService?: boolean; // purposely undocumented for now EXPERIMENTAL_useSourceOfProjectReferenceRedirect?: boolean; // purposely undocumented for now extraFileExtensions?: string[]; filePath?: string; diff --git a/packages/typescript-estree/src/clear-caches.ts b/packages/typescript-estree/src/clear-caches.ts index aea4d6cf845..015fd18e29c 100644 --- a/packages/typescript-estree/src/clear-caches.ts +++ b/packages/typescript-estree/src/clear-caches.ts @@ -1,6 +1,9 @@ import { clearWatchCaches } from './create-program/getWatchProgramsForProjects'; import { clearProgramCache as clearProgramCacheOriginal } from './parser'; -import { clearTSConfigMatchCache } from './parseSettings/createParseSettings'; +import { + clearTSConfigMatchCache, + clearTSServerProjectService, +} from './parseSettings/createParseSettings'; import { clearGlobCache } from './parseSettings/resolveProjectList'; /** @@ -14,6 +17,7 @@ export function clearCaches(): void { clearProgramCacheOriginal(); clearWatchCaches(); clearTSConfigMatchCache(); + clearTSServerProjectService(); clearGlobCache(); } diff --git a/packages/typescript-estree/src/create-program/createProjectProgram.ts b/packages/typescript-estree/src/create-program/createProjectProgram.ts index 51a2ebdfdfc..edfe00992c1 100644 --- a/packages/typescript-estree/src/create-program/createProjectProgram.ts +++ b/packages/typescript-estree/src/create-program/createProjectProgram.ts @@ -5,7 +5,6 @@ import * as ts from 'typescript'; import { firstDefined } from '../node-utils'; import type { ParseSettings } from '../parseSettings'; import { describeFilePath } from './describeFilePath'; -import { getWatchProgramsForProjects } from './getWatchProgramsForProjects'; import type { ASTAndDefiniteProgram } from './shared'; import { getAstFromProgram } from './shared'; @@ -28,12 +27,12 @@ const DEFAULT_EXTRA_FILE_EXTENSIONS = [ */ function createProjectProgram( parseSettings: ParseSettings, + programsForProjects: readonly ts.Program[], ): ASTAndDefiniteProgram | undefined { log('Creating project program for: %s', parseSettings.filePath); - const programsForProjects = getWatchProgramsForProjects(parseSettings); const astAndProgram = firstDefined(programsForProjects, currentProgram => - getAstFromProgram(currentProgram, parseSettings), + getAstFromProgram(currentProgram, parseSettings.filePath), ); // The file was either matched within the tsconfig, or we allow creating a default program diff --git a/packages/typescript-estree/src/create-program/createProjectService.ts b/packages/typescript-estree/src/create-program/createProjectService.ts new file mode 100644 index 00000000000..333d221f85b --- /dev/null +++ b/packages/typescript-estree/src/create-program/createProjectService.ts @@ -0,0 +1,49 @@ +/* eslint-disable @typescript-eslint/no-empty-function -- for TypeScript APIs*/ +import type * as ts from 'typescript/lib/tsserverlibrary'; + +const doNothing = (): void => {}; + +const createStubFileWatcher = (): ts.FileWatcher => ({ + close: doNothing, +}); + +export type TypeScriptProjectService = ts.server.ProjectService; + +export function createProjectService(): TypeScriptProjectService { + // We import this lazily to avoid its cost for users who don't use the service + const tsserver = require('typescript/lib/tsserverlibrary') as typeof ts; + + // TODO: see getWatchProgramsForProjects + // We don't watch the disk, we just refer to these when ESLint calls us + // there's a whole separate update pass in maybeInvalidateProgram at the bottom of getWatchProgramsForProjects + // (this "goes nuclear on TypeScript") + const system: ts.server.ServerHost = { + ...tsserver.sys, + clearImmediate, + clearTimeout, + setImmediate, + setTimeout, + watchDirectory: createStubFileWatcher, + watchFile: createStubFileWatcher, + }; + + return new tsserver.server.ProjectService({ + host: system, + cancellationToken: { isCancellationRequested: (): boolean => false }, + useSingleInferredProject: false, + useInferredProjectPerProjectRoot: false, + logger: { + close: doNothing, + endGroup: doNothing, + getLogFileName: () => undefined, + hasLevel: () => false, + info: doNothing, + loggingEnabled: () => false, + msg: doNothing, + perftrc: doNothing, + startGroup: doNothing, + }, + session: undefined, + }); +} +/* eslint-enable @typescript-eslint/no-empty-function */ diff --git a/packages/typescript-estree/src/create-program/getWatchProgramsForProjects.ts b/packages/typescript-estree/src/create-program/getWatchProgramsForProjects.ts index 2ec2b4ce35a..e5e4b70f7d0 100644 --- a/packages/typescript-estree/src/create-program/getWatchProgramsForProjects.ts +++ b/packages/typescript-estree/src/create-program/getWatchProgramsForProjects.ts @@ -261,7 +261,10 @@ function createWatchProgram( const watchCompilerHost = ts.createWatchCompilerHost( tsconfigPath, createDefaultCompilerOptionsFromExtra(parseSettings), - ts.sys, + { + ...ts.sys, + getCurrentDirectory: () => parseSettings.tsconfigRootDir, + }, ts.createAbstractBuilder, diagnosticReporter, // TODO: file issue on TypeScript to suggest making optional? diff --git a/packages/typescript-estree/src/create-program/shared.ts b/packages/typescript-estree/src/create-program/shared.ts index b0e39d19e7e..8966093372e 100644 --- a/packages/typescript-estree/src/create-program/shared.ts +++ b/packages/typescript-estree/src/create-program/shared.ts @@ -97,12 +97,12 @@ function getExtension(fileName: string | undefined): string | null { function getAstFromProgram( currentProgram: Program, - parseSettings: ParseSettings, + filePath: string, ): ASTAndDefiniteProgram | undefined { - const ast = currentProgram.getSourceFile(parseSettings.filePath); + const ast = currentProgram.getSourceFile(filePath); // working around https://github.com/typescript-eslint/typescript-eslint/issues/1573 - const expectedExt = getExtension(parseSettings.filePath); + const expectedExt = getExtension(filePath); const returnedExt = getExtension(ast?.fileName); if (expectedExt !== returnedExt) { return undefined; diff --git a/packages/typescript-estree/src/create-program/useProvidedPrograms.ts b/packages/typescript-estree/src/create-program/useProvidedPrograms.ts index 96093e9a3af..c2b67e79575 100644 --- a/packages/typescript-estree/src/create-program/useProvidedPrograms.ts +++ b/packages/typescript-estree/src/create-program/useProvidedPrograms.ts @@ -3,24 +3,25 @@ import * as fs from 'fs'; import * as path from 'path'; import * as ts from 'typescript'; -import type { ParseSettings } from '../parseSettings'; import type { ASTAndDefiniteProgram } from './shared'; import { CORE_COMPILER_OPTIONS, getAstFromProgram } from './shared'; const log = debug('typescript-eslint:typescript-estree:useProvidedProgram'); +export interface ProvidedProgramsSettings { + filePath: string; + tsconfigRootDir: string; +} + function useProvidedPrograms( programInstances: Iterable, - parseSettings: ParseSettings, + { filePath, tsconfigRootDir }: ProvidedProgramsSettings, ): ASTAndDefiniteProgram | undefined { - log( - 'Retrieving ast for %s from provided program instance(s)', - parseSettings.filePath, - ); + log('Retrieving ast for %s from provided program instance(s)', filePath); let astAndProgram: ASTAndDefiniteProgram | undefined; for (const programInstance of programInstances) { - astAndProgram = getAstFromProgram(programInstance, parseSettings); + astAndProgram = getAstFromProgram(programInstance, filePath); // Stop at the first applicable program instance if (astAndProgram) { break; @@ -29,8 +30,8 @@ function useProvidedPrograms( if (!astAndProgram) { const relativeFilePath = path.relative( - parseSettings.tsconfigRootDir || process.cwd(), - parseSettings.filePath, + tsconfigRootDir || process.cwd(), + filePath, ); const errorLines = [ '"parserOptions.programs" has been provided for @typescript-eslint/parser.', diff --git a/packages/typescript-estree/src/parseSettings/createParseSettings.ts b/packages/typescript-estree/src/parseSettings/createParseSettings.ts index b26e0037697..3062a6164b3 100644 --- a/packages/typescript-estree/src/parseSettings/createParseSettings.ts +++ b/packages/typescript-estree/src/parseSettings/createParseSettings.ts @@ -1,6 +1,8 @@ import debug from 'debug'; import type * as ts from 'typescript'; +import type { TypeScriptProjectService } from '../create-program/createProjectService'; +import { createProjectService } from '../create-program/createProjectService'; import { ensureAbsolutePath } from '../create-program/shared'; import type { TSESTreeOptions } from '../parser-options'; import { isSourceFile } from '../source-files'; @@ -19,6 +21,7 @@ const log = debug( ); let TSCONFIG_MATCH_CACHE: ExpiringCache | null; +let TSSERVER_PROJECT_SERVICE: TypeScriptProjectService | null = null; export function createParseSettings( code: ts.SourceFile | string, @@ -47,6 +50,13 @@ export function createParseSettings( : new Set(), errorOnTypeScriptSyntacticAndSemanticIssues: false, errorOnUnknownASTType: options.errorOnUnknownASTType === true, + EXPERIMENTAL_projectService: + (options.EXPERIMENTAL_useProjectService === true && + process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER !== 'false') || + (process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER === 'true' && + options.EXPERIMENTAL_useProjectService !== false) + ? (TSSERVER_PROJECT_SERVICE ??= createProjectService()) + : undefined, EXPERIMENTAL_useSourceOfProjectReferenceRedirect: options.EXPERIMENTAL_useSourceOfProjectReferenceRedirect === true, extraFileExtensions: @@ -114,8 +124,8 @@ export function createParseSettings( ); } - // Providing a program overrides project resolution - if (!parseSettings.programs) { + // Providing a program or project service overrides project resolution + if (!parseSettings.programs && !parseSettings.EXPERIMENTAL_projectService) { parseSettings.projects = resolveProjectList({ cacheLifetime: options.cacheLifetime, project: getProjectConfigFiles(parseSettings, options.project), @@ -134,6 +144,10 @@ export function clearTSConfigMatchCache(): void { TSCONFIG_MATCH_CACHE?.clear(); } +export function clearTSServerProjectService(): void { + TSSERVER_PROJECT_SERVICE = null; +} + /** * Ensures source code is a string. */ diff --git a/packages/typescript-estree/src/parseSettings/index.ts b/packages/typescript-estree/src/parseSettings/index.ts index 3cf3e2a6f69..da093dedfed 100644 --- a/packages/typescript-estree/src/parseSettings/index.ts +++ b/packages/typescript-estree/src/parseSettings/index.ts @@ -1,4 +1,5 @@ import type * as ts from 'typescript'; +import type * as tsserverlibrary from 'typescript/lib/tsserverlibrary'; import type { CanonicalPath } from '../create-program/shared'; import type { TSESTree } from '../ts-estree'; @@ -57,6 +58,13 @@ export interface MutableParseSettings { */ errorOnUnknownASTType: boolean; + /** + * Experimental: TypeScript server to power program creation. + */ + EXPERIMENTAL_projectService: + | tsserverlibrary.server.ProjectService + | undefined; + /** * Whether TS should use the source files for referenced projects instead of the compiled .d.ts files. * diff --git a/packages/typescript-estree/src/parser-options.ts b/packages/typescript-estree/src/parser-options.ts index b867f32e63b..711ef4bca34 100644 --- a/packages/typescript-estree/src/parser-options.ts +++ b/packages/typescript-estree/src/parser-options.ts @@ -94,6 +94,15 @@ interface ParseAndGenerateServicesOptions extends ParseOptions { */ errorOnTypeScriptSyntacticAndSemanticIssues?: boolean; + /** + * ***EXPERIMENTAL FLAG*** - Use this at your own risk. + * + * Whether to create a shared TypeScript server to power program creation. + * + * @see https://github.com/typescript-eslint/typescript-eslint/issues/6575 + */ + EXPERIMENTAL_useProjectService?: boolean; + /** * ***EXPERIMENTAL FLAG*** - Use this at your own risk. * @@ -102,7 +111,7 @@ interface ParseAndGenerateServicesOptions extends ParseOptions { * * This flag REQUIRES at least TS v3.9, otherwise it does nothing. * - * See: https://github.com/typescript-eslint/typescript-eslint/issues/2094 + * @see https://github.com/typescript-eslint/typescript-eslint/issues/2094 */ EXPERIMENTAL_useSourceOfProjectReferenceRedirect?: boolean; diff --git a/packages/typescript-estree/src/parser.ts b/packages/typescript-estree/src/parser.ts index 63723c6dd62..4bf5dec26bd 100644 --- a/packages/typescript-estree/src/parser.ts +++ b/packages/typescript-estree/src/parser.ts @@ -10,6 +10,7 @@ import { createNoProgram, createSourceFile, } from './create-program/createSourceFile'; +import { getWatchProgramsForProjects } from './create-program/getWatchProgramsForProjects'; import type { ASTAndProgram, CanonicalPath } from './create-program/shared'; import { createProgramFromConfigFile, @@ -25,6 +26,7 @@ import type { ParseSettings } from './parseSettings'; import { createParseSettings } from './parseSettings/createParseSettings'; import { getFirstSemanticOrSyntacticError } from './semantic-or-syntactic-errors'; import type { TSESTree } from './ts-estree'; +import { useProgramFromProjectService } from './useProgramFromProjectService'; const log = debug('typescript-eslint:typescript-estree:parser'); @@ -47,6 +49,16 @@ function getProgramAndAST( parseSettings: ParseSettings, hasFullTypeInformation: boolean, ): ASTAndProgram { + if (parseSettings.EXPERIMENTAL_projectService) { + const fromProjectService = useProgramFromProjectService( + parseSettings.EXPERIMENTAL_projectService, + parseSettings, + ); + if (fromProjectService) { + return fromProjectService; + } + } + if (parseSettings.programs) { const fromProvidedPrograms = useProvidedPrograms( parseSettings.programs, @@ -57,27 +69,30 @@ function getProgramAndAST( } } - if (hasFullTypeInformation) { - const fromProjectProgram = createProjectProgram(parseSettings); - if (fromProjectProgram) { - return fromProjectProgram; - } + // no need to waste time creating a program as the caller didn't want parser services + // so we can save time and just create a lonesome source file + if (!hasFullTypeInformation) { + return createNoProgram(parseSettings); + } + + const fromProjectProgram = createProjectProgram( + parseSettings, + getWatchProgramsForProjects(parseSettings), + ); + if (fromProjectProgram) { + return fromProjectProgram; + } + // eslint-disable-next-line deprecation/deprecation -- will be cleaned up with the next major + if (parseSettings.DEPRECATED__createDefaultProgram) { // eslint-disable-next-line deprecation/deprecation -- will be cleaned up with the next major - if (parseSettings.DEPRECATED__createDefaultProgram) { - // eslint-disable-next-line deprecation/deprecation -- will be cleaned up with the next major - const fromDefaultProgram = createDefaultProgram(parseSettings); - if (fromDefaultProgram) { - return fromDefaultProgram; - } + const fromDefaultProgram = createDefaultProgram(parseSettings); + if (fromDefaultProgram) { + return fromDefaultProgram; } - - return createIsolatedProgram(parseSettings); } - // no need to waste time creating a program as the caller didn't want parser services - // so we can save time and just create a lonesome source file - return createNoProgram(parseSettings); + return createIsolatedProgram(parseSettings); } // eslint-disable-next-line @typescript-eslint/no-empty-interface diff --git a/packages/typescript-estree/src/useProgramFromProjectService.ts b/packages/typescript-estree/src/useProgramFromProjectService.ts new file mode 100644 index 00000000000..16a7933a671 --- /dev/null +++ b/packages/typescript-estree/src/useProgramFromProjectService.ts @@ -0,0 +1,40 @@ +import path from 'path'; +import type * as ts from 'typescript'; +import type { server } from 'typescript/lib/tsserverlibrary'; + +import { createProjectProgram } from './create-program/createProjectProgram'; +import { type ASTAndDefiniteProgram } from './create-program/shared'; +import type { MutableParseSettings } from './parseSettings'; + +export function useProgramFromProjectService( + projectService: server.ProjectService, + parseSettings: Readonly, +): ASTAndDefiniteProgram | undefined { + const opened = projectService.openClientFile( + absolutify(parseSettings.filePath), + parseSettings.codeFullText, + /* scriptKind */ undefined, + parseSettings.tsconfigRootDir, + ); + if (!opened.configFileName) { + return undefined; + } + + const scriptInfo = projectService.getScriptInfo(parseSettings.filePath); + const program = projectService + .getDefaultProjectForFile(scriptInfo!.fileName, true)! + .getLanguageService(/*ensureSynchronized*/ true) + .getProgram(); + + if (!program) { + return undefined; + } + + return createProjectProgram(parseSettings, [program as ts.Program]); + + function absolutify(filePath: string): string { + return path.isAbsolute(filePath) + ? filePath + : path.join(projectService.host.getCurrentDirectory(), filePath); + } +} diff --git a/packages/typescript-estree/tests/lib/__snapshots__/parse.test.ts.snap b/packages/typescript-estree/tests/lib/__snapshots__/parse.test.ts.snap index 0f777af2df2..c4182c42113 100644 --- a/packages/typescript-estree/tests/lib/__snapshots__/parse.test.ts.snap +++ b/packages/typescript-estree/tests/lib/__snapshots__/parse.test.ts.snap @@ -1,87 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`parseAndGenerateServices invalid file error messages "parserOptions.extraFileExtensions" is empty the extension does not match 1`] = ` -"ESLint was configured to run on \`/other/unknownFileType.unknown\` using \`parserOptions.project\`: /tsconfig.json -The extension for the file (\`.unknown\`) is non-standard. You should add \`parserOptions.extraFileExtensions\` to your config." -`; - -exports[`parseAndGenerateServices invalid file error messages "parserOptions.extraFileExtensions" is non-empty invalid extension 1`] = ` -"ESLint was configured to run on \`/other/unknownFileType.unknown\` using \`parserOptions.project\`: /tsconfig.json -Found unexpected extension \`unknown\` specified with the \`parserOptions.extraFileExtensions\` option. Did you mean \`.unknown\`? -The extension for the file (\`.unknown\`) is non-standard. It should be added to your existing \`parserOptions.extraFileExtensions\`." -`; - -exports[`parseAndGenerateServices invalid file error messages "parserOptions.extraFileExtensions" is non-empty the extension does not match 1`] = ` -"ESLint was configured to run on \`/other/unknownFileType.unknown\` using \`parserOptions.project\`: /tsconfig.json -The extension for the file (\`.unknown\`) is non-standard. It should be added to your existing \`parserOptions.extraFileExtensions\`." -`; - -exports[`parseAndGenerateServices invalid file error messages "parserOptions.extraFileExtensions" is non-empty the extension matches duplicate extension 1`] = ` -"ESLint was configured to run on \`/ts/notIncluded.ts\` using \`parserOptions.project\`: /tsconfig.json -You unnecessarily included the extension \`.ts\` with the \`parserOptions.extraFileExtensions\` option. This extension is already handled by the parser by default. -However, that TSConfig does not include this file. Either: -- Change ESLint's list of included files to not include this file -- Change that TSConfig to include this file -- Create a new TSConfig that includes this file and include it in your parserOptions.project -See the typescript-eslint docs for more info: https://typescript-eslint.io/linting/troubleshooting#i-get-errors-telling-me-eslint-was-configured-to-run--however-that-tsconfig-does-not--none-of-those-tsconfigs-include-this-file" -`; - -exports[`parseAndGenerateServices invalid file error messages "parserOptions.extraFileExtensions" is non-empty the extension matches the file isn't included 1`] = ` -"ESLint was configured to run on \`/other/notIncluded.vue\` using \`parserOptions.project\`: /tsconfig.json -However, that TSConfig does not include this file. Either: -- Change ESLint's list of included files to not include this file -- Change that TSConfig to include this file -- Create a new TSConfig that includes this file and include it in your parserOptions.project -See the typescript-eslint docs for more info: https://typescript-eslint.io/linting/troubleshooting#i-get-errors-telling-me-eslint-was-configured-to-run--however-that-tsconfig-does-not--none-of-those-tsconfigs-include-this-file" -`; - -exports[`parseAndGenerateServices invalid file error messages project includes errors for not included files 1`] = ` -"ESLint was configured to run on \`/ts/notIncluded0j1.ts\` using \`parserOptions.project\`: /tsconfig.json -However, that TSConfig does not include this file. Either: -- Change ESLint's list of included files to not include this file -- Change that TSConfig to include this file -- Create a new TSConfig that includes this file and include it in your parserOptions.project -See the typescript-eslint docs for more info: https://typescript-eslint.io/linting/troubleshooting#i-get-errors-telling-me-eslint-was-configured-to-run--however-that-tsconfig-does-not--none-of-those-tsconfigs-include-this-file" -`; - -exports[`parseAndGenerateServices invalid file error messages project includes errors for not included files 2`] = ` -"ESLint was configured to run on \`/ts/notIncluded02.tsx\` using \`parserOptions.project\`: /tsconfig.json -However, that TSConfig does not include this file. Either: -- Change ESLint's list of included files to not include this file -- Change that TSConfig to include this file -- Create a new TSConfig that includes this file and include it in your parserOptions.project -See the typescript-eslint docs for more info: https://typescript-eslint.io/linting/troubleshooting#i-get-errors-telling-me-eslint-was-configured-to-run--however-that-tsconfig-does-not--none-of-those-tsconfigs-include-this-file" -`; - -exports[`parseAndGenerateServices invalid file error messages project includes errors for not included files 3`] = ` -"ESLint was configured to run on \`/js/notIncluded01.js\` using \`parserOptions.project\`: /tsconfig.json -However, that TSConfig does not include this file. Either: -- Change ESLint's list of included files to not include this file -- Change that TSConfig to include this file -- Create a new TSConfig that includes this file and include it in your parserOptions.project -See the typescript-eslint docs for more info: https://typescript-eslint.io/linting/troubleshooting#i-get-errors-telling-me-eslint-was-configured-to-run--however-that-tsconfig-does-not--none-of-those-tsconfigs-include-this-file" -`; - -exports[`parseAndGenerateServices invalid file error messages project includes errors for not included files 4`] = ` -"ESLint was configured to run on \`/js/notIncluded02.jsx\` using \`parserOptions.project\`: /tsconfig.json -However, that TSConfig does not include this file. Either: -- Change ESLint's list of included files to not include this file -- Change that TSConfig to include this file -- Create a new TSConfig that includes this file and include it in your parserOptions.project -See the typescript-eslint docs for more info: https://typescript-eslint.io/linting/troubleshooting#i-get-errors-telling-me-eslint-was-configured-to-run--however-that-tsconfig-does-not--none-of-those-tsconfigs-include-this-file" -`; - -exports[`parseAndGenerateServices invalid project error messages throws when non of multiple projects include the file 1`] = ` -"ESLint was configured to run on \`/ts/notIncluded0j1.ts\` using \`parserOptions.project\`: -- /tsconfig.json -- /tsconfig.extra.json -However, none of those TSConfigs include this file. Either: -- Change ESLint's list of included files to not include this file -- Change one of those TSConfigs to include this file -- Create a new TSConfig that includes this file and include it in your parserOptions.project -See the typescript-eslint docs for more info: https://typescript-eslint.io/linting/troubleshooting#i-get-errors-telling-me-eslint-was-configured-to-run--however-that-tsconfig-does-not--none-of-those-tsconfigs-include-this-file" -`; - exports[`parseAndGenerateServices isolated parsing should parse .js file - with JSX content - parserOptions.jsx = false 1`] = ` { "ast": { diff --git a/packages/typescript-estree/tests/lib/__snapshots__/semanticInfo.test.ts.snap b/packages/typescript-estree/tests/lib/__snapshots__/semanticInfo.test.ts.snap index 0d94ba3a46b..79772deb70e 100644 --- a/packages/typescript-estree/tests/lib/__snapshots__/semanticInfo.test.ts.snap +++ b/packages/typescript-estree/tests/lib/__snapshots__/semanticInfo.test.ts.snap @@ -1734,5 +1734,3 @@ exports[`semanticInfo fixtures/non-existent-estree-nodes.src 1`] = ` "type": "Program", } `; - -exports[`semanticInfo malformed project file 1`] = `"Compiler option 'compileOnSave' requires a value of type boolean."`; diff --git a/packages/typescript-estree/tests/lib/createProjectService.test.ts b/packages/typescript-estree/tests/lib/createProjectService.test.ts new file mode 100644 index 00000000000..f6f6d117e3b --- /dev/null +++ b/packages/typescript-estree/tests/lib/createProjectService.test.ts @@ -0,0 +1,7 @@ +import { createProjectService } from '../../src/create-program/createProjectService'; + +describe('createProjectService', () => { + it('does not crash', () => { + createProjectService(); + }); +}); diff --git a/packages/typescript-estree/tests/lib/parse.project-true.test.ts b/packages/typescript-estree/tests/lib/parse.project-true.test.ts index ff2ea0d0e3f..ca81ab99f50 100644 --- a/packages/typescript-estree/tests/lib/parse.project-true.test.ts +++ b/packages/typescript-estree/tests/lib/parse.project-true.test.ts @@ -35,15 +35,17 @@ describe('parseAndGenerateServices', () => { }); }); - 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'./, - ); - }); + if (process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER !== 'true') { + 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/typescript-estree/tests/lib/parse.test.ts b/packages/typescript-estree/tests/lib/parse.test.ts index 02bacdfadf3..658493c9150 100644 --- a/packages/typescript-estree/tests/lib/parse.test.ts +++ b/packages/typescript-estree/tests/lib/parse.test.ts @@ -166,6 +166,7 @@ describe('parseAndGenerateServices', () => { describe('isolated parsing', () => { const config: TSESTreeOptions = { + EXPERIMENTAL_useProjectService: false, comment: true, tokens: true, range: true, @@ -339,124 +340,184 @@ describe('parseAndGenerateServices', () => { }); }); - describe('invalid file error messages', () => { - const PROJECT_DIR = resolve(FIXTURES_DIR, '../invalidFileErrors'); - const code = 'var a = true'; - const config: TSESTreeOptions = { - comment: true, - tokens: true, - range: true, - loc: true, - tsconfigRootDir: PROJECT_DIR, - project: './tsconfig.json', - }; - const testParse = - (filePath: string, extraFileExtensions: string[] = ['.vue']) => - (): void => { - try { - parser.parseAndGenerateServices(code, { - ...config, - extraFileExtensions, - filePath: join(PROJECT_DIR, filePath), - }); - } catch (error) { - throw alignErrorPath(error as Error); - } + if (process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER !== 'true') { + describe('invalid file error messages', () => { + const PROJECT_DIR = resolve(FIXTURES_DIR, '../invalidFileErrors'); + const code = 'var a = true'; + const config: TSESTreeOptions = { + comment: true, + tokens: true, + range: true, + loc: true, + tsconfigRootDir: PROJECT_DIR, + project: './tsconfig.json', }; + const testParse = + (filePath: string, extraFileExtensions: string[] = ['.vue']) => + (): void => { + try { + parser.parseAndGenerateServices(code, { + ...config, + extraFileExtensions, + filePath: join(PROJECT_DIR, filePath), + }); + } catch (error) { + throw alignErrorPath(error as Error); + } + }; + + describe('project includes', () => { + it("doesn't error for matched files", () => { + expect(testParse('ts/included01.ts')).not.toThrow(); + expect(testParse('ts/included02.tsx')).not.toThrow(); + expect(testParse('js/included01.js')).not.toThrow(); + expect(testParse('js/included02.jsx')).not.toThrow(); + }); - describe('project includes', () => { - it("doesn't error for matched files", () => { - expect(testParse('ts/included01.ts')).not.toThrow(); - expect(testParse('ts/included02.tsx')).not.toThrow(); - expect(testParse('js/included01.js')).not.toThrow(); - expect(testParse('js/included02.jsx')).not.toThrow(); + it('errors for not included files', () => { + expect(testParse('ts/notIncluded0j1.ts')) + .toThrowErrorMatchingInlineSnapshot(` + "ESLint was configured to run on \`/ts/notIncluded0j1.ts\` using \`parserOptions.project\`: /tsconfig.json + However, that TSConfig does not include this file. Either: + - Change ESLint's list of included files to not include this file + - Change that TSConfig to include this file + - Create a new TSConfig that includes this file and include it in your parserOptions.project + See the typescript-eslint docs for more info: https://typescript-eslint.io/linting/troubleshooting#i-get-errors-telling-me-eslint-was-configured-to-run--however-that-tsconfig-does-not--none-of-those-tsconfigs-include-this-file" + `); + expect(testParse('ts/notIncluded02.tsx')) + .toThrowErrorMatchingInlineSnapshot(` + "ESLint was configured to run on \`/ts/notIncluded02.tsx\` using \`parserOptions.project\`: /tsconfig.json + However, that TSConfig does not include this file. Either: + - Change ESLint's list of included files to not include this file + - Change that TSConfig to include this file + - Create a new TSConfig that includes this file and include it in your parserOptions.project + See the typescript-eslint docs for more info: https://typescript-eslint.io/linting/troubleshooting#i-get-errors-telling-me-eslint-was-configured-to-run--however-that-tsconfig-does-not--none-of-those-tsconfigs-include-this-file" + `); + expect(testParse('js/notIncluded01.js')) + .toThrowErrorMatchingInlineSnapshot(` + "ESLint was configured to run on \`/js/notIncluded01.js\` using \`parserOptions.project\`: /tsconfig.json + However, that TSConfig does not include this file. Either: + - Change ESLint's list of included files to not include this file + - Change that TSConfig to include this file + - Create a new TSConfig that includes this file and include it in your parserOptions.project + See the typescript-eslint docs for more info: https://typescript-eslint.io/linting/troubleshooting#i-get-errors-telling-me-eslint-was-configured-to-run--however-that-tsconfig-does-not--none-of-those-tsconfigs-include-this-file" + `); + expect(testParse('js/notIncluded02.jsx')) + .toThrowErrorMatchingInlineSnapshot(` + "ESLint was configured to run on \`/js/notIncluded02.jsx\` using \`parserOptions.project\`: /tsconfig.json + However, that TSConfig does not include this file. Either: + - Change ESLint's list of included files to not include this file + - Change that TSConfig to include this file + - Create a new TSConfig that includes this file and include it in your parserOptions.project + See the typescript-eslint docs for more info: https://typescript-eslint.io/linting/troubleshooting#i-get-errors-telling-me-eslint-was-configured-to-run--however-that-tsconfig-does-not--none-of-those-tsconfigs-include-this-file" + `); + }); }); - it('errors for not included files', () => { - expect( - testParse('ts/notIncluded0j1.ts'), - ).toThrowErrorMatchingSnapshot(); - expect( - testParse('ts/notIncluded02.tsx'), - ).toThrowErrorMatchingSnapshot(); - expect(testParse('js/notIncluded01.js')).toThrowErrorMatchingSnapshot(); - expect( - testParse('js/notIncluded02.jsx'), - ).toThrowErrorMatchingSnapshot(); - }); - }); + describe('"parserOptions.extraFileExtensions" is empty', () => { + it('should not error', () => { + expect(testParse('ts/included01.ts', [])).not.toThrow(); + }); - describe('"parserOptions.extraFileExtensions" is empty', () => { - it('should not error', () => { - expect(testParse('ts/included01.ts', [])).not.toThrow(); + it('the extension does not match', () => { + expect(testParse('other/unknownFileType.unknown', [])) + .toThrowErrorMatchingInlineSnapshot(` + "ESLint was configured to run on \`/other/unknownFileType.unknown\` using \`parserOptions.project\`: /tsconfig.json + The extension for the file (\`.unknown\`) is non-standard. You should add \`parserOptions.extraFileExtensions\` to your config." + `); + }); }); - it('the extension does not match', () => { - expect( - testParse('other/unknownFileType.unknown', []), - ).toThrowErrorMatchingSnapshot(); - }); - }); + describe('"parserOptions.extraFileExtensions" is non-empty', () => { + describe('the extension matches', () => { + it('the file is included', () => { + expect(testParse('other/included.vue')).not.toThrow(); + }); - describe('"parserOptions.extraFileExtensions" is non-empty', () => { - describe('the extension matches', () => { - it('the file is included', () => { - expect(testParse('other/included.vue')).not.toThrow(); - }); + it("the file isn't included", () => { + expect(testParse('other/notIncluded.vue')) + .toThrowErrorMatchingInlineSnapshot(` + "ESLint was configured to run on \`/other/notIncluded.vue\` using \`parserOptions.project\`: /tsconfig.json + However, that TSConfig does not include this file. Either: + - Change ESLint's list of included files to not include this file + - Change that TSConfig to include this file + - Create a new TSConfig that includes this file and include it in your parserOptions.project + See the typescript-eslint docs for more info: https://typescript-eslint.io/linting/troubleshooting#i-get-errors-telling-me-eslint-was-configured-to-run--however-that-tsconfig-does-not--none-of-those-tsconfigs-include-this-file" + `); + }); - it("the file isn't included", () => { - expect( - testParse('other/notIncluded.vue'), - ).toThrowErrorMatchingSnapshot(); + it('duplicate extension', () => { + expect(testParse('ts/notIncluded.ts', ['.ts'])) + .toThrowErrorMatchingInlineSnapshot(` + "ESLint was configured to run on \`/ts/notIncluded.ts\` using \`parserOptions.project\`: /tsconfig.json + You unnecessarily included the extension \`.ts\` with the \`parserOptions.extraFileExtensions\` option. This extension is already handled by the parser by default. + However, that TSConfig does not include this file. Either: + - Change ESLint's list of included files to not include this file + - Change that TSConfig to include this file + - Create a new TSConfig that includes this file and include it in your parserOptions.project + See the typescript-eslint docs for more info: https://typescript-eslint.io/linting/troubleshooting#i-get-errors-telling-me-eslint-was-configured-to-run--however-that-tsconfig-does-not--none-of-those-tsconfigs-include-this-file" + `); + }); }); - it('duplicate extension', () => { - expect( - testParse('ts/notIncluded.ts', ['.ts']), - ).toThrowErrorMatchingSnapshot(); + it('invalid extension', () => { + expect(testParse('other/unknownFileType.unknown', ['unknown'])) + .toThrowErrorMatchingInlineSnapshot(` + "ESLint was configured to run on \`/other/unknownFileType.unknown\` using \`parserOptions.project\`: /tsconfig.json + Found unexpected extension \`unknown\` specified with the \`parserOptions.extraFileExtensions\` option. Did you mean \`.unknown\`? + The extension for the file (\`.unknown\`) is non-standard. It should be added to your existing \`parserOptions.extraFileExtensions\`." + `); }); - }); - - it('invalid extension', () => { - expect( - testParse('other/unknownFileType.unknown', ['unknown']), - ).toThrowErrorMatchingSnapshot(); - }); - it('the extension does not match', () => { - expect( - testParse('other/unknownFileType.unknown'), - ).toThrowErrorMatchingSnapshot(); + it('the extension does not match', () => { + expect(testParse('other/unknownFileType.unknown')) + .toThrowErrorMatchingInlineSnapshot(` + "ESLint was configured to run on \`/other/unknownFileType.unknown\` using \`parserOptions.project\`: /tsconfig.json + The extension for the file (\`.unknown\`) is non-standard. It should be added to your existing \`parserOptions.extraFileExtensions\`." + `); + }); }); }); - }); - describe('invalid project error messages', () => { - it('throws when non of multiple projects include the file', () => { - const PROJECT_DIR = resolve(FIXTURES_DIR, '../invalidFileErrors'); - const code = 'var a = true'; - const config: TSESTreeOptions = { - comment: true, - tokens: true, - range: true, - loc: true, - tsconfigRootDir: PROJECT_DIR, - project: ['./**/tsconfig.json', './**/tsconfig.extra.json'], - }; - const testParse = (filePath: string) => (): void => { - try { - parser.parseAndGenerateServices(code, { - ...config, - filePath: join(PROJECT_DIR, filePath), - }); - } catch (error) { - throw alignErrorPath(error as Error); - } - }; - - expect(testParse('ts/notIncluded0j1.ts')).toThrowErrorMatchingSnapshot(); + describe('invalid project error messages', () => { + if (process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER !== 'true') { + it('throws when none of multiple projects include the file', () => { + const PROJECT_DIR = resolve(FIXTURES_DIR, '../invalidFileErrors'); + const code = 'var a = true'; + const config: TSESTreeOptions = { + comment: true, + tokens: true, + range: true, + loc: true, + tsconfigRootDir: PROJECT_DIR, + project: ['./**/tsconfig.json', './**/tsconfig.extra.json'], + }; + const testParse = (filePath: string) => (): void => { + try { + parser.parseAndGenerateServices(code, { + ...config, + filePath: join(PROJECT_DIR, filePath), + }); + } catch (error) { + throw alignErrorPath(error as Error); + } + }; + + expect(testParse('ts/notIncluded0j1.ts')) + .toThrowErrorMatchingInlineSnapshot(` + "ESLint was configured to run on \`/ts/notIncluded0j1.ts\` using \`parserOptions.project\`: + - /tsconfig.json + - /tsconfig.extra.json + However, none of those TSConfigs include this file. Either: + - Change ESLint's list of included files to not include this file + - Change one of those TSConfigs to include this file + - Create a new TSConfig that includes this file and include it in your parserOptions.project + See the typescript-eslint docs for more info: https://typescript-eslint.io/linting/troubleshooting#i-get-errors-telling-me-eslint-was-configured-to-run--however-that-tsconfig-does-not--none-of-those-tsconfigs-include-this-file" + `); + }); + } }); - }); + } describe('debug options', () => { const debugEnable = jest.fn(); @@ -499,123 +560,127 @@ describe('parseAndGenerateServices', () => { ); }); - it('should turn on typescript debugger', () => { - expect(() => - parser.parseAndGenerateServices('const x = 1;', { - debugLevel: ['typescript'], - filePath: './path-that-doesnt-exist.ts', - project: ['./tsconfig-that-doesnt-exist.json'], - }), - ) // should throw because the file and tsconfig don't exist - .toThrow(); - expect(createDefaultCompilerOptionsFromExtra).toHaveBeenCalled(); - expect(createDefaultCompilerOptionsFromExtra).toHaveReturnedWith( - expect.objectContaining({ - extendedDiagnostics: true, - }), - ); - }); + if (process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER !== 'true') { + it('should turn on typescript debugger', () => { + expect(() => + parser.parseAndGenerateServices('const x = 1;', { + debugLevel: ['typescript'], + filePath: './path-that-doesnt-exist.ts', + project: ['./tsconfig-that-doesnt-exist.json'], + }), + ) // should throw because the file and tsconfig don't exist + .toThrow(); + expect(createDefaultCompilerOptionsFromExtra).toHaveBeenCalled(); + expect(createDefaultCompilerOptionsFromExtra).toHaveReturnedWith( + expect.objectContaining({ + extendedDiagnostics: true, + }), + ); + }); + } }); - describe('projectFolderIgnoreList', () => { - beforeEach(() => { - parser.clearCaches(); - }); - - const PROJECT_DIR = resolve(FIXTURES_DIR, '../projectFolderIgnoreList'); - const code = 'var a = true'; - const config: TSESTreeOptions = { - comment: true, - tokens: true, - range: true, - loc: true, - tsconfigRootDir: PROJECT_DIR, - project: './**/tsconfig.json', - }; + if (process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER !== 'true') { + describe('projectFolderIgnoreList', () => { + beforeEach(() => { + parser.clearCaches(); + }); - const testParse = - ( - filePath: 'ignoreme' | 'includeme', - projectFolderIgnoreList?: TSESTreeOptions['projectFolderIgnoreList'], - ) => - (): void => { - parser.parseAndGenerateServices(code, { - ...config, - projectFolderIgnoreList, - filePath: join(PROJECT_DIR, filePath, './file.ts'), - }); + const PROJECT_DIR = resolve(FIXTURES_DIR, '../projectFolderIgnoreList'); + const code = 'var a = true'; + const config: TSESTreeOptions = { + comment: true, + tokens: true, + range: true, + loc: true, + tsconfigRootDir: PROJECT_DIR, + project: './**/tsconfig.json', }; - it('ignores nothing when given nothing', () => { - expect(testParse('ignoreme')).not.toThrow(); - expect(testParse('includeme')).not.toThrow(); - }); + const testParse = + ( + filePath: 'ignoreme' | 'includeme', + projectFolderIgnoreList?: TSESTreeOptions['projectFolderIgnoreList'], + ) => + (): void => { + parser.parseAndGenerateServices(code, { + ...config, + projectFolderIgnoreList, + filePath: join(PROJECT_DIR, filePath, './file.ts'), + }); + }; + + it('ignores nothing when given nothing', () => { + expect(testParse('ignoreme')).not.toThrow(); + expect(testParse('includeme')).not.toThrow(); + }); - it('ignores a folder when given a string glob', () => { - const ignore = ['**/ignoreme/**']; - // cspell:disable-next-line - expect(testParse('ignoreme', ignore)).toThrow(); - // cspell:disable-next-line - expect(testParse('includeme', ignore)).not.toThrow(); + it('ignores a folder when given a string glob', () => { + const ignore = ['**/ignoreme/**']; + // cspell:disable-next-line + expect(testParse('ignoreme', ignore)).toThrow(); + // cspell:disable-next-line + expect(testParse('includeme', ignore)).not.toThrow(); + }); }); - }); - describe('cacheLifetime', () => { - describe('glob', () => { - function doParse(lifetime: CacheDurationSeconds): void { - parser.parseAndGenerateServices('const x = 1', { - cacheLifetime: { - glob: lifetime, - }, - filePath: join(FIXTURES_DIR, 'file.ts'), - tsconfigRootDir: FIXTURES_DIR, - project: ['./**/tsconfig.json', './**/tsconfig.extra.json'], - }); - } + describe('cacheLifetime', () => { + describe('glob', () => { + function doParse(lifetime: CacheDurationSeconds): void { + parser.parseAndGenerateServices('const x = 1', { + cacheLifetime: { + glob: lifetime, + }, + filePath: join(FIXTURES_DIR, 'file.ts'), + tsconfigRootDir: FIXTURES_DIR, + project: ['./**/tsconfig.json', './**/tsconfig.extra.json'], + }); + } - it('should cache globs if the lifetime is non-zero', () => { - doParse(30); - expect(globbySyncMock).toHaveBeenCalledTimes(1); - doParse(30); - // shouldn't call globby again due to the caching - expect(globbySyncMock).toHaveBeenCalledTimes(1); - }); + it('should cache globs if the lifetime is non-zero', () => { + doParse(30); + expect(globbySyncMock).toHaveBeenCalledTimes(1); + doParse(30); + // shouldn't call globby again due to the caching + expect(globbySyncMock).toHaveBeenCalledTimes(1); + }); - it('should not cache globs if the lifetime is zero', () => { - doParse(0); - expect(globbySyncMock).toHaveBeenCalledTimes(1); - doParse(0); - // should call globby again because we specified immediate cache expiry - expect(globbySyncMock).toHaveBeenCalledTimes(2); - }); + it('should not cache globs if the lifetime is zero', () => { + doParse(0); + expect(globbySyncMock).toHaveBeenCalledTimes(1); + doParse(0); + // should call globby again because we specified immediate cache expiry + expect(globbySyncMock).toHaveBeenCalledTimes(2); + }); - it('should evict the cache if the entry expires', () => { - hrtimeSpy.mockReturnValueOnce([1, 0]); + it('should evict the cache if the entry expires', () => { + hrtimeSpy.mockReturnValueOnce([1, 0]); - doParse(30); - expect(globbySyncMock).toHaveBeenCalledTimes(1); + doParse(30); + expect(globbySyncMock).toHaveBeenCalledTimes(1); - // wow so much time has passed - hrtimeSpy.mockReturnValueOnce([Number.MAX_VALUE, 0]); + // wow so much time has passed + hrtimeSpy.mockReturnValueOnce([Number.MAX_VALUE, 0]); - doParse(30); - // shouldn't call globby again due to the caching - expect(globbySyncMock).toHaveBeenCalledTimes(2); - }); + doParse(30); + // shouldn't call globby again due to the caching + expect(globbySyncMock).toHaveBeenCalledTimes(2); + }); - it('should infinitely cache if passed Infinity', () => { - hrtimeSpy.mockReturnValueOnce([1, 0]); + it('should infinitely cache if passed Infinity', () => { + hrtimeSpy.mockReturnValueOnce([1, 0]); - doParse('Infinity'); - expect(globbySyncMock).toHaveBeenCalledTimes(1); + doParse('Infinity'); + expect(globbySyncMock).toHaveBeenCalledTimes(1); - // wow so much time has passed - hrtimeSpy.mockReturnValueOnce([Number.MAX_VALUE, 0]); + // wow so much time has passed + hrtimeSpy.mockReturnValueOnce([Number.MAX_VALUE, 0]); - doParse('Infinity'); - // shouldn't call globby again due to the caching - expect(globbySyncMock).toHaveBeenCalledTimes(1); + doParse('Infinity'); + // shouldn't call globby again due to the caching + expect(globbySyncMock).toHaveBeenCalledTimes(1); + }); }); }); - }); + } }); diff --git a/packages/typescript-estree/tests/lib/persistentParse.test.ts b/packages/typescript-estree/tests/lib/persistentParse.test.ts index 63e81d7e260..710b9c54ab5 100644 --- a/packages/typescript-estree/tests/lib/persistentParse.test.ts +++ b/packages/typescript-estree/tests/lib/persistentParse.test.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import tmp from 'tmp'; +import { clearCaches } from '../../src/clear-caches'; import { clearWatchCaches } from '../../src/create-program/getWatchProgramsForProjects'; import { parseAndGenerateServices } from '../../src/parser'; @@ -86,6 +87,11 @@ function baseTests( tsConfigExcludeBar: Record, tsConfigIncludeAll: Record, ): void { + // The experimental project server creates a default project for files + if (process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER === 'true') { + return; + } + it('parses both files successfully when included', () => { const PROJECT_DIR = setup(tsConfigIncludeAll); @@ -177,6 +183,7 @@ function baseTests( // change the config file so it now includes all files writeTSConfig(PROJECT_DIR, tsConfigIncludeAll); + clearCaches(); expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow(); expect(() => parseFile('bar', PROJECT_DIR)).not.toThrow(); @@ -257,43 +264,48 @@ describe('persistent parse', () => { /* If there is no includes, then typescript will ask for a slightly different set of watchers. */ - describe('tsconfig with no includes / files', () => { - const tsConfigExcludeBar = { - exclude: ['./src/bar.ts'], - }; - const tsConfigIncludeAll = {}; - baseTests(tsConfigExcludeBar, tsConfigIncludeAll); + if (process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER !== 'true') { + describe('tsconfig with no includes / files', () => { + const tsConfigExcludeBar = { + exclude: ['./src/bar.ts'], + }; + const tsConfigIncludeAll = {}; - it('handles tsconfigs with no includes/excludes (single level)', () => { - const PROJECT_DIR = setup({}, false); + baseTests(tsConfigExcludeBar, tsConfigIncludeAll); - // parse once to: assert the config as correct, and to make sure the program is setup - expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow(); - expect(() => parseFile('bar', PROJECT_DIR)).toThrow(); + it('handles tsconfigs with no includes/excludes (single level)', () => { + const PROJECT_DIR = setup({}, false); - // write a new file and attempt to parse it - writeFile(PROJECT_DIR, 'bar'); + // parse once to: assert the config as correct, and to make sure the program is setup + expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow(); + expect(() => parseFile('bar', PROJECT_DIR)).toThrow(); - expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow(); - expect(() => parseFile('bar', PROJECT_DIR)).not.toThrow(); - }); + // write a new file and attempt to parse it + writeFile(PROJECT_DIR, 'bar'); + clearCaches(); + + expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow(); + expect(() => parseFile('bar', PROJECT_DIR)).not.toThrow(); + }); - it('handles tsconfigs with no includes/excludes (nested)', () => { - const PROJECT_DIR = setup({}, false); - const bazSlashBar = 'baz/bar' as const; + it('handles tsconfigs with no includes/excludes (nested)', () => { + const PROJECT_DIR = setup({}, false); + const bazSlashBar = 'baz/bar' as const; - // parse once to: assert the config as correct, and to make sure the program is setup - expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow(); - expect(() => parseFile(bazSlashBar, PROJECT_DIR)).toThrow(); + // parse once to: assert the config as correct, and to make sure the program is setup + expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow(); + expect(() => parseFile(bazSlashBar, PROJECT_DIR)).toThrow(); - // write a new file and attempt to parse it - writeFile(PROJECT_DIR, bazSlashBar); + // write a new file and attempt to parse it + writeFile(PROJECT_DIR, bazSlashBar); + clearCaches(); - expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow(); - expect(() => parseFile(bazSlashBar, PROJECT_DIR)).not.toThrow(); + expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow(); + expect(() => parseFile(bazSlashBar, PROJECT_DIR)).not.toThrow(); + }); }); - }); + } /* If there is no includes, then typescript will ask for a slightly different set of watchers. diff --git a/packages/typescript-estree/tests/lib/semanticInfo-singleRun.test.ts b/packages/typescript-estree/tests/lib/semanticInfo-singleRun.test.ts index 725195111f1..09daae795d6 100644 --- a/packages/typescript-estree/tests/lib/semanticInfo-singleRun.test.ts +++ b/packages/typescript-estree/tests/lib/semanticInfo-singleRun.test.ts @@ -135,124 +135,132 @@ describe('semanticInfo - singleRun', () => { process.env.CI = originalEnvCI; }); - it('should lazily create the required program out of the provided "parserOptions.project" one time when TSESTREE_SINGLE_RUN=true', () => { - /** - * Single run because of explicit environment variable TSESTREE_SINGLE_RUN - */ - const originalTSESTreeSingleRun = process.env.TSESTREE_SINGLE_RUN; - process.env.TSESTREE_SINGLE_RUN = 'true'; - - const resultProgram = parseAndGenerateServices(code, options).services - .program; - expect(resultProgram).toEqual(mockProgram); - - // Call parseAndGenerateServices() again to ensure caching of Programs is working correctly... - parseAndGenerateServices(code, options); - // ...by asserting this was only called once per project - expect(createProgramFromConfigFile).toHaveBeenCalledTimes(tsconfigs.length); - - expect(createProgramFromConfigFile).toHaveBeenNthCalledWith( - 1, - resolvedProject(tsconfigs[0]), - ); - expect(createProgramFromConfigFile).toHaveBeenNthCalledWith( - 2, - resolvedProject(tsconfigs[1]), - ); - - // Restore process data - process.env.TSESTREE_SINGLE_RUN = originalTSESTreeSingleRun; - }); - - it('should lazily create the required program out of the provided "parserOptions.project" one time when singleRun is inferred from CI=true', () => { - /** - * Single run because of CI=true (we need to make sure we respect the original value - * so that we won't interfere with our own usage of the variable) - */ - const originalEnvCI = process.env.CI; - process.env.CI = 'true'; - - const resultProgram = parseAndGenerateServices(code, options).services - .program; - expect(resultProgram).toEqual(mockProgram); - - // Call parseAndGenerateServices() again to ensure caching of Programs is working correctly... - parseAndGenerateServices(code, options); - // ...by asserting this was only called once per project - expect(createProgramFromConfigFile).toHaveBeenCalledTimes(tsconfigs.length); - - expect(createProgramFromConfigFile).toHaveBeenNthCalledWith( - 1, - resolvedProject(tsconfigs[0]), - ); - expect(createProgramFromConfigFile).toHaveBeenNthCalledWith( - 2, - resolvedProject(tsconfigs[1]), - ); - - // Restore process data - process.env.CI = originalEnvCI; - }); - - it('should lazily create the required program out of the provided "parserOptions.project" one time when singleRun is inferred from process.argv', () => { - /** - * Single run because of process.argv - */ - const originalProcessArgv = process.argv; - process.argv = ['', path.normalize('node_modules/.bin/eslint'), '']; - - const resultProgram = parseAndGenerateServices(code, options).services - .program; - expect(resultProgram).toEqual(mockProgram); - - // Call parseAndGenerateServices() again to ensure caching of Programs is working correctly... - parseAndGenerateServices(code, options); - // ...by asserting this was only called once per project - expect(createProgramFromConfigFile).toHaveBeenCalledTimes(tsconfigs.length); - - expect(createProgramFromConfigFile).toHaveBeenNthCalledWith( - 1, - resolvedProject(tsconfigs[0]), - ); - expect(createProgramFromConfigFile).toHaveBeenNthCalledWith( - 2, - resolvedProject(tsconfigs[1]), - ); - - // Restore process data - process.argv = originalProcessArgv; - }); - - it('should stop iterating through and lazily creating programs for the given "parserOptions.project" once a matching one has been found', () => { - /** - * Single run because of explicit environment variable TSESTREE_SINGLE_RUN - */ - const originalTSESTreeSingleRun = process.env.TSESTREE_SINGLE_RUN; - process.env.TSESTREE_SINGLE_RUN = 'true'; - - const optionsWithReversedTsconfigs = { - ...options, - // Now the matching tsconfig comes first - project: [...options.project].reverse(), - }; - - const resultProgram = parseAndGenerateServices( - code, - optionsWithReversedTsconfigs, - ).services.program; - expect(resultProgram).toEqual(mockProgram); - - // Call parseAndGenerateServices() again to ensure caching of Programs is working correctly... - parseAndGenerateServices(code, options); - // ...by asserting this was only called only once - expect(createProgramFromConfigFile).toHaveBeenCalledTimes(1); - - expect(createProgramFromConfigFile).toHaveBeenNthCalledWith( - 1, - resolvedProject(tsconfigs[1]), - ); - - // Restore process data - process.env.TSESTREE_SINGLE_RUN = originalTSESTreeSingleRun; - }); + if (process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER !== 'true') { + it('should lazily create the required program out of the provided "parserOptions.project" one time when TSESTREE_SINGLE_RUN=true', () => { + /** + * Single run because of explicit environment variable TSESTREE_SINGLE_RUN + */ + const originalTSESTreeSingleRun = process.env.TSESTREE_SINGLE_RUN; + process.env.TSESTREE_SINGLE_RUN = 'true'; + + const resultProgram = parseAndGenerateServices(code, options).services + .program; + expect(resultProgram).toEqual(mockProgram); + + // Call parseAndGenerateServices() again to ensure caching of Programs is working correctly... + parseAndGenerateServices(code, options); + // ...by asserting this was only called once per project + expect(createProgramFromConfigFile).toHaveBeenCalledTimes( + tsconfigs.length, + ); + + expect(createProgramFromConfigFile).toHaveBeenNthCalledWith( + 1, + resolvedProject(tsconfigs[0]), + ); + expect(createProgramFromConfigFile).toHaveBeenNthCalledWith( + 2, + resolvedProject(tsconfigs[1]), + ); + + // Restore process data + process.env.TSESTREE_SINGLE_RUN = originalTSESTreeSingleRun; + }); + + it('should lazily create the required program out of the provided "parserOptions.project" one time when singleRun is inferred from CI=true', () => { + /** + * Single run because of CI=true (we need to make sure we respect the original value + * so that we won't interfere with our own usage of the variable) + */ + const originalEnvCI = process.env.CI; + process.env.CI = 'true'; + + const resultProgram = parseAndGenerateServices(code, options).services + .program; + expect(resultProgram).toEqual(mockProgram); + + // Call parseAndGenerateServices() again to ensure caching of Programs is working correctly... + parseAndGenerateServices(code, options); + // ...by asserting this was only called once per project + expect(createProgramFromConfigFile).toHaveBeenCalledTimes( + tsconfigs.length, + ); + + expect(createProgramFromConfigFile).toHaveBeenNthCalledWith( + 1, + resolvedProject(tsconfigs[0]), + ); + expect(createProgramFromConfigFile).toHaveBeenNthCalledWith( + 2, + resolvedProject(tsconfigs[1]), + ); + + // Restore process data + process.env.CI = originalEnvCI; + }); + + it('should lazily create the required program out of the provided "parserOptions.project" one time when singleRun is inferred from process.argv', () => { + /** + * Single run because of process.argv + */ + const originalProcessArgv = process.argv; + process.argv = ['', path.normalize('node_modules/.bin/eslint'), '']; + + const resultProgram = parseAndGenerateServices(code, options).services + .program; + expect(resultProgram).toEqual(mockProgram); + + // Call parseAndGenerateServices() again to ensure caching of Programs is working correctly... + parseAndGenerateServices(code, options); + // ...by asserting this was only called once per project + expect(createProgramFromConfigFile).toHaveBeenCalledTimes( + tsconfigs.length, + ); + + expect(createProgramFromConfigFile).toHaveBeenNthCalledWith( + 1, + resolvedProject(tsconfigs[0]), + ); + expect(createProgramFromConfigFile).toHaveBeenNthCalledWith( + 2, + resolvedProject(tsconfigs[1]), + ); + + // Restore process data + process.argv = originalProcessArgv; + }); + + it('should stop iterating through and lazily creating programs for the given "parserOptions.project" once a matching one has been found', () => { + /** + * Single run because of explicit environment variable TSESTREE_SINGLE_RUN + */ + const originalTSESTreeSingleRun = process.env.TSESTREE_SINGLE_RUN; + process.env.TSESTREE_SINGLE_RUN = 'true'; + + const optionsWithReversedTsconfigs = { + ...options, + // Now the matching tsconfig comes first + project: [...options.project].reverse(), + }; + + const resultProgram = parseAndGenerateServices( + code, + optionsWithReversedTsconfigs, + ).services.program; + expect(resultProgram).toEqual(mockProgram); + + // Call parseAndGenerateServices() again to ensure caching of Programs is working correctly... + parseAndGenerateServices(code, options); + // ...by asserting this was only called only once + expect(createProgramFromConfigFile).toHaveBeenCalledTimes(1); + + expect(createProgramFromConfigFile).toHaveBeenNthCalledWith( + 1, + resolvedProject(tsconfigs[1]), + ); + + // Restore process data + process.env.TSESTREE_SINGLE_RUN = originalTSESTreeSingleRun; + }); + } }); diff --git a/packages/typescript-estree/tests/lib/semanticInfo.test.ts b/packages/typescript-estree/tests/lib/semanticInfo.test.ts index 3ebb735eb7f..83569479085 100644 --- a/packages/typescript-estree/tests/lib/semanticInfo.test.ts +++ b/packages/typescript-estree/tests/lib/semanticInfo.test.ts @@ -3,7 +3,7 @@ import glob = require('glob'); import * as path from 'path'; import * as ts from 'typescript'; -import { clearWatchCaches } from '../../src/create-program/getWatchProgramsForProjects'; +import { clearCaches } from '../../src'; import { createProgramFromConfigFile as createProgram } from '../../src/create-program/useProvidedPrograms'; import type { ParseAndGenerateServicesResult } from '../../src/parser'; import { parseAndGenerateServices } from '../../src/parser'; @@ -37,7 +37,7 @@ function createOptions(fileName: string): TSESTreeOptions & { cwd?: string } { } // ensure tsconfig-parser watch caches are clean for each test -beforeEach(() => clearWatchCaches()); +beforeEach(() => clearCaches()); describe('semanticInfo', () => { // test all AST snapshots @@ -246,55 +246,61 @@ describe('semanticInfo', () => { expect(parseResult.services.program).toBeDefined(); }); - it(`non-existent file should throw error when project provided`, () => { - expect(() => - parseCodeAndGenerateServices( - `function M() { return Base }`, - createOptions(''), - ), - ).toThrow( - /ESLint was configured to run on `\/estree\.ts` using/, - ); - }); - - it('non-existent project file', () => { - const fileName = path.resolve(FIXTURES_DIR, 'isolated-file.src.ts'); - const badConfig = createOptions(fileName); - badConfig.project = './tsconfigs.json'; - expect(() => - parseCodeAndGenerateServices( - fs.readFileSync(fileName, 'utf8'), - badConfig, - ), - ).toThrow(/Cannot read file .+tsconfigs\.json'/); - }); + if (process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER !== 'true') { + it(`non-existent file should throw error when project provided`, () => { + expect(() => + parseCodeAndGenerateServices( + `function M() { return Base }`, + createOptions(''), + ), + ).toThrow( + /ESLint was configured to run on `\/estree\.ts` using/, + ); + }); + } + + if (process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER !== 'true') { + it('non-existent project file', () => { + const fileName = path.resolve(FIXTURES_DIR, 'isolated-file.src.ts'); + const badConfig = createOptions(fileName); + badConfig.project = './tsconfigs.json'; + expect(() => + parseCodeAndGenerateServices( + fs.readFileSync(fileName, 'utf8'), + badConfig, + ), + ).toThrow(/Cannot read file .+tsconfigs\.json'/); + }); - it('fail to read project file', () => { - const fileName = path.resolve(FIXTURES_DIR, 'isolated-file.src.ts'); - const badConfig = createOptions(fileName); - badConfig.project = '.'; - expect(() => - parseCodeAndGenerateServices( - fs.readFileSync(fileName, 'utf8'), - badConfig, - ), - ).toThrow( - // case insensitive because unix based systems are case insensitive - /Cannot read file .+semanticInfo'./i, - ); - }); + it('fail to read project file', () => { + const fileName = path.resolve(FIXTURES_DIR, 'isolated-file.src.ts'); + const badConfig = createOptions(fileName); + badConfig.project = '.'; + expect(() => + parseCodeAndGenerateServices( + fs.readFileSync(fileName, 'utf8'), + badConfig, + ), + ).toThrow( + // case insensitive because unix based systems are case insensitive + /Cannot read file .+semanticInfo'/i, + ); + }); - it('malformed project file', () => { - const fileName = path.resolve(FIXTURES_DIR, 'isolated-file.src.ts'); - const badConfig = createOptions(fileName); - badConfig.project = './badTSConfig/tsconfig.json'; - expect(() => - parseCodeAndGenerateServices( - fs.readFileSync(fileName, 'utf8'), - badConfig, - ), - ).toThrowErrorMatchingSnapshot(); - }); + it('malformed project file', () => { + const fileName = path.resolve(FIXTURES_DIR, 'isolated-file.src.ts'); + const badConfig = createOptions(fileName); + badConfig.project = './badTSConfig/tsconfig.json'; + expect(() => + parseCodeAndGenerateServices( + fs.readFileSync(fileName, 'utf8'), + badConfig, + ), + ).toThrowErrorMatchingInlineSnapshot( + `"Compiler option 'compileOnSave' requires a value of type boolean."`, + ); + }); + } it('default program produced with option', () => { const parseResult = parseCodeAndGenerateServices('var foo = 5;', { @@ -314,20 +320,22 @@ describe('semanticInfo', () => { ); }); - it(`first matching provided program instance is returned in result`, () => { - const filename = testFiles[0]; - const program1 = createProgram(path.join(FIXTURES_DIR, 'tsconfig.json')); - const program2 = createProgram(path.join(FIXTURES_DIR, 'tsconfig.json')); - const code = fs.readFileSync(path.join(FIXTURES_DIR, filename), 'utf8'); - const options = createOptions(filename); - const optionsProjectString = { - ...options, - programs: [program1, program2], - project: './tsconfig.json', - }; - const parseResult = parseAndGenerateServices(code, optionsProjectString); - expect(parseResult.services.program).toBe(program1); - }); + if (process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER !== 'true') { + it(`first matching provided program instance is returned in result`, () => { + const filename = testFiles[0]; + const program1 = createProgram(path.join(FIXTURES_DIR, 'tsconfig.json')); + const program2 = createProgram(path.join(FIXTURES_DIR, 'tsconfig.json')); + const code = fs.readFileSync(path.join(FIXTURES_DIR, filename), 'utf8'); + const options = createOptions(filename); + const optionsProjectString = { + ...options, + programs: [program1, program2], + project: './tsconfig.json', + }; + const parseResult = parseAndGenerateServices(code, optionsProjectString); + expect(parseResult.services.program).toBe(program1); + }); + } it('file not in provided program instance(s)', () => { const filename = 'non-existent-file.ts'; @@ -369,6 +377,10 @@ describe('semanticInfo', () => { function testIsolatedFile( parseResult: ParseAndGenerateServicesResult, ): void { + if (process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER === 'true') { + return; + } + // get type checker expectToHaveParserServices(parseResult.services); const checker = parseResult.services.program.getTypeChecker(); diff --git a/packages/utils/src/ts-eslint/Rule.ts b/packages/utils/src/ts-eslint/Rule.ts index 4ff0546bb65..f15e92bdb38 100644 --- a/packages/utils/src/ts-eslint/Rule.ts +++ b/packages/utils/src/ts-eslint/Rule.ts @@ -223,7 +223,7 @@ interface RuleContext< * It is a path to a directory that should be considered as the current working directory. * @since 6.6.0 */ - getCwd?(): string; + getCwd(): string; /** * Returns the filename associated with the source. diff --git a/packages/website/src/components/linter/config.ts b/packages/website/src/components/linter/config.ts index f728dc1a635..fc6389b4e1b 100644 --- a/packages/website/src/components/linter/config.ts +++ b/packages/website/src/components/linter/config.ts @@ -13,6 +13,7 @@ export const defaultParseSettings: ParseSettings = { DEPRECATED__createDefaultProgram: false, errorOnTypeScriptSyntacticAndSemanticIssues: false, errorOnUnknownASTType: false, + EXPERIMENTAL_projectService: undefined, EXPERIMENTAL_useSourceOfProjectReferenceRedirect: false, extraFileExtensions: [], filePath: '',