Skip to content
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
3 changes: 2 additions & 1 deletion Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,8 @@ add cheese type mozzarella
```

Options with an optional option-argument are not greedy and will ignore arguments starting with a dash.
So `id` behaves as a boolean option for `--id -5`, but you can use a combined form if needed like `--id=-5`.
So `id` behaves as a boolean option for `--id -ABCD`, but you can use a combined form if needed like `--id=-ABCD`.
Negative numbers are special and are accepted as an option-argument.

For information about possible ambiguous cases, see [options taking varying arguments](./docs/options-in-depth.md).

Expand Down
28 changes: 25 additions & 3 deletions lib/command.js
Original file line number Diff line number Diff line change
Expand Up @@ -1737,6 +1737,17 @@ Expecting one of '${allowedValues.join("', '")}'`);
return arg.length > 1 && arg[0] === '-';
}

const negativeNumberArg = (arg) => {
// return false if not a negative number
if (!/^-\d*\.?\d+(e[+-]?\d+)?$/.test(arg)) return false;
// negative number is ok unless digit used as an option in command hierarchy
return !this._getCommandAndAncestors().some((cmd) =>
cmd.options
.map((opt) => opt.short)
.some((short) => /^-\d$/.test(short)),
);
};

// parse options
let activeVariadicOption = null;
while (args.length) {
Expand All @@ -1749,7 +1760,10 @@ Expecting one of '${allowedValues.join("', '")}'`);
break;
}

if (activeVariadicOption && !maybeOption(arg)) {
if (
activeVariadicOption &&
(!maybeOption(arg) || negativeNumberArg(arg))
) {
this.emit(`option:${activeVariadicOption.name()}`, arg);
continue;
}
Expand All @@ -1766,7 +1780,10 @@ Expecting one of '${allowedValues.join("', '")}'`);
} 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])) {
if (
args.length > 0 &&
(!maybeOption(args[0]) || negativeNumberArg(args[0]))
) {
value = args.shift();
}
this.emit(`option:${option.name()}`, value);
Expand Down Expand Up @@ -1812,7 +1829,12 @@ Expecting one of '${allowedValues.join("', '")}'`);
// Might be a command-argument, or subcommand option, or unknown option, or help command or option.

// An unknown option means further arguments also classified as unknown so can be reprocessed by subcommands.
if (maybeOption(arg)) {
// A negative number in a leaf command is not an unknown option.
if (
dest === operands &&
maybeOption(arg) &&
!(this.commands.length === 0 && negativeNumberArg(arg))
) {
dest = unknown;
}

Expand Down
267 changes: 267 additions & 0 deletions tests/negatives.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
const { Command } = require('../');

// boolean is whether is a consumable argument when negative numbers allowed
const negativeNumbers = [
['-.1', true],
['-123', true],
['-123.45', true],
['-1e3', true],
['-1e+3', true],
['-1e-3', true],
['-1.2e3', true],
['-1.2e+3', true],
['-1.2e-3', true],
['-1e-3.0', false], // invalid number format
['--1 ', false], // invalid number format
['-0', true],
['1', true],
['-1x', false], // whole string is not a number
['-x-1 ', false], // whole string is not a number
['', true],
['-0x1234', false], // not a plain number
];

test.each(negativeNumbers)(
`when option-argument for short optional is %s then consumed=%s`,
(value, consume) => {
const program = new Command();
program.exitOverride().configureOutput({ writeErr: () => {} });
program.option('-o, --optional [value]', 'optional option');
const args = ['-o', value];
let thrown = '';
try {
program.parse(args, { from: 'user' });
} catch (err) {
thrown = err.code;
}

expect(thrown).toEqual(consume ? '' : 'commander.unknownOption');
// throws after setting optional to true
expect(program.opts()['optional']).toBe(consume ? value : true);
},
);

test.each(negativeNumbers)(
`when option-argument for long optional is %s then consumed=%s`,
(value, consume) => {
const program = new Command();
program.exitOverride().configureOutput({ writeErr: () => {} });
program.option('-o, --optional [value]', 'optional option');
const args = ['--optional', value];
let thrown = '';
try {
program.parse(args, { from: 'user' });
} catch (err) {
thrown = err.code;
}

expect(thrown).toEqual(consume ? '' : 'commander.unknownOption');
// throws after setting optional to true
expect(program.opts()['optional']).toBe(consume ? value : true);
},
);

test.each(negativeNumbers)(
`when option-argument for short optional... is %s then consumed=%s`,
(value, consume) => {
const program = new Command();
program
.exitOverride()
.configureOutput({ writeErr: () => {} })
.option('-o, --optional [value...]', 'optional option');
const args = ['-o', 'first', value];
let thrown = '';
try {
program.parse(args, { from: 'user' });
} catch (err) {
thrown = err.code;
}

expect(thrown).toEqual(consume ? '' : 'commander.unknownOption');
// throws after consuming 'first'
expect(program.opts()['optional']).toEqual(
consume ? ['first', value] : ['first'],
);
},
);

test.each(negativeNumbers)(
`when option-argument for long optional... is %s then consumed=%s`,
(value, consume) => {
const program = new Command();
program
.exitOverride()
.configureOutput({ writeErr: () => {} })
.option('-o, --optional [value...]', 'optional option');
const args = ['--optional', 'first', value];
let thrown = '';
try {
program.parse(args, { from: 'user' });
} catch (err) {
thrown = err.code;
}

expect(thrown).toEqual(consume ? '' : 'commander.unknownOption');
// throws after consuming 'first'
expect(program.opts()['optional']).toEqual(
consume ? ['first', value] : ['first'],
);
},
);

test.each(negativeNumbers)(
`when command-argument is %s then consumed=%s`,
(value, consume) => {
const program = new Command();
program
.exitOverride()
.configureOutput({ writeErr: () => {} })
.argument('<value>', 'argument');
const args = [value];
let thrown = '';
try {
program.parse(args, { from: 'user' });
} catch (err) {
thrown = err.code;
}

expect(thrown).toEqual(consume ? '' : 'commander.unknownOption');
expect(consume ? program.args : undefined).toEqual(
consume ? [value] : undefined,
);
},
);

test.each(negativeNumbers)(
`when digit option defined and option-argument is %s then negative not consumed`,
(value, _ignore) => {
const program = new Command();
program
.exitOverride()
.configureOutput({ writeErr: () => {} })
.option('-o, --optional [value]', 'optional option')
.option('-9', 'register option using digit');
const args = ['-o', value];
let thrown = '';
try {
program.parse(args, { from: 'user' });
} catch (err) {
thrown = err.code;
}

let consume = value[0] !== '-';
expect(thrown).toEqual(consume ? '' : 'commander.unknownOption');
expect(program.opts()['optional']).toBe(consume ? value : true);
},
);

test.each(negativeNumbers)(
`when digit option defined and command-argument is %s then negative not consumed`,
(value, _ignore) => {
const program = new Command();
program
.exitOverride()
.configureOutput({ writeErr: () => {} })
.argument('[value]')
.option('-9', 'register option using digit');
const args = [value];
let thrown = '';
try {
program.parse(args, { from: 'user' });
} catch (err) {
thrown = err.code;
}

let consume = value[0] !== '-';
expect(thrown).toEqual(consume ? '' : 'commander.unknownOption');
expect(consume ? program.args : undefined).toEqual(
consume ? [value] : undefined,
);
},
);

test('when complex example with negative numbers then all consumed', () => {
const program = new Command();
program
.option('-o [value]', 'optional')
.option('-m <value>', 'required option-argument')
.option('-O [value...]', 'optional')
.option('-M <value...>', 'required option-argument')
.argument('[value...]', 'argument');
const args = [
'-10',
'-O',
'-40',
'-41',
'-M',
'-50',
'-51',
'-o',
'-20',
'-m',
'-30',
'-11',
];
program.parse(args, { from: 'user' });
expect(program.opts()).toEqual({
o: '-20',
m: '-30',
O: ['-40', '-41'],
M: ['-50', '-51'],
});
expect(program.args).toEqual(['-10', '-11']);
});

test('when program has digit option then negatives not allowed in leaf command', () => {
const program = new Command();
program
.exitOverride()
.configureOutput({ writeErr: () => {} })
.option('-2', 'double option');
let leafArgs;
program
.command('leaf')
.argument('[value...]')
.action((args) => {
leafArgs = args;
});
const args = ['leaf', '-1'];
expect(() => program.parse(args, { from: 'user' })).toThrow();
});

test('when default command without digit option then negatives accepted', () => {
const program = new Command();
let leafArgs;
program
.command('leaf', { isDefault: true })
.argument('[value...]')
.action((args) => {
leafArgs = args;
});
program.parse(['-1'], { from: 'user' });
expect(leafArgs).toEqual(['-1']);
});

test('when default command with digit option then negative throws', () => {
const program = new Command();
program.exitOverride().configureOutput({ writeErr: () => {} });
program
.command('leaf', { isDefault: true })
.option('-2')
.argument('[value...]')
.action(() => {});
expect(() => program.parse(['-1'], { from: 'user' })).toThrow();
});

test('when program has subcommand and action handler then negative command-argument unsupported', () => {
// Known limitation in parsing. Only allowed negative command-arguments in leaf commands
// to minimise changes to parsing when added support for negative numbers.
const program = new Command();
program
.exitOverride()
.configureOutput({ writeErr: () => {} })
.argument('[value...]')
.action(() => {});
program.command('leaf').action(() => {});
expect(() => program.parse(['-1'], { from: 'user' })).toThrow();
});