Home Reference Source Test

src/index.js

import {
  red, yellow, magenta, blue, grey,
} from 'chalk';
import dateFormat from 'dateformat';
import defaults from 'lodash.defaults';
import { separateMessageFromStack, formatStackTrace } from 'jest-message-util';
import { dirname } from 'path';
import { stringify } from 'purdy';
import stripAnsi from 'strip-ansi';

/**
 * Function that returns a Stream.
 *
 * @typedef {Function} Log~streamFn
 * @param {number} level - Level number.
 * @returns {Stream} A Stream
 */

/**
 * Options for `Log`.
 *
 * @typedef {Object} Log~options
 * @property {string[]} levels - Log levels.
 * @property {function[]} colors - Color function for each level.
 * @property {boolean[]} inverse - Whether to inverse color for each level.
 * @property {string[]} labels - Label of each level.
 * @property {string} separator - Separator between time and label, and between header and body
 * @property {Stream|Log~streamFn} stream - Stream or function that returns a Stream.
 * @property {boolean} displayTime - Whether to display time.
 * @property {string} displayTimeFormat - Format to display time in. See [dateformat].
 * @property {string} level - All log calls below this level will be ignored.
 *
 * [dateformat]: https://www.npmjs.com/package/dateformat
 */

/**
 * Add characters at the begenning of every line of a multi-line string.
 *
 * @param {string} string - Input string.
 * @param {number} length - Number of characters to insert at every line.
 * @param {string} [char=' '] - Character to insert.
 * @returns {string} The indented string.
 */
export const indent = (string, length, char = ' ') => `${string.replace(/\n/g, `\n${char.repeat(length)}`)}`;

/**
 * Transform an error into a user-readable, colored string.
 *
 * @param  {Error} error - Input error.
 * @returns {string} The formatted message.
 */
export function formatError(error) {
  const { message, stack } = separateMessageFromStack(error.stack);
  const stackTrace = formatStackTrace(
    stack,
    { rootDir: dirname(dirname(__dirname)), testMatch: [] },
    { noStackTrace: false },
  );
  return `${red(message)}\n${stackTrace.replace(/\n {4}/g, '\n')}\n`;
}

/**
 * Transform an object into a user-readable, colored string. If the resulting
 * string is shorter than 200 characters, inlines it.
 *
 * @param {Object} object - The input object.
 * @returns {string} A stringified version of the object.
 */
function formatObject(object) {
  const str = stringify(object, { indent: 2, arrayIndex: false });
  return grey(stripAnsi(str).length < 200 ? str.replace(/\n */g, ' ') : `\n${str}\n`);
}

/**
 * Transform a value of any type into a user-readable, colored string.
 *
 * @param {*} value - A value of any type.
 * @returns {string} A stringified version of the value.
 */
function format(value) {
  if (value instanceof Error) {
    return formatError(value);
  }
  return typeof value === 'string' ? value : formatObject(value);
}

/**
 * Default configuration
 * @type {Log~options}
 */
export const defaultOptions = {
  levels: ['emergency', 'alert', 'critical', 'error', 'warning', 'notice', 'info', 'debug'],
  colors: [red.bold, red, red.bold, red, magenta, yellow, blue, grey],
  inverse: [true, true],
  labels: ['EMERG', 'ALERT', 'CRITI', 'ERROR', ' WARN', ' NOTE', ' INFO', 'DEBUG'],
  separator: ' • ',
  stream: level => (level <= 4 ? process.stderr : process.stdout),
  format,
  displayTime: false,
  displayTimeFormat: 'yyyy-mm-dd HH:MM:ss.l',
  level: 'info',
};

/**
 * If an option is a function, apply it with given parameters, otherwise returns it.
 *
 * @param {Function|*} option - The option bvalue or function.
 * @param {*[]} args - Arguments to call `option` with, if `option` is a function.
 * @returns {*} The return of `option(...args)`, or `option`.
 */
const applyOption = (option, ...args) => (typeof option === 'function' ? option(...args) : option);

