Skip to content

Commit

Permalink
feat: add default option parameter (#142)
Browse files Browse the repository at this point in the history
  • Loading branch information
Eomm committed Oct 8, 2022
1 parent 89e1b63 commit cd20847
Show file tree
Hide file tree
Showing 6 changed files with 292 additions and 2 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
25 changes: 25 additions & 0 deletions examples/is-default-value.js
Original file line number Diff line number Diff line change
@@ -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
57 changes: 55 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ const {
const {
validateArray,
validateBoolean,
validateBooleanArray,
validateObject,
validateString,
validateStringArray,
validateUnion,
} = require('./internal/validators');

Expand All @@ -38,6 +40,7 @@ const {
isOptionLikeValue,
isShortOptionAndValue,
isShortOptionGroup,
useDefaultValueOption,
objectGetOwn,
optionsGetOwn,
} = require('./utils');
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -265,6 +286,7 @@ function argsToTokens(args, options) {

ArrayPrototypePush(tokens, { kind: 'positional', index, value: arg });
}

return tokens;
}

Expand All @@ -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;
Expand All @@ -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`);
}
}
);
Expand Down Expand Up @@ -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;
};

Expand Down
21 changes: 21 additions & 0 deletions internal/validators.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -63,6 +82,8 @@ module.exports = {
validateArray,
validateObject,
validateString,
validateStringArray,
validateUnion,
validateBoolean,
validateBooleanArray,
};
174 changes: 174 additions & 0 deletions test/default-values.js
Original file line number Diff line number Diff line change
@@ -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/);
});
14 changes: 14 additions & 0 deletions utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -179,6 +192,7 @@ module.exports = {
isOptionLikeValue,
isShortOptionAndValue,
isShortOptionGroup,
useDefaultValueOption,
objectGetOwn,
optionsGetOwn,
};

0 comments on commit cd20847

Please sign in to comment.