cli-kit
Version:
Everything you need to create awesome command line interfaces
277 lines (242 loc) • 9.88 kB
JavaScript
import E from '../lib/errors.js';
import { checkType, transformValue } from './types.js';
import { declareCLIKitClass } from '../lib/util.js';
const formatRegExp = /^(?:-(\w)(?:[ ,|]+)?)?(?:--([^\s=]+))?(?:[\s=]+(.+))?$/;
const valueRegExp = /^(\[(?=.+\]$)|<(?=.+>$))(.+)[\]>]$/;
const negateRegExp = /^no-(.+)$/;
const aliasRegExp = /^(!)?(?:-(.)|--(no-)?(.+))$/;
const numberRegExp = /^\d+(\.\d*)?$/;
/**
* Defines an option and it's parameters.
*/
export default class Option {
/**
* Creates an option descriptor.
*
* @param {String|Object} format - The option format or a option parameters.
* @param {String|Object} [params] - Either a description or parameters when `format` is a
* `String`.
* @param {Array.<String>|String} [params.aliases] - An array of aliases. If an alias starts
* with a `!`, then it is hidden from the help.
* @param {Function} [params.callback] - A function to call when the option has been parsed.
* @param {Boolean} [params.camelCase=true] - If option has a name or can derive a name from the
* long option format, then it the name be camel cased.
* @param {*} [params.default] - A default value. Defaults to `undefined` unless the `type` is
* set to `bool` and `negate` is `true`, then the default value will be set to `true`.
* @param {String} [params.desc] - The description of the option used in the help display.
* @param {String} [params.env] - The environment variable name to get a value from. If the
* environment variable is set, it overrides the value parsed from the arguments.
* @param {String} [params.errorMsg] - A generic message when the value is invalid.
* @param {Boolean} [params.hidden=false] - When `true`, the option is not displayed on the help
* screen or auto-suggest.
* @param {String} [params.hint] - The hint label if the option expects a value.
* @param {Number} [params.max] - When `type` is `int`, `number`, or `positiveInt`, the
* validator will assert the value is less than or equal to the specified value.
* @param {Number} [params.min] - When `type` is `int`, `number`, or `positiveInt`, the
* validator will assert the value is greater than or equal to the specified value.
* @param {Boolean} [params.multiple] - When `true`, if this option is parsed more than once,
* the values are put in an array. When `false`, the last parsed value overwrites the previously
* parsed value.
* @param {Boolean} [params.negate] - When `true`, it will automatically prepend `no-` to the
* option name on the help screen and convert the value from truthy to `false` or falsey to
* `true`.
* @param {Number} [params.order=Infinity] - A number used to sort the options within the group
* on the help screen. Options with a lower order are sorted before those with a higher order.
* If two options have the same order, then they are sorted alphabetically based on the name.
* @param {Boolean} [params.required] - Marks the option value as required.
* @param {String|RegExp} [params.type] - The option type to coerce the data type into. If type
* is a regular expression, then it'll use it to validate the option.
* @access public
*/
constructor(format, params) {
if (format && typeof format === 'object' && format.clikit instanceof Set && format.clikit.has('Option')) {
params = format;
format = format.format;
}
if (!format || typeof format !== 'string') {
throw E.INVALID_ARGUMENT('Expected option format to be a non-empty string', { name: 'format', scope: 'Option.constructor', value: format });
}
if (params === undefined || params === null) {
params = {};
} else if (typeof params === 'string') {
params = { desc: params };
}
if (typeof params !== 'object' || Array.isArray(params)) {
throw E.INVALID_ARGUMENT('Expected params to be an object', { name: 'params', scope: 'Option.constructor', value: params });
}
if (params.callback && typeof params.callback !== 'function') {
throw E.INVALID_ARGUMENT('Expected option callback to be a function', { name: 'params.callback', scope: 'Option.constructor', value: params.callback });
}
this.callback = params.callback;
this.desc = params.desc;
this.env = params.env;
this.errorMsg = params.errorMsg;
this.format = format.trim();
this.hidden = !!params.hidden;
this.max = params.max || null;
this.min = params.min || null;
this.multiple = !!params.multiple;
this.negate = false;
this.order = params.order || Infinity;
this.regex = params.type instanceof RegExp ? params.type : null;
this.required = !!params.required;
this.type = params.type;
// first try to see if we have a valid option format
const m = this.format.match(formatRegExp);
if (!m || (!m[1] && !m[2])) {
throw E.INVALID_OPTION_FORMAT(`Invalid option format "${this.format}"`, { name: 'format', scope: 'Option.constructor', value: this.format });
}
this.aliases = processAliases(params.aliases);
this.short = m[1] || null;
// check if we have a long option and name
if (m[2]) {
const negate = m[2].match(negateRegExp);
this.negate = !!negate;
this.name = negate ? negate[1] : m[2];
this.long = negate ? negate[1] : m[2];
}
this.name = this.name || this.long || this.short || this.format;
if (!this.name) {
throw E.INVALID_OPTION(`Option "${this.format}" has no name`, { name: 'name', scope: 'Option.constructor', value: params.name });
}
let hint = this.type !== 'count' && params.hint || m[3];
if (hint) {
const value = hint.match(valueRegExp);
if (value) {
this.hint = hint = value[2].trim();
this.required = this.required || value[1] === '<';
} else {
this.hint = hint;
}
}
this.camelCase = this.name ? params.camelCase !== false : false;
this.isFlag = !hint;
this.redact = params.redact === undefined ? !this.isFlag : params.redact !== false;
// determine the datatype
if (this.isFlag) {
this.datatype = checkType(params.type, 'bool');
if (this.datatype !== 'bool' && this.datatype !== 'count') {
throw E.CONFLICT(`Option "${this.format}" is a flag and must be type bool`, { name: 'flag', scope: 'Option.constructor', value: params.dataType });
}
} else {
this.datatype = checkType(params.type, this.hint, 'string');
}
if (this.datatype !== 'bool' && this.negate) {
throw E.CONFLICT(`Option "${this.format}" is negated and must be type bool`, { name: 'negate', scope: 'Option.constructor', value: params.negate });
}
this.default = params.default !== undefined ? params.default : (this.datatype === 'bool' && this.negate ? true : undefined);
declareCLIKitClass(this, 'Option');
// mix in any other custom props
for (const [ key, value ] of Object.entries(params)) {
if (!Object.prototype.hasOwnProperty.call(this, key)) {
this[key] = value;
}
}
}
/**
* Returns this option's schema.
*
* @returns {Object}
* @access public
*/
schema() {
return {
aliases: this.aliases,
desc: this.desc,
format: this.format,
hint: this.hint,
type: this.type
};
}
/**
* Transforms the given option value based on its type.
*
* @param {*} value - The value to transform.
* @param {Boolean} [negated] - Set to `true` if the parsed argument started with `no-`.
* @returns {*}
* @access public
*/
transform(value, negated) {
value = transformValue(value, this.datatype);
switch (this.datatype) {
case 'bool':
// for bools, we need to negate, but only if the option name specified negated version
if (negated) {
value = !value;
}
break;
case 'count':
break;
case 'int':
case 'number':
case 'positiveInt':
if (this.min !== null && value < this.min) {
throw E.RANGE_ERROR(`Value must be greater than or equal to ${this.min}`, { max: this.max, min: this.min, name: 'min', scope: 'Option.transform', value });
}
if (this.max !== null && value > this.max) {
throw E.RANGE_ERROR(`Value must be less than or equal to ${this.max}`, { max: this.max, min: this.min, name: 'max', scope: 'Option.transform', value });
}
break;
case 'regex':
if (!this.regex.test(value)) {
throw E.INVALID_VALUE(this.errorMsg || 'Invalid value', { name: 'regex', regex: this.regex, scope: 'Option.transform', value });
}
break;
default:
// check if value could be a number
if (numberRegExp.test(value)) {
const n = parseFloat(value);
if (!isNaN(n)) {
return n;
}
}
}
return value;
}
}
/**
* Processes aliases into sorted buckets for faster lookup.
*
* @param {Array.<String>|String} aliases - An array, object, or string containing aliases.
* @returns {Object}
*/
function processAliases(aliases) {
const result = {
long: {},
short: {}
};
if (!aliases) {
return result;
}
if (!Array.isArray(aliases)) {
if (typeof aliases === 'object') {
if (aliases.long && typeof aliases.long === 'object') {
Object.assign(result.long, aliases.long);
}
if (aliases.short && typeof aliases.short === 'object') {
Object.assign(result.short, aliases.short);
}
return result;
}
aliases = [ aliases ];
}
for (const alias of aliases) {
if (!alias || typeof alias !== 'string') {
throw E.INVALID_ALIAS('Expected aliases to be a string or an array of strings',
{ name: 'aliases', scope: 'Option.constructor', value: alias });
}
for (const a of alias.split(/[ ,|]+/)) {
const m = a.match(aliasRegExp);
if (!m) {
throw E.INVALID_ALIAS(`Invalid alias format "${alias}"`, { name: 'aliases', scope: 'Option.constructor', value: alias });
}
// note: m[3] contains the negate sequence, but we ignore since it's handled during parsing
if (m[2]) {
result.short[m[2]] = !m[1];
} else if (m[4]) {
result.long[m[4]] = !m[1];
}
}
}
return result;
}