diff --git a/.gitignore b/.gitignore index 662a338..55f23ab 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ build/ dist/ .direnv/ docs/ +# Naval Fate Example +naval-fate-store/ diff --git a/examples/naval-fate.ts b/examples/naval-fate.ts new file mode 100644 index 0000000..f404786 --- /dev/null +++ b/examples/naval-fate.ts @@ -0,0 +1,178 @@ +import { Args, CliApp, Command, Options, ValidationError } from "@effect/cli" +import * as KeyValueStore from "@effect/platform-node/KeyValueStore" +import * as NodeContext from "@effect/platform-node/NodeContext" +import * as Runtime from "@effect/platform-node/Runtime" +import * as Console from "effect/Console" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Option from "effect/Option" +import type { MineSubcommand, ShipSubcommand } from "./naval-fate/domain.js" +import { + MineCommand, + MoveShipCommand, + NewShipCommand, + RemoveMineCommand, + SetMineCommand, + ShipCommand, + ShootShipCommand +} from "./naval-fate/domain.js" +import * as NavalFateStore from "./naval-fate/store.js" + +// naval_fate [-h | --help] [--version] +// naval_fate ship new ... +// naval_fate ship move [--speed=] +// naval_fate ship shoot +// naval_fate mine set [--moored] +// naval_fate mine remove [--moored] + +const nameArg = Args.text({ name: "name" }) +const xArg = Args.integer({ name: "x" }) +const yArg = Args.integer({ name: "y" }) +const nameAndCoordinatesArg = Args.all({ name: nameArg, x: xArg, y: yArg }) +const coordinatesArg = Args.all({ x: xArg, y: yArg }) + +const mooredOption = Options.boolean("moored").pipe( + Options.withDescription("Whether the mine is moored (anchored) or drifting") +) +const speedOption = Options.integer("speed").pipe( + Options.withDescription("Speed in knots"), + Options.withDefault(10) +) + +const newShipCommand = Command.make("new", { + args: nameArg +}).pipe(Command.map(({ args }) => new NewShipCommand({ name: args }))) + +const moveShipCommand = Command.make("move", { + args: nameAndCoordinatesArg, + options: speedOption +}).pipe(Command.map(({ args, options }) => new MoveShipCommand({ ...args, speed: options }))) + +const shootShipCommand = Command.make("shoot", { + args: coordinatesArg +}).pipe(Command.map(({ args }) => new ShootShipCommand(args))) + +const shipCommand = Command.make("ship").pipe( + Command.withSubcommands([ + newShipCommand, + moveShipCommand, + shootShipCommand + ]), + Command.map(({ subcommand }) => new ShipCommand({ subcommand })) +) + +const setMineCommand = Command.make("set", { + args: coordinatesArg, + options: mooredOption +}).pipe(Command.map(({ args, options }) => new SetMineCommand({ ...args, moored: options }))) + +const removeMineCommand = Command.make("remove", { + args: coordinatesArg +}).pipe(Command.map(({ args }) => new RemoveMineCommand(args))) + +const mineCommand = Command.make("mine").pipe( + Command.withSubcommands([ + setMineCommand, + removeMineCommand + ]), + Command.map(({ subcommand }) => new MineCommand({ subcommand })) +) + +const navalFate = Command.make("naval_fate").pipe( + Command.withSubcommands([shipCommand, mineCommand]), + Command.withDescription("An implementation of the Naval Fate CLI application.") +) + +const navalFateApp = CliApp.make({ + name: "Naval Fate", + version: "1.0.0", + command: navalFate +}) + +const handleSubcommand = (command: ShipCommand | MineCommand) => { + switch (command._tag) { + case "ShipCommand": { + return Option.match(command.subcommand, { + onNone: () => Effect.fail(ValidationError.helpRequested(shipCommand)), + onSome: (subcommand) => handleShipSubcommand(subcommand) + }) + } + case "MineCommand": { + return Option.match(command.subcommand, { + onNone: () => Effect.fail(ValidationError.helpRequested(mineCommand)), + onSome: (subcommand) => handleMineSubcommand(subcommand) + }) + } + } +} + +const handleShipSubcommand = (command: ShipSubcommand) => + Effect.gen(function*($) { + const store = yield* $(NavalFateStore.NavalFateStore) + switch (command._tag) { + case "NewShipCommand": { + const { name } = command + yield* $(store.createShip(name)) + yield* $(Console.log(`Created ship: '${name}'`)) + break + } + case "MoveShipCommand": { + const { name, speed, x, y } = command + yield* $(store.moveShip(name, x, y)) + yield* $(Console.log(`Moving ship '${name}' to coordinates (${x}, ${y}) at ${speed} knots`)) + break + } + case "ShootShipCommand": { + const { x, y } = command + yield* $(store.shoot(x, y)) + yield* $(Console.log(`Shot cannons at coordinates (${x}, ${y})`)) + break + } + } + }) + +const handleMineSubcommand = (command: MineSubcommand) => + Effect.gen(function*($) { + const store = yield* $(NavalFateStore.NavalFateStore) + switch (command._tag) { + case "SetMineCommand": { + const { moored, x, y } = command + const mineType = moored ? "moored" : "drifting" + yield* $(store.setMine(x, y)) + yield* $(Console.log(`Set ${mineType} mine at coordinates (${x}, ${y})`)) + break + } + case "RemoveMineCommand": { + const { x, y } = command + yield* $(store.removeMine(x, y)) + yield* $(Console.log(`Removing mine at coordinates (${x}, ${y}), if present`)) + break + } + } + }) + +const main = Effect.sync(() => globalThis.process.argv.slice(2)).pipe( + Effect.flatMap((argv) => + CliApp.run( + navalFateApp, + argv, + Effect.unifiedFn((args) => + Option.match(args.subcommand, { + onNone: () => Effect.fail(ValidationError.helpRequested(navalFate)), + onSome: (subcommand) => handleSubcommand(subcommand) + }) + ) + ) + ) +) + +const MainLayer = NavalFateStore.layer.pipe( + Layer.use(KeyValueStore.layerFileSystem("naval-fate-store")), + Layer.merge(NodeContext.layer) +) + +main.pipe( + Effect.provide(MainLayer), + Effect.tapErrorCause(Effect.logError), + Runtime.runMain +) diff --git a/examples/naval-fate/domain.ts b/examples/naval-fate/domain.ts new file mode 100644 index 0000000..fff32d4 --- /dev/null +++ b/examples/naval-fate/domain.ts @@ -0,0 +1,116 @@ +import * as Schema from "@effect/schema/Schema" +import * as Data from "effect/Data" +import type * as Option from "effect/Option" + +/** + * An error that occurs when attempting to create a Naval Fate ship that already + * exists. + */ +export class ShipExistsError extends Data.TaggedError("ShipExistsError")<{ + readonly name: string +}> {} + +/** + * An error that occurs when attempting to move a Naval Fate ship that does not + * exist. + */ +export class ShipNotFoundError extends Data.TaggedError("ShipNotFoundError")<{ + readonly name: string + readonly x: number + readonly y: number +}> {} + +/** + * An error that occurs when attempting to move a Naval Fate ship to coordinates + * already occupied by another ship. + */ +export class CoordinatesOccupiedError extends Data.TaggedError("CoordinatesOccupiedError")<{ + readonly name: string + readonly x: number + readonly y: number +}> {} + +/** + * Represents a Naval Fate ship. + */ +export class Ship extends Schema.Class()({ + name: Schema.string, + x: Schema.NumberFromString, + y: Schema.NumberFromString, + status: Schema.literal("sailing", "destroyed") +}) { + static readonly decode = Schema.decode(Schema.ParseJson.pipe(Schema.compose(this))) + + static readonly encode = Schema.encode(Schema.ParseJson.pipe(Schema.compose(this))) + + static readonly create = (name: string) => new Ship({ name, x: 0, y: 0, status: "sailing" }) + + hasCoordinates(x: number, y: number): boolean { + return this.x === x && this.y === y + } + + move(x: number, y: number): Ship { + return new Ship({ name: this.name, x, y, status: this.status }) + } + + destroy(): Ship { + return new Ship({ name: this.name, x: this.x, y: this.y, status: "destroyed" }) + } +} + +/** + * Represents a Naval Fate mine. + */ +export class Mine extends Schema.Class()({ + x: Schema.NumberFromString, + y: Schema.NumberFromString +}) { + static readonly decode = Schema.decode(Schema.ParseJson.pipe(Schema.compose(this))) + + static readonly encode = Schema.encode(Schema.ParseJson.pipe(Schema.compose(this))) + + static readonly create = (x: number, y: number) => new Mine({ x, y }) + + hasCoordinates(x: number, y: number): boolean { + return this.x === x && this.y === y + } +} + +export class ShipCommand extends Data.TaggedClass("ShipCommand")<{ + readonly subcommand: Option.Option +}> {} + +export type ShipSubcommand = NewShipCommand | MoveShipCommand | ShootShipCommand + +export class NewShipCommand extends Data.TaggedClass("NewShipCommand")<{ + readonly name: string +}> {} + +export class MoveShipCommand extends Data.TaggedClass("MoveShipCommand")<{ + readonly name: string + readonly speed: number + readonly x: number + readonly y: number +}> {} + +export class ShootShipCommand extends Data.TaggedClass("ShootShipCommand")<{ + readonly x: number + readonly y: number +}> {} + +export class MineCommand extends Data.TaggedClass("MineCommand")<{ + readonly subcommand: Option.Option +}> {} + +export type MineSubcommand = SetMineCommand | RemoveMineCommand + +export class SetMineCommand extends Data.TaggedClass("SetMineCommand")<{ + readonly x: number + readonly y: number + readonly moored: boolean +}> {} + +export class RemoveMineCommand extends Data.TaggedClass("RemoveMineCommand")<{ + readonly x: number + readonly y: number +}> {} diff --git a/examples/naval-fate/store.ts b/examples/naval-fate/store.ts new file mode 100644 index 0000000..95311eb --- /dev/null +++ b/examples/naval-fate/store.ts @@ -0,0 +1,139 @@ +import * as KeyValueStore from "@effect/platform-node/KeyValueStore" +import * as Schema from "@effect/schema/Schema" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import { pipe } from "effect/Function" +import * as Layer from "effect/Layer" +import * as Option from "effect/Option" +import * as ReadonlyArray from "effect/ReadonlyArray" +import { + CoordinatesOccupiedError, + Mine, + Ship, + ShipExistsError, + ShipNotFoundError +} from "./domain.js" + +/** + * Represents the storage layer for the Naval Fate command-line application. + */ +export interface NavalFateStore { + createShip(name: string): Effect.Effect + moveShip( + name: string, + x: number, + y: number + ): Effect.Effect + shoot(x: number, y: number): Effect.Effect + setMine(x: number, y: number): Effect.Effect + removeMine(x: number, y: number): Effect.Effect +} + +export const NavalFateStore = Context.Tag() + +export const make = Effect.gen(function*($) { + const shipsStore = yield* $(Effect.map( + KeyValueStore.KeyValueStore, + (store) => store.forSchema(Schema.readonlyMap(Schema.string, Ship)) + )) + const minesStore = yield* $(Effect.map( + KeyValueStore.KeyValueStore, + (store) => store.forSchema(Schema.array(Mine)) + )) + + const getShips = shipsStore.get("ships").pipe( + Effect.map(Option.getOrElse>(() => new Map())), + Effect.orDie + ) + const getMines = minesStore.get("mines").pipe( + Effect.map(Option.getOrElse>(() => [])), + Effect.orDie + ) + const setShips = (ships: ReadonlyMap) => + shipsStore.set("ships", ships).pipe(Effect.orDie) + const setMines = (mines: ReadonlyArray) => minesStore.set("mines", mines).pipe(Effect.orDie) + + const createShip: NavalFateStore["createShip"] = (name) => + Effect.gen(function*($) { + const oldShips = yield* $(getShips) + const foundShip = Option.fromNullable(oldShips.get(name)) + if (Option.isSome(foundShip)) { + return yield* $(Effect.fail(new ShipExistsError({ name }))) + } + const ship = Ship.create(name) + const newShips = new Map(oldShips).set(name, ship) + yield* $(setShips(newShips)) + return ship + }) + + const moveShip: NavalFateStore["moveShip"] = (name, x, y) => + Effect.gen(function*($) { + const oldShips = yield* $(getShips) + const foundShip = Option.fromNullable(oldShips.get(name)) + if (Option.isNone(foundShip)) { + return yield* $(Effect.fail(new ShipNotFoundError({ name, x, y }))) + } + const shipAtCoords = pipe( + ReadonlyArray.fromIterable(oldShips.values()), + ReadonlyArray.findFirst((ship) => ship.hasCoordinates(x, y)) + ) + if (Option.isSome(shipAtCoords)) { + return yield* $(Effect.fail( + new CoordinatesOccupiedError({ name: shipAtCoords.value.name, x, y }) + )) + } + const mines = yield* $(getMines) + const mineAtCoords = ReadonlyArray.findFirst(mines, (mine) => mine.hasCoordinates(x, y)) + const ship = Option.isSome(mineAtCoords) + ? foundShip.value.move(x, y).destroy() + : foundShip.value.move(x, y) + const newShips = new Map(oldShips).set(name, ship) + yield* $(setShips(newShips)) + return ship + }) + + const shoot: NavalFateStore["shoot"] = (x, y) => + Effect.gen(function*($) { + const oldShips = yield* $(getShips) + const shipAtCoords = pipe( + ReadonlyArray.fromIterable(oldShips.values()), + ReadonlyArray.findFirst((ship) => ship.hasCoordinates(x, y)) + ) + if (Option.isSome(shipAtCoords)) { + const ship = shipAtCoords.value.destroy() + const newShips = new Map(oldShips).set(ship.name, ship) + yield* $(setShips(newShips)) + } + }) + + const setMine: NavalFateStore["setMine"] = (x, y) => + Effect.gen(function*($) { + const mines = yield* $(getMines) + const mineAtCoords = ReadonlyArray.findFirst(mines, (mine) => mine.hasCoordinates(x, y)) + if (Option.isNone(mineAtCoords)) { + const mine = Mine.create(x, y) + const newMines = ReadonlyArray.append(mines, mine) + yield* $(setMines(newMines)) + } + }) + + const removeMine: NavalFateStore["removeMine"] = (x, y) => + Effect.gen(function*($) { + const mines = yield* $(getMines) + const mineAtCoords = ReadonlyArray.findFirstIndex(mines, (mine) => mine.hasCoordinates(x, y)) + if (Option.isSome(mineAtCoords)) { + const newMines = ReadonlyArray.remove(mines, mineAtCoords.value) + yield* $(setMines(newMines)) + } + }) + + return NavalFateStore.of({ + createShip, + moveShip, + shoot, + setMine, + removeMine + }) +}) + +export const layer = Layer.effect(NavalFateStore, make) 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 |` )) }) diff --git a/tsconfig.base.json b/tsconfig.base.json index 445478a..3524967 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -44,6 +44,9 @@ ], "@effect/cli/*": [ "./src/*.js" + ], + "@effect/cli": [ + "./src/index.js" ] }, "plugins": [