You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

951 lines
22 KiB

var MAX_LINE_WIDTH = process.stdout.columns || 200;
var MIN_OFFSET = 25;
var errorHandler;
var commandsPath;
var reAstral = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g;
var ansiRegex = /\x1B\[([0-9]{1,3}(;[0-9]{1,3})*)?[m|K]/g;
var hasOwnProperty = Object.prototype.hasOwnProperty;
function stringLength(str){
return str
.replace(ansiRegex, '')
.replace(reAstral, ' ')
.length;
}
function camelize(name){
return name.replace(/-(.)/g, function(m, ch){
return ch.toUpperCase();
});
}
function assign(dest, source){
for (var key in source)
if (hasOwnProperty.call(source, key))
dest[key] = source[key];
return dest;
}
function returnFirstArg(value){
return value;
}
function pad(width, str){
return str + Array(Math.max(0, width - stringLength(str)) + 1).join(' ');
}
function noop(){
// nothing todo
}
function parseParams(str){
// params [..<required>] [..[optional]]
// <foo> - require
// [foo] - optional
var tmp;
var left = str.trim();
var result = {
minArgsCount: 0,
maxArgsCount: 0,
args: []
};
do {
tmp = left;
left = left.replace(/^<([a-zA-Z][a-zA-Z0-9\-\_]*)>\s*/, function(m, name){
result.args.push(new Argument(name, true));
result.minArgsCount++;
result.maxArgsCount++;
return '';
});
}
while (tmp != left);
do {
tmp = left;
left = left.replace(/^\[([a-zA-Z][a-zA-Z0-9\-\_]*)\]\s*/, function(m, name){
result.args.push(new Argument(name, false));
result.maxArgsCount++;
return '';
});
}
while (tmp != left);
if (left)
throw new SyntaxError('Bad parameter description: ' + str);
return result.args.length ? result : false;
}
/**
* @class
*/
var SyntaxError = function(message){
this.message = message;
};
SyntaxError.prototype = Object.create(Error.prototype);
SyntaxError.prototype.name = 'SyntaxError';
SyntaxError.prototype.clap = true;
/**
* @class
*/
var Argument = function(name, required){
this.name = name;
this.required = required;
};
Argument.prototype = {
required: false,
name: '',
normalize: returnFirstArg,
suggest: function(){
return [];
}
};
/**
* @class
* @param {string} usage
* @param {string} description
*/
var Option = function(usage, description){
var self = this;
var params;
var left = usage.trim()
// short usage
// -x
.replace(/^-([a-zA-Z])(?:\s*,\s*|\s+)/, function(m, name){
self.short = name;
return '';
})
// long usage
// --flag
// --no-flag - invert value if flag is boolean
.replace(/^--([a-zA-Z][a-zA-Z0-9\-\_]+)\s*/, function(m, name){
self.long = name;
self.name = name.replace(/(^|-)no-/, '$1');
self.defValue = self.name != self.long;
return '';
});
if (!this.long)
throw new SyntaxError('Usage has no long name: ' + usage);
try {
params = parseParams(left);
} catch(e) {
throw new SyntaxError('Bad paramenter description in usage for option: ' + usage, e);
}
if (params)
{
left = '';
this.name = this.long;
this.defValue = undefined;
assign(this, params);
}
if (left)
throw new SyntaxError('Bad usage description for option: ' + usage);
if (!this.name)
this.name = this.long;
this.description = description || '';
this.usage = usage.trim();
this.camelName = camelize(this.name);
};
Option.prototype = {
name: '',
description: '',
short: '',
long: '',
beforeInit: false,
required: false,
minArgsCount: 0,
maxArgsCount: 0,
args: null,
defValue: undefined,
normalize: returnFirstArg
};
//
// Command
//
function createOption(usage, description, opt_1, opt_2){
var option = new Option(usage, description);
// if (option.bool && arguments.length > 2)
// throw new SyntaxError('bool flags can\'t has default value or validator');
if (arguments.length == 3)
{
if (opt_1 && opt_1.constructor === Object)
{
for (var key in opt_1)
if (key == 'normalize' ||
key == 'defValue' ||
key == 'beforeInit')
option[key] = opt_1[key];
// old name for `beforeInit` setting is `hot`
if (opt_1.hot)
option.beforeInit = true;
}
else
{
if (typeof opt_1 == 'function')
option.normalize = opt_1;
else
option.defValue = opt_1;
}
}
if (arguments.length == 4)
{
if (typeof opt_1 == 'function')
option.normalize = opt_1;
option.defValue = opt_2;
}
return option;
}
function addOptionToCommand(command, option){
var commandOption;
// short
if (option.short)
{
commandOption = command.short[option.short];
if (commandOption)
throw new SyntaxError('Short option name -' + option.short + ' already in use by ' + commandOption.usage + ' ' + commandOption.description);
command.short[option.short] = option;
}
// long
commandOption = command.long[option.long];
if (commandOption)
throw new SyntaxError('Long option --' + option.long + ' already in use by ' + commandOption.usage + ' ' + commandOption.description);
command.long[option.long] = option;
// camel
commandOption = command.options[option.camelName];
if (commandOption)
throw new SyntaxError('Name option ' + option.camelName + ' already in use by ' + commandOption.usage + ' ' + commandOption.description);
command.options[option.camelName] = option;
// set default value
if (typeof option.defValue != 'undefined')
command.setOption(option.camelName, option.defValue, true);
// add to suggestions
command.suggestions.push('--' + option.long);
return option;
}
function findVariants(obj, entry){
return obj.suggestions.filter(function(item){
return item.substr(0, entry.length) == entry;
});
}
function processArgs(command, args, suggest){
function processOption(option, command){
var params = [];
if (option.maxArgsCount)
{
for (var j = 0; j < option.maxArgsCount; j++)
{
var suggestPoint = suggest && i + 1 + j >= args.length - 1;
var nextToken = args[i + 1];
// TODO: suggestions for options
if (suggestPoint)
{
// search for suggest
noSuggestions = true;
i = args.length;
return;
}
if (!nextToken || nextToken[0] == '-')
break;
params.push(args[++i]);
}
if (params.length < option.minArgsCount)
throw new SyntaxError('Option ' + token + ' should be used with at least ' + option.minArgsCount + ' argument(s)\nUsage: ' + option.usage);
if (option.maxArgsCount == 1)
params = params[0];
}
else
{
params = !option.defValue;
}
//command.values[option.camelName] = newValue;
resultToken.options.push({
option: option,
value: params
});
}
var resultToken = {
command: command,
args: [],
literalArgs: [],
options: []
};
var result = [resultToken];
var suggestStartsWith = '';
var noSuggestions = false;
var collectArgs = false;
var commandArgs = [];
var noOptionsYet = true;
var option;
commandsPath = [command.name];
for (var i = 0; i < args.length; i++)
{
var suggestPoint = suggest && i == args.length - 1;
var token = args[i];
if (collectArgs)
{
commandArgs.push(token);
continue;
}
if (suggestPoint && (token == '--' || token == '-' || token[0] != '-'))
{
suggestStartsWith = token;
break; // returns long option & command list outside the loop
}
if (token == '--')
{
resultToken.args = commandArgs;
commandArgs = [];
noOptionsYet = false;
collectArgs = true;
continue;
}
if (token[0] == '-')
{
noOptionsYet = false;
if (commandArgs.length)
{
//command.args_.apply(command, commandArgs);
resultToken.args = commandArgs;
commandArgs = [];
}
if (token[1] == '-')
{
// long option
option = command.long[token.substr(2)];
if (!option)
{
// option doesn't exist
if (suggestPoint)
return findVariants(command, token);
else
throw new SyntaxError('Unknown option: ' + token);
}
// process option
processOption(option, command);
}
else
{
// short flags sequence
if (!/^-[a-zA-Z]+$/.test(token))
throw new SyntaxError('Wrong short option sequence: ' + token);
if (token.length == 2)
{
option = command.short[token[1]];
if (!option)
throw new SyntaxError('Unknown short option name: -' + token[1]);
// single option
processOption(option, command);
}
else
{
// short options sequence
for (var j = 1; j < token.length; j++)
{
option = command.short[token[j]];
if (!option)
throw new SyntaxError('Unknown short option name: -' + token[j]);
if (option.maxArgsCount)
throw new SyntaxError('Non-boolean option -' + token[j] + ' can\'t be used in short option sequence: ' + token);
processOption(option, command);
}
}
}
}
else
{
if (command.commands[token] && (!command.params || commandArgs.length >= command.params.minArgsCount))
{
if (noOptionsYet)
{
resultToken.args = commandArgs;
commandArgs = [];
}
if (command.params && resultToken.args.length < command.params.minArgsCount)
throw new SyntaxError('Missed required argument(s) for command `' + command.name + '`');
// switch control to another command
command = command.commands[token];
noOptionsYet = true;
commandsPath.push(command.name);
resultToken = {
command: command,
args: [],
literalArgs: [],
options: []
};
result.push(resultToken);
}
else
{
if (noOptionsYet && command.params && commandArgs.length < command.params.maxArgsCount)
{
commandArgs.push(token);
continue;
}
if (suggestPoint)
return findVariants(command, token);
else
throw new SyntaxError('Unknown command: ' + token);
}
}
}
if (suggest)
{
if (collectArgs || noSuggestions)
return [];
return findVariants(command, suggestStartsWith);
}
else
{
if (!noOptionsYet)
resultToken.literalArgs = commandArgs;
else
resultToken.args = commandArgs;
if (command.params && resultToken.args.length < command.params.minArgsCount)
throw new SyntaxError('Missed required argument(s) for command `' + command.name + '`');
}
return result;
}
function setFunctionFactory(name){
return function(fn){
var property = name + '_';
if (this[property] !== noop)
throw new SyntaxError('Method `' + name + '` could be invoked only once');
if (typeof fn != 'function')
throw new SyntaxError('Value for `' + name + '` method should be a function');
this[property] = fn;
return this;
}
}
/**
* @class
*/
var Command = function(name, params){
this.name = name;
this.params = false;
try {
if (params)
this.params = parseParams(params);
} catch(e) {
throw new SyntaxError('Bad paramenter description in command definition: ' + this.name + ' ' + params);
}
this.commands = {};
this.options = {};
this.short = {};
this.long = {};
this.values = {};
this.defaults_ = {};
this.suggestions = [];
this.option('-h, --help', 'Output usage information', function(){
this.showHelp();
process.exit(0);
}, undefined);
};
Command.prototype = {
params: null,
commands: null,
options: null,
short: null,
long: null,
values: null,
defaults_: null,
suggestions: null,
description_: '',
version_: '',
initContext_: noop,
init_: noop,
delegate_: noop,
action_: noop,
args_: noop,
end_: null,
option: function(usage, description, opt_1, opt_2){
addOptionToCommand(this, createOption.apply(null, arguments));
return this;
},
shortcut: function(usage, description, fn, opt_1, opt_2){
if (typeof fn != 'function')
throw new SyntaxError('fn should be a function');
var command = this;
var option = addOptionToCommand(this, createOption(usage, description, opt_1, opt_2));
var normalize = option.normalize;
option.normalize = function(value){
var values;
value = normalize.call(command, value);
values = fn(value);
for (var name in values)
if (hasOwnProperty.call(values, name))
if (hasOwnProperty.call(command.options, name))
command.setOption(name, values[name]);
else
command.values[name] = values[name];
command.values[option.name] = value;
return value;
};
return this;
},
hasOption: function(name){
return hasOwnProperty.call(this.options, name);
},
hasOptions: function(){
return Object.keys(this.options).length > 0;
},
setOption: function(name, value, isDefault){
if (!this.hasOption(name))
throw new SyntaxError('Option `' + name + '` is not defined');
var option = this.options[name];
var oldValue = this.values[name];
var newValue = option.normalize.call(this, value, oldValue);
this.values[name] = option.maxArgsCount ? newValue : value;
if (isDefault && !hasOwnProperty.call(this.defaults_, name))
this.defaults_[name] = this.values[name];
},
setOptions: function(values){
for (var name in values)
if (hasOwnProperty.call(values, name) && this.hasOption(name))
this.setOption(name, values[name]);
},
reset: function(){
this.values = {};
assign(this.values, this.defaults_);
},
command: function(nameOrCommand, params){
var name;
var command;
if (nameOrCommand instanceof Command)
{
command = nameOrCommand;
name = command.name;
}
else
{
name = nameOrCommand;
if (!/^[a-zA-Z][a-zA-Z0-9\-\_]*$/.test(name))
throw new SyntaxError('Wrong command name: ' + name);
}
// search for existing one
var subcommand = this.commands[name];
if (!subcommand)
{
// create new one if not exists
subcommand = command || new Command(name, params);
subcommand.end_ = this;
this.commands[name] = subcommand;
this.suggestions.push(name);
}
return subcommand;
},
end: function() {
return this.end_;
},
hasCommands: function(){
return Object.keys(this.commands).length > 0;
},
version: function(version, usage, description){
if (this.version_)
throw new SyntaxError('Version for command could be set only once');
this.version_ = version;
this.option(
usage || '-v, --version',
description || 'Output version',
function(){
console.log(this.version_);
process.exit(0);
},
undefined
);
return this;
},
description: function(description){
if (this.description_)
throw new SyntaxError('Description for command could be set only once');
this.description_ = description;
return this;
},
init: setFunctionFactory('init'),
initContext: setFunctionFactory('initContext'),
args: setFunctionFactory('args'),
delegate: setFunctionFactory('delegate'),
action: setFunctionFactory('action'),
extend: function(fn){
fn.apply(null, [this].concat(Array.prototype.slice.call(arguments, 1)));
return this;
},
parse: function(args, suggest){
if (!args)
args = process.argv.slice(2);
if (!errorHandler)
return processArgs(this, args, suggest);
else
try {
return processArgs(this, args, suggest);
} catch(e) {
errorHandler(e.message || e);
}
},
run: function(args, context){
var commands = this.parse(args);
if (!commands)
return;
var prevCommand;
var context = assign({}, context || this.initContext_());
for (var i = 0; i < commands.length; i++)
{
var item = commands[i];
var command = item.command;
// reset command values
command.reset();
command.context = context;
command.root = this;
if (prevCommand)
prevCommand.delegate_(command);
// apply beforeInit options
item.options.forEach(function(entry){
if (entry.option.beforeInit)
command.setOption(entry.option.camelName, entry.value);
});
command.init_(item.args.slice()); // use slice to avoid args mutation in handler
if (item.args.length)
command.args_(item.args.slice()); // use slice to avoid args mutation in handler
// apply regular options
item.options.forEach(function(entry){
if (!entry.option.beforeInit)
command.setOption(entry.option.camelName, entry.value);
});
prevCommand = command;
}
// return last command action result
if (command)
return command.action_(item.args, item.literalArgs);
},
normalize: function(values){
var result = {};
if (!values)
values = {};
for (var name in this.values)
if (hasOwnProperty.call(this.values, name))
result[name] = hasOwnProperty.call(values, name) && hasOwnProperty.call(this.options, name)
? this.options[name].normalize.call(this, values[name])
: this.values[name];
for (var name in values)
if (hasOwnProperty.call(values, name) && !hasOwnProperty.call(result, name))
result[name] = values[name];
return result;
},
showHelp: function(){
console.log(showCommandHelp(this));
}
};
//
// help
//
/**
* Return program help documentation.
*
* @return {String}
* @api private
*/
function showCommandHelp(command){
function breakByLines(str, offset){
var words = str.split(' ');
var maxWidth = MAX_LINE_WIDTH - offset || 0;
var lines = [];
var line = '';
while (words.length)
{
var word = words.shift();
if (!line || (line.length + word.length + 1) < maxWidth)
{
line += (line ? ' ' : '') + word;
}
else
{
lines.push(line);
words.unshift(word);
line = '';
}
}
lines.push(line);
return lines.map(function(line, idx){
return (idx && offset ? pad(offset, '') : '') + line;
}).join('\n');
}
function args(command){
return command.params.args.map(function(arg){
return arg.required
? '<' + arg.name + '>'
: '[' + arg.name + ']';
}).join(' ');
}
function commandsHelp(){
if (!command.hasCommands())
return '';
var maxNameLength = MIN_OFFSET - 2;
var lines = Object.keys(command.commands).sort().map(function(name){
var subcommand = command.commands[name];
var line = {
name: chalk.green(name) + chalk.gray(
(subcommand.params ? ' ' + args(subcommand) : '')
// (subcommand.hasOptions() ? ' [options]' : '')
),
description: subcommand.description_ || ''
};
maxNameLength = Math.max(maxNameLength, stringLength(line.name));
return line;
});
return [
'',
'Commands:',
'',
lines.map(function(line){
return ' ' + pad(maxNameLength, line.name) + ' ' + breakByLines(line.description, maxNameLength + 4);
}).join('\n'),
''
].join('\n');
}
function optionsHelp(){
if (!command.hasOptions())
return '';
var hasShortOptions = Object.keys(command.short).length > 0;
var maxNameLength = MIN_OFFSET - 2;
var lines = Object.keys(command.long).sort().map(function(name){
var option = command.long[name];
var line = {
name: option.usage
.replace(/^(?:-., |)/, function(m){
return m || (hasShortOptions ? ' ' : '');
})
.replace(/(^|\s)(-[^\s,]+)/ig, function(m, p, flag){
return p + chalk.yellow(flag);
}),
description: option.description
};
maxNameLength = Math.max(maxNameLength, stringLength(line.name));
return line;
});
// Prepend the help information
return [
'',
'Options:',
'',
lines.map(function(line){
return ' ' + pad(maxNameLength, line.name) + ' ' + breakByLines(line.description, maxNameLength + 4);
}).join('\n'),
''
].join('\n');
}
var output = [];
var chalk = require('chalk');
chalk.enabled = module.exports.color && process.stdout.isTTY;
if (command.description_)
output.push(command.description_ + '\n');
output.push(
'Usage:\n\n ' +
chalk.cyan(commandsPath ? commandsPath.join(' ') : command.name) +
(command.params ? ' ' + chalk.magenta(args(command)) : '') +
(command.hasOptions() ? ' [' + chalk.yellow('options') + ']' : '') +
(command.hasCommands() ? ' [' + chalk.green('command') + ']' : ''),
commandsHelp() +
optionsHelp()
);
return output.join('\n');
};
//
// export
//
module.exports = {
color: true,
Error: SyntaxError,
Argument: Argument,
Command: Command,
Option: Option,
error: function(fn){
if (errorHandler)
throw new SyntaxError('Error handler should be set only once');
if (typeof fn != 'function')
throw new SyntaxError('Error handler should be a function');
errorHandler = fn;
return this;
},
create: function(name, params){
return new Command(name || require('path').basename(process.argv[1]) || 'cli', params);
},
confirm: function(message, fn){
process.stdout.write(message);
process.stdin.setEncoding('utf8');
process.stdin.once('data', function(val){
process.stdin.pause();
fn(/^y|yes|ok|true$/i.test(val.trim()));
});
process.stdin.resume();
}
};