Skip to content

feat(typescript-estree): use createProgram backed by a local source file cache #1102

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

Closed
wants to merge 2 commits 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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/typescript-estree/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ module.exports = {
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
testRegex: './tests/(lib/.*\\.(jsx?|tsx?)|ast-alignment/spec\\.ts)$',
testRegex: [
'./tests/host/.*\\.(jsx?|tsx?)$',
'./tests/lib/.*\\.(jsx?|tsx?)$',
'./tests/ast-alignment/spec\\.ts$',
],
collectCoverage: false,
collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
Expand Down
172 changes: 172 additions & 0 deletions packages/typescript-estree/src/host/ProgramCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import path from 'path';
import ts from 'typescript';
import {
getCanonicalFileName,
noop,
NormalisedTSConfigPath,
normaliseTSConfigPath,
TSConfigPath,
} from './common';
import { parseConfigFile } from './parseConfigFile';
import { SourceFileCache } from './SourceFileCache';

interface CachedProgram {
invalidated: boolean;
program?: ts.Program;
timeoutId?: NodeJS.Timeout;
}

const INVALIDATION_TIMEOUT_MS = 10;

class ProgramCache {
private readonly programCache = new Map<
NormalisedTSConfigPath,
CachedProgram
>();
private readonly sourceFileCache = new SourceFileCache();

/**
* Gets the current program for the given tsconfig path
*/
public getProgram(tsconfigPath: TSConfigPath): CachedProgram {
const normalisedTSConfigPath = normaliseTSConfigPath(tsconfigPath);
return this.getProgramNormalised(normalisedTSConfigPath);
}
private getProgramNormalised(
tsconfigPath: NormalisedTSConfigPath,
): CachedProgram {
const entry = this.programCache.get(tsconfigPath);
if (!entry) {
return { invalidated: true };
}

return entry;
}

public createProgram(
tsconfigPath: TSConfigPath,
oldProgram?: ts.Program,
): ts.Program {
const normalisedTSConfigPath = normaliseTSConfigPath(tsconfigPath);
// ensure the old program is invalidated if it exists
this.invalidateProgram(this.getProgramNormalised(normalisedTSConfigPath));

const tsconfig = parseConfigFile(normalisedTSConfigPath);

this.sourceFileCache.registerTSConfig(tsconfig, normalisedTSConfigPath);

const createProgramOptions = this.createProgramOptions(
tsconfig,
normalisedTSConfigPath,
);
createProgramOptions.oldProgram = oldProgram;
const program = ts.createProgram(createProgramOptions);

const entry: CachedProgram = {
program,
invalidated: false,
};
this.programCache.set(normalisedTSConfigPath, entry);
// schedule the invalidation of the program so we can handle newly created files
this.scheduleInvalidateProgram(entry);

return program;
}

private createProgramOptions(
tsconfig: ts.ParsedCommandLine,
tsconfigPath: NormalisedTSConfigPath,
): ts.CreateProgramOptions {
const compilerHost: ts.CompilerHost = {
getSourceFile: fileName =>
this.sourceFileCache.getSourceFile(fileName, tsconfigPath),
getSourceFileByPath: (fileName, filePath) =>
this.sourceFileCache.getSourceFileByPath(
fileName,
filePath,
tsconfigPath,
),
getCanonicalFileName,
useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames,
getNewLine: () => ts.sys.newLine,
getDefaultLibFileName: options => {
return path.join(
path.dirname(path.normalize(ts.sys.getExecutingFilePath())),
ts.getDefaultLibFileName(options),
);
},
writeFile: noop,
getCurrentDirectory: ts.sys.getCurrentDirectory,
fileExists: filePath =>
this.sourceFileCache.fileExists(filePath, tsconfigPath),
readFile: filePath =>
this.sourceFileCache.readFile(filePath, tsconfigPath),
realpath: ts.sys.realpath,
directoryExists: ts.sys.directoryExists,
getDirectories: ts.sys.getDirectories,
readDirectory: ts.sys.readDirectory,
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore - interal TS api - https://github.com/microsoft/TypeScript/blob/dd58bfc51417d0a037ea75fe8e8cd690ee9950eb/src/compiler/types.ts#L5469
onReleaseOldSourceFile: this.sourceFileCache.onReleaseSourceFile,
};

const createProgramOptions: ts.CreateProgramOptions = {
host: compilerHost,
options: tsconfig.options,
rootNames: tsconfig.fileNames,
};

return createProgramOptions;
}

public createOrUpdateSourceFile(
contents: string,
filePath: string,
tsconfigPath: string,
): void {
const normalisedTSConfigPath = normaliseTSConfigPath(tsconfigPath);
if (
this.sourceFileCache.createOrUpdateSourceFile(
filePath,
normalisedTSConfigPath,
contents,
)
) {
// invalidate the program because the source code changed
this.invalidateProgram(this.getProgramNormalised(normalisedTSConfigPath));
}
}

public clear(): void {
this.programCache.clear();
this.sourceFileCache.clear();
}

private scheduleInvalidateProgram(entry: CachedProgram): void {
if (entry.invalidated) {
return;
}

if (entry.timeoutId !== undefined) {
clearTimeout(entry.timeoutId);
}

entry.timeoutId = setTimeout(() => {
this.invalidateProgram(entry);
}, INVALIDATION_TIMEOUT_MS);
}
private invalidateProgram(entry: CachedProgram): void {
if (entry.invalidated) {
return;
}

if (entry.timeoutId !== undefined) {
clearTimeout(entry.timeoutId);
}

entry.invalidated = true;
delete entry.timeoutId;
}
}

export { ProgramCache };
168 changes: 168 additions & 0 deletions packages/typescript-estree/src/host/SourceFileCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import path from 'path';
import ts from 'typescript';
import { normaliseFilePath, NormalisedTSConfigPath, toPath } from './common';

interface CachedSourceFile {
sourceFile: ts.SourceFile;
refProjects: Set<NormalisedTSConfigPath>;
}

class SourceFileCache {
private readonly sourceFileCache = new Map<ts.Path, CachedSourceFile>();

public registerTSConfig(
tsconfig: ts.ParsedCommandLine,
tsconfigPath: NormalisedTSConfigPath,
): void {
tsconfig.fileNames.forEach(fileName => {
if (!this.getSourceFile(fileName, tsconfigPath)) {
this.createSourceFile(fileName, tsconfigPath);
}
});
}

/**
* @returns true if the file was created/updated, false if there were no changes
*/
public createOrUpdateSourceFile(
fileName: string,
tsconfigPath: NormalisedTSConfigPath,
contents: string,
): boolean {
const filePath = normaliseFilePath(fileName, tsconfigPath);
if (this.sourceFileCache.has(filePath)) {
return this.updateSourceFile(fileName, tsconfigPath, contents);
}

this.createSourceFile(fileName, tsconfigPath, contents);
return true;
}

private createSourceFile(
fileName: string,
tsconfigPath: NormalisedTSConfigPath,
contents?: string,
): void {
const filePath = normaliseFilePath(fileName, tsconfigPath);
contents = contents || ts.sys.readFile(filePath);
if (!contents) {
throw new Error(`Unable to find file ${filePath}`);
}

const sourceFile = ts.createSourceFile(
fileName,
contents,
ts.ScriptTarget.ESNext,
);

this.sourceFileCache.set(filePath, {
sourceFile,
refProjects: new Set([tsconfigPath]),
});
}

/**
* @returns true if the file was updated, false otherwise
*/
private updateSourceFile(
fileName: string,
tsconfigPath: NormalisedTSConfigPath,
contents: string,
): boolean {
const filePath = normaliseFilePath(fileName, tsconfigPath);
const entry = this.sourceFileCache.get(filePath);
if (!entry) {
throw new Error(
`Attempting to update file that is not in the cache: ${filePath}`,
);
}

if (entry.sourceFile.getText() === contents) {
return false;
}

const sourceFile = ts.createSourceFile(
fileName,
contents,
ts.ScriptTarget.ESNext,
);

this.sourceFileCache.set(filePath, {
sourceFile,
refProjects: new Set([...entry.refProjects, tsconfigPath]),
});
return true;
}

public fileExists(
fileName: string,
tsconfigPath: NormalisedTSConfigPath,
): boolean {
return this.sourceFileCache.has(normaliseFilePath(fileName, tsconfigPath));
}

public readFile(
fileName: string,
tsconfigPath: NormalisedTSConfigPath,
): string | undefined {
const filePath = normaliseFilePath(fileName, tsconfigPath);
const cache = this.sourceFileCache.get(filePath);
if (cache) {
if (typeof cache === 'string') {
return cache;
}
return cache.sourceFile.getText();
}

return ts.sys.readFile(filePath);
}

public getSourceFile(
fileName: string,
tsconfigPath: NormalisedTSConfigPath,
): ts.SourceFile | undefined {
return this.getSourceFileByPath(
fileName,
toPath(fileName, path.dirname(tsconfigPath)),
tsconfigPath,
);
}

public getSourceFileByPath(
_fileName: string,
filePath: ts.Path,
tsconfigPath: NormalisedTSConfigPath,
): ts.SourceFile | undefined {
const entry = this.sourceFileCache.get(filePath);
if (!entry) {
return undefined;
}

entry.refProjects.add(tsconfigPath);

return entry.sourceFile;
}

public onReleaseSourceFile(
oldSourceFile: ts.SourceFile,
tsconfigPath: NormalisedTSConfigPath,
): void {
const filePath = normaliseFilePath(oldSourceFile.fileName, tsconfigPath);
const entry = this.sourceFileCache.get(filePath);

if (!entry) {
return;
}

entry.refProjects.delete(tsconfigPath);
if (entry.refProjects.size <= 0) {
this.sourceFileCache.delete(filePath);
}
}

public clear(): void {
this.sourceFileCache.clear();
}
}

export { SourceFileCache };
39 changes: 39 additions & 0 deletions packages/typescript-estree/src/host/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import path from 'path';
import ts from 'typescript';

const getCanonicalFileName = ts.sys.useCaseSensitiveFileNames
? (path: string): string => path
: (path: string): string => path.toLowerCase();

const noop = (): void => {};

type TSConfigPath = string;
type NormalisedTSConfigPath = string & { __pathBrand: never };
function normaliseTSConfigPath(
tsconfigPath: TSConfigPath,
): NormalisedTSConfigPath {
return path.normalize(tsconfigPath) as NormalisedTSConfigPath;
}

function normaliseFilePath(
fileName: string,
tsconfigPath: NormalisedTSConfigPath,
): ts.Path {
return toPath(fileName, path.dirname(tsconfigPath));
}

function toPath(fileName: string, basePath: string | undefined): ts.Path {
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore - internal typescript API - https://github.com/microsoft/TypeScript/blob/dd58bfc51417d0a037ea75fe8e8cd690ee9950eb/src/compiler/path.ts#L554-L559
return ts.toPath(fileName, basePath, getCanonicalFileName);
}

export {
getCanonicalFileName,
noop,
toPath,
normaliseFilePath,
NormalisedTSConfigPath,
normaliseTSConfigPath,
TSConfigPath,
};
Loading