Skip to content

Commit d8767c5

Browse files
authored
feat: utility types for modular handler declaration (#73)
* configure modular utility types * expose default options
1 parent 924485f commit d8767c5

File tree

14 files changed

+301
-52
lines changed

14 files changed

+301
-52
lines changed

.changeset/late-zoos-glow.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+
Parsers now expose default options

.changeset/wise-onions-obey.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+
New utility types that allow for modular handler declaration!

docs/reference/handlers.md

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,20 +42,59 @@ const defaultHandler: CommandHandler<typeof options> = ({
4242
console.log({ args, globals, options, usage });
4343
};
4444

45-
const executeHandler = new Parser()
46-
.defaultHandler(defaultHandler)
47-
.parse(['hello', 'world']).call;
45+
options.defaultHandler(defaultHandler).parse(['hello', 'world']).call();
46+
```
47+
48+
Default handlers can be a good place to handle things that Tinyparse is not opinionated about. For example, you can print an error to the console and tell the user that they should select one of the available subcommands. You can also log the usage text to the console so that the user knows what to do next. The usage text is the same as if the app were invoked with a help command or flag (if defined).
49+
50+
## Modular Declaration
51+
52+
In the above example, we use the `CommandHandler` type to declare the default handler. This will annotate the function as taking an object literal with all parameters. Hence, if you want to invoke it somewhere else - like in a test - you will need to pass values for `args`, `globals`, `options`, and `usage`. This is a bit cumbersome. You can declare default and subcommand handlers more modularly by specifiying _exactly_ what they need using the `Handler*` utility types:
53+
54+
```ts
55+
import { Parser } from '@eegli/tinyparse';
56+
import type {
57+
HandlerParams,
58+
HandlerGlobals,
59+
HandlerOptions,
60+
} from '@eegli/tinyparse';
61+
62+
const options = new Parser();
4863

49-
// Time goes by...
64+
type Globals = HandlerGlobals<typeof options>;
65+
type Options = HandlerOptions<typeof options>;
5066

51-
await executeHandler();
67+
// Handler that only needs options and globals
68+
type DefaultHandler = HandlerParams<Options, never, Globals>;
5269

53-
expect(consoleLog).toHaveBeenCalledWith({
54-
args: ['hello', 'world'],
55-
globals: {},
56-
options: {},
57-
usage: expect.any(String),
58-
});
70+
const defaultHandler: DefaultHandler = ({ globals, options }) => {
71+
console.log({ globals, options });
72+
};
73+
74+
// Other possible options...
75+
76+
// No parameters
77+
type NoParamsHandler = HandlerParams;
78+
const noopHandler: NoParamsHandler = () => {};
79+
80+
// Positional arguments and options only
81+
type HandlerWithArgs = HandlerParams<Options, [string]>;
82+
const handlerWithArgs: HandlerWithArgs = ({ options, args }) => {};
83+
84+
// Usage only
85+
type HandlerWithUsage = HandlerParams<never, never, never, string>;
86+
const handlerWithUsage: HandlerWithUsage = ({ usage }) => {};
5987
```
6088

61-
Default handlers can be a good place to handle things that Tinyparse is not opinionated about. For example, you can print an error to the console and tell the user that they should select one of the available subcommands. You can also log the usage text to the console so that the user knows what to do next. The usage text is the same as if the app were invoked with a help command or flag (if defined).
89+
In the above example, we specify a handful of handlers, each of which only needs a subset of what Tinyparse feeds it. `HandlerParams` has the following signature:
90+
91+
```ts
92+
type HandlerParams<
93+
Options extends FlagValueRecord = never,
94+
Args extends string[] = never,
95+
Globals extends AnyGlobal = never,
96+
Usage extends string = never,
97+
> = (...)
98+
```
99+
100+
In summary, the `CommandHandler` can be a useful shortcut, but if you only need a subset of the parameters, it might make sense to declare the handler more modularly.

docs/reference/options.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,18 @@ const parser = new Parser()
1818
.defaultHandler();
1919
```
2020

21-
- `shortFlag` is the short flag _alias_ that will match the option
21+
- `shortFlag` is the short flag _alias_ that will match the option. It must start with `-`
2222
- `defaultValue` is used 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`
2323
- `required` indicates whether the option is required or not
2424
- `description` is used to generate the help text
2525

26-
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.
26+
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.
27+
28+
Default option values can be accessed via the `options` getter:
29+
30+
```ts
31+
parser.options; // { foo: 0 }
32+
```
2733

2834
## Validation
2935

src/commands.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ export class CommandBuilder<
6161
}
6262
};
6363

64+
/**
65+
* Add an option (flag)
66+
*/
6467
option<K extends string, V extends FlagValue>(key: K, opts: FlagOptions<V>) {
6568
const { longFlag, shortFlag } = opts;
6669
this.#validateOption(key);
@@ -76,6 +79,9 @@ export class CommandBuilder<
7679
>;
7780
}
7881

82+
/**
83+
* Add a subcommand
84+
*/
7985
subcommand<A extends CommandArgPattern>(
8086
command: string,
8187
opts: Subcommand<Options, Globals, A>,
@@ -85,25 +91,28 @@ export class CommandBuilder<
8591
return this;
8692
}
8793

88-
subparser<O extends FlagValueRecord, G extends AnyGlobal>(
89-
command: string,
90-
opts: Subparser<O, G>,
91-
) {
94+
/**
95+
* Add a subparser
96+
*/
97+
subparser(command: string, opts: Subparser<FlagValueRecord, AnyGlobal>) {
9298
this.#tryRegisterCommandToken(command);
93-
this.#config.parsers.set(
94-
command,
95-
opts as Subparser<FlagValueRecord, AnyGlobal>,
96-
);
99+
this.#config.parsers.set(command, opts);
97100
return this;
98101
}
99102

103+
/**
104+
* Set the globals
105+
*/
100106
setGlobals<G extends Globals>(
101107
setGlobals: (options: Options) => G | Promise<G>,
102108
) {
103109
this.#config.globalSetter = setGlobals;
104110
return this as unknown as CommandBuilder<Options, G>;
105111
}
106112

113+
/**
114+
* Set metadata
115+
*/
107116
setMeta(meta: MetaOptions) {
108117
this.#tryRegisterCommandToken(meta.help?.command);
109118
this.#tryRegisterCommandToken(meta.version?.command);
@@ -117,11 +126,17 @@ export class CommandBuilder<
117126
return this;
118127
}
119128

129+
/**
130+
* Set the error handler
131+
*/
120132
onError(handler: ErrorHandler) {
121133
this.#config.errorHandler = handler;
122134
return this;
123135
}
124136

137+
/**
138+
* Set the default handler
139+
*/
125140
defaultHandler(handler?: DefaultHandler<Options, Globals>) {
126141
return new Parser<Options, Globals>({
127142
...this.#config,

src/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ interface CoreConfig<O extends FlagValueRecord, G extends AnyGlobal> {
1313
meta: MetaOptions;
1414
options: FlagOptionsMap;
1515
commands: CommandOptionsMap<O, G>;
16-
parsers: SubparserOptionsMap<FlagValueRecord, AnyGlobal>;
16+
parsers: SubparserOptionsMap;
1717
}
1818

1919
export interface CommonConfig<O extends FlagValueRecord, G extends AnyGlobal>

src/help.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ export class HelpPrinter<O extends FlagValueRecord, G extends AnyGlobal> {
137137
const command = this.#meta?.command;
138138

139139
let str = summary ? `${summary}\n\nUsage:` : 'Usage:';
140-
if (command) str += ` ${command} [command] <...flags>`;
140+
if (command) str += ` ${command} [command?] <...flags>`;
141141
return str;
142142
}
143143

src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
export { CommandBuilder as Parser } from './commands';
22
export { ValidationError } from './error';
33

4-
export type { CommandHandler, ErrorHandler, GlobalSetter } from './types';
4+
export type {
5+
CommandHandler,
6+
ErrorHandler,
7+
GlobalSetter,
8+
HandlerGlobals,
9+
HandlerOptions,
10+
HandlerParams,
11+
} from './types';

src/parser.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,33 @@ import {
99
FlagValueRecord,
1010
Subcommand,
1111
} from './types';
12-
export class Parser<O extends FlagValueRecord, G extends AnyGlobal> {
13-
#config: CommonConfig<O, G>;
14-
#helpPrinter: HelpPrinter<O, G>;
12+
export class Parser<
13+
Options extends FlagValueRecord,
14+
Globals extends AnyGlobal,
15+
> {
16+
#config: CommonConfig<Options, Globals>;
17+
#helpPrinter: HelpPrinter<Options, Globals>;
1518

16-
constructor(config: CommonConfig<O, G>) {
19+
constructor(config: CommonConfig<Options, Globals>) {
1720
this.#config = config;
1821
this.#helpPrinter = new HelpPrinter(config);
1922
}
2023

24+
/**
25+
* Get the default options with their default values
26+
*/
27+
get options(): Options {
28+
const entries = [...this.#config.options.entries()].map(([k, v]) => [
29+
k,
30+
v.defaultValue,
31+
]);
32+
return Object.fromEntries(entries);
33+
}
34+
2135
#validateSubcommandArgs(
2236
command: string,
2337
args: string[],
24-
commandOpts: Subcommand<O, G, CommandArgPattern>,
38+
commandOpts: Subcommand<Options, Globals, CommandArgPattern>,
2539
) {
2640
if (Array.isArray(commandOpts.args)) {
2741
const expectedNumArgs = commandOpts.args.length;
@@ -71,6 +85,9 @@ export class Parser<O extends FlagValueRecord, G extends AnyGlobal> {
7185
throw error;
7286
}
7387

88+
/**
89+
* Parse the given argv and return a function to call the appropriate handler
90+
*/
7491
parse(argv: string[]): {
7592
call: () => Promise<void>;
7693
} {
@@ -89,8 +106,8 @@ export class Parser<O extends FlagValueRecord, G extends AnyGlobal> {
89106
return;
90107
}
91108
try {
92-
const options = collectFlags(flagMap, this.#config.options) as O;
93-
const setGlobals = this.#config.globalSetter || (() => ({}) as G);
109+
const options = collectFlags(flagMap, this.#config.options) as Options;
110+
const setGlobals = this.#config.globalSetter || (() => ({}) as Globals);
94111
const globals = await setGlobals(options);
95112
const subcommandOpts = this.#config.commands.get(subcommand);
96113
if (subcommandOpts) {

src/types.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,10 @@ export type Subparser<O extends FlagValueRecord, G extends AnyGlobal> = {
7878
/**
7979
* A map of subparsers and their settings.
8080
*/
81-
export type SubparserOptionsMap<
82-
O extends FlagValueRecord = FlagValueRecord,
83-
G extends AnyGlobal = AnyGlobal,
84-
> = Map<string, Subparser<O, G>>;
81+
export type SubparserOptionsMap = Map<
82+
string,
83+
Subparser<FlagValueRecord, AnyGlobal>
84+
>;
8585

8686
export type HelpOptions = {
8787
command?: string;
@@ -121,6 +121,34 @@ export type GlobalSetter<T> = T extends CommandBuilder<infer O, AnyGlobal>
121121

122122
export type ErrorHandler = (error: ValidationError, usage: string) => void;
123123

124+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
125+
export type HandlerOptions<T> = T extends CommandBuilder<infer O, any>
126+
? O
127+
: never;
128+
129+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
130+
export type HandlerGlobals<T> = T extends CommandBuilder<any, infer G>
131+
? G
132+
: never;
133+
134+
export type HandlerParams<
135+
Options extends FlagValueRecord = never,
136+
Args extends string[] = never,
137+
Globals extends AnyGlobal = never,
138+
Usage extends string = never,
139+
> = (
140+
params: RemoveNever<{
141+
options: Options;
142+
args: Args;
143+
globals: Globals;
144+
usage: Usage;
145+
}>,
146+
) => void | Promise<void>;
147+
148+
type RemoveNever<T> = {
149+
[K in keyof T as T[K] extends never ? never : K]: T[K];
150+
};
151+
124152
export type Downcast<T> = T extends unknown[]
125153
? {
126154
[P in keyof T]: T[P] extends number

0 commit comments

Comments
 (0)