Home Intro Source Mebo GitHub

src/Readers/CliArgs.js

const path = require('path');
const neodoc = require('neodoc');
const assert = require('assert');
const TypeCheck = require('js-typecheck');
const MeboErrors = require('../MeboErrors');
const Inputs = require('../Inputs');
const Settings = require('../Settings');
const Handler = require('../Handler');
const Reader = require('../Reader');


// symbols used for private members to avoid any potential clashing
// caused by re-implementations
const _args = Symbol('args');
const _executableNamePlaceHolder = Symbol('executableNamePlaceHolder');

/**
 * command-line arguments reader.
 *
 * This reader is used by the {@link Cli} handler. It supports most of
 * the docopt specification. Also, if the reader finds an error it's capable of
 * reporting it in user-friendly way. This is used to report `-h/--help` and
 * missing arguments.
 *
 * All serializable inputs are supported by this handler, they can be displayed
 * either as `argument` or `option` element. This is done by setting the input
 * property `elementType` (option is the default one).
 *
 * You can define the description displayed in the help of each input by
 * setting the input's property `description`. Also, the description for the action
 * itself can be defined by setting the action's metadata `description`.
 *
 * The `option` elements support `short option` by setting the input property
 * `shortOption`.
 *
 * In order to accommodate how vector values are represented in a command-line
 * interface, this reader expects vector elements to be separated by
 * the space character.
 *
 * Any {@link Bool} input specified as an `option` element behaves in a
 * special mode, since it's treated as a toogle option in command-line.
 * Therefore if the Bool input is assigned with a `true` then the option
 * gets the prefix `no-`.
 *
 * When a value is found for the input, it gets loaded via {@link Input.parseValue}
 * where each input implementation has its own way of parsing the serialized data,
 * to find out about how a value is serialized for an specific input type you could simply
 * set an arbitrary value to the input you are interested then query it back through
 * {@link Input.serializeValue}. Also, Mebo provides a reference datasheet
 * about the serialization forms for the inputs bundled with it, found at {@link Reader}.
 *
 * @see http://docopt.org
 */
class CliArgs extends Reader{

  /**
   * Creates an args reader
   *
   * @param {Action} action - action that should be used by the reader
   * @param {Array<string>} argv - list of arguments that should be used by
   * the reader
   */
  constructor(action, argv){
    super(action);

    this._setArgs(argv);
  }

  /**
   * Returns a list of args used by the reader, by default it uses
   * `process.argv`.
   *
   * @return {Array<string>}
   */
  args(){
    return this[_args];
  }

  /**
   * Returns the executable name based on the args
   * @param {string} placeHolder - when placeHolder is enabled it returns an uuid string
   * that can be used later to replace for the real executable name returned by default
   *
   * @return {string}
   */
  executableName(placeHolder=false){

    if (placeHolder){
      return this.constructor[_executableNamePlaceHolder];
    }

    let executableScript = this.args()[1];
    if (executableScript === process.cwd()){
      executableScript = '.';
    }

    const executableName = path.basename(this.args()[0]);
    const cliNames = Handler.get('cli').actionCommands(this.action().meta('action.name'));
    let cliSuffix = '';
    if (cliNames.length){
      cliSuffix = cliNames[0];
    }

    return `${executableName} ${executableScript} ${cliSuffix}`;
  }

