console.js

/**
 * Console APIs
 *
 * The Console APIs provide functionality to allow developers to perform debugging tasks,
 * such as logging messages or the values of variables at set points in your code.
 *
 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/console}
 *
 * @module Console
 */

/* eslint-disable no-control-regex */

import { performance } from 'perf_hooks';
import { green, yellow, cyan, red, bright_black } from 'colors';

const { callConsole } = process.binding('stdio');

// Returns a string with as many spaces as the parameter specified.
function pre(amount) {
  return ' '.repeat(amount);
}

// Small util for objects that might not have a `.toString` method.
function objectToString(value) {
  return Object.prototype.toString.call(value);
}

/**
 * Stringifies almost all JavaScript built-in types.
 *
 * @ignore
 * @param {*} value
 * @param {WeakSet} seen
 * @param {number} depth
 * @returns {string}
 */

function stringify(value, seen, depth = 0) {
  switch (typeof value) {
    case 'string':
      return depth > 0 ? stringifyText(value) : value;
    case 'number':
    case 'boolean':
      return yellow(String(value));
    case 'undefined':
      return bright_black(String(value));
    case 'symbol':
      return green(String(value));
    case 'bigint':
      return yellow(String(value) + 'n');
    case 'object':
      return !value ? 'null' : stringifyObject(value, seen, ++depth);
    case 'function':
      return !value.name
        ? cyan('[Function (anonymous)]')
        : cyan(`[Function: ${value.name}]`);
    default:
      return '[Unknown]';
  }
}

function stringifyText(value) {
  const text = value.length > 100 ? `${value.slice(0, 100)}...` : value;
  const textEscaped = JSON.stringify(text);
  return green(textEscaped);
}

function isArray(value) {
  return Array.isArray(value);
}

