@AppKu/Fairu

@AppKu/Fairu

v1.0.6

source

fairu.js

///<reference path="./fairu.d.js" />
///<reference path="./package-json.d.js" />
import path from 'path';
import fs from 'fs/promises';
import fsSync from 'fs';
import { constants } from 'fs';
import glob from 'glob';
import toml from '@iarna/toml';
import yaml from 'js-yaml';
import PathState from './path-state.js';
import ReadPathState from './read-path-state.js';

/**
 * @see https://nodejs.org/api/path.html
 * @callback FairuCallback.Path
 * @param {path.PlatformPath} p - The path module for constructing file-system paths.
 * @returns {String}
 */

/**
 * Fairu is a file-system reading & writing helper designed to simplify operations. It provides an asynchronous and
 * chained method interface to working with files that helps you focus less on file system operations, and more on
 * just getting things written and read reliably.
 */
class Fairu {
    constructor() {

        /**
         * The globbing options to apply to path discovery.
         * @see https://www.npmjs.com/package/glob#options
         * @type {glob.IOptions}
         */
        this.options = {
            absolute: true
        };

        /**
         * The metadata for the constructed Fairu operation.
         * @private
         */
        this.metadata = {
            /**
             * @type {Array.<String>}
             */
            with: [],
            /**
             * @type {Array.<String>}
             */
            without: [],
            /**
             * @type {FairuCallback.Condition}
             */
            when: null,
            /**
             * @type {Boolean}
             */
            throw: false,
            /**
             * @type {String}
             */
            format: null,
            /**
             * @type {String}
             */
            encoding: null
        };
    }

    /**
     * Reads a `package.json` file from the given directory. This operation is synchronous.
     * @param {String} dir - The directory of the `package.json` file.
     * @returns {PackageJSON}
     */
    static packageJSON(dir) {
        return JSON.parse(fsSync.readFileSync(path.join(dir, 'package.json')));
    }

    /**
     * Stringifies an object into the specified format (yaml, toml, or json).
     * @throws Error when the format is unknown.
     * @throws Error when stringification fails.
     * @param {String} format - Can be either 'json', 'yaml', or 'json'.
     * @param {*} object - The object to stringify.
     * @param {Number} [space=4] - The number of spaces to use for indentations.
     * @returns {String}
     */
    static stringify(format, object, space = 4) {
        if (format) {
            if (/json/i.test(format)) {
                return JSON.stringify(object, null, space);
            } else if (/yaml/i.test(format)) {
                return yaml.dump(object, {
                    indent: space,
                    lineWidth: 120
                });
            } else if (/toml/i.test(format)) {
                return toml.stringify(object);
            }
        }
        throw new Error(`Unknown format "${format}".`);
    }

    /**
     * Attempts to parse the given input string using the specified format parser into an object.
     * @throws Error when the format is unknown.
     * @throws Error when parsing fails.
     * @param {String} format - Can be either 'json', 'yaml', or 'json'.
     * @param {*} inputString - The string to be parsed.
     * @param {String} [filePath] - The file path used in error/warning messages.
     * @returns {*}
     */
    static parse(format, inputString, filePath) {
        if (format) {
            if (/json/i.test(format)) {
                return JSON.parse(inputString);
            } else if (/yaml/i.test(format)) {
                return yaml.load(inputString, {
                    filename: filePath
                });
            } else if (/toml/i.test(format)) {
                return toml.parse(inputString);
            }
        }
        throw new Error(`Unknown format "${format}".`);
    }

    /**
     * Copies a file or directory (recursively) to another.
     * @param {String} source - The file or directory path to use as the source.
     * @param {String} destination - The file or directory to use as the destination.
     */
    static async cp(source, destination) {
        let stats = await fs.stat(source);
        await fs.mkdir(path.dirname(destination), { recursive: true });
        if (stats.isDirectory()) {
            await fs.cp(source, destination, { recursive: true });
        } else {
            await fs.copyFile(source, destination);
        }
    }

    /**
     * Moves a file or directory (recursively) to another.
     * @param {String} source - The file or directory path to use as the source.
     * @param {String} destination - The file or directory to use as the destination.
     */
    static async mv(source, destination) {
        await fs.mkdir(path.dirname(destination), { recursive: true });
        await fs.rename(source, destination);
    }

