Skip to content

Commit 49423a2

Browse files
authored
Add save/restore state to allow multiple calls to parse (#2299)
1 parent 497c11d commit 49423a2

File tree

5 files changed

+295
-2
lines changed

5 files changed

+295
-2
lines changed

Readme.md

-2
Original file line numberDiff line numberDiff line change
@@ -959,8 +959,6 @@ program.parse(['--port', '80'], { from: 'user' }); // just user supplied argumen
959959

960960
Use parseAsync instead of parse if any of your action handlers are async.
961961

962-
If you want to parse multiple times, create a new program each time. Calling parse does not clear out any previous state.
963-
964962
### Parsing Configuration
965963

966964
If the default parsing does not suit your needs, there are some behaviours to support other usage patterns.

lib/command.js

+55
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ class Command extends EventEmitter {
5555
/** @type {(boolean | string)} */
5656
this._showHelpAfterError = false;
5757
this._showSuggestionAfterError = true;
58+
this._savedState = null; // used in save/restoreStateBeforeParse
5859

5960
// see configureOutput() for docs
6061
this._outputConfiguration = {
@@ -1069,6 +1070,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
10691070
*/
10701071

10711072
parse(argv, parseOptions) {
1073+
this._prepareForParse();
10721074
const userArgs = this._prepareUserArgs(argv, parseOptions);
10731075
this._parseCommand([], userArgs);
10741076

@@ -1097,12 +1099,62 @@ Expecting one of '${allowedValues.join("', '")}'`);
10971099
*/
10981100

10991101
async parseAsync(argv, parseOptions) {
1102+
this._prepareForParse();
11001103
const userArgs = this._prepareUserArgs(argv, parseOptions);
11011104
await this._parseCommand([], userArgs);
11021105

11031106
return this;
11041107
}
11051108

1109+
_prepareForParse() {
1110+
if (this._savedState === null) {
1111+
this.saveStateBeforeParse();
1112+
} else {
1113+
this.restoreStateBeforeParse();
1114+
}
1115+
}
1116+
1117+
/**
1118+
* Called the first time parse is called to save state and allow a restore before subsequent calls to parse.
1119+
* Not usually called directly, but available for subclasses to save their custom state.
1120+
*
1121+
* This is called in a lazy way. Only commands used in parsing chain will have state saved.
1122+
*/
1123+
saveStateBeforeParse() {
1124+
this._savedState = {
1125+
// name is stable if supplied by author, but may be unspecified for root command and deduced during parsing
1126+
_name: this._name,
1127+
// option values before parse have default values (including false for negated options)
1128+
// shallow clones
1129+
_optionValues: { ...this._optionValues },
1130+
_optionValueSources: { ...this._optionValueSources },
1131+
};
1132+
}
1133+
1134+
/**
1135+
* Restore state before parse for calls after the first.
1136+
* Not usually called directly, but available for subclasses to save their custom state.
1137+
*
1138+
* This is called in a lazy way. Only commands used in parsing chain will have state restored.
1139+
*/
1140+
restoreStateBeforeParse() {
1141+
if (this._storeOptionsAsProperties)
1142+
throw new Error(`Can not call parse again when storeOptionsAsProperties is true.
1143+
- either make a new Command for each call to parse, or stop storing options as properties`);
1144+
1145+
// clear state from _prepareUserArgs
1146+
this._name = this._savedState._name;
1147+
this._scriptPath = null;
1148+
this.rawArgs = [];
1149+
// clear state from setOptionValueWithSource
1150+
this._optionValues = { ...this._savedState._optionValues };
1151+
this._optionValueSources = { ...this._savedState._optionValueSources };
1152+
// clear state from _parseCommand
1153+
this.args = [];
1154+
// clear state from _processArguments
1155+
this.processedArgs = [];
1156+
}
1157+
11061158
/**
11071159
* Throw if expected executable is missing. Add lots of help for author.
11081160
*
@@ -1283,6 +1335,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
12831335
const subCommand = this._findCommand(commandName);
12841336
if (!subCommand) this.help({ error: true });
12851337

1338+
subCommand._prepareForParse();
12861339
let promiseChain;
12871340
promiseChain = this._chainOrCallSubCommandHook(
12881341
promiseChain,
@@ -1660,6 +1713,8 @@ Expecting one of '${allowedValues.join("', '")}'`);
16601713
* Parse options from `argv` removing known options,
16611714
* and return argv split into operands and unknown arguments.
16621715
*
1716+
* Side effects: modifies command by storing options. Does not reset state if called again.
1717+
*
16631718
* Examples:
16641719
*
16651720
* argv => operands, unknown

tests/command.parse.test.js

+218
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,221 @@ describe('parseAsync parameter is treated as readonly, per TypeScript declaratio
199199
expect(program.rawArgs).toEqual(oldRawArgs);
200200
});
201201
});
202+
203+
describe('.parse() called multiple times', () => {
204+
test('when use boolean options then option values reset', () => {
205+
const program = new commander.Command().option('--black').option('--white');
206+
207+
program.parse(['--black'], { from: 'user' });
208+
expect(program.opts()).toEqual({ black: true });
209+
210+
program.parse(['--white'], { from: 'user' });
211+
expect(program.opts()).toEqual({ white: true });
212+
});
213+
214+
test('when use options with option-argument then option values and sources reset', () => {
215+
const program = new commander.Command()
216+
.option('-f, --foo <value>')
217+
.option('-b, --bar <value>');
218+
219+
program.parse(['--foo', 'FOO'], { from: 'user' });
220+
expect(program.opts()).toEqual({ foo: 'FOO' });
221+
expect(program.getOptionValueSource('foo')).toEqual('cli');
222+
expect(program.getOptionValueSource('bar')).toBeUndefined();
223+
224+
program.parse(['--bar', 'BAR'], { from: 'user' });
225+
expect(program.opts()).toEqual({ bar: 'BAR' });
226+
expect(program.getOptionValueSource('foo')).toBeUndefined();
227+
expect(program.getOptionValueSource('bar')).toEqual('cli');
228+
});
229+
230+
test('when use options with option-argument and default then option values and sources reset', () => {
231+
const program = new commander.Command()
232+
.option('-f, --foo <value>', 'description', 'default-FOO')
233+
.option('-b, --bar <value>', 'description', 'default-BAR');
234+
235+
program.parse(['--foo', 'FOO'], { from: 'user' });
236+
expect(program.opts()).toEqual({ foo: 'FOO', bar: 'default-BAR' });
237+
expect(program.getOptionValueSource('foo')).toEqual('cli');
238+
expect(program.getOptionValueSource('bar')).toEqual('default');
239+
240+
program.parse(['--bar', 'BAR'], { from: 'user' });
241+
expect(program.opts()).toEqual({ foo: 'default-FOO', bar: 'BAR' });
242+
expect(program.getOptionValueSource('foo')).toEqual('default');
243+
expect(program.getOptionValueSource('bar')).toEqual('cli');
244+
});
245+
246+
test('when use negated options then option values reset', () => {
247+
const program = new commander.Command()
248+
.option('--no-foo')
249+
.option('--no-bar');
250+
251+
program.parse(['--no-foo'], { from: 'user' });
252+
expect(program.opts()).toEqual({ foo: false, bar: true });
253+
254+
program.parse(['--no-bar'], { from: 'user' });
255+
expect(program.opts()).toEqual({ foo: true, bar: false });
256+
});
257+
258+
test('when use variadic option then option values reset', () => {
259+
const program = new commander.Command().option('--var <items...>');
260+
261+
program.parse(['--var', 'a', 'b'], { from: 'user' });
262+
expect(program.opts()).toEqual({ var: ['a', 'b'] });
263+
264+
program.parse(['--var', 'c'], { from: 'user' });
265+
expect(program.opts()).toEqual({ var: ['c'] });
266+
});
267+
268+
test('when use collect example then option value resets', () => {
269+
function collect(value, previous) {
270+
return previous.concat([value]);
271+
}
272+
const program = new commander.Command();
273+
program.option('-c, --collect <value>', 'repeatable value', collect, []);
274+
275+
program.parse(['-c', 'a', '-c', 'b'], { from: 'user' });
276+
expect(program.opts()).toEqual({ collect: ['a', 'b'] });
277+
278+
program.parse(['-c', 'c'], { from: 'user' });
279+
expect(program.opts()).toEqual({ collect: ['c'] });
280+
});
281+
282+
test('when use increaseVerbosity example then option value resets', () => {
283+
function increaseVerbosity(dummyValue, previous) {
284+
return previous + 1;
285+
}
286+
const program = new commander.Command();
287+
program.option(
288+
'-v, --verbose',
289+
'verbosity that can be increased',
290+
increaseVerbosity,
291+
0,
292+
);
293+
294+
program.parse(['-vvv'], { from: 'user' });
295+
expect(program.opts()).toEqual({ verbose: 3 });
296+
program.parse(['-vv'], { from: 'user' });
297+
298+
expect(program.opts()).toEqual({ verbose: 2 });
299+
program.parse([], { from: 'user' });
300+
expect(program.opts()).toEqual({ verbose: 0 });
301+
});
302+
303+
test('when use parse and parseAsync then option values reset', async () => {
304+
const program = new commander.Command().option('--black').option('--white');
305+
306+
program.parse(['--black'], { from: 'user' });
307+
expect(program.opts()).toEqual({ black: true });
308+
await program.parseAsync(['--white'], { from: 'user' });
309+
expect(program.opts()).toEqual({ white: true });
310+
});
311+
312+
test('when call subcommand then option values reset (program and subcommand)', () => {
313+
const program = new commander.Command().option('--black').option('--white');
314+
const subcommand = program.command('sub').option('--red').option('--green');
315+
316+
program.parse(['--black', 'sub', '--red'], { from: 'user' });
317+
expect(subcommand.optsWithGlobals()).toEqual({ black: true, red: true });
318+
319+
program.parse(['--white', 'sub', '--green'], { from: 'user' });
320+
expect(subcommand.optsWithGlobals()).toEqual({ white: true, green: true });
321+
});
322+
323+
test('when call different subcommand then no reset because lazy', () => {
324+
// This is not a required behaviour, but is the intended behaviour.
325+
const program = new commander.Command();
326+
const sub1 = program.command('sub1').option('--red');
327+
const sub2 = program.command('sub2').option('--green');
328+
329+
program.parse(['sub1', '--red'], { from: 'user' });
330+
expect(sub1.opts()).toEqual({ red: true });
331+
expect(sub2.opts()).toEqual({});
332+
333+
program.parse(['sub2', '--green'], { from: 'user' });
334+
expect(sub1.opts()).toEqual({ red: true });
335+
expect(sub2.opts()).toEqual({ green: true });
336+
});
337+
338+
test('when parse with different implied program name then name changes', () => {
339+
const program = new commander.Command();
340+
341+
program.parse(['node', 'script1.js']);
342+
expect(program.name()).toEqual('script1');
343+
344+
program.parse(['electron', 'script2.js']);
345+
expect(program.name()).toEqual('script2');
346+
});
347+
348+
test('when parse with different arguments then args change', () => {
349+
// weak test, would work without store/reset!
350+
const program = new commander.Command()
351+
.argument('<first>')
352+
.argument('[second]');
353+
354+
program.parse(['one', 'two'], { from: 'user' });
355+
expect(program.args).toEqual(['one', 'two']);
356+
357+
program.parse(['alpha'], { from: 'user' });
358+
expect(program.args).toEqual(['alpha']);
359+
});
360+
361+
test('when parse with different arguments then rawArgs change', () => {
362+
// weak test, would work without store/reset!
363+
const program = new commander.Command()
364+
.argument('<first>')
365+
.option('--white')
366+
.option('--black');
367+
368+
program.parse(['--white', 'one'], { from: 'user' });
369+
expect(program.rawArgs).toEqual(['--white', 'one']);
370+
371+
program.parse(['--black', 'two'], { from: 'user' });
372+
expect(program.rawArgs).toEqual(['--black', 'two']);
373+
});
374+
375+
test('when parse with different arguments then processedArgs change', () => {
376+
// weak test, would work without store/reset!
377+
const program = new commander.Command().argument(
378+
'<first>',
379+
'first arg',
380+
parseFloat,
381+
);
382+
383+
program.parse([123], { from: 'user' });
384+
expect(program.processedArgs).toEqual([123]);
385+
386+
program.parse([456], { from: 'user' });
387+
expect(program.processedArgs).toEqual([456]);
388+
});
389+
390+
test('when parse subcommand then reset state before preSubcommand hook called', () => {
391+
let hookCalled = false;
392+
const program = new commander.Command().hook(
393+
'preSubcommand',
394+
(thisCommand, subcommand) => {
395+
hookCalled = true;
396+
expect(subcommand.opts()).toEqual({});
397+
},
398+
);
399+
const subcommand = program.command('sub').option('--red').option('--green');
400+
401+
hookCalled = false;
402+
program.parse(['sub', '--red'], { from: 'user' });
403+
expect(hookCalled).toBe(true);
404+
expect(subcommand.opts()).toEqual({ red: true });
405+
406+
hookCalled = false;
407+
program.parse(['sub', '--green'], { from: 'user' });
408+
expect(hookCalled).toBe(true);
409+
expect(subcommand.opts()).toEqual({ green: true });
410+
});
411+
412+
test('when using storeOptionsAsProperties then throw on second parse', () => {
413+
const program = new commander.Command().storeOptionsAsProperties();
414+
program.parse();
415+
expect(() => {
416+
program.parse();
417+
}).toThrow();
418+
});
419+
});

