subcommander
Version:
Command-line argument parser with sub-command support
527 lines (439 loc) • 14 kB
JavaScript
'use strict';
var chalk = require('chalk'),
optionPattern = /^(--([\w-]+)|-(\w+))(=(.*))?/,
helpPattern = /(\s|^)(-h|--help)(\s|$)/,
valuePattern = /^[^-].*/,
useColors = true;
/* =============================================================================================
PUBLIC APIs
============================================================================================= */
/**
* Constructor for an option.
* @param {String} name Option's name
* @param {Object} props Option's properties
* @param {String} [prop.abbr] Option's abbreviation
* @param {String} [prop.desc] Option's description
* @param {String} [prop.valueName] Name of the option's value displayed in the usage message
* @param {Boolean} [prop.flag] Define if option is a flag
* @param {*} [prop.default] Default value for the option
* @constructor
*/
function Option(name, props) {
this.name = name;
this.abbr = props.abbr;
this.valueName = props.valueName;
this.desc = props.desc || '';
this.flag = props.flag || false;
this.defaultValue = props.default;
}
/**
* Return pre-formatted usage information.
* @return {Object}
*/
Option.prototype.getUsage = function() {
var string = [];
/* istanbul ignore else */
if (this.abbr) {
string.push('-' + this.abbr);
if (!this.flag && this.valueName) {
string.push(this.valueName);
}
string[string.length - 1] += ',';
}
string.push('--' + this.name);
if (!this.flag && this.valueName) {
string.push(this.valueName);
}
return {
name: string.join(' '),
desc: this.desc + (this.defaultValue !== undefined ? (this.desc ? ' ' : '') + '[' + this.defaultValue + ']' : '')
};
};
/**
* Constructor for a command.
* @param {String} name Command's name
* @param {Object} props Command's properties
* @param {String} [props.desc] Command's description
* @param {String} [props.callback] Callback function for the command
* @constructor
*/
function Command(name, props) {
this.script = null;
this.options = {};
this.commands = {};
this.parsed = {};
this.args = [];
this.parent = null;
this.name = name;
this.desc = props && props.desc || '';
this.callback = props && props.callback;
}
/**
* Add a new option to the current command or the CLI's root. Following option formats are recognized:
* - `-f [value]`
* - `--foo [value]`
* - `-f=value`
* - `--foo=value`
* @param {String} name Option's name
* @param {Object} props Option's properties
* @param {String} [prop.abbr] Option's abbreviation
* @param {String} [prop.desc] Option's description
* @param {String} [prop.valueName] Name of the option's value displayed in the usage message
* @param {Boolean} [prop.flag] Define if option is a flag
* @param {*} [prop.default] Default value for the option
* @return {Command}
*/
Command.prototype.option = function(name, props) {
var option = new Option(name, props);
this.options[name] = option;
return this;
};
/**
* Add a new (sub-)command to the current command or CLI's root.
* @param {String} name Command's name
* @param {Object} props Command's properties
* @param {String} [props.desc] Command's description
* @param {String} [props.callback] Command's callback function executed if the command is run
* @return {Command} (Sub-)command instance
*/
Command.prototype.command = function(name, props) {
var command = new Command(name, props);
command.parent = this;
this.commands[name] = command;
return command;
};
/**
* Set the name of the script's executable.
* @param {String} name Name of the executable
* @return {Command}
*/
Command.prototype.scriptName = function(name) {
this.script = name;
return this;
};
/**
* Disable coloring in usage and error messages
* @return {Command}
*/
Command.prototype.noColors = function() {
useColors = false;
return this;
};
/**
* Parse the command line arguments
* @param {Array.<String>} [argv] Array of arguments
* @return {Object} List of parsed arguments
*/
Command.prototype.parse = function(argv) {
var expectCommand = Object.keys(this.commands).length > 0,
command,
parsed;
/* istanbul ignore next */
argv = argv || process.argv.slice(2);
// contains sub-commands
if (expectCommand) {
// no argument or just an option
if (!argv[0] || argv[0].charAt(0) === '-') {
// show usage information
if (helpPattern.test(argv.join(' '))) {
return this.usage();
}
// call the callback with parsed arguments
if (typeof this.callback == 'function') {
this._parseArgv(argv);
parsed = this._getParsed();
this.callback(parsed);
return parsed;
}
return this._printError('Missing command for "' + this._getScriptName() + '".');
}
// handle a command
if ((command = this.commands[argv[0]])) {
return command.parse.call(command, argv.slice(1));
}
return this._printError('Unknown command "' + argv[0] + '".');
}
this._parseArgv(argv);
parsed = this._getParsed();
if (typeof this.callback == 'function') {
this.callback(parsed);
}
return parsed;
};
/**
* End modifying current command and return to the parent
* @return {Command} Parent command (if any)
*/
Command.prototype.end = function() {
return this.parent || this;
};
/**
* Print command's usage message on the STDOUT.
*/
Command.prototype.usage = function() {
var commandNames,
optionNames,
longest,
options;
function spaces(num) {
return new Array(num + 3).join(' ');
}
commandNames = Object.keys(this.commands);
options = this._getOptions();
optionNames = Object.keys(options);
// print usage line
write(chalk.bold('\nUsage:'));
write(' ' + this._getCommandChain().join(' '));
if (commandNames.length) {
write(yellow(' <command>'));
}
if (optionNames.length) {
write(cyan(' [options]'));
}
write('\n');
// print commands list
if (commandNames.length) {
longest = commandNames.reduce(function(a, b) {
return a.length > b.length ? a : b;
}).length;
writeLine(chalk.bold(yellow('Commands:\n')));
commandNames.sort().forEach(function(name) {
var command = this.commands[name];
write(' ' + command.name);
write(spaces(longest - command.name.length));
write(grey(command.desc) + '\n');
}, this);
}
// print options list
if (optionNames.length) {
optionNames.forEach(function(name) {
options[name] = options[name].getUsage();
});
longest = optionNames.reduce(function(a, b) {
return options[a].name.length > options[b].name.length ? a : b;
});
longest = options[longest].name.length;
writeLine(chalk.bold(cyan('Options:\n')));
optionNames.sort().forEach(function(name) {
var option = options[name];
write(' ' + option.name);
write(spaces(longest - option.name.length));
write(grey(option.desc) + '\n');
});
}
write('\n');
};
/**
* Resets all properties of the command
* @return {Command}
*/
Command.prototype.reset = function() {
this.script = null;
this.options = {};
this.commands = {};
this.parsed = {};
this.args = [];
this.parent = null;
return this;
};
/* =============================================================================================
PRIVATE APIs
============================================================================================= */
/**
* Get an option an it's value based on a given argument.
* @param {String} arg Command line argument
* @return {Object}
* @private
*/
Command.prototype._getOption = function(arg) {
var options = this._getOptions(),
option,
match,
value,
name,
o;
// matches --foo, -f and --foo=<value>
if ((match = optionPattern.exec(arg))) {
name = match[2] || match[3];
value = match[5];
// handle -h / --help and stop execution
if (name === 'help' || name === 'h') {
this.usage();
process.exit(0);
}
for (o in options) {
if ((option = options[o]) !== undefined &&
name === o || name === option.abbr) {
return {
name: option.name,
defaultValue: option.defaultValue,
value: option.flag || value
};
}
}
return {
name: name,
value: value,
unknown: true
};
}
return null;
};
/**
* Write an error message to the STDOUT.
* @param {String} message Error message to print
* @private
*/
Command.prototype._printError = function(message) {
writeLine(red(chalk.bold('Error: ') + message));
this.usage();
process.exit(1);
};
/**
* Get the name of the script/command.
* @return {String}
* @private
*/
Command.prototype._getScriptName = function() {
return this.name || this.script;
};
/**
* Get all available options, merge parent's options if any.
* @return {Object}
* @private
*/
Command.prototype._getOptions = function() {
var result = {},
parents,
name;
for (name in this.options) {
/* istanbul ignore else */
if (this.options[name] !== undefined) {
result[name] = this.options[name];
}
}
if (this.parent) {
parents = this.parent._getOptions();
for (name in parents) {
/* istanbul ignore else */
if (parents[name] !== undefined) {
result[name] = parents[name];
}
}
}
return result;
};
/**
* Get all parsed options and arguments.
* @return {Object}
* @private
*/
Command.prototype._getParsed = function() {
var result = {},
parents,
item,
name;
for (name in this.parsed) {
/* istanbul ignore else */
if ((item = this.parsed[name]) !== undefined) {
result[name] = item;
}
}
for (name in this.options) {
/* istanbul ignore else */
if ((item = this.options[name]) !== undefined &&
!result[name] &&
item.defaultValue !== undefined) {
result[name] = item.defaultValue;
}
}
if (this.parent) {
parents = this.parent._getParsed();
for (name in parents) {
/* istanbul ignore else */
if ((item = parents[name]) !== undefined && !result[name]) {
result[name] = item;
}
}
}
/* istanbul ignore else */
if (this.args) {
this.args.forEach(function(arg, index) {
result[index] = arg;
});
}
return result;
};
/**
* Parse given arguments as options/values
* @param {Array.<String>} argv Array of arguments
* @private
*/
Command.prototype._parseArgv = function(argv) {
var option,
arg,
i;
for (i = 0; i < argv.length; i++) {
arg = argv[i];
// it's an option
if ((option = this._getOption(arg))) {
if (!option.value) {
// take the value from the next argument
if (argv[i + 1] && valuePattern.test(argv[i + 1])) {
option.value = argv[i + 1];
i++;
} else if (option.unknown) {
option.value = true;
} else {
this._printError('Missing value for "' + option.name + '" option.');
}
}
this.parsed[option.name] = option.value;
} else {
this.args.push(arg);
}
}
};
/**
* Return the script - command - sub-command(s) chain of the current command
* @return {Array.<String>}
* @private
*/
Command.prototype._getCommandChain = function() {
var result = [this.script || this.name];
return this.parent ? this.parent._getCommandChain().concat(result) : result;
};
/**
* Write a text to the STDOUT without adding new line character.
* @param {String} text Text to write
* @private
*/
/* istanbul ignore next */
function write(text) {
process.stdout.write(text);
}
/**
* Write a text to the STDOUT in a new line.
* @param {String} text Text to write
* @private
*/
/* istanbul ignore next */
function writeLine(text) {
process.stdout.write('\n' + text + '\n');
}
function red(text) {
return useColors ? chalk.red(text) : text;
}
function yellow(text) {
return useColors ? chalk.yellow(text) : text;
}
function cyan(text) {
return useColors ? chalk.cyan(text) : text;
}
function grey(text) {
return useColors ? chalk.grey(text) : text;
}
module.exports = new Command();
module.exports.script = require('path').basename(process.argv[1]);
module.exports.Option = Option;
module.exports.Command = Command;