Skip to content

Commit

Permalink
Add choices option (#228)
Browse files Browse the repository at this point in the history
  • Loading branch information
tommy-mitchell committed Mar 24, 2023
1 parent dc0e33c commit 149d7af
Show file tree
Hide file tree
Showing 6 changed files with 325 additions and 13 deletions.
9 changes: 6 additions & 3 deletions index.d.ts
Expand Up @@ -15,13 +15,14 @@ Callback function to determine if a flag is required during runtime.
*/
export type IsRequiredPredicate = (flags: Readonly<AnyFlags>, input: readonly string[]) => boolean;

export type Flag<Type extends FlagType, Default, IsMultiple = false> = {
readonly type?: Type;
export type Flag<PrimitiveType extends FlagType, Type, IsMultiple = false> = {
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>;
Expand Down Expand Up @@ -49,6 +50,7 @@ export type Options<Flags extends AnyFlags> = {
- `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`).
Expand All @@ -60,6 +62,7 @@ export type Options<Flags extends AnyFlags> = {
shortFlag: 'u',
default: ['rainbow', 'cat'],
isMultiple: true,
choices: ['rainbow', 'cat', 'unicorn'],
isRequired: (flags, input) => {
if (flags.otherFlag) {
return true;
Expand Down
101 changes: 93 additions & 8 deletions index.js
Expand Up @@ -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')}`);
}
};

Expand Down Expand Up @@ -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)) {
Expand Down
22 changes: 22 additions & 0 deletions index.test-d.ts
Expand Up @@ -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']},
},
});

Expand All @@ -67,13 +68,15 @@ expectType<boolean | undefined>(result.flags.foo);
expectType<number | undefined>(result.flags.fooBar);
expectType<string>(result.flags.bar);
expectType<string[] | undefined>(result.flags.abc);
expectType<string | undefined>(result.flags.baz);
expectType<boolean | undefined>(result.unnormalizedFlags.foo);
expectType<unknown>(result.unnormalizedFlags.f);
expectType<number | undefined>(result.unnormalizedFlags['foo-bar']);
expectType<unknown>(result.unnormalizedFlags.foobar);
expectType<unknown>(result.unnormalizedFlags.fooBar);
expectType<string>(result.unnormalizedFlags.bar);
expectType<string[] | undefined>(result.unnormalizedFlags.abc);
expectType<string | undefined>(result.unnormalizedFlags.baz);

result.showHelp();
result.showHelp(1);
Expand Down Expand Up @@ -106,3 +109,22 @@ expectAssignable<AnyFlag>({type: 'boolean', isMultiple: true, default: [false]})
expectError<AnyFlag>({type: 'string', isMultiple: true, default: 'cat'});
expectError<AnyFlag>({type: 'number', isMultiple: true, default: 42});
expectError<AnyFlag>({type: 'boolean', isMultiple: true, default: false});

expectAssignable<AnyFlag>({type: 'string', choices: ['cat', 'unicorn']});
expectAssignable<AnyFlag>({type: 'number', choices: [1, 2]});
expectAssignable<AnyFlag>({type: 'boolean', choices: [true, false]});
expectAssignable<AnyFlag>({type: 'string', isMultiple: true, choices: ['cat']});
expectAssignable<AnyFlag>({type: 'string', isMultiple: false, choices: ['cat']});

expectError<AnyFlag>({type: 'string', choices: 'cat'});
expectError<AnyFlag>({type: 'number', choices: 1});
expectError<AnyFlag>({type: 'boolean', choices: true});

expectError<AnyFlag>({type: 'string', choices: [1]});
expectError<AnyFlag>({type: 'number', choices: ['cat']});
expectError<AnyFlag>({type: 'boolean', choices: ['cat']});

expectAssignable<AnyFlag>({choices: ['cat']});
expectAssignable<AnyFlag>({choices: [1]});
expectAssignable<AnyFlag>({choices: [true]});
expectError<AnyFlag>({choices: ['cat', 1, true]});
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions readme.md
Expand Up @@ -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`).

Expand All @@ -125,6 +126,7 @@ flags: {
shortFlag: 'u',
default: ['rainbow', 'cat'],
isMultiple: true,
choices: ['rainbow', 'cat', 'unicorn'],
isRequired: (flags, input) => {
if (flags.otherFlag) {
return true;
Expand Down

0 comments on commit 149d7af

Please sign in to comment.