  /**
   * Implements the reader
   *
   * @param {Array<Input>} inputList - Valid list of inputs that should be used for
   * the parsing
   * @return {Promise<Object>}
   * @protected
   */
  async _perform(inputList){
    const helpElements = await this.constructor._helpElements(inputList);
    const helpString = await this._renderHelp(helpElements);

    let parsedArgs = {};
    // it thrown an exception if something went wrong (like missing a required parameter)
    try{
      parsedArgs = neodoc.run(helpString, {
        argv: (this.args().includes('--help') || this.args().includes('-h')) ? ['-h'] : this.args().slice(2),
        dontExit: true,
        smartOptions: true,
        repeatableOptions: true,
        version: Settings.get('apiVersion'),
      });
    }
    catch(err){
      const error = new MeboErrors.Help(err.message);
      error.message = error.message.replace(new RegExp(this.executableName(true), 'g'), this.executableName());
      throw error;
    }

    // however when the user asks for the help it should raise an exception
    // without the stack
    if ('.help' in parsedArgs){
      const error = new MeboErrors.Help(parsedArgs['.help']);
      error.message = error.message.replace(new RegExp(this.executableName(true), 'g'), this.executableName());
      throw error;
    }

    for (const input of inputList){
      if (input instanceof Inputs.Bool && !input.isVector()){
        input.setValue(Boolean(input.value()));
      }
    }

    const alreadyParsed = [];
    const result = {};

    // collecting the input values
    for (const elementName in parsedArgs){
      let foundInputName;

      // finding the input name
      for (const elementType in helpElements){
        for (const inputName in helpElements[elementType]){
          const inputData = helpElements[elementType][inputName];
          if (inputData.usageDisplay.split('=')[0] === elementName || inputData.shortOption === elementName){
            foundInputName = inputName;
            break;
          }
        }

        if (foundInputName){
          break;
        }
      }

      // querying the input value
      const inputNames = inputList.map((x) => x.name());
      if (foundInputName && !alreadyParsed.includes(foundInputName)){
        alreadyParsed.push(foundInputName);

        const input = inputList[inputNames.indexOf(foundInputName)];

        let value;
        if (TypeCheck.isBool(parsedArgs[elementName]) && !input.isVector()){
          value = String(!input.value());
        }
        else{
          if (input.isVector() && !TypeCheck.isList(parsedArgs[elementName])){
            value = [parsedArgs[elementName]];
          }
          else{
            value = parsedArgs[elementName];
          }
        }
        result[foundInputName] = value;
      }
    }

    return result;
  }

  /**
   * Computes the contents displayed as help
   *
   * @param {Object} elements - object generated by the method _helpElements
   * @return {Promise<string>}
   * @protected
   */
  _renderHelp(elements){
    let output = '';
    output += this._buildDescription(elements);
    output += this._buildUsage(elements);
    output += this.constructor._buildColumns(elements);

    return output;
  }

  /**
   * Sets a list of argument values used by the reader. It must follow
   * the same pattern found at `process.argv`
   *
   * @param {Array<string>} value - argument list
   * @private
   */
  _setArgs(value){
    assert(TypeCheck.isList(value), 'value needs to be a list');
    assert(value.length >= 2, 'missing first argument process.execPath and second argument java-script file being executed');

    this[_args] = value.slice(0);
  }

  /**
   * Returns an object containing the elements that can be used by the command
   *
   * @param {Array<Input>} inputList - list of input that should be used to build
   * query the help
   * @return {Object}
   * @private
   */
  static async _helpElements(inputList){

    const elements = {
      argument: {},
      option: {},
    };

    // building inputs
    const addedArgs = [];
    const descriptions = await Promise.all(inputList.map((x) => this._computeInfoDisplay(x)));

    let currentIndex = 0;
    for (const input of inputList){

      const inputName = input.name();
      let argName = this._camelCaseToArgument(inputName);

      // in case of a boolean input that is true by default adding
      // the `no` prefix to the input name automatically. For boolean inputs they
      // work as toggles when represented through the command line
      if (input instanceof Inputs.Bool && !input.isVector() && input.value()){
        argName = `no-${argName}`;
      }

      assert(!addedArgs.includes(argName), `Ambiguous argument name (${argName}), used multiple times!`);
      addedArgs.push(argName);

      const elementType = input.property('elementType');

      const inputData = {};
      inputData.description = descriptions[currentIndex];
      inputData.elementDisplay = this._elementDisplay(argName, input);
      inputData.usageDisplay = this._usageDisplay(argName, input);
      inputData.required = ((input.isRequired() && input.isEmpty()) && !(input instanceof Inputs.Bool && !input.isVector()));
      inputData.vector = input.isVector();

      if (elementType === 'option'){
        inputData.shortOptionDisplay = this._shortOptionDisplay(input);
        inputData.shortOption = this._shortOption(input);
      }

      elements[elementType][inputName] = inputData;

      currentIndex++;
    }

    return elements;
  }

  /**
   * Returns a string containing the full assembled info for the input
   *
   * @param {Input} input - input that should be used
   * @return {Promise<string>}
   * @private
   */
  static async _computeInfoDisplay(input){

    const inputTypeName = input.property('type');

    // adding the value type to the argument
    const isBoolInput = input instanceof Inputs.Bool;
    let description = input.property('description') || '';

    // querying any type hint defined for the input
    if (input.hasProperty('cliTypeHint')){
      if (description.length){
        description += ' ';
      }

      description += `(* ${input.property('cliTypeHint')})`;
    }

    if ((isBoolInput && input.isVector()) || !isBoolInput){

      // adding the default value as part of the description
      if (!input.isEmpty()){
        let serializedValue = await input.serializeValue();
        serializedValue = (input.isVector()) ? JSON.parse(serializedValue) : [serializedValue];
        const defaultValue = [];

        for (const value of serializedValue){

          if (TypeCheck.isString(value) && Number.isNaN(Number(value))){
            const scapedValue = value.replace(new RegExp('"', 'g'), '\\"');
            defaultValue.push(`"${scapedValue}"`);
          }
          else{
            defaultValue.push(value);
          }
        }

        if (description.length){
          description += ' ';
        }
        description += `[default: ${defaultValue.join(' ')}]`;
      }
    }

    const inputTypeDisplay = input.isVector() ? `${inputTypeName}[]` : inputTypeName;

    if (description.length){
      description += ' ';
    }
    description += `(${inputTypeDisplay} type).`;

    return description;
  }

