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

parseOptions rework phase 2 #1145

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
898a218
Remove openIssues test for #1062, fixed and being tested
shadowspawn Jan 4, 2020
dc48063
Rework parseOptions handling of unknown arguments
shadowspawn Jan 5, 2020
f978b77
First tests for parseOptions, enable prepared regression tests
shadowspawn Jan 5, 2020
3b3f8c0
Add tests for parseOptions
shadowspawn Jan 5, 2020
450eba4
Add tests on program.args after calling parse
shadowspawn Jan 5, 2020
36243bc
Minor update to README for changed parse behaviour
shadowspawn Jan 5, 2020
306eb2e
Merge branch 'release/5.x' into feature/parseOptions-rework
shadowspawn Jan 7, 2020
97c3f79
Add tests for Utility Conventions before changing code to match
shadowspawn Jan 7, 2020
96758be
Switch from preflight including normalise to just testing parseOptions
shadowspawn Jan 8, 2020
d7da74a
Only refactor known options
shadowspawn Jan 8, 2020
2a7a351
Add short flag processing to READMEm, and literal --
shadowspawn Jan 9, 2020
a342bd6
Improve character description
shadowspawn Jan 9, 2020
9012ce0
Add a note that options not positional.
shadowspawn Jan 10, 2020
a6bf6dd
Remove regression tests for bug not really fixed by this
shadowspawn Jan 10, 2020
5340525
Add back #561 into known issues
shadowspawn Jan 10, 2020
573a12d
Refactor to make a little clearer and symmetrical
shadowspawn Jan 10, 2020
8d9870b
Use template to construct strings consistently within parseOptions
shadowspawn Jan 10, 2020
efd8dbf
Merge branch 'release/5.x' into feature/normalize-rework
shadowspawn Jan 15, 2020
ebccd26
Fix example parsing
shadowspawn Jan 15, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
15 changes: 11 additions & 4 deletions Readme.md
Expand Up @@ -67,11 +67,18 @@ For larger programs which may use commander in multiple ways, including unit tes

## Options

Options are defined with the `.option()` method, also serving as documentation for the options. Each option can have a short flag (single character) and a long name, separated by a comma or space.
Options are defined with the `.option()` method, also serving as documentation for the options. Each option can have a short flag (single character) and a long name, separated by a comma or space or vertical bar ('|').

