nix-clap
Version:
Simple, lightweight, flexible, and comprehensive Un*x Command Line Argument Parsing for NodeJS
376 lines (319 loc) • 9.93 kB
JavaScript
"use strict";
/* eslint-disable one-var, max-statements, no-magic-numbers */
const assert = require("assert");
const PARSING = 1;
const GATHERING_OPT_PARAMS = 2;
const PARSING_CMD = 3;
const symbols = require("./symbols");
const CMD = symbols.CMD;
const OPTIONS = symbols.OPTIONS;
const TARGET = symbols.TARGET;
const xtil = require("./xtil");
const camelCase = xtil.camelCase;
const convertValue = xtil.convertValue;
/*
* Parser
*/
class Parser {
constructor(nc) {
this._nc = nc;
this._cliOptions = nc.cliOptions;
this._commands = nc.commands;
this._states = [];
this._state = PARSING;
this._optType = undefined;
this._optArgs = undefined;
this._cmdArgs = undefined;
this._parsed = undefined;
this._optCtx = undefined;
this._cmdCtx = undefined;
this._multiCommand = true; // allow specifying multiple commands at the same time?
}
getCmd(name) {
const ctx = this._commands.getContext(name);
// remember command context so any options that follow can be
// checked to apply against its private options
this._cmdCtx = ctx;
this._nc.emit("new-command", { context: ctx, parsed: this._parsed, nixClap: this._nc });
if (ctx.unknown) {
this._nc.emit("unknown-command", ctx);
}
this._parsed.commands.push(ctx);
if (ctx[CMD].args.length > 0) {
this._states.push(this._state);
this._state = PARSING_CMD;
this._cmdArgs = [];
}
}
convertOptValueType(ctx, value, verbatim) {
const ccLong = ctx.ccLong;
const opt = ctx.opt;
const type = opt.type;
if (verbatim !== undefined) {
ctx[TARGET].verbatim[ccLong] = verbatim;
}
if (type === "count") {
value = (ctx[TARGET].opts[ccLong] || 0) + 1;
} else if (type === "array") {
if (opt.subtype) {
value = value.map(v => convertValue(opt.subtype, v, opt));
}
} else {
value = convertValue(type, value[0], opt);
}
ctx[TARGET].opts[ccLong] = value;
}
checkOptionAllowCmd(ctx) {
const opt = ctx.opt;
if (!opt.allowCmd || opt.allowCmd.length < 1) return;
const valid = this._cmdCtx && opt.allowCmd.indexOf(this._cmdCtx.long) >= 0;
assert(
valid,
`option ${ctx.name} must follow one of these commands ${opt.allowCmd.join(", ")}`
);
}
findApplyOptions(options, target, name) {
const ctx = options.parse(name);
if (ctx) {
this.checkOptionAllowCmd(ctx);
ctx[TARGET] = target;
ctx[OPTIONS] = options;
this._optCtx = ctx;
}
return ctx;
}
setUnknownOption(name, value, verbatim) {
this._nc.emit("unknown-option", name);
const target = this._cmdCtx || this._parsed;
this._nc.emit("unknown-option-v2", { name, parsed: this._parsed, target, nixClap: this._nc });
const ccName = camelCase(name);
if (verbatim !== undefined) {
target.verbatim[ccName] = verbatim;
}
target.opts[ccName] = value !== undefined ? value[0] : true;
target.source[ccName] = "cli";
}
setOptValue(name, value, verbatim) {
const ctx =
// first check if option should be applied to a command
(this._cmdCtx && this.findApplyOptions(this._cmdCtx[CMD].options, this._cmdCtx, name)) ||
// then the top level
this.findApplyOptions(this._cliOptions, this._parsed, name);
if (!ctx) {
this.setUnknownOption(name, value, verbatim);
return;
}
const ccLong = ctx.ccLong;
const opt = ctx.opt;
ctx[TARGET].source[ccLong] = "cli";
if (ctx[TARGET].optCmd && this._cmdCtx) {
ctx[TARGET].optCmd[ccLong] = this._cmdCtx.name;
}
if (value !== undefined || opt.type === "count") {
this.convertOptValueType(ctx, value, verbatim);
} else {
this._states.push(this._state);
this._state = GATHERING_OPT_PARAMS;
this._optType = opt.type || "";
this._optArgs = [];
}
}
get applyOptions() {
if (this._optCtx) return this._optCtx[OPTIONS];
return this._cliOptions;
}
get applyTarget() {
if (this._optCtx) return this._optCtx[TARGET];
return this._parsed;
}
setInsideSingle(name) {
const singleOpts = name.split("");
if (singleOpts.length > 1) {
singleOpts
.slice(0, singleOpts.length - 1)
.forEach(x => this.applyOptions.setSingle(x, this.applyTarget));
}
return singleOpts[singleOpts.length - 1];
}
getOpt(arg) {
let name, value, verbatim;
if (arg.startsWith("--no-")) {
name = arg.substr(5);
value = [false];
verbatim = ["no-"];
} else {
const dashes = arg.startsWith("--") ? 2 : 1;
name = arg.substr(dashes);
const eqX = name.indexOf("=");
if (eqX > 0) {
value = [name.substr(eqX + 1)];
name = name.substr(0, eqX);
}
if (dashes === 1) {
name = this.setInsideSingle(name);
}
verbatim = value;
}
this.setOptValue(name, value, verbatim);
}
optEndGather() {
if (this._optArgs.length > 0) {
this.convertOptValueType(this._optCtx, this._optArgs, this._optArgs);
} else if (!this._optType || this._optType.indexOf("boolean") >= 0) {
this._optCtx[TARGET].opts[this._optCtx.ccLong] = true;
} else {
const ra = this._optCtx.opt.requireArg || this._optCtx.opt.requiresArg;
assert(!ra, `option ${this._optCtx.name} requires argument`);
// note: still leave source as "cli" since assigning default is due to
// user specifying the option on the command line
this._optCtx[TARGET].opts[this._optCtx.ccLong] = this._optCtx.opt.default;
}
this._optType = undefined;
this._optArgs = undefined;
this._state = this._states.pop();
}
cmdEndGatherArgs() {
const ctx = this._cmdCtx;
const cmd = ctx[CMD];
const args = cmd.args;
ctx.argList = this._cmdArgs;
assert(
this._cmdArgs.length >= cmd.needArgs,
`Not enough arguments for command ${cmd.name}
- Note that options for a command must be after its arguments.
ie: 'cli ${cmd.name} arg1 arg2 --opt1 --opt2', NOT: 'cli ${cmd.name} --opt1 --opt2 arg1 arg2'`
);
const setArg = (name, type, value) => {
if (!name) return;
if (type) {
ctx.args[name] = Array.isArray(value)
? value.map(v => convertValue(type, v, cmd.spec))
: convertValue(type, value, cmd.spec);
} else {
ctx.args[name] = value;
}
};
for (let i = 0; i < args.length && i < ctx.argList.length; i++) {
setArg(args[i].name, args[i].type, ctx.argList[i]);
}
if (cmd.isVariadicArgs()) {
const lastIx = args.length - 1;
if (ctx.argList.length > lastIx) {
setArg(args[lastIx].name, args[lastIx].type, ctx.argList.slice(lastIx));
}
}
}
cmdEndGather() {
this.cmdEndGatherArgs();
this._cmdArgs = undefined;
this._state = this._states.pop();
}
gatherOptParams(arg) {
const isOpt = arg.startsWith("-");
let endGather;
// if opt type is boolean, then only accept true/false as arg
if (this._optType === "boolean") {
const larg = arg.toLowerCase();
endGather = larg !== "true" && larg !== "false";
} else {
endGather = isOpt;
}
if (endGather) {
// another opt, end of gathering
this.optEndGather();
// allow -. or --. as terminator for variadic options arguments
if (arg !== "-." && arg !== "--.") {
if (isOpt) this.getOpt(arg);
else this.parseArg(arg);
}
} else {
// save
this._optArgs.push(arg);
// check if gathered everything
if (this._optType.indexOf("array") < 0) {
// if so, end gathering, resume parsing
this.optEndGather();
}
}
}
gatherCmdParams(arg) {
const cmd = this._cmdCtx[CMD];
this._cmdArgs.push(arg);
if (this._cmdArgs.length === cmd.expectArgs && !cmd.isVariadicArgs()) {
this.cmdEndGather();
}
}
parseArg(arg) {
const state = this._state;
if (state === GATHERING_OPT_PARAMS) {
return this.gatherOptParams(arg);
}
// terminate parsing for command in multi commands mode
if (this._multiCommand && (arg === "-." || arg === "--.") && state === PARSING_CMD) {
return this.cmdEndGather();
}
// an option
if (arg.startsWith("-")) {
// are we in PARSING_CMD mode
if (state === PARSING_CMD) {
// any -/-- opt terminates command arg gathering
this.cmdEndGatherArgs();
}
return this.getOpt(arg);
}
if (state === PARSING_CMD) {
return this.gatherCmdParams(arg);
}
assert(state === PARSING, `bug: unknown parsing state ${state}`);
// a command
return this.getCmd(arg);
}
parseArgIndex(x) {
this.parseArg(this._argv[x]);
}
checkTerminator() {
const state = this._state;
if (state === GATHERING_OPT_PARAMS) {
this.optEndGather();
} else if (state === PARSING_CMD) {
this.cmdEndGather();
}
}
get _defaultParsedObjet() {
const parsed = {
source: {},
commands: [],
opts: {},
optCmd: {},
verbatim: {},
nixClap: this._nc
};
Object.defineProperty(parsed, "nixClap", { enumerable: false });
return parsed;
}
parse(argv, start, parsed) {
this._argv = argv;
this._parsed = parsed || this._defaultParsedObjet;
let index = start !== undefined ? start : 0;
try {
for (; index < argv.length; index++) {
if (argv[index] === "--") {
this.checkTerminator();
break;
} else {
this.parseArgIndex(index);
}
}
if (this._state === GATHERING_OPT_PARAMS) {
this.optEndGather();
} else if (this._state === PARSING_CMD) {
this.cmdEndGather();
}
} catch (e) {
this._parsed.error = e;
}
this._parsed.index = index;
return this._parsed;
}
}
module.exports = Parser;