diff --git a/src/ValidationError.ts b/src/ValidationError.ts index 43fad2a..4923a5d 100644 --- a/src/ValidationError.ts +++ b/src/ValidationError.ts @@ -1,7 +1,10 @@ /** * @since 1.0.0 */ +import type { BuiltInOptions } from "./BuiltInOptions.js" +import type { Command } from "./Command.js" import type { HelpDoc } from "./HelpDoc.js" +import * as InternalCommand from "./internal/command.js" import * as InternalValidationError from "./internal/validationError.js" /** @@ -23,6 +26,7 @@ export type ValidationErrorTypeId = typeof ValidationErrorTypeId export type ValidationError = | CommandMismatch | CorrectedFlag + | HelpRequested | InvalidArgument | InvalidValue | MissingValue @@ -50,6 +54,16 @@ export interface CorrectedFlag extends ValidationError.Proto { readonly error: HelpDoc } +/** + * @since 1.0.0 + * @category models + */ +export interface HelpRequested extends ValidationError.Proto { + readonly _tag: "HelpRequested" + readonly error: HelpDoc + readonly showHelp: BuiltInOptions +} + /** * @since 1.0.0 * @category models @@ -159,6 +173,13 @@ export const isCommandMismatch: (self: ValidationError) => self is CommandMismat export const isCorrectedFlag: (self: ValidationError) => self is CorrectedFlag = InternalValidationError.isCorrectedFlag +/** + * @since 1.0.0 + * @category refinements + */ +export const isHelpRequested: (self: ValidationError) => self is HelpRequested = + InternalValidationError.isHelpRequested + /** * @since 1.0.0 * @category refinements @@ -229,6 +250,13 @@ export const commandMismatch: (error: HelpDoc) => ValidationError = export const correctedFlag: (error: HelpDoc) => ValidationError = InternalValidationError.correctedFlag +/** + * @since 1.0.0 + * @category constructors + */ +export const helpRequested: (command: Command) => ValidationError = + InternalCommand.helpRequestedError + /** * @since 1.0.0 * @category constructors diff --git a/src/internal/args.ts b/src/internal/args.ts index 5e60aae..9948567 100644 --- a/src/internal/args.ts +++ b/src/internal/args.ts @@ -734,7 +734,7 @@ const withDescriptionInternal = (self: Instruction, description: string): Args.A } } -const wizardHeader = InternalHelpDoc.p("ARGS WIZARD") +const wizardHeader = InternalHelpDoc.p("ARG WIZARD") const wizardInternal = (self: Instruction, config: CliConfig.CliConfig): Effect.Effect< FileSystem.FileSystem | Terminal.Terminal, diff --git a/src/internal/builtInOptions.ts b/src/internal/builtInOptions.ts index df4d550..cbdff68 100644 --- a/src/internal/builtInOptions.ts +++ b/src/internal/builtInOptions.ts @@ -2,7 +2,7 @@ import * as Option from "effect/Option" import type * as BuiltInOptions from "../BuiltInOptions.js" import type * as Command from "../Command.js" import type * as HelpDoc from "../HelpDoc.js" -import * as Options from "../Options.js" +import type * as Options from "../Options.js" import type * as Usage from "../Usage.js" import * as InternalOptions from "./options.js" @@ -62,7 +62,7 @@ export const completionsOptions: Options.Options< ["bash", "bash" as const], ["fish", "fish" as const], ["zsh", "zsh" as const] -]).pipe(Options.optional) +]).pipe(InternalOptions.optional) /** @internal */ export const helpOptions: Options.Options = InternalOptions.boolean("help").pipe( diff --git a/src/internal/cliApp.ts b/src/internal/cliApp.ts index 55794ed..8378639 100644 --- a/src/internal/cliApp.ts +++ b/src/internal/cliApp.ts @@ -84,7 +84,14 @@ export const run = dual< onSuccess: Effect.unifiedFn((directive) => { switch (directive._tag) { case "UserDefined": { - return execute(directive.value) + return execute(directive.value).pipe( + Effect.catchSome((e) => + InternalValidationError.isValidationError(e) && + InternalValidationError.isHelpRequested(e) + ? Option.some(handleBuiltInOption(self, e.showHelp, config)) + : Option.none() + ) + ) } case "BuiltIn": { return handleBuiltInOption(self, directive.option, config).pipe( @@ -210,7 +217,8 @@ const handleBuiltInOption = ( ]) ) const help = InternalHelpDoc.sequence(header, description) - return Console.log(InternalHelpDoc.toAnsiText(help)).pipe( + const text = InternalHelpDoc.toAnsiText(help).trimEnd() + return Console.log(text).pipe( Effect.zipRight(InternalCommand.wizard(builtIn.command, config)), Effect.tap((args) => Console.log(InternalHelpDoc.toAnsiText(renderWizardArgs(args)))) ) diff --git a/src/internal/cliConfig.ts b/src/internal/cliConfig.ts index 1e4d814..cc2c040 100644 --- a/src/internal/cliConfig.ts +++ b/src/internal/cliConfig.ts @@ -16,7 +16,7 @@ export const Tag = Context.Tag() export const defaultConfig: CliConfig.CliConfig = { isCaseSensitive: false, autoCorrectLimit: 2, - finalCheckBuiltIn: false, + finalCheckBuiltIn: true, showAllNames: true, showTypes: true } diff --git a/src/internal/command.ts b/src/internal/command.ts index e7b840e..80b2e9c 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -304,16 +304,9 @@ export const withSubcommands = dual< if (ReadonlyArray.isNonEmptyReadonlyArray(subcommands)) { const head = ReadonlyArray.headNonEmpty>(subcommands) const tail = ReadonlyArray.tailNonEmpty>(subcommands) - if (ReadonlyArray.isNonEmptyReadonlyArray(tail)) { - const child = ReadonlyArray.reduce( - ReadonlyArray.tailNonEmpty(tail), - orElse(head, ReadonlyArray.headNonEmpty(tail)), - orElse - ) - op.child = child - return op - } - op.child = head + op.child = ReadonlyArray.isNonEmptyReadonlyArray(tail) + ? ReadonlyArray.reduce(tail, head, orElse) + : head return op } throw new Error("[BUG]: Command.subcommands - received empty list of subcommands") @@ -376,7 +369,7 @@ const getHelpInternal = (self: Instruction): HelpDoc.HelpDoc => { case "Standard": case "GetUserInput": { const usage = InternalHelpDoc.getSpan(InternalUsage.getHelp(getUsageInternal(command))) - const usages = ReadonlyArray.prepend(preceding, usage) + const usages = ReadonlyArray.append(preceding, usage) const finalUsage = ReadonlyArray.reduce( usages, InternalSpan.empty, @@ -693,59 +686,59 @@ const parseInternal = ( args, (name) => !HashMap.has(subcommands, name) ) - const helpDirectiveForParent = Effect.succeed( - InternalCommandDirective.builtIn(InternalBuiltInOptions.showHelp( + const helpDirectiveForParent = Effect.sync(() => { + return InternalCommandDirective.builtIn(InternalBuiltInOptions.showHelp( getUsageInternal(self), getHelpInternal(self) )) - ) - const helpDirectiveForChild = parseInternal( - self.child as Instruction, - childArgs, - config - ).pipe( - Effect.flatMap((directive) => { - if ( - InternalCommandDirective.isBuiltIn(directive) && - InternalBuiltInOptions.isShowHelp(directive.option) - ) { - const parentName = Option.getOrElse(ReadonlyArray.head(names), () => "") - const newDirective = InternalCommandDirective.builtIn(InternalBuiltInOptions.showHelp( - InternalUsage.concat( - InternalUsage.named(ReadonlyArray.of(parentName), Option.none()), - directive.option.usage - ), - directive.option.helpDoc - )) - return Effect.succeed(newDirective) - } - return Effect.fail(InternalValidationError.invalidArgument(InternalHelpDoc.empty)) - }) - ) - const wizardDirectiveForParent = Effect.succeed( + }) + const helpDirectiveForChild = Effect.suspend(() => { + return parseInternal(self.child as Instruction, childArgs, config).pipe( + Effect.flatMap((directive) => { + if ( + InternalCommandDirective.isBuiltIn(directive) && + InternalBuiltInOptions.isShowHelp(directive.option) + ) { + const parentName = Option.getOrElse(ReadonlyArray.head(names), () => "") + const newDirective = InternalCommandDirective.builtIn(InternalBuiltInOptions.showHelp( + InternalUsage.concat( + InternalUsage.named(ReadonlyArray.of(parentName), Option.none()), + directive.option.usage + ), + directive.option.helpDoc + )) + return Effect.succeed(newDirective) + } + return Effect.fail(InternalValidationError.invalidArgument(InternalHelpDoc.empty)) + }) + ) + }) + const wizardDirectiveForParent = Effect.sync(() => InternalCommandDirective.builtIn(InternalBuiltInOptions.showWizard(self)) ) - const wizardDirectiveForChild = parseInternal( - self.child as Instruction, - childArgs, - config - ).pipe( - Effect.flatMap((directive) => { - if ( - InternalCommandDirective.isBuiltIn(directive) && - InternalBuiltInOptions.isShowWizard(directive.option) - ) { - return Effect.succeed(directive) - } - return Effect.fail(InternalValidationError.invalidArgument(InternalHelpDoc.empty)) - }) + const wizardDirectiveForChild = Effect.suspend(() => + parseInternal(self.child as Instruction, childArgs, config).pipe( + Effect.flatMap((directive) => { + if ( + InternalCommandDirective.isBuiltIn(directive) && + InternalBuiltInOptions.isShowWizard(directive.option) + ) { + return Effect.succeed(directive) + } + return Effect.fail(InternalValidationError.invalidArgument(InternalHelpDoc.empty)) + }) + ) ) return parseInternal(self.parent as Instruction, parentArgs, config).pipe( Effect.flatMap((directive) => { switch (directive._tag) { case "BuiltIn": { if (InternalBuiltInOptions.isShowHelp(directive.option)) { - return Effect.orElse(helpDirectiveForChild, () => helpDirectiveForParent) + // We do not want to display the child help docs if there are + // no arguments indicating the CLI command was for the child + return ReadonlyArray.isNonEmptyReadonlyArray(childArgs) + ? Effect.orElse(helpDirectiveForChild, () => helpDirectiveForParent) + : helpDirectiveForParent } if (InternalBuiltInOptions.isShowWizard(directive.option)) { return Effect.orElse(wizardDirectiveForChild, () => wizardDirectiveForParent) @@ -848,73 +841,124 @@ const withDescriptionInternal = ( } } -const wizardInternal = (self: Instruction, config: CliConfig.CliConfig): Effect.Effect< +const wizardInternal = ( + self: Instruction, + config: CliConfig.CliConfig +): Effect.Effect< FileSystem.FileSystem | Terminal.Terminal, ValidationError.ValidationError, ReadonlyArray > => { - switch (self._tag) { - case "Standard": { - const message = InternalHelpDoc.p(pipe( - InternalSpan.text("\n"), - InternalSpan.concat(InternalSpan.strong(InternalSpan.code("COMMAND:"))), - InternalSpan.concat(InternalSpan.space), - InternalSpan.concat(InternalSpan.code(self.name)) - )) - return Console.log(InternalHelpDoc.toAnsiText(message)).pipe(Effect.zipRight(Effect.zipWith( - InternalOptions.wizard(self.options, config), - InternalArgs.wizard(self.args, config), - (options, args) => ReadonlyArray.prepend(ReadonlyArray.appendAll(options, args), self.name) - ))) + const loop = (self: WizardCommandSequence): Effect.Effect< + FileSystem.FileSystem | Terminal.Terminal, + ValidationError.ValidationError, + ReadonlyArray + > => { + switch (self._tag) { + case "SingleCommandWizard": { + const optionsWizard = isStandard(self.command) + ? InternalOptions.wizard(self.command.options, config) + : Effect.succeed(ReadonlyArray.empty()) + const argsWizard = isStandard(self.command) + ? InternalArgs.wizard(self.command.args, config) + : Effect.succeed(ReadonlyArray.empty()) + const help = InternalHelpDoc.p(pipe( + InternalSpan.text("\n"), + InternalSpan.concat(InternalSpan.strong(InternalSpan.code("COMMAND:"))), + InternalSpan.concat(InternalSpan.space), + InternalSpan.concat(InternalSpan.code(self.command.name)) + )) + const message = InternalHelpDoc.toAnsiText(help) + return Console.log(message).pipe( + Effect.zipRight(Effect.zipWith(optionsWizard, argsWizard, (options, args) => + pipe( + ReadonlyArray.appendAll(options, args), + ReadonlyArray.prepend(self.command.name) + ))) + ) + } + case "AlternativeCommandWizard": { + const makeChoice = (title: string, value: WizardCommandSequence) => ({ title, value }) + const choices = self.alternatives.map((alternative) => { + switch (alternative._tag) { + case "SingleCommandWizard": { + return makeChoice(alternative.command.name, alternative) + } + case "SubcommandWizard": { + return makeChoice(alternative.names, alternative) + } + } + }) + const description = InternalHelpDoc.p("Select which command you would like to execute") + const message = InternalHelpDoc.toAnsiText(description).trimEnd() + return InternalSelectPrompt.select({ message, choices }).pipe( + Effect.flatMap((nextSequence) => loop(nextSequence)) + ) + } + case "SubcommandWizard": { + return Effect.zipWith( + loop(self.parent), + loop(self.child), + (parent, child) => ReadonlyArray.appendAll(parent, child) + ) + } } + } + return loop(getWizardCommandSequence(self)) +} + +type WizardCommandSequence = SingleCommandWizard | AlternativeCommandWizard | SubcommandWizard + +interface SingleCommandWizard { + readonly _tag: "SingleCommandWizard" + readonly command: GetUserInput | Standard +} + +interface AlternativeCommandWizard { + readonly _tag: "AlternativeCommandWizard" + readonly alternatives: ReadonlyArray +} + +interface SubcommandWizard { + _tag: "SubcommandWizard" + readonly names: string + readonly parent: WizardCommandSequence + readonly child: WizardCommandSequence +} + +/** + * Creates an intermediate data structure that allows commands to be properly + * sequenced by the prompts of Wizard Mode. + */ +const getWizardCommandSequence = (self: Instruction): WizardCommandSequence => { + switch (self._tag) { + case "Standard": case "GetUserInput": { - return Effect.succeed(ReadonlyArray.empty()) + return { _tag: "SingleCommandWizard", command: self } } case "Map": { - return wizardInternal(self.command as Instruction, config) + return getWizardCommandSequence(self.command as Instruction) } case "OrElse": { - const description = InternalHelpDoc.p("Select which command you would like to execute") - const makeChoice = (title: string, value: Instruction) => ({ - title, - value: [title, value] as const - }) - const choices = ReadonlyArray.compact([ - Option.map( - ReadonlyArray.head(Array.from(getNamesInternal(self.left as Instruction))), - (title) => makeChoice(title, self.left as Instruction) - ), - Option.map( - ReadonlyArray.head(Array.from(getNamesInternal(self.right as Instruction))), - (title) => makeChoice(title, self.right as Instruction) - ) - ]) - const message = InternalHelpDoc.toAnsiText(description).trimEnd() - return Console.log().pipe( - Effect.zipRight(InternalSelectPrompt.select({ message, choices })), - Effect.flatMap(([name, command]) => - wizardInternal(command, config).pipe(Effect.map(ReadonlyArray.prepend(name))) - ) - ) + const left = getWizardCommandSequence(self.left as Instruction) + const leftAlternatives = left._tag === "AlternativeCommandWizard" + ? left.alternatives + : ReadonlyArray.of(left) + const right = getWizardCommandSequence(self.right as Instruction) + const rightAlternatives = right._tag === "AlternativeCommandWizard" + ? right.alternatives + : ReadonlyArray.of(right) + const alternatives = ReadonlyArray.appendAll(leftAlternatives, rightAlternatives) + return { _tag: "AlternativeCommandWizard", alternatives } } case "Subcommands": { - const description = InternalHelpDoc.p("Select which command you would like to execute") - const makeChoice = (title: string, value: Instruction) => ({ title, value }) - const parentName = Option.getOrElse( - ReadonlyArray.head(Array.from(getNamesInternal(self))), - () => "" - ) - const parentChoice = makeChoice(parentName, self.parent as Instruction) - const childChoices = ReadonlyArray.map( - Array.from(getSubcommandsInternal(self)), - ([name, command]) => makeChoice(name, command as Instruction) - ) - const choices = ReadonlyArray.prepend(childChoices, parentChoice) - const message = InternalHelpDoc.toAnsiText(description).trimEnd() - return Console.log().pipe( - Effect.zipRight(InternalSelectPrompt.select({ message, choices })), - Effect.flatMap((command) => wizardInternal(command, config)) + const names = pipe( + ReadonlyArray.fromIterable(getNamesInternal(self.parent as Instruction)), + ReadonlyArray.join(" | ") ) + const parent = getWizardCommandSequence(self.parent as Instruction) + const child = getWizardCommandSequence(self.child as Instruction) + return { _tag: "SubcommandWizard", names, parent, child } } } } @@ -995,7 +1039,6 @@ const traverseCommand = ( const parentNames = Array.from(getNamesInternal(self.parent as Instruction)) const nextSubcommands = Array.from(getSubcommandsInternal(self.child as Instruction)) const nextParentCommands = ReadonlyArray.appendAll(parentCommands, parentNames) - console.log(self.parent, self.child) // Traverse the parent command using old parent names and next subcommands return loop(self.parent as Instruction, parentCommands, nextSubcommands, level).pipe( Effect.zipRight( @@ -1347,3 +1390,19 @@ const getZshSubcommandCases = ( } } } + +// Circular with ValidationError + +/** @internal */ +export const helpRequestedError = ( + command: Command.Command +): ValidationError.ValidationError => { + const op = Object.create(InternalValidationError.proto) + op._tag = "HelpRequested" + op.error = InternalHelpDoc.empty + op.showHelp = InternalBuiltInOptions.showHelp( + getUsageInternal(command as Instruction), + getHelpInternal(command as Instruction) + ) + return op +} diff --git a/src/internal/options.ts b/src/internal/options.ts index 4228b34..4c2c788 100644 --- a/src/internal/options.ts +++ b/src/internal/options.ts @@ -1350,7 +1350,7 @@ const parseInternal = ( } } -const wizardHeader = InternalHelpDoc.p("OPTIONS WIZARD") +const wizardHeader = InternalHelpDoc.p("Option Wizard") const wizardInternal = (self: Instruction, config: CliConfig.CliConfig): Effect.Effect< FileSystem.FileSystem | Terminal.Terminal, @@ -1417,12 +1417,13 @@ const wizardInternal = (self: Instruction, config: CliConfig.CliConfig): Effect. (title) => makeChoice(title, self.right as Instruction) ) ]) - return Console.log().pipe(Effect.zipRight( - InternalSelectPrompt.select({ + return Console.log().pipe( + Effect.zipRight(InternalSelectPrompt.select({ message: InternalHelpDoc.toAnsiText(message).trimEnd(), choices - }).pipe(Effect.flatMap((option) => wizardInternal(option, config))) - )) + })), + Effect.flatMap((option) => wizardInternal(option, config)) + ) } case "Variadic": { const repeatHelp = InternalHelpDoc.p( @@ -1460,15 +1461,13 @@ const wizardInternal = (self: Instruction, config: CliConfig.CliConfig): Effect. InternalHelpDoc.sequence(defaultHelp) ) return Console.log().pipe( - Effect.zipRight( - InternalSelectPrompt.select({ - message: InternalHelpDoc.toAnsiText(message).trimEnd(), - choices: [ - { title: `Default ['${JSON.stringify(self.fallback)}']`, value: true }, - { title: "Custom", value: false } - ] - }) - ), + Effect.zipRight(InternalSelectPrompt.select({ + message: InternalHelpDoc.toAnsiText(message).trimEnd(), + choices: [ + { title: `Default ['${JSON.stringify(self.fallback)}']`, value: true }, + { title: "Custom", value: false } + ] + })), Effect.flatMap((useFallback) => useFallback ? Effect.succeed(ReadonlyArray.empty()) diff --git a/src/internal/primitive.ts b/src/internal/primitive.ts index 7f2adef..0cc0462 100644 --- a/src/internal/primitive.ts +++ b/src/internal/primitive.ts @@ -539,7 +539,7 @@ const wizardInternal = (self: Instruction, help: HelpDoc.HelpDoc): Prompt.Prompt case "Integer": { const primitiveHelp = InternalHelpDoc.p("Enter an integer") const message = InternalHelpDoc.sequence(help, primitiveHelp) - return InternalNumberPrompt.float({ + return InternalNumberPrompt.integer({ message: InternalHelpDoc.toAnsiText(message).trimEnd() }).pipe(InternalPrompt.map((value) => `${value}`)) } diff --git a/src/internal/validationError.ts b/src/internal/validationError.ts index dca010a..7ac8c5c 100644 --- a/src/internal/validationError.ts +++ b/src/internal/validationError.ts @@ -8,7 +8,8 @@ export const ValidationErrorTypeId: ValidationError.ValidationErrorTypeId = Symb ValidationErrorSymbolKey ) as ValidationError.ValidationErrorTypeId -const proto: ValidationError.ValidationError.Proto = { +/** @internal */ +export const proto: ValidationError.ValidationError.Proto = { [ValidationErrorTypeId]: ValidationErrorTypeId } @@ -26,6 +27,11 @@ export const isCorrectedFlag = ( self: ValidationError.ValidationError ): self is ValidationError.CorrectedFlag => self._tag === "CorrectedFlag" +/** @internal */ +export const isHelpRequested = ( + self: ValidationError.ValidationError +): self is ValidationError.HelpRequested => self._tag === "HelpRequested" + /** @internal */ export const isInvalidArgument = ( self: ValidationError.ValidationError diff --git a/test/Command.test.ts b/test/Command.test.ts index ae0c530..c96cee2 100644 --- a/test/Command.test.ts +++ b/test/Command.test.ts @@ -307,9 +307,9 @@ describe("Command", () => { | | - child1 help 1 | - | - child2 child1 help 2 + | - child1 child2 help 2 | - | - child3 child1 help 3 + | - child1 child3 help 3 |` )) })