Skip to content

fix(typescript-estree): adds support for project services using extended config files #9306

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
69aa3c9
refactor(typescript-estree): refactors default project config and add…
higherorderfunctor Jun 8, 2024
d32c1e5
test(typescript-estree): fix original failing unit tests
higherorderfunctor Jun 8, 2024
148a9d0
chore(typescript-estree): cleans up PR submission
higherorderfunctor Jun 8, 2024
8bc4b40
chore: merge remote-tracking branch 'upstream/v8' into fix/9205-proje…
higherorderfunctor Jun 8, 2024
b6718a1
chore(typescript-estree): fixes test names to use the new function name
higherorderfunctor Jun 8, 2024
bde1a26
test(typescript-estree): adds additional tests for coverage
higherorderfunctor Jun 8, 2024
3315345
test(typescript-estree): fixes test for windows
higherorderfunctor Jun 8, 2024
2859ef9
test(typescript-estree): fixes test name typo
higherorderfunctor Jun 8, 2024
f01fe0e
chore(typescript-estree): minor cleanup to comments and variables
higherorderfunctor Jun 9, 2024
73e2c78
style(typescript-estree): unifies how compiler options are defined in…
higherorderfunctor Jun 9, 2024
4b8c010
chore(typescript-estree): updates create-program/getParsedConfigFile.…
higherorderfunctor Jun 26, 2024
4f0e2de
chore: merge upstream/v8
higherorderfunctor Jul 19, 2024
abd0095
test(typescript-estree): adds initial getParsedConfigFile tests
higherorderfunctor Jul 23, 2024
c563b8d
test(typescript-estree): updates createProjectService tests
higherorderfunctor Jul 24, 2024
4d043be
chore: merge upstream/v8
higherorderfunctor Jul 24, 2024
c7181b9
test(typescript-estree): cleans up PR
higherorderfunctor Jul 24, 2024
6d23cb1
Merge remote-tracking branch 'upstream/v8' into fix/9205-project-serv…
higherorderfunctor Jul 24, 2024
45551dc
test(typescript-estree): fixes snapshot tests
higherorderfunctor Jul 24, 2024
d5f06c5
style(typescript-estree): applies suggestions from code review
higherorderfunctor Jul 24, 2024
1dd15bf
style(typescript-estree): applies manual suggestions from code review
higherorderfunctor Jul 25, 2024
ac8c9bc
fixes(typescript-estree): fixes linting errors
higherorderfunctor Jul 25, 2024
4478aea
Update packages/typescript-estree/src/create-program/createProjectSer…
JoshuaKGoldberg Jul 28, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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,
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 };
Original file line number Diff line number Diff line change
@@ -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');

Expand Down Expand Up @@ -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 };
45 changes: 23 additions & 22 deletions packages/typescript-estree/tests/lib/createProjectService.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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();
Expand All @@ -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(() =>
Expand All @@ -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(() =>
Expand All @@ -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(
{
Expand Down
112 changes: 112 additions & 0 deletions packages/typescript-estree/tests/lib/getParsedConfigFile.test.ts
Original file line number Diff line number Diff line change
@@ -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),
);
});
});
Loading