Skip to content
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

feat(typescript-estree): add opt-in inference for single runs and create programs for projects up front #3512

Merged
merged 8 commits into from Jun 11, 2021
1 change: 1 addition & 0 deletions .eslintrc.js
Expand Up @@ -25,6 +25,7 @@ module.exports = {
'./tests/integration/utils/jsconfig.json',
'./packages/*/tsconfig.json',
],
allowAutomaticSingleRunInference: true,
tsconfigRootDir: __dirname,
warnOnUnsupportedTypeScriptVersion: false,
EXPERIMENTAL_useSourceOfProjectReferenceRedirect: false,
Expand Down
14 changes: 14 additions & 0 deletions packages/typescript-estree/README.md
Expand Up @@ -225,6 +225,20 @@ interface ParseAndGenerateServicesOptions extends ParseOptions {
* it will not error, but will instead parse the file and its dependencies in a new program.
*/
createDefaultProgram?: boolean;

/**
* ESLint (and therefore typescript-eslint) is used in both "single run"/one-time contexts,
* such as an ESLint CLI invocation, and long-running sessions (such as continuous feedback
* on a file in an IDE).
*
* When typescript-eslint handles TypeScript Program management behind the scenes, this distinction
* is important because there is significant overhead to managing the so called Watch Programs
* needed for the long-running use-case.
*
* When allowAutomaticSingleRunInference is enabled, we will use common heuristics to infer
* whether or not ESLint is being used as part of a single run.
*/
allowAutomaticSingleRunInference?: boolean;
}