  /**
   * Returns a string containing the full element display for either an option
   * or argument
   *
   * @param {string} name - element given name
   * @param {Input} input - input that should be used
   * @return {string}
   * @private
   */
  static _elementDisplay(name, input){

    let result = '';

    if (input.property('elementType') === 'option'){
      const shortOption = this._shortOptionDisplay(input);

      const isBoolInput = input instanceof Inputs.Bool;
      if ((isBoolInput && input.isVector()) || !isBoolInput){

        // adding short option
        if (shortOption){
          result += shortOption;

          if (input.isVector()){
            result += '...';
          }

          result += ', ';
        }

        result += this._usageDisplay(name, input);

        if (input.isVector()){
          result += '...';
        }
      }
      else{
        if (shortOption){
          result += shortOption;
          result += ', ';
        }

        result += this._usageDisplay(name, input);
      }
    }
    else{
      result = name;
    }

    return result;
  }

  /**
   * Returns a string containing the usage display for either
   * the option or argument
   *
   * @param {string} name - how the element should be called
   * @param {Input} input - input that should be used
   * @return {string}
   * @private
   */
  static _usageDisplay(name, input){
    let result = '';

    if (input.property('elementType') === 'option'){
      // adding long option
      result = `--${name}`;

      const isBoolInput = input instanceof Inputs.Bool;
      if ((isBoolInput && input.isVector()) || !isBoolInput){
        result = `${result}=<value>`;
      }
    }
    else{
      result = `<${name}>`;
    }

    return result;
  }

  /**
   * Returns a string containing the the short option, in case the input
   * does not have a short option property defined then an empty string
   * is returned instead
   *
   * @param {Input} input - input that should be used
   * @return {string}
   * @private
   */
  static _shortOption(input){
    const shortOption = input.property('shortOption');
    if (shortOption){
      return `-${shortOption}`;
    }

    return '';
  }

  /**
   * Returns a string containing the display of the short option,
   * This is used when listing the element options
   *
   * @param {Input} input - input that should be used
   * @return {string}
   * @private
   */
  static _shortOptionDisplay(input){
    let result = this._shortOption(input);
    if (result.length && !(input instanceof Inputs.Bool && !input.isVector())){
      result = `${result}=<value>`;
    }

    return result;
  }

  /**
   * Returns a string containing the description of the command
   *
   * @param {Object} elements - elements holder object
   * @return {string}
   * @private
   */
  _buildDescription(elements){
    let output = '';
    const description = this.action().meta('description', '');
    if (description.length){
      output += description;
      if (!description.endsWith('.')){
        output += '.';
      }
      output += '\n\n';
    }

    return output;
  }

