diff --git a/README.md b/README.md index 9dd0100..0a04192 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ changes: times. If `true`, all values will be collected in an array. If `false`, values for the option are last-wins. **Default:** `false`. * `short` {string} A single character alias for the option. + * `default` {string | boolean | string\[] | boolean\[]} The default option + value when it is not set by args. It must be of the same type as the + the `type` property. When `multiple` is `true`, it must be an array. * `strict` {boolean} Should an error be thrown when unknown arguments are encountered, or when arguments are passed that do not match the `type` configured in `options`. diff --git a/examples/is-default-value.js b/examples/is-default-value.js new file mode 100644 index 0000000..0a67972 --- /dev/null +++ b/examples/is-default-value.js @@ -0,0 +1,25 @@ +'use strict'; + +// This example shows how to understand if a default value is used or not. + +// 1. const { parseArgs } = require('node:util'); // from node +// 2. const { parseArgs } = require('@pkgjs/parseargs'); // from package +const { parseArgs } = require('..'); // in repo + +const options = { + file: { short: 'f', type: 'string', default: 'FOO' }, +}; + +const { values, tokens } = parseArgs({ options, tokens: true }); + +const isFileDefault = !tokens.some((token) => token.kind === 'option' && + token.name === 'file' +); + +console.log(values); +console.log(`Is the file option [${values.file}] the default value? ${isFileDefault}`); + +// Try the following: +// node is-default-value.js +// node is-default-value.js -f FILE +// node is-default-value.js --file FILE diff --git a/index.js b/index.js index fb0a10e..b1004c7 100644 --- a/index.js +++ b/index.js @@ -20,8 +20,10 @@ const { const { validateArray, validateBoolean, + validateBooleanArray, validateObject, validateString, + validateStringArray, validateUnion, } = require('./internal/validators'); @@ -38,6 +40,7 @@ const { isOptionLikeValue, isShortOptionAndValue, isShortOptionGroup, + useDefaultValueOption, objectGetOwn, optionsGetOwn, } = require('./utils'); @@ -142,6 +145,24 @@ function storeOption(longOption, optionValue, options, values) { } } +/** + * Store the default option value in `values`. + * + * @param {string} longOption - long option name e.g. 'foo' + * @param {string + * | boolean + * | string[] + * | boolean[]} optionValue - default value from option config + * @param {object} values - option values returned in `values` by parseArgs + */ +function storeDefaultOption(longOption, optionValue, values) { + if (longOption === '__proto__') { + return; // No. Just no. + } + + values[longOption] = optionValue; +} + /** * Process args and turn into identified tokens: * - option (along with value, if any) @@ -265,6 +286,7 @@ function argsToTokens(args, options) { ArrayPrototypePush(tokens, { kind: 'positional', index, value: arg }); } + return tokens; } @@ -289,7 +311,8 @@ const parseArgs = (config = kEmptyObject) => { validateObject(optionConfig, `options.${longOption}`); // type is required - validateUnion(objectGetOwn(optionConfig, 'type'), `options.${longOption}.type`, ['string', 'boolean']); + const optionType = objectGetOwn(optionConfig, 'type'); + validateUnion(optionType, `options.${longOption}.type`, ['string', 'boolean']); if (ObjectHasOwn(optionConfig, 'short')) { const shortOption = optionConfig.short; @@ -303,8 +326,24 @@ const parseArgs = (config = kEmptyObject) => { } } + const multipleOption = objectGetOwn(optionConfig, 'multiple'); if (ObjectHasOwn(optionConfig, 'multiple')) { - validateBoolean(optionConfig.multiple, `options.${longOption}.multiple`); + validateBoolean(multipleOption, `options.${longOption}.multiple`); + } + + const defaultValue = objectGetOwn(optionConfig, 'default'); + if (defaultValue !== undefined) { + let validator; + switch (optionType) { + case 'string': + validator = multipleOption ? validateStringArray : validateString; + break; + + case 'boolean': + validator = multipleOption ? validateBooleanArray : validateBoolean; + break; + } + validator(defaultValue, `options.${longOption}.default`); } } ); @@ -335,6 +374,20 @@ const parseArgs = (config = kEmptyObject) => { } }); + // Phase 3: fill in default values for missing args + ArrayPrototypeForEach(ObjectEntries(options), ({ 0: longOption, + 1: optionConfig }) => { + const mustSetDefault = useDefaultValueOption(longOption, + optionConfig, + result.values); + if (mustSetDefault) { + storeDefaultOption(longOption, + objectGetOwn(optionConfig, 'default'), + result.values); + } + }); + + return result; }; diff --git a/internal/validators.js b/internal/validators.js index 9dae8e9..b5ac4fb 100644 --- a/internal/validators.js +++ b/internal/validators.js @@ -1,5 +1,10 @@ 'use strict'; +// This file is a proxy of the original file located at: +// https://github.com/nodejs/node/blob/main/lib/internal/validators.js +// Every addition or modification to this file must be evaluated +// during the PR review. + const { ArrayIsArray, ArrayPrototypeIncludes, @@ -36,6 +41,20 @@ function validateArray(value, name) { } } +function validateStringArray(value, name) { + validateArray(value, name); + for (let i = 0; i < value.length; i++) { + validateString(value[i], `${name}[${i}]`); + } +} + +function validateBooleanArray(value, name) { + validateArray(value, name); + for (let i = 0; i < value.length; i++) { + validateBoolean(value[i], `${name}[${i}]`); + } +} + /** * @param {unknown} value * @param {string} name @@ -63,6 +82,8 @@ module.exports = { validateArray, validateObject, validateString, + validateStringArray, validateUnion, validateBoolean, + validateBooleanArray, }; diff --git a/test/default-values.js b/test/default-values.js new file mode 100644 index 0000000..ad0bea0 --- /dev/null +++ b/test/default-values.js @@ -0,0 +1,174 @@ +/* global assert */ +/* eslint max-len: 0 */ +'use strict'; + +const { test } = require('./utils'); +const { parseArgs } = require('../index.js'); + +test('default must be a boolean when option type is boolean', () => { + const args = []; + const options = { alpha: { type: 'boolean', default: 'not a boolean' } }; + assert.throws(() => { + parseArgs({ args, options }); + }, /options\.alpha\.default must be Boolean/ + ); +}); + +test('default must accept undefined value', () => { + const args = []; + const options = { alpha: { type: 'boolean', default: undefined } }; + const result = parseArgs({ args, options }); + const expected = { + values: { + __proto__: null, + }, + positionals: [] + }; + assert.deepStrictEqual(result, expected); +}); + +test('default must be a boolean array when option type is boolean and multiple', () => { + const args = []; + const options = { alpha: { type: 'boolean', multiple: true, default: 'not an array' } }; + assert.throws(() => { + parseArgs({ args, options }); + }, /options\.alpha\.default must be Array/ + ); +}); + +test('default must be a boolean array when option type is string and multiple is true', () => { + const args = []; + const options = { alpha: { type: 'boolean', multiple: true, default: [true, true, 42] } }; + assert.throws(() => { + parseArgs({ args, options }); + }, /options\.alpha\.default\[2\] must be Boolean/ + ); +}); + +test('default must be a string when option type is string', () => { + const args = []; + const options = { alpha: { type: 'string', default: true } }; + assert.throws(() => { + parseArgs({ args, options }); + }, /options\.alpha\.default must be String/ + ); +}); + +test('default must be an array when option type is string and multiple is true', () => { + const args = []; + const options = { alpha: { type: 'string', multiple: true, default: 'not an array' } }; + assert.throws(() => { + parseArgs({ args, options }); + }, /options\.alpha\.default must be Array/ + ); +}); + +test('default must be a string array when option type is string and multiple is true', () => { + const args = []; + const options = { alpha: { type: 'string', multiple: true, default: ['str', 42] } }; + assert.throws(() => { + parseArgs({ args, options }); + }, /options\.alpha\.default\[1\] must be String/ + ); +}); + +test('default accepted input when multiple is true', () => { + const args = ['--inputStringArr', 'c', '--inputStringArr', 'd', '--inputBoolArr', '--inputBoolArr']; + const options = { + inputStringArr: { type: 'string', multiple: true, default: ['a', 'b'] }, + emptyStringArr: { type: 'string', multiple: true, default: [] }, + fullStringArr: { type: 'string', multiple: true, default: ['a', 'b'] }, + inputBoolArr: { type: 'boolean', multiple: true, default: [false, true, false] }, + emptyBoolArr: { type: 'boolean', multiple: true, default: [] }, + fullBoolArr: { type: 'boolean', multiple: true, default: [false, true, false] }, + }; + const expected = { values: { __proto__: null, + inputStringArr: ['c', 'd'], + inputBoolArr: [true, true], + emptyStringArr: [], + fullStringArr: ['a', 'b'], + emptyBoolArr: [], + fullBoolArr: [false, true, false] }, + positionals: [] }; + const result = parseArgs({ args, options }); + assert.deepStrictEqual(result, expected); +}); + +test('when default is set, the option must be added as result', () => { + const args = []; + const options = { + a: { type: 'string', default: 'HELLO' }, + b: { type: 'boolean', default: false }, + c: { type: 'boolean', default: true } + }; + const expected = { values: { __proto__: null, a: 'HELLO', b: false, c: true }, positionals: [] }; + + const result = parseArgs({ args, options }); + assert.deepStrictEqual(result, expected); +}); + +test('when default is set, the args value takes precedence', () => { + const args = ['--a', 'WORLD', '--b', '-c']; + const options = { + a: { type: 'string', default: 'HELLO' }, + b: { type: 'boolean', default: false }, + c: { type: 'boolean', default: true } + }; + const expected = { values: { __proto__: null, a: 'WORLD', b: true, c: true }, positionals: [] }; + + const result = parseArgs({ args, options }); + assert.deepStrictEqual(result, expected); +}); + +test('tokens should not include the default options', () => { + const args = []; + const options = { + a: { type: 'string', default: 'HELLO' }, + b: { type: 'boolean', default: false }, + c: { type: 'boolean', default: true } + }; + + const expectedTokens = []; + + const { tokens } = parseArgs({ args, options, tokens: true }); + assert.deepStrictEqual(tokens, expectedTokens); +}); + +test('tokens:true should not include the default options after the args input', () => { + const args = ['--z', 'zero', 'positional-item']; + const options = { + z: { type: 'string' }, + a: { type: 'string', default: 'HELLO' }, + b: { type: 'boolean', default: false }, + c: { type: 'boolean', default: true } + }; + + const expectedTokens = [ + { kind: 'option', name: 'z', rawName: '--z', index: 0, value: 'zero', inlineValue: false }, + { kind: 'positional', index: 2, value: 'positional-item' }, + ]; + + const { tokens } = parseArgs({ args, options, tokens: true, allowPositionals: true }); + assert.deepStrictEqual(tokens, expectedTokens); +}); + +test('proto as default value must be ignored', () => { + const args = []; + const options = Object.create(null); + + // eslint-disable-next-line no-proto + options.__proto__ = { type: 'string', default: 'HELLO' }; + + const result = parseArgs({ args, options, allowPositionals: true }); + const expected = { values: { __proto__: null }, positionals: [] }; + assert.deepStrictEqual(result, expected); +}); + + +test('multiple as false should expect a String', () => { + const args = []; + const options = { alpha: { type: 'string', multiple: false, default: ['array'] } }; + assert.throws(() => { + parseArgs({ args, options }); + }, / must be String got array/); +}); diff --git a/utils.js b/utils.js index a89fb6f..d7f420a 100644 --- a/utils.js +++ b/utils.js @@ -170,6 +170,19 @@ function findLongOptionForShort(shortOption, options) { return longOptionEntry?.[0] ?? shortOption; } +/** + * Check if the given option includes a default value + * and that option has not been set by the input args. + * + * @param {string} longOption - long option name e.g. 'foo' + * @param {object} optionConfig - the option configuration properties + * @param {object} values - option values returned in `values` by parseArgs + */ +function useDefaultValueOption(longOption, optionConfig, values) { + return objectGetOwn(optionConfig, 'default') !== undefined && + values[longOption] === undefined; +} + module.exports = { findLongOptionForShort, isLoneLongOption, @@ -179,6 +192,7 @@ module.exports = { isOptionLikeValue, isShortOptionAndValue, isShortOptionGroup, + useDefaultValueOption, objectGetOwn, optionsGetOwn, };