Skip to content

Commit

Permalink
feat(typescript-estree): add opt-in inference for single runs and cre…
Browse files Browse the repository at this point in the history
…ate programs for projects up front (#3512)
  • Loading branch information
JamesHenry committed Jun 11, 2021
1 parent 79b510c commit 06c2d9b
Show file tree
Hide file tree
Showing 10 changed files with 399 additions and 25 deletions.
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: Iterable<ts.Program>,
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

0 comments on commit 06c2d9b

Please sign in to comment.