thebespokepixel/verbosity

View on GitHub
src/lib/verbosity.js

Summary

Maintainability
B
4 hrs
Test Coverage
/* ──────────╮
 │ verbosity │ Verbosity Controlling Console Writer/Emitter
 ╰───────────┴────────────────────────────────────────────────────────────────── */
/* eslint node/prefer-global/process: [error] */

import {format, inspect} from 'node:util'
import {Console} from 'node:console'
import termNG from 'term-ng'
import chalk from 'chalk'
import sparkles from 'sparkles'
import {bespokeTimeFormat} from '@thebespokepixel/time'
import matrix from './matrix.js'

/**
 * Generate a verbosity console
 * @param {object} options                      - Configuration options.
 * @param {stream.writable} options.outStream   - Stream to write normal output
 * @param {stream.writable} options.errorStream - Stream to write error output
 * @param {number} options.verbosity            - The verboseness of output:
 *                                              0: Mute
 *                                              1: Errors
 *                                              2: Notice
 *                                              3: Log
 *                                              4: Info
 *                                              5: Debug
 * @param {string} options.timestamp            - Timestamp format.
 * @param {string} options.namespace            - Sparkles namespace to emit events to.
 * @param {boolean} options.global              - Should changes to verbosity be made globally?
 * @param {string} options.prefix               - Logging message prefix.
 * @return {Verbosity} Verbosity's console object.
 */
export default class Verbosity extends Console {
    constructor({
        outStream,
        errorStream,
        verbosity = 3,
        timestamp,
        namespace,
        global,
        prefix,
    } = {}) {
        const sOut = (ws => {
            if (!ws.writable) {
                throw new Error('Provided output stream must be writable')
            }

            return ws
        })(outStream ? outStream : process.stdout)

        const sError = (ws => {
            if (!ws.writable) {
                throw new Error('Provided error stream must be writable')
            }

            return ws
        })(errorStream ? errorStream : sOut)

        super(sOut, sError)
        this.willEmit = Boolean(namespace)
        this.globalControl = Boolean(global)

        this.timeFormatter = (ts => ts
            ? () => `[${chalk.dim(bespokeTimeFormat(ts))}] `
            : () => ''
        )(timestamp)

        this.prefixFormatter = (pfix => pfix
            ? () => `[${pfix}] `
            : () => ''
        )(prefix)

        this._stdout = sOut
        this._stderr = sError
        this.threshold = verbosity
        this.globalVerbosityController = this.globalControl && sparkles('verbosityGlobal')
        this.emitter = this.willEmit && sparkles(namespace)
        this.matrix = matrix(sOut, sError)

        if (this.globalControl) {
            this.globalVerbosityController.on('level', ({level}) => {
                this.threshold = level
            })
        }
    }

    /**
     * Set the current verbosity.
     * @param  {number|string} level - The current level (0 to 5) or level name.
     * @return {number} The current verboseness (0 to 5).
     */
    verbosity(level) {
        if (level) {
            level = (typeof level === 'string') ? this.matrix[level].level : level

            if (level < 6) {
                this.threshold = level
            }

            if (this.globalControl) {
                this.globalVerbosityController.emit('level', {level})
            }
        }

        return this.threshold
    }

    /**
     * Can the requested logging level be written at this time.
     * @param  {number} level - The requested level (0 to 5).
     * @return {boolean} `true` if ok to write.
     */
    canWrite(level) {
        level = (typeof level === 'string') ? this.matrix[level] : level
        return this.threshold >= level
    }

    /**
     * Route message and emit if required.
     * @private
     * @param  {number}    level Source logging level
     * @param  {string}    message   Message to log
     * @param  {...string} a     Additional arguments to log
     */
    route(level, message, ...a) {
        message = (a.length > 0) ? format(message, ...a) : message
        if (this.willEmit) {
            this.emitter.emit(level, message)
        }

        if (this.threshold >= this.matrix[level].level) {
            const pfix = `${this.timeFormatter()}${this.prefixFormatter()}`
            this.matrix[level].stream.write(`${this.matrix[level].format(pfix, message)}\n`)
        }
    }

    /**
     * Log a debug message. (Level 5)
     * @param  {string}    message  The debug message to log.
     * @param  {...string} args Additional arguments to log.
     */
    debug(message, ...args) {
        this.route('debug', message, ...args)
    }

