lib/diagnose.js

'use strict';

const stream = require('stream');
const Decoder = require('./decoder');
const utils = require('./utils');
const NoFilter = require('nofilter');
const {MT, SYMS} = require('./constants');

/**
 * Things that can act as inputs, from which a NoFilter can be created.
 *
 * @typedef {string|Buffer|ArrayBuffer|ArrayBufferView
 *   |DataView|stream.Readable} BufferLike
 */

/**
 * @typedef DiagnoseOptions
 * @property {string} [separator='\n'] Output between detected objects.
 * @property {boolean} [stream_errors=false] Put error info into the
 *   output stream.
 * @property {number} [max_depth=-1] The maximum depth to parse.
 *   Use -1 for "until you run out of memory".  Set this to a finite
 *   positive number for un-trusted inputs.  Most standard inputs won't nest
 *   more than 100 or so levels; I've tested into the millions before
 *   running out of memory.
 * @property {object} [tags] Mapping from tag number to function(v),
 *   where v is the decoded value that comes after the tag, and where the
 *   function returns the correctly-created value for that tag.
 * @property {boolean} [preferWeb=false] If true, prefer Uint8Arrays to
 *   be generated instead of node Buffers.  This might turn on some more
 *   changes in the future, so forward-compatibility is not guaranteed yet.
 * @property {BufferEncoding} [encoding='hex'] The encoding of input, ignored if
 *   input is not string.
 */
/**
 * @callback diagnoseCallback
 * @param {Error} [error] If one was generated.
 * @param {string} [value] The diagnostic value.
 * @returns {void}
 */
/**
 * @param {DiagnoseOptions|diagnoseCallback|string} opts Options,
 *   the callback, or input incoding.
 * @param {diagnoseCallback} [cb] Called on completion.
 * @returns {{options: DiagnoseOptions, cb: diagnoseCallback}} Normalized.
 * @throws {TypeError} Unknown option type.
 * @private
 */
function normalizeOptions(opts, cb) {
  switch (typeof opts) {
    case 'function':
      return {options: {}, cb: /** @type {diagnoseCallback} */ (opts)};
    case 'string':
      return {options: {encoding: /** @type {BufferEncoding} */ (opts)}, cb};
    case 'object':
      return {options: opts || {}, cb};
    default:
      throw new TypeError('Unknown option type');
  }
}

/**
 * Output the diagnostic format from a stream of CBOR bytes.
 *
 * @extends stream.Transform
 */
class Diagnose extends stream.Transform {
  /**
   * Creates an instance of Diagnose.
   *
   * @param {DiagnoseOptions} [options={}] Options for creation.
   */
  constructor(options = {}) {
    const {
      separator = '\n',
      stream_errors = false,
      // Decoder options
      tags,
      max_depth,
      preferWeb,
      encoding,
      // Stream.Transform options
      ...superOpts
    } = options;
    super({
      ...superOpts,
      readableObjectMode: false,
      writableObjectMode: false,
    });

    this.float_bytes = -1;
    this.separator = separator;
    this.stream_errors = stream_errors;
    this.parser = new Decoder({
      tags,
      max_depth,
      preferWeb,
      encoding,
    });
    this.parser.on('more-bytes', this._on_more.bind(this));
    this.parser.on('value', this._on_value.bind(this));
    this.parser.on('start', this._on_start.bind(this));
    this.parser.on('stop', this._on_stop.bind(this));
    this.parser.on('data', this._on_data.bind(this));
    this.parser.on('error', this._on_error.bind(this));
  }

  /**
   * Transforming.
   *
   * @param {any} fresh Buffer to transcode.
   * @param {BufferEncoding} encoding Name of encoding.
   * @param {stream.TransformCallback} cb Callback when done.
   * @ignore
   */
  _transform(fresh, encoding, cb) {
    this.parser.write(fresh, encoding, cb);
  }

  /**
   * Flushing.
   *
   * @param {stream.TransformCallback} cb Callback when done.
   * @ignore
   */
  _flush(cb) {
    this.parser._flush(er => {
      if (this.stream_errors) {
        if (er) {
          this._on_error(er);
        }
        return cb();
      }
      return cb(er);
    });
  }

  /**
   * Convenience function to return a string in diagnostic format.
   *
   * @param {BufferLike} input The CBOR bytes to format.
   * @param {DiagnoseOptions |diagnoseCallback|string} [options={}]
   *   Options, the callback, or the input encoding.
   * @param {diagnoseCallback} [cb] Callback.
   * @returns {Promise} If callback not specified.
   * @throws {TypeError} Input not provided.
   */
  static diagnose(input, options = {}, cb = null) {
    if (input == null) {
      throw new TypeError('input required');
    }
    ({options, cb} = normalizeOptions(options, cb));
    const {encoding = 'hex', ...opts} = options;

    const bs = new NoFilter();
    const d = new Diagnose(opts);
    let p = null;
    if (typeof cb === 'function') {
      d.on('end', () => cb(null, bs.toString('utf8')));
      d.on('error', cb);
    } else {
      p = new Promise((resolve, reject) => {
        d.on('end', () => resolve(bs.toString('utf8')));
        d.on('error', reject);
      });
    }
    d.pipe(bs);
    utils.guessEncoding(input, encoding).pipe(d);
    return p;
  }

  /**
   * @ignore
   */
  _on_error(er) {
    if (this.stream_errors) {
      this.push(er.toString());
    } else {
      this.emit('error', er);
    }
  }

  /** @private */
  _on_more(mt, len, _parent_mt, _pos) {
    if (mt === MT.SIMPLE_FLOAT) {
      this.float_bytes = {
        2: 1,
        4: 2,
        8: 3,
      }[len];
    }
  }

  /** @private */
  _fore(parent_mt, pos) {
    switch (parent_mt) {
      case MT.BYTE_STRING:
      case MT.UTF8_STRING:
      case MT.ARRAY:
        if (pos > 0) {
          this.push(', ');
        }
        break;
      case MT.MAP:
        if (pos > 0) {
          if (pos % 2) {
            this.push(': ');
          } else {
            this.push(', ');
          }
        }
    }
  }

  /** @private */
  _on_value(val, parent_mt, pos) {
    if (val === SYMS.BREAK) {
      return;
    }
    this._fore(parent_mt, pos);
    const fb = this.float_bytes;
    this.float_bytes = -1;
    this.push(utils.cborValueToString(val, fb));
  }

  /** @private */
  _on_start(mt, tag, parent_mt, pos) {
    this._fore(parent_mt, pos);
    switch (mt) {
      case MT.TAG:
        this.push(`${tag}(`);
        break;
      case MT.ARRAY:
        this.push('[');
        break;
      case MT.MAP:
        this.push('{');
        break;
      case MT.BYTE_STRING:
      case MT.UTF8_STRING:
        this.push('(');
        break;
    }
    if (tag === SYMS.STREAM) {
      this.push('_ ');
    }
  }

  /** @private */
  _on_stop(mt) {
    switch (mt) {
      case MT.TAG:
        this.push(')');
        break;
      case MT.ARRAY:
        this.push(']');
        break;
      case MT.MAP:
        this.push('}');
        break;
      case MT.BYTE_STRING:
      case MT.UTF8_STRING:
        this.push(')');
        break;
    }
  }

  /** @private */
  _on_data() {
    this.push(this.separator);
  }
}

module.exports = Diagnose;