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

Cache ts.program between linted files #361

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 2 additions & 1 deletion .eslintrc.json
Expand Up @@ -25,7 +25,8 @@
"sourceType": "module",
"ecmaFeatures": {
"jsx": false
}
},
"project": "./tsconfig.base.json"
},
"overrides": [
{
Expand Down
26 changes: 10 additions & 16 deletions packages/typescript-estree/src/parser.ts
Expand Up @@ -6,7 +6,6 @@ import semver from 'semver';
import ts from 'typescript';
import convert from './ast-converter';
import { convertError } from './convert';
import { firstDefined } from './node-utils';
import { TSESTree } from './ts-estree';
import { Extra, ParserOptions, ParserServices } from './parser-options';
import { getFirstSemanticOrSyntacticError } from './semantic-errors';
Expand Down Expand Up @@ -65,20 +64,15 @@ function resetExtra(): void {
* @param options The config object
* @returns If found, returns the source file corresponding to the code and the containing program
*/
function getASTFromProject(code: string, options: ParserOptions) {
return firstDefined(
calculateProjectParserOptions(
code,
options.filePath || getFileName(options),
extra,
),
currentProgram => {
const ast = currentProgram.getSourceFile(
options.filePath || getFileName(options),
);
return ast && { ast, program: currentProgram };
},
);
function getASTFromProject(options: ParserOptions) {
const filePath = options.filePath || getFileName(options);
for (const program of calculateProjectParserOptions(extra)) {
const ast = program.getSourceFile(filePath);
if (ast !== undefined) {
return { ast, program };
}
}
return undefined;
}

/**
Expand Down Expand Up @@ -162,7 +156,7 @@ function getProgramAndAST(
shouldProvideParserServices: boolean,
) {
return (
(shouldProvideParserServices && getASTFromProject(code, options)) ||
(shouldProvideParserServices && getASTFromProject(options)) ||
(shouldProvideParserServices && getASTAndDefaultProject(code, options)) ||
createNewProgram(code)
);
Expand Down
187 changes: 44 additions & 143 deletions packages/typescript-estree/src/tsconfig-parser.ts
Expand Up @@ -14,28 +14,6 @@ const defaultCompilerOptions: ts.CompilerOptions = {
allowJs: true,
};

/**
* Maps tsconfig paths to their corresponding file contents and resulting watches
*/
const knownWatchProgramMap = new Map<
string,
ts.WatchOfConfigFile<ts.SemanticDiagnosticsBuilderProgram>
>();

/**
* Maps file paths to their set of corresponding watch callbacks
* There may be more than one per file if a file is shared between projects
*/
const watchCallbackTrackingMap = new Map<string, ts.FileWatcherCallback>();

/**
* Holds information about the file currently being linted
*/
const currentLintOperationState = {
code: '',
filePath: '',
};

/**
* Appropriately report issues found when reading a config file
* @param diagnostic The diagnostic raised when creating a program
Expand All @@ -46,7 +24,11 @@ function diagnosticReporter(diagnostic: ts.Diagnostic): void {
);
}

const noopFileWatcher = { close: () => {} };
function getTsconfigPath(tsconfigPath: string, extra: Extra): string {
return path.isAbsolute(tsconfigPath)
? tsconfigPath
: path.join(extra.tsconfigRootDir || process.cwd(), tsconfigPath);
}

/**
* Calculate project environments using options provided by consumer and paths from config
Expand All @@ -56,123 +38,47 @@ const noopFileWatcher = { close: () => {} };
* @param extra.project Provided tsconfig paths
* @returns The programs corresponding to the supplied tsconfig paths
*/
export function calculateProjectParserOptions(
code: string,
filePath: string,
extra: Extra,
): ts.Program[] {
const results = [];
const tsconfigRootDir = extra.tsconfigRootDir;

// preserve reference to code and file being linted
currentLintOperationState.code = code;
currentLintOperationState.filePath = filePath;

// Update file version if necessary
// TODO: only update when necessary, currently marks as changed on every lint
const watchCallback = watchCallbackTrackingMap.get(filePath);
if (typeof watchCallback !== 'undefined') {
watchCallback(filePath, ts.FileWatcherEventKind.Changed);
}
const cache: Map<string, ts.Program> = new Map();
export function calculateProjectParserOptions(extra: Extra): ts.Program[] {
const results: ts.Program[] = [];

extra.projects
.map(project => getTsconfigPath(project, extra))
.forEach(tsconfigPath => {
if (cache.has(tsconfigPath)) {
results.push(cache.get(tsconfigPath) as ts.Program);
return;
}

for (let tsconfigPath of extra.projects) {
// if absolute paths aren't provided, make relative to tsconfigRootDir
if (!path.isAbsolute(tsconfigPath)) {
tsconfigPath = path.join(tsconfigRootDir, tsconfigPath);
}

const existingWatch = knownWatchProgramMap.get(tsconfigPath);

if (typeof existingWatch !== 'undefined') {
// get new program (updated if necessary)
results.push(existingWatch.getProgram().getProgram());
continue;
}

// create compiler host
const watchCompilerHost = ts.createWatchCompilerHost(
tsconfigPath,
/*optionsToExtend*/ { allowNonTsExtensions: true } as ts.CompilerOptions,
ts.sys,
ts.createSemanticDiagnosticsBuilderProgram,
diagnosticReporter,
/*reportWatchStatus*/ () => {},
);

// ensure readFile reads the code being linted instead of the copy on disk
const oldReadFile = watchCompilerHost.readFile;
watchCompilerHost.readFile = (filePath, encoding) =>
path.normalize(filePath) ===
path.normalize(currentLintOperationState.filePath)
? currentLintOperationState.code
: oldReadFile(filePath, encoding);

// ensure process reports error on failure instead of exiting process immediately
watchCompilerHost.onUnRecoverableConfigFileDiagnostic = diagnosticReporter;

// ensure process doesn't emit programs
watchCompilerHost.afterProgramCreate = program => {
// report error if there are any errors in the config file
const configFileDiagnostics = program
.getConfigFileParsingDiagnostics()
.filter(
diag =>
diag.category === ts.DiagnosticCategory.Error &&
diag.code !== 18003,
);
if (configFileDiagnostics.length > 0) {
diagnosticReporter(configFileDiagnostics[0]);
const config = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
if (config.error !== undefined) {
diagnosticReporter(config.error);
}
};

// register callbacks to trigger program updates without using fileWatchers
watchCompilerHost.watchFile = (fileName, callback) => {
const normalizedFileName = path.normalize(fileName);
watchCallbackTrackingMap.set(normalizedFileName, callback);
return {
close: () => {
watchCallbackTrackingMap.delete(normalizedFileName);
},
const parseConfigHost: ts.ParseConfigHost = {
fileExists: ts.sys.fileExists,
readDirectory: ts.sys.readDirectory,
readFile: ts.sys.readFile,
useCaseSensitiveFileNames: true,
};
};

// ensure fileWatchers aren't created for directories
watchCompilerHost.watchDirectory = () => noopFileWatcher;

// allow files with custom extensions to be included in program (uses internal ts api)
const oldOnDirectoryStructureHostCreate = (watchCompilerHost as any)
.onCachedDirectoryStructureHostCreate;
(watchCompilerHost as any).onCachedDirectoryStructureHostCreate = (
host: any,
) => {
const oldReadDirectory = host.readDirectory;
host.readDirectory = (
path: string,
extensions?: ReadonlyArray<string>,
exclude?: ReadonlyArray<string>,
include?: ReadonlyArray<string>,
depth?: number,
) =>
oldReadDirectory(
path,
!extensions
? undefined
: extensions.concat(extra.extraFileExtensions),
exclude,
include,
depth,
);
oldOnDirectoryStructureHostCreate(host);
};

// create program
const programWatch = ts.createWatchProgram(watchCompilerHost);
const program = programWatch.getProgram().getProgram();

// cache watch program and return current program
knownWatchProgramMap.set(tsconfigPath, programWatch);
results.push(program);
}
const parsed = ts.parseJsonConfigFileContent(
config.config,
parseConfigHost,
extra.tsconfigRootDir || path.dirname(tsconfigPath),
{ noEmit: true },
);
if (parsed.errors !== undefined && parsed.errors.length > 0) {
diagnosticReporter(parsed.errors[0]);
}
const host = ts.createCompilerHost(
{ ...defaultCompilerOptions, ...parsed.options },
true,
);
const program = ts.createProgram(parsed.fileNames, parsed.options, host);

cache.set(tsconfigPath, program);

results.push(program);
});

return results;
}
Expand All @@ -190,12 +96,7 @@ export function createProgram(code: string, filePath: string, extra: Extra) {
return undefined;
}

let tsconfigPath = extra.projects[0];

// if absolute paths aren't provided, make relative to tsconfigRootDir
if (!path.isAbsolute(tsconfigPath)) {
tsconfigPath = path.join(extra.tsconfigRootDir, tsconfigPath);
}
const tsconfigPath = getTsconfigPath(extra.projects[0], extra);

const commandLine = ts.getParsedCommandLineOfConfigFile(
tsconfigPath,
Expand Down
51 changes: 49 additions & 2 deletions packages/typescript-estree/tests/lib/semanticInfo.ts
Expand Up @@ -48,6 +48,21 @@ describe('semanticInfo', () => {
);
});

it(`should cache the created ts.program`, () => {
const filename = testFiles[0];
const code = readFileSync(filename, 'utf8');
const options = createOptions(filename);
const optionsProjectString = {
...options,
project: './tsconfig.json',
};
expect(
parseAndGenerateServices(code, optionsProjectString).services.program,
).toBe(
parseAndGenerateServices(code, optionsProjectString).services.program,
);
});

it(`should handle "project": "./tsconfig.json" and "project": ["./tsconfig.json"] the same`, () => {
const filename = testFiles[0];
const code = readFileSync(filename, 'utf8');
Expand All @@ -65,6 +80,38 @@ describe('semanticInfo', () => {
);
});

it(`should resolve absolute and relative tsconfig paths the same`, () => {
const filename = testFiles[0];
const code = readFileSync(filename, 'utf8');
const options = createOptions(filename);
const optionsAbsolutePath = {
...options,
project: `${__dirname}/../fixtures/semanticInfo/tsconfig.json`,
};
const optionsRelativePath = {
...options,
project: `./tsconfig.json`,
};
const absolutePathResult = parseAndGenerateServices(
code,
optionsAbsolutePath,
);
const relativePathResult = parseAndGenerateServices(
code,
optionsRelativePath,
);
if (absolutePathResult.services.program === undefined) {
throw new Error('Unable to create ts.program for absolute tsconfig');
} else if (relativePathResult.services.program === undefined) {
throw new Error('Unable to create ts.program for relative tsconfig');
}
expect(
absolutePathResult.services.program.getResolvedProjectReferences(),
).toEqual(
relativePathResult.services.program.getResolvedProjectReferences(),
);
});

// case-specific tests
it('isolated-file tests', () => {
const fileName = resolve(FIXTURES_DIR, 'isolated-file.src.ts');
Expand Down Expand Up @@ -190,7 +237,7 @@ describe('semanticInfo', () => {
badConfig.project = './tsconfigs.json';
expect(() =>
parseCodeAndGenerateServices(readFileSync(fileName, 'utf8'), badConfig),
).toThrow(/File .+tsconfigs\.json' not found/);
).toThrow(/The specified path does not exist: .+tsconfigs\.json'/);
});

it('fail to read project file', () => {
Expand All @@ -199,7 +246,7 @@ describe('semanticInfo', () => {
badConfig.project = '.';
expect(() =>
parseCodeAndGenerateServices(readFileSync(fileName, 'utf8'), badConfig),
).toThrow(/File .+semanticInfo' not found/);
).toThrow(/The specified path does not exist: .+semanticInfo'/);
});

it('malformed project file', () => {
Expand Down