diff --git a/src/harness/collections.ts b/src/harness/collections.ts index 7c71296409c..5c46ff63015 100644 --- a/src/harness/collections.ts +++ b/src/harness/collections.ts @@ -210,6 +210,200 @@ namespace collections { } } + export class SortedSet { + private _comparer: (a: T, b: T) => number; + private _values: T[] = []; + private _order: number[] | undefined; + private _version = 0; + private _copyOnWrite = false; + + constructor(comparer: ((a: T, b: T) => number) | SortOptions, iterable?: Iterable) { + this._comparer = typeof comparer === "object" ? comparer.comparer : comparer; + this._order = typeof comparer === "object" && comparer.sort === "insertion" ? [] : undefined; + if (iterable) { + const iterator = getIterator(iterable); + try { + for (let i = nextResult(iterator); i; i = nextResult(iterator)) { + const value = i.value; + this.add(value); + } + } + finally { + closeIterator(iterator); + } + } + } + + public get size() { + return this._values.length; + } + + public get comparer() { + return this._comparer; + } + + public get [Symbol.toStringTag]() { + return "SortedSet"; + } + + public has(key: T) { + return ts.binarySearch(this._values, key, ts.identity, this._comparer) >= 0; + } + + public add(value: T) { + const index = ts.binarySearch(this._values, value, ts.identity, this._comparer); + if (index >= 0) { + this._values[index] = value; + } + else { + this.writePreamble(); + insertAt(this._values, ~index, value); + if (this._order) insertAt(this._order, ~index, this._version); + this.writePostScript(); + } + return this; + } + + public delete(value: T) { + const index = ts.binarySearch(this._values, value, ts.identity, this._comparer); + if (index >= 0) { + this.writePreamble(); + ts.orderedRemoveItemAt(this._values, index); + if (this._order) ts.orderedRemoveItemAt(this._order, index); + this.writePostScript(); + return true; + } + return false; + } + + public clear() { + if (this.size > 0) { + this.writePreamble(); + this._values.length = 0; + if (this._order) this._order.length = 0; + this.writePostScript(); + } + } + + public forEach(callback: (value: T, key: T, collection: this) => void, thisArg?: any) { + const values = this._values; + const indices = this.getIterationOrder(); + const version = this._version; + this._copyOnWrite = true; + try { + if (indices) { + for (const i of indices) { + callback.call(thisArg, values[i], values[i], this); + } + } + else { + for (const value of values) { + callback.call(thisArg, value, value, this); + } + } + } + finally { + if (version === this._version) { + this._copyOnWrite = false; + } + } + } + + public * keys() { + const values = this._values; + const indices = this.getIterationOrder(); + const version = this._version; + this._copyOnWrite = true; + try { + if (indices) { + for (const i of indices) { + yield values[i]; + } + } + else { + yield* values; + } + } + finally { + if (version === this._version) { + this._copyOnWrite = false; + } + } + } + + public * values() { + const values = this._values; + const indices = this.getIterationOrder(); + const version = this._version; + this._copyOnWrite = true; + try { + if (indices) { + for (const i of indices) { + yield values[i]; + } + } + else { + yield* values; + } + } + finally { + if (version === this._version) { + this._copyOnWrite = false; + } + } + } + + public * entries() { + const values = this._values; + const indices = this.getIterationOrder(); + const version = this._version; + this._copyOnWrite = true; + try { + if (indices) { + for (const i of indices) { + yield [values[i], values[i]] as [T, T]; + } + } + else { + for (const value of values) { + yield [value, value] as [T, T]; + } + } + } + finally { + if (version === this._version) { + this._copyOnWrite = false; + } + } + } + + public [Symbol.iterator]() { + return this.values(); + } + + private writePreamble() { + if (this._copyOnWrite) { + this._values = this._values.slice(); + if (this._order) this._order = this._order.slice(); + this._copyOnWrite = false; + } + } + + private writePostScript() { + this._version++; + } + + private getIterationOrder() { + if (this._order) { + const order = this._order; + return this._order + .map((_, i) => i) + .sort((x, y) => order[x] - order[y]); + } + return undefined; + } + } + export function insertAt(array: T[], index: number, value: T): void { if (index === 0) { array.unshift(value); diff --git a/src/harness/unittests/vfs.ts b/src/harness/unittests/vfs.ts new file mode 100644 index 00000000000..0538a7cbbfb --- /dev/null +++ b/src/harness/unittests/vfs.ts @@ -0,0 +1,481 @@ +// tslint:disable:object-literal-key-quotes +describe("vfs", () => { + function describeFileSystem(title: string, ignoreCase: boolean, root: string) { + describe(title, () => { + describe("statSync", () => { + it("ok (file)", () => { + const fs = new vfs.FileSystem(ignoreCase, { time: 0, files: { [root]: { "file": "testing" } } }); + fs.time(1); + const stats = fs.statSync("file"); + assert.isTrue(stats.isFile()); + assert.strictEqual(stats.nlink, 1); + assert.strictEqual(stats.size, 7); + assert.strictEqual(stats.atimeMs, 0); + assert.strictEqual(stats.mtimeMs, 0); + assert.strictEqual(stats.ctimeMs, 0); + assert.strictEqual(stats.birthtimeMs, 0); + }); + + it("ok (file, hardlink)", () => { + const fs = new vfs.FileSystem(ignoreCase, { time: 0, files: { [root]: { "file": "testing" } } }); + fs.time(1); + fs.linkSync("file", "link"); + const stats = fs.statSync("link"); + assert.isTrue(stats.isFile()); + assert.strictEqual(stats.nlink, 2); + assert.strictEqual(stats.size, 7); + assert.strictEqual(stats.atimeMs, 0); + assert.strictEqual(stats.mtimeMs, 0); + assert.strictEqual(stats.ctimeMs, 1); + assert.strictEqual(stats.birthtimeMs, 0); + }); + + it("ok (file, symlink)", () => { + const fs = new vfs.FileSystem(ignoreCase, { time: 0, files: { [root]: { "file": "testing" } } }); + fs.time(1); + fs.symlinkSync("file", "symlink"); + const stats = fs.statSync("symlink"); + assert.isTrue(stats.isFile()); + assert.strictEqual(stats.nlink, 1); + assert.strictEqual(stats.size, 7); + assert.strictEqual(stats.atimeMs, 0); + assert.strictEqual(stats.mtimeMs, 0); + assert.strictEqual(stats.ctimeMs, 0); + assert.strictEqual(stats.birthtimeMs, 0); + }); + + it("ok (directory)", () => { + const fs = new vfs.FileSystem(ignoreCase, { time: 0, files: { [root]: { "dir": {} } } }); + fs.time(1); + const stats = fs.statSync("dir"); + assert.isTrue(stats.isDirectory()); + assert.strictEqual(stats.nlink, 1); + assert.strictEqual(stats.size, 0); + assert.strictEqual(stats.atimeMs, 0); + assert.strictEqual(stats.mtimeMs, 0); + assert.strictEqual(stats.ctimeMs, 0); + assert.strictEqual(stats.birthtimeMs, 0); + }); + + it("ok (directory, symlink)", () => { + const fs = new vfs.FileSystem(ignoreCase, { time: 0, files: { [root]: { "dir": {} } } }); + fs.time(1); + fs.symlinkSync("dir", "symlink"); + const stats = fs.statSync("symlink"); + assert.isTrue(stats.isDirectory()); + assert.strictEqual(stats.nlink, 1); + assert.strictEqual(stats.size, 0); + assert.strictEqual(stats.atimeMs, 0); + assert.strictEqual(stats.mtimeMs, 0); + assert.strictEqual(stats.ctimeMs, 0); + assert.strictEqual(stats.birthtimeMs, 0); + }); + }); + + describe("lstatSync", () => { + it("ok (file)", () => { + const fs = new vfs.FileSystem(ignoreCase, { time: 0, files: { [root]: { "file": "testing" } } }); + fs.time(1); + const stats = fs.lstatSync("file"); + assert.isTrue(stats.isFile()); + assert.strictEqual(stats.nlink, 1); + assert.strictEqual(stats.size, 7); + assert.strictEqual(stats.atimeMs, 0); + assert.strictEqual(stats.mtimeMs, 0); + assert.strictEqual(stats.ctimeMs, 0); + assert.strictEqual(stats.birthtimeMs, 0); + }); + + it("ok (file, hardlink)", () => { + const fs = new vfs.FileSystem(ignoreCase, { time: 0, files: { [root]: { "file": "testing" } } }); + fs.time(1); + fs.linkSync("file", "link"); + const stats = fs.lstatSync("link"); + assert.isTrue(stats.isFile()); + assert.strictEqual(stats.nlink, 2); + assert.strictEqual(stats.size, 7); + assert.strictEqual(stats.atimeMs, 0); + assert.strictEqual(stats.mtimeMs, 0); + assert.strictEqual(stats.ctimeMs, 1); + assert.strictEqual(stats.birthtimeMs, 0); + }); + + it("ok (file, symlink)", () => { + const fs = new vfs.FileSystem(ignoreCase, { time: 0, files: { [root]: { "file": "testing" } } }); + fs.time(1); + fs.symlinkSync("file", "symlink"); + const stats = fs.lstatSync("symlink"); + assert.isTrue(stats.isSymbolicLink()); + assert.strictEqual(stats.nlink, 1); + assert.strictEqual(stats.size, 4); + assert.strictEqual(stats.atimeMs, 1); + assert.strictEqual(stats.mtimeMs, 1); + assert.strictEqual(stats.ctimeMs, 1); + assert.strictEqual(stats.birthtimeMs, 1); + }); + + it("ok (directory)", () => { + const fs = new vfs.FileSystem(ignoreCase, { time: 0, files: { [root]: { "dir": {} } } }); + fs.time(1); + const stats = fs.lstatSync("dir"); + assert.isTrue(stats.isDirectory()); + assert.strictEqual(stats.nlink, 1); + assert.strictEqual(stats.size, 0); + assert.strictEqual(stats.atimeMs, 0); + assert.strictEqual(stats.mtimeMs, 0); + assert.strictEqual(stats.ctimeMs, 0); + assert.strictEqual(stats.birthtimeMs, 0); + }); + + it("ok (directory, symlink)", () => { + const fs = new vfs.FileSystem(ignoreCase, { time: 0, files: { [root]: { "dir": {} } } }); + fs.time(1); + fs.symlinkSync("dir", "symlink"); + const stats = fs.lstatSync("symlink"); + assert.isTrue(stats.isSymbolicLink()); + assert.strictEqual(stats.nlink, 1); + assert.strictEqual(stats.size, 3); + assert.strictEqual(stats.atimeMs, 1); + assert.strictEqual(stats.mtimeMs, 1); + assert.strictEqual(stats.ctimeMs, 1); + assert.strictEqual(stats.birthtimeMs, 1); + }); + }); + + describe("readdirSync", () => { + it("ok", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "a": {} } } }); + const actual = fs.readdirSync(root); + assert.deepEqual(actual, ["a"]); + }); + }); + + describe("mkdirSync", () => { + it("ok", () => { + const path = vpath.combine(root, "a"); + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: {} } }); + fs.mkdirSync(path); + assert.isTrue(fs.statSync(path).isDirectory()); + }); + }); + + describe("rmdirSync", () => { + it("ok", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "a": {} } } }); + fs.rmdirSync(vpath.combine(root, "a")); + const actual = fs.readdirSync(root); + assert.deepEqual(actual.length, 0); + }); + }); + + describe("linkSync", () => { + it("ok", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "file": "" } } }); + fs.linkSync("file", "link"); + const stats1 = fs.statSync("file"); + const stats2 = fs.statSync("link"); + assert.deepEqual(stats2, stats1); + assert.strictEqual(stats1.nlink, 2); + }); + }); + + describe("unlinkSync", () => { + it("ok (nlink = 1)", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "file": "" } } }); + fs.unlinkSync("file"); + assert.throws(() => fs.statSync("file"), /ENOENT/); + }); + + it("ok (nlink > 1)", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "file": "" } } }); + fs.linkSync("file", "link"); + fs.unlinkSync("file"); + assert.throws(() => fs.statSync("file"), /ENOENT/); + const stats = fs.statSync("link"); + assert.strictEqual(stats.nlink, 1); + }); + }); + + describe("renameSync", () => { + it("ok", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "file": "" } } }); + const stats1 = fs.statSync("file"); + fs.renameSync("file", "renamed"); + assert.throws(() => fs.statSync("file"), /ENOENT/); + const stats2 = fs.statSync("renamed"); + assert.strictEqual(stats2.ino, stats1.ino); + }); + }); + + describe("symlinkSync", () => { + it("ok (absolute target)", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "file": "test" } } }); + fs.symlinkSync(vpath.combine(root, "file"), "symlink"); + assert.strictEqual(fs.readFileSync("symlink", "utf8"), "test"); + }); + + it("ok (relative target)", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "file": "test" } } }); + fs.symlinkSync("file", "symlink"); + assert.strictEqual(fs.readFileSync("symlink", "utf8"), "test"); + }); + + it("ok (indirect target)", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "file": "test" } } }); + fs.symlinkSync("file", "symlink1"); + fs.symlinkSync("symlink1", "symlink2"); + assert.strictEqual(fs.readFileSync("symlink2", "utf8"), "test"); + }); + }); + + describe("realpathSync", () => { + it("ok (absolute target)", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "file": "test" } } }); + fs.symlinkSync(vpath.combine(root, "file"), "symlink"); + assert.strictEqual(fs.realpathSync("symlink"), vpath.combine(root, "file")); + }); + + it("ok (relative target)", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "file": "test" } } }); + fs.symlinkSync("file", "symlink"); + assert.strictEqual(fs.realpathSync("symlink"), vpath.combine(root, "file")); + }); + + it("ok (indirect target)", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "file": "test" } } }); + fs.symlinkSync("file", "symlink1"); + fs.symlinkSync("symlink1", "symlink2"); + assert.strictEqual(fs.realpathSync("symlink2"), vpath.combine(root, "file")); + }); + }); + + describe("readFileSync", () => { + it("ok", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "file": "test" } } }); + assert.strictEqual(fs.readFileSync("file", "utf8"), "test"); + }); + }); + + describe("writeFileSync", () => { + it("ok", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "file": "test" } } }); + fs.writeFileSync("file", "replacement", "utf8"); + assert.strictEqual(fs.readFileSync("file", "utf8"), "replacement"); + }); + }); + + describe("mount", () => { + it("ok", () => { + const other = new vfs.FileSystem(/*ignoreCase*/ false, { files: { + "/": { + "subdir": {}, + "file": "" + } + } }); + + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: {} } }); + fs.mountSync("/", vpath.combine(root, "dir"), other); + + const names = fs.readdirSync(vpath.combine(root, "dir")); + assert.deepEqual(names, ["file", "subdir"]); + }); + }); + + describe("shadow", () => { + it("ok", () => { + const rootFs = new vfs.FileSystem(ignoreCase, { files: { [root]: { } } }).makeReadonly(); + const shadowFs = rootFs.shadow(); + assert.strictEqual(shadowFs.shadowRoot, rootFs); + }); + + it("shadow reads from root", () => { + const rootFs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "dir": {}, "file": "test" } } }).makeReadonly(); + const shadowFs = rootFs.shadow(); + assert.deepEqual(shadowFs.readdirSync(root), ["dir", "file"]); + assert.strictEqual(shadowFs.readFileSync("file", "utf8"), "test"); + }); + + it("shadow write does not affect root", () => { + const rootFs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "dir": {}, "file": "test" } } }).makeReadonly(); + const shadowFs = rootFs.shadow(); + shadowFs.writeFileSync("file", "replacement", "utf8"); + assert.strictEqual(rootFs.readFileSync("file", "utf8"), "test"); + }); + }); + + describe("meta", () => { + it("ok", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "file": "" } } }); + fs.filemeta("file").set("testKey", "testValue"); + assert.strictEqual(fs.filemeta("file").get("testKey"), "testValue"); + }); + + it("shadow inherits from root", () => { + const rootFs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "file": "" } } }); + rootFs.filemeta("file").set("testKey", "testValue"); + rootFs.makeReadonly(); + const shadowFs = rootFs.shadow(); + assert.strictEqual(shadowFs.filemeta("file").get("testKey"), "testValue"); + }); + + it("shadow inherits from root with mutation", () => { + const rootFs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "file": "" } } }); + rootFs.makeReadonly(); + const shadowFs = rootFs.shadow(); + rootFs.filemeta("file").set("testKey", "testValue"); + assert.strictEqual(shadowFs.filemeta("file").get("testKey"), "testValue"); + }); + + it("shadow does not mutate root", () => { + const rootFs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "file": "" } } }); + rootFs.filemeta("file").set("testKey", "testValue"); + rootFs.makeReadonly(); + const shadowFs = rootFs.shadow(); + shadowFs.filemeta("file").set("testKey", "newValue"); + assert.strictEqual(rootFs.filemeta("file").get("testKey"), "testValue"); + }); + }); + + describe("watch", () => { + it("writeFile() new file in directory", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "dir": {} } } }); + const invocations: [string, string][] = []; + const callback = (eventType: string, filename: string) => { invocations.push([eventType, filename]); }; + + fs.watch(vpath.combine(root, "dir"), callback); + fs.writeFileSync("dir/file", "test"); + + assert.deepEqual(invocations, [ + ["rename", "file"], + ["change", "file"] + ]); + }); + it("writeFile() replace file in directory", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "dir": { "file": "" } } } }); + const invocations: [string, string][] = []; + const callback = (eventType: string, filename: string) => { invocations.push([eventType, filename]); }; + + fs.watch(vpath.combine(root, "dir"), callback); + fs.writeFileSync("dir/file", "test"); + + assert.deepEqual(invocations, [ + ["change", "file"] + ]); + }); + it("rename() file in directory", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "dir": { "file": "test" } } } }); + const invocations: [string, string][] = []; + const callback = (eventType: string, filename: string) => { invocations.push([eventType, filename]); }; + + fs.watch(vpath.combine(root, "dir"), callback); + fs.renameSync(vpath.combine(root, "dir/file"), vpath.combine(root, "dir/file1")); + + assert.deepEqual(invocations, [ + ["rename", "file"], + ["rename", "file1"] + ]); + }); + it("link() new file in directory", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "dir": { "file": "test" } } } }); + const invocations: [string, string][] = []; + const callback = (eventType: string, filename: string) => { invocations.push([eventType, filename]); }; + + fs.watch(vpath.combine(root, "dir"), callback); + fs.linkSync(vpath.combine(root, "dir/file"), vpath.combine(root, "dir/file1")); + + assert.deepEqual(invocations, [ + ["rename", "file1"] + ]); + }); + it("symlink() file in directory", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "dir": { "file": "test" } } } }); + const invocations: [string, string][] = []; + const callback = (eventType: string, filename: string) => { invocations.push([eventType, filename]); }; + + fs.watch(vpath.combine(root, "dir"), callback); + fs.symlinkSync(vpath.combine(root, "dir/file"), vpath.combine(root, "dir/file1")); + + assert.deepEqual(invocations, [ + ["rename", "file1"] + ]); + }); + it("unlink() file in directory (single link)", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "dir": { "file": "" } } } }); + const invocations: [string, string][] = []; + const callback = (eventType: string, filename: string) => { invocations.push([eventType, filename]); }; + + fs.watch(vpath.combine(root, "dir"), callback); + fs.unlinkSync(vpath.combine(root, "dir/file")); + + assert.deepEqual(invocations, [ + ["rename", "file"] + ]); + }); + it("unlink() file in directory (multiple links)", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "dir": { "file": "", "file1": new vfs.Link("file") } } } }); + const invocations: [string, string][] = []; + const callback = (eventType: string, filename: string) => { invocations.push([eventType, filename]); }; + + fs.watch(vpath.combine(root, "dir"), callback); + fs.unlinkSync(vpath.combine(root, "dir/file")); + + assert.deepEqual(invocations, [ + ["rename", "file"] + ]); + }); + it("mkdir() new subdirectory in directory", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "dir": { } } } }); + const invocations: [string, string][] = []; + const callback = (eventType: string, filename: string) => { invocations.push([eventType, filename]); }; + + fs.watch(vpath.combine(root, "dir"), callback); + fs.mkdirSync(vpath.combine(root, "dir/subdir")); + + assert.deepEqual(invocations, [ + ["rename", "subdir"] + ]); + }); + it("rmdir() subdirectory in directory", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "dir": { "subdir": {} } } } }); + const invocations: [string, string][] = []; + const callback = (eventType: string, filename: string) => { invocations.push([eventType, filename]); }; + + fs.watch(vpath.combine(root, "dir"), callback); + fs.rmdirSync(vpath.combine(root, "dir/subdir")); + + assert.deepEqual(invocations, [ + ["rename", "subdir"] + ]); + }); + it("writeFile() replace file", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "dir": { "file": "" } } } }); + const invocations: [string, string][] = []; + const callback = (eventType: string, filename: string) => { invocations.push([eventType, filename]); }; + + fs.watch(vpath.combine(root, "dir/file"), callback); + fs.writeFileSync("dir/file", "test"); + + assert.deepEqual(invocations, [ + ["change", "file"] + ]); + }); + it("unlink() file (single link)", () => { + const fs = new vfs.FileSystem(ignoreCase, { files: { [root]: { "dir": { "file": "" } } } }); + const invocations: [string, string][] = []; + const callback = (eventType: string, filename: string) => { invocations.push([eventType, filename]); }; + + fs.watch(vpath.combine(root, "dir/file"), callback); + fs.unlinkSync(vpath.combine(root, "dir/file")); + + assert.deepEqual(invocations, [ + ["rename", "file"] + ]); + }); + }); + }); + } + + describeFileSystem("posix", /*ignoreCase*/ false, /*root*/ "/"); + describeFileSystem("win32", /*ignoreCase*/ true, /*root*/ "c:/"); +}); +// tslint:enable:object-literal-key-quotes \ No newline at end of file diff --git a/src/harness/vfs.ts b/src/harness/vfs.ts index 2bae5303996..0bdec73e223 100644 --- a/src/harness/vfs.ts +++ b/src/harness/vfs.ts @@ -43,18 +43,25 @@ namespace vfs { links?: collections.SortedMap; shadows?: Map; meta?: collections.Metadata; + watchedFiles?: collections.SortedMap>; + watchers?: collections.SortedMap; } = {}; private _cwd: string; // current working directory private _time: number | Date | (() => number | Date); private _shadowRoot: FileSystem | undefined; private _dirStack: string[] | undefined; + private _timers: FileSystemTimers; + private _noRecursiveWatchers: boolean; + private _directoryStructureVersion = 0; constructor(ignoreCase: boolean, options: FileSystemOptions = {}) { - const { time = -1, files, meta } = options; + const { time = -1, files, meta, timers = { setInterval, clearInterval }, noRecursiveWatchers = false } = options; this.ignoreCase = ignoreCase; this.stringComparer = this.ignoreCase ? vpath.compareCaseInsensitive : vpath.compareCaseSensitive; this._time = time; + this._timers = timers; + this._noRecursiveWatchers = noRecursiveWatchers; if (meta) { for (const key of Object.keys(meta)) { @@ -641,6 +648,7 @@ namespace vfs { node.size = node.buffer.byteLength; node.mtimeMs = time; node.ctimeMs = time; + this._notifySelf(node, "change"); } private _mknod(dev: number, type: typeof S_IFREG, mode: number, time?: number): FileInode; @@ -655,7 +663,8 @@ namespace vfs { mtimeMs: time, ctimeMs: time, birthtimeMs: time, - nlink: 0 + nlink: 0, + incomingLinks: new Map>() }; } @@ -663,15 +672,40 @@ namespace vfs { links.set(name, node); node.nlink++; node.ctimeMs = time; - if (parent) parent.mtimeMs = time; - if (!parent && !this._cwd) this._cwd = name; + this._invalidatePaths(); + + let set = node.incomingLinks.get(parent); + if (!set) node.incomingLinks.set(parent, set = new collections.SortedSet(this.stringComparer)); + set.add(name); + + if (parent) { + parent.mtimeMs = time; + this._notifyChild(parent, "rename", name); + this._notifyAncestors(parent, "change"); + } + else if (!this._cwd) { + this._cwd = name; + } } private _removeLink(parent: DirectoryInode | undefined, links: collections.SortedMap, name: string, node: Inode, time = this.time()) { links.delete(name); node.nlink--; node.ctimeMs = time; - if (parent) parent.mtimeMs = time; + this._invalidatePaths(); + + const set = node.incomingLinks.get(parent); + if (set) { + set.delete(name); + if (set.size === 0) node.incomingLinks.delete(parent); + } + + if (parent) { + parent.mtimeMs = time; + this._notifyChild(parent, "rename", name); + this._notifyAncestors(parent, "change"); + this._removeWatchers(parent, name); + } } private _replaceLink(oldParent: DirectoryInode, oldLinks: collections.SortedMap, oldName: string, newParent: DirectoryInode, newLinks: collections.SortedMap, newName: string, node: Inode, time: number) { @@ -684,6 +718,17 @@ namespace vfs { oldLinks.set(newName, node); oldParent.mtimeMs = time; newParent.mtimeMs = time; + this._invalidatePaths(); + + const set = node.incomingLinks.get(oldParent); + if (set) { + set.delete(oldName); + set.add(newName); + } + + this._notifyChild(oldParent, "rename", oldName); + this._notifyChild(newParent, "rename", newName); + this._notifyAncestors(newParent, "change"); } } @@ -799,6 +844,28 @@ namespace vfs { return node.buffer; } + private _invalidatePaths() { + this._directoryStructureVersion++; + } + + private _getPaths(node: Inode): ReadonlyArray { + if (!node.paths || node.pathsVersion !== this._directoryStructureVersion) { + const result: string[] = []; + node.incomingLinks.forEach((names, parent) => { + if (parent) { + for (const path of this._getPaths(parent)) { + names.forEach(name => result.push(vpath.combine(path, name))); + } + } + else { + names.forEach(name => result.push(name)); + } + }); + node.paths = result; + } + return node.paths; + } + /** * Walk a path to its end. * @@ -952,6 +1019,176 @@ namespace vfs { } return typeof value === "string" || Buffer.isBuffer(value) ? new File(value) : new Directory(value); } + + /** + * Watch a path for changes. + */ + public watch(path: string, callback?: (eventType: string, filename: string) => void): FSWatcher; + /** + * Watch a path for changes. + */ + public watch(path: string, options?: { recursive?: boolean }, callback?: (eventType: string, filename: string) => void): FSWatcher; + public watch(path: string, options?: { recursive?: boolean } | typeof callback, callback?: (eventType: string, filename: string) => void): FSWatcher { + if (typeof options === "function") callback = options, options = undefined; + if (options === undefined) options = {}; + path = this._resolve(path); + const watcher = new FSWatcher(this); + const realpath = this.realpathSync(path); + const recursive = this._noRecursiveWatchers ? false : !!options.recursive; + const watchers = this._lazy.watchers || (this._lazy.watchers = new collections.SortedMap(this.stringComparer)); + + let pathWatchers = watchers.get(realpath); + if (!pathWatchers) { + pathWatchers = new FSWatcherEntrySet(realpath); + watchers.set(realpath, pathWatchers); + } + + // tslint:disable-next-line:no-string-literal + pathWatchers.add(watcher["_entry"] = { watcher, path, recursive, container: pathWatchers }); + return typeof callback === "function" ? watcher.on("change", callback) : watcher; + } + + private _removeWatcher(entry: FSWatcherEntry) { + // tslint:disable-next-line:no-string-literal + entry.watcher["_entry"] = undefined; + entry.container.delete(entry); + if (entry.container.size === 0) { + const watchers = this._lazy.watchers; + if (watchers && entry.container === watchers.get(entry.container.path)) { + watchers.delete(entry.container.path); + } + } + } + + private _removeWatchers(parent: DirectoryInode | undefined, name: string) { + if (!this._lazy.watchers) return; + const paths = parent ? this._getPaths(parent).map(path => vpath.combine(path, name)) : [name]; + for (const path of paths) { + const watchers = this._lazy.watchers.get(path); + if (watchers) { + watchers.forEach(watcher => this._removeWatcher(watcher)); + } + } + } + + private _notifyChild(parent: DirectoryInode, eventType: "change" | "rename", name: string) { + this._notify(parent, eventType, name, /*noExactMatch*/ false); + } + + private _notifySelf(node: Inode, eventType: "change" | "rename") { + this._notify(node, eventType, /*childPath*/ undefined, /*noExactMatch*/ false); + } + + private _notifyAncestors(node: Inode, eventType: "change" | "rename") { + this._notify(node, eventType, /*childPath*/ undefined, /*noExactMatch*/ true); + } + + private _notify(node: Inode, eventType: "change" | "rename", childPath: string | undefined, noExactMatch: boolean) { + if (!this._lazy.watchers) return; + for (const path of this._getPaths(node)) { + const fullPath = childPath ? vpath.combine(path, childPath) : path; + const dirname = vpath.dirname(fullPath); + this._lazy.watchers.forEach((watchers, watchedPath) => { + const exactMatch = !noExactMatch && this.stringComparer(watchedPath, fullPath) === 0; + const nonRecursiveMatch = watchers.nonRecursiveCount > 0 && this.stringComparer(watchedPath, dirname) === 0; + const recursiveMatch = watchers.recursiveCount > 0 && vpath.beneath(watchedPath, dirname, this.ignoreCase); + if (exactMatch || nonRecursiveMatch || recursiveMatch) { + watchers.forEach(({ watcher, recursive }) => { + if (exactMatch || (recursive ? recursiveMatch : nonRecursiveMatch)) { + // tslint:disable-next-line:no-string-literal + const entry = watcher["_entry"]; + const name = exactMatch ? vpath.basename(entry ? entry.path : fullPath) : vpath.relative(watchedPath, fullPath, this.ignoreCase); + watcher.emit("change", eventType, name); + } + }); + } + }); + } + } + + /** + * Watch a path for changes using polling. + */ + public watchFile(path: string, callback: (current: Stats, previous: Stats) => void): void; + /** + * Watch a path for changes using polling. + */ + public watchFile(path: string, options: { interval?: number } | undefined, callback: (current: Stats, previous: Stats) => void): void; + public watchFile(path: string, options: { interval?: number } | typeof callback | undefined, callback?: (current: Stats, previous: Stats) => void): void { + if (typeof options === "function") callback = options, options = undefined; + if (options === undefined) options = {}; + if (typeof callback !== "function") throw createIOError("EINVAL"); + path = this._resolve(path); + const entry = this._walk(path, /*noFollow*/ undefined, () => "stop"); + const { interval = 5000 } = options; + const watchedFiles = this._lazy.watchedFiles || (this._lazy.watchedFiles = new collections.SortedMap(this.stringComparer)); + let watchedFileSet = watchedFiles.get(path); + if (!watchedFileSet) watchedFiles.set(path, watchedFileSet = new Set()); + const watchedFile: WatchedFile = { + path, + handle: this._timers.setInterval(() => this._onWatchInterval(watchedFile), interval), + previous: entry ? this._stat(entry) : new Stats(), + listener: callback + }; + watchedFileSet.add(watchedFile); + if (!entry) { + callback(watchedFile.previous, watchedFile.previous); + } + } + + private _onWatchInterval(watchedFile: WatchedFile) { + if (watchedFile.handle === undefined) return; + const entry = this._walk(watchedFile.path, /*noFollow*/ undefined, () => "stop"); + const previous = watchedFile.previous; + const current = entry ? this._stat(entry) : new Stats(); + if (current.dev !== previous.dev || + current.ino !== previous.ino || + current.mode !== previous.mode || + current.nlink !== previous.nlink || + current.uid !== previous.uid || + current.gid !== previous.gid || + current.rdev !== previous.rdev || + current.size !== previous.size || + current.blksize !== previous.blksize || + current.blocks !== previous.blocks || + current.atimeMs !== previous.atimeMs || + current.mtimeMs !== previous.mtimeMs || + current.ctimeMs !== previous.ctimeMs || + current.birthtimeMs !== previous.birthtimeMs) { + watchedFile.previous = current; + const callback = watchedFile.listener; + callback(current, previous); + } + } + + /** + * Stop watching a path for changes. + */ + public unwatchFile(path: string, callback?: (current: Stats, previous: Stats) => void) { + path = this._resolve(path); + const watchedFiles = this._lazy.watchedFiles; + if (!watchedFiles) return; + + const watchedFileSet = watchedFiles.get(path); + if (!watchedFileSet) return; + + const watchedFilesToDelete: WatchedFile[] = []; + watchedFileSet.forEach(watchedFile => { + if (!callback || watchedFile.listener === callback) { + this._timers.clearInterval(watchedFile.handle); + watchedFilesToDelete.push(watchedFile); + watchedFile.handle = undefined; + } + }); + + for (const watchedFile of watchedFilesToDelete) { + watchedFileSet.delete(watchedFile); + } + + if (watchedFileSet.size === 0) { + watchedFiles.delete(path); + } + } } export interface FileSystemOptions { @@ -967,6 +1204,9 @@ namespace vfs { // Sets initial metadata attached to the file system. meta?: Record; + + timers?: FileSystemTimers; + noRecursiveWatchers?: boolean; } export interface FileSystemCreateOptions { @@ -977,6 +1217,11 @@ namespace vfs { cwd?: string; } + export interface FileSystemTimers { + setInterval(callback: (...args: any[]) => void, timeout: number, ...args: any[]): any; + clearInterval(timeout: any): void; + } + export type Axis = "ancestors" | "ancestors-or-self" | "self" | "descendants-or-self" | "descendants"; export interface Traversal { @@ -1214,6 +1459,9 @@ namespace vfs { resolver?: FileSystemResolver; shadowRoot?: FileInode; meta?: collections.Metadata; + paths?: ReadonlyArray; + pathsVersion?: number; + incomingLinks: Map>; } interface DirectoryInode { @@ -1230,6 +1478,9 @@ namespace vfs { resolver?: FileSystemResolver; shadowRoot?: DirectoryInode; meta?: collections.Metadata; + paths?: ReadonlyArray; + pathsVersion?: number; + incomingLinks: Map>; } interface SymlinkInode { @@ -1244,6 +1495,9 @@ namespace vfs { symlink: string; shadowRoot?: SymlinkInode; meta?: collections.Metadata; + paths?: ReadonlyArray; + pathsVersion?: number; + incomingLinks: Map>; } function isFile(node: Inode | undefined): node is FileInode { @@ -1266,6 +1520,115 @@ namespace vfs { node: Inode | undefined; } + interface WatchedFile { + path: string; + handle: any; + previous: Stats; + listener: (current: Stats, previous: Stats) => void; + } + + interface FSWatcherEntry { + watcher: FSWatcher; + path: string; + container: FSWatcherEntrySet; + recursive: boolean; + } + + class FSWatcherEntrySet { + public readonly path: string; + private _recursiveCount = 0; + private _nonRecursiveCount = 0; + private _set = new Set(); + constructor(path: string) { + this.path = path; + } + + public get size() { return this._set.size; } + public get recursiveCount() { return this._recursiveCount; } + public get nonRecursiveCount() { return this._nonRecursiveCount; } + + public add(entry: FSWatcherEntry) { + const size = this.size; + this._set.add(entry); + if (this.size !== size) { + if (entry.recursive) { + this._recursiveCount++; + } + else { + this._nonRecursiveCount++; + } + } + return this; + } + + public delete(entry: FSWatcherEntry) { + if (this._set.delete(entry)) { + if (entry.recursive) { + this._recursiveCount--; + } + else { + this._nonRecursiveCount--; + } + return true; + } + return false; + } + + public clear() { + this._recursiveCount = 0; + this._nonRecursiveCount = 0; + } + + public forEach(callback: (value: FSWatcherEntry, key: FSWatcherEntry) => void) { + this._set.forEach(callback); + } + } + + // tslint:disable-next-line:variable-name + const events = require("events") as typeof import("events"); + + class FSWatcher extends events.EventEmitter { + private _fs: FileSystem; + private _entry: FSWatcherEntry | undefined; + + constructor(fs: FileSystem) { + super(); + this._fs = fs; + } + + public close(): void { + if (this._entry) { + // tslint:disable-next-line:no-string-literal + this._fs["_removeWatcher"](this._entry); + } + } + } + + // #region FSWatcher Event "change" + + interface FSWatcher { + on(event: "change", listener: (eventType: string, filename: string) => void): this; + once(event: "change", listener: (eventType: string, filename: string) => void): this; + addListener(event: "change", listener: (eventType: string, filename: string) => void): this; + removeListener(event: "change", listener: (eventType: string, filename: string) => void): this; + prependListener(event: "change", listener: (eventType: string, filename: string) => void): this; + prependOnceListener(event: "change", listener: (eventType: string, filename: string) => void): this; + emit(name: "change", eventType: string, filename: string): boolean; + } + // #endregion FSWatcher Event "change" + + // #region FSWatcher Event "error" + interface FSWatcher { + on(event: "error", listener: (error: Error) => void): this; + once(event: "error", listener: (error: Error) => void): this; + addListener(event: "error", listener: (error: Error) => void): this; + removeListener(event: "error", listener: (error: Error) => void): this; + prependListener(event: "error", listener: (error: Error) => void): this; + prependOnceListener(event: "error", listener: (error: Error) => void): this; + emit(name: "error", error: Error): boolean; + } + // #endregion FSWatcher Event "error" + let builtLocalHost: FileSystemResolverHost | undefined; let builtLocalCI: FileSystem | undefined; let builtLocalCS: FileSystem | undefined;