diff --git a/docs/packages/TypeScript_ESTree.mdx b/docs/packages/TypeScript_ESTree.mdx index 610f641d384d..27122374bd18 100644 --- a/docs/packages/TypeScript_ESTree.mdx +++ b/docs/packages/TypeScript_ESTree.mdx @@ -167,7 +167,7 @@ interface ParseAndGenerateServicesOptions extends ParseOptions { * * @see https://github.com/typescript-eslint/typescript-eslint/issues/6575 */ - EXPERIMENTAL_useProjectService?: boolean; + EXPERIMENTAL_useProjectService?: boolean | ProjectServiceOptions; /** * ***EXPERIMENTAL FLAG*** - Use this at your own risk. @@ -270,6 +270,16 @@ interface ParseAndGenerateServicesOptions extends ParseOptions { }; } +/** + * Granular options to configure the project service. + */ +interface ProjectServiceOptions { + /** + * Globs of files to allow running with the default inferred project settings. + */ + allowDefaultProjectForFiles?: string[]; +} + interface ParserServices { program: ts.Program; esTreeNodeToTSNodeMap: WeakMap; diff --git a/packages/eslint-plugin-tslint/tests/index.spec.ts b/packages/eslint-plugin-tslint/tests/index.spec.ts index 59f84be30770..bbe479c832b8 100644 --- a/packages/eslint-plugin-tslint/tests/index.spec.ts +++ b/packages/eslint-plugin-tslint/tests/index.spec.ts @@ -166,71 +166,73 @@ ruleTester.run('tslint/config', rule, { ], }); -describe('tslint/error', () => { - function testOutput(code: string, config: ClassicConfig.Config): void { - const linter = new TSESLint.Linter(); - linter.defineRule('tslint/config', rule); - linter.defineParser('@typescript-eslint/parser', parser); - - expect(() => linter.verify(code, config)).toThrow( - 'You have used a rule which requires parserServices to be generated. You must therefore provide a value for the "parserOptions.project" property for @typescript-eslint/parser.', - ); - } - - it('should error on missing project', () => { - testOutput('foo;', { - rules: { - 'tslint/config': [2, tslintRulesConfig], - }, - parser: '@typescript-eslint/parser', +if (process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER !== 'true') { + describe('tslint/error', () => { + function testOutput(code: string, config: ClassicConfig.Config): void { + const linter = new TSESLint.Linter(); + linter.defineRule('tslint/config', rule); + linter.defineParser('@typescript-eslint/parser', parser); + + expect(() => linter.verify(code, config)).toThrow( + 'You have used a rule which requires parserServices to be generated. You must therefore provide a value for the "parserOptions.project" property for @typescript-eslint/parser.', + ); + } + + it('should error on missing project', () => { + testOutput('foo;', { + rules: { + 'tslint/config': [2, tslintRulesConfig], + }, + parser: '@typescript-eslint/parser', + }); }); - }); - it('should error on default parser', () => { - testOutput('foo;', { - parserOptions: { - project: TEST_PROJECT_PATH, - }, - rules: { - 'tslint/config': [2, tslintRulesConfig], - }, + it('should error on default parser', () => { + testOutput('foo;', { + parserOptions: { + project: TEST_PROJECT_PATH, + }, + rules: { + 'tslint/config': [2, tslintRulesConfig], + }, + }); }); - }); - it('should not crash if there are no tslint rules specified', () => { - const linter = new TSESLint.Linter(); - jest.spyOn(console, 'warn').mockImplementation(); - linter.defineRule('tslint/config', rule); - linter.defineParser('@typescript-eslint/parser', parser); - - const filePath = path.resolve( - __dirname, - 'fixtures', - 'test-project', - 'extra.ts', - ); - - expect(() => - linter.verify( - 'foo;', - { - parserOptions: { - project: TEST_PROJECT_PATH, - }, - rules: { - 'tslint/config': [2, {}], + it('should not crash if there are no tslint rules specified', () => { + const linter = new TSESLint.Linter(); + jest.spyOn(console, 'warn').mockImplementation(); + linter.defineRule('tslint/config', rule); + linter.defineParser('@typescript-eslint/parser', parser); + + const filePath = path.resolve( + __dirname, + 'fixtures', + 'test-project', + 'extra.ts', + ); + + expect(() => + linter.verify( + 'foo;', + { + parserOptions: { + project: TEST_PROJECT_PATH, + }, + rules: { + 'tslint/config': [2, {}], + }, + parser: '@typescript-eslint/parser', }, - parser: '@typescript-eslint/parser', - }, - filePath, - ), - ).not.toThrow(); - - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining( - `Tried to lint ${filePath} but found no valid, enabled rules for this file type and file path in the resolved configuration.`, - ), - ); - jest.resetAllMocks(); + filePath, + ), + ).not.toThrow(); + + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining( + `Tried to lint ${filePath} but found no valid, enabled rules for this file type and file path in the resolved configuration.`, + ), + ); + jest.resetAllMocks(); + }); }); -}); +} diff --git a/packages/typescript-estree/package.json b/packages/typescript-estree/package.json index 52d2964594f0..a8c63c4c4ee3 100644 --- a/packages/typescript-estree/package.json +++ b/packages/typescript-estree/package.json @@ -57,6 +57,7 @@ "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", + "minimatch": "9.0.3", "semver": "^7.5.4", "ts-api-utils": "^1.0.1" }, diff --git a/packages/typescript-estree/src/create-program/createProjectService.ts b/packages/typescript-estree/src/create-program/createProjectService.ts index 380dec18f4cc..1cf7f9d82992 100644 --- a/packages/typescript-estree/src/create-program/createProjectService.ts +++ b/packages/typescript-estree/src/create-program/createProjectService.ts @@ -1,6 +1,8 @@ +/* eslint-disable @typescript-eslint/no-empty-function -- for TypeScript APIs*/ import type * as ts from 'typescript/lib/tsserverlibrary'; -// eslint-disable-next-line @typescript-eslint/no-empty-function +import type { ProjectServiceOptions } from '../parser-options'; + const doNothing = (): void => {}; const createStubFileWatcher = (): ts.FileWatcher => ({ @@ -9,9 +11,15 @@ const createStubFileWatcher = (): ts.FileWatcher => ({ export type TypeScriptProjectService = ts.server.ProjectService; +export interface ProjectServiceSettings { + allowDefaultProjectForFiles: string[] | undefined; + service: TypeScriptProjectService; +} + export function createProjectService( - jsDocParsingMode?: ts.JSDocParsingMode, -): TypeScriptProjectService { + options: boolean | ProjectServiceOptions | undefined, + jsDocParsingMode: ts.JSDocParsingMode | undefined, +): ProjectServiceSettings { // We import this lazily to avoid its cost for users who don't use the service // TODO: Once we drop support for TS<5.3 we can import from "typescript" directly const tsserver = require('typescript/lib/tsserverlibrary') as typeof ts; @@ -30,7 +38,7 @@ export function createProjectService( watchFile: createStubFileWatcher, }; - return new tsserver.server.ProjectService({ + const service = new tsserver.server.ProjectService({ host: system, cancellationToken: { isCancellationRequested: (): boolean => false }, useSingleInferredProject: false, @@ -49,4 +57,12 @@ export function createProjectService( session: undefined, jsDocParsingMode, }); + + return { + allowDefaultProjectForFiles: + typeof options === 'object' + ? options.allowDefaultProjectForFiles + : undefined, + service, + }; } diff --git a/packages/typescript-estree/src/parseSettings/createParseSettings.ts b/packages/typescript-estree/src/parseSettings/createParseSettings.ts index 6b3429571014..a30942aa0d5b 100644 --- a/packages/typescript-estree/src/parseSettings/createParseSettings.ts +++ b/packages/typescript-estree/src/parseSettings/createParseSettings.ts @@ -1,7 +1,7 @@ import debug from 'debug'; import * as ts from 'typescript'; -import type { TypeScriptProjectService } from '../create-program/createProjectService'; +import type { ProjectServiceSettings } from '../create-program/createProjectService'; import { createProjectService } from '../create-program/createProjectService'; import { ensureAbsolutePath } from '../create-program/shared'; import type { TSESTreeOptions } from '../parser-options'; @@ -21,7 +21,7 @@ const log = debug( ); let TSCONFIG_MATCH_CACHE: ExpiringCache | null; -let TSSERVER_PROJECT_SERVICE: TypeScriptProjectService | null = null; +let TSSERVER_PROJECT_SERVICE: ProjectServiceSettings | null = null; // NOTE - we intentionally use "unnecessary" `?.` here because in TS<5.3 this enum doesn't exist // This object exists so we can centralize these for tracking and so we don't proliferate these across the file @@ -80,11 +80,14 @@ export function createParseSettings( errorOnTypeScriptSyntacticAndSemanticIssues: false, errorOnUnknownASTType: options.errorOnUnknownASTType === true, EXPERIMENTAL_projectService: - (options.EXPERIMENTAL_useProjectService === true && + (options.EXPERIMENTAL_useProjectService && process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER !== 'false') || (process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER === 'true' && options.EXPERIMENTAL_useProjectService !== false) - ? (TSSERVER_PROJECT_SERVICE ??= createProjectService(jsDocParsingMode)) + ? (TSSERVER_PROJECT_SERVICE ??= createProjectService( + options.EXPERIMENTAL_useProjectService, + jsDocParsingMode, + )) : undefined, EXPERIMENTAL_useSourceOfProjectReferenceRedirect: options.EXPERIMENTAL_useSourceOfProjectReferenceRedirect === true, diff --git a/packages/typescript-estree/src/parseSettings/index.ts b/packages/typescript-estree/src/parseSettings/index.ts index 352798be158f..6aca7a863aae 100644 --- a/packages/typescript-estree/src/parseSettings/index.ts +++ b/packages/typescript-estree/src/parseSettings/index.ts @@ -1,6 +1,6 @@ import type * as ts from 'typescript'; -import type * as tsserverlibrary from 'typescript/lib/tsserverlibrary'; +import type { ProjectServiceSettings } from '../create-program/createProjectService'; import type { CanonicalPath } from '../create-program/shared'; import type { TSESTree } from '../ts-estree'; import type { CacheLike } from './ExpiringCache'; @@ -67,9 +67,7 @@ export interface MutableParseSettings { /** * Experimental: TypeScript server to power program creation. */ - EXPERIMENTAL_projectService: - | tsserverlibrary.server.ProjectService - | undefined; + EXPERIMENTAL_projectService: ProjectServiceSettings | 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 badd40ae8cfa..e86be6d50996 100644 --- a/packages/typescript-estree/src/parser-options.ts +++ b/packages/typescript-estree/src/parser-options.ts @@ -101,6 +101,16 @@ interface ParseOptions { suppressDeprecatedPropertyWarnings?: boolean; } +/** + * Granular options to configure the project service. + */ +export interface ProjectServiceOptions { + /** + * Globs of files to allow running with the default inferred project settings. + */ + allowDefaultProjectForFiles?: string[]; +} + interface ParseAndGenerateServicesOptions extends ParseOptions { /** * Causes the parser to error if the TypeScript compiler returns any unexpected syntax/semantic errors. @@ -114,7 +124,7 @@ interface ParseAndGenerateServicesOptions extends ParseOptions { * * @see https://github.com/typescript-eslint/typescript-eslint/issues/6575 */ - EXPERIMENTAL_useProjectService?: boolean; + EXPERIMENTAL_useProjectService?: boolean | ProjectServiceOptions; /** * ***EXPERIMENTAL FLAG*** - Use this at your own risk. diff --git a/packages/typescript-estree/src/parser.ts b/packages/typescript-estree/src/parser.ts index aeef8d1e45ff..08c29892e220 100644 --- a/packages/typescript-estree/src/parser.ts +++ b/packages/typescript-estree/src/parser.ts @@ -53,6 +53,7 @@ function getProgramAndAST( const fromProjectService = useProgramFromProjectService( parseSettings.EXPERIMENTAL_projectService, parseSettings, + hasFullTypeInformation, ); if (fromProjectService) { return fromProjectService; diff --git a/packages/typescript-estree/src/useProgramFromProjectService.ts b/packages/typescript-estree/src/useProgramFromProjectService.ts index 59f6b0fe50ab..d49acd55e095 100644 --- a/packages/typescript-estree/src/useProgramFromProjectService.ts +++ b/packages/typescript-estree/src/useProgramFromProjectService.ts @@ -1,26 +1,44 @@ -import path from 'path'; -import type { server } from 'typescript/lib/tsserverlibrary'; +import { minimatch } from 'minimatch'; import { createProjectProgram } from './create-program/createProjectProgram'; -import { type ASTAndDefiniteProgram } from './create-program/shared'; +import type { ProjectServiceSettings } from './create-program/createProjectService'; +import { + type ASTAndDefiniteProgram, + ensureAbsolutePath, + getCanonicalFileName, +} from './create-program/shared'; import type { MutableParseSettings } from './parseSettings'; export function useProgramFromProjectService( - projectService: server.ProjectService, + { allowDefaultProjectForFiles, service }: ProjectServiceSettings, parseSettings: Readonly, + hasFullTypeInformation: boolean, ): ASTAndDefiniteProgram | undefined { - const opened = projectService.openClientFile( - absolutify(parseSettings.filePath), + const filePath = getCanonicalFileName(parseSettings.filePath); + + const opened = service.openClientFile( + ensureAbsolutePath(filePath, service.host.getCurrentDirectory()), parseSettings.codeFullText, /* scriptKind */ undefined, parseSettings.tsconfigRootDir, ); - if (!opened.configFileName) { - return undefined; + + if (hasFullTypeInformation) { + if (opened.configFileName) { + if (filePathMatchedBy(filePath, allowDefaultProjectForFiles)) { + throw new Error( + `${filePath} was included by allowDefaultProjectForFiles but also was found in the project service. Consider removing it from allowDefaultProjectForFiles.`, + ); + } + } else if (!filePathMatchedBy(filePath, allowDefaultProjectForFiles)) { + throw new Error( + `${filePath} was not found by the project service. Consider either including it in the tsconfig.json or including it in allowDefaultProjectForFiles.`, + ); + } } - const scriptInfo = projectService.getScriptInfo(parseSettings.filePath); - const program = projectService + const scriptInfo = service.getScriptInfo(filePath); + const program = service .getDefaultProjectForFile(scriptInfo!.fileName, true)! .getLanguageService(/*ensureSynchronized*/ true) .getProgram(); @@ -30,10 +48,13 @@ export function useProgramFromProjectService( } return createProjectProgram(parseSettings, [program]); +} - function absolutify(filePath: string): string { - return path.isAbsolute(filePath) - ? filePath - : path.join(projectService.host.getCurrentDirectory(), filePath); - } +function filePathMatchedBy( + filePath: string, + allowDefaultProjectForFiles: string[] | undefined, +): boolean { + return !!allowDefaultProjectForFiles?.some(pattern => + minimatch(filePath, pattern), + ); } diff --git a/packages/typescript-estree/tests/lib/createProjectService.test.ts b/packages/typescript-estree/tests/lib/createProjectService.test.ts index f6f6d117e3bc..9541dcd43942 100644 --- a/packages/typescript-estree/tests/lib/createProjectService.test.ts +++ b/packages/typescript-estree/tests/lib/createProjectService.test.ts @@ -1,7 +1,21 @@ import { createProjectService } from '../../src/create-program/createProjectService'; describe('createProjectService', () => { - it('does not crash', () => { - createProjectService(); + it('sets allowDefaultProjectForFiles when options.allowDefaultProjectForFiles is defined', () => { + const allowDefaultProjectForFiles = ['./*.js']; + const settings = createProjectService( + { allowDefaultProjectForFiles }, + undefined, + ); + + expect(settings.allowDefaultProjectForFiles).toBe( + allowDefaultProjectForFiles, + ); + }); + + it('does not set allowDefaultProjectForFiles when options.allowDefaultProjectForFiles is not defined', () => { + const settings = createProjectService(undefined, undefined); + + expect(settings.allowDefaultProjectForFiles).toBeUndefined(); }); }); diff --git a/packages/typescript-estree/tests/lib/semanticInfo.test.ts b/packages/typescript-estree/tests/lib/semanticInfo.test.ts index 8502e70180c4..d580249079ab 100644 --- a/packages/typescript-estree/tests/lib/semanticInfo.test.ts +++ b/packages/typescript-estree/tests/lib/semanticInfo.test.ts @@ -337,7 +337,24 @@ describe('semanticInfo', () => { }); } - it('file not in provided program instance(s)', () => { + it('file not in single provided program instance should throw', () => { + const filename = 'non-existent-file.ts'; + const program = createProgram(path.join(FIXTURES_DIR, 'tsconfig.json')); + const options = createOptions(filename); + const optionsWithSingleProgram = { + ...options, + programs: [program], + }; + expect(() => + parseAndGenerateServices('const foo = 5;', optionsWithSingleProgram), + ).toThrow( + process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER === 'true' + ? `${filename} was not found by the project service. Consider either including it in the tsconfig.json or including it in allowDefaultProjectForFiles.` + : `The file was not found in any of the provided program instance(s): ${filename}`, + ); + }); + + it('file not in multiple provided program instances should throw a program instance error', () => { const filename = 'non-existent-file.ts'; const program1 = createProgram(path.join(FIXTURES_DIR, 'tsconfig.json')); const options = createOptions(filename); @@ -348,7 +365,9 @@ describe('semanticInfo', () => { expect(() => parseAndGenerateServices('const foo = 5;', optionsWithSingleProgram), ).toThrow( - `The file was not found in any of the provided program instance(s): ${filename}`, + process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER === 'true' + ? `${filename} was not found by the project service. Consider either including it in the tsconfig.json or including it in allowDefaultProjectForFiles.` + : `The file was not found in any of the provided program instance(s): ${filename}`, ); const program2 = createProgram(path.join(FIXTURES_DIR, 'tsconfig.json')); @@ -359,7 +378,9 @@ describe('semanticInfo', () => { expect(() => parseAndGenerateServices('const foo = 5;', optionsWithMultiplePrograms), ).toThrow( - `The file was not found in any of the provided program instance(s): ${filename}`, + process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER === 'true' + ? `${filename} was not found by the project service. Consider either including it in the tsconfig.json or including it in allowDefaultProjectForFiles.` + : `The file was not found in any of the provided program instance(s): ${filename}`, ); }); diff --git a/yarn.lock b/yarn.lock index d6dfd1b88c95..c0c163e1df6b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6130,6 +6130,7 @@ __metadata: jest: 29.7.0 jest-specific-snapshot: ^8.0.0 make-dir: "*" + minimatch: 9.0.3 prettier: ^3.0.3 rimraf: "*" semver: ^7.5.4 @@ -14845,6 +14846,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:9.0.3, minimatch@npm:^9.0.0, minimatch@npm:^9.0.1, minimatch@npm:~9.0.3": + version: 9.0.3 + resolution: "minimatch@npm:9.0.3" + dependencies: + brace-expansion: ^2.0.1 + checksum: 253487976bf485b612f16bf57463520a14f512662e592e95c571afdab1442a6a6864b6c88f248ce6fc4ff0b6de04ac7aa6c8bb51e868e99d1d65eb0658a708b5 + languageName: node + linkType: hard + "minimatch@npm:^3.0.4, minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": version: 3.1.2 resolution: "minimatch@npm:3.1.2" @@ -14881,15 +14891,6 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^9.0.0, minimatch@npm:^9.0.1, minimatch@npm:~9.0.3": - version: 9.0.3 - resolution: "minimatch@npm:9.0.3" - dependencies: - brace-expansion: ^2.0.1 - checksum: 253487976bf485b612f16bf57463520a14f512662e592e95c571afdab1442a6a6864b6c88f248ce6fc4ff0b6de04ac7aa6c8bb51e868e99d1d65eb0658a708b5 - languageName: node - linkType: hard - "minimist-options@npm:4.1.0": version: 4.1.0 resolution: "minimist-options@npm:4.1.0"