From 6afd93989e8863b256dfe7c08f20e45dcdb21abe Mon Sep 17 00:00:00 2001 From: Nathan Shively-Sanders <293473+sandersn@users.noreply.github.com> Date: Wed, 2 Mar 2022 07:18:37 -0800 Subject: [PATCH] Split fakesHosts and some utils into vfs. I should have done this as two commits. Sorry. --- src/harness/fakesHosts.ts | 773 +++++++++----------------------------- src/harness/util.ts | 22 +- src/vfs/fakesHosts.ts | 478 +++++++++++++++++++++++ src/vfs/tsconfig.json | 4 +- src/vfs/util.ts | 24 ++ 5 files changed, 688 insertions(+), 613 deletions(-) create mode 100644 src/vfs/fakesHosts.ts create mode 100644 src/vfs/util.ts diff --git a/src/harness/fakesHosts.ts b/src/harness/fakesHosts.ts index 58b29a4c7ef..dd4b727dd08 100644 --- a/src/harness/fakesHosts.ts +++ b/src/harness/fakesHosts.ts @@ -1,591 +1,182 @@ -/** - * 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); - } - } - - /** - * 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; - - private _setParentNodes: boolean; - private _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.shouldAssertInvariants); - if (this.shouldAssertInvariants) { - Utils.assertInvariants(parsed, /*parent*/ undefined); - } - - 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; - - 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++) { - indentText += " "; - } - return ` -${indentText}${text}`; - } - - function expectedDiagnosticMessageToText([message, ...args]: ExpectedDiagnosticMessage) { - let text = ts.getLocaleSpecificMessage(message); - if (args.length) { - text = ts.formatStringFromArgs(text, args); - } - return text; - } - - function expectedDiagnosticMessageChainToText({ message, next }: ExpectedDiagnosticMessageChain, indent = 0) { - let text = indentedText(indent, expectedDiagnosticMessageToText(message)); - if (next) { - indent++; - next.forEach(kid => text += expectedDiagnosticMessageChainToText(kid, indent)); - } - return text; - } - - function expectedDiagnosticRelatedInformationToText({ location, ...diagnosticMessage }: ExpectedDiagnosticRelatedInformation) { - const text = expectedDiagnosticMessageChainToText(diagnosticMessage); - if (location) { - const { file, start, length } = location; - return `${file}(${start}:${length}):: ${text}`; - } - return text; - } - - function expectedErrorDiagnosticToText({ relatedInformation, ...diagnosticRelatedInformation }: ExpectedErrorDiagnostic) { - let text = `${DiagnosticKind.Error}!: ${expectedDiagnosticRelatedInformationToText(diagnosticRelatedInformation)}`; - if (relatedInformation) { - for (const kid of relatedInformation) { - text += ` - related:: ${expectedDiagnosticRelatedInformationToText(kid)}`; - } - } - return text; - } - - function expectedDiagnosticToText(errorOrStatus: ExpectedDiagnostic) { - return ts.isArray(errorOrStatus) ? - `${DiagnosticKind.Status}!: ${expectedDiagnosticMessageToText(errorOrStatus)}` : - expectedErrorDiagnosticToText(errorOrStatus); - } - - function diagnosticMessageChainToText({ messageText, next}: ts.DiagnosticMessageChain, indent = 0) { - let text = indentedText(indent, messageText); - if (next) { - indent++; - next.forEach(kid => text += diagnosticMessageChainToText(kid, indent)); - } - return text; - } - - function diagnosticRelatedInformationToText({ file, start, length, messageText }: ts.DiagnosticRelatedInformation) { - const text = typeof messageText === "string" ? - messageText : - diagnosticMessageChainToText(messageText); - return file ? - `${file.fileName}(${start}:${length}):: ${text}` : - text; - } - - function diagnosticToText({ kind, diagnostic: { relatedInformation, ...diagnosticRelatedInformation } }: SolutionBuilderDiagnostic) { - let text = `${kind}!: ${diagnosticRelatedInformationToText(diagnosticRelatedInformation)}`; - if (relatedInformation) { - for (const kid of relatedInformation) { - text += ` - related:: ${diagnosticRelatedInformationToText(kid)}`; - } - } - 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; - } - - 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); - assert.deepEqual(actual, expected, `Diagnostic arrays did not match: -Actual: ${JSON.stringify(actual, /*replacer*/ undefined, " ")} -Expected: ${JSON.stringify(expected, /*replacer*/ undefined, " ")}`); - } - - assertErrors(...expectedDiagnostics: ExpectedErrorDiagnostic[]) { - const actual = this.diagnostics.filter(d => d.kind === DiagnosticKind.Error).map(diagnosticToText); - const expected = expectedDiagnostics.map(expectedDiagnosticToText); - assert.deepEqual(actual, expected, `Diagnostics arrays did not match: -Actual: ${JSON.stringify(actual, /*replacer*/ undefined, " ")} -Expected: ${JSON.stringify(expected, /*replacer*/ undefined, " ")} -Actual All:: ${JSON.stringify(this.diagnostics.slice().map(diagnosticToText), /*replacer*/ undefined, " ")}`); - } - - printDiagnostics(header = "== Diagnostics ==") { - const out = ts.createDiagnosticReporter(ts.sys); - ts.sys.write(header + "\r\n"); - for (const { diagnostic } of this.diagnostics) { - out(diagnostic); - } - } - - now() { - return this.sys.now(); - } - } -} - +namespace fakes { + export class CompilerHost extends FakeCompilerHost { + public readonly shouldAssertInvariants = !Harness.lightMode; + 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.shouldAssertInvariants); + if (this.shouldAssertInvariants) { + // TODO: Move this into a subclass + Utils.assertInvariants(parsed, /*parent*/ undefined); + } + + 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; + } + + } + function indentedText(indent: number, text: string) { + if (!indent) return text; + let indentText = ""; + for (let i = 0; i < indent; i++) { + indentText += " "; + } + return ` +${indentText}${text}`; + } + + function expectedDiagnosticMessageToText([message, ...args]: ExpectedDiagnosticMessage) { + let text = ts.getLocaleSpecificMessage(message); + if (args.length) { + text = ts.formatStringFromArgs(text, args); + } + return text; + } + + function expectedDiagnosticMessageChainToText({ message, next }: ExpectedDiagnosticMessageChain, indent = 0) { + let text = indentedText(indent, expectedDiagnosticMessageToText(message)); + if (next) { + indent++; + next.forEach(kid => text += expectedDiagnosticMessageChainToText(kid, indent)); + } + return text; + } + + function diagnosticMessageChainToText({ messageText, next}: ts.DiagnosticMessageChain, indent = 0) { + let text = indentedText(indent, messageText); + if (next) { + indent++; + next.forEach(kid => text += diagnosticMessageChainToText(kid, indent)); + } + return text; + } + + function diagnosticRelatedInformationToText({ file, start, length, messageText }: ts.DiagnosticRelatedInformation) { + const text = typeof messageText === "string" ? + messageText : + diagnosticMessageChainToText(messageText); + return file ? + `${file.fileName}(${start}:${length}):: ${text}` : + text; + } + + function diagnosticToText({ kind, diagnostic: { relatedInformation, ...diagnosticRelatedInformation } }: SolutionBuilderDiagnostic) { + let text = `${kind}!: ${diagnosticRelatedInformationToText(diagnosticRelatedInformation)}`; + if (relatedInformation) { + for (const kid of relatedInformation) { + text += ` + related:: ${diagnosticRelatedInformationToText(kid)}`; + } + } + return text; + } + + function expectedDiagnosticRelatedInformationToText({ location, ...diagnosticMessage }: ExpectedDiagnosticRelatedInformation) { + const text = expectedDiagnosticMessageChainToText(diagnosticMessage); + if (location) { + const { file, start, length } = location; + return `${file}(${start}:${length}):: ${text}`; + } + return text; + } + + function expectedErrorDiagnosticToText({ relatedInformation, ...diagnosticRelatedInformation }: ExpectedErrorDiagnostic) { + let text = `${DiagnosticKind.Error}!: ${expectedDiagnosticRelatedInformationToText(diagnosticRelatedInformation)}`; + if (relatedInformation) { + for (const kid of relatedInformation) { + text += ` + related:: ${expectedDiagnosticRelatedInformationToText(kid)}`; + } + } + return text; + } + + function expectedDiagnosticToText(errorOrStatus: ExpectedDiagnostic) { + return ts.isArray(errorOrStatus) ? + `${DiagnosticKind.Status}!: ${expectedDiagnosticMessageToText(errorOrStatus)}` : + expectedErrorDiagnosticToText(errorOrStatus); + } + + export class SolutionBuilderHost extends FakeSolutionBuilderHost implements ts.SolutionBuilderHost { + 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; + } + + assertDiagnosticMessages(...expectedDiagnostics: ExpectedDiagnostic[]) { + const actual = this.diagnostics.slice().map(diagnosticToText); + const expected = expectedDiagnostics.map(expectedDiagnosticToText); + assert.deepEqual(actual, expected, `Diagnostic arrays did not match: +Actual: ${JSON.stringify(actual, /*replacer*/ undefined, " ")} +Expected: ${JSON.stringify(expected, /*replacer*/ undefined, " ")}`); + } + + assertErrors(...expectedDiagnostics: ExpectedErrorDiagnostic[]) { + const actual = this.diagnostics.filter(d => d.kind === DiagnosticKind.Error).map(diagnosticToText); + const expected = expectedDiagnostics.map(expectedDiagnosticToText); + assert.deepEqual(actual, expected, `Diagnostics arrays did not match: +Actual: ${JSON.stringify(actual, /*replacer*/ undefined, " ")} +Expected: ${JSON.stringify(expected, /*replacer*/ undefined, " ")} +Actual All:: ${JSON.stringify(this.diagnostics.slice().map(diagnosticToText), /*replacer*/ undefined, " ")}`); + } + + printDiagnostics(header = "== Diagnostics ==") { + const out = ts.createDiagnosticReporter(ts.sys); + ts.sys.write(header + "\r\n"); + for (const { diagnostic } of this.diagnostics) { + out(diagnostic); + } + } + + + } +} diff --git a/src/harness/util.ts b/src/harness/util.ts index 6e0ab3cfb7d..8dd5e46e8d9 100644 --- a/src/harness/util.ts +++ b/src/harness/util.ts @@ -78,26 +78,6 @@ 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)); @@ -109,4 +89,4 @@ namespace Utils { value === undefined ? "undefined" : JSON.stringify(value); } -} \ No newline at end of file +} diff --git a/src/vfs/fakesHosts.ts b/src/vfs/fakesHosts.ts new file mode 100644 index 00000000000..6a687bdab63 --- /dev/null +++ b/src/vfs/fakesHosts.ts @@ -0,0 +1,478 @@ +/** + * 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); + } + } + + /** + * 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 24012adea5b..03adbb14d8c 100644 --- a/src/vfs/tsconfig.json +++ b/src/vfs/tsconfig.json @@ -20,6 +20,8 @@ "collectionsImpl.ts", "documentsUtil.ts", "vpathUtil.ts", - "vfsUtil.ts" + "vfsUtil.ts", + "util.ts", + "fakesHosts.ts" ] } diff --git a/src/vfs/util.ts b/src/vfs/util.ts new file mode 100644 index 00000000000..be99958e93a --- /dev/null +++ b/src/vfs/util.ts @@ -0,0 +1,24 @@ +/** + * 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; + } +}