Skip to content

feat: speed up non-type-aware linting with project service #8322

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
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
Expand Up @@ -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}`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not strictly needed for this PR, but it was so annoying having Prettier auto-save the snapshot to no longer have a trailing slash...

];
let hasMatchedAnError = false;

Expand Down
7 changes: 3 additions & 4 deletions packages/typescript-estree/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,11 +195,10 @@ function parseAndGenerateServices<T extends TSESTreeOptions = TSESTreeOptions>(
};
}

/**
* 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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yepitschunked following up on parserSettings.typeAware: whether the file is being parsed with type information is still inferable from existing parseSettings properties. So rather than refactor to add a new property, this just corrects the logic for const hasFullTypeInformation.


if (
typeof tsestreeOptions.errorOnTypeScriptSyntacticAndSemanticIssues ===
Expand Down
245 changes: 173 additions & 72 deletions packages/typescript-estree/src/useProgramFromProjectService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -42,70 +47,107 @@ const updateExtraFileExtensions = (
}
};

export function useProgramFromProjectService(
{
allowDefaultProject,
maximumDefaultProjectFileMatchCount,
service,
}: ProjectServiceSettings,
parseSettings: Readonly<MutableParseSettings>,
hasFullTypeInformation: boolean,
function openClientFileFromProjectService(
defaultProjectMatchedFiles: Set<string>,
): 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<MutableParseSettings>,
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.
`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that I've moved this "Too many files ..." notice to inside the if (opened.configFileName) in this PR. That should stop the notice from getting logged on all files!

);
}
}

return opened;
}

function createNoProgramWithProjectService(
filePathAbsolute: string,
parseSettings: Readonly<MutableParseSettings>,
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<MutableParseSettings>,
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();
Expand All @@ -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<MutableParseSettings>,
hasFullTypeInformation: boolean,
defaultProjectMatchedFiles: Set<string>,
): ASTAndProgram | undefined;
export function useProgramFromProjectService(
settings: ProjectServiceSettings,
parseSettings: Readonly<MutableParseSettings>,
hasFullTypeInformation: true,
defaultProjectMatchedFiles: Set<string>,
): ASTAndDefiniteProgram | undefined;
export function useProgramFromProjectService(
settings: ProjectServiceSettings,
parseSettings: Readonly<MutableParseSettings>,
hasFullTypeInformation: false,
defaultProjectMatchedFiles: Set<string>,
): ASTAndNoProgram | undefined;
export function useProgramFromProjectService(
serviceSettings: ProjectServiceSettings,
parseSettings: Readonly<MutableParseSettings>,
hasFullTypeInformation: boolean,
defaultProjectMatchedFiles: Set<string>,
): 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(
Expand Down
29 changes: 10 additions & 19 deletions packages/typescript-estree/tests/lib/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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/,
);
});
});
Expand Down Expand Up @@ -566,7 +557,7 @@ describe('parseAndGenerateServices', () => {

expect(testParse('ts/notIncluded0j1.ts'))
.toThrowErrorMatchingInlineSnapshot(`
"ESLint was configured to run on \`<tsconfigRootDir>/ts/notIncluded0j1.ts\` using \`parserOptions.project\`:
"ESLint was configured to run on \`<tsconfigRootDir>/ts/notIncluded0j1.ts\` using \`parserOptions.project\`:
- <tsconfigRootDir>/tsconfig.json
- <tsconfigRootDir>/tsconfig.extra.json
However, none of those TSConfigs include this file. Either:
Expand Down
Loading
Loading