• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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