    /**
     * Specify the file-system paths (including glob patterns) you will be performing an operation on. You may
     * optionally provide a callback that returns a path to use- the callback will be passed the `path` module as an
     * argument.
     * 
     * Calling this resets the paths used in this Fairu operation, letting you daisy-chain multiple operations
     * together, each starting with the `with` specification.
     * 
     * This function is not cummulative, specified paths will overwrite those set by a previous call.
     * @throws Error when a specified path is not a string or callback function.
     * @param  {...String | FairuCallback.Path} paths - The series of file-system paths or callback functions. 
     * @returns {Fairu}
     * @example
     * // Daisy chaining multiple operations:
     * await Fairu
     *   .with('./file1.txt', p => p.join('/home/', 'file2.txt'))
     *   .write(content)
     *   .with('./file3.txt')
     *   .write(otherContent);
     * 
     * @example
     * // Using glob paths:
     * await Fairu
     *   .with('./**', './+(hello|greetings)?(world|mars|venus).txt')
     *   .read();
     */
    static with(...paths) {
        return new Fairu().with(...paths);
    }

    /**
     * Specify the file-system paths (including glob patterns) you will be performing an operation on. You may
     * optionally provide a callback that returns a path to use- the callback will be passed the `path` module as an
     * argument.
     * 
     * Calling this resets the paths used in this Fairu operation, letting you daisy-chain multiple operations
     * together, each starting with the `with` specification.
     * 
     * This function is not cummulative, specified paths will overwrite those set by a previous call.
     * @throws Error when a specified path is not a string or callback function.
     * @param  {...String | FairuCallback.Path} paths - The series of file-system paths or callback functions. 
     * @returns {Fairu}
     * @example
     * // Daisy chaining multiple operations:
     * await Fairu
     *   .with('./file1.txt', p => p.join('/home/', 'file2.txt'))
     *   .write(content)
     *   .with('./file3.txt')
     *   .write(otherContent);
     * 
     * @example
     * // Using glob paths:
     * await Fairu
     *   .with('./**', './+(hello|greetings)?(world|mars|venus).txt')
     *   .read();
     */
    with(...paths) {
        this.metadata.with = [];
        for (let p of paths) {
            let pathType = typeof p;
            if (pathType === 'function') {
                this.metadata.with.push(p(path));
            } else if (pathType === 'string') {
                this.metadata.with.push(p);
            } else if (pathType === 'undefined' || p === null) {
                continue;
            } else {
                throw new Error('The "paths" argument encountered a specified non-string/non-function path. Only callbacks and strings are allowed paths. If a null or undefined value is found it is skipped.');
            }
        }
        return this;
    }

    /**
     * Specify the file-system paths (including glob patterns) you *do not* want to perform an operation on. You may
     * optionally provide a callback that returns a path to use- the callback will be passed the `path` module as an
     * argument.
     * 
     * This function is not cummulative, specified paths will overwrite those set by a previous call.
     * @throws Error when a specified path is not a string or callback function.
     * @param  {...String | FairuCallback.Path} paths - The series of file-system paths or callback functions. 
     * @returns {Fairu}
     * @example
     * // Skipping over certain files, in this case finding all `.js` files without `.test.` in the file name:
     * Fairu.
     *   .with('./*.js')
     *   .without('./*.test.*')
     *   .discover();
     */
    without(...paths) {
        this.metadata.without = [];
        for (let p of paths) {
            let pathType = typeof p;
            if (pathType === 'function') {
                this.metadata.without.push(p(path));
            } else if (pathType === 'string') {
                this.metadata.without.push(p);
            } else if (pathType === 'undefined' || p === null) {
                continue;
            } else {
                throw new Error('The "paths" argument encountered a specified non-string/non-function path. Only callbacks and strings are allowed paths. If a null or undefined value is found it is skipped.');
            }
        }
        return this;
    }

    /**
     * Sets the text file encoding to the specified value.
     * By default the encoding is not set.
     * 
     * Calling this function without an argument or `null` will reset it to it's default (not set).
     * @throws Error when the encoding value is specified and not a string.
     * @param {String} encoding - The file text encoding to use when reading and writing files.
     * @returns {Fairu}
     */
    encoding(encoding) {
        let encodingType = typeof encoding;
        if (encodingType !== 'undefined' && encoding !== null && encodingType !== 'string') {
            throw new Error('The "encoding" argument, when specified, must be a string.');
        }
        this.metadata.encoding = encoding || null;
        return this;
    }