interface ParserServices {
Expand Down
Expand Up @@ -50,7 +50,7 @@ const parsedFilesSeenHash = new Map<CanonicalPath, string>();
* Clear all of the parser caches.
* This should only be used in testing to ensure the parser is clean between tests.
*/
function clearCaches(): void {
function clearWatchCaches(): void {
knownWatchProgramMap.clear();
fileWatchCallbackTrackingMap.clear();
folderWatchCallbackTrackingMap.clear();
Expand Down Expand Up @@ -530,4 +530,4 @@ function maybeInvalidateProgram(
return null;
}

export { clearCaches, createWatchProgram, getProgramsForProjects };
export { clearWatchCaches, createWatchProgram, getProgramsForProjects };
Expand Up @@ -12,7 +12,7 @@ import {
const log = debug('typescript-eslint:typescript-estree:useProvidedProgram');

function useProvidedPrograms(
programInstances: ts.Program[],
programInstances: ts.Program[] | Iterable<ts.Program>,
JamesHenry marked this conversation as resolved.
Show resolved Hide resolved
extra: Extra,
): ASTAndProgram | undefined {
log(
Expand Down
2 changes: 1 addition & 1 deletion packages/typescript-estree/src/index.ts
Expand Up @@ -2,7 +2,7 @@ export * from './parser';
export { ParserServices, TSESTreeOptions } from './parser-options';
export { simpleTraverse } from './simple-traverse';
export * from './ts-estree';
export { clearCaches } from './create-program/createWatchProgram';
export { clearWatchCaches as clearCaches } from './create-program/createWatchProgram';
export { createProgramFromConfigFile as createProgram } from './create-program/useProvidedPrograms';

// re-export for backwards-compat
Expand Down
21 changes: 18 additions & 3 deletions packages/typescript-estree/src/parser-options.ts
@@ -1,7 +1,7 @@
import { DebugLevel } from '@typescript-eslint/types';
import { Program } from 'typescript';
import { TSESTree, TSNode, TSESTreeToTSNode, TSToken } from './ts-estree';
import type { Program } from 'typescript';
import { CanonicalPath } from './create-program/shared';
import { TSESTree, TSESTreeToTSNode, TSNode, TSToken } from './ts-estree';

type DebugModule = 'typescript-eslint' | 'eslint' | 'typescript';

Expand All @@ -18,9 +18,10 @@ export interface Extra {
filePath: string;
jsx: boolean;
loc: boolean;
singleRun: boolean;
log: (message: string) => void;
preserveNodeMaps?: boolean;
programs: null | Program[];
programs: null | Iterable<Program>;
projects: CanonicalPath[];
range: boolean;
strict: boolean;
Expand Down Expand Up @@ -187,6 +188,20 @@ interface ParseAndGenerateServicesOptions extends ParseOptions {
* it will not error, but will instead parse the file and its dependencies in a new program.
*/
createDefaultProgram?: boolean;

/**
* ESLint (and therefore typescript-eslint) is used in both "single run"/one-time contexts,
* such as an ESLint CLI invocation, and long-running sessions (such as continuous feedback
* on a file in an IDE).
*
* When typescript-eslint handles TypeScript Program management behind the scenes, this distinction
* is important because there is significant overhead to managing the so called Watch Programs
* needed for the long-running use-case.
*
* When allowAutomaticSingleRunInference is enabled, we will use common heuristics to infer
* whether or not ESLint is being used as part of a single run.
*/
allowAutomaticSingleRunInference?: boolean;
}

export type TSESTreeOptions = ParseAndGenerateServicesOptions;
Expand Down
109 changes: 102 additions & 7 deletions packages/typescript-estree/src/parser.ts
Expand Up @@ -2,6 +2,7 @@ import debug from 'debug';
import { sync as globSync } from 'globby';
import isGlob from 'is-glob';
import semver from 'semver';
import { normalize } from 'path';
import * as ts from 'typescript';
import { astConverter } from './ast-converter';
import { convertError } from './convert';
Expand All @@ -18,8 +19,10 @@ import {
ensureAbsolutePath,
getCanonicalFileName,
} from './create-program/shared';
import { Program } from 'typescript';
import { useProvidedPrograms } from './create-program/useProvidedPrograms';
import {
createProgramFromConfigFile,
useProvidedPrograms,
} from './create-program/useProvidedPrograms';

const log = debug('typescript-eslint:typescript-estree:parser');

Expand All @@ -44,6 +47,16 @@ const isRunningSupportedTypeScriptVersion = semver.satisfies(
let extra: Extra;
let warnedAboutTSVersion = false;

/**
* Cache existing programs for the single run use-case.
*
* clearProgramCache() is only intended to be used in testing to ensure the parser is clean between tests.
*/
const existingPrograms = new Map<CanonicalPath, ts.Program>();
function clearProgramCache(): void {
existingPrograms.clear();
}

function enforceString(code: unknown): string {
/**
* Ensure the source code is a string
Expand All @@ -57,20 +70,19 @@ function enforceString(code: unknown): string {

/**
* @param code The code of the file being linted
* @param programInstances One or more existing programs to use
* @param programInstances One or more (potentially lazily constructed) existing programs to use
* @param shouldProvideParserServices True if the program should be attempted to be calculated from provided tsconfig files
* @param shouldCreateDefaultProgram True if the program should be created from compiler host
* @returns Returns a source file and program corresponding to the linted code
*/
function getProgramAndAST(
code: string,
programInstances: Program[] | null,
programInstances: Iterable<ts.Program> | null,
shouldProvideParserServices: boolean,
shouldCreateDefaultProgram: boolean,
): ASTAndProgram {
return (
(programInstances?.length &&
useProvidedPrograms(programInstances, extra)) ||
(programInstances && useProvidedPrograms(programInstances, extra)) ||
(shouldProvideParserServices &&
createProjectProgram(code, shouldCreateDefaultProgram, extra)) ||
(shouldProvideParserServices &&
Expand Down Expand Up @@ -118,6 +130,11 @@ function resetExtra(): void {
tokens: null,
tsconfigRootDir: process.cwd(),
useJSXTextNode: false,
/**
* Unless we can reliably infer otherwise, we default to assuming that this run could be part
* of a long-running session (e.g. in an IDE) and watch programs will therefore be required
*/
singleRun: false,
};
}

Expand Down Expand Up @@ -347,6 +364,47 @@ function warnAboutTSVersion(): void {
}
}

/**
* ESLint (and therefore typescript-eslint) is used in both "single run"/one-time contexts,
* such as an ESLint CLI invocation, and long-running sessions (such as continuous feedback
* on a file in an IDE).
*
* When typescript-eslint handles TypeScript Program management behind the scenes, this distinction
* is important because there is significant overhead to managing the so called Watch Programs
* needed for the long-running use-case. We therefore use the following logic to figure out which
* of these contexts applies to the current execution.
*/
function inferSingleRun(options: TSESTreeOptions | undefined): void {
// Allow users to explicitly inform us of their intent to perform a single run (or not) with TSESTREE_SINGLE_RUN
if (process.env.TSESTREE_SINGLE_RUN === 'false') {
extra.singleRun = false;
return;
}
if (process.env.TSESTREE_SINGLE_RUN === 'true') {
extra.singleRun = true;
return;
}

// Currently behind a flag while we gather real-world feedback
if (options?.allowAutomaticSingleRunInference) {
if (
// Default to single runs for CI processes. CI=true is set by most CI providers by default.
process.env.CI === 'true' ||
// This will be true for invocations such as `npx eslint ...` and `./node_modules/.bin/eslint ...`
process.argv[1].endsWith(normalize('node_modules/.bin/eslint'))
) {
extra.singleRun = true;
return;
}
}

/**
* We default to assuming that this run could be part of a long-running session (e.g. in an IDE)
* and watch programs will therefore be required
*/
extra.singleRun = false;
}

// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface EmptyObject {}
type AST<T extends TSESTreeOptions> = TSESTree.Program &
Expand Down Expand Up @@ -408,6 +466,11 @@ function parseWithNodeMapsInternal<T extends TSESTreeOptions = TSESTreeOptions>(
*/
warnAboutTSVersion();

/**
* Figure out whether this is a single run or part of a long-running process
*/
inferSingleRun(options);

/**
* Create a ts.SourceFile directly, no ts.Program is needed for a simple
* parse
Expand Down Expand Up @@ -468,7 +531,38 @@ function parseAndGenerateServices<T extends TSESTreeOptions = TSESTreeOptions>(
warnAboutTSVersion();

/**
* Generate a full ts.Program or offer provided instance in order to be able to provide parser services, such as type-checking
* Figure out whether this is a single run or part of a long-running process
*/
inferSingleRun(options);

/**
* If this is a single run in which the user has not provided any existing programs but there
* are programs which need to be created from the provided "project" option,
* create an Iterable which will lazily create the programs as needed by the iteration logic
*/
if (extra.singleRun && !extra.programs && extra.projects?.length > 0) {
extra.programs = {
*[Symbol.iterator](): Iterator<ts.Program> {
for (const configFile of extra.projects) {
const existingProgram = existingPrograms.get(configFile);
if (existingProgram) {
yield existingProgram;
} else {
log(
'Detected single-run/CLI usage, creating Program once ahead of time for project: %s',
configFile,
);
const newProgram = createProgramFromConfigFile(configFile);
existingPrograms.set(configFile, newProgram);
yield newProgram;
}
}
},
};
}

/**
* Generate a full ts.Program or offer provided instances in order to be able to provide parser services, such as type-checking
*/
const shouldProvideParserServices =
extra.programs != null || (extra.projects && extra.projects.length > 0);
Expand Down Expand Up @@ -519,4 +613,5 @@ export {
parseWithNodeMaps,
ParseAndGenerateServicesResult,
ParseWithNodeMapsResult,
clearProgramCache,
};
5 changes: 3 additions & 2 deletions packages/typescript-estree/tests/lib/persistentParse.test.ts
@@ -1,7 +1,8 @@
import fs from 'fs';
import path from 'path';
import tmp from 'tmp';
import { clearCaches, parseAndGenerateServices } from '../../src';
import { clearWatchCaches } from '../../src/create-program/createWatchProgram';
import { parseAndGenerateServices } from '../../src/parser';

const CONTENTS = {
foo: 'console.log("foo")',
Expand All @@ -17,7 +18,7 @@ const cwdCopy = process.cwd();
const tmpDirs = new Set<tmp.DirResult>();
afterEach(() => {
// stop watching the files and folders
clearCaches();
clearWatchCaches();

// clean up the temporary files and folders
tmpDirs.forEach(t => t.removeCallback());
Expand Down