The options can be accessed as properties on the Command object. Multi-word options such as "--template-engine" are camel-cased, becoming `program.templateEngine` etc. Multiple short flags may be combined as a single arg, for example `-abc` is equivalent to `-a -b -c`.
The options can be accessed as properties on the Command object. Multi-word options such as "--template-engine" are camel-cased, becoming `program.templateEngine` etc. See also optional new behaviour to [avoid name clashes](#avoiding-option-name-clashes).

See also optional new behaviour to [avoid name clashes](#avoiding-option-name-clashes).
Multiple short flags may optionally be combined in a single argument following the dash: boolean flags, the last flag may take a value, and the value.
For example `-a -b -p 80` may be written as `-ab -p80` or even `-abp80`.

You can use `--` to indicate the end of the options, and any remaining arguments will be used without being interpreted.
This is particularly useful for passing options through to another
command, like: `do -- git --version`.

Options on the command line are not positional, and can be specified before or after other command arguments.

### Common option types, boolean and value

Expand Down Expand Up @@ -251,7 +258,7 @@ $ custom --list x,y,z

### Required option

You may specify a required (mandatory) option using `.requiredOption`. The option must be specified on the command line, or by having a default value. The method is otherwise the same as `.option` in format, taking flags and description, and optional default value or custom processing.
You may specify a required (mandatory) option using `.requiredOption`. The option must have a value after parsing, usually specified on the command line, or perhaps from a default value (say from environment). The method is otherwise the same as `.option` in format, taking flags and description, and optional default value or custom processing.

```js
const program = require('commander');
Expand Down
144 changes: 57 additions & 87 deletions index.js
Expand Up @@ -637,9 +637,8 @@ Command.prototype.parse = function(argv) {
argv.push(this._helpLongFlag);
}

// process argv
const normalized = this.normalize(argv.slice(2));
const parsed = this.parseOptions(normalized);
// process argv, leaving off first two args which are app and scriptname.
const parsed = this.parseOptions(argv.slice(2));
const args = parsed.operands.concat(parsed.unknown);
this.args = args.slice();

Expand Down Expand Up @@ -822,57 +821,6 @@ Command.prototype.executeSubCommand = function(argv, args, executableFile) {
this.runningCommand = proc;
};

/**
* Normalize `args`, splitting joined short flags. For example
* the arg "-abc" is equivalent to "-a -b -c".
* This also normalizes equal sign and splits "--abc=def" into "--abc def".
*
* @param {Array} args
* @return {Array}
* @api private
*/

Command.prototype.normalize = function(args) {
var ret = [],
arg,
lastOpt,
index,
short,
opt;

for (var i = 0, len = args.length; i < len; ++i) {
arg = args[i];
if (i > 0) {
lastOpt = this.optionFor(args[i - 1]);
}

if (arg === '--') {
// Honor option terminator
ret = ret.concat(args.slice(i));
break;
} else if (lastOpt && lastOpt.required) {
ret.push(arg);
} else if (arg.length > 2 && arg[0] === '-' && arg[1] !== '-') {
short = arg.slice(0, 2);
opt = this.optionFor(short);
if (opt && (opt.required || opt.optional)) {
ret.push(short);
ret.push(arg.slice(2));
} else {
arg.slice(1).split('').forEach(function(c) {
ret.push('-' + c);
});
}
} else if (/^--/.test(arg) && ~(index = arg.indexOf('='))) {
ret.push(arg.slice(0, index), arg.slice(index + 1));
} else {
ret.push(arg);
}
}

return ret;
};

/**
* Parse command `args`.
*
Expand Down Expand Up @@ -960,56 +908,78 @@ Command.prototype._checkForMissingMandatoryOptions = function() {
Command.prototype.parseOptions = function(argv) {
const operands = []; // operands, not options or values
const unknown = []; // first unknown option and remaining unknown args
let literal = false;
let dest = operands;
const args = argv.slice();

function maybeOption(arg) {
return arg.length > 1 && arg[0] === '-';
}

// parse options
for (var i = 0; i < argv.length; ++i) {
const arg = argv[i];
while (args.length) {
const arg = args.shift();

// literal args after --
if (literal) {
dest.push(arg);
continue;
// literal
if (arg === '--') {
if (dest === unknown) dest.push(arg);
dest.push(...args);
break;
}

if (arg === '--') {
literal = true;
if (dest === unknown) dest.push('--');
continue;
if (maybeOption(arg)) {
const option = this.optionFor(arg);
// recognised option, call listener to assign value with possible custom processing
if (option) {
if (option.required) {
const value = args.shift();
if (value === undefined) this.optionMissingArgument(option);
this.emit(`option:${option.name()}`, value);
} else if (option.optional) {
let value = null;
// historical behaviour is optional value is following arg unless an option
if (args.length > 0 && !maybeOption(args[0])) {
value = args.shift();
}
this.emit(`option:${option.name()}`, value);
} else { // boolean flag
this.emit(`option:${option.name()}`);
}
continue;
}
}

// find matching Option
const option = this.optionFor(arg);

// recognised option, call listener to assign value with possible custom processing
if (option) {
if (option.required) {
const value = argv[++i];
if (value === undefined) this.optionMissingArgument(option);
this.emit('option:' + option.name(), value);
} else if (option.optional) {
let value = argv[i + 1];
// do not use a following option as a value
if (value === undefined || (value[0] === '-' && value !== '-')) {
value = null;
// Look for combo options following single dash, eat first one if known.
if (arg.length > 2 && arg[0] === '-' && arg[1] !== '-') {
const option = this.optionFor(`-${arg[1]}`);
if (option) {
if (option.required || option.optional) {
// option with value following in same argument
this.emit(`option:${option.name()}`, arg.slice(2));
} else {
++i;
// boolean option, emit and put back remainder of arg for further processing
this.emit(`option:${option.name()}`);
args.unshift(`-${arg.slice(2)}`);
}
this.emit('option:' + option.name(), value);
// flag
} else {
this.emit('option:' + option.name());
continue;
}
}

// Look for known long flag with value, like --foo=bar
if (/^--[^=]+=/.test(arg)) {
const index = arg.indexOf('=');
const option = this.optionFor(arg.slice(0, index));
if (option && (option.required || option.optional)) {
this.emit(`option:${option.name()}`, arg.slice(index + 1));
continue;
}
continue;
}

// looks like an option, unknowns from here
// looks like an option but unknown, unknowns from here
if (arg.length > 1 && arg[0] === '-') {
dest = unknown;
}

// arg
// add arg
dest.push(arg);
}

Expand Down
6 changes: 3 additions & 3 deletions tests/args.literal.test.js
@@ -1,9 +1,9 @@
const commander = require('../');

// Guideline 10:
// The first -- argument that is not an option-argument should be accepted as a delimiter indicating the end of options. Any following arguments should be treated as operands, even if they begin with the '-' character.
// Utility Conventions: http://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html#tag_12_02
//
// http://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html#tag_12_02
// 12.2 Utility Syntax Guidelines, Guideline 10:
// The first -- argument that is not an option-argument should be accepted as a delimiter indicating the end of options. Any following arguments should be treated as operands, even if they begin with the '-' character.

test('when arguments includes -- then stop processing options', () => {
const program = new commander.Command();
Expand Down
114 changes: 103 additions & 11 deletions tests/command.parseOptions.test.js
Expand Up @@ -7,17 +7,6 @@ const path = require('path');

// Tests created from reported bugs
describe('regression tests', () => {
// https://github.com/tj/commander.js/issues/1039
// https://github.com/tj/commander.js/issues/561
test('when unknown option and multiple arguments then unknown option detected', () => {
const program = new commander.Command();
program
.exitOverride();
expect(() => {
program.parse(['node', 'text', '--bug', '0', '1', '2', '3']);
}).toThrow();
});

// https://github.com/tj/commander.js/issues/1032
function createProgram1032() {
const program = new commander.Command();
Expand Down Expand Up @@ -196,3 +185,106 @@ describe('parse and program.args', () => {
expect(program.args).toEqual(['sub', '--sub-flag', 'arg']);
});
});

// Utility Conventions: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html#tag_12_01
//
// 12.1 Utility Argument Syntax
// 2. Option-arguments are shown separated from their options by <blank> characters, except when the option-argument is
// enclosed in the '[' and ']' notation to indicate that it is optional. This reflects the situation in which an
// optional option-argument (if present) is included within the same argument string as the option; for a mandatory option-argument,
// it is the next argument. The Utility Syntax Guidelines in Utility Syntax Guidelines require that the option be a separate argument
// from its option-argument and that option-arguments not be optional, but there are some exceptions in POSIX.1-2017 to ensure
// continued operation of historical applications:
//
// a. If the SYNOPSIS of a standard utility shows an option with a mandatory option-argument (as with [ -c option_argument] in the example),
// a conforming application shall use separate arguments for that option and its option-argument. However, a conforming implementation shall
// also permit applications to specify the option and option-argument in the same argument string without intervening <blank> characters.
//
// b. If the SYNOPSIS shows an optional option-argument (as with [ -f[ option_argument]] in the example), a conforming application
// shall place any option-argument for that option directly adjacent to the option in the same argument string, without intervening
// <blank> characters. If the utility receives an argument containing only the option, it shall behave as specified in its description
// for an omitted option-argument; it shall not treat the next argument (if any) as the option-argument for that option.
//
// 12.2 Utility Syntax Guidelines, Guideline 5:
// One or more options without option-arguments, followed by at most one option that takes an option-argument, should be accepted when
// grouped behind one '-' delimiter.

// Commander conformance:
// - allows separate argument for required option-argument
// - allows value in same argument for short flag with required option-argument
// - non-conforming: allows separate argument for optional option-argument if does not start with '-'
// - allows value in same argument for short flag with optional option-argument
// - allows short flags as per 12.2
//
// The focus in this file is testing the behaviours with known vs unknown options.
// See options.values.test.js for more general testing of known options.

describe('Utility Conventions', () => {
function createProgram() {
const program = new commander.Command();
program
.option('-a,--aaa')
.option('-b,--bbb')
.option('-c,--ccc <value>')
.option('-d,--ddd [value]');
program
.action(() => { });
return program;
};

test('when program has combo known boolean short flags then arg removed', () => {
const program = createProgram();
const result = program.parseOptions(['-ab']);
expect(result).toEqual({ operands: [], unknown: [] });
expect(program.opts()).toEqual({ aaa: true, bbb: true });
});

test('when program has combo unknown short flags then arg preserved', () => {
const program = createProgram();
const result = program.parseOptions(['-pq']);
expect(result).toEqual({ operands: [], unknown: ['-pq'] });
expect(program.opts()).toEqual({ });
});

test('when program has combo known short option and required value then arg removed', () => {
const program = createProgram();
const result = program.parseOptions(['-cvalue']);
expect(result).toEqual({ operands: [], unknown: [] });
expect(program.opts()).toEqual({ ccc: 'value' });
});

test('when program has combo known short option and optional value then arg removed', () => {
const program = createProgram();
const result = program.parseOptions(['-dvalue']);
expect(result).toEqual({ operands: [], unknown: [] });
expect(program.opts()).toEqual({ ddd: 'value' });
});

test('when program has known combo short boolean flags and required value then arg removed', () => {
const program = createProgram();
const result = program.parseOptions(['-abcvalue']);
expect(result).toEqual({ operands: [], unknown: [] });
expect(program.opts()).toEqual({ aaa: true, bbb: true, ccc: 'value' });
});

test('when program has known combo short boolean flags and optional value then arg removed', () => {
const program = createProgram();
const result = program.parseOptions(['-abdvalue']);
expect(result).toEqual({ operands: [], unknown: [] });
expect(program.opts()).toEqual({ aaa: true, bbb: true, ddd: 'value' });
});

test('when program has known long flag=value then arg removed', () => {
const program = createProgram();
const result = program.parseOptions(['--ccc=value']);
expect(result).toEqual({ operands: [], unknown: [] });
expect(program.opts()).toEqual({ ccc: 'value' });
});

test('when program has unknown long flag=value then arg preserved', () => {
const program = createProgram();
const result = program.parseOptions(['--rrr=value']);
expect(result).toEqual({ operands: [], unknown: ['--rrr=value'] });
expect(program.opts()).toEqual({ });
});
});
12 changes: 12 additions & 0 deletions tests/openIssues.test.js.skip
@@ -1,4 +1,16 @@
const commander = require('../');

describe('open issues', () => {
// https://github.com/tj/commander.js/issues/561
test('#561: when specify argument and unknown option with no action handler then error', () => {
const program = new commander.Command();
program
.exitOverride()
.option('-x, --x-flag', 'A flag')
.arguments('<file>');

expect(() => {
program.parse(['node', 'test', '1', '--NONSENSE', '2', '3']);
}).toThrow();
});
});
1 change: 1 addition & 0 deletions tests/options.values.test.js
@@ -1,6 +1,7 @@
const commander = require('../');

// Test the ways values can be specified for options.
// See also references on "Utility Conventions" in command.parseOptions.test.js

// options with required values can eat values starting with a dash, including just dash sometimes used as alias for stdin
//
Expand Down