diff --git a/Jakefile.js b/Jakefile.js index 49641cab710..621111aa7f7 100644 --- a/Jakefile.js +++ b/Jakefile.js @@ -95,6 +95,7 @@ var harnessCoreSources = [ "harness.ts", "collections.ts", "vpath.ts", + "events.ts", "vfs.ts", "virtualFileSystemWithWatch.ts", "sourceMapRecorder.ts", diff --git a/src/harness/collections.ts b/src/harness/collections.ts index e0f5e9a2aea..7d661cdeacc 100644 --- a/src/harness/collections.ts +++ b/src/harness/collections.ts @@ -1,13 +1,23 @@ /// -namespace Collections { - import compareValues = ts.compareValues; +namespace collections { + // NOTE: Some of the functions here duplicate functionality from compiler/core.ts. They have been added + // to reduce the number of direct dependencies on compiler and services to eventually break away + // from depending directly on the compiler to speed up compilation time. + import binarySearch = ts.binarySearch; import removeAt = ts.orderedRemoveItemAt; + export function compareValues(a: T, b: T): number { + if (a === b) return 0; + if (a === undefined) return -1; + if (b === undefined) return +1; + return a < b ? -1 : +1; + } + const caseInsensitiveComparisonCollator = typeof Intl === "object" ? new Intl.Collator(/*locales*/ undefined, { usage: "sort", sensitivity: "accent" }) : undefined; const caseSensitiveComparisonCollator = typeof Intl === "object" ? new Intl.Collator(/*locales*/ undefined, { usage: "sort", sensitivity: "variant" }) : undefined; - export function compareStrings(a: string | undefined, b: string | undefined, ignoreCase?: boolean) { + export function compareStrings(a: string | undefined, b: string | undefined, ignoreCase: boolean) { if (a === b) return 0; if (a === undefined) return -1; if (b === undefined) return +1; @@ -23,13 +33,8 @@ namespace Collections { } export namespace compareStrings { - export function caseSensitive(a: string | undefined, b: string | undefined) { - return compareStrings(a, b, /*ignoreCase*/ false); - } - - export function caseInsensitive(a: string | undefined, b: string | undefined) { - return compareStrings(a, b, /*ignoreCase*/ true); - } + export function caseSensitive(a: string | undefined, b: string | undefined) { return compareStrings(a, b, /*ignoreCase*/ false); } + export function caseInsensitive(a: string | undefined, b: string | undefined) { return compareStrings(a, b, /*ignoreCase*/ true); } } function insertAt(array: T[], index: number, value: T) { @@ -50,7 +55,7 @@ namespace Collections { /** * A collection of key/value pairs sorted by key. */ - export class KeyedCollection { + export class SortedCollection { private _comparer: (a: K, b: K) => number; private _keys: K[] = []; private _values: V[] = []; @@ -145,6 +150,14 @@ namespace Collections { const undefinedSentinel = {}; + function escapeKey(text: string) { + return (text.length >= 2 && text.charAt(0) === "_" && text.charAt(1) === "_" ? "_" + text : text); + } + + function unescapeKey(text: string) { + return (text.length >= 3 && text.charAt(0) === "_" && text.charAt(1) === "_" && text.charAt(2) === "_" ? text.slice(1) : text); + } + /** * A collection of metadata that supports inheritance. */ @@ -173,24 +186,25 @@ namespace Collections { } public has(key: string): boolean { - return this._map[key] !== undefined; + return this._map[escapeKey(key)] !== undefined; } public get(key: string): any { - const value = this._map[key]; + const value = this._map[escapeKey(key)]; return value === undefinedSentinel ? undefined : value; } public set(key: string, value: any): this { - this._map[key] = value === undefined ? undefinedSentinel : value; + this._map[escapeKey(key)] = value === undefined ? undefinedSentinel : value; this._size = -1; this._version++; return this; } public delete(key: string): boolean { - if (this._map[key] !== undefined) { - delete this._map[key]; + const escapedKey = escapeKey(key); + if (this._map[escapedKey] !== undefined) { + delete this._map[escapedKey]; this._size = -1; this._version++; return true; @@ -206,7 +220,7 @@ namespace Collections { public forEach(callback: (value: any, key: string, map: this) => void) { for (const key in this._map) { - callback(this._map[key], key, this); + callback(this._map[key], unescapeKey(key), this); } } } diff --git a/src/harness/events.ts b/src/harness/events.ts new file mode 100644 index 00000000000..9443bad9dfe --- /dev/null +++ b/src/harness/events.ts @@ -0,0 +1,26 @@ +/// +namespace events { + const _events = require("events"); + + export const EventEmitter: { + new (): EventEmitter; + prototype: EventEmitter; + defaultMaxListeners: number; + } = _events.EventEmitter; + + export interface EventEmitter { + on(event: string | symbol, listener: (...args: any[]) => void): this; + once: this["on"]; + addListener: this["on"]; + prependListener: this["on"]; + prependOnceListener: this["on"]; + removeListener: this["on"]; + removeAllListeners(event?: string | symbol): this; + setMaxListeners(n: number): this; + getMaxListeners(): number; + listeners(event: string | symbol): Function[]; + emit(event: string | symbol, ...args: any[]): boolean; + eventNames(): (string | symbol)[]; + listenerCount(type: string | symbol): number; + } +} \ No newline at end of file diff --git a/src/harness/harness.ts b/src/harness/harness.ts index f24ee950d7d..f9340088279 100644 --- a/src/harness/harness.ts +++ b/src/harness/harness.ts @@ -32,7 +32,6 @@ // this will work in the browser via browserify var _chai: typeof chai = require("chai"); var assert: typeof _chai.assert = _chai.assert; -declare var __dirname: string; // Node-specific var global: NodeJS.Global = Function("return this").call(undefined); declare var window: {}; @@ -43,9 +42,13 @@ interface XMLHttpRequest { readonly readyState: number; readonly responseText: string; readonly status: number; + readonly statusText: string; open(method: string, url: string, async?: boolean, user?: string, password?: string): void; send(data?: string): void; setRequestHeader(header: string, value: string): void; + getAllResponseHeaders(): string; + getResponseHeader(header: string): string | null; + overrideMimeType(mime: string): void; } /* tslint:enable:no-var-keyword */ @@ -489,16 +492,23 @@ namespace Harness { fileExists(fileName: string): boolean; directoryExists(path: string): boolean; deleteFile(fileName: string): void; - listFiles(path: string, filter: RegExp, options?: { recursive?: boolean }): string[]; + listFiles(path: string, filter?: RegExp, options?: { recursive?: boolean }): string[]; log(text: string): void; - getMemoryUsage?(): number; args(): string[]; getExecutingFilePath(): string; exit(exitCode?: number): void; readDirectory(path: string, extension?: ReadonlyArray, exclude?: ReadonlyArray, include?: ReadonlyArray, depth?: number): string[]; + getAccessibleFileSystemEntries(dirname: string): FileSystemEntries; tryEnableSourceMapsForHost?(): void; getEnvironmentVariable?(name: string): string; + getMemoryUsage?(): number; } + + export interface FileSystemEntries { + files: string[]; + directories: string[]; + } + export let IO: IO; // harness always uses one kind of new line @@ -508,253 +518,380 @@ namespace Harness { // Root for file paths that are stored in a virtual file system export const virtualFileSystemRoot = "/"; - namespace IOImpl { - export namespace Node { - declare const require: any; - let fs: any, pathModule: any; - if (require) { - fs = require("fs"); - pathModule = require("path"); - } - else { - fs = pathModule = {}; - } - - export const resolvePath = (path: string) => ts.sys.resolvePath(path); - export const getCurrentDirectory = () => ts.sys.getCurrentDirectory(); - export const newLine = () => harnessNewLine; - export const useCaseSensitiveFileNames = () => ts.sys.useCaseSensitiveFileNames; - export const args = () => ts.sys.args; - export const getExecutingFilePath = () => ts.sys.getExecutingFilePath(); - export const exit = (exitCode: number) => ts.sys.exit(exitCode); - export const getDirectories: typeof IO.getDirectories = path => ts.sys.getDirectories(path); - - export const readFile: typeof IO.readFile = path => ts.sys.readFile(path); - export const writeFile: typeof IO.writeFile = (path, content) => ts.sys.writeFile(path, content); - export const fileExists: typeof IO.fileExists = fs.existsSync; - export const log: typeof IO.log = s => console.log(s); - export const getEnvironmentVariable: typeof IO.getEnvironmentVariable = name => ts.sys.getEnvironmentVariable(name); - - export function tryEnableSourceMapsForHost() { - if (ts.sys.tryEnableSourceMapsForHost) { - ts.sys.tryEnableSourceMapsForHost(); - } - } - export const readDirectory: typeof IO.readDirectory = (path, extension, exclude, include, depth) => ts.sys.readDirectory(path, extension, exclude, include, depth); - - export function createDirectory(path: string) { - if (!directoryExists(path)) { - fs.mkdirSync(path); - } - } - - export function deleteFile(path: string) { - try { - fs.unlinkSync(path); - } - catch (e) { - } - } - - export function directoryExists(path: string): boolean { - return fs.existsSync(path) && fs.statSync(path).isDirectory(); - } - - export function directoryName(path: string) { - const dirPath = pathModule.dirname(path); - // Node will just continue to repeat the root path, rather than return null - return dirPath === path ? undefined : dirPath; - } - - export let listFiles: typeof IO.listFiles = (path, spec?, options?) => { - options = options || {}; - - function filesInFolder(folder: string): string[] { - let paths: string[] = []; - - const files = fs.readdirSync(folder); - for (let i = 0; i < files.length; i++) { - const pathToFile = pathModule.join(folder, files[i]); - const stat = fs.statSync(pathToFile); - if (options.recursive && stat.isDirectory()) { - paths = paths.concat(filesInFolder(pathToFile)); - } - else if (stat.isFile() && (!spec || files[i].match(spec))) { - paths.push(pathToFile); - } - } - - return paths; - } - - return filesInFolder(path); - }; - - export let getMemoryUsage: typeof IO.getMemoryUsage = () => { - if (global.gc) { - global.gc(); - } - return process.memoryUsage().heapUsed; - }; + function createNodeIO(): IO { + let fs: any, pathModule: any; + if (require) { + fs = require("fs"); + pathModule = require("path"); + } + else { + fs = pathModule = {}; } - export namespace Network { - const serverRoot = "http://localhost:8888/"; - - export const newLine = () => harnessNewLine; - export const useCaseSensitiveFileNames = () => false; - export const getCurrentDirectory = () => ""; - export const args = () => []; - export const getExecutingFilePath = () => ""; - export const exit = ts.noop; - export const getDirectories = () => []; - - export let log = (s: string) => console.log(s); - - namespace Http { - function waitForXHR(xhr: XMLHttpRequest) { - while (xhr.readyState !== 4) { } - return { status: xhr.status, responseText: xhr.responseText }; - } - - /// Ask the server to use node's path.resolve to resolve the given path - - export interface XHRResponse { - status: number; - responseText: string; - } - - /// Ask the server for the contents of the file at the given URL via a simple GET request - export function getFileFromServerSync(url: string): XHRResponse { - const xhr = new XMLHttpRequest(); - try { - xhr.open("GET", url, /*async*/ false); - xhr.send(); - } - catch (e) { - return { status: 404, responseText: undefined }; - } - - return waitForXHR(xhr); - } - - /// Submit a POST request to the server to do the given action (ex WRITE, DELETE) on the provided URL - export function writeToServerSync(url: string, action: string, contents?: string): XHRResponse { - const xhr = new XMLHttpRequest(); - try { - const actionMsg = "?action=" + action; - xhr.open("POST", url + actionMsg, /*async*/ false); - xhr.setRequestHeader("Access-Control-Allow-Origin", "*"); - xhr.send(contents); - } - catch (e) { - log(`XHR Error: ${e}`); - return { status: 500, responseText: undefined }; - } - - return waitForXHR(xhr); - } + function deleteFile(path: string) { + try { + fs.unlinkSync(path); } - - export function createDirectory() { - // Do nothing (?) - } - - export function deleteFile(path: string) { - Http.writeToServerSync(serverRoot + path, "DELETE"); - } - - export function directoryExists(): boolean { - return false; - } - - function directoryNameImpl(path: string) { - let dirPath = path; - // root of the server - if (dirPath.match(/localhost:\d+$/) || dirPath.match(/localhost:\d+\/$/)) { - dirPath = undefined; - // path + fileName - } - else if (dirPath.indexOf(".") === -1) { - dirPath = dirPath.substring(0, dirPath.lastIndexOf("/")); - // path - } - else { - // strip any trailing slash - if (dirPath.match(/.*\/$/)) { - dirPath = dirPath.substring(0, dirPath.length - 2); - } - dirPath = dirPath.substring(0, dirPath.lastIndexOf("/")); - } - - return dirPath; - } - export let directoryName: typeof IO.directoryName = Utils.memoize(directoryNameImpl, path => path); - - export function resolvePath(path: string) { - const response = Http.getFileFromServerSync(serverRoot + path + "?resolve=true"); - if (response.status === 200) { - return response.responseText; - } - else { - return undefined; - } - } - - export function fileExists(path: string): boolean { - const response = Http.getFileFromServerSync(serverRoot + path); - return response.status === 200; - } - - export const listFiles = Utils.memoize((path: string, spec?: RegExp, options?: { recursive?: boolean }): string[] => { - const response = Http.getFileFromServerSync(serverRoot + path); - if (response.status === 200) { - let results = response.responseText.split(","); - if (spec) { - results = results.filter(file => spec.test(file)); - } - if (options && !options.recursive) { - results = results.filter(file => (ts.getDirectoryPath(ts.normalizeSlashes(file)) === path)); - } - return results; - } - else { - return [""]; - } - }, (path: string, spec?: RegExp, options?: { recursive?: boolean }) => `${path}|${spec}|${options ? options.recursive : undefined}`); - - export function readFile(file: string): string | undefined { - const response = Http.getFileFromServerSync(serverRoot + file); - if (response.status === 200) { - return response.responseText; - } - else { - return undefined; - } - } - - export function writeFile(path: string, contents: string) { - Http.writeToServerSync(serverRoot + path, "WRITE", contents); - } - - export function readDirectory(path: string, extension?: string[], exclude?: string[], include?: string[], depth?: number) { - const fs = new Utils.VirtualFileSystem(path, useCaseSensitiveFileNames()); - for (const file of listFiles(path)) { - fs.addFile(file); - } - return ts.matchFiles(path, extension, exclude, include, useCaseSensitiveFileNames(), getCurrentDirectory(), depth, path => { - const entry = fs.getEntry(path); - if (entry instanceof Utils.VirtualDirectory) { - const directory = entry; - return { - files: ts.map(directory.getFiles(), f => f.name), - directories: ts.map(directory.getDirectories(), d => d.name) - }; - } - return { files: [], directories: [] }; - }); + catch (e) { } } + + function directoryName(path: string) { + const dirPath = pathModule.dirname(path); + // Node will just continue to repeat the root path, rather than return null + return dirPath === path ? undefined : dirPath; + } + + function listFiles(path: string, spec: RegExp, options?: { recursive?: boolean }) { + options = options || {}; + + function filesInFolder(folder: string): string[] { + let paths: string[] = []; + + const files = fs.readdirSync(folder); + for (let i = 0; i < files.length; i++) { + const pathToFile = pathModule.join(folder, files[i]); + const stat = fs.statSync(pathToFile); + if (options.recursive && stat.isDirectory()) { + paths = paths.concat(filesInFolder(pathToFile)); + } + else if (stat.isFile() && (!spec || files[i].match(spec))) { + paths.push(pathToFile); + } + } + + return paths; + } + + return filesInFolder(path); + } + + function getAccessibleFileSystemEntries(dirname: string): FileSystemEntries { + try { + const entries: string[] = fs.readdirSync(dirname || ".").sort(ts.sys.useCaseSensitiveFileNames ? ts.compareStrings : ts.compareStringsCaseInsensitive); + const files: string[] = []; + const directories: string[] = []; + for (const entry of entries) { + if (entry === "." || entry === "..") continue; + const name = vpath.combine(dirname, entry); + try { + const stat = fs.statSync(name); + if (!stat) continue; + if (stat.isFile()) { + files.push(entry); + } + else if (stat.isDirectory()) { + directories.push(entry); + } + } + catch (e) { } + } + return { files, directories }; + } + catch (e) { + return { files: [], directories: [] }; + } + } + + return { + newLine: () => harnessNewLine, + getCurrentDirectory: () => ts.sys.getCurrentDirectory(), + useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames, + resolvePath: (path: string) => ts.sys.resolvePath(path), + readFile: path => ts.sys.readFile(path), + writeFile: (path, content) => ts.sys.writeFile(path, content), + directoryName, + getDirectories: path => ts.sys.getDirectories(path), + createDirectory: path => ts.sys.createDirectory(path), + fileExists: path => ts.sys.fileExists(path), + directoryExists: path => ts.sys.directoryExists(path), + deleteFile, + listFiles, + log: s => console.log(s), + args: () => ts.sys.args, + getExecutingFilePath: () => ts.sys.getExecutingFilePath(), + exit: exitCode => ts.sys.exit(exitCode), + readDirectory: (path, extension, exclude, include, depth) => ts.sys.readDirectory(path, extension, exclude, include, depth), + getAccessibleFileSystemEntries, + tryEnableSourceMapsForHost: () => ts.sys.tryEnableSourceMapsForHost && ts.sys.tryEnableSourceMapsForHost(), + getMemoryUsage: () => ts.sys.getMemoryUsage && ts.sys.getMemoryUsage(), + getEnvironmentVariable: name => ts.sys.getEnvironmentVariable(name), + }; + } + + interface URL { + hash: string; + host: string; + hostname: string; + href: string; + password: string; + pathname: string; + port: string; + protocol: string; + search: string; + username: string; + toString(): string; + } + + declare var URL: { + prototype: URL; + new(url: string, base?: string | URL): URL; + }; + + function createBrowserIO(): IO { + const serverRoot = new URL("http://localhost:8888/"); + + interface HttpHeaders { + [key: string]: string | string[] | undefined; + } + + const HttpHeaders = { + combine(left: HttpHeaders | undefined, right: HttpHeaders | undefined): HttpHeaders { + return left && right ? { ...left, ...right } : + left ? { ...left } : + right ? { ...right } : + {}; + }, + writeRequestHeaders(xhr: XMLHttpRequest, headers: HttpHeaders) { + for (const key in headers) { + if (!headers.hasOwnProperty(key)) continue; + const keyLower = key.toLowerCase(); + + if (keyLower === "access-control-allow-origin" || keyLower === "content-length") continue; + const values = headers[key]; + const value = Array.isArray(values) ? values.join(",") : values; + if (keyLower === "content-type") { + xhr.overrideMimeType(value); + continue; + } + + xhr.setRequestHeader(key, value); + } + }, + readResponseHeaders(xhr: XMLHttpRequest): HttpHeaders { + const allHeaders = xhr.getAllResponseHeaders(); + const headers: HttpHeaders = {}; + for (const header of allHeaders.split(/\r\n/g)) { + const colonIndex = header.indexOf(":"); + if (colonIndex >= 0) { + const key = header.slice(0, colonIndex).trim(); + const value = header.slice(colonIndex + 1).trim(); + const values = value.split(","); + headers[key] = values.length > 1 ? values : value; + } + } + return headers; + } + }; + + interface HttpContent { + headers: HttpHeaders; + content: string; + } + + const HttpContent = { + create(headers: HttpHeaders, content: string): HttpContent { + return { headers, content }; + }, + fromMediaType(mediaType: string, content: string) { + return HttpContent.create({ "Content-Type": mediaType }, content); + }, + text(content: string) { + return HttpContent.fromMediaType("text/plain", content); + }, + json(content: object) { + return HttpContent.fromMediaType("application/json", JSON.stringify(content)); + }, + readResponseContent(xhr: XMLHttpRequest) { + if (typeof xhr.responseText === "string") { + return HttpContent.create({ + "Content-Type": xhr.getResponseHeader("Content-Type") || undefined, + "Content-Length": xhr.getResponseHeader("Content-Length") || undefined + }, xhr.responseText); + } + return undefined; + } + }; + + interface HttpRequestMessage { + method: string; + url: URL; + headers: HttpHeaders; + content?: HttpContent; + } + + const HttpRequestMessage = { + create(method: string, url: string | URL, headers: HttpHeaders = {}, content?: HttpContent): HttpRequestMessage { + if (typeof url === "string") url = new URL(url); + return { method, url, headers, content }; + }, + options(url: string | URL) { + return HttpRequestMessage.create("OPTIONS", url); + }, + head(url: string | URL) { + return HttpRequestMessage.create("HEAD", url); + }, + get(url: string | URL) { + return HttpRequestMessage.create("GET", url); + }, + delete(url: string | URL) { + return HttpRequestMessage.create("DELETE", url); + }, + put(url: string | URL, content: HttpContent) { + return HttpRequestMessage.create("PUT", url, {}, content); + }, + post(url: string | URL, content: HttpContent) { + return HttpRequestMessage.create("POST", url, {}, content); + }, + }; + + interface HttpResponseMessage { + statusCode: number; + statusMessage: string; + headers: HttpHeaders; + content?: HttpContent; + } + + const HttpResponseMessage = { + create(statusCode: number, statusMessage: string, headers: HttpHeaders = {}, content?: HttpContent): HttpResponseMessage { + return { statusCode, statusMessage, headers, content }; + }, + notFound(): HttpResponseMessage { + return HttpResponseMessage.create(404, "Not Found"); + }, + hasSuccessStatusCode(response: HttpResponseMessage) { + return response.statusCode === 304 || (response.statusCode >= 200 && response.statusCode < 300); + }, + readResponseMessage(xhr: XMLHttpRequest) { + return HttpResponseMessage.create( + xhr.status, + xhr.statusText, + HttpHeaders.readResponseHeaders(xhr), + HttpContent.readResponseContent(xhr)); + } + }; + + function send(request: HttpRequestMessage): HttpResponseMessage { + const xhr = new XMLHttpRequest(); + try { + HttpHeaders.writeRequestHeaders(xhr, request.headers); + HttpHeaders.writeRequestHeaders(xhr, request.content && request.content.headers); + xhr.setRequestHeader("Access-Control-Allow-Origin", "*"); + xhr.open(request.method, request.url.toString(), /*async*/ false); + xhr.send(request.content && request.content.content); + while (xhr.readyState !== 4); // block until ready + return HttpResponseMessage.readResponseMessage(xhr); + } + catch (e) { + return HttpResponseMessage.notFound(); + } + } + + let caseSensitivity: "CI" | "CS" | undefined; + + function useCaseSensitiveFileNames() { + if (!caseSensitivity) { + const response = send(HttpRequestMessage.options(new URL("*", serverRoot))); + const xCaseSensitivity = response.headers["X-Case-Sensitivity"]; + caseSensitivity = xCaseSensitivity === "CS" ? "CS" : "CI"; + } + return caseSensitivity === "CS"; + } + + function resolvePath(path: string) { + const response = send(HttpRequestMessage.post(new URL("/api/resolve", serverRoot), HttpContent.text(path))); + return HttpResponseMessage.hasSuccessStatusCode(response) && response.content ? response.content.content : undefined; + } + + function readFile(path: string): string | undefined { + const response = send(HttpRequestMessage.get(new URL(path, serverRoot))); + return HttpResponseMessage.hasSuccessStatusCode(response) && response.content ? response.content.content : undefined; + } + + function writeFile(path: string, contents: string) { + send(HttpRequestMessage.put(new URL(path, serverRoot), HttpContent.text(contents))); + } + + function fileExists(path: string): boolean { + const response = send(HttpRequestMessage.head(new URL(path, serverRoot))); + return HttpResponseMessage.hasSuccessStatusCode(response); + } + + function directoryExists(path: string): boolean { + const response = send(HttpRequestMessage.post(new URL("/api/directoryExists", serverRoot), HttpContent.text(path))); + return HttpResponseMessage.hasSuccessStatusCode(response) + && (response.content && response.content.content) === "true"; + } + + function deleteFile(path: string) { + send(HttpRequestMessage.delete(new URL(path, serverRoot))); + } + + function directoryName(path: string) { + const url = new URL(path, serverRoot); + return ts.getDirectoryPath(ts.normalizeSlashes(url.pathname || "/")); + } + + function listFiles(dirname: string, spec?: RegExp, options?: { recursive?: boolean }): string[] { + if (spec || (options && !options.recursive)) { + let results = IO.listFiles(dirname); + if (spec) { + results = results.filter(file => spec.test(file)); + } + if (options && !options.recursive) { + results = results.filter(file => ts.getDirectoryPath(ts.normalizeSlashes(file)) === dirname); + } + return results; + } + + const response = send(HttpRequestMessage.post(new URL("/api/listFiles", serverRoot), HttpContent.text(dirname))); + return HttpResponseMessage.hasSuccessStatusCode(response) + && response.content + && response.content.headers["Content-Type"] === "application/json" + ? JSON.parse(response.content.content) + : []; + } + + function readDirectory(path: string, extension?: string[], exclude?: string[], include?: string[], depth?: number) { + const fs = new vfs.VirtualFileSystem(path, useCaseSensitiveFileNames()); + fs.addFiles(IO.listFiles(path)); + return ts.matchFiles(path, extension, exclude, include, useCaseSensitiveFileNames(), /*currentDirectory*/ "", depth, path => getAccessibleVirtualFileSystemEntries(fs, path)); + } + + function getAccessibleFileSystemEntries(dirname: string): FileSystemEntries { + const fs = new vfs.VirtualFileSystem(dirname, useCaseSensitiveFileNames()); + fs.addFiles(IO.listFiles(dirname)); + return getAccessibleVirtualFileSystemEntries(fs, dirname); + } + + function getAccessibleVirtualFileSystemEntries(fs: vfs.VirtualFileSystem, dirname: string): FileSystemEntries { + const directory = fs.getDirectory(dirname); + return directory + ? { files: directory.getFileNames(), directories: directory.getDirectoryNames() } + : { files: [], directories: [] }; + } + + return { + newLine: () => harnessNewLine, + getCurrentDirectory: () => "", + useCaseSensitiveFileNames, + resolvePath, + readFile, + writeFile, + directoryName: Utils.memoize(directoryName, path => path), + getDirectories: () => [], + createDirectory: () => {}, + fileExists, + directoryExists, + deleteFile, + listFiles: Utils.memoize(listFiles, (path, spec, options) => `${path}|${spec}|${options ? options.recursive === true : true}`), + log: s => console.log(s), + args: () => [], + getExecutingFilePath: () => "", + exit: () => {}, + readDirectory, + getAccessibleFileSystemEntries + }; } export function mockHash(s: string): string { @@ -764,10 +901,10 @@ namespace Harness { const environment = Utils.getExecutionEnvironment(); switch (environment) { case Utils.ExecutionEnvironment.Node: - IO = IOImpl.Node; + IO = createNodeIO(); break; case Utils.ExecutionEnvironment.Browser: - IO = IOImpl.Network; + IO = createBrowserIO(); break; default: throw new Error(`Unknown value '${environment}' for ExecutionEnvironment.`); diff --git a/src/harness/harnessLanguageService.ts b/src/harness/harnessLanguageService.ts index 9e353a14351..ebfd9b9b02b 100644 --- a/src/harness/harnessLanguageService.ts +++ b/src/harness/harnessLanguageService.ts @@ -123,7 +123,7 @@ namespace Harness.LanguageService { } export class LanguageServiceAdapterHost { - protected virtualFileSystem: Utils.VirtualFileSystem = new Utils.VirtualFileSystem(virtualFileSystemRoot, /*useCaseSensitiveFilenames*/ false); + protected virtualFileSystem: vfs.VirtualFileSystem = new vfs.VirtualFileSystem(virtualFileSystemRoot, /*useCaseSensitiveFilenames*/ false); constructor(protected cancellationToken = DefaultHostCancellationToken.Instance, protected settings = ts.getDefaultCompilerOptions()) { @@ -135,8 +135,8 @@ namespace Harness.LanguageService { public getFilenames(): string[] { const fileNames: string[] = []; - for (const virtualEntry of this.virtualFileSystem.getFiles({ recursive: true })) { - const scriptInfo = virtualEntry.metadata.get("scriptInfo"); + for (const virtualEntry of this.virtualFileSystem.getDirectory("/").getFiles({ recursive: true })) { + const scriptInfo = virtualEntry.metadata.get("scriptInfo") as ScriptInfo; if (scriptInfo && scriptInfo.isRootFile) { // only include root files here // usually it means that we won't include lib.d.ts in the list of root files so it won't mess the computation of compilation root dir. @@ -160,7 +160,7 @@ namespace Harness.LanguageService { const script = file && file.metadata.get("scriptInfo") as ScriptInfo; if (script) { script.editContent(start, end, newText); - file.setContent(script.content); + file.content = script.content; return; } diff --git a/src/harness/tsconfig.json b/src/harness/tsconfig.json index 2838ba5b0c4..dd8719a1bee 100644 --- a/src/harness/tsconfig.json +++ b/src/harness/tsconfig.json @@ -99,6 +99,7 @@ "runner.ts", "collections.ts", "vpath.ts", + "events.ts", "vfs.ts", "virtualFileSystemWithWatch.ts", "../server/protocol.ts", diff --git a/src/harness/vfs.ts b/src/harness/vfs.ts index bd33e632d6f..42ac54b076f 100644 --- a/src/harness/vfs.ts +++ b/src/harness/vfs.ts @@ -1,70 +1,533 @@ /// /// /// +/// /// -namespace Utils { - import compareStrings = Collections.compareStrings; - import removeAt = ts.orderedRemoveItemAt; - import KeyedCollection = Collections.KeyedCollection; - import Metadata = Collections.Metadata; +namespace vfs { + import compareStrings = collections.compareStrings; + import KeyedCollection = collections.SortedCollection; + import Metadata = collections.Metadata; + import EventEmitter = events.EventEmitter; + import IO = Harness.IO; - // import IO = Harness.IO; + export interface PathMappings { + [path: string]: string; + } - // export interface PathMappings { - // [path: string]: string; - // } + export interface FileSystemResolver { + getEntries(dir: VirtualDirectory): { files: string[], directories: string[] }; + getContent(file: VirtualFile): string | undefined; + } - // export interface FileSystemResolver { - // getEntries(dir: VirtualDirectory): { files: string[], directories: string[] }; - // getContent(file: VirtualFile): string | undefined; - // } + export type ContentResolver = FileSystemResolver["getContent"]; - // function createMapper(ignoreCase: boolean, map: PathMappings | undefined) { - // if (!map) return identity; - // const roots = Object.keys(map); - // const patterns = roots.map(root => createPattern(root, ignoreCase)); - // return function (path: string) { - // for (let i = 0; i < patterns.length; i++) { - // const match = patterns[i].exec(path); - // if (match) { - // const prefix = path.slice(0, match.index); - // const suffix = path.slice(match.index + match[0].length); - // return vpath.combine(prefix, map[roots[i]], suffix); - // } - // } - // return path; - // }; - // } + function identityMapper(path: string) { return path; } - // function createPattern(path: string, ignoreCase: boolean) { - // path = vpath.normalizeSlashes(path); - // const components = vpath.parse(path); - // let pattern = ""; - // for (let i = 1; i < components.length; i++) { - // const component = components[i]; - // if (pattern) pattern += "/"; - // pattern += escapeRegExp(component); - // } - // pattern = (components[0] ? "^" + escapeRegExp(components[0]) : "/") + pattern + "(/|$)"; - // return new RegExp(pattern, ignoreCase ? "i" : ""); - // } + function createMapper(ignoreCase: boolean, map: PathMappings | undefined) { + if (!map) return identityMapper; + const roots = Object.keys(map); + const patterns = roots.map(root => createPattern(root, ignoreCase)); + return function (path: string) { + for (let i = 0; i < patterns.length; i++) { + const match = patterns[i].exec(path); + if (match) { + const prefix = path.slice(0, match.index); + const suffix = path.slice(match.index + match[0].length); + return vpath.combine(prefix, map[roots[i]], suffix); + } + } + return path; + }; + } - // export function createResolver(io: IO, map?: PathMappings): FileSystemResolver { - // const mapper = createMapper(!io.useCaseSensitiveFileNames(), map); - // return { - // getEntries(dir) { - // return io.getAccessibleFileSystemEntries(mapper(dir.path)); - // }, - // getContent(file) { - // return io.readFile(mapper(file.path)); - // } - // }; - // } + const reservedCharacterRegExp = /[^\w\s\/]/g; - export type FindAxis = "ancestors" | "ancestors-or-self" | "self" | "descendents-or-self" | "descendents"; + function escapeRegExp(pattern: string) { + return pattern.replace(reservedCharacterRegExp, match => "\\" + match); + } - export abstract class VirtualFileSystemEntry { + function createPattern(path: string, ignoreCase: boolean) { + path = vpath.normalizeSeparators(path); + const components = vpath.parse(path); + let pattern = ""; + for (let i = 1; i < components.length; i++) { + const component = components[i]; + if (pattern) pattern += "/"; + pattern += escapeRegExp(component); + } + pattern = (components[0] ? "^" + escapeRegExp(components[0]) : "/") + pattern + "(/|$)"; + return new RegExp(pattern, ignoreCase ? "i" : ""); + } + + export function createResolver(io: IO, map?: PathMappings): FileSystemResolver { + const mapper = createMapper(!io.useCaseSensitiveFileNames(), map); + return { + getEntries(dir) { + return io.getAccessibleFileSystemEntries(mapper(dir.path)); + }, + getContent(file) { + return io.readFile(mapper(file.path)); + } + }; + } + + export type Axis = "ancestors" | "ancestors-or-self" | "self" | "descendents-or-self" | "descendents"; + export type FileSystemChange = "added" | "modified" | "removed"; + export type VirtualEntry = VirtualFile | VirtualDirectory; + export type VirtualSymlink = VirtualFileSymlink | VirtualDirectorySymlink; + + type VirtualEntryView = VirtualFileView | VirtualDirectoryView; + + interface FileWatcherEntry { + watcher: (path: string, change: FileSystemChange) => void; + } + + interface DirectoryWatcherEntryArray extends Array { + recursiveCount?: number; + } + + interface DirectoryWatcherEntry { + watcher: (path: string) => void; + recursive: boolean; + } + + export abstract class VirtualFileSystemObject extends EventEmitter { private _readonly = false; + + /** + * Gets a value indicating whether this entry is read-only. + */ + public get isReadOnly(): boolean { + return this._readonly; + } + + public makeReadOnly(): void { + this.makeReadOnlyCore(); + this._readonly = true; + } + + protected abstract makeReadOnlyCore(): void; + + protected writePreamble(): void { + if (this._readonly) throw new Error("Cannot modify a frozen entry."); + } + } + + export class VirtualFileSystem extends VirtualFileSystemObject { + private static _builtLocal: VirtualFileSystem | undefined; + private static _builtLocalCI: VirtualFileSystem | undefined; + private static _builtLocalCS: VirtualFileSystem | undefined; + + private _root: VirtualRoot; + private _useCaseSensitiveFileNames: boolean; + private _currentDirectory: string; + private _currentDirectoryStack: string[] | undefined; + private _shadowRoot: VirtualFileSystem | undefined; + private _watchedFiles: KeyedCollection | undefined; + private _watchedDirectories: KeyedCollection | undefined; + private _onRootFileSystemChange: (path: string, change: FileSystemChange) => void; + + constructor(currentDirectory: string, useCaseSensitiveFileNames: boolean) { + super(); + this._currentDirectory = currentDirectory.replace(/\\/g, "/"); + this._useCaseSensitiveFileNames = useCaseSensitiveFileNames; + this._onRootFileSystemChange = (path, change) => this.onRootFileSystemChange(path, change); + } + + /** + * Gets the file system shadowed by this instance. + */ + public get shadowRoot(): VirtualFileSystem | undefined { + return this._shadowRoot; + } + + /** + * Gets a value indicating whether to use case sensitive file names. + */ + public get useCaseSensitiveFileNames() { + return this._useCaseSensitiveFileNames; + } + + /** + * Gets the path to the current directory. + */ + public get currentDirectory() { + return this._currentDirectory; + } + + private get root(): VirtualRoot { + if (this._root === undefined) { + if (this._shadowRoot) { + this._root = this._shadowRoot.root._shadow(this); + } + else { + this._root = new VirtualRoot(this); + } + this._root.addListener("fileSystemChange", this._onRootFileSystemChange); + if (this.isReadOnly) this._root.makeReadOnly(); + } + return this._root; + } + + /** + * Gets a virtual file system with the following entries: + * + * | path | physical/virtual | + * |:-------|:----------------------| + * | /.ts | physical: built/local | + * | /.lib | physical: tests/lib | + * | /.test | virtual | + */ + public static getBuiltLocal(useCaseSensitiveFileNames: boolean = IO.useCaseSensitiveFileNames()): VirtualFileSystem { + let vfs = useCaseSensitiveFileNames ? this._builtLocalCS : this._builtLocalCI; + if (!vfs) { + vfs = this._builtLocal; + if (!vfs) { + const resolver = createResolver(IO, { + "/.ts": __dirname, + "/.lib": vpath.resolve(__dirname, "../../tests/lib") + }); + vfs = new VirtualFileSystem("/", IO.useCaseSensitiveFileNames()); + vfs.addDirectory("/.ts", resolver); + vfs.addDirectory("/.lib", resolver); + vfs.addDirectory("/.test"); + vfs.changeDirectory("/.test"); + vfs.makeReadOnly(); + this._builtLocal = vfs; + } + if (vfs._useCaseSensitiveFileNames !== useCaseSensitiveFileNames) { + vfs = vfs.shadow(); + vfs._useCaseSensitiveFileNames = useCaseSensitiveFileNames; + vfs.makeReadOnly(); + } + return useCaseSensitiveFileNames + ? this._builtLocalCS = vfs + : this._builtLocalCI = vfs; + } + return vfs; + } + + /** + * Gets a value indicating whether to file names are equivalent for the file system's case sensitivity. + */ + public sameName(a: string, b: string) { + return compareStrings(a, b, !this.useCaseSensitiveFileNames) === 0; + } + + /** + * Changes the current directory to the supplied path. + */ + public changeDirectory(path: string) { + this.writePreamble(); + if (path) { + this._currentDirectory = vpath.resolve(this._currentDirectory, path); + } + } + + /** + * Pushes the current directory onto the location stack and changes the current directory to the supplied path. + */ + public pushDirectory(path = this.currentDirectory) { + this.writePreamble(); + if (this._currentDirectoryStack === undefined) { + this._currentDirectoryStack = [this.currentDirectory]; + } + else { + this._currentDirectoryStack.push(this.currentDirectory); + } + this.changeDirectory(path); + } + + /** + * Pops the previous directory from the location stack and changes the current directory to that directory. + */ + public popDirectory() { + this.writePreamble(); + const previousDirectory = this._currentDirectoryStack && this._currentDirectoryStack.pop(); + if (previousDirectory !== undefined) { + this._currentDirectory = previousDirectory; + } + } + + /** + * Adds a directory (and all intermediate directories) to a path relative to the current directory. + */ + public addDirectory(path: string, resolver?: FileSystemResolver) { + return this.root.addDirectory(vpath.resolve(this.currentDirectory, path), resolver); + } + + /** + * Adds a file (and all intermediate directories) to a path relative to the current directory. + */ + public addFile(path: string, content?: FileSystemResolver | ContentResolver | string, options?: { overwrite?: boolean }) { + return this.root.addFile(vpath.resolve(this.currentDirectory, path), content, options); + } + + /** + * Adds multiple files (and all intermediate directories) to paths relative to the current directory. + */ + public addFiles(files: string[]) { + for (const file of files) { + this.addFile(file); + } + } + + /** + * Adds a symbolic link to a target entry for a path relative to the current directory. + */ + public addSymlink(path: string, target: VirtualFile): VirtualFileSymlink | undefined; + /** + * Adds a symbolic link to a target entry for a path relative to the current directory. + */ + public addSymlink(path: string, target: VirtualDirectory): VirtualDirectorySymlink | undefined; + /** + * Adds a symbolic link to a target entry for a path relative to the current directory. + */ + public addSymlink(path: string, target: string | VirtualEntry): VirtualSymlink | undefined; + public addSymlink(path: string, target: string | VirtualEntry) { + if (typeof target === "string") target = vpath.resolve(this.currentDirectory, target); + return this.root.addSymlink(vpath.resolve(this.currentDirectory, path), target); + } + + /** + * Removes a directory (and all of its contents) at a path relative to the current directory. + */ + public removeDirectory(path: string): boolean { + return this.root.removeDirectory(vpath.resolve(this.currentDirectory, path)); + } + + /** + * Removes a file at a path relative to the current directory. + */ + public removeFile(path: string): boolean { + return this.root.removeFile(vpath.resolve(this.currentDirectory, path)); + } + + /** + * Reads the contents of a file at a path relative to the current directory. + */ + public readFile(path: string): string | undefined { + const file = this.getFile(vpath.resolve(this.currentDirectory, path)); + return file && file.content; + } + + /** + * Writes the contents of a file at a path relative to the current directory. + */ + public writeFile(path: string, content: string): void { + path = vpath.resolve(this.currentDirectory, path); + const file = this.getFile(path) || this.addFile(path); + if (file) { + file.content = content; + } + } + + /** + * Gets a value indicating whether a path relative to the current directory exists and is a directory. + */ + public directoryExists(path: string) { + return this.getEntry(path) instanceof VirtualDirectory; + } + + /** + * Gets a value indicating whether a path relative to the current directory exists and is a file. + */ + public fileExists(path: string) { + return this.getEntry(path) instanceof VirtualFile; + } + + /** + * If an entry is a symbolic link, gets the resolved target of the link. Otherwise, returns the entry. + */ + public getRealEntry(entry: VirtualDirectory): VirtualDirectory | undefined; + /** + * If an entry is a symbolic link, gets the resolved target of the link. Otherwise, returns the entry. + */ + public getRealEntry(entry: VirtualFile): VirtualFile | undefined; + /** + * If an entry is a symbolic link, gets the resolved target of the link. Otherwise, returns the entry. + */ + public getRealEntry(entry: VirtualEntry): VirtualEntry | undefined; + public getRealEntry(entry: VirtualEntry): VirtualEntry | undefined { + if (entry instanceof VirtualFileSymlink || entry instanceof VirtualDirectorySymlink) { + return findTarget(this, entry.targetPath); + } + return entry; + } + + /** + * Gets an entry from a path relative to the current directory. + */ + public getEntry(path: string, options: { followSymlinks?: boolean, pattern?: RegExp, kind: "file" }): VirtualFile | undefined; + /** + * Gets an entry from a path relative to the current directory. + */ + public getEntry(path: string, options: { followSymlinks?: boolean, pattern?: RegExp, kind: "directory" }): VirtualDirectory | undefined; + /** + * Gets an entry from a path relative to the current directory. + */ + public getEntry(path: string, options?: { followSymlinks?: boolean, pattern?: RegExp, kind?: "file" | "directory" }): VirtualEntry | undefined; + public getEntry(path: string, options?: { followSymlinks?: boolean, pattern?: RegExp, kind?: "file" | "directory" }) { + return this.root.getEntry(vpath.resolve(this.currentDirectory, path), options); + } + + /** + * Gets a file from a path relative to the current directory. + */ + public getFile(path: string, options?: { followSymlinks?: boolean, pattern?: RegExp }): VirtualFile | undefined { + return this.root.getFile(vpath.resolve(this.currentDirectory, path), options); + } + + /** + * Gets a directory from a path relative to the current directory. + */ + public getDirectory(path: string, options?: { followSymlinks?: boolean, pattern?: RegExp }): VirtualDirectory | undefined { + return this.root.getDirectory(vpath.resolve(this.currentDirectory, path), options); + } + + /** + * Gets the accessible file system entries from a path relative to the current directory. + */ + public getAccessibleFileSystemEntries(path: string) { + const entry = this.getEntry(path); + if (entry instanceof VirtualDirectory) { + return { + files: entry.getFiles().map(f => f.name), + directories: entry.getDirectories().map(d => d.name) + }; + } + return { files: [], directories: [] }; + } + + /** + * Watch a path for changes to a file. + */ + public watchFile(path: string, watcher: (path: string, change: FileSystemChange) => void): ts.FileWatcher { + if (!this._watchedFiles) { + const pathComparer = this.useCaseSensitiveFileNames ? vpath.compare.caseSensitive : vpath.compare.caseInsensitive; + this._watchedFiles = new KeyedCollection(pathComparer); + } + + path = vpath.resolve(this.currentDirectory, path); + let watchers = this._watchedFiles.get(path); + if (!watchers) this._watchedFiles.set(path, watchers = []); + + const entry: FileWatcherEntry = { watcher }; + watchers.push(entry); + + return { + close: () => { + const watchers = this._watchedFiles.get(path); + if (watchers) { + ts.orderedRemoveItem(watchers, entry); + if (watchers.length === 0) { + this._watchedFiles.delete(path); + } + } + } + }; + } + + /** + * Watch a directory for changes to the contents of the directory. + */ + public watchDirectory(path: string, watcher: (path: string) => void, recursive?: boolean) { + if (!this._watchedDirectories) { + const pathComparer = this.useCaseSensitiveFileNames ? vpath.compare.caseSensitive : vpath.compare.caseInsensitive; + this._watchedDirectories = new KeyedCollection(pathComparer); + } + + path = vpath.resolve(this.currentDirectory, path); + let watchers = this._watchedDirectories.get(path); + if (!watchers) { + watchers = []; + watchers.recursiveCount = 0; + this._watchedDirectories.set(path, watchers); + } + + const entry: DirectoryWatcherEntry = { watcher, recursive }; + watchers.push(entry); + if (recursive) watchers.recursiveCount++; + + return { + close: () => { + const watchers = this._watchedDirectories.get(path); + if (watchers) { + ts.orderedRemoveItem(watchers, entry); + if (watchers.length === 0) { + this._watchedDirectories.delete(path); + } + else if (entry.recursive) { + watchers.recursiveCount--; + } + } + } + }; + } + + /** + * Creates a shadow copy of this file system. Changes made to the shadow do not affect + * this file system. + */ + public shadow(): VirtualFileSystem { + const fs = new VirtualFileSystem(this.currentDirectory, this.useCaseSensitiveFileNames); + fs._shadowRoot = this; + return fs; + } + + public debugPrint(): void { + console.log(`cwd: ${this.currentDirectory}`); + for (const entry of this.root.getEntries({ recursive: true })) { + if (entry instanceof VirtualDirectory) { + console.log(entry.path.endsWith("/") ? entry.path : entry.path + "/"); + if (entry instanceof VirtualDirectorySymlink) { + console.log(`-> ${entry.targetPath.endsWith("/") ? entry.targetPath : entry.targetPath + "/"}`); + } + } + else { + console.log(entry.path); + if (entry instanceof VirtualFileSymlink) { + console.log(`-> ${entry.targetPath}`); + } + } + } + } + + protected makeReadOnlyCore() { + this.root.makeReadOnly(); + } + + private onRootFileSystemChange(path: string, change: FileSystemChange) { + const fileWatchers = this._watchedFiles && this._watchedFiles.get(path); + if (fileWatchers) { + for (const { watcher } of fileWatchers) { + watcher(path, change); + } + } + + if (this._watchedDirectories && (change === "added" || change === "removed")) { + const ignoreCase = !this.useCaseSensitiveFileNames; + const dirname = vpath.dirname(path); + this._watchedDirectories.forEach((watchers, path) => { + const exactMatch = vpath.equals(dirname, path, ignoreCase); + if (exactMatch || (watchers.recursiveCount > 0 && vpath.beneath(dirname, path, ignoreCase))) { + for (const { recursive, watcher } of watchers) { + if (exactMatch || !recursive) { + watcher(path); + } + } + } + }); + } + } + } + + export interface VirtualFileSystemEntry { + on(event: "fileSystemChange", listener: (path: string, change: FileSystemChange) => void): this; + emit(event: "fileSystemChange", path: string, change: FileSystemChange): boolean; + } + + export abstract class VirtualFileSystemEntry extends VirtualFileSystemObject { private _path: string; private _metadata: Metadata; @@ -74,6 +537,7 @@ namespace Utils { public readonly name: string; constructor(name: string) { + super(); this.name = name; } @@ -81,18 +545,19 @@ namespace Utils { * Gets the file system to which this entry belongs. */ public get fileSystem(): VirtualFileSystem { + if (!this.parent) throw new TypeError(); return this.parent.fileSystem; } /** - * Gets the container for this entry. + * Gets the parent directory for this entry. */ - public abstract get parent(): VirtualFileSystemContainer; + public abstract get parent(): VirtualDirectory | undefined; /** * Gets the entry that this entry shadows. */ - public abstract get shadowRoot(): VirtualFileSystemEntry | undefined; + public abstract get shadowRoot(): VirtualEntry | undefined; /** * Gets metadata about this entry. @@ -102,31 +567,31 @@ namespace Utils { } /** - * Gets a value indicating whether this entry is read-only. + * Gets the full path to this entry. */ - public get isReadOnly(): boolean { - return this._readonly; - } - public get path(): string { return this._path || (this._path = vpath.combine(this.parent.path, this.name)); } + /** + * Gets the path to this entry relative to the current directory. + */ public get relative(): string { return this.relativeTo(this.fileSystem.currentDirectory); } + /** + * Gets a value indicating whether this entry exists. + */ public get exists(): boolean { return this.parent.exists - && this.parent.getEntry(this.name) as VirtualFileSystemEntry === this; + && this.parent.getEntry(this.name) === this as VirtualFileSystemEntry; } - public makeReadOnly(): void { - this.makeReadOnlyCore(); - this._readonly = true; - } - - public relativeTo(other: string | VirtualFileSystemEntry) { + /** + * Gets a relative path from this entry to another entry. + */ + public relativeTo(other: string | VirtualEntry) { if (other) { const otherPath = typeof other === "string" ? other : other.path; const ignoreCase = !this.fileSystem.useCaseSensitiveFileNames; @@ -139,67 +604,80 @@ namespace Utils { * Creates a file system entry that shadows this file system entry. * @param parent The container for the shadowed entry. */ - public abstract shadow(parent: VirtualFileSystemContainer): VirtualFileSystemEntry; + public abstract shadow(parent: VirtualDirectory): VirtualEntry; - protected abstract makeReadOnlyCore(): void; - - protected writePreamble(): void { - if (this._readonly) throw new Error("Cannot modify a frozen entry."); + protected shadowPreamble(parent: VirtualDirectory): void { + this.checkShadowParent(parent); + this.checkShadowFileSystem(parent.fileSystem); } - protected shadowPreamble(parent: VirtualFileSystemContainer): void { - if (this.parent !== parent.shadowRoot) throw new Error("Incorrect shadow parent"); + protected checkShadowParent(shadowParent: VirtualDirectory) { + if (this.parent !== shadowParent.shadowRoot) throw new Error("Incorrect shadow parent"); + } + + protected checkShadowFileSystem(shadowFileSystem: VirtualFileSystem) { let fileSystem: VirtualFileSystem | undefined = this.fileSystem; while (fileSystem) { - if (parent.fileSystem === fileSystem) throw new Error("Cannot create shadow for parent in the same file system."); + if (shadowFileSystem === fileSystem) throw new Error("Cannot create shadow for parent in the same file system."); fileSystem = fileSystem.shadowRoot; } } } - export abstract class VirtualFileSystemContainer extends VirtualFileSystemEntry { - private _childAddedCallbacks: ((entry: VirtualFile | VirtualDirectory) => void)[]; - private _childRemovedCallbacks: ((entry: VirtualFile | VirtualDirectory) => void)[]; + export interface VirtualDirectory { + on(event: "fileSystemChange", listener: (path: string, change: FileSystemChange) => void): this; + on(event: "childAdded", listener: (child: VirtualEntry) => void): this; + on(event: "childRemoved", listener: (child: VirtualEntry) => void): this; + emit(event: "fileSystemChange", path: string, change: FileSystemChange): boolean; + emit(event: "childAdded", child: VirtualEntry): boolean; + emit(event: "childRemoved", child: VirtualEntry): boolean; + } - public abstract get shadowRoot(): VirtualFileSystemContainer | undefined; + export class VirtualDirectory extends VirtualFileSystemEntry { + protected _shadowRoot: VirtualDirectory | undefined; + private _parent: VirtualDirectory; + private _entries: KeyedCollection | undefined; + private _resolver: FileSystemResolver | undefined; + private _onChildFileSystemChange: (path: string, change: FileSystemChange) => void; - public addOnChildAdded(callback: (entry: VirtualFile | VirtualDirectory) => void) { - if (!this._childAddedCallbacks) { - this._childAddedCallbacks = [callback]; - } - else if (this._childAddedCallbacks.indexOf(callback) === -1) { - this._childAddedCallbacks.push(callback); - } + constructor(parent: VirtualDirectory | undefined, name: string, resolver?: FileSystemResolver) { + super(name); + if (parent === undefined && !(this instanceof VirtualRoot)) throw new TypeError(); + this._parent = parent; + this._entries = undefined; + this._resolver = resolver; + this._shadowRoot = undefined; + this._onChildFileSystemChange = (path, change) => this.onChildFileSystemChange(path, change); } - public removeOnChildAdded(callback: (entry: VirtualFile | VirtualDirectory) => void) { - if (this._childAddedCallbacks) { - const index = this._childAddedCallbacks.indexOf(callback); - if (index >= 0) removeAt(this._childAddedCallbacks, index); - } + /** + * Gets the container for this entry. + */ + public get parent(): VirtualDirectory | undefined { + return this._parent; } - public addOnChildRemoved(callback: (entry: VirtualFile | VirtualDirectory) => void) { - if (!this._childRemovedCallbacks) { - this._childRemovedCallbacks = [callback]; - } - else if (this._childRemovedCallbacks.indexOf(callback) === -1) { - this._childRemovedCallbacks.push(callback); - } - } - - public removeOnChildRemoved(callback: (entry: VirtualFile | VirtualDirectory) => void) { - if (this._childRemovedCallbacks) { - const index = this._childRemovedCallbacks.indexOf(callback); - if (index >= 0) removeAt(this._childRemovedCallbacks, index); - } + /** + * Gets the entry that this entry shadows. + */ + public get shadowRoot(): VirtualDirectory | undefined { + return this._shadowRoot; } + /** + * Gets the child entries in this directory for the provided options. + */ public getEntries(options: { recursive?: boolean, pattern?: RegExp, kind: "file" }): VirtualFile[]; + /** + * Gets the child entries in this directory for the provided options. + */ public getEntries(options: { recursive?: boolean, pattern?: RegExp, kind: "directory" }): VirtualDirectory[]; - public getEntries(options?: { recursive?: boolean, pattern?: RegExp, kind?: "file" | "directory" }): (VirtualFile | VirtualDirectory)[]; - public getEntries(options: { recursive?: boolean, pattern?: RegExp, kind?: "file" | "directory" } = {}): (VirtualFile | VirtualDirectory)[] { - const results: (VirtualFile | VirtualDirectory)[] = []; + /** + * Gets the child entries in this directory for the provided options. + */ + public getEntries(options?: { recursive?: boolean, pattern?: RegExp, kind?: "file" | "directory" }): VirtualEntry[]; + public getEntries(options: { recursive?: boolean, pattern?: RegExp, kind?: "file" | "directory" } = {}): VirtualEntry[] { + const results: VirtualEntry[] = []; if (options.recursive) { this.getOwnEntries().forEach(entry => { if (entry instanceof VirtualFile) { @@ -227,14 +705,23 @@ namespace Utils { return results; } + /** + * Gets the child directories in this directory for the provided options. + */ public getDirectories(options: { recursive?: boolean, pattern?: RegExp } = {}): VirtualDirectory[] { return this.getEntries({ ...options, kind: "directory" }); } + /** + * Gets the child files in this directory for the provided options. + */ public getFiles(options: { recursive?: boolean, pattern?: RegExp } = {}): VirtualFile[] { return this.getEntries({ ...options, kind: "file" }); } + /** + * Gets the names of the child entries in this directory for the provided options. + */ public getEntryNames(options: { recursive?: boolean, qualified?: boolean, pattern?: RegExp, kind?: "file" | "directory" } = {}): string[] { return this.getEntries(options).map(entry => options && options.qualified ? entry.path : @@ -242,30 +729,65 @@ namespace Utils { entry.name); } + /** + * Gets the names of the child directories in this directory for the provided options. + */ public getDirectoryNames(options: { recursive?: boolean, qualified?: boolean, pattern?: RegExp } = {}): string[] { return this.getEntryNames({ ...options, kind: "directory" }); } + /** + * Gets the names of the child files in this directory for the provided options. + */ public getFileNames(options: { recursive?: boolean, qualified?: boolean, pattern?: RegExp } = {}): string[] { return this.getEntryNames({ ...options, kind: "file" }); } - public abstract getEntry(path: string, options: { followSymlinks?: boolean, pattern?: RegExp, kind: "file" }): VirtualFile | undefined; - public abstract getEntry(path: string, options: { followSymlinks?: boolean, pattern?: RegExp, kind: "directory" }): VirtualDirectory | undefined; - public abstract getEntry(path: string, options?: { followSymlinks?: boolean, pattern?: RegExp, kind?: "file" | "directory" }): VirtualFile | VirtualDirectory | undefined; + /** + * Gets an entry from a path relative to this directory. + */ + public getEntry(path: string, options: { followSymlinks?: boolean, pattern?: RegExp, kind: "file" }): VirtualFile | undefined; + /** + * Gets an entry from a path relative to this directory. + */ + public getEntry(path: string, options: { followSymlinks?: boolean, pattern?: RegExp, kind: "directory" }): VirtualDirectory | undefined; + /** + * Gets an entry from a path relative to this directory. + */ + public getEntry(path: string, options?: { followSymlinks?: boolean, pattern?: RegExp, kind?: "file" | "directory" }): VirtualEntry | undefined; + public getEntry(path: string, options: { followSymlinks?: boolean, pattern?: RegExp, kind?: "file" | "directory" } = {}): VirtualEntry | undefined { + const components = this.parsePath(path); + const directory = this.walkContainers(components, /*create*/ false); + return directory && directory.getOwnEntry(components[components.length - 1], options); + } + /** + * Gets a directory from a path relative to this directory. + */ public getDirectory(path: string, options: { followSymlinks?: boolean, pattern?: RegExp } = {}): VirtualDirectory | undefined { return this.getEntry(path, { ...options, kind: "directory" }); } + /** + * Gets a file from a path relative to this directory. + */ public getFile(path: string, options: { followSymlinks?: boolean, pattern?: RegExp } = {}): VirtualFile | undefined { return this.getEntry(path, { ...options, kind: "file" }); } - public findEntry(path: string, axis: FindAxis, options: { followSymlinks?: boolean, pattern?: RegExp, kind: "file" }): VirtualFile | undefined; - public findEntry(path: string, axis: FindAxis, options: { followSymlinks?: boolean, pattern?: RegExp, kind: "directory" }): VirtualDirectory | undefined; - public findEntry(path: string, axis: FindAxis, options?: { followSymlinks?: boolean, pattern?: RegExp, kind?: "file" | "directory" }): VirtualFile | VirtualDirectory | undefined; - public findEntry(path: string, axis: FindAxis, options: { followSymlinks?: boolean, pattern?: RegExp, kind?: "file" | "directory" } = {}): VirtualFile | VirtualDirectory | undefined { + /** + * Finds an entry for a relative path along the provided axis. + */ + public findEntry(path: string, axis: Axis, options: { followSymlinks?: boolean, pattern?: RegExp, kind: "file" }): VirtualFile | undefined; + /** + * Finds an entry for a relative path along the provided axis. + */ + public findEntry(path: string, axis: Axis, options: { followSymlinks?: boolean, pattern?: RegExp, kind: "directory" }): VirtualDirectory | undefined; + /** + * Finds an entry for a relative path along the provided axis. + */ + public findEntry(path: string, axis: Axis, options?: { followSymlinks?: boolean, pattern?: RegExp, kind?: "file" | "directory" }): VirtualEntry | undefined; + public findEntry(path: string, axis: Axis, options: { followSymlinks?: boolean, pattern?: RegExp, kind?: "file" | "directory" } = {}): VirtualEntry | undefined { const walkAncestors = axis === "ancestors-or-self" || axis === "ancestors"; const walkDescendents = axis === "descendents-or-self" || axis === "descendents"; const walkSelf = axis === "ancestors-or-self" || axis === "self" || axis === "descendents-or-self"; @@ -274,7 +796,7 @@ namespace Utils { if (entry) return entry; } if (walkAncestors) { - const entry = !(this instanceof VirtualFileSystem) && this.parent.findEntry(path, "ancestors-or-self", options); + const entry = this.parent && this.parent.findEntry(path, "ancestors-or-self", options); if (entry) return entry; } if (walkDescendents) { @@ -285,345 +807,53 @@ namespace Utils { } } - public findDirectory(path: string, axis: FindAxis, options: { followSymlinks?: boolean, pattern?: RegExp } = {}): VirtualDirectory | undefined { + /** + * Finds a directory for a relative path along the provided axis. + */ + public findDirectory(path: string, axis: Axis, options: { followSymlinks?: boolean, pattern?: RegExp } = {}): VirtualDirectory | undefined { return this.findEntry(path, axis, { ...options, kind: "directory" }); } - public findFile(path: string, axis: FindAxis, options: { followSymlinks?: boolean, pattern?: RegExp } = {}): VirtualFile | undefined { + /** + * Finds a file for a relative path along the provided axis. + */ + public findFile(path: string, axis: Axis, options: { followSymlinks?: boolean, pattern?: RegExp } = {}): VirtualFile | undefined { return this.findEntry(path, axis, { ...options, kind: "file" }); } - protected abstract getOwnEntries(): KeyedCollection; - - protected raiseOnChildAdded(entry: VirtualFile | VirtualDirectory) { - if (this._childAddedCallbacks) { - for (const callback of this._childAddedCallbacks) { - callback(entry); - } - } - } - - protected raiseOnChildRemoved(entry: VirtualFile | VirtualDirectory) { - if (this._childRemovedCallbacks) { - for (const callback of this._childRemovedCallbacks) { - callback(entry); - } - } - } - } - - export class VirtualFileSystem extends VirtualFileSystemContainer { - // private static _builtLocal: VirtualFileSystem | undefined; - // private static _builtLocalCI: VirtualFileSystem | undefined; - // private static _builtLocalCS: VirtualFileSystem | undefined; - - private _root: VirtualDirectory; - private _useCaseSensitiveFileNames: boolean; - private _currentDirectory: string; - private _currentDirectoryStack: string[] | undefined; - private _shadowRoot: VirtualFileSystem | undefined; - - constructor(currentDirectory: string, useCaseSensitiveFileNames: boolean) { - super(""); - this._currentDirectory = currentDirectory.replace(/\\/g, "/"); - this._useCaseSensitiveFileNames = useCaseSensitiveFileNames; - } - - public get fileSystem(): VirtualFileSystem { - return this; - } - - public get parent(): VirtualFileSystemContainer { - return this; - } - - public get shadowRoot(): VirtualFileSystem | undefined { - return this._shadowRoot; - } - - public get useCaseSensitiveFileNames() { - return this._useCaseSensitiveFileNames; - } - - public get currentDirectory() { - return this._currentDirectory; - } - - public get path() { - return ""; - } - - public get relative() { - return ""; - } - - public get exists() { - return true; - } - - public get root() { - if (this._root === undefined) { - if (this._shadowRoot) { - this._root = this._shadowRoot.root.shadow(this); - } - else { - this._root = new VirtualDirectory(this, ""); - } - if (this.isReadOnly) this._root.makeReadOnly(); - } - return this._root; - } - - // public static getBuiltLocal(useCaseSensitiveFileNames: boolean = io.useCaseSensitiveFileNames()): VirtualFileSystem { - // let vfs = useCaseSensitiveFileNames ? this._builtLocalCS : this._builtLocalCI; - // if (!vfs) { - // vfs = this._builtLocal; - // if (!vfs) { - // const resolver = createResolver(io, { - // "/.ts": getBuiltDirectory(), - // "/.lib": getLibFilesDirectory() - // }); - // vfs = new VirtualFileSystem("/", io.useCaseSensitiveFileNames()); - // vfs.addDirectory("/.ts", resolver); - // vfs.addDirectory("/.lib", resolver); - // vfs.addDirectory("/.test"); - // vfs.changeDirectory("/.test"); - // vfs.makeReadOnly(); - // this._builtLocal = vfs; - // } - // if (vfs._useCaseSensitiveFileNames !== useCaseSensitiveFileNames) { - // vfs = vfs.shadow(); - // vfs._useCaseSensitiveFileNames = useCaseSensitiveFileNames; - // vfs.makeReadOnly(); - // } - // return useCaseSensitiveFileNames - // ? this._builtLocalCS = vfs - // : this._builtLocalCI = vfs; - // } - // return vfs; - // } - - // public static createFromOptions(options: { useCaseSensitiveFileNames?: boolean, currentDirectory?: string }) { - // const vfs = this.getBuiltLocal(options.useCaseSensitiveFileNames).shadow(); - // if (options.currentDirectory) { - // vfs.addDirectory(options.currentDirectory); - // vfs.changeDirectory(options.currentDirectory); - // } - // return vfs; - // } - - // public static createFromDocuments(options: { useCaseSensitiveFileNames?: boolean, currentDirectory?: string }, documents: TextDocument[], fileOptions?: { overwrite?: boolean }) { - // const vfs = this.createFromOptions(options); - // for (const document of documents) { - // const file = vfs.addFile(document.file, document.text, fileOptions)!; - // assert.isDefined(file, `Failed to add file: '${document.file}'`); - // file.metadata.set("document", document); - // // Add symlinks - // const symlink = document.meta.get("symlink"); - // if (file && symlink) { - // for (const link of symlink.split(",")) { - // const symlink = vfs.addSymlink(vpath.resolve(vfs.currentDirectory, link.trim()), file)!; - // assert.isDefined(symlink, `Failed to symlink: '${link}'`); - // symlink.metadata.set("document", document); - // } - // } - // } - // return vfs; - // } - - public changeDirectory(path: string) { - this.writePreamble(); - if (path) { - this._currentDirectory = vpath.resolve(this._currentDirectory, path); - } - } - - public pushDirectory(path = this.currentDirectory) { - this.writePreamble(); - if (this._currentDirectoryStack === undefined) { - this._currentDirectoryStack = [this.currentDirectory]; - } - else { - this._currentDirectoryStack.push(this.currentDirectory); - } - this.changeDirectory(path); - } - - public popDirectory() { - this.writePreamble(); - const previousDirectory = this._currentDirectoryStack && this._currentDirectoryStack.pop(); - if (previousDirectory !== undefined) { - this._currentDirectory = previousDirectory; - } - } - - public addDirectory(path: string /*, resolver?: FileSystemResolver */) { - return this.root.addDirectory(vpath.resolve(this.currentDirectory, path) /*, resolver */); - } - - public addFile(path: string, content?: /*FileSystemResolver["getContent"] |*/ string, options?: { overwrite?: boolean }) { - return this.root.addFile(vpath.resolve(this.currentDirectory, path), content, options); - } - - public addSymlink(path: string, target: VirtualFile): VirtualFileSymlink | undefined; - public addSymlink(path: string, target: VirtualDirectory): VirtualDirectorySymlink | undefined; - public addSymlink(path: string, target: string | VirtualFile | VirtualDirectory): VirtualSymlink | undefined; - public addSymlink(path: string, target: string | VirtualFile | VirtualDirectory) { - if (typeof target === "string") target = vpath.resolve(this.currentDirectory, target); - return this.root.addSymlink(vpath.resolve(this.currentDirectory, path), target); - } - - public removeDirectory(path: string): boolean { - return this.root.removeDirectory(vpath.resolve(this.currentDirectory, path)); - } - - public removeFile(path: string): boolean { - return this.root.removeFile(vpath.resolve(this.currentDirectory, path)); - } - - public readFile(path: string): string | undefined { - const file = this.getFile(vpath.resolve(this.currentDirectory, path)); - return file && file.getContent(); - } - - public writeFile(path: string, content: string): void { - path = vpath.resolve(this.currentDirectory, path); - const file = this.getFile(path) || this.addFile(path); - if (file) { - file.setContent(content); - } - } - - public directoryExists(path: string) { - return this.getEntry(path) instanceof VirtualDirectory; - } - - public fileExists(path: string) { - return this.getEntry(path) instanceof VirtualFile; - } - - public sameName(a: string, b: string) { - return compareStrings(a, b, !this.useCaseSensitiveFileNames) === 0; - } - - public getRealEntry(entry: VirtualDirectory): VirtualDirectory | undefined; - public getRealEntry(entry: VirtualFile): VirtualFile | undefined; - public getRealEntry(entry: VirtualFile | VirtualDirectory): VirtualFile | VirtualDirectory | undefined; - public getRealEntry(entry: VirtualFile | VirtualDirectory): VirtualFile | VirtualDirectory | undefined { - if (entry instanceof VirtualFileSymlink || entry instanceof VirtualDirectorySymlink) { - return findTarget(this, entry.target); - } - return entry; - } - - public getEntry(path: string, options: { followSymlinks?: boolean, pattern?: RegExp, kind: "file" }): VirtualFile | undefined; - public getEntry(path: string, options: { followSymlinks?: boolean, pattern?: RegExp, kind: "directory" }): VirtualDirectory | undefined; - public getEntry(path: string, options?: { followSymlinks?: boolean, pattern?: RegExp, kind?: "file" | "directory" }): VirtualFile | VirtualDirectory | undefined; - public getEntry(path: string, options?: { followSymlinks?: boolean, pattern?: RegExp, kind?: "file" | "directory" }) { - return this.root.getEntry(vpath.resolve(this.currentDirectory, path), options); - } - - public getFile(path: string, options?: { followSymlinks?: boolean, pattern?: RegExp }): VirtualFile | undefined { - return this.root.getFile(vpath.resolve(this.currentDirectory, path), options); - } - - public getDirectory(path: string, options?: { followSymlinks?: boolean, pattern?: RegExp }): VirtualDirectory | undefined { - return this.root.getDirectory(vpath.resolve(this.currentDirectory, path), options); - } - - public getAccessibleFileSystemEntries(path: string) { - const entry = this.getEntry(path); - if (entry instanceof VirtualDirectory) { - return { - files: entry.getFiles().map(f => f.name), - directories: entry.getDirectories().map(d => d.name) - }; - } - return { files: [], directories: [] }; - } - - public shadow(): VirtualFileSystem { - const fs = new VirtualFileSystem(this.currentDirectory, this.useCaseSensitiveFileNames); - fs._shadowRoot = this; - return fs; - } - - public debugPrint(): void { - console.log(`cwd: ${this.currentDirectory}`); - for (const entry of this.getEntries({ recursive: true })) { - if (entry instanceof VirtualDirectory) { - console.log(entry.path.endsWith("/") ? entry.path : entry.path + "/"); - if (entry instanceof VirtualDirectorySymlink) { - console.log(`-> ${entry.target.endsWith("/") ? entry.target : entry.target + "/"}`); - } - } - else { - console.log(entry.path); - if (entry instanceof VirtualFileSymlink) { - console.log(`-> ${entry.target}`); - } - } - } - } - - protected makeReadOnlyCore() { - this.root.makeReadOnly(); - } - - protected getOwnEntries() { - return this.root["getOwnEntries"](); - } - } - - export class VirtualDirectory extends VirtualFileSystemContainer { - protected _shadowRoot: VirtualDirectory | undefined; - private _parent: VirtualFileSystemContainer; - private _entries: KeyedCollection | undefined; - // private _resolver: FileSystemResolver | undefined; - - constructor(parent: VirtualFileSystemContainer, name: string /*, resolver?: FileSystemResolver */) { - super(name); - this._parent = parent; - this._entries = undefined; - // this._resolver = resolver; - this._shadowRoot = undefined; - } - - public get parent(): VirtualFileSystemContainer { - return this._parent; - } - - public get shadowRoot(): VirtualDirectory | undefined { - return this._shadowRoot; - } - - public getEntry(path: string, options: { followSymlinks?: boolean, pattern?: RegExp, kind: "file" }): VirtualFile | undefined; - public getEntry(path: string, options: { followSymlinks?: boolean, pattern?: RegExp, kind: "directory" }): VirtualDirectory | undefined; - public getEntry(path: string, options?: { followSymlinks?: boolean, pattern?: RegExp, kind?: "file" | "directory" }): VirtualFile | VirtualDirectory | undefined; - public getEntry(path: string, options: { followSymlinks?: boolean, pattern?: RegExp, kind?: "file" | "directory" } = {}): VirtualFile | VirtualDirectory | undefined { - const components = this.parsePath(path); - const directory = this.walkContainers(components, /*create*/ false); - return directory && directory.getOwnEntry(components[components.length - 1], options); - } - - public addDirectory(path: string /*, resolver?: FileSystemResolver*/): VirtualDirectory | undefined { + /** + * Adds a directory (and all intermediate directories) for a path relative to this directory. + */ + public addDirectory(path: string, resolver?: FileSystemResolver): VirtualDirectory | undefined { this.writePreamble(); const components = this.parsePath(path); const directory = this.walkContainers(components, /*create*/ true); - return directory && directory.addOwnDirectory(components[components.length - 1] /*, resolver*/); + return directory && directory.addOwnDirectory(components[components.length - 1], resolver); } - public addFile(path: string, content?: /*FileSystemResolver["getContent"] |*/ string | undefined, options?: { overwrite?: boolean }): VirtualFile | undefined { + /** + * Adds a file (and all intermediate directories) for a path relative to this directory. + */ + public addFile(path: string, content?: FileSystemResolver | ContentResolver | string, options?: { overwrite?: boolean }): VirtualFile | undefined { this.writePreamble(); const components = this.parsePath(path); const directory = this.walkContainers(components, /*create*/ true); return directory && directory.addOwnFile(components[components.length - 1], content, options); } + /** + * Adds a symbolic link to a target entry for a path relative to this directory. + */ public addSymlink(path: string, target: VirtualFile): VirtualFileSymlink | undefined; + /** + * Adds a symbolic link to a target entry for a path relative to this directory. + */ public addSymlink(path: string, target: VirtualDirectory): VirtualDirectorySymlink | undefined; - public addSymlink(path: string, target: string | VirtualFile | VirtualDirectory): VirtualSymlink | undefined; - public addSymlink(path: string, target: string | VirtualFile | VirtualDirectory): VirtualSymlink | undefined { + /** + * Adds a symbolic link to a target entry for a path relative to this directory. + */ + public addSymlink(path: string, target: string | VirtualEntry): VirtualSymlink | undefined; + public addSymlink(path: string, target: string | VirtualEntry): VirtualSymlink | undefined { this.writePreamble(); const targetEntry = typeof target === "string" ? this.fileSystem.getEntry(vpath.resolve(this.path, target)) : target; if (targetEntry === undefined) return undefined; @@ -632,6 +862,9 @@ namespace Utils { return directory && directory.addOwnSymlink(components[components.length - 1], targetEntry); } + /** + * Removes a directory (and all of its contents) at a path relative to this directory. + */ public removeDirectory(path: string): boolean { this.writePreamble(); const components = this.parsePath(path); @@ -639,6 +872,9 @@ namespace Utils { return directory ? directory.removeOwnDirectory(components[components.length - 1]) : false; } + /** + * Removes a file at a path relative to this directory. + */ public removeFile(path: string): boolean { this.writePreamble(); this.writePreamble(); @@ -647,9 +883,13 @@ namespace Utils { return directory ? directory.removeOwnFile(components[components.length - 1]) : false; } - public shadow(parent: VirtualFileSystemContainer): VirtualDirectory { - this.shadowPreamble(parent); - const shadow = new VirtualDirectory(parent, this.name); + /** + * Creates a shadow copy of this directory. Changes made to the shadow do not affect + * this directory. + */ + public shadow(shadowParent: VirtualDirectory): VirtualDirectory { + this.shadowPreamble(shadowParent); + const shadow = new VirtualDirectory(shadowParent, this.name); shadow._shadowRoot = this; return shadow; } @@ -662,10 +902,11 @@ namespace Utils { protected getOwnEntries() { if (!this._entries) { - // const resolver = this._resolver; - const entries = new KeyedCollection(this.fileSystem.useCaseSensitiveFileNames ? compareStrings.caseSensitive : compareStrings.caseInsensitive); - // this._resolver = undefined; - /*if (resolver) { + const entries = new KeyedCollection(this.fileSystem.useCaseSensitiveFileNames ? compareStrings.caseSensitive : compareStrings.caseInsensitive); + const resolver = this._resolver; + const shadowRoot = this._shadowRoot; + if (resolver) { + this._resolver = undefined; const { files, directories } = resolver.getEntries(this); for (const dir of directories) { const vdir = new VirtualDirectory(this, dir, resolver); @@ -673,14 +914,14 @@ namespace Utils { entries.set(vdir.name, vdir); } for (const file of files) { - const vfile = new VirtualFile(this, file, file => resolver.getContent(file)); + const vfile = new VirtualFile(this, file, resolver); if (this.isReadOnly) vfile.makeReadOnly(); entries.set(vfile.name, vfile); } } - else*/ if (this._shadowRoot) { - this._shadowRoot.getOwnEntries().forEach(entry => { - const clone = (entry).shadow(this); + else if (shadowRoot) { + shadowRoot.getOwnEntries().forEach(entry => { + const clone = entry.shadow(this); if (this.isReadOnly) clone.makeReadOnly(); entries.set(clone.name, clone); }); @@ -690,59 +931,24 @@ namespace Utils { return this._entries; } - private parsePath(path: string) { - return vpath.parse(vpath.normalize(path)); - } - - private walkContainers(components: string[], create: boolean) { - // no absolute paths (unless this is the root) - if (!!components[0] === !(this.parent instanceof VirtualFileSystem)) return undefined; - - // no relative paths - if (components[1] === "..") return undefined; - - // walk the components - let directory: VirtualDirectory | undefined = this; - for (let i = this.parent instanceof VirtualFileSystem ? 0 : 1; i < components.length - 1; i++) { - directory = create ? directory.getOrAddOwnDirectory(components[i]) : directory.getOwnDirectory(components[i]); - if (directory === undefined) return undefined; - } - - return directory; - } - - private getOwnEntry(name: string, options: { followSymlinks?: boolean, pattern?: RegExp, kind: "file" }): VirtualFile | undefined; - private getOwnEntry(name: string, options: { followSymlinks?: boolean, pattern?: RegExp, kind: "directory" }): VirtualDirectory | undefined; - private getOwnEntry(name: string, options?: { followSymlinks?: boolean, pattern?: RegExp, kind?: "file" | "directory" }): VirtualFile | VirtualDirectory | undefined; - private getOwnEntry(name: string, options: { followSymlinks?: boolean, pattern?: RegExp, kind?: "file" | "directory" } = {}): VirtualFile | VirtualDirectory | undefined { - const entry = this.getOwnEntries().get(name); - return entry && isMatch(entry, options) ? options.followSymlinks ? this.fileSystem.getRealEntry(entry) : entry : undefined; - } - - private getOwnDirectory(name: string) { - return this.getOwnEntry(name, { kind: "directory" }); - } - - private getOrAddOwnDirectory(name: string) { - return this.getOwnDirectory(name) || this.addOwnDirectory(name); - } - - private addOwnDirectory(name: string /*, resolver?: FileSystemResolver */): VirtualDirectory | undefined { + protected addOwnDirectory(name: string, resolver?: FileSystemResolver): VirtualDirectory | undefined { const existing = this.getOwnEntry(name); if (existing) { - if (/*!resolver &&*/ existing instanceof VirtualDirectory) { + if (!resolver && existing instanceof VirtualDirectory) { return existing; } return undefined; } - const entry = new VirtualDirectory(this, name /*, resolver */); + const entry = new VirtualDirectory(this, name, resolver); this.getOwnEntries().set(entry.name, entry); - this.raiseOnChildAdded(entry); + this.emit("childAdded", entry); + entry.emit("fileSystemChange", entry.path, "added"); + entry.addListener("fileSystemChange", this._onChildFileSystemChange); return entry; } - private addOwnFile(name: string, content?: /*FileSystemResolver["getContent"]*/ | string | undefined, options: { overwrite?: boolean } = {}): VirtualFile | undefined { + protected addOwnFile(name: string, content?: FileSystemResolver | ContentResolver | string, options: { overwrite?: boolean } = {}): VirtualFile | undefined { const existing = this.getOwnEntry(name); if (existing) { if (!options.overwrite || !(existing instanceof VirtualFile)) { @@ -755,177 +961,322 @@ namespace Utils { const entry = new VirtualFile(this, name, content); this.getOwnEntries().set(entry.name, entry); - this.raiseOnChildAdded(entry); + this.emit("childAdded", entry); + entry.emit("fileSystemChange", entry.path, "added"); + entry.addListener("fileSystemChange", this._onChildFileSystemChange); return entry; } - private addOwnSymlink(name: string, target: VirtualFile | VirtualDirectory): VirtualSymlink | undefined { + protected addOwnSymlink(name: string, target: VirtualEntry): VirtualSymlink | undefined { if (this.getOwnEntry(name)) return undefined; const entry = target instanceof VirtualFile ? new VirtualFileSymlink(this, name, target.path) : new VirtualDirectorySymlink(this, name, target.path); this.getOwnEntries().set(entry.name, entry); - this.raiseOnChildAdded(entry); + this.emit("childAdded", entry); + entry.emit("fileSystemChange", entry.path, "added"); + entry.addListener("fileSystemChange", this._onChildFileSystemChange); return entry; } - private removeOwnDirectory(name: string) { + protected removeOwnDirectory(name: string) { const entries = this.getOwnEntries(); const entry = entries.get(name); if (entry instanceof VirtualDirectory) { entries.delete(name); - this.raiseOnChildRemoved(entry); + this.emit("childRemoved", entry); + this.emit("fileSystemChange", entry.path, "removed"); + entry.removeListener("fileSystemChange", this._onChildFileSystemChange); return true; } return false; } - private removeOwnFile(name: string) { + protected removeOwnFile(name: string) { const entries = this.getOwnEntries(); const entry = entries.get(name); if (entry instanceof VirtualFile) { entries.delete(name); - this.raiseOnChildRemoved(entry); + this.emit("childRemoved", entry); + this.emit("fileSystemChange", entry.path, "removed"); + entry.removeListener("fileSystemChange", this._onChildFileSystemChange); return true; } return false; } + + private parsePath(path: string) { + return vpath.parse(vpath.normalize(path)); + } + + private walkContainers(components: string[], create: boolean) { + // no absolute paths (unless this is the root) + if (!!components[0] === !!this.parent) return undefined; + + // no relative paths + if (components[1] === "..") return undefined; + + // walk the components + let directory: VirtualDirectory | undefined = this; + for (let i = this.parent ? 1 : 0; i < components.length - 1; i++) { + directory = create ? directory.getOrAddOwnDirectory(components[i]) : directory.getOwnDirectory(components[i]); + if (directory === undefined) return undefined; + } + + return directory; + } + + private getOwnEntry(name: string, options: { followSymlinks?: boolean, pattern?: RegExp, kind: "file" }): VirtualFile | undefined; + private getOwnEntry(name: string, options: { followSymlinks?: boolean, pattern?: RegExp, kind: "directory" }): VirtualDirectory | undefined; + private getOwnEntry(name: string, options?: { followSymlinks?: boolean, pattern?: RegExp, kind?: "file" | "directory" }): VirtualEntry | undefined; + private getOwnEntry(name: string, options: { followSymlinks?: boolean, pattern?: RegExp, kind?: "file" | "directory" } = {}): VirtualEntry | undefined { + const entry = this.getOwnEntries().get(name); + return entry && isMatch(entry, options) ? options.followSymlinks ? this.fileSystem.getRealEntry(entry) : entry : undefined; + } + + private getOwnDirectory(name: string) { + return this.getOwnEntry(name, { kind: "directory" }); + } + + private getOrAddOwnDirectory(name: string) { + return this.getOwnDirectory(name) || this.addOwnDirectory(name); + } + + private onChildFileSystemChange(path: string, change: FileSystemChange) { + this.emit("fileSystemChange", path, change); + } } export class VirtualDirectorySymlink extends VirtualDirectory { private _targetPath: string; private _target: VirtualDirectory | undefined; - private _symLinks = new Map(); - private _symEntries: KeyedCollection | undefined; - private _onTargetParentChildRemoved: (entry: VirtualFile | VirtualDirectory) => void; - private _onTargetChildRemoved: (entry: VirtualFile | VirtualDirectory) => void; - private _onTargetChildAdded: (entry: VirtualFile | VirtualDirectory) => void; + private _pullEntries: KeyedCollection | undefined; + private _allEntries: KeyedCollection | undefined; + private _onTargetParentChildRemoved: (entry: VirtualEntry) => void; + private _onTargetChildRemoved: (entry: VirtualEntry) => void; + private _onTargetChildAdded: (entry: VirtualEntry) => void; + private _onTargetFileSystemChange: (path: string, change: FileSystemChange) => void; - constructor(parent: VirtualFileSystemContainer, name: string, target: string) { + constructor(parent: VirtualDirectory, name: string, target: string) { super(parent, name); + this._pullEntries = new KeyedCollection(this.fileSystem.useCaseSensitiveFileNames ? compareStrings.caseSensitive : compareStrings.caseInsensitive); this._targetPath = target; this._onTargetParentChildRemoved = entry => this.onTargetParentChildRemoved(entry); this._onTargetChildAdded = entry => this.onTargetChildAdded(entry); this._onTargetChildRemoved = entry => this.onTargetChildRemoved(entry); + this._onTargetFileSystemChange = (path, change) => this.onTargetFileSystemChange(path, change); } - public get target() { + /** + * Gets the path to the target of the symbolic link. + */ + public get targetPath() { return this._targetPath; } - public set target(value: string) { - this.writePreamble(); + /** + * Sets the path to the target of the symbolic link. + */ + public set targetPath(value: string) { if (this._targetPath !== value) { - this._targetPath = value; + this.writePreamble(); + this._targetPath = vpath.resolve(this.path, value); this.invalidateTarget(); } } - public get isBroken(): boolean { - return this.getRealDirectory() === undefined; - } - - public getRealDirectory(): VirtualDirectory | undefined { + /** + * Gets the resolved target directory for this symbolic link. + */ + public get target(): VirtualDirectory | undefined { this.resolveTarget(); return this._target; } - public addDirectory(path: string /*, resolver?: FileSystemResolver*/): VirtualDirectory | undefined { - const target = this.getRealDirectory(); - return target && target.addDirectory(path /*, resolver*/); + /** + * Gets a value indicating whether the symbolic link is broken. + */ + public get isBroken(): boolean { + return this.target === undefined; } - public addFile(path: string, content?: /*FileSystemResolver["getContent"]*/ | string | undefined): VirtualFile | undefined { - const target = this.getRealDirectory(); - return target && target.addFile(path, content); - } - - public removeDirectory(path: string): boolean { - const target = this.getRealDirectory(); - return target && target.removeDirectory(path) || false; - } - - public removeFile(path: string): boolean { - const target = this.getRealDirectory(); - return target && target.removeFile(path) || false; - } - - public shadow(parent: VirtualFileSystemContainer): VirtualDirectorySymlink { - this.shadowPreamble(parent); - const shadow = new VirtualDirectorySymlink(parent, this.name, this.target); + /** + * Creates a shadow copy of this directory. Changes made to the shadow do not affect + * this directory. + */ + public shadow(shadowParent: VirtualDirectory): VirtualDirectorySymlink { + this.shadowPreamble(shadowParent); + const shadow = new VirtualDirectorySymlink(shadowParent, this.name, this.targetPath); shadow._shadowRoot = this; return shadow; } - public resolveTarget(): void { - if (!this._target) { - const entry = findTarget(this.fileSystem, this.target); - if (entry instanceof VirtualDirectory) { - this._target = entry; - this._target.parent.addOnChildRemoved(this._onTargetParentChildRemoved); - this._target.addOnChildAdded(this._onTargetChildAdded); - this._target.addOnChildRemoved(this._onTargetChildRemoved); - } - } + protected addOwnDirectory(name: string, resolver?: FileSystemResolver): VirtualDirectory | undefined { + const realTarget = this.target; + const child = realTarget && realTarget.addDirectory(name, resolver); + return child && this.getView(child); } - protected getOwnEntries(): KeyedCollection { - if (!this._symEntries) { - const target = this.getRealDirectory(); - this._symEntries = new KeyedCollection(this.fileSystem.useCaseSensitiveFileNames ? compareStrings.caseSensitive : compareStrings.caseInsensitive); - if (target) { - for (const entry of target.getEntries()) { - this._symEntries.set(entry.name, this.getWrappedEntry(entry)); + protected addOwnFile(name: string, content?: FileSystemResolver | ContentResolver | string): VirtualFile | undefined { + const realTarget = this.target; + const child = realTarget && realTarget.addFile(name, content); + return child && this.getView(child); + } + + protected addOwnSymlink(name: string, target: VirtualEntry): VirtualSymlink | undefined { + const realTarget = this.target; + const child = realTarget && realTarget.addSymlink(name, target); + return child && this.getView(child); + } + + protected removeOwnDirectory(name: string): boolean { + const realTarget = this.target; + return realTarget && realTarget.removeDirectory(name) || false; + } + + protected removeOwnFile(name: string): boolean { + const realTarget = this.target; + return realTarget && realTarget.removeFile(name) || false; + } + + protected getOwnEntries(): KeyedCollection { + if (!this._allEntries) { + const realTarget = this.target; + this._allEntries = new KeyedCollection(this.fileSystem.useCaseSensitiveFileNames ? compareStrings.caseSensitive : compareStrings.caseInsensitive); + if (realTarget) { + for (const entry of realTarget.getEntries()) { + this._allEntries.set(entry.name, this.getView(entry)); } } } - return this._symEntries; + return this._allEntries; } - private getWrappedEntry(entry: VirtualFile | VirtualDirectory) { - let symlink = this._symLinks.get(entry); + private getView(entry: VirtualFile): VirtualFileView; + private getView(entry: VirtualDirectory): VirtualDirectoryView; + private getView(entry: VirtualEntry): VirtualEntryView; + private getView(entry: VirtualEntry) { + let symlink = this._pullEntries.get(entry.name); if (entry instanceof VirtualFile) { - if (symlink instanceof VirtualFileSymlink) { + if (symlink instanceof VirtualFileView) { return symlink; } - symlink = new VirtualFileSymlink(this, entry.name, entry.path); - this._symLinks.set(entry, symlink); + symlink = new VirtualFileView(this, entry.name, entry.path); + this._pullEntries.set(entry.name, symlink); } else { - if (symlink instanceof VirtualDirectorySymlink) { + if (symlink instanceof VirtualDirectoryView) { return symlink; } - symlink = new VirtualDirectorySymlink(this, entry.name, entry.path); - this._symLinks.set(entry, symlink); + symlink = new VirtualDirectoryView(this, entry.name, entry.path); + this._pullEntries.set(entry.name, symlink); } return symlink; } - private onTargetParentChildRemoved(entry: VirtualFileSystemEntry) { - if (entry !== this._target) return; - this.invalidateTarget(); - } - - private onTargetChildAdded(entry: VirtualFile | VirtualDirectory) { - const wrapped = this.getWrappedEntry(entry); - this.getOwnEntries().set(entry.name, wrapped); - this.raiseOnChildAdded(wrapped); - } - - private onTargetChildRemoved(entry: VirtualFile | VirtualDirectory) { - const wrapped = this.getWrappedEntry(entry); - this.getOwnEntries().delete(entry.name); - this._symLinks.delete(entry); - this.raiseOnChildRemoved(wrapped); + private resolveTarget(): void { + if (!this._target) { + const entry = findTarget(this.fileSystem, this.targetPath); + if (entry instanceof VirtualDirectory) { + this._target = entry; + if (this._target.parent) this._target.parent.addListener("childRemoved", this._onTargetParentChildRemoved); + this._target.addListener("childAdded", this._onTargetChildAdded); + this._target.addListener("childRemoved", this._onTargetChildRemoved); + this._target.addListener("fileSystemChange", this._onTargetFileSystemChange); + } + } } private invalidateTarget() { if (!this._target) return; - this._target.parent.removeOnChildRemoved(this._onTargetParentChildRemoved); - this._target.removeOnChildAdded(this._onTargetChildAdded); - this._target.removeOnChildRemoved(this._onTargetChildRemoved); + if (this._target.parent) this._target.parent.removeListener("childRemoved", this._onTargetParentChildRemoved); + this._target.removeListener("childAdded", this._onTargetChildAdded); + this._target.removeListener("childRemoved", this._onTargetChildRemoved); + this._target.removeListener("fileSystemChange", this._onTargetFileSystemChange); this._target = undefined; - this._symLinks.clear(); - this._symEntries = undefined; + this._pullEntries.clear(); + this._allEntries = undefined; } + + private onTargetParentChildRemoved(entry: VirtualEntry) { + if (entry === this._target) { + this.invalidateTarget(); + } + } + + private onTargetChildAdded(entry: VirtualEntry) { + const wrapped = this.getView(entry); + this.getOwnEntries().set(entry.name, wrapped); + this.emit("childAdded", wrapped); + } + + private onTargetChildRemoved(entry: VirtualEntry) { + const symlink = this.getView(entry); + this.getOwnEntries().delete(entry.name); + this._pullEntries.delete(entry.name); + this.emit("childRemoved", symlink); + } + + protected onTargetFileSystemChange(path: string, change: FileSystemChange) { + const ignoreCase = !this.fileSystem.useCaseSensitiveFileNames; + if (vpath.beneath(this.targetPath, path, ignoreCase)) { + const relative = vpath.relative(this.targetPath, path, ignoreCase); + const symbolicPath = vpath.combine(this.path, relative); + this.emit("fileSystemChange", symbolicPath, change); + } + } + } + + class VirtualDirectoryView extends VirtualDirectorySymlink { + /** + * Creates a shadow copy of this directory. Changes made to the shadow do not affect + * this directory. + */ + public shadow(shadowParent: VirtualDirectory): VirtualDirectoryView { + this.shadowPreamble(shadowParent); + const shadow = new VirtualDirectoryView(shadowParent, this.name, this.targetPath); + shadow._shadowRoot = this; + return shadow; + } + + protected onTargetFileSystemChange() { /* views do not propagate file system events */ } + } + + class VirtualRoot extends VirtualDirectory { + private _fileSystem: VirtualFileSystem; + + constructor(fileSystem: VirtualFileSystem) { + super(/*parent*/ undefined, ""); + this._fileSystem = fileSystem; + } + + public get fileSystem(): VirtualFileSystem { + return this._fileSystem; + } + + public get path(): string { + return ""; + } + + public get exists(): boolean { + return true; + } + + public _shadow(shadowFileSystem: VirtualFileSystem) { + super.checkShadowFileSystem(shadowFileSystem); + const shadow = new VirtualRoot(shadowFileSystem); + shadow._shadowRoot = this; + return shadow; + } + + public shadow(): never { + throw new TypeError(); + } + } + + export interface VirtualFile { + on(event: "fileSystemChange", listener: (path: string, change: FileSystemChange) => void): this; + on(event: "contentChanged", listener: (entry: VirtualFile) => void): this; + emit(event: "fileSystemChange", path: string, change: FileSystemChange): boolean; + emit(event: "contentChanged", entry: VirtualFile): boolean; } export class VirtualFile extends VirtualFileSystemEntry { @@ -933,51 +1284,72 @@ namespace Utils { private _parent: VirtualDirectory; private _content: string | undefined; private _contentWasSet: boolean; - // private _resolver: FileSystemResolver["getContent"] | undefined; + private _resolver: FileSystemResolver | ContentResolver | undefined; - constructor(parent: VirtualDirectory, name: string, content?: /*FileSystemResolver["getContent"]*/ | string | undefined) { + constructor(parent: VirtualDirectory, name: string, content?: FileSystemResolver | ContentResolver | string) { super(name); this._parent = parent; this._content = typeof content === "string" ? content : undefined; - // this._resolver = typeof content === "function" ? content : undefined; + this._resolver = typeof content !== "string" ? content : undefined; this._shadowRoot = undefined; this._contentWasSet = this._content !== undefined; } + /** + * Gets the parent directory for this entry. + */ public get parent(): VirtualDirectory { return this._parent; } + /** + * Gets the entry that this entry shadows. + */ public get shadowRoot(): VirtualFile | undefined { return this._shadowRoot; } - public getContent(): string | undefined { + /** + * Gets the text content of this file. + */ + public get content(): string | undefined { if (!this._contentWasSet) { - // const resolver = this._resolver; + const resolver = this._resolver; const shadowRoot = this._shadowRoot; - /* if (resolver) { - this._content = resolver(this); + if (resolver) { + this._resolver = undefined; + this._content = typeof resolver === "function" ? resolver(this) : resolver.getContent(this); this._contentWasSet = true; } - else */ if (shadowRoot) { - this._content = shadowRoot.getContent(); + else if (shadowRoot) { + this._content = shadowRoot.content; this._contentWasSet = true; } } return this._content; } - public setContent(value: string | undefined) { - this.writePreamble(); - // this._resolver = undefined; - this._content = value; - this._contentWasSet = true; + /** + * Sets the text content of this file. + */ + public set content(value: string | undefined) { + if (this.content !== value) { + this.writePreamble(); + this._resolver = undefined; + this._content = value; + this._contentWasSet = true; + this.emit("contentChanged", this); + this.emit("fileSystemChange", this.path, "modified"); + } } - public shadow(parent: VirtualDirectory): VirtualFile { - this.shadowPreamble(parent); - const shadow = new VirtualFile(parent, this.name); + /** + * Creates a shadow copy of this file. Changes made to the shadow do not affect + * this file. + */ + public shadow(shadowParent: VirtualDirectory): VirtualFile { + this.shadowPreamble(shadowParent); + const shadow = new VirtualFile(shadowParent, this.name); shadow._shadowRoot = this; shadow._contentWasSet = false; return shadow; @@ -988,69 +1360,150 @@ namespace Utils { } export class VirtualFileSymlink extends VirtualFile { - private _target: string; + private _targetPath: string; + private _target: VirtualFile | undefined; + private _onTargetParentChildRemoved: (entry: VirtualEntry) => void; + private _onTargetContentChanged: () => void; + private _onTargetFileSystemChange: (path: string, change: FileSystemChange) => void; constructor(parent: VirtualDirectory, name: string, target: string) { super(parent, name); - this._target = target; + this._targetPath = target; + this._onTargetParentChildRemoved = entry => this.onTargetParentChildRemoved(entry); + this._onTargetContentChanged = () => this.onTargetContentChanged(); + this._onTargetFileSystemChange = (path, change) => this.onTargetFileSystemChange(path, change); } - public get target(): string { + /** + * Gets the path to the target of the symbolic link. + */ + public get targetPath(): string { + return this._targetPath; + } + + /** + * Sets the path to the target of the symbolic link. + */ + public set targetPath(value: string) { + if (this._targetPath !== value) { + this.writePreamble(); + this._targetPath = vpath.resolve(this.path, value); + this.invalidateTarget(); + } + } + + /** + * Gets the resolved target file for this symbolic link. + */ + public get target(): VirtualFile | undefined { + this.resolveTarget(); return this._target; } - public set target(value: string) { - this.writePreamble(); - this._target = value; - } - + /** + * Gets a value indicating whether the symbolic link is broken. + */ public get isBroken(): boolean { - return this.getRealFile() === undefined; + return this.target === undefined; } - public getRealFile(): VirtualFile | undefined { - const entry = findTarget(this.fileSystem, this.target); - return entry instanceof VirtualFile ? entry : undefined; + /** + * Gets the text content of this file. + */ + public get content(): string | undefined { + const realTarget = this.target; + return realTarget && realTarget.content; } - public getContent(): string | undefined { - const target = this.getRealFile(); - return target && target.getContent(); + /** + * Sets the text content of this file. + */ + public set content(value: string | undefined) { + const realTarget = this.target; + if (realTarget) realTarget.content = value; } - public setContent(value: string | undefined) { - const target = this.getRealFile(); - if (target) target.setContent(value); - } - - public shadow(parent: VirtualDirectory) { - this.shadowPreamble(parent); - const shadow = new VirtualFileSymlink(parent, this.name, this.target); + /** + * Creates a shadow copy of this file. Changes made to the shadow do not affect + * this file. + */ + public shadow(shadowParent: VirtualDirectory) { + this.shadowPreamble(shadowParent); + const shadow = new VirtualFileSymlink(shadowParent, this.name, this.targetPath); shadow._shadowRoot = this; return shadow; } + + private resolveTarget() { + if (!this._target) { + const entry = findTarget(this.fileSystem, this.targetPath); + if (entry instanceof VirtualFile) { + this._target = entry; + if (this._target.parent) this._target.parent.addListener("childRemoved", this._onTargetParentChildRemoved); + this._target.addListener("contentChanged", this._onTargetContentChanged); + this._target.addListener("fileSystemChange", this._onTargetFileSystemChange); + } + } + } + + private invalidateTarget() { + if (!this._target) return; + if (this._target.parent) this._target.parent.removeListener("childRemoved", this._onTargetParentChildRemoved); + this._target.removeListener("contentChanged", this._onTargetContentChanged); + this._target.removeListener("fileSystemChange", this._onTargetFileSystemChange); + this._target = undefined; + } + + private onTargetParentChildRemoved(entry: VirtualEntry) { + if (entry === this._target) { + this.invalidateTarget(); + } + } + + private onTargetContentChanged() { + this.emit("contentChanged", this); + } + + protected onTargetFileSystemChange(_path: string, change: FileSystemChange) { + this.emit("fileSystemChange", this.path, change); + } } - export type VirtualSymlink = VirtualDirectorySymlink | VirtualFileSymlink; + class VirtualFileView extends VirtualFileSymlink { + /** + * Creates a shadow copy of this file. Changes made to the shadow do not affect + * this file. + */ + public shadow(shadowParent: VirtualDirectory) { + this.shadowPreamble(shadowParent); + const shadow = new VirtualFileView(shadowParent, this.name, this.targetPath); + shadow._shadowRoot = this; + return shadow; + } - function findTarget(vfs: VirtualFileSystem, target: string, set?: Set): VirtualFile | VirtualDirectory | undefined { + protected onTargetFileSystemChange() { /* views do not propagate file system events */ } + } + + function findTarget(vfs: VirtualFileSystem, target: string, set?: Set): VirtualEntry | undefined { const entry = vfs.getEntry(target); if (entry instanceof VirtualFileSymlink || entry instanceof VirtualDirectorySymlink) { - if (!set) set = new Set(); + if (!set) set = new Set(); if (set.has(entry)) return undefined; set.add(entry); - return findTarget(vfs, entry.target, set); + return findTarget(vfs, entry.targetPath, set); } return entry; } - function isMatch(entry: VirtualFile | VirtualDirectory, options: { pattern?: RegExp, kind?: "file" | "directory" }) { + function isMatch(entry: VirtualEntry, options: { pattern?: RegExp, kind?: "file" | "directory" }) { return (options.pattern === undefined || options.pattern.test(entry.name)) && (options.kind !== (entry instanceof VirtualFile ? "directory" : "file")); } +} +namespace Utils { // TODO(rbuckton): Move or retire this. - export class MockParseConfigHost extends VirtualFileSystem implements ts.ParseConfigHost { + export class MockParseConfigHost extends vfs.VirtualFileSystem implements ts.ParseConfigHost { constructor(currentDirectory: string, ignoreCase: boolean, files: ts.Map | string[]) { super(currentDirectory, ignoreCase); if (files instanceof Array) { diff --git a/src/harness/virtualFileSystemWithWatch.ts b/src/harness/virtualFileSystemWithWatch.ts index c16f57235e4..0175f2a77cb 100644 --- a/src/harness/virtualFileSystemWithWatch.ts +++ b/src/harness/virtualFileSystemWithWatch.ts @@ -1,5 +1,7 @@ /// +// TODO(rbuckton): Migrate this to use vfs. + namespace ts.TestFSWithWatch { const { content: libFileContent } = Harness.getDefaultLibraryFile(Harness.IO); export const libFile: FileOrFolder = { diff --git a/src/harness/vpath.ts b/src/harness/vpath.ts index b2e98a4859f..44783ccd229 100644 --- a/src/harness/vpath.ts +++ b/src/harness/vpath.ts @@ -1,10 +1,16 @@ /// - namespace vpath { - import compareStrings = ts.compareStrings; + // NOTE: Some of the functions here duplicate functionality from compiler/core.ts. They have been added + // to reduce the number of direct dependencies on compiler and services to eventually break away + // from depending directly on the compiler to speed up compilation time. - export function normalizeSlashes(path: string): string { - return path.replace(/\s*[\\/]\s*/g, "/").trim(); + import compareValues = collections.compareValues; + import compareStrings = collections.compareStrings; + + export const sep = "/"; + + export function normalizeSeparators(path: string): string { + return path.replace(/\s*[\\/]\s*/g, sep).trim(); } const rootRegExp = /^[\\/]([\\/](.*?[\\/](.*?[\\/])?)?)?|^[a-zA-Z]:[\\/]?|^\w+:\/{2}[^\\/]*\/?/; @@ -20,10 +26,18 @@ namespace vpath { const trailingSeperatorRegExp = /[\\/]$/; - export function hasTrailingSeperator(path: string) { + export function hasTrailingSeparator(path: string) { return trailingSeperatorRegExp.test(path); } + export function addTrailingSeparator(path: string) { + return hasTrailingSeparator(path) ? path : path + "/"; + } + + export function removeTrailingSeparator(path: string) { + return hasTrailingSeparator(path) ? path.slice(0, -1) : path; + } + function reduce(components: string[]) { const normalized = [components[0]]; for (let i = 1; i < components.length; i++) { @@ -41,20 +55,16 @@ namespace vpath { export function normalize(path: string): string { const components = reduce(parse(path)); - return components.length > 1 && hasTrailingSeperator(path) ? format(components) + "/" : format(components); + return components.length > 1 && hasTrailingSeparator(path) ? format(components) + sep : format(components); } export function combine(path: string, ...paths: string[]) { - path = normalizeSlashes(path); + path = normalizeSeparators(path); for (let name of paths) { - name = normalizeSlashes(name); + name = normalizeSeparators(name); if (name.length === 0) continue; - if (path.length === 0 || isAbsolute(name)) { - path = name; - } - else { - path = hasTrailingSeperator(path) ? path + name : path + "/" + name; - } + path = path.length === 0 || isAbsolute(name) ? name : + addTrailingSeparator(path) + name; } return path; } @@ -89,25 +99,65 @@ namespace vpath { return format(["", ...components]); } + export namespace relative { + export function caseSensitive(from: string, to: string) { return relative(from, to, /*ignoreCase*/ false); } + export function caseInsensitive(from: string, to: string) { return relative(from, to, /*ignoreCase*/ true); } + } + + export function compare(a: string, b: string, ignoreCase: boolean) { + if (!isAbsolute(a)) throw new Error("Path not absolute"); + if (!isAbsolute(b)) throw new Error("Path not absolute"); + if (a === b) return 0; + a = removeTrailingSeparator(a); + b = removeTrailingSeparator(b); + if (a === b) return 0; + const aComponents = reduce(parse(a)); + const bComponents = reduce(parse(b)); + const len = Math.min(aComponents.length, bComponents.length); + for (let i = 0; i < len; i++) { + const result = compareStrings(aComponents[i], bComponents[i], ignoreCase); + if (result !== 0) return result; + } + return compareValues(aComponents.length, bComponents.length); + } + + export namespace compare { + export function caseSensitive(a: string, b: string) { return compare(a, b, /*ignoreCase*/ false); } + export function caseInsensitive(a: string, b: string) { return compare(a, b, /*ignoreCase*/ true); } + } + + export function equals(a: string, b: string, ignoreCase: boolean) { + return compare(a, b, ignoreCase) === 0; + } + + export namespace equals { + export function caseSensitive(a: string, b: string) { return equals(a, b, /*ignoreCase*/ false); } + export function caseInsensitive(a: string, b: string) { return equals(a, b, /*ignoreCase*/ true); } + } + export function beneath(ancestor: string, descendant: string, ignoreCase: boolean) { if (!isAbsolute(ancestor)) throw new Error("Path not absolute"); if (!isAbsolute(descendant)) throw new Error("Path not absolute"); const ancestorComponents = reduce(parse(ancestor)); const descendantComponents = reduce(parse(descendant)); - + const len = Math.min(ancestorComponents.length, descendantComponents.length); let start: number; - for (start = 0; start < ancestorComponents.length && start < descendantComponents.length; start++) { + for (start = 0; start < len; start++) { if (compareStrings(ancestorComponents[start], descendantComponents[start], ignoreCase)) { break; } } - return start === ancestorComponents.length; } + export namespace beneath { + export function caseSensitive(ancestor: string, descendant: string) { return beneath(ancestor, descendant, /*ignoreCase*/ false); } + export function caseInsensitive(ancestor: string, descendant: string) { return beneath(ancestor, descendant, /*ignoreCase*/ true); } + } + export function parse(path: string) { - path = normalizeSlashes(path); + path = normalizeSeparators(path); const rootLength = getRootLength(path); const root = path.substring(0, rootLength); const rest = path.substring(rootLength).split(/\/+/g); @@ -116,19 +166,19 @@ namespace vpath { } export function format(components: string[]) { - return components.length ? components[0] + components.slice(1).join("/") : ""; + return components.length ? components[0] + components.slice(1).join(sep) : ""; } export function dirname(path: string) { - path = normalizeSlashes(path); - return path.substr(0, Math.max(getRootLength(path), path.lastIndexOf("/"))); + path = normalizeSeparators(path); + return path.substr(0, Math.max(getRootLength(path), path.lastIndexOf(sep))); } export function basename(path: string, ext?: string): string; export function basename(path: string, options?: { extensions?: string[], ignoreCase?: boolean }): string; export function basename(path: string, options?: { extensions?: string[], ignoreCase?: boolean } | string) { - path = normalizeSlashes(path); - const name = path.substr(Math.max(getRootLength(path), path.lastIndexOf("/") + 1)); + path = normalizeSeparators(path); + const name = path.substr(Math.max(getRootLength(path), path.lastIndexOf(sep) + 1)); const extension = typeof options === "string" ? options.startsWith(".") ? options : "." + options : options && options.extensions ? extname(name, options) : undefined; @@ -136,6 +186,7 @@ namespace vpath { } const extRegExp = /\.\w+$/; + export function extname(path: string, options?: { extensions?: string[], ignoreCase?: boolean }) { if (options && options.extensions) { for (let extension of options.extensions) { @@ -153,9 +204,4 @@ namespace vpath { const match = extRegExp.exec(path); return match ? match[0] : ""; } - - export function chext(path: string, ext: string, options?: { extensions?: string[], ignoreCase?: boolean }) { - const pathext = extname(path, options); - return pathext ? path.slice(0, path.length - pathext.length) + (ext.startsWith(".") ? ext : "." + ext) : path; - } } \ No newline at end of file diff --git a/tests/webTestServer.ts b/tests/webTestServer.ts index 5a3b4cc5048..a1f1747adcb 100644 --- a/tests/webTestServer.ts +++ b/tests/webTestServer.ts @@ -4,333 +4,767 @@ import http = require("http"); import fs = require("fs"); import path = require("path"); import url = require("url"); +import URL = url.URL; import child_process = require("child_process"); import os = require("os"); - -/// Command line processing /// - -if (process.argv[2] == "--help") { - console.log("Runs a node server on port 8888, looking for tests folder in the current directory\n"); - console.log("Syntax: node nodeServer.js [typescriptEnlistmentDirectory] [tests] [--browser] [--verbose]\n"); - console.log("Examples: \n\tnode nodeServer.js ."); - console.log("\tnode nodeServer.js 3000 D:/src/typescript/public --verbose IE"); -} - -function switchToForwardSlashes(path: string) { - return path.replace(/\\/g, "/").replace(/\/\//g, "/"); -} +import crypto = require("crypto"); const port = 8888; // harness.ts and webTestResults.html depend on this exact port number. +const baseUrl = new URL(`http://localhost:8888/`); +const rootDir = path.dirname(__dirname); +const useCaseSensitiveFileNames = isFileSystemCaseSensitive(); -let browser: string; -if (process.argv[2]) { - browser = process.argv[2]; - if (browser !== "chrome" && browser !== "IE") { - console.log(`Invalid command line arguments. Got ${browser} but expected chrome, IE or nothing.`); +let browser = "IE"; +let grep: string | undefined; +let verbose = false; + +interface HttpContent { + headers: any; + content: string; +} + +namespace HttpContent { + export function create(headers: object = {}, content?: string) { + return { headers, content }; + } + + export function clone(content: HttpContent): HttpContent { + return content && create(HttpHeaders.clone(content.headers), content.content); + } + + export function forMediaType(mediaType: string | string[], content: string): HttpContent { + return create({ "Content-Type": mediaType, "Content-Length": Buffer.byteLength(content, "utf8") }, content); + } + + export function text(content: string): HttpContent { + return forMediaType("text/plain", content); + } + + export function json(content: any): HttpContent { + return forMediaType("application/json", JSON.stringify(content)); } } -const grep = process.argv[3]; +namespace HttpHeaders { + export function clone(headers: http.OutgoingHttpHeaders) { + return { ...headers }; + } -let verbose = false; -if (process.argv[4] == "--verbose") { - verbose = true; -} -else if (process.argv[4] && process.argv[4] !== "--verbose") { - console.log(`Invalid command line arguments. Got ${process.argv[4]} but expected --verbose or nothing.`); + export function getCacheControl(headers: http.IncomingHttpHeaders | http.OutgoingHttpHeaders) { + let cacheControl = headers["Cache-Control"]; + let noCache = false; + let noStore = false; + let maxAge: number = undefined; + let maxStale: number = undefined; + let minFresh: number = undefined; + if (typeof cacheControl === "string") cacheControl = [cacheControl]; + if (Array.isArray(cacheControl)) { + for (const directive of cacheControl) { + if (directive === "no-cache") noCache = true; + else if (directive === "no-store") noStore = true; + else if (directive === "max-stale") maxStale = Infinity; + else if (/^no-cache=/.test(directive)) noCache = true; + else if (/^max-age=/.test(directive)) maxAge = +directive.slice(8).trim(); + else if (/^min-fresh=/.test(directive)) minFresh = +directive.slice(10).trim(); + else if (/^max-stale=/.test(directive)) maxStale = +directive.slice(10).trim(); + } + } + return { noCache, noStore, maxAge, maxStale, minFresh }; + } + + export function getExpires(headers: http.IncomingHttpHeaders | http.OutgoingHttpHeaders) { + const expires = headers["Expires"]; + if (typeof expires !== "string") return Infinity; + return new Date(expires).getTime(); + } + + export function getIfConditions(headers: http.IncomingHttpHeaders): { ifMatch: "*" | string[], ifNoneMatch: "*" | string[], ifModifiedSince: Date, ifUnmodifiedSince: Date } { + const ifMatch = toMatch(headers["If-Match"]); + const ifNoneMatch = toMatch(headers["If-None-Match"]); + const ifModifiedSince = toDate(headers["If-Modified-Since"]); + const ifUnmodifiedSince = toDate(headers["If-Unmodified-Since"]); + return { ifMatch, ifNoneMatch, ifModifiedSince, ifUnmodifiedSince }; + + function toMatch(value: string | string[]) { + return typeof value === "string" && value !== "*" ? [value] : value; + } + + function toDate(value: string | string[]) { + return value ? new Date(Array.isArray(value) ? value[0] : value) : undefined; + } + } + + export function combine(left: http.OutgoingHttpHeaders, right: http.OutgoingHttpHeaders) { + return left && right ? { ...left, ...right } : + left ? { ...left } : + right ? { ...right } : + {}; + } +} + +interface HttpRequestMessage { + url: url.URL; + method: string; + headers: http.IncomingHttpHeaders; + content?: HttpContent; + file?: string; + stats?: fs.Stats; +} + +namespace HttpRequestMessage { + export function create(method: string, url: URL | string, headers: http.IncomingHttpHeaders, content?: HttpContent) { + return { method, url: typeof url === "string" ? new URL(url, baseUrl) : url, headers, content }; + } + + export function getFile(message: HttpRequestMessage) { + return message.file || (message.file = path.join(rootDir, decodeURIComponent(message.url.pathname))); + } + + export function getStats(message: HttpRequestMessage, throwErrors?: boolean) { + return message.stats || (message.stats = throwErrors ? fs.statSync(getFile(message)) : tryStat(getFile(message))); + } + + export function readRequest(req: http.ServerRequest) { + return new Promise((resolve, reject) => { + let entityData: string | undefined; + req.setEncoding("utf8"); + req.on("data", (data: string) => { + if (entityData === undefined) { + entityData = data; + } + else { + entityData += data; + } + }); + req.on("end", () => { + const content = entityData !== undefined + ? HttpContent.forMediaType(req.headers["Content-Type"], entityData) + : undefined; + resolve(HttpRequestMessage.create(req.method, req.url, req.headers, content)); + }); + req.on("error", reject); + }); + } +} + +interface HttpResponseMessage { + statusCode?: number; + statusMessage?: string; + headers: http.OutgoingHttpHeaders; + content?: HttpContent; +} + +namespace HttpResponseMessage { + export function create(statusCode: number, headers: http.OutgoingHttpHeaders = {}, content?: HttpContent) { + return { statusCode, headers, content }; + } + + export function clone(message: HttpResponseMessage): HttpResponseMessage { + return { + statusCode: message.statusCode, + statusMessage: message.statusMessage, + headers: HttpHeaders.clone(message.headers), + content: HttpContent.clone(message.content) + }; + } + + export function ok(headers: http.OutgoingHttpHeaders, content: HttpContent | undefined): HttpResponseMessage; + export function ok(content?: HttpContent): HttpResponseMessage; + export function ok(contentOrHeaders: http.OutgoingHttpHeaders | HttpContent | undefined, content?: HttpContent): HttpResponseMessage { + let headers: http.OutgoingHttpHeaders; + if (!content) { + content = contentOrHeaders; + headers = {}; + } + return create(200, headers, content); + } + + export function created(location?: string, etag?: string): HttpResponseMessage { + return create(201, { "Location": location, "ETag": etag }); + } + + export function noContent(headers?: http.OutgoingHttpHeaders): HttpResponseMessage { + return create(204, headers); + } + + export function notModified(): HttpResponseMessage { + return create(304); + } + + export function badRequest(): HttpResponseMessage { + return create(400); + } + + export function notFound(): HttpResponseMessage { + return create(404); + } + + export function methodNotAllowed(allowedMethods: string[]): HttpResponseMessage { + return create(405, { "Allow": allowedMethods }); + } + + export function preconditionFailed(): HttpResponseMessage { + return create(412); + } + + export function unsupportedMediaType(): HttpResponseMessage { + return create(415); + } + + export function internalServerError(content?: HttpContent): HttpResponseMessage { + return create(500, {}, content); + } + + export function notImplemented(): HttpResponseMessage { + return create(501); + } + + export function setHeaders(obj: HttpResponseMessage | HttpContent, headers: http.OutgoingHttpHeaders) { + Object.assign(obj.headers, headers); + } + + export function writeResponse(message: HttpResponseMessage, response: http.ServerResponse) { + const content = message.content; + const headers = HttpHeaders.combine(message.headers, content && content.headers); + response.writeHead(message.statusCode, message.statusMessage || http.STATUS_CODES[message.statusCode], headers); + response.end(content && content.content, "utf8"); + } +} + +namespace HttpFileMessageHandler { + function handleGetRequest(request: HttpRequestMessage): HttpResponseMessage { + const file = HttpRequestMessage.getFile(request); + const stat = HttpRequestMessage.getStats(request); + const etag = ETag.compute(stat); + const headers: http.OutgoingHttpHeaders = { + "Last-Modified": stat.mtime.toUTCString(), + "ETag": etag + }; + + let content: HttpContent | undefined; + if (stat.isFile()) { + if (request.method === "HEAD") { + headers["Content-Type"] = guessMediaType(file); + headers["Content-Length"] = stat.size; + } + else { + content = HttpContent.forMediaType(guessMediaType(file), fs.readFileSync(file, "utf8")); + } + } + else { + return HttpResponseMessage.notFound(); + } + + return HttpResponseMessage.ok(headers, content); + } + + function handlePutRequest(request: HttpRequestMessage): HttpResponseMessage { + if (request.headers["Content-Encoding"]) return HttpResponseMessage.unsupportedMediaType(); + if (request.headers["Content-Range"]) return HttpResponseMessage.notImplemented(); + + const file = toLocalPath(request.url); + const exists = fs.existsSync(file); + mkdir(path.dirname(file)); + fs.writeFileSync(file, request.content, "utf8"); + return exists ? HttpResponseMessage.noContent() : HttpResponseMessage.created(); + } + + function handleDeleteRequest(request: HttpRequestMessage): HttpResponseMessage { + const file = HttpRequestMessage.getFile(request); + const stats = HttpRequestMessage.getStats(request); + if (stats.isFile()) { + fs.unlinkSync(file); + } + else if (stats.isDirectory()) { + fs.rmdirSync(file); + } + + return HttpResponseMessage.noContent(); + } + + function handleOptionsRequest(request: HttpRequestMessage): HttpResponseMessage { + return HttpResponseMessage.noContent({ + "X-Case-Sensitivity": useCaseSensitiveFileNames ? "CS" : "CI" + }); + } + + function handleRequestCore(request: HttpRequestMessage): HttpResponseMessage { + switch (request.method) { + case "HEAD": + case "GET": + return handleGetRequest(request); + case "PUT": + return handlePutRequest(request); + case "DELETE": + return handleDeleteRequest(request); + case "OPTIONS": + return handleOptionsRequest(request); + default: + return HttpResponseMessage.methodNotAllowed(["HEAD", "GET", "PUT", "DELETE", "OPTIONS"]); + } + } + + export function handleRequest(request: HttpRequestMessage): HttpResponseMessage { + let response = HttpCache.get(request); + if (!response) HttpCache.set(request, response = handleRequestCore(request)); + return response; + } +} + +namespace HttpApiMessageHandler { + function handleResolveRequest(request: HttpRequestMessage): HttpResponseMessage { + if (!request.content) return HttpResponseMessage.badRequest(); + const localPath = path.resolve(rootDir, request.content.content); + const relativePath = toURLPath(localPath); + return relativePath === undefined + ? HttpResponseMessage.badRequest() + : HttpResponseMessage.ok(HttpContent.text(relativePath)); + } + + function handleListFilesRequest(request: HttpRequestMessage): HttpResponseMessage { + if (!request.content) return HttpResponseMessage.badRequest(); + const localPath = path.resolve(rootDir, request.content.content); + const files: string[] = []; + visit(localPath, files); + return HttpResponseMessage.ok(HttpContent.json(files)); + + function visit(dirname: string, results: string[]) { + const { files, directories } = getAccessibleFileSystemEntries(dirname); + for (const file of files) { + results.push(toURLPath(path.join(dirname, file))); + } + for (const directory of directories) { + visit(path.join(dirname, directory), results); + } + } + } + + function handleDirectoryExistsRequest(request: HttpRequestMessage): HttpResponseMessage { + if (!request.content) return HttpResponseMessage.badRequest(); + const localPath = path.resolve(rootDir, request.content.content); + return HttpResponseMessage.ok(HttpContent.json(directoryExists(localPath))); + } + + function handlePostRequest(request: HttpRequestMessage): HttpResponseMessage { + switch (request.url.pathname) { + case "/api/resolve": + return handleResolveRequest(request); + case "/api/listFiles": + return handleListFilesRequest(request); + case "/api/directoryExists": + return handleDirectoryExistsRequest(request); + default: + return HttpResponseMessage.notFound(); + } + } + + export function handleRequest(request: HttpRequestMessage): HttpResponseMessage { + switch (request.method) { + case "POST": + return handlePostRequest(request); + default: + return HttpResponseMessage.methodNotAllowed(["POST"]); + } + } + + export function match(request: HttpRequestMessage) { + return /^\/api\//.test(request.url.pathname); + } +} + +namespace HttpMessageHandler { + export function handleRequest(request: HttpRequestMessage): HttpResponseMessage { + const { ifMatch, ifNoneMatch, ifModifiedSince, ifUnmodifiedSince } = HttpHeaders.getIfConditions(request.headers); + const stats = HttpRequestMessage.getStats(request, /*throwErrors*/ false); + if (stats) { + const etag = ETag.compute(stats); + if (ifNoneMatch) { + if (ETag.matches(etag, ifNoneMatch)) { + return HttpResponseMessage.notModified(); + } + } + else if (ifModifiedSince && stats.mtime.getTime() <= ifModifiedSince.getTime()) { + return HttpResponseMessage.notModified(); + } + + if (ifMatch && !ETag.matches(etag, ifMatch)) { + return HttpResponseMessage.preconditionFailed(); + } + + if (ifUnmodifiedSince && stats.mtime.getTime() > ifUnmodifiedSince.getTime()) { + return HttpResponseMessage.preconditionFailed(); + } + } + else if (ifMatch === "*") { + return HttpResponseMessage.preconditionFailed(); + } + + if (HttpApiMessageHandler.match(request)) { + return HttpApiMessageHandler.handleRequest(request); + } + else { + return HttpFileMessageHandler.handleRequest(request); + } + } + + export function handleError(e: any): HttpResponseMessage { + switch (e.code) { + case "ENOENT": return HttpResponseMessage.notFound(); + default: return HttpResponseMessage.internalServerError(HttpContent.text(e.toString())); + } + } +} + +namespace HttpCache { + interface CacheEntry { + timestamp: number; + expires: number; + response: HttpResponseMessage; + } + + const cache: Record = Object.create(null); + + export function get(request: HttpRequestMessage) { + if (request.method !== "GET" && request.method !== "HEAD") return undefined; + + const cacheControl = HttpHeaders.getCacheControl(request.headers); + if (cacheControl.noCache) return undefined; + + const entry = cache[request.url.toString()]; + if (!entry) return undefined; + + const age = (Date.now() - entry.timestamp) / 1000; + const lifetime = (entry.expires - Date.now()) / 1000; + + if (cacheControl.maxAge !== undefined && cacheControl.maxAge < age) return undefined; + if (lifetime >= 0) { + if (cacheControl.minFresh !== undefined && cacheControl.minFresh < lifetime) return undefined; + } + else { + if (cacheControl.maxStale === undefined || cacheControl.maxStale < -lifetime) { + return undefined; + } + } + + if (request.method === "GET" && !entry.response.content) { + return undefined; // partial response + } + + const response = HttpResponseMessage.clone(entry.response); + response.headers["Age"] = Math.floor(age); + return response; + } + + export function set(request: HttpRequestMessage, response: HttpResponseMessage) { + if (request.method !== "GET" && request.method !== "HEAD") return response; + + const cacheControl = HttpHeaders.getCacheControl(request.headers); + if (cacheControl.noCache) return response; + if (cacheControl.noStore) return response; + + const timestamp = Date.now(); + const expires = HttpHeaders.getExpires(response.headers); + const age = (Date.now() - timestamp) / 1000; + const lifetime = (expires - Date.now()) / 1000; + + if (cacheControl.maxAge !== undefined && cacheControl.maxAge < age) return response; + if (lifetime >= 0) { + if (cacheControl.minFresh !== undefined && cacheControl.minFresh < lifetime) return response; + } + else { + if (cacheControl.maxStale === undefined || cacheControl.maxStale < -lifetime) return response; + } + + cache[request.url.toString()] = { + timestamp, + expires, + response: HttpResponseMessage.clone(response) + }; + + response.headers["Age"] = Math.floor(age); + return response; + } + + function cleanupCache() { + for (const url in cache) { + const entry = cache[url]; + if (entry.expires < Date.now()) delete cache[url]; + } + } + + setInterval(cleanupCache, 60000).unref(); +} + +namespace ETag { + export function compute(stats: fs.Stats) { + return JSON.stringify(crypto + .createHash("sha1") + .update(JSON.stringify({ + dev: stats.dev, + ino: stats.ino, + mtime: stats.mtimeMs, + size: stats.size + })) + .digest("base64")); + } + + export function matches(etag: string | undefined, condition: "*" | string[]) { + return etag && condition === "*" || condition.indexOf(etag) >= 0; + } +} + +function isFileSystemCaseSensitive(): boolean { + // win32\win64 are case insensitive platforms + const platform = os.platform(); + if (platform === "win32" || platform === "win64") { + return false; + } + // If this file exists under a different case, we must be case-insensitve. + return !fs.existsSync(swapCase(__filename)); +} + +function swapCase(s: string): string { + return s.replace(/\w/g, (ch) => { + const up = ch.toUpperCase(); + return ch === up ? ch.toLowerCase() : up; + }); +} + +function hasLeadingSeparator(pathname: string) { + const ch = pathname.charAt(0); + return ch === "/" || ch === "\\"; +} + +function ensureLeadingSeparator(pathname: string) { + return hasLeadingSeparator(pathname) ? pathname : "/" + pathname; +} + +function trimLeadingSeparator(pathname: string) { + return hasLeadingSeparator(pathname) ? pathname.slice(1) : pathname; +} + +function normalizeSlashes(path: string) { + return path.replace(/\\+/g, "/"); +} + +function hasTrailingSeparator(pathname: string) { + const ch = pathname.charAt(pathname.length - 1); + return ch === "/" || ch === "\\"; +} + +function toLocalPath(url: url.URL) { + const pathname = decodeURIComponent(url.pathname); + return path.join(rootDir, pathname); +} + +function toURLPath(pathname: string) { + pathname = normalizeSlashes(pathname); + pathname = trimLeadingSeparator(pathname); + + const resolvedPath = path.resolve(rootDir, pathname); + if (resolvedPath.slice(0, rootDir.length) !== rootDir) { + return undefined; + } + + let relativePath = resolvedPath.slice(rootDir.length); + relativePath = ensureLeadingSeparator(relativePath); + relativePath = normalizeSlashes(relativePath); + return relativePath; +} + +function directoryExists(dirname: string) { + const stat = tryStat(dirname); + return !!stat && stat.isDirectory(); +} + +function mkdir(dirname: string) { + try { + fs.mkdirSync(dirname); + } + catch (e) { + if (e.code === "EEXIST") { + return; + } + if (e.code === "ENOENT") { + const parentdir = path.dirname(dirname); + if (!parentdir || parentdir === dirname) throw e; + mkdir(parentdir); + fs.mkdirSync(dirname); + return; + } + throw e; + } +} + +function tryStat(pathname: string) { + try { + return fs.statSync(pathname); + } + catch (e) { + return undefined; + } +} + +function getAccessibleFileSystemEntries(pathname: string) { + try { + const entries = fs.readdirSync(pathname).sort(); + const files: string[] = []; + const directories: string[] = []; + for (const entry of entries) { + // This is necessary because on some file system node fails to exclude + // "." and "..". See https://github.com/nodejs/node/issues/4002 + if (entry === "." || entry === "..") { + continue; + } + const name = path.join(pathname, entry); + + let stat: fs.Stats; + try { + stat = fs.statSync(name); + } + catch (e) { + continue; + } + + if (stat.isFile()) { + files.push(entry); + } + else if (stat.isDirectory()) { + directories.push(entry); + } + } + return { files, directories }; + } + catch (e) { + return { files: [], directories: [] }; + } } -/// Utils /// function log(msg: string) { if (verbose) { console.log(msg); } } - -const directorySeparator = "/"; - -function getRootLength(path: string): number { - if (path.charAt(0) === directorySeparator) { - if (path.charAt(1) !== directorySeparator) return 1; - const p1 = path.indexOf("/", 2); - if (p1 < 0) return 2; - const p2 = path.indexOf("/", p1 + 1); - if (p2 < 0) return p1 + 1; - return p2 + 1; +function guessMediaType(pathname: string) { + switch (path.extname(pathname).toLowerCase()) { + case ".html": return "text/html"; + case ".css": return "text/css"; + case ".js": return "application/javascript"; + case ".ts": return "text/plain"; + case ".json": return "text/plain"; + default: return "binary"; } - if (path.charAt(1) === ":") { - if (path.charAt(2) === directorySeparator) return 3; - return 2; - } - // Per RFC 1738 'file' URI schema has the shape file:/// - // if is omitted then it is assumed that host value is 'localhost', - // however slash after the omitted is not removed. - // file:///folder1/file1 - this is a correct URI - // file://folder2/file2 - this is an incorrect URI - if (path.lastIndexOf("file:///", 0) === 0) { - return "file:///".length; - } - const idx = path.indexOf("://"); - if (idx !== -1) { - return idx + "://".length; - } - return 0; } -function getDirectoryPath(path: string): any { - path = switchToForwardSlashes(path); - return path.substr(0, Math.max(getRootLength(path), path.lastIndexOf(directorySeparator))); +function printHelp() { + console.log("Runs an http server on port 8888, looking for tests folder in the current directory\n"); + console.log("Syntax: node webTestServer.js [browser] [tests] [--verbose]\n"); + console.log("Options:"); + console.log(" The browser to launch. One of 'IE', 'chrome', or 'none' (default 'IE')."); + console.log(" A regular expression to pass to Mocha."); + console.log(" --verbose Enables verbose logging.") } -function ensureDirectoriesExist(path: string) { - path = switchToForwardSlashes(path); - if (path.length > getRootLength(path) && !fs.existsSync(path)) { - const parentDirectory = getDirectoryPath(path); - ensureDirectoriesExist(parentDirectory); - if (!fs.existsSync(path)) { - fs.mkdirSync(path); +function parseCommandLine(args: string[]) { + let offset = 0; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const argLower = arg.toLowerCase(); + if (argLower === "--help") { + printHelp(); + return false; } - } -} - -// Copied from the compiler sources -function dir(dirPath: string, spec?: string, options?: any) { - options = options || <{ recursive?: boolean; }>{}; - return filesInFolder(dirPath); - - function filesInFolder(folder: string): string[] { - folder = switchToForwardSlashes(folder); - let paths: string[] = []; - // Everything after the current directory is relative - const baseDirectoryLength = process.cwd().length + 1; - - try { - const files = fs.readdirSync(folder); - for (let i = 0; i < files.length; i++) { - const stat = fs.statSync(path.join(folder, files[i])); - if (options.recursive && stat.isDirectory()) { - paths = paths.concat(filesInFolder(path.join(folder, files[i]))); - } - else if (stat.isFile() && (!spec || files[i].match(spec))) { - const relativePath = folder.substring(baseDirectoryLength); - paths.push(path.join(relativePath, files[i])); - } + else if (argLower === "--verbose") { + verbose = true; + } + else { + if (offset === 0) { + browser = arg; } - } - catch (err) { - // Skip folders that are inaccessible - } - return paths; - } -} - -function writeFile(path: string, data: any) { - ensureDirectoriesExist(getDirectoryPath(path)); - fs.writeFileSync(path, data); -} - -/// Request Handling /// - -function handleResolutionRequest(filePath: string, res: http.ServerResponse) { - let resolvedPath = path.resolve(filePath, ""); - resolvedPath = resolvedPath.substring(resolvedPath.indexOf("tests")); - resolvedPath = switchToForwardSlashes(resolvedPath); - send(ResponseCode.Success, res, resolvedPath); -} - -const enum ResponseCode { - Success = 200, - BadRequest = 400, - NotFound = 404, - MethodNotAllowed = 405, - PayloadTooLarge = 413, - Fail = 500 -} - -function send(responseCode: number, res: http.ServerResponse, contents: string, contentType = "binary"): void { - res.writeHead(responseCode, { "Content-Type": contentType }); - res.end(contents); -} - -// Reads the data from a post request and passes it to the given callback -function processPost(req: http.ServerRequest, res: http.ServerResponse, callback: (data: string) => any): void { - let queryData = ""; - if (typeof callback !== "function") return; - - if (req.method == "POST") { - req.on("data", (data: string) => { - queryData += data; - if (queryData.length > 1e8) { - queryData = ""; - send(ResponseCode.PayloadTooLarge, res, undefined); - console.log("ERROR: destroying connection"); - req.connection.destroy(); + else if (offset === 1) { + grep = arg; } - }); + else { + console.log(`Unrecognized argument: ${arg}\n`); + return false; + } + offset++; + } + } - req.on("end", () => { - // res.post = url.parse(req.url).query; - callback(queryData); - }); + if (browser !== "IE" && browser !== "chrome") { + console.log(`Unrecognized browser '${browser}', expected 'IE' or 'chrome'.`); + return false; + } + return true; +} + +function startServer() { + console.log(`Static file server running at\n => http://localhost:${port}/\nCTRL + C to shutdown`); + http.createServer((serverRequest: http.ServerRequest, serverResponse: http.ServerResponse) => { + log(`${serverRequest.method} ${serverRequest.url}`); + HttpRequestMessage + .readRequest(serverRequest) + .then(HttpMessageHandler.handleRequest) + .catch(HttpMessageHandler.handleError) + .then(response => HttpResponseMessage.writeResponse(response, serverResponse)); + }).listen(port); +} + +function startClient() { + let browserPath: string; + if (browser === "none") { + return; + } + + if (browser === "chrome") { + let defaultChromePath = ""; + switch (os.platform()) { + case "win32": + defaultChromePath = "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe"; + break; + case "darwin": + defaultChromePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; + break; + case "linux": + defaultChromePath = "/opt/google/chrome/chrome"; + break; + default: + console.log(`default Chrome location is unknown for platform '${os.platform()}'`); + break; + } + if (fs.existsSync(defaultChromePath)) { + browserPath = defaultChromePath; + } + else { + browserPath = browser; + } } else { - send(ResponseCode.MethodNotAllowed, res, undefined); - } -} - -enum RequestType { - GetFile, - GetDir, - ResolveFile, - WriteFile, - DeleteFile, - WriteDir, - DeleteDir, - AppendFile, - Unknown -} - -function getRequestOperation(req: http.ServerRequest) { - if (req.method === "GET" && req.url.indexOf("?") === -1) { - if (req.url.indexOf(".") !== -1) return RequestType.GetFile; - else return RequestType.GetDir; - } - else { - - const queryData: any = url.parse(req.url, /*parseQueryString*/ true).query; - if (req.method === "GET" && queryData.resolve !== undefined) return RequestType.ResolveFile; - // mocha uses ?grep= query string as equivalent to the --grep command line option used to filter tests - if (req.method === "GET" && queryData.grep !== undefined) return RequestType.GetFile; - if (req.method === "POST" && queryData.action) { - const path = req.url.substr(0, req.url.lastIndexOf("?")); - const isFile = path.substring(path.lastIndexOf("/")).indexOf(".") !== -1; - switch (queryData.action.toUpperCase()) { - case "WRITE": - return isFile ? RequestType.WriteFile : RequestType.WriteDir; - case "DELETE": - return isFile ? RequestType.DeleteFile : RequestType.DeleteDir; - case "APPEND": - return isFile ? RequestType.AppendFile : RequestType.Unknown; - } + const defaultIEPath = "C:/Program Files/Internet Explorer/iexplore.exe"; + if (fs.existsSync(defaultIEPath)) { + browserPath = defaultIEPath; } - return RequestType.Unknown; - } -} - -function handleRequestOperation(req: http.ServerRequest, res: http.ServerResponse, operation: RequestType, reqPath: string) { - switch (operation) { - case RequestType.GetDir: - const filesInFolder = dir(reqPath, "", { recursive: true }); - send(ResponseCode.Success, res, filesInFolder.join(",")); - break; - case RequestType.GetFile: - fs.readFile(reqPath, (err, file) => { - const contentType = contentTypeForExtension(path.extname(reqPath)); - if (err) { - send(ResponseCode.NotFound, res, err.message, contentType); - } - else { - send(ResponseCode.Success, res, file, contentType); - } - }); - break; - case RequestType.ResolveFile: - const resolveRequest = req.url.match(/(.*)\?resolve/); - handleResolutionRequest(resolveRequest[1], res); - break; - case RequestType.WriteFile: - processPost(req, res, (data) => { - writeFile(reqPath, data); - }); - send(ResponseCode.Success, res, undefined); - break; - case RequestType.WriteDir: - fs.mkdirSync(reqPath); - send(ResponseCode.Success, res, undefined); - break; - case RequestType.DeleteFile: - if (fs.existsSync(reqPath)) { - fs.unlinkSync(reqPath); - } - send(ResponseCode.Success, res, undefined); - break; - case RequestType.DeleteDir: - if (fs.existsSync(reqPath)) { - fs.rmdirSync(reqPath); - } - send(ResponseCode.Success, res, undefined); - break; - case RequestType.AppendFile: - processPost(req, res, (data) => { - fs.appendFileSync(reqPath, data); - }); - send(ResponseCode.Success, res, undefined); - break; - case RequestType.Unknown: - default: - send(ResponseCode.BadRequest, res, undefined); - break; - } - - function contentTypeForExtension(ext: string) { - switch (ext) { - case ".js": return "text/javascript"; - case ".css": return "text/css"; - case ".html": return "text/html"; - default: return "binary"; + else { + browserPath = browser; } } + + console.log(`Using browser: ${browserPath}`); + + const queryString = grep ? `?grep=${grep}` : ""; + child_process.spawn(browserPath, [`http://localhost:${port}/tests/webTestResults.html${queryString}`], { + stdio: "inherit" + }); } -console.log(`Static file server running at\n => http://localhost:${port}/\nCTRL + C to shutdown`); - -http.createServer((req: http.ServerRequest, res: http.ServerResponse) => { - log(`${req.method} ${req.url}`); - const uri = decodeURIComponent(url.parse(req.url).pathname); - const reqPath = path.join(process.cwd(), uri); - const operation = getRequestOperation(req); - handleRequestOperation(req, res, operation, reqPath); -}).listen(port); - -let browserPath: string; -if (browser === "chrome") { - let defaultChromePath = ""; - switch (os.platform()) { - case "win32": - defaultChromePath = "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe"; - break; - case "darwin": - defaultChromePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; - break; - case "linux": - defaultChromePath = "/opt/google/chrome/chrome"; - break; - default: - console.log(`default Chrome location is unknown for platform '${os.platform()}'`); - break; - } - if (fs.existsSync(defaultChromePath)) { - browserPath = defaultChromePath; - } - else { - browserPath = browser; - } -} -else { - const defaultIEPath = "C:/Program Files/Internet Explorer/iexplore.exe"; - if (fs.existsSync(defaultIEPath)) { - browserPath = defaultIEPath; - } - else { - browserPath = browser; +function main() { + if (parseCommandLine(process.argv.slice(2))) { + startServer(); + startClient(); } } -console.log(`Using browser: ${browserPath}`); - -const queryString = grep ? `?grep=${grep}` : ""; -child_process.spawn(browserPath, [`http://localhost:${port}/tests/webTestResults.html${queryString}`], { - stdio: "inherit" -}); +main(); \ No newline at end of file