From a2528f61bfaa5b28d84c10cb1978cec7c9a86acb Mon Sep 17 00:00:00 2001 From: Dylan Kirkby Date: Sun, 24 Feb 2019 19:00:04 -0800 Subject: [PATCH] feat: cache code parse result between files --- .eslintrc.json | 3 +- packages/typescript-estree/src/parser.ts | 26 +-- .../typescript-estree/src/tsconfig-parser.ts | 187 +++++------------- .../tests/lib/semanticInfo.ts | 51 ++++- 4 files changed, 105 insertions(+), 162 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index e3abc256901..818b90dfab5 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -25,7 +25,8 @@ "sourceType": "module", "ecmaFeatures": { "jsx": false - } + }, + "project": "./tsconfig.base.json" }, "overrides": [ { diff --git a/packages/typescript-estree/src/parser.ts b/packages/typescript-estree/src/parser.ts index 28a0c157a29..29d95da8425 100644 --- a/packages/typescript-estree/src/parser.ts +++ b/packages/typescript-estree/src/parser.ts @@ -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'; @@ -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; } /** @@ -162,7 +156,7 @@ function getProgramAndAST( shouldProvideParserServices: boolean, ) { return ( - (shouldProvideParserServices && getASTFromProject(code, options)) || + (shouldProvideParserServices && getASTFromProject(options)) || (shouldProvideParserServices && getASTAndDefaultProject(code, options)) || createNewProgram(code) ); diff --git a/packages/typescript-estree/src/tsconfig-parser.ts b/packages/typescript-estree/src/tsconfig-parser.ts index c136c518c2d..146fd51c6b3 100644 --- a/packages/typescript-estree/src/tsconfig-parser.ts +++ b/packages/typescript-estree/src/tsconfig-parser.ts @@ -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 ->(); - -/** - * 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(); - -/** - * 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 @@ -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 @@ -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 = 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, - exclude?: ReadonlyArray, - include?: ReadonlyArray, - 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; } @@ -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, diff --git a/packages/typescript-estree/tests/lib/semanticInfo.ts b/packages/typescript-estree/tests/lib/semanticInfo.ts index ab7df7278a0..c50d56cf02e 100644 --- a/packages/typescript-estree/tests/lib/semanticInfo.ts +++ b/packages/typescript-estree/tests/lib/semanticInfo.ts @@ -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'); @@ -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'); @@ -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', () => { @@ -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', () => {