diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index a4b991deb2a..56704c64784 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -1831,7 +1831,8 @@ namespace ts.server { if (this.serverMode !== LanguageServiceMode.Semantic) { return undefined; } - + // TODO: My code leaves deleted files as: the scriptinfo doesn't list a containing project, but the contianing ProjectService.openFiles still has the file + // OR maybe vice versa Debug.assert(!isOpenScriptInfo(info) || this.openFiles.has(info.path)); const projectRootPath = this.openFiles.get(info.path); const scriptInfo = Debug.checkDefined(this.getScriptInfo(info.path)); @@ -3691,11 +3692,6 @@ namespace ts.server { // TODO: Maybe it is somehow gauche or verboten to use protocol types but the translation in applyChangesInOpenFiles seems stupid // It is also WEIRD that we use iterators here not arrays // 1. set some internal tsserver state for mocked FS (if it hasn't already been set, this might not be the first message) - // - change this.host at least - this.host // no, it's readonly, we get this from a parent object - // - ScriptInfo instances might need to update their host -- what is textStorage? - // - they deffo have FileWatcher instances - // - TextStorage.host needs to update if (!this.fs) { this.fs = new vfs.FileSystem(/*ignoreCase*/ true, { files: { @@ -3704,9 +3700,13 @@ namespace ts.server { cwd: "/", // maybe not needed meta: { } // probably not needed }) + // -nervous laugh- ;(this as any).host = new fakes.FakeServerHost(this.fs, { executingFilePath: "TEST" }) ;(this.session as any).host = this.host } + // 2. update vfs + // I THINK that only vfs needs to update, because none of these files should be open. + // (I guess files could update from the filesystem while they are still open, but that's something to solve at the end of prototyping I think) if (createdFiles) { let it while (!(it = createdFiles.next()).done) { @@ -3728,10 +3728,8 @@ namespace ts.server { this.fs.apply(fileset) } if (deletedFiles) { - // TODO: - closeClientFile -> closeOpenFile -> ScriptInfo.close - // (maybe -- it may be enough to just delete it from the filesystem) + // these files should already be closed, so I THINK deleting it is enough for (const file of deletedFiles) { - this.closeClientFile(file) // maybe copy stuff from applyChanges -- mostly I just want to leave a pointer to more code this.fs.rimrafSync(file) } } diff --git a/src/testRunner/unittests/tsserver/applyChangesToOpenFiles.ts b/src/testRunner/unittests/tsserver/applyChangesToOpenFiles.ts index 1cb0d634729..af1d65a5876 100644 --- a/src/testRunner/unittests/tsserver/applyChangesToOpenFiles.ts +++ b/src/testRunner/unittests/tsserver/applyChangesToOpenFiles.ts @@ -40,22 +40,35 @@ interface Array { length: number; [n: number]: T; }` return `// some copy right notice ${'content' in file ? file.content : file.fileContent}`; } - it("with updateOpen request", () => { + function verifyFileSystem(fs: vfs.FileSystem | undefined, files: protocol.FileSystemRequestArgs[]) { + // 1. make sure that everything in files is there + assert.isDefined(fs) + const result = fs!.scanSync('.', "descendants-or-self", { + accept: (_, stats) => stats.isFile() + }) + assert.equal(result.length, files.length) + let i = 0 + for (const { file, fileContent } of files) { + assert.equal(result[i], file) + assert.equal(fs!.readFileSync(file, 'utf8'), fileContent) + i++ + } + // 2. make sure nothing else is + } + it("with updateFileSystem request", () => { // 1. Create a server host with no files, then updateFS and make sure everything works as before // Things still to test - // 2. Send another updateFS request and assert that the vfs content changes - // 3. Send another updateFS request and assert that the internal reported content changes (such as projectversion) - // 4. Send a close message (or whatever will write a file?) and make sure that the vfs state is updated // after file watchers are implemented: // 5. send updateFS request with a create/update/delete of a watched file, assert that file watchers fired // 6. probably some other watcher tests, not sure what const host = createServerHost([]); // old path goes into virtualFileSystemWithWatch.ts, so I guess it's getting the old host, not the replaced one const session = createSession(host); + const created = [app, file1, file2, file3, config, lib] session.executeCommandSeq({ command: protocol.CommandTypes.UpdateFileSystem, arguments:{ fileSystem: 'memfs', - created: [app, file3, file1, file2, lib, config], + created, deleted: [], // string[]; updated: [], //FileSystemRequestArgs[]; } @@ -64,11 +77,15 @@ ${'content' in file ? file.content : file.fileContent}`; command: protocol.CommandTypes.Open, arguments: { file: app.file } }); - const service = session.getProjectService(); + const service = session.getProjectService(); // session -> service -> project const project = service.configuredProjects.get(config.file)!; const vfs = (session as any).host.vfs + const fakehost = (session as any).host as fakes.FakeServerHost assert.isDefined(vfs); assert.isDefined(project); + assert.equal(fakehost.fsWatches.size, 0) + assert.equal(fakehost.fsWatchesRecursive.size, 0) + assert.equal(fakehost.watchedFiles.size, 0) verifyProjectVersion(project, 1); session.executeCommandSeq({ command: protocol.CommandTypes.Open, @@ -80,10 +97,14 @@ ${'content' in file ? file.content : file.fileContent}`; verifyProjectVersion(project, 2); // Verify Texts + verifyFileSystem(service.fs, created) verifyText(service, file1.file, file1.fileContent!); verifyText(service, commonFile2.path, commonFile2.content); verifyText(service, app.file, app.fileContent!); verifyText(service, file3.file, fileContentWithComment(file3)); + assert.equal(fakehost.fsWatches.size, 0) + assert.equal(fakehost.fsWatchesRecursive.size, 0) + assert.equal(fakehost.watchedFiles.size, 0) session.executeCommandSeq({ command: protocol.CommandTypes.UpdateFileSystem, @@ -98,10 +119,14 @@ ${'content' in file ? file.content : file.fileContent}`; verifyProjectVersion(project, 2); // Verify Texts + verifyFileSystem(service.fs, created) verifyText(service, file1.file, file1.fileContent!); verifyText(service, commonFile2.path, commonFile2.content); verifyText(service, app.file, app.fileContent!); verifyText(service, file3.file, fileContentWithComment(file3)); + assert.equal(fakehost.fsWatches.size, 0) + assert.equal(fakehost.fsWatchesRecursive.size, 0) + assert.equal(fakehost.watchedFiles.size, 0) session.executeCommandSeq({ command: protocol.CommandTypes.UpdateFileSystem, @@ -112,14 +137,36 @@ ${'content' in file ? file.content : file.fileContent}`; updated: [], //FileSystemRequestArgs[]; } }); - verifyProjectVersion(project, 3); + // also no change when deleting a file (changes session version but not project version?) + verifyProjectVersion(project, 2); // Verify Texts + verifyFileSystem(service.fs, [app, file2, file3, config, lib]) verifyText(service, file1.file, file1.fileContent!); verifyText(service, commonFile2.path, commonFile2.content); verifyText(service, app.file, app.fileContent!); verifyText(service, file3.file, fileContentWithComment(file3)); + assert.equal(fakehost.fsWatches.size, 0) + assert.equal(fakehost.fsWatchesRecursive.size, 0) + assert.equal(fakehost.watchedFiles.size, 0) + session.executeCommandSeq({ + command: protocol.CommandTypes.Close, + arguments: { file: app.file } + }); + + // also no change when closing a file??? (changes session version but not project version?) + verifyProjectVersion(project, 2); + + // Verify Texts + verifyFileSystem(service.fs, [app, file2, file3, config, lib]) + verifyText(service, file1.file, file1.fileContent!); + verifyText(service, commonFile2.path, commonFile2.content); + verifyText(service, app.file, app.fileContent!); + verifyText(service, file3.file, fileContentWithComment(file3)); + assert.equal(fakehost.fsWatches.size, 0) + assert.equal(fakehost.fsWatchesRecursive.size, 0) + assert.equal(fakehost.watchedFiles.size, 1) }); function verifyText(service: server.ProjectService, file: string, expected: string) { diff --git a/src/vfs/fakesHosts.ts b/src/vfs/fakesHosts.ts index 29063daaac8..c8b52fdd247 100644 --- a/src/vfs/fakesHosts.ts +++ b/src/vfs/fakesHosts.ts @@ -218,35 +218,167 @@ namespace fakes { * @implements {server.ServerHost} but that would create a circular dependency */ export class FakeServerHost extends System { - watchFile(/*path: string, callback: ts.FileWatcherCallback, pollingInterval?: number, options?: ts.WatchOptions*/): ts.FileWatcher { - throw new Error("Not implemented: Still need to steal implementation from virtualFileSystemWithWatch.ts") - return { - close() { - } - } + timeoutCallbacks = new Callbacks() + immediateCallbacks = new Callbacks() + readonly watchedFiles = ts.createMultiMap(); + readonly fsWatches = ts.createMultiMap(); + readonly fsWatchesRecursive = ts.createMultiMap(); + readonly currentDirectory: string // TODO: Needed? Probably not + readonly toPath: (f: string) => ts.Path; + watchFile: ts.HostWatchFile + watchDirectory: ts.HostWatchDirectory + constructor(vfs: vfs.FileSystem, options: SystemOptions = {}) { + super(vfs, options) + + this.currentDirectory = '/'; // THIS IS FINE + this.toPath = s => ts.toPath(s, this.currentDirectory, s => s); + + const { watchFile, watchDirectory } = ts.createSystemWatchFunctions({ + // We dont have polling watch file + // it is essentially fsWatch but lets get that separate from fsWatch and + // into watchedFiles for easier testing + pollingWatchFile: /*tscWatchFile === Tsc_WatchFile.SingleFileWatcherPerName ? + createSingleFileWatcherPerName( + this.watchFileWorker.bind(this), + this.useCaseSensitiveFileNames + ) :*/ + this.watchFileWorker.bind(this), + getModifiedTime: this.getModifiedTime.bind(this), + setTimeout: this.setTimeout.bind(this), + clearTimeout: this.clearTimeout.bind(this), + fsWatch: this.fsWatch.bind(this), + fileExists: this.fileExists.bind(this), + useCaseSensitiveFileNames: this.useCaseSensitiveFileNames, + getCurrentDirectory: this.getCurrentDirectory.bind(this), + // TODO: Tests usually set "run without recursive watches" to true, except for two tests + // watchOptions/with excludeFiles option${...} + // and the same, but excludeDirectories + // .............guessing that it's true on a real FS + fsSupportsRecursiveFsWatch: true, /*tscWatchDirectory ? false : !runWithoutRecursiveWatches */ + directoryExists: this.directoryExists.bind(this), + getAccessibleSortedChildDirectories: path => this.getDirectories(path), + realpath: this.realpath.bind(this), + tscWatchFile: undefined, + tscWatchDirectory: undefined, + defaultWatchFileKind: () => undefined, // () => this.defaultWatchFileKind?.(), + }) + this.watchFile = watchFile + this.watchDirectory = watchDirectory } - watchDirectory(/*path: string, callback: ts.DirectoryWatcherCallback, recursive?: boolean, options?: ts.WatchOptions*/): ts.FileWatcher { - throw new Error("Not implemented: Still need to steal implementation from virtualFileSystemWithWatch.ts") - return { - close() { - } - } - } - setTimeout(callback: (...args: any[]) => void, ms: number, ...args: any[]): any { - // TODO: Probably want to do a fake thing, actually (or parametrised by the constructor) - return setTimeout(callback, ms, ...args) + setTimeout(callback: (...args: any[]) => void, _ms: number, ...args: any[]): any { + return this.timeoutCallbacks.register(callback, args) } clearTimeout(timeoutId: any): void { - clearTimeout(timeoutId) + this.timeoutCallbacks.unregister(timeoutId) } setImmediate(callback: (...args: any[]) => void, ...args: any[]): any { - return setImmediate(callback, ...args) + return this.immediateCallbacks.register(callback, args) } clearImmediate(timeoutId: any): void { - clearImmediate(timeoutId) + this.immediateCallbacks.unregister(timeoutId) + } + watchFileWorker(fileName: string, cb: ts.FileWatcherCallback, pollingInterval: ts.PollingInterval) { + console.log('watchfileworker') + return createWatcher( + this.watchedFiles, + this.toFullPath(fileName), + { fileName, cb, pollingInterval } + ); + } + + fsWatch( + fileOrDirectory: string, + _entryKind: ts.FileSystemEntryKind, + cb: ts.FsWatchCallback, + recursive: boolean, + fallbackPollingInterval: ts.PollingInterval, + fallbackOptions: ts.WatchOptions | undefined): ts.FileWatcher { + console.log('fswatch') + /*return this.runWithFallbackPolling ? + this.watchFile( + fileOrDirectory, + createFileWatcherCallback(cb), + fallbackPollingInterval, + fallbackOptions + ) :*/ + return createWatcher( + recursive ? this.fsWatchesRecursive : this.fsWatches, + this.toFullPath(fileOrDirectory), + { + directoryName: fileOrDirectory, + cb, + fallbackPollingInterval, + fallbackOptions + } + ); + } + toFullPath(s: string) { + return this.toPath(this.toNormalizedAbsolutePath(s)); + } + toNormalizedAbsolutePath(s: string) { + return ts.getNormalizedAbsolutePath(s, this.currentDirectory); + } + } + function createWatcher(map: ts.MultiMap, path: ts.Path, callback: T): ts.FileWatcher { + map.add(path, callback); + return { close: () => map.remove(path, callback) }; + } + /** Copied from virtualFileSystemWithWatch */ + type TimeOutCallback = () => any; + /** Copied from virtualFileSystemWithWatch */ + interface TestFileWatcher { + cb: ts.FileWatcherCallback; + fileName: string; + pollingInterval: ts.PollingInterval; + } + /** Copied from virtualFileSystemWithWatch */ + interface TestFsWatcher { + cb: ts.FsWatchCallback; + directoryName: string; + fallbackPollingInterval: ts.PollingInterval; + fallbackOptions: ts.WatchOptions | undefined; + } + /** Copied from virtualFileSystemWithWatch */ + class Callbacks { + private map: TimeOutCallback[] = []; + private nextId = 1; + getNextId() { + return this.nextId; + } + register(cb: (...args: any[]) => void, args: any[]) { + const timeoutId = this.nextId; + this.nextId++; + this.map[timeoutId] = cb.bind(/*this*/ undefined, ...args); + return timeoutId; + } + unregister(id: any) { + if (typeof id === "number") { + delete this.map[id]; + } + } + count() { + // ?????????????????????????????????????????????????????????????????? + let n = 0; + for (const _ in this.map) { + n++; + } + return n; + } + invoke(invokeKey?: number) { + if (invokeKey) { + this.map[invokeKey](); + delete this.map[invokeKey]; + return; + } + // Note: invoking a callback may result in new callbacks been queued, + // so do not clear the entire callback list regardless. Only remove the + // ones we have invoked. + for (const key in this.map) { + this.map[key](); + delete this.map[key]; + } } } - /** * A fake `ts.CompilerHost` that leverages a virtual file system. */