Skip to content

Commit 0b001a3

Browse files
committed
util: add default value option to parsearg
Users can set a default value for every expected input argument
1 parent 2568325 commit 0b001a3

File tree

5 files changed

+260
-2
lines changed

5 files changed

+260
-2
lines changed

doc/api/util.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,9 @@ added:
10311031
- v18.3.0
10321032
- v16.17.0
10331033
changes:
1034+
- version: REPLACEME
1035+
pr-url: https://github.com/nodejs/node/pull/44631
1036+
description: add support for default values in input `config`.
10341037
- version:
10351038
- v18.7.0
10361039
- v16.17.0
@@ -1053,6 +1056,9 @@ changes:
10531056
times. If `true`, all values will be collected in an array. If
10541057
`false`, values for the option are last-wins. **Default:** `false`.
10551058
* `short` {string} A single character alias for the option.
1059+
* `default` {string | boolean | string[] | boolean[]} The default option
1060+
value when it is not set by args. It must be of the same type as the
1061+
the `type` property. When `multiple` is `true`, it must be an array.
10561062
* `strict` {boolean} Should an error be thrown when unknown arguments
10571063
are encountered, or when arguments are passed that do not match the
10581064
`type` configured in `options`.

lib/internal/util/parse_args/parse_args.js

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ const {
2020
const {
2121
validateArray,
2222
validateBoolean,
23+
validateBooleanArray,
2324
validateObject,
2425
validateString,
26+
validateStringArray,
2527
validateUnion,
2628
} = require('internal/validators');
2729

@@ -34,6 +36,7 @@ const {
3436
isOptionLikeValue,
3537
isShortOptionAndValue,
3638
isShortOptionGroup,
39+
useDefaultValueOption,
3740
objectGetOwn,
3841
optionsGetOwn,
3942
} = require('internal/util/parse_args/utils');
@@ -143,6 +146,24 @@ function storeOption(longOption, optionValue, options, values) {
143146
}
144147
}
145148

149+
/**
150+
* Store the default option value in `values`.
151+
*
152+
* @param {string} longOption - long option name e.g. 'foo'
153+
* @param {string
154+
* | boolean
155+
* | string[]
156+
* | boolean[]} optionValue - default value from option config
157+
* @param {object} values - option values returned in `values` by parseArgs
158+
*/
159+
function storeDefaultOption(longOption, optionValue, values) {
160+
if (longOption === '__proto__') {
161+
return; // No. Just no.
162+
}
163+
164+
values[longOption] = optionValue;
165+
}
166+
146167
/**
147168
* Process args and turn into identified tokens:
148169
* - option (along with value, if any)
@@ -290,7 +311,8 @@ const parseArgs = (config = kEmptyObject) => {
290311
validateObject(optionConfig, `options.${longOption}`);
291312

292313
// type is required
293-
validateUnion(objectGetOwn(optionConfig, 'type'), `options.${longOption}.type`, ['string', 'boolean']);
314+
const optionType = objectGetOwn(optionConfig, 'type');
315+
validateUnion(optionType, `options.${longOption}.type`, ['string', 'boolean']);
294316

295317
if (ObjectHasOwn(optionConfig, 'short')) {
296318
const shortOption = optionConfig.short;
@@ -304,8 +326,22 @@ const parseArgs = (config = kEmptyObject) => {
304326
}
305327
}
306328

329+
const multipleOption = objectGetOwn(optionConfig, 'multiple');
307330
if (ObjectHasOwn(optionConfig, 'multiple')) {
308-
validateBoolean(optionConfig.multiple, `options.${longOption}.multiple`);
331+
validateBoolean(multipleOption, `options.${longOption}.multiple`);
332+
}
333+
334+
if (ObjectHasOwn(optionConfig, 'default')) {
335+
const defaultValue = objectGetOwn(optionConfig, 'default');
336+
if (optionType === 'string' && !multipleOption) {
337+
validateString(defaultValue, `options.${longOption}.default`);
338+
} else if (optionType === 'string' && multipleOption) {
339+
validateStringArray(defaultValue, `options.${longOption}.default`);
340+
} else if (optionType === 'boolean' && !multipleOption) {
341+
validateBoolean(defaultValue, `options.${longOption}.default`);
342+
} else if (optionType === 'boolean' && multipleOption) {
343+
validateBooleanArray(defaultValue, `options.${longOption}.default`);
344+
}
309345
}
310346
}
311347
);
@@ -336,6 +372,20 @@ const parseArgs = (config = kEmptyObject) => {
336372
}
337373
});
338374

375+
// Phase 3: fill in default values for missing args
376+
ArrayPrototypeForEach(ObjectEntries(options), ({ 0: longOption,
377+
1: optionConfig }) => {
378+
const mustSetDefault = useDefaultValueOption(longOption,
379+
optionConfig,
380+
result.values);
381+
if (mustSetDefault) {
382+
storeDefaultOption(longOption,
383+
objectGetOwn(optionConfig, 'default'),
384+
result.values);
385+
}
386+
});
387+
388+
339389
return result;
340390
};
341391

lib/internal/util/parse_args/utils.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,19 @@ function findLongOptionForShort(shortOption, options) {
170170
return longOptionEntry?.[0] ?? shortOption;
171171
}
172172

173+
/**
174+
* Check if the given option includes a default value
175+
* and that option has not been set by the input args.
176+
*
177+
* @param {string} longOption - long option name e.g. 'foo'
178+
* @param {object} optionConfig - the option configuration properties
179+
* @param {object} values - option values returned in `values` by parseArgs
180+
*/
181+
function useDefaultValueOption(longOption, optionConfig, values) {
182+
return objectGetOwn(optionConfig, 'default') !== undefined &&
183+
values[longOption] === undefined;
184+
}
185+
173186
module.exports = {
174187
findLongOptionForShort,
175188
isLoneLongOption,
@@ -179,6 +192,7 @@ module.exports = {
179192
isOptionLikeValue,
180193
isShortOptionAndValue,
181194
isShortOptionGroup,
195+
useDefaultValueOption,
182196
objectGetOwn,
183197
optionsGetOwn,
184198
};

lib/internal/validators.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,36 @@ const validateArray = hideStackFrames((value, name, minLength = 0) => {
268268
}
269269
});
270270

