Skip to content

Commit c2b5e72

Browse files
authored
feat!: switch type:string option arguments to greedy, but with error for suspect cases in strict mode (#88)
1 parent 3643338 commit c2b5e72

File tree

5 files changed

+269
-18
lines changed

5 files changed

+269
-18
lines changed

index.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const {
2828
isLoneShortOption,
2929
isLongOptionAndValue,
3030
isOptionValue,
31+
isOptionLikeValue,
3132
isShortOptionAndValue,
3233
isShortOptionGroup,
3334
objectGetOwn,
@@ -77,6 +78,27 @@ function getMainArgs() {
7778
return ArrayPrototypeSlice(process.argv, 2);
7879
}
7980

81+
/**
82+
* In strict mode, throw for possible usage errors like --foo --bar
83+
*
84+
* @param {string} longOption - long option name e.g. 'foo'
85+
* @param {string|undefined} optionValue - value from user args
86+
* @param {string} shortOrLong - option used, with dashes e.g. `-l` or `--long`
87+
* @param {boolean} strict - show errors, from parseArgs({ strict })
88+
*/
89+
function checkOptionLikeValue(longOption, optionValue, shortOrLong, strict) {
90+
if (strict && isOptionLikeValue(optionValue)) {
91+
// Only show short example if user used short option.
92+
const example = (shortOrLong.length === 2) ?
93+
`'--${longOption}=-XYZ' or '${shortOrLong}-XYZ'` :
94+
`'--${longOption}=-XYZ'`;
95+
const errorMessage = `Option '${shortOrLong}' argument is ambiguous.
96+
Did you forget to specify the option argument for '${shortOrLong}'?
97+
Or to specify an option argument starting with a dash use ${example}.`;
98+
throw new ERR_PARSE_ARGS_INVALID_OPTION_VALUE(errorMessage);
99+
}
100+
}
101+
80102
/**
81103
* In strict mode, throw for usage errors.
82104
*
@@ -210,6 +232,7 @@ const parseArgs = (config = { __proto__: null }) => {
210232
isOptionValue(nextArg)) {
211233
// e.g. '-f', 'bar'
212234
optionValue = ArrayPrototypeShift(remainingArgs);
235+
checkOptionLikeValue(longOption, optionValue, arg, strict);
213236
}
214237
checkOptionUsage(longOption, optionValue, options,
215238
arg, strict, allowPositionals);
@@ -256,6 +279,7 @@ const parseArgs = (config = { __proto__: null }) => {
256279
isOptionValue(nextArg)) {
257280
// e.g. '--foo', 'bar'
258281
optionValue = ArrayPrototypeShift(remainingArgs);
282+
checkOptionLikeValue(longOption, optionValue, arg, strict);
259283
}
260284
checkOptionUsage(longOption, optionValue, options,
261285
arg, strict, allowPositionals);

test/index.js

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,3 +436,137 @@ test('null prototype: when --toString then values.toString is true', () => {
436436
const result = parseArgs({ args, options });
437437
assert.deepStrictEqual(result, expectedResult);
438438
});
439+
440+
const candidateGreedyOptions = [
441+
'',
442+
'-',
443+
'--',
444+
'abc',
445+
'123',
446+
'-s',
447+
'--foo',
448+
];
449+
450+
candidateGreedyOptions.forEach((value) => {
451+
test(`greedy: when short option with value '${value}' then eaten`, () => {
452+
const args = ['-w', value];
453+
const options = { with: { type: 'string', short: 'w' } };
454+
const expectedResult = { values: { __proto__: null, with: value }, positionals: [] };
455+
456+
const result = parseArgs({ args, options, strict: false });
457+
assert.deepStrictEqual(result, expectedResult);
458+
});
459+
460+
test(`greedy: when long option with value '${value}' then eaten`, () => {
461+
const args = ['--with', value];
462+
const options = { with: { type: 'string', short: 'w' } };
463+
const expectedResult = { values: { __proto__: null, with: value }, positionals: [] };
464+
465+
const result = parseArgs({ args, options, strict: false });
466+
assert.deepStrictEqual(result, expectedResult);
467+
});
468+
});
469+
470+
test('strict: when candidate option value is plain text then does not throw', () => {
471+
const args = ['--with', 'abc'];
472+
const options = { with: { type: 'string' } };
473+
const expectedResult = { values: { __proto__: null, with: 'abc' }, positionals: [] };
474+
475+
const result = parseArgs({ args, options, strict: true });
476+
assert.deepStrictEqual(result, expectedResult);
477+
});
478+
479+
test("strict: when candidate option value is '-' then does not throw", () => {
480+
const args = ['--with', '-'];
481+
const options = { with: { type: 'string' } };
482+
const expectedResult = { values: { __proto__: null, with: '-' }, positionals: [] };
483+
484+
const result = parseArgs({ args, options, strict: true });
485+
assert.deepStrictEqual(result, expectedResult);
486+
});
487+
488+
test("strict: when candidate option value is '--' then throws", () => {
489+
const args = ['--with', '--'];
490+
const options = { with: { type: 'string' } };
491+
492+
assert.throws(() => {
493+
parseArgs({ args, options });
494+
}, {
495+
code: 'ERR_PARSE_ARGS_INVALID_OPTION_VALUE'
496+
});
497+
});
498+
499+
test('strict: when candidate option value is short option then throws', () => {
500+
const args = ['--with', '-a'];
501+
const options = { with: { type: 'string' } };
502+
503+
assert.throws(() => {
504+
parseArgs({ args, options });
505+
}, {
506+
code: 'ERR_PARSE_ARGS_INVALID_OPTION_VALUE'
507+
});
508+
});
509+
510+
test('strict: when candidate option value is short option digit then throws', () => {
511+
const args = ['--with', '-1'];
512+
const options = { with: { type: 'string' } };
513+
514+
assert.throws(() => {
515+
parseArgs({ args, options });
516+
}, {
517+
code: 'ERR_PARSE_ARGS_INVALID_OPTION_VALUE'
518+
});
519+
});
520+
521+
test('strict: when candidate option value is long option then throws', () => {
522+
const args = ['--with', '--foo'];
523+
const options = { with: { type: 'string' } };
524+
525+
assert.throws(() => {
526+
parseArgs({ args, options });
527+
}, {
528+
code: 'ERR_PARSE_ARGS_INVALID_OPTION_VALUE'
529+
});
530+
});
531+
532+
test('strict: when short option and suspect value then throws with short option in error message', () => {
533+
const args = ['-w', '--foo'];
534+
const options = { with: { type: 'string', short: 'w' } };
535+
536+
assert.throws(() => {
537+
parseArgs({ args, options });
538+
}, /for '-w'/
539+
);
540+
});
541+
542+
test('strict: when long option and suspect value then throws with long option in error message', () => {
543+
const args = ['--with', '--foo'];
544+
const options = { with: { type: 'string' } };
545+
546+
assert.throws(() => {
547+
parseArgs({ args, options });
548+
}, /for '--with'/
549+
);
550+
});
551+
552+
test('strict: when short option and suspect value then throws with whole expected message', () => {
553+
const args = ['-w', '--foo'];
554+
const options = { with: { type: 'string', short: 'w' } };
555+
556+
assert.throws(() => {
557+
parseArgs({ args, options });
558+
// eslint-disable-next-line max-len
559+
}, /Error: Option '-w' argument is ambiguous\.\nDid you forget to specify the option argument for '-w'\?\nOr to specify an option argument starting with a dash use '--with=-XYZ' or '-w-XYZ'\./
560+
);
561+
});
562+
563+
test('strict: when long option and suspect value then throws with whole expected message', () => {
564+
const args = ['--with', '--foo'];
565+
const options = { with: { type: 'string', short: 'w' } };
566+
567+
assert.throws(() => {
568+
parseArgs({ args, options });
569+
// eslint-disable-next-line max-len
570+
}, /Error: Option '--with' argument is ambiguous\.\nDid you forget to specify the option argument for '--with'\?\nOr to specify an option argument starting with a dash use '--with=-XYZ'\./
571+
);
572+
});

test/is-option-like-value.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
'use strict';
2+
/* eslint max-len: 0 */
3+
4+
const test = require('tape');
5+
const { isOptionLikeValue } = require('../utils.js');
6+
7+
// Basically rejecting values starting with a dash, but run through the interesting possibilities.
8+
9+
test('isOptionLikeValue: when passed plain text then returns false', (t) => {
10+
t.false(isOptionLikeValue('abc'));
11+
t.end();
12+
});
13+
14+
test('isOptionLikeValue: when passed digits then returns false', (t) => {
15+
t.false(isOptionLikeValue(123));
16+
t.end();
17+
});
18+
19+
test('isOptionLikeValue: when passed empty string then returns false', (t) => {
20+
t.false(isOptionLikeValue(''));
21+
t.end();
22+
});
23+
24+
// Special case, used as stdin/stdout et al and not reason to reject
25+
test('isOptionLikeValue: when passed dash then returns false', (t) => {
26+
t.false(isOptionLikeValue('-'));
27+
t.end();
28+
});
29+
30+
test('isOptionLikeValue: when passed -- then returns true', (t) => {
31+
// Not strictly option-like, but is supect
32+
t.true(isOptionLikeValue('--'));
33+
t.end();
34+
});
35+
36+
// Supporting undefined so can pass element off end of array without checking
37+
test('isOptionLikeValue: when passed undefined then returns false', (t) => {
38+
t.false(isOptionLikeValue(undefined));
39+
t.end();
40+
});
41+
42+
test('isOptionLikeValue: when passed short option then returns true', (t) => {
43+
t.true(isOptionLikeValue('-a'));
44+
t.end();
45+
});
46+
47+
test('isOptionLikeValue: when passed short option digit then returns true', (t) => {
48+
t.true(isOptionLikeValue('-1'));
49+
t.end();
50+
});
51+
52+
test('isOptionLikeValue: when passed negative number then returns true', (t) => {
53+
t.true(isOptionLikeValue('-123'));
54+
t.end();
55+
});
56+
57+
test('isOptionLikeValue: when passed short option group of short option with value then returns true', (t) => {
58+
t.true(isOptionLikeValue('-abd'));
59+
t.end();
60+
});
61+
62+
test('isOptionLikeValue: when passed long option then returns true', (t) => {
63+
t.true(isOptionLikeValue('--foo'));
64+
t.end();
65+
});
66+
67+
test('isOptionLikeValue: when passed long option with value then returns true', (t) => {
68+
t.true(isOptionLikeValue('--foo=bar'));
69+
t.end();
70+
});

test/is-option-value.js

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
const test = require('tape');
55
const { isOptionValue } = require('../utils.js');
66

7+
// Options are greedy so simple behaviour, but run through the interesting possibilities.
8+
79
test('isOptionValue: when passed plain text then returns true', (t) => {
810
t.true(isOptionValue('abc'));
911
t.end();
@@ -25,28 +27,43 @@ test('isOptionValue: when passed dash then returns true', (t) => {
2527
t.end();
2628
});
2729

28-
// Supporting undefined so can pass element off end of array without checking
30+
test('isOptionValue: when passed -- then returns true', (t) => {
31+
t.true(isOptionValue('--'));
32+
t.end();
33+
});
34+
35+
// Checking undefined so can pass element off end of array.
2936
test('isOptionValue: when passed undefined then returns false', (t) => {
3037
t.false(isOptionValue(undefined));
3138
t.end();
3239
});
3340

34-
test('isOptionValue: when passed short option then returns false', (t) => {
35-
t.false(isOptionValue('-a'));
41+
test('isOptionValue: when passed short option then returns true', (t) => {
42+
t.true(isOptionValue('-a'));
43+
t.end();
44+
});
45+
46+
test('isOptionValue: when passed short option digit then returns true', (t) => {
47+
t.true(isOptionValue('-1'));
48+
t.end();
49+
});
50+
51+
test('isOptionValue: when passed negative number then returns true', (t) => {
52+
t.true(isOptionValue('-123'));
3653
t.end();
3754
});
3855

39-
test('isOptionValue: when passed short option group of short option with value then returns false', (t) => {
40-
t.false(isOptionValue('-abd'));
56+
test('isOptionValue: when passed short option group of short option with value then returns true', (t) => {
57+
t.true(isOptionValue('-abd'));
4158
t.end();
4259
});
4360

44-
test('isOptionValue: when passed long option then returns false', (t) => {
45-
t.false(isOptionValue('--foo'));
61+
test('isOptionValue: when passed long option then returns true', (t) => {
62+
t.true(isOptionValue('--foo'));
4663
t.end();
4764
});
4865

49-
test('isOptionValue: when passed long option with value then returns false', (t) => {
50-
t.false(isOptionValue('--foo=bar'));
66+
test('isOptionValue: when passed long option with value then returns true', (t) => {
67+
t.true(isOptionValue('--foo=bar'));
5168
t.end();
5269
});

utils.js

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,24 +39,29 @@ function optionsGetOwn(options, longOption, prop) {
3939

4040
/**
4141
* Determines if the argument may be used as an option value.
42-
* NB: We are choosing not to accept option-ish arguments.
4342
* @example
4443
* isOptionValue('V') // returns true
45-
* isOptionValue('-v') // returns false
46-
* isOptionValue('--foo') // returns false
44+
* isOptionValue('-v') // returns true (greedy)
45+
* isOptionValue('--foo') // returns true (greedy)
4746
* isOptionValue(undefined) // returns false
4847
*/
4948
function isOptionValue(value) {
5049
if (value == null) return false;
51-
if (value === '-') return true; // e.g. representing stdin/stdout for file
5250

5351
// Open Group Utility Conventions are that an option-argument
5452
// is the argument after the option, and may start with a dash.
55-
// However, we are currently rejecting these and prioritising the
56-
// option-like appearance of the argument. Rejection allows more error
57-
// detection for strict:true, but comes at the cost of rejecting intended
58-
// values starting with a dash, especially negative numbers.
59-
return !StringPrototypeStartsWith(value, '-');
53+
return true; // greedy!
54+
}
55+
56+
/**
57+
* Detect whether there is possible confusion and user may have omitted
58+
* the option argument, like `--port --verbose` when `port` of type:string.
59+
* In strict mode we throw errors if value is option-like.
60+
*/
61+
function isOptionLikeValue(value) {
62+
if (value == null) return false;
63+
64+
return value.length > 1 && StringPrototypeCharAt(value, 0) === '-';
6065
}
6166

6267
/**
@@ -172,6 +177,7 @@ module.exports = {
172177
isLoneShortOption,
173178
isLongOptionAndValue,
174179
isOptionValue,
180+
isOptionLikeValue,
175181
isShortOptionAndValue,
176182
isShortOptionGroup,
177183
objectGetOwn,

0 commit comments

Comments
 (0)