Source: opts/argparser.js

/**
 * Parse the command line arguments.
 * @module jsdoc/opts/argparser
 * @author Michael Mathews <micmath@gmail.com>
 * @license Apache License 2.0 - See file 'LICENSE.md' in this project.
 */
'use strict';

var _ = require('underscore');
var util = require('util');

var hasOwnProp = Object.prototype.hasOwnProperty;

/**
 * Create an instance of the parser.
 * @classdesc A parser to interpret the key-value pairs entered on the command line.
 * @constructor
 * @alias module:jsdoc/opts/argparser
 */
var ArgParser = function() {
    this._options = [];
    this._shortNameIndex = {};
    this._longNameIndex = {};
};

ArgParser.prototype._getOptionByShortName = function(name) {
    if (hasOwnProp.call(this._shortNameIndex, name)) {
        return this._options[this._shortNameIndex[name]];
    }
    return null;
};

ArgParser.prototype._getOptionByLongName = function(name) {
    if (hasOwnProp.call(this._longNameIndex, name)) {
        return this._options[this._longNameIndex[name]];
    }
    return null;
};

ArgParser.prototype._addOption = function(option) {
    var currentIndex;

    var longName = option.longName;
    var shortName = option.shortName;

    this._options.push(option);
    currentIndex = this._options.length - 1;

    if (shortName) {
        this._shortNameIndex[shortName] = currentIndex;
    }
    if (longName) {
        this._longNameIndex[longName] = currentIndex;
    }

    return this;
};

/**
 * Provide information about a legal option.
 * @param {character} shortName The short name of the option, entered like: -T.
 * @param {string}    longName The equivalent long name of the option, entered like: --test.
 * @param {boolean}   hasValue Does this option require a value? Like: -t templatename
 * @param {string}    helpText A brief description of the option.
 * @param {boolean}   [canHaveMultiple=false] Set to `true` if the option can be provided more than once.
 * @param {function}  [coercer] A function to coerce the given value to a specific type.
 * @return {this}
 * @example
 * myParser.addOption('t', 'template', true, 'The path to the template.');
 * myParser.addOption('h', 'help', false, 'Show the help message.');
 */
ArgParser.prototype.addOption = function(shortName, longName, hasValue, helpText, canHaveMultiple, coercer) {
    var option = {
        shortName: shortName,
        longName: longName,
        hasValue: hasValue,
        helpText: helpText,
        canHaveMultiple: (canHaveMultiple || false),
        coercer: coercer
    };

    return this._addOption(option);
};

// TODO: refactor addOption to accept objects, then get rid of this method
/**
 * Provide information about an option that should not cause an error if present, but that is always
 * ignored (for example, an option that was used in previous versions but is no longer supported).
 *
 * @private
 * @param {string} shortName - The short name of the option with a leading hyphen (for example,
 * `-v`).
 * @param {string} longName - The long name of the option with two leading hyphens (for example,
 * `--version`).
 */
ArgParser.prototype.addIgnoredOption = function(shortName, longName) {
    var option = {
        shortName: shortName,
        longName: longName,
        ignore: true
    };

    return this._addOption(option);
};

function padding(length) {
    return new Array(length + 1).join(' ');
}

function padLeft(str, length) {
    return padding(length) + str;
}

function padRight(str, length) {
    return str + padding(length);
}

function findMaxLength(arr) {
    var max = 0;

    arr.forEach(function(item) {
        if (item.length > max) {
            max = item.length;
        }
    });

    return max;
}

function concatWithMaxLength(items, maxLength) {
    var result = '';
    // to prevent endless loops, always use the first item, regardless of length
    result += items.shift();

    while ( items.length && (result.length + items[0].length < maxLength) ) {
        result += ' ' + items.shift();
    }

    return result;
}

