diff --git a/index.d.ts b/index.d.ts index b9ba23c..a68251a 100644 --- a/index.d.ts +++ b/index.d.ts @@ -15,13 +15,14 @@ Callback function to determine if a flag is required during runtime. */ export type IsRequiredPredicate = (flags: Readonly, input: readonly string[]) => boolean; -export type Flag = { - readonly type?: Type; +export type Flag = { + readonly type?: PrimitiveType; readonly shortFlag?: string; - readonly default?: Default; + readonly default?: Type; readonly isRequired?: boolean | IsRequiredPredicate; readonly isMultiple?: IsMultiple; readonly aliases?: string[]; + readonly choices?: Type extends unknown[] ? Type : Type[]; }; type StringFlag = Flag<'string', string> | Flag<'string', string[], true>; @@ -49,6 +50,7 @@ export type Options = { - `isMultiple`: Indicates a flag can be set multiple times. Values are turned into an array. (Default: false) Multiple values are provided by specifying the flag multiple times, for example, `$ foo -u rainbow -u cat`. Space- or comma-separated values are *not* supported. - `aliases`: Other names for the flag. + - `choices`: Limit valid values to a predefined set of choices. Note that flags are always defined using a camel-case key (`myKey`), but will match arguments in kebab-case (`--my-key`). @@ -60,6 +62,7 @@ export type Options = { shortFlag: 'u', default: ['rainbow', 'cat'], isMultiple: true, + choices: ['rainbow', 'cat', 'unicorn'], isRequired: (flags, input) => { if (flags.otherFlag) { return true; diff --git a/index.js b/index.js index 2b805e3..0748519 100644 --- a/index.js +++ b/index.js @@ -45,22 +45,106 @@ const getMissingRequiredFlags = (flags, receivedFlags, input) => { return missingRequiredFlags; }; +const decamelizeFlagKey = flagKey => `--${decamelize(flagKey, {separator: '-'})}`; + const reportMissingRequiredFlags = missingRequiredFlags => { console.error(`Missing required flag${missingRequiredFlags.length > 1 ? 's' : ''}`); for (const flag of missingRequiredFlags) { - console.error(`\t--${decamelize(flag.key, {separator: '-'})}${flag.shortFlag ? `, -${flag.shortFlag}` : ''}`); + console.error(`\t${decamelizeFlagKey(flag.key)}${flag.shortFlag ? `, -${flag.shortFlag}` : ''}`); + } +}; + +const joinFlagKeys = (flagKeys, prefix = '--') => `\`${prefix}${flagKeys.join(`\`, \`${prefix}`)}\``; + +const validateOptions = options => { + const invalidOptionFilters = { + flags: { + flagsWithDashes: { + filter: ([flagKey]) => flagKey.includes('-') && flagKey !== '--', + message: flagKeys => `Flag keys may not contain '-'. Invalid flags: ${joinFlagKeys(flagKeys, '')}`, + }, + flagsWithAlias: { + filter: ([, flag]) => flag.alias !== undefined, + message: flagKeys => `The option \`alias\` has been renamed to \`shortFlag\`. The following flags need to be updated: ${joinFlagKeys(flagKeys)}`, + }, + flagsWithNonArrayChoices: { + filter: ([, flag]) => flag.choices !== undefined && !Array.isArray(flag.choices), + message: flagKeys => `The option \`choices\` must be an array. Invalid flags: ${joinFlagKeys(flagKeys)}`, + }, + flagsWithChoicesOfDifferentTypes: { + filter: ([, flag]) => flag.type && Array.isArray(flag.choices) && flag.choices.some(choice => typeof choice !== flag.type), + message(flagKeys) { + const flagKeysAndTypes = flagKeys.map(flagKey => `(\`${decamelizeFlagKey(flagKey)}\`, type: '${options.flags[flagKey].type}')`); + return `Each value of the option \`choices\` must be of the same type as its flag. Invalid flags: ${flagKeysAndTypes.join(', ')}`; + }, + }, + }, + }; + + const errorMessages = []; + + for (const [optionKey, filters] of Object.entries(invalidOptionFilters)) { + const optionEntries = Object.entries(options[optionKey]); + + for (const {filter, message} of Object.values(filters)) { + const invalidOptions = optionEntries.filter(option => filter(option)); + const invalidOptionKeys = invalidOptions.map(([key]) => key); + + if (invalidOptions.length > 0) { + errorMessages.push(message(invalidOptionKeys)); + } + } + } + + if (errorMessages.length > 0) { + throw new Error(errorMessages.join('\n')); } }; -const validateOptions = ({flags}) => { - const invalidFlags = Object.keys(flags).filter(flagKey => flagKey.includes('-') && flagKey !== '--'); - if (invalidFlags.length > 0) { - throw new Error(`Flag keys may not contain '-': ${invalidFlags.join(', ')}`); +const validateChoicesByFlag = (flagKey, flagValue, receivedInput) => { + const {choices, isRequired} = flagValue; + + if (!choices) { + return; + } + + const valueMustBeOneOf = `Value must be one of: [\`${choices.join('`, `')}\`]`; + + if (!receivedInput) { + if (isRequired) { + return `Flag \`${decamelizeFlagKey(flagKey)}\` has no value. ${valueMustBeOneOf}`; + } + + return; + } + + if (Array.isArray(receivedInput)) { + const unknownValues = receivedInput.filter(index => !choices.includes(index)); + + if (unknownValues.length > 0) { + const valuesText = unknownValues.length > 1 ? 'values' : 'value'; + + return `Unknown ${valuesText} for flag \`${decamelizeFlagKey(flagKey)}\`: \`${unknownValues.join('`, `')}\`. ${valueMustBeOneOf}`; + } + } else if (!choices.includes(receivedInput)) { + return `Unknown value for flag \`${decamelizeFlagKey(flagKey)}\`: \`${receivedInput}\`. ${valueMustBeOneOf}`; + } +}; + +const validateChoices = (flags, receivedFlags) => { + const errors = []; + + for (const [flagKey, flagValue] of Object.entries(flags)) { + const receivedInput = receivedFlags[flagKey]; + const errorMessage = validateChoicesByFlag(flagKey, flagValue, receivedInput); + + if (errorMessage) { + errors.push(errorMessage); + } } - const flagsWithAlias = Object.keys(flags).filter(flagKey => flags[flagKey].alias !== undefined); - if (flagsWithAlias.length > 0) { - throw new Error(`The option \`alias\` has been renamed to \`shortFlag\`. The following flags need to be updated: \`${flagsWithAlias.join('`, `')}\``); + if (errors.length > 0) { + throw new Error(`${errors.join('\n')}`); } }; @@ -242,6 +326,7 @@ const meow = (helpText, options = {}) => { const unnormalizedFlags = {...flags}; validateFlags(flags, options); + validateChoices(options.flags, flags); for (const flagValue of Object.values(options.flags)) { if (Array.isArray(flagValue.aliases)) { diff --git a/index.test-d.ts b/index.test-d.ts index 8e26884..09250a7 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -56,6 +56,7 @@ const result = meow('Help text', { 'foo-bar': {type: 'number', aliases: ['foobar', 'fooBar']}, bar: {type: 'string', default: ''}, abc: {type: 'string', isMultiple: true}, + baz: {type: 'string', choices: ['rainbow', 'cat', 'unicorn']}, }, }); @@ -67,6 +68,7 @@ expectType(result.flags.foo); expectType(result.flags.fooBar); expectType(result.flags.bar); expectType(result.flags.abc); +expectType(result.flags.baz); expectType(result.unnormalizedFlags.foo); expectType(result.unnormalizedFlags.f); expectType(result.unnormalizedFlags['foo-bar']); @@ -74,6 +76,7 @@ expectType(result.unnormalizedFlags.foobar); expectType(result.unnormalizedFlags.fooBar); expectType(result.unnormalizedFlags.bar); expectType(result.unnormalizedFlags.abc); +expectType(result.unnormalizedFlags.baz); result.showHelp(); result.showHelp(1); @@ -106,3 +109,22 @@ expectAssignable({type: 'boolean', isMultiple: true, default: [false]}) expectError({type: 'string', isMultiple: true, default: 'cat'}); expectError({type: 'number', isMultiple: true, default: 42}); expectError({type: 'boolean', isMultiple: true, default: false}); + +expectAssignable({type: 'string', choices: ['cat', 'unicorn']}); +expectAssignable({type: 'number', choices: [1, 2]}); +expectAssignable({type: 'boolean', choices: [true, false]}); +expectAssignable({type: 'string', isMultiple: true, choices: ['cat']}); +expectAssignable({type: 'string', isMultiple: false, choices: ['cat']}); + +expectError({type: 'string', choices: 'cat'}); +expectError({type: 'number', choices: 1}); +expectError({type: 'boolean', choices: true}); + +expectError({type: 'string', choices: [1]}); +expectError({type: 'number', choices: ['cat']}); +expectError({type: 'boolean', choices: ['cat']}); + +expectAssignable({choices: ['cat']}); +expectAssignable({choices: [1]}); +expectAssignable({choices: [true]}); +expectError({choices: ['cat', 1, true]}); diff --git a/package.json b/package.json index 39cdedb..5e0b5cc 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ }, "devDependencies": { "ava": "^4.3.3", + "common-tags": "^1.8.2", "execa": "^6.1.0", "indent-string": "^5.0.0", "read-pkg": "^7.1.0", diff --git a/readme.md b/readme.md index a3a5c15..b24cc51 100644 --- a/readme.md +++ b/readme.md @@ -113,6 +113,7 @@ The key is the flag name in camel-case and the value is an object with any of: - `isMultiple`: Indicates a flag can be set multiple times. Values are turned into an array. (Default: false) - Multiple values are provided by specifying the flag multiple times, for example, `$ foo -u rainbow -u cat`. Space- or comma-separated values are [currently *not* supported](https://github.com/sindresorhus/meow/issues/164). - `aliases`: Other names for the flag. +- `choices`: Limit valid values to a predefined set of choices. Note that flags are always defined using a camel-case key (`myKey`), but will match arguments in kebab-case (`--my-key`). @@ -125,6 +126,7 @@ flags: { shortFlag: 'u', default: ['rainbow', 'cat'], isMultiple: true, + choices: ['rainbow', 'cat', 'unicorn'], isRequired: (flags, input) => { if (flags.otherFlag) { return true; diff --git a/test/test.js b/test/test.js index b78dcd3..c489753 100644 --- a/test/test.js +++ b/test/test.js @@ -3,6 +3,7 @@ import process from 'node:process'; import {fileURLToPath} from 'node:url'; import test from 'ava'; import indentString from 'indent-string'; +import {stripIndent} from 'common-tags'; import {execa} from 'execa'; import {readPackage} from 'read-pkg'; import meow from '../index.js'; @@ -128,7 +129,7 @@ test('flag declared in kebab-case is an error', t => { flags: {'kebab-case': 'boolean', test: 'boolean', 'another-one': 'boolean'}, }); }); - t.is(error.message, 'Flag keys may not contain \'-\': kebab-case, another-one'); + t.is(error.message, 'Flag keys may not contain \'-\'. Invalid flags: `kebab-case`, `another-one`'); }); test('type inference', t => { @@ -599,7 +600,7 @@ test('suggests renaming alias to shortFlag', t => { }, }, }); - }, {message: 'The option `alias` has been renamed to `shortFlag`. The following flags need to be updated: `foo`, `bar`'}); + }, {message: 'The option `alias` has been renamed to `shortFlag`. The following flags need to be updated: `--foo`, `--bar`'}); }); test('aliases - accepts one', t => { @@ -683,6 +684,204 @@ test('aliases - unnormalized flags', t => { }); }); +test('choices - success case', t => { + const cli = meow({ + importMeta, + argv: ['--animal', 'cat', '--number=2.2'], + flags: { + animal: { + choices: ['dog', 'cat', 'unicorn'], + }, + number: { + type: 'number', + choices: [1.1, 2.2, 3.3], + }, + }, + }); + + t.is(cli.flags.animal, 'cat'); + t.is(cli.flags.number, 2.2); +}); + +test('choices - throws if input does not match choices', t => { + t.throws(() => { + meow({ + importMeta, + argv: ['--animal', 'rainbow', '--number', 5], + flags: { + animal: { + choices: ['dog', 'cat', 'unicorn'], + }, + number: { + choices: [1, 2, 3], + }, + }, + }); + }, { + message: stripIndent` + Unknown value for flag \`--animal\`: \`rainbow\`. Value must be one of: [\`dog\`, \`cat\`, \`unicorn\`] + Unknown value for flag \`--number\`: \`5\`. Value must be one of: [\`1\`, \`2\`, \`3\`] + `, + }); +}); + +test('choices - throws if choices is not array', t => { + t.throws(() => { + meow({ + importMeta, + argv: ['--animal', 'cat'], + flags: { + animal: { + choices: 'cat', + }, + }, + }); + }, {message: 'The option `choices` must be an array. Invalid flags: `--animal`'}); +}); + +test('choices - does not throw error when isRequired is false', t => { + t.notThrows(() => { + meow({ + importMeta, + argv: [], + flags: { + animal: { + isRequired: false, + choices: ['dog', 'cat', 'unicorn'], + }, + }, + }); + }); +}); + +test('choices - throw error when isRequired is true', t => { + t.throws(() => { + meow({ + importMeta, + argv: [], + flags: { + animal: { + isRequired: true, + choices: ['dog', 'cat', 'unicorn'], + }, + }, + }); + }, {message: 'Flag `--animal` has no value. Value must be one of: [`dog`, `cat`, `unicorn`]'}); +}); + +test('choices - success with isMultiple', t => { + const cli = meow({ + importMeta, + argv: ['--animal=dog', '--animal=unicorn'], + flags: { + animal: { + type: 'string', + isMultiple: true, + choices: ['dog', 'cat', 'unicorn'], + }, + }, + }); + + t.deepEqual(cli.flags.animal, ['dog', 'unicorn']); +}); + +test('choices - throws with isMultiple, one unknown value', t => { + t.throws(() => { + meow({ + importMeta, + argv: ['--animal=dog', '--animal=rabbit'], + flags: { + animal: { + type: 'string', + isMultiple: true, + choices: ['dog', 'cat', 'unicorn'], + }, + }, + }); + }, {message: 'Unknown value for flag `--animal`: `rabbit`. Value must be one of: [`dog`, `cat`, `unicorn`]'}); +}); + +test('choices - throws with isMultiple, multiple unknown value', t => { + t.throws(() => { + meow({ + importMeta, + argv: ['--animal=dog', '--animal=rabbit'], + flags: { + animal: { + type: 'string', + isMultiple: true, + choices: ['cat', 'unicorn'], + }, + }, + }); + }, {message: 'Unknown values for flag `--animal`: `dog`, `rabbit`. Value must be one of: [`cat`, `unicorn`]'}); +}); + +test('choices - throws with multiple flags', t => { + t.throws(() => { + meow({ + importMeta, + argv: ['--animal=dog', '--plant=succulent'], + flags: { + animal: { + type: 'string', + choices: ['cat', 'unicorn'], + }, + plant: { + type: 'string', + choices: ['tree', 'flower'], + }, + }, + }); + }, {message: stripIndent` + Unknown value for flag \`--animal\`: \`dog\`. Value must be one of: [\`cat\`, \`unicorn\`] + Unknown value for flag \`--plant\`: \`succulent\`. Value must be one of: [\`tree\`, \`flower\`] + `}); +}); + +test('choices - choices must be of the same type', t => { + t.throws(() => { + meow({ + importMeta, + flags: { + number: { + type: 'number', + choices: [1, '2'], + }, + boolean: { + type: 'boolean', + choices: [true, 'false'], + }, + }, + }); + }, {message: 'Each value of the option `choices` must be of the same type as its flag. Invalid flags: (`--number`, type: \'number\'), (`--boolean`, type: \'boolean\')'}); +}); + +test('options - multiple validation errors', t => { + t.throws(() => { + meow({ + importMeta, + flags: { + animal: { + type: 'string', + choices: 'cat', + }, + plant: { + type: 'string', + alias: 'p', + }, + 'some-thing': { + type: 'string', + }, + }, + }); + }, {message: stripIndent` + Flag keys may not contain '-'. Invalid flags: \`some-thing\` + The option \`alias\` has been renamed to \`shortFlag\`. The following flags need to be updated: \`--plant\` + The option \`choices\` must be an array. Invalid flags: \`--animal\` + `}); +}); + if (NODE_MAJOR_VERSION >= 14) { test('supports es modules', async t => { try {