Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add save/restore state to allow multiple calls to parse #2299

Merged
merged 3 commits into from
Dec 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading