Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add choices option #228

Merged
merged 11 commits into from Mar 24, 2023
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