From 714fe74dfe919b79384480cd62d1a2f62f537932 Mon Sep 17 00:00:00 2001 From: Maxwell Brown Date: Sun, 19 Nov 2023 20:24:23 -0500 Subject: [PATCH] Implement support for variadic `Options` (#383) --- .changeset/odd-eels-add.md | 5 + src/Args.ts | 7 - src/Options.ts | 37 ++++ src/ValidationError.ts | 37 ++-- src/internal/args.ts | 25 +-- src/internal/options.ts | 312 ++++++++++++++++++++++++++++++-- src/internal/validationError.ts | 53 +++--- test/Options.test.ts | 110 ++++++++--- 8 files changed, 477 insertions(+), 109 deletions(-) create mode 100644 .changeset/odd-eels-add.md diff --git a/.changeset/odd-eels-add.md b/.changeset/odd-eels-add.md new file mode 100644 index 0000000..6c67f66 --- /dev/null +++ b/.changeset/odd-eels-add.md @@ -0,0 +1,5 @@ +--- +"@effect/cli": patch +--- + +add support for variadic options diff --git a/src/Args.ts b/src/Args.ts index 5285882..84b0a65 100644 --- a/src/Args.ts +++ b/src/Args.ts @@ -319,13 +319,6 @@ export const path: (config?: Args.PathArgsConfig) => Args = InternalArgs */ export const repeated: (self: Args) => Args> = InternalArgs.repeated -/** - * @since 1.0.0 - * @category combinators - */ -export const repeatedAtLeastOnce: (self: Args) => Args> = - InternalArgs.repeatedAtLeastOnce - /** * Creates a text argument. * diff --git a/src/Options.ts b/src/Options.ts index 0b3ff99..88ccdc5 100644 --- a/src/Options.ts +++ b/src/Options.ts @@ -276,6 +276,37 @@ export const text: (name: string) => Options = InternalOptions.text // Combinators // ============================================================================= +/** + * @since 1.0.0 + * @category combinators + */ +export const atMost: { + (times: number): (self: Options) => Options> + (self: Options, times: number): Options> +} = InternalOptions.atMost + +/** + * @since 1.0.0 + * @category combinators + */ +export const atLeast: { + (times: 0): (self: Options) => Options> + (times: number): (self: Options) => Options> + (self: Options, times: 0): Options> + (self: Options, times: number): Options]> +} = InternalOptions.atLeast + +/** + * @since 1.0.0 + * @category combinators + */ +export const between: { + (min: 0, max: number): (self: Options) => Options> + (min: number, max: number): (self: Options) => Options> + (self: Options, min: 0, max: number): Options> + (self: Options, min: number, max: number): Options> +} = InternalOptions.between + /** * @since 1.0.0 * @category combinators @@ -361,6 +392,12 @@ export const parse: { ): Effect } = InternalOptions.parse +/** + * @since 1.0.0 + * @category combinators + */ +export const repeated: (self: Options) => Options> = InternalOptions.repeated + /** * Returns a `RegularLanguage` whose accepted language is equivalent to the * language accepted by the provided `Options`. diff --git a/src/ValidationError.ts b/src/ValidationError.ts index 6109d30..43fad2a 100644 --- a/src/ValidationError.ts +++ b/src/ValidationError.ts @@ -25,9 +25,9 @@ export type ValidationError = | CorrectedFlag | InvalidArgument | InvalidValue - | KeyValuesDetected | MissingValue | MissingFlag + | MultipleValuesDetected | MissingSubcommand | NoBuiltInMatch | UnclusteredFlag @@ -38,6 +38,7 @@ export type ValidationError = */ export interface CommandMismatch extends ValidationError.Proto { readonly _tag: "CommandMismatch" + readonly error: HelpDoc } /** @@ -46,6 +47,7 @@ export interface CommandMismatch extends ValidationError.Proto { */ export interface CorrectedFlag extends ValidationError.Proto { readonly _tag: "CorrectedFlag" + readonly error: HelpDoc } /** @@ -54,6 +56,7 @@ export interface CorrectedFlag extends ValidationError.Proto { */ export interface InvalidArgument extends ValidationError.Proto { readonly _tag: "InvalidArgument" + readonly error: HelpDoc } /** @@ -62,15 +65,7 @@ export interface InvalidArgument extends ValidationError.Proto { */ export interface InvalidValue extends ValidationError.Proto { readonly _tag: "InvalidValue" -} - -/** - * @since 1.0.0 - * @category models - */ -export interface KeyValuesDetected extends ValidationError.Proto { - readonly _tag: "KeyValuesDetected" - readonly keyValues: ReadonlyArray + readonly error: HelpDoc } /** @@ -79,6 +74,7 @@ export interface KeyValuesDetected extends ValidationError.Proto { */ export interface MissingFlag extends ValidationError.Proto { readonly _tag: "MissingFlag" + readonly error: HelpDoc } /** @@ -87,6 +83,7 @@ export interface MissingFlag extends ValidationError.Proto { */ export interface MissingValue extends ValidationError.Proto { readonly _tag: "MissingValue" + readonly error: HelpDoc } /** @@ -95,6 +92,17 @@ export interface MissingValue extends ValidationError.Proto { */ export interface MissingSubcommand extends ValidationError.Proto { readonly _tag: "MissingSubcommand" + readonly error: HelpDoc +} + +/** + * @since 1.0.0 + * @category models + */ +export interface MultipleValuesDetected extends ValidationError.Proto { + readonly _tag: "MultipleValuesDetected" + readonly error: HelpDoc + readonly values: ReadonlyArray } /** @@ -103,6 +111,7 @@ export interface MissingSubcommand extends ValidationError.Proto { */ export interface NoBuiltInMatch extends ValidationError.Proto { readonly _tag: "NoBuiltInMatch" + readonly error: HelpDoc } /** @@ -111,6 +120,7 @@ export interface NoBuiltInMatch extends ValidationError.Proto { */ export interface UnclusteredFlag extends ValidationError.Proto { readonly _tag: "UnclusteredFlag" + readonly error: HelpDoc readonly unclustered: ReadonlyArray readonly rest: ReadonlyArray } @@ -125,7 +135,6 @@ export declare namespace ValidationError { */ export interface Proto { readonly [ValidationErrorTypeId]: ValidationErrorTypeId - readonly error: HelpDoc } } @@ -168,8 +177,8 @@ export const isInvalidValue: (self: ValidationError) => self is InvalidValue = * @since 1.0.0 * @category refinements */ -export const isKeyValuesDetected: (self: ValidationError) => self is KeyValuesDetected = - InternalValidationError.isKeyValuesDetected +export const isMultipleValuesDetected: (self: ValidationError) => self is MultipleValuesDetected = + InternalValidationError.isMultipleValuesDetected /** * @since 1.0.0 @@ -241,7 +250,7 @@ export const invalidValue: (error: HelpDoc) => ValidationError = export const keyValuesDetected: ( error: HelpDoc, keyValues: ReadonlyArray -) => ValidationError = InternalValidationError.keyValuesDetected +) => ValidationError = InternalValidationError.multipleValuesDetected /** * @since 1.0.0 diff --git a/src/internal/args.ts b/src/internal/args.ts index 11ef03b..c8da6df 100644 --- a/src/internal/args.ts +++ b/src/internal/args.ts @@ -307,21 +307,6 @@ export const optional = (self: Args.Args): Args.Args> => export const repeated = (self: Args.Args): Args.Args> => makeVariadic(self, Option.none(), Option.none()) -/** @internal */ -export const repeatedAtLeastOnce = ( - self: Args.Args -): Args.Args> => - map(makeVariadic(self, Option.some(1), Option.none()), (values) => { - if (ReadonlyArray.isNonEmptyReadonlyArray(values)) { - return values - } - const message = Option.match(getIdentifierInternal(self as Instruction), { - onNone: () => "An anonymous variadic argument", - onSome: (identifier) => `The variadic option '${identifier}' ` - }) - throw new Error(`${message} is not respecting the required minimum of 1`) - }) - /** @internal */ export const toRegularLanguage = ( self: Args.Args @@ -507,9 +492,9 @@ const getMaxSizeInternal = (self: Instruction): number => { return getMaxSizeInternal(self.args as Instruction) } case "Both": { - const leftMinSize = getMaxSizeInternal(self.left as Instruction) - const rightMinSize = getMaxSizeInternal(self.right as Instruction) - return leftMinSize + rightMinSize + const leftMaxSize = getMaxSizeInternal(self.left as Instruction) + const rightMaxSize = getMaxSizeInternal(self.right as Instruction) + return leftMaxSize + rightMaxSize } case "Variadic": { const argsMaxSize = getMaxSizeInternal(self.args as Instruction) @@ -725,11 +710,11 @@ const validateInternal = ( acc.length >= min1 && ReadonlyArray.isEmptyReadonlyArray(args) ? Effect.succeed([args, acc]) : Effect.fail(failure), - onSuccess: ([args, a]) => loop(args, ReadonlyArray.prepend(acc, a)) + onSuccess: ([args, a]) => loop(args, ReadonlyArray.append(acc, a)) })) } return loop(args, ReadonlyArray.empty()).pipe( - Effect.map(([args, acc]) => [args, ReadonlyArray.reverse(acc)]) + Effect.map(([args, acc]) => [args, acc]) ) } } diff --git a/src/internal/options.ts b/src/internal/options.ts index 0266eed..0446412 100644 --- a/src/internal/options.ts +++ b/src/internal/options.ts @@ -9,6 +9,7 @@ import * as Option from "effect/Option" import * as Order from "effect/Order" import { pipeArguments } from "effect/Pipeable" import * as ReadonlyArray from "effect/ReadonlyArray" +import * as Ref from "effect/Ref" import type * as CliConfig from "../CliConfig.js" import type * as HelpDoc from "../HelpDoc.js" import type * as Options from "../Options.js" @@ -19,8 +20,10 @@ import type * as ValidationError from "../ValidationError.js" import * as InternalAutoCorrect from "./autoCorrect.js" import * as InternalCliConfig from "./cliConfig.js" import * as InternalHelpDoc from "./helpDoc.js" +import * as InternalSpan from "./helpDoc/span.js" import * as InternalPrimitive from "./primitive.js" import * as InternalListPrompt from "./prompt/list.js" +import * as InternalNumberPrompt from "./prompt/number.js" import * as InternalSelectPrompt from "./prompt/select.js" import * as InternalRegularLanguage from "./regularLanguage.js" import * as InternalUsage from "./usage.js" @@ -55,10 +58,11 @@ export type Instruction = | Map | Both | OrElse + | Variadic | WithDefault /** @internal */ -export type ParseableInstruction = Single | KeyValueMap +export type ParseableInstruction = Single | KeyValueMap | Variadic /** @internal */ export interface Empty extends Op<"Empty", {}> {} @@ -107,6 +111,15 @@ export interface OrElse extends }> {} +/** @internal */ +export interface Variadic extends + Op<"Variadic", { + readonly argumentOption: Single + readonly min: Option.Option + readonly max: Option.Option + }> +{} + /** @internal */ export interface WithDefault extends Op<"WithDefault", { @@ -287,7 +300,7 @@ export const keyValueMap = ( return makeKeyValueMap(single as Single) } if (!isSingle(option as Instruction)) { - throw new Error("InvalidArgumentException: the provided option must be a single option") + throw new Error("InvalidArgumentException: only single options can be key/value maps") } else { return makeKeyValueMap(option as Single) } @@ -308,6 +321,48 @@ export const text = (name: string): Options.Options => // Combinators // ============================================================================= +/** @internal */ +export const atLeast = dual< + { + (times: 0): (self: Options.Options) => Options.Options> + ( + times: number + ): (self: Options.Options) => Options.Options> + }, + { + (self: Options.Options, times: 0): Options.Options> + ( + self: Options.Options, + times: number + ): Options.Options> + } +>(2, (self, times) => makeVariadic(self, Option.some(times), Option.none()) as any) + +/** @internal */ +export const atMost = dual< + (times: number) => (self: Options.Options) => Options.Options>, + (self: Options.Options, times: number) => Options.Options> +>(2, (self, times) => makeVariadic(self, Option.none(), Option.some(times)) as any) + +/** @internal */ +export const between = dual< + { + (min: 0, max: number): (self: Options.Options) => Options.Options> + ( + min: number, + max: number + ): (self: Options.Options) => Options.Options> + }, + { + (self: Options.Options, min: 0, max: number): Options.Options> + ( + self: Options.Options, + min: number, + max: number + ): Options.Options> + } +>(3, (self, min, max) => makeVariadic(self, Option.some(min), Option.some(max)) as any) + /** @internal */ export const isBool = (self: Options.Options): boolean => isBoolInternal(self as Instruction) @@ -319,6 +374,14 @@ export const getHelp = (self: Options.Options): HelpDoc.HelpDoc => export const getIdentifier = (self: Options.Options): Option.Option => getIdentifierInternal(self as Instruction) +/** @internal */ +export const getMinSize = (self: Options.Options): number => + getMinSizeInternal(self as Instruction) + +/** @internal */ +export const getMaxSize = (self: Options.Options): number => + getMaxSizeInternal(self as Instruction) + /** @internal */ export const getUsage = (self: Options.Options): Usage.Usage => getUsageInternal(self as Instruction) @@ -393,6 +456,10 @@ export const parse = dual< ) => Effect.Effect >(3, (self, args, config) => parseInternal(self as Instruction, args, config) as any) +/** @internal */ +export const repeated = (self: Options.Options): Options.Options> => + makeVariadic(self, Option.none(), Option.none()) + /** @internal */ export const toRegularLanguage = (self: Options.Options): RegularLanguage.RegularLanguage => toRegularLanguageInternal(self as Instruction) @@ -573,6 +640,24 @@ const getHelpInternal = (self: Instruction): HelpDoc.HelpDoc => { getHelpInternal(self.right as Instruction) ) } + case "Variadic": { + const help = getHelpInternal(self.argumentOption as Instruction) + return InternalHelpDoc.mapDescriptionList(help, (oldSpan, oldBlock) => { + const min = getMinSizeInternal(self as Instruction) + const max = getMaxSizeInternal(self as Instruction) + const newSpan = InternalSpan.text( + Option.isSome(self.max) ? ` ${min} - ${max}` : min === 0 ? "..." : ` ${min}+` + ) + const newBlock = InternalHelpDoc.p( + Option.isSome(self.max) + ? `This option must be repeated at least ${min} times and may be repeated up to ${max} times.` + : min === 0 + ? "This option may be repeated zero or more times." + : `This option must be repeated at least ${min} times.` + ) + return [InternalSpan.concat(oldSpan, newSpan), InternalHelpDoc.sequence(oldBlock, newBlock)] + }) + } case "WithDefault": { return InternalHelpDoc.mapDescriptionList( getHelpInternal(self.options as Instruction), @@ -610,7 +695,8 @@ const getIdentifierInternal = (self: Instruction): Option.Option => { onNonEmpty: (ids) => Option.some(ReadonlyArray.join(ids, ", ")) }) } - case "KeyValueMap": { + case "KeyValueMap": + case "Variadic": { return getIdentifierInternal(self.argumentOption as Instruction) } case "Map": @@ -620,6 +706,72 @@ const getIdentifierInternal = (self: Instruction): Option.Option => { } } +const getMinSizeInternal = (self: Instruction): number => { + switch (self._tag) { + case "Empty": + case "WithDefault": { + return 0 + } + case "Single": + case "KeyValueMap": { + return 1 + } + case "Map": { + return getMinSizeInternal(self.options as Instruction) + } + case "Both": { + const leftMinSize = getMinSizeInternal(self.left as Instruction) + const rightMinSize = getMinSizeInternal(self.right as Instruction) + return leftMinSize + rightMinSize + } + case "OrElse": { + const leftMinSize = getMinSizeInternal(self.left as Instruction) + const rightMinSize = getMinSizeInternal(self.right as Instruction) + return Math.min(leftMinSize, rightMinSize) + } + case "Variadic": { + const selfMinSize = Option.getOrElse(self.min, () => 0) + const argumentOptionMinSize = getMinSizeInternal(self.argumentOption as Instruction) + return selfMinSize * argumentOptionMinSize + } + } +} + +const getMaxSizeInternal = (self: Instruction): number => { + switch (self._tag) { + case "Empty": { + return 0 + } + case "Single": { + return 1 + } + case "KeyValueMap": { + return Number.MAX_SAFE_INTEGER + } + case "Map": { + return getMaxSizeInternal(self.options as Instruction) + } + case "Both": { + const leftMaxSize = getMaxSizeInternal(self.left as Instruction) + const rightMaxSize = getMaxSizeInternal(self.right as Instruction) + return leftMaxSize + rightMaxSize + } + case "OrElse": { + const leftMin = getMaxSizeInternal(self.left as Instruction) + const rightMin = getMaxSizeInternal(self.right as Instruction) + return Math.min(leftMin, rightMin) + } + case "Variadic": { + const selfMaxSize = Option.getOrElse(self.max, () => Number.MAX_SAFE_INTEGER / 2) + const optionsMaxSize = getMaxSizeInternal(self.argumentOption as Instruction) + return Math.floor(selfMaxSize * optionsMaxSize) + } + case "WithDefault": { + return getMaxSizeInternal(self.options as Instruction) + } + } +} + const getUsageInternal = (self: Instruction): Usage.Usage => { switch (self._tag) { case "Empty": { @@ -652,6 +804,9 @@ const getUsageInternal = (self: Instruction): Usage.Usage => { getUsageInternal(self.right as Instruction) ) } + case "Variadic": { + return InternalUsage.repeated(getUsageInternal(self.argumentOption as Instruction)) + } case "WithDefault": { return InternalUsage.optional(getUsageInternal(self.options as Instruction)) } @@ -741,6 +896,22 @@ const makeSingle = ( return op } +const makeVariadic = ( + argumentOption: Options.Options, + min: Option.Option, + max: Option.Option +): Options.Options> => { + if (!isSingle(argumentOption as Instruction)) { + throw new Error("InvalidArgumentException: only single options can be variadic") + } + const op = Object.create(proto) + op._tag = "Variadic" + op.argumentOption = argumentOption + op.min = min + op.max = max + return op +} + const makeWithDefault = (options: Options.Options, fallback: A): Options.Options => { const op = Object.create(proto) op._tag = "WithDefault" @@ -775,6 +946,9 @@ const modifySingle = (self: Instruction, f: (single: Single) => Single): Options modifySingle(self.right as Instruction, f) ) } + case "Variadic": { + return makeVariadic(f(self.argumentOption), self.min, self.max) + } case "WithDefault": { return makeWithDefault(modifySingle(self.options as Instruction, f), self.fallback) } @@ -880,7 +1054,7 @@ const parseOptions = ( ) { const keyValueString = leftover[1]!.trim() const split = keyValueString.split("=") - if (split.length < 2 || split[1] === "" || split[1] === "=") { + if (split.length < 2 || split[1].length === 0 || split[1] === "=") { break } else { keyValues = ReadonlyArray.prepend(keyValues, keyValueString) @@ -889,12 +1063,11 @@ const parseOptions = ( // Or, it can be in the form of "-d key1=value1 key2=value2" } else { const split = flagOrKeyValue.split("=") - if (split.length < 2 || split[1] === "" || split[1] === "=") { + if (split.length < 2 || split[1].length === 0 || split[1] === "=") { break } else { keyValues = ReadonlyArray.prepend(keyValues, flagOrKeyValue) leftover = leftover.slice(1) - continue } } } @@ -905,6 +1078,43 @@ const parseOptions = ( } return Effect.succeed([ReadonlyArray.empty(), args]) } + case "Variadic": { + const singleNames = ReadonlyArray.map( + names(self.argumentOption), + (name) => InternalCliConfig.normalizeCase(config, name) + ) + if (ReadonlyArray.isNonEmptyReadonlyArray(args)) { + const head = ReadonlyArray.headNonEmpty(args) + const tail = ReadonlyArray.tailNonEmpty(args) + if (ReadonlyArray.contains(singleNames, head)) { + let values: ReadonlyArray = ReadonlyArray.empty() + let leftover: ReadonlyArray = tail + while (ReadonlyArray.isNonEmptyReadonlyArray(leftover)) { + const value = ReadonlyArray.headNonEmpty(leftover).trim() + if (value.length === 0) { + break + } else if ( + leftover.length >= 2 + && ReadonlyArray.contains( + singleNames, + InternalCliConfig.normalizeCase(config, value) + ) + ) { + values = ReadonlyArray.append(values, leftover[1].trim()) + leftover = leftover.slice(2) + } else { + values = ReadonlyArray.append(values, value) + leftover = leftover.slice(1) + } + } + if (ReadonlyArray.isEmptyReadonlyArray(values)) { + return Effect.succeed([ReadonlyArray.empty(), args]) + } + return Effect.succeed([ReadonlyArray.prepend(values, head), leftover]) + } + } + return Effect.succeed([ReadonlyArray.empty(), args]) + } default: { return absurd(self) } @@ -917,7 +1127,8 @@ const toParseableInstruction = (self: Instruction): ReadonlyArray InternalValidationError.invalidValue(InternalHelpDoc.p(e))) ) } - return Effect.fail(InternalValidationError.keyValuesDetected(InternalHelpDoc.empty, head)) + return Effect.fail( + InternalValidationError.multipleValuesDetected(InternalHelpDoc.empty, head) + ) } const error = InternalHelpDoc.p( `More than one reference to option '${self.fullName}' detected` @@ -1039,19 +1259,23 @@ const parseInternal = ( } case "KeyValueMap": { const extractKeyValue = ( - keyValue: string + value: unknown ): Effect.Effect => { - const split = keyValue.trim().split("=") + if (typeof value !== "string") { + const error = `Expected a string but received: ${JSON.stringify(value)}` + return Effect.fail(InternalValidationError.invalidValue(InternalHelpDoc.p(error))) + } + const split = value.trim().split("=") if (ReadonlyArray.isNonEmptyReadonlyArray(split) && split.length === 2 && split[1] !== "") { return Effect.succeed(split as unknown as [string, string]) } - const error = InternalHelpDoc.p(`Expected a key/value pair but received '${keyValue}'`) + const error = InternalHelpDoc.p(`Expected a key/value pair but received '${value}'`) return Effect.fail(InternalValidationError.invalidArgument(error)) } return parseInternal(self.argumentOption, args, config).pipe(Effect.matchEffect({ onFailure: (e) => - InternalValidationError.isKeyValuesDetected(e) - ? Effect.forEach(e.keyValues, (kv) => extractKeyValue(kv)).pipe( + InternalValidationError.isMultipleValuesDetected(e) + ? Effect.forEach(e.values, (kv) => extractKeyValue(kv)).pipe( Effect.map(HashMap.fromIterable) ) : Effect.fail(e), @@ -1121,6 +1345,35 @@ const parseInternal = ( }) ) } + case "Variadic": { + const min = Option.getOrElse(self.min, () => 0) + const max = Option.getOrElse(self.max, () => Number.MAX_SAFE_INTEGER) + const validateMinMax = (values: ReadonlyArray) => { + if (values.length < min) { + const name = self.argumentOption.fullName + const error = `Expected at least ${min} value(s) for option: '${name}'` + return Effect.fail(InternalValidationError.invalidValue(InternalHelpDoc.p(error))) + } + if (values.length > max) { + const name = self.argumentOption.fullName + const error = `Expected at most ${max} value(s) for option: '${name}'` + return Effect.fail(InternalValidationError.invalidValue(InternalHelpDoc.p(error))) + } + const primitive = self.argumentOption.primitiveType + const validatePrimitive = (value: string) => + InternalPrimitive.validate(primitive, Option.some(value), config).pipe( + Effect.mapError((e) => InternalValidationError.invalidValue(InternalHelpDoc.p(e))) + ) + return Effect.forEach(values, (value) => validatePrimitive(value)) + } + return parseInternal(self.argumentOption, args, config).pipe(Effect.matchEffect({ + onFailure: (error) => + InternalValidationError.isMultipleValuesDetected(error) + ? validateMinMax(error.values) + : Effect.fail(error), + onSuccess: (value) => validateMinMax(ReadonlyArray.of(value as string)) + })) + } case "WithDefault": { return parseInternal(self.options as Instruction, args, config).pipe( Effect.catchTag("MissingValue", () => Effect.succeed(self.fallback)) @@ -1203,6 +1456,34 @@ const wizardInternal = (self: Instruction, config: CliConfig.CliConfig): Effect. }).pipe(Effect.flatMap((option) => wizardInternal(option, config))) )) } + case "Variadic": { + const repeatHelp = InternalHelpDoc.p( + "How many times should this argument should be repeated?" + ) + const message = pipe( + wizardHeader, + InternalHelpDoc.sequence(getHelpInternal(self)), + InternalHelpDoc.sequence(repeatHelp) + ) + return Console.log().pipe( + Effect.zipRight(InternalNumberPrompt.integer({ + message: InternalHelpDoc.toAnsiText(message).trimEnd(), + min: getMinSizeInternal(self), + max: getMaxSizeInternal(self) + })), + Effect.flatMap((n) => + Ref.make(ReadonlyArray.empty()).pipe( + Effect.flatMap((ref) => + wizardInternal(self.argumentOption as Instruction, config).pipe( + Effect.flatMap((args) => Ref.update(ref, ReadonlyArray.appendAll(args))), + Effect.repeatN(n - 1), + Effect.zipRight(Ref.get(ref)) + ) + ) + ) + ) + ) + } case "WithDefault": { const defaultHelp = InternalHelpDoc.p(`This option is optional - use the default?`) const message = pipe( @@ -1278,7 +1559,8 @@ const matchOptions = ( ] > => { if ( - ReadonlyArray.isNonEmptyReadonlyArray(input) && ReadonlyArray.isNonEmptyReadonlyArray(options) + ReadonlyArray.isNonEmptyReadonlyArray(input) + && ReadonlyArray.isNonEmptyReadonlyArray(options) ) { return findOptions(input, options, config).pipe( Effect.flatMap(([otherArgs, otherOptions, map1]) => { @@ -1345,7 +1627,7 @@ const findOptions = ( Effect.flatMap(([nameValues, leftover]) => { if (ReadonlyArray.isNonEmptyReadonlyArray(nameValues)) { const name = ReadonlyArray.headNonEmpty(nameValues) - const values: ReadonlyArray = ReadonlyArray.tailNonEmpty(nameValues) + const values: ReadonlyArray = ReadonlyArray.tailNonEmpty(nameValues) return Effect.succeed([leftover, tail, HashMap.make([name, values])] as [ ReadonlyArray, ReadonlyArray, diff --git a/src/internal/validationError.ts b/src/internal/validationError.ts index 12df8bf..dca010a 100644 --- a/src/internal/validationError.ts +++ b/src/internal/validationError.ts @@ -8,10 +8,9 @@ export const ValidationErrorTypeId: ValidationError.ValidationErrorTypeId = Symb ValidationErrorSymbolKey ) as ValidationError.ValidationErrorTypeId -const proto = (error: HelpDoc.HelpDoc): ValidationError.ValidationError.Proto => ({ - [ValidationErrorTypeId]: ValidationErrorTypeId, - error -}) +const proto: ValidationError.ValidationError.Proto = { + [ValidationErrorTypeId]: ValidationErrorTypeId +} /** @internal */ export const isValidationError = (u: unknown): u is ValidationError.ValidationError => @@ -38,9 +37,9 @@ export const isInvalidValue = ( ): self is ValidationError.InvalidValue => self._tag === "InvalidValue" /** @internal */ -export const isKeyValuesDetected = ( +export const isMultipleValuesDetected = ( self: ValidationError.ValidationError -): self is ValidationError.KeyValuesDetected => self._tag === "KeyValuesDetected" +): self is ValidationError.MultipleValuesDetected => self._tag === "MultipleValuesDetected" /** @internal */ export const isMissingFlag = ( @@ -69,7 +68,7 @@ export const isUnclusteredFlag = ( /** @internal */ export const commandMismatch = (error: HelpDoc.HelpDoc): ValidationError.ValidationError => { - const op = Object.create(proto(error)) + const op = Object.create(proto) op._tag = "CommandMismatch" op.error = error return op @@ -77,7 +76,7 @@ export const commandMismatch = (error: HelpDoc.HelpDoc): ValidationError.Validat /** @internal */ export const correctedFlag = (error: HelpDoc.HelpDoc): ValidationError.ValidationError => { - const op = Object.create(proto(error)) + const op = Object.create(proto) op._tag = "CorrectedFlag" op.error = error return op @@ -85,7 +84,7 @@ export const correctedFlag = (error: HelpDoc.HelpDoc): ValidationError.Validatio /** @internal */ export const invalidArgument = (error: HelpDoc.HelpDoc): ValidationError.ValidationError => { - const op = Object.create(proto(error)) + const op = Object.create(proto) op._tag = "InvalidArgument" op.error = error return op @@ -93,27 +92,15 @@ export const invalidArgument = (error: HelpDoc.HelpDoc): ValidationError.Validat /** @internal */ export const invalidValue = (error: HelpDoc.HelpDoc): ValidationError.ValidationError => { - const op = Object.create(proto(error)) + const op = Object.create(proto) op._tag = "InvalidValue" op.error = error return op } -/** @internal */ -export const keyValuesDetected = ( - error: HelpDoc.HelpDoc, - keyValues: ReadonlyArray -): ValidationError.ValidationError => { - const op = Object.create(proto(error)) - op._tag = "KeyValuesDetected" - op.error = error - op.keyValues = keyValues - return op -} - /** @internal */ export const missingFlag = (error: HelpDoc.HelpDoc): ValidationError.ValidationError => { - const op = Object.create(proto(error)) + const op = Object.create(proto) op._tag = "MissingFlag" op.error = error return op @@ -121,7 +108,7 @@ export const missingFlag = (error: HelpDoc.HelpDoc): ValidationError.ValidationE /** @internal */ export const missingValue = (error: HelpDoc.HelpDoc): ValidationError.ValidationError => { - const op = Object.create(proto(error)) + const op = Object.create(proto) op._tag = "MissingValue" op.error = error return op @@ -129,15 +116,27 @@ export const missingValue = (error: HelpDoc.HelpDoc): ValidationError.Validation /** @internal */ export const missingSubcommand = (error: HelpDoc.HelpDoc): ValidationError.ValidationError => { - const op = Object.create(proto(error)) + const op = Object.create(proto) op._tag = "MissingSubcommand" op.error = error return op } +/** @internal */ +export const multipleValuesDetected = ( + error: HelpDoc.HelpDoc, + values: ReadonlyArray +): ValidationError.ValidationError => { + const op = Object.create(proto) + op._tag = "MultipleValuesDetected" + op.error = error + op.values = values + return op +} + /** @internal */ export const noBuiltInMatch = (error: HelpDoc.HelpDoc): ValidationError.ValidationError => { - const op = Object.create(proto(error)) + const op = Object.create(proto) op._tag = "NoBuiltInMatch" op.error = error return op @@ -149,7 +148,7 @@ export const unclusteredFlag = ( unclustered: ReadonlyArray, rest: ReadonlyArray ): ValidationError.ValidationError => { - const op = Object.create(proto(error)) + const op = Object.create(proto) op._tag = "UnclusteredFlag" op.error = error op.unclustered = unclustered diff --git a/test/Options.test.ts b/test/Options.test.ts index 9de12fa..4a617fa 100644 --- a/test/Options.test.ts +++ b/test/Options.test.ts @@ -155,6 +155,36 @@ describe("Options", () => { ))) }).pipe(runEffect)) + it("validates a option with choices", () => + Effect.gen(function*($) { + const option = Options.choice("animal", ["cat", "dog"]) + const args1 = ReadonlyArray.make("--animal", "cat") + const args2 = ReadonlyArray.make("--animal", "dog") + const result1 = yield* $(validation(option, args1, CliConfig.defaultConfig)) + const result2 = yield* $(validation(option, args2, CliConfig.defaultConfig)) + expect(result1).toEqual([[], "cat"]) + expect(result2).toEqual([[], "dog"]) + }).pipe(runEffect)) + + it("validates an option with choices that map to values", () => + Effect.gen(function*($) { + type Animal = Dog | Cat + class Dog extends Data.TaggedClass("Dog")<{}> {} + class Cat extends Data.TaggedClass("Dog")<{}> {} + const cat = new Cat() + const dog = new Dog() + const option: Options.Options = Options.choiceWithValue("animal", [ + ["dog", dog], + ["cat", cat] + ]) + const args1 = ReadonlyArray.make("--animal", "cat") + const args2 = ReadonlyArray.make("--animal", "dog") + const result1 = yield* $(validation(option, args1, CliConfig.defaultConfig)) + const result2 = yield* $(validation(option, args2, CliConfig.defaultConfig)) + expect(result1).toEqual([[], cat]) + expect(result2).toEqual([[], dog]) + }).pipe(runEffect)) + it("validates a text option", () => Effect.gen(function*(_) { const result = yield* _( @@ -439,33 +469,61 @@ describe("Options", () => { ]) }).pipe(runEffect)) - it("choice", () => - Effect.gen(function*($) { - const option = Options.choice("animal", ["cat", "dog"]) - const args1 = ReadonlyArray.make("--animal", "cat") - const args2 = ReadonlyArray.make("--animal", "dog") - const result1 = yield* $(validation(option, args1, CliConfig.defaultConfig)) - const result2 = yield* $(validation(option, args2, CliConfig.defaultConfig)) - expect(result1).toEqual([[], "cat"]) - expect(result2).toEqual([[], "dog"]) + it("repeated", () => + Effect.gen(function*(_) { + const option = Options.integer("foo").pipe(Options.repeated) + const args1 = ["--foo", "1", "--foo", "2", "--foo", "3"] + const args2 = ["--foo", "1", "--foo", "v2", "--foo", "3"] + const result1 = yield* _(validation(option, args1, CliConfig.defaultConfig)) + const result2 = yield* _(Effect.flip(validation(option, args2, CliConfig.defaultConfig))) + expect(result1).toEqual([ReadonlyArray.empty(), [1, 2, 3]]) + expect(result2).toEqual(ValidationError.invalidValue(HelpDoc.p("'v2' is not a integer"))) }).pipe(runEffect)) - it("choiceWithValue", () => - Effect.gen(function*($) { - type Animal = Dog | Cat - class Dog extends Data.TaggedClass("Dog")<{}> {} - class Cat extends Data.TaggedClass("Dog")<{}> {} - const cat = new Cat() - const dog = new Dog() - const option: Options.Options = Options.choiceWithValue("animal", [ - ["dog", dog], - ["cat", cat] - ]) - const args1 = ReadonlyArray.make("--animal", "cat") - const args2 = ReadonlyArray.make("--animal", "dog") - const result1 = yield* $(validation(option, args1, CliConfig.defaultConfig)) - const result2 = yield* $(validation(option, args2, CliConfig.defaultConfig)) - expect(result1).toEqual([[], cat]) - expect(result2).toEqual([[], dog]) + it("atLeast", () => + Effect.gen(function*(_) { + const option = Options.integer("foo").pipe(Options.atLeast(2)) + const args1 = ["--foo", "1", "--foo", "2"] + const args2 = ["--foo", "1"] + const result1 = yield* _(validation(option, args1, CliConfig.defaultConfig)) + const result2 = yield* _(Effect.flip(validation(option, args2, CliConfig.defaultConfig))) + expect(result1).toEqual([ReadonlyArray.empty(), [1, 2]]) + expect(result2).toEqual(ValidationError.invalidValue(HelpDoc.p( + "Expected at least 2 value(s) for option: '--foo'" + ))) + }).pipe(runEffect)) + + it("atMost", () => + Effect.gen(function*(_) { + const option = Options.integer("foo").pipe(Options.atMost(2)) + const args1 = ["--foo", "1", "--foo", "2"] + const args2 = ["--foo", "1", "--foo", "2", "--foo", "3"] + const result1 = yield* _(validation(option, args1, CliConfig.defaultConfig)) + const result2 = yield* _(Effect.flip(validation(option, args2, CliConfig.defaultConfig))) + expect(result1).toEqual([ReadonlyArray.empty(), [1, 2]]) + expect(result2).toEqual(ValidationError.invalidValue(HelpDoc.p( + "Expected at most 2 value(s) for option: '--foo'" + ))) + }).pipe(runEffect)) + + it("between", () => + Effect.gen(function*(_) { + const option = Options.integer("foo").pipe(Options.between(2, 3)) + const args1 = ["--foo", "1"] + const args2 = ["--foo", "1", "--foo", "2"] + const args3 = ["--foo", "1", "--foo", "2", "--foo", "3"] + const args4 = ["--foo", "1", "--foo", "2", "--foo", "3", "--foo 4"] + const result1 = yield* _(Effect.flip(validation(option, args1, CliConfig.defaultConfig))) + const result2 = yield* _(validation(option, args2, CliConfig.defaultConfig)) + const result3 = yield* _(validation(option, args3, CliConfig.defaultConfig)) + const result4 = yield* _(Effect.flip(validation(option, args4, CliConfig.defaultConfig))) + expect(result1).toEqual(ValidationError.invalidValue(HelpDoc.p( + "Expected at least 2 value(s) for option: '--foo'" + ))) + expect(result2).toEqual([ReadonlyArray.empty(), [1, 2]]) + expect(result3).toEqual([ReadonlyArray.empty(), [1, 2, 3]]) + expect(result4).toEqual(ValidationError.invalidValue(HelpDoc.p( + "Expected at most 3 value(s) for option: '--foo'" + ))) }).pipe(runEffect)) })