  /**
   * Builds a string containing the usage
   *
   * @param {Object} elements - elements holder object
   * @return {string}
   * @private
   */
  _buildUsage(elements){
    let output = `Usage: ${this.executableName(true)} `;

    const requiredArguments = {};
    const optionalArguments = {};
    const requiredOptions = Object.keys(elements.option).filter((x) => elements.option[x].required);
    let requiredArgumentsOrder = [];
    let optionalArgumentsOrder = [];

    if (requiredOptions.length){
      output += requiredOptions.map((x) => elements.option[x].usageDisplay).join(' ');
      output += ' ';
    }
    output += '[options]';

    // building arguments
    if (Object.keys(elements.argument).length){
      for (const inputName in elements.argument){
        if (elements.argument[inputName].required){
          requiredArguments[inputName] = elements.argument[inputName];
        }
        else{
          optionalArguments[inputName] = elements.argument[inputName];
        }
      }

      const requiredArgumentNames = Object.keys(requiredArguments);
      requiredArgumentsOrder = requiredArgumentNames.filter((x) => !requiredArguments[x].vector);
      requiredArgumentsOrder = requiredArgumentsOrder.concat(requiredArgumentNames.filter((x) => !requiredArgumentsOrder.includes(x)));

      // first adding the required arguments
      let hasVectorRequiredArgument = false;
      for (const inputName of requiredArgumentsOrder){
        output += ' ';
        output += requiredArguments[inputName].usageDisplay;

        if (requiredArguments[inputName].vector && Object.keys(optionalArguments).length === 0){
          if (requiredArgumentsOrder.indexOf(inputName) === requiredArgumentsOrder.length - 1){
            output += '...';
            hasVectorRequiredArgument = true;
          }
        }
      }

      // then adding the optional ones
      const optionalArgumentNames = Object.keys(optionalArguments);
      optionalArgumentsOrder = optionalArgumentNames.filter((x) => !optionalArguments[x].vector);
      optionalArgumentsOrder = optionalArgumentsOrder.concat(optionalArgumentNames.filter((x) => !optionalArgumentsOrder.includes(x)));

      for (const inputName in optionalArguments){
        output += ' [';
        output += optionalArguments[inputName].usageDisplay;
        output += ']';

        if (optionalArguments[inputName].vector && !hasVectorRequiredArgument){
          if (optionalArgumentsOrder.indexOf(inputName) === optionalArgumentsOrder.length - 1){
            output += '...';
          }
        }
      }
    }

    output += this._buildUsageVectorOptions(elements, requiredArgumentsOrder, requiredOptions);

    return output;
  }

  /**
   * Builds a string containing the usage for the vector options
   *
   * @param {Object} elements - elements holder object
   * @param {Array<string>} argumentNames - list of argument names
   * @param {Array<string>} requiredOptionNames - list of required option names
   * @return {string}
   * @private
   */
  _buildUsageVectorOptions(elements, argumentNames, requiredOptionNames){
    let output = '';

    // adding the usage for the vector options
    for (const inputName in elements.option){

      if (elements.option[inputName].vector){
        output += `\n       ${this.executableName(true)} `;

        for (const requiredArg of argumentNames){
          output += elements.argument[requiredArg].usageDisplay;
          output += ' ';
        }

        output += '[options] ';
        if (requiredOptionNames.length){
          output += requiredOptionNames.filter((y) => y !== inputName).map((x) => elements.option[x].usageDisplay).join(' ');
          output += ' ';
        }

        if (!requiredOptionNames.includes(inputName)){
          output += '[';
        }

        output += elements.option[inputName].usageDisplay;
        output += '...';

        if (!requiredOptionNames.includes(inputName)){
          output += ']';
        }
      }
    }

    return output;
  }

  /**
   * Builds a string containing the columns displayed by the arguments and options
   *
   * @param {Object} elements - elements holder object
   * @return {string}
   * @private
   */
  static _buildColumns(elements){
    let columns = '\n';
    const elementTypeDisplayName = {};
    elementTypeDisplayName.option = 'Options:';
    elementTypeDisplayName.argument = 'Arguments:';

    // figuring out the element column width
    const elementTypeWidth = this._computeElementsWidth(elements);

    for (const element in elements){

      if (Object.keys(elements[element]).length){
        columns += '\n';
        columns += elementTypeDisplayName[element];
        columns += '\n';

        for (const inputName in elements[element]){

          const elementData = elements[element][inputName];

          // element
          columns += '  ';
          columns += elementData.elementDisplay;
          columns += ' '.repeat(elementTypeWidth[element] - elementData.elementDisplay.length);

          // description
          // the second separator is actually a `hair space` char, this is necessary to separate
          // the element from the description in neodoc
          columns += '  ';
          columns += elementData.description;
          columns += '\n';
        }
      }
    }

    return columns;
  }

  /**
   * Returns a plain object containing the width for each of the element types
   * (argument and option)
   *
   * @param {Object} elements - elements holder object
   * @return {Object}
   * @private
   */
  static _computeElementsWidth(elements){
    const elementTypeWidth = {};
    for (const elementType in elements){
      for (const inputName in elements[elementType]){
        elementTypeWidth[elementType] = Math.max(elementTypeWidth[elementType] || 0,
          elements[elementType][inputName].elementDisplay.length);
      }
    }

    return elementTypeWidth;
  }

  /**
   * Converts the input text from camelCase to dash-convention used
   * in CLI applications
   *
   * @param {string} text - text that should be converted
   * @return {string}
   * @private
   */
  static _camelCaseToArgument(text){
    return text.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
  }
}

CliArgs[_executableNamePlaceHolder] = 'f4d33b27-d6f3-42b6-ba98-5254bdf3b307';


// registering reader
Handler.registerReader(CliArgs, 'cli');

module.exports = CliArgs;