cli-kit
Version:
Everything you need to create awesome command line interfaces
834 lines (720 loc) • 23.5 kB
JavaScript
import camelCase from 'lodash.camelcase';
import Command from './command.js';
import Context from './context.js';
import debug from '../lib/debug.js';
import E from '../lib/errors.js';
import Extension from './extension.js';
import HookEmitter from 'hook-emitter';
import ParsedArgument from './parsed-argument.js';
import pluralize from 'pluralize';
import { declareCLIKitClass } from '../lib/util.js';
import { transformValue } from './types.js';
const { log } = debug('cli-kit:parser');
const { highlight, note } = debug.styles;
const dashOpt = /^(?:-|—)(.+?)(?:=(.+))?$/;
const negateRegExp = /^no-(.+)$/;
const optRE = /^(?:--|—)(?:([^=]+)(?:=([\s\S]*))?)$/;
/**
* A collection of parsed CLI arguments.
*
* @extends {HookEmitter}
*/
export default class Parser extends HookEmitter {
/**
* An object containing all of the parsed options, flags, and named arguments.
*
* @type {Object}
*/
argv = {};
/**
* A list of options and arguments in which their callbacks were fired during parsing so that
* we don't fire the callbacks again when setting the default values.
* @type {Set}
*/
_fired = new Set();
/**
* An object containing only unknown parsed options and flags.
*
* @type {Object}
*/
unknown = {};
/**
* An array of parsed arguments.
*
* @type {Array}
*/
_ = [];
/**
* Initializes the internal properties and class name.
*
* @param {Object} [opts] - Various options.
* @param {Object} [opts.data] - User-defined data to pass into the selected command.
* @param {Function} [opts.exitCode] - A function that sets the exit code.
* @param {Termianl} [opts.terminal] - A terminal instance to override the default CLI terminal
* instance.
* @access public
*/
constructor(opts = {}) {
super();
Object.defineProperties(this, {
/**
* The array of arguments. As arguments are identified, they are replaced with
* `ParsedArgument` instances and in the case of options with values and extra options,
* the array is shortened.
* @type {Array.<String|ParsedArgument>}
*/
args: {
value: null,
writable: true
},
/**
* A stack of contexts applied to the arguments. The first element is the most specific
* context, usually a command. The last element is typically the root `CLI` instance.
* @type {Array.<Context>}
*/
contexts: {
value: null,
writable: true
},
/**
* A map of option and argument environment variable values derived from all options
* and arguments found across all contexts.
* @type {Object}
*/
env: {
value: null,
writable: true
},
/**
* Options possibly containing a `data` payload, `exitCode`, and `terminal` instance.
* @type {Object}
*/
opts: {
value: opts
},
/**
* A list of all required arguments and options that were missing. The caller (e.g.
* the `CLI` instance) is responsible for enforcing missing arguments.
* @type {Set}
*/
required: {
value: null,
writable: true
}
});
declareCLIKitClass(this, 'Parser');
}
/**
* Loops over the contexts in reverse from the top-level to the most specific context and
* gathers the option defaults as well as any options specified as environment variables.
*
* @returns {Promise}
* @access private
*/
async applyDefaults() {
const requiredOptions = {
long: {},
short: {}
};
const len = this.contexts.length;
log(`Processing default options and environment variables for ${highlight(len)} ${pluralize('context', len)}`);
this.env = {};
// loop through every context
for (let i = len; i; i--) {
const ctx = this.contexts[i - 1];
// init options
for (const options of ctx.options.values()) {
for (const option of options) {
if (option.name) {
const name = option.camelCase || ctx.get('camelCase') ? camelCase(option.name) : option.name;
if (this.argv[name] === undefined) {
let value = option.default;
if (option.datatype === 'bool' && typeof value !== 'boolean') {
value = !!option.negate;
} else if (option.type === 'count') {
value = 0;
}
if (option.multiple && !Array.isArray(value)) {
value = value !== undefined ? [ value ] : [];
}
if (!this._fired.has(option) && typeof option.callback === 'function') {
const newValue = await option.callback({
ctx,
data: this.opts.data,
exitCode: this.opts.exitCode,
input: [ value ],
name,
async next() {},
opts: this.opts,
option,
parser: this,
value
});
if (newValue !== undefined) {
value = newValue;
}
}
this.argv[name] = value;
}
if (option.env && process.env[option.env] !== undefined) {
this.env[name] = option.transform(process.env[option.env]);
}
}
if (option.required) {
if (option.long) {
requiredOptions.long[option.long] = option;
}
if (option.short) {
requiredOptions.short[option.short] = option;
}
} else {
if (option.long) {
delete requiredOptions.long[option.long];
}
if (option.short) {
delete requiredOptions.short[option.short];
}
}
}
}
// init arguments
for (const arg of ctx.args) {
if (arg.name) {
const name = arg.camelCase || ctx.get('camelCase') ? camelCase(arg.name) : arg.name;
if (this.argv[name] === undefined) {
let value = arg.default;
if (arg.multiple && !Array.isArray(value)) {
value = value !== undefined ? [ value ] : [];
}
if (!this._fired.has(arg) && typeof arg.callback === 'function') {
const newValue = await arg.callback({
arg,
ctx,
data: this.opts.data,
exitCode: this.opts.exitCode,
name,
opts: this.opts,
parser: this,
value
});
if (newValue !== undefined) {
value = newValue;
}
}
this.argv[name] = value;
}
if (arg.env && process.env[arg.env] !== undefined) {
this.env[name] = arg.transform(process.env[arg.env]);
}
}
}
}
this.required = new Set(Object.values(requiredOptions.long));
for (const option of Object.values(requiredOptions.short)) {
this.required.add(option);
}
}
/**
* Loops over the parsed arguments and populates the `argv` and `_` properties.
*
* @returns {Promise}
* @access private
*/
async fillArgv() {
// from here, we want to deal with the most specific context
const ctx = this.contexts[0];
// loop over the parsed args and fill in the `argv` and `_`
log('Filling argv and _');
// combine parsed args that are options with multiple flag set
for (let k = 0; k < this.args.length; k++) {
let current = this.args[k];
if (current instanceof ParsedArgument && current.type === 'option' && current.option.multiple) {
for (let j = k + 1; j < this.args.length; j++) {
let next = this.args[j];
if (next instanceof ParsedArgument && next.type === 'option' && next.option === current.option) {
if (!Array.isArray(current.value)) {
current.value = [ current.value ];
}
if (next.value !== undefined) {
current.value = [].concat(current.value, next.value);
}
this.args.splice(j--, 1);
}
}
}
}
let index = 0;
let extra = [];
const setArg = async (idx, value) => {
const arg = ctx.args[idx];
// extract the parsed arg value
if (value instanceof ParsedArgument) {
value.arg = arg;
value = value.input[0];
}
if (arg) {
const name = arg.camelCase || ctx.get('camelCase') ? camelCase(arg.name) : arg.name;
value = arg.transform(value);
if (typeof arg.callback === 'function') {
const newValue = await arg.callback({
arg,
ctx,
data: this.opts.data,
exitCode: this.opts.exitCode,
name,
opts: this.opts,
parser: this,
value
});
if (newValue !== undefined) {
value = newValue;
}
this._fired.add(arg);
}
if (arg.multiple) {
// if this arg gobbles up multiple parsed args, then we decrement `i` so
// that we never increment it and no further arguments will be applied
index--;
if (Array.isArray(this.argv[name])) {
this.argv[name].push(value);
} else {
this.argv[name] = [ value ];
}
} else {
this.argv[name] = value;
}
}
this._.push(value);
};
// loop over the parsed args and assign the values to _ and argv
for (const parsedArg of this.args) {
let name;
const isParsed = parsedArg instanceof ParsedArgument;
if (!isParsed || parsedArg.type === 'argument') {
await setArg(index++, parsedArg);
continue;
}
switch (parsedArg.type) {
case 'argument':
// already handled above
break;
case 'extra':
extra = parsedArg.args;
break;
case 'option':
{
const { option } = parsedArg;
name = option.camelCase || ctx.get('camelCase') ? camelCase(option.name) : option.name;
let { value } = parsedArg;
if (option.type === 'count') {
value = (this.argv[name] || 0) + 1;
}
// non-multiple option callbacks have already been fired, now we need
// to do it just for multiple value options
if (typeof option.callback === 'function' && option.multiple) {
log(`Firing option ${highlight(option.format)} callback ${note(`(${option.parent.name})`)}`);
const newValue = await option.callback({
ctx,
data: this.opts.data,
exitCode: this.opts.exitCode,
input: [ value ],
name,
async next() {},
opts: this.opts,
option,
parser: this,
value
});
if (newValue !== undefined) {
value = newValue;
}
this._fired.add(option);
}
if (value !== undefined) {
// set the parsed value (overwrites the default value)
this.argv[name] = value;
}
// argv[name] either has the new value or the default value, but either way we must re-check it
if (this.argv[name] !== undefined && (!option.multiple || this.argv[name].length)) {
this.required.delete(option);
}
// if argv[name] has no value and no default, at least set it to an empty string
// note: this must be done after the required check above
if (this.argv[name] === undefined && option.datatype === 'string') {
this.argv[name] = option.transform('');
}
}
break;
case 'unknown':
// since this is an unknown option, we try to guess it's type and if it's
// a bool, we will honor the negate (e.g. --no-<name>)
let { value } = parsedArg;
value = value === undefined ? true : transformValue(value);
if (typeof value === 'boolean' && parsedArg.negated) {
value = !value;
}
// clean up the name
name = ctx.get('camelCase') ? camelCase(parsedArg.name) : parsedArg.name;
this.argv[name] = this.unknown[name] = value;
if (ctx.get('treatUnknownOptionsAsArguments')) {
this._.push(parsedArg.input[0]);
}
break;
}
}
// add the extra items
this._.push.apply(this._, extra);
// process env vars
log('Mixing in environment variable values');
Object.assign(this.argv, this.env);
}
/**
* Parses the command line arguments.
*
* @param {Object} opts - Various options.
* @param {Array} opts.args - An array of raw, unparsed arguments.
* @param {Context} opts.ctx - The context to reference for commands, options, and arguments.
* @returns {Promise<Parser>}
* @access public
*/
async parse(opts) {
const fn = this.hook('parse', async ({ args, ctx }) => {
if (!Array.isArray(args)) {
throw E.INVALID_ARGUMENT('Expected args to be an array', { name: 'args', scope: 'Parser.parse', value: args });
}
if (!(ctx instanceof Context)) {
throw E.INVALID_ARGUMENT('Expected ctx to be a context', { name: 'ctx', scope: 'Parser.parse', value: ctx });
}
this.args = args;
this.contexts = [ ctx ];
log(`Processing ${pluralize('argument', args.length, true)}: ${highlight(this.args.join(', '))}`);
// process the arguments against the context
await this.parseWithContext(ctx);
return this;
});
try {
return await fn({
...opts,
data: this.opts.data,
parser: this
});
} catch (err) {
err.contexts = this.contexts;
throw err;
}
}
/**
* Processes the arguments against the given context. If a command is found, it recursively
* calls itself.
*
* @param {CLI|Command} ctx - The context to apply when parsing the command line arguments.
* @returns {Promise}
* @access private
*/
async parseWithContext(ctx) {
// print the context's info
log(`Context: ${highlight(ctx.name)}`);
if (!ctx.lookup.empty) {
log(ctx.lookup.toString());
}
await this.parseArg(ctx, 0);
}
/**
* Parses a single argument as apart of a chain of promises.
*
* @param {CLI|Command} ctx - The context to apply when parsing the command line arguments.
* @param {Number} i - The argument index number to parse.
* @param {Number} [to] - The index to go until.
* @returns {Promise}
* @access private
*/
async parseArg(ctx, i, to) {
if (to !== undefined && i >= to) {
// all caught up, return
return;
}
let { rev } = ctx;
let { length } = this.args;
const checkRev = async (ctx, to) => {
if (ctx.rev > rev) {
// we always need a `to`
if (to === undefined) {
to = this.args.length;
}
log(`Rev changed from ${highlight(rev)} to ${highlight(ctx.rev)}, reparsing ${highlight(0)} through ${highlight(to)}`);
rev = ctx.rev;
await this.parseArg(ctx, 0, to);
}
};
if (to === undefined && i >= length) {
let cmd = this.contexts[0];
// if there are no more contexts to descend, check if the top-most context is actually
// a default subcommand
if (cmd === ctx && cmd.action instanceof Command) {
cmd = cmd.action;
cmd.link(ctx);
this.contexts.unshift(cmd);
}
if (cmd !== ctx) {
log('Descending into next context\'s parser');
return this.parseWithContext(cmd);
}
await this.hook('finalize', async () => {
await checkRev(ctx); // check if pre-finalize changed the rev
await this.applyDefaults();
await checkRev(ctx); // check if applyDefaults changed the rev
await this.fillArgv();
})({ ctx, data: this.opts.data, parser: this });
await checkRev(ctx); // check if post-finalize or fillArgv changed the rev
log('End of the line');
return;
}
// create a ParsedArgument object for the next argument
const arg = await this.createParsedArgument(ctx, i);
// if the length was shortened, then decrement `to`
if (to !== undefined && this.args.length < length) {
to -= (length - this.args.length);
log(`Argument list was shortened from ${length} to ${this.args.length} (to=${to})`);
}
if (arg) {
const { type } = arg;
// check if the context changed (e.g. we found a command/extension) so that we continue
// to process arguments against the current context before we descend into the newly
// discovered command/extension context
const sameContext = this.contexts[0] === ctx;
if ((type !== 'command' && type !== 'extension') || sameContext) {
this.args[i] = arg;
}
if ((type === 'command' || type === 'extension') && sameContext) {
// link the context hook emitters
const cmd = arg.command;
cmd.link(ctx);
if (typeof cmd.callback === 'function') {
await cmd.callback({
command: arg.command,
data: this.opts.data,
parser: this
});
}
// add the context to the stack
this.contexts.unshift(cmd);
} else if (type === 'option' && !sameContext && this.contexts[0] instanceof Extension && !this.contexts[0].isCLIKitExtension) {
log(`Forcing option ${highlight(arg.option.format)} to be an argument because we found a non-cli-kit extension ${highlight(this.contexts[0].name)}`);
this.args[i] = new ParsedArgument('argument', {
input: arg.input
});
} else if (type === 'option' && typeof arg.option.callback === 'function' && !arg.option.multiple) {
const { option } = arg;
log(`Firing option ${highlight(option.format)} callback ${note(`(${option.parent.name})`)}`);
let fired = false;
try {
const value = await option.callback({
ctx,
data: this.opts.data,
exitCode: this.opts.exitCode,
input: arg.input,
name: option.camelCase || ctx.get('camelCase') ? camelCase(option.name) : option.name,
next: async () => {
if (fired) {
log('next() already fired');
return;
}
fired = true;
log(`Option ${highlight(option.format)} called next(), processing next arg`);
await checkRev(ctx, i);
await this.parseArg(ctx, i + 1, to);
return this.args[i].value;
},
opts: this.opts,
option,
parser: this,
value: arg.value
});
if (value === undefined) {
log(`Option ${highlight(option.format)} callback did not change the value`);
} else {
log(`Option ${highlight(option.format)} callback changed value ${highlight(arg.value)} to ${highlight(value)}`);
arg.value = value;
}
this._fired.add(option);
if (fired) {
return;
}
log(`Option ${highlight(option.format)} did not call next(), processing next arg`);
} catch (err) {
if (err.code !== 'ERR_NOT_AN_OPTION') {
throw err;
}
this.args[i] = new ParsedArgument('argument', {
input: arg.input
});
}
}
}
await checkRev(ctx, i);
await this.parseArg(ctx, i + 1, to);
}
/**
* Detects what the argument is and returns an object that identifies what was found.
*
* @param {CLI|Command} ctx - The context to apply when parsing the command line arguments.
* @param {Number} i - The argument index number to parse.
* @returns {Promise<?ParsedArgument>} Resolves a `ParsedArgument` or `undefined` if the
* argument was already a `ParsedArgument` and didn't change.
* @access private
*/
async createParsedArgument(ctx, i) {
const { args } = this;
if (i >= args.length) {
throw E.RANGE_ERROR(`Expected argument index to be between 0 and ${args.length}`, { name: 'index', scope: 'Parser.createParsedArgument', value: i, range: [ 0, args.length - 1 ] });
}
const arg = args[i];
const isParsed = arg instanceof ParsedArgument;
const type = isParsed && arg.type;
log(`Processing argument [${i}]: ${highlight(arg)}`);
// check if the argument is a the `--` extra arguments sequence
if (arg === '--') {
const extra = args.splice(i + 1, args.length);
return new ParsedArgument('extra', {
args: extra,
input: [ arg, ...extra ]
});
}
if (type === 'extra') {
log('Skipping extra arguments');
return;
}
const { lookup } = ctx;
let subject = isParsed ? (arg.input ? arg.input[0] : null) : arg;
// check if the argument is an option
if (!type || type === 'option' || type === 'unknown') {
let m = subject.match(optRE);
let negated = false;
let option;
if (m) {
// --something or --something=foo
negated = m[1].match(negateRegExp);
const name = negated ? negated[1] : m[1];
option = lookup.long[name] || null;
// check if short option
} else if (m = subject.match(dashOpt)) {
if (m[1].length > 1) {
log(`Splitting group: ${highlight(m[1])}`);
const newArgs = m[1].split('').map((arg, i, arr) => i + 1 === arr.length && m[2] ? `-${arg}=${m[2]}` : `-${arg}`);
subject = newArgs.shift();
log(`Inserting arguments: ${newArgs.join(', ')}`);
this.args.splice(i + 1, 0, ...newArgs);
}
option = lookup.short[m[1][0]] || null;
}
if (!option && type) {
// not an option in this context, leave it alone
log(`Skipping ${type === 'unknown' ? 'un' : ''}known option: ${highlight(arg.getName())}`);
return;
}
if (option) {
log(`${type === 'option' ? 'Overriding' : 'Found'} option: ${highlight(option.name)} ${note(`(${option.datatype})`)} Negated? ${highlight(!!negated)}`);
let input = [ subject ];
let value;
if (option.isFlag) {
value = option.transform(type && arg.value !== undefined ? arg.value : true, negated);
} else if (type === 'option') {
value = option.transform(args[i].value);
} else if (m[2]) {
value = option.transform(m[2], negated);
} else if (i + 1 < args.length) {
const nextArg = args[i + 1];
if (nextArg instanceof ParsedArgument) {
if (nextArg.type === 'argument') {
input.push(...nextArg.input);
args.splice(i + 1, 1);
// maybe the unknown option is actually a value that just
// happens to match the pattern for an option?
value = option.transform(nextArg.input[0], negated);
} else {
// next arg has already been identified, so treat this option as a flag
value = true;
}
} else {
input.push(nextArg);
args.splice(i + 1, 1);
value = option.transform(nextArg, negated);
}
}
if (value === undefined) {
if (type && arg.value !== undefined) {
value = option.transform(arg.value, negated);
} else if (option.type === 'bool') {
value = option.transform(true, negated);
}
}
return new ParsedArgument('option', {
input,
option,
value: value !== undefined && !Array.isArray(value) && option.multiple ? [ value ] : value
});
}
// if the argument matched an option pattern, but didn't match a defined option, then we
// can add it as an unknown option which will eventually become a flag
if (option === null && !type) {
log(`Found unknown option: ${highlight(subject)}`);
return new ParsedArgument('unknown', {
input: [ subject ],
name: negated ? negated[1] : m[1],
negated,
value: m[2] === undefined && isParsed ? arg.value : m[2]
});
}
}
// check if the argument is a command
if (type === 'command' || type === 'extension') {
log(`Skipping known ${type}: ${highlight(arg.command.name)}`);
return;
}
// check if command and make sure we haven't already added a command this round
const cmd = lookup.commands[subject];
if (cmd) {
log(`Found command: ${highlight(cmd.name)}`);
await cmd.load();
return new ParsedArgument('command', {
command: cmd,
input: isParsed ? arg.input : [ arg ]
});
}
const ext = lookup.extensions[subject];
if (ext) {
log(`Found extension: ${highlight(ext.name)}`);
if (typeof ext.load === 'function') {
await ext.load(subject);
}
return new ParsedArgument('extension', {
command: ext,
input: isParsed ? arg.input : [ arg ]
});
}
if (!type) {
log(`Found unknown argument: ${highlight(arg)}`);
return new ParsedArgument('argument', {
input: isParsed ? arg.input : [ arg ]
});
}
}
/**
* Reconstructs the arguments into a string.
*
* @returns {String}
* @access public
*/
toString() {
return this.valueOf().join(' ');
}
/**
* Returns a reconstruction of `process.argv`.
*
* @returns {Array.<String>}
* @access public
*/
valueOf() {
return this.args.map(arg => String(arg));
}
}