1import debug from 'debug'; 2import fs from 'fs'; 3import semver from 'semver'; 4import * as ts from 'typescript'; 5import { Extra } from '../parser-options'; 6import { WatchCompilerHostOfConfigFile } from './WatchCompilerHostOfConfigFile'; 7import { 8 canonicalDirname, 9 CanonicalPath, 10 createDefaultCompilerOptionsFromExtra, 11 getCanonicalFileName, 12 getTsconfigPath, 13} from './shared'; 14 15const log = debug('typescript-eslint:typescript-estree:createWatchProgram'); 16 17/** 18 * Maps tsconfig paths to their corresponding file contents and resulting watches 19 */ 20const knownWatchProgramMap = new Map< 21 CanonicalPath, 22 ts.WatchOfConfigFile<ts.BuilderProgram> 23>(); 24 25/** 26 * Maps file/folder paths to their set of corresponding watch callbacks 27 * There may be more than one per file/folder if a file/folder is shared between projects 28 */ 29const fileWatchCallbackTrackingMap = new Map< 30 CanonicalPath, 31 Set<ts.FileWatcherCallback> 32>(); 33const folderWatchCallbackTrackingMap = new Map< 34 CanonicalPath, 35 Set<ts.FileWatcherCallback> 36>(); 37 38/** 39 * Stores the list of known files for each program 40 */ 41const programFileListCache = new Map<CanonicalPath, Set<CanonicalPath>>(); 42 43/** 44 * Caches the last modified time of the tsconfig files 45 */ 46const tsconfigLastModifiedTimestampCache = new Map<CanonicalPath, number>(); 47 48const parsedFilesSeenHash = new Map<CanonicalPath, string>(); 49 50/** 51 * Clear all of the parser caches. 52 * This should only be used in testing to ensure the parser is clean between tests. 53 */ 54function clearCaches(): void { 55 knownWatchProgramMap.clear(); 56 fileWatchCallbackTrackingMap.clear(); 57 folderWatchCallbackTrackingMap.clear(); 58 parsedFilesSeenHash.clear(); 59 programFileListCache.clear(); 60 tsconfigLastModifiedTimestampCache.clear(); 61} 62 63function saveWatchCallback( 64 trackingMap: Map<string, Set<ts.FileWatcherCallback>>, 65) { 66 return ( 67 fileName: string, 68 callback: ts.FileWatcherCallback, 69 ): ts.FileWatcher => { 70 const normalizedFileName = getCanonicalFileName(fileName); 71 const watchers = ((): Set<ts.FileWatcherCallback> => { 72 let watchers = trackingMap.get(normalizedFileName); 73 if (!watchers) { 74 watchers = new Set(); 75 trackingMap.set(normalizedFileName, watchers); 76 } 77 return watchers; 78 })(); 79 watchers.add(callback); 80 81 return { 82 close: (): void => { 83 watchers.delete(callback); 84 }, 85 }; 86 }; 87} 88 89/** 90 * Holds information about the file currently being linted 91 */ 92const currentLintOperationState: { code: string; filePath: CanonicalPath } = { 93 code: '', 94 filePath: '' as CanonicalPath, 95}; 96 97/** 98 * Appropriately report issues found when reading a config file 99 * @param diagnostic The diagnostic raised when creating a program 100 */ 101function diagnosticReporter(diagnostic: ts.Diagnostic): void { 102 throw new Error( 103 ts.flattenDiagnosticMessageText(diagnostic.messageText, ts.sys.newLine), 104 ); 105} 106 107/** 108 * Hash content for compare content. 109 * @param content hashed contend 110 * @returns hashed result 111 */ 112function createHash(content: string): string { 113 // No ts.sys in browser environments. 114 if (ts.sys?.createHash) { 115 return ts.sys.createHash(content); 116 } 117 return content; 118} 119 120function updateCachedFileList( 121 tsconfigPath: CanonicalPath, 122 program: ts.Program, 123 extra: Extra, 124): Set<CanonicalPath> { 125 const fileList = extra.EXPERIMENTAL_useSourceOfProjectReferenceRedirect 126 ? new Set( 127 program.getSourceFiles().map(sf => getCanonicalFileName(sf.fileName)), 128 ) 129 : new Set(program.getRootFileNames().map(f => getCanonicalFileName(f))); 130 programFileListCache.set(tsconfigPath, fileList); 131 return fileList; 132} 133 134/** 135 * Calculate project environments using options provided by consumer and paths from config 136 * @param code The code being linted 137 * @param filePathIn The path of the file being parsed 138 * @param extra.tsconfigRootDir The root directory for relative tsconfig paths 139 * @param extra.projects Provided tsconfig paths 140 * @returns The programs corresponding to the supplied tsconfig paths 141 */ 142function getProgramsForProjects( 143 code: string, 144 filePathIn: string, 145 extra: Extra, 146): ts.Program[] { 147 const filePath = getCanonicalFileName(filePathIn); 148 const results = []; 149 150 // preserve reference to code and file being linted 151 currentLintOperationState.code = code; 152 currentLintOperationState.filePath = filePath; 153 154 // Update file version if necessary 155 const fileWatchCallbacks = fileWatchCallbackTrackingMap.get(filePath); 156 const codeHash = createHash(code); 157 if ( 158 parsedFilesSeenHash.get(filePath) !== codeHash && 159 fileWatchCallbacks && 160 fileWatchCallbacks.size > 0 161 ) { 162 fileWatchCallbacks.forEach(cb => 163 cb(filePath, ts.FileWatcherEventKind.Changed), 164 ); 165 } 166 167 /* 168 * before we go into the process of attempting to find and update every program 169 * see if we know of a program that contains this file 170 */ 171 for (const [tsconfigPath, existingWatch] of knownWatchProgramMap.entries()) { 172 let fileList = programFileListCache.get(tsconfigPath); 173 let updatedProgram: ts.Program | null = null; 174 if (!fileList) { 175 updatedProgram = existingWatch.getProgram().getProgram(); 176 fileList = updateCachedFileList(tsconfigPath, updatedProgram, extra); 177 } 178 179 if (fileList.has(filePath)) { 180 log('Found existing program for file. %s', filePath); 181 182 updatedProgram = 183 updatedProgram ?? existingWatch.getProgram().getProgram(); 184 // sets parent pointers in source files 185 updatedProgram.getTypeChecker(); 186 187 return [updatedProgram]; 188 } 189 } 190 log( 191 'File did not belong to any existing programs, moving to create/update. %s', 192 filePath, 193 ); 194 195 /* 196 * We don't know of a program that contains the file, this means that either: 197 * - the required program hasn't been created yet, or 198 * - the file is new/renamed, and the program hasn't been updated. 199 */ 200 for (const rawTsconfigPath of extra.projects) { 201 const tsconfigPath = getTsconfigPath(rawTsconfigPath, extra); 202 203 const existingWatch = knownWatchProgramMap.get(tsconfigPath); 204 205 if (existingWatch) { 206 const updatedProgram = maybeInvalidateProgram( 207 existingWatch, 208 filePath, 209 tsconfigPath, 210 ); 211 if (!updatedProgram) { 212 continue; 213 } 214 215 // sets parent pointers in source files 216 updatedProgram.getTypeChecker(); 217 218 // cache and check the file list 219 const fileList = updateCachedFileList( 220 tsconfigPath, 221 updatedProgram, 222 extra, 223 ); 224 if (fileList.has(filePath)) { 225 log('Found updated program for file. %s', filePath); 226 // we can return early because we know this program contains the file 227 return [updatedProgram]; 228 } 229 230 results.push(updatedProgram); 231 continue; 232 } 233 234 const programWatch = createWatchProgram(tsconfigPath, extra); 235 knownWatchProgramMap.set(tsconfigPath, programWatch); 236 237 const program = programWatch.getProgram().getProgram(); 238 // sets parent pointers in source files 239 program.getTypeChecker(); 240 241 // cache and check the file list 242 const fileList = updateCachedFileList(tsconfigPath, program, extra); 243 if (fileList.has(filePath)) { 244 log('Found program for file. %s', filePath); 245 // we can return early because we know this program contains the file 246 return [program]; 247 } 248 249 results.push(program); 250 } 251 252 return results; 253} 254 255const isRunningNoTimeoutFix = semver.satisfies(ts.version, '>=3.9.0-beta', { 256 includePrerelease: true, 257}); 258 259function createWatchProgram( 260 tsconfigPath: string, 261 extra: Extra, 262): ts.WatchOfConfigFile<ts.BuilderProgram> { 263 log('Creating watch program for %s.', tsconfigPath); 264 265 // create compiler host 266 const watchCompilerHost = ts.createWatchCompilerHost( 267 tsconfigPath, 268 createDefaultCompilerOptionsFromExtra(extra), 269 ts.sys, 270 ts.createAbstractBuilder, 271 diagnosticReporter, 272 /*reportWatchStatus*/ () => {}, 273 ) as WatchCompilerHostOfConfigFile<ts.BuilderProgram>; 274 275 // ensure readFile reads the code being linted instead of the copy on disk 276 const oldReadFile = watchCompilerHost.readFile; 277 watchCompilerHost.readFile = (filePathIn, encoding): string | undefined => { 278 const filePath = getCanonicalFileName(filePathIn); 279 const fileContent = 280 filePath === currentLintOperationState.filePath 281 ? currentLintOperationState.code 282 : oldReadFile(filePath, encoding); 283 if (fileContent !== undefined) { 284 parsedFilesSeenHash.set(filePath, createHash(fileContent)); 285 } 286 return fileContent; 287 }; 288 289 // ensure process reports error on failure instead of exiting process immediately 290 watchCompilerHost.onUnRecoverableConfigFileDiagnostic = diagnosticReporter; 291 292 // ensure process doesn't emit programs 293 watchCompilerHost.afterProgramCreate = (program): void => { 294 // report error if there are any errors in the config file 295 const configFileDiagnostics = program 296 .getConfigFileParsingDiagnostics() 297 .filter( 298 diag => 299 diag.category === ts.DiagnosticCategory.Error && diag.code !== 18003, 300 ); 301 if (configFileDiagnostics.length > 0) { 302 diagnosticReporter(configFileDiagnostics[0]); 303 } 304 }; 305 306 /* 307 * From the CLI, the file watchers won't matter, as the files will be parsed once and then forgotten. 308 * When running from an IDE, these watchers will let us tell typescript about changes. 309 * 310 * ESLint IDE plugins will send us unfinished file content as the user types (before it's saved to disk). 311 * We use the file watchers to tell typescript about this latest file content. 312 * 313 * When files are created (or renamed), we won't know about them because we have no filesystem watchers attached. 314 * We use the folder watchers to tell typescript it needs to go and find new files in the project folders. 315 */ 316 watchCompilerHost.watchFile = saveWatchCallback(fileWatchCallbackTrackingMap); 317 watchCompilerHost.watchDirectory = saveWatchCallback( 318 folderWatchCallbackTrackingMap, 319 ); 320 321 // allow files with custom extensions to be included in program (uses internal ts api) 322 const oldOnDirectoryStructureHostCreate = 323 watchCompilerHost.onCachedDirectoryStructureHostCreate; 324 watchCompilerHost.onCachedDirectoryStructureHostCreate = (host): void => { 325 const oldReadDirectory = host.readDirectory; 326 host.readDirectory = ( 327 path, 328 extensions, 329 exclude, 330 include, 331 depth, 332 ): string[] => 333 oldReadDirectory( 334 path, 335 !extensions ? undefined : extensions.concat(extra.extraFileExtensions), 336 exclude, 337 include, 338 depth, 339 ); 340 oldOnDirectoryStructureHostCreate(host); 341 }; 342 // This works only on 3.9 343 watchCompilerHost.extraFileExtensions = extra.extraFileExtensions.map( 344 extension => ({ 345 extension, 346 isMixedContent: true, 347 scriptKind: ts.ScriptKind.Deferred, 348 }), 349 ); 350 watchCompilerHost.trace = log; 351 352 /** 353 * TODO: this needs refinement and development, but we're allowing users to opt-in to this for now for testing and feedback. 354 * See https://github.com/typescript-eslint/typescript-eslint/issues/2094 355 */ 356 watchCompilerHost.useSourceOfProjectReferenceRedirect = (): boolean => 357 extra.EXPERIMENTAL_useSourceOfProjectReferenceRedirect; 358 359 // Since we don't want to asynchronously update program we want to disable timeout methods 360 // So any changes in the program will be delayed and updated when getProgram is called on watch 361 let callback: (() => void) | undefined; 362 if (isRunningNoTimeoutFix) { 363 watchCompilerHost.setTimeout = undefined; 364 watchCompilerHost.clearTimeout = undefined; 365 } else { 366 log('Running without timeout fix'); 367 // But because of https://github.com/microsoft/TypeScript/pull/37308 we cannot just set it to undefined 368 // instead save it and call before getProgram is called 369 watchCompilerHost.setTimeout = (cb, _ms, ...args): unknown => { 370 callback = cb.bind(/*this*/ undefined, ...args); 371 return callback; 372 }; 373 watchCompilerHost.clearTimeout = (): void => { 374 callback = undefined; 375 }; 376 } 377 const watch = ts.createWatchProgram(watchCompilerHost); 378 if (!isRunningNoTimeoutFix) { 379 const originalGetProgram = watch.getProgram; 380 watch.getProgram = (): ts.BuilderProgram => { 381 if (callback) { 382 callback(); 383 } 384 callback = undefined; 385 return originalGetProgram.call(watch); 386 }; 387 } 388 return watch; 389} 390 391function hasTSConfigChanged(tsconfigPath: CanonicalPath): boolean { 392 const stat = fs.statSync(tsconfigPath); 393 const lastModifiedAt = stat.mtimeMs; 394 const cachedLastModifiedAt = tsconfigLastModifiedTimestampCache.get( 395 tsconfigPath, 396 ); 397 398 tsconfigLastModifiedTimestampCache.set(tsconfigPath, lastModifiedAt); 399 400 if (cachedLastModifiedAt === undefined) { 401 return false; 402 } 403 404 return Math.abs(cachedLastModifiedAt - lastModifiedAt) > Number.EPSILON; 405} 406 407function maybeInvalidateProgram( 408 existingWatch: ts.WatchOfConfigFile<ts.BuilderProgram>, 409 filePath: CanonicalPath, 410 tsconfigPath: CanonicalPath, 411): ts.Program | null { 412 /* 413 * By calling watchProgram.getProgram(), it will trigger a resync of the program based on 414 * whatever new file content we've given it from our input. 415 */ 416 let updatedProgram = existingWatch.getProgram().getProgram(); 417 418 // In case this change causes problems in larger real world codebases 419 // Provide an escape hatch so people don't _have_ to revert to an older version 420 if (process.env.TSESTREE_NO_INVALIDATION === 'true') { 421 return updatedProgram; 422 } 423 424 if (hasTSConfigChanged(tsconfigPath)) { 425 /* 426 * If the stat of the tsconfig has changed, that could mean the include/exclude/files lists has changed 427 * We need to make sure typescript knows this so it can update appropriately 428 */ 429 log('tsconfig has changed - triggering program update. %s', tsconfigPath); 430 fileWatchCallbackTrackingMap 431 .get(tsconfigPath)! 432 .forEach(cb => cb(tsconfigPath, ts.FileWatcherEventKind.Changed)); 433 434 // tsconfig change means that the file list more than likely changed, so clear the cache 435 programFileListCache.delete(tsconfigPath); 436 } 437 438 let sourceFile = updatedProgram.getSourceFile(filePath); 439 if (sourceFile) { 440 return updatedProgram; 441 } 442 /* 443 * Missing source file means our program's folder structure might be out of date. 444 * So we need to tell typescript it needs to update the correct folder. 445 */ 446 log('File was not found in program - triggering folder update. %s', filePath); 447 448 // Find the correct directory callback by climbing the folder tree 449 const currentDir = canonicalDirname(filePath); 450 let current: CanonicalPath | null = null; 451 let next = currentDir; 452 let hasCallback = false; 453 while (current !== next) { 454 current = next; 455 const folderWatchCallbacks = folderWatchCallbackTrackingMap.get(current); 456 if (folderWatchCallbacks) { 457 folderWatchCallbacks.forEach(cb => { 458 if (currentDir !== current) { 459 cb(currentDir, ts.FileWatcherEventKind.Changed); 460 } 461 cb(current!, ts.FileWatcherEventKind.Changed); 462 }); 463 hasCallback = true; 464 } 465 466 next = canonicalDirname(current); 467 } 468 if (!hasCallback) { 469 /* 470 * No callback means the paths don't matchup - so no point returning any program 471 * this will signal to the caller to skip this program 472 */ 473 log('No callback found for file, not part of this program. %s', filePath); 474 return null; 475 } 476 477 // directory update means that the file list more than likely changed, so clear the cache 478 programFileListCache.delete(tsconfigPath); 479 480 // force the immediate resync 481 updatedProgram = existingWatch.getProgram().getProgram(); 482 sourceFile = updatedProgram.getSourceFile(filePath); 483 if (sourceFile) { 484 return updatedProgram; 485 } 486 487 /* 488 * At this point we're in one of two states: 489 * - The file isn't supposed to be in this program due to exclusions 490 * - The file is new, and was renamed from an old, included filename 491 * 492 * For the latter case, we need to tell typescript that the old filename is now deleted 493 */ 494 log( 495 'File was still not found in program after directory update - checking file deletions. %s', 496 filePath, 497 ); 498 499 const rootFilenames = updatedProgram.getRootFileNames(); 500 // use find because we only need to "delete" one file to cause typescript to do a full resync 501 const deletedFile = rootFilenames.find(file => !fs.existsSync(file)); 502 if (!deletedFile) { 503 // There are no deleted files, so it must be the former case of the file not belonging to this program 504 return null; 505 } 506 507 const fileWatchCallbacks = fileWatchCallbackTrackingMap.get( 508 getCanonicalFileName(deletedFile), 509 ); 510 if (!fileWatchCallbacks) { 511 // shouldn't happen, but just in case 512 log('Could not find watch callbacks for root file. %s', deletedFile); 513 return updatedProgram; 514 } 515 516 log('Marking file as deleted. %s', deletedFile); 517 fileWatchCallbacks.forEach(cb => 518 cb(deletedFile, ts.FileWatcherEventKind.Deleted), 519 ); 520 521 // deleted files means that the file list _has_ changed, so clear the cache 522 programFileListCache.delete(tsconfigPath); 523 524 updatedProgram = existingWatch.getProgram().getProgram(); 525 sourceFile = updatedProgram.getSourceFile(filePath); 526 if (sourceFile) { 527 return updatedProgram; 528 } 529 530 log( 531 'File was still not found in program after deletion check, assuming it is not part of this program. %s', 532 filePath, 533 ); 534 return null; 535} 536 537export { clearCaches, createWatchProgram, getProgramsForProjects }; 538