Skip to content

Commit 94284db

Browse files
authored
feat: value constraints on flags (#83)
* cleanup types * narrow on required * fix types * format help * refactor tests * docs * additional checks
1 parent 6880d57 commit 94284db

File tree

12 files changed

+471
-177
lines changed

12 files changed

+471
-177
lines changed

.changeset/selfish-waves-worry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@eegli/tinyparse": patch
3+
---
4+
5+
Flag options can now be configured to be one of a set of available values!

docs/reference/help-and-meta.md

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -24,26 +24,26 @@ const parser = new Parser()
2424
shortFlag: '-V',
2525
},
2626
})
27-
.option('foo', {
28-
longFlag: '--foo',
29-
shortFlag: '-f',
30-
required: true,
31-
defaultValue: '',
32-
description: 'Foo option',
27+
.option('verbose', {
28+
longFlag: '--verbose',
29+
shortFlag: '-v',
30+
defaultValue: false,
31+
description: 'Enable verbose mode',
3332
})
34-
.option('bar', {
35-
longFlag: '--bar',
36-
defaultValue: new Date(),
37-
description: 'Bar option',
33+
.option('outdir', {
34+
longFlag: '--out',
35+
defaultValue: '',
36+
required: true,
37+
description: 'Output directory',
3838
})
39-
.subcommand('baz', {
40-
args: ['arg'] as const,
39+
.subcommand('fetch', {
40+
args: ['url'] as const,
4141
handler: () => {},
42-
description: 'Baz command',
42+
description: 'Fetch a URL and save the html',
4343
})
44-
.subparser('fuzz', {
44+
.subparser('afetch', {
4545
parser: new Parser().defaultHandler(),
46-
description: 'Fuzz command',
46+
description: 'Fetch a URL with more options',
4747
})
4848
.defaultHandler()
4949
.parse(['help'])
@@ -55,20 +55,20 @@ This will print the following to the console:
5555
```sh
5656
A brief description of my-cli
5757

58-
Usage: my-cli [command] <...flags>
58+
Usage: my-cli [command?] <...flags>
5959

6060
Commands
61-
baz <arg> Baz command
62-
fuzz Fuzz command
63-
help Print this help message
61+
afetch Fetch a URL with more options
62+
fetch <url> Fetch a URL and save the html
63+
help Print this help message
6464

6565
Required flags
66-
-f, --foo [string] Foo option
66+
--out [string] Output directory
6767

6868
Optional flags
69-
--bar [date] Bar option
70-
-h, --help Print this help message
71-
-V, --version Print the version
69+
-v, --verbose [boolean] Enable verbose mode
70+
-h, --help Print this help message
71+
-V, --version Print the version
7272
```
7373

7474
When you set your help and metadata configuration, Tinyparse will validate the arguments to make sure there are no conflicts with existing flags or subcommands. Note that subsequent calls to `.setMeta()` will overwrite the previous configuration.

docs/reference/options.md

Lines changed: 67 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const parser = new Parser()
1313
shortFlag: '-f',
1414
defaultValue: 0,
1515
required: true,
16+
oneOf: [0, 1],
1617
description: 'Foo option',
1718
})
1819
.defaultHandler();
@@ -21,19 +22,12 @@ const parser = new Parser()
2122
- `shortFlag` is the short flag _alias_ that will match the option. It must start with `-`
2223
- `defaultValue` is used as a fallback and to infer the _type_ of the option. To make this possible, it can only be of **four easily checkable types**: `string`, `number`, `boolean` or `Date`
2324
- `required` indicates whether the option is required or not. If it is required, `defaultValue` will be overwritten (or an error is thrown if it is not present in the user input, see [validation](#validation))
25+
- `oneOf` allows you to constrain the user input to a set of allowed values - see more below
2426
- `description` is used to generate the usage text
2527

2628
If and only if the _expected value_ (i.e., `defaultValue`) for a flag is a _number_ or valid Javascript _date string_, Tinyparse will try to convert it accordingly.
2729

28-
## Methods
29-
30-
- **Default option values** - i.e., the values you specify when you setup the parser - can be accessed via the `options` getter method:
31-
32-
```ts
33-
parser.options; // { foo: 0 }
34-
```
35-
36-
## Validation
30+
### Validation
3731

3832
Parsing will fail if either a required option is not present or the expected type does not match the input value (here, a string that can be parsed to a number):
3933

@@ -44,15 +38,15 @@ parser.parse([]).call();
4438
// Throws: "Invalid type for --foo. 'zero' is not a valid number"
4539
parser.parse(['--foo', 'zero']).call();
4640

47-
// Ok - "12" can be parsed as a number
48-
parser.parse(['--foo', '12']).call();
41+
// Ok - "1" can be parsed as a number
42+
parser.parse(['--foo', '1']).call();
4943
```
5044

5145
Furthermore, _building_ a parser will fail if you declare the same option or a flag twice. This also holds for any unique identifier (such as a subcommand or subparser).
5246

5347
See the docs about [error handling](reference/error-handling.md) for more.
5448

55-
## Boolean Options
49+
### Boolean Options
5650

5751
If the default value of an option is a boolean, two special rules apply:
5852

@@ -77,10 +71,68 @@ const inputs: string[][] = [
7771
['--foo=false'], // false
7872
['--foo', 'false'], // false
7973
];
74+
```
75+
76+
### Methods
77+
78+
- **Default option values** - i.e., the values you specify when you setup the parser - can be accessed via the `options` getter method:
79+
80+
```ts
81+
new Parser()
82+
.option('foo', {
83+
longFlag: '--foo',
84+
defaultValue: 'abc',
85+
})
86+
.defaultHandler().options; // { foo: "abc" }
87+
```
88+
89+
## Constraining Option Values
90+
91+
There are many scenarios in which you'd like the user to provide a value from a set of _allowed values_. Consider the following example with an `--output` option that lets the user choose an output format. By setting the `oneOf` property, you can constrain the user input to a set of allowed values. This only has an effect when the default value is a `string` or `number`. Tinyparse is smart enough to have TypeScript infer that `options.output` is either `'json'` or `'yaml'`:
92+
93+
```ts
94+
const parser = new Parser()
95+
.option('output', {
96+
longFlag: '--output',
97+
defaultValue: 'json',
98+
oneOf: ['yaml'],
99+
})
100+
.defaultHandler(({ options }) => {
101+
// Type: "json" | "yaml"
102+
console.log(options.output);
103+
});
104+
```
105+
106+
With this approach, the parser will throw an error if the user provides an output format that does not equal the string literals `'json'` (the default) or `'yaml'`:
107+
108+
```ts
109+
// Throws: Invalid value "csv" for option --output, expected one of: json, yaml
110+
parser.parse(['--output', 'csv']).call();
111+
```
112+
113+
It's important to know that **parsing works differently if an option is required** and `oneOf` is set!
114+
115+
- If the option is _optional_, the default value is considered one of the allowed values, as shown above. Type inference also accounts for this
116+
- If the option is _required_, the default value is guaranteed to be overwritten. Hence, only the manually provided values via `oneOf` are allowed!
117+
118+
In the below example, `--output` can only equal `'yaml'`. TypeScript will infer the type of `options.output` to be `'yaml'`:
119+
120+
```ts
121+
new Parser()
122+
.option('output', {
123+
longFlag: '--output',
124+
defaultValue: 'json',
125+
required: true,
126+
oneOf: ['yaml'],
127+
})
128+
.defaultHandler(({ options }) => {
129+
// Type: "yaml"
130+
console.log(options.output);
131+
})
132+
.parse(['--output', 'json'])
133+
.call();
80134

81-
for (const input of inputs) {
82-
await expect(parser.parse(input).call()).resolves.not.toThrow();
83-
}
135+
// Throws: Invalid value "json" for option --output, expected one of: yaml
84136
```
85137

86138
## Good to know

src/commands.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
DowncastFlag,
88
ErrorHandler,
99
FlagOptions,
10+
FlagOptionsExt,
1011
FlagValue,
1112
FlagValueRecord,
1213
MetaOptions,
@@ -44,6 +45,19 @@ export class CommandBuilder<Options, Globals> {
4445
}
4546
};
4647

48+
#validateOneOf = <T>(id: string, type: T, ...options: T[]): void => {
49+
const expectedType = typeof type;
50+
if (expectedType !== 'string' && expectedType !== 'number') {
51+
throw new Error(`OneOf can only be used with string or number`);
52+
}
53+
const wrongType = options.find((opt) => typeof opt !== expectedType);
54+
if (wrongType) {
55+
throw new Error(
56+
`OneOf for option "${id}" contains invalid type ${typeof wrongType}, expected ${expectedType}`,
57+
);
58+
}
59+
};
60+
4761
#tryRegisterCommandToken = (command?: string) => {
4862
if (command) {
4963
this.#validateCommand(command);
@@ -61,8 +75,31 @@ export class CommandBuilder<Options, Globals> {
6175
/**
6276
* Add an option (flag)
6377
*/
64-
option<K extends string, V extends FlagValue>(key: K, opts: FlagOptions<V>) {
65-
const { longFlag, shortFlag } = opts;
78+
option<
79+
K extends string,
80+
V extends FlagValue,
81+
R extends boolean,
82+
O extends unknown[],
83+
Choices = O[number],
84+
Values = R extends true ? Choices : V | Choices,
85+
>(
86+
key: K,
87+
opts: FlagOptionsExt<V, R, Choices>,
88+
): CommandBuilder<Options & Record<K, Values>, Globals>;
89+
option<K extends string, V extends FlagValue, R extends boolean>(
90+
key: K,
91+
opts: FlagOptions<V, R>,
92+
): CommandBuilder<Options & Record<K, DowncastFlag<V>>, Globals>;
93+
option<K extends string, V extends FlagValue, R extends boolean>(
94+
key: K,
95+
opts: FlagOptions<V, R>,
96+
) {
97+
const { longFlag, shortFlag, oneOf, defaultValue } = opts;
98+
99+
if (oneOf) {
100+
this.#validateOneOf(key, defaultValue, ...oneOf);
101+
}
102+
66103
this.#validateOption(key);
67104
this.#config.options.set(key, opts);
68105

src/flags.ts

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,33 @@ import { ValidationError } from './error';
22
import { FlagOptions, FlagValue } from './types/internals';
33
import Utils, { Type } from './utils';
44

5+
const assertIsOneOf = <T>({
6+
id,
7+
value,
8+
options,
9+
includeDefaultValue,
10+
defaultValue,
11+
}: {
12+
id: string;
13+
value: T;
14+
defaultValue: T;
15+
includeDefaultValue?: boolean;
16+
options: T[];
17+
}): void => {
18+
if (includeDefaultValue) {
19+
// Don't mutate the original array
20+
options = options.concat(defaultValue);
21+
}
22+
if (!options.includes(value)) {
23+
const available = options.sort().join(', ');
24+
const err = `Invalid value "${value}" for option ${id}, expected one of: ${available}`;
25+
throw new ValidationError(err);
26+
}
27+
};
28+
529
export const collectFlags = (
630
inputFlags: Map<string, string | null>,
7-
flagOptions: Map<string, FlagOptions>,
31+
flagOptions: Map<string, FlagOptions<FlagValue>>,
832
) => {
933
const output = new Map<string, FlagValue>();
1034
for (const [key, opts] of flagOptions) {
@@ -29,11 +53,21 @@ export const collectFlags = (
2953
const argumentIsNull = flagArg === null;
3054

3155
if (expectedArgType === Type.String) {
32-
if (!argumentIsNull) {
33-
output.set(key, flagArg as string);
34-
continue;
56+
if (argumentIsNull) {
57+
throw new ValidationError(`${longFlag} expects an argument`);
3558
}
36-
throw new ValidationError(`${longFlag} expects an argument`);
59+
if (opts.oneOf) {
60+
assertIsOneOf({
61+
id: longFlag,
62+
value: flagArg,
63+
defaultValue: defaultValue,
64+
// If the option is required, do not include the default value as a valid value
65+
includeDefaultValue: !required,
66+
options: opts.oneOf,
67+
});
68+
}
69+
output.set(key, flagArg as string);
70+
continue;
3771
}
3872
if (expectedArgType === Type.Boolean) {
3973
if (argumentIsNull) {
@@ -51,15 +85,25 @@ export const collectFlags = (
5185
}
5286
if (expectedArgType === Type.Number) {
5387
const asNumber = Utils.tryToNumber(flagArg as string);
54-
if (asNumber) {
55-
output.set(key, asNumber);
56-
continue;
88+
if (!asNumber) {
89+
throw new ValidationError(
90+
`Invalid type for ${longFlag}. '${
91+
flagArg as string
92+
}' is not a valid number`,
93+
);
5794
}
58-
throw new ValidationError(
59-
`Invalid type for ${longFlag}. '${
60-
flagArg as string
61-
}' is not a valid number`,
62-
);
95+
if (opts.oneOf) {
96+
assertIsOneOf({
97+
id: longFlag,
98+
value: asNumber,
99+
defaultValue: defaultValue,
100+
101+
includeDefaultValue: !required,
102+
options: opts.oneOf,
103+
});
104+
}
105+
output.set(key, asNumber);
106+
continue;
63107
}
64108
if (expectedArgType === Type.Date) {
65109
const asDate = Utils.tryToDate(flagArg as string);

0 commit comments

Comments
 (0)