    /**
     * Sets the flag to have Fairu throw an error if one is encountered (`true`), or simply halt the operation for 
     * that path (`false`). By default Fairu will throw an error (`true`).
     * When the flag is `false` and an error occurs:
     * - `read()` will return `null` value for the `data` property in the result.
     * - `write()` may or may not occur or may only partially write.
     * - `touch()` may or may not occur.
     * 
     * Calling this function without an argument will reset it to it's default (`true`).
     * @throws Error when the `throwErrors` argument is not a boolean value.
     * @param {Boolean} [throwErrors=true] - If `true` an error is thrown as soon as it is encountered, when `false`
     * no errors are thrown and the next path operation is attempted. 
     * @returns {Fairu}
     */
    throw(throwErrors) {
        if (typeof throwErrors === 'undefined') {
            throwErrors = true;
        } else if (typeof throwErrors !== 'boolean') {
            throw new Error('The "throwErrors" argument, when specified, must be a boolean.');
        }
        this.metadata.throw = !!throwErrors;
        return this;
    }

    /**
     * Enables formatting of written or read objects or data to the specified format, either: "json", "toml", or
     * "yaml". You may also ensure raw bufferred reads or writes by passing a `null` argument to clear the setting.
     * By default the format is not set.
     * 
     * Calling this function without an argument or `null` will reset it to it's default (not set).
     * @throws Error when the specified `format` argument is not "json", "yaml", "toml".
     * @param {Fairu.Format} format - The format (or `null`) to use for reading & writing. Can be: "json", "toml", or
     *   "yaml".
     * @returns {Fairu}
     */
    format(format) {
        let formatType = typeof format;
        if (formatType !== 'undefined' && format !== null && formatType !== 'string') {
            throw new Error('The "format" argument, when specified, must be a string.');
        } else if (
            formatType !== 'undefined'
            && format !== null
            && format !== Fairu.Format.json
            && format !== Fairu.Format.toml
            && format !== Fairu.Format.yaml) {
            throw new Error(`The "format" argument, when specified, must be either "json", "toml", or "yaml. Instead "${format}" was specified.`);
        }
        this.metadata.format = format || null;
        return this;
    }

    /**
     * Sets the conditional callback that determines, per discovered path, that the file-system operation being
     * performed can continue and be processed. 
     * 
     * Calling this function without an argument or `null` will reset it to it's default (no conditional callback).
     * @throws Error when the specified conditions are not a callback function.
     * @param {FairuCallback.Condition} conditions - Conditional flags indicating what states of the path must appear
     *  to be true before proceeding with the operation for a path.
     * @returns {Fairu}
     * @example
     * // Discovering only paths that are writable with a minimum size of 1024 bytes.
     * let states = await Fairu
     *   .with('./*.js')
     *   .when(s => s.stats && s.stats.size > 1024 && s.writable)
     *   .discover();
     * console.log(states);
     */
    when(conditions) {
        let conditionsType = typeof conditions;
        if (conditionsType !== 'undefined' && conditions !== null) {
            if (conditionsType === 'function') {
                this.metadata.when = conditions;
            } else {
                throw new Error('The "conditions" argument must be a callback.');
            }
        } else {
            this.metadata.when = null;
        }
        return this;
    }

    /**
     * Asynchronously resolves a glob pattern.
     * @param {String} pattern - The glob pattern to resolve.
     * @param {String | Array.<String>} [ignore] - An ignore pattern to exclude results from the matching file paths.
     * @returns {Promise.<Array.<String>>}
     * @private
     */
    async _globFind(pattern, ignore) {
        let hasDirTail = pattern.endsWith(path.sep);
        return await new Promise((resolve, reject) => {
            glob(pattern, Object.assign({}, this.options, {
                ignore: ignore
            }), (err, matches) => {
                if (err) reject(err);
                if (matches.length === 0 && glob.hasMagic(pattern, this.options) === false && (!ignore || Array.isArray(ignore) && ignore.length === 0)) {
                    matches.push(path.resolve(pattern));
                }
                if (hasDirTail) {
                    //re-affix the directory seperater, as it was on the tail, thus only directories should be found.
                    for (let i = 0; i < matches.length; i++) {
                        matches[i] += path.sep;
                    }
                }
                return resolve(matches);
            });
        });
    }

