From 050e058723ec9248ae8b85713bc46f348a18770e Mon Sep 17 00:00:00 2001 From: Nathan Shively-Sanders <293473+sandersn@users.noreply.github.com> Date: Fri, 25 Mar 2022 09:50:02 -0700 Subject: [PATCH] Switch to virtualFileSystemWithWatch, move other files back Doesn't quite build yet, uploading so I can use github to view the diff. --- src/{vfs => harness}/collectionsImpl.ts | 0 src/{vfs => harness}/documentsUtil.ts | 0 src/harness/fakesHosts.ts | 578 ++++++++- src/harness/tsconfig.json | 4 + src/harness/util.ts | 20 + src/{vfs => harness}/vfsUtil.ts | 0 src/harness/virtualFileSystemWithWatch.ts | 1431 +++------------------ src/{vfs => harness}/vpathUtil.ts | 0 src/server/editorServices.ts | 34 +- src/vfs/fakesHosts.ts | 643 --------- src/vfs/tsconfig.json | 7 +- src/vfs/util.ts | 24 - src/vfs/virtualFileSystemWithWatch.ts | 1096 ++++++++++++++++ 13 files changed, 1888 insertions(+), 1949 deletions(-) rename src/{vfs => harness}/collectionsImpl.ts (100%) rename src/{vfs => harness}/documentsUtil.ts (100%) rename src/{vfs => harness}/vfsUtil.ts (100%) rename src/{vfs => harness}/vpathUtil.ts (100%) delete mode 100644 src/vfs/fakesHosts.ts delete mode 100644 src/vfs/util.ts create mode 100644 src/vfs/virtualFileSystemWithWatch.ts diff --git a/src/vfs/collectionsImpl.ts b/src/harness/collectionsImpl.ts similarity index 100% rename from src/vfs/collectionsImpl.ts rename to src/harness/collectionsImpl.ts diff --git a/src/vfs/documentsUtil.ts b/src/harness/documentsUtil.ts similarity index 100% rename from src/vfs/documentsUtil.ts rename to src/harness/documentsUtil.ts diff --git a/src/harness/fakesHosts.ts b/src/harness/fakesHosts.ts index 846dc1241f1..201d11a81c1 100644 --- a/src/harness/fakesHosts.ts +++ b/src/harness/fakesHosts.ts @@ -1,6 +1,492 @@ namespace fakes { - export class CompilerHost extends FakeCompilerHost { + const processExitSentinel = new Error("System exit"); + + export interface SystemOptions { + executingFilePath?: string; + newLine?: "\r\n" | "\n"; + env?: Record; + } + + /** + * A fake `ts.System` that leverages a virtual file system. + */ + export class System implements ts.System { + public readonly vfs: vfs.FileSystem; + public readonly args: string[] = []; + public readonly output: string[] = []; + public readonly newLine: string; + public readonly useCaseSensitiveFileNames: boolean; + public exitCode: number | undefined; + + private readonly _executingFilePath: string | undefined; + private readonly _env: Record | undefined; + + constructor(vfs: vfs.FileSystem, { executingFilePath, newLine = "\r\n", env }: SystemOptions = {}) { + this.vfs = vfs.isReadonly ? vfs.shadow() : vfs; + this.useCaseSensitiveFileNames = !this.vfs.ignoreCase; + this.newLine = newLine; + this._executingFilePath = executingFilePath; + this._env = env; + } + + private testTerminalWidth = Number.parseInt(this.getEnvironmentVariable("TS_TEST_TERMINAL_WIDTH")); + getWidthOfTerminal = Number.isNaN(this.testTerminalWidth) ? undefined : () => this.testTerminalWidth; + + // Pretty output + writeOutputIsTTY() { + return true; + } + + public write(message: string) { + this.output.push(message); + } + + public readFile(path: string) { + try { + const content = this.vfs.readFileSync(path, "utf8"); + return content === undefined ? undefined : Utils.removeByteOrderMark(content); + } + catch { + return undefined; + } + } + + public writeFile(path: string, data: string, writeByteOrderMark?: boolean): void { + this.vfs.mkdirpSync(vpath.dirname(path)); + this.vfs.writeFileSync(path, writeByteOrderMark ? Utils.addUTF8ByteOrderMark(data) : data); + } + + public deleteFile(path: string) { + this.vfs.unlinkSync(path); + } + + public fileExists(path: string) { + const stats = this._getStats(path); + return stats ? stats.isFile() : false; + } + + public directoryExists(path: string) { + const stats = this._getStats(path); + return stats ? stats.isDirectory() : false; + } + + public createDirectory(path: string): void { + this.vfs.mkdirpSync(path); + } + + public getCurrentDirectory() { + return this.vfs.cwd(); + } + + public getDirectories(path: string) { + const result: string[] = []; + try { + for (const file of this.vfs.readdirSync(path)) { + if (this.vfs.statSync(vpath.combine(path, file)).isDirectory()) { + result.push(file); + } + } + } + catch { /*ignore*/ } + return result; + } + + public readDirectory(path: string, extensions?: readonly string[], exclude?: readonly string[], include?: readonly string[], depth?: number): string[] { + return ts.matchFiles(path, extensions, exclude, include, this.useCaseSensitiveFileNames, this.getCurrentDirectory(), depth, path => this.getAccessibleFileSystemEntries(path), path => this.realpath(path)); + } + + public getAccessibleFileSystemEntries(path: string): ts.FileSystemEntries { + const files: string[] = []; + const directories: string[] = []; + try { + for (const file of this.vfs.readdirSync(path)) { + try { + const stats = this.vfs.statSync(vpath.combine(path, file)); + if (stats.isFile()) { + files.push(file); + } + else if (stats.isDirectory()) { + directories.push(file); + } + } + catch { /*ignored*/ } + } + } + catch { /*ignored*/ } + return { files, directories }; + } + + public exit(exitCode?: number) { + this.exitCode = exitCode; + throw processExitSentinel; + } + + public getFileSize(path: string) { + const stats = this._getStats(path); + return stats && stats.isFile() ? stats.size : 0; + } + + public resolvePath(path: string) { + return vpath.resolve(this.vfs.cwd(), path); + } + + public getExecutingFilePath() { + if (this._executingFilePath === undefined) return ts.notImplemented(); + return this._executingFilePath; + } + + public getModifiedTime(path: string) { + const stats = this._getStats(path); + return stats ? stats.mtime : undefined!; // TODO: GH#18217 + } + + public setModifiedTime(path: string, time: Date) { + this.vfs.utimesSync(path, time, time); + } + + public createHash(data: string): string { + return `${ts.generateDjb2Hash(data)}-${data}`; + } + + public realpath(path: string) { + try { + return this.vfs.realpathSync(path); + } + catch { + return path; + } + } + + public getEnvironmentVariable(name: string): string { + return (this._env && this._env[name])!; // TODO: GH#18217 + } + + private _getStats(path: string) { + try { + return this.vfs.existsSync(path) ? this.vfs.statSync(path) : undefined; + } + catch { + return undefined; + } + } + + now() { + return new Date(this.vfs.time()); + } + } + + /** + * A fake `ts.ParseConfigHost` that leverages a virtual file system. + */ + export class ParseConfigHost implements ts.ParseConfigHost { + public readonly sys: System; + + constructor(sys: System | vfs.FileSystem) { + if (sys instanceof vfs.FileSystem) sys = new System(sys); + this.sys = sys; + } + + public get vfs() { + return this.sys.vfs; + } + + public get useCaseSensitiveFileNames() { + return this.sys.useCaseSensitiveFileNames; + } + + public fileExists(fileName: string): boolean { + return this.sys.fileExists(fileName); + } + + public directoryExists(directoryName: string): boolean { + return this.sys.directoryExists(directoryName); + } + + public readFile(path: string): string | undefined { + return this.sys.readFile(path); + } + + public readDirectory(path: string, extensions: string[], excludes: string[], includes: string[], depth: number): string[] { + return this.sys.readDirectory(path, extensions, excludes, includes, depth); + } + } + + /** + * @implements {server.ServerHost} but that would create a circular dependency + */ + export class FakeServerHost extends System { + 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 + } + setTimeout(callback: (...args: any[]) => void, _ms: number, ...args: any[]): any { + return this.timeoutCallbacks.register(callback, args) + } + clearTimeout(timeoutId: any): void { + this.timeoutCallbacks.unregister(timeoutId) + } + setImmediate(callback: (...args: any[]) => void, ...args: any[]): any { + return this.immediateCallbacks.register(callback, args) + } + clearImmediate(timeoutId: any): void { + 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. + */ + export class CompilerHost implements ts.CompilerHost { + public readonly sys: System; + public readonly defaultLibLocation: string; + public readonly outputs: documents.TextDocument[] = []; + private readonly _outputsMap: collections.SortedMap; + public readonly traces: string[] = []; public readonly shouldAssertInvariants = !Harness.lightMode; + protected _setParentNodes: boolean; + protected _sourceFiles: collections.SortedMap; + private _parseConfigHost: ParseConfigHost | undefined; + private _newLine: string; + + constructor(sys: System | vfs.FileSystem, options = ts.getDefaultCompilerOptions(), setParentNodes = false) { + if (sys instanceof vfs.FileSystem) sys = new System(sys); + this.sys = sys; + this.defaultLibLocation = sys.vfs.meta.get("defaultLibLocation") || ""; + this._newLine = ts.getNewLineCharacter(options, () => this.sys.newLine); + this._sourceFiles = new collections.SortedMap({ comparer: sys.vfs.stringComparer, sort: "insertion" }); + this._setParentNodes = setParentNodes; + this._outputsMap = new collections.SortedMap(this.vfs.stringComparer); + } + + public get vfs() { + return this.sys.vfs; + } + + public get parseConfigHost() { + return this._parseConfigHost || (this._parseConfigHost = new ParseConfigHost(this.sys)); + } + + public getCurrentDirectory(): string { + return this.sys.getCurrentDirectory(); + } + + public useCaseSensitiveFileNames(): boolean { + return this.sys.useCaseSensitiveFileNames; + } + + public getNewLine(): string { + return this._newLine; + } + + public getCanonicalFileName(fileName: string): string { + return this.sys.useCaseSensitiveFileNames ? fileName : fileName.toLowerCase(); + } + + public deleteFile(fileName: string) { + this.sys.deleteFile(fileName); + } + + public fileExists(fileName: string): boolean { + return this.sys.fileExists(fileName); + } + + public directoryExists(directoryName: string): boolean { + return this.sys.directoryExists(directoryName); + } + + public getModifiedTime(fileName: string) { + return this.sys.getModifiedTime(fileName); + } + + public setModifiedTime(fileName: string, time: Date) { + return this.sys.setModifiedTime(fileName, time); + } + + public getDirectories(path: string): string[] { + return this.sys.getDirectories(path); + } + + public readDirectory(path: string, extensions?: readonly string[], exclude?: readonly string[], include?: readonly string[], depth?: number): string[] { + return this.sys.readDirectory(path, extensions, exclude, include, depth); + } + + public readFile(path: string): string | undefined { + return this.sys.readFile(path); + } + + public writeFile(fileName: string, content: string, writeByteOrderMark: boolean) { + if (writeByteOrderMark) content = Utils.addUTF8ByteOrderMark(content); + this.sys.writeFile(fileName, content); + + const document = new documents.TextDocument(fileName, content); + document.meta.set("fileName", fileName); + this.vfs.filemeta(fileName).set("document", document); + if (!this._outputsMap.has(document.file)) { + this._outputsMap.set(document.file, this.outputs.length); + this.outputs.push(document); + } + this.outputs[this._outputsMap.get(document.file)!] = document; + } + + public trace(s: string): void { + this.traces.push(s); + } + + public realpath(path: string): string { + return this.sys.realpath(path); + } + + public getDefaultLibLocation(): string { + return vpath.resolve(this.getCurrentDirectory(), this.defaultLibLocation); + } + + public getDefaultLibFileName(options: ts.CompilerOptions): string { + return vpath.resolve(this.getDefaultLibLocation(), ts.getDefaultLibFileName(options)); + } + public getSourceFile(fileName: string, languageVersion: number): ts.SourceFile | undefined { const canonicalFileName = this.getCanonicalFileName(vpath.resolve(this.getCurrentDirectory(), fileName)); const existing = this._sourceFiles.get(canonicalFileName); @@ -27,7 +513,6 @@ namespace fakes { const parsed = ts.createSourceFile(fileName, content, languageVersion, this._setParentNodes || this.shouldAssertInvariants); if (this.shouldAssertInvariants) { - // TODO: Move this into a subclass Utils.assertInvariants(parsed, /*parent*/ undefined); } @@ -64,7 +549,37 @@ namespace fakes { } - function indentedText(indent: number, text: string) { + export type ExpectedDiagnosticMessage = [ts.DiagnosticMessage, ...(string | number)[]]; + export interface ExpectedDiagnosticMessageChain { + message: ExpectedDiagnosticMessage; + next?: ExpectedDiagnosticMessageChain[]; + } + + export interface ExpectedDiagnosticLocation { + file: string; + start: number; + length: number; + } + export interface ExpectedDiagnosticRelatedInformation extends ExpectedDiagnosticMessageChain { + location?: ExpectedDiagnosticLocation; + } + + export enum DiagnosticKind { + Error = "Error", + Status = "Status" + } + export interface ExpectedErrorDiagnostic extends ExpectedDiagnosticRelatedInformation { + relatedInformation?: ExpectedDiagnosticRelatedInformation[]; + } + + export type ExpectedDiagnostic = ExpectedDiagnosticMessage | ExpectedErrorDiagnostic; + + export interface SolutionBuilderDiagnostic { + kind: DiagnosticKind; + diagnostic: ts.Diagnostic; + } + + function indentedText(indent: number, text: string) { if (!indent) return text; let indentText = ""; for (let i = 0; i < indent; i++) { @@ -120,6 +635,34 @@ ${indentText}${text}`; return text; } + export const version = "FakeTSVersion"; + + export function patchHostForBuildInfoReadWrite(sys: T) { + const originalReadFile = sys.readFile; + sys.readFile = (path, encoding) => { + const value = originalReadFile.call(sys, path, encoding); + if (!value || !ts.isBuildInfoFile(path)) return value; + const buildInfo = ts.getBuildInfo(value); + ts.Debug.assert(buildInfo.version === version); + buildInfo.version = ts.version; + return ts.getBuildInfoText(buildInfo); + }; + const originalWrite = sys.write; + sys.write = msg => originalWrite.call(sys, msg.replace(ts.version, version)); + + if (sys.writeFile) { + const originalWriteFile = sys.writeFile; + sys.writeFile = (fileName: string, content: string, writeByteOrderMark: boolean) => { + if (!ts.isBuildInfoFile(fileName)) return originalWriteFile.call(sys, fileName, content, writeByteOrderMark); + const buildInfo = ts.getBuildInfo(content); + buildInfo.version = version; + originalWriteFile.call(sys, fileName, ts.getBuildInfoText(buildInfo), writeByteOrderMark); + }; + } + return sys; + } + + function expectedDiagnosticRelatedInformationToText({ location, ...diagnosticMessage }: ExpectedDiagnosticRelatedInformation) { const text = expectedDiagnosticMessageChainToText(diagnosticMessage); if (location) { @@ -146,13 +689,36 @@ ${indentText}${text}`; expectedErrorDiagnosticToText(errorOrStatus); } - export class SolutionBuilderHost extends FakeSolutionBuilderHost implements ts.SolutionBuilderHost { + export class SolutionBuilderHost extends CompilerHost implements ts.SolutionBuilderHost { + createProgram: ts.CreateProgram; + private constructor(sys: System | vfs.FileSystem, options?: ts.CompilerOptions, setParentNodes?: boolean, createProgram?: ts.CreateProgram) { + super(sys, options, setParentNodes); + this.createProgram = createProgram || ts.createEmitAndSemanticDiagnosticsBuilderProgram; + } static create(sys: System | vfs.FileSystem, options?: ts.CompilerOptions, setParentNodes?: boolean, createProgram?: ts.CreateProgram) { const host = new SolutionBuilderHost(sys, options, setParentNodes, createProgram); patchHostForBuildInfoReadWrite(host.sys); return host; } + createHash(data: string) { + return `${ts.generateDjb2Hash(data)}-${data}`; + } + + diagnostics: SolutionBuilderDiagnostic[] = []; + + reportDiagnostic(diagnostic: ts.Diagnostic) { + this.diagnostics.push({ kind: DiagnosticKind.Error, diagnostic }); + } + + reportSolutionBuilderStatus(diagnostic: ts.Diagnostic) { + this.diagnostics.push({ kind: DiagnosticKind.Status, diagnostic }); + } + + clearDiagnostics() { + this.diagnostics.length = 0; + } + assertDiagnosticMessages(...expectedDiagnostics: ExpectedDiagnostic[]) { const actual = this.diagnostics.slice().map(diagnosticToText); const expected = expectedDiagnostics.map(expectedDiagnosticToText); @@ -178,6 +744,8 @@ Actual All:: ${JSON.stringify(this.diagnostics.slice().map(diagnosticToText), /* } } - + now() { + return this.sys.now(); + } } } diff --git a/src/harness/tsconfig.json b/src/harness/tsconfig.json index 5299b72bd27..2dc98961071 100644 --- a/src/harness/tsconfig.json +++ b/src/harness/tsconfig.json @@ -20,7 +20,11 @@ ], "files": [ + "collectionsImpl.ts", "util.ts", + "documentsUtil.ts", + "vpathUtil.ts", + "vfsUtil.ts", "compilerImpl.ts", "evaluatorImpl.ts", "fakesHosts.ts", diff --git a/src/harness/util.ts b/src/harness/util.ts index 8dd5e46e8d9..56349e3352d 100644 --- a/src/harness/util.ts +++ b/src/harness/util.ts @@ -78,6 +78,26 @@ namespace Utils { return indentation; } + export function getByteOrderMarkLength(text: string): number { + if (text.length >= 1) { + const ch0 = text.charCodeAt(0); + if (ch0 === 0xfeff) return 1; + if (ch0 === 0xfe) return text.length >= 2 && text.charCodeAt(1) === 0xff ? 2 : 0; + if (ch0 === 0xff) return text.length >= 2 && text.charCodeAt(1) === 0xfe ? 2 : 0; + if (ch0 === 0xef) return text.length >= 3 && text.charCodeAt(1) === 0xbb && text.charCodeAt(2) === 0xbf ? 3 : 0; + } + return 0; + } + + export function removeByteOrderMark(text: string): string { + const length = getByteOrderMarkLength(text); + return length ? text.slice(length) : text; + } + + export function addUTF8ByteOrderMark(text: string) { + return getByteOrderMarkLength(text) === 0 ? "\u00EF\u00BB\u00BF" + text : text; + } + export function theory(name: string, cb: (...args: T) => void, data: T[]) { for (const entry of data) { it(`${name}(${entry.map(formatTheoryDatum).join(", ")})`, () => cb(...entry)); diff --git a/src/vfs/vfsUtil.ts b/src/harness/vfsUtil.ts similarity index 100% rename from src/vfs/vfsUtil.ts rename to src/harness/vfsUtil.ts diff --git a/src/harness/virtualFileSystemWithWatch.ts b/src/harness/virtualFileSystemWithWatch.ts index 62c6cd042ef..20e95bac1d5 100644 --- a/src/harness/virtualFileSystemWithWatch.ts +++ b/src/harness/virtualFileSystemWithWatch.ts @@ -1,1250 +1,181 @@ -namespace ts.TestFSWithWatch { - export const libFile: File = { - path: "/a/lib/lib.d.ts", - content: `/// -interface Boolean {} -interface Function {} -interface CallableFunction {} -interface NewableFunction {} -interface IArguments {} -interface Number { toExponential: any; } -interface Object {} -interface RegExp {} -interface String { charAt: any; } -interface Array { length: number; [n: number]: T; }` - }; - - export const safeList = { - path: "/safeList.json" as Path, - content: JSON.stringify({ - commander: "commander", - express: "express", - jquery: "jquery", - lodash: "lodash", - moment: "moment", - chroma: "chroma-js" - }) - }; - - function getExecutingFilePathFromLibFile(): string { - return combinePaths(getDirectoryPath(libFile.path), "tsc.js"); - } - - export interface TestServerHostCreationParameters { - useCaseSensitiveFileNames?: boolean; - executingFilePath?: string; - currentDirectory?: string; - newLine?: string; - windowsStyleRoot?: string; - environmentVariables?: ESMap; - runWithoutRecursiveWatches?: boolean; - runWithFallbackPolling?: boolean; - } - - export function createWatchedSystem(fileOrFolderList: readonly FileOrFolderOrSymLink[], params?: TestServerHostCreationParameters): TestServerHost { - return new TestServerHost(/*withSafelist*/ false, fileOrFolderList, params); - } - - export function createServerHost(fileOrFolderList: readonly FileOrFolderOrSymLink[], params?: TestServerHostCreationParameters): TestServerHost { - const host = new TestServerHost(/*withSafelist*/ true, fileOrFolderList, params); - // Just like sys, patch the host to use writeFile - patchWriteFileEnsuringDirectory(host); - return host; - } - - export interface File { - path: string; - content: string; - fileSize?: number; - } - - export interface Folder { - path: string; - } - - export interface SymLink { - /** Location of the symlink. */ - path: string; - /** Relative path to the real file. */ - symLink: string; - } - - export type FileOrFolderOrSymLink = File | Folder | SymLink; - export function isFile(fileOrFolderOrSymLink: FileOrFolderOrSymLink): fileOrFolderOrSymLink is File { - return isString((fileOrFolderOrSymLink as File).content); - } - - export function isSymLink(fileOrFolderOrSymLink: FileOrFolderOrSymLink): fileOrFolderOrSymLink is SymLink { - return isString((fileOrFolderOrSymLink as SymLink).symLink); - } - - interface FSEntryBase { - path: Path; - fullPath: string; - modifiedTime: Date; - } - - interface FsFile extends FSEntryBase { - content: string; - fileSize?: number; - } - - interface FsFolder extends FSEntryBase { - entries: SortedArray; - } - - interface FsSymLink extends FSEntryBase { - symLink: string; - } - - type FSEntry = FsFile | FsFolder | FsSymLink; - - function isFsFolder(s: FSEntry | undefined): s is FsFolder { - return !!s && isArray((s as FsFolder).entries); - } - - function isFsFile(s: FSEntry | undefined): s is FsFile { - return !!s && isString((s as FsFile).content); - } - - function isFsSymLink(s: FSEntry | undefined): s is FsSymLink { - return !!s && isString((s as FsSymLink).symLink); - } - - function invokeWatcherCallbacks(callbacks: readonly T[] | undefined, invokeCallback: (cb: T) => void): void { - if (callbacks) { - // The array copy is made to ensure that even if one of the callback removes the callbacks, - // we dont miss any callbacks following it - const cbs = callbacks.slice(); - for (const cb of cbs) { - invokeCallback(cb); - } - } - } - - function createWatcher(map: MultiMap, path: Path, callback: T): FileWatcher { - map.add(path, callback); - return { close: () => map.remove(path, callback) }; - } - - export function getDiffInKeys(map: ESMap, expectedKeys: readonly string[]) { - if (map.size === expectedKeys.length) { - return ""; - } - const notInActual: string[] = []; - const duplicates: string[] = []; - const seen = new Map(); - forEach(expectedKeys, expectedKey => { - if (seen.has(expectedKey)) { - duplicates.push(expectedKey); - return; - } - seen.set(expectedKey, true); - if (!map.has(expectedKey)) { - notInActual.push(expectedKey); - } - }); - const inActualNotExpected: string[] = []; - map.forEach((_value, key) => { - if (!seen.has(key)) { - inActualNotExpected.push(key); - } - seen.set(key, true); - }); - return `\n\nNotInActual: ${notInActual}\nDuplicates: ${duplicates}\nInActualButNotInExpected: ${inActualNotExpected}`; - } - - export function verifyMapSize(caption: string, map: ESMap, expectedKeys: readonly string[]) { - assert.equal(map.size, expectedKeys.length, `${caption}: incorrect size of map: Actual keys: ${arrayFrom(map.keys())} Expected: ${expectedKeys}${getDiffInKeys(map, expectedKeys)}`); - } - - export type MapValueTester = [ESMap | undefined, (value: T) => U]; - - export function checkMap(caption: string, actual: MultiMap, expectedKeys: ReadonlyESMap, valueTester?: MapValueTester): void; - export function checkMap(caption: string, actual: MultiMap, expectedKeys: readonly string[], eachKeyCount: number, valueTester?: MapValueTester): void; - export function checkMap(caption: string, actual: ESMap | MultiMap, expectedKeys: readonly string[], eachKeyCount: undefined): void; - export function checkMap( - caption: string, - actual: ESMap | MultiMap, - expectedKeysMapOrArray: ReadonlyESMap | readonly string[], - eachKeyCountOrValueTester?: number | MapValueTester, - valueTester?: MapValueTester) { - const expectedKeys = isArray(expectedKeysMapOrArray) ? arrayToMap(expectedKeysMapOrArray, s => s, () => eachKeyCountOrValueTester as number) : expectedKeysMapOrArray; - verifyMapSize(caption, actual, isArray(expectedKeysMapOrArray) ? expectedKeysMapOrArray : arrayFrom(expectedKeys.keys())); - if (!isNumber(eachKeyCountOrValueTester)) { - valueTester = eachKeyCountOrValueTester; - } - const [expectedValues, valueMapper] = valueTester || [undefined, undefined!]; - expectedKeys.forEach((count, name) => { - assert.isTrue(actual.has(name), `${caption}: expected to contain ${name}, actual keys: ${arrayFrom(actual.keys())}`); - // Check key information only if eachKeyCount is provided - if (!isArray(expectedKeysMapOrArray) || eachKeyCountOrValueTester !== undefined) { - assert.equal((actual as MultiMap).get(name)!.length, count, `${caption}: Expected to be have ${count} entries for ${name}. Actual entry: ${JSON.stringify(actual.get(name))}`); - if (expectedValues) { - assert.deepEqual( - (actual as MultiMap).get(name)!.map(valueMapper), - expectedValues.get(name), - `${caption}:: expected values mismatch for ${name}` - ); - } - } - }); - } - - export function checkArray(caption: string, actual: readonly string[], expected: readonly string[]) { - checkMap(caption, arrayToMap(actual, identity), expected, /*eachKeyCount*/ undefined); - } - - export function checkWatchedFiles(host: TestServerHost, expectedFiles: readonly string[], additionalInfo?: string) { - checkMap(`watchedFiles:: ${additionalInfo || ""}::`, host.watchedFiles, expectedFiles, /*eachKeyCount*/ undefined); - } - - export interface WatchFileDetails { - fileName: string; - pollingInterval: PollingInterval; - } - export function checkWatchedFilesDetailed(host: TestServerHost, expectedFiles: ReadonlyESMap, expectedDetails?: ESMap): void; - export function checkWatchedFilesDetailed(host: TestServerHost, expectedFiles: readonly string[], eachFileWatchCount: number, expectedDetails?: ESMap): void; - export function checkWatchedFilesDetailed(host: TestServerHost, expectedFiles: ReadonlyESMap | readonly string[], eachFileWatchCountOrExpectedDetails?: number | ESMap, expectedDetails?: ESMap) { - if (!isNumber(eachFileWatchCountOrExpectedDetails)) expectedDetails = eachFileWatchCountOrExpectedDetails; - if (isArray(expectedFiles)) { - checkMap( - "watchedFiles", - host.watchedFiles, - expectedFiles, - eachFileWatchCountOrExpectedDetails as number, - [expectedDetails, ({ fileName, pollingInterval }) => ({ fileName, pollingInterval })] - ); - } - else { - checkMap( - "watchedFiles", - host.watchedFiles, - expectedFiles, - [expectedDetails, ({ fileName, pollingInterval }) => ({ fileName, pollingInterval })] - ); - } - } - - export function checkWatchedDirectories(host: TestServerHost, expectedDirectories: string[], recursive: boolean) { - checkMap(`watchedDirectories${recursive ? " recursive" : ""}`, recursive ? host.fsWatchesRecursive : host.fsWatches, expectedDirectories, /*eachKeyCount*/ undefined); - } - - export interface WatchDirectoryDetails { - directoryName: string; - fallbackPollingInterval: PollingInterval; - fallbackOptions: WatchOptions | undefined; - } - export function checkWatchedDirectoriesDetailed(host: TestServerHost, expectedDirectories: ReadonlyESMap, recursive: boolean, expectedDetails?: ESMap): void; - export function checkWatchedDirectoriesDetailed(host: TestServerHost, expectedDirectories: readonly string[], eachDirectoryWatchCount: number, recursive: boolean, expectedDetails?: ESMap): void; - export function checkWatchedDirectoriesDetailed(host: TestServerHost, expectedDirectories: ReadonlyESMap | readonly string[], recursiveOrEachDirectoryWatchCount: boolean | number, recursiveOrExpectedDetails?: boolean | ESMap, expectedDetails?: ESMap) { - if (typeof recursiveOrExpectedDetails !== "boolean") expectedDetails = recursiveOrExpectedDetails; - if (isArray(expectedDirectories)) { - checkMap( - `fsWatches${recursiveOrExpectedDetails ? " recursive" : ""}`, - recursiveOrExpectedDetails as boolean ? host.fsWatchesRecursive : host.fsWatches, - expectedDirectories, - recursiveOrEachDirectoryWatchCount as number, - [expectedDetails, ({ directoryName, fallbackPollingInterval, fallbackOptions }) => ({ directoryName, fallbackPollingInterval, fallbackOptions })] - ); - } - else { - recursiveOrExpectedDetails = recursiveOrEachDirectoryWatchCount as boolean; - checkMap( - `fsWatches${recursiveOrExpectedDetails ? " recursive" : ""}`, - recursiveOrExpectedDetails ? host.fsWatchesRecursive : host.fsWatches, - expectedDirectories, - [expectedDetails, ({ directoryName, fallbackPollingInterval, fallbackOptions }) => ({ directoryName, fallbackPollingInterval, fallbackOptions })] - ); - } - } - - export function checkOutputContains(host: TestServerHost, expected: readonly string[]) { - const mapExpected = new Set(expected); - const mapSeen = new Set(); - for (const f of host.getOutput()) { - assert.isFalse(mapSeen.has(f), `Already found ${f} in ${JSON.stringify(host.getOutput())}`); - if (mapExpected.has(f)) { - mapExpected.delete(f); - mapSeen.add(f); - } - } - assert.equal(mapExpected.size, 0, `Output has missing ${JSON.stringify(arrayFrom(mapExpected.keys()))} in ${JSON.stringify(host.getOutput())}`); - } - - export function checkOutputDoesNotContain(host: TestServerHost, expectedToBeAbsent: string[] | readonly string[]) { - const mapExpectedToBeAbsent = new Set(expectedToBeAbsent); - for (const f of host.getOutput()) { - assert.isFalse(mapExpectedToBeAbsent.has(f), `Contains ${f} in ${JSON.stringify(host.getOutput())}`); - } - } - - 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]; - } - } - } - - type TimeOutCallback = () => any; - - export interface TestFileWatcher { - cb: FileWatcherCallback; - fileName: string; - pollingInterval: PollingInterval; - } - - export interface TestFsWatcher { - cb: FsWatchCallback; - directoryName: string; - fallbackPollingInterval: PollingInterval; - fallbackOptions: WatchOptions | undefined; - } - - export interface ReloadWatchInvokeOptions { - /** Invokes the directory watcher for the parent instead of the file changed */ - invokeDirectoryWatcherInsteadOfFileChanged: boolean; - /** When new file is created, do not invoke watches for it */ - ignoreWatchInvokedWithTriggerAsFileCreate: boolean; - /** Invoke the file delete, followed by create instead of file changed */ - invokeFileDeleteCreateAsPartInsteadOfChange: boolean; - } - - export enum Tsc_WatchFile { - DynamicPolling = "DynamicPriorityPolling", - SingleFileWatcherPerName = "SingleFileWatcherPerName" - } - - export enum Tsc_WatchDirectory { - WatchFile = "RecursiveDirectoryUsingFsWatchFile", - NonRecursiveWatchDirectory = "RecursiveDirectoryUsingNonRecursiveWatchDirectory", - DynamicPolling = "RecursiveDirectoryUsingDynamicPriorityPolling" - } - - const timeIncrements = 1000; - export interface TestServerHostOptions { - useCaseSensitiveFileNames: boolean; - executingFilePath: string; - currentDirectory: string; - fileOrFolderorSymLinkList: readonly FileOrFolderOrSymLink[]; - newLine?: string; - useWindowsStylePaths?: boolean; - environmentVariables?: ESMap; - } - - export class TestServerHost implements server.ServerHost, FormatDiagnosticsHost, ModuleResolutionHost { - args: string[] = []; - - private readonly output: string[] = []; - - private fs: ESMap = new Map(); - private time = timeIncrements; - getCanonicalFileName: (s: string) => string; - private toPath: (f: string) => Path; - private timeoutCallbacks = new Callbacks(); - private immediateCallbacks = new Callbacks(); - readonly screenClears: number[] = []; - - readonly watchedFiles = createMultiMap(); - readonly fsWatches = createMultiMap(); - readonly fsWatchesRecursive = createMultiMap(); - runWithFallbackPolling: boolean; - public readonly useCaseSensitiveFileNames: boolean; - public readonly newLine: string; - public readonly windowsStyleRoot?: string; - private readonly environmentVariables?: ESMap; - private readonly executingFilePath: string; - private readonly currentDirectory: string; - public require: ((initialPath: string, moduleName: string) => RequireResult) | undefined; - public defaultWatchFileKind?: () => WatchFileKind | undefined; - watchFile: HostWatchFile; - watchDirectory: HostWatchDirectory; - constructor( - public withSafeList: boolean, - fileOrFolderorSymLinkList: readonly FileOrFolderOrSymLink[], - { - useCaseSensitiveFileNames, executingFilePath, currentDirectory, - newLine, windowsStyleRoot, environmentVariables, - runWithoutRecursiveWatches, runWithFallbackPolling - }: TestServerHostCreationParameters = {}) { - this.useCaseSensitiveFileNames = !!useCaseSensitiveFileNames; - this.newLine = newLine || "\n"; - this.windowsStyleRoot = windowsStyleRoot; - this.environmentVariables = environmentVariables; - currentDirectory = currentDirectory || "/"; - this.getCanonicalFileName = createGetCanonicalFileName(!!useCaseSensitiveFileNames); - this.toPath = s => toPath(s, currentDirectory, this.getCanonicalFileName); - this.executingFilePath = this.getHostSpecificPath(executingFilePath || getExecutingFilePathFromLibFile()); - this.currentDirectory = this.getHostSpecificPath(currentDirectory); - this.runWithFallbackPolling = !!runWithFallbackPolling; - const tscWatchFile = this.environmentVariables && this.environmentVariables.get("TSC_WATCHFILE"); - const tscWatchDirectory = this.environmentVariables && this.environmentVariables.get("TSC_WATCHDIRECTORY"); - const { watchFile, watchDirectory } = 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), - fsSupportsRecursiveFsWatch: tscWatchDirectory ? false : !runWithoutRecursiveWatches, - directoryExists: this.directoryExists.bind(this), - getAccessibleSortedChildDirectories: path => this.getDirectories(path), - realpath: this.realpath.bind(this), - tscWatchFile, - tscWatchDirectory, - defaultWatchFileKind: () => this.defaultWatchFileKind?.(), - }); - this.watchFile = watchFile; - this.watchDirectory = watchDirectory; - this.reloadFS(fileOrFolderorSymLinkList); - } - - // Output is pretty - writeOutputIsTTY() { - return true; - } - - getNewLine() { - return this.newLine; - } - - toNormalizedAbsolutePath(s: string) { - return getNormalizedAbsolutePath(s, this.currentDirectory); - } - - toFullPath(s: string) { - return this.toPath(this.toNormalizedAbsolutePath(s)); - } - - getHostSpecificPath(s: string) { - if (this.windowsStyleRoot && s.startsWith(directorySeparator)) { - return this.windowsStyleRoot + s.substring(1); - } - return s; - } - - now() { - this.time += timeIncrements; - return new Date(this.time); - } - - private reloadFS(fileOrFolderOrSymLinkList: readonly FileOrFolderOrSymLink[], options?: Partial) { - Debug.assert(this.fs.size === 0); - fileOrFolderOrSymLinkList = fileOrFolderOrSymLinkList.concat(this.withSafeList ? safeList : []); - const filesOrFoldersToLoad: readonly FileOrFolderOrSymLink[] = !this.windowsStyleRoot ? fileOrFolderOrSymLinkList : - fileOrFolderOrSymLinkList.map(f => { - const result = clone(f); - result.path = this.getHostSpecificPath(f.path); - return result; - }); - for (const fileOrDirectory of filesOrFoldersToLoad) { - const path = this.toFullPath(fileOrDirectory.path); - // If its a change - const currentEntry = this.fs.get(path); - if (currentEntry) { - if (isFsFile(currentEntry)) { - if (isFile(fileOrDirectory)) { - // Update file - if (currentEntry.content !== fileOrDirectory.content) { - this.modifyFile(fileOrDirectory.path, fileOrDirectory.content, options); - } - } - else { - // TODO: Changing from file => folder/Symlink - } - } - else if (isFsSymLink(currentEntry)) { - // TODO: update symlinks - } - else { - // Folder - if (isFile(fileOrDirectory)) { - // TODO: Changing from folder => file - } - else { - // Folder update: Nothing to do. - currentEntry.modifiedTime = this.now(); - this.invokeFsWatches(currentEntry.fullPath, "change"); - } - } - } - else { - this.ensureFileOrFolder(fileOrDirectory, options && options.ignoreWatchInvokedWithTriggerAsFileCreate); - } - } - } - - modifyFile(filePath: string, content: string, options?: Partial) { - const path = this.toFullPath(filePath); - const currentEntry = this.fs.get(path); - if (!currentEntry || !isFsFile(currentEntry)) { - throw new Error(`file not present: ${filePath}`); - } - - if (options && options.invokeFileDeleteCreateAsPartInsteadOfChange) { - this.removeFileOrFolder(currentEntry, returnFalse); - this.ensureFileOrFolder({ path: filePath, content }); - } - else { - currentEntry.content = content; - currentEntry.modifiedTime = this.now(); - this.fs.get(getDirectoryPath(currentEntry.path))!.modifiedTime = this.now(); - if (options && options.invokeDirectoryWatcherInsteadOfFileChanged) { - const directoryFullPath = getDirectoryPath(currentEntry.fullPath); - this.invokeFileWatcher(directoryFullPath, FileWatcherEventKind.Changed, /*useFileNameInCallback*/ true); - this.invokeFsWatchesCallbacks(directoryFullPath, "rename", currentEntry.fullPath); - this.invokeRecursiveFsWatches(directoryFullPath, "rename", currentEntry.fullPath); - } - else { - this.invokeFileAndFsWatches(currentEntry.fullPath, FileWatcherEventKind.Changed); - } - } - } - - renameFile(fileName: string, newFileName: string) { - const fullPath = getNormalizedAbsolutePath(fileName, this.currentDirectory); - const path = this.toPath(fullPath); - const file = this.fs.get(path) as FsFile; - Debug.assert(!!file); - - // Only remove the file - this.removeFileOrFolder(file, returnFalse, /*isRenaming*/ true); - - // Add updated folder with new folder name - const newFullPath = getNormalizedAbsolutePath(newFileName, this.currentDirectory); - const newFile = this.toFsFile({ path: newFullPath, content: file.content }); - const newPath = newFile.path; - const basePath = getDirectoryPath(path); - Debug.assert(basePath !== path); - Debug.assert(basePath === getDirectoryPath(newPath)); - const baseFolder = this.fs.get(basePath) as FsFolder; - this.addFileOrFolderInFolder(baseFolder, newFile); - } - - renameFolder(folderName: string, newFolderName: string) { - const fullPath = getNormalizedAbsolutePath(folderName, this.currentDirectory); - const path = this.toPath(fullPath); - const folder = this.fs.get(path) as FsFolder; - Debug.assert(!!folder); - - // Only remove the folder - this.removeFileOrFolder(folder, returnFalse, /*isRenaming*/ true); - - // Add updated folder with new folder name - const newFullPath = getNormalizedAbsolutePath(newFolderName, this.currentDirectory); - const newFolder = this.toFsFolder(newFullPath); - const newPath = newFolder.path; - const basePath = getDirectoryPath(path); - Debug.assert(basePath !== path); - Debug.assert(basePath === getDirectoryPath(newPath)); - const baseFolder = this.fs.get(basePath) as FsFolder; - this.addFileOrFolderInFolder(baseFolder, newFolder); - - // Invoke watches for files in the folder as deleted (from old path) - this.renameFolderEntries(folder, newFolder); - } - - private renameFolderEntries(oldFolder: FsFolder, newFolder: FsFolder) { - for (const entry of oldFolder.entries) { - this.fs.delete(entry.path); - this.invokeFileAndFsWatches(entry.fullPath, FileWatcherEventKind.Deleted); - - entry.fullPath = combinePaths(newFolder.fullPath, getBaseFileName(entry.fullPath)); - entry.path = this.toPath(entry.fullPath); - if (newFolder !== oldFolder) { - newFolder.entries.push(entry); - } - this.fs.set(entry.path, entry); - this.invokeFileAndFsWatches(entry.fullPath, FileWatcherEventKind.Created); - if (isFsFolder(entry)) { - this.renameFolderEntries(entry, entry); - } - } - } - - ensureFileOrFolder(fileOrDirectoryOrSymLink: FileOrFolderOrSymLink, ignoreWatchInvokedWithTriggerAsFileCreate?: boolean, ignoreParentWatch?: boolean) { - if (isFile(fileOrDirectoryOrSymLink)) { - const file = this.toFsFile(fileOrDirectoryOrSymLink); - // file may already exist when updating existing type declaration file - if (!this.fs.get(file.path)) { - const baseFolder = this.ensureFolder(getDirectoryPath(file.fullPath), ignoreParentWatch); - this.addFileOrFolderInFolder(baseFolder, file, ignoreWatchInvokedWithTriggerAsFileCreate); - } - } - else if (isSymLink(fileOrDirectoryOrSymLink)) { - const symLink = this.toFsSymLink(fileOrDirectoryOrSymLink); - Debug.assert(!this.fs.get(symLink.path)); - const baseFolder = this.ensureFolder(getDirectoryPath(symLink.fullPath), ignoreParentWatch); - this.addFileOrFolderInFolder(baseFolder, symLink, ignoreWatchInvokedWithTriggerAsFileCreate); - } - else { - const fullPath = getNormalizedAbsolutePath(fileOrDirectoryOrSymLink.path, this.currentDirectory); - this.ensureFolder(getDirectoryPath(fullPath), ignoreParentWatch); - this.ensureFolder(fullPath, ignoreWatchInvokedWithTriggerAsFileCreate); - } - } - - private ensureFolder(fullPath: string, ignoreWatch: boolean | undefined): FsFolder { - const path = this.toPath(fullPath); - let folder = this.fs.get(path) as FsFolder; - if (!folder) { - folder = this.toFsFolder(fullPath); - const baseFullPath = getDirectoryPath(fullPath); - if (fullPath !== baseFullPath) { - // Add folder in the base folder - const baseFolder = this.ensureFolder(baseFullPath, ignoreWatch); - this.addFileOrFolderInFolder(baseFolder, folder, ignoreWatch); - } - else { - // root folder - Debug.assert(this.fs.size === 0 || !!this.windowsStyleRoot); - this.fs.set(path, folder); - } - } - Debug.assert(isFsFolder(folder)); - return folder; - } - - private addFileOrFolderInFolder(folder: FsFolder, fileOrDirectory: FsFile | FsFolder | FsSymLink, ignoreWatch?: boolean) { - if (!this.fs.has(fileOrDirectory.path)) { - insertSorted(folder.entries, fileOrDirectory, (a, b) => compareStringsCaseSensitive(getBaseFileName(a.path), getBaseFileName(b.path))); - } - folder.modifiedTime = this.now(); - this.fs.set(fileOrDirectory.path, fileOrDirectory); - - if (ignoreWatch) { - return; - } - this.invokeFileAndFsWatches(fileOrDirectory.fullPath, FileWatcherEventKind.Created); - this.invokeFileAndFsWatches(folder.fullPath, FileWatcherEventKind.Changed); - } - - private removeFileOrFolder(fileOrDirectory: FsFile | FsFolder | FsSymLink, isRemovableLeafFolder: (folder: FsFolder) => boolean, isRenaming = false) { - const basePath = getDirectoryPath(fileOrDirectory.path); - const baseFolder = this.fs.get(basePath) as FsFolder; - if (basePath !== fileOrDirectory.path) { - Debug.assert(!!baseFolder); - baseFolder.modifiedTime = this.now(); - filterMutate(baseFolder.entries, entry => entry !== fileOrDirectory); - } - this.fs.delete(fileOrDirectory.path); - - if (isFsFolder(fileOrDirectory)) { - Debug.assert(fileOrDirectory.entries.length === 0 || isRenaming); - } - this.invokeFileAndFsWatches(fileOrDirectory.fullPath, FileWatcherEventKind.Deleted); - this.invokeFileAndFsWatches(baseFolder.fullPath, FileWatcherEventKind.Changed); - if (basePath !== fileOrDirectory.path && - baseFolder.entries.length === 0 && - isRemovableLeafFolder(baseFolder)) { - this.removeFileOrFolder(baseFolder, isRemovableLeafFolder); - } - } - - deleteFile(filePath: string) { - const path = this.toFullPath(filePath); - const currentEntry = this.fs.get(path) as FsFile; - Debug.assert(isFsFile(currentEntry)); - this.removeFileOrFolder(currentEntry, returnFalse); - } - - deleteFolder(folderPath: string, recursive?: boolean) { - const path = this.toFullPath(folderPath); - const currentEntry = this.fs.get(path) as FsFolder; - Debug.assert(isFsFolder(currentEntry)); - if (recursive && currentEntry.entries.length) { - const subEntries = currentEntry.entries.slice(); - subEntries.forEach(fsEntry => { - if (isFsFolder(fsEntry)) { - this.deleteFolder(fsEntry.fullPath, recursive); - } - else { - this.removeFileOrFolder(fsEntry, returnFalse); - } - }); - } - this.removeFileOrFolder(currentEntry, returnFalse); - } - - private watchFileWorker(fileName: string, cb: FileWatcherCallback, pollingInterval: PollingInterval) { - return createWatcher( - this.watchedFiles, - this.toFullPath(fileName), - { fileName, cb, pollingInterval } - ); - } - - private fsWatch( - fileOrDirectory: string, - _entryKind: FileSystemEntryKind, - cb: FsWatchCallback, - recursive: boolean, - fallbackPollingInterval: PollingInterval, - fallbackOptions: WatchOptions | undefined): FileWatcher { - return this.runWithFallbackPolling ? - this.watchFile( - fileOrDirectory, - createFileWatcherCallback(cb), - fallbackPollingInterval, - fallbackOptions - ) : - createWatcher( - recursive ? this.fsWatchesRecursive : this.fsWatches, - this.toFullPath(fileOrDirectory), - { - directoryName: fileOrDirectory, - cb, - fallbackPollingInterval, - fallbackOptions - } - ); - } - - invokeFileWatcher(fileFullPath: string, eventKind: FileWatcherEventKind, useFileNameInCallback?: boolean) { - invokeWatcherCallbacks(this.watchedFiles.get(this.toPath(fileFullPath)), ({ cb, fileName }) => cb(useFileNameInCallback ? fileName : fileFullPath, eventKind)); - } - - private fsWatchCallback(map: MultiMap, fullPath: string, eventName: "rename" | "change", entryFullPath?: string) { - invokeWatcherCallbacks(map.get(this.toPath(fullPath)), ({ cb }) => cb(eventName, entryFullPath ? this.getRelativePathToDirectory(fullPath, entryFullPath) : "")); - } - - invokeFsWatchesCallbacks(fullPath: string, eventName: "rename" | "change", entryFullPath?: string) { - this.fsWatchCallback(this.fsWatches, fullPath, eventName, entryFullPath); - } - - invokeFsWatchesRecursiveCallbacks(fullPath: string, eventName: "rename" | "change", entryFullPath?: string) { - this.fsWatchCallback(this.fsWatchesRecursive, fullPath, eventName, entryFullPath); - } - - private getRelativePathToDirectory(directoryFullPath: string, fileFullPath: string) { - return getRelativePathToDirectoryOrUrl(directoryFullPath, fileFullPath, this.currentDirectory, this.getCanonicalFileName, /*isAbsolutePathAnUrl*/ false); - } - - private invokeRecursiveFsWatches(fullPath: string, eventName: "rename" | "change", entryFullPath?: string) { - this.invokeFsWatchesRecursiveCallbacks(fullPath, eventName, entryFullPath); - const basePath = getDirectoryPath(fullPath); - if (this.getCanonicalFileName(fullPath) !== this.getCanonicalFileName(basePath)) { - this.invokeRecursiveFsWatches(basePath, eventName, entryFullPath || fullPath); - } - } - - private invokeFsWatches(fullPath: string, eventName: "rename" | "change") { - this.invokeFsWatchesCallbacks(fullPath, eventName); - this.invokeFsWatchesCallbacks(getDirectoryPath(fullPath), eventName, fullPath); - this.invokeRecursiveFsWatches(fullPath, eventName); - } - - private invokeFileAndFsWatches(fileOrFolderFullPath: string, eventKind: FileWatcherEventKind) { - this.invokeFileWatcher(fileOrFolderFullPath, eventKind); - this.invokeFsWatches(fileOrFolderFullPath, eventKind === FileWatcherEventKind.Changed ? "change" : "rename"); - } - - private toFsEntry(path: string): FSEntryBase { - const fullPath = getNormalizedAbsolutePath(path, this.currentDirectory); - return { - path: this.toPath(fullPath), - fullPath, - modifiedTime: this.now() - }; - } - - private toFsFile(file: File): FsFile { - const fsFile = this.toFsEntry(file.path) as FsFile; - fsFile.content = file.content; - fsFile.fileSize = file.fileSize; - return fsFile; - } - - private toFsSymLink(symLink: SymLink): FsSymLink { - const fsSymLink = this.toFsEntry(symLink.path) as FsSymLink; - fsSymLink.symLink = getNormalizedAbsolutePath(symLink.symLink, getDirectoryPath(fsSymLink.fullPath)); - return fsSymLink; - } - - private toFsFolder(path: string): FsFolder { - const fsFolder = this.toFsEntry(path) as FsFolder; - fsFolder.entries = [] as FSEntry[] as SortedArray; // https://github.com/Microsoft/TypeScript/issues/19873 - return fsFolder; - } - - private getRealFsEntry(isFsEntry: (fsEntry: FSEntry) => fsEntry is T, path: Path, fsEntry = this.fs.get(path)!): T | undefined { - if (isFsEntry(fsEntry)) { - return fsEntry; - } - - if (isFsSymLink(fsEntry)) { - return this.getRealFsEntry(isFsEntry, this.toPath(fsEntry.symLink)); - } - - if (fsEntry) { - // This fs entry is something else - return undefined; - } - - const realpath = this.toPath(this.realpath(path)); - if (path !== realpath) { - return this.getRealFsEntry(isFsEntry, realpath); - } - - return undefined; - } - - private isFsFile(fsEntry: FSEntry) { - return !!this.getRealFile(fsEntry.path, fsEntry); - } - - private getRealFile(path: Path, fsEntry?: FSEntry): FsFile | undefined { - return this.getRealFsEntry(isFsFile, path, fsEntry); - } - - private isFsFolder(fsEntry: FSEntry) { - return !!this.getRealFolder(fsEntry.path, fsEntry); - } - - private getRealFolder(path: Path, fsEntry = this.fs.get(path)): FsFolder | undefined { - return this.getRealFsEntry(isFsFolder, path, fsEntry); - } - - fileExists(s: string) { - const path = this.toFullPath(s); - return !!this.getRealFile(path); - } - - getModifiedTime(s: string) { - const path = this.toFullPath(s); - const fsEntry = this.fs.get(path); - return (fsEntry && fsEntry.modifiedTime)!; // TODO: GH#18217 - } - - setModifiedTime(s: string, date: Date) { - const path = this.toFullPath(s); - const fsEntry = this.fs.get(path); - if (fsEntry) { - fsEntry.modifiedTime = date; - this.invokeFileAndFsWatches(fsEntry.fullPath, FileWatcherEventKind.Changed); - } - } - - readFile(s: string): string | undefined { - const fsEntry = this.getRealFile(this.toFullPath(s)); - return fsEntry ? fsEntry.content : undefined; - } - - getFileSize(s: string) { - const path = this.toFullPath(s); - const entry = this.fs.get(path)!; - if (isFsFile(entry)) { - return entry.fileSize ? entry.fileSize : entry.content.length; - } - return undefined!; // TODO: GH#18217 - } - - directoryExists(s: string) { - const path = this.toFullPath(s); - return !!this.getRealFolder(path); - } - - getDirectories(s: string): string[] { - const path = this.toFullPath(s); - const folder = this.getRealFolder(path); - if (folder) { - return mapDefined(folder.entries, entry => this.isFsFolder(entry) ? getBaseFileName(entry.fullPath) : undefined); - } - Debug.fail(folder ? "getDirectories called on file" : "getDirectories called on missing folder"); - return []; - } - - readDirectory(path: string, extensions?: readonly string[], exclude?: readonly string[], include?: readonly string[], depth?: number): string[] { - return matchFiles(path, extensions, exclude, include, this.useCaseSensitiveFileNames, this.getCurrentDirectory(), depth, (dir) => { - const directories: string[] = []; - const files: string[] = []; - const folder = this.getRealFolder(this.toPath(dir)); - if (folder) { - folder.entries.forEach((entry) => { - if (this.isFsFolder(entry)) { - directories.push(getBaseFileName(entry.fullPath)); - } - else if (this.isFsFile(entry)) { - files.push(getBaseFileName(entry.fullPath)); - } - else { - Debug.fail("Unknown entry"); - } - }); - } - return { directories, files }; - }, path => this.realpath(path)); - } - - createHash(s: string): string { - return `${generateDjb2Hash(s)}-${s}`; - } - - createSHA256Hash(s: string): string { - return sys.createSHA256Hash!(s); - } - - // TOOD: record and invoke callbacks to simulate timer events - setTimeout(callback: TimeOutCallback, _time: number, ...args: any[]) { - return this.timeoutCallbacks.register(callback, args); - } - - getNextTimeoutId() { - return this.timeoutCallbacks.getNextId(); - } - - clearTimeout(timeoutId: any): void { - this.timeoutCallbacks.unregister(timeoutId); - } - - clearScreen(): void { - this.screenClears.push(this.output.length); - } - - checkTimeoutQueueLengthAndRun(expected: number) { - this.checkTimeoutQueueLength(expected); - this.runQueuedTimeoutCallbacks(); - } - - checkTimeoutQueueLength(expected: number) { - const callbacksCount = this.timeoutCallbacks.count(); - assert.equal(callbacksCount, expected, `expected ${expected} timeout callbacks queued but found ${callbacksCount}.`); - } - - runQueuedTimeoutCallbacks(timeoutId?: number) { - try { - this.timeoutCallbacks.invoke(timeoutId); - } - catch (e) { - if (e.message === this.exitMessage) { - return; - } - throw e; - } - } - - runQueuedImmediateCallbacks(checkCount?: number) { - if (checkCount !== undefined) { - assert.equal(this.immediateCallbacks.count(), checkCount); - } - this.immediateCallbacks.invoke(); - } - - setImmediate(callback: TimeOutCallback, _time: number, ...args: any[]) { - return this.immediateCallbacks.register(callback, args); - } - - clearImmediate(timeoutId: any): void { - this.immediateCallbacks.unregister(timeoutId); - } - - createDirectory(directoryName: string): void { - const folder = this.toFsFolder(directoryName); - - // base folder has to be present - const base = getDirectoryPath(folder.path); - const baseFolder = this.fs.get(base) as FsFolder; - Debug.assert(isFsFolder(baseFolder)); - - Debug.assert(!this.fs.get(folder.path)); - this.addFileOrFolderInFolder(baseFolder, folder); - } - - writeFile(path: string, content: string): void { - const file = this.toFsFile({ path, content }); - - // base folder has to be present - const base = getDirectoryPath(file.path); - const folder = Debug.checkDefined(this.getRealFolder(base)); - - if (folder.path === base) { - if (!this.fs.has(file.path)) { - this.addFileOrFolderInFolder(folder, file); - } - else { - this.modifyFile(path, content); - } - } - else { - this.writeFile(this.realpath(path), content); - } - } - - prependFile(path: string, content: string, options?: Partial): void { - this.modifyFile(path, content + this.readFile(path), options); - } - - appendFile(path: string, content: string, options?: Partial): void { - this.modifyFile(path, this.readFile(path) + content, options); - } - - write(message: string) { - this.output.push(message); - } - - getOutput(): readonly string[] { - return this.output; - } - - clearOutput() { - clear(this.output); - this.screenClears.length = 0; - } - - serializeOutput(baseline: string[]) { - const output = this.getOutput(); - let start = 0; - baseline.push("Output::"); - for (const screenClear of this.screenClears) { - baselineOutputs(baseline, output, start, screenClear); - start = screenClear; - baseline.push(">> Screen clear"); - } - baselineOutputs(baseline, output, start); - baseline.push(""); - this.clearOutput(); - } - - snap(): ESMap { - const result = new Map(); - this.fs.forEach((value, key) => { - const cloneValue = clone(value); - if (isFsFolder(cloneValue)) { - cloneValue.entries = cloneValue.entries.map(clone) as SortedArray; - } - result.set(key, cloneValue); - }); - - return result; - } - - writtenFiles?: ESMap; - diff(baseline: string[], base: ESMap = new Map()) { - this.fs.forEach(newFsEntry => { - diffFsEntry(baseline, base.get(newFsEntry.path), newFsEntry, this.writtenFiles); - }); - base.forEach(oldFsEntry => { - const newFsEntry = this.fs.get(oldFsEntry.path); - if (!newFsEntry) { - diffFsEntry(baseline, oldFsEntry, newFsEntry, this.writtenFiles); - } - }); - baseline.push(""); - } - - serializeWatches(baseline: string[]) { - serializeMultiMap(baseline, "WatchedFiles", this.watchedFiles, ({ fileName, pollingInterval }) => ({ fileName, pollingInterval })); - baseline.push(""); - serializeMultiMap(baseline, "FsWatches", this.fsWatches, serializeTestFsWatcher); - baseline.push(""); - serializeMultiMap(baseline, "FsWatchesRecursive", this.fsWatchesRecursive, serializeTestFsWatcher); - baseline.push(""); - } - - realpath(s: string): string { - const fullPath = this.toNormalizedAbsolutePath(s); - const path = this.toPath(fullPath); - if (getDirectoryPath(path) === path) { - // Root - return s; - } - const dirFullPath = this.realpath(getDirectoryPath(fullPath)); - const realFullPath = combinePaths(dirFullPath, getBaseFileName(fullPath)); - const fsEntry = this.fs.get(this.toPath(realFullPath))!; - if (isFsSymLink(fsEntry)) { - return this.realpath(fsEntry.symLink); - } - - // realpath supports non-existent files, so there may not be an fsEntry - return fsEntry?.fullPath || realFullPath; - } - - readonly exitMessage = "System Exit"; - exitCode: number | undefined; - readonly resolvePath = (s: string) => s; - readonly getExecutingFilePath = () => this.executingFilePath; - readonly getCurrentDirectory = () => this.currentDirectory; - exit(exitCode?: number) { - this.exitCode = exitCode; - throw new Error(this.exitMessage); - } - getEnvironmentVariable(name: string) { - return this.environmentVariables && this.environmentVariables.get(name) || ""; - } - } - - function diffFsFile(baseline: string[], fsEntry: FsFile) { - baseline.push(`//// [${fsEntry.fullPath}]\r\n${fsEntry.content}`, ""); - } - function diffFsSymLink(baseline: string[], fsEntry: FsSymLink) { - baseline.push(`//// [${fsEntry.fullPath}] symlink(${fsEntry.symLink})`); - } - function diffFsEntry(baseline: string[], oldFsEntry: FSEntry | undefined, newFsEntry: FSEntry | undefined, writtenFiles: ESMap | undefined): void { - const file = newFsEntry && newFsEntry.fullPath; - if (isFsFile(oldFsEntry)) { - if (isFsFile(newFsEntry)) { - if (oldFsEntry.content !== newFsEntry.content) { - diffFsFile(baseline, newFsEntry); - } - else if (oldFsEntry.modifiedTime !== newFsEntry.modifiedTime) { - if (oldFsEntry.fullPath !== newFsEntry.fullPath) { - baseline.push(`//// [${file}] file was renamed from file ${oldFsEntry.fullPath}`); - } - else if (writtenFiles && !writtenFiles.has(newFsEntry.path)) { - baseline.push(`//// [${file}] file changed its modified time`); - } - else { - baseline.push(`//// [${file}] file written with same contents`); - } - } - } - else { - baseline.push(`//// [${oldFsEntry.fullPath}] deleted`); - if (isFsSymLink(newFsEntry)) { - diffFsSymLink(baseline, newFsEntry); - } - } - } - else if (isFsSymLink(oldFsEntry)) { - if (isFsSymLink(newFsEntry)) { - if (oldFsEntry.symLink !== newFsEntry.symLink) { - diffFsSymLink(baseline, newFsEntry); - } - else if (oldFsEntry.modifiedTime !== newFsEntry.modifiedTime) { - if (oldFsEntry.fullPath !== newFsEntry.fullPath) { - baseline.push(`//// [${file}] symlink was renamed from symlink ${oldFsEntry.fullPath}`); - } - else if (writtenFiles && !writtenFiles.has(newFsEntry.path)) { - baseline.push(`//// [${file}] symlink changed its modified time`); - } - else { - baseline.push(`//// [${file}] symlink written with same link`); - } - } - } - else { - baseline.push(`//// [${oldFsEntry.fullPath}] deleted symlink`); - if (isFsFile(newFsEntry)) { - diffFsFile(baseline, newFsEntry); - } - } - } - else if (isFsFile(newFsEntry)) { - diffFsFile(baseline, newFsEntry); - } - else if (isFsSymLink(newFsEntry)) { - diffFsSymLink(baseline, newFsEntry); - } - } - - function serializeTestFsWatcher({ directoryName, fallbackPollingInterval, fallbackOptions }: TestFsWatcher) { - return { - directoryName, - fallbackPollingInterval, - fallbackOptions: serializeWatchOptions(fallbackOptions) - }; - } - - function serializeWatchOptions(fallbackOptions: WatchOptions | undefined) { - if (!fallbackOptions) return undefined; - const { watchFile, watchDirectory, fallbackPolling, ...rest } = fallbackOptions; - return { - watchFile: watchFile !== undefined ? WatchFileKind[watchFile] : undefined, - watchDirectory: watchDirectory !== undefined ? WatchDirectoryKind[watchDirectory] : undefined, - fallbackPolling: fallbackPolling !== undefined ? PollingWatchKind[fallbackPolling] : undefined, - ...rest - }; - } - - function serializeMultiMap(baseline: string[], caption: string, multiMap: MultiMap, valueMapper: (value: T) => U) { - baseline.push(`${caption}::`); - multiMap.forEach((values, key) => { - baseline.push(`${key}:`); - for (const value of values) { - baseline.push(` ${JSON.stringify(valueMapper(value))}`); - } - }); - } - - function baselineOutputs(baseline: string[], output: readonly string[], start: number, end = output.length) { - let baselinedOutput: string[] | undefined; - for (let i = start; i < end; i++) { - (baselinedOutput ||= []).push(output[i].replace(/Elapsed::\s[0-9]+(?:\.\d+)?ms/g, "Elapsed:: *ms")); - } - if (baselinedOutput) baseline.push(baselinedOutput.join("")); - } - - export type TestServerHostTrackingWrittenFiles = TestServerHost & { writtenFiles: ESMap; }; - - export function changeToHostTrackingWrittenFiles(inputHost: TestServerHost) { - const host = inputHost as TestServerHostTrackingWrittenFiles; - const originalWriteFile = host.writeFile; - host.writtenFiles = new Map(); - host.writeFile = (fileName, content) => { - originalWriteFile.call(host, fileName, content); - const path = host.toFullPath(fileName); - host.writtenFiles.set(path, (host.writtenFiles.get(path) || 0) + 1); - }; - return host; - } - export const tsbuildProjectsLocation = "/user/username/projects"; - export function getTsBuildProjectFilePath(project: string, file: string) { - return `${tsbuildProjectsLocation}/${project}/${file}`; - } - - export function getTsBuildProjectFile(project: string, file: string): File { - return { - path: getTsBuildProjectFilePath(project, file), - content: Harness.IO.readFile(`${Harness.IO.getWorkspaceRoot()}/tests/projects/${project}/${file}`)! - }; - } -} +namespace ts.TestFSWithWatch { + export function createWatchedSystem(fileOrFolderList: readonly vfs.FileOrFolderOrSymLink[], params?: vfs.TestServerHostCreationParameters): TestServerHost { + return new TestServerHost(/*withSafelist*/ false, fileOrFolderList, params); + } + + export function createServerHost(fileOrFolderList: readonly vfs.FileOrFolderOrSymLink[], params?: vfs.TestServerHostCreationParameters): TestServerHost { + const host = new TestServerHost(/*withSafelist*/ true, fileOrFolderList, params); + // Just like sys, patch the host to use writeFile + patchWriteFileEnsuringDirectory(host); + return host; + } + export function verifyMapSize(caption: string, map: ESMap, expectedKeys: readonly string[]) { + assert.equal(map.size, expectedKeys.length, `${caption}: incorrect size of map: Actual keys: ${arrayFrom(map.keys())} Expected: ${expectedKeys}${vfs.getDiffInKeys(map, expectedKeys)}`); + } + + export type MapValueTester = [ESMap | undefined, (value: T) => U]; + + export function checkMap(caption: string, actual: MultiMap, expectedKeys: ReadonlyESMap, valueTester?: MapValueTester): void; + export function checkMap(caption: string, actual: MultiMap, expectedKeys: readonly string[], eachKeyCount: number, valueTester?: MapValueTester): void; + export function checkMap(caption: string, actual: ESMap | MultiMap, expectedKeys: readonly string[], eachKeyCount: undefined): void; + export function checkMap( + caption: string, + actual: ESMap | MultiMap, + expectedKeysMapOrArray: ReadonlyESMap | readonly string[], + eachKeyCountOrValueTester?: number | MapValueTester, + valueTester?: MapValueTester) { + const expectedKeys = isArray(expectedKeysMapOrArray) ? arrayToMap(expectedKeysMapOrArray, s => s, () => eachKeyCountOrValueTester as number) : expectedKeysMapOrArray; + verifyMapSize(caption, actual, isArray(expectedKeysMapOrArray) ? expectedKeysMapOrArray : arrayFrom(expectedKeys.keys())); + if (!isNumber(eachKeyCountOrValueTester)) { + valueTester = eachKeyCountOrValueTester; + } + const [expectedValues, valueMapper] = valueTester || [undefined, undefined!]; + expectedKeys.forEach((count, name) => { + assert.isTrue(actual.has(name), `${caption}: expected to contain ${name}, actual keys: ${arrayFrom(actual.keys())}`); + // Check key information only if eachKeyCount is provided + if (!isArray(expectedKeysMapOrArray) || eachKeyCountOrValueTester !== undefined) { + assert.equal((actual as MultiMap).get(name)!.length, count, `${caption}: Expected to be have ${count} entries for ${name}. Actual entry: ${JSON.stringify(actual.get(name))}`); + if (expectedValues) { + assert.deepEqual( + (actual as MultiMap).get(name)!.map(valueMapper), + expectedValues.get(name), + `${caption}:: expected values mismatch for ${name}` + ); + } + } + }); + } + + export function checkArray(caption: string, actual: readonly string[], expected: readonly string[]) { + checkMap(caption, arrayToMap(actual, identity), expected, /*eachKeyCount*/ undefined); + } + + export function checkWatchedFiles(host: TestServerHost, expectedFiles: readonly string[], additionalInfo?: string) { + checkMap(`watchedFiles:: ${additionalInfo || ""}::`, host.watchedFiles, expectedFiles, /*eachKeyCount*/ undefined); + } + export interface WatchFileDetails { + fileName: string; + pollingInterval: PollingInterval; + } + export function checkWatchedFilesDetailed(host: TestServerHost, expectedFiles: ReadonlyESMap, expectedDetails?: ESMap): void; + export function checkWatchedFilesDetailed(host: TestServerHost, expectedFiles: readonly string[], eachFileWatchCount: number, expectedDetails?: ESMap): void; + export function checkWatchedFilesDetailed(host: TestServerHost, expectedFiles: ReadonlyESMap | readonly string[], eachFileWatchCountOrExpectedDetails?: number | ESMap, expectedDetails?: ESMap) { + if (!isNumber(eachFileWatchCountOrExpectedDetails)) expectedDetails = eachFileWatchCountOrExpectedDetails; + if (isArray(expectedFiles)) { + checkMap( + "watchedFiles", + host.watchedFiles, + expectedFiles, + eachFileWatchCountOrExpectedDetails as number, + [expectedDetails, ({ fileName, pollingInterval }) => ({ fileName, pollingInterval })] + ); + } + else { + checkMap( + "watchedFiles", + host.watchedFiles, + expectedFiles, + [expectedDetails, ({ fileName, pollingInterval }) => ({ fileName, pollingInterval })] + ); + } + } + + export function checkWatchedDirectories(host: TestServerHost, expectedDirectories: string[], recursive: boolean) { + checkMap(`watchedDirectories${recursive ? " recursive" : ""}`, recursive ? host.fsWatchesRecursive : host.fsWatches, expectedDirectories, /*eachKeyCount*/ undefined); + } + + export interface WatchDirectoryDetails { + directoryName: string; + fallbackPollingInterval: PollingInterval; + fallbackOptions: WatchOptions | undefined; + } + export function checkWatchedDirectoriesDetailed(host: TestServerHost, expectedDirectories: ReadonlyESMap, recursive: boolean, expectedDetails?: ESMap): void; + export function checkWatchedDirectoriesDetailed(host: TestServerHost, expectedDirectories: readonly string[], eachDirectoryWatchCount: number, recursive: boolean, expectedDetails?: ESMap): void; + export function checkWatchedDirectoriesDetailed(host: TestServerHost, expectedDirectories: ReadonlyESMap | readonly string[], recursiveOrEachDirectoryWatchCount: boolean | number, recursiveOrExpectedDetails?: boolean | ESMap, expectedDetails?: ESMap) { + if (typeof recursiveOrExpectedDetails !== "boolean") expectedDetails = recursiveOrExpectedDetails; + if (isArray(expectedDirectories)) { + checkMap( + `fsWatches${recursiveOrExpectedDetails ? " recursive" : ""}`, + recursiveOrExpectedDetails as boolean ? host.fsWatchesRecursive : host.fsWatches, + expectedDirectories, + recursiveOrEachDirectoryWatchCount as number, + [expectedDetails, ({ directoryName, fallbackPollingInterval, fallbackOptions }) => ({ directoryName, fallbackPollingInterval, fallbackOptions })] + ); + } + else { + recursiveOrExpectedDetails = recursiveOrEachDirectoryWatchCount as boolean; + checkMap( + `fsWatches${recursiveOrExpectedDetails ? " recursive" : ""}`, + recursiveOrExpectedDetails ? host.fsWatchesRecursive : host.fsWatches, + expectedDirectories, + [expectedDetails, ({ directoryName, fallbackPollingInterval, fallbackOptions }) => ({ directoryName, fallbackPollingInterval, fallbackOptions })] + ); + } + } + + export function checkOutputContains(host: TestServerHost, expected: readonly string[]) { + const mapExpected = new Set(expected); + const mapSeen = new Set(); + for (const f of host.getOutput()) { + assert.isFalse(mapSeen.has(f), `Already found ${f} in ${JSON.stringify(host.getOutput())}`); + if (mapExpected.has(f)) { + mapExpected.delete(f); + mapSeen.add(f); + } + } + assert.equal(mapExpected.size, 0, `Output has missing ${JSON.stringify(arrayFrom(mapExpected.keys()))} in ${JSON.stringify(host.getOutput())}`); + } + + export function checkOutputDoesNotContain(host: TestServerHost, expectedToBeAbsent: string[] | readonly string[]) { + const mapExpectedToBeAbsent = new Set(expectedToBeAbsent); + for (const f of host.getOutput()) { + assert.isFalse(mapExpectedToBeAbsent.has(f), `Contains ${f} in ${JSON.stringify(host.getOutput())}`); + } + } + + export type TestServerHostTrackingWrittenFiles = TestServerHost & { writtenFiles: ESMap; }; + + export function changeToHostTrackingWrittenFiles(inputHost: TestServerHost) { + const host = inputHost as TestServerHostTrackingWrittenFiles; + const originalWriteFile = host.writeFile; + host.writtenFiles = new Map(); + host.writeFile = (fileName, content) => { + originalWriteFile.call(host, fileName, content); + const path = host.toFullPath(fileName); + host.writtenFiles.set(path, (host.writtenFiles.get(path) || 0) + 1); + }; + return host; + } + + export function getTsBuildProjectFile(project: string, file: string): vfs.File { + return { + path: vfs.getTsBuildProjectFilePath(project, file), + content: Harness.IO.readFile(`${Harness.IO.getWorkspaceRoot()}/tests/projects/${project}/${file}`)! + }; + } + + export class TestServerHost extends vfs.VirtualServerHost implements server.ServerHost { + constructor( + public withSafeList: boolean, + fileOrFolderorSymLinkList: readonly vfs.FileOrFolderOrSymLink[], + options: vfs.TestServerHostCreationParameters = {}) { + super(withSafeList, fileOrFolderorSymLinkList, options); + } + runQueuedImmediateCallbacks(checkCount?: number) { + if (checkCount !== undefined) { + assert.equal(this.immediateCallbacks.count(), checkCount); + } + super.runQueuedImmediateCallbacks(); + } + + checkTimeoutQueueLengthAndRun(expected: number) { + this.checkTimeoutQueueLength(expected); + this.runQueuedTimeoutCallbacks(); + } + + checkTimeoutQueueLength(expected: number) { + const callbacksCount = this.timeoutCallbacks.count(); + assert.equal(callbacksCount, expected, `expected ${expected} timeout callbacks queued but found ${callbacksCount}.`); + } + } +} diff --git a/src/vfs/vpathUtil.ts b/src/harness/vpathUtil.ts similarity index 100% rename from src/vfs/vpathUtil.ts rename to src/harness/vpathUtil.ts diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index 56704c64784..c451e580cd8 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -758,7 +758,7 @@ namespace ts.server { readonly toCanonicalFileName: (f: string) => string; public host: ServerHost; - public fs: vfs.FileSystem | undefined; + public fs: vfs.VirtualServerHost | undefined; public readonly logger: Logger; public readonly cancellationToken: HostCancellationToken; public readonly useSingleInferredProject: boolean; @@ -3690,19 +3690,12 @@ namespace ts.server { /* @internal */ updateFileSystem(createdFiles: Iterator | undefined, updatedFiles?: Iterator, deletedFiles?: string[]) { // 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) if (!this.fs) { - this.fs = new vfs.FileSystem(/*ignoreCase*/ true, { - files: { - "/": {} - }, - 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 + this.fs = vfs.createVirtualServerHost([]) + ;(this as any).host = this.fs + ;(this.session as any).host = this.fs } // 2. update vfs // I THINK that only vfs needs to update, because none of these files should be open. @@ -3712,25 +3705,24 @@ namespace ts.server { while (!(it = createdFiles.next()).done) { const document = it.value if (document.fileContent) { - this.fs.mkdirpSync(vpath.dirname(document.file)); - this.fs.writeFileSync(document.file, document.fileContent, "utf8"); - this.fs.filemeta(document.file).set("document", document); + // TODO: This isn't recursive, have to create parents AND walk past ones that already exist. + this.fs.createDirectory(ts.getDirectoryPath(document.file)); + this.fs.writeFile(document.file, document.fileContent); } } } if (updatedFiles) { - const fileset: vfs.FileSet = {} let it - while (!(it = updatedFiles.next()).done) { - fileset[it.value.file] = it.value.fileContent - } - this.fs.apply(fileset) + while (!(it = updatedFiles.next()).done) + if (it.value.fileContent) + // TODO: Also should check that file exists, probably creating if it doesn't + this.fs.modifyFile(it.value.file, it.value.fileContent) } if (deletedFiles) { - // these files should already be closed, so I THINK deleting it is enough + // TODO: Probably want to delete empty parent folders while they are empty too for (const file of deletedFiles) { - this.fs.rimrafSync(file) + this.fs.deleteFile(file) } } } diff --git a/src/vfs/fakesHosts.ts b/src/vfs/fakesHosts.ts deleted file mode 100644 index c8b52fdd247..00000000000 --- a/src/vfs/fakesHosts.ts +++ /dev/null @@ -1,643 +0,0 @@ -/** - * Fake implementations of various compiler dependencies. - */ -namespace fakes { - const processExitSentinel = new Error("System exit"); - - export interface SystemOptions { - executingFilePath?: string; - newLine?: "\r\n" | "\n"; - env?: Record; - } - - /** - * A fake `ts.System` that leverages a virtual file system. - */ - export class System implements ts.System { - public readonly vfs: vfs.FileSystem; - public readonly args: string[] = []; - public readonly output: string[] = []; - public readonly newLine: string; - public readonly useCaseSensitiveFileNames: boolean; - public exitCode: number | undefined; - - private readonly _executingFilePath: string | undefined; - private readonly _env: Record | undefined; - - constructor(vfs: vfs.FileSystem, { executingFilePath, newLine = "\r\n", env }: SystemOptions = {}) { - this.vfs = vfs.isReadonly ? vfs.shadow() : vfs; - this.useCaseSensitiveFileNames = !this.vfs.ignoreCase; - this.newLine = newLine; - this._executingFilePath = executingFilePath; - this._env = env; - } - - private testTerminalWidth = Number.parseInt(this.getEnvironmentVariable("TS_TEST_TERMINAL_WIDTH")); - getWidthOfTerminal = Number.isNaN(this.testTerminalWidth) ? undefined : () => this.testTerminalWidth; - - // Pretty output - writeOutputIsTTY() { - return true; - } - - public write(message: string) { - this.output.push(message); - } - - public readFile(path: string) { - try { - const content = this.vfs.readFileSync(path, "utf8"); - return content === undefined ? undefined : Utils.removeByteOrderMark(content); - } - catch { - return undefined; - } - } - - public writeFile(path: string, data: string, writeByteOrderMark?: boolean): void { - this.vfs.mkdirpSync(vpath.dirname(path)); - this.vfs.writeFileSync(path, writeByteOrderMark ? Utils.addUTF8ByteOrderMark(data) : data); - } - - public deleteFile(path: string) { - this.vfs.unlinkSync(path); - } - - public fileExists(path: string) { - const stats = this._getStats(path); - return stats ? stats.isFile() : false; - } - - public directoryExists(path: string) { - const stats = this._getStats(path); - return stats ? stats.isDirectory() : false; - } - - public createDirectory(path: string): void { - this.vfs.mkdirpSync(path); - } - - public getCurrentDirectory() { - return this.vfs.cwd(); - } - - public getDirectories(path: string) { - const result: string[] = []; - try { - for (const file of this.vfs.readdirSync(path)) { - if (this.vfs.statSync(vpath.combine(path, file)).isDirectory()) { - result.push(file); - } - } - } - catch { /*ignore*/ } - return result; - } - - public readDirectory(path: string, extensions?: readonly string[], exclude?: readonly string[], include?: readonly string[], depth?: number): string[] { - return ts.matchFiles(path, extensions, exclude, include, this.useCaseSensitiveFileNames, this.getCurrentDirectory(), depth, path => this.getAccessibleFileSystemEntries(path), path => this.realpath(path)); - } - - public getAccessibleFileSystemEntries(path: string): ts.FileSystemEntries { - const files: string[] = []; - const directories: string[] = []; - try { - for (const file of this.vfs.readdirSync(path)) { - try { - const stats = this.vfs.statSync(vpath.combine(path, file)); - if (stats.isFile()) { - files.push(file); - } - else if (stats.isDirectory()) { - directories.push(file); - } - } - catch { /*ignored*/ } - } - } - catch { /*ignored*/ } - return { files, directories }; - } - - public exit(exitCode?: number) { - this.exitCode = exitCode; - throw processExitSentinel; - } - - public getFileSize(path: string) { - const stats = this._getStats(path); - return stats && stats.isFile() ? stats.size : 0; - } - - public resolvePath(path: string) { - return vpath.resolve(this.vfs.cwd(), path); - } - - public getExecutingFilePath() { - if (this._executingFilePath === undefined) return ts.notImplemented(); - return this._executingFilePath; - } - - public getModifiedTime(path: string) { - const stats = this._getStats(path); - return stats ? stats.mtime : undefined!; // TODO: GH#18217 - } - - public setModifiedTime(path: string, time: Date) { - this.vfs.utimesSync(path, time, time); - } - - public createHash(data: string): string { - return `${ts.generateDjb2Hash(data)}-${data}`; - } - - public realpath(path: string) { - try { - return this.vfs.realpathSync(path); - } - catch { - return path; - } - } - - public getEnvironmentVariable(name: string): string { - return (this._env && this._env[name])!; // TODO: GH#18217 - } - - private _getStats(path: string) { - try { - return this.vfs.existsSync(path) ? this.vfs.statSync(path) : undefined; - } - catch { - return undefined; - } - } - - now() { - return new Date(this.vfs.time()); - } - } - - /** - * A fake `ts.ParseConfigHost` that leverages a virtual file system. - */ - export class ParseConfigHost implements ts.ParseConfigHost { - public readonly sys: System; - - constructor(sys: System | vfs.FileSystem) { - if (sys instanceof vfs.FileSystem) sys = new System(sys); - this.sys = sys; - } - - public get vfs() { - return this.sys.vfs; - } - - public get useCaseSensitiveFileNames() { - return this.sys.useCaseSensitiveFileNames; - } - - public fileExists(fileName: string): boolean { - return this.sys.fileExists(fileName); - } - - public directoryExists(directoryName: string): boolean { - return this.sys.directoryExists(directoryName); - } - - public readFile(path: string): string | undefined { - return this.sys.readFile(path); - } - - public readDirectory(path: string, extensions: string[], excludes: string[], includes: string[], depth: number): string[] { - return this.sys.readDirectory(path, extensions, excludes, includes, depth); - } - } - - /** - * @implements {server.ServerHost} but that would create a circular dependency - */ - export class FakeServerHost extends System { - 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 - } - setTimeout(callback: (...args: any[]) => void, _ms: number, ...args: any[]): any { - return this.timeoutCallbacks.register(callback, args) - } - clearTimeout(timeoutId: any): void { - this.timeoutCallbacks.unregister(timeoutId) - } - setImmediate(callback: (...args: any[]) => void, ...args: any[]): any { - return this.immediateCallbacks.register(callback, args) - } - clearImmediate(timeoutId: any): void { - 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. - */ - export class FakeCompilerHost implements ts.CompilerHost { - public readonly sys: System; - public readonly defaultLibLocation: string; - public readonly outputs: documents.TextDocument[] = []; - private readonly _outputsMap: collections.SortedMap; - public readonly traces: string[] = []; - - protected _setParentNodes: boolean; - protected _sourceFiles: collections.SortedMap; - private _parseConfigHost: ParseConfigHost | undefined; - private _newLine: string; - - constructor(sys: System | vfs.FileSystem, options = ts.getDefaultCompilerOptions(), setParentNodes = false) { - if (sys instanceof vfs.FileSystem) sys = new System(sys); - this.sys = sys; - this.defaultLibLocation = sys.vfs.meta.get("defaultLibLocation") || ""; - this._newLine = ts.getNewLineCharacter(options, () => this.sys.newLine); - this._sourceFiles = new collections.SortedMap({ comparer: sys.vfs.stringComparer, sort: "insertion" }); - this._setParentNodes = setParentNodes; - this._outputsMap = new collections.SortedMap(this.vfs.stringComparer); - } - - public get vfs() { - return this.sys.vfs; - } - - public get parseConfigHost() { - return this._parseConfigHost || (this._parseConfigHost = new ParseConfigHost(this.sys)); - } - - public getCurrentDirectory(): string { - return this.sys.getCurrentDirectory(); - } - - public useCaseSensitiveFileNames(): boolean { - return this.sys.useCaseSensitiveFileNames; - } - - public getNewLine(): string { - return this._newLine; - } - - public getCanonicalFileName(fileName: string): string { - return this.sys.useCaseSensitiveFileNames ? fileName : fileName.toLowerCase(); - } - - public deleteFile(fileName: string) { - this.sys.deleteFile(fileName); - } - - public fileExists(fileName: string): boolean { - return this.sys.fileExists(fileName); - } - - public directoryExists(directoryName: string): boolean { - return this.sys.directoryExists(directoryName); - } - - public getModifiedTime(fileName: string) { - return this.sys.getModifiedTime(fileName); - } - - public setModifiedTime(fileName: string, time: Date) { - return this.sys.setModifiedTime(fileName, time); - } - - public getDirectories(path: string): string[] { - return this.sys.getDirectories(path); - } - - public readDirectory(path: string, extensions?: readonly string[], exclude?: readonly string[], include?: readonly string[], depth?: number): string[] { - return this.sys.readDirectory(path, extensions, exclude, include, depth); - } - - public readFile(path: string): string | undefined { - return this.sys.readFile(path); - } - - public writeFile(fileName: string, content: string, writeByteOrderMark: boolean) { - if (writeByteOrderMark) content = Utils.addUTF8ByteOrderMark(content); - this.sys.writeFile(fileName, content); - - const document = new documents.TextDocument(fileName, content); - document.meta.set("fileName", fileName); - this.vfs.filemeta(fileName).set("document", document); - if (!this._outputsMap.has(document.file)) { - this._outputsMap.set(document.file, this.outputs.length); - this.outputs.push(document); - } - this.outputs[this._outputsMap.get(document.file)!] = document; - } - - public trace(s: string): void { - this.traces.push(s); - } - - public realpath(path: string): string { - return this.sys.realpath(path); - } - - public getDefaultLibLocation(): string { - return vpath.resolve(this.getCurrentDirectory(), this.defaultLibLocation); - } - - public getDefaultLibFileName(options: ts.CompilerOptions): string { - return vpath.resolve(this.getDefaultLibLocation(), ts.getDefaultLibFileName(options)); - } - - public getSourceFile(fileName: string, languageVersion: number): ts.SourceFile | undefined { - const canonicalFileName = this.getCanonicalFileName(vpath.resolve(this.getCurrentDirectory(), fileName)); - const existing = this._sourceFiles.get(canonicalFileName); - if (existing) return existing; - - const content = this.readFile(canonicalFileName); - if (content === undefined) return undefined; - - // A virtual file system may shadow another existing virtual file system. This - // allows us to reuse a common virtual file system structure across multiple - // tests. If a virtual file is a shadow, it is likely that the file will be - // reused across multiple tests. In that case, we cache the SourceFile we parse - // so that it can be reused across multiple tests to avoid the cost of - // repeatedly parsing the same file over and over (such as lib.d.ts). - const cacheKey = this.vfs.shadowRoot && `SourceFile[languageVersion=${languageVersion},setParentNodes=${this._setParentNodes}]`; - if (cacheKey) { - const meta = this.vfs.filemeta(canonicalFileName); - const sourceFileFromMetadata = meta.get(cacheKey) as ts.SourceFile | undefined; - if (sourceFileFromMetadata && sourceFileFromMetadata.getFullText() === content) { - this._sourceFiles.set(canonicalFileName, sourceFileFromMetadata); - return sourceFileFromMetadata; - } - } - - const parsed = ts.createSourceFile(fileName, content, languageVersion, this._setParentNodes); - this._sourceFiles.set(canonicalFileName, parsed); - - if (cacheKey) { - // store the cached source file on the unshadowed file with the same version. - const stats = this.vfs.statSync(canonicalFileName); - - let fs = this.vfs; - while (fs.shadowRoot) { - try { - const shadowRootStats = fs.shadowRoot.existsSync(canonicalFileName) ? fs.shadowRoot.statSync(canonicalFileName) : undefined!; // TODO: GH#18217 - if (shadowRootStats.dev !== stats.dev || - shadowRootStats.ino !== stats.ino || - shadowRootStats.mtimeMs !== stats.mtimeMs) { - break; - } - - fs = fs.shadowRoot; - } - catch { - break; - } - } - - if (fs !== this.vfs) { - fs.filemeta(canonicalFileName).set(cacheKey, parsed); - } - } - - return parsed; - } - } - - export type ExpectedDiagnosticMessage = [ts.DiagnosticMessage, ...(string | number)[]]; - export interface ExpectedDiagnosticMessageChain { - message: ExpectedDiagnosticMessage; - next?: ExpectedDiagnosticMessageChain[]; - } - - export interface ExpectedDiagnosticLocation { - file: string; - start: number; - length: number; - } - export interface ExpectedDiagnosticRelatedInformation extends ExpectedDiagnosticMessageChain { - location?: ExpectedDiagnosticLocation; - } - - export enum DiagnosticKind { - Error = "Error", - Status = "Status" - } - export interface ExpectedErrorDiagnostic extends ExpectedDiagnosticRelatedInformation { - relatedInformation?: ExpectedDiagnosticRelatedInformation[]; - } - - export type ExpectedDiagnostic = ExpectedDiagnosticMessage | ExpectedErrorDiagnostic; - - export interface SolutionBuilderDiagnostic { - kind: DiagnosticKind; - diagnostic: ts.Diagnostic; - } - - export const version = "FakeTSVersion"; - - export function patchHostForBuildInfoReadWrite(sys: T) { - const originalReadFile = sys.readFile; - sys.readFile = (path, encoding) => { - const value = originalReadFile.call(sys, path, encoding); - if (!value || !ts.isBuildInfoFile(path)) return value; - const buildInfo = ts.getBuildInfo(value); - ts.Debug.assert(buildInfo.version === version); - buildInfo.version = ts.version; - return ts.getBuildInfoText(buildInfo); - }; - const originalWrite = sys.write; - sys.write = msg => originalWrite.call(sys, msg.replace(ts.version, version)); - - if (sys.writeFile) { - const originalWriteFile = sys.writeFile; - sys.writeFile = (fileName: string, content: string, writeByteOrderMark: boolean) => { - if (!ts.isBuildInfoFile(fileName)) return originalWriteFile.call(sys, fileName, content, writeByteOrderMark); - const buildInfo = ts.getBuildInfo(content); - buildInfo.version = version; - originalWriteFile.call(sys, fileName, ts.getBuildInfoText(buildInfo), writeByteOrderMark); - }; - } - return sys; - } - - export class FakeSolutionBuilderHost extends FakeCompilerHost implements ts.SolutionBuilderHost { - createProgram: ts.CreateProgram; - - constructor(sys: System | vfs.FileSystem, options?: ts.CompilerOptions, setParentNodes?: boolean, createProgram?: ts.CreateProgram) { - super(sys, options, setParentNodes); - this.createProgram = createProgram || ts.createEmitAndSemanticDiagnosticsBuilderProgram; - } - - static create(sys: System | vfs.FileSystem, options?: ts.CompilerOptions, setParentNodes?: boolean, createProgram?: ts.CreateProgram) { - const host = new FakeSolutionBuilderHost(sys, options, setParentNodes, createProgram); - patchHostForBuildInfoReadWrite(host.sys); - return host; - } - - createHash(data: string) { - return `${ts.generateDjb2Hash(data)}-${data}`; - } - - diagnostics: SolutionBuilderDiagnostic[] = []; - - reportDiagnostic(diagnostic: ts.Diagnostic) { - this.diagnostics.push({ kind: DiagnosticKind.Error, diagnostic }); - } - - reportSolutionBuilderStatus(diagnostic: ts.Diagnostic) { - this.diagnostics.push({ kind: DiagnosticKind.Status, diagnostic }); - } - - clearDiagnostics() { - this.diagnostics.length = 0; - } - - now() { - return this.sys.now(); - } - } -} diff --git a/src/vfs/tsconfig.json b/src/vfs/tsconfig.json index 03adbb14d8c..655951fd7d3 100644 --- a/src/vfs/tsconfig.json +++ b/src/vfs/tsconfig.json @@ -17,11 +17,6 @@ ], "files": [ - "collectionsImpl.ts", - "documentsUtil.ts", - "vpathUtil.ts", - "vfsUtil.ts", - "util.ts", - "fakesHosts.ts" + "virtualFileSystemWithWatch.ts" ] } diff --git a/src/vfs/util.ts b/src/vfs/util.ts deleted file mode 100644 index be99958e93a..00000000000 --- a/src/vfs/util.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Common utilities - */ -namespace Utils { - export function getByteOrderMarkLength(text: string): number { - if (text.length >= 1) { - const ch0 = text.charCodeAt(0); - if (ch0 === 0xfeff) return 1; - if (ch0 === 0xfe) return text.length >= 2 && text.charCodeAt(1) === 0xff ? 2 : 0; - if (ch0 === 0xff) return text.length >= 2 && text.charCodeAt(1) === 0xfe ? 2 : 0; - if (ch0 === 0xef) return text.length >= 3 && text.charCodeAt(1) === 0xbb && text.charCodeAt(2) === 0xbf ? 3 : 0; - } - return 0; - } - - export function removeByteOrderMark(text: string): string { - const length = getByteOrderMarkLength(text); - return length ? text.slice(length) : text; - } - - export function addUTF8ByteOrderMark(text: string) { - return getByteOrderMarkLength(text) === 0 ? "\u00EF\u00BB\u00BF" + text : text; - } -} diff --git a/src/vfs/virtualFileSystemWithWatch.ts b/src/vfs/virtualFileSystemWithWatch.ts new file mode 100644 index 00000000000..d90c672a716 --- /dev/null +++ b/src/vfs/virtualFileSystemWithWatch.ts @@ -0,0 +1,1096 @@ +/* @internal */ +namespace ts.vfs { + export const libFile: File = { + path: "/a/lib/lib.d.ts", + content: `/// +interface Boolean {} +interface Function {} +interface CallableFunction {} +interface NewableFunction {} +interface IArguments {} +interface Number { toExponential: any; } +interface Object {} +interface RegExp {} +interface String { charAt: any; } +interface Array { length: number; [n: number]: T; }` + }; + + export const safeList = { + path: "/safeList.json" as Path, + content: JSON.stringify({ + commander: "commander", + express: "express", + jquery: "jquery", + lodash: "lodash", + moment: "moment", + chroma: "chroma-js" + }) + }; + + function getExecutingFilePathFromLibFile(): string { + return combinePaths(getDirectoryPath(libFile.path), "tsc.js"); + } + + export interface TestServerHostCreationParameters { + useCaseSensitiveFileNames?: boolean; + executingFilePath?: string; + currentDirectory?: string; + newLine?: string; + windowsStyleRoot?: string; + environmentVariables?: ESMap; + runWithoutRecursiveWatches?: boolean; + runWithFallbackPolling?: boolean; + } + + export function createVirtualWatchedSystem(fileOrFolderList: readonly FileOrFolderOrSymLink[], params?: TestServerHostCreationParameters): VirtualServerHost { + return new VirtualServerHost(/*withSafelist*/ false, fileOrFolderList, params); + } + + export function createVirtualServerHost(fileOrFolderList: readonly FileOrFolderOrSymLink[], params?: TestServerHostCreationParameters): VirtualServerHost { + const host = new VirtualServerHost(/*withSafelist*/ true, fileOrFolderList, params); + // Just like sys, patch the host to use writeFile + patchWriteFileEnsuringDirectory(host); + return host; + } + + export interface File { + path: string; + content: string; + fileSize?: number; + } + + export interface Folder { + path: string; + } + + export interface SymLink { + /** Location of the symlink. */ + path: string; + /** Relative path to the real file. */ + symLink: string; + } + + export type FileOrFolderOrSymLink = File | Folder | SymLink; + export function isFile(fileOrFolderOrSymLink: FileOrFolderOrSymLink): fileOrFolderOrSymLink is File { + return isString((fileOrFolderOrSymLink as File).content); + } + + export function isSymLink(fileOrFolderOrSymLink: FileOrFolderOrSymLink): fileOrFolderOrSymLink is SymLink { + return isString((fileOrFolderOrSymLink as SymLink).symLink); + } + + interface FSEntryBase { + path: Path; + fullPath: string; + modifiedTime: Date; + } + + interface FsFile extends FSEntryBase { + content: string; + fileSize?: number; + } + + interface FsFolder extends FSEntryBase { + entries: SortedArray; + } + + interface FsSymLink extends FSEntryBase { + symLink: string; + } + + type FSEntry = FsFile | FsFolder | FsSymLink; + + function isFsFolder(s: FSEntry | undefined): s is FsFolder { + return !!s && isArray((s as FsFolder).entries); + } + + function isFsFile(s: FSEntry | undefined): s is FsFile { + return !!s && isString((s as FsFile).content); + } + + function isFsSymLink(s: FSEntry | undefined): s is FsSymLink { + return !!s && isString((s as FsSymLink).symLink); + } + + function invokeWatcherCallbacks(callbacks: readonly T[] | undefined, invokeCallback: (cb: T) => void): void { + if (callbacks) { + // The array copy is made to ensure that even if one of the callback removes the callbacks, + // we dont miss any callbacks following it + const cbs = callbacks.slice(); + for (const cb of cbs) { + invokeCallback(cb); + } + } + } + + function createWatcher(map: MultiMap, path: Path, callback: T): FileWatcher { + map.add(path, callback); + return { close: () => map.remove(path, callback) }; + } + + export function getDiffInKeys(map: ESMap, expectedKeys: readonly string[]) { + if (map.size === expectedKeys.length) { + return ""; + } + const notInActual: string[] = []; + const duplicates: string[] = []; + const seen = new Map(); + forEach(expectedKeys, expectedKey => { + if (seen.has(expectedKey)) { + duplicates.push(expectedKey); + return; + } + seen.set(expectedKey, true); + if (!map.has(expectedKey)) { + notInActual.push(expectedKey); + } + }); + const inActualNotExpected: string[] = []; + map.forEach((_value, key) => { + if (!seen.has(key)) { + inActualNotExpected.push(key); + } + seen.set(key, true); + }); + return `\n\nNotInActual: ${notInActual}\nDuplicates: ${duplicates}\nInActualButNotInExpected: ${inActualNotExpected}`; + } + + 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]; + } + } + } + + type TimeOutCallback = () => any; + + export interface TestFileWatcher { + cb: FileWatcherCallback; + fileName: string; + pollingInterval: PollingInterval; + } + + export interface TestFsWatcher { + cb: FsWatchCallback; + directoryName: string; + fallbackPollingInterval: PollingInterval; + fallbackOptions: WatchOptions | undefined; + } + + export interface ReloadWatchInvokeOptions { + /** Invokes the directory watcher for the parent instead of the file changed */ + invokeDirectoryWatcherInsteadOfFileChanged: boolean; + /** When new file is created, do not invoke watches for it */ + ignoreWatchInvokedWithTriggerAsFileCreate: boolean; + /** Invoke the file delete, followed by create instead of file changed */ + invokeFileDeleteCreateAsPartInsteadOfChange: boolean; + } + + export enum Tsc_WatchFile { + DynamicPolling = "DynamicPriorityPolling", + SingleFileWatcherPerName = "SingleFileWatcherPerName" + } + + export enum Tsc_WatchDirectory { + WatchFile = "RecursiveDirectoryUsingFsWatchFile", + NonRecursiveWatchDirectory = "RecursiveDirectoryUsingNonRecursiveWatchDirectory", + DynamicPolling = "RecursiveDirectoryUsingDynamicPriorityPolling" + } + + const timeIncrements = 1000; + export interface TestServerHostOptions { + useCaseSensitiveFileNames: boolean; + executingFilePath: string; + currentDirectory: string; + fileOrFolderorSymLinkList: readonly FileOrFolderOrSymLink[]; + newLine?: string; + useWindowsStylePaths?: boolean; + environmentVariables?: ESMap; + } + + /** + * also implements {server.ServerHost} but that would create a circular dependency + */ + export class VirtualServerHost implements FormatDiagnosticsHost, ModuleResolutionHost { + args: string[] = []; + + private readonly output: string[] = []; + + private fs: ESMap = new Map(); + private time = timeIncrements; + getCanonicalFileName: (s: string) => string; + private toPath: (f: string) => Path; + protected timeoutCallbacks = new Callbacks(); + protected immediateCallbacks = new Callbacks(); + readonly screenClears: number[] = []; + + readonly watchedFiles = createMultiMap(); + readonly fsWatches = createMultiMap(); + readonly fsWatchesRecursive = createMultiMap(); + runWithFallbackPolling: boolean; + public readonly useCaseSensitiveFileNames: boolean; + public readonly newLine: string; + public readonly windowsStyleRoot?: string; + private readonly environmentVariables?: ESMap; + private readonly executingFilePath: string; + private readonly currentDirectory: string; + public require: ((initialPath: string, moduleName: string) => RequireResult) | undefined; + public defaultWatchFileKind?: () => WatchFileKind | undefined; + watchFile: HostWatchFile; + watchDirectory: HostWatchDirectory; + constructor( + public withSafeList: boolean, + fileOrFolderorSymLinkList: readonly FileOrFolderOrSymLink[], + { + useCaseSensitiveFileNames, executingFilePath, currentDirectory, + newLine, windowsStyleRoot, environmentVariables, + runWithoutRecursiveWatches, runWithFallbackPolling + }: TestServerHostCreationParameters = {}) { + this.useCaseSensitiveFileNames = !!useCaseSensitiveFileNames; + this.newLine = newLine || "\n"; + this.windowsStyleRoot = windowsStyleRoot; + this.environmentVariables = environmentVariables; + currentDirectory = currentDirectory || "/"; + this.getCanonicalFileName = createGetCanonicalFileName(!!useCaseSensitiveFileNames); + this.toPath = s => toPath(s, currentDirectory, this.getCanonicalFileName); + this.executingFilePath = this.getHostSpecificPath(executingFilePath || getExecutingFilePathFromLibFile()); + this.currentDirectory = this.getHostSpecificPath(currentDirectory); + this.runWithFallbackPolling = !!runWithFallbackPolling; + const tscWatchFile = this.environmentVariables && this.environmentVariables.get("TSC_WATCHFILE"); + const tscWatchDirectory = this.environmentVariables && this.environmentVariables.get("TSC_WATCHDIRECTORY"); + const { watchFile, watchDirectory } = 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), + fsSupportsRecursiveFsWatch: tscWatchDirectory ? false : !runWithoutRecursiveWatches, + directoryExists: this.directoryExists.bind(this), + getAccessibleSortedChildDirectories: path => this.getDirectories(path), + realpath: this.realpath.bind(this), + tscWatchFile, + tscWatchDirectory, + defaultWatchFileKind: () => this.defaultWatchFileKind?.(), + }); + this.watchFile = watchFile; + this.watchDirectory = watchDirectory; + this.reloadFS(fileOrFolderorSymLinkList); + } + + // Output is pretty + writeOutputIsTTY() { + return true; + } + + getNewLine() { + return this.newLine; + } + + toNormalizedAbsolutePath(s: string) { + return getNormalizedAbsolutePath(s, this.currentDirectory); + } + + toFullPath(s: string) { + return this.toPath(this.toNormalizedAbsolutePath(s)); + } + + getHostSpecificPath(s: string) { + if (this.windowsStyleRoot && s.startsWith(directorySeparator)) { + return this.windowsStyleRoot + s.substring(1); + } + return s; + } + + now() { + this.time += timeIncrements; + return new Date(this.time); + } + + private reloadFS(fileOrFolderOrSymLinkList: readonly FileOrFolderOrSymLink[], options?: Partial) { + Debug.assert(this.fs.size === 0); + fileOrFolderOrSymLinkList = fileOrFolderOrSymLinkList.concat(this.withSafeList ? safeList : []); + const filesOrFoldersToLoad: readonly FileOrFolderOrSymLink[] = !this.windowsStyleRoot ? fileOrFolderOrSymLinkList : + fileOrFolderOrSymLinkList.map(f => { + const result = clone(f); + result.path = this.getHostSpecificPath(f.path); + return result; + }); + for (const fileOrDirectory of filesOrFoldersToLoad) { + const path = this.toFullPath(fileOrDirectory.path); + // If its a change + const currentEntry = this.fs.get(path); + if (currentEntry) { + if (isFsFile(currentEntry)) { + if (isFile(fileOrDirectory)) { + // Update file + if (currentEntry.content !== fileOrDirectory.content) { + this.modifyFile(fileOrDirectory.path, fileOrDirectory.content, options); + } + } + else { + // TODO: Changing from file => folder/Symlink + } + } + else if (isFsSymLink(currentEntry)) { + // TODO: update symlinks + } + else { + // Folder + if (isFile(fileOrDirectory)) { + // TODO: Changing from folder => file + } + else { + // Folder update: Nothing to do. + currentEntry.modifiedTime = this.now(); + this.invokeFsWatches(currentEntry.fullPath, "change"); + } + } + } + else { + this.ensureFileOrFolder(fileOrDirectory, options && options.ignoreWatchInvokedWithTriggerAsFileCreate); + } + } + } + + modifyFile(filePath: string, content: string, options?: Partial) { + const path = this.toFullPath(filePath); + const currentEntry = this.fs.get(path); + if (!currentEntry || !isFsFile(currentEntry)) { + throw new Error(`file not present: ${filePath}`); + } + + if (options && options.invokeFileDeleteCreateAsPartInsteadOfChange) { + this.removeFileOrFolder(currentEntry, returnFalse); + this.ensureFileOrFolder({ path: filePath, content }); + } + else { + currentEntry.content = content; + currentEntry.modifiedTime = this.now(); + this.fs.get(getDirectoryPath(currentEntry.path))!.modifiedTime = this.now(); + if (options && options.invokeDirectoryWatcherInsteadOfFileChanged) { + const directoryFullPath = getDirectoryPath(currentEntry.fullPath); + this.invokeFileWatcher(directoryFullPath, FileWatcherEventKind.Changed, /*useFileNameInCallback*/ true); + this.invokeFsWatchesCallbacks(directoryFullPath, "rename", currentEntry.fullPath); + this.invokeRecursiveFsWatches(directoryFullPath, "rename", currentEntry.fullPath); + } + else { + this.invokeFileAndFsWatches(currentEntry.fullPath, FileWatcherEventKind.Changed); + } + } + } + + renameFile(fileName: string, newFileName: string) { + const fullPath = getNormalizedAbsolutePath(fileName, this.currentDirectory); + const path = this.toPath(fullPath); + const file = this.fs.get(path) as FsFile; + Debug.assert(!!file); + + // Only remove the file + this.removeFileOrFolder(file, returnFalse, /*isRenaming*/ true); + + // Add updated folder with new folder name + const newFullPath = getNormalizedAbsolutePath(newFileName, this.currentDirectory); + const newFile = this.toFsFile({ path: newFullPath, content: file.content }); + const newPath = newFile.path; + const basePath = getDirectoryPath(path); + Debug.assert(basePath !== path); + Debug.assert(basePath === getDirectoryPath(newPath)); + const baseFolder = this.fs.get(basePath) as FsFolder; + this.addFileOrFolderInFolder(baseFolder, newFile); + } + + renameFolder(folderName: string, newFolderName: string) { + const fullPath = getNormalizedAbsolutePath(folderName, this.currentDirectory); + const path = this.toPath(fullPath); + const folder = this.fs.get(path) as FsFolder; + Debug.assert(!!folder); + + // Only remove the folder + this.removeFileOrFolder(folder, returnFalse, /*isRenaming*/ true); + + // Add updated folder with new folder name + const newFullPath = getNormalizedAbsolutePath(newFolderName, this.currentDirectory); + const newFolder = this.toFsFolder(newFullPath); + const newPath = newFolder.path; + const basePath = getDirectoryPath(path); + Debug.assert(basePath !== path); + Debug.assert(basePath === getDirectoryPath(newPath)); + const baseFolder = this.fs.get(basePath) as FsFolder; + this.addFileOrFolderInFolder(baseFolder, newFolder); + + // Invoke watches for files in the folder as deleted (from old path) + this.renameFolderEntries(folder, newFolder); + } + + private renameFolderEntries(oldFolder: FsFolder, newFolder: FsFolder) { + for (const entry of oldFolder.entries) { + this.fs.delete(entry.path); + this.invokeFileAndFsWatches(entry.fullPath, FileWatcherEventKind.Deleted); + + entry.fullPath = combinePaths(newFolder.fullPath, getBaseFileName(entry.fullPath)); + entry.path = this.toPath(entry.fullPath); + if (newFolder !== oldFolder) { + newFolder.entries.push(entry); + } + this.fs.set(entry.path, entry); + this.invokeFileAndFsWatches(entry.fullPath, FileWatcherEventKind.Created); + if (isFsFolder(entry)) { + this.renameFolderEntries(entry, entry); + } + } + } + + ensureFileOrFolder(fileOrDirectoryOrSymLink: FileOrFolderOrSymLink, ignoreWatchInvokedWithTriggerAsFileCreate?: boolean, ignoreParentWatch?: boolean) { + if (isFile(fileOrDirectoryOrSymLink)) { + const file = this.toFsFile(fileOrDirectoryOrSymLink); + // file may already exist when updating existing type declaration file + if (!this.fs.get(file.path)) { + const baseFolder = this.ensureFolder(getDirectoryPath(file.fullPath), ignoreParentWatch); + this.addFileOrFolderInFolder(baseFolder, file, ignoreWatchInvokedWithTriggerAsFileCreate); + } + } + else if (isSymLink(fileOrDirectoryOrSymLink)) { + const symLink = this.toFsSymLink(fileOrDirectoryOrSymLink); + Debug.assert(!this.fs.get(symLink.path)); + const baseFolder = this.ensureFolder(getDirectoryPath(symLink.fullPath), ignoreParentWatch); + this.addFileOrFolderInFolder(baseFolder, symLink, ignoreWatchInvokedWithTriggerAsFileCreate); + } + else { + const fullPath = getNormalizedAbsolutePath(fileOrDirectoryOrSymLink.path, this.currentDirectory); + this.ensureFolder(getDirectoryPath(fullPath), ignoreParentWatch); + this.ensureFolder(fullPath, ignoreWatchInvokedWithTriggerAsFileCreate); + } + } + + private ensureFolder(fullPath: string, ignoreWatch: boolean | undefined): FsFolder { + const path = this.toPath(fullPath); + let folder = this.fs.get(path) as FsFolder; + if (!folder) { + folder = this.toFsFolder(fullPath); + const baseFullPath = getDirectoryPath(fullPath); + if (fullPath !== baseFullPath) { + // Add folder in the base folder + const baseFolder = this.ensureFolder(baseFullPath, ignoreWatch); + this.addFileOrFolderInFolder(baseFolder, folder, ignoreWatch); + } + else { + // root folder + Debug.assert(this.fs.size === 0 || !!this.windowsStyleRoot); + this.fs.set(path, folder); + } + } + Debug.assert(isFsFolder(folder)); + return folder; + } + + private addFileOrFolderInFolder(folder: FsFolder, fileOrDirectory: FsFile | FsFolder | FsSymLink, ignoreWatch?: boolean) { + if (!this.fs.has(fileOrDirectory.path)) { + insertSorted(folder.entries, fileOrDirectory, (a, b) => compareStringsCaseSensitive(getBaseFileName(a.path), getBaseFileName(b.path))); + } + folder.modifiedTime = this.now(); + this.fs.set(fileOrDirectory.path, fileOrDirectory); + + if (ignoreWatch) { + return; + } + this.invokeFileAndFsWatches(fileOrDirectory.fullPath, FileWatcherEventKind.Created); + this.invokeFileAndFsWatches(folder.fullPath, FileWatcherEventKind.Changed); + } + + private removeFileOrFolder(fileOrDirectory: FsFile | FsFolder | FsSymLink, isRemovableLeafFolder: (folder: FsFolder) => boolean, isRenaming = false) { + const basePath = getDirectoryPath(fileOrDirectory.path); + const baseFolder = this.fs.get(basePath) as FsFolder; + if (basePath !== fileOrDirectory.path) { + Debug.assert(!!baseFolder); + baseFolder.modifiedTime = this.now(); + filterMutate(baseFolder.entries, entry => entry !== fileOrDirectory); + } + this.fs.delete(fileOrDirectory.path); + + if (isFsFolder(fileOrDirectory)) { + Debug.assert(fileOrDirectory.entries.length === 0 || isRenaming); + } + this.invokeFileAndFsWatches(fileOrDirectory.fullPath, FileWatcherEventKind.Deleted); + this.invokeFileAndFsWatches(baseFolder.fullPath, FileWatcherEventKind.Changed); + if (basePath !== fileOrDirectory.path && + baseFolder.entries.length === 0 && + isRemovableLeafFolder(baseFolder)) { + this.removeFileOrFolder(baseFolder, isRemovableLeafFolder); + } + } + + deleteFile(filePath: string) { + const path = this.toFullPath(filePath); + const currentEntry = this.fs.get(path) as FsFile; + Debug.assert(isFsFile(currentEntry)); + this.removeFileOrFolder(currentEntry, returnFalse); + } + + deleteFolder(folderPath: string, recursive?: boolean) { + const path = this.toFullPath(folderPath); + const currentEntry = this.fs.get(path) as FsFolder; + Debug.assert(isFsFolder(currentEntry)); + if (recursive && currentEntry.entries.length) { + const subEntries = currentEntry.entries.slice(); + subEntries.forEach(fsEntry => { + if (isFsFolder(fsEntry)) { + this.deleteFolder(fsEntry.fullPath, recursive); + } + else { + this.removeFileOrFolder(fsEntry, returnFalse); + } + }); + } + this.removeFileOrFolder(currentEntry, returnFalse); + } + + private watchFileWorker(fileName: string, cb: FileWatcherCallback, pollingInterval: PollingInterval) { + return createWatcher( + this.watchedFiles, + this.toFullPath(fileName), + { fileName, cb, pollingInterval } + ); + } + + private fsWatch( + fileOrDirectory: string, + _entryKind: FileSystemEntryKind, + cb: FsWatchCallback, + recursive: boolean, + fallbackPollingInterval: PollingInterval, + fallbackOptions: WatchOptions | undefined): FileWatcher { + return this.runWithFallbackPolling ? + this.watchFile( + fileOrDirectory, + createFileWatcherCallback(cb), + fallbackPollingInterval, + fallbackOptions + ) : + createWatcher( + recursive ? this.fsWatchesRecursive : this.fsWatches, + this.toFullPath(fileOrDirectory), + { + directoryName: fileOrDirectory, + cb, + fallbackPollingInterval, + fallbackOptions + } + ); + } + + invokeFileWatcher(fileFullPath: string, eventKind: FileWatcherEventKind, useFileNameInCallback?: boolean) { + invokeWatcherCallbacks(this.watchedFiles.get(this.toPath(fileFullPath)), ({ cb, fileName }) => cb(useFileNameInCallback ? fileName : fileFullPath, eventKind)); + } + + private fsWatchCallback(map: MultiMap, fullPath: string, eventName: "rename" | "change", entryFullPath?: string) { + invokeWatcherCallbacks(map.get(this.toPath(fullPath)), ({ cb }) => cb(eventName, entryFullPath ? this.getRelativePathToDirectory(fullPath, entryFullPath) : "")); + } + + invokeFsWatchesCallbacks(fullPath: string, eventName: "rename" | "change", entryFullPath?: string) { + this.fsWatchCallback(this.fsWatches, fullPath, eventName, entryFullPath); + } + + invokeFsWatchesRecursiveCallbacks(fullPath: string, eventName: "rename" | "change", entryFullPath?: string) { + this.fsWatchCallback(this.fsWatchesRecursive, fullPath, eventName, entryFullPath); + } + + private getRelativePathToDirectory(directoryFullPath: string, fileFullPath: string) { + return getRelativePathToDirectoryOrUrl(directoryFullPath, fileFullPath, this.currentDirectory, this.getCanonicalFileName, /*isAbsolutePathAnUrl*/ false); + } + + private invokeRecursiveFsWatches(fullPath: string, eventName: "rename" | "change", entryFullPath?: string) { + this.invokeFsWatchesRecursiveCallbacks(fullPath, eventName, entryFullPath); + const basePath = getDirectoryPath(fullPath); + if (this.getCanonicalFileName(fullPath) !== this.getCanonicalFileName(basePath)) { + this.invokeRecursiveFsWatches(basePath, eventName, entryFullPath || fullPath); + } + } + + private invokeFsWatches(fullPath: string, eventName: "rename" | "change") { + this.invokeFsWatchesCallbacks(fullPath, eventName); + this.invokeFsWatchesCallbacks(getDirectoryPath(fullPath), eventName, fullPath); + this.invokeRecursiveFsWatches(fullPath, eventName); + } + + private invokeFileAndFsWatches(fileOrFolderFullPath: string, eventKind: FileWatcherEventKind) { + this.invokeFileWatcher(fileOrFolderFullPath, eventKind); + this.invokeFsWatches(fileOrFolderFullPath, eventKind === FileWatcherEventKind.Changed ? "change" : "rename"); + } + + private toFsEntry(path: string): FSEntryBase { + const fullPath = getNormalizedAbsolutePath(path, this.currentDirectory); + return { + path: this.toPath(fullPath), + fullPath, + modifiedTime: this.now() + }; + } + + private toFsFile(file: File): FsFile { + const fsFile = this.toFsEntry(file.path) as FsFile; + fsFile.content = file.content; + fsFile.fileSize = file.fileSize; + return fsFile; + } + + private toFsSymLink(symLink: SymLink): FsSymLink { + const fsSymLink = this.toFsEntry(symLink.path) as FsSymLink; + fsSymLink.symLink = getNormalizedAbsolutePath(symLink.symLink, getDirectoryPath(fsSymLink.fullPath)); + return fsSymLink; + } + + private toFsFolder(path: string): FsFolder { + const fsFolder = this.toFsEntry(path) as FsFolder; + fsFolder.entries = [] as FSEntry[] as SortedArray; // https://github.com/Microsoft/TypeScript/issues/19873 + return fsFolder; + } + + private getRealFsEntry(isFsEntry: (fsEntry: FSEntry) => fsEntry is T, path: Path, fsEntry = this.fs.get(path)!): T | undefined { + if (isFsEntry(fsEntry)) { + return fsEntry; + } + + if (isFsSymLink(fsEntry)) { + return this.getRealFsEntry(isFsEntry, this.toPath(fsEntry.symLink)); + } + + if (fsEntry) { + // This fs entry is something else + return undefined; + } + + const realpath = this.toPath(this.realpath(path)); + if (path !== realpath) { + return this.getRealFsEntry(isFsEntry, realpath); + } + + return undefined; + } + + private isFsFile(fsEntry: FSEntry) { + return !!this.getRealFile(fsEntry.path, fsEntry); + } + + private getRealFile(path: Path, fsEntry?: FSEntry): FsFile | undefined { + return this.getRealFsEntry(isFsFile, path, fsEntry); + } + + private isFsFolder(fsEntry: FSEntry) { + return !!this.getRealFolder(fsEntry.path, fsEntry); + } + + private getRealFolder(path: Path, fsEntry = this.fs.get(path)): FsFolder | undefined { + return this.getRealFsEntry(isFsFolder, path, fsEntry); + } + + fileExists(s: string) { + const path = this.toFullPath(s); + return !!this.getRealFile(path); + } + + getModifiedTime(s: string) { + const path = this.toFullPath(s); + const fsEntry = this.fs.get(path); + return (fsEntry && fsEntry.modifiedTime)!; // TODO: GH#18217 + } + + setModifiedTime(s: string, date: Date) { + const path = this.toFullPath(s); + const fsEntry = this.fs.get(path); + if (fsEntry) { + fsEntry.modifiedTime = date; + this.invokeFileAndFsWatches(fsEntry.fullPath, FileWatcherEventKind.Changed); + } + } + + readFile(s: string): string | undefined { + const fsEntry = this.getRealFile(this.toFullPath(s)); + return fsEntry ? fsEntry.content : undefined; + } + + getFileSize(s: string) { + const path = this.toFullPath(s); + const entry = this.fs.get(path)!; + if (isFsFile(entry)) { + return entry.fileSize ? entry.fileSize : entry.content.length; + } + return undefined!; // TODO: GH#18217 + } + + directoryExists(s: string) { + const path = this.toFullPath(s); + return !!this.getRealFolder(path); + } + + getDirectories(s: string): string[] { + const path = this.toFullPath(s); + const folder = this.getRealFolder(path); + if (folder) { + return mapDefined(folder.entries, entry => this.isFsFolder(entry) ? getBaseFileName(entry.fullPath) : undefined); + } + Debug.fail(folder ? "getDirectories called on file" : "getDirectories called on missing folder"); + return []; + } + + readDirectory(path: string, extensions?: readonly string[], exclude?: readonly string[], include?: readonly string[], depth?: number): string[] { + return matchFiles(path, extensions, exclude, include, this.useCaseSensitiveFileNames, this.getCurrentDirectory(), depth, (dir) => { + const directories: string[] = []; + const files: string[] = []; + const folder = this.getRealFolder(this.toPath(dir)); + if (folder) { + folder.entries.forEach((entry) => { + if (this.isFsFolder(entry)) { + directories.push(getBaseFileName(entry.fullPath)); + } + else if (this.isFsFile(entry)) { + files.push(getBaseFileName(entry.fullPath)); + } + else { + Debug.fail("Unknown entry"); + } + }); + } + return { directories, files }; + }, path => this.realpath(path)); + } + + createHash(s: string): string { + return `${generateDjb2Hash(s)}-${s}`; + } + + createSHA256Hash(s: string): string { + return sys.createSHA256Hash!(s); + } + + // TOOD: record and invoke callbacks to simulate timer events + setTimeout(callback: TimeOutCallback, _time: number, ...args: any[]) { + return this.timeoutCallbacks.register(callback, args); + } + + getNextTimeoutId() { + return this.timeoutCallbacks.getNextId(); + } + + clearTimeout(timeoutId: any): void { + this.timeoutCallbacks.unregister(timeoutId); + } + + clearScreen(): void { + this.screenClears.push(this.output.length); + } + + runQueuedTimeoutCallbacks(timeoutId?: number) { + try { + this.timeoutCallbacks.invoke(timeoutId); + } + catch (e) { + if (e.message === this.exitMessage) { + return; + } + throw e; + } + } + + runQueuedImmediateCallbacks() { + this.immediateCallbacks.invoke(); + } + + setImmediate(callback: TimeOutCallback, _time: number, ...args: any[]) { + return this.immediateCallbacks.register(callback, args); + } + + clearImmediate(timeoutId: any): void { + this.immediateCallbacks.unregister(timeoutId); + } + + createDirectory(directoryName: string): void { + const folder = this.toFsFolder(directoryName); + + // base folder has to be present + const base = getDirectoryPath(folder.path); + const baseFolder = this.fs.get(base) as FsFolder; + Debug.assert(isFsFolder(baseFolder)); + + Debug.assert(!this.fs.get(folder.path)); + this.addFileOrFolderInFolder(baseFolder, folder); + } + + writeFile(path: string, content: string): void { + const file = this.toFsFile({ path, content }); + + // base folder has to be present + const base = getDirectoryPath(file.path); + const folder = Debug.checkDefined(this.getRealFolder(base)); + + if (folder.path === base) { + if (!this.fs.has(file.path)) { + this.addFileOrFolderInFolder(folder, file); + } + else { + this.modifyFile(path, content); + } + } + else { + this.writeFile(this.realpath(path), content); + } + } + + prependFile(path: string, content: string, options?: Partial): void { + this.modifyFile(path, content + this.readFile(path), options); + } + + appendFile(path: string, content: string, options?: Partial): void { + this.modifyFile(path, this.readFile(path) + content, options); + } + + write(message: string) { + this.output.push(message); + } + + getOutput(): readonly string[] { + return this.output; + } + + clearOutput() { + clear(this.output); + this.screenClears.length = 0; + } + + serializeOutput(baseline: string[]) { + const output = this.getOutput(); + let start = 0; + baseline.push("Output::"); + for (const screenClear of this.screenClears) { + baselineOutputs(baseline, output, start, screenClear); + start = screenClear; + baseline.push(">> Screen clear"); + } + baselineOutputs(baseline, output, start); + baseline.push(""); + this.clearOutput(); + } + + snap(): ESMap { + const result = new Map(); + this.fs.forEach((value, key) => { + const cloneValue = clone(value); + if (isFsFolder(cloneValue)) { + cloneValue.entries = cloneValue.entries.map(clone) as SortedArray; + } + result.set(key, cloneValue); + }); + + return result; + } + + writtenFiles?: ESMap; + diff(baseline: string[], base: ESMap = new Map()) { + this.fs.forEach(newFsEntry => { + diffFsEntry(baseline, base.get(newFsEntry.path), newFsEntry, this.writtenFiles); + }); + base.forEach(oldFsEntry => { + const newFsEntry = this.fs.get(oldFsEntry.path); + if (!newFsEntry) { + diffFsEntry(baseline, oldFsEntry, newFsEntry, this.writtenFiles); + } + }); + baseline.push(""); + } + + serializeWatches(baseline: string[]) { + serializeMultiMap(baseline, "WatchedFiles", this.watchedFiles, ({ fileName, pollingInterval }) => ({ fileName, pollingInterval })); + baseline.push(""); + serializeMultiMap(baseline, "FsWatches", this.fsWatches, serializeTestFsWatcher); + baseline.push(""); + serializeMultiMap(baseline, "FsWatchesRecursive", this.fsWatchesRecursive, serializeTestFsWatcher); + baseline.push(""); + } + + realpath(s: string): string { + const fullPath = this.toNormalizedAbsolutePath(s); + const path = this.toPath(fullPath); + if (getDirectoryPath(path) === path) { + // Root + return s; + } + const dirFullPath = this.realpath(getDirectoryPath(fullPath)); + const realFullPath = combinePaths(dirFullPath, getBaseFileName(fullPath)); + const fsEntry = this.fs.get(this.toPath(realFullPath))!; + if (isFsSymLink(fsEntry)) { + return this.realpath(fsEntry.symLink); + } + + // realpath supports non-existent files, so there may not be an fsEntry + return fsEntry?.fullPath || realFullPath; + } + + readonly exitMessage = "System Exit"; + exitCode: number | undefined; + readonly resolvePath = (s: string) => s; + readonly getExecutingFilePath = () => this.executingFilePath; + readonly getCurrentDirectory = () => this.currentDirectory; + exit(exitCode?: number) { + this.exitCode = exitCode; + throw new Error(this.exitMessage); + } + getEnvironmentVariable(name: string) { + return this.environmentVariables && this.environmentVariables.get(name) || ""; + } + } + + function diffFsFile(baseline: string[], fsEntry: FsFile) { + baseline.push(`//// [${fsEntry.fullPath}]\r\n${fsEntry.content}`, ""); + } + function diffFsSymLink(baseline: string[], fsEntry: FsSymLink) { + baseline.push(`//// [${fsEntry.fullPath}] symlink(${fsEntry.symLink})`); + } + function diffFsEntry(baseline: string[], oldFsEntry: FSEntry | undefined, newFsEntry: FSEntry | undefined, writtenFiles: ESMap | undefined): void { + const file = newFsEntry && newFsEntry.fullPath; + if (isFsFile(oldFsEntry)) { + if (isFsFile(newFsEntry)) { + if (oldFsEntry.content !== newFsEntry.content) { + diffFsFile(baseline, newFsEntry); + } + else if (oldFsEntry.modifiedTime !== newFsEntry.modifiedTime) { + if (oldFsEntry.fullPath !== newFsEntry.fullPath) { + baseline.push(`//// [${file}] file was renamed from file ${oldFsEntry.fullPath}`); + } + else if (writtenFiles && !writtenFiles.has(newFsEntry.path)) { + baseline.push(`//// [${file}] file changed its modified time`); + } + else { + baseline.push(`//// [${file}] file written with same contents`); + } + } + } + else { + baseline.push(`//// [${oldFsEntry.fullPath}] deleted`); + if (isFsSymLink(newFsEntry)) { + diffFsSymLink(baseline, newFsEntry); + } + } + } + else if (isFsSymLink(oldFsEntry)) { + if (isFsSymLink(newFsEntry)) { + if (oldFsEntry.symLink !== newFsEntry.symLink) { + diffFsSymLink(baseline, newFsEntry); + } + else if (oldFsEntry.modifiedTime !== newFsEntry.modifiedTime) { + if (oldFsEntry.fullPath !== newFsEntry.fullPath) { + baseline.push(`//// [${file}] symlink was renamed from symlink ${oldFsEntry.fullPath}`); + } + else if (writtenFiles && !writtenFiles.has(newFsEntry.path)) { + baseline.push(`//// [${file}] symlink changed its modified time`); + } + else { + baseline.push(`//// [${file}] symlink written with same link`); + } + } + } + else { + baseline.push(`//// [${oldFsEntry.fullPath}] deleted symlink`); + if (isFsFile(newFsEntry)) { + diffFsFile(baseline, newFsEntry); + } + } + } + else if (isFsFile(newFsEntry)) { + diffFsFile(baseline, newFsEntry); + } + else if (isFsSymLink(newFsEntry)) { + diffFsSymLink(baseline, newFsEntry); + } + } + + function serializeTestFsWatcher({ directoryName, fallbackPollingInterval, fallbackOptions }: TestFsWatcher) { + return { + directoryName, + fallbackPollingInterval, + fallbackOptions: serializeWatchOptions(fallbackOptions) + }; + } + + function serializeWatchOptions(fallbackOptions: WatchOptions | undefined) { + if (!fallbackOptions) return undefined; + const { watchFile, watchDirectory, fallbackPolling, ...rest } = fallbackOptions; + return { + watchFile: watchFile !== undefined ? WatchFileKind[watchFile] : undefined, + watchDirectory: watchDirectory !== undefined ? WatchDirectoryKind[watchDirectory] : undefined, + fallbackPolling: fallbackPolling !== undefined ? PollingWatchKind[fallbackPolling] : undefined, + ...rest + }; + } + + function serializeMultiMap(baseline: string[], caption: string, multiMap: MultiMap, valueMapper: (value: T) => U) { + baseline.push(`${caption}::`); + multiMap.forEach((values, key) => { + baseline.push(`${key}:`); + for (const value of values) { + baseline.push(` ${JSON.stringify(valueMapper(value))}`); + } + }); + } + + function baselineOutputs(baseline: string[], output: readonly string[], start: number, end = output.length) { + let baselinedOutput: string[] | undefined; + for (let i = start; i < end; i++) { + (baselinedOutput ||= []).push(output[i].replace(/Elapsed::\s[0-9]+(?:\.\d+)?ms/g, "Elapsed:: *ms")); + } + if (baselinedOutput) baseline.push(baselinedOutput.join("")); + } + + export const tsbuildProjectsLocation = "/user/username/projects"; + export function getTsBuildProjectFilePath(project: string, file: string) { + return `${tsbuildProjectsLocation}/${project}/${file}`; + } +}