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