From c4c5ee2cb2fb7c87d6269f6876c01159ab1cc124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20L=C3=B6fgren?= Date: Tue, 5 May 2020 18:17:05 +0200 Subject: [PATCH] Add `isMultiple` option for flags (#143) Co-authored-by: Sindre Sorhus --- index.d.ts | 5 +- index.js | 69 +++++++++++++++----- index.test-d.ts | 4 +- package.json | 1 + readme.md | 4 +- test.js | 170 ++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 233 insertions(+), 20 deletions(-) diff --git a/index.d.ts b/index.d.ts index c084e02..a54fe3f 100644 --- a/index.d.ts +++ b/index.d.ts @@ -7,6 +7,7 @@ declare namespace meow { readonly type?: Type; readonly alias?: string; readonly default?: Default; + readonly isMultiple?: boolean; } type StringFlag = Flag<'string', string>; @@ -24,6 +25,7 @@ declare namespace meow { - `type`: Type of value. (Possible values: `string` `boolean` `number`) - `alias`: Usually used to define a short flag alias. - `default`: Default value when the flag is not specified. + - `isMultiple`: Indicates a flag can be set multiple times. Values are turned into an array. (Default: false) @example ``` @@ -31,7 +33,8 @@ declare namespace meow { unicorn: { type: 'string', alias: 'u', - default: 'rainbow' + default: ['rainbow', 'cat'], + isMultiple: true } } ``` diff --git a/index.js b/index.js index 9e698cf..d2974d4 100644 --- a/index.js +++ b/index.js @@ -9,11 +9,51 @@ const redent = require('redent'); const readPkgUp = require('read-pkg-up'); const hardRejection = require('hard-rejection'); const normalizePackageData = require('normalize-package-data'); +const arrify = require('arrify'); // Prevent caching of this module so module.parent is always accurate delete require.cache[__filename]; const parentDir = path.dirname(module.parent.filename); +const buildParserFlags = ({flags, booleanDefault}) => + Object.entries(flags).reduce((parserFlags, [flagKey, flagValue]) => { + const flag = {...flagValue}; + + if ( + typeof booleanDefault !== 'undefined' && + flag.type === 'boolean' && + !Object.prototype.hasOwnProperty.call(flag, 'default') + ) { + flag.default = flag.isMultiple ? [booleanDefault] : booleanDefault; + } + + if (flag.isMultiple) { + flag.type = 'array'; + delete flag.isMultiple; + } + + parserFlags[flagKey] = flag; + + return parserFlags; + }, {}); + +/** +Convert to alternative syntax for coercing values to expected type, according to https://github.com/yargs/yargs-parser#requireyargs-parserargs-opts. +*/ +const convertToTypedArrayOption = (arrayOption, flags) => + arrify(arrayOption).map(flagKey => ({ + key: flagKey, + [flags[flagKey].type || 'string']: true + })); + +const validateFlags = (flags, options) => { + for (const [flagKey, flagValue] of Object.entries(options.flags)) { + if (flagKey !== '--' && !flagValue.isMultiple && Array.isArray(flags[flagKey])) { + throw new Error(`The flag --${flagKey} can only be set once.`); + } + } +}; + const meow = (helpText, options) => { if (typeof helpText !== 'string') { options = helpText; @@ -26,6 +66,7 @@ const meow = (helpText, options) => { normalize: false }).packageJson || {}, argv: process.argv.slice(2), + flags: {}, inferType: false, input: 'string', help: helpText, @@ -40,20 +81,9 @@ const meow = (helpText, options) => { hardRejection(); } - const parserFlags = options.flags && typeof options.booleanDefault !== 'undefined' ? Object.keys(options.flags).reduce( - (flags, flag) => { - if (flags[flag].type === 'boolean' && !Object.prototype.hasOwnProperty.call(flags[flag], 'default')) { - flags[flag].default = options.booleanDefault; - } - - return flags; - }, - options.flags - ) : options.flags; - let parserOptions = { arguments: options.input, - ...parserFlags + ...buildParserFlags(options) }; parserOptions = decamelizeKeys(parserOptions, '-', {exclude: ['stopEarly', '--']}); @@ -71,6 +101,13 @@ const meow = (helpText, options) => { }; } + if (parserOptions.array !== undefined) { + // `yargs` supports 'string|number|boolean' arrays, + // but `minimist-options` only support 'string' as element type. + // Open issue to add support to `minimist-options`: https://github.com/vadimdemedes/minimist-options/issues/18. + parserOptions.array = convertToTypedArrayOption(parserOptions.array, options.flags); + } + const {pkg} = options; const argv = yargs(options.argv, parserOptions); let help = redent(trimNewlines((options.help || '').replace(/\t+\n*$/, '')), 2); @@ -112,10 +149,10 @@ const meow = (helpText, options) => { const flags = camelcaseKeys(argv, {exclude: ['--', /^\w$/]}); const unnormalizedFlags = {...flags}; - if (options.flags !== undefined) { - for (const flagValue of Object.values(options.flags)) { - delete flags[flagValue.alias]; - } + validateFlags(flags, options); + + for (const flagValue of Object.values(options.flags)) { + delete flags[flagValue.alias]; } return { diff --git a/index.test-d.ts b/index.test-d.ts index 791c8cf..640b8ca 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -35,8 +35,8 @@ const result = meow('Help text', { foo: {type: 'boolean', alias: 'f'}, 'foo-bar': {type: 'number'}, bar: {type: 'string', default: ''} - }} -); + } +}); expectType(result.input); expectType(result.pkg); diff --git a/package.json b/package.json index 6b163bb..b83e450 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ ], "dependencies": { "@types/minimist": "^1.2.0", + "arrify": "^2.0.1", "camelcase-keys": "^6.2.2", "decamelize-keys": "^1.1.0", "hard-rejection": "^2.1.0", diff --git a/readme.md b/readme.md index 833acf1..9d59c78 100644 --- a/readme.md +++ b/readme.md @@ -137,6 +137,7 @@ The key is the flag name and the value is an object with any of: - `type`: Type of value. (Possible values: `string` `boolean` `number`) - `alias`: Usually used to define a short flag alias. - `default`: Default value when the flag is not specified. +- `isMultiple`: Indicates a flag can be set multiple times. Values are turned into an array. (Default: false) Example: @@ -145,7 +146,8 @@ flags: { unicorn: { type: 'string', alias: 'u', - default: 'rainbow' + default: ['rainbow', 'cat'], + isMultiple: true } } ``` diff --git a/test.js b/test.js index 0060af6..7e147b0 100644 --- a/test.js +++ b/test.js @@ -311,6 +311,176 @@ test('supports `number` flag type - throws on incorrect default value', t => { }); }); +test('isMultiple - flag set once returns array', t => { + t.deepEqual(meow({ + argv: ['--foo=bar'], + flags: { + foo: { + type: 'string', + isMultiple: true + } + } + }).flags, { + foo: ['bar'] + }); +}); + +test('isMultiple - flag set multiple times', t => { + t.deepEqual(meow({ + argv: ['--foo=bar', '--foo=baz'], + flags: { + foo: { + type: 'string', + isMultiple: true + } + } + }).flags, { + foo: ['bar', 'baz'] + }); +}); + +test('isMultiple - flag with space separated values', t => { + t.deepEqual(meow({ + argv: ['--foo', 'bar', 'baz'], + flags: { + foo: { + type: 'string', + isMultiple: true + } + } + }).flags, { + foo: ['bar', 'baz'] + }); +}); + +test('single flag set more than once => throws', t => { + t.throws(() => { + meow({ + argv: ['--foo=bar', '--foo=baz'], + flags: { + foo: { + type: 'string' + } + } + }); + }, {message: 'The flag --foo can only be set once.'}); +}); + +test('isMultiple - boolean flag', t => { + t.deepEqual(meow({ + argv: ['--foo', '--foo=false'], + flags: { + foo: { + type: 'boolean', + isMultiple: true + } + } + }).flags, { + foo: [true, false] + }); +}); + +test('isMultiple - boolean flag is false by default', t => { + t.deepEqual(meow({ + argv: [], + flags: { + foo: { + type: 'boolean', + isMultiple: true + } + } + }).flags, { + foo: [false] + }); +}); + +test('isMultiple - flag with `booleanDefault: undefined` => filter out unset boolean args', t => { + t.deepEqual(meow({ + argv: ['--foo'], + booleanDefault: undefined, + flags: { + foo: { + type: 'boolean', + isMultiple: true + }, + bar: { + type: 'boolean', + isMultiple: true + } + } + }).flags, { + foo: [true] + }); +}); + +test('isMultiple - number flag', t => { + t.deepEqual(meow({ + argv: ['--foo=1.3', '--foo=-1'], + flags: { + foo: { + type: 'number', + isMultiple: true + } + } + }).flags, { + foo: [1.3, -1] + }); +}); + +test('isMultiple - flag default values', t => { + t.deepEqual(meow({ + argv: [], + flags: { + string: { + type: 'string', + isMultiple: true, + default: ['foo'] + }, + boolean: { + type: 'boolean', + isMultiple: true, + default: [true] + }, + number: { + type: 'number', + isMultiple: true, + default: [0.5] + } + } + }).flags, { + string: ['foo'], + boolean: [true], + number: [0.5] + }); +}); + +test('isMultiple - multiple flag default values', t => { + t.deepEqual(meow({ + argv: [], + flags: { + string: { + type: 'string', + isMultiple: true, + default: ['foo', 'bar'] + }, + boolean: { + type: 'boolean', + isMultiple: true, + default: [true, false] + }, + number: { + type: 'number', + isMultiple: true, + default: [0.5, 1] + } + } + }).flags, { + string: ['foo', 'bar'], + boolean: [true, false], + number: [0.5, 1] + }); +}); + if (NODE_MAJOR_VERSION >= 14) { test('supports es modules', async t => { try {