    /**
     * Expands globbed paths and discovers information about them, returning a record for each path (including invalid)
     * ones.
     * @throws Error when the `throw` flag is true and an error discovering paths is encountered.
     * @throws Error when the "when" condition for the Fairu operation fails to return a boolean result.
     * @param {FairuCallback.PathStateCreate} [create] - Optional callback that returns an initialized `PathState`.
     * @param {FairuCallback.DiscoverErrorHandler} [handleError] - Optional callback to handle an error if it occurs. 
     * @returns {Promise.<Array.<PathState>>}
     */
    async discover(create, handleError) {
        //de-glob
        let paths = [];
        for (let globPath of this.metadata.with) {
            let foundPaths = await this._globFind(globPath, this.metadata.without);
            //ensure the path only shows up once in the results.
            for (let fp of foundPaths) {
                if (paths.indexOf(fp) <= -1) {
                    paths.push(fp);
                }
            }
        }
        //iterate & discover.
        let results = [];
        for (let p of paths) {
            //build default state
            let state = null;
            if (typeof create === 'function') {
                state = create(p); //allow other Fairu operations to hijack discover for their usage.
            } else {
                state = new PathState(p);
                state.operation = 'discover';
            }
            state.exists = true;
            try {
                //gather dicey details
                state.stats = await fs.stat(p); //allowed to fail
                try {
                    await fs.access(p, constants.R_OK);
                    state.readable = true;
                } catch (readErr) { } // eslint-disable-line no-empty
                try {
                    await fs.access(p, constants.W_OK);
                    state.writable = true;
                } catch (writeErr) { } // eslint-disable-line no-empty
            } catch (err) {
                state.error = err;
                if (typeof handleError === 'function') {
                    state.error = handleError(err, state);
                }
                if (state.error) {
                    if (err.code === 'ENOENT') {
                        state.exists = false;
                        if (this.metadata.throw) {
                            throw err;
                        } else {
                            state.error = null;
                        }
                    } else if (this.metadata.throw) {
                        throw err;
                    }
                }
            }
            if (this.metadata.when) {
                let whenResult = this.metadata.when(state);
                if (whenResult === true) {
                    results.push(state);
                } else if (whenResult !== false) {
                    throw new Error(`The "when" condition for the Fairu operation "${state.operation}" failed to return a boolean result.`);
                }
            } else {
                results.push(state);
            }
        }
        return results;
    }

    /**
     * Reads from all paths discovered from the specified `.with` paths.
     * Directories will return data that is the top-level list of files and directories they contain. 
     * Files and other types will return the data read in the form of a `Buffer` unless a `format` was specified.
     * 
     * If a format was specified, the read data will be (attempted) to parse from that format into an in-memory object.
     * 
     * If the file is in an errored state prior to the read, it will not be read and data will be `null`.
     * @returns {Promise.<Array.<ReadPathState>>}
     */
    async read() {
        let states = await this.discover(tp => new ReadPathState(tp));
        for (let state of states) {
            if (!state.error) { //skip paths in an errored state
                try {
                    if (state.stats.isDirectory()) {
                        //path points to a directory, read the file list instead.
                        state.data = await fs.readdir(state.path);
                    } else {
                        state.data = await fs.readFile(state.path, {
                            encoding: this.metadata.encoding
                        });
                        if (this.metadata.format) {
                            state.data = Fairu.parse(
                                this.metadata.format,
                                state.data.toString(this.metadata.encoding || undefined),
                                state.path
                            );
                        }
                    }
                } catch (err) {
                    state.error = err;
                    if (this.metadata.throw) {
                        throw err;
                    }
                }
            }
        }
        return states;
    }