typings/index.d.ts

+18
Original file line numberDiff line numberDiff line change
@@ -821,10 +821,28 @@ export class Command {
821821
parseOptions?: ParseOptions,
822822
): Promise<this>;
823823

824+
/**
825+
* Called the first time parse is called to save state and allow a restore before subsequent calls to parse.
826+
* Not usually called directly, but available for subclasses to save their custom state.
827+
*
828+
* This is called in a lazy way. Only commands used in parsing chain will have state saved.
829+
*/
830+
saveStateBeforeParse(): void;
831+
832+
/**
833+
* Restore state before parse for calls after the first.
834+
* Not usually called directly, but available for subclasses to save their custom state.
835+
*
836+
* This is called in a lazy way. Only commands used in parsing chain will have state restored.
837+
*/
838+
restoreStateBeforeParse(): void;
839+
824840
/**
825841
* Parse options from `argv` removing known options,
826842
* and return argv split into operands and unknown arguments.
827843
*
844+
* Side effects: modifies command by storing options. Does not reset state if called again.
845+
*
828846
* argv => operands, unknown
829847
* --known kkk op => [op], []
830848
* op --known kkk => [op], []

typings/index.test-d.ts

+4
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,10 @@ expectType<{ operands: string[]; unknown: string[] }>(
370370
program.parseOptions(['node', 'script.js', 'hello']),
371371
);
372372

373+
// save/restore state
374+
expectType<void>(program.saveStateBeforeParse());
375+
expectType<void>(program.restoreStateBeforeParse());
376+
373377
// opts
374378
const opts = program.opts();
375379
expectType<commander.OptionValues>(opts);

0 commit comments

Comments
 (0)