Skip to content

Commit

Permalink
feat: support option enums
Browse files Browse the repository at this point in the history
  • Loading branch information
carloscortonc committed Mar 2, 2024
1 parent 1406a51 commit 98c2f11
Show file tree
Hide file tree
Showing 11 changed files with 107 additions and 25 deletions.
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ If a cli application does not have registered a root command (logic executed wit

You also use `CliOptions.rootCommand` to define a default command to execute, when no command/namespace is supplied (check this [webpack-cli example](/examples/webpack-cli)).

### Typing command's options
### [Typescript] Typing command's options
When defining a command handler inside a script file, in order to have typed options the following steps are needed:
- Define the command using `Cli.defineCommand`:
```typescript
Expand Down
2 changes: 1 addition & 1 deletion docs/cli-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Base path where the `ProcessingOutput.location` will start from.</br>
> **Deprecated since 0.13.0 in favor of `CliOptions.baseLocation`**
#### `commandsPath`
Path where the single-command scripts (not contained in any namespace) are stored. A relative value can be provided, using `CliOptions.baseScriptLocation` as base path.</br>
Path where the single-command scripts (not contained in any namespace) are stored. A relative value can be provided, using `CliOptions.baseLocation` as base path.</br>
**Default**: `"commands"`

#### `errors`
Expand Down
6 changes: 4 additions & 2 deletions docs/definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ type Command = BaseElement & {
action?: (out: ParsingOutput) => void;
}
```
- **aliases**: alternative names for the command. If specified, the will added on top of command key. Default `[key]`
- **aliases**: alternative names for the command. If specified, the will added on top of command key. Default: `[key]`
- **action**: method that will be called when the command is matched, receiving the output of the parsing process.
## Option
Expand All @@ -54,6 +54,7 @@ type Option = BaseElement & {
default?: any;
required?: boolean;
type?: "string" | "boolean" | "list" | "number" | "float";
enum?: string[];
parser?: (input: ValueParserInput) => ValueParserOutput
}
```
Expand All @@ -63,6 +64,7 @@ type Option = BaseElement & {
- **default**: default value for the option.
- **required**: specifies an option as required, generating an error if a value is not provided. Default: `false`
- **type**: type of option, to load the appropriate parser. Default: `string`
- **enum**: restrict the possible option-values based on the given list. Available for option-types `string`, `boolean` and `list`.
- **parser**: allows defining [custom parser](#custom-parser) for an option, instead of using the supported types.
### Positional options
Expand Down Expand Up @@ -115,7 +117,7 @@ type ValueParserOutput = {
value?: any;
/** Number of additional arguments that the parser consumed. For example, a boolean option
* might not consume any additional arguments ("--show-config", next=0) while a string option
* would ("--path path-value", next=1). The main case of `next=0` is when incoming value
* would ("--path path-value", next=1). The main use-case of `next=0` is when incoming value
* is `undefined` */
next?: number;
/** Error generated during parsing */
Expand Down
2 changes: 1 addition & 1 deletion examples/intl-cli/intl/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

"command_not_found": "Comando \"{0}\" no encontrado. Quieres decir \"{1}\" ?",
"option_not_found": "Opcion desconocida \"{0}\"",
"option_wrong_value": "Valor erróneo para la opción \"{0}\". Se esperaba <{1}> pero se encontró \"{2}\"",
"option_wrong_value": "Valor erróneo para la opción \"{0}\". Se esperaba {1} pero se encontró \"{2}\"",
"option_missing_value": "Se esperaba un valor de tipo <{0}> para la opción \"{1}\"",
"option_required": "Se esperaba una opción obligatoria \"{0}\"",

Expand Down
4 changes: 2 additions & 2 deletions src/cli-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Cli from ".";
export const ERROR_MESSAGES = {
command_not_found: 'Command "{command}" not found',
option_not_found: 'Unknown option "{option}"',
option_wrong_value: 'Wrong value for option "{option}". Expected <{expected}> but found "{found}"',
option_wrong_value: 'Wrong value for option "{option}". Expected {expected} but found "{found}"',
option_missing_value: 'Missing value of type <{type}> for option "{option}"',
option_required: 'Missing required option "{option}"',
} as const;
Expand All @@ -16,7 +16,7 @@ export type ErrorType = keyof typeof ERROR_MESSAGES;
export class CliError {
/** Test if the given error message matches an error type */
private static test(value: string, error: string) {
return new RegExp(error.replace(/\{\w+\}/g, "[a-zA-Z-0-9/\\.]+")).test(value);
return new RegExp(error.replace(/\{\w+\}/g, "[a-zA-Z-0-9/\\., \"'|]+")).test(value);
}
/** Analize the given error message to identify its type */
static analize(value: string | undefined): ErrorType | undefined {
Expand Down
3 changes: 2 additions & 1 deletion src/cli-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ export const CLI_MESSAGES = {
"generate-help.scope-not-found": "Unable to find the specified scope ({scope})",
"generate-help.usage": "Usage",
"generate-help.has-options": "[OPTIONS]",
"generate-help.option-default": "(default: {default})",
"generate-help.option-default": "default: {default}",
"generate-help.option-enum": "allowed: {enum}",
"generate-help.namespaces-title": "Namespaces",
"generate-help.commands-title": "Commands",
"generate-help.options-title": "Options",
Expand Down
28 changes: 22 additions & 6 deletions src/cli-option-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,39 @@ export default function parseOptionValue({ value, current, option }: ValueParser
};
const wrongValueError = Cli.formatMessage("option_wrong_value", {
option: option.key,
expected: type,
expected: `<${type}>`,
found: value!,
});
// Validate Option.enum
const validateEnum = (values: (string | undefined)[]) => {
if (!option.enum) {
return undefined;
}
const found = values.find((v) => !option.enum!.includes(v!));
const formatEnum = () => option.enum!.join(" | ");
return found
? Cli.formatMessage("option_wrong_value", { option: option.key, expected: `'${formatEnum()}'`, found })
: undefined;
};

/** Implemented parsers */
const valueParsers: { [key in Type]: Partial<ValueParserOutput> | (() => Partial<ValueParserOutput>) } = {
[Type.STRING]: {
[Type.STRING]: () => ({
value,
},
error: defaultParserOutput.error || validateEnum([value]),
}),
[Type.BOOLEAN]: () => ({
value: ["true", undefined].includes(value),
next: ["true", "false"].includes(value as string) ? 1 : 0,
error: undefined,
}),
[Type.LIST]: () => ({
value: ((current as string[]) || []).concat(value?.split(",") as string[]),
}),
[Type.LIST]: () => {
const v = ((current as string[]) || []).concat(value?.split(",") as string[]);
return {
value: v,
error: defaultParserOutput.error || validateEnum(v),
};
},
[Type.NUMBER]: () => {
const v = parseInt(value as string);
return {
Expand Down
37 changes: 31 additions & 6 deletions src/cli-utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import path from "path";
import fs from "fs";
import url from "url";
import { addLineBreaks, clone, ColumnFormatter, debug, DEBUG_TYPE, deprecationWarning, logErrorAndExit } from "./utils";
import {
addLineBreaks,
clone,
ColumnFormatter,
debug,
DEBUG_TYPE,
deprecationWarning,
logErrorAndExit,
quote,
} from "./utils";
import { Kind, ParsingOutput, Definition, Type, CliOptions, Option, Namespace, Command } from "./types";
import parseOptionValue from "./cli-option-parser";
import { validatePositional } from "./definition-validations";
Expand Down Expand Up @@ -325,7 +334,7 @@ export function parseArguments(
current: output.options[outputKey],
option: {
...(optionDefinition as Option),
key: curr,
key: isPositional === Positional.TRUE ? optionDefinition.key! : curr,
},
format: () =>
deprecationWarning({
Expand Down Expand Up @@ -540,10 +549,26 @@ function generateHelp(
// Generate the formatted versions of aliases
const formatAliases = (aliases: string[] = []) => aliases.join(", ");
// Generate default-value hint, if present
const defaultHint = (option: DefinitionElement) =>
option.default !== undefined
? " ".concat(Cli.formatMessage("generate-help.option-default", { default: option.default.toString() }))
: "";
const defaultHint = (option: DefinitionElement) => {
const w = (c: string) => (c ? ` (${c})` : c);
// format default/enum value
const f = (v: any) => {
if (typeof v !== "string" && !Array.isArray(v)) {
return v;
}
return (Array.isArray(v) ? v : [v]).map(quote).join(", ");
};
return w(
[
Array.isArray(option.enum) ? Cli.formatMessage("generate-help.option-enum", { enum: f(option.enum) }) : "",
option.default !== undefined
? Cli.formatMessage("generate-help.option-default", { default: f(option.default) })
: "",
]
.filter((e) => e)
.join(", "),
);
};
// Format all the information relative to an element
const formatElement = (element: ExtendedDefinitionElement, formatter: ColumnFormatter, indentation: number) => {
const start = [" ".repeat(indentation), formatter.format("name", element.name, 2)].join("");
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export type Option = BaseElement & {
* @default false
*/
positional?: boolean | number;
/** Possible values for an option */
enum?: string[];
/** If type=boolean, whether to include negated aliases
* e.g. --debug => --no-debug/--nodebug
* @default false
Expand Down
3 changes: 3 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ export function addLineBreaks(value: string, params: { start: number; rightMargi
return lines.join(`\n${" ".repeat(start + indent)}`).concat("\n");
}

/** Add doble quotes to a given string */
export const quote = (e: string) => `"${e}"`;

/** Shortened method for logging an error an exiting */
export const logErrorAndExit = (message?: string) => {
if (message) {
Expand Down
43 changes: 38 additions & 5 deletions test/cli-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,13 @@ describe("parseArguments", () => {
errors: ['Missing value of type <string> for option "--opt"'],
location: expect.anything(),
});
expect(
parseArguments(["--opt", "othervalue"], { opt: { ...d.opt, enum: ["optv1", "optv2"] } }, cliOptions),
).toStrictEqual({
options: { opt: "defaultvalue", _: [] },
errors: ['Wrong value for option "--opt". Expected \'optv1 | optv2\' but found "othervalue"'],
location: expect.anything(),
});
});
it("Parse BOOLEAN value", () => {
const d: Definition<DefinitionElement> = {
Expand All @@ -231,6 +238,13 @@ describe("parseArguments", () => {
errors: ['Missing value of type <list> for option "--opt"'],
location: expect.anything(),
});
expect(
parseArguments(["--opt", "optv1,optv3"], { opt: { ...d.opt, enum: ["optv1", "optv2"] } }, cliOptions),
).toStrictEqual({
options: { _: [] },
errors: ['Wrong value for option "--opt". Expected \'optv1 | optv2\' but found "optv3"'],
location: expect.anything(),
});
});
it("Parse LIST value by repeated appearances", () => {
const d: Definition<DefinitionElement> = {
Expand Down Expand Up @@ -277,10 +291,12 @@ describe("parseArguments", () => {
it("Option with parser property", () => {
const d = new Cli({
opt: {
parser: ({ value }) => {
parser: ({ value, option: { key } }) => {
// return error if value is not a date
if (isNaN(Date.parse(value || ""))) {
return { error: Cli.formatMessage("option_wrong_value", { option: "x", expected: "x", found: "x" }) };
return {
error: Cli.formatMessage("option_wrong_value", { option: key, expected: "<date>", found: value! }),
};
}
return { value: new Date(value!) };
},
Expand All @@ -289,7 +305,7 @@ describe("parseArguments", () => {
expect(parseArguments(["--opt", "not-a-date"], d, cliOptions)).toStrictEqual({
options: { _: [] },
location: expect.anything(),
errors: [expect.stringContaining("Wrong value for option")],
errors: ['Wrong value for option "--opt". Expected <date> but found "not-a-date"'],
});
});
it("No arguments", () => {
Expand Down Expand Up @@ -432,6 +448,13 @@ describe("parseArguments", () => {
location: [],
errors: ['Unknown option "extra"'],
});
expect(
parseArguments(["optvalue"], { opt: { ...definition.opt, enum: ["optv1", "optv2"] } }, cliOptions),
).toStrictEqual({
options: { _: [] },
errors: ['Wrong value for option "opt". Expected \'optv1 | optv2\' but found "optvalue"'],
location: expect.anything(),
});
});
it("Positional option (true)", () => {
const { definition, options } = new Cli({ opt: {}, popt: { positional: true } }, baseConfig);
Expand Down Expand Up @@ -634,7 +657,7 @@ Commands:
gcmd Description for global command
Options:
-g, --global Option shared between all commands (default: globalvalue)
-g, --global Option shared between all commands (default: "globalvalue")
-h, --help Display global help, or scoped to a namespace/command
`);
Expand All @@ -652,7 +675,7 @@ Commands:
cmd Description for the command
Options:
-g, --global Option shared between all commands (default: globalvalue)
-g, --global Option shared between all commands (default: "globalvalue")
-h, --help Display global help, or scoped to a namespace/command
`);
Expand Down Expand Up @@ -722,6 +745,11 @@ This is a custom footer
logger.mockImplementation((m: any) => !!(output += m));
const { definition: def } = new Cli({
bool: { type: "boolean", default: true, description: "boolean option" },
num: { type: "number", default: 10, description: "number option" },
float: { type: "float", default: 0.5, description: "float option" },
list: { type: "list", default: ["one", "two"], description: "list option" },
enum: { enum: ["opt1", "opt2"], description: "string with enum" },
enumdef: { enum: ["opt1", "opt2"], default: "opt1", description: "string with enum and default" },
arg1: { positional: 0, required: true, description: "first positional mandatory option" },
arg2: { positional: 1, description: "second positional option" },
arg3: { positional: true, description: "catch-all positional option" },
Expand All @@ -734,6 +762,11 @@ cli-description
Options:
--bool boolean option (default: true)
--num number option (default: 10)
--float float option (default: 0.5)
--list list option (default: "one", "two")
--enum string with enum (allowed: "opt1", "opt2")
--enumdef string with enum and default (allowed: "opt1", "opt2", default: "opt1")
--arg1 first positional mandatory option
--arg2 second positional option
--arg3 catch-all positional option
Expand Down

0 comments on commit 98c2f11

Please sign in to comment.