Skip to content

Commit

Permalink
feat: 🎸 implement .mkdir method
Browse files Browse the repository at this point in the history
  • Loading branch information
streamich committed Jun 20, 2023
1 parent ed11dff commit 2623049
Show file tree
Hide file tree
Showing 9 changed files with 266 additions and 164 deletions.
56 changes: 44 additions & 12 deletions src/fsa-to-node/FsaNodeFs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type {FsCallbackApi} from '../node/types';
import { createPromisesApi } from '../node/promises';
import { getMkdirOptions } from '../node/options';
import {createError, modeToNumber, pathToFilename, validateCallback} from '../node/util';
import {pathToLocation} from './util';
import type {FsCallbackApi, FsPromisesApi} from '../node/types';
import type * as misc from '../node/types/misc';
import type * as opts from '../node/types/options';
import type * as fsa from '../fsa/types';
Expand All @@ -12,6 +16,8 @@ const notImplemented: ((...args: unknown[]) => unknown) = () => {
* [`FileSystemDirectoryHandle` object](https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryHandle).
*/
export class FsaNodeFs implements FsCallbackApi {
public readonly promises: FsPromisesApi = createPromisesApi(this);

public constructor (protected readonly root: fsa.IFileSystemDirectoryHandle) {}

public readonly open: FsCallbackApi['open'] = (path: misc.PathLike, flags: misc.TFlags, a?: misc.TMode | misc.TCallback<number>, b?: misc.TCallback<number> | string) => {
Expand Down Expand Up @@ -169,17 +175,43 @@ export class FsaNodeFs implements FsCallbackApi {
throw new Error('Not implemented');
}

mkdir(path: misc.PathLike, callback: misc.TCallback<void>);
mkdir(
path: misc.PathLike,
mode: misc.TMode | (opts.IMkdirOptions & { recursive?: false }),
callback: misc.TCallback<void>,
);
mkdir(path: misc.PathLike, mode: opts.IMkdirOptions & { recursive: true }, callback: misc.TCallback<string>);
mkdir(path: misc.PathLike, mode: misc.TMode | opts.IMkdirOptions, callback: misc.TCallback<string>);
mkdir(path: misc.PathLike, a: misc.TCallback<void> | misc.TMode | opts.IMkdirOptions, b?: misc.TCallback<string> | misc.TCallback<void>) {
throw new Error('Not implemented');
}
/**
* @param path Path from root to the new folder.
* @param create Whether to create the folders if they don't exist.
*/
private async getDir(path: string[], create: boolean): Promise<fsa.IFileSystemDirectoryHandle> {
let curr: fsa.IFileSystemDirectoryHandle = this.root;
const options: fsa.GetDirectoryHandleOptions = {create};
for (const name of path)
curr = await curr.getDirectoryHandle(name, options);
return curr;
}

public readonly mkdir: FsCallbackApi['mkdir'] = (path: misc.PathLike, a: misc.TCallback<void> | misc.TMode | opts.IMkdirOptions, b?: misc.TCallback<string> | misc.TCallback<void>) => {
const opts: misc.TMode | opts.IMkdirOptions = getMkdirOptions(a);
const callback = validateCallback(typeof a === 'function' ? a : b!);
// const modeNum = modeToNumber(opts.mode, 0o777);
const filename = pathToFilename(path);
const [folder, name] = pathToLocation(filename);
// TODO: need to throw if directory already exists
this.getDir(folder, opts.recursive ?? false)
.then(dir => dir.getDirectoryHandle(name, {create: true}))
.then(() => callback(null), error => {
if (!error || typeof error !== 'object') {
callback(createError('', 'mkdir'));
return;
}
switch (error.name) {
case 'NotFoundError': {
const err = createError('ENOTDIR', 'mkdir', folder.join('/'));
callback(err);
}
default: {
callback(createError('', 'mkdir'));
}
}
});
};

mkdirp(path: misc.PathLike, callback: misc.TCallback<string>);
mkdirp(path: misc.PathLike, mode: misc.TMode, callback: misc.TCallback<string>);
Expand Down
49 changes: 49 additions & 0 deletions src/fsa-to-node/__tests__/FsaNodeFs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {IFsWithVolume, memfs} from '../..';
import {nodeToFsa} from '../../node-to-fsa';
import {FsaNodeFs} from '../FsaNodeFs';

const setup = () => {
const mfs = memfs({ mountpoint: null }) as IFsWithVolume;
const dir = nodeToFsa(mfs, '/mountpoint', {mode: 'readwrite'});
const fs = new FsaNodeFs(dir);
return { fs, mfs, dir };
};

describe('.mkdir()', () => {
test('can create a sub-folder', async () => {
const { fs, mfs } = setup();
await new Promise<void>((resolve, reject) => fs.mkdir('/test', (err) => {
if (err) return reject(err);
return resolve();
}));
expect(mfs.statSync('/mountpoint/test').isDirectory()).toBe(true);
});

test('throws when creating sub-sub-folder', async () => {
const { fs } = setup();
try {
await new Promise<void>((resolve, reject) => fs.mkdir('/test/subtest', (err) => {
if (err) return reject(err);
return resolve();
}));
throw new Error('Expected error');
} catch (error) {
expect(error.code).toBe('ENOTDIR');
}
});

test('can create sub-sub-folder with "recursive" flag', async () => {
const { fs, mfs } = setup();
await new Promise<void>((resolve, reject) => fs.mkdir('/test/subtest', {recursive: true}, (err) => {
if (err) return reject(err);
return resolve();
}));
expect(mfs.statSync('/mountpoint/test/subtest').isDirectory()).toBe(true);
});

test('can create sub-sub-folder with "recursive" flag with Promises API', async () => {
const { fs, mfs } = setup();
await fs.promises.mkdir('/test/subtest', {recursive: true});
expect(mfs.statSync('/mountpoint/test/subtest').isDirectory()).toBe(true);
});
});
2 changes: 1 addition & 1 deletion src/fsa-to-node/util.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {FsaToNodeConstants} from "./constants";
import {FsaToNodeConstants} from "./constants";;
import type {FsLocation} from "./types";

export const pathToLocation = (path: string): FsLocation => {
Expand Down
6 changes: 3 additions & 3 deletions src/node-to-fsa/NodeFileSystemHandle.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NodePermissionStatus } from './NodePermissionStatus';
import type { IFileSystemHandle, NodeFileSystemHandlePermissionDescriptor } from '../fsa/types';
import type { IFileSystemHandle, FileSystemHandlePermissionDescriptor } from '../fsa/types';

/**
* Represents a File System Access API file handle `FileSystemHandle` object,
Expand All @@ -25,7 +25,7 @@ export abstract class NodeFileSystemHandle implements IFileSystemHandle {
* @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemHandle/queryPermission
*/
public queryPermission(
fileSystemHandlePermissionDescriptor: NodeFileSystemHandlePermissionDescriptor,
fileSystemHandlePermissionDescriptor: FileSystemHandlePermissionDescriptor,
): NodePermissionStatus {
throw new Error('Not implemented');
}
Expand All @@ -41,7 +41,7 @@ export abstract class NodeFileSystemHandle implements IFileSystemHandle {
* @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemHandle/requestPermission
*/
public requestPermission(
fileSystemHandlePermissionDescriptor: NodeFileSystemHandlePermissionDescriptor,
fileSystemHandlePermissionDescriptor: FileSystemHandlePermissionDescriptor,
): NodePermissionStatus {
throw new Error('Not implemented');
}
Expand Down
24 changes: 24 additions & 0 deletions src/node/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Default modes for opening files.
export const enum MODE {
FILE = 0o666,
DIR = 0o777,
DEFAULT = MODE.FILE,
}

export const ERRSTR = {
PATH_STR: 'path must be a string or Buffer',
// FD: 'file descriptor must be a unsigned 32-bit integer',
FD: 'fd must be a file descriptor',
MODE_INT: 'mode must be an int',
CB: 'callback must be a function',
UID: 'uid must be an unsigned int',
GID: 'gid must be an unsigned int',
LEN: 'len must be an integer',
ATIME: 'atime must be an integer',
MTIME: 'mtime must be an integer',
PREFIX: 'filename prefix is required',
BUFFER: 'buffer must be an instance of Buffer or StaticBuffer',
OFFSET: 'offset must be an integer',
LENGTH: 'length must be an integer',
POSITION: 'position must be an integer',
};
12 changes: 12 additions & 0 deletions src/node/options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type * as opts from "./types/options";
import { MODE } from './constants';

const mkdirDefaults: opts.IMkdirOptions = {
mode: MODE.DIR,
recursive: false,
};

export const getMkdirOptions = (options): opts.IMkdirOptions => {
if (typeof options === 'number') return Object.assign({}, mkdirDefaults, { mode: options });
return Object.assign({}, mkdirDefaults, options);
};
5 changes: 2 additions & 3 deletions src/node/promises.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@ import type * as opts from './types/options';
import type * as misc from './types/misc';
import type { FsCallbackApi, FsPromisesApi } from './types';

export function createPromisesApi(vol: FsCallbackApi): null | FsPromisesApi {
if (typeof Promise === 'undefined') return null;
export function createPromisesApi(vol: FsCallbackApi): FsPromisesApi {
return {
FileHandle,
FileHandle: FileHandle as any,

access(path: misc.PathLike, mode?: number): Promise<void> {
return promisify(vol, 'access')(path, mode);
Expand Down
127 changes: 127 additions & 0 deletions src/node/util.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import {ERRSTR} from './constants';
import * as errors from '../internal/errors';
import type { FsCallbackApi } from './types';
import type * as misc from "./types/misc";

export function promisify(
fs: FsCallbackApi,
Expand All @@ -13,3 +16,127 @@ export function promisify(
});
});
}

export function validateCallback<T>(callback: T): misc.AssertCallback<T> {
if (typeof callback !== 'function') throw TypeError(ERRSTR.CB);
return callback as misc.AssertCallback<T>;
}

function _modeToNumber(mode: misc.TMode | undefined, def?): number | undefined {
if (typeof mode === 'number') return mode;
if (typeof mode === 'string') return parseInt(mode, 8);
if (def) return modeToNumber(def);
return undefined;
}

export function modeToNumber(mode: misc.TMode | undefined, def?): number {
const result = _modeToNumber(mode, def);
if (typeof result !== 'number' || isNaN(result)) throw new TypeError(ERRSTR.MODE_INT);
return result;
}

export function nullCheck(path, callback?) {
if (('' + path).indexOf('\u0000') !== -1) {
const er = new Error('Path must be a string without null bytes');
(er as any).code = 'ENOENT';
if (typeof callback !== 'function') throw er;
process.nextTick(callback, er);
return false;
}
return true;
}

function getPathFromURLPosix(url): string {
if (url.hostname !== '') {
throw new errors.TypeError('ERR_INVALID_FILE_URL_HOST', process.platform);
}
const pathname = url.pathname;
for (let n = 0; n < pathname.length; n++) {
if (pathname[n] === '%') {
const third = pathname.codePointAt(n + 2) | 0x20;
if (pathname[n + 1] === '2' && third === 102) {
throw new errors.TypeError('ERR_INVALID_FILE_URL_PATH', 'must not include encoded / characters');
}
}
}
return decodeURIComponent(pathname);
}

export function pathToFilename(path: misc.PathLike): string {
if (typeof path !== 'string' && !Buffer.isBuffer(path)) {
try {
if (!(path instanceof require('url').URL)) throw new TypeError(ERRSTR.PATH_STR);
} catch (err) {
throw new TypeError(ERRSTR.PATH_STR);
}

path = getPathFromURLPosix(path);
}

const pathString = String(path);
nullCheck(pathString);
// return slash(pathString);
return pathString;
}

const ENOENT = 'ENOENT';
const EBADF = 'EBADF';
const EINVAL = 'EINVAL';
const EPERM = 'EPERM';
const EPROTO = 'EPROTO';
const EEXIST = 'EEXIST';
const ENOTDIR = 'ENOTDIR';
const EMFILE = 'EMFILE';
const EACCES = 'EACCES';
const EISDIR = 'EISDIR';
const ENOTEMPTY = 'ENOTEMPTY';
const ENOSYS = 'ENOSYS';
const ERR_FS_EISDIR = 'ERR_FS_EISDIR';

function formatError(errorCode: string, func = '', path = '', path2 = '') {
let pathFormatted = '';
if (path) pathFormatted = ` '${path}'`;
if (path2) pathFormatted += ` -> '${path2}'`;

switch (errorCode) {
case ENOENT:
return `ENOENT: no such file or directory, ${func}${pathFormatted}`;
case EBADF:
return `EBADF: bad file descriptor, ${func}${pathFormatted}`;
case EINVAL:
return `EINVAL: invalid argument, ${func}${pathFormatted}`;
case EPERM:
return `EPERM: operation not permitted, ${func}${pathFormatted}`;
case EPROTO:
return `EPROTO: protocol error, ${func}${pathFormatted}`;
case EEXIST:
return `EEXIST: file already exists, ${func}${pathFormatted}`;
case ENOTDIR:
return `ENOTDIR: not a directory, ${func}${pathFormatted}`;
case EISDIR:
return `EISDIR: illegal operation on a directory, ${func}${pathFormatted}`;
case EACCES:
return `EACCES: permission denied, ${func}${pathFormatted}`;
case ENOTEMPTY:
return `ENOTEMPTY: directory not empty, ${func}${pathFormatted}`;
case EMFILE:
return `EMFILE: too many open files, ${func}${pathFormatted}`;
case ENOSYS:
return `ENOSYS: function not implemented, ${func}${pathFormatted}`;
case ERR_FS_EISDIR:
return `[ERR_FS_EISDIR]: Path is a directory: ${func} returned EISDIR (is a directory) ${path}`;
default:
return `${errorCode}: error occurred, ${func}${pathFormatted}`;
}
}

export function createError(errorCode: string, func = '', path = '', path2 = '', Constructor = Error) {
const error = new Constructor(formatError(errorCode, func, path, path2));
(error as any).code = errorCode;

if (path) {
(error as any).path = path;
}

return error;
}
Loading

0 comments on commit 2623049

Please sign in to comment.