blob: a7b0b04e52538f79fd481d8597a5bcf1dca5a484 [file] [log] [blame] [edit]
// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The SFC licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
/**
* @fileoverview A simple command line option parser.
*/
'use strict';
var path = require('path');
/**
* Repeats a string n times.
* @param {string} str The string to repeat.
* @param {number} n The number of times to repeat the string.
* @return {string} The repeated string, concatenated together.
*/
function repeatStr(str, n) {
return new Array(n + 1).join(str);
}
/**
* Trims a string of all trailing and leading whitespace.
* @param {string} str The string to trim.
* @return {string} The trimmed string.
*/
function trimStr(str) {
return str.replace(/^[\s\xa0]+|[\s\xa0]+$/g, '');
}
/**
* Wraps the provided {@code text} so every line is at most {@code width}
* characters long.
* @param {string} text The text to wrap.
* @param {number} width The maximum line length.
* @param {string=} opt_indent String that will be prepended to each line.
* Defaults to the empty string.
* @return {!Array.<string>} A list of lines, without newline characters.
*/
function wrapStr(text, width, opt_indent) {
var out = [],
indent = opt_indent || '';
if (indent.length >= width) {
throw Error('Wrapped line indentation is longer than permitted width: ' +
indent.length + ' >= ' + width);
}
text.split('\n').forEach(function(line) {
if (/^\s*$/.test(line)) {
out.push(''); // Push a blank line.
return;
}
do {
line = indent + trimStr(line);
out.push(line.substring(0, width));
line = line.substring(width);
} while (line);
});
return out;
}
/**
* The total number of columns for output in a printed help message.
* @type {number}
* @const
*/
var TOTAL_WIDTH = 80;
/**
* Indentation to use between the left column and options string, and between
* the options string and help text.
* @type {string}
* @const
*/
var IDENTATION = ' ';
/**
* The maximum column for option text.
* @type {number}
* @const
*/
var MAX_HELP_POSITION = 24;
/**
* Column where the help text should begin.
* @type {number}
* @const
*/
var HELP_TEXT_POSITION = MAX_HELP_POSITION + IDENTATION.length;
/**
* Formats a help message for the given parser.
* @param {string} usage The usage string. All occurrences of "$0" will be
* replaced with the name of the current program.
* @param {!Object.<!Option>} options The options to format.
* @return {string} The formatted help message.
*/
function formatHelpMsg(usage, options) {
var prog = path.basename(
process.argv[0]) + ' ' + path.basename(process.argv[1]);
var help = [
usage.replace(/\$0\b/g, prog),
'',
'Options:',
formatOption('help', 'Show this message and exit')
];
Object.keys(options).sort().forEach(function(key) {
help.push(formatOption(key, options[key].help));
});
help.push('');
return help.join('\n');
}
/**
* Formats the help message for a single option. Will place the option string
* and help text on the same line whenever possible.
* @param {string} name The name of the option.
* @param {string} helpMsg The option's help message.
* @return {string} The formatted option.
*/
function formatOption(name, helpMsg) {
var result = [];
var options = IDENTATION + '--' + name;
if (options.length > MAX_HELP_POSITION) {
result.push(options);
result.push('\n');
result.push(wrapStr(helpMsg, TOTAL_WIDTH,
repeatStr(' ', HELP_TEXT_POSITION)).join('\n'));
} else {
var spaceCount = HELP_TEXT_POSITION - options.length;
options += repeatStr(' ', spaceCount) + helpMsg;
result.push(options.substring(0, TOTAL_WIDTH));
options = options.substring(TOTAL_WIDTH);
if (options) {
result.push('\n');
result.push(wrapStr(options, TOTAL_WIDTH,
repeatStr(' ', HELP_TEXT_POSITION)).join('\n'));
}
}
return result.join('');
}
var OPTION_NAME_PATTERN = '[a-zA-Z][a-zA-Z0-9_]*';
var OPTION_NAME_REGEX = new RegExp('^' + OPTION_NAME_PATTERN + '$');
var OPTION_FLAG_REGEX = new RegExp(
'^--(' + OPTION_NAME_PATTERN + ')(?:=(.*))?$');
function checkOptionName(name, options) {
if ('help' === name) {
throw Error('"help" is a reserved option name');
}
if (!OPTION_NAME_REGEX.test(name)) {
throw Error('option ' + JSON.stringify(name) + ' must match ' +
OPTION_NAME_REGEX);
}
if (name in options) {
throw Error('option ' + JSON.stringify(name) + ' has already been defined');
}
}
function parseOptionValue(name, spec, value) {
try {
return spec.parse(value);
} catch (ex) {
ex.message = 'Invalid value for ' + JSON.stringify('--' + name) +
': ' + ex.message;
throw ex;
}
}
function parseBoolean(value) {
// Empty string if the option was specified without a value; implies true.
if (!value) {
return true;
}
var tmp = value.toLowerCase();
if (tmp === 'true' || tmp === '1') {
return true;
} else if (tmp === 'false' || tmp === '0') {
return false;
}
throw Error(JSON.stringify(value) + ' is not a valid boolean value');
}
parseBoolean['default'] = false;
function parseNumber(value) {
if (/^0x\d+$/.test(value)) {
return parseInt(value);
}
var num = parseFloat(value);
if (isNaN(num) || num != value) {
throw Error(JSON.stringify(value) + ' is not a valid number');
}
return num;
}
parseNumber['default'] = 0;
function parseString(value) { return value; }
parseString['default'] = '';
function parseRegex(value) { return new RegExp(value); }
/**
* A command line option parser. Will automatically parse the command line
* arguments to this program upon accessing the {@code options} or {@code argv}
* properies. The command line will only be re-parsed if this parser's
* configuration has changed since the last access.
* @constructor
*/
function OptionParser() {
/** @type {string} */
var usage = OptionParser.DEFAULT_USAGE;
/** @type {boolean} */
var mustParse = true;
/** @type {!Object.<*>} */
var parsedOptions = {};
/** @type {!Array.<string>} */
var extraArgs = [];
/**
* Sets the usage string. All occurrences of "$0" will be replaced with the
* current executable's name.
* @param {string} usageStr The new usage string.
* @return {!OptionParser} A self reference.
* @this {OptionParser}
*/
this.usage = function(usageStr) {
mustParse = true;
usage = usageStr;
return this;
};
/**
* @type {!Object.<{
* help: string,
* parse: function(string): *,
* required: boolean,
* list: boolean,
* callback: function(*),
* default: *
* }>}
*/
var options = {};
/**
* Adds a new option to this parser.
* @param {string} name The name of the option.
* @param {function(string): *} parseFn The function to use for parsing the
* option. If this function has a "default" property, it will be used as
* the default value for the option if it was not provided on the command
* line. If not specified, the default value will be undefined. The
* default value may be overridden using the "default" property in the
* option spec.
* @param {Object=} opt_spec The option spec.
* @return {!OptionParser} A self reference.
* @this {OptionParser}
*/
this.addOption = function(name, parseFn, opt_spec) {
checkOptionName(name, options);
var spec = opt_spec || {};
// Quoted notation for "default" to bypass annoying IDE syntax bug.
var defaultValue = spec['default'] ||
(!!spec.list ? [] : parseFn['default']);
options[name] = {
help: spec.help || '',
parse: parseFn,
required: !!spec.required,
list: !!spec.list,
callback: spec.callback || function() {},
'default': defaultValue
};
mustParse = true;
return this;
};
/**
* Defines a boolean option. Option values may be one of {'', 'true', 'false',
* '0', '1'}. In the case of the empty string (''), the option value will be
* set to true.
* @param {string} name The name of the option.
* @param {Object=} opt_spec The option spec.
* @return {!OptionParser} A self reference.
* @this {OptionParser}
*/
this.boolean = function(name, opt_spec) {
return this.addOption(name, parseBoolean, opt_spec);
};
/**
* Defines a numeric option.
* @param {string} name The name of the option.
* @param {Object=} opt_spec The option spec.
* @return {!OptionParser} A self reference.
* @this {OptionParser}
*/
this.number = function(name, opt_spec) {
return this.addOption(name, parseNumber, opt_spec);
};
/**
* Defines a path option. Each path will be resolved relative to the
* current working directory, if not absolute.
* @param {string} name The name of the option.
* @param {Object=} opt_spec The option spec.
* @return {!OptionParser} A self reference.
* @this {OptionParser}
*/
this.path = function(name, opt_spec) {
return this.addOption(name, path.resolve, opt_spec);
};
/**
* Defines a basic string option.
* @param {string} name The name of the option.
* @param {Object=} opt_spec The option spec.
* @return {!OptionParser} A self reference.
* @this {OptionParser}
*/
this.string = function(name, opt_spec) {
return this.addOption(name, parseString, opt_spec);
};
/**
* Defines a regular expression based option.
* @param {string} name The name of the option.
* @param {Object=} opt_spec The option spec.
* @return {!OptionParser} A self reference.
* @this {OptionParser}
*/
this.regex = function(name, opt_spec) {
return this.addOption(name, parseRegex, opt_spec);
};
/**
* Returns the parsed command line options.
*
* <p>The command line arguments will be re-parsed if this parser's
* configuration has changed since the last access.
*
* @return {!Object.<*>} The parsed options.
*/
this.__defineGetter__('options', function() {
parse();
return parsedOptions;
});
/**
* Returns the remaining command line arguments after all options have been
* parsed.
*
* <p>The command line arguments will be re-parsed if this parser's
* configuration has changed since the last access.
*
* @return {!Array.<string>} The remaining command line arguments.
*/
this.__defineGetter__('argv', function() {
parse();
return extraArgs;
});
/**
* Parses a list of arguments. After parsing, the options property of this
* object will contain the value for each parsed flags, and the argv
* property will contain all remaining command line arguments.
* @throws {Error} If the arguments could not be parsed.
*/
this.parse = parse;
/**
* Returns a formatted help message for this parser.
* @return {string} The help message for this parser.
*/
this.getHelpMsg = getHelpMsg;
function getHelpMsg() {
return formatHelpMsg(usage, options);
}
function parse() {
if (!mustParse) {
return;
}
parsedOptions = {};
extraArgs = [];
var args = process.argv.slice(2);
var n = args.length;
try {
for (var i = 0; i < n; ++i) {
var arg = args[i];
if (arg === '--') {
extraArgs = args.slice(i + 1);
break;
}
var match = arg.match(OPTION_FLAG_REGEX);
if (match) {
// Special case --help.
if (match[1] === 'help') {
printHelpAndDie('', 0);
}
parseOption(match);
} else {
extraArgs = args.slice(i + 1);
break;
}
}
} catch (ex) {
printHelpAndDie(ex.message, 1);
}
for (var name in options) {
var option = options[name];
if (!(name in parsedOptions)) {
if (option.required) {
printHelpAndDie('Missing required option: --' + name, 1);
}
parsedOptions[name] = option.list ? [] : option['default'];
}
}
mustParse = false;
}
function printHelpAndDie(errorMessage, exitCode) {
process.stdout.write(errorMessage + '\n');
process.stdout.write(getHelpMsg());
process.exit(exitCode);
}
function parseOption(match) {
var option = options[match[1]];
if (!option) {
throw Error(JSON.stringify('--' + match[1]) + ' is not a valid option');
}
var value = match[2];
if (typeof value !== 'undefined') {
value = parseOptionValue(match[1], option, value);
} else if (option.parse === parseBoolean) {
value = true;
} else {
throw Error('Option ' + JSON.stringify('--' + option.name) +
'requires an operand');
}
option.callback(value);
if (option.list) {
var array = parsedOptions[match[1]] || [];
parsedOptions[match[1]] = array;
array.push(value);
} else {
parsedOptions[match[1]] = value;
}
}
}
/**
* The default usage message for an option parser.
*/
Object.defineProperty(OptionParser, 'DEFAULT_USAGE', {
value: 'Usage: $0 [options] [arguments]',
writable: false,
enumerable: true,
configurable: false
});
/** @type {!OptionParser} */
exports.OptionParser = OptionParser;
if (module === require.main) {
var parser = new OptionParser().
boolean('bool_flag', {
help: 'This is a boolean flag'
}).
number('number_flag', {
help: 'This is a number flag'
}).
path('path_flag', {
help: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' +
'Nam pharetra pellentesque augue ut auctor. Mauris eget est ' +
'vitae quam imperdiet mollis. Ut lorem lorem, commodo et ' +
'interdum cursus, convallis quis mi. Aenean facilisis adipiscing ' +
'imperdiet. Etiam tristique facilisis ullamcorper. Vestibulum ' +
'at mauris quis eros lobortis viverra vel non massa. ' +
'Aenean eu sodales quam.'
}).
string('the_quick_brown_fox_jumped_over_the_very_lazy_dog', {
help: 'The quick brown fox jumped over the very lazy dog'
});
console.log('Options are: ');
for (var key in parser.options) {
console.log(' --' + key + '=' + parser.options[key]);
}
console.log('Args are: ');
console.log(' ', parser.argv.join(' '));
}