    /**
     * Log an info message. (Level 4)
     * @param  {string}    message  The info message to log.
     * @param  {...string} args Additional arguments to log.
     */
    info(message, ...args) {
        this.route('info', message, ...args)
    }

    /**
     * Log a normal message. (Level 3)
     * @param  {string}    message  The normal message to log.
     * @param  {...string} args Additional arguments to log.
     */
    log(message, ...args) {
        this.route('log', message, ...args)
    }

    /**
     * Log a warning message. (Level 2)
     * @param  {string}    message  The warning message to log.
     * @param  {...string} args Additional arguments to log.
     */
    warn(message, ...args) {
        this.route('warn', message, ...args)
    }

    /**
     * Log an error message. (Level 1)
     * @param  {string}    message  The error message to log.
     * @param  {...string} args Additional arguments to log.
     */
    error(message, ...args) {
        this.route('error', message, ...args)
    }

    /**
     * Log a critical error message, if something breaks. (Level 1)
     * @param  {string}    message  The critical error message to log.
     * @param  {...string} args Additional arguments to log.
     */
    critical(message, ...args) {
        this.route('critical', message, ...args)
    }

    /**
     * Log a panic error message if something unexpected happens. (Level 1)
     * @param  {string}    message  The panic message to log.
     * @param  {...string} args Additional arguments to log.
     */
    panic(message, ...args) {
        this.route('panic', message, ...args)
    }

    /**
     * Log a emergency message, for when something needs emergency attention. (Level 1)
     * @param  {string}    message  The debug message to log.
     * @param  {...string} args Additional arguments to log.
     */
    emergency(message, ...args) {
        this.route('emergency', message, ...args)
    }

    /**
     * As console.dir, but defaults to colour (if appropriate) and zero depth.
     * @param  {object} object     The Object to print.
     * @param  {object} options As console.dir options object.
     */
    dir(object, options = {}) {
        const {depth = 0, colors = termNG.color.basic} = options
        options.depth = depth
        options.colors = colors
        this._stdout.write(format(inspect(object, options)))
    }

    /**
     * Pretty prints object, similar to OS X's plutil -p. Defaults to zero depth.
     * @param  {object} object   The Object to print.
     * @param  {number} depth How many object levels to print.
     * @param  {boolean} color Print output in color, if supported.
     * @example
     * console.pretty(console)
     *
     * // Outputs:
     *    Object: VerbosityMatrix
     *      critical ▸ [Function]
     *      error ▸ [Function ▸ bound ]
     *      warn ▸ [Function ▸ bound ]
     *      log ▸ [Function ▸ bound ]
     *      info ▸ [Function ▸ bound ]
     *      debug ▸ [Function]
     *      canWrite ▸ [Function]
     *      ...
     */
    pretty(object, depth = 0, color = true) {
        this._stdout.write(format('Content: %s\n', inspect(object, {
            depth,
            colors: color && termNG.color.basic,
        })
            .slice(0, -1)
            .replace(/^{/, 'Object\n ')
            .replace(/^\[/, 'Array\n ')
            .replace(/^(\w+) {/, '$1')
            .replace(/(\w+):/g, '$1 ▸')
            .replace(/,\n/g, '\n'),
        ))
    }

    /**
     * Helper function for pretty printing a summary of the current 'yargs' options.
     *
     * Only prints 'long options', `._` as 'arguments' and `$0` as 'self'.
     * @param  {object} object The Yargs argv object to print.
     * @param  {boolean} color Print output in color, if supported.
     * @example
     * console.yargs(yargs)
     *
     * // Outputs:
     * Object (yargs):
     *   left ▸ 2
     *   right ▸ 2
     *   mode ▸ 'hard'
     *   encoding ▸ 'utf8'
     *   ...
     *   self ▸ '/usr/local/bin/truwrap'
     */
    yargs(object, color = true) {
        const parsed = {}

        for (const key_ of Object.keys(object)) {
            const value = object[key_]
            switch (key_) {
                case '_':
                    if (value.length > 0) {
                        parsed.arguments = value.join(' ')
                    }

                    break
                case '$0':
                    parsed.self = value
                    break
                default:
                    if (key_.length > 1) {
                        parsed[key_] = value
                    }
            }
        }

        this._stdout.write(format('Options (yargs):\n  %s\n', inspect(parsed, {
            colors: color && termNG.color.basic,
        })
            .slice(2, -1)
            .replace(/(\w+):/g, '$1 ▸')
            .replace(/,\n/g, '\n')))
    }
}