diff --git a/packages/typescript-estree/src/create-program/createProjectService.ts b/packages/typescript-estree/src/create-program/createProjectService.ts index 4b8c44c0321c..edf5f9f9167f 100644 --- a/packages/typescript-estree/src/create-program/createProjectService.ts +++ b/packages/typescript-estree/src/create-program/createProjectService.ts @@ -1,10 +1,11 @@ /* eslint-disable @typescript-eslint/no-empty-function -- for TypeScript APIs*/ -import os from 'node:os'; +import path from 'node:path'; import debug from 'debug'; import type * as ts from 'typescript/lib/tsserverlibrary'; import type { ProjectServiceOptions } from '../parser-options'; +import { getParsedConfigFile } from './getParsedConfigFile'; import { validateDefaultProjectForFilesGlob } from './validateDefaultProjectForFilesGlob'; const DEFAULT_PROJECT_MATCHED_FILES_THRESHOLD = 8; @@ -123,38 +124,26 @@ export function createProjectService( if (options.defaultProject) { log('Enabling default project: %s', options.defaultProject); - let configRead; + let configFile: ts.ParsedCommandLine; try { - configRead = tsserver.readConfigFile( + configFile = getParsedConfigFile( + tsserver, options.defaultProject, - system.readFile, + path.dirname(options.defaultProject), ); } catch (error) { throw new Error( - `Could not parse default project '${options.defaultProject}': ${(error as Error).message}`, - ); - } - - if (configRead.error) { - throw new Error( - `Could not read default project '${options.defaultProject}': ${tsserver.formatDiagnostic( - configRead.error, - { - getCurrentDirectory: system.getCurrentDirectory, - getCanonicalFileName: fileName => fileName, - getNewLine: () => os.EOL, - }, - )}`, + `Could not read default project '${options.defaultProject}': ${(error as Error).message}`, ); } service.setCompilerOptionsForInferredProjects( - ( - configRead.config as { - compilerOptions: ts.server.protocol.InferredProjectCompilerOptions; - } - ).compilerOptions, + // NOTE: The inferred projects API is not intended for source files when a tsconfig + // exists. There is no API that generates an InferredProjectCompilerOptions suggesting + // it is meant for hard coded options passed in. Hard asserting as a work around. + // See https://github.com/microsoft/TypeScript/blob/27bcd4cb5a98bce46c9cdd749752703ead021a4b/src/server/protocol.ts#L1904 + configFile.options as ts.server.protocol.InferredProjectCompilerOptions, ); } diff --git a/packages/typescript-estree/src/create-program/getParsedConfigFile.ts b/packages/typescript-estree/src/create-program/getParsedConfigFile.ts new file mode 100644 index 000000000000..703b8500f070 --- /dev/null +++ b/packages/typescript-estree/src/create-program/getParsedConfigFile.ts @@ -0,0 +1,60 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type * as ts from 'typescript/lib/tsserverlibrary'; + +import { CORE_COMPILER_OPTIONS } from './shared'; + +/** + * Utility offered by parser to help consumers parse a config file. + * + * @param configFile the path to the tsconfig.json file, relative to `projectDirectory` + * @param projectDirectory the project directory to use as the CWD, defaults to `process.cwd()` + */ +function getParsedConfigFile( + tsserver: typeof ts, + configFile: string, + projectDirectory?: string, +): ts.ParsedCommandLine { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (tsserver.sys === undefined) { + throw new Error( + '`getParsedConfigFile` is only supported in a Node-like environment.', + ); + } + + const parsed = tsserver.getParsedCommandLineOfConfigFile( + configFile, + CORE_COMPILER_OPTIONS, + { + onUnRecoverableConfigFileDiagnostic: diag => { + throw new Error(formatDiagnostics([diag])); // ensures that `parsed` is defined. + }, + fileExists: fs.existsSync, + getCurrentDirectory, + readDirectory: tsserver.sys.readDirectory, + readFile: file => fs.readFileSync(file, 'utf-8'), + useCaseSensitiveFileNames: tsserver.sys.useCaseSensitiveFileNames, + }, + ); + + if (parsed?.errors.length) { + throw new Error(formatDiagnostics(parsed.errors)); + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return parsed!; + + function getCurrentDirectory(): string { + return projectDirectory ? path.resolve(projectDirectory) : process.cwd(); + } + + function formatDiagnostics(diagnostics: ts.Diagnostic[]): string | undefined { + return tsserver.formatDiagnostics(diagnostics, { + getCanonicalFileName: f => f, + getCurrentDirectory, + getNewLine: () => '\n', + }); + } +} + +export { getParsedConfigFile }; diff --git a/packages/typescript-estree/src/create-program/useProvidedPrograms.ts b/packages/typescript-estree/src/create-program/useProvidedPrograms.ts index 753dbd8a2b97..e4e03eb2d0d4 100644 --- a/packages/typescript-estree/src/create-program/useProvidedPrograms.ts +++ b/packages/typescript-estree/src/create-program/useProvidedPrograms.ts @@ -1,11 +1,11 @@ import debug from 'debug'; -import * as fs from 'fs'; import * as path from 'path'; import * as ts from 'typescript'; import type { ParseSettings } from '../parseSettings'; +import { getParsedConfigFile } from './getParsedConfigFile'; import type { ASTAndDefiniteProgram } from './shared'; -import { CORE_COMPILER_OPTIONS, getAstFromProgram } from './shared'; +import { getAstFromProgram } from './shared'; const log = debug('typescript-eslint:typescript-estree:useProvidedProgram'); @@ -60,44 +60,9 @@ function createProgramFromConfigFile( configFile: string, projectDirectory?: string, ): ts.Program { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (ts.sys === undefined) { - throw new Error( - '`createProgramFromConfigFile` is only supported in a Node-like environment.', - ); - } - - const parsed = ts.getParsedCommandLineOfConfigFile( - configFile, - CORE_COMPILER_OPTIONS, - { - onUnRecoverableConfigFileDiagnostic: diag => { - throw new Error(formatDiagnostics([diag])); // ensures that `parsed` is defined. - }, - fileExists: fs.existsSync, - getCurrentDirectory: () => - (projectDirectory && path.resolve(projectDirectory)) || process.cwd(), - readDirectory: ts.sys.readDirectory, - readFile: file => fs.readFileSync(file, 'utf-8'), - useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, - }, - ); - // parsed is not undefined, since we throw on failure. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const result = parsed!; - if (result.errors.length) { - throw new Error(formatDiagnostics(result.errors)); - } - const host = ts.createCompilerHost(result.options, true); - return ts.createProgram(result.fileNames, result.options, host); -} - -function formatDiagnostics(diagnostics: ts.Diagnostic[]): string | undefined { - return ts.formatDiagnostics(diagnostics, { - getCanonicalFileName: f => f, - getCurrentDirectory: process.cwd, - getNewLine: () => '\n', - }); + const parsed = getParsedConfigFile(ts, configFile, projectDirectory); + const host = ts.createCompilerHost(parsed.options, true); + return ts.createProgram(parsed.fileNames, parsed.options, host); } export { useProvidedPrograms, createProgramFromConfigFile }; diff --git a/packages/typescript-estree/tests/lib/createProjectService.test.ts b/packages/typescript-estree/tests/lib/createProjectService.test.ts index 5f34dfeb802e..cbf3d72dab9c 100644 --- a/packages/typescript-estree/tests/lib/createProjectService.test.ts +++ b/packages/typescript-estree/tests/lib/createProjectService.test.ts @@ -1,15 +1,16 @@ import debug from 'debug'; import * as ts from 'typescript'; -import { createProjectService } from '../../src/create-program/createProjectService'; - -const mockReadConfigFile = jest.fn(); +const mockGetParsedConfigFile = jest.fn(); const mockSetCompilerOptionsForInferredProjects = jest.fn(); const mockSetHostConfiguration = jest.fn(); +jest.mock('../../src/create-program/getParsedConfigFile', () => ({ + getParsedConfigFile: mockGetParsedConfigFile, +})); + jest.mock('typescript/lib/tsserverlibrary', () => ({ ...jest.requireActual('typescript/lib/tsserverlibrary'), - readConfigFile: mockReadConfigFile, server: { ...jest.requireActual('typescript/lib/tsserverlibrary').server, ProjectService: class { @@ -33,6 +34,11 @@ jest.mock('typescript/lib/tsserverlibrary', () => ({ }, })); +const { + createProjectService, + // eslint-disable-next-line @typescript-eslint/no-require-imports +} = require('../../src/create-program/createProjectService'); + describe('createProjectService', () => { afterEach(() => { jest.resetAllMocks(); @@ -51,18 +57,9 @@ describe('createProjectService', () => { expect(settings.allowDefaultProject).toBeUndefined(); }); - it('throws an error when options.defaultProject is set and readConfigFile returns an error', () => { - mockReadConfigFile.mockReturnValue({ - error: { - category: ts.DiagnosticCategory.Error, - code: 1234, - file: ts.createSourceFile('./tsconfig.json', '', { - languageVersion: ts.ScriptTarget.Latest, - }), - start: 0, - length: 0, - messageText: 'Oh no!', - } satisfies ts.Diagnostic, + it('throws an error when options.defaultProject is set and getParsedConfigFile throws a diagnostic error', () => { + mockGetParsedConfigFile.mockImplementation(() => { + throw new Error('./tsconfig.json(1,1): error TS1234: Oh no!'); }); expect(() => @@ -78,9 +75,11 @@ describe('createProjectService', () => { ); }); - it('throws an error when options.defaultProject is set and readConfigFile throws an error', () => { - mockReadConfigFile.mockImplementation(() => { - throw new Error('Oh no!'); + it('throws an error when options.defaultProject is set and getParsedConfigFile throws an environment error', () => { + mockGetParsedConfigFile.mockImplementation(() => { + throw new Error( + '`getParsedConfigFile` is only supported in a Node-like environment.', + ); }); expect(() => @@ -91,12 +90,14 @@ describe('createProjectService', () => { }, undefined, ), - ).toThrow("Could not parse default project './tsconfig.json': Oh no!"); + ).toThrow( + "Could not read default project './tsconfig.json': `getParsedConfigFile` is only supported in a Node-like environment.", + ); }); - it('uses the default projects compiler options when options.defaultProject is set and readConfigFile succeeds', () => { + it('uses the default projects compiler options when options.defaultProject is set and getParsedConfigFile succeeds', () => { const compilerOptions = { strict: true }; - mockReadConfigFile.mockReturnValue({ config: { compilerOptions } }); + mockGetParsedConfigFile.mockReturnValue({ options: compilerOptions }); const { service } = createProjectService( { diff --git a/packages/typescript-estree/tests/lib/getParsedConfigFile.test.ts b/packages/typescript-estree/tests/lib/getParsedConfigFile.test.ts new file mode 100644 index 000000000000..a1aa1a2c04e4 --- /dev/null +++ b/packages/typescript-estree/tests/lib/getParsedConfigFile.test.ts @@ -0,0 +1,112 @@ +import path from 'node:path'; + +import * as ts from 'typescript'; + +import { getParsedConfigFile } from '../../src/create-program/getParsedConfigFile'; + +const mockGetParsedCommandLineOfConfigFile = jest.fn(); + +const mockTsserver: typeof ts = { + sys: {} as ts.System, + formatDiagnostics: ts.formatDiagnostics, + getParsedCommandLineOfConfigFile: mockGetParsedCommandLineOfConfigFile, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as any; + +describe('getParsedConfigFile', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('throws an error when tsserver.sys is undefined', () => { + expect(() => + getParsedConfigFile({} as typeof ts, './tsconfig.json'), + ).toThrow( + '`getParsedConfigFile` is only supported in a Node-like environment.', + ); + }); + + it('uses the cwd as the default project directory', () => { + getParsedConfigFile(mockTsserver, './tsconfig.json'); + expect(mockGetParsedCommandLineOfConfigFile).toHaveBeenCalledTimes(1); + const [_configFileName, _optionsToExtend, host] = + mockGetParsedCommandLineOfConfigFile.mock.calls[0]; + expect(host.getCurrentDirectory()).toBe(process.cwd()); + }); + + it('resolves a relative project directory when passed', () => { + getParsedConfigFile( + mockTsserver, + './tsconfig.json', + path.relative('./', path.dirname(__filename)), + ); + expect(mockGetParsedCommandLineOfConfigFile).toHaveBeenCalledTimes(1); + const [_configFileName, _optionsToExtend, host] = + mockGetParsedCommandLineOfConfigFile.mock.calls[0]; + expect(host.getCurrentDirectory()).toBe(path.dirname(__filename)); + }); + + it('resolves an absolute project directory when passed', () => { + getParsedConfigFile(mockTsserver, './tsconfig.json', __dirname); + expect(mockGetParsedCommandLineOfConfigFile).toHaveBeenCalledTimes(1); + const [_configFileName, _optionsToExtend, host] = + mockGetParsedCommandLineOfConfigFile.mock.calls[0]; + expect(host.getCurrentDirectory()).toBe(__dirname); + }); + + it('throws a diagnostic error when getParsedCommandLineOfConfigFile returns an error', () => { + mockGetParsedCommandLineOfConfigFile.mockReturnValue({ + errors: [ + { + category: ts.DiagnosticCategory.Error, + code: 1234, + file: ts.createSourceFile('./tsconfig.json', '', { + languageVersion: ts.ScriptTarget.Latest, + }), + start: 0, + length: 0, + messageText: 'Oh no!', + }, + ] satisfies ts.Diagnostic[], + }); + expect(() => getParsedConfigFile(mockTsserver, './tsconfig.json')).toThrow( + /.+ error TS1234: Oh no!/, + ); + }); + + it('throws a diagnostic error when getParsedCommandLineOfConfigFile throws an error', () => { + mockGetParsedCommandLineOfConfigFile.mockImplementation( + ( + ...[_configFileName, _optionsToExtend, host]: Parameters< + typeof ts.getParsedCommandLineOfConfigFile + > + ) => { + return host.onUnRecoverableConfigFileDiagnostic({ + category: ts.DiagnosticCategory.Error, + code: 1234, + file: ts.createSourceFile('./tsconfig.json', '', { + languageVersion: ts.ScriptTarget.Latest, + }), + start: 0, + length: 0, + messageText: 'Oh no!', + } satisfies ts.Diagnostic); + }, + ); + expect(() => getParsedConfigFile(mockTsserver, './tsconfig.json')).toThrow( + /.+ error TS1234: Oh no!/, + ); + }); + + it('uses compiler options when parsing a config file succeeds', () => { + const parsedConfigFile = { + options: { strict: true }, + raw: { compilerOptions: { strict: true } }, + errors: [], + }; + mockGetParsedCommandLineOfConfigFile.mockReturnValue(parsedConfigFile); + expect(getParsedConfigFile(mockTsserver, 'tsconfig.json')).toEqual( + expect.objectContaining(parsedConfigFile), + ); + }); +});