Skip to content

Commit

Permalink
feat(command) improve support for generic custom types (#191)
Browse files Browse the repository at this point in the history
  • Loading branch information
c4spar committed May 15, 2021
1 parent d4c0f12 commit 59b1a93
Show file tree
Hide file tree
Showing 4 changed files with 118 additions and 24 deletions.
46 changes: 38 additions & 8 deletions command/README.md
Expand Up @@ -674,13 +674,12 @@ Some info

## ❯ Commands

The command class acts like as a factory class. It has an internal command
pointer that points per default to the command instance itself. Each time the
`.command()` method is called, the internal pointer points to the newly created
command. All methods such as `.name()`, `.description()`, `.option()`,
`.action()`, etc... always work on the command to which the pointer points. If
you need to change the pointer back to the command instance you can call the
`.reset()` method.
The command class is a factory class and has an internal command pointer that
points to the command instance itself by default. Each time the `.command()`
method is called, the internal pointer points to the newly created command. All
methods like `.name()`, `.description()`, `.option()`, `.action()`, etc...
always act on the command the pointer points to. If you need to change the
pointer back to the command instance, you can call the `.reset()` method.

```typescript
import { Command } from "https://deno.land/x/cliffy/command/mod.ts";
Expand Down Expand Up @@ -1500,7 +1499,8 @@ You can also define the types directly in the constructor. The

- `O` Options (defined with `.option()`)
- `A` Arguments (defined with `.arguments()`)
- `G` Global options (defined with `.globalOption()`)
- `G` Global options (defined with `.globalOption()` or
`.option(..., { global: true })`)
- `PG` Global parent command options
- `P` Parent command

Expand Down Expand Up @@ -1587,6 +1587,36 @@ await new Command<void>()
.parse(Deno.args);
```

### Generic custom types

Custom types can be used to define your generic types.

```typescript
import { Command, NumberType } from "https://deno.land/x/cliffy/command/mod.ts";

// Create an instance of your custom type.
const amount = new NumberType();

await new Command<void>()
// Add the type.
.type("amount", amount)
// Use the type of your type instance as option type.
// The correct type will be infered and used fot the option value.
.option<{ amount?: typeof amount }>(
"-a, --amount <amount:amount>",
"The amount.",
)
// Same as:
.option<{ amount?: TypeValue<typeof amount> }>(
"-a, --amount <amount:amount>",
"The amount.",
)
// amount will be of type number.
.action(({ amount }) => {
console.log("amount:", amount);
});
```

## ❯ Version option

The `--version` and `-V` option flag prints the version number defined with the
Expand Down
34 changes: 25 additions & 9 deletions command/command.ts
Expand Up @@ -84,6 +84,16 @@ interface IDefaultOption<
type OneOf<T, V> = T extends void ? V : T;
type Merge<T, V> = T extends void ? V : (V extends void ? T : T & V);

type MapOptionTypes<O extends Record<string, unknown> | void> = O extends
Record<string, unknown>
? { [K in keyof O]: O[K] extends Type<infer T> ? T : O[K] }
: void;

type MapArgumentTypes<A extends Array<unknown>> = A extends Array<unknown>
? { [I in keyof A]: A[I] extends Type<infer T> ? T : A[I] }
: // deno-lint-ignore no-explicit-any
any;

export class Command<
// deno-lint-ignore no-explicit-any
CO extends Record<string, any> | void = any,
Expand Down Expand Up @@ -271,7 +281,7 @@ export class Command<
): Command<
// deno-lint-ignore no-explicit-any
CO extends number ? any : void,
A,
MapArgumentTypes<A>,
// deno-lint-ignore no-explicit-any
CO extends number ? any : void,
Merge<PG, CG>,
Expand Down Expand Up @@ -482,9 +492,10 @@ export class Command<
*/
public arguments<A extends Array<unknown> = CA>(
args: string,
): Command<CO, A, CG, PG, P> {
): Command<CO, MapArgumentTypes<A>, CG, PG, P>;
public arguments(args: string): Command {
this.cmd.argsDefinition = args;
return this as Command as Command<CO, A, CG, PG, P>;
return this;
}

