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
82 changes: 79 additions & 3 deletions index.js
Expand Up @@ -53,9 +53,84 @@ const reportMissingRequiredFlags = missingRequiredFlags => {
};

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 keys = Object.keys(flags);
const entries = Object.entries(flags);

const invalidOptions = {
flagsWithDash: {
values: keys.filter(flagKey => flagKey.includes('-') && flagKey !== '--'),
message: values => `Flag keys may not contain '-': ${values.join(', ')}`,
},
flagsWithAlias: {
values: keys.filter(flagKey => flags[flagKey].alias !== undefined),
message: values => `The option \`alias\` has been renamed to \`shortFlag\`. The following flags need to be updated: \`${values.join('`, `')}\``,
},
flagsWithNonArrayChoices: {
values: entries.filter(([_, {choices}]) => choices && !Array.isArray(choices)),
message(values) {
const formattedChoices = values.map(([flagKey, {choices}]) => `flag \`${flagKey}\`: ${choices}`).join(', ');
return `Flag choices must be an array: ${formattedChoices}`;
},
},
};

const errorMessages = [];

for (const {values, message} of Object.values(invalidOptions)) {
if (values.length > 0) {
errorMessages.push(message(values));
}
}

if (errorMessages.length > 0) {
throw new Error(errorMessages.join('\n'));
}
};

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 \`${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 \`${flagKey}\`: \`${unknownValues.join(', ')}\`. ${valueMustBeOneOf}`;
}
} else if (!choices.includes(receivedInput)) {
return `Unknown value for flag \`${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);
}
}

if (errors.length > 0) {
throw new Error(`${errors.join('\n')}`);
}

const flagsWithAlias = Object.keys(flags).filter(flagKey => flags[flagKey].alias !== undefined);
Expand Down Expand Up @@ -242,6 +317,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