import * as mri from 'mri'; import * as os from 'os'; import { getCommandDefinitionParameterValue, validateCommandDefinitionParameterValue } from './parameters'; import { ParserErrors } from './ParserErrors'; import { CliProgram } from './program'; import { CliPromptHandler, CliPromptResult } from './prompt'; import { CliCommandDefinition, CliCommandDefinitionArgument, CliCommandDefinitionOption, CliCommandDefinitionParameter, CliParsedCommandDefinitionArgument, CliParsedCommandDefinitionOption, CliParsedCommandHandler, CliProgramDefinition, CliRunResult, CliValue, CliValueValidatorFunction, ParsedCliCommand, ParserErrorType, } from './types'; import { dashCase, getOptionsMriOpts, isFunction, passedOptionsToNames } from './utils'; export class Parser { private _parsedCommand: Partial; private _definitionsMap: Map = new Map(); private _argVals: string[] = []; private _optionVals: { [name: string]: CliValue } = {}; private _definitionOptions: CliCommandDefinitionOption[]; private _commandPackage: CliProgram; private _passedOptions: string[] = []; private _parserErrors = new ParserErrors(); constructor( private argv: string[] = process.argv, commandPackageDefinition: CliProgramDefinition, private runHandler: CliParsedCommandHandler, private errorHandler: CliParsedCommandHandler, private promptHandler: CliPromptHandler, private version: string | undefined, private internalOptions: CliCommandDefinitionOption[] = [], private showPrompts = false ) { this._commandPackage = CliProgram.create(commandPackageDefinition, internalOptions); if (this._commandPackage.validationErrors.length > 0) { throw new Error(`Invalid Command Definition: ${this._commandPackage.validationErrors.join(os.EOL)}`); } this._definitionsMap = this._commandPackage.commandsMap; } private findCommandDefinition = (name: string): CliCommandDefinition => { let cmdDef = this._definitionsMap.get(name); if (!cmdDef) { // if no exact match on command name, try to match on all lower case, or dash-case of command name const cmdDefs = Array.from(this._definitionsMap.values()); cmdDef = cmdDefs.find( d => dashCase(d.name).toLowerCase() === name.toLowerCase() || d.name.toLowerCase() === name.toLowerCase() ); } return cmdDef; }; private parseCommandLine = () => { let offset = 2; // parse using root and internal options this._definitionOptions = this.internalOptions.concat(this._commandPackage.options || []); let parsedMri = mri(this.argv.slice(offset), getOptionsMriOpts(this._definitionOptions)); // Loop thru possible command(s) let tryName: string; let name = ''; let i = 1; const len = parsedMri._.length + 1; for (; i < len; i++) { tryName = parsedMri._.slice(0, i).join(' '); // if (this._definitionsMap.has(tryName)) { if (this.findCommandDefinition(tryName)) { name = tryName; offset = i + 2; // argv slicer } } let cmdDef = this.findCommandDefinition(name); if (!cmdDef) { if (name) { this.pushError('command', `Unknown Command: '${name}'`); } else if (this._commandPackage.defaultCommandName) { cmdDef = this._definitionsMap.get(this._commandPackage.defaultCommandName); } else if (this._commandPackage.commands.length > 0) { this.pushError('command', `No Command Specified`); } } // this._passedOptions = passedOptionsToNames(, ; const rawPassedOptions = Object.keys(parsedMri).filter(v => v !== '_'); // if a command definition is identified, re-parse the command line with definition configurations if (cmdDef) { this._definitionOptions.push(...(cmdDef.options || [])); parsedMri = mri(this.argv.slice(offset), getOptionsMriOpts(this._definitionOptions)); this._argVals = parsedMri._; this._parsedCommand.command = cmdDef; this._parsedCommand.parsedCommandName = name; } this._passedOptions = passedOptionsToNames(rawPassedOptions, this._definitionOptions); // Collect parsed Options this._optionVals = Object.keys(parsedMri) .filter(v => v !== '_') .reduce((prev, curr) => { prev[curr] = parsedMri[curr]; return prev; }, {}); // Create a hash with all active options for final parsed output this._parsedCommand.optionDefinitions = this._definitionOptions.reduce((prev, curr) => { prev[curr.name] = curr; return prev; }, {}); }; private parseArgs = () => { const cmd = this.parsedCommand.command; const defArgs = cmd ? cmd.arguments || [] : []; const requiredDefArgs = defArgs.filter(a => !a.isOptional); const isVariadic = defArgs.length > 0 && defArgs[defArgs.length - 1].isVariadic; // check for too many args if (this._argVals.length > defArgs.length && !isVariadic) { this.pushError('argument', `Too many Arguments`); } // check for too few args if (this._argVals.length < requiredDefArgs.length) { // don't raise insufficient args if an option with a handler is set (ie help/version); if (!this.getParsedOptionCommandName()) { this.pushError('argument', `Insufficient arguments`); } } const parsedArgs: { [name: string]: CliParsedCommandDefinitionArgument } = {}; const validateArg = (val: CliValue, arg: CliCommandDefinitionArgument) => { const validation = validateCommandDefinitionParameterValue(val, arg); if (validation !== true) { const msg = typeof validation === 'string' ? validation : undefined; this.pushError('argument', `Value for argument '${arg.name}' is invalid${msg ? `: ${msg}` : ''}`); } }; let argValIndex = 0; // Loop through definition arguments, and assign each prop in definition to args value array for (let i = 0; i < defArgs.length; i++) { const defArg = defArgs[i]; if (defArg.isVariadic) { // variadic arg, must be last arg, assign remainder of command line const vals = this._argVals.slice(argValIndex).map(a => getCommandDefinitionParameterValue(a, defArg)); // validate each value vals.forEach(v => validateArg(v, defArg)); parsedArgs[defArg.name] = { ...defArg, wasPassed: true, value: vals, }; argValIndex = this._argVals.length; } else if (this._argVals.length > argValIndex) { // still arguments from command line, assign next arg to args arry // assign value from command line to args array const val = getCommandDefinitionParameterValue(this._argVals[argValIndex], defArg); // validate validateArg(val, defArg); parsedArgs[defArg.name] = { ...defArg, wasPassed: true, value: val, }; argValIndex++; } else { // no more arguments on command line, assign default parsedArgs[defArg.name] = { ...defArg, wasPassed: false, value: getCommandDefinitionParameterValue(undefined, defArg, false), }; argValIndex++; } } // create args hash with just values const args = Object.keys(parsedArgs).reduce((prev, curr) => { prev[curr] = parsedArgs[curr].value; return prev; }, {}); const transformedArgs = isFunction(cmd.transformArguments) ? cmd.transformArguments(args) : args; // re-assign parsed arg value from transformed args (transform could add or remove entries, so not necessarily 1:1) Object.keys(transformedArgs).forEach(ta => { if (parsedArgs[ta]) { parsedArgs[ta].value = transformedArgs[ta]; } }); this.parsedCommand.parsedArguments = parsedArgs; this.parsedCommand.arguments = transformedArgs; }; private parseOptions = () => { const cmd = this._parsedCommand.command; Object.keys(this._optionVals).forEach(o => { if (!this._definitionOptions.find(co => co.name === o || co.flag === o || dashCase(co.name) === o)) { this.pushError('option', `Invalid Option: ${o}`); } }); const parsedOptions: { [name: string]: CliParsedCommandDefinitionOption } = {}; this._definitionOptions.forEach(o => { const wasPassed = this._passedOptions.indexOf(o.name) >= 0; parsedOptions[o.name] = { ...o, wasPassed, value: getCommandDefinitionParameterValue(this._optionVals[o.name], o, wasPassed), }; if (wasPassed) { // if option set on command line, and not a boolean option, make sure there is a value if (o.valueType && o.valueType !== 'boolean' && !parsedOptions[o.name].value) { this.pushError('option', `Value for option '${o.name}' not specified`); } else { const validation = validateCommandDefinitionParameterValue(parsedOptions[o.name].value, o); if (validation !== true) { const msg = typeof validation === 'string' ? validation : undefined; this.pushError('option', `Value for option '${o.name}' is invalid${msg ? `: ${msg}` : ''}`); } } } }); // create args hash with just values const options = Object.keys(parsedOptions).reduce((prev, curr) => { prev[curr] = parsedOptions[curr].value; return prev; }, {}); const transformedOptions = cmd ? isFunction(cmd.transformOptions) ? cmd.transformOptions(options) : options : options; // re-assign parsed option value from transformed options (transform could add or remove entries, so not necessarily 1:1) Object.keys(transformedOptions).forEach(to => { if (parsedOptions[to]) { parsedOptions[to].value = transformedOptions[to]; } }); this._parsedCommand.parsedOptions = parsedOptions; this._parsedCommand.options = transformedOptions; }; private pushError = (type: ParserErrorType, message: string) => { this._parserErrors.push(type, message); }; private getEnv = (): { [name: string]: string } => { const cmd = this.parsedCommand.command; const env: { [name: string]: string } = {}; if (cmd && cmd.env) { return cmd.env; } return env; }; private parseArgsAndOptions = () => { this.parseOptions(); if (this._parsedCommand.command) { this.parseArgs(); } }; public get parsedCommand(): ParsedCliCommand { if (!this._parsedCommand) { this._parsedCommand = {}; this._parsedCommand.errors = this._parserErrors; this._parsedCommand.program = this._commandPackage; this._parsedCommand.run = this.runner; this._parsedCommand.version = this.version; this.parseCommandLine(); this.parseArgsAndOptions(); this._parsedCommand.env = this.getEnv(); } return this._parsedCommand as ParsedCliCommand; } private getParsedOptionCommandName = () => { const parsed = this._parsedCommand; return Object.keys(parsed.options) .filter(o => typeof parsed.options[o] !== 'undefined') .find(o => !!(parsed.optionDefinitions[o] && parsed.optionDefinitions[o].commandHandler)); }; private setProcessEnvFromParsedCommand = () => { Object.keys(this.parsedCommand.env).forEach(k => { process.env[k] = this.parsedCommand.env[k]; }); }; private promptArgsToArgVals = (promptArgs: object): string[] => { const cmd = this.parsedCommand.command; const defArgs = cmd ? cmd.arguments || [] : []; const argVals: string[] = []; // Loop through definition arguments, and assign value from promptArgs Hash for (let i = 0; i < defArgs.length; i++) { const defArg = defArgs[i]; const promptArgVal = promptArgs[defArg.name]; if (defArg.isVariadic) { const promptArgValAry = Array.isArray(promptArgVal) ? promptArgVal : [promptArgVal]; argVals.push(...promptArgValAry); } else { argVals.push(promptArgVal); } } return argVals; }; private runAndParsePrompt = async () => { if (this.showPrompts) { const parsedCommand = this._parsedCommand as ParsedCliCommand; // call out to prompt handler to get args and options const promptResult = await this.promptHandler(parsedCommand); // reset arg/option errors this._parserErrors.clearErrors('argument'); this._parserErrors.clearErrors('option'); // convert prompt arg values to command line array to re-parse them this._argVals = this.promptArgsToArgVals(promptResult.arguments); // assign prompt options to _optionVals to re-parse them this._optionVals = promptResult.options; // re-parse args and options from prompt values this.parseArgsAndOptions(); } }; private runner = async (): Promise => { const parsedCommand = this._parsedCommand as ParsedCliCommand; // determine if an option is set to call a handler (in to over-ride the command def handler) const commandOption = this.getParsedOptionCommandName(); let exitCode; if (commandOption) { // a selected option has it's own handler, set it up and run the option's handler (in lieu of the command's handler) const commandOptionHandler = parsedCommand.optionDefinitions[commandOption].commandHandler; exitCode = await Promise.resolve(commandOptionHandler(parsedCommand)); } else { // delete options that are commands themselves (i.e. '--help', '--version') Object.keys(parsedCommand.options) .filter( o => !!(parsedCommand.optionDefinitions[o] && parsedCommand.optionDefinitions[o].commandHandler) ) .forEach(o => { delete parsedCommand.options[o]; }); await this.runAndParsePrompt(); if (parsedCommand.errors.hasErrors) { // parsed errors, call the errorHandler exitCode = (await Promise.resolve(this.errorHandler(parsedCommand))) || 1; } else { // set process env from command env settings this.setProcessEnvFromParsedCommand(); // call the handler with parsed command exitCode = await this.runHandler(parsedCommand); } } return { parsedCommand, exitCode: typeof exitCode == 'number' ? exitCode : 0, }; }; }