/**
Expand Down Expand Up @@ -665,9 +676,12 @@ export class Command<
flags: string,
desc: string,
opts?:
| Omit<ICommandOption<Partial<CO>, CA, Merge<CG, G>, PG, P>, "global">
| Omit<
ICommandOption<Partial<CO>, CA, Merge<CG, MapOptionTypes<G>>, PG, P>,
"global"
>
| IFlagValueHandler,
): Command<CO, CA, Merge<CG, G>, PG, P> {
): Command<CO, CA, Merge<CG, MapOptionTypes<G>>, PG, P> {
if (typeof opts === "function") {
return this.option(flags, desc, { value: opts, global: true });
}
Expand All @@ -684,16 +698,18 @@ export class Command<
flags: string,
desc: string,
opts:
| ICommandOption<Partial<CO>, CA, Merge<CG, G>, PG, P> & { global: true }
| ICommandOption<Partial<CO>, CA, Merge<CG, MapOptionTypes<G>>, PG, P> & {
global: true;
}
| IFlagValueHandler,
): Command<CO, CA, Merge<CG, G>, PG, P>;
): Command<CO, CA, Merge<CG, MapOptionTypes<G>>, PG, P>;
public option<O extends Record<string, unknown> | void = CO>(
flags: string,
desc: string,
opts?:
| ICommandOption<Merge<CO, O>, CA, CG, PG, P>
| ICommandOption<Merge<CO, MapOptionTypes<O>>, CA, CG, PG, P>
| IFlagValueHandler,
): Command<Merge<CO, O>, CA, CG, PG, P>;
): Command<Merge<CO, MapOptionTypes<O>>, CA, CG, PG, P>;
public option(
flags: string,
desc: string,
Expand Down
56 changes: 50 additions & 6 deletions command/test/type/custom_test.ts
@@ -1,6 +1,7 @@
import { assertEquals, assertThrowsAsync } from "../../../dev_deps.ts";
import { Command } from "../../command.ts";
import type { ITypeHandler, ITypeInfo } from "../../types.ts";
import { Type } from "../../type.ts";

const email = (): ITypeHandler<string> => {
const emailRegex =
Expand Down Expand Up @@ -52,20 +53,63 @@ Deno.test("command - type - custom - child command with valid value", async () =

Deno.test("command - type - custom - with unknown type", async () => {
await assertThrowsAsync(
async () => {
await cmd.parse(["init", "-e", "my@email.com"]);
},
() => cmd.parse(["init", "-e", "my@email.com"]),
Error,
`Unknown type "email". Did you mean type "email2"?`,
);
});

Deno.test("command - type - custom - with invalid value", async () => {
await assertThrowsAsync(
async () => {
await cmd.parse(["init", "-E", "my @email.com"]);
},
() => cmd.parse(["-E", "my @email.com"]),
Error,
`Option "--email2" must be a valid "email", but got "my @email.com".`,
);
});

Deno.test("command - type - custom - with invalid value on child command", async () => {
await assertThrowsAsync(
() => cmd.parse(["init", "-E", "my @email.com"]),
Error,
`Option "--email2" must be a valid "email", but got "my @email.com".`,
);
});

class CustomType<T extends string> extends Type<T> {
constructor(private formats: Array<T>) {
super();
}

parse(type: ITypeInfo): T {
if (!this.formats.includes(type.value as T)) {
throw new Error(`invalid type: ${type.value}`);
}
return type.value as T;
}
}

Deno.test("command - type - custom - generic custom type", async () => {
const format = new CustomType(["foo", "bar"]);
const cmd = new Command<void>()
.throwErrors()
.type("format", format)
.option<{ format: typeof format }>(
"-f, --format [format:format]",
"...",
)
.action(({ format }) => {
// @ts-expect-error format cannot be xyz
format === "xyz";
format === "foo";
format === "bar";
});

const { options } = await cmd.parse(["-f", "foo"]);
assertEquals(options, { format: "foo" });

await assertThrowsAsync(
() => cmd.parse(["-f", "xyz"]),
Error,
`invalid type: xyz`,
);
});
6 changes: 5 additions & 1 deletion flags/_errors.ts
Expand Up @@ -156,7 +156,11 @@ export class InvalidTypeError extends ValidationError {
) {
super(
`${label} "${name}" must be of type "${type}", but got "${value}".` + (
expected ? ` Expected values: ${expected.join(", ")}` : ""
expected
? ` Expected values: ${
expected.map((value) => `"${value}"`).join(", ")
}`
: ""
),
);
Object.setPrototypeOf(this, MissingOptionValue.prototype);
Expand Down

0 comments on commit 59b1a93

Please sign in to comment.