Skip to content

Commit

Permalink
Add save/restore state to allow multiple calls to parse (#2299)
Browse files Browse the repository at this point in the history
  • Loading branch information
shadowspawn authored Dec 27, 2024
1 parent 497c11d commit 49423a2
Show file tree
Hide file tree
Showing 5 changed files with 295 additions and 2 deletions.
2 changes: 0 additions & 2 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -959,8 +959,6 @@ program.parse(['--port', '80'], { from: 'user' }); // just user supplied argumen

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

If you want to parse multiple times, create a new program each time. Calling parse does not clear out any previous state.

### Parsing Configuration

If the default parsing does not suit your needs, there are some behaviours to support other usage patterns.
Expand Down
55 changes: 55 additions & 0 deletions lib/command.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class Command extends EventEmitter {
/** @type {(boolean | string)} */
this._showHelpAfterError = false;
this._showSuggestionAfterError = true;
this._savedState = null; // used in save/restoreStateBeforeParse

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

parse(argv, parseOptions) {
this._prepareForParse();
const userArgs = this._prepareUserArgs(argv, parseOptions);
this._parseCommand([], userArgs);

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

async parseAsync(argv, parseOptions) {
this._prepareForParse();
const userArgs = this._prepareUserArgs(argv, parseOptions);
await this._parseCommand([], userArgs);

return this;
}

_prepareForParse() {
if (this._savedState === null) {
this.saveStateBeforeParse();
} else {
this.restoreStateBeforeParse();
}
}

/**
* Called the first time parse is called to save state and allow a restore before subsequent calls to parse.
* Not usually called directly, but available for subclasses to save their custom state.
*
* This is called in a lazy way. Only commands used in parsing chain will have state saved.
*/
saveStateBeforeParse() {
this._savedState = {
// name is stable if supplied by author, but may be unspecified for root command and deduced during parsing
_name: this._name,
// option values before parse have default values (including false for negated options)
// shallow clones
_optionValues: { ...this._optionValues },
_optionValueSources: { ...this._optionValueSources },
};
}

/**
* Restore state before parse for calls after the first.
* Not usually called directly, but available for subclasses to save their custom state.
*
* This is called in a lazy way. Only commands used in parsing chain will have state restored.
*/
restoreStateBeforeParse() {
if (this._storeOptionsAsProperties)
throw new Error(`Can not call parse again when storeOptionsAsProperties is true.
- either make a new Command for each call to parse, or stop storing options as properties`);

// clear state from _prepareUserArgs
this._name = this._savedState._name;
this._scriptPath = null;
this.rawArgs = [];
// clear state from setOptionValueWithSource
this._optionValues = { ...this._savedState._optionValues };
this._optionValueSources = { ...this._savedState._optionValueSources };
// clear state from _parseCommand
this.args = [];
// clear state from _processArguments
this.processedArgs = [];
}

/**
* Throw if expected executable is missing. Add lots of help for author.
*
Expand Down Expand Up @@ -1283,6 +1335,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
const subCommand = this._findCommand(commandName);
if (!subCommand) this.help({ error: true });

subCommand._prepareForParse();
let promiseChain;
promiseChain = this._chainOrCallSubCommandHook(
promiseChain,
Expand Down Expand Up @@ -1660,6 +1713,8 @@ Expecting one of '${allowedValues.join("', '")}'`);
* Parse options from `argv` removing known options,
* and return argv split into operands and unknown arguments.
*
* Side effects: modifies command by storing options. Does not reset state if called again.
*
* Examples:
*
* argv => operands, unknown
Expand Down
218 changes: 218 additions & 0 deletions tests/command.parse.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,221 @@ describe('parseAsync parameter is treated as readonly, per TypeScript declaratio
expect(program.rawArgs).toEqual(oldRawArgs);
});
});

describe('.parse() called multiple times', () => {
test('when use boolean options then option values reset', () => {
const program = new commander.Command().option('--black').option('--white');

program.parse(['--black'], { from: 'user' });
expect(program.opts()).toEqual({ black: true });

program.parse(['--white'], { from: 'user' });
expect(program.opts()).toEqual({ white: true });
});

test('when use options with option-argument then option values and sources reset', () => {
const program = new commander.Command()
.option('-f, --foo <value>')
.option('-b, --bar <value>');

program.parse(['--foo', 'FOO'], { from: 'user' });
expect(program.opts()).toEqual({ foo: 'FOO' });
expect(program.getOptionValueSource('foo')).toEqual('cli');
expect(program.getOptionValueSource('bar')).toBeUndefined();

program.parse(['--bar', 'BAR'], { from: 'user' });
expect(program.opts()).toEqual({ bar: 'BAR' });
expect(program.getOptionValueSource('foo')).toBeUndefined();
expect(program.getOptionValueSource('bar')).toEqual('cli');
});

test('when use options with option-argument and default then option values and sources reset', () => {
const program = new commander.Command()
.option('-f, --foo <value>', 'description', 'default-FOO')
.option('-b, --bar <value>', 'description', 'default-BAR');

program.parse(['--foo', 'FOO'], { from: 'user' });
expect(program.opts()).toEqual({ foo: 'FOO', bar: 'default-BAR' });
expect(program.getOptionValueSource('foo')).toEqual('cli');
expect(program.getOptionValueSource('bar')).toEqual('default');

program.parse(['--bar', 'BAR'], { from: 'user' });
expect(program.opts()).toEqual({ foo: 'default-FOO', bar: 'BAR' });
expect(program.getOptionValueSource('foo')).toEqual('default');
expect(program.getOptionValueSource('bar')).toEqual('cli');
});

test('when use negated options then option values reset', () => {
const program = new commander.Command()
.option('--no-foo')
.option('--no-bar');

program.parse(['--no-foo'], { from: 'user' });
expect(program.opts()).toEqual({ foo: false, bar: true });

program.parse(['--no-bar'], { from: 'user' });
expect(program.opts()).toEqual({ foo: true, bar: false });
});

test('when use variadic option then option values reset', () => {
const program = new commander.Command().option('--var <items...>');

program.parse(['--var', 'a', 'b'], { from: 'user' });
expect(program.opts()).toEqual({ var: ['a', 'b'] });

program.parse(['--var', 'c'], { from: 'user' });
expect(program.opts()).toEqual({ var: ['c'] });
});

test('when use collect example then option value resets', () => {
function collect(value, previous) {
return previous.concat([value]);
}
const program = new commander.Command();
program.option('-c, --collect <value>', 'repeatable value', collect, []);

program.parse(['-c', 'a', '-c', 'b'], { from: 'user' });
expect(program.opts()).toEqual({ collect: ['a', 'b'] });

program.parse(['-c', 'c'], { from: 'user' });
expect(program.opts()).toEqual({ collect: ['c'] });
});

test('when use increaseVerbosity example then option value resets', () => {
function increaseVerbosity(dummyValue, previous) {
return previous + 1;
}
const program = new commander.Command();
program.option(
'-v, --verbose',
'verbosity that can be increased',
increaseVerbosity,
0,
);

program.parse(['-vvv'], { from: 'user' });
expect(program.opts()).toEqual({ verbose: 3 });
program.parse(['-vv'], { from: 'user' });

expect(program.opts()).toEqual({ verbose: 2 });
program.parse([], { from: 'user' });
expect(program.opts()).toEqual({ verbose: 0 });
});

test('when use parse and parseAsync then option values reset', async () => {
const program = new commander.Command().option('--black').option('--white');

program.parse(['--black'], { from: 'user' });
expect(program.opts()).toEqual({ black: true });
await program.parseAsync(['--white'], { from: 'user' });
expect(program.opts()).toEqual({ white: true });
});

test('when call subcommand then option values reset (program and subcommand)', () => {
const program = new commander.Command().option('--black').option('--white');
const subcommand = program.command('sub').option('--red').option('--green');

program.parse(['--black', 'sub', '--red'], { from: 'user' });
expect(subcommand.optsWithGlobals()).toEqual({ black: true, red: true });

program.parse(['--white', 'sub', '--green'], { from: 'user' });
expect(subcommand.optsWithGlobals()).toEqual({ white: true, green: true });
});

test('when call different subcommand then no reset because lazy', () => {
// This is not a required behaviour, but is the intended behaviour.
const program = new commander.Command();
const sub1 = program.command('sub1').option('--red');
const sub2 = program.command('sub2').option('--green');

program.parse(['sub1', '--red'], { from: 'user' });
expect(sub1.opts()).toEqual({ red: true });
expect(sub2.opts()).toEqual({});

program.parse(['sub2', '--green'], { from: 'user' });
expect(sub1.opts()).toEqual({ red: true });
expect(sub2.opts()).toEqual({ green: true });
});

test('when parse with different implied program name then name changes', () => {
const program = new commander.Command();

program.parse(['node', 'script1.js']);
expect(program.name()).toEqual('script1');

program.parse(['electron', 'script2.js']);
expect(program.name()).toEqual('script2');
});

test('when parse with different arguments then args change', () => {
// weak test, would work without store/reset!
const program = new commander.Command()
.argument('<first>')
.argument('[second]');

program.parse(['one', 'two'], { from: 'user' });
expect(program.args).toEqual(['one', 'two']);

program.parse(['alpha'], { from: 'user' });
expect(program.args).toEqual(['alpha']);
});

test('when parse with different arguments then rawArgs change', () => {
// weak test, would work without store/reset!
const program = new commander.Command()
.argument('<first>')
.option('--white')
.option('--black');

program.parse(['--white', 'one'], { from: 'user' });
expect(program.rawArgs).toEqual(['--white', 'one']);

program.parse(['--black', 'two'], { from: 'user' });
expect(program.rawArgs).toEqual(['--black', 'two']);
});

test('when parse with different arguments then processedArgs change', () => {
// weak test, would work without store/reset!
const program = new commander.Command().argument(
'<first>',
'first arg',
parseFloat,
);

program.parse([123], { from: 'user' });
expect(program.processedArgs).toEqual([123]);

program.parse([456], { from: 'user' });
expect(program.processedArgs).toEqual([456]);
});

test('when parse subcommand then reset state before preSubcommand hook called', () => {
let hookCalled = false;
const program = new commander.Command().hook(
'preSubcommand',
(thisCommand, subcommand) => {
hookCalled = true;
expect(subcommand.opts()).toEqual({});
},
);
const subcommand = program.command('sub').option('--red').option('--green');

hookCalled = false;
program.parse(['sub', '--red'], { from: 'user' });
expect(hookCalled).toBe(true);
expect(subcommand.opts()).toEqual({ red: true });

hookCalled = false;
program.parse(['sub', '--green'], { from: 'user' });
expect(hookCalled).toBe(true);
expect(subcommand.opts()).toEqual({ green: true });
});

test('when using storeOptionsAsProperties then throw on second parse', () => {
const program = new commander.Command().storeOptionsAsProperties();
program.parse();
expect(() => {
program.parse();
}).toThrow();
});
});
18 changes: 18 additions & 0 deletions typings/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -821,10 +821,28 @@ export class Command {
parseOptions?: ParseOptions,
): Promise<this>;

/**
* Called the first time parse is called to save state and allow a restore before subsequent calls to parse.
* Not usually called directly, but available for subclasses to save their custom state.
*
* This is called in a lazy way. Only commands used in parsing chain will have state saved.
*/
saveStateBeforeParse(): void;

/**
* Restore state before parse for calls after the first.
* Not usually called directly, but available for subclasses to save their custom state.
*
* This is called in a lazy way. Only commands used in parsing chain will have state restored.
*/
restoreStateBeforeParse(): void;

/**
* Parse options from `argv` removing known options,
* and return argv split into operands and unknown arguments.
*
* Side effects: modifies command by storing options. Does not reset state if called again.
*
* argv => operands, unknown
* --known kkk op => [op], []
* op --known kkk => [op], []
Expand Down
4 changes: 4 additions & 0 deletions typings/index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,10 @@ expectType<{ operands: string[]; unknown: string[] }>(
program.parseOptions(['node', 'script.js', 'hello']),
);

// save/restore state
expectType<void>(program.saveStateBeforeParse());
expectType<void>(program.restoreStateBeforeParse());

// opts
const opts = program.opts();
expectType<commander.OptionValues>(opts);
Expand Down

0 comments on commit 49423a2

Please sign in to comment.