function stringifyArray(arr, seen, depth) {
  // Special formatting required if array has only numbers.
  const hasOnlyNumbers = arr.every((elem) => typeof elem === 'number');

  const entries = [];
  for (const elem of arr) {
    entries.push(stringify(elem, seen, depth));
  }

  // Remove the color characters to get the proper length.
  const uncoloredEntries = entries.join('').replace(/\u001b\[[0-9;]*m/g, '');

  // Multiline formatting.
  if (uncoloredEntries.length > 60) {
    const start = '[\n';
    const end = `\n${pre((depth - 1) * 2)}]`;
    const entriesPretty = prettifyArray(entries, depth, hasOnlyNumbers);
    return `${start}${entriesPretty}${end}`;
  }

  // Inline formatting.
  return entries.length > 0 ? `[ ${entries.join(', ')} ]` : `[]`;
}

function isTypedArray(value) {
  switch (Object.prototype.toString.call(value)) {
    case '[object Int8Array]':
    case '[object Uint8Array]':
    case '[object Uint8ClampedArray]':
    case '[object Uint16Array]':
    case '[object Int32Array]':
    case '[object Uint32Array]':
    case '[object Float32Array]':
    case '[object Float64Array]':
      return true;
    default:
      return false;
  }
}

// Calculate the grid size (trying to make perfect squares and minimizing empty space).
// 1. Max out at 12xN.
// 2. Max out at 01xN (if the lengthier element is too big).
function getMaxElementsPerRow(arr, avgElementLength, maxElementLength) {
  if (maxElementLength > 30) return 1;
  return Math.min(
    Math.max(
      Math.floor((Math.sqrt(arr.length) * avgElementLength) / maxElementLength),
      1
    ),
    12
  );
}

function prettifyArray(arr, depth = 0, hasOnlyNumbers) {
  // Remove the color characters so we can calculate the AVG and MAX correctly.
  const uncolored = arr.map(
    (elem) => elem.replace(/\u001b\[[0-9;]*m/g, '').length
  );

  const maxElementLength = Math.max(...uncolored);
  const avgElementLength = uncolored.reduce((a, b) => a + b) / uncolored.length;

  // Calculate the grid size.
  const maxElementsPerRow = getMaxElementsPerRow(
    arr,
    avgElementLength,
    maxElementLength
  );

  // Tries to align the columns.
  const alignColumn = (elem, i) => {
    const length = elem.replace(/\u001b\[[0-9;]*m/g, '').length;
    const shift = maxElementsPerRow === 1 ? 0 : maxElementLength - length;
    if (hasOnlyNumbers) {
      return i === arr.length - 1
        ? pre(shift) + elem
        : pre(shift) + elem + ', ';
    } else {
      return i === arr.length - 1
        ? elem + pre(shift)
        : elem + ', ' + pre(shift);
    }
  };

  // Creates rows of length `maxElementsPerRow`.
  const groupRows = (acc, elem, i) => {
    if (acc.atRow === maxElementsPerRow || i === 0) {
      acc.list.push([elem]);
      acc.atRow = 1;
    } else {
      acc.list[acc.list.length - 1].push(elem);
      acc.atRow++;
    }
    return acc;
  };

  // Indents row based on the depth we're currently in.
  const indentRow = (row) => pre(depth * 2) + row.join('');

  let output = arr.map(alignColumn);

  output = output.reduce(groupRows, { atRow: 0, list: [] });
  output = output.list.map(indentRow).join('\n');

  return output;
}

function stringifyTypedArray(arr, depth = 0) {
  // Colorize internal values.
  let pretty = arr
    .toString()
    .split(',')
    .map((elem) => yellow(elem));

  // Get typed-array's specific type.
  const type = Object.prototype.toString
    .call(arr)
    .replace('[object ', '')
    .replace(']', '');

  if (pretty.length > 50) {
    pretty = prettifyArray(pretty, depth, true);
    return `${type}(${arr.length}) [\n${pretty}\n${pre((depth - 1) * 2)}]`;
  }

  return `${type}(${arr.length}) [ ${pretty.join(', ')} ]`;
}

function isDate(value) {
  return Object.prototype.toString.call(value) === '[object Date]';
}

function stringifyDate(date) {
  return date.toISOString();
}

function isRexExp(value) {
  return Object.prototype.toString.call(value) === '[object RegExp]';
}

function stringifyRexExp(exp) {
  return exp.toString();
}

function isError(value) {
  return Object.prototype.toString.call(value) === '[object Error]';
}

function stringifyError(error) {
  return error.stack;
}

function isArrayBuffer(value) {
  return value instanceof ArrayBuffer;
}

function stringifyArrayBuffer(value) {
  return `ArrayBuffer { byteLength: ${stringify(value.byteLength)} }`;
}

function isPromise(value) {
  return value instanceof Promise;
}

function stringifyPromise(value) {
  // We have to use a Rust binding to inspect the contents of a promise
  // object because JS doesn't expose that kind of functionality.
  const binding = process.binding('promise');
  const { state, value: promiseValue } = binding.peek(value);

  if (state === 'PENDING') {
    return `Promise { ${cyan('<pending>')} }`;
  }

  const output = stringify(promiseValue, undefined, 1);
  const end = `${output.length > 50 ? '\n' : ' '}}`;

  const prefix =
    state === 'FULFILLED'
      ? `${output.length > 50 ? '\n  ' : ''}`
      : `${output.length > 50 ? '\n  ' : ''}${red('<rejected>')} `;

  return 'Promise { ' + prefix + output + end;
}

const specialCharsRegex = new RegExp('[^A-Za-z0-9|_]+');

/**
 * Specifically stringifies JavaScript objects.
 *
 * @ignore
 * @param {*} value
 * @param {WeakSet} seen
 * @param {number} depth
 * @returns {string}
 */

function stringifyObject(value, seen = new WeakSet(), depth) {
  // We have to check the type of the value parameter to decide which stringify
  // transformer we should use.
  if (isArray(value)) {
    return stringifyArray(value, seen, depth);
  }

  if (isArrayBuffer(value)) {
    return stringifyArrayBuffer(value);
  }

  if (isTypedArray(value)) {
    return stringifyTypedArray(value, depth);
  }

  if (isDate(value)) {
    return stringifyDate(value);
  }

  if (isRexExp(value)) {
    return stringifyRexExp(value);
  }

  if (isError(value)) {
    return stringifyError(value);
  }

  if (isPromise(value)) {
    return stringifyPromise(value);
  }

  // It's an object type that console does not support.
  if (objectToString(value) !== '[object Object]') {
    const type = objectToString(value).replace('[object ', '').replace(']', '');
    return `${type} {}`;
  }

  // Looks like it's a regular object.
  const entries = [];
  for (const key of Object.keys(value)) {
    if (seen.has(value[key])) {
      entries.push(`${pre(depth * 2)}${key}: [Circular]`);
      continue;
    }
    // The following wraps in quotes object keys that contain special
    // characters like { "Foo-Bar": 123 }.
    const keyValue = specialCharsRegex.test(key) ? `"${key}"` : key;

    seen.add(value);
    entries.push(
      `${pre(depth * 2)}${keyValue}: ${stringify(value[key], seen, depth)}`
    );
  }

  // Output the class name if the object is a class instance.
  const className = value?.constructor?.name;
  const prefix = !className || className === 'Object' ? '' : className + ' ';

  // Apply multi-line formatting on long properties.
  if (entries.map((v) => v.trim()).join('').length > 50) {
    const start = `${prefix}{\n`;
    const end = `\n${pre((depth - 1) * 2)}}`;
    return `${start}${entries.join(',\n')}${end}`;
  }

  // Inline formatting.
  const entriesPretty = entries.map((v) => v.trim());
  const content = entries.length > 0 ? `{ ${entriesPretty.join(', ')} }` : `{}`;

  return `${prefix}${content}`;
}

/**
 * Shows the given message and waits for the user's input.
 *
 * @param {String} [message] - A string of text to display to the user.
 * @param {String} [defaultValue] - A string containing the default value displayed in the text input field.
 */
export function prompt(message = 'Prompt', defaultValue = null) {
  // Write prompt message to stdout.
  process.stdout.write(
    `${message} ${defaultValue ? `[${defaultValue}] ` : ''}`
  );
  // Read and return user's input.
  return process.stdin.read() || defaultValue;
}

/**
 * Console is a subset implementation of MDN's Console API.
 */
export class Console {
  // Holds timers initialized by console.
  // https://developer.mozilla.org/en-US/docs/Web/API/Console/time
  #timers = new Map();

  /**
   * Outputs data to the `stdout` stream.
   *
   * @param  {...*} args - Prints to stdout with newline.
   */
  log(...args) {
    const output = args.map((arg) => stringify(arg)).join(' ');
    process.stdout.write(`${output}\n`);
  }

  /**
   * An alias to `console.log()`.
   *
   * @param  {...*} args - Prints to stdout with newline.
   */
  info(...args) {
    const output = args.map((arg) => stringify(arg)).join(' ');
    process.stdout.write(`${output}\n`);
  }

  /**
   * An alias to `console.log()`.
   *
   * @param  {...*} args - Prints to stdout with newline.
   */
  debug(...args) {
    const output = args.map((arg) => stringify(arg)).join(' ');
    process.stdout.write(`${output}\n`);
  }

  /**
   * Same as `console.log` but prepends the output with "WARNING".
   *
   * @param  {...*} args - Prints to stdout with newline.
   */
  warn(...args) {
    const output = args.map((arg) => stringify(arg)).join(' ');
    process.stderr.write(`WARNING: ${output}\n`);
  }

  /**
   * Same as `console.log` but prepends the output with "WARNING".
   *
   * @param  {...*} args - Prints to stdout with newline.
   */
  error(...args) {
    const output = args.map((arg) => stringify(arg)).join(' ');
    process.stderr.write(`WARNING: ${output}\n`);
  }

  /**
   * Clears the console if the environment allows it.
   */
  clear() {
    try {
      process.binding('stdio').clear();
    } catch (e) {
      this.warn('This environment does not support console clearing');
    }
  }

  /**
   * Starts a timer you can use to track how long an operation takes.
   *
   * @param {String} [label] - A string representing the name to give the new timer.
   */
  time(label = 'default') {
    if (this.#timers.has(label)) {
      this.warn(`Timer '${label}' already exists`);
      return;
    }

    this.#timers.set(label, performance.now());
  }

  /**
   * Logs the current value of a timer that was previously started by calling
   * console.time() to the console.
   *
   * @param {String} [label] - The name of the timer to log to the console.
   */
  timeLog(label = 'default') {
    if (!this.#timers.has(label)) {
      this.warn(`Timer '${label}' does not exist`);
      return;
    }

    const difference = performance.now() - this.#timers.get(label);
    this.log(`${label}: ${difference} ms`);
  }

  /**
   * Stops a timer that was previously started by calling console.time().
   *
   * @param {String} [label] - A string representing the name of the timer to stop.
   */
  timeEnd(label = 'default') {
    if (!this.#timers.has(label)) {
      this.warn(`Timer '${label}' does not exist`);
      return;
    }

    this.timeLog(label);
    this.#timers.delete(label);
  }
}

// This wrapper forwards console messages to V8's internal console implementation,
// triggering the `Runtime.consoleAPICalled` event. This ensures that the
// attached debugger (if exists) is notified about the console call.
//
// https://github.com/v8/v8/blob/master/src/inspector/v8-console.cc
// https://chromedevtools.github.io/devtools-protocol/tot/Runtime/#event-consoleAPICalled
//
export function wrapConsole(console, consoleFromV8) {
  // Get the property names of the console prototype.
  const prototype = Object.getPrototypeOf(console);
  const propertyNames = Object.getOwnPropertyNames(prototype);

  for (const key of Object.keys(consoleFromV8)) {
    // If global console has the same method as inspector console,
    // then wrap these two methods into one.
    if (propertyNames.includes(key)) {
      console[key] = callConsole.bind(
        console,
        consoleFromV8[key],
        console[key]
      );
    } else {
      // Add additional console APIs from the inspector.
      console[key] = consoleFromV8[key];
    }
  }
}

export default { Console, prompt, wrapConsole };