diff --git a/packages/typescript-estree/src/create-program/createProjectProgram.ts b/packages/typescript-estree/src/create-program/createProjectProgram.ts index af4e6378972e..4a9fc7824115 100644 --- a/packages/typescript-estree/src/create-program/createProjectProgram.ts +++ b/packages/typescript-estree/src/create-program/createProjectProgram.ts @@ -52,10 +52,10 @@ function createProjectProgram( ); const describedPrograms = relativeProjects.length === 1 - ? relativeProjects[0] + ? ` ${relativeProjects[0]}` : `\n${relativeProjects.map(project => `- ${project}`).join('\n')}`; const errorLines = [ - `ESLint was configured to run on \`${describedFilePath}\` using \`parserOptions.project\`: ${describedPrograms}`, + `ESLint was configured to run on \`${describedFilePath}\` using \`parserOptions.project\`:${describedPrograms}`, ]; let hasMatchedAnError = false; diff --git a/packages/typescript-estree/src/parser.ts b/packages/typescript-estree/src/parser.ts index 721242dd39f8..45e5a0a92b87 100644 --- a/packages/typescript-estree/src/parser.ts +++ b/packages/typescript-estree/src/parser.ts @@ -195,11 +195,10 @@ function parseAndGenerateServices( }; } - /** - * Generate a full ts.Program or offer provided instances in order to be able to provide parser services, such as type-checking - */ const hasFullTypeInformation = - parseSettings.programs != null || parseSettings.projects.size > 0; + parseSettings.programs != null || + parseSettings.projects.size > 0 || + !!parseSettings.projectService; if ( typeof tsestreeOptions.errorOnTypeScriptSyntacticAndSemanticIssues === diff --git a/packages/typescript-estree/src/useProgramFromProjectService.ts b/packages/typescript-estree/src/useProgramFromProjectService.ts index 516ad0dbcdb5..fdb438930df1 100644 --- a/packages/typescript-estree/src/useProgramFromProjectService.ts +++ b/packages/typescript-estree/src/useProgramFromProjectService.ts @@ -7,7 +7,12 @@ import * as ts from 'typescript'; import { createProjectProgram } from './create-program/createProjectProgram'; import type { ProjectServiceSettings } from './create-program/createProjectService'; -import type { ASTAndDefiniteProgram } from './create-program/shared'; +import { createNoProgram } from './create-program/createSourceFile'; +import type { + ASTAndDefiniteProgram, + ASTAndNoProgram, + ASTAndProgram, +} from './create-program/shared'; import { DEFAULT_PROJECT_FILES_ERROR_EXPLANATION } from './create-program/validateDefaultProjectForFilesGlob'; import type { MutableParseSettings } from './parseSettings'; @@ -42,70 +47,107 @@ const updateExtraFileExtensions = ( } }; -export function useProgramFromProjectService( - { - allowDefaultProject, - maximumDefaultProjectFileMatchCount, - service, - }: ProjectServiceSettings, - parseSettings: Readonly, - hasFullTypeInformation: boolean, +function openClientFileFromProjectService( defaultProjectMatchedFiles: Set, -): ASTAndDefiniteProgram | undefined { - // NOTE: triggers a full project reload when changes are detected - updateExtraFileExtensions(service, parseSettings.extraFileExtensions); - - // We don't canonicalize the filename because it caused a performance regression. - // See https://github.com/typescript-eslint/typescript-eslint/issues/8519 - const filePathAbsolute = absolutify(parseSettings.filePath); - log( - 'Opening project service file for: %s at absolute path %s', - parseSettings.filePath, - filePathAbsolute, - ); - - const opened = service.openClientFile( + isDefaultProjectAllowed: boolean, + filePathAbsolute: string, + parseSettings: Readonly, + serviceSettings: ProjectServiceSettings, +): ts.server.OpenConfiguredProjectResult { + const opened = serviceSettings.service.openClientFile( filePathAbsolute, parseSettings.codeFullText, /* scriptKind */ undefined, parseSettings.tsconfigRootDir, ); - log('Opened project service file: %o', opened); + log( + 'Project service type information enabled; checking for file path match on: %o', + serviceSettings.allowDefaultProject, + ); - if (hasFullTypeInformation) { - log( - 'Project service type information enabled; checking for file path match on: %o', - allowDefaultProject, - ); - const isDefaultProjectAllowedPath = filePathMatchedBy( - parseSettings.filePath, - allowDefaultProject, - ); + log( + 'Default project allowed path: %s, based on config file: %s', + isDefaultProjectAllowed, + opened.configFileName, + ); - log( - 'Default project allowed path: %s, based on config file: %s', - isDefaultProjectAllowedPath, - opened.configFileName, + if (opened.configFileName) { + if (isDefaultProjectAllowed) { + throw new Error( + `${parseSettings.filePath} was included by allowDefaultProject but also was found in the project service. Consider removing it from allowDefaultProject.`, + ); + } + } else if (!isDefaultProjectAllowed) { + throw new Error( + `${parseSettings.filePath} was not found by the project service. Consider either including it in the tsconfig.json or including it in allowDefaultProject.`, ); + } + + // No a configFileName indicates this file wasn't included in a TSConfig. + // That means it must get its type information from the default project. + if (!opened.configFileName) { + defaultProjectMatchedFiles.add(filePathAbsolute); + if ( + defaultProjectMatchedFiles.size > + serviceSettings.maximumDefaultProjectFileMatchCount + ) { + const filePrintLimit = 20; + const filesToPrint = Array.from(defaultProjectMatchedFiles).slice( + 0, + filePrintLimit, + ); + const truncatedFileCount = + defaultProjectMatchedFiles.size - filesToPrint.length; - if (opened.configFileName) { - if (isDefaultProjectAllowedPath) { - throw new Error( - `${parseSettings.filePath} was included by allowDefaultProject but also was found in the project service. Consider removing it from allowDefaultProject.`, - ); - } - } else if (!isDefaultProjectAllowedPath) { throw new Error( - `${parseSettings.filePath} was not found by the project service. Consider either including it in the tsconfig.json or including it in allowDefaultProject.`, + `Too many files (>${serviceSettings.maximumDefaultProjectFileMatchCount}) have matched the default project.${DEFAULT_PROJECT_FILES_ERROR_EXPLANATION} +Matching files: +${filesToPrint.map(file => `- ${file}`).join('\n')} +${truncatedFileCount ? `...and ${truncatedFileCount} more files\n` : ''} +If you absolutely need more files included, set parserOptions.projectService.maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING to a larger value. +`, ); } } + + return opened; +} + +function createNoProgramWithProjectService( + filePathAbsolute: string, + parseSettings: Readonly, + service: ts.server.ProjectService, +): ASTAndNoProgram { + log('No project service information available. Creating no program.'); + + // If the project service knows about this file, this informs if of changes. + // Doing so ensures that: + // - if the file is not part of a project, we don't waste time creating a program (fast non-type-aware linting) + // - otherwise, we refresh the file in the project service (moderately fast, since the project is already loaded) + if (service.getScriptInfo(filePathAbsolute)) { + log('Script info available. Opening client file in project service.'); + service.openClientFile( + filePathAbsolute, + parseSettings.codeFullText, + /* scriptKind */ undefined, + parseSettings.tsconfigRootDir, + ); + } + + return createNoProgram(parseSettings); +} + +function retrieveASTAndProgramFor( + filePathAbsolute: string, + parseSettings: Readonly, + serviceSettings: ProjectServiceSettings, +): ASTAndDefiniteProgram | undefined { log('Retrieving script info and then program for: %s', filePathAbsolute); - const scriptInfo = service.getScriptInfo(filePathAbsolute); + const scriptInfo = serviceSettings.service.getScriptInfo(filePathAbsolute); /* eslint-disable @typescript-eslint/no-non-null-assertion */ - const program = service + const program = serviceSettings.service .getDefaultProjectForFile(scriptInfo!.fileName, true)! .getLanguageService(/*ensureSynchronized*/ true) .getProgram(); @@ -116,37 +158,96 @@ export function useProgramFromProjectService( return undefined; } - if (!opened.configFileName) { - defaultProjectMatchedFiles.add(filePathAbsolute); - } - if (defaultProjectMatchedFiles.size > maximumDefaultProjectFileMatchCount) { - const filePrintLimit = 20; - const filesToPrint = Array.from(defaultProjectMatchedFiles).slice( - 0, - filePrintLimit, - ); - const truncatedFileCount = - defaultProjectMatchedFiles.size - filesToPrint.length; + log('Found project service program for: %s', filePathAbsolute); - throw new Error( - `Too many files (>${maximumDefaultProjectFileMatchCount}) have matched the default project.${DEFAULT_PROJECT_FILES_ERROR_EXPLANATION} -Matching files: -${filesToPrint.map(file => `- ${file}`).join('\n')} -${truncatedFileCount ? `...and ${truncatedFileCount} more files\n` : ''} -If you absolutely need more files included, set parserOptions.projectService.maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING to a larger value. -`, + return createProjectProgram(parseSettings, [program]); +} + +export function useProgramFromProjectService( + settings: ProjectServiceSettings, + parseSettings: Readonly, + hasFullTypeInformation: boolean, + defaultProjectMatchedFiles: Set, +): ASTAndProgram | undefined; +export function useProgramFromProjectService( + settings: ProjectServiceSettings, + parseSettings: Readonly, + hasFullTypeInformation: true, + defaultProjectMatchedFiles: Set, +): ASTAndDefiniteProgram | undefined; +export function useProgramFromProjectService( + settings: ProjectServiceSettings, + parseSettings: Readonly, + hasFullTypeInformation: false, + defaultProjectMatchedFiles: Set, +): ASTAndNoProgram | undefined; +export function useProgramFromProjectService( + serviceSettings: ProjectServiceSettings, + parseSettings: Readonly, + hasFullTypeInformation: boolean, + defaultProjectMatchedFiles: Set, +): ASTAndProgram | undefined { + // NOTE: triggers a full project reload when changes are detected + updateExtraFileExtensions( + serviceSettings.service, + parseSettings.extraFileExtensions, + ); + + // We don't canonicalize the filename because it caused a performance regression. + // See https://github.com/typescript-eslint/typescript-eslint/issues/8519 + const filePathAbsolute = absolutify(parseSettings.filePath, serviceSettings); + log( + 'Opening project service file for: %s at absolute path %s', + parseSettings.filePath, + filePathAbsolute, + ); + + const isDefaultProjectAllowed = filePathMatchedBy( + parseSettings.filePath, + serviceSettings.allowDefaultProject, + ); + + // Type-aware linting is disabled for this file. + // However, type-aware lint rules might still rely on its contents. + if (!hasFullTypeInformation && !isDefaultProjectAllowed) { + return createNoProgramWithProjectService( + filePathAbsolute, + parseSettings, + serviceSettings.service, ); } - log('Found project service program for: %s', filePathAbsolute); + // If type info was requested, we attempt to open it in the project service. + // By now, the file is known to be one of: + // - in the project service (valid configuration) + // - allowlisted in the default project (valid configuration) + // - neither, which openClientFileFromProjectService will throw an error for + const opened = + hasFullTypeInformation && + openClientFileFromProjectService( + defaultProjectMatchedFiles, + isDefaultProjectAllowed, + filePathAbsolute, + parseSettings, + serviceSettings, + ); - return createProjectProgram(parseSettings, [program]); + log('Opened project service file: %o', opened); - function absolutify(filePath: string): string { - return path.isAbsolute(filePath) - ? filePath - : path.join(service.host.getCurrentDirectory(), filePath); - } + return retrieveASTAndProgramFor( + filePathAbsolute, + parseSettings, + serviceSettings, + ); +} + +function absolutify( + filePath: string, + serviceSettings: ProjectServiceSettings, +): string { + return path.isAbsolute(filePath) + ? filePath + : path.join(serviceSettings.service.host.getCurrentDirectory(), filePath); } function filePathMatchedBy( diff --git a/packages/typescript-estree/tests/lib/parse.test.ts b/packages/typescript-estree/tests/lib/parse.test.ts index 0b976af5b7a8..1be10f4d5eca 100644 --- a/packages/typescript-estree/tests/lib/parse.test.ts +++ b/packages/typescript-estree/tests/lib/parse.test.ts @@ -369,17 +369,12 @@ describe('parseAndGenerateServices', () => { }; const testExtraFileExtensions = (filePath: string, extraFileExtensions: string[]) => (): void => { - const result = parser.parseAndGenerateServices(code, { + parser.parseAndGenerateServices(code, { ...config, extraFileExtensions, filePath: join(PROJECT_DIR, filePath), projectService: true, }); - const compilerOptions = result.services.program?.getCompilerOptions(); - - if (!compilerOptions?.configFilePath) { - throw new Error('No config file found, using inferred project'); - } }; describe('project includes', () => { @@ -505,35 +500,31 @@ describe('parseAndGenerateServices', () => { it("the file isn't included", () => { expect( testExtraFileExtensions('other/notIncluded.vue', ['.vue']), - ).toThrowErrorMatchingInlineSnapshot( - `"No config file found, using inferred project"`, - ); + ).toThrow(/notIncluded\.vue was not found by the project service/); }); it('duplicate extension', () => { expect( testExtraFileExtensions('ts/notIncluded.ts', ['.ts']), - ).toThrowErrorMatchingInlineSnapshot( - `"No config file found, using inferred project"`, - ); + ).toThrow(/notIncluded\.ts was not found by the project service/); }); }); - it('invalid extension', () => { + it('extension matching the file name but not a file on disk', () => { expect( testExtraFileExtensions('other/unknownFileType.unknown', [ 'unknown', ]), - ).toThrowErrorMatchingInlineSnapshot( - `"No config file found, using inferred project"`, + ).toThrow( + /unknownFileType\.unknown was not found by the project service/, ); }); - it('the extension does not match', () => { + it('the extension does not match the file name', () => { expect( testExtraFileExtensions('other/unknownFileType.unknown', ['.vue']), - ).toThrowErrorMatchingInlineSnapshot( - `"No config file found, using inferred project"`, + ).toThrow( + /unknownFileType\.unknown was not found by the project service/, ); }); }); @@ -566,7 +557,7 @@ describe('parseAndGenerateServices', () => { expect(testParse('ts/notIncluded0j1.ts')) .toThrowErrorMatchingInlineSnapshot(` - "ESLint was configured to run on \`/ts/notIncluded0j1.ts\` using \`parserOptions.project\`: + "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: diff --git a/packages/typescript-estree/tests/lib/useProgramFromProjectService.test.ts b/packages/typescript-estree/tests/lib/useProgramFromProjectService.test.ts index 03940dda400e..b3a8e4e4e4e4 100644 --- a/packages/typescript-estree/tests/lib/useProgramFromProjectService.test.ts +++ b/packages/typescript-estree/tests/lib/useProgramFromProjectService.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type -- Fancy mocks */ import path from 'path'; -import { ScriptKind } from 'typescript'; +import * as ts from 'typescript'; import type { ProjectServiceSettings, @@ -9,6 +9,14 @@ import type { import type { ParseSettings } from '../../src/parseSettings'; import { useProgramFromProjectService } from '../../src/useProgramFromProjectService'; +const mockCreateNoProgram = jest.fn(); + +jest.mock('../../src/create-program/createSourceFile', () => ({ + get createNoProgram() { + return mockCreateNoProgram; + }, +})); + const mockCreateProjectProgram = jest.fn(); jest.mock('../../src/create-program/createProjectProgram', () => ({ @@ -44,8 +52,10 @@ function createMockProjectService() { }; } +const mockFileName = 'camelCaseFile.ts'; + const mockParseSettings = { - filePath: 'path/PascalCaseDirectory/camelCaseFile.ts', + filePath: `path/PascalCaseDirectory/${mockFileName}`, extraFileExtensions: [] as readonly string[], } as ParseSettings; @@ -59,6 +69,29 @@ const createProjectServiceSettings = < }); describe('useProgramFromProjectService', () => { + it('creates a standalone AST with no program when hasFullTypeInformation is false and allowDefaultProject is falsy', () => { + const { service } = createMockProjectService(); + + const stubASTAndNoProgram = { + ast: ts.createSourceFile(mockFileName, '', ts.ScriptTarget.Latest), + program: null, + }; + + mockCreateNoProgram.mockReturnValueOnce(stubASTAndNoProgram); + + const actual = useProgramFromProjectService( + createProjectServiceSettings({ + allowDefaultProject: undefined, + service, + }), + mockParseSettings, + false, + new Set(), + ); + + expect(actual).toBe(stubASTAndNoProgram); + }); + it('passes an absolute, case-matching file path to service.openClientFile', () => { const { service } = createMockProjectService(); @@ -123,7 +156,7 @@ describe('useProgramFromProjectService', () => { ); }); - it('throws an error when called more than the maximum allowed file count', () => { + it('throws an error when more than the maximum allowed file count is matched to the default project', () => { const { service } = createMockProjectService(); const program = { getSourceFile: jest.fn() }; @@ -255,7 +288,7 @@ If you absolutely need more files included, set parserOptions.projectService.max expect(actual).toBe(program); }); - it('returns a created program when hasFullTypeInformation is disabled, the file is neither in the project service nor allowDefaultProject, and the service has a matching program', () => { + it('returns undefined when hasFullTypeInformation is disabled, the file is neither in the project service nor allowDefaultProject, and the service has a matching program', () => { const { service } = createMockProjectService(); const program = { getSourceFile: jest.fn() }; @@ -274,10 +307,10 @@ If you absolutely need more files included, set parserOptions.projectService.max new Set(), ); - expect(actual).toBe(program); + expect(actual).toBeUndefined(); }); - it('returns a created program when hasFullTypeInformation is disabled, the file is in the project service, the service has a matching program, and no out-of-project files are allowed', () => { + it('returns undefined when hasFullTypeInformation is disabled, the file is in the project service, the service has a matching program, and no out', () => { const { service } = createMockProjectService(); const program = { getSourceFile: jest.fn() }; @@ -299,7 +332,7 @@ If you absolutely need more files included, set parserOptions.projectService.max new Set(), ); - expect(actual).toBe(program); + expect(actual).toBeUndefined(); }); it('does not call setHostConfiguration on the service with default extensions if extraFileExtensions are not provided', () => { @@ -358,7 +391,7 @@ If you absolutely need more files included, set parserOptions.projectService.max { extension: '.vue', isMixedContent: false, - scriptKind: ScriptKind.Deferred, + scriptKind: ts.ScriptKind.Deferred, }, ], }); @@ -387,7 +420,7 @@ If you absolutely need more files included, set parserOptions.projectService.max { extension: '.vue', isMixedContent: false, - scriptKind: ScriptKind.Deferred, + scriptKind: ts.ScriptKind.Deferred, }, ], }); @@ -427,7 +460,7 @@ If you absolutely need more files included, set parserOptions.projectService.max { extension: '.vue', isMixedContent: false, - scriptKind: ScriptKind.Deferred, + scriptKind: ts.ScriptKind.Deferred, }, ], }); @@ -471,7 +504,7 @@ If you absolutely need more files included, set parserOptions.projectService.max { extension: '.vue', isMixedContent: false, - scriptKind: ScriptKind.Deferred, + scriptKind: ts.ScriptKind.Deferred, }, ], });