Watch for the creation of missing files#16684
Conversation
|
@amcasey, |
|
This PR supersedes #16527. |
| /** | ||
| * Get a list of file names that were passed to 'createProgram' or referenced in a | ||
| * program source file but could not be located. | ||
| */ |
There was a problem hiding this comment.
This doesn't need to be optional.
| // will be created until we encounter a change that prevents complete structure reuse. | ||
| // During this interval, creation of the file will go unnoticed. We expect this to be | ||
| // both rare and low-impact. | ||
| const missingFilePaths: Path[] = oldProgram.getMissingFilePaths() || emptyArray; |
There was a problem hiding this comment.
It looks like getMissingFilePaths already never returns undefined. (Let's keep it that way.) Inline this declaration.
| } | ||
|
|
||
| for (const p of oldProgram.getMissingFilePaths()) { | ||
| filesByName.set(p, undefined); |
There was a problem hiding this comment.
The type of filesByName should indicate that this can be undefined.
There was a problem hiding this comment.
I'm happy to update the type, but I should mention that the map already had undefined values (this change just ensures that they persist across structure reuse).
There was a problem hiding this comment.
To confirm, you're suggesting const filesByName = createFileMap<SourceFile | undefined>(); ?
There was a problem hiding this comment.
That doesn't appear to have any effect on the inferred type. Presumably, it would matter with strict null-checking enabled?
| @@ -340,11 +340,20 @@ namespace ts { | |||
| } | |||
|
|
|||
| function fileChanged(curr: any, prev: any) { | |||
There was a problem hiding this comment.
It was this way before, but it would be nice to remove any and include a type declaration saying what you expect this to be.
There was a problem hiding this comment.
I would be interested to learn how to do this. Do I just specify that it has to have an mtime property or is there something more?
There was a problem hiding this comment.
After getting offline help, I don't think this is worth fixing as part of this change.
| const missingPaths = compileResult.program.getMissingFilePaths() || []; | ||
| missingPaths.forEach((path: Path): void => { | ||
| const fileWatcher = sys.watchFile(path, (_fileName: string, removed?: boolean) => { | ||
| // removed = deleted ? true : (added ? false : undefined) |
There was a problem hiding this comment.
This could use an enum rather than true | false | undefined. true -> E.Deleted, false -> E.Added, undefined -> E.Changed.
There was a problem hiding this comment.
I was under the impression that we didn't control the signature, but I'll confirm.
| /** | ||
| * Get a list of file names that were passed to 'createProgram' or referenced in a | ||
| * program source file but could not be located. | ||
| */ |
There was a problem hiding this comment.
Would make this internal. (/* @internal */)
| const referenceFile: Harness.Compiler.TestFile = { | ||
| unitName: referenceFileName, | ||
| content: | ||
| "/// <reference path=\"d:/imaginary/nonexistent1.ts\"/>\n" + // Absolute |
There was a problem hiding this comment.
Is it necessary to test all the different kinds of paths here? Also, would include a reference to an existing file for contrast.
There was a problem hiding this comment.
I believe I have a test that mixes existent and non-existent paths, but I'll confirm.
There was a problem hiding this comment.
I do have such a test, but it is for root files. I thought that duplicating all of the tests for triple-slash references would be redundant (and add to maintenance and runtime costs), so I skipped them. Let me know if you feel strongly and I'll make a second set of tests.
| it("handles missing root file", () => { | ||
| const program = createProgram(["./nonexistent.ts"], options, testCompilerHost); | ||
| const missing = program.getMissingFilePaths(); | ||
| assert.isDefined(missing); |
There was a problem hiding this comment.
Use assert.deepEqual instead of asserting the length and elements separately.
There was a problem hiding this comment.
No need for assert.isDefined since you're about to assert that it's equal to a defined value.
| private rootFilesMap: FileMap<ScriptInfo> = createFileMap<ScriptInfo>(); | ||
| private program: ts.Program; | ||
| private externalFiles: SortedReadonlyArray<string>; | ||
| private missingFilesMap: FileMap<FileWatcher> = createFileMap<FileWatcher>(); |
There was a problem hiding this comment.
Don't need a FileMap, just a Map. Then you can iterate using forEach. (#16715)
There was a problem hiding this comment.
Don't I need a FileMap if the key type is Path?
There was a problem hiding this comment.
A Path is a subtype of string, so you can use those as map keys. (It might be slightly more type safe to specify that keys must be Paths, but I don't think that's worth using a FileMap.)
There was a problem hiding this comment.
I don't understand when we use Path and FileMap. Do you have some time to explain?
There was a problem hiding this comment.
Also, I've made the change.
| // Clean up file watchers waiting for missing files | ||
| for (const p of this.missingFilesMap.getKeys()) { | ||
| this.missingFilesMap.get(p).close(); | ||
| this.missingFilesMap.remove(p); |
There was a problem hiding this comment.
What's the point of removing items one-by-one if you just set this.missingFileMap = undefined; at the end?
There was a problem hiding this comment.
Good point. I cleared the map as an afterthought.
| } | ||
| } | ||
|
|
||
| if (hasChanges) { |
There was a problem hiding this comment.
It was this way before, but I would prefer const hasChanges = ...; over `let hasChanges = false; if (...) { hasChanges = true; }
| } | ||
|
|
||
| if (hasChanges) { | ||
| const missingFilePaths = this.program.getMissingFilePaths() || emptyArray; |
There was a problem hiding this comment.
Again, getMissingFilePaths should always be defined, so no need for || emptyArray.
|
|
||
| if (hasChanges) { | ||
| const missingFilePaths = this.program.getMissingFilePaths() || emptyArray; | ||
| const missingFilePathsSet = createMap<true>(); |
There was a problem hiding this comment.
arrayToMap(missingFilePaths, path => path);
There was a problem hiding this comment.
Does that have an advantage other than brevity?
There was a problem hiding this comment.
It seems to result in a String-Path map, rather than a Path-true map.
There was a problem hiding this comment.
There should probably be a helper for this:
export function arrayToSet(array: string[]): Map<boolean> {
return arrayToMap(array, key => key, () => true);
}| export function getDocumentHighlights(program: Program, cancellationToken: CancellationToken, sourceFile: SourceFile, position: number, sourceFilesToSearch: SourceFile[]): DocumentHighlights[] | undefined { | ||
| const node = getTouchingWord(sourceFile, position, /*includeJsDocComment*/ true); | ||
| if (!node) return undefined; | ||
| // Note that getTouchingWord indicates failure by returning the sourceFile node. |
There was a problem hiding this comment.
It's a separate commit, so it would be no extra work to make it a separate PR. What's the local convention?
There was a problem hiding this comment.
I didn't find strong support for pulling it out. If it were more contentious, I would.
There was a problem hiding this comment.
Was there a test that was breaking under the old behavior?
There was a problem hiding this comment.
No, otherwise the code would have been correct. 😉
| }); | ||
|
|
||
| // Missing files that are not yet watched should be added to the map. | ||
| missingFilePaths.forEach(p => { |
There was a problem hiding this comment.
Use a memorable variable name for p, like missingPath.
There was a problem hiding this comment.
I'm not in the habit of giving detailed names to iteration variables, but sure.
|
@Andy-MS I think I've addressed all of your feedback, other than the ones marked with ❓. |
| * | ||
| * @param array the array of input elements. | ||
| * | ||
| * This function makes no effort to avoid collisions; if any two elements produce |
There was a problem hiding this comment.
This is a Map<true>, so the elements of the array aren't present in the result. So the element with the higher index in the array will be the one associated with the produced key can't be true.
| // Files that are no longer missing (e.g. because they are no longer required) | ||
| // should no longer be watched. | ||
| for (const missingFilePath of this.missingFilesMap.getKeys()) | ||
| const missingFilesToDelete: string[] = []; |
There was a problem hiding this comment.
It should be safe to delete values from a map while iterating over it. Are you sure this was the cause of an error?
There was a problem hiding this comment.
No, I just couldn't find any documentation indicating that it was safe.
| } | ||
| } | ||
|
|
||
| const missingFilePaths = filesByName.getKeys().filter(p => !filesByName.get(p)); |
There was a problem hiding this comment.
This will have a semantic conflict with #16724 as filesByName will be a Map, which doesn't have a getKeys(). You can use mapDefined<[string, string], string>(arrayFrom(m.entries()), ([path, value]) => value === undefined ? undefined : path);. Or add a version of mapDefined that works on iterators to avoid the arrayFrom.
There was a problem hiding this comment.
I'll rebase on top of your commit.
| // will be created until we encounter a change that prevents complete structure reuse. | ||
| // During this interval, creation of the file will go unnoticed. We expect this to be | ||
| // both rare and low-impact. | ||
| for (const missingFilePath of oldProgram.getMissingFilePaths()) { |
There was a problem hiding this comment.
Nit: just use oldProgram.getMissingFilePaths().some(path => host.fileExists(path))
| const created = !isCurrZero && isPrevZero; | ||
| const deleted = isCurrZero && !isPrevZero; | ||
|
|
||
| const eventKind = created |
There was a problem hiding this comment.
Nit: No need for created and deleted variables, just use eventKind === FileWatcherEventKind.Changed instead of !created && !deleted
There was a problem hiding this comment.
I think the existence of the constants makes the code more readable, but I agree that the if is better expressed in terms of eventKind.
| reportWatchDiagnostic(createCompilerDiagnostic(Diagnostics.Compilation_complete_Watching_for_file_changes)); | ||
|
|
||
| const missingPaths = compileResult.program.getMissingFilePaths(); | ||
| missingPaths.forEach((path: Path): void => { |
There was a problem hiding this comment.
Nit: Avoid annotating the types of callbacks if not necessary.
| return; | ||
| } | ||
|
|
||
| // removed = deleted ? true : (added ? false : undefined) |
`getTouchingWord` indicates failure by returning the sourceFile node, rather than `undefined`.
1. Expose missing files from the `Program`. 2. In `tsc --watch` and `tsserver`, add file watchers to missing files. 3. When missing files are created, schedule compilation (tsc) or refresh the containing projects (tsserver).
Call `this.projectService.host.watchFile`, rather than `ts.sys.watchFile` so that it gets mocked correctly in the unit tests. Repair two failing tests.
1. Test `Program.getMissingFilePaths` 2. Test program structure reuse (i.e. that the appearance of a missing file prevents complete reuse)
Make Program.getMissingFilePaths required Assume getMissingFilePaths always returns a defined value Make getMissingFilePaths internal Replace nullable-bool with enum Update type to reflect possibility of undefined Use deepEqual to simplify tests Make condition const Don't bother cleaning up map before freeing it Switch from foreach to for-of to simplify debugging Use a Map, rather than a FileMap, to track open FileWatchers Fix compilation errors Introduce and consume arrayToSet Fix lint warnings about misplaced braces Delete incorrect comment Delete from map during iteration Eliminate unnecessary type annotations
| // Missing files that are not yet watched should be added to the map. | ||
| for (const missingFilePath of missingFilePaths) { | ||
| if (!this.missingFilesMap.has(missingFilePath)) { | ||
| const fileWatcher = this.projectService.host.watchFile(missingFilePath, (_filename: string, eventKind: FileWatcherEventKind) => { |
There was a problem hiding this comment.
host.watchFile is actually optional. We could make it non-optional and mark it as a breaking change. See also #16776.
| * | ||
| * @param array the array of input elements. | ||
| */ | ||
| export function arrayToSet<T>(array: T[], makeKey: (value: T) => string): Map<true> { |
There was a problem hiding this comment.
This is used exactly ONCE. Is it worth extracting like that -- from performance, code clarity or any other perspective?
There was a problem hiding this comment.
It's generally useful to factor generic code out of a more specific context. That way you don't have to worry about the specifics of how an array is converted to a set, and only have to worry about the actual problem domain.
When we discover that a file is missing, call
watchFileon its path so that we can update our state when it is created.tsc --watch, we schedule a new buildtsserver, we update the containing projectsCaveat: VS will not know about the state change in tsserver until the next time it synchronizes its project list