// we want to format names and descriptions like this:
// |    -f, --foo    Very long description very long description very long    |
// |                 description very long description.                       |
function formatHelpInfo(options) {
    var MARGIN_LENGTH = 4;
    var results = [];

    var maxLength = process.stdout.columns;
    var maxNameLength = findMaxLength(options.names);
    var maxDescriptionLength = findMaxLength(options.descriptions);

    var wrapDescriptionAt = maxLength - (MARGIN_LENGTH * 3) - maxNameLength;
    // build the string for each option
    options.names.forEach(function(name, i) {
        var result;
        var partialDescription;
        var words;

        // add a left margin to the name
        result = padLeft(options.names[i], MARGIN_LENGTH);
        // and a right margin, with extra padding so the descriptions line up with one another
        result = padRight(result, maxNameLength - options.names[i].length + MARGIN_LENGTH);

        // split the description on spaces
        words = options.descriptions[i].split(' ');
        // add as much of the description as we can fit on the first line
        result += concatWithMaxLength(words, wrapDescriptionAt);
        // if there's anything left, keep going until we've consumed the description
        while (words.length) {
            partialDescription = padding( maxNameLength + (MARGIN_LENGTH * 2) );
            partialDescription += concatWithMaxLength(words, wrapDescriptionAt);
            result += '\n' + partialDescription;
        }

        results.push(result);
    });

    return results;
}

/**
 * Generate a summary of all the options with corresponding help text.
 * @returns {string}
 */
ArgParser.prototype.help = function() {
    var options = {
        names: [],
        descriptions: []
    };

    this._options.forEach(function(option) {
        var name = '';

        // don't show ignored options
        if (option.ignore) {
            return;
        }

        if (option.shortName) {
            name += '-' + option.shortName + (option.longName ? ', ' : '');
        }

        if (option.longName) {
            name += '--' + option.longName;
        }

        if (option.hasValue) {
            name += ' <value>';
        }

        options.names.push(name);
        options.descriptions.push(option.helpText);
    });

    return 'Options:\n' + formatHelpInfo(options).join('\n');
};

/**
 * Get the options.
 * @param {Array.<string>} args An array, like ['-x', 'hello']
 * @param {Object} [defaults={}] An optional collection of default values.
 * @returns {Object} The keys will be the longNames, or the shortName if no longName is defined for
 * that option. The values will be the values provided, or `true` if the option accepts no value.
 */
ArgParser.prototype.parse = function(args, defaults) {
    var result = defaults && _.defaults({}, defaults) || {};

    result._ = [];
    for (var i = 0, leni = args.length; i < leni; i++) {
        var arg = '' + args[i],
            next = (i < leni - 1) ? '' + args[i + 1] : null,
            option,
            shortName = null,
            longName,
            name,
            value = null;

        // like -t
        if (arg.charAt(0) === '-') {
            // like --template
            if (arg.charAt(1) === '-') {
                name = longName = arg.slice(2);
                option = this._getOptionByLongName(longName);
            }
            else {
                name = shortName = arg.slice(1);
                option = this._getOptionByShortName(shortName);
            }

            if (option === null) {
                throw new Error( util.format('Unknown command-line option "%s".', name) );
            }

            if (option.hasValue) {
                value = next;
                i++;

                if (value === null || value.charAt(0) === '-') {
                    throw new Error( util.format('The command-line option "%s" requires a value.', name) );
                }
            }
            else {
                value = true;
            }

            // skip ignored options now that we've consumed the option text
            if (option.ignore) {
                continue;
            }

            if (option.longName && shortName) {
                name = option.longName;
            }

            if (typeof option.coercer === 'function') {
                value = option.coercer(value);
            }

            // Allow for multiple options of the same type to be present
            if (option.canHaveMultiple && hasOwnProp.call(result, name)) {
                var val = result[name];

                if (val instanceof Array) {
                    val.push(value);
                } else {
                    result[name] = [val, value];
                }
            }
            else {
                result[name] = value;
            }
        }
        else {
            result._.push(arg);
        }
    }

    return result;
};

module.exports = ArgParser;