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
2 changes: 1 addition & 1 deletion packages/experimental-utils/src/eslint-utils/RuleTester.ts
Expand Up @@ -33,7 +33,7 @@ class RuleTester extends TSESLint.RuleTester {
// instead of creating a hard dependency, just use a soft require
// a bit weird, but if they're using this tooling, it'll be installed
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
require(parser).clearCaches();
require(parser).clearWatchCaches();
JamesHenry marked this conversation as resolved.
Show resolved Hide resolved
} catch {
// ignored
}
Expand Down
2 changes: 1 addition & 1 deletion packages/parser/src/index.ts
@@ -1,7 +1,7 @@
export { parse, parseForESLint, ParserOptions } from './parser';
export {
ParserServices,
clearCaches,
clearWatchCaches,
JamesHenry marked this conversation as resolved.
Show resolved Hide resolved
createProgram,
} from '@typescript-eslint/typescript-estree';

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 };
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 } from './create-program/createWatchProgram';
JamesHenry marked this conversation as resolved.
Show resolved Hide resolved
export { createProgramFromConfigFile as createProgram } from './create-program/useProvidedPrograms';

// re-export for backwards-compat
Expand Down
1 change: 1 addition & 0 deletions packages/typescript-estree/src/parser-options.ts
Expand Up @@ -18,6 +18,7 @@ export interface Extra {
filePath: string;
jsx: boolean;
loc: boolean;
singleRun: boolean;
log: (message: string) => void;
preserveNodeMaps?: boolean;
programs: null | Program[];
Expand Down
90 changes: 88 additions & 2 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 @@ -19,7 +20,10 @@ import {
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 +48,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 Down Expand Up @@ -118,6 +132,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 +366,41 @@ 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(): 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' ||
// 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;
JamesHenry marked this conversation as resolved.
Show resolved Hide resolved
}

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

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

/**
* Create a ts.SourceFile directly, no ts.Program is needed for a simple
* parse
Expand Down Expand Up @@ -468,7 +527,33 @@ 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();

/**
* 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 the programs once ahead of time and avoid watch programs
*/
if (extra.singleRun && !extra.programs && extra.projects?.length > 0) {
extra.programs = extra.projects.map(configFile => {
const existingProgram = existingPrograms.get(configFile);
if (existingProgram) {
return existingProgram;
}
log(
'Detected single-run/CLI usage, creating Program once ahead of time for project: %s',
configFile,
);
const newProgram = createProgramFromConfigFile(configFile);
existingPrograms.set(configFile, newProgram);
return newProgram;
});
JamesHenry marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* 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 +604,5 @@ export {
parseWithNodeMaps,
ParseAndGenerateServicesResult,
ParseWithNodeMapsResult,
clearProgramCache,
};
2 changes: 1 addition & 1 deletion packages/typescript-estree/tests/lib/parse.test.ts
Expand Up @@ -632,7 +632,7 @@ describe('parseAndGenerateServices', () => {

describe('projectFolderIgnoreList', () => {
beforeEach(() => {
parser.clearCaches();
parser.clearWatchCaches();
});

const PROJECT_DIR = resolve(FIXTURES_DIR, '../projectFolderIgnoreList');
Expand Down
4 changes: 2 additions & 2 deletions packages/typescript-estree/tests/lib/persistentParse.test.ts
@@ -1,7 +1,7 @@
import fs from 'fs';
import path from 'path';
import tmp from 'tmp';
import { clearCaches, parseAndGenerateServices } from '../../src';
import { clearWatchCaches, parseAndGenerateServices } from '../../src';

const CONTENTS = {
foo: 'console.log("foo")',
Expand All @@ -17,7 +17,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
175 changes: 175 additions & 0 deletions packages/typescript-estree/tests/lib/semanticInfo-singleRun.test.ts
@@ -0,0 +1,175 @@
import glob from 'glob';
import * as path from 'path';
import { clearProgramCache, parseAndGenerateServices } from '../../src';

const mockProgram = {
getSourceFile(): void {
return;
},
};

jest.mock('../../src/ast-converter', () => {
return {
astConverter(): unknown {
return { estree: {}, astMaps: {} };
},
};
});

jest.mock('../../src/create-program/useProvidedPrograms.ts', () => {
return {
...jest.requireActual('../../src/create-program/useProvidedPrograms.ts'),
useProvidedPrograms: jest.fn(() => {
return {
ast: {
parseDiagnostics: [],
},
program: mockProgram,
};
}),
createProgramFromConfigFile: jest.fn(() => mockProgram),
};
});

const {
createProgramFromConfigFile,
} = require('../../src/create-program/useProvidedPrograms');

const FIXTURES_DIR = './tests/fixtures/semanticInfo';
const testFiles = glob.sync(`**/*.src.ts`, {
cwd: FIXTURES_DIR,
});

const code = 'const foo = 5;';
const tsconfigs = ['./tsconfig.json', './badTSConfig/tsconfig.json'];
const options = {
filePath: testFiles[0],
tsconfigRootDir: path.join(process.cwd(), FIXTURES_DIR),
loggerFn: false,
project: tsconfigs,
} as const;

const resolvedProject = (p: string): string =>
path.resolve(path.join(process.cwd(), FIXTURES_DIR), p).toLowerCase();

describe('semanticInfo - singleRun', () => {
beforeEach(() => {
// ensure caches are clean for each test
clearProgramCache();
// ensure invocations of mock are clean for each test
(createProgramFromConfigFile as jest.Mock).mockClear();
});

it('should not create any programs ahead of time by default when there is no way to infer singleRun=true', () => {
/**
* Nothing to indicate it is a single run, so createProgramFromConfigFile should
* never be called
*/
parseAndGenerateServices(code, options);
expect(createProgramFromConfigFile).not.toHaveBeenCalled();
});

it('should not create any programs ahead of time when when TSESTREE_SINGLE_RUN=false, even if other inferrence criteria apply', () => {
const originalTSESTreeSingleRun = process.env.TSESTREE_SINGLE_RUN;
process.env.TSESTREE_SINGLE_RUN = 'false';

// Normally CI=true would be used to infer singleRun=true, but TSESTREE_SINGLE_RUN is explicitly set to false
const originalEnvCI = process.env.CI;
process.env.CI = 'true';

parseAndGenerateServices(code, options);
expect(createProgramFromConfigFile).not.toHaveBeenCalled();

// Restore process data
process.env.TSESTREE_SINGLE_RUN = originalTSESTreeSingleRun;
process.env.CI = originalEnvCI;
});

it('should create all programs for provided "parserOptions.project" once ahead of time when TSESTREE_SINGLE_RUN=true', () => {
/**
* Single run because of explicit environment variable TSESTREE_SINGLE_RUN
*/
const originalTSESTreeSingleRun = process.env.TSESTREE_SINGLE_RUN;
process.env.TSESTREE_SINGLE_RUN = 'true';

const resultProgram = parseAndGenerateServices(code, options).services
.program;
expect(resultProgram).toBe(mockProgram);

// Call parseAndGenerateServices() again to ensure caching of Programs is working correctly...
parseAndGenerateServices(code, options);
// ...by asserting this was only called once per project
expect(createProgramFromConfigFile).toHaveBeenCalledTimes(tsconfigs.length);

expect(createProgramFromConfigFile).toHaveBeenNthCalledWith(
1,
resolvedProject(tsconfigs[0]),
);
expect(createProgramFromConfigFile).toHaveBeenNthCalledWith(
2,
resolvedProject(tsconfigs[1]),
);

// Restore process data
process.env.TSESTREE_SINGLE_RUN = originalTSESTreeSingleRun;
});

it('should create all programs for provided "parserOptions.project" once ahead of time when singleRun is inferred from CI=true', () => {
/**
* Single run because of CI=true (we need to make sure we respect the original value
* so that we won't interfere with our own usage of the variable)
*/
const originalEnvCI = process.env.CI;
process.env.CI = 'true';

const resultProgram = parseAndGenerateServices(code, options).services
.program;
expect(resultProgram).toBe(mockProgram);

// Call parseAndGenerateServices() again to ensure caching of Programs is working correctly...
parseAndGenerateServices(code, options);
// ...by asserting this was only called once per project
expect(createProgramFromConfigFile).toHaveBeenCalledTimes(tsconfigs.length);

expect(createProgramFromConfigFile).toHaveBeenNthCalledWith(
1,
resolvedProject(tsconfigs[0]),
);
expect(createProgramFromConfigFile).toHaveBeenNthCalledWith(
2,
resolvedProject(tsconfigs[1]),
);

// Restore process data
process.env.CI = originalEnvCI;
});

it('should create all programs for provided "parserOptions.project" once ahead of time when singleRun is inferred from process.argv', () => {
/**
* Single run because of process.argv
*/
const originalProcessArgv = process.argv;
process.argv = ['', 'node_modules/.bin/eslint', ''];

const resultProgram = parseAndGenerateServices(code, options).services
.program;
expect(resultProgram).toBe(mockProgram);

// Call parseAndGenerateServices() again to ensure caching of Programs is working correctly...
parseAndGenerateServices(code, options);
// ...by asserting this was only called once per project
expect(createProgramFromConfigFile).toHaveBeenCalledTimes(tsconfigs.length);

expect(createProgramFromConfigFile).toHaveBeenNthCalledWith(
1,
resolvedProject(tsconfigs[0]),
);
expect(createProgramFromConfigFile).toHaveBeenNthCalledWith(
2,
resolvedProject(tsconfigs[1]),
);

// Restore process data
process.argv = originalProcessArgv;
});
});