271+
/**
272+
* @callback validateStringArray
273+
* @param {*} value
274+
* @param {string} name
275+
* @returns {asserts value is string[]}
276+
*/
277+
278+
/** @type {validateStringArray} */
279+
function validateStringArray(value, name) {
280+
validateArray(value, name);
281+
for (let i = 0; i < value.length; i++) {
282+
validateString(value[i], `${name}[${i}]`);
283+
}
284+
}
285+
286+
/**
287+
* @callback validateBooleanArray
288+
* @param {*} value
289+
* @param {string} name
290+
* @returns {asserts value is boolean[]}
291+
*/
292+
293+
/** @type {validateBooleanArray} */
294+
function validateBooleanArray(value, name) {
295+
validateArray(value, name);
296+
for (let i = 0; i < value.length; i++) {
297+
validateBoolean(value[i], `${name}[${i}]`);
298+
}
299+
}
300+
271301
// eslint-disable-next-line jsdoc/require-returns-check
272302
/**
273303
* @param {*} signal
@@ -423,6 +453,8 @@ module.exports = {
423453
isUint32,
424454
parseFileMode,
425455
validateArray,
456+
validateStringArray,
457+
validateBooleanArray,
426458
validateBoolean,
427459
validateBuffer,
428460
validateEncoding,

test/parallel/test-parse-args.mjs

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -823,3 +823,159 @@ test('tokens: strict:false with -- --', () => {
823823
const { tokens } = parseArgs({ strict: false, args, tokens: true });
824824
assert.deepStrictEqual(tokens, expectedTokens);
825825
});
826+
827+
test('default must be a boolean when option type is boolean', () => {
828+
const args = [];
829+
const options = { alpha: { type: 'boolean', default: 'not a boolean' } };
830+
assert.throws(() => {
831+
parseArgs({ args, options });
832+
}, `TypeError [ERR_INVALID_ARG_TYPE]: The "options.alpha.default" property must be an instance of Array. Received type string ('not an array')`
833+
);
834+
});
835+
836+
test('default must be a boolean array when option type is boolean and multiple', () => {
837+
const args = [];
838+
const options = { alpha: { type: 'boolean', multiple: true, default: 'not an array' } };
839+
assert.throws(() => {
840+
parseArgs({ args, options });
841+
}, `TypeError [ERR_INVALID_ARG_TYPE]: The "options.alpha.default" property must be an instance of Array. Received type string ('not an array')`
842+
);
843+
});
844+
845+
test('default must be a boolean array when option type is string and multiple is true', () => {
846+
const args = [];
847+
const options = { alpha: { type: 'boolean', multiple: true, default: [true, true, 42] } };
848+
assert.throws(() => {
849+
parseArgs({ args, options });
850+
}, `TypeError [ERR_INVALID_ARG_TYPE]: The "options.alpha.default[2]" property must be of type boolean. Received type number (42)`
851+
);
852+
});
853+
854+
test('default must be a string when option type is string', () => {
855+
const args = [];
856+
const options = { alpha: { type: 'string', default: true } };
857+
assert.throws(() => {
858+
parseArgs({ args, options });
859+
}, `TypeError [ERR_INVALID_ARG_TYPE]: The "options.alpha.default" property must be of type string. Received type boolean (true)`
860+
);
861+
});
862+
863+
test('default must be an array when option type is string and multiple is true', () => {
864+
const args = [];
865+
const options = { alpha: { type: 'string', multiple: true, default: 'not an array' } };
866+
assert.throws(() => {
867+
parseArgs({ args, options });
868+
}, `TypeError [ERR_INVALID_ARG_TYPE]: The "options.alpha.default" property must be an instance of Array. Received type string ('not an array')`
869+
);
870+
});
871+
872+
test('default must be a string array when option type is string and multiple is true', () => {
873+
const args = [];
874+
const options = { alpha: { type: 'string', multiple: true, default: ['str', 42] } };
875+
assert.throws(() => {
876+
parseArgs({ args, options });
877+
}, `TypeError [ERR_INVALID_ARG_TYPE]: The "options.alpha.default[1]" property must be of type string. Received type number (42)`
878+
);
879+
});
880+
881+
test('default accepted input when multiple is true', () => {
882+
const args = ['--inputStringArr', 'c', '--inputStringArr', 'd', '--inputBoolArr', '--inputBoolArr'];
883+
const options = {
884+
inputStringArr: { type: 'string', multiple: true, default: ['a', 'b'] },
885+
emptyStringArr: { type: 'string', multiple: true, default: [] },
886+
fullStringArr: { type: 'string', multiple: true, default: ['a', 'b'] },
887+
inputBoolArr: { type: 'boolean', multiple: true, default: [false, true, false] },
888+
emptyBoolArr: { type: 'boolean', multiple: true, default: [] },
889+
fullBoolArr: { type: 'boolean', multiple: true, default: [false, true, false] },
890+
};
891+
const expected = { values: { __proto__: null,
892+
inputStringArr: ['c', 'd'],
893+
inputBoolArr: [true, true],
894+
emptyStringArr: [],
895+
fullStringArr: ['a', 'b'],
896+
emptyBoolArr: [],
897+
fullBoolArr: [false, true, false] },
898+
positionals: [] };
899+
const result = parseArgs({ args, options });
900+
assert.deepStrictEqual(result, expected);
901+
});
902+
903+
test('when default is set, the option must be added as result', () => {
904+
const args = [];
905+
const options = {
906+
a: { type: 'string', default: 'HELLO' },
907+
b: { type: 'boolean', default: false },
908+
c: { type: 'boolean', default: true }
909+
};
910+
const expected = { values: { __proto__: null, a: 'HELLO', b: false, c: true }, positionals: [] };
911+
912+
const result = parseArgs({ args, options });
913+
assert.deepStrictEqual(result, expected);
914+
});
915+
916+
test('when default is set, the args value takes precedence', () => {
917+
const args = ['--a', 'WORLD', '--b', '-c'];
918+
const options = {
919+
a: { type: 'string', default: 'HELLO' },
920+
b: { type: 'boolean', default: false },
921+
c: { type: 'boolean', default: true }
922+
};
923+
const expected = { values: { __proto__: null, a: 'WORLD', b: true, c: true }, positionals: [] };
924+
925+
const result = parseArgs({ args, options });
926+
assert.deepStrictEqual(result, expected);
927+
});
928+
929+
test('tokens should not include the default options', () => {
930+
const args = [];
931+
const options = {
932+
a: { type: 'string', default: 'HELLO' },
933+
b: { type: 'boolean', default: false },
934+
c: { type: 'boolean', default: true }
935+
};
936+
937+
const expectedTokens = [];
938+
939+
const { tokens } = parseArgs({ args, options, tokens: true });
940+
assert.deepStrictEqual(tokens, expectedTokens);
941+
});
942+
943+
test('tokens:true should not include the default options after the args input', () => {
944+
const args = ['--z', 'zero', 'positional-item'];
945+
const options = {
946+
z: { type: 'string' },
947+
a: { type: 'string', default: 'HELLO' },
948+
b: { type: 'boolean', default: false },
949+
c: { type: 'boolean', default: true }
950+
};
951+
952+
const expectedTokens = [
953+
{ kind: 'option', name: 'z', rawName: '--z', index: 0, value: 'zero', inlineValue: false },
954+
{ kind: 'positional', index: 2, value: 'positional-item' },
955+
];
956+
957+
const { tokens } = parseArgs({ args, options, tokens: true, allowPositionals: true });
958+
assert.deepStrictEqual(tokens, expectedTokens);
959+
});
960+
961+
test('proto as default value must be ignored', () => {
962+
const args = [];
963+
const options = Object.create(null);
964+
965+
// eslint-disable-next-line no-proto
966+
options.__proto__ = { type: 'string', default: 'HELLO' };
967+
968+
const result = parseArgs({ args, options, allowPositionals: true });
969+
const expected = { values: { __proto__: null }, positionals: [] };
970+
assert.deepStrictEqual(result, expected);
971+
});
972+
973+
974+
test('multiple as false should expect a String', () => {
975+
const args = [];
976+
const options = { alpha: { type: 'string', multiple: false, default: ['array'] } };
977+
assert.throws(() => {
978+
parseArgs({ args, options });
979+
}, `TypeError [ERR_INVALID_ARG_TYPE]: The "options.alpha.default" property must be of type string. Received an instance of Array`
980+
);
981+
});

0 commit comments

Comments
 (0)