diff --git a/src/Dir.ts b/src/Dir.ts new file mode 100644 index 000000000..c56a4c345 --- /dev/null +++ b/src/Dir.ts @@ -0,0 +1,170 @@ +import { Link } from './node'; +import type { IDir, IDirent, TCallback } from './node/types/misc'; +import { validateCallback } from './node/util'; +import * as opts from './node/types/options'; +import Dirent from './Dirent'; + +/** + * A directory stream, like `fs.Dir`. + */ +export class Dir implements IDir { + static build(link: Link, options: opts.IOpendirOptions) { + const dir = new Dir(); + dir.link = link; + dir.path = link.getParentPath(); + dir.options = options; + dir.iteratorInfo.push(link.children[Symbol.iterator]()); + return dir; + } + + path: string; + private link: Link; + private options: opts.IOpendirOptions; + private iteratorInfo: IterableIterator<[string, Link | undefined]>[] = []; + + private wrapAsync(method: (...args) => void, args: any[], callback: TCallback) { + validateCallback(callback); + setImmediate(() => { + let result; + try { + result = method.apply(this, args); + } catch (err) { + callback(err); + return; + } + callback(null, result); + }); + } + + private isFunction(x: any): x is Function { + return typeof x === "function"; + } + + private promisify( + obj: T, + fn: keyof T + ): (...args: any[]) => Promise { + return (...args) => + new Promise((resolve, reject) => { + if (this.isFunction(obj[fn])) { + obj[fn].bind(obj)(...args, (error: Error, result: any) => { + if (error) reject(error); + resolve(result); + }); + } + else { + reject("Not a function"); + } + }); + } + + private closeBase(): void { + } + + closeBaseAsync(callback: (err?: Error) => void): void { + this.wrapAsync(this.closeBase, [], callback); + } + + close(): Promise; + close(callback?: (err?: Error) => void): void; + close(callback?: unknown): void | Promise { + if (typeof callback === "function") { + this.closeBaseAsync(callback as (err?: Error) => void); + } + else { + return this.promisify(this, "closeBaseAsync")(); + } + } + closeSync(): void { + this.closeBase(); + } + + private readBase(iteratorInfo: IterableIterator<[string, Link | undefined]>[]): IDirent | null { + let done: boolean | undefined; + let value: [string, Link | undefined]; + let name: string; + let link: Link | undefined; + + do { + do { + ({ done, value } = iteratorInfo[iteratorInfo.length - 1].next()); + + if (!done) { + [name, link] = value; + } + else { + break; + } + } while (name === "." || name === ".."); + + if (done) { + iteratorInfo.pop(); + + if (iteratorInfo.length === 0) { + break; + } + else { + done = false; + } + } + else { + if (this.options.recursive && link!.children.size) { + iteratorInfo.push(link!.children[Symbol.iterator]()); + }; + + return Dirent.build(link!, this.options.encoding); + } + } + while (!done); + + return null; + } + + readBaseAsync(callback: (err: Error | null, dir?: IDirent | null) => void): void { + this.wrapAsync(this.readBase, [this.iteratorInfo], callback); + } + + read(): Promise; + read(callback?: (err: Error | null, dir?: IDirent | null) => void): void; + read(callback?: unknown): void | Promise { + if (typeof callback === "function") { + this.readBaseAsync(callback as (err: Error | null, dir?: IDirent | null) => void); + } + else { + return this.promisify(this, "readBaseAsync")(); + } + } + readSync(): IDirent | null { + return this.readBase(this.iteratorInfo); + } + [Symbol.asyncIterator](): AsyncIterableIterator { + const iteratorInfo: IterableIterator<[string, Link | undefined]>[] = []; + const _this = this; + + iteratorInfo.push(_this.link.children[Symbol.iterator]()); + + // auxiliary object so promisify() can be used + const o = { + readBaseAsync(callback: (err: Error | null, dir?: IDirent | null) => void): void { + _this.wrapAsync(_this.readBase, [iteratorInfo], callback); + } + } + + return { + async next() { + const dirEnt = await _this.promisify(o, "readBaseAsync")(); + + if (dirEnt !== null) { + return { done: false, value: dirEnt }; + } + else { + return { done: true, value: undefined }; + } + }, + + [Symbol.asyncIterator](): AsyncIterableIterator { + throw new Error("Not implemented"); + } + } + } +} diff --git a/src/__tests__/volume.test.ts b/src/__tests__/volume.test.ts index d1181ab26..14db543a2 100644 --- a/src/__tests__/volume.test.ts +++ b/src/__tests__/volume.test.ts @@ -8,6 +8,7 @@ import { tryGetChild, tryGetChildNode } from './util'; import { genRndStr6 } from '../node/util'; import queueMicrotask from '../queueMicrotask'; import { constants } from '../constants'; +import { IDirent } from '../node/types/misc'; const { O_RDWR, O_SYMLINK } = constants; @@ -786,7 +787,7 @@ describe('volume', () => { }); }); describe('.symlink(target, path[, type], callback)', () => { - xit('...', () => {}); + xit('...', () => { }); }); describe('.realpathSync(path[, options])', () => { const vol = new Volume(); @@ -899,7 +900,7 @@ describe('volume', () => { }); }); describe('.lstat(path, callback)', () => { - xit('...', () => {}); + xit('...', () => { }); }); describe('.statSync(path)', () => { const vol = new Volume(); @@ -944,7 +945,7 @@ describe('volume', () => { }); }); describe('.stat(path, callback)', () => { - xit('...', () => {}); + xit('...', () => { }); }); describe('.fstatSync(fd)', () => { const vol = new Volume(); @@ -992,7 +993,7 @@ describe('volume', () => { }); }); describe('.fstat(fd, callback)', () => { - xit('...', () => {}); + xit('...', () => { }); }); describe('.linkSync(existingPath, newPath)', () => { const vol = new Volume(); @@ -1012,7 +1013,7 @@ describe('volume', () => { }); }); describe('.link(existingPath, newPath, callback)', () => { - xit('...', () => {}); + xit('...', () => { }); }); describe('.readdirSync(path)', () => { it('Returns simple list', () => { @@ -1039,7 +1040,175 @@ describe('volume', () => { }); }); describe('.readdir(path, callback)', () => { - xit('...', () => {}); + xit('...', () => { }); + }); + describe('.opendirSync(path, options)', () => { + it('Using dir.readSync()', () => { + const vol = new Volume(); + vol.fromJSON({ + '/1.js': '123', + '/2.js': '123', + '/test/a.js': '123', + '/test/b.js': '123', + }) + + const dir = vol.opendirSync("/"); + + let de = dir.readSync(); + expect(de).not.toBeNull(); + expect(de!.name).toBe("1.js"); + + de = dir.readSync(); + expect(de).not.toBeNull(); + expect(de!.name).toBe("2.js"); + + de = dir.readSync(); + expect(de).not.toBeNull(); + expect(de!.name).toBe("test"); + + de = dir.readSync(); + expect(de).toBeNull(); + }); + it('With \'recursive=true\' and using dir.readSync()', () => { + const vol = new Volume(); + vol.fromJSON({ + '/1.js': '123', + '/2.js': '123', + '/test/a.js': '123', + '/test/b.js': '123', + }) + + const dir = vol.opendirSync("/", { recursive: true }); + + let de = dir.readSync(); + expect(de).not.toBeNull(); + expect(de!.name).toBe("1.js"); + + de = dir.readSync(); + expect(de).not.toBeNull(); + expect(de!.name).toBe("2.js"); + + de = dir.readSync(); + expect(de).not.toBeNull(); + expect(de!.name).toBe("test"); + + de = dir.readSync(); + expect(de).not.toBeNull(); + expect(de!.name).toBe("a.js"); + + de = dir.readSync(); + expect(de).not.toBeNull(); + expect(de!.name).toBe("b.js"); + + de = dir.readSync(); + expect(de).toBeNull(); + }); + it('Using dir.read()', async () => { + const vol = new Volume(); + vol.fromJSON({ + '/1.js': '123', + '/2.js': '123', + '/test/a.js': '123', + '/test/b.js': '123', + '/3.js': '123', + }) + + const dir = vol.opendirSync("/"); + + let de = await dir.read(); + expect(de).not.toBeNull(); + expect(de!.name).toBe("1.js"); + + de = await dir.read(); + expect(de).not.toBeNull(); + expect(de!.name).toBe("2.js"); + + de = await dir.read(); + expect(de).not.toBeNull(); + expect(de!.name).toBe("test"); + + de = await dir.read(); + expect(de).not.toBeNull(); + expect(de!.name).toBe("3.js"); + + de = await dir.read(); + expect(de).toBeNull(); + }); + it('With \'recursive=true\' and using dir.read()', async () => { + const vol = new Volume(); + vol.fromJSON({ + '/1.js': '123', + '/2.js': '123', + '/test/a.js': '123', + '/test/b.js': '123', + '/3.js': '123', + }) + + const dir = vol.opendirSync("/", { recursive: true }); + + let de = await dir.read(); + expect(de).not.toBeNull(); + expect(de!.name).toBe("1.js"); + + de = await dir.read(); + expect(de).not.toBeNull(); + expect(de!.name).toBe("2.js"); + + de = await dir.read(); + expect(de).not.toBeNull(); + expect(de!.name).toBe("test"); + + de = await dir.read(); + expect(de).not.toBeNull(); + expect(de!.name).toBe("a.js"); + + de = await dir.read(); + expect(de).not.toBeNull(); + expect(de!.name).toBe("b.js"); + + de = await dir.read(); + expect(de).not.toBeNull(); + expect(de!.name).toBe("3.js"); + + de = await dir.read(); + expect(de).toBeNull(); + }); + it('Using dir.read(callback)', (done) => { + const vol = new Volume(); + vol.fromJSON({ + '/1.js': '123', + '/2.js': '123', + '/test/a.js': '123', + '/test/b.js': '123', + '/3.js': '123', + }) + + const dir = vol.opendirSync("/"); + + let de = dir.read((err: Error | null, dir?: IDirent | null): void => { + expect(dir).not.toBeNull(); + expect(dir!.name).toBe("1.js"); + done(); + }); + }); + it('Using dir[Symbol.asyncIterator]()', async () => { + const vol = new Volume(); + vol.fromJSON({ + '/1.js': '123', + '/2.js': '123', + '/test/a.js': '123', + '/test/b.js': '123', + '/3.js': '123', + }) + + const dir = vol.opendirSync("/"); + const expected = ['1.js', '2.js', 'test', '3.js']; + let i = 0; + + for await(const de of dir) { + expect(de.name).toBe(expected[i++]); + } + }); }); describe('.readlinkSync(path[, options])', () => { it('Simple symbolic link to one file', () => { @@ -1087,7 +1256,7 @@ describe('volume', () => { }); }); describe('.ftruncate(fd[, len], callback)', () => { - xit('...', () => {}); + xit('...', () => { }); }); describe('.truncateSync(path[, len])', () => { const vol = new Volume(); @@ -1114,7 +1283,7 @@ describe('volume', () => { }); }); describe('.truncate(path[, len], callback)', () => { - xit('...', () => {}); + xit('...', () => { }); }); describe('.utimesSync(path, atime, mtime)', () => { const vol = new Volume(); @@ -1134,7 +1303,7 @@ describe('volume', () => { }); }); describe('.utimes(path, atime, mtime, callback)', () => { - xit('...', () => {}); + xit('...', () => { }); }); describe('.mkdirSync(path[, options])', () => { it('Create dir at root', () => { @@ -1180,8 +1349,8 @@ describe('volume', () => { }); }); describe('.mkdir(path[, mode], callback)', () => { - xit('...', () => {}); - xit('Create /dir1/dir2/dir3', () => {}); + xit('...', () => { }); + xit('Create /dir1/dir2/dir3', () => { }); }); describe('.mkdtempSync(prefix[, options])', () => { it('Create temp dir at root', () => { @@ -1200,14 +1369,14 @@ describe('volume', () => { }); }); describe('.mkdtemp(prefix[, options], callback)', () => { - xit('Create temp dir at root', () => {}); + xit('Create temp dir at root', () => { }); it('throws when prefix is not a string', () => { const vol = new Volume(); - expect(() => vol.mkdtemp({} as string, () => {})).toThrow(TypeError); + expect(() => vol.mkdtemp({} as string, () => { })).toThrow(TypeError); }); it('throws when prefix contains null bytes', () => { const vol = new Volume(); - expect(() => vol.mkdtemp('/tmp-\u0000', () => {})).toThrow(/path.+string.+null bytes/i); + expect(() => vol.mkdtemp('/tmp-\u0000', () => { })).toThrow(/path.+string.+null bytes/i); }); }); describe('.rmdirSync(path)', () => { @@ -1226,7 +1395,7 @@ describe('volume', () => { }); }); describe('.rmdir(path, callback)', () => { - xit('Remove single dir', () => {}); + xit('Remove single dir', () => { }); it('Async remove dir /dir1/dir2/dir3 recursively', done => { const vol = new Volume(); vol.mkdirSync('/dir1/dir2/dir3', { recursive: true }); @@ -1370,7 +1539,7 @@ describe('volume', () => { vol.writeFileSync('/lol.txt', '2'); }, 1); }); - xit('Multiple listeners for one file', () => {}); + xit('Multiple listeners for one file', () => { }); }); describe('.unwatchFile(path[, listener])', () => { it('Stops watching before .writeFile', done => { diff --git a/src/node/options.ts b/src/node/options.ts index 656144b8c..cab72a10d 100644 --- a/src/node/options.ts +++ b/src/node/options.ts @@ -81,6 +81,16 @@ export const getReaddirOptsAndCb = optsAndCbGenerator(opendirDefaults); +export const getOpendirOptsAndCb = optsAndCbGenerator( + getOpendirOptions, +); + const appendFileDefaults: opts.IAppendFileOptions = { encoding: 'utf8', mode: MODE.DEFAULT, diff --git a/src/volume.ts b/src/volume.ts index 32ff2c99c..7c625d268 100644 --- a/src/volume.ts +++ b/src/volume.ts @@ -38,6 +38,8 @@ import { getRealpathOptions, getWriteFileOptions, writeFileDefaults, + getOpendirOptsAndCb, + getOpendirOptions, } from './node/options'; import { validateCallback, @@ -59,6 +61,7 @@ import { import type { PathLike, symlink } from 'fs'; import type { FsPromisesApi, FsSynchronousApi } from './node/types'; import { fsSynchronousApiList } from './node/lists/fsSynchronousApiList'; +import { Dir } from './Dir'; const resolveCrossPlatform = pathModule.resolve; const { @@ -2010,13 +2013,37 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { public cpSync: FsSynchronousApi['cpSync'] = notImplemented; public lutimesSync: FsSynchronousApi['lutimesSync'] = notImplemented; public statfsSync: FsSynchronousApi['statfsSync'] = notImplemented; - public opendirSync: FsSynchronousApi['opendirSync'] = notImplemented; public cp: FsCallbackApi['cp'] = notImplemented; public lutimes: FsCallbackApi['lutimes'] = notImplemented; public statfs: FsCallbackApi['statfs'] = notImplemented; public openAsBlob: FsCallbackApi['openAsBlob'] = notImplemented; - public opendir: FsCallbackApi['opendir'] = notImplemented; + + private opendirBase(filename: string, options: opts.IOpendirOptions): Dir { + const steps = filenameToSteps(filename); + const link: Link | null = this.getResolvedLink(steps); + if (!link) throw createError(ENOENT, 'opendir', filename); + + const node = link.getNode(); + if (!node.isDirectory()) throw createError(ENOTDIR, 'scandir', filename); + + return Dir.build(link, options); + } + + opendirSync(path: PathLike, options?: opts.IOpendirOptions | string): Dir { + const opts = getOpendirOptions(options); + const filename = pathToFilename(path); + return this.opendirBase(filename, opts); + } + + opendir(path: PathLike, callback: TCallback); + opendir(path: PathLike, options: opts.IOpendirOptions | string, callback: TCallback); + opendir(path: PathLike, a?, b?) { + const [options, callback] = getOpendirOptsAndCb(a, b); + const filename = pathToFilename(path); + this.wrapAsync(this.opendirBase, [filename, options], callback); + } + } function emitStop(self) {