UNPKG

16.5 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 { 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 (let i = 0; i < defArgs.length; i++) {
157 const defArg = defArgs[i];
158 if (defArg.isVariadic) {
159 // variadic arg, must be last arg, assign remainder of command line
160 const vals = this._argVals.slice(argValIndex).map(a => getCommandDefinitionParameterValue(a, defArg));
161 // validate each value
162 vals.forEach(v => validateArg(v, defArg));
163 parsedArgs[defArg.name] = {
164 ...defArg,
165 wasPassed: true,
166 value: vals,
167 };
168 argValIndex = this._argVals.length;
169 } else if (this._argVals.length > argValIndex) {
170 // still arguments from command line, assign next arg to args arry
171 // assign value from command line to args array
172 const val = getCommandDefinitionParameterValue(this._argVals[argValIndex], defArg);
173 // validate
174 validateArg(val, defArg);
175 parsedArgs[defArg.name] = {
176 ...defArg,
177 wasPassed: true,
178 value: val,
179 };
180 argValIndex++;
181 } else {
182 // no more arguments on command line, assign default
183 parsedArgs[defArg.name] = {
184 ...defArg,
185 wasPassed: false,
186 value: getCommandDefinitionParameterValue(undefined, defArg, false),
187 };
188 argValIndex++;
189 }
190 }
191
192 // create args hash with just values
193 const args = Object.keys(parsedArgs).reduce((prev, curr) => {
194 prev[curr] = parsedArgs[curr].value;
195 return prev;
196 }, {});
197
198 const transformedArgs = isFunction(cmd.transformArguments) ? cmd.transformArguments(args) : args;
199
200 // re-assign parsed arg value from transformed args (transform could add or remove entries, so not necessarily 1:1)
201 Object.keys(transformedArgs).forEach(ta => {
202 if (parsedArgs[ta]) {
203 parsedArgs[ta].value = transformedArgs[ta];
204 }
205 });
206
207 this.parsedCommand.parsedArguments = parsedArgs;
208 this.parsedCommand.arguments = transformedArgs;
209 };
210
211 private parseOptions = () => {
212 const cmd = this._parsedCommand.command;
213 Object.keys(this._optionVals).forEach(o => {
214 if (!this._definitionOptions.find(co => co.name === o || co.flag === o || dashCase(co.name) === o)) {
215 this.pushError('option', `Invalid Option: ${o}`);
216 }
217 });
218
219 const parsedOptions: { [name: string]: CliParsedCommandDefinitionOption } = {};
220
221 this._definitionOptions.forEach(o => {
222 const wasPassed = this._passedOptions.indexOf(o.name) >= 0;
223 parsedOptions[o.name] = {
224 ...o,
225 wasPassed,
226 value: getCommandDefinitionParameterValue(this._optionVals[o.name], o, wasPassed),
227 };
228 if (wasPassed) {
229 // if option set on command line, and not a boolean option, make sure there is a value
230 if (o.valueType && o.valueType !== 'boolean' && !parsedOptions[o.name].value) {
231 this.pushError('option', `Value for option '${o.name}' not specified`);
232 } else {
233 const validation = validateCommandDefinitionParameterValue(parsedOptions[o.name].value, o);
234 if (validation !== true) {
235 const msg = typeof validation === 'string' ? validation : undefined;
236 this.pushError('option', `Value for option '${o.name}' is invalid${msg ? `: ${msg}` : ''}`);
237 }
238 }
239 }
240 });
241
242 // create args hash with just values
243 const options = Object.keys(parsedOptions).reduce((prev, curr) => {
244 prev[curr] = parsedOptions[curr].value;
245 return prev;
246 }, {});
247
248 const transformedOptions = cmd
249 ? isFunction(cmd.transformOptions)
250 ? cmd.transformOptions(options)
251 : options
252 : options;
253
254 // re-assign parsed option value from transformed options (transform could add or remove entries, so not necessarily 1:1)
255 Object.keys(transformedOptions).forEach(to => {
256 if (parsedOptions[to]) {
257 parsedOptions[to].value = transformedOptions[to];
258 }
259 });
260
261 this._parsedCommand.parsedOptions = parsedOptions;
262 this._parsedCommand.options = transformedOptions;
263 };
264
265 private pushError = (type: ParserErrorType, message: string) => {
266 this._parserErrors.push(type, message);
267 };
268
269 private getEnv = (): { [name: string]: string } => {
270 const cmd = this.parsedCommand.command;
271 const env: { [name: string]: string } = {};
272 if (cmd && cmd.env) {
273 return cmd.env;
274 }
275 return env;
276 };
277
278 private parseArgsAndOptions = () => {
279 this.parseOptions();
280 if (this._parsedCommand.command) {
281 this.parseArgs();
282 }
283 };
284
285 public get parsedCommand(): ParsedCliCommand {
286 if (!this._parsedCommand) {
287 this._parsedCommand = {};
288 this._parsedCommand.errors = this._parserErrors;
289 this._parsedCommand.program = this._commandPackage;
290 this._parsedCommand.run = this.runner;
291 this._parsedCommand.version = this.version;
292 this.parseCommandLine();
293 this.parseArgsAndOptions();
294 this._parsedCommand.env = this.getEnv();
295 }
296 return this._parsedCommand as ParsedCliCommand;
297 }
298
299 private getParsedOptionCommandName = () => {
300 const parsed = this._parsedCommand;
301 return Object.keys(parsed.options)
302 .filter(o => typeof parsed.options[o] !== 'undefined')
303 .find(o => !!(parsed.optionDefinitions[o] && parsed.optionDefinitions[o].commandHandler));
304 };
305
306 private setProcessEnvFromParsedCommand = () => {
307 Object.keys(this.parsedCommand.env).forEach(k => {
308 process.env[k] = this.parsedCommand.env[k];
309 });
310 };
311
312 private promptArgsToArgVals = (promptArgs: object): string[] => {
313 const cmd = this.parsedCommand.command;
314 const defArgs = cmd ? cmd.arguments || [] : [];
315 const argVals: string[] = [];
316
317 // Loop through definition arguments, and assign value from promptArgs Hash
318 for (let i = 0; i < defArgs.length; i++) {
319 const defArg = defArgs[i];
320 const promptArgVal = promptArgs[defArg.name];
321 if (defArg.isVariadic) {
322 const promptArgValAry = Array.isArray(promptArgVal) ? promptArgVal : [promptArgVal];
323 argVals.push(...promptArgValAry);
324 } else {
325 argVals.push(promptArgVal);
326 }
327 }
328
329 return argVals;
330 };
331
332 private runAndParsePrompt = async () => {
333 if (this.showPrompts) {
334 const parsedCommand = this._parsedCommand as ParsedCliCommand;
335 // call out to prompt handler to get args and options
336 const promptResult = await this.promptHandler(parsedCommand);
337 // reset arg/option errors
338 this._parserErrors.clearErrors('argument');
339 this._parserErrors.clearErrors('option');
340 // convert prompt arg values to command line array to re-parse them
341 this._argVals = this.promptArgsToArgVals(promptResult.arguments);
342 // assign prompt options to _optionVals to re-parse them
343 this._optionVals = promptResult.options;
344 // re-parse args and options from prompt values
345 this.parseArgsAndOptions();
346 }
347 };
348
349 private runner = async (): Promise<CliRunResult> => {
350 const parsedCommand = this._parsedCommand as ParsedCliCommand;
351 // determine if an option is set to call a handler (in to over-ride the command def handler)
352 const commandOption = this.getParsedOptionCommandName();
353 let exitCode;
354 if (commandOption) {
355 // a selected option has it's own handler, set it up and run the option's handler (in lieu of the command's handler)
356 const commandOptionHandler = parsedCommand.optionDefinitions[commandOption].commandHandler;
357 exitCode = await Promise.resolve(commandOptionHandler(parsedCommand));
358 } else {
359 // delete options that are commands themselves (i.e. '--help', '--version')
360 Object.keys(parsedCommand.options)
361 .filter(
362 o => !!(parsedCommand.optionDefinitions[o] && parsedCommand.optionDefinitions[o].commandHandler)
363 )
364 .forEach(o => {
365 delete parsedCommand.options[o];
366 });
367
368 await this.runAndParsePrompt();
369
370 if (parsedCommand.errors.hasErrors) {
371 // parsed errors, call the errorHandler
372 exitCode = (await Promise.resolve(this.errorHandler(parsedCommand))) || 1;
373 } else {
374 // set process env from command env settings
375 this.setProcessEnvFromParsedCommand();
376
377 // call the handler with parsed command
378 exitCode = await this.runHandler(parsedCommand);
379 }
380 }
381
382 return {
383 parsedCommand,
384 exitCode: typeof exitCode == 'number' ? exitCode : 0,
385 };
386 };
387}
388
\No newline at end of file