UNPKG

17.2 kBPlain TextView Raw
1import * as mri from 'mri';
2import * as os from 'os';
3import { getCommandDefinitionParameterValue, validateCommandDefinitionParameterValue } from './parameters';
4import { ParserErrors } from './ParserErrors';
5import { CliProgram } from './program';
6import { CliPromptHandler, CliPromptResult } from './prompt';
7import {
8 CliCommandDefinition,
9 CliCommandDefinitionArgument,
10 CliCommandDefinitionOption,
11 CliCommandDefinitionParameter,
12 CliParsedCommandDefinitionArgument,
13 CliParsedCommandDefinitionOption,
14 CliParsedCommandHandler,
15 CliProgramDefinition,
16 CliRunResult,
17 CliValue,
18 CliValueValidatorFunction,
19 ParsedCliCommand,
20 ParserErrorType,
21} from './types';
22import { camelCase, dashCase, getOptionsMriOpts, isFunction, passedOptionsToNames } from './utils';
23
24export class Parser {
25 private _parsedCommand: Partial<ParsedCliCommand>;
26 private _definitionsMap: Map<string, CliCommandDefinition> = new Map();
27 private _argVals: string[] = [];
28 private _optionVals: { [name: string]: CliValue } = {};
29 private _definitionOptions: CliCommandDefinitionOption[];
30 private _commandPackage: CliProgram;
31 private _passedOptions: string[] = [];
32 private _parserErrors = new ParserErrors();
33
34 constructor(
35 private argv: string[] = process.argv,
36 commandPackageDefinition: CliProgramDefinition,
37 private runHandler: CliParsedCommandHandler,
38 private errorHandler: CliParsedCommandHandler,
39 private promptHandler: CliPromptHandler,
40 private version: string | undefined,
41 private internalOptions: CliCommandDefinitionOption[] = [],
42 private showPrompts = false
43 ) {
44 this._commandPackage = CliProgram.create(commandPackageDefinition, internalOptions);
45 if (this._commandPackage.validationErrors.length > 0) {
46 throw new Error(`Invalid Command Definition: ${this._commandPackage.validationErrors.join(os.EOL)}`);
47 }
48 this._definitionsMap = this._commandPackage.commandsMap;
49 }
50
51 private findCommandDefinition = (name: string): CliCommandDefinition => {
52 let cmdDef = this._definitionsMap.get(name);
53 if (!cmdDef) {
54 // if no exact match on command name, try to match on all lower case, or dash-case of command name
55 const cmdDefs = Array.from(this._definitionsMap.values());
56 cmdDef = cmdDefs.find(
57 d =>
58 dashCase(d.name).toLowerCase() === name.toLowerCase() || d.name.toLowerCase() === name.toLowerCase()
59 );
60 }
61 return cmdDef;
62 };
63
64 private parseCommandLine = () => {
65 let offset = 2;
66
67 // parse using root and internal options
68 this._definitionOptions = this.internalOptions.concat(this._commandPackage.options || []);
69
70 let parsedMri = mri(this.argv.slice(offset), getOptionsMriOpts(this._definitionOptions));
71
72 // Loop thru possible command(s)
73 let tryName: string;
74 let name = '';
75 let i = 1;
76 const len = parsedMri._.length + 1;
77 for (; i < len; i++) {
78 tryName = parsedMri._.slice(0, i).join(' ');
79 // if (this._definitionsMap.has(tryName)) {
80 if (this.findCommandDefinition(tryName)) {
81 name = tryName;
82 offset = i + 2; // argv slicer
83 }
84 }
85
86 let cmdDef = this.findCommandDefinition(name);
87
88 if (!cmdDef) {
89 if (name) {
90 this.pushError('command', `Unknown Command: '${name}'`);
91 } else if (this._commandPackage.defaultCommandName) {
92 cmdDef = this._definitionsMap.get(this._commandPackage.defaultCommandName);
93 } else if (this._commandPackage.commands.length > 0) {
94 this.pushError('command', `No Command Specified`);
95 }
96 }
97
98 // this._passedOptions = passedOptionsToNames(, ;
99 const rawPassedOptions = Object.keys(parsedMri).filter(v => v !== '_');
100
101 // if a command definition is identified, re-parse the command line with definition configurations
102 if (cmdDef) {
103 this._definitionOptions.push(...(cmdDef.options || []));
104 parsedMri = mri(this.argv.slice(offset), getOptionsMriOpts(this._definitionOptions));
105 this._argVals = parsedMri._;
106 this._parsedCommand.command = cmdDef;
107 this._parsedCommand.parsedCommandName = name;
108 }
109
110 this._passedOptions = passedOptionsToNames(rawPassedOptions, this._definitionOptions);
111
112 // Collect parsed Options
113 this._optionVals = Object.keys(parsedMri)
114 .filter(v => v !== '_')
115 .reduce((prev, curr) => {
116 prev[curr] = parsedMri[curr];
117 return prev;
118 }, {});
119
120 // Create a hash with all active options for final parsed output
121 this._parsedCommand.optionDefinitions = this._definitionOptions.reduce((prev, curr) => {
122 prev[curr.name] = curr;
123 return prev;
124 }, {});
125 };
126
127 private parseArgs = () => {
128 const cmd = this.parsedCommand.command;
129 const defArgs = cmd ? cmd.arguments || [] : [];
130 const requiredDefArgs = defArgs.filter(a => !a.isOptional);
131 const isVariadic = defArgs.length > 0 && defArgs[defArgs.length - 1].isVariadic;
132
133 // check for too many args
134 if (this._argVals.length > defArgs.length && !isVariadic) {
135 this.pushError('argument', `Too many Arguments`);
136 }
137
138 // check for too few args
139 if (this._argVals.length < requiredDefArgs.length) {
140 // don't raise insufficient args if an option with a handler is set (ie help/version);
141 if (!this.getParsedOptionCommandName()) {
142 this.pushError('argument', `Insufficient arguments`);
143 }
144 }
145
146 const parsedArgs: { [name: string]: CliParsedCommandDefinitionArgument } = {};
147 const validateArg = (val: CliValue, arg: CliCommandDefinitionArgument) => {
148 const validation = validateCommandDefinitionParameterValue(val, arg);
149 if (validation !== true) {
150 const msg = typeof validation === 'string' ? validation : undefined;
151 this.pushError('argument', `Value for argument '${arg.name}' is invalid${msg ? `: ${msg}` : ''}`);
152 }
153 };
154 let argValIndex = 0;
155 // Loop through definition arguments, and assign each prop in definition to args value array
156 for (const defArg of defArgs) {
157 if (defArg.isVariadic) {
158 // variadic arg, must be last arg, assign remainder of command line
159 const vals = this._argVals.slice(argValIndex).map(a => getCommandDefinitionParameterValue(a, defArg));
160 // validate each value
161 vals.forEach(v => validateArg(v, defArg));
162 parsedArgs[defArg.name] = {
163 ...defArg,
164 wasPassed: true,
165 value: vals,
166 };
167 argValIndex = this._argVals.length;
168 } else if (this._argVals.length > argValIndex) {
169 // still arguments from command line, assign next arg to args arry
170 // assign value from command line to args array
171 const val = getCommandDefinitionParameterValue(this._argVals[argValIndex], defArg);
172 // validate
173 validateArg(val, defArg);
174 parsedArgs[defArg.name] = {
175 ...defArg,
176 wasPassed: true,
177 value: val,
178 };
179 argValIndex++;
180 } else {
181 // no more arguments on command line, assign default
182 parsedArgs[defArg.name] = {
183 ...defArg,
184 wasPassed: false,
185 value: getCommandDefinitionParameterValue(undefined, defArg, false),
186 };
187 argValIndex++;
188 }
189 }
190
191 // create args hash with just values
192 const args = Object.keys(parsedArgs).reduce((prev, curr) => {
193 prev[curr] = parsedArgs[curr].value;
194 return prev;
195 }, {});
196
197 const transformedArgs = isFunction(cmd.transformArguments) ? cmd.transformArguments(args) : args;
198
199 // re-assign parsed arg value from transformed args (transform could add or remove entries, so not necessarily 1:1)
200 Object.keys(transformedArgs).forEach(ta => {
201 if (parsedArgs[ta]) {
202 parsedArgs[ta].value = transformedArgs[ta];
203 }
204 });
205
206 this.parsedCommand.parsedArguments = parsedArgs;
207 this.parsedCommand.arguments = transformedArgs;
208 };
209
210 private parseOptions = () => {
211 const cmd = this._parsedCommand.command;
212 const dynamicOptions: { [name: string]: any } = {};
213 Object.keys(this._optionVals).forEach(o => {
214 if (!this._definitionOptions.find(co => co.name === o || co.flag === o || dashCase(co.name) === o)) {
215 // if option not defined, add as dynamic if allowed, otherwise add as error
216 if (cmd && cmd.allowDynamicOptions) {
217 dynamicOptions[camelCase(o)] = this._optionVals[o];
218 } else {
219 this.pushError('option', `Invalid Option: ${o}`);
220 }
221 }
222 });
223
224 const parsedOptions: { [name: string]: CliParsedCommandDefinitionOption } = {};
225
226 this._definitionOptions.forEach(o => {
227 const wasPassed = this._passedOptions.indexOf(o.name) >= 0;
228 parsedOptions[o.name] = {
229 ...o,
230 wasPassed,
231 value: getCommandDefinitionParameterValue(this._optionVals[o.name], o, wasPassed),
232 };
233 if (wasPassed) {
234 // if option set on command line, and not a boolean option, make sure there is a value
235 if (o.valueType && o.valueType !== 'boolean' && !parsedOptions[o.name].value) {
236 this.pushError('option', `Value for option '${o.name}' not specified`);
237 } else {
238 const validation = validateCommandDefinitionParameterValue(parsedOptions[o.name].value, o);
239 if (validation !== true) {
240 const msg = typeof validation === 'string' ? validation : undefined;
241 this.pushError('option', `Value for option '${o.name}' is invalid${msg ? `: ${msg}` : ''}`);
242 }
243 }
244 }
245 });
246
247 // add any dynamic options to parsedOptions
248 if (cmd && cmd.allowDynamicOptions) {
249 Object.keys(dynamicOptions).forEach(dyo => {
250 parsedOptions[dyo] = {
251 name: dyo,
252 wasPassed: true,
253 value: dynamicOptions[dyo],
254 };
255 });
256 }
257
258 // create options hash with just values
259 const options = Object.keys(parsedOptions).reduce((prev, curr) => {
260 prev[curr] = parsedOptions[curr].value;
261 return prev;
262 }, {});
263
264 const transformedOptions = cmd
265 ? isFunction(cmd.transformOptions)
266 ? cmd.transformOptions(options)
267 : options
268 : options;
269
270 // re-assign parsed option value from transformed options (transform could add or remove entries, so not necessarily 1:1)
271 Object.keys(transformedOptions).forEach(to => {
272 if (parsedOptions[to]) {
273 parsedOptions[to].value = transformedOptions[to];
274 }
275 });
276
277 this._parsedCommand.parsedOptions = parsedOptions;
278 this._parsedCommand.options = transformedOptions;
279 this._parsedCommand.dynamicOptions = dynamicOptions;
280 };
281
282 private pushError = (type: ParserErrorType, message: string) => {
283 this._parserErrors.push(type, message);
284 };
285
286 private getEnv = (): { [name: string]: string } => {
287 const cmd = this.parsedCommand.command;
288 const env: { [name: string]: string } = {};
289 if (cmd && cmd.env) {
290 return cmd.env;
291 }
292 return env;
293 };
294
295 private parseArgsAndOptions = () => {
296 this.parseOptions();
297 if (this._parsedCommand.command) {
298 this.parseArgs();
299 }
300 };
301
302 public get parsedCommand(): ParsedCliCommand {
303 if (!this._parsedCommand) {
304 this._parsedCommand = {};
305 this._parsedCommand.errors = this._parserErrors;
306 this._parsedCommand.program = this._commandPackage;
307 this._parsedCommand.run = this.runner;
308 this._parsedCommand.version = this.version;
309 this.parseCommandLine();
310 this.parseArgsAndOptions();
311 this._parsedCommand.env = this.getEnv();
312 }
313 return this._parsedCommand as ParsedCliCommand;
314 }
315
316 private getParsedOptionCommandName = () => {
317 const parsed = this._parsedCommand;
318 return Object.keys(parsed.options)
319 .filter(o => typeof parsed.options[o] !== 'undefined')
320 .find(o => !!(parsed.optionDefinitions[o] && parsed.optionDefinitions[o].commandHandler));
321 };
322
323 private setProcessEnvFromParsedCommand = () => {
324 Object.keys(this.parsedCommand.env).forEach(k => {
325 process.env[k] = this.parsedCommand.env[k];
326 });
327 };
328
329 private promptArgsToArgVals = (promptArgs: object): string[] => {
330 const cmd = this.parsedCommand.command;
331 const defArgs = cmd ? cmd.arguments || [] : [];
332 const argVals: string[] = [];
333
334 // Loop through definition arguments, and assign value from promptArgs Hash
335 for (const defArg of defArgs) {
336 const promptArgVal = promptArgs[defArg.name];
337 if (defArg.isVariadic) {
338 const promptArgValAry = Array.isArray(promptArgVal) ? promptArgVal : [promptArgVal];
339 argVals.push(...promptArgValAry);
340 } else {
341 argVals.push(promptArgVal);
342 }
343 }
344
345 return argVals;
346 };
347
348 private runAndParsePrompt = async () => {
349 if (this.showPrompts) {
350 const parsedCommand = this._parsedCommand as ParsedCliCommand;
351 // call out to prompt handler to get args and options
352 const promptResult = await this.promptHandler(parsedCommand);
353 // reset arg/option errors
354 this._parserErrors.clearErrors('argument');
355 this._parserErrors.clearErrors('option');
356 // convert prompt arg values to command line array to re-parse them
357 this._argVals = this.promptArgsToArgVals(promptResult.arguments);
358 // assign prompt options to _optionVals to re-parse them
359 this._optionVals = promptResult.options;
360 // re-parse args and options from prompt values
361 this.parseArgsAndOptions();
362 }
363 };
364
365 private runner = async (): Promise<CliRunResult> => {
366 const parsedCommand = this._parsedCommand as ParsedCliCommand;
367 // determine if an option is set to call a handler (in to over-ride the command def handler)
368 const commandOption = this.getParsedOptionCommandName();
369 let exitCode;
370 if (commandOption) {
371 // a selected option has it's own handler, set it up and run the option's handler (in lieu of the command's handler)
372 const commandOptionHandler = parsedCommand.optionDefinitions[commandOption].commandHandler;
373 exitCode = await Promise.resolve(commandOptionHandler(parsedCommand));
374 } else {
375 // delete options that are commands themselves (i.e. '--help', '--version')
376 Object.keys(parsedCommand.options)
377 .filter(
378 o => !!(parsedCommand.optionDefinitions[o] && parsedCommand.optionDefinitions[o].commandHandler)
379 )
380 .forEach(o => {
381 delete parsedCommand.options[o];
382 });
383
384 await this.runAndParsePrompt();
385
386 if (parsedCommand.errors.hasErrors) {
387 // parsed errors, call the errorHandler
388 exitCode = (await Promise.resolve(this.errorHandler(parsedCommand))) || 1;
389 } else {
390 // set process env from command env settings
391 this.setProcessEnvFromParsedCommand();
392
393 // call the handler with parsed command
394 exitCode = await this.runHandler(parsedCommand);
395 }
396 }
397
398 return {
399 parsedCommand,
400 exitCode: typeof exitCode === 'number' ? exitCode : 0,
401 };
402 };
403}
404
\No newline at end of file