/**
 * Fancy lightweight logging utility.
 *
 * @class

 */
export class Log {
  /**
   * Creates an instance of `Log`.
   *
   * @param {Log~options} [options={}] - Options.
   * @returns {Log} A new instance of Log.
   */
  constructor(options = {}) {
    /**
     * @var {Log~options} options - Logger options.
     */
    this.options = defaults({}, options, defaultOptions);

    this.options.levels.forEach((level, logLevel) => {
      /**
       * Logging method for each level of `options.levels`.
       *
       * @param {...*} parts - Any value: `string`, `Object`, `Error`, etc.
       * @returns {undefined} Nothing.
       */
      this[level] = (...parts) => this.write(logLevel, parts);
    });
  }

  /**
   * @property {number} level - Integer value of `this.options.level`.
   * @returns {number} The level.
   */
  get level() {
    return this.options.levels.indexOf(this.options.level);
  }

  /**
   * Build the header.
   *
   * @param {number} level - Level number.
   * @returns {string} The constructed header.
   */
  buildHeader(level) {
    const {
      labels, colors, inverse, displayTime, displayTimeFormat, separator,
    } = this.options;
    const time = displayTime ? grey(dateFormat(Date.now(), displayTimeFormat)) : '';
    const color = colors[level];
    const labelColor = inverse[level] ? color.inverse : color;
    const label = `${labels[level]}`;
    return `${time && grey(`${time}${separator}`)}${labelColor(label)}${color(separator)}`;
  }

  /**
   * Build the body.
   *
   * @param {*[]} parts - Parts of the message, not formatted.
   * @returns {string} The constructed body.
   */
  buildBody(parts) {
    const messages = parts.map(this.options.format);
    return messages.join(messages.some(s => s.includes('\n')) ? '\n' : ' ');
  }

  /**
   * Build the entire message.
   *
   * @param {number} level - Level number.
   * @param {*[]} parts - Parts of the message, not formatted.
   * @returns {string} The constructed message.
   */
  buildMessage(level, parts) {
    const header = this.buildHeader(level);
    const body = this.buildBody(parts);
    return `${header}${indent(body, stripAnsi(header).length)}\n`;
  }

  /**
   * Build a message and write it to the stream.
   *
   * @param {number} level - Level number.
   * @param {*[]} parts - Parts of the message, not formatted.
   * @returns {undefined} Nothing.
   */
  write(level, parts) {
    if (level > this.level) {
      return;
    }
    const stream = applyOption(this.options.stream, [level]);
    stream.write(this.buildMessage(level, parts));
  }
}

/**
 * Utility function that will create a new instance of `Log` with options taken
 * from environment variables.
 *
 * Variables:
 * - `NODE_ENV`:
 *   - `'development'` (default): `displayTime` is disabled,
 *   - `'test'`: `displayTime` is disabled, `'level'` is set to `'critical'`,
 *   - `'production'`: Use default values for each option.
 * - `LOG`: Sets `level` value.
 * - `LOG_LEVEL`: Alias for `LOG`.
 * - `LOG_TIME`: When set to `true` or `1`, enables `displayTime`.
 * - `LOG_TIME_FORMAT`: Sets `displayTimeFormat` value.
 *
 * @param {Object} [options={}] - Additional options. Will take precedence over other ones.
 * @param {Object} [env=process.env || {}] - `env` object.
 * @param {string} [environment=env.NODE_ENV || 'development'] - Environment.
 * @returns {Log} A new instance of `Log`.
 */
export function fromEnv(options = {}, env = process.env || {}, environment = env.NODE_ENV || 'development') {
  const mergedOptions = defaults(
    options,
    {
      level: env.LOG || env.LOG_LEVEL,
      displayTime: env.LOG_TIME && ['1', 'true'].indexOf(env.LOG_TIME) !== -1,
      displayTimeFormat: env.LOG_TIME_FORMAT,
    },
    {
      development: { displayTime: false },
      test: { level: 'critical', displayTime: false },
    }[environment],
  );
  return new Log(mergedOptions);
}