/
CommandLineParser.ts
293 lines (256 loc) · 10.2 KB
/
CommandLineParser.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.
import * as argparse from 'argparse';
import colors from 'colors';
import type { CommandLineAction } from './CommandLineAction';
import type { AliasCommandLineAction } from './AliasCommandLineAction';
import { CommandLineParameterProvider, type ICommandLineParserData } from './CommandLineParameterProvider';
import { CommandLineParserExitError, CustomArgumentParser } from './CommandLineParserExitError';
import { TabCompleteAction } from './TabCompletionAction';
/**
* Options for the {@link CommandLineParser} constructor.
* @public
*/
export interface ICommandLineParserOptions {
/**
* The name of your tool when invoked from the command line
*/
toolFilename: string;
/**
* General documentation that is included in the "--help" main page
*/
toolDescription: string;
/**
* An optional string to append at the end of the "--help" main page. If not provided, an epilog
* will be automatically generated based on the toolFilename.
*/
toolEpilog?: string;
/**
* Set to true to auto-define a tab completion action. False by default.
*/
enableTabCompletionAction?: boolean;
}
/**
* The "argparse" library is a relatively advanced command-line parser with features such
* as word-wrapping and intelligible error messages (that are lacking in other similar
* libraries such as commander, yargs, and nomnom). Unfortunately, its ruby-inspired API
* is awkward to use. The abstract base classes CommandLineParser and CommandLineAction
* provide a wrapper for "argparse" that makes defining and consuming arguments quick
* and simple, and enforces that appropriate documentation is provided for each parameter.
*
* @public
*/
export abstract class CommandLineParser extends CommandLineParameterProvider {
/**
* Reports which CommandLineAction was specified on the command line.
* @remarks
* The value will be assigned before onExecute() is invoked.
*/
public selectedAction: CommandLineAction | undefined;
private readonly _argumentParser: argparse.ArgumentParser;
private _actionsSubParser: argparse.SubParser | undefined;
private readonly _options: ICommandLineParserOptions;
private readonly _actions: CommandLineAction[];
private readonly _actionsByName: Map<string, CommandLineAction>;
private _executed: boolean = false;
private _tabCompleteActionWasAdded: boolean = false;
public constructor(options: ICommandLineParserOptions) {
super();
this._options = options;
this._actions = [];
this._actionsByName = new Map<string, CommandLineAction>();
this._argumentParser = new CustomArgumentParser({
addHelp: true,
prog: this._options.toolFilename,
description: this._options.toolDescription,
epilog: colors.bold(
this._options.toolEpilog ??
`For detailed help about a specific command, use: ${this._options.toolFilename} <command> -h`
)
});
this.onDefineParameters?.();
}
/**
* Returns the list of actions that were defined for this CommandLineParser object.
*/
public get actions(): ReadonlyArray<CommandLineAction> {
return this._actions;
}
/**
* Defines a new action that can be used with the CommandLineParser instance.
*/
public addAction(action: CommandLineAction): void {
if (!this._actionsSubParser) {
this._actionsSubParser = this._argumentParser.addSubparsers({
metavar: '<command>',
dest: 'action'
});
}
action._buildParser(this._actionsSubParser);
this._actions.push(action);
this._actionsByName.set(action.actionName, action);
}
/**
* Retrieves the action with the specified name. If no matching action is found,
* an exception is thrown.
*/
public getAction(actionName: string): CommandLineAction {
const action: CommandLineAction | undefined = this.tryGetAction(actionName);
if (!action) {
throw new Error(`The action "${actionName}" was not defined`);
}
return action;
}
/**
* Retrieves the action with the specified name. If no matching action is found,
* undefined is returned.
*/
public tryGetAction(actionName: string): CommandLineAction | undefined {
return this._actionsByName.get(actionName);
}
/**
* The program entry point will call this method to begin parsing command-line arguments
* and executing the corresponding action.
*
* @remarks
* The returned promise will never reject: If an error occurs, it will be printed
* to stderr, process.exitCode will be set to 1, and the promise will resolve to false.
* This simplifies the most common usage scenario where the program entry point doesn't
* want to be involved with the command-line logic, and will discard the promise without
* a then() or catch() block.
*
* If your caller wants to trap and handle errors, use {@link CommandLineParser.executeWithoutErrorHandling}
* instead.
*
* @param args - the command-line arguments to be parsed; if omitted, then
* the process.argv will be used
*/
public async execute(args?: string[]): Promise<boolean> {
if (this._options.enableTabCompletionAction && !this._tabCompleteActionWasAdded) {
this.addAction(new TabCompleteAction(this.actions, this.parameters));
this._tabCompleteActionWasAdded = true;
}
try {
await this.executeWithoutErrorHandling(args);
return true;
} catch (err) {
if (err instanceof CommandLineParserExitError) {
// executeWithoutErrorHandling() handles the successful cases,
// so here we can assume err has a nonzero exit code
if (err.message) {
console.error(err.message);
}
if (!process.exitCode) {
process.exitCode = err.exitCode;
}
} else {
let message: string = ((err as Error).message || 'An unknown error occurred').trim();
// If the message doesn't already start with "Error:" then add a prefix
if (!/^(error|internal error|warning)\b/i.test(message)) {
message = 'Error: ' + message;
}
console.error();
console.error(colors.red(message));
if (!process.exitCode) {
process.exitCode = 1;
}
}
return false;
}
}
/**
* This is similar to {@link CommandLineParser.execute}, except that execution errors
* simply cause the promise to reject. It is the caller's responsibility to trap
*/
public async executeWithoutErrorHandling(args?: string[]): Promise<void> {
try {
if (this._executed) {
// In the future we could allow the same parser to be invoked multiple times
// with different arguments. We'll do that work as soon as someone encounters
// a real world need for it.
throw new Error('execute() was already called for this parser instance');
}
this._executed = true;
this._validateDefinitions();
// Register the parameters before we print help or parse the CLI
this._registerDefinedParameters();
if (!args) {
// 0=node.exe, 1=script name
args = process.argv.slice(2);
}
if (this.actions.length > 0) {
if (args.length === 0) {
// Parsers that use actions should print help when 0 args are provided. Allow
// actionless parsers to continue on zero args.
this._argumentParser.printHelp();
return;
}
// Alias actions may provide a list of default params to add after the action name.
// Since we don't know which params are required and which are optional, perform a
// manual search for the action name to obtain the default params and insert them if
// any are found. We will guess that the action name is the first arg that doesn't
// start with a hyphen.
const actionNameIndex: number | undefined = args.findIndex((x) => !x.startsWith('-'));
if (actionNameIndex !== undefined) {
const actionName: string = args[actionNameIndex];
const action: CommandLineAction | undefined = this.tryGetAction(actionName);
const aliasAction: AliasCommandLineAction | undefined = action as AliasCommandLineAction;
if (aliasAction?.defaultParameters?.length) {
const insertIndex: number = actionNameIndex + 1;
args = args.slice(0, insertIndex).concat(aliasAction.defaultParameters, args.slice(insertIndex));
}
}
}
const data: ICommandLineParserData = this._argumentParser.parseArgs(args);
this._processParsedData(this._options, data);
this.selectedAction = this.tryGetAction(data.action);
if (this.actions.length > 0 && !this.selectedAction) {
const actions: string[] = this.actions.map((x) => x.actionName);
throw new Error(`An action must be specified (${actions.join(', ')})`);
}
this.selectedAction?._processParsedData(this._options, data);
return this.onExecute();
} catch (err) {
if (err instanceof CommandLineParserExitError) {
if (!err.exitCode) {
// non-error exit modeled using exception handling
if (err.message) {
console.log(err.message);
}
return;
}
}
throw err;
}
}
/** @internal */
public _registerDefinedParameters(): void {
super._registerDefinedParameters();
for (const action of this._actions) {
action._registerDefinedParameters();
}
}
private _validateDefinitions(): void {
if (this.remainder && this.actions.length > 0) {
// This is apparently not supported by argparse
throw new Error('defineCommandLineRemainder() cannot be called for a CommandLineParser with actions');
}
}
/**
* {@inheritDoc CommandLineParameterProvider._getArgumentParser}
* @internal
*/
protected _getArgumentParser(): argparse.ArgumentParser {
// override
return this._argumentParser;
}
/**
* This hook allows the subclass to perform additional operations before or after
* the chosen action is executed.
*/
protected async onExecute(): Promise<void> {
if (this.selectedAction) {
await this.selectedAction._execute();
}
}
}