Skip to content

Commit

Permalink
feat(flags,command): add did-you-mean support and improve error messa…
Browse files Browse the repository at this point in the history
…ges (#131)
  • Loading branch information
c4spar committed Jan 8, 2021
1 parent df45bd8 commit afd8697
Show file tree
Hide file tree
Showing 48 changed files with 364 additions and 187 deletions.
18 changes: 9 additions & 9 deletions command/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ console.log(options);

```
$ deno run https://deno.land/x/cliffy/examples/command/common_option_types.ts -p
Error: Missing value for option: --pizza-type
Error: Missing value for option "--pizza-type".
$ deno run https://deno.land/x/cliffy/examples/command/common_option_types.ts -sp vegetarian --amount 3
{ small: true, pizzaType: "vegetarian", amount: 3 }
Expand Down Expand Up @@ -472,7 +472,7 @@ $ deno run https://deno.land/x/cliffy/examples/command/depending_options.ts -a a
{ audioCodec: "aac" }
$ deno run https://deno.land/x/cliffy/examples/command/depending_options.ts -v x265
Option --video-codec depends on option: --audio-codec
Error: Option "--video-codec" depends on option "--audio-codec".
$ deno run https://deno.land/x/cliffy/examples/command/depending_options.ts -a aac -v x265
{ audioCodec: "aac", videoCodec: "x265" }
Expand Down Expand Up @@ -521,7 +521,7 @@ const { options } = await new Command()
value: (value: string, previous: string[] = []): string[] => {
if (["blue", "yellow", "red"].indexOf(value) === -1) {
throw new Error(
`Color must be one of blue, yellow or red but got: ${value}`,
`Color must be one of "blue, yellow or red", but got "${value}".`,
);
}
previous.push(value);
Expand Down Expand Up @@ -837,7 +837,7 @@ try {

```textile
$ deno run https://deno.land/x/cliffy/examples/command/override_exit_handling.ts -t
[CUSTOM_ERROR] Error: Unknown option: -t
[CUSTOM_ERROR] Error: Unknown option "-t".
```

## ❯ Custom types
Expand All @@ -857,7 +857,7 @@ const emailRegex =

function emailType({ label, name, value }: ITypeInfo): string {
if (!emailRegex.test(value.toLowerCase())) {
throw new Error(`${label} ${name} must be a valid email but got: ${value}`);
throw new Error(`${label} "${name}" must be a valid "email", but got "${value}".`);
}

return value;
Expand All @@ -878,7 +878,7 @@ $ deno run https://deno.land/x/cliffy/examples/command/custom_option_type.ts -e

```
$ deno run https://deno.land/x/cliffy/examples/command/custom_option_type.ts -e "my @email.com"
Option --email must be a valid email but got: my @email.com
Error: Option "--email" must be a valid "email", but got "my @email.com".
```

### Class types
Expand All @@ -896,7 +896,7 @@ class EmailType extends Type<string> {
public parse({ label, name, value }: ITypeInfo): string {
if (!this.emailRegex.test(value.toLowerCase())) {
throw new Error(
`${label} ${name} must be a valid email but got: ${value}`,
`${label} "${name}" must be a valid "email", but got "${value}".`,
);
}

Expand All @@ -919,7 +919,7 @@ $ deno run https://deno.land/x/cliffy/examples/command/custom_option_type_class.

```
$ deno run https://deno.land/x/cliffy/examples/command/custom_option_type_class.ts -e "my @email.de"
Option --email must be a valid email but got: my @email.de
Error: Option "--email" must be a valid "email", but got "my @email.de".
```

### Global types
Expand Down Expand Up @@ -976,7 +976,7 @@ $ SOME_ENV_VAR=1 deno run --allow-env --unstable https://deno.land/x/cliffy/exam
1
$ SOME_ENV_VAR=abc deno run --allow-env --unstable https://deno.land/x/cliffy/examples/command/environment_variables.ts
Error: Environment variable SOME_ENV_VAR must be of type number but got: abc
Error: Environment variable "SOME_ENV_VAR" must be of type "number", but got "abc".
```

## ❯ Add examples
Expand Down
2 changes: 1 addition & 1 deletion command/_arguments_parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export class ArgumentsParser {

if (!details.optionalValue && hasOptional) {
throw new Error(
"An required argument can not follow an optional argument.",
`An required argument can not follow an optional argument but "${details.name}" is defined as required.`,
);
}

Expand Down
78 changes: 61 additions & 17 deletions command/command.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { didYouMean, didYouMeanType } from "../flags/_utils.ts";
import { parseFlags } from "../flags/flags.ts";
import type { IFlagsResult } from "../flags/types.ts";
import { existsSync, red } from "./deps.ts";
Expand Down Expand Up @@ -221,7 +222,7 @@ export class Command<O = any, A extends Array<any> = any> {

if (this.getBaseCommand(name, true)) {
if (!override) {
throw this.error(new Error(`Duplicate command: ${name}`));
throw this.error(new Error(`Duplicate command "${name}".`));
}
this.removeCommand(name);
}
Expand Down Expand Up @@ -284,12 +285,12 @@ export class Command<O = any, A extends Array<any> = any> {
public alias(alias: string): this {
if (this.cmd === this) {
throw this.error(
new Error(`Failed to add alias '${alias}'. No sub command selected.`),
new Error(`Failed to add alias "${alias}". No sub command selected.`),
);
}

if (this.cmd.aliases.indexOf(alias) !== -1) {
throw this.error(new Error(`Duplicate alias: ${alias}`));
throw this.error(new Error(`Duplicate alias "${alias}".`));
}

this.cmd.aliases.push(alias);
Expand All @@ -310,7 +311,13 @@ export class Command<O = any, A extends Array<any> = any> {
const cmd = this.getBaseCommand(name, true);

if (!cmd) {
throw this.error(new Error(`Sub-command not found: ${name}`));
throw this.error(
new Error(
`Unknown sub-command "${name}".${
didYouMeanCommand(name, this.getBaseCommands(true))
}`,
),
);
}

this.cmd = cmd;
Expand Down Expand Up @@ -446,7 +453,7 @@ export class Command<O = any, A extends Array<any> = any> {
options?: ITypeOptions,
): this {
if (this.cmd.types.get(name) && !options?.override) {
throw this.error(new Error(`Type '${name}' already exists.`));
throw this.error(new Error(`Type with name "${name}" already exists.`));
}

this.cmd.types.set(name, { ...options, name, handler });
Expand Down Expand Up @@ -475,7 +482,9 @@ export class Command<O = any, A extends Array<any> = any> {
options?: ICompleteOptions,
): this {
if (this.cmd.completions.has(name) && !options?.override) {
throw this.error(new Error(`Completion '${name}' already exists.`));
throw this.error(
new Error(`Completion with name "${name}" already exists.`),
);
}

this.cmd.completions.set(name, {
Expand Down Expand Up @@ -549,7 +558,9 @@ export class Command<O = any, A extends Array<any> = any> {
if (
option.name === name || option.aliases && ~option.aliases.indexOf(name)
) {
throw this.error(new Error(`Duplicate command name: ${name}`));
throw this.error(
new Error(`Command with name "${name}" already exists.`),
);
}

if (!option.name && isLong) {
Expand All @@ -564,7 +575,9 @@ export class Command<O = any, A extends Array<any> = any> {
if (opts?.override) {
this.removeOption(name);
} else {
throw this.error(new Error(`Duplicate option name: ${name}`));
throw this.error(
new Error(`Option with name "${name}" already exists.`),
);
}
}
}
Expand All @@ -585,7 +598,9 @@ export class Command<O = any, A extends Array<any> = any> {
*/
public example(name: string, description: string): this {
if (this.cmd.hasExample(name)) {
throw this.error(new Error("Example already exists."));
throw this.error(
new Error(`Example with name "${name}" already exists.`),
);
}

this.cmd.examples.push({ name, description });
Expand All @@ -612,7 +627,7 @@ export class Command<O = any, A extends Array<any> = any> {

if (result.flags.some((envName) => this.cmd.getBaseEnvVar(envName, true))) {
throw this.error(
new Error(`Environment variable already exists: ${name}`),
new Error(`Environment variable with name "${name}" already exists.`),
);
}

Expand All @@ -623,19 +638,19 @@ export class Command<O = any, A extends Array<any> = any> {
if (details.length > 1) {
throw this.error(
new Error(
`An environment variable can only have one value but got: ${name}`,
`An environment variable can only have one value but "${name}" has more than one.`,
),
);
} else if (details.length && details[0].optionalValue) {
throw this.error(
new Error(
`An environment variable can not have an optional value but '${name}' is defined as optional.`,
`An environment variable can not have an optional value but "${name}" is defined as optional.`,
),
);
} else if (details.length && details[0].variadic) {
throw this.error(
new Error(
`An environment variable can not have an variadic value but '${name}' is defined as variadic.`,
`An environment variable can not have an variadic value but "${name}" is defined as variadic.`,
),
);
}
Expand Down Expand Up @@ -664,6 +679,8 @@ export class Command<O = any, A extends Array<any> = any> {
args: string[] = Deno.args,
dry?: boolean,
): Promise<IParseResult<O, A>> {
// @TODO: remove all `this.error()` calls and catch errors only in parse method!

this.reset()
.registerDefaults();

Expand Down Expand Up @@ -791,7 +808,11 @@ export class Command<O = any, A extends Array<any> = any> {

if (!cmd) {
throw this.error(
new Error(`Default command '${this.defaultCommand}' not found.`),
new Error(
`Default command "${this.defaultCommand}" not found.${
didYouMeanCommand(this.defaultCommand, this.getCommands())
}`,
),
);
}

Expand Down Expand Up @@ -920,7 +941,13 @@ export class Command<O = any, A extends Array<any> = any> {
const typeSettings: IType | undefined = this.getType(type.type);

if (!typeSettings) {
throw this.error(new Error(`No type registered with name: ${type.type}`));
throw this.error(
new Error(
`Unknown type "${type.type}".${
didYouMeanType(type.type, this.getTypes().map((type) => type.name))
}`,
),
);
}

return typeSettings.handler instanceof Type
Expand Down Expand Up @@ -967,10 +994,16 @@ export class Command<O = any, A extends Array<any> = any> {
if (!this.hasArguments()) {
if (args.length) {
if (this.hasCommands(true)) {
throw this.error(new Error(`Unknown command: ${args.join(" ")}`));
throw this.error(
new Error(
`Unknown command "${args[0]}".${
didYouMeanCommand(args[0], this.getCommands())
}`,
),
);
} else {
throw this.error(
new Error(`No arguments allowed for command: ${this.getPath()}`),
new Error(`No arguments allowed for command "${this.getPath()}".`),
);
}
}
Expand Down Expand Up @@ -1755,3 +1788,14 @@ function isDebug(): boolean {
const debug: string | undefined = Deno.env.get("CLIFFY_DEBUG");
return debug === "true" || debug === "1";
}

export function didYouMeanCommand(
command: string,
commands: Array<Command>,
excludes: Array<string> = [],
): string {
const commandNames = commands
.map((command) => command.getName())
.filter((command) => !excludes.includes(command));
return didYouMean(" Did you mean command", command, commandNames);
}
6 changes: 3 additions & 3 deletions command/completions/complete.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Command } from "../command.ts";
import { Command, didYouMeanCommand } from "../command.ts";
import type { ICompletion } from "../types.ts";

/** Execute auto completion method of command and action. */
Expand All @@ -20,8 +20,8 @@ export class CompleteCommand extends Command {
const childCmd: Command | undefined = cmd.getCommand(name, false);
if (!childCmd) {
throw new Error(
`Auto-completion failed. Command not found: ${
commandNames.join(" ")
`Auto-completion failed. Unknown command "${name}".${
didYouMeanCommand(name, cmd.getCommands())
}`,
);
}
Expand Down
11 changes: 1 addition & 10 deletions command/help/_help_generator.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getFlag } from "../../flags/_utils.ts";
import { Table } from "../../table/table.ts";
import { ArgumentsParser } from "../_arguments_parser.ts";
import type { Command } from "../command.ts";
Expand Down Expand Up @@ -231,13 +232,3 @@ function inspect(value: unknown): string {
{ depth: 1, colors: true, trailingComma: false } as Deno.InspectOptions,
);
}

function getFlag(name: string) {
if (name.startsWith("-")) {
return name;
}
if (name.length > 1) {
return `--${name}`;
}
return `-${name}`;
}
12 changes: 10 additions & 2 deletions command/help/mod.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Command } from "../command.ts";
import { Command, didYouMeanCommand } from "../command.ts";
import { CommandType } from "../types/command.ts";

/** Generates well formatted and colored help output for specified command. */
Expand All @@ -15,8 +15,16 @@ export class HelpCommand extends Command {
: this.getGlobalParent();
}
if (!cmd) {
const cmds = this.getGlobalParent()?.getCommands();
throw new Error(
`Failed to generate help for command '${name}'. Command not found.`,
`Unknown command "${name}".${
name && cmds
? didYouMeanCommand(name, cmds, [
this.getName(),
...this.getAliases(),
])
: ""
}`,
);
}
cmd.help();
Expand Down
8 changes: 4 additions & 4 deletions command/test/command/arguments_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ function cmd() {
.type("color", ({ label, name, type, value }: ITypeInfo) => {
if (!["red", "blue", "yellow"].includes(value)) {
throw new Error(
`${label} ${name} must be a valid ${type} but got: ${value}`,
`${label} "${name}" must be a valid "${type}", but got "${value}".`,
);
}
return value;
Expand All @@ -34,7 +34,7 @@ Deno.test("invalid number command argument type", async () => {
await cmd().parse(["abc", "xyz", "true", "red"]);
},
Error,
"Argument bar must be of type number but got: xyz",
`Argument "bar" must be of type "number", but got "xyz".`,
);
});

Expand All @@ -54,7 +54,7 @@ Deno.test("invalid boolean command argument type", async () => {
await cmd().parse(["abc", "123", "xyz", "red"]);
},
Error,
"Argument baz must be of type boolean but got: xyz",
`Argument "baz" must be of type "boolean", but got "xyz".`,
);
});

Expand All @@ -64,6 +64,6 @@ Deno.test("invalid custom command argument type", async () => {
await cmd().parse(["abc", "123", "true", "xyz"]);
},
Error,
"Argument color must be a valid color but got: xyz",
`Argument "color" must be a valid "color", but got "xyz".`,
);
});

0 comments on commit afd8697

Please sign in to comment.