    /**
     * Writes the content to the paths specified. If the file is already present, the content is overwritten.
     * If the path is a directory, it will be created.
     * 
     * If a format was specified, the written data will be stringified into that format before being written.
     * 
     * If the file is in an errored state prior to the write, it is skipped.
     * @param {String|Buffer} content - The content to be written to path.
     * @returns {Promise.<Array.<PathState>>}
     */
    async write(content) {
        let states = await this.discover(tp => {
            let ps = new PathState(tp);
            ps.operation = 'write';
            return ps;
        });
        for (let state of states) {
            if (!state.error) { //skip paths in an errored state
                try {
                    if (state.path.endsWith(path.sep) || (state.stats && state.stats.isDirectory())) {
                        await fs.mkdir(state.path, { recursive: true });
                    } else {
                        await fs.mkdir(path.dirname(state.path), { recursive: true });
                        if (this.metadata.format) {
                            content = Fairu.stringify(this.metadata.format, content);
                        }
                        await fs.writeFile(state.path, content, {
                            encoding: this.metadata.encoding
                        });
                    }
                } catch (err) {
                    state.error = err;
                    if (this.metadata.throw) {
                        throw err;
                    }
                }
            }
        }
        return states;
    }

    /**
     * Appends the content to the paths specified. If the file does not exist, it is created.
     * 
     * If the path is a directory, it will be created.
     * 
     * If a format was specified, the written data will be stringified into that format before being written.
     * 
     * If the file is in an errored state prior to the write, it is skipped.
     * @param {String|Buffer} content - The content to be written to path.
     * @returns {Promise.<Array.<PathState>>}
     */
    async append(content) {
        let states = await this.discover(tp => {
            let ps = new PathState(tp);
            ps.operation = 'write';
            return ps;
        });
        for (let state of states) {
            if (!state.error) { //skip paths in an errored state
                try {
                    if (state.path.endsWith(path.sep) || (state.stats && state.stats.isDirectory())) {
                        await fs.mkdir(state.path, { recursive: true });
                    } else {
                        await fs.mkdir(path.dirname(state.path), { recursive: true });
                        if (this.metadata.format) {
                            content = Fairu.stringify(this.metadata.format, content);
                        }
                        await fs.appendFile(state.path, content, {
                            encoding: this.metadata.encoding
                        });
                    }
                } catch (err) {
                    state.error = err;
                    if (this.metadata.throw) {
                        throw err;
                    }
                }
            }
        }
        return states;

    }

    /**
     * Creates a blank file write or directory if the path does not exist, and ensures the directory tree is present.
     * 
     * The file access and modified time is updated on the path.
     * 
     * If the file is in an errored state prior to the write, it is skipped.
     * @returns {Promise.<Array.<PathState>>}
     */
    async touch() {
        let states = await this.discover(
            tp => {
                let ps = new PathState(tp);
                ps.operation = 'touch';
                return ps;
            },
            (err, state) => {
                if (err.code === 'ENOENT') {
                    state.exists = false;
                    state.error = null;
                    return null;
                }
                return err;
            }
        );
        for (let state of states) {
            if (!state.error) { //skip paths in an errored state
                try {
                    let stamp = new Date();
                    if (state.path.endsWith(path.sep) || (state.stats && state.stats.isDirectory())) {
                        await fs.mkdir(state.path, { recursive: true });
                    } else {
                        await fs.mkdir(path.dirname(state.path), { recursive: true });
                        await fs.appendFile(state.path, Buffer.alloc(0));
                    }
                    await fs.utimes(state.path, stamp, stamp);
                } catch (err) {
                    state.error = err;
                    if (err.code === 'ENOENT') {
                        state.exists = false;
                    } else if (this.metadata.throw) {
                        throw err;
                    }
                }
            }
        }
        return states;
    }

    /**
     * Deletes the files and/or directories discovered from the `.with` paths.
     * 
     * If the path is a directory, it is recursively deleted. 
     * @returns {Promise.<Array.<PathState>>}
     */
    async unlink() {
        let states = await this.discover(tp => {
            let ps = new PathState(tp);
            ps.operation = 'unlink';
            return ps;
        });
        for (let state of states) {
            if (!state.error) { //skip paths in an errored state
                try {
                    if (state.path.endsWith(path.sep) || (state.stats && state.stats.isDirectory())) {
                        await fs.rm(state.path, { recursive: true });
                    } else {
                        await fs.unlink(state.path);
                    }
                } catch (err) {
                    state.error = err;
                    if (this.metadata.throw) {
                        throw err;
                    }
                }
            }
        }
        return states;
    }

}

/**
 * @enum {String}
 */
Fairu.Format = {
    yaml: 'yaml',
    toml: 'toml',
    json: 'json'
};

export { Fairu as default };